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 | `<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:

```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 `<nav>`, 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 <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
> `<backend-host>` (`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 | `<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`:

```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 +
    '</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.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** — `task<name>modality<…>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] + '</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 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) |