8.8 KiB
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
{"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.pyhas 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:
TestFCountriesMatchesBackendre-parses the backend's Python and asserts the Go copy matches. This one skips in CI — the frontend image's build context isfrontend/, sobackend/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.TestFCountriesMatchesUnitsJSparsesstatic/units.jsand diffs both directions. This one does run in the image build —frontend/Dockerfilecopies 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_INTERNALis 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 athttp://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_versioncan 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
vNrouter alongside, bumpAPI_CONTRACT_VERSION, checkmin_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_ordinalcopies and the colour tokens. - Did I touch the Fahrenheit set? → four copies, and
units.jsis unguarded. - Did I change the
/cellbundle shape? → the slice→view map incache.js. - Did I change ETag derivation or CORS headers? → re-read
cache.js.
Next: CI and release.