ADR-091 — Station TLEs live in the served /data overlay, refreshed by one server-side fetcher
Status · Accepted · 2026-09-07 · Operator-directed (Physics Lab v0.9 · H4c · #464) · applies the launches overlay pattern (RFC-035 / ADR-065) to station element sets · supersedes the runtime browser→Celestrak fetch introduced with the AR sky (#405)
Context
Two code paths fetched station TLEs (ISS / Tiangong) from Celestrak, independently:
- The AR sky (
/explore"look at the sky", #405) fetched Celestrak from the browser at runtime (tle-source.ts), cached in localStorage for a day, and fell back to a bundled sample. - A build-time refresh (
scripts/fetch-station-tles.mjs) existed but nothing ran it — its "runs daily" comment was false and the bundled set had gone ~49 days stale (found in H · #464).
Meanwhile the Lab's iss-pass read the bundled JSON directly (baked into the app + the MCP image).
This is three sources of truth and a browser egress dependency. A stale TLE propagated weeks forward puts the ISS marker / pass in the wrong place; the browser fetch adds a CORS / uptime dependency at the moment a user opens the feature; and the MCP image only ever saw the baked copy.
The launches subsystem already solved the same shape (RFC-035): fetch server-side into the served static/data overlay, refresh it live on the VPS every 6h with no redeploy, and let the SPA read the same-origin file.
Decision
One canonical file, one Celestrak fetcher, served from the /data overlay.
- The element sets live in
static/data/station-tles.json(served overlay), not insrc. scripts/fetch-station-tles.mjsis the only code that touches Celestrak, and it runs server-side in two places, exactly like launches:- daily on
mainvia.github/workflows/refresh-station-tles.yml(bot-commit,chore(tle): refresh, on theci-databot-*concurrency group so it never cancels user CI); - every 6h on the prod VPS via
ops/refresh-prod-data.sh(live overlay write, backup + min-bytes safety-restore, precompressed-sibling drop — no rebuild, no redeploy).
- daily on
- The app reads the fresh served copy at runtime through the single resolver
resolveStationTle/resolveStationTleBlock(tle-source.ts), fetching same-origin${base}/data/station-tles.json, memoised per session, with no browser→Celestrak egress. Both the AR sky and the Lab's injectediss-passTLE go through it. - The build-baked import (
stations.ts→static/data/station-tles.json) is the offline / MCP fallback. The MCP image carries the baked copy; a manually-deployed container that lags is not silently wrong becauseiss-passdisclosesepochAgeDaysvsstaleAfterDays(H5).
Consequences
- No browser egress; one Celestrak-fetching code path; prod freshness ≤6h without a redeploy.
- The AR-sky consumer is unchanged at the call site (
resolveStationTlekeeps its signature); only the source behind it changed. - Freshness is now uniform across the AR sky, the Lab, and (baked + disclosed) the MCP tool.
- Trade-off: the served copy and the baked copy can diverge between deploys — intended. The app prefers the served (fresh) copy; the baked copy is the honest, age-disclosed fallback.