Image-to-Text
PyTorch
Safetensors
PEFT
English
remote-sensing
satellite-imagery
earth-observation
change-detection
visual-grounding
image-captioning
visual-question-answering
optical-sar-fusion
sar
multimodal
lora
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
File size: 78,258 Bytes
6413c14 757eac0 6413c14 2bc815e 6413c14 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 | # 09 — The Frontend
**Parent:** [Architecture hub](README.md) · **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.md` and 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.md` header 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 of `fetch(`,
> `XMLHttpRequest`, `axios`, `EventSource`, `WebSocket`, `/v1/`, `import.meta.env` or `process.env`.
> The only URL-shaped string anywhere is the SVG XML namespace at `frontend/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:
```js
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.css` previously opened with a render-blocking `@import` of the Google Fonts CSS
> API. That `@import` is gone, replaced by 10 `@font-face` blocks pointing at 11 woff2 files in
> `assets/fonts/` (518,198 B total, plus `OFL.txt`). This matters more than it looks: a CSS `@import`
> is 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 | `` | 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:
```html
<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` | 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**" |
| 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/*.html` header ``, reusing the existing `.navlink--ext` pattern."*
> (`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 == local `frontend/`**"* (session `HANDOFF_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 (hence `stage_pages.mjs`)."*
> (`HANDOFF_NEXT_AGENT.md` §4.3, session workspace)
### 3.1 It is reference-driven, not a hardcoded list
```js
/**
* 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:
```js
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:
> *"`_headers` and `robots.txt` must be force-included because no page references them.
> `provenance.json` and `CREDITS.md` likewise — they are provenance records, not assets."*
> (`docs/DEPLOYMENT_DECISION.md` §7)
### 3.3 A real measured run
```bash
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
```
(`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 word `of` out 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 in `scripts/stage_pages.mjs` by adding `stripComments(ext, text)`,
> called at the top of `extractRefs`: 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()`:
```js
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`, `masked` and `comparison` — visible in the `if/else` chain above. `Grounding`, `Change`,
> `Optical`, `SAR` and `Fusion` are **not** separate viewer modes in the shipped console; region and
> mask output is rendered through the `ev` overlay on the `evidence`/`masked` modes, and the
> optical/SAR pair is rendered as the `comparison` wipe. 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
```js
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:
```js
/* =============================================================================
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
```js
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:
```js
traceNow.textContent = 'preview — no files selected';
```
(`frontend/assets/js/mission.js:563`)
```js
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:
```js
/* ----------------------------------------------------------- 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:
```js
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:
```js
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_GENERATED` carries `regions: []` and the
> note *"no specialist output connected"*; `CONFIDENCE_COMPUTED` carries `degraded: true` with the
> reason *"No result produced — no calibrated confidence."*; `RESULT_ASSEMBLED` carries
> `text: 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*
```js
/* 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:
```js
/**
* 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:
```js
/* 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
```js
/* ----------------------------------------------------------- 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()`:
```js
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`/`COMPLETED` are 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), measuring
> `elapsed_ms` from 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
```js
.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:
```js
/* 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
```js
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:
```js
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
```js
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:
```js
/**
* 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:
```js
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:
```js
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:
```js
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:
```js
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:
```js
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__fill` width 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
> `` (`capabilities` → `assets` → `infer`; two `assets` calls for
> the pair tasks)."* (`DELIVERY_REPORT_2026-09-25.md` §3)
The implementation note records that the bar was *fixed* to reach this:
> *"`markState` now sets `traceFill.style.width` from the furthest state reached and toggles
> `is-done`/`is-active`/`is-idle`; `resetUI` resets both. CSS classes already existed
> (`system.css:654-660`)."* (`docs/FINAL_DELIVERY_TODO.md` §4, P5-T02)
and the reset clears it:
```js
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
> `ResultEnvelope` with 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'`).
```js
/* --- 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.js` `SQ.policy` (the architecture page's mock router) still carried
> `built` in its change regex, so that page's **own shipped sample** — *"Where is the built-up
> area?"* — fired `intent.change` on `built` and `intent.quantify` on `area`, and was answered as
> `CHANGE_VQA` with a change-detector specialist. Same defect as `mission.js`, on a second surface.
> `where` is now evaluated first, `built` removed, and `new` counts only outside a `where` question;
> a small `record()` helper keeps the rule list's display order. Regression tests drive the
> **shipped** `SQ.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:
```js
/* `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 reads `vqa` (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:
```js
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:
```js
/* 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_BANK` and `SQ.scene()` are preview-only.** The live driver never reads them: it renders
> `result.answer` verbatim (`mission.js:712-714`). The `synthetic: true` flag and the `platform`
> string 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
```js
/*: 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
```js
/**
* 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 | `` | 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`:
```js
/**
* 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 `/api` to Render lets the
> browser talk to `http://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 in
> `deploy/render/main.py` exist for."* (`frontend/assets/js/live.js:40-43`)
### 7.3 The content-type map, derived from the extension
```js
/*: 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:
```js
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 `geotiff` extension key, so a file named `scene.geotiff` is refused with a message
> that names its own format. The server would accept it as `image/geotiff`. Recorded as a defect in
> the client, not smoothed over.
### 7.4 Uploads are sequential, on purpose
```js
/**
* 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"`
```js
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_task` is 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
```js
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:
```js
/* 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
```js
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`)
> *"`stage` names the step ('upload' | 'infer'), `status` is the HTTP status if a response was
> received, and `code` is 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
```js
/**
* 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:
```js
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/capabilities` declares `requires_pair` and `max_assets` per task, and the page chooses the
> task with the asset count in mind."* (`frontend/assets/js/mission.js:134-135`)
```js
/* 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:
```js
/* 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 +
' — ' + choice.reason + '. Add a T0 frame to run <span class="mono">' +
choice.wanted + '.';
}
```
(`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.js` contains `assetsForTask`; 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
```js
/* 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."*
> (session `HANDOFF_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**:
```text
# 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:
```text
# 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)** | `_headers` revalidates JS/CSS; the EO pair was
> instead given NEW URLs because Cloudflare **concatenates** matching `_headers` rules — see §1.7
> item 9 |"* (`docs/FINAL_DELIVERY_TODO.md` §1.4)
and §1.7 item 9:
> *"**Cloudflare `_headers` CONCATENATES matching rules** instead of overriding them. A specific rule
> under a broad `/assets/img/*` rule produces `Cache-Control: public, max-age=604800, …, max-age=0,
> must-revalidate`, and Chromium honours the **first** `max-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 `_headers` rule."*
> (`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 `200` from Cloudflare's **edge cache** (`CF-Cache-Status:
> HIT`) although the files are deleted; a cache-busted request returns `404`. 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`.** Drive `https://satquery.pages.dev/mission`."*
> (session `HANDOFF_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 `.html` path; 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.xml` *does* exist in the tree (`frontend/sitemap.xml`, 741 B) and
> `robots.txt` carries a `Sitemap:` 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
```text
# 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:
```text
/*
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)** | `_headers` revalidates JS/CSS; the EO pair was
> instead given NEW URLs because Cloudflare **concatenates** matching `_headers` rules"*
> (`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 -I` to confirm header."* (`docs/FINAL_DELIVERY_TODO.md` §4)
> **`UNKNOWN — not established from the available evidence`:** the post-deploy `curl -I` output
> confirming the live `Cache-Control` on `/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 the `capabilities` call — i.e. the **mock** path.
> Diagnosis: the harness drove the query box with `fill_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.value`
> unchanged, while `type_text("Q")` (CDP `Input.insertText`, not focus-gated) inserted fine.
> `fill_input` has **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 read `taskchange…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/2` channels), 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 computed `verdict`. 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()` or `press_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. Use `js()` to set
> `#qtext.value` (plus `input`/`change` events) and/or `type_text()` (CDP `Input.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 `#obsTail` to `ready`."*
> (session `HANDOFF_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:
>
> 1. **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 is `answer_tag` when present, else the intent panel's reading.
> 2. **The intent panel renders a concatenated string** — `taskmodality<…>temporal<…>`.
> Matching `task([a-z_]+)` greedily swallows the whole string; it must be `task([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:
```js
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] + '' + 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 reads `change`, dispatches `change_vqa`)."*
> (session `HANDOFF_NEXT_AGENT.md` §5.3)
The panel preserves both, on purpose:
```js
if (dispatched) rows.push(['reading', intent.task]);
```
(`frontend/assets/js/mission.js:352`)
with the reasoning in the docstring:
> *"The `task` chip then names what was SENT and a `reading` chip 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 in `results_pass3.json` are recomputed from
> the recorded evidence by `recompute_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 with `satquery_result: false` and `role: 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](./02-deployment-topology.md) |
| the controller's nine states in full, and the server-side events | [03 — Request Lifecycle](./03-request-lifecycle.md) §36–§38 |
| the evidence records and the confidence rules the console renders | [06 — Evidence and Confidence](./06-evidence-and-confidence.md) |
| the endpoints the client calls, and their envelopes | [08 — The API Contract](./08-api-contract.md) |
| the health payload, the trace as an observability object, the runbook | [10 — Observability and Operations](./10-observability-and-ops.md) |
|