Instructions to use thundercode/SatQuery with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use thundercode/SatQuery with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
09 — The Frontend
Parent: Architecture hub · Status tags: IMPLEMENTED · VERIFIED ·
MEASURED · NOT RUN · SUPPORTED · OPEN
Sources of truth for this chapter, all read before writing:
| Source | Lines | What it establishes |
|---|---|---|
frontend/assets/js/core.js |
1029 | the 8 execution events (SQ.EVENT_NAMES), the 9-state trace spine, the deterministic policy, the raster synthesiser |
frontend/assets/js/live.js |
392 | the real client: /api/assets → /api/infer, base-URL resolution, error translation |
frontend/assets/js/mission.js |
~1000 | the Analyze console: runLive / runMock, markState, the trace fill formula, the intent panel |
frontend/_headers |
77 | the Cloudflare cache/security rules and the measured concatenation finding |
frontend/*.html |
11 files | the page set, the shared header <nav> with the GitHub and Hugging Face links |
scripts/stage_pages.mjs |
~380 | the reference-driven staging pipeline, its exit codes and its 25 MiB limit |
docs/DEPLOYMENT_DECISION.md |
205 | the hermeticity audit, the film, what was deliberately not created |
docs/DEPLOYMENT_TOPOLOGY.md |
248 | §3.1 the Pages tier; the "EXCEPT mission.html" correction |
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 |
DELIVERY_REPORT_2026-09-25.md |
299 | §3 the live validation and the harness trap; §5 the cache-busting measurement |
session HANDOFF_NEXT_AGENT.md |
149 | §4 the hard constraints, including the 308 redirect and the harness rules |
The honesty rule this chapter inherits.
frontend/HANDOFF.mdand the style guide both forbid presenting a synthetic value as a measured one. This chapter therefore labels every figure with where it came from, and it names the one place where the shipped code does something the documentation around it does not describe.
1. Where the frontend sits
The frontend is the first of the four tiers. It is a static site served by Cloudflare Pages.
USER
│ HTTPS
▼
Cloudflare Pages (static frontend) ← frontend/ , staged via scripts/stage_pages.mjs
│ HTTPS, JSON
▼
Render (orchestrator / API gateway) ← deploy/render/ , render.yaml blueprint
│ server-to-server
▼
GitHub Codespace (FastAPI inference) ← deploy/codespace/
(docs/DEPLOYMENT_TOPOLOGY.md §1)
docs/DEPLOYMENT_TOPOLOGY.md §3.1 gives the tier's responsibility in one line:
"Responsibility: serve the static site. No backend, no secrets, no API calls of any kind (verified hermetic — see
DEPLOYMENT_DECISION.md§3)."
and then the document's own header corrects that claim for one page:
"§3.1's "no API calls of any kind (verified hermetic)" holds for every static page EXCEPT
mission.html, which calls the orchestrator." (docs/DEPLOYMENT_TOPOLOGY.mdheader note)
1.1 The hermeticity audit, and its one exception
docs/DEPLOYMENT_DECISION.md §3 records the audit that made the static-only deployment viable:
"Audited across all of
frontend/(excluding.tools/): zero occurrences offetch(,XMLHttpRequest,axios,EventSource,WebSocket,/v1/,import.meta.envorprocess.env. The only URL-shaped string anywhere is the SVG XML namespace atfrontend/assets/js/core.js:31, which is not a fetch." (docs/DEPLOYMENT_DECISION.md§3)
The namespace string is real and is an XML namespace, not a network call:
svg: function (tag, attrs) {
var n = document.createElementNS('http://www.w3.org/2000/svg', tag);
(frontend/assets/js/core.js:30-31)
*"The staging audit reports
external network deps: 0 (HERMETIC)."* (docs/DEPLOYMENT_DECISION.md§3)
That audit predates the Analyze console. _headers now carries a dated correction of the same claim:
"NOTE (2026-09-25): the site is no longer fully static — mission.html loads live.js and POSTs to the Render orchestrator (the Analyze console), so a real CSP would also need a connect-src for that API origin." (
frontend/_headers:14-17)
The page set is therefore mostly static, with one live page, and the two statements are not in conflict: the audit measured what it measured on the tree it measured, and the correction names the change.
1.2 Fonts are self-hosted, which is what makes the site hermetic
"
assets/css/system.csspreviously opened with a render-blocking@importof the Google Fonts CSS API. That@importis gone, replaced by 10@font-faceblocks pointing at 11 woff2 files inassets/fonts/(518,198 B total, plusOFL.txt). This matters more than it looks: a CSS@importis render-blocking and transitively script-blocking — a classic synchronous<script>waits on pending stylesheets, so a font-host stall could kill the site's JS." (HANDOFF_NEXT_AGENT.md§4.1, session workspace)
2. The eleven pages
The site is eleven deployable HTML files: ten content pages plus a real 404 page.
| # | File | <title> |
Role |
|---|---|---|---|
| 1 | index.html |
SATQUERY — Ask the Earth a question. |
the landing page; hosts the self-hosted launch film |
| 2 | mission.html |
SATQUERY — Analyze |
the Analyze console — the only page that calls the API |
| 3 | architecture.html |
SATQUERY — Architecture |
the architecture walk-through; carries its own sample query |
| 4 | atlas.html |
SATQUERY — Earth Query Atlas |
the query atlas |
| 5 | benchmark.html |
SATQUERY — Benchmark Lab |
per-specialist metrics with honest status labels |
| 6 | research.html |
SATQUERY — Research Ledger |
research entries traced to real artifacts/limitations |
| 7 | journey.html |
SATQUERY — The Lab |
build history by phase |
| 8 | run.html |
SATQUERY — Anatomy of a Run |
a real captured ResultEnvelope, rendered |
| 9 | video.html |
SATQUERY — Film archive |
the launch film |
| 10 | references.html |
SATQUERY — References |
provenance and credits |
| 11 | 404.html |
SATQUERY — Not found |
a real 404 in the site's design language |
The 404 page was added deliberately and is counted:
"
frontend/404.html| Real 404 page in the existing design language (light/warm/ochre, one accent, no rounded cards). Auto-discovered by the staging seed list, so its references are walked — 11 deployable pages now." (docs/DEPLOYMENT_DECISION.md§6)
2.1 The header, shared by all eleven
Every page carries the same <nav> fragment, with the GitHub and Hugging Face links last:
<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>
<a class="navlink navlink--ext" href="https://huggingface.co/thundercode/SatQuery" target="_blank" rel="noopener" title="SatQuery model on Hugging Face">Hugging Face</a>
(frontend/mission.html:38-39)
The GitHub target is the only public repository, and that is why it is the one linked:
"Target:
https://github.com/Anish-lab-blip/SatQuery-AI— the ONLY public repo (Frontend/ Backend/Inference are private → their links would 404 for the audience)." (docs/FINAL_DELIVERY_TODO.md§4, P9-T01)
The Hugging Face link is B-01, and its history is worth recording because it shows the blocker lifecycle:
| Stage | Status |
|---|---|
| earlier | *"B-01 |
| 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) |
The verification is a live DOM query, not a file grep:
"
E-13| B-01 | live DOM query for the HF anchor |a[href*="huggingface.co/thundercode/SatQuery"]present on the deployed site | VERIFIED" (docs/FINAL_DELIVERY_TODO.md§6)
Measured, in the working tree: all eleven files carry exactly one occurrence of each link.
| Link | Files carrying it | Occurrences per file |
|---|---|---|
https://huggingface.co/thundercode/SatQuery |
11 / 11 | 1 |
https://github.com/Anish-lab-blip/SatQuery-AI |
11 / 11 | 1 |
(measured by grepping frontend/*.html)
The link is styled by a class added with it, so the external-link affordance is part of the same change:
"Files: all 11
frontend/*.htmlheader<nav>, reusing the existing.navlink--extpattern." (docs/FINAL_DELIVERY_TODO.md§4, P9-T02)
2.2 The deployed revision
| Repo | Role | Deployed HEAD |
|---|---|---|
Anish-lab-blip/SatQuery-Frontend |
Cloudflare Pages (static) | 2d7ae53b482d |
(docs/FINAL_DELIVERY_TODO.md §6 E-10; DELIVERY_REPORT_2026-09-25.md §1)
and the three commits that produced it:
| Commit | Subject | Files |
|---|---|---|
ff46eba42b18 |
correct the lexical-router misroute; same-shape change demo pair; measured calibration curve; HF header link | 17 |
d413d3672311 |
give the change-demo pair new URLs; drop the ineffective cache carve-out | 8 (+5, −2) |
2d7ae53b482d |
sibling SQ.policy misroute; state which calibration diagram is plotted |
2 |
(DELIVERY_REPORT_2026-09-25.md §1)
The deployed repo's root is the local
frontend/directory. *"Anish-lab-blip/SatQuery-Frontend— Cloudflare Pages; repo root == localfrontend/"* (sessionHANDOFF_NEXT_AGENT.md§2). There is no build step in the deployed repo; the staging script is a local packaging convenience, not a CI pipeline.
3. The staging pipeline — scripts/stage_pages.mjs
Cloudflare Pages has no .assetsignore, so the deployable tree must be curated. That is what the
staging script is for.
*"Cloudflare Pages constraints that already bit us: per-file limit is 25 MiB (26,214,400 B); Pages has no
.assetsignore, so you must stage a curated directory (hencestage_pages.mjs)."* (HANDOFF_NEXT_AGENT.md§4.3, session workspace)
3.1 It is reference-driven, not a hardcoded list
/**
* stage_pages.mjs — build a Cloudflare-Pages-deployable staging tree for the
* SatQuery frontend by walking the ACTUAL asset references of the deployable
* HTML pages (reference-driven closure), rather than a hardcoded file list.
*
* Why reference-driven: a font set under assets/fonts/ and a regenerated film
* encode are both landing. A hardcoded list would silently omit them; this
* walks each page's src/href/poster, then each CSS @import/url(), then each JS
* import/export-from/dynamic-import, and copies the transitive closure.
*/
(scripts/stage_pages.mjs:1-10)
The limit is a constant in the script:
const PAGES_FILE_LIMIT = 26214400; // 25 MiB (Cloudflare Pages hard limit)
const BIG_WARN_BYTES = 10485760; // 10 MiB (informational)
(scripts/stage_pages.mjs:43-44)
3.2 Options and exit codes
| Flag | Meaning |
|---|---|
--frontend=<dir> |
Source frontend dir (default <repo>/frontend) |
--out=<dir> |
Staging dir (default <repo>/.deploy/pages) |
--launch-src=<path> |
Rewrite the homepage launch-film <video src> in the staged copy only |
--include=<file> |
Force-add a file no page references (repeatable) |
--no-clean |
Do not wipe the staging dir before staging |
--help / -h |
usage |
| Exit code | Meaning |
|---|---|
0 |
success (staged + verified) |
2 |
a staged file exceeds the 25 MiB Cloudflare Pages per-file limit |
3 |
a reference in the staged tree does not resolve (broken deploy) |
1 |
other error |
(scripts/stage_pages.mjs:25-32)
*"SAFETY: never writes to the frontend/ source tree. Copies out only."* (
scripts/stage_pages.mjs:34)
The --include= files are needed because the walk is strictly reference-driven:
"The
--include=files are force-added because no page references them; the default is strictly reference-driven." (HANDOFF_NEXT_AGENT.md§4.1, session workspace)
_headers and robots.txt are the canonical examples:
"
_headersandrobots.txtmust be force-included because no page references them.provenance.jsonandCREDITS.mdlikewise — they are provenance records, not assets." (docs/DEPLOYMENT_DECISION.md§7)
3.3 A real measured run
cd C:/Users/anish/satquery-ai
node scripts/stage_pages.mjs \
--out=.deploy/dist-final \
--include=_headers \
--include=robots.txt \
--include=assets/img/eo/provenance.json \
--include=assets/img/eo/CREDITS.md
npx wrangler pages deploy "C:/Users/anish/satquery-ai/.deploy/dist-final" --project-name <name>
(docs/DEPLOYMENT_DECISION.md §7)
Measured result of that staging run:
files staged : 60
total bytes : 39,173,936 (37.36 MiB)
largest file : assets/video/satquery-launch-50s.mp4 22,710,313 B (21.66 MiB)
25 MiB headroom left : 3,504,087 B on the largest file
missing refs in staged : 0
external network deps : 0 (HERMETIC)
exit : 0
(docs/DEPLOYMENT_DECISION.md §7)
An earlier run of the same script reports a slightly different total, and the difference is recorded rather than reconciled:
files staged : 57
total bytes : 39,163,483 B (37.35 MiB)
largest file : 22,710,313 B (21.66 MiB)
missing refs : 0
external network deps : 0
exit : 0
(HANDOFF_NEXT_AGENT.md §4.1, session workspace)
Do not treat either number as a constant.
HANDOFF_NEXT_AGENT.md§8 item 10 records exactly this hazard: "Hardcoded counts in docs drift. The RUNBOOK's archive size moved 353 → 792 → 364 → 366 as the tree changed. Re-measure rather than trusting a recorded number." 57 and 60 files are two measurements of two trees.
3.4 A real bug the script had, and its fix
"
RE_CSS_IMPORT's bare-token alternative captured the prose wordofout of a stylesheet comment ("This replaced an @import of the Google Fonts CSS API") and failed the run with a phantom missing reference. Fixed at the root inscripts/stage_pages.mjsby addingstripComments(ext, text), called at the top ofextractRefs: CSS/* */, HTML<!-- -->, and — deliberately — block comments only for JS, because stripping//naively would truncate anything after a//inside a string such as'http://www.w3.org/2000/svg'. A reference inside a comment is never fetched, so this is correct, not a suppression." (HANDOFF_NEXT_AGENT.md§4.1, session workspace)
4. The Analyze console
mission.html is the only page that talks to the backend. Its <title> is SATQUERY — Analyze, and
its structure is a scientific instrument, not a chat window:
| Element | id |
Role |
|---|---|---|
| query box | qtext |
"What changed here?" by default (mission.html:51) |
| run button | btnRun |
"Run query" (mission.html:53) |
| observation block | obsTail |
none → ready when a file is selected (mission.html:67) |
| primary file input | fileInput |
hidden; flipped by the drop zone (mission.html:72) |
| second file input | fileInputT0 |
the T0 frame for pair tasks (mission.html:120) |
| intent panel | intentHost |
the router's reading, as chips (mission.html:102) |
| viewer state | viewerState |
"Illustrative frame" → "Your upload" → "Your upload · analysed" (mission.html:141) |
| plate | plateImg |
the user's own image (mission.html:146) |
| evidence SVG layer | ev |
region overlay (mission.html:155) |
| comparison view | cmpWrap, cmpT0, cmpT1, cmpRange, cmpCredit, cmpEmpty |
the T0/T1 wipe (mission.html:171-185) |
| answer | answerHost |
the server's string, verbatim (mission.html:198) |
| evidence list | evHost |
the server's Evidence records (mission.html:211) |
| confidence | confC |
— until a real result arrives (mission.html:226) |
| provenance | pRun, pPolicy, pProtocol, pSchema |
awaiting backend until a real result (mission.html:239) |
| trace bar | trace |
the 9-state spine (mission.html:268) |
| event drawer | drawer, evlog |
the raw event log (mission.html:280-286) |
4.1 The design law the console must obey
"Frontend design law: light/warm/ochre, ONE accent = ochre
#A5662E. NO rounded-rectangle card aesthetic — the target is a scientific instrument, not an "AI dashboard". No glassmorphism, no drop-shadow-as-elevation, no map tiles or map providers. The retired graphite/dark tokens are forbidden." (HANDOFF_NEXT_AGENT.md§7, repo copy)
4.2 The viewer modes
The plan (§51) lists viewer tabs Original, Evidence, Grounding, Change, Optical, SAR,
Fusion. The shipped console implements a four-mode viewer, driven by setMode():
function setMode(m) {
mode = m;
var showCmp = (m === 'comparison');
cmpWrap.hidden = !showCmp;
plate.style.visibility = showCmp ? 'hidden' : 'visible';
evNote.hidden = !(m === 'evidence' || m === 'masked');
if (m === 'evidence') { evNoteText.textContent = 'Awaiting backend — no region, mask or change map has been returned.'; viewerState.textContent = 'Evidence · none'; }
else if (m === 'masked') { evNoteText.textContent = 'Awaiting backend — no availability or change mask has been returned.'; viewerState.textContent = 'Masked · none'; }
else if (m === 'comparison') {
if (cmpWrap.dataset.ready === '1') { viewerState.textContent = 'Comparison'; cmpEmpty.hidden = true; }
else { viewerState.textContent = 'Comparison · needs pair'; cmpEmpty.hidden = false; }
} else { viewerState.textContent = plateImg.dataset.uploaded ? (plateImg.dataset.analysed ? 'Your upload · analysed' : 'Your upload') : 'Illustrative frame'; }
...
}
(frontend/assets/js/mission.js:840-857)
The plan's seven tabs are not the shipped four modes. The modes are
original(the default),evidence,maskedandcomparison— visible in theif/elsechain above.Grounding,Change,Optical,SARandFusionare not separate viewer modes in the shipped console; region and mask output is rendered through theevoverlay on theevidence/maskedmodes, and the optical/SAR pair is rendered as thecomparisonwipe. This is a divergence between the plan's GUI sketch and the built page, and it is recorded rather than papered over.
The empty-state wording is itself a disclosure, not a placeholder: "Awaiting backend — no region, mask or change map has been returned."
4.3 The confidence panel refuses to invent a number
confC.textContent = '—';
confNote.textContent = 'Calibrated confidence is reported only with a real result. Until then it reads “—” — not a placeholder number.';
(frontend/assets/js/mission.js:452, :457)
5. REAL versus PREVIEW — the two drivers, one event seam
mission.js opens with the distinction as the file's governing design:
/* =============================================================================
SATQUERY — ANALYZE (mission)
Drives the page from the production event seam: SQ.run().ingest(type, payload).
TWO DRIVERS, ONE EVENT SEAM
---------------------------
* LIVE (default when files are chosen): the browser uploads the user's own
imagery to `POST /api/assets`, receives asset IDs, posts them to
`POST /api/infer`, and feeds the REAL result into the same eight events.
Every value on screen then traces to the server's own response.
* PREVIEW (no files chosen): the deterministic router still runs so the
instrument is legible, but the specialist/result stages stay honestly empty
("awaiting backend") instead of pretending an analysis happened.
What is NEVER done in either mode: fabricating an answer, a confidence value,
an evidence record, a run id or a coordinate. If the backend is unreachable
the page says which step failed and shows the server's own message.
============================================================================= */
(frontend/assets/js/mission.js:1-18)
5.1 The switch is the presence of a selected file
function runQuery() {
var q = qtext.value.trim() || QUERY;
/* Live whenever the user has actually selected imagery; preview otherwise.
This is the whole point: the demo demonstrates the intended workflow, and
a fixture is never silently substituted for a real upload. */
if (selectedT1) runLive(q);
else runMock(q);
}
(frontend/assets/js/mission.js:799-806)
| Driver | Trigger | liveRun |
Label shown |
|---|---|---|---|
runLive |
a file is selected (selectedT1 truthy) |
true |
live · N evidence · transport … |
runMock |
no file selected | false |
preview — no files selected |
The label is set at the top of each driver:
traceNow.textContent = 'preview — no files selected';
(frontend/assets/js/mission.js:563)
traceNow.textContent = 'live · ' + (ev.length) + ' evidence · transport ' + (out.transport || 'direct');
(frontend/assets/js/mission.js:752)
5.2 What the preview does and does not emit — a correction to the common summary
The preview path is often summarised as "it emits no specialist events". The shipped code emits all eight event names in both modes. What the preview withholds is the content, not the event:
/* ----------------------------------------------------------- mock driver --
PREVIEW ONLY — runs when no file has been chosen. Emits the eight
production events through the seam with EMPTY payloads. RECEIVE/PARSE/PLAN
carry the honest interpretation; the specialist + result stages carry
nothing fabricated. */
(frontend/assets/js/mission.js:550-554)
The preview's specialist events carry a component name and no measurement:
specialists.forEach(function (name, i) {
at(cursor, function () { engine.ingest('SPECIALIST_STARTED', { component: name, index: i + 1, of: specialists.length, stage: 'EXECUTE' }); });
cursor += 260;
at(cursor, function () { engine.ingest('SPECIALIST_COMPLETED', { component: name }); });
cursor += 120;
});
(frontend/assets/js/mission.js:573-578)
and the result stages carry explicit emptiness:
at(cursor + 200, function () {
engine.ingest('EVIDENCE_GENERATED', { regions: [], count: 0, note: 'no specialist output connected' });
});
at(cursor + 460, function () {
engine.ingest('CONFIDENCE_COMPUTED', { degraded: true, degradation_reason: 'No result produced — no calibrated confidence.' });
});
at(cursor + 700, function () {
engine.ingest('RESULT_ASSEMBLED', { text: null, task: intent.task, evidence_ids: [], confidence: null, provenance: null });
});
(frontend/assets/js/mission.js:580-588)
The accurate statement is therefore:
The preview emits all eight event names, but no specialist measurement, no evidence record, no confidence value, no answer, and no run id.
EVIDENCE_GENERATEDcarriesregions: []and the note "no specialist output connected";CONFIDENCE_COMPUTEDcarriesdegraded: truewith the reason "No result produced — no calibrated confidence.";RESULT_ASSEMBLEDcarriestext: null,confidence: null,provenance: null.
The style guide's rule applies here: the code is authoritative, and a summary that says "no specialist events" is not what the code does. Recorded.
5.3 The state notes distinguish preview from live visually
/* Notes for the PREVIEW driver only. When a real result arrives these are
overwritten by measured facts (component names, model revisions, timings). */
var STATE_NOTE = {
RECEIVE: 'received', PARSE: 'interpreted (mock router)', VALIDATE: 'validated (mock router)',
PLAN: 'routed (mock router)', PREPROCESS: 'awaiting backend', EXECUTE: 'awaiting backend',
AGGREGATE: 'awaiting backend', VERIFY: 'awaiting backend', RESPOND: 'awaiting backend'
};
(frontend/assets/js/mission.js:367-373)
and the is-mock class is the visual marker:
/**
* Mark a ControllerState reached, optionally with a measured note.
*
* `is-mock` is the "driven by the preview router" styling. When a real result
* is being rendered the note is a measurement, so the mock class is removed
* instead — otherwise a genuine run would be visually indistinguishable from
* the preview, which is exactly the confusion this page is built to avoid.
*/
function markState(id, note, isLive) {
var n = traceNodes[id];
if (!n) return;
n.node.classList.toggle('is-mock', !isLive);
n.tm.textContent = note !== undefined ? note : (STATE_NOTE[id] || '');
...
(frontend/assets/js/mission.js:396-408)
In the live driver the notes become measurements, which is the point of the distinction:
/* State notes become MEASUREMENTS, replacing the preview wording. */
markState('PREPROCESS', specialists.join(', '), true);
markState('EXECUTE', (trace.selected_models || []).map(function (m) { return m.name; }).join(', ') || 'executed', true);
markState('AGGREGATE', ev.length + ' evidence', true);
markState('VERIFY', conf ? (conf.method || 'uncalibrated') : 'no confidence', true);
markState('RESPOND', out.transport ? ('via ' + out.transport) : 'responded', true);
(frontend/assets/js/mission.js:745-750)
5.4 The live driver's event sequence is a record, not an animation
/* ----------------------------------------------------------- live driver -
The real flow. Uploads the user's files, then runs the analysis and feeds
the server's own result into the same eight events.
The event sequence is emitted around the network calls rather than faked on
a timer: QUERY_RECEIVED/UNDERSTOOD/ROUTE_SELECTED are genuinely known before
the request (they are the client's own reading), SPECIALIST_STARTED is
emitted when the request is dispatched, and SPECIALIST_COMPLETED through
RESULT_ASSEMBLED are emitted from the response. So the trace's shape is a
real record of when the work happened, not a plausible-looking animation. */
(frontend/assets/js/mission.js:591-600)
The three pre-dispatch events are emitted before SQ.live.run is called; the rest are emitted inside
its .then():
engine.ingest('QUERY_RECEIVED', { query: query });
engine.ingest('QUERY_UNDERSTOOD', { intent: intent, dispatched: choice.substituted ? forced : null });
engine.ingest('ROUTE_SELECTED', { intent: intent, specialists: specialists, policy: 'deterministic-rule', policyVersion: 'pc-3.2.1', force_task: forced });
var started = {};
specialists.forEach(function (name) { started[name] = performance.now(); });
SQ.live
.run(filesToSend, query, { forceTask: forced })
.then(function (out) { ... });
(frontend/assets/js/mission.js:654-668)
A nuance worth stating.
SPECIALIST_STARTED/COMPLETEDare emitted from the response, not at dispatch time — the code comment above says "SPECIALIST_STARTED is emitted when the request is dispatched", but the implementation emits both inside the.then()(lines 678-687), measuringelapsed_msfrom a timestamp taken before the request. The timestamps are honest (they bracket the real network call); the event ordering is response-time. Recorded because the comment and the code differ on this one point.
5.5 The failure path never fills the gap
.catch(function (err) {
/* HONEST FAILURE. Say which step failed, and show the server's message.
The specialist/result stages stay unfilled rather than being given
invented content, and the trace states are marked as not executed. */
var stage = (err && err.stage) || 'request';
var status = (err && err.status) ? ' (HTTP ' + err.status + ')' : '';
traceNow.textContent = 'failed at ' + stage + status;
answerHost.innerHTML = '<span class="answer__empty label">No result — the ' + stage + ' step failed</span>';
...
markState('PREPROCESS', 'not executed', false);
markState('EXECUTE', 'not executed', false);
markState('AGGREGATE', 'not executed', false);
markState('VERIFY', 'not executed', false);
markState('RESPOND', 'not executed', false);
(frontend/assets/js/mission.js:773-791)
The plate caption also refuses to keep calling the image illustrative once a real run has touched it:
/* The plate caption must not keep calling the image illustrative once a
real analysis has run on it. The image is still the user's own file,
so the credit names the run rather than claiming the imagery is a
SatQuery output -- the picture is input, the FINDINGS are output.
BOTH halves of the caption are driven: the leading <b> is a literal in
the markup, and leaving it as "Illustrative" would keep asserting the
frame is a stand-in while showing the user's own upload. */
plateCreditLead.textContent = 'Your upload';
plateCredit.textContent = 'analysed in run ' + (env.run_id || '—');
(frontend/assets/js/mission.js:754-762)
5.6 The mode is disclosed in the answer's own tail
answerHost.textContent = answerText;
ansTail.textContent = 'live';
(frontend/assets/js/mission.js:714-715)
and on an empty answer it says so rather than leaving the previous text:
answerHost.innerHTML = '<span class="answer__empty label">The engine returned no answer text for this task</span>';
ansTail.textContent = 'empty';
(frontend/assets/js/mission.js:718-719)
6. The eight execution events, and the trace bar
6.1 The event names
SQ.EVENT_NAMES = [
'QUERY_RECEIVED', 'QUERY_UNDERSTOOD', 'ROUTE_SELECTED',
'SPECIALIST_STARTED', 'SPECIALIST_COMPLETED',
'EVIDENCE_GENERATED', 'CONFIDENCE_COMPUTED', 'RESULT_ASSEMBLED'
];
(frontend/assets/js/core.js:616-620)
core.js's own header states the integration seam this defines:
"The run engine is deliberately dumb: it renders whatever events it receives. Nothing about the visuals depends on the events being synthetic. Swapping the mock driver for a websocket / SSE feed of the same event names is the entire integration surface." (
frontend/assets/js/core.js:8-11)
and the seam itself:
/**
* THE INTEGRATION SEAM.
* Feed real execution events here — same names, same payload shapes — and
* every visual state in the prototype updates identically.
*/
ingest: function (type, payload) {
(frontend/assets/js/core.js:737-742)
6.2 The event envelope
Every emitted event carries four fields:
function emit(type, payload) {
var ev = {
type: type,
t: performance.now() - t0,
seq: state.events.length + 1,
payload: payload || {}
};
state.events.push(ev);
listeners.forEach(function (fn) { try { fn(ev, state); } catch (e) { console.error(e); } });
}
(frontend/assets/js/core.js:709-718)
| Field | Meaning |
|---|---|
type |
one of the eight names |
t |
milliseconds since QUERY_RECEIVED |
seq |
1-based sequence number within the run |
payload |
the event-specific body |
The event drawer prints exactly this:
function logEvent(ev) {
var line = document.createElement('div');
line.innerHTML = '<span class="n">' + U.pad(ev.seq, 2) + ' +' + Math.round(ev.t) + 'ms </span>' +
'<span class="k">' + ev.type + '</span> ' +
'<span>' + JSON.stringify(ev.payload) + '</span>';
evlog.appendChild(line);
evlog.scrollTop = evlog.scrollHeight;
}
(frontend/assets/js/mission.js:428-435)
6.3 The nine-state spine
The trace bar is not the eight events. It is a nine-state ControllerState spine, and the
eight events map onto it:
var STATES = ['RECEIVE', 'PARSE', 'VALIDATE', 'PLAN', 'PREPROCESS', 'EXECUTE', 'AGGREGATE', 'VERIFY', 'RESPOND'];
var EVENT_TO_STATE = {
QUERY_RECEIVED: 'RECEIVE', QUERY_UNDERSTOOD: 'PARSE', ROUTE_SELECTED: 'PLAN',
SPECIALIST_STARTED: 'PREPROCESS', SPECIALIST_COMPLETED: 'EXECUTE',
EVIDENCE_GENERATED: 'AGGREGATE', CONFIDENCE_COMPUTED: 'VERIFY', RESULT_ASSEMBLED: 'RESPOND'
};
(frontend/assets/js/mission.js:361-366)
| Event | State(s) marked |
|---|---|
QUERY_RECEIVED |
RECEIVE |
QUERY_UNDERSTOOD |
PARSE and VALIDATE |
ROUTE_SELECTED |
PLAN |
SPECIALIST_STARTED |
PREPROCESS |
SPECIALIST_COMPLETED |
EXECUTE |
EVIDENCE_GENERATED |
AGGREGATE |
CONFIDENCE_COMPUTED |
VERIFY |
RESULT_ASSEMBLED |
RESPOND |
The subscription is the mechanism:
case 'QUERY_UNDERSTOOD':
renderIntent(ev.payload.intent, ev.payload.dispatched || null);
intentTail.textContent = ev.payload.dispatched ? ('routed as ' + ev.payload.dispatched) : 'resolved';
markState('PARSE', undefined, liveRun); markState('VALIDATE', undefined, liveRun);
traceNow.textContent = 'interpreting';
break;
(frontend/assets/js/mission.js:511-516)
VALIDATE has no event of its own and is marked together with PARSE. The ControllerState enum
in core/schemas.py:79-89 defines all nine; the frontend's STATES array is a literal transcription
of it.
6.4 The fill formula, and the measured 94.4444 %
The bar's width is a pure function of the furthest state reached:
var idx = STATES.indexOf(id);
if (idx > traceProgress) traceProgress = idx;
STATES.forEach(function (s, k) {
var node = traceNodes[s].node;
node.classList.toggle('is-done', k < traceProgress);
node.classList.toggle('is-active', k === traceProgress);
node.classList.toggle('is-idle', k > traceProgress);
});
if (traceFill) {
traceFill.style.width = (((traceProgress + 0.5) / STATES.length) * 100) + '%';
}
(frontend/assets/js/mission.js:414-424)
The comment states the intent:
"Advance the trace to the furthest state reached. This is driven by the same events that carry the real result, so the bar and the node states move only when the run actually reaches a stage — never on a timer. The fill spans from the left edge to the centre of the current node." (
frontend/assets/js/mission.js:410-413)
The arithmetic, worked:
STATES.length = 9
final traceProgress = 8 (RESPOND is the last index, and it is reached)
fill = ((8 + 0.5) / 9) * 100
= (8.5 / 9) * 100
= 94.4444444…%
So 94.4444 % is the fully-complete trace bar, not a partial one: it is 8.5/9, and the missing 5.5556 % is the half-node at the right edge that the "centre of the current node" rule deliberately leaves unfilled.
Measured, live, 2026-09-25:
"| Execution trace (progress bar) | VERIFIED |
.trace__fillwidth is set from real event count (measured 94.4444% live, 2026-09-25) |" (docs/FINAL_DELIVERY_TODO.md§1.4)
and confirmed across all three live passes:
"Common to all twenty-four: a real
run_*id,mock_nodes = 0, live trace bar at 94.4444%, the HF link present in the DOM, and every/api/*call addressed to<backend-host>(capabilities→assets→infer; twoassetscalls for the pair tasks)." (DELIVERY_REPORT_2026-09-25.md§3)
The implementation note records that the bar was fixed to reach this:
"
markStatenow setstraceFill.style.widthfrom the furthest state reached and togglesis-done/is-active/is-idle;resetUIresets both. CSS classes already existed (system.css:654-660)." (docs/FINAL_DELIVERY_TODO.md§4, P5-T02)
and the reset clears it:
traceProgress = -1;
if (traceFill) traceFill.style.width = '0';
(frontend/assets/js/mission.js:440-441)
94.4444 % is an artifact metric, not a system-level claim. It measures one CSS width in one page. It says nothing about the pipeline's own progress reporting — the server returns a completed
ResultEnvelopewith no streaming, so the bar reflects the client's event timeline, which is itself reconstructed from a single request/response pair.
6.5 The bar is not a determinate progress bar, and the docs say so
"A determinate-looking progress bar would lie. Use an indeterminate state with a 'this can take up to a minute' hint." (
docs/FRONTEND_INTEGRATION.md§6)
The trace bar is a stage indicator — which states have been reached — not a percentage of elapsed time. The 94.4444 % figure is the completed state of that stage indicator, and reading it as "94 % of the work is done" would be a misreading the code does not invite.
6.6 The deterministic policy the preview uses
The preview's routing is a rule list, not the learned router, and the page says so in the intent
panel (source, policyVersion: 'pc-3.2.1', basis: 'rule match, no learned router').
/* --- deterministic policy: mirrors the intended controller, rules only --- */
SQ.policy = function (query) {
var q = (query || '').toLowerCase();
var rules = [];
function hit(re, name) { var m = re.test(q); rules.push({ rule: name, fired: m }); return m; }
...
(frontend/assets/js/core.js:622-626)
The B-08 defect lived here and in mission.js, and its fix is worth recording because it is the
clearest example of a lexical router failing in a way that produced a wrong answer rather than an
error:
"Sibling defect:
core.jsSQ.policy(the architecture page's mock router) still carriedbuiltin its change regex, so that page's own shipped sample — "Where is the built-up area?" — firedintent.changeonbuiltandintent.quantifyonarea, and was answered asCHANGE_VQAwith a change-detector specialist. Same defect asmission.js, on a second surface.whereis now evaluated first,builtremoved, andnewcounts only outside awherequestion; a smallrecord()helper keeps the rule list's display order. Regression tests drive the shippedSQ.policy— 4 red before the fix, 6 green after." (DELIVERY_REPORT_2026-09-25.md§1.3)
The fixed rule order is visible in the code, with the reason in a comment:
/* `where` is evaluated FIRST because the change rule below depends on it: a
word that reads as "change" only OUTSIDE a location question must not turn
a `where` question into a change request. */
var where = /where|locate|position|which part|bound|outline|coordinate/.test(q);
var sar = hit(/\bsar\b|radar|backscatter|sentinel-1|insar/, 'sensor.sar');
/* `built` was removed and `new` counts only outside a `where` question.
"Where is the built-up area?" -- the architecture page's OWN sample --
previously fired intent.change on "built", then intent.quantify on "area",
and was answered as CHANGE_VQA with a CHANGE_DETECTOR specialist: a
location question routed to a change question. */
var changeStem = /chang|differ|expand|grow|encroach|lost|removed/.test(q);
var newAsChange = /\bnew\b/.test(q) && !where;
var change = record('intent.change', changeStem || newAsChange);
(frontend/assets/js/core.js:631-645)
B-08 is CLOSED, with two documented residuals:
"Residuals: "What is the new runway?" still reads
change(non-where+new); "How much built-up area was added?" now readsvqa(under-trigger) — both documented." (docs/FINAL_DELIVERY_TODO.md§5, B-08)
6.7 The answer bank is prototype text, and it is labelled
SQ.ANSWER_BANK holds per-task sentence templates for the preview path only:
SQ.ANSWER_BANK = {
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.',
...
};
(frontend/assets/js/core.js:677-684)
They are filled from SQ.scene(), whose own comment is the disclosure:
/* SYNTHETIC DEMO CONSTANTS — the geo / temporal / metric fields below are
illustrative placeholders, not real observations. They exist only so the
prototype renders a populated instrument; the platform string above
already marks the plate as PROTOTYPE SYNTHETIC. Any page that displays
these values MUST disclose that they are synthetic (see the `synthetic`
flag below) and MUST NOT present them as measured satellite data.
core.js is shared across pages and is intentionally NOT removed here —
only labelled. If a live backend ever supplies a real scene, it should
set synthetic:false and override these fields. */
return {
seed: seed,
biome: biome,
regions: regions,
synthetic: true, // every field below is a demo placeholder
gsd: 10, // metres per pixel (placeholder)
aoi: { lat: 31.204, lon: 72.816 }, // placeholder coordinate
dates: { t0: '2024-03-14', t1: '2025-03-19' }, // placeholder epochs
sensor: 'OPTICAL / MSI',
platform: 'SENTINEL-2 · L2A (PROTOTYPE SYNTHETIC)',
registrationRMSE: 0.42 // placeholder residual
};
(frontend/assets/js/core.js:268-288)
SQ.ANSWER_BANKandSQ.scene()are preview-only. The live driver never reads them: it rendersresult.answerverbatim (mission.js:712-714). Thesynthetic: trueflag and theplatformstring exist so a reader of the preview cannot mistake it for a measurement. This is the design the style guide's "never upgrade a status" rule requires, implemented in the data itself.
7. The live client — frontend/assets/js/live.js
7.1 The endpoints it calls
/*: The orchestrator's proxied routes (deploy/render/main.py). These are NOT
the Space's own `/v1/*` routes -- the browser never talks to the Space
directly; the orchestrator is the only public door. */
SQ.ENDPOINTS = {
assets: '/assets',
infer: '/infer',
capabilities: '/capabilities',
health: '/health'
};
(frontend/assets/js/live.js:52-60)
7.2 Base-URL resolution, in three ordered steps
/**
* The orchestrator base URL, with no trailing slash.
*
* Resolution order is documented in the file header. Returning `/api` rather
* than '' keeps the failure mode legible: a misconfigured deployment asks the
* Pages host for `/api/infer` and gets a clean 404, instead of the page's
* own index.html being fetched as JSON and producing a confusing parse error.
*/
SQ.live.baseUrl = function () {
var injected = window.SATQUERY_API_BASE;
if (injected) return _normalizeBase(String(injected));
var meta = document.querySelector('meta[name="satquery-api-base"]');
if (meta && meta.content) return _normalizeBase(String(meta.content));
return '/api';
};
(frontend/assets/js/live.js:91-107)
| Order | Source | Why |
|---|---|---|
| 1 | window.SATQUERY_API_BASE |
an inline config so a deployment points at its own backend without rebuilding the JS |
| 2 | <meta name="satquery-api-base"> |
the same idea, declarative |
| 3 | /api on the current origin |
correct for a same-origin deployment and for the local dev proxy |
The normaliser exists because an absolute base naturally omits /api:
/**
* A configured base, with `/api` guaranteed for absolute origins.
* ...
* someone configuring an absolute URL naturally writes the
* ORIGIN -- `https://host` -- and then `url()` produced `https://host/assets`
* instead of `https://host/api/assets`. Every call 404s, and it is a silent
* failure: the page reports a network error rather than a misconfiguration.
*/
function _normalizeBase(raw) {
var base = String(raw).replace(/\/+$/, '');
if (base.indexOf('://') === -1) return base; // relative: as written
var after = base.slice(base.indexOf('://') + 3);
var slash = after.indexOf('/');
var path = slash === -1 ? '' : after.slice(slash);
if (path === '' || path === '/') return base + '/api';
return base;
}
(frontend/assets/js/live.js:109-131)
"Rule 3 is why development needs no secret: a dev server that proxies
/apito Render lets the browser talk tohttp://localhost:8080/api/...and the CORS allowlist is then a non-issue. Direct cross-origin calls also work, and that is what the localhost CORS entries indeploy/render/main.pyexist for." (frontend/assets/js/live.js:40-43)
7.3 The content-type map, derived from the extension
/*: Extensions the Space's store accepts, mirroring the gateway content-type
allowlist (gateway/policy.py `allowed_content_types`). The browser sets the
Content-Type header from this map; a wrong type is a 422 from the store, so
guessing it from the extension is more reliable than trusting the File's
own `.type`, which browsers leave empty for GeoTIFF. */
SQ.CONTENT_TYPES = {
tif: 'image/tiff',
tiff: 'image/tiff',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg'
};
(frontend/assets/js/live.js:62-73)
Note the client's map has four types and omits image/geotiff and application/octet-stream,
which the server's allowlist of five includes. A .geotiff file therefore has no client-side mapping
and is refused by uploadAsset before any request:
var contentType = SQ.contentTypeFor(file);
if (!contentType) {
return Promise.reject(
LiveError(
'upload',
'Unsupported file type: ' + (file && file.name ? file.name : '(unnamed)') +
'. Use GeoTIFF, TIFF, PNG or JPEG.'
)
);
}
(frontend/assets/js/live.js:204-213)
This is a real client/server asymmetry. The message says "Use GeoTIFF, TIFF, PNG or JPEG" while the map has no
geotiffextension key, so a file namedscene.geotiffis refused with a message that names its own format. The server would accept it asimage/geotiff. Recorded as a defect in the client, not smoothed over.
7.4 Uploads are sequential, on purpose
/**
* Upload several Files, sequentially, preserving order.
*
* Sequential rather than parallel, and that is a considered choice: the
* Codespace runs `cache_max_models: 1` and puts v1 execution in a single
* process with sequential plans (gateway/assets.py). Firing five uploads at
* once gains nothing and makes a partial failure harder to reason about --
* the caller learns exactly which file failed, by index.
*/
(frontend/assets/js/live.js:245-254)
This is the client's implementation of the contract's "Serialize requests" obligation
(docs/API_CONTRACT.md §6, item 3).
7.5 The analysis request body is minimal, because extra="forbid"
var body = { assets: ids, query: String(query || '') };
if (opts.forceTask) body.force_task = opts.forceTask;
(frontend/assets/js/live.js:301-302)
"
extra="forbid"is why this function sends nothing else: an extra key is a 422, not an ignored field.force_taskis omitted rather than sent as null, because both are accepted but omitting it keeps the payload minimal and lets the server's own router decide." (frontend/assets/js/live.js:287-291)
7.6 The response is asserted at the boundary
return resp.json().catch(function () { return null; }).then(function (parsed) {
if (!resp.ok) throw describeFailure('infer', resp.status, parsed);
if (!parsed || !parsed.result) {
throw LiveError('infer', 'The service returned no result.', {
status: resp.status,
detail: JSON.stringify(parsed).slice(0, 400)
});
}
return { envelope: parsed, state: state, transport: transport };
});
(frontend/assets/js/live.js:318-327)
and on the upload path:
/* Assert the shape at the boundary. A 200 whose body lacks asset_id
would otherwise travel into `/api/infer` as `undefined` and fail
there, naming the wrong cause. */
if (!body || typeof body.asset_id !== 'string' || !body.asset_id) {
throw LiveError('upload', 'Upload succeeded but returned no asset id.', { ... });
}
(frontend/assets/js/live.js:224-231)
7.7 The error object carries the contract's classification
function LiveError(stage, message, opts) {
opts = opts || {};
var err = new Error(message);
err.name = 'SQ.LiveError';
err.stage = stage;
err.status = opts.status || 0;
err.code = opts.code || '';
err.detail = opts.detail || '';
err.recoverable = !!opts.recoverable;
return err;
}
(frontend/assets/js/live.js:152-162)
"
stagenames the step ('upload' | 'infer'),statusis the HTTP status if a response was received, andcodeis the contract's error code when the server supplied the v1 envelope. The server's own message is preserved rather than replaced -- a generic "something went wrong" would hide the difference between "your file is too large" and "the engine is waking"." (frontend/assets/js/live.js:142-150)
7.8 The capabilities call exists so the page can refuse to promise
/**
* GET /api/capabilities, for the UI to show what the engine can actually do.
*
* Not part of the analysis flow; it exists so the page can refuse to promise
* a task the deployment cannot serve, rather than failing after an upload.
*/
(frontend/assets/js/live.js:368-373)
mission.js consumes it at start-up:
engine = SQ.run({ query: QUERY });
engine.on(onEvent);
resetUI();
runMock(QUERY);
loadCapabilities();
(frontend/assets/js/mission.js:971-975)
7.9 The pair-aware dispatch — the client honours requires_pair
"
/api/capabilitiesdeclaresrequires_pairandmax_assetsper task, and the page chooses the task with the asset count in mind." (frontend/assets/js/mission.js:134-135)
/* Send ONLY the assets the dispatched task requires. A single-image task
(vqa / grounding / caption) must NOT receive the optional T0 frame: the
backend rejects a two-asset payload for a one-asset task with
`invalid_request`. Temporal tasks (change / change_vqa) need T0+T1, and
optical_sar needs the optical+SAR pair (T1 + the second modality in T0). */
var filesToSend = assetsForTask(forced, selectedT1, selectedT0);
(frontend/assets/js/mission.js:624-629)
and the substitution is disclosed before the request, not after:
/* Say the substitution where the user is looking, before the request, so
the result is not surprising. It is a fact about the request, not an
error: one image genuinely cannot support change detection. */
if (choice.substituted) {
obsNote.innerHTML = 'Analysing as <span class="mono">' + forced +
'</span> — ' + choice.reason + '. Add a T0 frame to run <span class="mono">' +
choice.wanted + '</span>.';
}
(frontend/assets/js/mission.js:645-652)
This was a deployed defect (P1 in the status board) and its fix is recorded:
"Acceptance: deployed
mission.jscontainsassetsForTask; vqa-with-pair returns real result (only T1 uploaded)." (docs/FINAL_DELIVERY_TODO.md§4, P4-T01)
7.10 Optical-SAR gets an early warning, because its pair is two modalities
/* Optical-SAR is the one task whose pair is two MODALITIES, not two times.
Warn early (before the upload) when the second file looks like a plain
photo rather than a radar product, so the round-trip does not fail opaquely. */
if (forced === 'optical_sar' && pairNote) {
var sarCheck = validateOpticalSar(selectedT1, selectedT0);
pairNote.innerHTML = sarCheck.level !== 'ok' ? sarCheck.message : pairNoteDefault;
}
(frontend/assets/js/mission.js:631-637)
The contract's rule the validator implements:
"Optical+SAR contract: modality inferred from band count —
{1,2}⇒ SAR,{3,4,8,11,12,13}⇒ optical; both GeoTIFF, same W×H, uint8/uint16, rasterio-readable." (sessionHANDOFF_NEXT_AGENT.md§4)
8. Cloudflare traps
Three measured behaviours of the Cloudflare Pages tier cost real time on this project. All three are recorded because they are non-obvious and each one produced a wrong assumption.
8.1 _headers rules CONCATENATE — they do not override
This is the finding, and the file's own earlier comment was false:
# Format: a path pattern, then indented Header: value lines. `*` matches any
# number of characters. A request that matches several rules inherits ALL of
# them, and a header set by more than one rule is JOINED with a comma in file
# order -- it is NOT overridden. Verified live 2026-09-25: a narrower
# Cache-Control rule did not replace the broader one, it appended to it. To
# remove a header contributed by a broader rule, detach it with a
# "! Header-Name" line (Cloudflare Pages supports `!` detach).
(frontend/_headers:3-9)
The measurement. A specific max-age=0 rule placed under the broad /assets/img/*
max-age=604800 rule produced this live response:
Cache-Control: public, max-age=604800, public, max-age=0, must-revalidate
(DELIVERY_REPORT_2026-09-25.md §5; frontend/_headers:70)
The consequence, and why it is worse than a normalisation failure: Chromium takes the first
max-age it finds, so the broad week-long value still won:
"Chromium honours the first
max-age, so the returning browser kept the stale image and the carve-out was ineffective." (DELIVERY_REPORT_2026-09-25.md§5)
The _headers file records the same conclusion and forbids reintroducing the pattern:
# NOTE (2026-09-25): an earlier revision of this file tried to carve the
# delta-growth change-demo pair out of the week-long rule above with two literal
# path blocks carrying max-age=0. It did NOT work. Cloudflare does not override a
# header when a second rule sets it -- it JOINS the values in file order, and the
# live response was "public, max-age=604800, public, max-age=0, must-revalidate".
# Chromium takes the FIRST max-age it finds, so the broad week-long value still
# won and a returning browser kept the stale image. Do not reintroduce a
# Cache-Control carve-out here: any rule broad enough to matter also matches
# /assets/img/*, so the broad value is always present. The pair was instead given
# NEW URLs (delta-growth-t0-1975-720.jpg / delta-growth-t1-2025-720.jpg), which
# is the only cache-busting that does not depend on _headers semantics.
(frontend/_headers:66-76)
The status board records the corrected comment as a documentation fix, which is what it was:
"| Cache-busting | VERIFIED (with a caveat) |
_headersrevalidates JS/CSS; the EO pair was instead given NEW URLs because Cloudflare concatenates matching_headersrules — see §1.7 item 9 |" (docs/FINAL_DELIVERY_TODO.md§1.4)
and §1.7 item 9:
"Cloudflare
_headersCONCATENATES matching rules instead of overriding them. A specific rule under a broad/assets/img/*rule producesCache-Control: public, max-age=604800, …, max-age=0, must-revalidate, and Chromium honours the firstmax-age— so a per-path override cannot un-cache a long-lived asset. Measured live 2026-09-25. The cache-busting fix therefore renames the asset to a new URL rather than adding a_headersrule." (docs/FINAL_DELIVERY_TODO.md§1.7 item 9)
A consequence that persists: the deleted old URLs still answer from the edge cache.
"Consequence: the old URLs still answer
200from Cloudflare's edge cache (CF-Cache-Status: HIT) although the files are deleted; a cache-busted request returns404. Nothing references them." (DELIVERY_REPORT_2026-09-25.md§5)
8.2 The ! detach escape hatch
Cloudflare Pages supports ! Header-Name to detach a header contributed by a broader rule. The
_headers file records it as the correct way to remove an inherited header
(frontend/_headers:8-9) — but the project chose URL renaming instead for the EO pair, because
"any rule broad enough to matter also matches /assets/img/*, so the broad value is always
present" and a detach would have removed Cache-Control for every image rather than for one.
8.3 The 308 redirect: X.html → /X
"Cloudflare Pages 308-redirects
X.html→/X. Drivehttps://satquery.pages.dev/mission." (sessionHANDOFF_NEXT_AGENT.md§4)
| Request | Response |
|---|---|
GET /mission.html |
308 → Location: /mission |
GET /mission |
200, the page |
Why it matters for a harness. A headed-browser driver that navigates to
https://satquery.pages.dev/mission.html is redirected, and any assertion written against the
pre-redirect URL — or against a location.pathname that still ends in .html — sees a different
document URL than it expected. The rule the project adopted is to drive the extensionless path.
Why it does not matter for the site itself. docs/DEPLOYMENT_DECISION.md §6 records that no
_redirects file was created, and the reason:
"**
_redirects* — every link in the site is already a literal.htmlpath; there are no pretty URLs to map."*
So the site's own internal links are .html and Cloudflare's 308 is a host-level behaviour that
the site does not depend on. The two facts are consistent: the site never needs the redirect, and a
harness that types the URL directly must account for it.
8.4 What was deliberately NOT created
docs/DEPLOYMENT_DECISION.md §6 lists the files that were considered and refused, with reasons:
| Not created | Reason |
|---|---|
_redirects |
every link is already a literal .html path; there are no pretty URLs to map |
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." |
wrangler.toml |
optional for Pages; the deploy command carries the project name |
| 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." |
Note the correction.
sitemap.xmldoes exist in the tree (frontend/sitemap.xml, 741 B) androbots.txtcarries aSitemap:line, so the "needs a canonical domain" blocker was resolved after that decision record was written. The decision record is retained as the historical record; the tree is the current state.
9. Cache-busting
9.1 The rule that matters: JS and CSS must revalidate
# CSS and JS are NOT content-hashed. They MUST revalidate on every request, or a
# deploy is masked by a cached asset for up to the max-age window — observed on
# 2026-09-25 when a returning browser served the pre-fix mission.js and kept
# hitting the old invalid_request. max-age=0 + must-revalidate makes the browser
# re-fetch (and Cloudflare re-validate) on every load, so a deploy is picked up
# immediately, exactly like the HTML above.
/assets/css/*
Cache-Control: public, max-age=0, must-revalidate
/assets/js/*
Cache-Control: public, max-age=0, must-revalidate
(frontend/_headers:50-60)
The incident that produced the rule is named in the comment: a returning browser served the
pre-fix mission.js and "kept hitting the old invalid_request". The cache was masking a
correct deploy — the same failure class as the phantom defect chase that
session HANDOFF_NEXT_AGENT.md §4 warns about:
"Stale browser cache can mask a correct deploy. Verify server-side (GitHub API sha256) and client-side (CDP
Network.clearBrowserCache), or you will chase a phantom."
9.2 The full _headers policy
| Path pattern | Cache-Control |
Why |
|---|---|---|
/* |
(none set) | the baseline block sets only security headers |
/ |
public, max-age=0, must-revalidate |
HTML revalidates every time |
/*.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" |
/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" |
/assets/fonts/* |
public, max-age=31536000 |
"Stable, versioned by presence rather than by filename, so a long max-age is appropriate." |
/assets/css/* |
public, max-age=0, must-revalidate |
not content-hashed |
/assets/js/* |
public, max-age=0, must-revalidate |
not content-hashed |
/assets/img/* |
public, max-age=604800 |
"Copernicus / ESA / NASA reference imagery. Stable." |
(frontend/_headers:21-64)
The baseline security headers, which apply to every path:
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Cross-Origin-Opener-Policy: same-origin
(frontend/_headers:21-25)
No CSP, and the file says why. "No Content-Security-Policy is set, deliberately. NOTE (2026-09-25): the site is no longer fully static — mission.html loads live.js and POSTs to the Render orchestrator (the Analyze console), so a real CSP would also need a connect-src for that API origin. atlas.html additionally carries one inline
style=""attribute, so any CSP would have to allow'unsafe-inline'anyway." (frontend/_headers:14-19)
9.3 The status, with its caveat
"| Cache-busting | VERIFIED (with a caveat) |
_headersrevalidates JS/CSS; the EO pair was instead given NEW URLs because Cloudflare concatenates matching_headersrules" (docs/FINAL_DELIVERY_TODO.md§1.4)
and the phase item is honest about which half was verified when:
"P4-T02 — Cache-busting for JS/CSS | Status: COMPLETE (live confirmation pending P10-T01 re-deploy) | Acceptance: returning users get fresh JS on next load. | Evidence: file edited (2026-09-25). Post-deploy
curl -Ito confirm header." (docs/FINAL_DELIVERY_TODO.md§4)
UNKNOWN — not established from the available evidence: the post-deploycurl -Ioutput confirming the liveCache-Controlon/assets/js/*. The file was edited and the phase marked complete; the recorded evidence is the file edit, not a captured response header.
10. The harness lesson — a headed-browser driver that records false passes
This is the most valuable operational finding in this chapter, because it is a false pass, not a false failure.
10.1 The failure, as it was observed
"When I re-ran the suite to cover the final commit, the first case came back
run_id=0002,mock_nodes=9,answer="No answer yet", and only thecapabilitiescall — i.e. the mock path. Diagnosis: the harness drove the query box withfill_input(), which types using real CDP key events, and Chrome drops synthesized key events when the browser window does not hold OS focus. Measured directly: with Chrome backgrounded,press_key("Z")left#qtext.valueunchanged, whiletype_text("Q")(CDPInput.insertText, not focus-gated) inserted fine.fill_inputhas no assertion, so the harness happily clicked Run with the page's default query still in the box." (DELIVERY_REPORT_2026-09-25.md§3)
The shape of the false pass: the query box kept the page's default (What changed here?), the
harness clicked Run anyway, the page produced a result — and that result was recorded as the
verdict for a case whose query was never entered. The harness had no way to tell the difference
between "the query was entered and the run used it" and "the query was never entered".
10.2 Why the earlier 8/8 run was not infected — the three discriminators
The report does not simply re-run and hope. It checks whether the earlier result was contaminated, using evidence the harness recorded:
*"I then checked whether the earlier 8/8 run was infected by the same silent failure. It was not:
- its recorded intents are query-specific — A1 reads
taskvqa…temporalnone, whereas the default query "What changed here?" would readtaskchange…temporalrequired(exactly what the failed run showed);- its answers embed the query text — e.g.
[grounding] Located 6 candidate region(s) for 'Where are the built-up areas in this image?';- A6 required two files (
optical 4/12 + SAR 2/2channels), which only the uploaded pair supplies.So the 8/8 result is a valid measurement."* (
DELIVERY_REPORT_2026-09-25.md§3)
The three discriminators generalise:
| Discriminator | What it proves |
|---|---|
| the recorded intent is query-specific | the query reached the router |
| the answer embeds the query text | the server received the intended query |
| a case requires an artefact only the setup supplies | the setup really happened |
10.3 The fix: deterministic query entry plus pre-dispatch assertions
"The harness has since been rebuilt (
run_all_postfix2.harness) to set the query deterministically and to assert the form state before clicking Run, recording per case:q_ok(the box really held the query),obs_ok(#obsTail == 'ready'and one file on#fileInput),t0_ok(both frames for pair tasks),no_mock_nodes, and a computedverdict. A silent no-op can no longer be recorded as a pass." (DELIVERY_REPORT_2026-09-25.md§3)
The three pre-dispatch assertions, and the rule they implement:
"Do NOT use
fill_input()orpress_key()to enter the query. They type with real CDP key events, which Chrome silently drops when the browser window does not hold OS focus — the box keeps its default text and the run silently exercises the wrong query. Usejs()to set#qtext.value(plusinput/changeevents) and/ortype_text()(CDPInput.insertText, not focus-gated). Always assert the form state before clicking Run —q_ok(box holds the query),obs_ok(#obsTail == 'ready'),t0_ok(both frames for pair tasks) — or a no-op will be recorded as a pass.upload_file()is fine and flips#obsTailtoready." (sessionHANDOFF_NEXT_AGENT.md§5.2)
| Assertion | Checks | Failure it prevents |
|---|---|---|
q_ok |
#qtext.value holds the intended query |
the silent-drop false pass |
obs_ok |
#obsTail == 'ready' and one file on #fileInput |
an upload that did not land |
t0_ok |
both frames present, for pair tasks | a pair task run on one asset |
no_mock_nodes |
mock_nodes == 0 |
the preview path being recorded as live |
obs_ok's second half is a real DOM fact, because the page sets that tail from the upload:
#obsTail reads none in the markup (mission.html:67) and the live driver flips it to ready.
10.4 Two more harness bugs — both false failures
*"Two further harness bugs surfaced while re-running — both produced false failures, never false passes, but they are easy to repeat:
- The answer tag is not universal. The server prefixes the answer with
[task]only for the region tasks (grounding,change,change_vqa,optical_sar). vqa answers are bare (Grassland) and caption answers are prose, so a tag-only discriminator wrongly fails them. Fix: the dispatched task isanswer_tagwhen present, else the intent panel's reading.- The intent panel renders a concatenated string —
task<name>modality<…>temporal<…>. Matchingtask([a-z_]+)greedily swallows the whole string; it must betask([a-z_]+?)modality."* (DELIVERY_REPORT_2026-09-25.md§3)
The concatenation is a real property of the intent panel, which renders chips without separators:
function renderIntent(intent, dispatched) {
intentHost.innerHTML = '';
var rows = [
['task', dispatched || intent.task], ['modality', intent.modality], ['temporal', intent.temporal],
['spatial', intent.spatial_output], ['evidence', intent.evidence], ['source', intent.source]
];
if (dispatched) rows.push(['reading', intent.task]);
rows.forEach(function (r) {
var c = U.el('span', 'chip chip--plain');
c.innerHTML = '<span class="k">' + r[0] + '</span>' + r[1];
intentHost.appendChild(c);
});
}
(frontend/assets/js/mission.js:344-356)
10.5 The reading-versus-dispatch distinction, which is a feature not a bug
"Read the dispatched task from the answer's
[task]tag when present, else from the intent panel's reading — the panel shows the router's reading, and a quantifier upgrade legitimately makes the two differ (A5 readschange, dispatcheschange_vqa)." (sessionHANDOFF_NEXT_AGENT.md§5.3)
The panel preserves both, on purpose:
if (dispatched) rows.push(['reading', intent.task]);
(frontend/assets/js/mission.js:352)
with the reasoning in the docstring:
"The
taskchip then names what was SENT and areadingchip preserves what the router saw — showing only one of the two would either misreport the request or hide the router's input." (frontend/assets/js/mission.js:341-343)
10.6 The safety property that made the harness bugs survivable
"Pass 3's raw harness output reports
SUMMARY 0/8— because it was launched with the harness build that still had the two discriminator bugs. Its verdicts inresults_pass3.jsonare recomputed from the recorded evidence byrecompute_verdicts.py. This is exactly the intended safety property: the recorded evidence is independent of the verdict computation, so a harness bug never forces a 24-minute browser re-run — and never silently flips a real failure into a pass." (DELIVERY_REPORT_2026-09-25.md§3)
That is the generalisable lesson: record evidence, compute verdicts separately. A harness that computes its verdict inline has no way to re-derive it when the verdict logic turns out to be wrong.
10.7 The three live passes
| Pass | Target | Result | Raw output |
|---|---|---|---|
| 1 | ff46eba42b18 + d413d3672311 |
8/8 | run_output.txt |
| 2 | final HEAD 2d7ae53b482d, asserting harness |
8/8 | run_final2.txt → results_final.json |
| 3 | final HEAD 2d7ae53b482d, repeat |
8/8 | run_final3.txt → results_pass3.json |
"24 live runs, 24 correct dispatches, no run id repeated across passes." (
DELIVERY_REPORT_2026-09-25.md§3)
The eight cases and their pass-2 run ids:
| case | query | expected | dispatched | pass 2 run_id |
|---|---|---|---|---|
| A1 | What type of terrain dominates this scene? | vqa | vqa | run_0843db184e32 |
| A2 | Describe the main visual characteristics of this scene. | caption | caption | run_5b766f2d7df7 |
| A3 | Where are the visible buildings in this image? | grounding | grounding | run_ea590b6fd70f |
| A4 | What changed between the earlier and later image? | change | change | run_65a4b2f9d912 |
| A5 | Did the coastline advance between the two observations? | change_vqa | change_vqa | run_efe24b98d217 |
| A6 | …combining the optical and SAR observations? | optical_sar | optical_sar | run_6375b80dcb8e |
| B1 | Where are the built-up areas in this image? | grounding | grounding | run_2a07dcdbae96 |
| B2 | Where is the new airport? | grounding | grounding | run_9134f40a258c |
(DELIVERY_REPORT_2026-09-25.md §3)
A6's query is elided in the source as
"…combining the optical and SAR observations?"— the leading words are not reproduced in the report, and this chapter does not invent them.
10.8 The two verdicts, kept separate
"1. Deployment / integration: PASS — the full pipeline works on unseen imagery and questions. 2. Model quality: MIXED — caption and grounding are meaningful; change/change_vqa are plausible; VQA is weak-but-related; optical-SAR still returns a bare class index (
class_18 (margin 1.000; optical channels 4/12, SAR channels 2/2)), not a human label." (DELIVERY_REPORT_2026-09-25.md§3)
This is the style guide's rule applied at the harness level: "a mixed result is never 'all work perfectly'." The integration passes; the model quality does not, and the two are not merged.
10.9 The harness's hard constraints
| Constraint | Detail |
|---|---|
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" |
grep block-buffers when piped |
"piping the harness through grep swallows all output if the pipeline is killed — redirect to a file" |
| sandbox proxy is dead | "Every network call needs --noproxy '*' (curl) or ProxyHandler({}) / --no-proxy-server (Python / browser)" |
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 |
(session HANDOFF_NEXT_AGENT.md §4, §5.1, §5.5)
11. What is NOT RUN, OPEN, SUPPORTED or BLOCKED for this topic
| Item | Status | Detail |
|---|---|---|
| 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) |
| 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) |
| The trace bar's fill | VERIFIED (live) | measured 94.4444 % (docs/FINAL_DELIVERY_TODO.md §1.4) |
| 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) |
| Research page | VERIFIED | "entries now trace to real artifacts/reports with honest limitations" (docs/FINAL_DELIVERY_TODO.md §4, P7-T01) |
| Journey/Lab page | VERIFIED | "stages correspond to real phases/reports; implemented/verified/attempted/blocked distinguished" (docs/FINAL_DELIVERY_TODO.md §4, P7-T02) |
| 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) |
| HF header link | VERIFIED | present in all 11 navs; live DOM-confirmed (docs/FINAL_DELIVERY_TODO.md §6 E-13) |
| GitHub header link | VERIFIED | target is the only public repo (docs/FINAL_DELIVERY_TODO.md §4, P9-T01) |
| Cache-busting | VERIFIED (with a caveat) | see §9.3 — the live curl -I confirmation is not in the recorded evidence |
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) |
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 |
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) |
| The plan's seven viewer tabs vs the shipped four modes | DIVERGENCE, recorded | see §4.2 |
| 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 |
11.1 Two honesty constraints that outlive the sprint
"Imagery honesty: everything in
frontend/assets/img/eo/is Copernicus / ESA / NASA reference material withsatquery_result: falseandrole: illustrative. Nothing may imply it is SatQuery pipeline output. Never replace a fake value with another fake value — either measure it or label it with.disclose." (HANDOFF_NEXT_AGENT.md§7, repo copy)
"Never fabricate. No invented confidence values, areas, RMSE, run IDs, acquisition dates, lat/lon, model outputs or execution times." (
HANDOFF_NEXT_AGENT.md§7, repo copy)
12. Where the evidence lives
| Claim class | File | What it establishes |
|---|---|---|
| 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) |
| the live client | frontend/assets/js/live.js |
endpoints (:55), base URL (:99), upload (:202), infer (:294), run (:345) |
| the console | frontend/assets/js/mission.js |
runMock (:556), runLive (:601), onEvent (:502), markState (:404), the fill formula (:423) |
| the cache/security policy | frontend/_headers |
the concatenation finding (:3-9), the EO note (:66-76), the JS/CSS rule (:50-60) |
| the page set and the header nav | frontend/*.html |
11 files; the two external links on each |
| the staging pipeline | scripts/stage_pages.mjs |
the reference walk, the 25 MiB limit (:43), the exit codes (:25-32) |
| hermeticity, the film, what was not created | docs/DEPLOYMENT_DECISION.md |
§3, §6, §7 |
| the Pages tier and its one live page | docs/DEPLOYMENT_TOPOLOGY.md |
§3.1 and the header correction |
| the status board, the Cloudflare finding, B-08 | docs/FINAL_DELIVERY_TODO.md |
§1.4, §1.7 item 9, §4, §5, §6 |
| the live validation and the harness trap | DELIVERY_REPORT_2026-09-25.md |
§1, §3, §5 |
| the hard constraints, the harness rules | session HANDOFF_NEXT_AGENT.md |
§4, §5 |
| the design law and the imagery-honesty rule | repo HANDOFF_NEXT_AGENT.md |
§7 |
12.1 Cross-references
| For… | Read |
|---|---|
| the topology, the tiers, the tunnel | 02 — Deployment Topology |
| the controller's nine states in full, and the server-side events | 03 — Request Lifecycle §36–§38 |
| the evidence records and the confidence rules the console renders | 06 — Evidence and Confidence |
| the endpoints the client calls, and their envelopes | 08 — The API Contract |
| the health payload, the trace as an observability object, the runbook | 10 — Observability and Operations |