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
|
|
|
# 6. Cross-service contracts
|
|
|
|
|
|
|
|
|
|
**Read this before any change that touches both domains.**
|
|
|
|
|
|
|
|
|
|
Backend and frontend build separate images and deploy independently. That
|
|
|
|
|
independence is the point of the architecture — and it means there is no build
|
|
|
|
|
step, no type checker and no linker that will catch a mismatch between them. The
|
|
|
|
|
items below are the seams. Each one fails *silently and in production*, which is
|
|
|
|
|
why they're enumerated rather than left to judgement.
|
|
|
|
|
|
|
|
|
|
## 1. API version — `GET /api/version`
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{"backend_version": "2", "min_frontend": "1", "payload_ver": "p2"}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Driven by `API_CONTRACT_VERSION` and `MIN_SUPPORTED_FRONTEND` in
|
|
|
|
|
`backend/web/app.py`.
|
|
|
|
|
|
|
|
|
|
**The rule for a breaking change:** ship a new `v3` router mounted **alongside**
|
|
|
|
|
`v2`, re-registering only the handlers that changed, and bump the constant in
|
|
|
|
|
the same PR. `/api` and `/api/v1` stay mounted as aliases. Every version stays
|
|
|
|
|
served simultaneously — that is how FE and BE ship out of lockstep at all.
|
|
|
|
|
|
|
|
|
|
**The frontend pins the version in exactly two places:**
|
|
|
|
|
|
|
|
|
|
| Where | What |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `frontend/server/internal/config` (Go) — `THERMOGRAPH_API_VERSION`, default `v2` | every server-side path builder reads it |
|
|
|
|
|
| `frontend/static/account.js` (JS) — the exported `API_VERSION` and the `uv(path)` helper | every browser-side call |
|
|
|
|
|
|
|
|
|
|
Never hardcode `api/v2/...` anywhere else. Bump both in one PR, and **only
|
|
|
|
|
after** the target backend's `/api/version` confirms it serves that version and
|
|
|
|
|
its `min_frontend` doesn't exclude the one you're leaving.
|
|
|
|
|
|
|
|
|
|
> The Python `frontend/api_client.py` has a third pin. It isn't deployed, but if
|
|
|
|
|
> you're using it locally, keep it in step or you'll debug a phantom.
|
|
|
|
|
|
|
|
|
|
## 2. `PAYLOAD_VER` — the cache invalidation token
|
|
|
|
|
|
|
|
|
|
`backend/api/payloads.py`, currently `"p2"`.
|
|
|
|
|
|
|
|
|
|
This is **separate from URL versioning** and does a different job: it's the
|
|
|
|
|
cache/ETag invalidation token. Every derived row is stored against a validity
|
|
|
|
|
token built from it, and a stored token that doesn't match the caller's current
|
|
|
|
|
token is simply a miss.
|
|
|
|
|
|
|
|
|
|
**Bump it whenever a response payload's shape changes.** One bump atomically
|
|
|
|
|
orphans every pre-upgrade cached row across every environment. Forget it and
|
|
|
|
|
the backend serves old-shaped JSON out of cache indefinitely, with no error
|
|
|
|
|
anywhere — the new code is live and the old data keeps flowing.
|
|
|
|
|
|
|
|
|
|
`CONTENT_VER` is deliberately separate so a content-only shape change doesn't
|
|
|
|
|
invalidate the whole grading cache.
|
|
|
|
|
|
|
|
|
|
## 3. ETag / `If-None-Match` — and `expose_headers`
|
|
|
|
|
|
|
|
|
|
Every graded payload is cached against a validity token that doubles as a weak
|
|
|
|
|
ETag. `frontend/static/cache.js` fetches `/api/v2/cell` once per view-set,
|
|
|
|
|
slices it, and revalidates each view individually with `If-None-Match`.
|
|
|
|
|
|
|
|
|
|
**`expose_headers=["ETag"]` in the CORS middleware matters as much as
|
|
|
|
|
`allow_origins`.** Browsers hide every response header except a fixed "simple"
|
|
|
|
|
allowlist from cross-origin JS. Without `ETag` exposed, `cache.js`'s
|
|
|
|
|
`res.headers.get("ETag")` silently returns `null`, 304 revalidation quietly
|
|
|
|
|
stops working, and **there is no console error**. You'd see it only as a traffic
|
|
|
|
|
bill.
|
|
|
|
|
|
|
|
|
|
CORS is off by default (`THERMOGRAPH_CORS_ORIGINS` unset), which is today's real
|
|
|
|
|
same-origin topology. Never set it to `*` — Starlette itself refuses a
|
|
|
|
|
credentialed wildcard.
|
|
|
|
|
|
|
|
|
|
Don't change the ETag derivation or that header list without checking
|
|
|
|
|
`cache.js`. And when the payload shape changes, the **slice → view map** in
|
|
|
|
|
`cache.js` has to track it.
|
|
|
|
|
|
|
|
|
|
## 4. `pct_ordinal()` — mirrored in four places
|
|
|
|
|
|
|
|
|
|
Floor a percentile into `1..99`. **Never 0, never 100.**
|
|
|
|
|
|
|
|
|
|
| Copy | File | Guarded by |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| canonical | `backend/data/grading.py::pct_ordinal` | its own unit tests |
|
|
|
|
|
| Go SSR | `frontend/server/internal/format/format.go::PctOrdinal` | `format_test.go::TestPctOrdinal` (table-driven, not a cross-check) |
|
|
|
|
|
| browser | `frontend/static/shared.js::pctOrd` | nothing automated |
|
|
|
|
|
| Python SSR *(not deployed)* | `frontend/format.py::pct_ordinal` | the Python unit tier |
|
|
|
|
|
|
|
|
|
|
Every percentile on every surface goes through one of these. If they diverge,
|
|
|
|
|
the same reading says two different things in two places. The backend copy is
|
|
|
|
|
canonical; the others follow it.
|
|
|
|
|
|
|
|
|
|
`TEMP_BANDS` / `RAIN_BANDS` in `grading.py` are the source of truth for tier
|
|
|
|
|
names and thresholds. Changing them without the frontend produces tiers drawn
|
|
|
|
|
in colours that disagree with the labels the API returns.
|
|
|
|
|
|
|
|
|
|
## 5. The Fahrenheit country set
|
|
|
|
|
|
|
|
|
|
Fourteen codes: `US PR GU VI AS MP UM BS BZ KY PW FM MH LR`.
|
|
|
|
|
|
|
|
|
|
| Copy | File |
|
|
|
|
|
|---|---|
|
|
|
|
|
| canonical | `backend/api/content_payloads.py::F_COUNTRIES` |
|
|
|
|
|
| Go SSR | `frontend/server/internal/format/format.go::FCountries` |
|
|
|
|
|
| browser | `frontend/static/units.js::F_REGIONS` |
|
|
|
|
|
| Python SSR *(not deployed)* | `frontend/format.py::F_COUNTRIES` |
|
|
|
|
|
|
frontend: fix the three inconsistencies the onboarding guide found
1. CLAUDE.md and README.md described the superseded Python service. Both now
describe server/ (Go), say plainly that the Python files at that level are
the original the port was made from, and drop CLAUDE.md's claim that
`make test-unit` is "the tier CI runs" — CI's only frontend check is the
Dockerfile builder stage's gofmt + vet + go test.
2. static/units.js's F_REGIONS was guarded by nothing, despite three source
comments claiming "a test asserts all three stay identical": the only check
compared the Go set against the backend's Python. TestFCountriesMatchesUnitsJS
now diffs the browser copy both directions.
That backend cross-check also skips in CI — the image build context is
frontend/, so backend/ is unreachable from the builder stage, which is the
only place CI runs these tests. static/ IS in the context, so the Dockerfile
copies units.js into the builder and the new assertion runs during the image
build. Verified by mutating units.js and confirming the build fails.
3. Both docker-compose.test.yml files defaulted to the retired
emi/thermograph-backend/app path, and the frontend harness pinned the
split-era v0.0.2-split-ci tag. Path corrected in both. Rather than swap one
hardcoded pin for another, backend-for-tests.sh now derives the tag from the
checkout — sha-<12hex of `git log -1 -- backend/`>, the same domain-keyed
rule build-push.yml and deploy.yml use — and compose requires the variable
so a stale pin cannot creep back in.
Verified: backend 429 passed/8 skipped; frontend go vet clean and all packages
ok; frontend image builds; `make backend-up` pulls and serves on the derived
tag; shellcheck zero findings across the tree.
Unrelated pre-existing issue noted in the docs, not fixed here:
`make test-integration` fails 7/16 with 503 against a cold throwaway backend
(empty database, nothing warm). Reproduced identically on the old image, so it
predates this change.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:42:16 +00:00
|
|
|
Two tests in `frontend/server/internal/format/format_test.go` guard this:
|
|
|
|
|
|
|
|
|
|
- `TestFCountriesMatchesBackend` re-parses the backend's Python and asserts the
|
|
|
|
|
Go copy matches. **This one skips in CI** — the frontend image's build context
|
|
|
|
|
is `frontend/`, so `backend/` is unreachable from the builder stage, and the
|
|
|
|
|
builder stage is the only place CI runs these tests. Treat it as a
|
|
|
|
|
checkout-only guard: it fires on your machine and in a full-checkout run, not
|
|
|
|
|
during the image build.
|
|
|
|
|
- `TestFCountriesMatchesUnitsJS` parses `static/units.js` and diffs both
|
|
|
|
|
directions. This one *does* run in the image build — `frontend/Dockerfile`
|
|
|
|
|
copies that single file into the builder stage for exactly that reason — so a
|
|
|
|
|
divergence fails the build.
|
|
|
|
|
|
|
|
|
|
The browser copy was unguarded until that second test was added; several source
|
|
|
|
|
comments had long claimed "a test asserts all three stay identical" when only
|
|
|
|
|
the Go↔backend pair was checked. The Python `frontend/format.py` copy remains
|
|
|
|
|
guarded only by the local Python tier, which is fine — it isn't deployed.
|
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
|
|
|
|
|
|
|
|
## 6. Backend boot must survive an unreachable frontend, and vice versa
|
|
|
|
|
|
|
|
|
|
Both directions are load-bearing for asynchronous deploys:
|
|
|
|
|
|
|
|
|
|
- **Frontend → backend:** boot tries the IndexNow-key fetch once, catches
|
|
|
|
|
failure, and falls back to a lazy per-request lookup. Don't make it fatal.
|
|
|
|
|
- **Backend → frontend:** `THERMOGRAPH_FRONTEND_BASE_INTERNAL` is required at
|
|
|
|
|
import (fails loud), but it's only *used* by the catch-all proxy fallback —
|
|
|
|
|
an unreachable value doesn't stop the backend serving its own routes. That's
|
|
|
|
|
why the test suite and the smoke harness both point it at
|
|
|
|
|
`http://127.0.0.1:1`.
|
|
|
|
|
|
|
|
|
|
`/healthz` on both services is liveness only. Neither proves the other is
|
|
|
|
|
reachable, deliberately — readiness is the ingress health-gate's job.
|
|
|
|
|
|
|
|
|
|
## 7. `THERMOGRAPH_BASE` must match on both processes
|
|
|
|
|
|
|
|
|
|
Default `/thermograph`; both images set `/`. Both processes must be configured
|
|
|
|
|
**identically** in every real deployment — it's how they agree on the shared URL
|
|
|
|
|
prefix. The Go frontend's content client reads it too, so a sub-path deployment
|
|
|
|
|
works rather than only ever working at the root.
|
|
|
|
|
|
|
|
|
|
## 8. Image tags are keyed to the domain, not the branch tip
|
|
|
|
|
|
|
|
|
|
Both `build-push.yml` and `deploy.yml` compute the tag as
|
|
|
|
|
`sha-` + the first 12 hex of `git log -1 --format=%H -- <domain>/`.
|
|
|
|
|
|
|
|
|
|
Two consequences:
|
|
|
|
|
|
|
|
|
|
- An infra-only commit at the branch tip cannot send a deploy chasing an image
|
|
|
|
|
no build produced.
|
|
|
|
|
- `deployed_version` can legitimately show a tag that is **not** the head of the
|
|
|
|
|
branch. That's correct, not drift.
|
|
|
|
|
|
|
|
|
|
`fetch-depth: 0` in the deploy workflow exists for exactly this: in a
|
|
|
|
|
path-filtered monorepo the tip is often another domain's commit, and a depth-1
|
|
|
|
|
clone can't see past it.
|
|
|
|
|
|
|
|
|
|
## 9. Cookie auth is same-origin today
|
|
|
|
|
|
|
|
|
|
The interactive tool's auth is an HttpOnly session cookie. `account.js` uses
|
|
|
|
|
`credentials: "include"` (not the fetch default) so the cookie still rides along
|
|
|
|
|
if the script is ever loaded cross-origin — but the backend must actually be
|
|
|
|
|
configured to accept credentialed cross-origin requests (CORS +
|
|
|
|
|
`SameSite`) for that to work. In the default same-origin deployment it's a
|
|
|
|
|
non-issue. **Don't assume a fully decoupled, independently-hosted frontend
|
|
|
|
|
"just works" for logged-in features** without checking that configuration.
|
|
|
|
|
|
|
|
|
|
## Quick checklist
|
|
|
|
|
|
|
|
|
|
Before opening a PR that touches both domains:
|
|
|
|
|
|
|
|
|
|
- [ ] Did any response payload shape change? → bump `PAYLOAD_VER`.
|
|
|
|
|
- [ ] Did any route or payload break compatibility? → new `vN` router
|
|
|
|
|
alongside, bump `API_CONTRACT_VERSION`, check `min_frontend`.
|
|
|
|
|
- [ ] Did I change the API version pin? → both Go config **and**
|
|
|
|
|
`static/account.js`, in one PR, after checking the target backend.
|
|
|
|
|
- [ ] Did I touch tier names, thresholds, or percentile rendering? → check all
|
|
|
|
|
four `pct_ordinal` copies and the colour tokens.
|
|
|
|
|
- [ ] Did I touch the Fahrenheit set? → four copies, and `units.js` is
|
|
|
|
|
unguarded.
|
|
|
|
|
- [ ] Did I change the `/cell` bundle shape? → the slice→view map in
|
|
|
|
|
`cache.js`.
|
|
|
|
|
- [ ] Did I change ETag derivation or CORS headers? → re-read `cache.js`.
|
|
|
|
|
|
|
|
|
|
Next: [CI and release](07-ci-and-release.md).
|