thundercode commited on
Commit
6413c14
·
verified ·
1 Parent(s): 72f6a5b

release: add docs/architecture/09-frontend.md

Browse files
Files changed (1) hide show
  1. docs/architecture/09-frontend.md +1655 -0
docs/architecture/09-frontend.md ADDED
@@ -0,0 +1,1655 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 09 — The Frontend
2
+
3
+ **Parent:** [Architecture hub](../ARCHITECTURE.md) · **Status tags:** `IMPLEMENTED` · `VERIFIED` ·
4
+ `MEASURED` · `NOT RUN` · `SUPPORTED` · `OPEN`
5
+
6
+ **Sources of truth for this chapter, all read before writing:**
7
+
8
+ | Source | Lines | What it establishes |
9
+ |---|---|---|
10
+ | `frontend/assets/js/core.js` | 1029 | the 8 execution events (`SQ.EVENT_NAMES`), the 9-state trace spine, the deterministic policy, the raster synthesiser |
11
+ | `frontend/assets/js/live.js` | 392 | the real client: `/api/assets` → `/api/infer`, base-URL resolution, error translation |
12
+ | `frontend/assets/js/mission.js` | ~1000 | the Analyze console: `runLive` / `runMock`, `markState`, the trace fill formula, the intent panel |
13
+ | `frontend/_headers` | 77 | the Cloudflare cache/security rules and the measured concatenation finding |
14
+ | `frontend/*.html` | 11 files | the page set, the shared header `<nav>` with the GitHub and Hugging Face links |
15
+ | `scripts/stage_pages.mjs` | ~380 | the reference-driven staging pipeline, its exit codes and its 25 MiB limit |
16
+ | `docs/DEPLOYMENT_DECISION.md` | 205 | the hermeticity audit, the film, what was deliberately not created |
17
+ | `docs/DEPLOYMENT_TOPOLOGY.md` | 248 | §3.1 the Pages tier; the "EXCEPT `mission.html`" correction |
18
+ | `docs/FINAL_DELIVERY_TODO.md` | 366 | §1.4 the status board, §1.7 item 9 the Cloudflare finding, §5 B-08, §6 E-11/E-13/E-14 |
19
+ | `DELIVERY_REPORT_2026-09-25.md` | 299 | §3 the live validation and the **harness trap**; §5 the cache-busting measurement |
20
+ | session `HANDOFF_NEXT_AGENT.md` | 149 | §4 the hard constraints, including the 308 redirect and the harness rules |
21
+
22
+ > **The honesty rule this chapter inherits.** `frontend/HANDOFF.md` and the style guide both forbid
23
+ > presenting a synthetic value as a measured one. This chapter therefore labels every figure with
24
+ > where it came from, and it names the one place where the shipped code does something the
25
+ > documentation around it does not describe.
26
+
27
+ ---
28
+
29
+ ## 1. Where the frontend sits
30
+
31
+ The frontend is the **first** of the four tiers. It is a static site served by Cloudflare Pages.
32
+
33
+ ```
34
+ USER
35
+ │ HTTPS
36
+ ▼
37
+ Cloudflare Pages (static frontend) ← frontend/ , staged via scripts/stage_pages.mjs
38
+ │ HTTPS, JSON
39
+ ▼
40
+ Render (orchestrator / API gateway) ← deploy/render/ , render.yaml blueprint
41
+ │ server-to-server
42
+ ▼
43
+ GitHub Codespace (FastAPI inference) ← deploy/codespace/
44
+ ```
45
+ (`docs/DEPLOYMENT_TOPOLOGY.md` §1)
46
+
47
+ `docs/DEPLOYMENT_TOPOLOGY.md` §3.1 gives the tier's responsibility in one line:
48
+
49
+ > *"**Responsibility:** serve the static site. No backend, no secrets, no API calls of any kind
50
+ > (verified hermetic — see `DEPLOYMENT_DECISION.md` §3)."*
51
+
52
+ and then the document's own header corrects that claim for one page:
53
+
54
+ > *"§3.1's "no API calls of any kind (verified hermetic)" holds for every static page EXCEPT
55
+ > `mission.html`, which calls the orchestrator."* (`docs/DEPLOYMENT_TOPOLOGY.md` header note)
56
+
57
+ ### 1.1 The hermeticity audit, and its one exception
58
+
59
+ `docs/DEPLOYMENT_DECISION.md` §3 records the audit that made the static-only deployment viable:
60
+
61
+ > *"Audited across all of `frontend/` (excluding `.tools/`): **zero** occurrences of `fetch(`,
62
+ > `XMLHttpRequest`, `axios`, `EventSource`, `WebSocket`, `/v1/`, `import.meta.env` or `process.env`.
63
+ > The only URL-shaped string anywhere is the SVG XML namespace at `frontend/assets/js/core.js:31`,
64
+ > which is not a fetch."* (`docs/DEPLOYMENT_DECISION.md` §3)
65
+
66
+ The namespace string is real and is an XML namespace, not a network call:
67
+
68
+ ```js
69
+ svg: function (tag, attrs) {
70
+ var n = document.createElementNS('http://www.w3.org/2000/svg', tag);
71
+ ```
72
+ (`frontend/assets/js/core.js:30-31`)
73
+
74
+ > *"The staging audit reports **`external network deps: 0 (HERMETIC)`**."*
75
+ > (`docs/DEPLOYMENT_DECISION.md` §3)
76
+
77
+ That audit predates the Analyze console. `_headers` now carries a dated correction of the same claim:
78
+
79
+ > *"NOTE (2026-09-25): the site is no longer fully static — mission.html loads live.js and POSTs to
80
+ > the Render orchestrator (the Analyze console), so a real CSP would also need a connect-src for that
81
+ > API origin."* (`frontend/_headers:14-17`)
82
+
83
+ The page set is therefore **mostly static, with one live page**, and the two statements are not in
84
+ conflict: the audit measured what it measured on the tree it measured, and the correction names the
85
+ change.
86
+
87
+ ### 1.2 Fonts are self-hosted, which is what makes the site hermetic
88
+
89
+ > *"`assets/css/system.css` previously opened with a render-blocking `@import` of the Google Fonts CSS
90
+ > API. That `@import` is gone, replaced by 10 `@font-face` blocks pointing at 11 woff2 files in
91
+ > `assets/fonts/` (518,198 B total, plus `OFL.txt`). This matters more than it looks: a CSS `@import`
92
+ > is render-blocking **and** transitively script-blocking — a classic synchronous `<script>` waits on
93
+ > pending stylesheets, so a font-host stall could kill the site's JS."*
94
+ > (`HANDOFF_NEXT_AGENT.md` §4.1, session workspace)
95
+
96
+ ---
97
+
98
+ ## 2. The eleven pages
99
+
100
+ The site is **eleven** deployable HTML files: ten content pages plus a real 404 page.
101
+
102
+ | # | File | `<title>` | Role |
103
+ |---|---|---|---|
104
+ | 1 | `index.html` | `SATQUERY — Ask the Earth a question.` | the landing page; hosts the self-hosted launch film |
105
+ | 2 | `mission.html` | `SATQUERY — Analyze` | **the Analyze console** — the only page that calls the API |
106
+ | 3 | `architecture.html` | `SATQUERY — Architecture` | the architecture walk-through; carries its own sample query |
107
+ | 4 | `atlas.html` | `SATQUERY — Earth Query Atlas` | the query atlas |
108
+ | 5 | `benchmark.html` | `SATQUERY — Benchmark Lab` | per-specialist metrics with honest status labels |
109
+ | 6 | `research.html` | `SATQUERY — Research Ledger` | research entries traced to real artifacts/limitations |
110
+ | 7 | `journey.html` | `SATQUERY — The Lab` | build history by phase |
111
+ | 8 | `run.html` | `SATQUERY — Anatomy of a Run` | a real captured `ResultEnvelope`, rendered |
112
+ | 9 | `video.html` | `SATQUERY — Film archive` | the launch film |
113
+ | 10 | `references.html` | `SATQUERY — References` | provenance and credits |
114
+ | 11 | `404.html` | `SATQUERY — Not found` | a real 404 in the site's design language |
115
+
116
+ The 404 page was added deliberately and is counted:
117
+
118
+ > *"`frontend/404.html` | Real 404 page in the existing design language (light/warm/ochre, one accent,
119
+ > no rounded cards). Auto-discovered by the staging seed list, so its references are walked — **11
120
+ > deployable pages now**."* (`docs/DEPLOYMENT_DECISION.md` §6)
121
+
122
+ ### 2.1 The header, shared by all eleven
123
+
124
+ Every page carries the same `<nav>` fragment, with the GitHub and Hugging Face links last:
125
+
126
+ ```html
127
+ <a class="navlink navlink--ext" href="https://github.com/Anish-lab-blip/SatQuery-AI" target="_blank" rel="noopener" title="SatQuery source repository on GitHub">GitHub</a>
128
+ <a class="navlink navlink--ext" href="https://huggingface.co/thundercode/SatQuery" target="_blank" rel="noopener" title="SatQuery model on Hugging Face">Hugging Face</a>
129
+ ```
130
+ (`frontend/mission.html:38-39`)
131
+
132
+ The **GitHub** target is the only **public** repository, and that is why it is the one linked:
133
+
134
+ > *"Target: `https://github.com/Anish-lab-blip/SatQuery-AI` — the ONLY **public** repo (Frontend/
135
+ > Backend/Inference are private → their links would 404 for the audience)."*
136
+ > (`docs/FINAL_DELIVERY_TODO.md` §4, P9-T01)
137
+
138
+ The **Hugging Face** link is B-01, and its history is worth recording because it shows the blocker
139
+ lifecycle:
140
+
141
+ | Stage | Status |
142
+ |---|---|
143
+ | earlier | *"`B-01` | HF page + token not created | No HF header link; no HF doc push | Owner → P9-T02 | link to a pinned model page in the interim | **BLOCKED**" |
144
+ | 2026-09-25 | *"**CLOSED** 2026-09-25 — owner supplied `https://huggingface.co/thundercode/SatQuery`; link added to all 11 pages"* (`docs/FINAL_DELIVERY_TODO.md` §5) |
145
+
146
+ The verification is a live DOM query, not a file grep:
147
+
148
+ > *"`E-13` | B-01 | live DOM query for the HF anchor |
149
+ > `a[href*="huggingface.co/thundercode/SatQuery"]` present on the deployed site | VERIFIED"*
150
+ > (`docs/FINAL_DELIVERY_TODO.md` §6)
151
+
152
+ **Measured, in the working tree:** all eleven files carry exactly one occurrence of each link.
153
+
154
+ | Link | Files carrying it | Occurrences per file |
155
+ |---|---|---|
156
+ | `https://huggingface.co/thundercode/SatQuery` | 11 / 11 | 1 |
157
+ | `https://github.com/Anish-lab-blip/SatQuery-AI` | 11 / 11 | 1 |
158
+
159
+ (measured by grepping `frontend/*.html`)
160
+
161
+ The link is styled by a class added with it, so the external-link affordance is part of the same
162
+ change:
163
+
164
+ > *"Files: all 11 `frontend/*.html` header `<nav>`, reusing the existing `.navlink--ext` pattern."*
165
+ > (`docs/FINAL_DELIVERY_TODO.md` §4, P9-T02)
166
+
167
+ ### 2.2 The deployed revision
168
+
169
+ | Repo | Role | Deployed HEAD |
170
+ |---|---|---|
171
+ | `Anish-lab-blip/SatQuery-Frontend` | Cloudflare Pages (static) | `2d7ae53b482d` |
172
+
173
+ (`docs/FINAL_DELIVERY_TODO.md` §6 E-10; `DELIVERY_REPORT_2026-09-25.md` §1)
174
+
175
+ and the three commits that produced it:
176
+
177
+ | Commit | Subject | Files |
178
+ |---|---|---|
179
+ | `ff46eba42b18` | correct the lexical-router misroute; same-shape change demo pair; measured calibration curve; HF header link | 17 |
180
+ | `d413d3672311` | give the change-demo pair new URLs; drop the ineffective cache carve-out | 8 (+5, −2) |
181
+ | `2d7ae53b482d` | sibling `SQ.policy` misroute; state which calibration diagram is plotted | 2 |
182
+
183
+ (`DELIVERY_REPORT_2026-09-25.md` §1)
184
+
185
+ > **The deployed repo's root *is* the local `frontend/` directory.** *"`Anish-lab-blip/SatQuery-Frontend`
186
+ > — Cloudflare Pages; **repo root == local `frontend/`**"* (session `HANDOFF_NEXT_AGENT.md` §2). There
187
+ > is no build step in the deployed repo; the staging script is a local packaging convenience, not a
188
+ > CI pipeline.
189
+
190
+ ---
191
+
192
+ ## 3. The staging pipeline — `scripts/stage_pages.mjs`
193
+
194
+ Cloudflare Pages has **no `.assetsignore`**, so the deployable tree must be curated. That is what the
195
+ staging script is for.
196
+
197
+ > *"Cloudflare Pages constraints that already bit us: per-file limit is **25 MiB** (26,214,400 B);
198
+ > Pages has **no `.assetsignore`**, so you must stage a curated directory (hence `stage_pages.mjs`)."*
199
+ > (`HANDOFF_NEXT_AGENT.md` §4.3, session workspace)
200
+
201
+ ### 3.1 It is reference-driven, not a hardcoded list
202
+
203
+ ```js
204
+ /**
205
+ * stage_pages.mjs — build a Cloudflare-Pages-deployable staging tree for the
206
+ * SatQuery frontend by walking the ACTUAL asset references of the deployable
207
+ * HTML pages (reference-driven closure), rather than a hardcoded file list.
208
+ *
209
+ * Why reference-driven: a font set under assets/fonts/ and a regenerated film
210
+ * encode are both landing. A hardcoded list would silently omit them; this
211
+ * walks each page's src/href/poster, then each CSS @import/url(), then each JS
212
+ * import/export-from/dynamic-import, and copies the transitive closure.
213
+ */
214
+ ```
215
+ (`scripts/stage_pages.mjs:1-10`)
216
+
217
+ The limit is a constant in the script:
218
+
219
+ ```js
220
+ const PAGES_FILE_LIMIT = 26214400; // 25 MiB (Cloudflare Pages hard limit)
221
+ const BIG_WARN_BYTES = 10485760; // 10 MiB (informational)
222
+ ```
223
+ (`scripts/stage_pages.mjs:43-44`)
224
+
225
+ ### 3.2 Options and exit codes
226
+
227
+ | Flag | Meaning |
228
+ |---|---|
229
+ | `--frontend=<dir>` | Source frontend dir (default `<repo>/frontend`) |
230
+ | `--out=<dir>` | Staging dir (default `<repo>/.deploy/pages`) |
231
+ | `--launch-src=<path>` | Rewrite the homepage launch-film `<video src>` **in the staged copy only** |
232
+ | `--include=<file>` | Force-add a file no page references (repeatable) |
233
+ | `--no-clean` | Do not wipe the staging dir before staging |
234
+ | `--help` / `-h` | usage |
235
+
236
+ | Exit code | Meaning |
237
+ |---|---|
238
+ | `0` | success (staged + verified) |
239
+ | `2` | a staged file exceeds the 25 MiB Cloudflare Pages per-file limit |
240
+ | `3` | a reference in the staged tree does not resolve (broken deploy) |
241
+ | `1` | other error |
242
+
243
+ (`scripts/stage_pages.mjs:25-32`)
244
+
245
+ > *"**SAFETY: never writes to the frontend/ source tree. Copies out only.**"*
246
+ > (`scripts/stage_pages.mjs:34`)
247
+
248
+ The `--include=` files are needed because the walk is strictly reference-driven:
249
+
250
+ > *"The `--include=` files are force-added because no page references them; the default is strictly
251
+ > reference-driven."* (`HANDOFF_NEXT_AGENT.md` §4.1, session workspace)
252
+
253
+ `_headers` and `robots.txt` are the canonical examples:
254
+
255
+ > *"`_headers` and `robots.txt` must be force-included because no page references them.
256
+ > `provenance.json` and `CREDITS.md` likewise — they are provenance records, not assets."*
257
+ > (`docs/DEPLOYMENT_DECISION.md` §7)
258
+
259
+ ### 3.3 A real measured run
260
+
261
+ ```bash
262
+ cd C:/Users/anish/satquery-ai
263
+
264
+ node scripts/stage_pages.mjs \
265
+ --out=.deploy/dist-final \
266
+ --include=_headers \
267
+ --include=robots.txt \
268
+ --include=assets/img/eo/provenance.json \
269
+ --include=assets/img/eo/CREDITS.md
270
+
271
+ npx wrangler pages deploy "C:/Users/anish/satquery-ai/.deploy/dist-final" --project-name <name>
272
+ ```
273
+ (`docs/DEPLOYMENT_DECISION.md` §7)
274
+
275
+ **Measured result of that staging run:**
276
+
277
+ ```
278
+ files staged : 60
279
+ total bytes : 39,173,936 (37.36 MiB)
280
+ largest file : assets/video/satquery-launch-50s.mp4 22,710,313 B (21.66 MiB)
281
+ 25 MiB headroom left : 3,504,087 B on the largest file
282
+ missing refs in staged : 0
283
+ external network deps : 0 (HERMETIC)
284
+ exit : 0
285
+ ```
286
+ (`docs/DEPLOYMENT_DECISION.md` §7)
287
+
288
+ An earlier run of the same script reports a slightly different total, and the difference is recorded
289
+ rather than reconciled:
290
+
291
+ ```
292
+ files staged : 57
293
+ total bytes : 39,163,483 B (37.35 MiB)
294
+ largest file : 22,710,313 B (21.66 MiB)
295
+ missing refs : 0
296
+ external network deps : 0
297
+ exit : 0
298
+ ```
299
+ (`HANDOFF_NEXT_AGENT.md` §4.1, session workspace)
300
+
301
+ > **Do not treat either number as a constant.** `HANDOFF_NEXT_AGENT.md` §8 item 10 records exactly
302
+ > this hazard: *"Hardcoded counts in docs drift. The RUNBOOK's archive size moved 353 → 792 → 364 →
303
+ > 366 as the tree changed. Re-measure rather than trusting a recorded number."* 57 and 60 files are
304
+ > two measurements of two trees.
305
+
306
+ ### 3.4 A real bug the script had, and its fix
307
+
308
+ > *"`RE_CSS_IMPORT`'s bare-token alternative captured the prose word `of` out of a stylesheet comment
309
+ > (*"This replaced an @import of the Google Fonts CSS API"*) and failed the run with a phantom missing
310
+ > reference. Fixed at the root in `scripts/stage_pages.mjs` by adding `stripComments(ext, text)`,
311
+ > called at the top of `extractRefs`: CSS `/* */`, HTML `<!-- -->`, and — deliberately — **block
312
+ > comments only for JS**, because stripping `//` naively would truncate anything after a `//` inside a
313
+ > string such as `'http://www.w3.org/2000/svg'`. A reference inside a comment is never fetched, so
314
+ > this is correct, not a suppression."* (`HANDOFF_NEXT_AGENT.md` §4.1, session workspace)
315
+
316
+ ---
317
+
318
+ ## 4. The Analyze console
319
+
320
+ `mission.html` is the only page that talks to the backend. Its `<title>` is `SATQUERY — Analyze`, and
321
+ its structure is a scientific instrument, not a chat window:
322
+
323
+ | Element | `id` | Role |
324
+ |---|---|---|
325
+ | query box | `qtext` | *"What changed here?"* by default (`mission.html:51`) |
326
+ | run button | `btnRun` | *"Run query"* (`mission.html:53`) |
327
+ | observation block | `obsTail` | `none` → `ready` when a file is selected (`mission.html:67`) |
328
+ | primary file input | `fileInput` | hidden; flipped by the drop zone (`mission.html:72`) |
329
+ | second file input | `fileInputT0` | the T0 frame for pair tasks (`mission.html:120`) |
330
+ | intent panel | `intentHost` | the router's reading, as chips (`mission.html:102`) |
331
+ | viewer state | `viewerState` | *"Illustrative frame"* → *"Your upload"* → *"Your upload · analysed"* (`mission.html:141`) |
332
+ | plate | `plateImg` | the user's own image (`mission.html:146`) |
333
+ | evidence SVG layer | `ev` | region overlay (`mission.html:155`) |
334
+ | comparison view | `cmpWrap`, `cmpT0`, `cmpT1`, `cmpRange`, `cmpCredit`, `cmpEmpty` | the T0/T1 wipe (`mission.html:171-185`) |
335
+ | answer | `answerHost` | the server's string, verbatim (`mission.html:198`) |
336
+ | evidence list | `evHost` | the server's `Evidence` records (`mission.html:211`) |
337
+ | confidence | `confC` | `—` until a real result arrives (`mission.html:226`) |
338
+ | provenance | `pRun`, `pPolicy`, `pProtocol`, `pSchema` | `awaiting backend` until a real result (`mission.html:239`) |
339
+ | trace bar | `trace` | the 9-state spine (`mission.html:268`) |
340
+ | event drawer | `drawer`, `evlog` | the raw event log (`mission.html:280-286`) |
341
+
342
+ ### 4.1 The design law the console must obey
343
+
344
+ > *"**Frontend design law:** light/warm/ochre, ONE accent = ochre `#A5662E`. **NO rounded-rectangle
345
+ > card aesthetic** — the target is a *scientific instrument*, not an "AI dashboard". No
346
+ > glassmorphism, no drop-shadow-as-elevation, no map tiles or map providers. The retired
347
+ > graphite/dark tokens are forbidden."* (`HANDOFF_NEXT_AGENT.md` §7, repo copy)
348
+
349
+ ### 4.2 The viewer modes
350
+
351
+ The plan (§51) lists viewer tabs `Original`, `Evidence`, `Grounding`, `Change`, `Optical`, `SAR`,
352
+ `Fusion`. The shipped console implements a **four-mode** viewer, driven by `setMode()`:
353
+
354
+ ```js
355
+ function setMode(m) {
356
+ mode = m;
357
+ var showCmp = (m === 'comparison');
358
+ cmpWrap.hidden = !showCmp;
359
+ plate.style.visibility = showCmp ? 'hidden' : 'visible';
360
+ evNote.hidden = !(m === 'evidence' || m === 'masked');
361
+
362
+ if (m === 'evidence') { evNoteText.textContent = 'Awaiting backend — no region, mask or change map has been returned.'; viewerState.textContent = 'Evidence · none'; }
363
+ else if (m === 'masked') { evNoteText.textContent = 'Awaiting backend — no availability or change mask has been returned.'; viewerState.textContent = 'Masked · none'; }
364
+ else if (m === 'comparison') {
365
+ if (cmpWrap.dataset.ready === '1') { viewerState.textContent = 'Comparison'; cmpEmpty.hidden = true; }
366
+ else { viewerState.textContent = 'Comparison · needs pair'; cmpEmpty.hidden = false; }
367
+ } else { viewerState.textContent = plateImg.dataset.uploaded ? (plateImg.dataset.analysed ? 'Your upload · analysed' : 'Your upload') : 'Illustrative frame'; }
368
+ ...
369
+ }
370
+ ```
371
+ (`frontend/assets/js/mission.js:840-857`)
372
+
373
+ > **The plan's seven tabs are not the shipped four modes.** The modes are `original` (the default),
374
+ > `evidence`, `masked` and `comparison` — visible in the `if/else` chain above. `Grounding`, `Change`,
375
+ > `Optical`, `SAR` and `Fusion` are **not** separate viewer modes in the shipped console; region and
376
+ > mask output is rendered through the `ev` overlay on the `evidence`/`masked` modes, and the
377
+ > optical/SAR pair is rendered as the `comparison` wipe. This is a divergence between the plan's GUI
378
+ > sketch and the built page, and it is recorded rather than papered over.
379
+
380
+ The empty-state wording is itself a disclosure, not a placeholder: *"Awaiting backend — no region,
381
+ mask or change map has been returned."*
382
+
383
+ ### 4.3 The confidence panel refuses to invent a number
384
+
385
+ ```js
386
+ confC.textContent = '—';
387
+ confNote.textContent = 'Calibrated confidence is reported only with a real result. Until then it reads “—” — not a placeholder number.';
388
+ ```
389
+ (`frontend/assets/js/mission.js:452`, `:457`)
390
+
391
+ ---
392
+
393
+ ## 5. REAL versus PREVIEW — the two drivers, one event seam
394
+
395
+ `mission.js` opens with the distinction as the file's governing design:
396
+
397
+ ```js
398
+ /* =============================================================================
399
+ SATQUERY — ANALYZE (mission)
400
+ Drives the page from the production event seam: SQ.run().ingest(type, payload).
401
+
402
+ TWO DRIVERS, ONE EVENT SEAM
403
+ ---------------------------
404
+ * LIVE (default when files are chosen): the browser uploads the user's own
405
+ imagery to `POST /api/assets`, receives asset IDs, posts them to
406
+ `POST /api/infer`, and feeds the REAL result into the same eight events.
407
+ Every value on screen then traces to the server's own response.
408
+ * PREVIEW (no files chosen): the deterministic router still runs so the
409
+ instrument is legible, but the specialist/result stages stay honestly empty
410
+ ("awaiting backend") instead of pretending an analysis happened.
411
+
412
+ What is NEVER done in either mode: fabricating an answer, a confidence value,
413
+ an evidence record, a run id or a coordinate. If the backend is unreachable
414
+ the page says which step failed and shows the server's own message.
415
+ ============================================================================= */
416
+ ```
417
+ (`frontend/assets/js/mission.js:1-18`)
418
+
419
+ ### 5.1 The switch is the presence of a selected file
420
+
421
+ ```js
422
+ function runQuery() {
423
+ var q = qtext.value.trim() || QUERY;
424
+ /* Live whenever the user has actually selected imagery; preview otherwise.
425
+ This is the whole point: the demo demonstrates the intended workflow, and
426
+ a fixture is never silently substituted for a real upload. */
427
+ if (selectedT1) runLive(q);
428
+ else runMock(q);
429
+ }
430
+ ```
431
+ (`frontend/assets/js/mission.js:799-806`)
432
+
433
+ | Driver | Trigger | `liveRun` | Label shown |
434
+ |---|---|---|---|
435
+ | `runLive` | a file is selected (`selectedT1` truthy) | `true` | `live · N evidence · transport …` |
436
+ | `runMock` | no file selected | `false` | `preview — no files selected` |
437
+
438
+ The label is set at the top of each driver:
439
+
440
+ ```js
441
+ traceNow.textContent = 'preview — no files selected';
442
+ ```
443
+ (`frontend/assets/js/mission.js:563`)
444
+
445
+ ```js
446
+ traceNow.textContent = 'live · ' + (ev.length) + ' evidence · transport ' + (out.transport || 'direct');
447
+ ```
448
+ (`frontend/assets/js/mission.js:752`)
449
+
450
+ ### 5.2 What the preview does and does not emit — a correction to the common summary
451
+
452
+ The preview path is often summarised as "it emits no specialist events". **The shipped code emits all
453
+ eight event names in both modes.** What the preview withholds is the *content*, not the event:
454
+
455
+ ```js
456
+ /* ----------------------------------------------------------- mock driver --
457
+ PREVIEW ONLY — runs when no file has been chosen. Emits the eight
458
+ production events through the seam with EMPTY payloads. RECEIVE/PARSE/PLAN
459
+ carry the honest interpretation; the specialist + result stages carry
460
+ nothing fabricated. */
461
+ ```
462
+ (`frontend/assets/js/mission.js:550-554`)
463
+
464
+ The preview's specialist events carry a component **name** and no measurement:
465
+
466
+ ```js
467
+ specialists.forEach(function (name, i) {
468
+ at(cursor, function () { engine.ingest('SPECIALIST_STARTED', { component: name, index: i + 1, of: specialists.length, stage: 'EXECUTE' }); });
469
+ cursor += 260;
470
+ at(cursor, function () { engine.ingest('SPECIALIST_COMPLETED', { component: name }); });
471
+ cursor += 120;
472
+ });
473
+ ```
474
+ (`frontend/assets/js/mission.js:573-578`)
475
+
476
+ and the result stages carry explicit emptiness:
477
+
478
+ ```js
479
+ at(cursor + 200, function () {
480
+ engine.ingest('EVIDENCE_GENERATED', { regions: [], count: 0, note: 'no specialist output connected' });
481
+ });
482
+ at(cursor + 460, function () {
483
+ engine.ingest('CONFIDENCE_COMPUTED', { degraded: true, degradation_reason: 'No result produced — no calibrated confidence.' });
484
+ });
485
+ at(cursor + 700, function () {
486
+ engine.ingest('RESULT_ASSEMBLED', { text: null, task: intent.task, evidence_ids: [], confidence: null, provenance: null });
487
+ });
488
+ ```
489
+ (`frontend/assets/js/mission.js:580-588`)
490
+
491
+ The accurate statement is therefore:
492
+
493
+ > **The preview emits all eight event names, but no specialist measurement, no evidence record, no
494
+ > confidence value, no answer, and no run id.** `EVIDENCE_GENERATED` carries `regions: []` and the
495
+ > note *"no specialist output connected"*; `CONFIDENCE_COMPUTED` carries `degraded: true` with the
496
+ > reason *"No result produced — no calibrated confidence."*; `RESULT_ASSEMBLED` carries
497
+ > `text: null`, `confidence: null`, `provenance: null`.
498
+
499
+ The style guide's rule applies here: the code is authoritative, and a summary that says "no
500
+ specialist events" is not what the code does. Recorded.
501
+
502
+ ### 5.3 The state notes distinguish preview from live *visually*
503
+
504
+ ```js
505
+ /* Notes for the PREVIEW driver only. When a real result arrives these are
506
+ overwritten by measured facts (component names, model revisions, timings). */
507
+ var STATE_NOTE = {
508
+ RECEIVE: 'received', PARSE: 'interpreted (mock router)', VALIDATE: 'validated (mock router)',
509
+ PLAN: 'routed (mock router)', PREPROCESS: 'awaiting backend', EXECUTE: 'awaiting backend',
510
+ AGGREGATE: 'awaiting backend', VERIFY: 'awaiting backend', RESPOND: 'awaiting backend'
511
+ };
512
+ ```
513
+ (`frontend/assets/js/mission.js:367-373`)
514
+
515
+ and the `is-mock` class is the visual marker:
516
+
517
+ ```js
518
+ /**
519
+ * Mark a ControllerState reached, optionally with a measured note.
520
+ *
521
+ * `is-mock` is the "driven by the preview router" styling. When a real result
522
+ * is being rendered the note is a measurement, so the mock class is removed
523
+ * instead — otherwise a genuine run would be visually indistinguishable from
524
+ * the preview, which is exactly the confusion this page is built to avoid.
525
+ */
526
+ function markState(id, note, isLive) {
527
+ var n = traceNodes[id];
528
+ if (!n) return;
529
+ n.node.classList.toggle('is-mock', !isLive);
530
+ n.tm.textContent = note !== undefined ? note : (STATE_NOTE[id] || '');
531
+ ...
532
+ ```
533
+ (`frontend/assets/js/mission.js:396-408`)
534
+
535
+ In the **live** driver the notes become measurements, which is the point of the distinction:
536
+
537
+ ```js
538
+ /* State notes become MEASUREMENTS, replacing the preview wording. */
539
+ markState('PREPROCESS', specialists.join(', '), true);
540
+ markState('EXECUTE', (trace.selected_models || []).map(function (m) { return m.name; }).join(', ') || 'executed', true);
541
+ markState('AGGREGATE', ev.length + ' evidence', true);
542
+ markState('VERIFY', conf ? (conf.method || 'uncalibrated') : 'no confidence', true);
543
+ markState('RESPOND', out.transport ? ('via ' + out.transport) : 'responded', true);
544
+ ```
545
+ (`frontend/assets/js/mission.js:745-750`)
546
+
547
+ ### 5.4 The live driver's event sequence is a record, not an animation
548
+
549
+ ```js
550
+ /* ----------------------------------------------------------- live driver -
551
+ The real flow. Uploads the user's files, then runs the analysis and feeds
552
+ the server's own result into the same eight events.
553
+
554
+ The event sequence is emitted around the network calls rather than faked on
555
+ a timer: QUERY_RECEIVED/UNDERSTOOD/ROUTE_SELECTED are genuinely known before
556
+ the request (they are the client's own reading), SPECIALIST_STARTED is
557
+ emitted when the request is dispatched, and SPECIALIST_COMPLETED through
558
+ RESULT_ASSEMBLED are emitted from the response. So the trace's shape is a
559
+ real record of when the work happened, not a plausible-looking animation. */
560
+ ```
561
+ (`frontend/assets/js/mission.js:591-600`)
562
+
563
+ The three pre-dispatch events are emitted before `SQ.live.run` is called; the rest are emitted inside
564
+ its `.then()`:
565
+
566
+ ```js
567
+ engine.ingest('QUERY_RECEIVED', { query: query });
568
+ engine.ingest('QUERY_UNDERSTOOD', { intent: intent, dispatched: choice.substituted ? forced : null });
569
+ engine.ingest('ROUTE_SELECTED', { intent: intent, specialists: specialists, policy: 'deterministic-rule', policyVersion: 'pc-3.2.1', force_task: forced });
570
+
571
+ var started = {};
572
+ specialists.forEach(function (name) { started[name] = performance.now(); });
573
+
574
+ SQ.live
575
+ .run(filesToSend, query, { forceTask: forced })
576
+ .then(function (out) { ... });
577
+ ```
578
+ (`frontend/assets/js/mission.js:654-668`)
579
+
580
+ > **A nuance worth stating.** `SPECIALIST_STARTED`/`COMPLETED` are emitted **from the response**, not
581
+ > at dispatch time — the code comment above says "SPECIALIST_STARTED is emitted when the request is
582
+ > dispatched", but the implementation emits both inside the `.then()` (lines 678-687), measuring
583
+ > `elapsed_ms` from a timestamp taken *before* the request. The timestamps are honest (they bracket
584
+ > the real network call); the event *ordering* is response-time. Recorded because the comment and the
585
+ > code differ on this one point.
586
+
587
+ ### 5.5 The failure path never fills the gap
588
+
589
+ ```js
590
+ .catch(function (err) {
591
+ /* HONEST FAILURE. Say which step failed, and show the server's message.
592
+ The specialist/result stages stay unfilled rather than being given
593
+ invented content, and the trace states are marked as not executed. */
594
+ var stage = (err && err.stage) || 'request';
595
+ var status = (err && err.status) ? ' (HTTP ' + err.status + ')' : '';
596
+ traceNow.textContent = 'failed at ' + stage + status;
597
+ answerHost.innerHTML = '<span class="answer__empty label">No result — the ' + stage + ' step failed</span>';
598
+ ...
599
+ markState('PREPROCESS', 'not executed', false);
600
+ markState('EXECUTE', 'not executed', false);
601
+ markState('AGGREGATE', 'not executed', false);
602
+ markState('VERIFY', 'not executed', false);
603
+ markState('RESPOND', 'not executed', false);
604
+ ```
605
+ (`frontend/assets/js/mission.js:773-791`)
606
+
607
+ The plate caption also refuses to keep calling the image illustrative once a real run has touched it:
608
+
609
+ ```js
610
+ /* The plate caption must not keep calling the image illustrative once a
611
+ real analysis has run on it. The image is still the user's own file,
612
+ so the credit names the run rather than claiming the imagery is a
613
+ SatQuery output -- the picture is input, the FINDINGS are output.
614
+ BOTH halves of the caption are driven: the leading <b> is a literal in
615
+ the markup, and leaving it as "Illustrative" would keep asserting the
616
+ frame is a stand-in while showing the user's own upload. */
617
+ plateCreditLead.textContent = 'Your upload';
618
+ plateCredit.textContent = 'analysed in run ' + (env.run_id || '—');
619
+ ```
620
+ (`frontend/assets/js/mission.js:754-762`)
621
+
622
+ ### 5.6 The mode is disclosed in the answer's own tail
623
+
624
+ ```js
625
+ answerHost.textContent = answerText;
626
+ ansTail.textContent = 'live';
627
+ ```
628
+ (`frontend/assets/js/mission.js:714-715`)
629
+
630
+ and on an empty answer it says so rather than leaving the previous text:
631
+
632
+ ```js
633
+ answerHost.innerHTML = '<span class="answer__empty label">The engine returned no answer text for this task</span>';
634
+ ansTail.textContent = 'empty';
635
+ ```
636
+ (`frontend/assets/js/mission.js:718-719`)
637
+
638
+ ---
639
+
640
+ ## 6. The eight execution events, and the trace bar
641
+
642
+ ### 6.1 The event names
643
+
644
+ ```js
645
+ SQ.EVENT_NAMES = [
646
+ 'QUERY_RECEIVED', 'QUERY_UNDERSTOOD', 'ROUTE_SELECTED',
647
+ 'SPECIALIST_STARTED', 'SPECIALIST_COMPLETED',
648
+ 'EVIDENCE_GENERATED', 'CONFIDENCE_COMPUTED', 'RESULT_ASSEMBLED'
649
+ ];
650
+ ```
651
+ (`frontend/assets/js/core.js:616-620`)
652
+
653
+ `core.js`'s own header states the integration seam this defines:
654
+
655
+ > *"The run engine is deliberately dumb: it renders whatever events it receives. Nothing about the
656
+ > visuals depends on the events being synthetic. Swapping the mock driver for a websocket / SSE feed
657
+ > of the same event names is the entire integration surface."* (`frontend/assets/js/core.js:8-11`)
658
+
659
+ and the seam itself:
660
+
661
+ ```js
662
+ /**
663
+ * THE INTEGRATION SEAM.
664
+ * Feed real execution events here — same names, same payload shapes — and
665
+ * every visual state in the prototype updates identically.
666
+ */
667
+ ingest: function (type, payload) {
668
+ ```
669
+ (`frontend/assets/js/core.js:737-742`)
670
+
671
+ ### 6.2 The event envelope
672
+
673
+ Every emitted event carries four fields:
674
+
675
+ ```js
676
+ function emit(type, payload) {
677
+ var ev = {
678
+ type: type,
679
+ t: performance.now() - t0,
680
+ seq: state.events.length + 1,
681
+ payload: payload || {}
682
+ };
683
+ state.events.push(ev);
684
+ listeners.forEach(function (fn) { try { fn(ev, state); } catch (e) { console.error(e); } });
685
+ }
686
+ ```
687
+ (`frontend/assets/js/core.js:709-718`)
688
+
689
+ | Field | Meaning |
690
+ |---|---|
691
+ | `type` | one of the eight names |
692
+ | `t` | milliseconds since `QUERY_RECEIVED` |
693
+ | `seq` | 1-based sequence number within the run |
694
+ | `payload` | the event-specific body |
695
+
696
+ The event drawer prints exactly this:
697
+
698
+ ```js
699
+ function logEvent(ev) {
700
+ var line = document.createElement('div');
701
+ line.innerHTML = '<span class="n">' + U.pad(ev.seq, 2) + ' +' + Math.round(ev.t) + 'ms </span>' +
702
+ '<span class="k">' + ev.type + '</span> ' +
703
+ '<span>' + JSON.stringify(ev.payload) + '</span>';
704
+ evlog.appendChild(line);
705
+ evlog.scrollTop = evlog.scrollHeight;
706
+ }
707
+ ```
708
+ (`frontend/assets/js/mission.js:428-435`)
709
+
710
+ ### 6.3 The nine-state spine
711
+
712
+ The trace bar is **not** the eight events. It is a **nine-state** `ControllerState` spine, and the
713
+ eight events map onto it:
714
+
715
+ ```js
716
+ var STATES = ['RECEIVE', 'PARSE', 'VALIDATE', 'PLAN', 'PREPROCESS', 'EXECUTE', 'AGGREGATE', 'VERIFY', 'RESPOND'];
717
+ var EVENT_TO_STATE = {
718
+ QUERY_RECEIVED: 'RECEIVE', QUERY_UNDERSTOOD: 'PARSE', ROUTE_SELECTED: 'PLAN',
719
+ SPECIALIST_STARTED: 'PREPROCESS', SPECIALIST_COMPLETED: 'EXECUTE',
720
+ EVIDENCE_GENERATED: 'AGGREGATE', CONFIDENCE_COMPUTED: 'VERIFY', RESULT_ASSEMBLED: 'RESPOND'
721
+ };
722
+ ```
723
+ (`frontend/assets/js/mission.js:361-366`)
724
+
725
+ | Event | State(s) marked |
726
+ |---|---|
727
+ | `QUERY_RECEIVED` | `RECEIVE` |
728
+ | `QUERY_UNDERSTOOD` | `PARSE` **and** `VALIDATE` |
729
+ | `ROUTE_SELECTED` | `PLAN` |
730
+ | `SPECIALIST_STARTED` | `PREPROCESS` |
731
+ | `SPECIALIST_COMPLETED` | `EXECUTE` |
732
+ | `EVIDENCE_GENERATED` | `AGGREGATE` |
733
+ | `CONFIDENCE_COMPUTED` | `VERIFY` |
734
+ | `RESULT_ASSEMBLED` | `RESPOND` |
735
+
736
+ The subscription is the mechanism:
737
+
738
+ ```js
739
+ case 'QUERY_UNDERSTOOD':
740
+ renderIntent(ev.payload.intent, ev.payload.dispatched || null);
741
+ intentTail.textContent = ev.payload.dispatched ? ('routed as ' + ev.payload.dispatched) : 'resolved';
742
+ markState('PARSE', undefined, liveRun); markState('VALIDATE', undefined, liveRun);
743
+ traceNow.textContent = 'interpreting';
744
+ break;
745
+ ```
746
+ (`frontend/assets/js/mission.js:511-516`)
747
+
748
+ `VALIDATE` has **no event of its own** and is marked together with `PARSE`. The `ControllerState` enum
749
+ in `core/schemas.py:79-89` defines all nine; the frontend's `STATES` array is a literal transcription
750
+ of it.
751
+
752
+ ### 6.4 The fill formula, and the measured 94.4444 %
753
+
754
+ The bar's width is a pure function of the furthest state reached:
755
+
756
+ ```js
757
+ var idx = STATES.indexOf(id);
758
+ if (idx > traceProgress) traceProgress = idx;
759
+ STATES.forEach(function (s, k) {
760
+ var node = traceNodes[s].node;
761
+ node.classList.toggle('is-done', k < traceProgress);
762
+ node.classList.toggle('is-active', k === traceProgress);
763
+ node.classList.toggle('is-idle', k > traceProgress);
764
+ });
765
+ if (traceFill) {
766
+ traceFill.style.width = (((traceProgress + 0.5) / STATES.length) * 100) + '%';
767
+ }
768
+ ```
769
+ (`frontend/assets/js/mission.js:414-424`)
770
+
771
+ The comment states the intent:
772
+
773
+ > *"Advance the trace to the furthest state reached. This is driven by the same events that carry the
774
+ > real result, so the bar and the node states move only when the run actually reaches a stage — never
775
+ > on a timer. The fill spans from the left edge to the centre of the current node."*
776
+ > (`frontend/assets/js/mission.js:410-413`)
777
+
778
+ **The arithmetic, worked:**
779
+
780
+ ```
781
+ STATES.length = 9
782
+ final traceProgress = 8 (RESPOND is the last index, and it is reached)
783
+ fill = ((8 + 0.5) / 9) * 100
784
+ = (8.5 / 9) * 100
785
+ = 94.4444444…%
786
+ ```
787
+
788
+ So **94.4444 % is the fully-complete trace bar**, not a partial one: it is 8.5/9, and the missing
789
+ 5.5556 % is the half-node at the right edge that the "centre of the current node" rule deliberately
790
+ leaves unfilled.
791
+
792
+ **Measured, live, 2026-09-25:**
793
+
794
+ > *"| Execution trace (progress bar) | **VERIFIED** | `.trace__fill` width is set from real event
795
+ > count (measured 94.4444% live, 2026-09-25) |"* (`docs/FINAL_DELIVERY_TODO.md` §1.4)
796
+
797
+ and confirmed across all three live passes:
798
+
799
+ > *"Common to all twenty-four: a real `run_*` id, `mock_nodes = 0`, live trace bar at 94.4444%, the HF
800
+ > link present in the DOM, and every `/api/*` call addressed to
801
+ > `satquery-backend-m4yv.onrender.com` (`capabilities` → `assets` → `infer`; two `assets` calls for
802
+ > the pair tasks)."* (`DELIVERY_REPORT_2026-09-25.md` §3)
803
+
804
+ The implementation note records that the bar was *fixed* to reach this:
805
+
806
+ > *"`markState` now sets `traceFill.style.width` from the furthest state reached and toggles
807
+ > `is-done`/`is-active`/`is-idle`; `resetUI` resets both. CSS classes already existed
808
+ > (`system.css:654-660`)."* (`docs/FINAL_DELIVERY_TODO.md` §4, P5-T02)
809
+
810
+ and the reset clears it:
811
+
812
+ ```js
813
+ traceProgress = -1;
814
+ if (traceFill) traceFill.style.width = '0';
815
+ ```
816
+ (`frontend/assets/js/mission.js:440-441`)
817
+
818
+ > **94.4444 % is an artifact metric, not a system-level claim.** It measures one CSS width in one
819
+ > page. It says nothing about the pipeline's own progress reporting — the server returns a completed
820
+ > `ResultEnvelope` with no streaming, so the bar reflects the *client's* event timeline, which is
821
+ > itself reconstructed from a single request/response pair.
822
+
823
+ ### 6.5 The bar is not a determinate progress bar, and the docs say so
824
+
825
+ > *"A determinate-looking progress bar would lie. Use an indeterminate state with a 'this can take up
826
+ > to a minute' hint."* (`docs/FRONTEND_INTEGRATION.md` §6)
827
+
828
+ The trace bar is a **stage** indicator — which states have been reached — not a percentage of elapsed
829
+ time. The 94.4444 % figure is the completed state of that stage indicator, and reading it as "94 % of
830
+ the work is done" would be a misreading the code does not invite.
831
+
832
+ ### 6.6 The deterministic policy the preview uses
833
+
834
+ The preview's routing is a **rule list**, not the learned router, and the page says so in the intent
835
+ panel (`source`, `policyVersion: 'pc-3.2.1'`, `basis: 'rule match, no learned router'`).
836
+
837
+ ```js
838
+ /* --- deterministic policy: mirrors the intended controller, rules only --- */
839
+ SQ.policy = function (query) {
840
+ var q = (query || '').toLowerCase();
841
+ var rules = [];
842
+ function hit(re, name) { var m = re.test(q); rules.push({ rule: name, fired: m }); return m; }
843
+ ...
844
+ ```
845
+ (`frontend/assets/js/core.js:622-626`)
846
+
847
+ The B-08 defect lived here and in `mission.js`, and its fix is worth recording because it is the
848
+ clearest example of a *lexical* router failing in a way that produced a wrong answer rather than an
849
+ error:
850
+
851
+ > *"**Sibling defect**: `core.js` `SQ.policy` (the architecture page's mock router) still carried
852
+ > `built` in its change regex, so that page's **own shipped sample** — *"Where is the built-up
853
+ > area?"* — fired `intent.change` on `built` and `intent.quantify` on `area`, and was answered as
854
+ > `CHANGE_VQA` with a change-detector specialist. Same defect as `mission.js`, on a second surface.
855
+ > `where` is now evaluated first, `built` removed, and `new` counts only outside a `where` question;
856
+ > a small `record()` helper keeps the rule list's display order. Regression tests drive the
857
+ > **shipped** `SQ.policy` — **4 red before the fix, 6 green after**."*
858
+ > (`DELIVERY_REPORT_2026-09-25.md` §1.3)
859
+
860
+ The fixed rule order is visible in the code, with the reason in a comment:
861
+
862
+ ```js
863
+ /* `where` is evaluated FIRST because the change rule below depends on it: a
864
+ word that reads as "change" only OUTSIDE a location question must not turn
865
+ a `where` question into a change request. */
866
+ var where = /where|locate|position|which part|bound|outline|coordinate/.test(q);
867
+
868
+ var sar = hit(/\bsar\b|radar|backscatter|sentinel-1|insar/, 'sensor.sar');
869
+ /* `built` was removed and `new` counts only outside a `where` question.
870
+ "Where is the built-up area?" -- the architecture page's OWN sample --
871
+ previously fired intent.change on "built", then intent.quantify on "area",
872
+ and was answered as CHANGE_VQA with a CHANGE_DETECTOR specialist: a
873
+ location question routed to a change question. */
874
+ var changeStem = /chang|differ|expand|grow|encroach|lost|removed/.test(q);
875
+ var newAsChange = /\bnew\b/.test(q) && !where;
876
+ var change = record('intent.change', changeStem || newAsChange);
877
+ ```
878
+ (`frontend/assets/js/core.js:631-645`)
879
+
880
+ **B-08 is `CLOSED`**, with two documented residuals:
881
+
882
+ > *"Residuals: "What is the new runway?" still reads `change` (non-`where` + `new`); "How much
883
+ > built-up area was added?" now reads `vqa` (under-trigger) — both documented."*
884
+ > (`docs/FINAL_DELIVERY_TODO.md` §5, B-08)
885
+
886
+ ### 6.7 The answer bank is prototype text, and it is labelled
887
+
888
+ `SQ.ANSWER_BANK` holds per-task sentence templates for the **preview** path only:
889
+
890
+ ```js
891
+ SQ.ANSWER_BANK = {
892
+ CHANGE_ANALYSIS: 'Significant change detected. {n} coherent regions totalling {area}; dominant transition is {dominant}. Registration residual {reg} px — the pair is usable for pixel comparison.',
893
+ ...
894
+ };
895
+ ```
896
+ (`frontend/assets/js/core.js:677-684`)
897
+
898
+ They are filled from `SQ.scene()`, whose own comment is the disclosure:
899
+
900
+ ```js
901
+ /* SYNTHETIC DEMO CONSTANTS — the geo / temporal / metric fields below are
902
+ illustrative placeholders, not real observations. They exist only so the
903
+ prototype renders a populated instrument; the platform string above
904
+ already marks the plate as PROTOTYPE SYNTHETIC. Any page that displays
905
+ these values MUST disclose that they are synthetic (see the `synthetic`
906
+ flag below) and MUST NOT present them as measured satellite data.
907
+ core.js is shared across pages and is intentionally NOT removed here —
908
+ only labelled. If a live backend ever supplies a real scene, it should
909
+ set synthetic:false and override these fields. */
910
+ return {
911
+ seed: seed,
912
+ biome: biome,
913
+ regions: regions,
914
+ synthetic: true, // every field below is a demo placeholder
915
+ gsd: 10, // metres per pixel (placeholder)
916
+ aoi: { lat: 31.204, lon: 72.816 }, // placeholder coordinate
917
+ dates: { t0: '2024-03-14', t1: '2025-03-19' }, // placeholder epochs
918
+ sensor: 'OPTICAL / MSI',
919
+ platform: 'SENTINEL-2 · L2A (PROTOTYPE SYNTHETIC)',
920
+ registrationRMSE: 0.42 // placeholder residual
921
+ };
922
+ ```
923
+ (`frontend/assets/js/core.js:268-288`)
924
+
925
+ > **`SQ.ANSWER_BANK` and `SQ.scene()` are preview-only.** The live driver never reads them: it renders
926
+ > `result.answer` verbatim (`mission.js:712-714`). The `synthetic: true` flag and the `platform`
927
+ > string exist so a reader of the *preview* cannot mistake it for a measurement. This is the design
928
+ > the style guide's "never upgrade a status" rule requires, implemented in the data itself.
929
+
930
+ ---
931
+
932
+ ## 7. The live client — `frontend/assets/js/live.js`
933
+
934
+ ### 7.1 The endpoints it calls
935
+
936
+ ```js
937
+ /*: The orchestrator's proxied routes (deploy/render/main.py). These are NOT
938
+ the Space's own `/v1/*` routes -- the browser never talks to the Space
939
+ directly; the orchestrator is the only public door. */
940
+ SQ.ENDPOINTS = {
941
+ assets: '/assets',
942
+ infer: '/infer',
943
+ capabilities: '/capabilities',
944
+ health: '/health'
945
+ };
946
+ ```
947
+ (`frontend/assets/js/live.js:52-60`)
948
+
949
+ ### 7.2 Base-URL resolution, in three ordered steps
950
+
951
+ ```js
952
+ /**
953
+ * The orchestrator base URL, with no trailing slash.
954
+ *
955
+ * Resolution order is documented in the file header. Returning `/api` rather
956
+ * than '' keeps the failure mode legible: a misconfigured deployment asks the
957
+ * Pages host for `/api/infer` and gets a clean 404, instead of the page's
958
+ * own index.html being fetched as JSON and producing a confusing parse error.
959
+ */
960
+ SQ.live.baseUrl = function () {
961
+ var injected = window.SATQUERY_API_BASE;
962
+ if (injected) return _normalizeBase(String(injected));
963
+
964
+ var meta = document.querySelector('meta[name="satquery-api-base"]');
965
+ if (meta && meta.content) return _normalizeBase(String(meta.content));
966
+
967
+ return '/api';
968
+ };
969
+ ```
970
+ (`frontend/assets/js/live.js:91-107`)
971
+
972
+ | Order | Source | Why |
973
+ |---|---|---|
974
+ | 1 | `window.SATQUERY_API_BASE` | an inline config so a deployment points at its own backend without rebuilding the JS |
975
+ | 2 | `<meta name="satquery-api-base">` | the same idea, declarative |
976
+ | 3 | `/api` on the current origin | correct for a same-origin deployment and for the local dev proxy |
977
+
978
+ The normaliser exists because an absolute base naturally omits `/api`:
979
+
980
+ ```js
981
+ /**
982
+ * A configured base, with `/api` guaranteed for absolute origins.
983
+ * ...
984
+ * someone configuring an absolute URL naturally writes the
985
+ * ORIGIN -- `https://host` -- and then `url()` produced `https://host/assets`
986
+ * instead of `https://host/api/assets`. Every call 404s, and it is a silent
987
+ * failure: the page reports a network error rather than a misconfiguration.
988
+ */
989
+ function _normalizeBase(raw) {
990
+ var base = String(raw).replace(/\/+$/, '');
991
+ if (base.indexOf('://') === -1) return base; // relative: as written
992
+ var after = base.slice(base.indexOf('://') + 3);
993
+ var slash = after.indexOf('/');
994
+ var path = slash === -1 ? '' : after.slice(slash);
995
+ if (path === '' || path === '/') return base + '/api';
996
+ return base;
997
+ }
998
+ ```
999
+ (`frontend/assets/js/live.js:109-131`)
1000
+
1001
+ > *"Rule 3 is why development needs no secret: a dev server that proxies `/api` to Render lets the
1002
+ > browser talk to `http://localhost:8080/api/...` and the CORS allowlist is then a non-issue. Direct
1003
+ > cross-origin calls also work, and that is what the localhost CORS entries in
1004
+ > `deploy/render/main.py` exist for."* (`frontend/assets/js/live.js:40-43`)
1005
+
1006
+ ### 7.3 The content-type map, derived from the extension
1007
+
1008
+ ```js
1009
+ /*: Extensions the Space's store accepts, mirroring the gateway content-type
1010
+ allowlist (gateway/policy.py `allowed_content_types`). The browser sets the
1011
+ Content-Type header from this map; a wrong type is a 422 from the store, so
1012
+ guessing it from the extension is more reliable than trusting the File's
1013
+ own `.type`, which browsers leave empty for GeoTIFF. */
1014
+ SQ.CONTENT_TYPES = {
1015
+ tif: 'image/tiff',
1016
+ tiff: 'image/tiff',
1017
+ png: 'image/png',
1018
+ jpg: 'image/jpeg',
1019
+ jpeg: 'image/jpeg'
1020
+ };
1021
+ ```
1022
+ (`frontend/assets/js/live.js:62-73`)
1023
+
1024
+ Note the client's map has **four** types and omits `image/geotiff` and `application/octet-stream`,
1025
+ which the server's allowlist of five includes. A `.geotiff` file therefore has no client-side mapping
1026
+ and is refused by `uploadAsset` before any request:
1027
+
1028
+ ```js
1029
+ var contentType = SQ.contentTypeFor(file);
1030
+ if (!contentType) {
1031
+ return Promise.reject(
1032
+ LiveError(
1033
+ 'upload',
1034
+ 'Unsupported file type: ' + (file && file.name ? file.name : '(unnamed)') +
1035
+ '. Use GeoTIFF, TIFF, PNG or JPEG.'
1036
+ )
1037
+ );
1038
+ }
1039
+ ```
1040
+ (`frontend/assets/js/live.js:204-213`)
1041
+
1042
+ > **This is a real client/server asymmetry.** The message says "Use GeoTIFF, TIFF, PNG or JPEG" while
1043
+ > the map has no `geotiff` extension key, so a file named `scene.geotiff` is refused with a message
1044
+ > that names its own format. The server would accept it as `image/geotiff`. Recorded as a defect in
1045
+ > the client, not smoothed over.
1046
+
1047
+ ### 7.4 Uploads are sequential, on purpose
1048
+
1049
+ ```js
1050
+ /**
1051
+ * Upload several Files, sequentially, preserving order.
1052
+ *
1053
+ * Sequential rather than parallel, and that is a considered choice: the
1054
+ * Codespace runs `cache_max_models: 1` and puts v1 execution in a single
1055
+ * process with sequential plans (gateway/assets.py). Firing five uploads at
1056
+ * once gains nothing and makes a partial failure harder to reason about --
1057
+ * the caller learns exactly which file failed, by index.
1058
+ */
1059
+ ```
1060
+ (`frontend/assets/js/live.js:245-254`)
1061
+
1062
+ This is the client's implementation of the contract's *"Serialize requests"* obligation
1063
+ (`docs/API_CONTRACT.md` §6, item 3).
1064
+
1065
+ ### 7.5 The analysis request body is minimal, because `extra="forbid"`
1066
+
1067
+ ```js
1068
+ var body = { assets: ids, query: String(query || '') };
1069
+ if (opts.forceTask) body.force_task = opts.forceTask;
1070
+ ```
1071
+ (`frontend/assets/js/live.js:301-302`)
1072
+
1073
+ > *"`extra="forbid"` is why this function sends nothing else: an extra key is a 422, not an ignored
1074
+ > field. `force_task` is omitted rather than sent as null, because both are accepted but omitting it
1075
+ > keeps the payload minimal and lets the server's own router decide."* (`frontend/assets/js/live.js:287-291`)
1076
+
1077
+ ### 7.6 The response is asserted at the boundary
1078
+
1079
+ ```js
1080
+ return resp.json().catch(function () { return null; }).then(function (parsed) {
1081
+ if (!resp.ok) throw describeFailure('infer', resp.status, parsed);
1082
+ if (!parsed || !parsed.result) {
1083
+ throw LiveError('infer', 'The service returned no result.', {
1084
+ status: resp.status,
1085
+ detail: JSON.stringify(parsed).slice(0, 400)
1086
+ });
1087
+ }
1088
+ return { envelope: parsed, state: state, transport: transport };
1089
+ });
1090
+ ```
1091
+ (`frontend/assets/js/live.js:318-327`)
1092
+
1093
+ and on the upload path:
1094
+
1095
+ ```js
1096
+ /* Assert the shape at the boundary. A 200 whose body lacks asset_id
1097
+ would otherwise travel into `/api/infer` as `undefined` and fail
1098
+ there, naming the wrong cause. */
1099
+ if (!body || typeof body.asset_id !== 'string' || !body.asset_id) {
1100
+ throw LiveError('upload', 'Upload succeeded but returned no asset id.', { ... });
1101
+ }
1102
+ ```
1103
+ (`frontend/assets/js/live.js:224-231`)
1104
+
1105
+ ### 7.7 The error object carries the contract's classification
1106
+
1107
+ ```js
1108
+ function LiveError(stage, message, opts) {
1109
+ opts = opts || {};
1110
+ var err = new Error(message);
1111
+ err.name = 'SQ.LiveError';
1112
+ err.stage = stage;
1113
+ err.status = opts.status || 0;
1114
+ err.code = opts.code || '';
1115
+ err.detail = opts.detail || '';
1116
+ err.recoverable = !!opts.recoverable;
1117
+ return err;
1118
+ }
1119
+ ```
1120
+ (`frontend/assets/js/live.js:152-162`)
1121
+
1122
+ > *"`stage` names the step ('upload' | 'infer'), `status` is the HTTP status if a response was
1123
+ > received, and `code` is the contract's error code when the server supplied the v1 envelope. The
1124
+ > server's own message is preserved rather than replaced -- a generic "something went wrong" would
1125
+ > hide the difference between "your file is too large" and "the engine is waking"."*
1126
+ > (`frontend/assets/js/live.js:142-150`)
1127
+
1128
+ ### 7.8 The capabilities call exists so the page can refuse to promise
1129
+
1130
+ ```js
1131
+ /**
1132
+ * GET /api/capabilities, for the UI to show what the engine can actually do.
1133
+ *
1134
+ * Not part of the analysis flow; it exists so the page can refuse to promise
1135
+ * a task the deployment cannot serve, rather than failing after an upload.
1136
+ */
1137
+ ```
1138
+ (`frontend/assets/js/live.js:368-373`)
1139
+
1140
+ `mission.js` consumes it at start-up:
1141
+
1142
+ ```js
1143
+ engine = SQ.run({ query: QUERY });
1144
+ engine.on(onEvent);
1145
+ resetUI();
1146
+ runMock(QUERY);
1147
+ loadCapabilities();
1148
+ ```
1149
+ (`frontend/assets/js/mission.js:971-975`)
1150
+
1151
+ ### 7.9 The pair-aware dispatch — the client honours `requires_pair`
1152
+
1153
+ > *"`/api/capabilities` declares `requires_pair` and `max_assets` per task, and the page chooses the
1154
+ > task with the asset count in mind."* (`frontend/assets/js/mission.js:134-135`)
1155
+
1156
+ ```js
1157
+ /* Send ONLY the assets the dispatched task requires. A single-image task
1158
+ (vqa / grounding / caption) must NOT receive the optional T0 frame: the
1159
+ backend rejects a two-asset payload for a one-asset task with
1160
+ `invalid_request`. Temporal tasks (change / change_vqa) need T0+T1, and
1161
+ optical_sar needs the optical+SAR pair (T1 + the second modality in T0). */
1162
+ var filesToSend = assetsForTask(forced, selectedT1, selectedT0);
1163
+ ```
1164
+ (`frontend/assets/js/mission.js:624-629`)
1165
+
1166
+ and the substitution is disclosed before the request, not after:
1167
+
1168
+ ```js
1169
+ /* Say the substitution where the user is looking, before the request, so
1170
+ the result is not surprising. It is a fact about the request, not an
1171
+ error: one image genuinely cannot support change detection. */
1172
+ if (choice.substituted) {
1173
+ obsNote.innerHTML = 'Analysing as <span class="mono">' + forced +
1174
+ '</span> — ' + choice.reason + '. Add a T0 frame to run <span class="mono">' +
1175
+ choice.wanted + '</span>.';
1176
+ }
1177
+ ```
1178
+ (`frontend/assets/js/mission.js:645-652`)
1179
+
1180
+ This was a **deployed defect** (P1 in the status board) and its fix is recorded:
1181
+
1182
+ > *"Acceptance: deployed `mission.js` contains `assetsForTask`; vqa-with-pair returns real result
1183
+ > (only T1 uploaded)."* (`docs/FINAL_DELIVERY_TODO.md` §4, P4-T01)
1184
+
1185
+ ### 7.10 Optical-SAR gets an early warning, because its pair is two modalities
1186
+
1187
+ ```js
1188
+ /* Optical-SAR is the one task whose pair is two MODALITIES, not two times.
1189
+ Warn early (before the upload) when the second file looks like a plain
1190
+ photo rather than a radar product, so the round-trip does not fail opaquely. */
1191
+ if (forced === 'optical_sar' && pairNote) {
1192
+ var sarCheck = validateOpticalSar(selectedT1, selectedT0);
1193
+ pairNote.innerHTML = sarCheck.level !== 'ok' ? sarCheck.message : pairNoteDefault;
1194
+ }
1195
+ ```
1196
+ (`frontend/assets/js/mission.js:631-637`)
1197
+
1198
+ The contract's rule the validator implements:
1199
+
1200
+ > *"**Optical+SAR contract:** modality inferred from band count — `{1,2}` ⇒ SAR, `{3,4,8,11,12,13}` ⇒
1201
+ > optical; both GeoTIFF, same W×H, uint8/uint16, rasterio-readable."*
1202
+ > (session `HANDOFF_NEXT_AGENT.md` §4)
1203
+
1204
+ ---
1205
+
1206
+ ## 8. Cloudflare traps
1207
+
1208
+ Three measured behaviours of the Cloudflare Pages tier cost real time on this project. All three are
1209
+ recorded because they are non-obvious and each one produced a wrong assumption.
1210
+
1211
+ ### 8.1 `_headers` rules CONCATENATE — they do not override
1212
+
1213
+ This is the finding, and the file's own earlier comment was **false**:
1214
+
1215
+ ```text
1216
+ # Format: a path pattern, then indented Header: value lines. `*` matches any
1217
+ # number of characters. A request that matches several rules inherits ALL of
1218
+ # them, and a header set by more than one rule is JOINED with a comma in file
1219
+ # order -- it is NOT overridden. Verified live 2026-09-25: a narrower
1220
+ # Cache-Control rule did not replace the broader one, it appended to it. To
1221
+ # remove a header contributed by a broader rule, detach it with a
1222
+ # "! Header-Name" line (Cloudflare Pages supports `!` detach).
1223
+ ```
1224
+ (`frontend/_headers:3-9`)
1225
+
1226
+ **The measurement.** A specific `max-age=0` rule placed under the broad `/assets/img/*`
1227
+ `max-age=604800` rule produced this live response:
1228
+
1229
+ ```
1230
+ Cache-Control: public, max-age=604800, public, max-age=0, must-revalidate
1231
+ ```
1232
+ (`DELIVERY_REPORT_2026-09-25.md` §5; `frontend/_headers:70`)
1233
+
1234
+ **The consequence, and why it is worse than a normalisation failure:** Chromium takes the **first**
1235
+ `max-age` it finds, so the broad week-long value still won:
1236
+
1237
+ > *"Chromium honours the **first** `max-age`, so the returning browser kept the stale image and the
1238
+ > carve-out was ineffective."* (`DELIVERY_REPORT_2026-09-25.md` §5)
1239
+
1240
+ The `_headers` file records the same conclusion and forbids reintroducing the pattern:
1241
+
1242
+ ```text
1243
+ # NOTE (2026-09-25): an earlier revision of this file tried to carve the
1244
+ # delta-growth change-demo pair out of the week-long rule above with two literal
1245
+ # path blocks carrying max-age=0. It did NOT work. Cloudflare does not override a
1246
+ # header when a second rule sets it -- it JOINS the values in file order, and the
1247
+ # live response was "public, max-age=604800, public, max-age=0, must-revalidate".
1248
+ # Chromium takes the FIRST max-age it finds, so the broad week-long value still
1249
+ # won and a returning browser kept the stale image. Do not reintroduce a
1250
+ # Cache-Control carve-out here: any rule broad enough to matter also matches
1251
+ # /assets/img/*, so the broad value is always present. The pair was instead given
1252
+ # NEW URLs (delta-growth-t0-1975-720.jpg / delta-growth-t1-2025-720.jpg), which
1253
+ # is the only cache-busting that does not depend on _headers semantics.
1254
+ ```
1255
+ (`frontend/_headers:66-76`)
1256
+
1257
+ The status board records the corrected comment as a *documentation* fix, which is what it was:
1258
+
1259
+ > *"| Cache-busting | **VERIFIED (with a caveat)** | `_headers` revalidates JS/CSS; the EO pair was
1260
+ > instead given NEW URLs because Cloudflare **concatenates** matching `_headers` rules — see §1.7
1261
+ > item 9 |"* (`docs/FINAL_DELIVERY_TODO.md` §1.4)
1262
+
1263
+ and §1.7 item 9:
1264
+
1265
+ > *"**Cloudflare `_headers` CONCATENATES matching rules** instead of overriding them. A specific rule
1266
+ > under a broad `/assets/img/*` rule produces `Cache-Control: public, max-age=604800, …, max-age=0,
1267
+ > must-revalidate`, and Chromium honours the **first** `max-age` — so a per-path override cannot
1268
+ > un-cache a long-lived asset. Measured live 2026-09-25. The cache-busting fix therefore **renames**
1269
+ > the asset to a new URL rather than adding a `_headers` rule."*
1270
+ > (`docs/FINAL_DELIVERY_TODO.md` §1.7 item 9)
1271
+
1272
+ **A consequence that persists:** the deleted old URLs still answer from the edge cache.
1273
+
1274
+ > *"Consequence: the old URLs still answer `200` from Cloudflare's **edge cache** (`CF-Cache-Status:
1275
+ > HIT`) although the files are deleted; a cache-busted request returns `404`. Nothing references
1276
+ > them."* (`DELIVERY_REPORT_2026-09-25.md` §5)
1277
+
1278
+ ### 8.2 The `!` detach escape hatch
1279
+
1280
+ Cloudflare Pages supports `! Header-Name` to detach a header contributed by a broader rule. The
1281
+ `_headers` file records it as the **correct** way to remove an inherited header
1282
+ (`frontend/_headers:8-9`) — but the project chose URL renaming instead for the EO pair, because
1283
+ *"any rule broad enough to matter also matches `/assets/img/*`, so the broad value is always
1284
+ present"* and a detach would have removed `Cache-Control` for *every* image rather than for one.
1285
+
1286
+ ### 8.3 The `308` redirect: `X.html` → `/X`
1287
+
1288
+ > *"**Cloudflare Pages 308-redirects `X.html` → `/X`.** Drive `https://satquery.pages.dev/mission`."*
1289
+ > (session `HANDOFF_NEXT_AGENT.md` §4)
1290
+
1291
+ | Request | Response |
1292
+ |---|---|
1293
+ | `GET /mission.html` | `308` → `Location: /mission` |
1294
+ | `GET /mission` | `200`, the page |
1295
+
1296
+ **Why it matters for a harness.** A headed-browser driver that navigates to
1297
+ `https://satquery.pages.dev/mission.html` is redirected, and any assertion written against the
1298
+ *pre-redirect* URL — or against a `location.pathname` that still ends in `.html` — sees a different
1299
+ document URL than it expected. The rule the project adopted is to drive the **extensionless** path.
1300
+
1301
+ **Why it does not matter for the site itself.** `docs/DEPLOYMENT_DECISION.md` §6 records that no
1302
+ `_redirects` file was created, and the reason:
1303
+
1304
+ > *"**`_redirects`** — every link in the site is already a literal `.html` path; there are no pretty
1305
+ > URLs to map."*
1306
+
1307
+ So the site's own internal links are `.html` and Cloudflare's `308` is a *host-level* behaviour that
1308
+ the site does not depend on. The two facts are consistent: the site never needs the redirect, and a
1309
+ harness that types the URL directly must account for it.
1310
+
1311
+ ### 8.4 What was deliberately NOT created
1312
+
1313
+ `docs/DEPLOYMENT_DECISION.md` §6 lists the files that were considered and refused, with reasons:
1314
+
1315
+ | Not created | Reason |
1316
+ |---|---|
1317
+ | `_redirects` | every link is already a literal `.html` path; there are no pretty URLs to map |
1318
+ | `sitemap.xml` | *"needs a canonical production domain. Inventing one would publish a URL that does not resolve, so it is omitted until the Pages domain is fixed."* |
1319
+ | `wrangler.toml` | optional for Pages; the deploy command carries the project name |
1320
+ | a **Content-Security-Policy** | *"`atlas.html` carries one inline `style=""` attribute, so a strict CSP would need `'unsafe-inline'` anyway. A CSP permitting unsafe-inline is security theatre; add a real one after that attribute is moved into `pages.css`."* |
1321
+
1322
+ > **Note the correction.** `sitemap.xml` *does* exist in the tree (`frontend/sitemap.xml`, 741 B) and
1323
+ > `robots.txt` carries a `Sitemap:` line, so the "needs a canonical domain" blocker was resolved
1324
+ > after that decision record was written. The decision record is retained as the historical record;
1325
+ > the tree is the current state.
1326
+
1327
+ ---
1328
+
1329
+ ## 9. Cache-busting
1330
+
1331
+ ### 9.1 The rule that matters: JS and CSS must revalidate
1332
+
1333
+ ```text
1334
+ # CSS and JS are NOT content-hashed. They MUST revalidate on every request, or a
1335
+ # deploy is masked by a cached asset for up to the max-age window — observed on
1336
+ # 2026-09-25 when a returning browser served the pre-fix mission.js and kept
1337
+ # hitting the old invalid_request. max-age=0 + must-revalidate makes the browser
1338
+ # re-fetch (and Cloudflare re-validate) on every load, so a deploy is picked up
1339
+ # immediately, exactly like the HTML above.
1340
+ /assets/css/*
1341
+ Cache-Control: public, max-age=0, must-revalidate
1342
+
1343
+ /assets/js/*
1344
+ Cache-Control: public, max-age=0, must-revalidate
1345
+ ```
1346
+ (`frontend/_headers:50-60`)
1347
+
1348
+ **The incident that produced the rule is named in the comment:** a returning browser served the
1349
+ **pre-fix `mission.js`** and *"kept hitting the old `invalid_request`"*. The cache was masking a
1350
+ correct deploy — the same failure class as the phantom defect chase that
1351
+ session `HANDOFF_NEXT_AGENT.md` §4 warns about:
1352
+
1353
+ > *"**Stale browser cache can mask a correct deploy.** Verify server-side (GitHub API sha256) *and*
1354
+ > client-side (CDP `Network.clearBrowserCache`), or you will chase a phantom."*
1355
+
1356
+ ### 9.2 The full `_headers` policy
1357
+
1358
+ | Path pattern | `Cache-Control` | Why |
1359
+ |---|---|---|
1360
+ | `/*` | *(none set)* | the baseline block sets only security headers |
1361
+ | `/` | `public, max-age=0, must-revalidate` | HTML revalidates every time |
1362
+ | `/*.html` | `public, max-age=0, must-revalidate` | *"so a deploy is picked up immediately rather than being masked by a cached page that still points at yesterday's CSS"* |
1363
+ | `/assets/video/*` | `public, max-age=604800` | the launch film, 22,710,313 B — *"the single largest asset on the site and the one worth not re-downloading"* |
1364
+ | `/assets/fonts/*` | `public, max-age=31536000` | *"Stable, versioned by presence rather than by filename, so a long max-age is appropriate."* |
1365
+ | `/assets/css/*` | `public, max-age=0, must-revalidate` | not content-hashed |
1366
+ | `/assets/js/*` | `public, max-age=0, must-revalidate` | not content-hashed |
1367
+ | `/assets/img/*` | `public, max-age=604800` | *"Copernicus / ESA / NASA reference imagery. Stable."* |
1368
+
1369
+ (`frontend/_headers:21-64`)
1370
+
1371
+ The baseline security headers, which apply to every path:
1372
+
1373
+ ```text
1374
+ /*
1375
+ X-Content-Type-Options: nosniff
1376
+ Referrer-Policy: strict-origin-when-cross-origin
1377
+ X-Frame-Options: DENY
1378
+ Cross-Origin-Opener-Policy: same-origin
1379
+ ```
1380
+ (`frontend/_headers:21-25`)
1381
+
1382
+ > **No CSP, and the file says why.** *"No Content-Security-Policy is set, deliberately. NOTE
1383
+ > (2026-09-25): the site is no longer fully static — mission.html loads live.js and POSTs to the
1384
+ > Render orchestrator (the Analyze console), so a real CSP would also need a connect-src for that API
1385
+ > origin. atlas.html additionally carries one inline `style=""` attribute, so any CSP would have to
1386
+ > allow `'unsafe-inline'` anyway."* (`frontend/_headers:14-19`)
1387
+
1388
+ ### 9.3 The status, with its caveat
1389
+
1390
+ > *"| Cache-busting | **VERIFIED (with a caveat)** | `_headers` revalidates JS/CSS; the EO pair was
1391
+ > instead given NEW URLs because Cloudflare **concatenates** matching `_headers` rules"*
1392
+ > (`docs/FINAL_DELIVERY_TODO.md` §1.4)
1393
+
1394
+ and the phase item is honest about which half was verified when:
1395
+
1396
+ > *"**P4-T02** — Cache-busting for JS/CSS | Status: **COMPLETE** (live confirmation pending P10-T01
1397
+ > re-deploy) | Acceptance: returning users get fresh JS on next load. | Evidence: file edited
1398
+ > (2026-09-25). Post-deploy `curl -I` to confirm header."* (`docs/FINAL_DELIVERY_TODO.md` §4)
1399
+
1400
+ > **`UNKNOWN — not established from the available evidence`:** the post-deploy `curl -I` output
1401
+ > confirming the live `Cache-Control` on `/assets/js/*`. The file was edited and the phase marked
1402
+ > complete; the recorded evidence is the file edit, not a captured response header.
1403
+
1404
+ ---
1405
+
1406
+ ## 10. The harness lesson — a headed-browser driver that records false passes
1407
+
1408
+ This is the most valuable operational finding in this chapter, because it is a **false pass**, not a
1409
+ false failure.
1410
+
1411
+ ### 10.1 The failure, as it was observed
1412
+
1413
+ > *"When I re-ran the suite to cover the final commit, the first case came back `run_id=0002`,
1414
+ > `mock_nodes=9`, `answer="No answer yet"`, and only the `capabilities` call — i.e. the **mock** path.
1415
+ > Diagnosis: the harness drove the query box with `fill_input()`, which types using **real CDP key
1416
+ > events**, and Chrome **drops synthesized key events when the browser window does not hold OS
1417
+ > focus**. Measured directly: with Chrome backgrounded, `press_key("Z")` left `#qtext.value`
1418
+ > unchanged, while `type_text("Q")` (CDP `Input.insertText`, not focus-gated) inserted fine.
1419
+ > `fill_input` has **no assertion**, so the harness happily clicked Run with the page's **default**
1420
+ > query still in the box."* (`DELIVERY_REPORT_2026-09-25.md` §3)
1421
+
1422
+ **The shape of the false pass:** the query box kept the page's default (`What changed here?`), the
1423
+ harness clicked Run anyway, the page produced a *result* — and that result was recorded as the
1424
+ verdict for a case whose query was never entered. The harness had no way to tell the difference
1425
+ between "the query was entered and the run used it" and "the query was never entered".
1426
+
1427
+ ### 10.2 Why the earlier 8/8 run was *not* infected — the three discriminators
1428
+
1429
+ The report does not simply re-run and hope. It checks whether the earlier result was contaminated,
1430
+ using evidence the harness recorded:
1431
+
1432
+ > *"I then checked whether the earlier 8/8 run was infected by the same silent failure. It was not:
1433
+ >
1434
+ > * its recorded intents are **query-specific** — A1 reads `taskvqa…temporalnone`, whereas the default
1435
+ > query *"What changed here?"* would read `taskchange…temporalrequired` (exactly what the failed run
1436
+ > showed);
1437
+ > * its answers **embed the query text** — e.g. `[grounding] Located 6 candidate region(s) for 'Where
1438
+ > are the built-up areas in this image?'`;
1439
+ > * A6 required two files (`optical 4/12 + SAR 2/2` channels), which only the uploaded pair supplies.
1440
+ >
1441
+ > So the 8/8 result is a valid measurement."* (`DELIVERY_REPORT_2026-09-25.md` §3)
1442
+
1443
+ The three discriminators generalise:
1444
+
1445
+ | Discriminator | What it proves |
1446
+ |---|---|
1447
+ | the recorded **intent** is query-specific | the query reached the router |
1448
+ | the **answer** embeds the query text | the server received the intended query |
1449
+ | a case **requires an artefact** only the setup supplies | the setup really happened |
1450
+
1451
+ ### 10.3 The fix: deterministic query entry plus pre-dispatch assertions
1452
+
1453
+ > *"The harness has since been rebuilt (`run_all_postfix2.harness`) to set the query deterministically
1454
+ > and to **assert the form state before clicking Run**, recording per case: `q_ok` (the box really
1455
+ > held the query), `obs_ok` (`#obsTail == 'ready'` and one file on `#fileInput`), `t0_ok` (both frames
1456
+ > for pair tasks), `no_mock_nodes`, and a computed `verdict`. A silent no-op can no longer be recorded
1457
+ > as a pass."* (`DELIVERY_REPORT_2026-09-25.md` §3)
1458
+
1459
+ The three pre-dispatch assertions, and the rule they implement:
1460
+
1461
+ > *"**Do NOT use `fill_input()` or `press_key()` to enter the query.** They type with real CDP key
1462
+ > events, which Chrome **silently drops when the browser window does not hold OS focus** — the box
1463
+ > keeps its default text and the run silently exercises the wrong query. Use `js()` to set
1464
+ > `#qtext.value` (plus `input`/`change` events) and/or `type_text()` (CDP `Input.insertText`, not
1465
+ > focus-gated). **Always assert the form state before clicking Run** — `q_ok` (box holds the query),
1466
+ > `obs_ok` (`#obsTail == 'ready'`), `t0_ok` (both frames for pair tasks) — or a no-op will be recorded
1467
+ > as a pass. `upload_file()` is fine and flips `#obsTail` to `ready`."*
1468
+ > (session `HANDOFF_NEXT_AGENT.md` §5.2)
1469
+
1470
+ | Assertion | Checks | Failure it prevents |
1471
+ |---|---|---|
1472
+ | `q_ok` | `#qtext.value` holds the intended query | the silent-drop false pass |
1473
+ | `obs_ok` | `#obsTail == 'ready'` **and** one file on `#fileInput` | an upload that did not land |
1474
+ | `t0_ok` | both frames present, for pair tasks | a pair task run on one asset |
1475
+ | `no_mock_nodes` | `mock_nodes == 0` | the preview path being recorded as live |
1476
+
1477
+ `obs_ok`'s second half is a real DOM fact, because the page sets that tail from the upload:
1478
+
1479
+ `#obsTail` reads `none` in the markup (`mission.html:67`) and the live driver flips it to `ready`.
1480
+
1481
+ ### 10.4 Two more harness bugs — both false *failures*
1482
+
1483
+ > *"Two further harness bugs surfaced while re-running — both produced **false failures**, never false
1484
+ > passes, but they are easy to repeat:
1485
+ >
1486
+ > 1. **The answer tag is not universal.** The server prefixes the answer with `[task]` only for the
1487
+ > region tasks (`grounding`, `change`, `change_vqa`, `optical_sar`). vqa answers are bare
1488
+ > (`Grassland`) and caption answers are prose, so a tag-only discriminator wrongly fails them.
1489
+ > Fix: the **dispatched** task is `answer_tag` when present, else the intent panel's reading.
1490
+ > 2. **The intent panel renders a concatenated string** — `task<name>modality<…>temporal<…>`.
1491
+ > Matching `task([a-z_]+)` greedily swallows the whole string; it must be `task([a-z_]+?)modality`."*
1492
+ > (`DELIVERY_REPORT_2026-09-25.md` §3)
1493
+
1494
+ The concatenation is a real property of the intent panel, which renders chips without separators:
1495
+
1496
+ ```js
1497
+ function renderIntent(intent, dispatched) {
1498
+ intentHost.innerHTML = '';
1499
+ var rows = [
1500
+ ['task', dispatched || intent.task], ['modality', intent.modality], ['temporal', intent.temporal],
1501
+ ['spatial', intent.spatial_output], ['evidence', intent.evidence], ['source', intent.source]
1502
+ ];
1503
+ if (dispatched) rows.push(['reading', intent.task]);
1504
+ rows.forEach(function (r) {
1505
+ var c = U.el('span', 'chip chip--plain');
1506
+ c.innerHTML = '<span class="k">' + r[0] + '</span>' + r[1];
1507
+ intentHost.appendChild(c);
1508
+ });
1509
+ }
1510
+ ```
1511
+ (`frontend/assets/js/mission.js:344-356`)
1512
+
1513
+ ### 10.5 The reading-versus-dispatch distinction, which is a *feature* not a bug
1514
+
1515
+ > *"**Read the *dispatched* task from the answer's `[task]` tag when present, else from the intent
1516
+ > panel's reading** — the panel shows the router's *reading*, and a quantifier upgrade legitimately
1517
+ > makes the two differ (A5 reads `change`, dispatches `change_vqa`)."*
1518
+ > (session `HANDOFF_NEXT_AGENT.md` §5.3)
1519
+
1520
+ The panel preserves both, on purpose:
1521
+
1522
+ ```js
1523
+ if (dispatched) rows.push(['reading', intent.task]);
1524
+ ```
1525
+ (`frontend/assets/js/mission.js:352`)
1526
+
1527
+ with the reasoning in the docstring:
1528
+
1529
+ > *"The `task` chip then names what was SENT and a `reading` chip preserves what the router saw —
1530
+ > showing only one of the two would either misreport the request or hide the router's input."*
1531
+ > (`frontend/assets/js/mission.js:341-343`)
1532
+
1533
+ ### 10.6 The safety property that made the harness bugs survivable
1534
+
1535
+ > *"Pass 3's raw harness output reports `SUMMARY 0/8` — because it was launched with the harness build
1536
+ > that still had the two discriminator bugs. Its verdicts in `results_pass3.json` are recomputed from
1537
+ > the recorded evidence by `recompute_verdicts.py`. This is exactly the intended safety property:
1538
+ > **the recorded evidence is independent of the verdict computation**, so a harness bug never forces a
1539
+ > 24-minute browser re-run — and never silently flips a real failure into a pass."*
1540
+ > (`DELIVERY_REPORT_2026-09-25.md` §3)
1541
+
1542
+ That is the generalisable lesson: **record evidence, compute verdicts separately.** A harness that
1543
+ computes its verdict inline has no way to re-derive it when the verdict logic turns out to be wrong.
1544
+
1545
+ ### 10.7 The three live passes
1546
+
1547
+ | Pass | Target | Result | Raw output |
1548
+ |---|---|---|---|
1549
+ | 1 | `ff46eba42b18` + `d413d3672311` | 8/8 | `run_output.txt` |
1550
+ | 2 | final HEAD `2d7ae53b482d`, asserting harness | 8/8 | `run_final2.txt` → `results_final.json` |
1551
+ | 3 | final HEAD `2d7ae53b482d`, repeat | 8/8 | `run_final3.txt` → `results_pass3.json` |
1552
+
1553
+ > *"24 live runs, 24 correct dispatches, no run id repeated across passes."*
1554
+ > (`DELIVERY_REPORT_2026-09-25.md` §3)
1555
+
1556
+ The eight cases and their pass-2 run ids:
1557
+
1558
+ | case | query | expected | dispatched | pass 2 run_id |
1559
+ |---|---|---|---|---|
1560
+ | A1 | What type of terrain dominates this scene? | vqa | vqa | `run_0843db184e32` |
1561
+ | A2 | Describe the main visual characteristics of this scene. | caption | caption | `run_5b766f2d7df7` |
1562
+ | A3 | Where are the visible buildings in this image? | grounding | grounding | `run_ea590b6fd70f` |
1563
+ | A4 | What changed between the earlier and later image? | change | change | `run_65a4b2f9d912` |
1564
+ | A5 | Did the coastline advance between the two observations? | change_vqa | change_vqa | `run_efe24b98d217` |
1565
+ | A6 | …combining the optical and SAR observations? | optical_sar | optical_sar | `run_6375b80dcb8e` |
1566
+ | **B1** | **Where are the built-up areas in this image?** | **grounding** | **grounding** | **`run_2a07dcdbae96`** |
1567
+ | **B2** | **Where is the new airport?** | **grounding** | **grounding** | **`run_9134f40a258c`** |
1568
+
1569
+ (`DELIVERY_REPORT_2026-09-25.md` §3)
1570
+
1571
+ > **A6's query is elided in the source as `"…combining the optical and SAR observations?"`** — the
1572
+ > leading words are not reproduced in the report, and this chapter does not invent them.
1573
+
1574
+ ### 10.8 The two verdicts, kept separate
1575
+
1576
+ > *"1. **Deployment / integration: PASS** — the full pipeline works on unseen imagery and questions.
1577
+ > 2. **Model quality: MIXED** — caption and grounding are meaningful; change/change_vqa are plausible;
1578
+ > VQA is weak-but-related; optical-SAR still returns a bare class index
1579
+ > (`class_18 (margin 1.000; optical channels 4/12, SAR channels 2/2)`), not a human label."*
1580
+ > (`DELIVERY_REPORT_2026-09-25.md` §3)
1581
+
1582
+ This is the style guide's rule applied at the harness level: *"a mixed result is never 'all work
1583
+ perfectly'."* The integration passes; the model quality does not, and the two are not merged.
1584
+
1585
+ ### 10.9 The harness's hard constraints
1586
+
1587
+ | Constraint | Detail |
1588
+ |---|---|
1589
+ | `browser-use` block-buffers stdout | *"the output file sits at 0 bytes until the process exits — that looks exactly like a stall but is not"* |
1590
+ | `grep` block-buffers when piped | *"piping the harness through `grep` swallows all output if the pipeline is killed — redirect to a file"* |
1591
+ | sandbox proxy is dead | *"Every network call needs `--noproxy '*'` (curl) or `ProxyHandler({})` / `--no-proxy-server` (Python / browser)"* |
1592
+ | the harness is a `.harness` script | piped to `browser-use.exe` via stdin; helpers are `goto_url`, `upload_file`, `fill_input`, `type_text`, `press_key`, `js`, `capture_screenshot`, `wait_for_element` |
1593
+
1594
+ (session `HANDOFF_NEXT_AGENT.md` §4, §5.1, §5.5)
1595
+
1596
+ ---
1597
+
1598
+ ## 11. What is NOT RUN, OPEN, SUPPORTED or BLOCKED for this topic
1599
+
1600
+ | Item | Status | Detail |
1601
+ |---|---|---|
1602
+ | The Analyze console's live path | **VERIFIED** | `mission.js` live driver; 8/8 × 3 passes; real `run_*` ids (`docs/FINAL_DELIVERY_TODO.md` §6 E-11, E-14) |
1603
+ | The preview/mock path | **SUPPORTED** | *"`runMock` only when no file selected; emits empty payloads, marked `is-mock`; not in production path"* (`docs/FINAL_DELIVERY_TODO.md` §1.4) |
1604
+ | The trace bar's fill | **VERIFIED (live)** | measured 94.4444 % (`docs/FINAL_DELIVERY_TODO.md` §1.4) |
1605
+ | Benchmark page | **VERIFIED (section 03 only)** | §03's reliability curve is real from `artifacts/calibration_v001.json`; the remaining section-03 PR curves are *"still labelled illustrative"* (`docs/FINAL_DELIVERY_TODO.md` §4, P6-T01 note) |
1606
+ | Research page | **VERIFIED** | *"entries now trace to real artifacts/reports with honest limitations"* (`docs/FINAL_DELIVERY_TODO.md` §4, P7-T01) |
1607
+ | Journey/Lab page | **VERIFIED** | *"stages correspond to real phases/reports; implemented/verified/attempted/blocked distinguished"* (`docs/FINAL_DELIVERY_TODO.md` §4, P7-T02) |
1608
+ | Anatomy of a Run | **VERIFIED (was SYNTHETIC)** | rebuilt around the real captured `run_d124d8b9adea`; *"synthetic `SEED=917`/`SQ-RUN-0917`/`a3f19c2`/`0.74` removed"* (`docs/FINAL_DELIVERY_TODO.md` §4, P8-T01/T02) |
1609
+ | HF header link | **VERIFIED** | present in all 11 navs; live DOM-confirmed (`docs/FINAL_DELIVERY_TODO.md` §6 E-13) |
1610
+ | GitHub header link | **VERIFIED** | target is the only public repo (`docs/FINAL_DELIVERY_TODO.md` §4, P9-T01) |
1611
+ | Cache-busting | **VERIFIED (with a caveat)** | see §9.3 — the live `curl -I` confirmation is not in the recorded evidence |
1612
+ | `frontend/.tools/shot.sh` rendering the live tree | **NOT RUN** | the script pointed `ROOT` at the **retired prototype**; the fix was specified and *"was **never started**"* (`HANDOFF_NEXT_AGENT.md` §0, §4.2 item E) |
1613
+ | Five audited visual defects (contrast, occluded disclosure, `[hidden]`, 390 px overflow, `shot.sh`) | **NOT RUN** | *"I authorised all five and sent the spec, but the session was interrupted before any file was touched."* (`HANDOFF_NEXT_AGENT.md` §4.2) — `mission.html` mtime and the untouched `system.css` are the verification |
1614
+ | The client/server `image/geotiff` asymmetry | **OPEN (defect, undocumented elsewhere)** | the client's `CONTENT_TYPES` map has no `geotiff` key while the server's allowlist has `image/geotiff` (§7.3) |
1615
+ | The plan's seven viewer tabs vs the shipped four modes | **DIVERGENCE, recorded** | see §4.2 |
1616
+ | **UNKNOWN — not established from the available evidence** | — | whether the deployed bundle's `_headers` is byte-identical to the working tree's; the live `Cache-Control` header on `/assets/js/*`; the measured rendering of the five authorised-but-unstarted visual fixes; the A6 query's leading words |
1617
+
1618
+ ### 11.1 Two honesty constraints that outlive the sprint
1619
+
1620
+ > *"**Imagery honesty:** everything in `frontend/assets/img/eo/` is Copernicus / ESA / NASA reference
1621
+ > material with `satquery_result: false` and `role: illustrative`. Nothing may imply it is SatQuery
1622
+ > pipeline output. **Never replace a fake value with another fake value** — either measure it or label
1623
+ > it with `.disclose`."* (`HANDOFF_NEXT_AGENT.md` §7, repo copy)
1624
+
1625
+ > *"**Never fabricate.** No invented confidence values, areas, RMSE, run IDs, acquisition dates,
1626
+ > lat/lon, model outputs or execution times."* (`HANDOFF_NEXT_AGENT.md` §7, repo copy)
1627
+
1628
+ ---
1629
+
1630
+ ## 12. Where the evidence lives
1631
+
1632
+ | Claim class | File | What it establishes |
1633
+ |---|---|---|
1634
+ | the 8 events, the 9 states, the policy | `frontend/assets/js/core.js` | `SQ.EVENT_NAMES` (:616), `SQ.STAGES` (:605), `SQ.policy` (:623), `SQ.scene` (:207) |
1635
+ | the live client | `frontend/assets/js/live.js` | endpoints (:55), base URL (:99), upload (:202), infer (:294), run (:345) |
1636
+ | the console | `frontend/assets/js/mission.js` | `runMock` (:556), `runLive` (:601), `onEvent` (:502), `markState` (:404), the fill formula (:423) |
1637
+ | the cache/security policy | `frontend/_headers` | the concatenation finding (:3-9), the EO note (:66-76), the JS/CSS rule (:50-60) |
1638
+ | the page set and the header nav | `frontend/*.html` | 11 files; the two external links on each |
1639
+ | the staging pipeline | `scripts/stage_pages.mjs` | the reference walk, the 25 MiB limit (:43), the exit codes (:25-32) |
1640
+ | hermeticity, the film, what was not created | `docs/DEPLOYMENT_DECISION.md` | §3, §6, §7 |
1641
+ | the Pages tier and its one live page | `docs/DEPLOYMENT_TOPOLOGY.md` | §3.1 and the header correction |
1642
+ | the status board, the Cloudflare finding, B-08 | `docs/FINAL_DELIVERY_TODO.md` | §1.4, §1.7 item 9, §4, §5, §6 |
1643
+ | the live validation and the harness trap | `DELIVERY_REPORT_2026-09-25.md` | §1, §3, §5 |
1644
+ | the hard constraints, the harness rules | session `HANDOFF_NEXT_AGENT.md` | §4, §5 |
1645
+ | the design law and the imagery-honesty rule | repo `HANDOFF_NEXT_AGENT.md` | §7 |
1646
+
1647
+ ### 12.1 Cross-references
1648
+
1649
+ | For… | Read |
1650
+ |---|---|
1651
+ | the topology, the tiers, the tunnel | [02 — Deployment Topology](./02-deployment-topology.md) |
1652
+ | the controller's nine states in full, and the server-side events | [03 — Request Lifecycle](./03-request-lifecycle.md) §36–§38 |
1653
+ | the evidence records and the confidence rules the console renders | [06 — Evidence and Confidence](./06-evidence-and-confidence.md) |
1654
+ | the endpoints the client calls, and their envelopes | [08 — The API Contract](./08-api-contract.md) |
1655
+ | the health payload, the trace as an observability object, the runbook | [10 — Observability and Operations](./10-observability-and-ops.md) |