thermograph/docs/onboarding/06-contracts.md

191 lines
8.8 KiB
Markdown
Raw Permalink Normal View History

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