diff --git a/docs/build-status.md b/docs/build-status.md index eb33e49..01f15b3 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -621,3 +621,42 @@ finding code and the metrics survive, then deleted it. - Accounts/OIDC on the server, which is what `uploads=account` is waiting for. - Nothing in this entry has been exercised on a phone yet — all of it was verified from the PC against the live server. On-device verification is the next step. + +### SemVer compatibility windows between app and server (server-v0.5.0 … v0.5.2, 2026-08-01) +Both artifacts are SemVer, and each now declares — and enforces — which peer versions it will talk +to. Spec: `docs/probe-protocol.md` §8. + +**Two axes, deliberately not conflated.** Release versions are a *proxy* for what actually has to +match, so the real thing is checked first: +- `protocol_version` — **can** these builds talk. Advertised in the profile; a peer in a different + breaking series is refused whatever its release version says. Below 1.0.0 the **minor** is the + breaking axis (SemVer §4). +- release-version window — **may** they, per policy. `[min, max)`, min inclusive, max exclusive, + because the useful bound is always "the version that broke it". + +Bounds sit at breaking boundaries, not at releases, so shipping a patch never requires editing a +range. The app requires server `>= 0.4.2` for a stated reason, not caution: earlier multi-homed +servers mis-addressed granted sends and the client measured 100 % downstream loss that never +happened. Operators override the server side with `ECHOLOT_MIN_APP_VERSION` / +`ECHOLOT_MAX_APP_VERSION`; a malformed bound is fatal at startup rather than ignored, so a typo +cannot silently disable a restriction. + +Three rules that shaped the implementation: +1. **`GET /v1/profile` is never gated.** It is where a refused client learns which version it needs; + gating it leaves the user with a network error instead of an answer. +2. **An unparseable or absent version is `unknown`, and is allowed.** Dev builds report `dev`, and a + client too old to send the header cannot be identified anyway. +3. **Refusal is 426 with a body naming both versions and the window**, surfaced client-side as a + distinct `VersionRefused` rather than folded into "network error". + +The app's `versionCode` is now derived from its SemVer (`major*1e6 + minor*1e4 + patch*10`) instead +of being a second number to remember. + +Verified live against fmr (`LiveCompatTest`): profile advertises the window and stays readable for a +refused version; 0.1.0 and 99.0.0 are both refused with actionable messages; 0.2.0 and a missing +header are both served. + +One user-visible bug caught in the process: Go's JSON encoder HTML-escapes `<`, `>` and `&` by +default, so the refusal reached the client as `needs \u003e= 0.2.0`. Disabled at the encoder (this +is an API, not a page), and the client now *parses* the error field instead of pattern-matching it, +so it survives whatever a future encoder decides to escape.