docs: add a developer onboarding guide for the monorepo
Twelve documents under docs/onboarding/ covering orientation, local setup,
the repo map, per-domain deep dives, the cross-service contracts, CI and the
release flow, infra and secrets, observability, task recipes, and a list of
which docs in this tree are currently stale.
Every command in the setup guide was run against this checkout: the backend
suite (429 passed, 8 skipped), the frontend Go suite, a venv boot of the
backend, and a build+boot of the Go frontend.
Two findings recorded along the way:
- static/units.js's F_REGIONS is guarded by no test, despite three source
comments claiming "a test asserts all three stay identical". The Go test
only cross-checks the Go copy against the backend's Python. All four copies
are currently identical.
- backend/ and frontend/docker-compose.test.yml still default to the retired
emi/thermograph-backend/app image path, and the frontend harness pins the
split-era v0.0.2-split-ci tag.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:25:52 +00:00
|
|
|
|
# 10. Recipes
|
|
|
|
|
|
|
|
|
|
|
|
Task-shaped walkthroughs. Each one names the files you'll touch and the traps
|
|
|
|
|
|
specific to that change.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Add a field to a graded API payload
|
|
|
|
|
|
|
|
|
|
|
|
1. **Build it** in `backend/api/payloads.py` (or `content_payloads.py` for the
|
|
|
|
|
|
SSR content API). Payload assembly is pure `inputs → dict`; keep HTTP
|
|
|
|
|
|
semantics with the caller.
|
|
|
|
|
|
2. **Bump `PAYLOAD_VER`** in `api/payloads.py`. Non-negotiable — the derived
|
|
|
|
|
|
store is token-validated with no clock expiry, so without a bump the old
|
|
|
|
|
|
shape serves out of cache forever, silently. If the change is content-API
|
|
|
|
|
|
only, `CONTENT_VER` is the narrower lever.
|
|
|
|
|
|
3. **Decide whether it's breaking.** Additive → fine on `v2`. Breaking → a new
|
|
|
|
|
|
`v3` router mounted alongside `v2` re-registering only the changed handlers,
|
|
|
|
|
|
plus `API_CONTRACT_VERSION` bumped in the same PR.
|
|
|
|
|
|
4. **Check `/cell`.** If the field lands in a slice, the slice→view map in
|
|
|
|
|
|
`frontend/static/cache.js` has to track it.
|
|
|
|
|
|
5. **Consume it** in `frontend/server/internal/contentapi/types.go` and whichever
|
|
|
|
|
|
template or JS renders it.
|
|
|
|
|
|
6. **Test both sides**: `cd backend && make test`, then
|
|
|
|
|
|
`cd frontend/server && go test ./...`.
|
|
|
|
|
|
7. **Refresh fixtures if the shape moved**: `cd frontend && make capture-fixtures`
|
|
|
|
|
|
against a live backend. Those fixtures feed the **Go** tests too.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Add a new SSR page
|
|
|
|
|
|
|
|
|
|
|
|
The backend owns the page's *data and metadata*; the frontend owns its *markup*.
|
|
|
|
|
|
|
|
|
|
|
|
1. `backend/api/content_payloads.py` — a builder returning JSON-safe dicts (raw
|
|
|
|
|
|
floats, no `Markup`, no ContextVar unit rendering), including `page_title`,
|
|
|
|
|
|
`canonical_path`, `breadcrumb` and `jsonld`. Those live here, **not** in the
|
|
|
|
|
|
frontend.
|
|
|
|
|
|
2. `backend/api/content_routes.py` — route wiring plus the same derived-store /
|
|
|
|
|
|
ETag pattern the neighbouring routes use.
|
|
|
|
|
|
3. `frontend/server/internal/contentapi/` — a typed client method.
|
|
|
|
|
|
4. `frontend/server/internal/render/templates/<page>.html.tmpl` — embedded in
|
|
|
|
|
|
the binary, so a template change needs a rebuild.
|
|
|
|
|
|
5. `frontend/server/internal/content/pages.go` — the handler; register the route.
|
|
|
|
|
|
6. If the page should be crawlable, add it to the sitemap
|
|
|
|
|
|
(`backend/api/sitemap.py` and the `/content/sitemap` payload).
|
|
|
|
|
|
7. Copy that isn't per-city data goes in `frontend/content/pages.yaml`, loaded
|
|
|
|
|
|
fail-loud at boot.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Change something visual
|
|
|
|
|
|
|
|
|
|
|
|
1. Read [`frontend/DESIGN.md`](../../frontend/DESIGN.md) first. It wins over any
|
|
|
|
|
|
general design guidance.
|
|
|
|
|
|
2. Edit `frontend/static/style.css`. **Use `var(--token)`; never paste a hex.**
|
|
|
|
|
|
New surfaces take their colour from existing tokens so light mode keeps
|
|
|
|
|
|
working for free.
|
|
|
|
|
|
3. If it's a grade colour, keep the scale's order and midpoints intact — they
|
|
|
|
|
|
encode meaning, not decoration, and the charts pull from the same tokens as
|
|
|
|
|
|
the legend.
|
|
|
|
|
|
4. **Verify at 390 / 800 / 1920 / 2560 / 3840px in both light and dark:**
|
|
|
|
|
|
```bash
|
|
|
|
|
|
cd frontend && .venv/bin/python tools/shoot.py
|
|
|
|
|
|
```
|
|
|
|
|
|
or drive a real browser with the Chrome DevTools MCP. Narrow while
|
|
|
|
|
|
iterating: `tools/shoot.py index --width 390 --scheme dark`.
|
|
|
|
|
|
5. Watch the hard rules: inputs at `font-size: 16px` minimum (iOS zoom), ~44px
|
|
|
|
|
|
touch targets, honour `prefers-reduced-motion`, metric order always
|
|
|
|
|
|
`Precip · High · Low`.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Add or rotate a secret
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
sops edit infra/deploy/secrets/prod.yaml # never a plain editor
|
|
|
|
|
|
git commit && push && open a PR
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Then it reaches the hosts by either route: an `infra/**` push to `main` fires
|
2026-07-26 06:56:38 +00:00
|
|
|
|
`infra-sync.yml` (re-renders prod's and beta's env files — `/etc/thermograph.env`
|
|
|
|
|
|
and `/etc/thermograph-beta.env`, both on vps2 — rolls nothing), or the next app
|
|
|
|
|
|
deploy renders it as step 2 of `deploy.sh`. Dev's vault (`dev.yaml`) is never
|
|
|
|
|
|
part of that sync — it's rendered only by dev's own deploy on vps1, and it
|
|
|
|
|
|
never layers `common.yaml` regardless.
|
docs: add a developer onboarding guide for the monorepo
Twelve documents under docs/onboarding/ covering orientation, local setup,
the repo map, per-domain deep dives, the cross-service contracts, CI and the
release flow, infra and secrets, observability, task recipes, and a list of
which docs in this tree are currently stale.
Every command in the setup guide was run against this checkout: the backend
suite (429 passed, 8 skipped), the frontend Go suite, a venv boot of the
backend, and a build+boot of the Go frontend.
Two findings recorded along the way:
- static/units.js's F_REGIONS is guarded by no test, despite three source
comments claiming "a test asserts all three stay identical". The Go test
only cross-checks the Go copy against the backend's Python. All four copies
are currently identical.
- backend/ and frontend/docker-compose.test.yml still default to the retired
emi/thermograph-backend/app image path, and the frontend harness pins the
split-era v0.0.2-split-ci tag.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:25:52 +00:00
|
|
|
|
|
|
|
|
|
|
Decide **which file** first:
|
|
|
|
|
|
|
|
|
|
|
|
- identical on prod *and* beta, and not a "lets one environment act as another"
|
2026-07-26 06:56:38 +00:00
|
|
|
|
credential → `common.yaml` (never reaches dev, on vps1, at all);
|
|
|
|
|
|
- per-environment, or one of `POSTGRES_PASSWORD` / `THERMOGRAPH_AUTH_SECRET` /
|
docs: add a developer onboarding guide for the monorepo
Twelve documents under docs/onboarding/ covering orientation, local setup,
the repo map, per-domain deep dives, the cross-service contracts, CI and the
release flow, infra and secrets, observability, task recipes, and a list of
which docs in this tree are currently stale.
Every command in the setup guide was run against this checkout: the backend
suite (429 passed, 8 skipped), the frontend Go suite, a venv boot of the
backend, and a build+boot of the Go frontend.
Two findings recorded along the way:
- static/units.js's F_REGIONS is guarded by no test, despite three source
comments claiming "a test asserts all three stay identical". The Go test
only cross-checks the Go copy against the backend's Python. All four copies
are currently identical.
- backend/ and frontend/docker-compose.test.yml still default to the retired
emi/thermograph-backend/app image path, and the frontend harness pins the
split-era v0.0.2-split-ci tag.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:25:52 +00:00
|
|
|
|
`THERMOGRAPH_DATABASE_URL` → `prod.yaml` / `beta.yaml`;
|
|
|
|
|
|
- dev → `dev.yaml`, and give it **its own** value. Never a copy of prod's.
|
|
|
|
|
|
|
|
|
|
|
|
Preview with `infra/deploy/secrets/dry-run-render.sh`. Audit cross-environment
|
|
|
|
|
|
drift with the `key-gaps` skill (names only, never values).
|
|
|
|
|
|
|
|
|
|
|
|
**Never** hand-edit `/etc/thermograph.env`, paste a plaintext secret onto a box,
|
|
|
|
|
|
or run `seed-from-live.sh`.
|
|
|
|
|
|
|
|
|
|
|
|
If a rotated value needs containers recreated to take effect, remember
|
|
|
|
|
|
`infra-sync` rolls nothing — you need an app deploy or a by-hand
|
|
|
|
|
|
`SERVICE=all … deploy.sh`.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Add a recurring background job
|
|
|
|
|
|
|
|
|
|
|
|
Ask one question first: **does it need data?**
|
|
|
|
|
|
|
|
|
|
|
|
- **No** (a timer, a connection, an external poke) → add a `cron.Job` in
|
|
|
|
|
|
`backend/daemon/main.go`. One replica, so no election needed. Give it an env
|
|
|
|
|
|
var for its interval, defaulting sensibly and falling back to the default on a
|
|
|
|
|
|
malformed value.
|
|
|
|
|
|
- **Yes** → add an `/internal/*` route in `backend/api/internal_routes.py` and
|
|
|
|
|
|
have the daemon POST to it. Grading depends on polars and the cache;
|
|
|
|
|
|
reimplementing it out-of-process would let results drift from the API's.
|
|
|
|
|
|
|
|
|
|
|
|
If you genuinely need an in-process background thread in the Python app instead,
|
|
|
|
|
|
**read `core/singleton.py` first**. Anything that issues upstream fetches on a
|
|
|
|
|
|
timer must run behind `claim_leader()` — N workers × M hosts multiplies quota
|
|
|
|
|
|
use N×M, and that has already caused overnight 429s in production. Things that
|
|
|
|
|
|
drain a per-worker request-fed queue (the neighbour warmer) correctly run
|
|
|
|
|
|
everywhere; so does the heartbeat, because a beat costs one local file append.
|
|
|
|
|
|
|
|
|
|
|
|
Then give it a heartbeat (`audit.log_heartbeat()`) so observability can alert on
|
|
|
|
|
|
its death — see the "too quiet to alert on" gap in
|
|
|
|
|
|
[observability](09-observability.md).
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Change a Grafana alert or dashboard
|
|
|
|
|
|
|
|
|
|
|
|
**Never through the UI.** Provisioning overwrites it on the next start.
|
|
|
|
|
|
|
|
|
|
|
|
1. Edit `observability/grafana/provisioning/alerting/*.yml` or
|
|
|
|
|
|
`observability/grafana/dashboards/*.json`.
|
|
|
|
|
|
2. If you're changing a threshold, **re-derive it against real Loki data** — the
|
|
|
|
|
|
working for the current number is in the comments beside the rule.
|
|
|
|
|
|
3. Open a PR. `observability-validate.yml` will check that every condition names
|
|
|
|
|
|
a real refId, every policy routes to a real receiver, and no literal webhook
|
|
|
|
|
|
URL is committed.
|
|
|
|
|
|
4. **Merging is not deploying.** Ship it by hand per
|
|
|
|
|
|
`observability/README.md` — and remember `docker restart` won't pick up a new
|
|
|
|
|
|
environment variable.
|
|
|
|
|
|
5. Verify by forcing a real message into `#ops-alerts`. The API redacts the
|
|
|
|
|
|
webhook, so arrival is the only proof.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Debug: "the site is serving stale data"
|
|
|
|
|
|
|
|
|
|
|
|
Work the token chain, in this order:
|
|
|
|
|
|
|
|
|
|
|
|
1. `GET /api/version` on the affected environment — is `payload_ver` what you
|
|
|
|
|
|
expect? If a payload shape changed and this didn't move, that's your answer.
|
|
|
|
|
|
2. Is the derived-store token advancing? It's built from `PAYLOAD_VER` plus the
|
|
|
|
|
|
history end date (`history_token`), so a cell whose history stopped updating
|
|
|
|
|
|
pins its token.
|
|
|
|
|
|
3. Is the *history* updating? Check `climate_sync` freshness
|
|
|
|
|
|
(`infra/ops/dbq.sh prod …`) and whether upstream is in a rate-limit cooldown
|
|
|
|
|
|
(the errors log, tagged `retry`/`error`).
|
|
|
|
|
|
4. Is it the **frontend's** TTL cache instead? `THERMOGRAPH_SSR_CACHE_TTL`
|
|
|
|
|
|
defaults to 600s, in front of the backend's own caching.
|
|
|
|
|
|
5. Is it the **browser**? `cache.js` seeds IndexedDB from the `/cell` bundle. If
|
|
|
|
|
|
ETags stopped being visible cross-origin (`expose_headers`), revalidation
|
|
|
|
|
|
silently stops.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Debug: "a deploy went green but nothing changed"
|
|
|
|
|
|
|
|
|
|
|
|
Common causes, most likely first:
|
|
|
|
|
|
|
|
|
|
|
|
1. **The tree was already identical.** A promotion between branches whose trees
|
|
|
|
|
|
match merges nothing while still firing the deploy workflows.
|
|
|
|
|
|
2. **The domain wasn't touched.** `deploy.yml` re-checks whether *this* push
|
|
|
|
|
|
touched *this* domain, and skips the leg if not.
|
|
|
|
|
|
3. **The tag is domain-keyed.** `deployed_version` showing a tag that isn't the
|
|
|
|
|
|
branch head is correct behaviour, not drift.
|
|
|
|
|
|
4. **Someone hand-edited `/opt/thermograph`.** `deploy.sh` runs
|
|
|
|
|
|
`git reset --hard` first; the edit is gone.
|
|
|
|
|
|
5. **It's an observability change.** `/opt/observability` points at the archived
|
|
|
|
|
|
repo and cannot pull. See above.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Promote to beta, and then to prod
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
dev → main your call, batched. Beta is the test environment;
|
|
|
|
|
|
not promoting is not testing.
|
|
|
|
|
|
main → release ALWAYS the owner's call. Prepare it, state what would ship,
|
|
|
|
|
|
the CI evidence and how long it soaked on beta, then recommend —
|
|
|
|
|
|
including recommending against.
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Centralis's `promote(from, to)` does the mechanics and enforces the rule;
|
|
|
|
|
|
`forge_pr_merge` refuses `main → release` under any flag. Before promoting,
|
|
|
|
|
|
compare the two tips' **trees**, not their commit counts.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Roll back
|
|
|
|
|
|
|
|
|
|
|
|
Redeploying the previous image tag:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
SERVICE=backend BACKEND_IMAGE_TAG=sha-<previous12hex> \
|
|
|
|
|
|
/opt/thermograph/infra/deploy/deploy.sh
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Check the tag actually exists first.** Both deploy scripts *end* by deleting
|
2026-07-26 06:56:38 +00:00
|
|
|
|
images outside the running pair. Beta now runs Swarm exactly like prod
|
|
|
|
|
|
(co-resident on vps2), so both usually keep a handful of stopped-task
|
|
|
|
|
|
references; dev, the one remaining compose environment (on vps1), is the one
|
|
|
|
|
|
that typically holds no local rollback target at all. `rollback_to(dry_run:
|
|
|
|
|
|
true)` checks host and registry and will tell you when it's in neither.
|
docs: add a developer onboarding guide for the monorepo
Twelve documents under docs/onboarding/ covering orientation, local setup,
the repo map, per-domain deep dives, the cross-service contracts, CI and the
release flow, infra and secrets, observability, task recipes, and a list of
which docs in this tree are currently stale.
Every command in the setup guide was run against this checkout: the backend
suite (429 passed, 8 skipped), the frontend Go suite, a venv boot of the
backend, and a build+boot of the Go frontend.
Two findings recorded along the way:
- static/units.js's F_REGIONS is guarded by no test, despite three source
comments claiming "a test asserts all three stay identical". The Go test
only cross-checks the Go copy against the backend's Python. All four copies
are currently identical.
- backend/ and frontend/docker-compose.test.yml still default to the retired
emi/thermograph-backend/app image path, and the frontend harness pins the
split-era v0.0.2-split-ci tag.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:25:52 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Regenerate the bundled city data
|
|
|
|
|
|
|
|
|
|
|
|
`backend/cities.json` and `cities_flavor.json` are generated reference data, not
|
|
|
|
|
|
runtime state:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
cd backend
|
|
|
|
|
|
python gen_cities.py # the curated city set
|
|
|
|
|
|
python gen_flavor.py # the editorial blurbs
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Editorial copy has its own voice guide — the **`city-copy`** skill defines what
|
|
|
|
|
|
good reads like, and **`backfill-city-copy`** drives generation at scale. Both
|
|
|
|
|
|
enforce the relative-grades rule.
|
|
|
|
|
|
|
|
|
|
|
|
After a deploy, `warm_cities.py` pre-warms those cells so `/climate` pages serve
|
|
|
|
|
|
from cache and a crawl never bursts the archive quota. It's idempotent, paced,
|
|
|
|
|
|
and claims a flock — deploys launch it detached with no overlap check, so two
|
|
|
|
|
|
overlapping runs would otherwise double-spend quota and race the cache writes.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Work on a live host
|
|
|
|
|
|
|
|
|
|
|
|
Prefer Centralis: `run_on_host`, `fleet_status`, `deployed_version`,
|
|
|
|
|
|
`logs_query`, `sql_query`.
|
|
|
|
|
|
|
|
|
|
|
|
You may, without asking: read anything; restart or roll a wedged service; clear
|
|
|
|
|
|
a full disk; kill a runaway process; bounce a crash-looping container; run the
|
|
|
|
|
|
blessed deploy path onto an already-published image.
|
|
|
|
|
|
|
|
|
|
|
|
You must not: hand-edit the checkout to persist a change (`git reset --hard`
|
|
|
|
|
|
eats it); hand-edit `/etc/thermograph.env` (rendered artifact); force-push;
|
|
|
|
|
|
deploy prod from an unmerged branch; `docker system prune -a` on a live box (it
|
|
|
|
|
|
deletes your rollback image — `docker image prune -f` is the safe form); or
|
|
|
|
|
|
restart the `_db` service casually. Postgres/Timescale restarts and volume
|
|
|
|
|
|
operations get confirmed with the operator first.
|
|
|
|
|
|
|
|
|
|
|
|
Next: [Traps and stale docs](11-traps.md).
|