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

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.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.

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.