thermograph/docs/onboarding/06-contracts.md
Emi Griffith b2b56bdc1a 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 11:43:22 -07:00

190 lines
8.8 KiB
Markdown

# 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` |
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.
## 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).