All checks were successful
secrets-guard / encrypted (push) Successful in 5s
shell-lint / shellcheck (push) Successful in 8s
secrets-guard / encrypted (pull_request) Successful in 18s
PR build (required check) / changes (pull_request) Successful in 22s
shell-lint / shellcheck (pull_request) Successful in 19s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 1m11s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m14s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m1s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 2s
190 lines
8.8 KiB
Markdown
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).
|