File size: 106,014 Bytes
6aa2c9f
 
b86eb40
6aa2c9f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
# 07 — Configuration Freeze

**Parent:** [Architecture hub](README.md) · **Status tags:** `IMPLEMENTED` · `VERIFIED` ·
`MEASURED` · `OPEN`

---

## 0. Why this chapter exists, and what it is really about

This is not a chapter about a YAML file. It is a chapter about a **single decision** and its
consequences:

> every tunable number in SatQuery AI lives in **one** registry file, that file is **validated**
> against the frozen architecture at load time, and it is **hashed** so that every measurement the
> project has ever published can be traced back to the exact configuration that produced it.

Those three properties — one place, validated, hashed — are load-bearing for the whole project, and
they are in tension with each other. "One place" makes the config a magnet for edits. "Validated"
means a bad edit fails loudly instead of quietly. "Hashed" means a *good* edit still breaks
something, because it detaches published numbers from the configuration they were measured under.

The consequence a reader must internalise before touching anything in `configs/base.yaml`:

> **Editing the registry MOVES the config hash. Moving the hash INVALIDATES every artifact keyed to
> it.** The frozen value is `78f1e3700da15aa1`. A value that "looks wrong" is therefore not casually
> fixed — the fix has a cost that is not visible at the point of editing.

Everything else in this chapter — the loader pipeline, the twelve validation rules, the finding ids,
the `deploy.yaml` manifest, the frozen-value table, the open-for-tuning list — is elaboration of that
sentence.

| Property | Mechanism | Where |
|---|---|---|
| One place | `configs/base.yaml` is the only file the loader reads | `core/config.py:25` (`DEFAULT_CONFIG`) |
| Validated | `Config.__init__` calls `_validate()`; a bad config raises before any model loads | `core/config.py:46-49`, `:94-222` |
| Hashed | `Config.hash` = first 16 hex chars of a sha256 over the whole registry | `core/config.py:76-80` |

**Grounding note.** Every rule, key, number and finding id in this chapter was read out of the
repository at `core/config.py`, `configs/base.yaml`, `configs/deploy.yaml`, the `docs/` records it
cites, and the test suite. Where a claim could not be established from a file, it is written as
`UNKNOWN — not established from the available evidence` rather than guessed.

---

# Part A — The registry, and the rule

## 1. The registry

The registry is one file:

```
configs/base.yaml          # 294 lines, the authoritative registry
```

`core/config.py` names it once, at module scope:

```python
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG = REPO_ROOT / "configs" / "base.yaml"
```

(`core/config.py:24-25`)

That is the **only** path the loader resolves by default. `load_config()` with no argument reads
exactly this file, and `get_config()` — the singleton most of the codebase imports — reads exactly
this file. The loader **never globs** `configs/*.yaml`. This is not incidental; it is the property
that lets `configs/deploy.yaml` sit beside the registry without joining it (Part E).

### 1.1 What the registry contains

`configs/base.yaml` is organised into **eighteen** top-level blocks. Every one of them is loaded,
validated and hashed:

| Block | Purpose | Representative keys |
|---|---|---|
| `project` | identity, seed, schema version | `name`, `version`, `seed`, `schema_version` |
| `image` | input geometry and tiling policy | `max_pixels`, `tile_size`, `tile_overlap`, `max_tiles`, `top_k_tiles` |
| `optical` | optical band handling | `normalization`, `lower_percentile`, `upper_percentile`, `canonical_channels` |
| `sar` | SAR band handling | `representation`, `clip_min_db`, `clip_max_db`, `canonical_channels` |
| `router` | intent router (encoder + adapter + training) | `model`, `revision`, `max_length`, `embedding_dim`, `num_tasks`, `tasks`, `training.*` |
| `vlm` | SmolVLM contract | `checkpoint`, `revision`, `loader_class_preference`, `processor_longest_edge`, `prompt_must_use_chat_template` |
| `grounding` | RemoteCLIP grounding encoder | `checkpoint_repo`, `image_size`, `resolution_frozen`, `encoder_projected_dim` |
| `grounding_head` | the trainable head over the frozen encoder | `hidden_dim`, `dropout`, `feature_dim`, `positive_confidence_weight`, `decode` |
| `grounding_training` | grounding head training protocol | `learning_rate`, `epochs`, `box_loss_weight`, `giou_loss_weight`, `confidence_loss_weight` |
| `change` | Siamese change detection | `tile_size`, `threshold`, `encoder`, `sa_mode`, `bce_weight`, `dice_weight`, `levir_split.*` |
| `croma` | optical-SAR encoder | `checkpoint_repo`, `image_resolution`, `encoder_dim`, `optical_channels`, `sar_channels`, `modalities_used` |
| `fusion` | optical-SAR fusion head | `input_dim`, `hidden_dim`, `dropout`, `num_classes` |
| `evidence` | evidence aggregation bounds | `max_items`, `coordinate_system_default` |
| `confidence` | calibration switch + artifact | `temperature_scaling`, `calibration_file` |
| `agent` | controller policy | `max_specialists`, `timeout_seconds`, `unload_after_workflow`, `states` |
| `training` | VLM adaptation protocol | `precision`, `vlm_batch_size`, `lora_rank`, `gradient_checkpointing` |
| `evaluation` | evaluation isolation | `immutable_public_test`, `hidden_data_access`, `leakage_split_key` |
| `deployment` | frozen deployment declaration | `platform`, `sdk`, `zerogpu`, `torch_compile`, `gpu_duration_*`, `cpu_mode_required` |

(Every key above is present in `configs/base.yaml`; the block count is eighteen. The table is the
authoritative enumeration.)

## 2. The rule: "no magic numbers in Python"

The very top of `configs/base.yaml` states the rule in three lines:

```yaml
# SatQuery AI — authoritative configuration registry
# Architecture v1.0 (docs/ARCHITECTURE_FREEZE.md)
#
# RULE: no magic numbers anywhere in Python. Everything tunable lives here.
# Every value below is loaded, validated and hashed by core/config.py.
```

(`configs/base.yaml:1-5`)

The `core/config.py` module docstring restates the intent from the loader's side:

> *"One config system. No duplicated constants. Every value in `configs/base.yaml` is loaded,
> validated against the frozen architecture, and hashed so evaluation runs are reproducible."*
> (`core/config.py:1-9`)

### 2.1 Why the rule exists — the two reasons, and they are different

It is tempting to read "no magic numbers" as a style preference. It is not. It buys two specific
properties, and the second is the one that matters most.

**Reason 1 — one place to change.** A number that lives in a module is a number that can be
duplicated. `image.tile_size` is 512. If it were a literal in `preprocessing/tiling.py`, then the VLM
processor's `longest_edge` pin, the grounding tile geometry, and the tiling policy would each carry
their own copy, and the copies would drift. The loader's own comment on the F5-2 guard makes this
explicit — the pin is *"tied to `image.tile_size`"* so that *"the processor cannot silently start
upscaling tiles again"* (`core/config.py:129-130`). A cross-block invariant is only expressible if
both blocks are in the same registry and the loader can compare them.

**Reason 2 — one hash to pin.** This is the reason that turns a style rule into an architecture
decision. Because every value is in one file, the **whole file can be hashed**, and that hash can be
recorded alongside a measurement. `Config.hash` is that hash. `artifacts/change/levir_change_v001/model_metadata.json`
records the hash the shipped change head was trained under, and `scripts/eval_change.py` **refuses to
score** when the current hash drifts from it (exit `3`) — proven by the test docstring at
`tests/test_config.py:46-73`:

> *"The hash is a frozen CONTRACT, not merely a stability property. … A well-meaning config edit
> therefore silently invalidates the project's benchmark."*

If numbers lived in modules, there would be no single artifact to hash, and the reproduction check
would have to enumerate every module — which is the same as not having it. The "one place" rule is
what makes the "one hash" rule possible. They are one decision, not two.

### 2.2 What the rule does NOT cover — deployment state

The rule is "everything **tunable** lives here", and the corollary is that things that are **not
architecture** deliberately do not. The clearest worked example is the asset-upload store, whose
sizing lives in the environment, not the registry:

> *"Read from the environment rather than from `configs/base.yaml` on purpose: adding a key there
> moves `Config.hash` off `78f1e3700da15aa1` and invalidates the frozen Phase-9 benchmark. Sizing is
> deployment state."* (`app/space_app.py`, `_asset_max_files` docstring)

The same reasoning governs `SATQUERY_ASSET_MAX_FILES`, `SATQUERY_ASSET_TTL_S`,
`SATQUERY_MAX_FILE_BYTES`, `SATQUERY_MAX_BODY_BYTES`, `SATQUERY_ASSET_ENABLED` and
`SATQUERY_ASSET_DIR` (`docs/DEPLOYMENT_ARCHITECTURE.md` §4). And owner decision **D-4** records the
same pattern for a *model path*: rather than add `grounding_head.head_path` to `base.yaml`, the
default lives in code as `DEFAULT_HEAD_PATH`, *"because setting it would have moved the frozen config
hash"* (`docs/OWNER_DECISIONS_2026-09-23.md` D-4).

The pattern is a rule of thumb the reader can apply:

> If the value changes the **architecture**, it belongs in the registry and is hashed.
> If the value changes only **where or how large** the deployment is, it belongs in the environment.

Two facts make that rule checkable rather than aspirational: `change.checkpoint_path` and
`grounding_head.head_path` are both **absent** from `configs/base.yaml` (verified by reading all 294
lines), and `core/registry.py` declares them as *optional* config keys resolved at the call site
(`docs/OWNER_DECISIONS_2026-09-23.md` D-4; `docs/DEPLOYMENT_ARCHITECTURE.md` §5.6).

---

# Part B — The loader pipeline

## 3. `load_config` — the five stages

The entry point is `load_config`, 28 lines, and it does exactly five things in a fixed order:

```python
def load_config(
    path: str | Path | None = None,
    overrides: dict[str, Any] | None = None,
) -> Config:
    """Load, merge and validate the configuration registry."""
    cfg_path = Path(path) if path else DEFAULT_CONFIG
    if not cfg_path.exists():
        raise ConfigError(f"config file not found: {cfg_path}")

    with cfg_path.open("r", encoding="utf-8") as fh:
        data = yaml.safe_load(fh) or {}

    if overrides:
        data = _deep_merge(data, overrides)

    # Environment overrides for the two values most likely to differ by host.
    if env_prec := os.environ.get("SATQUERY_PRECISION"):
        data.setdefault("training", {})["precision"] = env_prec
    if env_dev := os.environ.get("SATQUERY_TORCH_COMPILE"):
        data.setdefault("deployment", {})["torch_compile"] = env_dev.lower() == "true"

    return Config(data, source=str(cfg_path))
```

(`core/config.py:246-267`)

The stages, in order, with what each one is for:

| # | Stage | Code | What it establishes |
|---|---|---|---|
| 1 | **Resolve path** | `Path(path) if path else DEFAULT_CONFIG` | explicit path, or the one registry |
| 2 | **Fail fast if absent** | `if not cfg_path.exists(): raise ConfigError` | a missing registry is a named error, not a `FileNotFoundError` traceback |
| 3 | **Parse YAML** | `yaml.safe_load(fh) or {}` | a mapping; empty file → `{}`, never `None` |
| 4 | **Deep-merge overrides** | `_deep_merge(data, overrides)` | programmatic overrides that do not touch the file |
| 5 | **Env overrides** | `SATQUERY_PRECISION`, `SATQUERY_TORCH_COMPILE` | the two values most likely to differ by host |
| 6 | **Construct + validate** | `Config(data, source=...)` | `_validate()` runs in `__init__` |

Two ordering facts are load-bearing and easy to miss:

1. **Environment overrides win over `overrides`.** Stage 5 runs *after* stage 4, and it assigns
   directly into `data`. A caller that passes `overrides={"training": {"precision": "bf16"}}` while
   `SATQUERY_PRECISION=fp16` is set gets `fp16`. The environment is the outermost layer.
2. **Environment overrides still go through validation.** They are written into `data` *before*
   `Config(...)` is constructed, so `SATQUERY_TORCH_COMPILE=true` does **not** quietly enable
   `torch.compile` — it sets `deployment.torch_compile = True`, which the C-8 guard then rejects with
   a `ConfigError`. Likewise `SATQUERY_PRECISION=tf32` is rejected by the C-6 guard. The environment
   is a legitimate override surface, not a way around the guards.

Note also stage 3's `or {}`: `yaml.safe_load` returns `None` for an empty document, and the loader
coerces that to an empty mapping so the subsequent `data.setdefault(...)` calls cannot raise
`AttributeError`. A registry that parses to nothing then fails validation on every required key,
which is the correct outcome — but it fails with *named* errors, not a crash inside the loader.

### 3.1 `_deep_merge` — recursive, right-biased, and shallow-safe

```python
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
    out = dict(base)
    for key, value in override.items():
        if key in out and isinstance(out[key], dict) and isinstance(value, dict):
            out[key] = _deep_merge(out[key], value)
        else:
            out[key] = value
    return out
```

(`core/config.py:33-40`)

The semantics, exhaustively:

- **A new top-level key is copied verbatim** — `override` can *add* blocks the registry lacks. This
  is exercised by `test_unknown_key_in_override_is_kept_not_crashing` (`tests/test_config.py:345-348`):
  *"overrides may add keys; the registry is permissive, the guards are specific."*
- **A nested mapping merges recursively** — `{"fusion": {"hidden_dim": 1024}}` changes only
  `fusion.hidden_dim`, leaving `fusion.input_dim`, `fusion.dropout` and `fusion.num_classes` intact.
  This is the whole point of a deep merge.
- **A non-mapping value replaces** — a scalar, list, `None` or a mapping-over-scalar all overwrite.
  There is no list concatenation and no list merging; a list override is a replacement.
- **`base` is not mutated** — `out = dict(base)` copies the top level, and recursive calls copy each
  nested level they descend into. The caller's `data` dict from `yaml.safe_load` is safe to reuse.

The `isinstance(..., dict)` check on **both** sides is what makes the merge total: if `base[key]` is a
list and `override[key]` is a dict (or vice versa), the branch falls to `else` and the override wins
outright rather than raising.

The consequence for hash stability is direct: `test_hash_changes_when_config_changes`
(`tests/test_config.py:35-38`) asserts that `load_config(overrides={"fusion": {"hidden_dim": 1024}})`
produces a hash **different** from the base config. An override is a config change, and a config
change is a hash change. That is exactly why the registry's `builders=` override (Part D.4) is a
*call-site* mechanism and not a config key.

### 3.2 The two environment overrides, exactly

```python
if env_prec := os.environ.get("SATQUERY_PRECISION"):
    data.setdefault("training", {})["precision"] = env_prec
if env_dev := os.environ.get("SATQUERY_TORCH_COMPILE"):
    data.setdefault("deployment", {})["torch_compile"] = env_dev.lower() == "true"
```

(`core/config.py:261-265`)

| Variable | Writes | Coercion | Validation that then applies |
|---|---|---|---|
| `SATQUERY_PRECISION` | `training.precision` | none (verbatim string) | **C-6**: must be `fp16` / `bf16` / `fp32` |
| `SATQUERY_TORCH_COMPILE` | `deployment.torch_compile` | `.lower() == "true"` → bool | **C-8**: `True` is forbidden |

Three precise behaviours worth recording:

- **Truthiness gate, not presence gate.** `os.environ.get(...)` is used in a walrus with a truthiness
  test, so `SATQUERY_TORCH_COMPILE=""` (empty string) is *ignored* — the registry value stands. Only a
  non-empty value overrides. The comment calls these *"the two values most likely to differ by host"*.
- **The bool coercion is case-insensitive and exact.** `SATQUERY_TORCH_COMPILE=TRUE` → `True`;
  `SATQUERY_TORCH_COMPILE=1` → `False` (because `"1".lower() != "true"`); `SATQUERY_TORCH_COMPILE=yes`
  → `False`. Anything that is not `true` (case-insensitively) becomes `False`, which is the C-8-safe
  direction — an unparsable value cannot *enable* `torch.compile`.
- **`setdefault` makes the write safe on a partial registry.** `data.setdefault("training", {})` only
  creates the block if it is absent, so an override that already provided `training` is not
  clobbered at the block level (only the `precision` leaf is set).

These two are the *only* environment overrides in the loader. Every other environment variable in the
project — device selection, asset store sizing, gateway limits — is read at its point of use, not by
the registry loader. That split is deliberate: `SATQUERY_DEVICE` (Part B.6) must be readable *without
importing torch*, which the loader's validation path must not require.

## 4. `get_config()` — the process-wide singleton

```python
@lru_cache(maxsize=1)
def get_config() -> Config:
    """Process-wide singleton. Import this, do not re-read YAML."""
    return load_config()
```

(`core/config.py:270-273`)

Three properties follow from `lru_cache(maxsize=1)`:

1. **The YAML is read once per process.** The first caller pays the file read and the `_validate()`
   pass; every later caller gets the identical object. There is no re-read and no re-validation.
2. **The hash is computed on demand but the data is fixed.** `hash` is a `@property`
   (`core/config.py:76-80`), so it recomputes the sha256 each access — but it hashes the same frozen
   `_data`, so it is stable. It is not cached, and it does not need to be.
3. **`maxsize=1` with no arguments means exactly one cached entry.** Because `get_config()` takes no
   arguments, the cache can never hold more than one `Config`, which is what "process-wide singleton"
   means here. There is no key to vary.

**The import convention.** The docstring is an instruction, not a description: *"Import this, do not
re-read YAML."* A module that calls `load_config()` in a hot path would re-read the file and
re-validate on every call. `get_config()` is the intended surface, and the registry it returns is the
one every subsystem sees.

**One caveat about `Config`'s "immutable" docstring.** The class docstring calls itself an
*"Immutable, validated, hashable view over the YAML registry"* (`core/config.py:44`). Read precisely,
that means the class exposes **no mutating API**: there is no setter, no `__setitem__`, and the only
way to obtain a plain mutable copy is `as_dict()`, which returns a fresh structure via a JSON
round-trip. The underlying `self._data` dict is reachable through `__getitem__` and `get`, and no
code path in the repository mutates it. The practical guarantee a reader should rely on is therefore:
*treat the returned `Config` as read-only, and if you need a modified registry, call `load_config`
with `overrides` and accept that the hash moves.*

## 5. Access surfaces: `get`, `require`, `__getitem__`, `as_dict`, `seed`

Four access methods and two convenience properties make up the whole read surface.

### 5.1 `get(path, default=None)` — dotted-path traversal

```python
def get(self, path: str, default: Any = None) -> Any:
    """Dotted-path access: cfg.get('croma.image_resolution')."""
    node: Any = self._data
    for part in path.split("."):
        if not isinstance(node, dict) or part not in node:
            return default
        node = node[part]
    return node
```

(`core/config.py:57-64`)

Behaviour, exhaustively:

- The path is split on `"."`; each segment must be a key of a dict.
- A missing segment returns `default` — **never raises**.
- Traversing *through* a non-dict returns `default`. `cfg.get("project.name.oops")` returns `None`,
  because `project.name` is a string and `isinstance(node, dict)` fails on the next iteration.
- `default` defaults to `None`, so `get` cannot distinguish "key absent" from "key present and `None`"
  by itself. That ambiguity is what `require` exists to resolve.
- A single-segment path works: `cfg.get("project")` returns the whole block.

`test_dotted_access_and_require` (`tests/test_config.py:84-89`) pins the basics:
`cfg.get("change.tile_size") == 256`.

### 5.2 `require(path)` — a sentinel, so `None` is a legal value

```python
def require(self, path: str) -> Any:
    sentinel = object()
    value = self.get(path, sentinel)
    if value is sentinel:
        raise ConfigError(f"required config key missing: {path}")
    return value
```

(`core/config.py:66-71`)

The `sentinel = object()` is the whole design. If `require` called `self.get(path)` with the default
`None` and then tested `if value is None`, a key that is legitimately `None` — and the registry has
several, such as `evaluation.official_aggregate_weights: null` — would be reported as *missing*. A
fresh `object()` is unique, cannot appear in YAML, and therefore separates "absent" from "present and
null" exactly.

`require` raises `ConfigError` (a `WorkflowPlanError` subclass), so a missing required key surfaces as
the project's own error taxonomy, not a bare `KeyError`. `test_dotted_access_and_require` pins both
halves: `cfg.require("project.seed") == 42`, and `cfg.require("does.not.exist")` raises `ConfigError`.

### 5.3 `__getitem__` — top-level only, and it raises

```python
def __getitem__(self, key: str) -> Any:
    if key not in self._data:
        raise KeyError(key)
    return self._data[key]
```

(`core/config.py:52-55`)

Unlike `get`, `cfg["x"]` is a **top-level** lookup that raises `KeyError` when absent. It does not
understand dotted paths — `cfg["croma.image_resolution"]` raises `KeyError`, because there is no
top-level key with that literal name. The two surfaces are deliberately different: `[]` is for
"this must exist and I want it to fail loudly", `get` is for "this may not exist and I have a
fallback".

### 5.4 `as_dict()` — a JSON-safe deep copy

```python
def as_dict(self) -> dict[str, Any]:
    return json.loads(json.dumps(self._data, default=str))
```

(`core/config.py:73-74`)

The `json.dumps(..., default=str)` → `json.loads(...)` round-trip does two things: it produces a
**fresh** structure (no aliasing of the registry's nested dicts), and it coerces any non-JSON value
to its string form via `default=str`. `test_as_dict_roundtrips_to_json_safe_types`
(`tests/test_config.py:332-337`) asserts the result `json.dumps` cleanly and that
`payload["project"]["name"] == "satquery-ai"`. This is the surface used when a registry snapshot has
to be embedded in a response or a trace.

### 5.5 `seed` — the one convenience property for reproducibility

```python
@property
def seed(self) -> int:
    return int(self.get("project.seed", 42))
```

(`core/config.py:82-84`)

Note the fallback: if `project.seed` were absent, `seed` returns `42` rather than raising. The
registry declares `project.seed: 42` (`configs/base.yaml:10`), so the fallback is a safety net, not
the source of truth. `test_seed_is_int` (`tests/test_config.py:92-93`) pins `42`.

## 6. `device_preference` and `SATQUERY_DEVICE`

```python
@property
def device_preference(self) -> str:
    override = os.environ.get("SATQUERY_DEVICE")
    if override:
        return override
    return "cuda" if _torch_cuda_available() else "cpu"
```

(`core/config.py:86-91`)

```python
def _torch_cuda_available() -> bool:
    try:
        import torch  # noqa: PLC0415
        return bool(torch.cuda.is_available())
    except Exception:
        return False
```

(`core/config.py:237-243`)

Four facts about this property:

1. **Environment wins.** If `SATQUERY_DEVICE` is set to any non-empty string, that string is returned
   **verbatim** — no validation, no case-folding, no normalisation at this layer. The value is
   passed through as the caller wrote it.
2. **Otherwise it probes torch, and the probe is defensive.** `_torch_cuda_available()` wraps the
   import *and* the call in a single `try/except Exception` and returns `False` on any failure. On a
   machine with no torch installed — which is the CPU development environment and the base CI image —
   the property returns `"cpu"` rather than raising `ModuleNotFoundError`. The comment `# noqa: PLC0415`
   records that the local import is intentional: it keeps `import core.config` cheap.
3. **It is not part of the hash.** `device_preference` is a computed property over `os.environ` and a
   runtime probe. It does not read or write `self._data`, so it cannot move `Config.hash`. This is
   why the device can differ per host without invalidating a single published number.
4. **It is deliberately not read on the metadata path.** `docs/DEPLOYMENT_ARCHITECTURE.md` §3.3
   records that `build_serving_registry()` *"via `Config.device_preference`, a `@property` that calls
   `_torch_cuda_available()`"* was one of two things that *did* import torch on the health path, and
   that both were removed. The test
   `test_the_metadata_path_does_not_import_torch` runs the import in a subprocess and asserts
   `torch imported: False`. So the property exists, is correct, and is simply not on the path that
   must stay torch-free.

**A note on validation of the served value.** `docs/DEPLOYMENT_ARCHITECTURE.md` §4 records finding
**F-8**: the *served* device value is validated downstream (the contract publishes a closed set
`cpu | cuda | mps | null`, and an unrecognised value yields `null` rather than being echoed). That
validation lives in the deployment adapter, not in `Config.device_preference`. The distinction is
worth keeping straight: the config layer passes the string through; the serving layer narrows it.

## 7. `ConfigError` and the error taxonomy

```python
class ConfigError(WorkflowPlanError):
    code = "config_error"
    user_message = "The system configuration is invalid."
```

(`core/config.py:28-30`)

A configuration failure is a **`WorkflowPlanError`** subclass carrying a machine `code`
(`config_error`) and a client-safe `user_message`. It is raised from exactly three places:

| Site | Trigger |
|---|---|
| `load_config` | the registry file does not exist (`config file not found: …`) |
| `require` | a required key is missing (`required config key missing: …`) |
| `_validate` | one or more frozen-architecture guards failed (a multi-line list) |

Because `_validate` collects *all* errors and raises **one** `ConfigError` listing every failure, a
bad registry reports every problem in a single startup message rather than one per run. That
collection behaviour is covered in Part C.13.

---

# Part C — Every validation rule

## 8. How `_validate` is structured

```python
def _validate(self) -> None:
    errors: list[str] = []
    ...
    if errors:
        raise ConfigError(
            "configuration failed frozen-architecture validation:\n  - "
            + "\n  - ".join(errors)
        )
```

(`core/config.py:94-95`, `:218-222`)

Every check **appends** to `errors`; nothing raises mid-pass. The single raise at the end means the
failure message is a bulleted list of every violated rule at once. `_validate` runs from
`Config.__init__` (`core/config.py:46-49`), so **a `Config` object that exists is a `Config` object
that passed every rule** — there is no unvalidated state to hold.

The rules are grouped by the finding they encode. The table below is the map; each rule gets its own
sub-section.

| # | Rule | Finding | Source |
|---|---|---|---|
| C.1 | `croma.image_resolution` is an int multiple of 8 | **C-7** | `core/config.py:97-105` |
| C.2 | `training.precision ∈ {fp16, bf16, fp32}` | **C-6** | `core/config.py:107-112` |
| C.3 | `deployment.torch_compile` must not be `True` | **C-8** | `core/config.py:114-119` |
| C.4 | `vlm.processor_longest_edge` is an int ≥ 1 and ≤ `image.tile_size` | **F5-2** (C-3) | `core/config.py:121-142` |
| C.5 | `vlm.prompt_must_use_chat_template` must be `True` | **F5-3** | `core/config.py:144-150` |
| C.6 | `fusion.input_dim == 3·768 + 12 + 2` | **C-1** | `core/config.py:152-162` |
| C.7 | `croma.optical_channels == 12`, `croma.sar_channels == 2` | **C-1** | `core/config.py:164-168` |
| C.8 | `grounding_head.feature_dim == 4 · grounding.encoder_projected_dim` | **P7-1** | `core/config.py:170-196` |
| C.9 | `router.tasks` includes `unsupported` | router ontology | `core/config.py:198-201` |
| C.10 | `router.num_tasks == len(router.tasks)` | router ontology | `core/config.py:202-206` |
| C.11 | `change.encoder` present; `change.sa_mode ∈ {BAM, PAM}` | change config | `core/config.py:208-212` |
| C.12 | `image.top_k_tiles ≤ image.max_tiles` | tiling policy | `core/config.py:214-216` |

## 9. C-7 — `croma.image_resolution` must be a multiple of 8

```python
# --- C-7: CROMA image_resolution must be a multiple of 8 -----------
croma_res = self.get("croma.image_resolution")
if croma_res is None:
    errors.append("croma.image_resolution is required")
elif not isinstance(croma_res, int) or croma_res % 8 != 0:
    errors.append(
        f"croma.image_resolution must be an int multiple of 8 "
        f"(CROMA asserts image_resolution % 8 == 0); got {croma_res!r}"
    )
```

(`core/config.py:97-105`)

**What it checks.** Three things at once: the key is present, it is an `int`, and it is divisible by
8.

**Why.** CROMA's own constructor contains `assert image_resolution % 8 == 0`
(`docs/PHASE0_CONTRACT_VALIDATION.md` §1.1). The `8` is CROMA's patch size: `num_patches =
int((image_resolution / 8) ** 2)`. A resolution that is not a multiple of 8 does not merely produce a
different patch count — it violates an assertion **inside the vendored model**, which means the
failure would surface at model construction, after the config has been accepted and the process has
started. The loader moves that failure to startup, and gives it a name.

The registry's value is `image_resolution: 120` with the comment *"finding C-7: `image_resolution %
8 == 0`. Native 120 → 225 patches."* (`configs/base.yaml:208-209`). The arithmetic:
`120 / 8 = 15`, and `15² = 225` patches — matching the verified `num_patches = 225 (15×15)` at
`docs/PHASE0_CONTRACT_VALIDATION.md` §1.3.

**Why the type check matters.** `not isinstance(croma_res, int)` rejects a float such as `120.0` even
though `120.0 % 8 == 0.0` would be falsy-safe — because CROMA's `int(...)` cast and its assertion are
written against an integer, and a float would change `num_patches`' arithmetic. It also rejects
`True`, since `isinstance(True, int)` is `True` in Python but `True % 8 != 0`, so the guard still
catches it.

**Pinned by:** `test_croma_resolution_must_be_multiple_of_8` (rejects `121`) and
`test_croma_native_resolution_is_valid` (accepts `120`, asserts `% 8 == 0`) —
`tests/test_config.py:123-131`.

## 10. C-6 — `training.precision` must be a viable precision, and T4 makes it `fp16`

```python
# --- C-6: precision must be viable on the target accelerator -------
precision = self.get("training.precision")
if precision not in {"fp16", "bf16", "fp32"}:
    errors.append(
        f"training.precision must be fp16|bf16|fp32, got {precision!r}"
    )
```

(`core/config.py:107-112`)

**What it checks.** Membership in a closed set of three strings. Note there is **no** type guard
beyond set membership — a non-string simply fails to be in the set and is rejected.

**Why three, and why `fp16` is the frozen choice.** The Phase-0 contract validation measured the
training accelerator and found the plan's declared `bf16` unusable:

| Source | Statement |
|---|---|
| `docs/PHASE0_CONTRACT_VALIDATION.md` §7 (contradiction register) | C-6: plan said `precision: bf16`; *"T4 is SM 7.5, no bf16 tensor cores"*; resolution *"fp16 + AMP default"* |
| `configs/base.yaml:254` (block header) | *"Training (finding C-6: T4 is SM 7.5 → fp16, NOT bf16)"* |
| `configs/base.yaml:257` | `precision: fp16` |
| `docs/ARCHITECTURE_FREEZE.md` §4 | `training.precision` = `fp16`, reason *"T4 = SM 7.5, no bf16 tensor cores (C-6)"* |

The rationale in one sentence: **bf16 requires hardware support that the target accelerator does not
have.** NVIDIA's T4 is compute capability **SM 7.5**, which predates bf16 tensor cores; requesting
bf16 there is not "slower", it is unsupported. `fp16` is the mixed-precision format that the T4 *does*
accelerate, so `fp16` is the frozen default rather than a preference.

`fp32` remains in the allowed set because a full-precision run is a legitimate (if slower) choice and
is not architecturally invalid; `bf16` remains in the set because the guard is about *internal
consistency of the value*, not about forbidding the format — the host-specific consequence is
documented, and the loader's job is to reject typos and invented formats like `tf32` or `bf16-amp`.

**Pinned by:** `test_precision_must_be_known` (rejects `tf32`) and `test_precision_defaults_to_fp16_for_t4`
(asserts `fp16`) — `tests/test_config.py:175-182`.

## 11. C-8 — `deployment.torch_compile` is forbidden

```python
# --- C-8: ZeroGPU forbids torch.compile ----------------------------
if self.get("deployment.torch_compile") is True:
    errors.append(
        "deployment.torch_compile=true is forbidden: ZeroGPU does not "
        "support torch.compile (finding C-8)"
    )
```

(`core/config.py:114-119`)

**What it checks.** `is True` — an identity test against the boolean `True`. Not truthiness. A value
of `1`, `"true"` (string) or `"yes"` does **not** trip this guard, because none of them *is* `True`.
The registry's `torch_compile: false` (`configs/base.yaml:287`) is a real YAML boolean, so the
comparison is boolean-to-boolean.

**Why.** `docs/PHASE0_CONTRACT_VALIDATION.md` §5.1 records the verified ZeroGPU constraint directly:
*"`torch.compile` | **not supported** (use AoTI, torch 2.8+)"*. Finding C-8's resolution states:
*"`torch.compile` is forbidden throughout."* Enabling it on a ZeroGPU Space is not a performance
regression — it is a configuration the platform does not support, so the loader makes it impossible
to ship.

The registry carries the same statement inline:

```yaml
deployment:
  platform: huggingface-spaces
  sdk: gradio
  zerogpu: true
  # finding C-8: ZeroGPU does not support torch.compile. Never enable.
  torch_compile: false
```

(`configs/base.yaml:282-287`)

**Interaction with the environment override.** As noted in Part B.3.2,
`SATQUERY_TORCH_COMPILE=true` sets this key to `True` and is then rejected here. The guard is the
backstop for the override, not a bypass of it.

**Pinned by:** `test_torch_compile_is_forbidden_on_zerogpu` — `tests/test_config.py:185-187`. The
deploy validator independently requires `torch_compile: False` in both `base.yaml` and `deploy.yaml`
(`scripts/validate_deploy_config.py`, `REQUIRED_BOOL`).

## 12. F5-2 — the VLM processor must not upscale our tiles

This is the longest guard in the file, and its comment block is the reason: it encodes a *measurement*,
not an assumption.

```python
# --- C-3 / F5-2: the VLM processor must not upscale our tiles -----
#
# MEASURED (docs/PHASE5_VLM_CONTRACT.md): the processor's default
# longest_edge is 2048. A 512 px tile is upscaled 4x and then split by
# do_image_splitting into 4x4 sub-images + 1 overview = 17 images and
# 1142 prompt tokens, versus 1 image when pinned. The plan estimated a
# 4x overrun; the real figure is ~17x.
#
# Tying this to image.tile_size makes the pin a control rather than a
# comment: the processor cannot silently start upscaling tiles again.
proc_edge = self.get("vlm.processor_longest_edge")
tile_size = self.get("image.tile_size")
if not isinstance(proc_edge, int) or proc_edge < 1:
    errors.append(
        f"vlm.processor_longest_edge must be an int >= 1, got {proc_edge!r}"
    )
elif isinstance(tile_size, int) and proc_edge > tile_size:
    errors.append(
        f"vlm.processor_longest_edge={proc_edge} exceeds "
        f"image.tile_size={tile_size}; the processor would upscale every "
        f"tile and then split it into ~17 sub-images (finding F5-2)"
    )
```

(`core/config.py:121-142`)

**What it checks.** Two things: `processor_longest_edge` is a positive integer, and — when
`image.tile_size` is an int — it does not exceed the tile size.

**Why — the measurement.** The VLM processor's default `longest_edge` is **2048**. A 512 px tile is
therefore upscaled 4×, and then `do_image_splitting=True` cuts the 2048 px image into 4×4 = 16
sub-images of 512 px each, plus one overview image. The measured result
(`docs/PHASE5_VLM_CONTRACT.md` §"The headline finding"):

```
INPUT: one 512×512 RGB tile

DEFAULT  (size.longest_edge = 2048, do_image_splitting = True)
    pixel_values  (1, 17, 3, 512, 512)     <- 17 images
    input_ids     (1, 1142)                <- 1142 tokens

PINNED   (size = {"longest_edge": 512})
    pixel_values  (1,  1, 3, 512, 512)     <-  1 image
```

The plan's finding **C-3** predicted a **4×** cost overrun. The measured figure is **~17×**, and the
same document states why the difference is not academic: *"the difference between '4×' and '17×' is
the difference between a cost you absorb and a cost that makes the deployment quota non-viable."* At
1142 prompt tokens per call, an un-pinned processor *"would spend a user's entire daily quota on one
or two queries"* against the 5 GPU-minutes/day free tier.

**Why the guard is *tied to the tile size* rather than merely asserting `512`.** The comment says it
plainly: *"Tying this to `image.tile_size` makes the pin a control rather than a comment: the
processor cannot silently start upscaling tiles again."* A hard-coded `512` check would pass even if
someone later changed `image.tile_size` to 1024, leaving the processor at 512 — now *downscaling*.
The relational check keeps the two numbers in the relationship the architecture requires, which is
only possible because both live in one registry.

**The `isinstance(tile_size, int)` guard on the second branch** is a defensive detail: if
`image.tile_size` were missing or non-int, the comparison is skipped rather than raising a `TypeError`
inside the validator. The first branch still enforces positivity on `proc_edge` independently.

**The registry values.** `processor_longest_edge: 512` with the F5-2 comment and the measured
before/after, and the note *"This value MUST be set explicitly on the processor at construction
time."* (`configs/base.yaml:98-105`). `image.tile_size: 512` (`configs/base.yaml:18`). The two are
equal, which is the strongest form of the constraint.

**Why `do_image_splitting` stays `true`.** The registry explains: *"Kept true so genuinely oversized
inputs still split, but at 512 a tile no longer exceeds `max_image_size` (also 512) and splitting does
not trigger."* (`configs/base.yaml:106-108`). The pin removes the *upscaling* that triggers splitting;
it does not remove the splitting capability.

**Pinned by** four tests (`tests/test_config.py:137-162`): `test_processor_longest_edge_must_be_positive_int`
(rejects `0`), `test_processor_must_not_upscale_tiles` (asserts `≤`), `test_processor_upscaling_is_rejected`
(rejects `4096`), and `test_processor_pin_is_tied_to_tile_size` (asserts **equality** with
`image.tile_size`). And the pin is confirmed in the real load path, not just the probe:
`docs/PHASE5_VLM_CONTRACT.md` records `max_images_seen = 1` as *"the F5-2 confirmation: the pin
survives model construction, processor construction, and generation. Unpinned it would read 17."*

## 13. F5-3 — prompts must go through the chat template

```python
# --- F5-3: prompts must go through the chat template --------------
if self.get("vlm.prompt_must_use_chat_template") is not True:
    errors.append(
        "vlm.prompt_must_use_chat_template must be true: SmolVLM raises "
        "ValueError on prompts lacking one <image> token per image "
        "(finding F5-3)"
    )
```

(`core/config.py:144-150`)

**What it checks.** `is not True` — so anything other than the boolean `True` (including a missing
key, `1`, `"true"`, or `None`) is rejected. This is the strictest form in the file, and deliberately
so: the setting is not a tunable, it is an invariant.

**Why.** SmolVLM requires **one `<image>` token per image** in the prompt. A hand-written prompt
string lacks them and raises `ValueError`. `docs/PHASE5_VLM_CONTRACT.md` §F5-3 records the finding,
and the registry records the fix as a permanent fact rather than a rediscovery:

```yaml
# Finding F5-3: SmolVLM requires one <image> token per image in the prompt.
# Hand-written prompt strings raise ValueError; always build prompts through
# processor.apply_chat_template(). Recorded so it is not rediscovered.
prompt_must_use_chat_template: true
```

(`configs/base.yaml:109-112`)

**Why it is a *config* guard at all.** The value does not parametrise anything — the prompt builder
either uses the chat template or it does not. Making it a registry key with a hard `is not True`
check converts an implementation convention into an enforced invariant: *"the chat-template path must
not be disableable"* (`tests/test_config.py:165-169`). A future refactor cannot quietly introduce a
hand-built prompt path, because the registry declares the invariant and the loader enforces it.

**Pinned by:** `test_chat_template_is_required` (rejects `False`) — `tests/test_config.py:165-169`.

## 14. C-1 — `fusion.input_dim` must match the verified CROMA concatenation

```python
# --- C-1: fusion input dim must match the verified concatenation ---
expected = self._expected_fusion_dim()
declared = self.get("fusion.input_dim")
if expected is not None and declared != expected:
    errors.append(
        f"fusion.input_dim={declared} disagrees with the verified CROMA "
        f"concatenation ({expected} = 3*encoder_dim + optical_channels + "
        f"sar_channels). CROMA emits optical/SAR/joint GAP vectors; the "
        f"availability mask is consumed by the fusion head, not by CROMA "
        f"(finding C-1)."
    )
```

(`core/config.py:152-162`)

**What it checks.** `fusion.input_dim` equals `_expected_fusion_dim()`, when that expectation is
computable.

### 14.1 The expected value is *derived*, not hard-coded

```python
def _expected_fusion_dim(self) -> int | None:
    dim = self.get("croma.encoder_dim")
    mods = self.get("croma.modalities_used") or []
    opt = self.get("croma.optical_channels")
    sar = self.get("croma.sar_channels")
    if None in (dim, opt, sar) or not mods:
        return None
    return len(mods) * int(dim) + int(opt) + int(sar)
```

(`core/config.py:224-231`)

The formula is `len(modalities_used) · encoder_dim + optical_channels + sar_channels`. It returns
`None` — meaning "cannot compute, skip the check" — if any input is missing. This is why the guard is
`if expected is not None and declared != expected`: a partial registry produces other, more specific
errors (missing channel counts, missing `encoder_dim`) rather than a spurious dimension mismatch.

### 14.2 The arithmetic, with the registry's values

| Term | Source | Value |
|---|---|---|
| `len(croma.modalities_used)` | `configs/base.yaml:214` → `[optical, sar, joint]` | **3** |
| `croma.encoder_dim` | `configs/base.yaml:210` | **768** |
| `croma.optical_channels` | `configs/base.yaml:211` | **12** |
| `croma.sar_channels` | `configs/base.yaml:212` | **2** |

```
expected = 3 · 768 + 12 + 2
         = 2304   + 14
         = 2318
```

And the registry declares `fusion.input_dim: 2318` (`configs/base.yaml:219`), with the derivation
written inline:

```yaml
fusion:
  # concatenated dim = 3 * 768 + 12 + 2 = 2318
  # finding C-1: the availability mask is consumed HERE, not by CROMA.
  input_dim: 2318
```

### 14.3 Why — C-1 is the project's most consequential contract finding

`docs/PHASE0_CONTRACT_VALIDATION.md` §1.2 records finding **C-1 (P0)**. CROMA's `ViT.__init__` builds
its patch embedding as a *fixed-shape* `nn.Linear`:

```python
pixels_per_patch = int(self.patch_size * self.patch_size * in_channels)
self.linear_input = nn.Linear(pixels_per_patch, self.dim)
```

and its `forward` signature is `def forward(self, SAR_images=None, optical_images=None):` — **no mask
parameter, no missing-channel token, no mask argument anywhere**. The finding is therefore:

> *"CROMA **cannot** consume a channel-availability mask. The master plan §19 … is **not
> implementable against the official code.**"*

The **resolution** is what fixes the fusion dimension: zero-fill the optical/SAR tensors to the
canonical channel counts (which *is* compatible with the fixed `Linear`), and route the availability
mask to the **fusion head** as a first-class input. The verified concatenation
(`docs/ARCHITECTURE_FREEZE.md` §2.5) is:

```
optical_GAP      (B, 768)
SAR_GAP          (B, 768)
joint_GAP        (B, 768)
optical_mask     (B, 12)     <- availability, from sensor adapter
sar_mask         (B,  2)     <- availability, from sensor adapter
                 ---------
concat           (B, 2318)
```

The three `GAP` vectors come from CROMA's verified six-key forward output
(`docs/PHASE0_CONTRACT_VALIDATION.md` §1.3); the two masks come from the sensor adapter, **not** from
CROMA. The guard's error message names exactly this: *"the availability mask is consumed by the
fusion head, not by CROMA (finding C-1)."*

### 14.4 Why it is enforced rather than documented

If `fusion.input_dim` disagreed with the real concatenation width, the fusion head's first `Linear`
would have the wrong `in_features`. PyTorch would raise a shape error — but only when the mismatched
tensor reached that layer, which is *after* CROMA has run and *after* the fusion head has been
constructed and loaded. The loader's check moves that failure to startup, before any model is
touched, and its message contains the derivation so the reader can see *why* 2318 is the right
number rather than merely that 2318 is expected.

**Pinned by:** `test_fusion_dim_matches_verified_croma_concatenation` (recomputes the formula and
asserts `2318`, then asserts `fusion.input_dim == expected`) and `test_fusion_dim_mismatch_is_rejected`
(rejects `999`) — `tests/test_config.py:99-110`.

## 15. C-1 (continued) — the fixed channel counts, 12 and 2

```python
# --- channel counts must match CROMA's fixed inputs ---------------
if self.get("croma.optical_channels") != 12:
    errors.append("croma.optical_channels must be 12 (CROMA s2_channels is fixed)")
if self.get("croma.sar_channels") != 2:
    errors.append("croma.sar_channels must be 2 (CROMA s1_channels is fixed)")
```

(`core/config.py:164-168`)

**What it checks.** Exact equality with `12` and `2`. Two independent guards, each naming the CROMA
attribute it mirrors.

**Why — these are *fixed*, not defaulted.** `docs/PHASE0_CONTRACT_VALIDATION.md` §1.1 quotes CROMA's
source directly:

| Attribute | Verified value | Evidence (verbatim) |
|---|---|---|
| SAR channels | **fixed 2** | `self.s1_channels = 2  # fixed at 2 SAR backscatter channels` |
| Optical channels | **fixed 12** | `self.s2_channels = 12  # fixed at 12 multispectral optical channels` |

Because CROMA's patch embedding is a fixed-shape `Linear` over
`patch_size² · in_channels`, the channel count is not a hyperparameter — it is baked into the weight
shapes. Feeding 8 optical channels to a model whose `linear_input` expects 12 is a shape error at the
input layer. The guards prevent a config from claiming a channel count the frozen encoder cannot
accept.

The registry states the same, with the canonical-order note:

```yaml
optical:
  normalization: percentile
  lower_percentile: 2
  upper_percentile: 98
  # CROMA expects exactly 12 optical channels.
  canonical_channels: 12
...
sar:
  representation: db
  clip_min_db: -30
  clip_max_db: 5
  # CROMA expects exactly 2 SAR channels (VV, VH).
  canonical_channels: 2
```

(`configs/base.yaml:26-38`)

Note the relationship to the `optical` and `sar` blocks: they each carry a `canonical_channels` key
**and** the `croma` block carries `optical_channels` / `sar_channels`. The guards check the **`croma`**
values, because those are the ones the fusion dimension formula consumes. The `optical` / `sar`
`canonical_channels` values are the sensor-adapter's statement of the same fact from its side.

`docs/ARCHITECTURE_FREEZE.md` §2.5 adds the operational detail: optical input is *"12 channels,
zero-filled to canonical order"* and SAR input is *"2 channels, zero-filled to canonical order"*.

**Pinned by:** `test_croma_channel_counts_are_pinned` (rejects `optical_channels: 8` and
`sar_channels: 3`) — `tests/test_config.py:113-117`.

## 16. P7-1 — `grounding_head.feature_dim == 4 · encoder_projected_dim`

This is the guard whose *enforcement* is the point, so it gets the longest comment in the file after
F5-2.

```python
# --- Phase 8: the head's assembled feature must match the encoder ---
#
# Per-cell feature = concat([patch, text, patch*text, global_pool]),
# i.e. 4 x the encoder's PROJECTED dim. Measured as 512, NOT the 768
# transformer width (finding P7-1): `visual.proj` is (768, 512).
#
# A mismatch here is a SILENT shape error. torch only raises at the
# similarity step, by which point the patch features have already been
# computed and cached — so the failure surfaces far from its cause.
#
# grounding.encoder_projected_dim is declared in config so this guard
# needs no torch import; specialists/grounding/remoteclip.py asserts
# the same value against the real model at load time.
projected = self.get("grounding.encoder_projected_dim")
head_dim = self.get("grounding_head.feature_dim")
if not isinstance(projected, int) or projected < 1:
    errors.append(
        f"grounding.encoder_projected_dim must be a positive int, "
        f"got {projected!r}"
    )
elif not isinstance(head_dim, int) or head_dim != 4 * projected:
    errors.append(
        f"grounding_head.feature_dim={head_dim} but the frozen encoder "
        f"projects to {projected}, so the assembled per-cell feature is "
        f"{4 * projected}-d. Expected feature_dim == 4 * "
        f"grounding.encoder_projected_dim (finding P7-1)."
    )
```

(`core/config.py:170-196`)

**What it checks.** `grounding.encoder_projected_dim` is a positive int, and
`grounding_head.feature_dim == 4 · encoder_projected_dim`.

### 16.1 The arithmetic

| Term | Source | Value |
|---|---|---|
| `grounding.encoder_projected_dim` | `configs/base.yaml:142` | **512** |
| `grounding_head.feature_dim` | `configs/base.yaml:158` | **2048** |

```
expected = 4 · 512 = 2048      ✓ matches the declared feature_dim
```

### 16.2 Why the factor is 4, and why 512 and not 768

The per-cell feature is the concatenation of **four** vectors:

```
per-cell = concat([patch, text, patch·text, global_pool])   ->  4 · projected_dim
```

The registry records the definition and the "4×" (`configs/base.yaml:150-158`), and the freeze states
the head structure. The trap the comment names is **768 vs 512**:

> *"the 768-vs-512 distinction is the one that bites. `visual.positional_embedding` is 768 wide and
> `visual.proj` is (768, 512); the embeddings the text tower can be compared against are the
> PROJECTED ones. Using 768 anywhere here would be a shape error that torch would only surface at the
> similarity computation, by which point the patch features have already been computed and cached."*
> (`specialists/grounding/remoteclip.py:15-19`)

So RemoteCLIP ViT-B/32 has transformer width **768** but projects to **512**. The *comparison space*
is 512-dimensional, so the head must assemble 4·512 = 2048, not 4·768 = 3072. `4 · 768 = 3072` would
be the "obvious" wrong answer, and it is a shape error rather than a type error — which is exactly
why the loader refuses it.

### 16.3 Why it is ENFORCED rather than documented — the silence argument

This is the load-bearing sentence in the guard's comment:

> *"A mismatch here is a SILENT shape error. torch only raises at the similarity step, by which point
> the patch features have already been computed and cached — so the failure surfaces far from its
> cause."*

Unpack the failure mode:

1. The head is constructed with `feature_dim = 3072` (say).
2. The encoder runs and produces patch features projected to 512.
3. The head assembles a 3072-wide feature from 512-wide inputs — this *may* still construct, if the
   assembly pads or the mismatch lands at a `Linear` whose `in_features` was set from `feature_dim`.
4. The error does not fire at construction. It fires at the **similarity step**, after every patch
   feature has been computed and cached.

The consequences that make this worse than an ordinary shape error:

- **It is far from its cause.** The traceback points at the similarity computation inside the encoder
  or head, not at the config value that was wrong.
- **It happens after expensive work.** Patch features are already computed and cached by the time it
  raises, so a training or inference run has paid the cost before failing.
- **It is easy to misattribute.** A reader seeing a shape error in the similarity step is likely to
  suspect the encoder or the head's forward, not a single integer in `configs/base.yaml`.

Enforcing the relation in the loader converts a distant, expensive, misattributable runtime failure
into a **startup** failure whose message contains the expected value and the derivation.

### 16.4 The guard needs no torch import — and a second assertion backs it at load

The comment records the design that makes this possible:

> *"`grounding.encoder_projected_dim` is declared in config so this guard needs no torch import;
> `specialists/grounding/remoteclip.py` asserts the same value against the real model at load time."*

So there are **two** enforcement points, and they check different things:

| Point | Checks | Against |
|---|---|---|
| `core/config.py` P7-1 guard | `feature_dim == 4 · encoder_projected_dim` | the **registry's declaration** |
| `specialists/grounding/remoteclip.py` | `visual.proj.shape[1] == VERIFIED_PROJECTED_DIM` | the **real loaded model** |

The config guard is arithmetic over declared numbers, so it is torch-free and runs at startup. The
specialist guard checks the *actual* model, so it catches a declaration that is internally consistent
but wrong about the checkpoint:

```python
#: Measured, not assumed. Asserted against at load time.
VERIFIED_PATCH_SIZE = 32
VERIFIED_TRANSFORMER_WIDTH = 768
VERIFIED_PROJECTED_DIM = 512
VERIFIED_PARAMETERS = 151_277_313
```

(`specialists/grounding/remoteclip.py:38-42`)

```python
proj = getattr(visual, "proj", None)
if proj is not None and proj.shape[1] != VERIFIED_PROJECTED_DIM:
    raise ModelLoadError(
        f"visual.proj maps to {proj.shape[1]}, expected "
        f"{VERIFIED_PROJECTED_DIM}",
        specialist="grounding",
    )
```

(`specialists/grounding/remoteclip.py:195-201`)

The two together mean: a registry that is self-consistent passes the config guard, and a model whose
`visual.proj` disagrees with the declaration fails at load. Neither alone is sufficient.

**Pinned by:** `test_head_feature_dim_matches_the_frozen_encoder` (imports `VERIFIED_PROJECTED_DIM`
and asserts `feature_dim == 4 · VERIFIED_PROJECTED_DIM`) and `test_head_feature_dim_mismatch_is_rejected`
(rejects `768`) — `tests/test_config.py:273-288`.

### 16.5 The neighbouring `grounding_head` value, and why it is *not* guarded

`grounding_head.positive_confidence_weight: 20.0` is in the same block but is **not** a validated
invariant — it is a tuning value. Its rationale is documented in the registry rather than enforced:

```yaml
# Objectness BCE sees 1 positive cell out of 49. Unweighted, the optimum is
# "no object" everywhere; this weight is what stops that collapse.
positive_confidence_weight: 20.0
```

(`configs/base.yaml:159-161`)

The distinction is the point of Part C: **relations that encode a contract are enforced; values that
encode a tuning choice are documented.** The test suite pins the *direction* of the tuning value
(`test_head_positive_confidence_weight_is_set` asserts `> 1.0`) without pinning the number, which is
the right granularity for a tunable.

## 17. Router ontology — `unsupported` must exist, and the count must match

```python
# --- router ontology ----------------------------------------------
tasks = self.get("router.tasks") or []
if "unsupported" not in tasks:
    errors.append("router.tasks must include 'unsupported'")
if self.get("router.num_tasks") != len(tasks):
    errors.append(
        f"router.num_tasks={self.get('router.num_tasks')} does not match "
        f"router.tasks length ({len(tasks)})"
    )
```

(`core/config.py:198-206`)

**What it checks.** Two invariants: the task list contains the literal `unsupported`, and the declared
count equals the list's length.

**Why `unsupported` must be present.** `unsupported` is the router's explicit *"this is not a
satellite-imagery question"* class (`docs/ARCHITECTURE_FREEZE.md` §2.1 lists the six classes as
`vqa | caption | grounding | change | optical_sar | unsupported`). It is the target of the
below-threshold fallback: *"Below `router.confidence_threshold` → deterministic lexical fallback →
else `unsupported`."* Removing it would leave the router with no way to decline a question, which
would force every query into a specialist — the exact failure the class exists to prevent. The guard
makes that impossible to configure away.

Note the `or []` on the first line: a missing or null `router.tasks` is treated as an empty list, so
`"unsupported" not in []` is `True` and the guard fires with the right message, rather than raising
`TypeError` on `in None`.

**Why `num_tasks` must equal `len(tasks)`.** `num_tasks` is the classifier's output width — the
number of logits the adapter's task head emits. The registry declares it **separately** from the list
because the two are consumed in different places:

```yaml
router:
  ...
  num_tasks: 6
  tasks:
    - vqa
    - caption
    - grounding
    - change
    - optical_sar
    - unsupported
```

(`configs/base.yaml:59-66`)

Two sources for one number is a drift hazard: a task list of six with a head of five would be a shape
mismatch between the ontology and the classifier, and it would fail only when the head was built. The
guard keeps them equal, so the ontology and the head width cannot disagree.

**The asymmetry a reader should know about.** `docs/architecture/01-system-overview.md` §2 records
that the router's six classes and the capabilities endpoint's six tasks are **different sixes** —
`unsupported` is in the router's label space but not the capability list, and `change_vqa` is the
reverse. The config's `router.tasks` is the **router's** ontology (with `unsupported`), and the guard
is scoped to that. `core/schemas.py::Task` carries all seven values.

**Pinned by:** `test_router_tasks_include_unsupported`, `test_router_num_tasks_matches_list`, and
`test_router_missing_unsupported_is_rejected` (rejects `["vqa", "caption"]`) —
`tests/test_config.py:197-208`.

## 18. Change config — `encoder` required, `sa_mode ∈ {BAM, PAM}`

```python
# --- change config -------------------------------------------------
if self.get("change.encoder") is None:
    errors.append("change.encoder is required")
if self.get("change.sa_mode") not in {"BAM", "PAM"}:
    errors.append("change.sa_mode must be BAM or PAM")
```

(`core/config.py:208-212`)

**What it checks.** `change.encoder` is present (specifically, not `None`), and `change.sa_mode` is
one of two exact strings.

**Why `encoder` is required.** The change specialist is a Siamese network: two encoders sharing
weights, plus a decoder. The encoder backbone is not optional — a change detector with no encoder
cannot produce a difference representation. The registry declares `encoder: resnet18`
(`configs/base.yaml:187`). The check is `is None`, not truthiness, so an empty string would pass;
the intent is "the key must be populated with a backbone name", and the registry supplies one.

**Why `sa_mode` is a closed set of two.** The spatial-attention module has exactly two verified modes.
`docs/PHASE0_CONTRACT_VALIDATION.md` §4.1 records the upstream STANet contract:

| Property | Verified value |
|---|---|
| Model variants | `CDF0` (base), `CDFA` (with SA module) |
| Attention modes | `--SA_mode BAM` or `--SA_mode PAM` |

The upstream training invocation shows the flag in use: `--model CDFA --SA_mode PAM`. The registry
declares:

```yaml
change:
  ...
  encoder: resnet18
  sa_mode: PAM             # BAM | PAM
  pretrained: true
```

(`configs/base.yaml:187-189`)

The guard's job is to reject anything else — a typo like `Pam`, `pan`, or `none` — because an
unrecognised mode would silently select neither attention variant. Note the set is **case-sensitive**:
`pam` is rejected. The comment `# BAM | PAM` in the registry is the human-readable form of the same
constraint.

**Why the freeze's change contract is what it is.** `docs/ARCHITECTURE_FREEZE.md` §2.4 records the
verified hyperparameters that go with this: *"lr = 1e-3, batch_size = 8, patch size 256×256
non-overlapping, loss 0.5·BCE + 0.5·Dice"*, all present in the `change` block
(`configs/base.yaml:190-193`). The encoder and SA mode are the two *structural* choices; the loader
guards those, and the test suite separately pins the hyperparameters
(`test_change_hyperparameters_match_verified_stanet`, `tests/test_config.py:228-233`).

**Pinned by:** `test_change_sa_mode_is_valid` and `test_change_sa_mode_rejects_junk` (rejects `XYZ`) —
`tests/test_config.py:236-242`.

## 19. Tiling policy — `top_k_tiles ≤ max_tiles`

```python
# --- tiling policy -------------------------------------------------
if self.get("image.top_k_tiles", 0) > self.get("image.max_tiles", 0):
    errors.append("image.top_k_tiles cannot exceed image.max_tiles")
```

(`core/config.py:214-216`)

**What it checks.** The number of tiles actually sent through a specialist does not exceed the hard
ceiling on tiles examined.

**Why.** The two values have distinct meanings, stated in the registry:

```yaml
image:
  max_pixels: 25000000
  tile_size: 512
  tile_overlap: 128
  max_tiles: 64
  # plan section 9.1 tile policy: whole-image thumbnail first, then top-K tiles.
  # max_tiles is the hard ceiling on tiles *examined*; top_k_tiles is how many
  # are actually sent through a specialist.
  top_k_tiles: 4
```

(`configs/base.yaml:16-24`)

- `max_tiles: 64` — the **hard ceiling** on tiles examined. This bounds the preprocessing work.
- `top_k_tiles: 4` — how many tiles are **actually sent through a specialist**. This bounds the
  expensive inference work.

`top_k_tiles > max_tiles` would mean "send more tiles to a specialist than were ever examined", which
is incoherent: the selection step cannot choose from a set larger than the set it built. The guard
makes that impossible.

**The `0` defaults are deliberate.** Both lookups use `self.get(key, 0)`, so a missing `top_k_tiles`
or `max_tiles` reads as `0` and the comparison is `0 > 0` → `False`. The guard does **not** fire for a
missing key; that is handled elsewhere (the key simply has no value, and downstream code that needs it
will fail with its own named error). The guard's scope is precisely "the ordering relation between two
present values", nothing more.

**The relationship to the VLM pin.** `image.tile_size: 512` is the value the F5-2 guard compares
against `vlm.processor_longest_edge`. So the `image` block participates in two guards — F5-2 and the
tiling check — which is a small illustration of why centralising the registry makes cross-block
invariants expressible.

**Pinned by:** `test_top_k_cannot_exceed_max_tiles` (rejects `top_k_tiles: 999`) —
`tests/test_config.py:214-216`. A companion test pins a *neighbouring* relation that the loader does
**not** guard: `test_tile_overlap_smaller_than_tile_size` asserts `image.tile_overlap < image.tile_size`
and `change.tile_overlap < change.tile_size` (`tests/test_config.py:219-222`) — a test-only invariant.

## 20. Rules the loader does NOT enforce (and the tests that pin them instead)

Not every frozen value is a loader guard. The distinction is worth stating explicitly, because a
reader who assumes "frozen ⇒ guarded" will overestimate what `_validate` protects.

| Value | Why it is not a loader guard | Where it is pinned |
|---|---|---|
| `grounding.image_size: 224` | the value is frozen by *measurement*, not by a structural relation; there is no arithmetic to check | `test_grounding_resolution_is_frozen_at_224`, `test_resolution_experiment_flag_is_gone` (`tests/test_config.py:248-267`) |
| `grounding.resolution_frozen: true` | a declaration of a decided question, not a constraint | same tests |
| `evaluation.hidden_data_access: false` | an isolation policy, asserted where evaluation runs | `test_hidden_data_access_is_disabled` (`tests/test_config.py:317-318`) |
| `evaluation.official_aggregate_weights: null` | *"the uploaded specification explicitly prohibits inventing an official aggregate formula"* (plan §63) | `test_no_official_aggregate_weights_are_invented` (`tests/test_config.py:321-322`) |
| `evaluation.leakage_split_key: scene_id` | a leakage policy, exercised by the leakage tests | `test_leakage_split_key_is_scene_level` (`tests/test_config.py:325-326`) |
| `change.learning_rate`, `change.batch_size`, `change.tile_size` | verified upstream hyperparameters, not structural relations | `test_change_hyperparameters_match_verified_stanet` (`tests/test_config.py:228-233`) |
| `deployment.cpu_mode_required: true` | a deployment declaration; the *validator* checks it, the loader does not | `test_cpu_mode_is_required` (`tests/test_config.py:190-191`); `scripts/validate_deploy_config.py` `REQUIRED_BOOL` |
| `grounding_training.*` | a training protocol, present rather than constrained | `test_grounding_training_exposes_run_protocol_keys` (`tests/test_config.py:300-306`) |
| `grounding.benchmark_box_scale: 100.0` | a unit conversion; wrongness is a data error, not a shape error | `test_vrsbench_box_scale_is_declared` (`tests/test_config.py:309-311`) |

The general principle: **`_validate` guards the values whose wrongness produces a failure far from
its cause** (a shape error in a model, a silent upscale, a forbidden platform feature). Values whose
wrongness is locally visible — a bad learning rate, an incorrect box scale — are pinned by tests and
documented in the registry, but not enforced at load.

## 21. The validation failure mode: collect all, raise once

```python
if errors:
    raise ConfigError(
        "configuration failed frozen-architecture validation:\n  - "
        + "\n  - ".join(errors)
    )
```

(`core/config.py:218-222`)

Because every check appends and nothing raises mid-pass, a registry with three problems reports
**all three** in one message. The rendered shape is:

```
configuration failed frozen-architecture validation:
  - croma.image_resolution must be an int multiple of 8 (CROMA asserts image_resolution % 8 == 0); got 121
  - training.precision must be fp16|bf16|fp32, got 'tf32'
  - image.top_k_tiles cannot exceed image.max_tiles
```

Two properties of this design:

1. **Fix-once feedback.** An operator editing a config sees every violation in one run rather than
   discovering them one per attempt.
2. **The message is a contract surface.** Each line names the key and the expectation, and the
   finding-carrying rules name their finding id (`C-8`, `F5-2`, `C-1`, `P7-1`). A reader who hits a
   guard can search the id and find the measurement that produced it — which is the whole reason the
   ids are in the messages.

**The error is a `WorkflowPlanError` subclass** (Part B.7), so a config failure is catchable as the
project's own taxonomy and carries `code = "config_error"` and a client-safe `user_message`.

---

# Part D — The hash

## 22. `Config.hash` — the definition

```python
@property
def hash(self) -> str:
    """Stable hash of the whole registry. Recorded in every evaluation run."""
    blob = json.dumps(self._data, sort_keys=True, default=str).encode()
    return hashlib.sha256(blob).hexdigest()[:16]
```

(`core/config.py:76-80`)

Decomposed, exactly:

| Step | Code | Effect |
|---|---|---|
| Serialise | `json.dumps(self._data, sort_keys=True, default=str)` | the **whole** registry as canonical JSON |
| Canonicalise key order | `sort_keys=True` | key insertion order cannot change the hash |
| Coerce non-JSON types | `default=str` | any non-serialisable value becomes its string form |
| Encode | `.encode()` | UTF-8 bytes |
| Digest | `hashlib.sha256(blob)` | a 64-hex-character sha256 |
| Truncate | `.hexdigest()[:16]` | the **first 16 hex characters** |

Three consequences worth stating plainly:

1. **It hashes the whole registry, not a subset.** Every block, every key, every value. `sort_keys=True`
   means the hash depends on the *content*, not on the file's key ordering, so reformatting the YAML
   without changing values does **not** move the hash. Adding, removing or editing a value does.
2. **`default=str` makes the hash total.** A value the JSON encoder cannot handle is stringified
   rather than raising, so hashing never fails on an exotic type.
3. **16 hex characters is 64 bits.** It is a fingerprint, not a cryptographic commitment: it is
   chosen to be short enough to print in a manifest and a log line. Two *different* registries
   colliding in 64 bits is not a concern for this use — the hash is a drift detector, not a security
   primitive.

`repr(Config)` surfaces it: `<Config source=… hash=…>` (`core/config.py:233-234`).

## 23. The frozen value: `78f1e3700da15aa1` — `VERIFIED`

**The frozen config hash is `78f1e3700da15aa1`.**

This is stated in three independent places and was **re-verified by execution** while writing this
chapter — `get_config().hash` was run in the repository's virtual environment and returned exactly
`78f1e3700da15aa1`:

| Source | Statement |
|---|---|
| `tests/test_config.py:43` | `FROZEN_CONFIG_HASH = "78f1e3700da15aa1"` |
| `docs/PHASE9_FREEZE.md` §3 | *"Config hash | `78f1e3700da15aa1`"* |
| `docs/OWNER_DECISIONS_2026-09-23.md` §cross-cutting rule 4 | *"`Config.hash = 78f1e3700da15aa1` is verified untouched."* |
| `docs/DEPLOYMENT_ARCHITECTURE.md` §3.3 requirement 5 | the entrypoint *"must not … otherwise move `Config.hash` off `78f1e3700da15aa1`"* |

The test that pins it is explicit that the value — not merely the stability — is the contract:

```python
def test_config_hash_matches_the_shipped_checkpoint_record() -> None:
    """The hash is a frozen CONTRACT, not merely a stability property.

    `test_config_hash_is_stable` only checks `a.hash == b.hash`; it never pins
    the VALUE. But the value is load-bearing: `scripts/eval_change.py` reads
    `config_hash` from `artifacts/change/levir_change_v001/model_metadata.json`
    and REFUSES TO SCORE when the current hash drifts (exit 3). A well-meaning
    config edit therefore silently invalidates the project's benchmark.
    """
    assert load_config().hash == FROZEN_CONFIG_HASH, (...)
```

(`tests/test_config.py:46-81`)

There are two distinct hash tests, and the difference between them is the whole point:

| Test | Asserts | Catches |
|---|---|---|
| `test_config_hash_is_stable` | `a.hash == b.hash`, `len == 16` | non-determinism |
| `test_config_hash_matches_the_shipped_checkpoint_record` | `hash == "78f1e3700da15aa1"` | **any** value drift |

`test_config_hash_is_stable` would pass even if every value in the registry changed, as long as the
hash stayed deterministic. Only the second test pins the value, and it is the one that guards the
published benchmark.

### 23.1 The sibling artifact hash

`configs/base.yaml` also has a **file-level** sha256, distinct from the registry hash. `docs/PHASE9_FREEZE.md`
§2 records it as a frozen artifact:

| Path | Bytes | SHA256 (first 16) |
|---|---|---|
| `configs/base.yaml` | 10,637 | `88434f7f8f78e2b8` |

The two are related but not identical: the file hash covers the file's **bytes** (including comments
and formatting), while `Config.hash` covers the parsed **data**. Reformatting the YAML moves the file
hash and leaves `Config.hash` untouched. Both are frozen; neither substitutes for the other.

## 24. The rule: editing the config MOVES the hash and INVALIDATES keyed artifacts

This is the central operational fact of the chapter.

### 24.1 The dependency chain

```
configs/base.yaml
      │  (parsed)
      ▼
   Config._data ──► Config.hash ──► recorded in artifacts/change/levir_change_v001/model_metadata.json
      │                                      │
      │ (validated by _validate)             ▼
      ▼                              scripts/eval_change.py compares
  the running system                 current hash vs recorded hash
                                              │
                              drift ──────────┴────────── match
                                │                            │
                                ▼                            ▼
                          exit 3: REFUSES TO SCORE     scoring proceeds
```

### 24.2 The practical consequence

> **A value that "looks wrong" is not casually fixed.**

Concretely, the project's own most-tempting edit is the worked example. `docs/PHASE9_FREEZE.md` §6
records it:

> *"Editing `base.yaml` to point at the checkpoint **moves** the config hash away from
> `78f1e3700da15aa1` — which trips the drift guard and makes `scripts/eval_change.py` exit `3`. The
> frozen benchmark number would be detached from its config."*

The edit in question — populating `change.checkpoint_path` so the trained change head is reachable
from serving — is *architecturally correct*. It is the right thing for the system to do. It is still
refused, because it would detach the published `0.8122` test number from the configuration it was
measured under. The resolution was **not** to edit the config; it was the registry's `builders=`
override, a **call-site** mechanism that injects the checkpoint **without moving the hash**
(`docs/PHASE9_FREEZE.md` §6; `docs/DEPLOYMENT_ARCHITECTURE.md` §3.3 requirement 2).

Owner decision **D-4** records the same discipline for the grounding head: the default head path
*"lives in code"* as `DEFAULT_HEAD_PATH` rather than in `base.yaml`, *"because setting it would have
moved the frozen config hash."* The cross-cutting rule is stated in
`docs/OWNER_DECISIONS_2026-09-23.md`:

> *"Frozen artefacts stay frozen. `Config.hash = 78f1e3700da15aa1` is verified untouched. **New
> defaults live in code, not in the config registry.**"*

### 24.3 There is no single "drifted value" — five renderings, five hashes

A subtlety that catches people who try to *report* the drift: because the hash covers the **whole**
registry, the moved hash depends on the exact string written in. The test docstring records a
measurement of five renderings of the *same logical change*:

| Rendering of the same path | Resulting hash |
|---|---|
| backslashes / a `Path` object | `f4487e1a2cf13733` |
| forward slashes | `f571a5f85372ff54` |
| relative | `b1d8637d6ab733d9` |
| bare filename | `4fcc6a4a4f681c87` |
| directory with a trailing slash | `f5a6ec3db8217218` |

(`tests/test_config.py:59-68`)

The consequence, stated in the test: *"**Do NOT quote a single 'drifted value' — it does not exist.**
… Only the UNMODIFIED hash above is stable and quotable."* A doc that printed one drifted hash would
be printing a fact about one particular string, not about the change.

### 24.4 Why the drift guard is a *guard* and not an inconvenience

`scripts/eval_change.py` exiting `3` on drift is the mechanism that makes the freeze real. Without
it, a config edit would silently change the system under test, and the recorded `0.8122` would be
re-reported as if nothing had changed. `docs/PHASE9_FREEZE.md` §1 states the freeze's meaning:

> *"This freeze is a **claim about the past**: 'these are the numbers this run produced, and here is
> the evidence they have not moved.'"*

The hash is how "they have not moved" is checkable. `docs/PHASE9_FREEZE.md` §3 lists the config hash
among the **frozen invariants** alongside the scene split, the decision threshold and the model
identity — and each of those invariants names where it is enforced.

---

# Part E — `configs/deploy.yaml`, and why it is left undisturbed

## 25. What `configs/deploy.yaml` is

`configs/deploy.yaml` is a **45-line Hugging Face Spaces packaging manifest** from Phase 18. It is
**not** part of the configuration registry, and the file says so in its own header, in capitals:

```yaml
# SatQuery AI — Hugging Face Spaces packaging manifest (Phase 18).
#
# THIS FILE IS NOT PART OF THE CONFIG REGISTRY.
#
# `core/config.py` reads exactly ONE file — `configs/base.yaml` (the module's
# `DEFAULT_CONFIG`) — through a single `yaml.safe_load`. It never globs
# `configs/*.yaml`, so this file is never read by the loader. That matters
# because `Config.hash` is a sha256 over the WHOLE registry; merging any key from
# this file into the registry would move the hash off the recorded benchmark
# value `78f1e3700da15aa1`. The `registry: false` marker below makes the
# non-membership explicit and machine-readable, and
# `scripts/validate_deploy_config.py` asserts it.
```

(`configs/deploy.yaml:1-12`)

It carries a single top-level marker and a `deployment:` block that is a **byte-for-byte copy** of
`configs/base.yaml` lines 282-295:

```yaml
registry: false

deployment:
  platform: huggingface-spaces
  sdk: gradio
  zerogpu: true
  # finding C-8: ZeroGPU does not support torch.compile. Never enable.
  torch_compile: false
  # ZeroGPU free tier = 5 GPU-min/day. Declared duration reserves quota up front.
  gpu_duration_vqa: 20
  gpu_duration_grounding: 45
  gpu_duration_change: 30
  gpu_duration_optical_sar: 45
  cpu_mode_required: true
  lazy_load: true
  cache_max_models: 1
```

(`configs/deploy.yaml:29-45`)

The file's header states the reason the copy is identical:

> *"The `deployment:` block below is a byte-for-byte copy of `configs/base.yaml` lines 282-295. It is
> kept identical on purpose so the two cannot silently drift; `scripts/validate_deploy_config.py`
> enforces the equality key for key."*

## 26. Why it is FROZEN PAPERWORK

`docs/DEPLOYMENT_TOPOLOGY.md` §3.4 states the conclusion, and `docs/DEPLOYMENT_DECISION.md` §4 gives
the reasoning. The reasoning is structural, not sentimental:

> *"the legacy `configs/deploy.yaml` still describes an HF Space + Gradio + ZeroGPU target. That
> manifest is **frozen paperwork** — no Gradio runtime exists in code, and editing it would move
> `Config.hash`. It is left undisturbed."* (`docs/DEPLOYMENT_TOPOLOGY.md` §3.4)

Two independent reasons, and **both paths are closed**:

### 26.1 Reason 1 — `Config.hash` cannot move

The registry hash is `78f1e3700da15aa1` (Part D). `configs/deploy.yaml` carries `registry: false` and
is never read by the loader, so editing the *file* does not move the hash directly. But
`scripts/validate_deploy_config.py` **hard-fails** if the `deployment:` block in `deploy.yaml` differs
key-for-key from `base.yaml`'s:

> *"So changing `zerogpu: true` → `false` in `deploy.yaml` alone fails the validator, and moving
> `base.yaml` to match moves the frozen hash. **Both paths are closed.** The files stay as they
> are."* (`docs/DEPLOYMENT_DECISION.md` §4)

The validator's check is symmetric and names the mismatch in either direction
(`scripts/validate_deploy_config.py`, `_check_deployment_blocks_equal`): a key present in one and
missing from the other is reported, and a key present in both with different values is reported with
both values. So there is no quiet edit: either the file fails the validator, or the registry hash
moves.

The validator's own docstring is careful about what it does and does not prove:

> *"This is a NECESSARY condition for the file being outside the registry — NOT a proof against a
> merge: the registry is built FROM `configs/base.yaml`, so a key merged into `base.yaml` would appear
> on both sides and compare equal. The real guarantee against a merge is the `Config.hash` regression
> guard, `tests/unit/test_deploy_config.py::test_config_hash_regression_guard`, which pins the
> recorded hash."*

That is the honest statement: the key-equality check catches *divergence between the two files*; the
hash regression test catches *a merge*. Neither alone is sufficient, and the design uses both.

### 26.2 Reason 2 — there is no Gradio runtime to conflict with

The manifest declares `sdk: gradio`, but no Gradio application exists in the code:

> *"No `import gradio`, no `gr.Blocks`, no `gr.Interface` and no Gradio entrypoint exists anywhere.
> Gradio appears only as `requirements.txt:36` and the manifest value `sdk: gradio`
> (`configs/base.yaml:284`)."* (`docs/DEPLOYMENT_DECISION.md` §4)

The one ZeroGPU code path sits inside a function that is **never applied to a route**:

```python
def decorate_gpu(task: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    ...
    spaces = _spaces_module()
    if spaces is None or not hasattr(spaces, "GPU"):
        def _identity(fn): return fn
        return _identity
    return spaces.GPU(duration=duration)
```

(`app/space_app.py:144-165`)

The routes use plain `@api.get` / `@api.post` (`app/space_app.py:521`, `:549`, `:555`, `:661`), and
the real entrypoint is FastAPI's `build_space_app()` (`app/space_app.py:409`). The manifest therefore
describes a Space that does not exist in code:

> *"Conclusion: the frozen contract describes a Gradio Space that does not exist in code. It is frozen
> paperwork, not a competing deployment."* (`docs/DEPLOYMENT_DECISION.md` §4)

Because there is nothing for it to conflict with, there is no benefit to editing it — and a real cost
(reason 1). So it stays.

### 26.3 What the manifest deliberately does NOT specify

The header lists the absences explicitly, so a reader does not mistake them for oversights:

> *"Deliberately ABSENT (the freeze does not specify these): a hardware SKU, an SDK version pin, a
> requirements filename, the Space entrypoint/`app_file`, Space visibility/owner, and a Python
> version."* (`configs/deploy.yaml:24-27`)

## 27. The `gpu_duration_*` values and the 5 GPU-minute budget

The `deployment` block's four durations encode a quota constraint measured in Phase 0:

| Task | `gpu_duration_*` | Source |
|---|---|---|
| `vqa` / `caption` | 20 s | `configs/deploy.yaml` |
| `grounding` | 45 s | `configs/deploy.yaml` |
| `change` | 30 s | `configs/deploy.yaml` |
| `optical_sar` | 45 s | `configs/deploy.yaml` |
| `change_vqa` | 30 s | follows `change` — shares the STANet detector |

(`docs/DEPLOYMENT_ARCHITECTURE.md` §3.4)

**Why the durations exist at all.** `docs/PHASE0_CONTRACT_VALIDATION.md` §5.1 records the verified
ZeroGPU constraints:

| Constraint | Verified value |
|---|---|
| SDK | **Gradio only** (Docker/Static cannot schedule onto ZeroGPU) |
| Decorator | `@spaces.GPU(duration=N)`, default **60 s** |
| Free account quota | **5 GPU-minutes/day** |
| Quota window | 24 h from first use, not calendar day |
| `torch.compile` | **not supported** (use AoTI, torch 2.8+) |

Finding **C-8** records the operational consequence the plan omitted:

> *"the plan never states the operational consequence: at 5 min/day, a single VQA call declaring the
> 60 s default consumes **1/5 of a user's entire daily budget**."*
> (`docs/PHASE0_CONTRACT_VALIDATION.md` §5.2)

The resolution is that *"every GPU-decorated function declares an explicit, realistic `duration`"* —
hence the four frozen values. The registry states the same rationale inline: *"ZeroGPU free tier = 5
GPU-min/day. Declared duration reserves quota up front."* (`configs/base.yaml:288`).

## 28. The `change_vqa` detail — a duration that has no key of its own

`app/space_app.py` transcribes the durations into a Python dict:

```python
#: ZeroGPU duration per task, from `configs/deploy.yaml` -- the FROZEN values,
#: not new guesses. `change_vqa` has no key of its own and reuses `change`,
#: because adding a key would move `Config.hash` off `78f1e3700da15aa1`
#: (`docs/DEPLOYMENT_ARCHITECTURE.md` section 3.4).
GPU_DURATIONS: dict[str, int] = {
    "vqa": 20,
    "caption": 20,
    "grounding": 45,
    "change": 30,
    "optical_sar": 45,
    "change_vqa": 30,
}
```

(`app/space_app.py:105-116`)

Note the shape: **six tasks, four `gpu_duration_*` keys.** `vqa` and `caption` share the `vqa` budget;
`change_vqa` shares the `change` budget. And `change_vqa` is the interesting one, because the reason
is the hash:

> *"`change_vqa` has **no** `gpu_duration_change_vqa` key, and adding one would move `Config.hash`.
> Repurposing the `change` budget is the choice that avoids that, and it is recorded here as a
> decision rather than presented as a plan fact."* (`docs/DEPLOYMENT_ARCHITECTURE.md` §3.4)

The justification for the *repurposing itself* is also recorded: `change_vqa` *"follows `change` — it
shares the STANet detector."* So the shared budget is not arbitrary; it reflects that the two tasks
run the same detector. The config-freeze reasoning and the engineering reasoning agree here, which is
why this is a good example rather than a tension.

`decorate_gpu` refuses an undeclared task rather than guessing a duration:

```python
if task not in GPU_DURATIONS:
    raise KeyError(
        f"no gpu_duration_* is declared for {task!r}; add it to "
        f"configs/deploy.yaml (which moves Config.hash) or map it to an "
        f"existing task. Do not guess a duration."
    )
```

(`app/space_app.py:152-157`)

The error message names both options and the cost of the first — a small, self-documenting instance of
the chapter's central rule.

## 29. The ZeroGPU decoration has never executed

The honest status of the ZeroGPU path is stated in the entrypoint's own docstring:

> *"`spaces` (the ZeroGPU decorator package) is not installed in this environment … The decoration is
> therefore applied **conditionally** … The consequence is recorded in
> `docs/PHASE19_FINAL_HARDENING.md`: the ZeroGPU decoration has **never executed** here. It is
> specified from finding C-8 and the frozen `gpu_duration_*` values, and that is all it is."*
> (`app/space_app.py:31-42`)

This is `IMPLEMENTED (not run)`: the code exists, the durations are frozen and transcribed, and the
decoration has never been exercised. `docs/architecture/02-deployment-topology.md` §15 lists
"ZeroGPU decoration execution | `NOT RUN`" in the same terms. The conditional application is itself
correct rather than a workaround, because `configs/deploy.yaml` sets `cpu_mode_required: true` — a CPU
run must work, and it does, via the identity decorator.

---

# Part F — The frozen values a reader might want to change

This is the table the chapter exists to provide. Every row is a value that a reader, seeing it in
`configs/base.yaml`, might reasonably want to change — and the reason it is frozen.

## 30. Frozen values, and why

| Key | Frozen value | Why it is frozen | Enforced by |
|---|---|---|---|
| `croma.image_resolution` | `120` | CROMA asserts `% 8 == 0`; native resolution → 225 patches. A non-multiple of 8 violates an assertion inside the vendored model. | **C-7 guard** (`core/config.py:97-105`) |
| `croma.optical_channels` | `12` | CROMA's `s2_channels` is fixed at 12; the patch embedding is a fixed-shape `Linear`. | **channel guard** (`core/config.py:164-166`) |
| `croma.sar_channels` | `2` | CROMA's `s1_channels` is fixed at 2 (VV, VH). | **channel guard** (`core/config.py:167-168`) |
| `fusion.input_dim` | `2318` | Must equal `3·768 + 12 + 2`; a mismatch is a `Linear` shape error after CROMA has run. | **C-1 guard** (`core/config.py:152-162`) |
| `training.precision` | `fp16` | The target accelerator (T4, SM 7.5) has no bf16 tensor cores. | **C-6 guard** (`core/config.py:107-112`) |
| `vlm.processor_longest_edge` | `512` | The processor default (2048) upscales and splits every tile into 17 sub-images and 1142 tokens (measured). | **F5-2 guard** (`core/config.py:121-142`) |
| `vlm.prompt_must_use_chat_template` | `true` | SmolVLM raises `ValueError` without one `<image>` token per image. | **F5-3 guard** (`core/config.py:144-150`) |
| `grounding.encoder_projected_dim` | `512` | RemoteCLIP's `visual.proj` is (768, 512); the comparison space is the **projected** dim, not the transformer width. | **P7-1 guard** (`core/config.py:183-196`) + `remoteclip.py` load-time assert |
| `grounding_head.feature_dim` | `2048` | `4 · 512`; a mismatch is a **silent** shape error at the similarity step, after patch features are cached. | **P7-1 guard** (`core/config.py:190-196`) |
| `deployment.torch_compile` | `false` | ZeroGPU does not support `torch.compile` (use AoTI, torch 2.8+). | **C-8 guard** (`core/config.py:114-119`); deploy validator |
| `deployment.cpu_mode_required` | `true` | CPU must work; ZeroGPU is an accelerator, not a dependency. | deploy validator (`REQUIRED_BOOL`) |
| `grounding.image_size` | `224` | **RESOLVED by measurement**: 448 lost on mean best IoU (−0.0147), every recall threshold, and latency (1.59×) over 16,159 VRSBench records. | test + `resolution_frozen: true` |
| `grounding.resolution_frozen` | `true` | Asserts the resolution question is *decided*; a stale "experiment" flag would invite a re-run. | test |
| `change.sa_mode` | `PAM` | Upstream STANet's two verified modes are `BAM` and `PAM`; an unrecognised mode selects neither. | **change guard** (`core/config.py:211-212`) |
| `change.tile_size` | `256` | STANet's verified convention: 256×256 **non-overlapping** patches. | test |
| `change.bce_weight` + `change.dice_weight` | `0.5 + 0.5` | STANet's verified loss is `0.5·BCE + 0.5·Dice`. | test (sums to 1.0) |
| `image.tile_size` | `512` | The tile size the VLM processor pin is tied to, and the unit of the tiling policy. | referenced by **F5-2 guard** |
| `image.top_k_tiles` | `4` | Plan §9.1 tile policy: whole-image thumbnail first, then top-K tiles. | **tiling guard** (`≤ max_tiles`) |
| `router.tasks` (incl. `unsupported`) | 6 classes | `unsupported` is the router's "not a satellite question" outcome; removing it forces every query into a specialist. | **ontology guard** (`core/config.py:198-206`) |
| `router.num_tasks` | `6` | The classifier's output width; must equal the ontology length. | **ontology guard** (`core/config.py:202-206`) |
| `evaluation.official_aggregate_weights` | `null` | The uploaded specification prohibits inventing an official aggregate formula. | test |
| `evaluation.hidden_data_access` | `false` | Evaluation isolation. | test |
| `evaluation.leakage_split_key` | `scene_id` | Scene-level leakage isolation is a non-negotiable. | test |
| `project.seed` | `42` | Reproducibility. | `Config.seed` |
| `deployment.gpu_duration_*` | `20/45/30/45` | Derived from the verified 5 GPU-min/day ZeroGPU budget. | deploy validator (positive int) |

## 31. The hash itself

| Item | Frozen value | Why |
|---|---|---|
| `Config.hash` | **`78f1e3700da15aa1`** | Recorded in `artifacts/change/levir_change_v001/model_metadata.json`; `scripts/eval_change.py` exits `3` on drift. |
| `configs/base.yaml` file sha256 (first 16) | `88434f7f8f78e2b8` | A frozen Phase-9 artifact (`docs/PHASE9_FREEZE.md` §2); 10,637 bytes, mtime 2026-09-16 19:30. |

---

# Part G — What is NOT frozen: open for tuning

The freeze is a claim about **which** values are decided, not a claim that nothing may ever change.
The master plan draws the line explicitly, in two adjacent sections, and the repository's own
structure follows it.

## 32. The plan's own split

The plan separates immutable decisions from tunables in **§67**, **§68** and **§69**.

### 32.1 §67 — Immutable decisions

The implementation model **must not redesign** these (plan §67):

```
[ ] modular-monolith architecture
[ ] tiny NLP intent router
[ ] deterministic policy engine
[ ] common specialist interface
[ ] SmolVLM VLM layer
[ ] RemoteCLIP grounding path
[ ] STANet-style change path
[ ] CROMA optical-SAR path
[ ] shared evidence engine
[ ] calibrated confidence
[ ] common result schema
[ ] execution trace
[ ] leakage isolation
```

These are **structural**. Note that the list is about *components and interfaces*, not numbers. The
config registry is the mechanism by which the numbers those components depend on are pinned.

### 32.2 §68 — Variables open for tuning

The plan's tunable list (plan §68):

```
LoRA rank
VLM learning rate
router adapter dimension
router confidence threshold
grounding head architecture
grounding learning rate
change threshold
change loss weighting
CROMA fusion head width
tile size
tile overlap
top-K tile count
confidence calibration temperature
```

### 32.3 §69 — Variables requiring experimental optimization

The plan gives explicit ranges for the tunables it expects to be searched (plan §69):

| Component | Variable | Range |
|---|---|---|
| Router | hidden dimension | `64–256` |
| Router | dropout | `0–0.3` |
| Router | confidence | `0.60–0.90` |
| Grounding | head width | `256–1024` |
| Grounding | learning rate | `5e-5–2e-4` |
| Grounding | NMS | `0.4–0.6` |
| Change | threshold | `0.30–0.70` |
| Change | minimum component | `16–128 px` |
| CROMA | fusion width | `256–1024` |
| CROMA | dropout | `0–0.3` |

The plan's selection rule is one line: **"Selection: validation only."**

### 32.4 Where the registry's values sit against the plan's ranges

The registry's tunables fall inside (or beside) the plan's ranges. Cross-referencing them is
instructive, because it shows the registry is *the plan's tunables, realised*:

| Plan variable | Registry key | Registry value | In plan range? |
|---|---|---|---|
| Router hidden dimension | `router.hidden_dim` | `128` | ✅ within `64–256` |
| Router dropout | `router.dropout` | `0.10` | ✅ within `0–0.3` |
| Router confidence | `router.confidence_threshold` | `0.70` | ✅ within `0.60–0.90` |
| Grounding head width | `grounding_head.hidden_dim` | `512` | ✅ within `256–1024` |
| Grounding learning rate | `grounding_training.learning_rate` | `0.0001` (1e-4) | ✅ within `5e-5–2e-4` |
| Grounding NMS | `grounding.nms_iou` | `0.50` | ✅ within `0.4–0.6` |
| Change threshold | `change.threshold` | `0.50` | ✅ within `0.30–0.70` |
| Change min component | `change.min_component_pixels` | `32` | ✅ within `16–128 px` |
| CROMA fusion width | `fusion.hidden_dim` | `512` | ✅ within `256–1024` |
| CROMA dropout | `fusion.dropout` | `0.2` | ✅ within `0–0.3` |
| LoRA rank | `training.lora_rank` | `16` | (no range given) |
| VLM learning rate | `training.vlm_learning_rate` | `0.0002` | (no range given) |
| Tile size | `image.tile_size` | `512` | (no range given) |
| Tile overlap | `image.tile_overlap` | `128` | (no range given) |
| Top-K tile count | `image.top_k_tiles` | `4` | (no range given) |
| Calibration temperature | — | `UNKNOWN — not established from the available evidence` | the artifact is `calibration_v001.json`; no temperature is declared in `base.yaml` |

Two honest notes on that table:

1. **The plan's tunables are the registry's values.** Every range-bearing tunable the plan names has
   a registry key with a value inside the stated range. This is the plan's intent realised, not a
   coincidence — the registry was written from the freeze, which was written from the plan.
2. **The calibration temperature is not in the registry.** `confidence.temperature_scaling: true` and
   `confidence.calibration_file: calibration_v001.json` are the registry's confidence keys
   (`configs/base.yaml:231-233`); the fitted temperature itself lives in the artifact file. Whether
   that temperature is a "tunable" in the plan's §68 sense is `UNKNOWN — not established from the
   available evidence`; the artifact is covered in
   `docs/architecture/06-evidence-and-confidence.md` §8–§10, including the measured result that
   calibration made ECE **worse** (0.013755 → 0.014929) and is retained only because it is in the
   frozen config.

## 33. The real tension: tunable ≠ free

Here is the part a reader must not miss. **A value can be on the plan's tunable list and still be
frozen in practice.**

The plan's §68 list says `change threshold` is open for tuning. The registry has
`change.threshold: 0.50`. But `docs/PHASE9_FREEZE.md` §5 records that the threshold lever is
**CLOSED**:

> *"The val-only sweep scored 19 thresholds. Pooled IoU peaks at 0.35–0.40 (**0.8239**) against
> **0.8232** at 0.50 — a gain of **+0.0007**. … the last five validation epochs span 0.8213–0.8232, a
> spread of **0.0019**, so the entire available threshold gain is **0.37× the epoch-to-epoch noise**.
> A gain smaller than the run's own variance is not a finding. **0.50 is retained. This hypothesis is
> eliminated, not deferred.**"*

So there are **three** states a value can be in, not two:

| State | Meaning | Example |
|---|---|---|
| **Structurally frozen** | a guard rejects any other value | `fusion.input_dim`, `training.precision` |
| **Measured-frozen** | a value was chosen by measurement and the question is closed | `grounding.image_size` (224), `change.threshold` (0.50) |
| **Open for tuning** | genuinely searchable, with a validation-only selection rule | the plan's §69 ranges |

And a fourth, operational state that overrides all three:

| State | Meaning | Example |
|---|---|---|
| **Hash-pinned** | editing it moves `Config.hash` and detaches a published number | *any* key in `configs/base.yaml` |

The last row is the reason this chapter exists. A tunable that is "open" is still an edit to
`configs/base.yaml`, and an edit to `configs/base.yaml` still moves the hash. The supported way to
change a value without detaching a benchmark is **not** to edit the registry — it is to pass
`overrides` to `load_config`, or to use a call-site mechanism like the registry's `builders=`
override, and to accept that the resulting run has a **different** hash and is therefore a **different**
measurement.

`docs/OWNER_DECISIONS_2026-09-23.md` states this as a cross-cutting rule:

> *"**Frozen artefacts stay frozen.** `Config.hash = 78f1e3700da15aa1` is verified untouched. New
> defaults live in **code**, not in the config registry."*

## 34. The override surfaces that DO exist

If the registry is frozen, what is not? Three legitimate surfaces:

| Surface | Mechanism | Effect on the hash | Example |
|---|---|---|---|
| **Programmatic overrides** | `load_config(overrides={...})` | **moves it** — a different registry, honestly different | `load_config(overrides={"fusion": {"hidden_dim": 1024}})` |
| **Environment overrides** | `SATQUERY_PRECISION`, `SATQUERY_TORCH_COMPILE` | **moves it** — they write into `data` before hashing | `SATQUERY_PRECISION=fp32` |
| **Call-site injection** | the registry's `builders=` override | **leaves it bit-identical** | `build_serving_registry()` wiring the change head |

The first two are *config changes* and the hash moves with them — which is correct, because the run
really is different. The third is the one that changes **behaviour** without changing the
**configuration**, and it is the mechanism the project uses when a trained artifact needs to be wired
in without detaching the benchmark (`docs/PHASE9_FREEZE.md` §6;
`docs/DEPLOYMENT_ARCHITECTURE.md` §3.3 requirement 2).

**Environment variables outside the loader** are a fourth, weaker surface: `SATQUERY_DEVICE` (Part
B.6) changes the device without touching the registry, and the asset-store variables (Part A.2.2)
change deployment sizing. These are not config-registry values and therefore cannot move the hash —
they are the "deployment state" half of the Part A.2.2 rule of thumb.

---

# Part H — Honest boundaries

## 35. What is `NOT RUN` / `OPEN` / `UNKNOWN` for this topic

| Item | Status | Note |
|---|---|---|
| `Config.hash` frozen value `78f1e3700da15aa1` | **`VERIFIED`** | re-run by execution while writing this chapter; matches `tests/test_config.py:43` |
| Every `_validate` guard firing on a bad value | **`VERIFIED`** | each guard has a `pytest.raises` test in `tests/test_config.py` |
| `configs/deploy.yaml` left undisturbed | **`VERIFIED`** | `registry: false`; validator enforces key-for-key equality |
| ZeroGPU decoration execution | **`NOT RUN`** | *"the ZeroGPU decoration has **never executed** here"* (`app/space_app.py`) |
| The `5 GPU-min/day` quota against a live Space | **`NOT RUN`** | no live Space; the budget is a verified platform constraint, not a measured consumption |
| `SATQUERY_TORCH_COMPILE=true` observed at runtime | **`NOT RUN`** | the guard is tested in-process; no deployment was run with the variable set |
| `SATQUERY_PRECISION` used to switch precision in a real run | **`UNKNOWN — not established from the available evidence`** | the override is implemented; no run record was found that exercised it |
| The calibration temperature as a tunable | **`UNKNOWN — not established from the available evidence`** | the value lives in the artifact, not the registry |
| Whether `change.checkpoint_path` ever appeared in `base.yaml` | **absent** | verified by reading all 294 lines; the registry declares it optional and it resolves to `None` |
| A live config with `image.top_k_tiles` as the binding constraint | **`UNKNOWN — not established from the available evidence`** | the guard is tested; no run record was found |
| `Config.hash` computed on a registry other than `configs/base.yaml` in production | **`UNKNOWN — not established from the available evidence`** | `get_config()` reads the default path; no production override was found |
| The `Config` docstring's "immutable" claim as a hard guarantee | **`IMPLEMENTED`** | no mutating API exists; `_data` is reachable via `__getitem__`, and no code path mutates it |
| The CROMA mask contradiction (C-1) as resolved in code | **`RESOLVED`** (design) | the mask routes to the fusion head; the fusion dimension is guarded. Whether the fusion head has been trained is a Phase-12 question (`docs/OWNER_DECISIONS_2026-09-23.md` D-1) |
| `deployment.platform: huggingface-spaces` describing the live deployment | **superseded** | the active topology is Render + Codespace + Cloudflare Pages (`docs/DEPLOYMENT_TOPOLOGY.md`); the manifest is frozen paperwork |

## 36. Where the evidence lives

| Claim | Source |
|---|---|
| The loader, all guards, the hash, the access methods | `core/config.py` (275 lines; guards at `:94-222`) |
| The registry and every value | `configs/base.yaml` (294 lines) |
| `_deep_merge` semantics | `core/config.py:33-40` |
| Env overrides `SATQUERY_PRECISION` / `SATQUERY_TORCH_COMPILE` | `core/config.py:261-265` |
| `get_config()` singleton | `core/config.py:270-273` |
| `device_preference` + `SATQUERY_DEVICE` | `core/config.py:86-91`, `:237-243` |
| `ConfigError` taxonomy | `core/config.py:28-30` |
| C-7 `% 8` rule | `core/config.py:97-105`; `docs/PHASE0_CONTRACT_VALIDATION.md` §1.1 |
| C-6 precision / T4 SM 7.5 | `core/config.py:107-112`; `docs/PHASE0_CONTRACT_VALIDATION.md` §7; `configs/base.yaml:254-257` |
| C-8 `torch.compile` forbidden | `core/config.py:114-119`; `docs/PHASE0_CONTRACT_VALIDATION.md` §5.1 |
| F5-2 / C-3 processor upscaling, the 17 sub-images and 1142 tokens | `core/config.py:121-142`; `docs/PHASE5_VLM_CONTRACT.md` §headline finding |
| F5-3 chat template | `core/config.py:144-150`; `docs/PHASE5_VLM_CONTRACT.md` §F5-3 |
| C-1 fusion dim 2318, mask to the fusion head | `core/config.py:152-162`, `:224-231`; `docs/PHASE0_CONTRACT_VALIDATION.md` §1.2–§1.3; `docs/ARCHITECTURE_FREEZE.md` §2.5 |
| CROMA fixed channel counts 12 / 2 | `core/config.py:164-168`; `docs/PHASE0_CONTRACT_VALIDATION.md` §1.1 |
| P7-1 feature dim 2048, the silence argument | `core/config.py:170-196`; `specialists/grounding/remoteclip.py:15-19`, `:38-42`, `:195-201` |
| Router ontology guards | `core/config.py:198-206`; `docs/ARCHITECTURE_FREEZE.md` §2.1 |
| Change `encoder` / `sa_mode` guards | `core/config.py:208-212`; `docs/PHASE0_CONTRACT_VALIDATION.md` §4.1 |
| Tiling guard | `core/config.py:214-216`; `configs/base.yaml:16-24` |
| The frozen hash `78f1e3700da15aa1` | `tests/test_config.py:43`; `docs/PHASE9_FREEZE.md` §3; `docs/OWNER_DECISIONS_2026-09-23.md`; `docs/DEPLOYMENT_ARCHITECTURE.md` §3.3 |
| The five drifted-hash renderings | `tests/test_config.py:59-68` |
| The `configs/base.yaml` file sha256 `88434f7f8f78e2b8` | `docs/PHASE9_FREEZE.md` §2 |
| `configs/deploy.yaml` is not registry material | `configs/deploy.yaml:1-12`; `scripts/validate_deploy_config.py` |
| Why the manifest is frozen paperwork | `docs/DEPLOYMENT_TOPOLOGY.md` §3.4; `docs/DEPLOYMENT_DECISION.md` §4 |
| ZeroGPU duration mapping + `change_vqa` reuses `change` | `docs/DEPLOYMENT_ARCHITECTURE.md` §3.4; `app/space_app.py:105-116`, `:144-165` |
| ZeroGPU verified constraints (5 GPU-min/day, Gradio-only, no `torch.compile`) | `docs/PHASE0_CONTRACT_VALIDATION.md` §5.1–§5.2 |
| New defaults live in code, not the registry (D-4) | `docs/OWNER_DECISIONS_2026-09-23.md` D-4 and cross-cutting rule 4 |
| The threshold lever is closed | `docs/PHASE9_FREEZE.md` §5 |
| The plan's immutable / tunable / experimental split | plan §67, §68, §69 |
| The guard tests | `tests/test_config.py` (348 lines) |
| The deploy validator's required values | `scripts/validate_deploy_config.py` (`REQUIRED_BOOL`, `REQUIRED_INT`, `REQUIRED_STR`) |

---

**Next:** [08 API contract](08-api-contract.md) — the four endpoints, the `ResultEnvelope` and error
envelopes, the error codes, and the transport headers.