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
# 1. Orientation
## What the product actually claims
Thermograph answers one question: **how unusual is the weather here, right now,
compared to this exact place's own history?**
That framing is not decoration — it constrains the code, the copy and the
colour scales:
- A grade is a **percentile against ~45 years of that grid cell's own record** ,
not an absolute reading. 60 °F is *Above Normal* on a cool coast and *Below
Normal* inland on the same afternoon.
- Tier names are therefore relative words — *Near Record* , *High* , *Above
Normal*, *Normal* , *Below Normal* , *Low* , *Near Record* — never "hot",
"warm", "cold". This rule binds prose, alt text, commit messages and API
fields alike.
- Precipitation is graded on its **own** ladder, because most days are dry: a
rainy day is ranked only among the rain days in its seasonal window, and dry
days are coloured by dry-streak length instead.
Public since **2026-07-16** . Free. Run by one operator plus automation, which
is why so much of this repo is about making irreversible things hard.
## The domain model, in six steps
1. **Grid** (`backend/data/grid.py`) — an arbitrary lat/lon snaps to a stable
** ~4 sq mi cell**. Latitude rows are ~2 miles tall; longitude spacing scales
by `cos(latitude)` so cells stay roughly square at any latitude. The cell id
is deterministic and is the cache key for everything downstream. Worldwide
coverage, no gaps.
2. **History** (`backend/data/climate.py`) — the full **1980 → present daily
record** (max/min temp, precip, wind, gust, humidity, feels-like) for that
cell, fetched once and stored durably. Expensive to obtain, so it is treated
as source-of-truth data, not cache.
3. **Recent + forecast** — a separate bundle of recent observations plus ~8
forward days, refreshed on its own TTL. This is what "today" is graded from.
4. **Grading** (`backend/data/grading.py`) — for a given day of year, the
reference distribution is *every historical day within ±7 days of it*
(a 15-day seasonal window that wraps the year end). The observed value is
placed on that distribution as an **empirical mid-rank percentile** , then
mapped onto `TEMP_BANDS` / `RAIN_BANDS` .
5. **Payloads** (`backend/api/payloads.py`) — pure "inputs → response dict"
assembly. One definition per payload shape, shared by the HTTP routes, the
`/cell` bundle and offline tooling.
6. **Caching** (`backend/data/store.py`) — every derived payload is stored
against a **validity token** that encodes everything the payload depends on.
A token mismatch is simply a miss. Freshness is driven by the token
advancing, *never* by clock expiry — so stale data cannot be served, and one
`PAYLOAD_VER` bump atomically orphans every pre-upgrade row.
If you internalise one thing: **the token is the cache-correctness mechanism,
and `PAYLOAD_VER` is the lever that moves it.** See
[contracts ](06-contracts.md ).
## The estate
2026-07-26 06:56:38 +00:00
Hosts are named by **role** , not by environment, plus the operator's desktop —
all on a WireGuard mesh (`10.10.0.0/24`):
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
| Host | Mesh IP | Public | Runs |
|---|---|---|---|
2026-07-26 06:56:38 +00:00
| **vps1** | `10.10.0.2` | `75.119.132.91` , git.thermograph.org, dashboard.thermograph.org | Forgejo (git + CI + registry), Grafana + Loki + Alloy, the `emigriffith.dev` portfolio, and **dev** — its own Postgres, plain compose, **mesh-only** (no public DNS, no Caddy site, no TLS) |
| **vps2** | `10.10.0.1` | `169.58.46.181` , thermograph.org, beta.thermograph.org | **prod** and **beta** as two separate Docker **Swarm** stacks, Centralis, Postfix, backups |
| **desktop** | `10.10.0.3` | — | AI-model hosting (voice-to-text, an upcoming-feature LLM) + flex Swarm capacity. Hosts **no** Thermograph environment — `make dev-up` there is a laptop convenience only |
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
| phone | — | — | alerts |
2026-07-26 06:56:38 +00:00
vps2 runs prod and beta **co-resident on one box** : one TimescaleDB instance
serves both, on separate databases and separate roles (`thermograph` /
`thermograph_beta` , the latter `NOSUPERUSER` /`NOCREATEDB`, `CONNECT` revoked
from `PUBLIC` ); separate checkouts, env files and loopback LB ports; beta's
Swarm service names are prefixed (`beta-web`, not `web` ) because Swarm
registers a service's short name as a DNS alias on every network it joins, and
beta shares prod's network to reach the database. Beta rehearses prod's actual
orchestrator this way — that's the point of putting it next to prod rather
than next to dev.
The mesh IPs did not move when this topology landed — vps1 is the box that used
to be called "beta", vps2 the one that used to be called "prod". What moved is
which environment lives where. `infra/deploy/env-topology.sh` is the single
source of truth for every per-environment path, port and role; `THERMOGRAPH_ENV`
(`dev`/`beta`/`prod`) is the input a deploy leg passes, since vps2 can no longer
tell beta and prod apart by which host it's running on.
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
Two consequences you will hit within the first week:
2026-07-26 06:56:38 +00:00
- **Forgejo is mesh-only.** `git.thermograph.org` resolves publicly to vps1's
IP, but vps1's Caddy rejects `/v2/*` (the registry API) from outside the
mesh. Any host that pulls images needs `10.10.0.2 git.thermograph.org` in
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
`/etc/hosts` .
2026-07-26 06:56:38 +00:00
- **Beta and prod run the same orchestrator now — Swarm, as two stacks on one
box.** Dev is the only environment on compose (`infra/docker-compose.yml`),
and it lives alone on vps1. Most of the time you don't care which stack
you're reading — but when you're reading logs or naming containers, you do
(see [observability ](09-observability.md )), and on vps2 you additionally have
to say *which* environment: `deploy.sh` there routes on the explicit
`THERMOGRAPH_ENV` a caller passes, not on which host it's running on.
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
## How a change travels
```
2026-07-26 06:56:38 +00:00
PR ──(required check: `gate` )──▶ dev ──▶ dev (vps1, mesh-only)
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
│
promotion PR
▼
2026-07-26 06:56:38 +00:00
main ──▶ beta.thermograph.org (vps2)
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
│
promotion PR ← the owner's call
▼
2026-07-26 06:56:38 +00:00
release ──▶ thermograph.org (vps2)
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
```
`dev` , `main` and `release` are **protected** — no direct pushes, for humans or
agents. Promotion is by pull request, continuously and per-decision; there is
no release calendar. Full detail in [CI and release ](07-ci-and-release.md ).
## The four rules that are not negotiable
These come from the operator, via Centralis's own onboarding. They override
convenience.
1. **Everything is a PR.** If it didn't go through the pipeline, it didn't
happen. This includes Grafana dashboards (provisioned from repo JSON — a UI
edit is silently overwritten) and secrets (SOPS vault only).
2. **Boring prod.** Anything clever proves itself on `dev` or beta first.
3. **Privacy is a feature.** No tracking cookies, no per-visitor IDs,
allowlisted anonymous counters only (`backend/core/metrics.py`,
`POST /api/v2/event` ). Cheap to keep, expensive to retrofit.
4. **Grades are relative.** Never describe a percentile as hot or cold.
### And a fifth, local to the repo
**Commits and PRs are concise and technical, and never mention AI, assistants
or automated authorship.** Every domain `CLAUDE.md` says this; it applies to
you too.
## Quota discipline
Thermograph runs on free upstream data sources. Two hard rules protect them:
- **Anything named "prefetch" or "warm" must never spend the Open-Meteo
quota.** The warming paths call `climate.load_cached_history` /
`load_cached_recent_forecast` — the cache-only loaders — and skip cells that
aren't warm. A cell self-heals on first real request instead.
- **Nominatim gets at most ~1 request/second.** All reverse geocoding funnels
through a single dedicated worker thread
(`climate._revgeo_worker`) that paces itself; never call it from a request
thread.
Breaking either one gets the whole service rate-limited, which is why
`singleton.claim_leader()` exists: a background sweep running in N uvicorn
workers × M hosts multiplies quota use by N× M. That failure has already
happened once, in production, at 3 workers.
## Where the written record lives
| Question | Where |
|---|---|
| How does this repo work? | The domain `CLAUDE.md` files, then this set |
| Why is the architecture like this? | `thermograph-docs` (ADRs) — `docs_search` |
| How do I operate the fleet? | `thermograph-docs` runbooks, plus the `thermograph-ops` Centralis skill |
| What did someone find out last Tuesday? | `notes_search` |
| What is and isn't live yet? | [`CUTOVER-NOTES.md` ](../../CUTOVER-NOTES.md ) at the repo root |
Next: [Local setup ](02-setup.md ).