๐Ÿ“ฆ EqualifyEverything / equalify-iris

๐Ÿ“„ API.md ยท 5387 lines
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
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
4474
4475
4476
4477
4478
4479
4480
4481
4482
4483
4484
4485
4486
4487
4488
4489
4490
4491
4492
4493
4494
4495
4496
4497
4498
4499
4500
4501
4502
4503
4504
4505
4506
4507
4508
4509
4510
4511
4512
4513
4514
4515
4516
4517
4518
4519
4520
4521
4522
4523
4524
4525
4526
4527
4528
4529
4530
4531
4532
4533
4534
4535
4536
4537
4538
4539
4540
4541
4542
4543
4544
4545
4546
4547
4548
4549
4550
4551
4552
4553
4554
4555
4556
4557
4558
4559
4560
4561
4562
4563
4564
4565
4566
4567
4568
4569
4570
4571
4572
4573
4574
4575
4576
4577
4578
4579
4580
4581
4582
4583
4584
4585
4586
4587
4588
4589
4590
4591
4592
4593
4594
4595
4596
4597
4598
4599
4600
4601
4602
4603
4604
4605
4606
4607
4608
4609
4610
4611
4612
4613
4614
4615
4616
4617
4618
4619
4620
4621
4622
4623
4624
4625
4626
4627
4628
4629
4630
4631
4632
4633
4634
4635
4636
4637
4638
4639
4640
4641
4642
4643
4644
4645
4646
4647
4648
4649
4650
4651
4652
4653
4654
4655
4656
4657
4658
4659
4660
4661
4662
4663
4664
4665
4666
4667
4668
4669
4670
4671
4672
4673
4674
4675
4676
4677
4678
4679
4680
4681
4682
4683
4684
4685
4686
4687
4688
4689
4690
4691
4692
4693
4694
4695
4696
4697
4698
4699
4700
4701
4702
4703
4704
4705
4706
4707
4708
4709
4710
4711
4712
4713
4714
4715
4716
4717
4718
4719
4720
4721
4722
4723
4724
4725
4726
4727
4728
4729
4730
4731
4732
4733
4734
4735
4736
4737
4738
4739
4740
4741
4742
4743
4744
4745
4746
4747
4748
4749
4750
4751
4752
4753
4754
4755
4756
4757
4758
4759
4760
4761
4762
4763
4764
4765
4766
4767
4768
4769
4770
4771
4772
4773
4774
4775
4776
4777
4778
4779
4780
4781
4782
4783
4784
4785
4786
4787
4788
4789
4790
4791
4792
4793
4794
4795
4796
4797
4798
4799
4800
4801
4802
4803
4804
4805
4806
4807
4808
4809
4810
4811
4812
4813
4814
4815
4816
4817
4818
4819
4820
4821
4822
4823
4824
4825
4826
4827
4828
4829
4830
4831
4832
4833
4834
4835
4836
4837
4838
4839
4840
4841
4842
4843
4844
4845
4846
4847
4848
4849
4850
4851
4852
4853
4854
4855
4856
4857
4858
4859
4860
4861
4862
4863
4864
4865
4866
4867
4868
4869
4870
4871
4872
4873
4874
4875
4876
4877
4878
4879
4880
4881
4882
4883
4884
4885
4886
4887
4888
4889
4890
4891
4892
4893
4894
4895
4896
4897
4898
4899
4900
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
4914
4915
4916
4917
4918
4919
4920
4921
4922
4923
4924
4925
4926
4927
4928
4929
4930
4931
4932
4933
4934
4935
4936
4937
4938
4939
4940
4941
4942
4943
4944
4945
4946
4947
4948
4949
4950
4951
4952
4953
4954
4955
4956
4957
4958
4959
4960
4961
4962
4963
4964
4965
4966
4967
4968
4969
4970
4971
4972
4973
4974
4975
4976
4977
4978
4979
4980
4981
4982
4983
4984
4985
4986
4987
4988
4989
4990
4991
4992
4993
4994
4995
4996
4997
4998
4999
5000
5001
5002
5003
5004
5005
5006
5007
5008
5009
5010
5011
5012
5013
5014
5015
5016
5017
5018
5019
5020
5021
5022
5023
5024
5025
5026
5027
5028
5029
5030
5031
5032
5033
5034
5035
5036
5037
5038
5039
5040
5041
5042
5043
5044
5045
5046
5047
5048
5049
5050
5051
5052
5053
5054
5055
5056
5057
5058
5059
5060
5061
5062
5063
5064
5065
5066
5067
5068
5069
5070
5071
5072
5073
5074
5075
5076
5077
5078
5079
5080
5081
5082
5083
5084
5085
5086
5087
5088
5089
5090
5091
5092
5093
5094
5095
5096
5097
5098
5099
5100
5101
5102
5103
5104
5105
5106
5107
5108
5109
5110
5111
5112
5113
5114
5115
5116
5117
5118
5119
5120
5121
5122
5123
5124
5125
5126
5127
5128
5129
5130
5131
5132
5133
5134
5135
5136
5137
5138
5139
5140
5141
5142
5143
5144
5145
5146
5147
5148
5149
5150
5151
5152
5153
5154
5155
5156
5157
5158
5159
5160
5161
5162
5163
5164
5165
5166
5167
5168
5169
5170
5171
5172
5173
5174
5175
5176
5177
5178
5179
5180
5181
5182
5183
5184
5185
5186
5187
5188
5189
5190
5191
5192
5193
5194
5195
5196
5197
5198
5199
5200
5201
5202
5203
5204
5205
5206
5207
5208
5209
5210
5211
5212
5213
5214
5215
5216
5217
5218
5219
5220
5221
5222
5223
5224
5225
5226
5227
5228
5229
5230
5231
5232
5233
5234
5235
5236
5237
5238
5239
5240
5241
5242
5243
5244
5245
5246
5247
5248
5249
5250
5251
5252
5253
5254
5255
5256
5257
5258
5259
5260
5261
5262
5263
5264
5265
5266
5267
5268
5269
5270
5271
5272
5273
5274
5275
5276
5277
5278
5279
5280
5281
5282
5283
5284
5285
5286
5287
5288
5289
5290
5291
5292
5293
5294
5295
5296
5297
5298
5299
5300
5301
5302
5303
5304
5305
5306
5307
5308
5309
5310
5311
5312
5313
5314
5315
5316
5317
5318
5319
5320
5321
5322
5323
5324
5325
5326
5327
5328
5329
5330
5331
5332
5333
5334
5335
5336
5337
5338
5339
5340
5341
5342
5343
5344
5345
5346
5347
5348
5349
5350
5351
5352
5353
5354
5355
5356
5357
5358
5359
5360
5361
5362
5363
5364
5365
5366
5367
5368
5369
5370
5371
5372
5373
5374
5375
5376
5377
5378
5379
5380
5381
5382
5383
5384
5385
5386
5387# Equalify Iris โ€” API Guide (bash / curl)

Every endpoint is under `/v1`. All responses are JSON unless noted. **No endpoint takes a GitHub
token** โ€” Iris holds its own and you never see it ([Authenticate](#authenticate)). Whether you send
anything at all depends on the deployment: where the operator set `server.api_token`, `/v1/me` and
`/v1/sessions` need `Authorization: Bearer <that shared secret>`; where they did not, send no header.
`/v1/health`, `/v1/stats` and `/v1/limits` never need one. `/v1/quality` is the exception in the other
direction: it has its own separate secret
([Quality tally](#quality-tally-shared-secret-off-by-default)).

These commands are copy-pasteable. They are the same calls exercised by `test/e2e.sh`, which
runs the whole lifecycle against mock GitHub + mock model services and asserts every response.

Requests are rate limited per client, and every response says how much of the budget is left:
[Rate limits](#rate-limits-how-often-you-may-ask) has the numbers, the headers, and what a `429` looks like.

`fragment`, `block`, `verdict`, `declaration` and `round` all mean something specific here, and
[README ยง Terms](../README.md#terms) defines them. These ones appear below in both of their senses:

- **`round`** โ€” a round of the reader/editor loop, or one captured run of a corpus. The text says
  which: a **bench round** or a **deployed round**.
- **`fragment`** โ€” one page's extracted HTML, or the `#id` part of a link. The second sense is what
  `links_unresolved_rate` under [Quality tally](#quality-tally-shared-secret-off-by-default) is about.
- **`block`** โ€” one top-level element of the assembled document, which is what
  `@editor-truncated blocks B of T` counts, or a group of table rows: the "header block" in
  `table_no_body_rate` is a `<thead>`.
- **`declaration`** โ€” the page agent's claim that a page holds no content, except on the
  [run log](#run-log)'s `page_main_stripped` line, where it is the `lang` declaration on the
  document's root element.

`verdict` has one sense here: the Feedback Agent's decision about one page.

```bash
export BASE=http://localhost:8080/v1
```

## Health (unauthenticated)

```bash
curl -s "$BASE/health"
# {"status":"ok","service":"equalify-iris","version":"1.0.0"}
```

`version` is the running build's, so this is how you check a deployment is the one you meant to
deploy. It is never gated and never rate limited โ€” the container's own `HEALTHCHECK` polls it.

## Public tally (unauthenticated)

How much this deployment has actually made accessible. No token: the browser app shows it to
visitors before anyone signs in, and it is the number the project celebrates with.

```bash
curl -s "$BASE/stats"
```
```json
{ "pages_processed": 1284, "documents_processed": 212, "since": "2026-05-22T18:00:00.000Z",
  "quality": { "window_days": 30, "documents": 212, "clean_rate": 0.93, "mean_rounds": 1.8 } }
```

* `pages_processed` โ€” **distinct page images** Iris has converted to accessible HTML. A session
  counts once it has reached `ready_for_review`, so an upload that has **never** reached it โ€” still
  queued, still running, or failed on its first run โ€” is not in the number, and a feedback re-run
  does **not** count its pages again: this is a tally of pages made accessible, not of model calls.
  Note the asymmetry with a `failed` status: a session that completed once and then failed a re-run
  stays counted (see "the number only ever goes up" below).
* `documents_processed` โ€” sessions counted, on the same basis. A 40-page PDF is one document and
  forty pages.
* `since` โ€” when the earliest counted document finished, or `null` before anything has, so a
  client can say "since May 2026" without hardcoding a launch date.
* `quality` โ€” how *well* it went, or **`null`** when the deployment has too few recent documents to
  say (below). Present either way, so a client can tell "nothing to report" from an older server
  without the field. Four fields, and no more:
  * `window_days` โ€” the window the two rates cover, echoed so a client can write "over the last 30
    days" without hardcoding it. Fixed at 30 and **not** caller-adjustable: this endpoint takes no
    parameters, which is what keeps its single shared cache entry correct for everyone, and a public
    `?days=` would let anyone narrow the window until the denominator was one document.
  * `documents` โ€” the denominator: documents delivered inside the window, flawless ones included.
    Distinct from `documents_processed`, which is all-time.
  * `clean_rate` โ€” share (0โ€“1) of those documents the **reviewer** read in full and reported nothing
    left open on. Stated the positive way round because this one is read by someone deciding whether
    to trust Iris with a file. It is 1 โˆ’ (the
    [quality tally](#quality-tally-shared-secret-off-by-default)'s `unresolved_rate` โˆช
    `review_unread_rate`), not the complement of the first alone: a document part of which the
    reviewer never answered about has nothing open because nothing was looked for, and counting that
    as clean is what made this field worth doubting (#186). Note also what it is *not*: it is the
    Reader Agent's remaining-issue list, not the final axe result, so a document carrying a
    violation the reviewer never raised still counts here. The
    [quality tally](#quality-tally-shared-secret-off-by-default) can see that gap (its `rules` come
    from the final lint of the same run); this field cannot, which is why the demo page's sentence
    credits the reviewer rather than saying the document came out clean.
  * `mean_rounds` โ€” mean reader/editor passes per document. **0 is the good value:** the loop stops
    as soon as the Reader finds nothing, so a document that reads clean immediately contributes 0.
    It is not *only* a good value, though, and this number cannot tell the two apart: the loop also
    stops as soon as a round changes nothing, so a document whose remaining issues are ones the
    loop is designed not to fix contributes a low count too. Read it beside `clean_rate`, which
    convergence can only move DOWNWARD โ€” stopping early can report issues a further Reader sample
    might have called clean, never the reverse. So
    a falling `mean_rounds` beside a steady `clean_rate` is the loop wasting fewer rounds; a
    falling `mean_rounds` is not by itself evidence of anything improving.

`quality` is `null` until the window holds at least 20 documents (`PUBLIC_QUALITY_MIN_DOCUMENTS`),
and that floor is a privacy control, not a presentation choice. A rate over three documents is not
a measurement โ€” one bad afternoon reads as "67% clean" on a front page โ€” and, more to the point, on
a quiet deployment the aggregate *is* the individual: "50% finished clean" next to a document count
of four is a statement about identifiable people's uploads. The floor is enforced in
`Store.publicQuality`, not in the route, so a future route edit that reads the fields it wants
cannot walk around it. Below the floor the answer is `null` rather than zeros, because a route has
no way to tell a real 0% from an absent one and "0% clean" is the worst claim the field can make.

**The floor bounds a snapshot, not a series of them.** `documents` and `clean_rate` together give an
exact integer count of not-clean documents, and `documents_processed` deltas were already
inferrable before `quality` existed โ€” so an observer polling this endpoint on a deployment near the
floor can difference the readings over days and attribute not-clean status to a single document,
which is the inference the floor exists to prevent. Nothing identifying is exposed (no login, no
filename, no per-document timestamp), so out-of-band knowledge of who uploaded when is needed for it
to mean anything, and it is inherent to publishing any windowed rate rather than specific to these
fields. An operator for whom that matters should treat it the same way as the page-count delta above:
keep the endpoint off the public internet.

Volume is all-time while quality is windowed, which is deliberate rather than an inconsistency: an
all-time rate converges and stops responding to a fix, while an all-time page count is the
achievement being reported. No rule ids here, unlike the
[quality tally](#quality-tally-shared-secret-off-by-default) โ€” a standing list of what Iris still
fails at belongs in front of the people who would fix it, not on a public page.

The number only ever goes up. It is derived from a write-once `first_completed_at` stamp rather
than from current `status`, precisely so that asking Iris to re-run a finished document โ€” which
moves it back to `queued`, and possibly on to `failed` โ€” cannot make the public count dip. For
databases that predate that column, sessions already in `ready_for_review`/`closed` are backfilled
from `updated_at`, so nothing already converted is dropped from the count.

*One session is missed by that backfill, once:* one that had completed and was **mid-re-run** at the
moment the upgraded build booted. It is not `ready_for_review`/`closed` at that instant, and the
startup sweep then marks it `failed`, so it is excluded for good rather than until the re-run ends.
Counting in-flight sessions instead would be worse โ€” it would credit first runs that had produced
nothing โ€” so the tally undercounts by that one document. Only the upgrade moment is affected.

Everything here is a deployment-wide aggregate: no session ids, logins, user ids, filenames or
content. It is not, however, free of per-upload information: the **delta** between two reads is one
โ€” `documents_processed` +1 with `pages_processed` +40 means a 40-page document finished in that
window, and on a quiet deployment the aggregate is the individual. Nothing identifying follows from
it (no who, no what), but an operator who treats document sizes as sensitive should keep this
endpoint off the public internet. Responses are cached for 60 seconds
(`Cache-Control: public, max-age=60`), which coarsens *when* a conversion shows up but not the page
count โ€” so a page you just converted may take up to a minute to appear.

## Quality tally (shared secret, off by default)

How *good* the output has been, as opposed to how much of it there was. Iris already measures
itself on every run โ€” how many reader/editor rounds a document needed, which axe-core rules its
HTML still violates, whether a link from the source went missing โ€” and this is the only place
those measurements are readable across sessions.

It exists for one caller: `.github/workflows/quality-report.yml`, which reads it weekly, compares
the rates against thresholds held in that workflow, and files a GitHub issue when one is crossed.

```bash
curl -s -H "Authorization: Bearer $IRIS_QUALITY_TOKEN" "$BASE/quality?days=30"
```
```json
{
  "window_days": 30,
  "documents": 212,
  "since": "2026-07-14T00:00:00.000Z",
  "mean_rounds": 1.8,
  "unresolved_rate": 0.061,
  "first_read": { "documents": 212, "mean_issues": 2.4, "unread_documents": 2 },
  "unresolved_severity": [
    { "severity": "high", "documents": 1 },
    { "severity": "medium", "documents": 4 },
    { "severity": "low", "documents": 12 },
    { "severity": "unrated", "documents": 0 }
  ],
  "review_stopped": [
    { "where": "clean", "documents": 197 },
    { "where": "unread", "documents": 2 },
    { "where": "converged", "documents": 11 },
    { "where": "truncated", "documents": 2 },
    { "where": "cap", "documents": 0 }
  ],
  "links_dropped_rate": 0.02,
  "links_unresolved_rate": 0.11,
  "markup_unbalanced_rate": 0.01,
  "table_no_body_rate": 0.005,
  "structural_defect_rate": 0.09,
  "lint_error_rate": 0.01,
  "lint_error_where": [
    { "where": "parse", "documents": 0 },
    { "where": "inject", "documents": 0 },
    { "where": "run", "documents": 2 }
  ],
  "documents_linted": 210,
  "editor_truncated_rate": 0.01,
  "editor_truncated_lost_rate": 0.002,
  "editor_headings_gated_rate": 0.014,
  "review_unread_rate": 0.01,
  "unfinished_page_rate": 0.03,
  "rules": [
    { "id": "heading-order", "impact": "moderate", "documents": 81, "share": 0.382, "nodes": 240 }
  ]
}
```

* `documents` โ€” delivered documents in the window, **including flawless ones**. This is the
  denominator for every `_rate` below (the rule table divides by `documents_linted` instead, for the
  reason given there), and it is the whole reason the tally is stored the way it is: a
  clean run produces no violation rows, so counting "documents that had a problem" would divide by
  the bad documents alone and report every rate near 100%.
* `mean_rounds` โ€” mean **editor** passes per document, against `defaults.max_review_iterations`. The
  loop stops as soon as the Reader finds nothing, so a document that reads clean on the first look
  contributes `0`: low is good, and `0.0` across the window means nothing needed fixing. `null`,
  not `0`, when nothing has run โ€” otherwise an empty deployment reports the best possible score.
  It also stops as soon as a round changes nothing: an editor that answers and returns the document
  it was given has said what it would say to the same request next round, so the remaining rounds
  would rewrite the document into itself. That means a low `mean_rounds` beside a non-zero
  `unresolved_rate` is now an ordinary reading rather than a contradiction โ€” the document stopped
  early *because* what was left could not be fixed here, not because it was fixed.
* `unresolved_rate` โ€” share of documents that finished with issues the review loop could not
  resolve. Only the **count** of those issues is used, never their text โ€” see below. One class of
  issue is deliberately never resolved and so always lands here: two headings the document labels
  alike where nothing the copy editor was given says whether they are one section or two. It is
  reported and left standing rather than guessed at, because merging two real sections cannot be
  undone โ€” so a document whose only remaining issue is one of these raises this rate while its
  `mean_rounds` stays low: the first round that leaves it alone changes nothing and the loop stops
  there. That is the honest reading โ€” it shipped with an ambiguity a reader meets, and the rounds it
  did not spend would each have rewritten the document into itself.
  A `[not legible]` marker can end the same way: the copy editor is usually given that page's image
  and may well read what the extractor could not โ€” usually, because the per-round image budget
  (`capEditorImages`) and a provider that refuses a request for size both leave it with fewer images
  than the issues named, or none. Where the image is absent, or the marks do not resolve for it
  either, the marker stays and the issue is reported unresolved every round. That is the source page being
  unreadable, not the pipeline failing to try, and the alternative โ€” a plausible word, or a quiet
  deletion โ€” is the one outcome a reader cannot detect. A `[page not fully transcribed]` marker
  always ends this way, by design: no pass in the review loop can resolve it, because finishing a
  page means transcribing that page from its image, which is a re-extraction โ€” a pass with a whole
  response for one page and its own gates on what came back โ€” and not a correction to the markup
  around it. What the copy editor would produce instead is a paragraph written while looking at a
  page, delivered where nothing downstream can tell the two apart. So it is reported
  and left standing, and it
  raises this rate for a document that is otherwise sound โ€” but only for as many rounds as it takes
  the editor to leave the document alone once, which is what now ends the loop. Read it as what it is โ€” one page
  arrived short, and the document says where.
  Everything in the two paragraphs above is a claim about a **floor** under this rate, and the two
  fields that follow, plus `unfinished_page_rate` below, are what measure it. Until #264 nothing did,
  which is how a threshold of 15% came to be compared against a rate of 84% with no way to tell
  which part was inherent.
* `first_read` โ€” what the reviewer **found**, before any of it was fixed. Every other number in this
  tally is taken after the editor has run, which makes the two facts a reviewer change most needs to
  be told apart arrive identically: an editor that fixed everything and a reviewer that faulted
  nothing both deliver an empty `@unresolved` list and a `clean` exit, and both *lower*
  `unresolved_rate`. This is the one field that separates them.
  * `mean_issues` โ€” mean issues raised by the **first** read of a document, averaged over the
    documents that recorded one. The first read specifically, and not a sum over rounds: every later
    round reads a body the editor has already rewritten, so a total would measure how many rounds
    ran as much as what the reviewer saw, and the first read is the only one taken on extraction's
    own output. A document the reviewer cleared contributes `0` โ€” that is the observation, not a
    missing one โ€” and the field is `null`, not `0`, when no document in the window recorded a read.
    A feedback re-run ([Submit feedback](#submit-feedback-re-run)) does **not** replace it, unlike
    every other value in this tally: a document-level re-run re-reviews the body already delivered,
    so its first read is on bytes the copy editor has rewritten and would land here as a smaller
    number for a reason that is not the reviewer's. A re-run that re-extracts does replace it,
    because that read *is* on fresh extraction output. One consequence worth knowing when reading
    this across a model change, which is its stated use: a carried-forward count is **re-dated** to
    the re-run, like every other row for that session, so a document converted before the change and
    given document-level feedback after it contributes the old reviewer's number to the new window.
    The alternative is worse โ€” keeping the original date would drop the row out of the window while
    the document itself stays in it, and `first_read.documents` would fall short for a reason that
    is not a missing measurement. [`first_read_carried`](#first_read_carried) in the run log names
    every document this happened to.
  * `documents` โ€” how many documents recorded a first read, which is the denominator `mean_issues`
    was divided by. Compare it with `documents` at the top of the response: it is the same on a
    window whose runs all pass through the review loop, and short of it otherwise.
  * `unread_documents` โ€” of those, how many had at least one window of that first read come back
    unusable. This is the error bar on `mean_issues` rather than a defect rate: on those documents the
    count is a floor, so a fall in `mean_issues` with this number rising is a reviewer that could not
    answer, and a fall with it flat is a reviewer that found less. Distinct from
    `review_unread_rate` below, which is about the **last** read โ€” the one taken on the bytes that
    shipped. A document can be counted here and not there, and the reverse.
* `unresolved_severity` โ€” how the Reader rated what was left open, one entry per severity and always
  all four including the zeroes. **Per document, and not a partition of `unresolved_rate`**: a
  document with three `low` issues and one `high` is one entry in each of those two, so the counts
  can sum to more than `unresolved_rate ร— documents`. This is the field that says whether the rate
  above describes a defect โ€” it counts documents that shipped with *anything* open, and `high` is
  the part of it a reader of the document would call a barrier. `unrated` is not a fifth severity
  but the Reader having written something outside the three, or nothing; the severities are
  model-written and unvalidated, so anything unrecognised is bucketed there rather than published
  as found.
* `review_stopped` โ€” which of the review loop's exits ended each document, one entry per reason and
  always all five. Recorded for **every** delivered document, so unlike `unresolved_severity` these
  *are* a partition: on a window where every run recorded one, the counts sum to `documents`.
  `clean` is the only exit that re-read the
  finished document and found nothing. `converged`, `truncated` and `cap` each deliver an
  `@unresolved` list, so those three are the documents `unresolved_rate` counts โ€” in the example
  above, 11 + 2 + 0 of 212, which is the 0.061 beside them. `unread` is the one exit that stops with
  an EMPTY list and is still not clean, which is why it has a rate of its own
  (`review_unread_rate`) and why the document says so under `@review-unread`. Which exit it was is
  which fix is being asked for โ€” `cap` is a config number
  (`defaults.max_review_iterations`), `converged` is a prompt, `truncated` is an output ceiling,
  `unread` is a reviewer that could not read part of what it was judging. The `cap`/`converged`
  split is the one that could not be had before: from outside the loop they are the same shape, and
  only `cap` is something more rounds would help. A sum **below** `documents` means either
  documents delivered before this was recorded or an exit added to the loop with no reason attached
  โ€” never a sixth kind of exit. Where they differ, this breakdown describes the documents it sums
  to and **not** the window: a rate read against `documents` and a split read against the attributed
  subset are two denominators, and the split does not scale up to the rate.
  * **And it is the split of `unresolved_rate` #264 asked for.** That rate counts a document that
    was re-read and still had problems, and a document whose open list may predate the bytes that
    shipped, as the same thing. On `cap` and `converged` the `@unresolved` list was read on the
    delivered bytes โ€” the loop re-reads at the top of every round and both exits are taken before
    the next editor call โ€” so an open issue there is an open issue in the delivered document.
    `truncated` is the one exit where the list may be older than the document: the editor's reply
    was cut off, what the reply had already said and the sectioned retry may between them have
    corrected most of the body afterwards, and the round that would have re-read it is the one that
    could not be made (`src/pipeline/review.ts`). So it over-reports there on purpose, and a
    truncation that rescued nothing over-reports not at
    all โ€” a distinction this tally cannot draw and the delivered document can
    (`@editor-truncated blocks B of T`, `sections N of M`). Those are a claim about the document and a claim about
    the round: read `cap` + `converged` as the part of the rate that is about the document, and
    `truncated` beside `editor_truncated_rate` and the output ceiling. One threshold over both
    cannot be set honestly, which is why the weekly report's is still on the mixture and says so.
* `links_dropped_rate` โ€” share of documents where an `href` present before the copy editor was
  missing after it.
* `links_unresolved_rate` โ€” share of documents that shipped with an in-document reference that
  lands nowhere: an `href="#"`, or a fragment naming an `id` the delivered document does not
  contain. Counted per document; the per-reference numbers, and *which* ids failed, are on the
  deployment's `internal_links` log line and stay there, because a fragment is text the model chose
  out of the document. This is a different defect from `links_dropped_rate` โ€” nothing was lost, the
  target was never there โ€” and a document can have either without the other. Neither shape is an
  axe violation, which is why it needed measuring at all: a table of contents where every entry is
  `href="#"` lints clean, reads as finished, and does nothing when a reader activates it. `#` and
  `#top` are excluded, being the two fragments a browser resolves without an element.
* `markup_unbalanced_rate` โ€” share of documents delivered with markup that does not balance: an
  element whose end tag HTML **requires** (`table`, `ul`, `a`, `section`, โ€ฆ โ€” never `<li>` or
  `<tr>`, whose end tags are optional and legally omitted) appearing a different number of times as
  a start tag and as an end tag. The only rate here measured on the delivered **bytes**, and it has
  to be: an HTML parser repairs malformed markup before axe is given the document, so a document
  delivered with an unclosed `<table>` lints clean and reports `ready_for_review`. Which element,
  and the two counts, are on the deployment's `delivered_markup` log line โ€” what the imbalance costs
  a reader depends entirely on which element it was.
* `table_no_body_rate` โ€” share of documents delivered with at least one table holding no row a
  reader receives as content: no rows at all, no row outside a declared `<thead>`, or โ€” where no
  header block was declared โ€” no row that is anything but column headers. A screen reader
  announces the table, reads its caption and every column header, and there is nothing in it. A
  table whose body cells are all `<th scope="row">` is content and is not counted. Measured on the **parsed** tree, unlike the rate above,
  because this is a question about what a reader receives and parser recovery is part of that. No
  axe rule covers it โ€” `empty-table-header` is about a header *cell* with no text โ€” and the captions
  stay on the deployment's log line, a caption being text out of the user's own document.
* `structural_defect_rate` โ€” share of documents that shipped promising a reader something the
  document does not contain: an `aria-labelledby`, `aria-describedby` or `label[for]` naming an
  absent `id`; a `<dl>` with terms and no definitions; or a `<nav>`, `<aside>` or named `<section>`
  holding nothing a reader receives. Three checks under one rate because they fail identically from
  the outside โ€” nothing is malformed, so the gate returns clean and the run reaches
  `ready_for_review` anyway. axe reports the dangling ARIA reference as `incomplete` and never as a
  violation (`aria-valid-attr-value` is `reviewOnFail`), `<dl><div><dt>Term</dt></div></dl>` passes
  `definition-list` outright because the wrapper is legal HTML, and an empty `<nav>` breaks no rule
  at all. Which class fired, and the elements it fired on, are on the deployment's
  `delivered_structure` log line โ€” as is `lang_on_void`, a language tag on an element with no text,
  which is measured there but is deliberately **not** in this rate: it is wasted output rather than
  something a reader loses.
* `lint_error_rate` โ€” share of documents whose lint pass **errored** instead of running. Recorded
  explicitly rather than inferred, because a linter that cannot run has no violations to report and
  a broken one would otherwise read as a deployment that got better. Those documents are delivered
  with **no accessibility verdict at all**: the run's log line carries `lint_ok: false` and *no*
  `violations` figure (the count in a check that did not happen is unknown, not zero), and the
  delivered document carries an `@lint-unavailable` comment saying so to whoever opens it. The
  `assembly` line โ€” and `lint_unavailable`, if a later correction round's re-lint is the one that
  failed โ€” carries `lint_error` with `lint_error_where` (`parse`, `inject` or `run`),
  `lint_error_name` and the first frames of `lint_error_stack`, which is what makes one occurrence
  chaseable; `axe-core` and `jsdom` are pinned to exact versions so the linter's behaviour changes
  only when someone changes it. One cause of this rate is now prevented rather than reported: an
  attribute name the selector engine cannot compile used to take the whole rule set offline, so the
  lint drops **those** names from its own copy of the document before axe walks it and reports them
  on the line instead (`malformed_attributes_removed`, with `malformed_attributes` counting every
  malformed name whether or not it had to go, and `lint_debris` where there is no `assembly` line โ€”
  #257). A run with `malformed_attributes_removed` set and no `lint_error` is a document that used
  to be delivered unchecked.
* `lint_error_where` โ€” which of the three steps failed on those documents: `parse` (jsdom refused
  the assembled HTML), `inject` (axe's own source would not evaluate, which is a dependency problem
  and cannot depend on the document) or `run` (the rule pass threw while walking the document).
  Always all three entries, including the zeroes, so "measured and none of these" is distinguishable
  from a deployment too old to record it โ€” the same distinction `documents_linted` preserves for the
  rule shares. Published because the per-step detail otherwise exists **only** in one session's run
  log, and a run log cannot answer a question about the deployment: it belongs to a single user's
  document. #263 reported six documents with no verdict and no way to tell whether the cause was the
  one #257 had just fixed or a new one, which is how a fixed cause and a live one come to look the
  same in a weekly report. The counts may sum to **less** than `lint_error_rate ร— documents`, and the
  shortfall is documents linted before this was recorded โ€” not a fourth kind of failure. The error
  message and stack stay out of this endpoint on purpose: a parse failure quotes the markup it choked
  on, and this response is copied into a public issue.
* `documents_linted` โ€” how many of `documents` the linter actually examined, i.e. `documents` minus
  the `lint_error_rate` ones. This is the denominator for `rules[].share`, and it is published
  because otherwise that share cannot be read: an unexamined document looks exactly like one where
  the rule did not fire, so a spell of failing lints would make every rule appear to be getting
  fixed. When it is well below `documents`, the rule table is a measurement of a subset โ€” fix that
  before reading the rules.
* `editor_truncated_rate` โ€” share of documents where a correction round's **response** hit the
  model's output-token ceiling. The editor answers with the blocks it changed, so its output
  length follows how much of the document is wrong rather than how long the document is (#250);
  what remains is a model that returns more than it was asked for. Under the contract this rate
  was defined against โ€” the whole corrected body, every round โ€” output length followed document
  length alone, so at a large `max_pages` an ordinary document doing exactly what it was told
  could exceed a fixed `max_tokens`. Such a round is not thrown away. What the reply had already
  said is **read**: the contract makes it a list of independent block edits, so every edit that
  finished arriving is applied, and the round covers the document up to the last block it named โ€” or
  up to the first block the reply handed back with less content in it than it had, whichever comes
  first (`editor_salvaged`, `lost_at`). Only the part it never reached, or the part behind that
  block, is asked for again, **a section at a time** โ€”
  cut at top-level boundaries into pieces sized from what the truncated response actually returned โ€”
  and the loop then stops either way. So the document may carry that round's own corrections for
  part of it, section corrections for the rest (from requests that each saw one section, so a
  problem spanning two of them may be untouched), or none at all where neither route worked; the
  delivered `@editor-truncated` comment says which, and `editor_salvaged` / `editor_sections` in the
  run log say how much each covered. This rate counts the ceiling being hit, whatever was rescued afterwards,
  and those documents are also counted in `unresolved_rate` โ€” the issues in the `@unresolved`
  block are the reading that preceded the truncated round and were never looked for again.
  A non-zero value is a statement about the **deployment**, not about the documents: either
  `providers.<name>.max_tokens` is too low for the pages allowed per session, or `max_pages` is
  too high for it. It is deliberately the one rate here with **no threshold** in
  `.github/workflows/quality-report.yml`, and the rate below is why: a reply too long for the
  ceiling is a property of the document and of what the model chose to return, not of whether
  anything was lost, so a rise in it can be a rise in nothing but document length โ€” under the
  whole-body contract it went 1/4 โ†’ 2/4 โ†’ 3/4 across three bench rounds in which every section
  came back โ€” and an alarm on it would fire on a pipeline that lost nothing (#159).
* `editor_truncated_lost_rate` โ€” share of documents where neither route covered the whole body. The
  reply's own prefix covers the blocks it reached, so what this counts is the **remainder**: a reply
  whose edits list closed, or that named the document's last block, leaves none and costs a reader
  nothing (`editor_salvaged`, `closed`/`reached`) โ€” unless it gave content up on the way, in which
  case the claim was cut back to that block and the remainder starts there however far the reply got
  (`lost_at` on the same line). Where there is a remainder, either the prefix
  itself was refused โ€” the run log says why on an `editor_salvage_declined` line, `reason` one of
  `no_edits_list`, `no_complete_edit`, `unknown_block`, `unreadable_edit`, `out_of_order`,
  `all_refused` or `loss_before_cut` โ€” or the sections over that remainder
  were declined or came back with nothing โ€” an
  `editor_sections_declined` line, whose `reason` is one of `unmeasured`, `budget_too_small`,
  `budget_exceeds_body`, `indivisible` or `too_many_sections` โ€” or a section truncated in its turn
  and kept the text it went in with, or a section that came back complete but with under half the
  prose it was given (`editor_section_failed`, `reason: "shrank"`). That last cause is why this
  rate is no longer only about the output ceiling: it also fires on the SHAPE of a reply โ€” one
  section answered with a sentence about itself rather than with the section โ€” and the remedy for
  that is not more `max_tokens`. The log line says which, and the two differ in what they cost a
  reader only in that the shrunk one had a reply and declined to trust it. This is
  the truncation number that carries a threshold, because it is the one that costs a reader
  something: those parts of the document had no editor pass at all, and a truncation is the loop's
  last round, so nothing looks for their issues again. A strict subset of `editor_truncated_rate`
  above and of `unresolved_rate` โ€” it adds no documents to this tally, it says *why* those
  documents ended where they did, and the remedy it points at is a `max_tokens` or `max_pages`
  number rather than a prompt.
* `editor_headings_gated_rate` โ€” share of documents where at least one correction round had **blocks
  handed back** because applying them would have left the delivered body with **fewer headings than it
  had**, every word still there (`headings_reverted` on an `editor_patch` line, or `discarded:
  "headings_lost"` where the whole round went; #331). The one number in this tally that goes **up**
  when a guard is working: what it counts did not ship, so the document it is counted on kept its
  outline. It is a rate of *attempts*, not of damage, and per
  **document** rather than per round โ€” a document whose editor demotes a heading on three rounds
  counts once, and how many headings each of those rounds would have taken is on that session's run
  log rather than here, deliberately, because a count of a document's own structure is a description
  of the document. Read it against `review_stopped`: gated rounds are retried, so a document can
  carry this signal and still stop at `clean`, and that pairing is this rate working as intended. The
  shape to worry about is a rise in this rate beside a rise in `cap` โ€” an editor demoting headings
  every round, with nothing else in its replies, spends the whole budget and the document is delivered
  as it entered, with its issues in `@unresolved`. That is a statement about the Copy Editor's
  system prompt (`EDITOR_SYSTEM` in `src/pipeline/review.ts`) or the model behind it, not about the budget: raising `max_review_iterations` buys more of the same
  round. A rise with no `cap` beside it is the cheap case โ€” the demoted blocks were handed back and the
  rest of each reply was delivered โ€” and can also be the two known false positives: a heading correctly
  re-expressed as a `<label>`, `<caption>`, `<dt>` or `<th>`, or a reprinted title dropped in a way that
  left the prose no shorter. Neither costs the document its other corrections; each costs the one block
  it happened in, for the round it happened on โ€” with one exception, which is when the re-expression
  lands in a **different** block from the heading it replaces (the stray `<h4>` emptied, the `<label>`
  seated inside the form). Re-seating that block would print those words twice, so that round is refused
  whole and logs `discarded: "headings_lost"` with `headings_dropped` and no `headings_reverted` beside
  it. Until #376 the exception was wider and covered a block that dropped a heading and corrected **any**
  of its own words โ€” a typo fixed in the same `<div>` โ€” which is an ordinary round, moved nothing
  anywhere, and was refused entire. What is read now is where the words went, not whether they changed.

  **What a 0 here does not mean.** It is not evidence that no round demotes a heading, so it is not
  on its own a reason to retire the guard. The guard is on **one of the three paths a reply is
  applied through** โ€” the block patch, where a fall can be attributed to the block that dropped it
  and that block alone handed back. The other two adopt a reply whole: the whole-body branch and
  each section of a sectioned round (the loop's **last** round, so nothing looks at its output
  again). Both check only the size floor, which a demotion cannot move โ€” it keeps every word and
  grows the bytes โ€” so a demotion on either is applied and delivered, and this rate stays 0. That is
  still true and is now **measurable rather than only stated** (#375): both paths compute the same
  reading and log it as [`editor_navigation`](#editor_navigation) in the run log, refusing nothing,
  with a line on every delivered reply so the denominator is there too. So a 0 here paired with
  `editor_navigation` lines carrying no `headings` is a guard that has nothing to fire on; a 0 here
  paired with `headings` falls on those lines is a guard looking where the demotions are not. Which
  of the two it is is not answerable from this number, before #375 or after it โ€” it is answerable
  from that line, which is the point of having it. **And only where there are such lines**: a
  deployment whose rounds are all block patches writes none at all, and their absence is an empty
  population rather than evidence about the guard โ€” the same mistake as reading a 0 here as
  evidence, one step along. What #375 changed is that the population can be collected, not that it
  has been.
* `review_unread_rate` โ€” share of documents where part of the reviewer's last read of them came back
  **unusable**, so some of the document has no review verdict at all. The document is read in
  windows (long ones in several), and a reply that carries no issue list this code can read โ€” prose,
  an apology, `{"issues": "none"}` โ€” is a window nobody got an answer about. It is recorded because
  without it that outcome is invisible in every other number here *and reads as the best one*: no
  issues were found, so there is no `iris:unresolved` row, so the document was being counted clean.
  This is the same principle as `lint_error_rate` above โ€” an absent verdict must not count as a good
  one โ€” and it is why `clean_rate` in the [public tally](#public-tally-unauthenticated) subtracts
  both. Not disjoint from `unresolved_rate`: the windows that *did* answer may have found issues.
  The delivered document carries a `@review-unread` comment saying how many windows of how many, and
  `reader_no_output` in the run log carries the reply's size and which of the two ways it failed. A
  non-zero value is a statement about the reader model or its prompt, and that call's
  [`agent_call`](#agent_call) `output` in the run log โ€” the reply itself โ€” is where to start. The
  **last** read is the one this counts, because what ships is one reading of the body that shipped;
  `first_read.unread_documents` above is the same failure on the first read, where it is an error
  bar on that read's count rather than a gap in the delivered document's verdict.
* `unfinished_page_rate` โ€” share of documents delivered with a `[page not fully transcribed]` marker
  still in the body, i.e. documents that **could not** have finished the review loop clean whatever
  budget they were given. Not a defect rate of its own: the Reader is instructed to report every one
  of those markers with its page, and nothing in the loop is allowed to resolve one, so each of these
  documents is a guaranteed member of `unresolved_rate`. This is the measured floor under that rate
  โ€” subtract it before asking whether a threshold on it is being met. The cause is upstream of
  everything else here (a page the extractor could not return in full), so a high value is a question
  about extraction and `max_pages` rather than about review.
* `rules[]` โ€” axe-core rule ids, **per document**: `documents` is how many documents violated the
  rule and `share` is that over `documents_linted`, with `nodes` (total offending elements) alongside
  rather than folded in. One pathological scan with 400 bad headings is a worse `nodes` and the same
  `documents` as any other single failure โ€” "fails on 40% of documents" names a prompt defect,
  while "is 90% of our violations" moves when an unrelated rule is fixed.
* `window_days` โ€” the window actually used, echoed back. `?days=N` is clamped to 1โ€“365 and a
  garbled value falls back to 30, so read this rather than assuming what you asked for. Windowed
  rather than all-time on purpose: an all-time rate converges and stops responding to a fix.

**Nothing here can carry document content, and that is a hard constraint rather than a
convention.** The consumer copies these values into a *public* GitHub issue, and the documents
behind them are user uploads โ€” at the reference deployment, student records. Rule ids come from
axe-core's fixed vocabulary and are safe to publish; the review loop's unresolved-issue
descriptions are model-written prose about one person's document, which is why only their count
appears, and dropped `href`s came from the user's own PDF, which is why only their count appears.
A field added here that quoted a document would leak it through a path no reviewer of the workflow
would think to check.

**Off unless configured**, and unset means **404**, not 401: a deployment that has not opted in
does not acknowledge the endpoint at all. Set `server.quality_token`
(`IRIS_QUALITY_TOKEN`) to a long random value โ€” `openssl rand -hex 32` โ€” and restart. This token is
its own, deliberately not `server.api_token`: the caller is a scheduled workflow that needs a page
tally, and `api_token` would also give it every session's document. So **setting `api_token` does
not gate this endpoint**: it is one of the four that stay reachable on a closed deployment
([Authenticate](#authenticate)). That is deliberate โ€” closing a deployment must not silently stop
its own quality report. Responses carry
`Cache-Control: no-store` and are cached in-process for five minutes.

Two more values live in the repo that reads it โ€” the `QUALITY_URL` **variable** (the deployment's
**origin**, no `/v1`: the job appends the path, and a value carrying one produces a 404 that looks
exactly like a deployment which never opted in) and the `QUALITY_TOKEN` **secret**, byte-for-byte the
token above. Verify the pair with `gh workflow run quality-report.yml -f dry_run=true` rather than
waiting for the weekly schedule; [ci.md](ci.md)'s "Weekly quality report" section has the full procedure,
including why a green run that declines to file is the expected result on a young deployment.

## Authenticate

**There is no sign-in.** You do not create an account, run an OAuth flow, or hand Iris a GitHub
token. The deployment holds one GitHub credential of its own (`github.token`, server-side only) and
uses it for every session's feedback. Nothing you send is a GitHub credential.

That has one cost worth knowing before you use it: **your contributions are filed under the
deployment's account, not yours.** Feedback still reaches the shared agent library, and it still
improves Iris for the next person, but your name is not on it.

The operator chooses one of two modes.

**Open** โ€” `server.api_token` is blank. Send no `Authorization` header at all. This is the default,
and it is what makes the bundled browser app work with no setup.

**Gated** โ€” the operator set `server.api_token` to a shared secret and gave it to you. Send it as a
bearer token:

```bash
export AUTH="Authorization: Bearer the-secret-the-operator-gave-you"
curl -s -H "$AUTH" "$BASE/me"
```

This secret is **not** a GitHub token. It answers one question โ€” may this caller use the API? โ€” and
it makes you nobody in particular: every caller who presents it is the same deployment account, so
it is a door key, not an identity. Where a deployment is open, drop `-H "$AUTH"` from every example
below.

To find out which kind you are talking to, call `GET /v1/me` with no header: **200** means open,
**401** means gated. That answer cannot go stale, because it is the same check an upload runs.

### What the gate covers

`server.api_token` protects `/v1/me` and everything under `/v1/sessions`, and nothing else โ€” the
gate is attached to those two mounts rather than to `/v1` as a whole. Four endpoints are outside it
and stay reachable on a gated deployment, on purpose. None of them touches a document or an
identity:

| Endpoint | Why it is open |
| --- | --- |
| [`GET /v1/health`](#health-unauthenticated) | A load balancer's probe cannot hold a secret. |
| [`GET /v1/limits`](#upload-limits-unauthenticated) | Someone deciding whether their scan is small enough should not need the key to find out. |
| [`GET /v1/stats`](#public-tally-unauthenticated) | A deployment-wide tally with no per-session detail. |
| [`GET /v1/quality`](#quality-tally-shared-secret-off-by-default) | Has its own separate token, and is 404 unless the operator set it. |

*Operators:* an earlier build stored a token per user, in a `github_token` column. There is no
migration โ€” delete any `data/iris.sqlite` from before that change. The service refuses to start
against such a file rather than adopting it, since it would still hold live plaintext tokens.

## Current user

Despite the name, this describes the **deployment**, not you. There is one identity here and every
caller reaches it.

```bash
curl -s -H "$AUTH" "$BASE/me"
```
```json
{
  "github_login": "iris-tester",
  "github_user_id": 4242,
  "upstream_repo": "https://github.com/example/iris",
  "defaults": { "max_review_iterations": 3 }
}
```

`github_login` is the account this deployment files contributions as. `upstream_repo` is where it
files them. `defaults.max_review_iterations` is the review cap every session gets. There is no
`fork_repo` field: contributions are filed as issues, so no fork is ever created.

**Call this first.** It is the cheapest way to find out whether the deployment works at all, because
it runs exactly what an upload runs:

| Answer | What it means |
| --- | --- |
| **200** | Usable. If you sent no header, the deployment is open. |
| **401** `This deployment requires a shared API token.` | Gated โ€” you need `server.api_token` from the operator. |
| **401** `This deployment could not authenticate to GitHubโ€ฆ` | The operator's `github.token` is wrong, revoked, or GitHub is down. Not your problem to fix; tell them. Iris retries GitHub 30 seconds after a failure, so a transient one clears itself. |
| **500** `github.token is not configuredโ€ฆ` | The operator never set it. |

## Create a session (upload images)

`multipart/form-data`. Repeat `images` once per file; **the order of the parts is the
processing order** (not the filename). `images` is the only part the endpoint reads โ€” there are
no per-session options. (A `config` part used to override `max_review_iterations` for one
session; it was removed, and sending one now is ignored rather than an error. The cap comes from
your account default, seeded from the deployment's `defaults.max_review_iterations` โ€” see
[`GET /v1/me`](#current-user).)

```bash
create=$(curl -s -X POST -H "$AUTH" "$BASE/sessions" \
  -F "images=@page-001.png" \
  -F "images=@page-002.png")
echo "$create"
export SID=$(echo "$create" | jq -r .session_id)
```
```json
{ "session_id": "ses_01HXYZ...", "status": "queued", "image_count": 2, "created_at": "..." }
```
Accepted file types: PNG, JPEG, GIF, WebP, **and PDF**. A PDF is rasterized server-side into
one image per page (in page order) and processed like any other page sequence. Total pages
(across all parts) are capped per deployment.

Each **image** part also has a size limit, and an upload over it is rejected here with a `400`
rather than accepted and failed later. The limit is not Iris's own: an uploaded image is passed
to the vision model byte for byte, so the model's per-image cap is the cap โ€” currently **5 MB
base64**, which is **3.75 MB on disk**, on Amazon Bedrock. Ask the deployment instead of
assuming, since it moves with the configured model and provider:
[`GET /v1/limits`](#upload-limits-unauthenticated). A **PDF** is not measured against that
limit โ€” the file you send is not what reaches the model, since Iris rasterizes its pages at its
own resolution โ€” but each *rendered page* is, and a page over it fails with a `400` naming the
page and the PDF. That happens with large-format pages: rasterizing at a fixed DPI means the
page image scales with the physical page, so a letter page renders well inside the limit and an
ARCH-D drawing does not.

Pixel dimensions are mostly **not** a limit worth planning around, and this is the common
misdiagnosis: a large-but-light image converts fine, while a small-but-heavy photo is what
fails. The model downscales anything over its long-edge limit (1568 px on Sonnet 4.6, 2576 px
on Claude 4.7 and later) before reading it, so extra pixels buy no fidelity โ€” they only spend
bytes against the cap. Re-saving a 12-megapixel scan at 1568 px on the long edge, or as a JPEG,
loses nothing the conversion would have used. There is one hard ceiling, `max_dimension_px`
(8000 px on either edge): above it the model rejects the request outright rather than
downscaling, so Iris rejects it here instead, reading the dimensions from the file's header.

Both statements in that paragraph are documented for Claude, which is what a deployment normally
runs. One that runs a vision model Iris has no published limits for gets the same two numbers
enforced the same way โ€” they are the conservative end of what is known โ€” but they are then
stand-ins rather than facts about it: nothing promises that the model discards the pixels above
the long edge, and the 8000 px ceiling is Iris's rule rather than a refusal it has seen. This is
why `hint` is the thing to quote ([Upload limits](#upload-limits-unauthenticated)): it is written
from whichever of the two holds.

### Upload limits (unauthenticated)

`GET /v1/limits` โ€” unauthenticated. What this deployment accepts, resolved from the model and
provider it is configured to use, so a client never has to hardcode numbers that change when
the model does. The demo page states its file limits from this endpoint.

```bash
curl -s "$BASE/limits" | jq
```
```json
{
  "max_pages": 25,
  "image": {
    "max_bytes": 3932160,
    "max_long_edge_px": 1568,
    "max_dimension_px": 8000,
    "media_types": ["image/png", "image/jpeg", "image/gif", "image/webp"],
    "extensions": [".png", ".jpg", ".jpeg", ".gif", ".webp"],
    "hint": "Each image must be under 3.7 MB and in one of PNG, JPEG, GIF, WEBP format. โ€ฆ"
  },
  "pdf": { "max_pages": 25 },
  "upload": { "max_files": 25, "max_request_bytes": 134217728 },
  "rate_limits": {
    "general_per_minute": 240,
    "upload_per_minute": 12,
    "max_upload_memory_mb": 256,
    "window_seconds": 60
  }
}
```

`max_bytes` is what `POST /v1/sessions` enforces per image part, and `hint` is the same sentence
its `400` carries โ€” quote it rather than composing your own, and the two cannot disagree.
`max_bytes` and `max_dimension_px` are both enforced โ€” the second only when the dimensions can be
read from the file's header, since a header Iris cannot parse must not become a rejection;
`max_long_edge_px` is advice, not a limit โ€”
nothing rejects an image for exceeding it, because the model downscales past it instead of
failing. If a client can only surface one number, surface `max_bytes`: it is what nearly every
rejected upload will have broken. The model and provider that produced these numbers are
deliberately not named here; that is deployment detail, and this endpoint answers a question
about files.

That last point is also why the advice about pixels lives in `hint` rather than in a field of its
own. A deployment may run a vision model Iris has no published image limits for, and then the two
pixel numbers are its conservative stand-ins rather than facts about that model โ€” the same values,
enforced the same way, but not something to tell a user they can rely on. `hint` is written from
whichever of the two situations holds, so quoting it is always accurate; composing your own
sentence from `max_long_edge_px` is what can go stale, and this is a second reason not to.

`upload` is what one **request** may be, as opposed to what one image may be: `max_files` parts and
`max_request_bytes` across all of them. They are refused at different moments, which matters if you
are streaming: the byte total is checked before the body is read (or counted as it arrives, when the
request declares no length), while the part count is refused during parsing, once a part past
`max_files` appears. `rate_limits` is how often you may ask
([Rate limits](#rate-limits-how-often-you-may-ask)), and is `null` on a deployment that does not
limit requests in the app โ€” which means "not limiting", not "unknown".

A PDF's **links survive**, which rasterizing alone would not manage: a link is an annotation
over the page rather than something drawn on it, so the page image carries the link text and
none of its target. The link targets are read out of the file separately and given to the page
agent as ground truth, and the output's `<a href>`s are checked against them โ€” the run log
carries a `page_links` line per page that had any, and `page_links_missing` /
`page_links_unrecovered` when one did not make it into the HTML. Three kinds are dropped on
purpose: links to a destination inside the same document (the page they point at is in the
delivered HTML already); any URL whose scheme is not `http(s)`, `mailto`, `tel`, or `ftp` โ€” a
PDF can carry a `javascript:` action, and that is not something to re-emit into a document โ€”
and any URL containing a character that would end the attribute it is written into (a quote,
`<`, whitespace), which no legitimate URL carries unencoded. A link over an image with no text
under it has nothing to attach to and is lost.

**A placeholder is not a description.** `alt="image"` satisfies every machine-checkable rule
there is โ€” `image-alt` asks whether the attribute is *present* โ€” and it tells a reader who
cannot see the image nothing at all. So a closed list of words that name the medium rather than
the content (`image`, `photo`, `figure`, `logo`, `screenshot`, `placeholder`, `null`, and about
twenty more) is checked against every `alt` on every page, in code, and a page that carries one
is sent back to the page agent with the image and asked to describe it โ€” or to write `alt=""` if
the image carries nothing a reader needs. `page_generic_alt` is the finding,
`page_generic_alt_unrecovered` says the correction did not clear it, and
`extraction_complete.alts_generic` is the count over the fragments the document is assembled
*from*, beside `alts_checked` as its denominator. The delivered bytes are a separate line,
`delivered_alt`, for the reason `delivered_markup` is measured there too: the review loop runs
after extraction and replaces a top-level block's markup wholesale, `<img>` and its `alt`
included, so a copy-edit round that guts an alt ships a placeholder the extraction counts never
saw. It is deliberately **not** a length rule: the shortest alts
this pipeline legitimately writes are `"M"`, `"Home"` and `"Meta"`, all of them logos, where one
word is the correct answer โ€” and `alt=""` is left alone, because an empty alt is a valid
statement that an image is decorative. Measured over 1,064 non-empty alts across 32 bench run
directories, the rule flags nothing the page agents wrote (issue #290). It exists because this
was the one defect class where the only thing watching was the most expensive model in the
deployment: the verifier catches a gutted alt 6 times out of 6, and the cheaper models it may be
swapped for catch it 0โ€“2 times out of 6.

**One page's ids are its own, and a page that collides with itself is asked at the page step.** Ids
arriving from independently extracted pages are made unique across the document by prefixing each
page's (`namespaceAnchors`, written up under [`page_duplicate_ids`](#page_duplicate_ids)) โ€” but a
page that used one id twice gets the same prefix on both copies and stays collided, which is the one
case that pass declines. Its only other reporter is lint on the **assembled** document, after that
page's last chance to be read against its image โ€” and where a prefixed id no longer carries the name
the page gave it, so the finding is on `p3-fn-1`. That last part holds only where the rename ran:
prefixing touches ids more than one *page* claims, so a document whose sole defect is page 3 using
`fn-1` twice, with no other page claiming `fn-1`, reaches lint with `fn-1` intact. Per-page footnote
numbering usually does produce the cross-page collision that renames it, but the reason to ask at
the page step does not depend on that. So the duplicate is checked in code on the page's own
fragment, off the parsed tree, and handed to the same self-correction pass as a dropped link:
`page_duplicate_ids` is the finding, `page_duplicate_ids_unrecovered` says the correction did not
clear it, and `extraction_complete.ids_duplicated` is the count over the fragments the document is
assembled *from*, beside `ids_checked` as its denominator. It is a correction rather than a rename
in code because renaming one end of the pair is how this defect gets worse: `href="#fn-1"` currently
reaches the first copy, and a rename that leaves the reference behind turns a wrong target into a
dangling one โ€” and only the agent that wrote the page knows which sentence meant which note.
Measured over every page reply in 32 bench run directories (1,501 fragments, 1,421 of them carrying
an id), 2 duplicate an id within themselves, both of them footnote ids โ€” one of the two on
`gpt-5.6-luna`, which has been the deployed page model since 2026-09-10 (issues #373 and #344).

Recovering a link never costs a page its structure, and neither does replacing a placeholder or
renumbering an id. When a page passed its fidelity check and is re-rendered only to attach a link,
to describe an image or to clear a collision, the rewrite is verified in turn, and one that lost
something โ€” a heading level, a `<th scope>` โ€” is discarded in favour of the fragment that passed,
logged as `page_links_correction_rejected` (`trigger` says a code-checked repair was refused, and
`links` / `alts` / `ids` on the same line say which, since `both` names no pair). All three are
local additions; the accessibility of a page that already checked out is not something any of them
may be paid for with.

**No correction may delete a page**, whatever triggered it. A self-correction pass is single-shot,
so what it returns is what the document keeps โ€” and a reply that comes back at less than a quarter
of the size of the page it was given has not corrected that page. It is refused, the page it was
asked to correct is what ships, and the run log says so (`page_correction_rejected`).

`status` is `queued` on creation and becomes `running` when the pipeline actually starts. Those are
usually the same instant, but a deployment runs at most `defaults.max_concurrent_runs` pipelines at
once (default 2): beyond that, the session **waits in `queued`** โ€” in FIFO order, for as long as it
takes โ€” rather than being rejected. Nothing is lost; the upload is already stored. If a session sits
in `queued`, check its run log for `run_queued` / `run_dequeued` to see the wait rather than
assuming a hang.

### Rate limits (how often you may ask)

A deployment limits requests at the HTTP layer, because it is a single process whose reads hit
SQLite synchronously โ€” one client's runaway loop is felt by everyone, including the runs already
in flight. Two budgets, each per minute, both published by `GET /v1/limits`:

| Budget | Applies to | Default | Counted per |
| --- | --- | --- | --- |
| `general_per_minute` | everything under `/v1` except `/v1/health` | 240 | address |
| `upload_per_minute` | `POST /v1/sessions` | 12 | address |

Every response carries the budget it was counted against, so a client can pace itself without
being refused first:

```bash
curl -si "${AUTH[@]}" "$BASE/sessions" | grep -i '^ratelimit'
# ratelimit: limit=240, remaining=238, reset=41
# ratelimit-policy: 240;w=60
```

Over budget is a `429` with `Retry-After` (seconds) and the standard error body:

```json
{ "error": { "code": "rate_limited",
             "message": "Too many requests: this deployment allows 240 per minute per client. Retry in 41s.",
             "details": { "limit": 240, "window_seconds": 60, "retry_after_seconds": 41 } } }
```

**Wait `Retry-After` seconds; do not retry immediately.** A tight retry loop spends the next
window before it opens. If you are polling a session, poll every 2โ€“5 seconds โ€” a conversion takes
minutes, and nothing changes faster than that.

Two more refusals concern uploads specifically. Both normally answer **before** the body is read,
so a rejected upload costs you nothing but the round trip โ€” the one exception is a request that
declares no length, which can only be refused while it is arriving:

- `413 upload_too_large` โ€” the request is bigger than `upload.max_request_bytes`. Retrying it
  unchanged will fail again; split the batch across sessions. Normally answered from the declared
  `Content-Length` before a byte of body is read; a request that declares no length (chunked) is
  counted as it arrives and cut off at the same ceiling, mid-upload, with `received_bytes` in
  `details` instead of `declared_bytes`.
- `429 rate_limited` with `max_upload_memory_bytes` in `details` โ€” too much upload is arriving at
  once across all callers (`max_upload_memory_mb`). This is about *bytes in flight*, not your
  request count, so small uploads are essentially never refused for it. Retry in a few seconds.

**Everything is counted per address**, and nothing you can send changes that. There is no per-user
budget to earn: the only credential you might present is a shared secret, so it is the same for every
caller and would put the whole internet in one bucket. The cost is real โ€” behind NAT or a shared
proxy you share a budget with everyone else there. Operators: `server.trust_proxy` has to be right,
or every request looks like it came from the proxy.

## Poll status

The pipeline runs asynchronously; poll until `status` is `ready_for_review` (or `failed`).

```bash
curl -s -H "$AUTH" "$BASE/sessions/$SID" | jq
```
```json
{
  "session_id": "ses_01HXYZ...",
  "status": "running",
  "phase": "extraction",
  "iterations_completed": 0,
  "iterations_max": 3,
  "image_count": 2,
  "created_at": "...",
  "updated_at": "..."
}
```
`status` is one of `queued`, `running`, `ready_for_review`, `closed`, `failed`. One field is not in
the payload above because it appears on a failed session and only there: `error`, the message the run
threw, the same one the run log's [`run_failed`](#run_failed) line carries. Absent rather than empty
on every other status, so branch on the status and not on the field.

`phase` is one of `extraction`, `assembly`, `review`, `done`, and is only meaningful while
`status` is `running` โ€” a `queued` session reports the phase it will start in, not one it has
reached. These are the four phases the pipeline enters; `triage` and `reconciliation` appear in
an earlier design but are **not implemented** and are no longer emitted, so a client should not
branch on them. Treat the list as open anyway: fall back to displaying the raw value rather than
showing nothing for a phase you don't recognize.

A simple wait loop:
```bash
until [ "$(curl -s -H "$AUTH" "$BASE/sessions/$SID" | jq -r .status)" = "ready_for_review" ]; do
  sleep 2
done
```

## Fetch the HTML output

```bash
curl -s -H "$AUTH" "$BASE/sessions/$SID/output" -o output.html
```
`text/html` โ€” clean, content-only accessible HTML. Provenance comments (`@source`, `@agent`,
`@fragment`) are **not** included, deliberately; provenance lives in the [run log](#run-log)
instead. An `<!-- @unresolved -->` comment listing outstanding issues is appended if the review loop
stopped with any still open โ€” at its iteration cap, or on a round that changed nothing, which is how
a document whose remaining issues the loop is designed not to fix ordinarily ends. A third stop
reason adds a second comment: `<!-- @editor-truncated -->` says a correction round's response hit
the model's output ceiling โ€” read together with `@unresolved`, which on its own would say the editor
tried and could not fix them (the [quality tally](#quality-tally-shared-secret-off-by-default)'s
`editor_truncated_rate`). It comes in three forms, and the difference is what corrections the
document in your hand contains, and which part of it has which kind. `@editor-truncated blocks B of
T` is the commonest: the reply was read as far as it got, so the first `B` of the document's `T`
top-level blocks carry that round's **own** corrections, made by a call that saw the whole document
and the page images. The same comment then says what happened to the other `T โˆ’ B` โ€” asked for again
a section at a time (`C of N sections`), or not divisible and so left as they were โ€” and `B` equal
to `T` means the reply named its last block before the ceiling cut it, so nothing was left to ask
for at all. `B` is not always where the ceiling stopped the reply: where the reply's next change
would have left a block holding less than it came in with, the claim stops **there** instead (the
[quality tally](#quality-tally-shared-secret-off-by-default)'s `editor_salvaged`, `lost_at`), and
the comment says so in its own words โ€” including that a passage moved backwards across that point
may now be in the document twice, which is the trade that rule accepts and the one thing about it a
person reading the document can act on. `@editor-truncated sections C of N` with no block count
means no prefix could be used, so the **whole** body was re-made a section at a time and `C` of `N`
came back corrected; each of those was made by a request that saw one section and not the rest of
the document. Under both, the `@unresolved` list is the reading that preceded the corrections and
was never taken again, so some of it may already be fixed. The bare `@editor-truncated` means
nothing was rescued โ€” the round was discarded and **none** of the issues below it were worked on.
What `editor_truncated_lost_rate` counts deployment-wide is any part of the document that no editor
pass reached: the bare form, `C` short of `N`, and blocks past `B` that no section covered. A third
comment, `<!-- @lint-unavailable -->`, says axe-core could not run on this document at all, so
**nothing** in it was checked for accessibility violations and an empty `@unresolved` is not a clean
bill of health (the [quality tally](#quality-tally-shared-secret-off-by-default)'s
`lint_error_rate`). Returns `409` while the session is still running.

**Image references do not resolve, by design.** A graphic on the page โ€” a logo, a diagram, a
photograph โ€” is emitted as an `<img>` with a description and a placeholder `src` naming the page
and the graphic (`src="page-1-logo.png"`), because the extractor sees a rasterized page and has no
asset to embed; the placeholder is also recorded in the run log. Iris serves no image endpoint, so
those references 404 until a consumer supplies the files. What a screen-reader user receives is the
`alt` text, which is the content the picture carries โ€” but a client that renders this HTML in a
browser will show broken images, and one that rewrites the `src`s has the log and the fragment to
match them against.

## Submit feedback (re-run)

Triggers a new run within the same session, with the feedback injected as a top-level
instruction to every agent. The prior output is snapshotted to
`sessions/<id>/history/` so it can be reverted to.

```bash
curl -s -X POST -H "$AUTH" "$BASE/sessions/$SID/feedback" \
  -H 'content-type: application/json' \
  -d '{"feedback":"The footnote on page 4 was inlined as body text. Keep footnotes distinct."}'
# 202 {"session_id":"ses_...","status":"running","phase":"extraction"}
```
Then poll status again as in step 4. A re-run is subject to the same `max_concurrent_runs` cap as a
new upload, so the 202 may instead report `{"status":"queued","phase":"extraction"}` โ€” accepted, waiting
for a slot. Either way the session is no longer `ready_for_review`, so a second feedback POST gets a
`409` until this run finishes.

A re-run on a session that already produced output builds on the **existing document** rather
than regenerating it, and is routed by what the feedback is about (visible in the run log as a
`feedback_scoped` event):

| Scope | What runs | Typical feedback |
| --- | --- | --- |
| `document` | Re-lint the saved body, then the feedback-aware review loop. No re-extraction. | tone, wording, ordering, an accessibility rule |
| `extraction` | The named pages go back to the page agent **with their source image**, then reassemble + review. Other pages keep their prior fragments. | "the revenue figure on page 2 is wrong", missed or misread content |

The second case exists because the **Reader** never sees the source images (by design โ€” it
reads the way a screen-reader user does), so a misreading of the source is invisible to it: no
issue is raised, and the loop has nothing to act on. Routing is biased toward the cheaper
`document` path: if the pages can't be localized, or the feedback claims more than half the
document, it falls back rather than re-extracting broadly.

## Run log

```bash
curl -s -H "$AUTH" "$BASE/sessions/$SID/logs"
```
`application/x-ndjson` โ€” one JSON object per line (agent calls with git-SHA / inline-content
version pinning, model-call timing, no-content signals, phase transitions).

The events worth grepping for have a section each below, and the index is a link to it. Reach for
the index when you have a `type` off a log line and want to know what it means; read a section when
you want to know what the field it names is for and what it costs.

**The index is the whole log.** `src/` emits **118** event types and every one of them has a section
below โ€” **113** sections, because a few cover a pair of events that are only read together. So a
`type` you cannot find here is not one the index skipped: it is a misread line, or a name `src/` no
longer emits.

That is a claim about the code, so it is not maintained by hand. The test suite reads both numbers
back out of this paragraph and every event name out of the headings, and checks them against the emit
sites in `src/` โ€” three different call shapes, since a grep for one of them cannot see the others. A
new event fails that test until it has a section here, and a section for an event `src/` no longer
emits fails it too.

| `type` | What it records |
| --- | --- |
| [`run_queued` / `run_dequeued`](#run_queued--run_dequeued) | The run's wait for a concurrency slot |
| [`run_start`](#run_start) | The run's opening line: how many source pages, and which of the three paths it took |
| [`phase`](#phase) | The pipeline entered a phase |
| [`model_call_start` / `model_call`](#model_call_start--model_call) | One completion, from the layer that resolved it |
| [`agent_call`](#agent_call) | One agent call that returned, with **the reply itself** and the prompt version that produced it |
| [`extraction_start`](#extraction_start) | The page pass is about to run |
| [`page_lessons_injected`](#page_lessons_injected) | Past lessons were added to this run's page prompts |
| [`feedback_rerun`](#feedback_rerun) | Feedback arrived, and the document it is about was snapshotted first |
| [`feedback_scoped`](#feedback_scoped) | How a feedback re-run was routed |
| [`first_read_carried`](#first_read_carried) | A feedback re-run kept the document's earlier first-read count |
| [`reextract_start` / `reextract_complete`](#reextract_start--reextract_complete) | Which pages went back to the page agent |
| [`reextract_skipped`](#reextract_skipped) | Pages a re-extraction could not attempt, for want of an input |
| [`page_redrawn`](#page_redrawn) | A draw that carried no page and claimed nothing about it, asked once more |
| [`page_no_output`](#page_no_output) | The page agent answered, and no HTML could be read out of the answer |
| [`page_bare_html`](#page_bare_html) | The reply was **markup rather than the envelope**, so the page was rescued as it stood |
| [`page_blank`](#page_blank) | The page agent read the page and reported it empty |
| [`page_blank_refused`](#page_blank_refused) | A re-extraction declared a page blank that the document already has content for |
| [`page_extraction_failed`](#page_extraction_failed) | One page's own extraction threw |
| [`extraction_complete`](#extraction_complete) | How many page fragments came out, and which pages failed |
| [`page_generic_alt`](#page_generic_alt) | A page described an image with a placeholder instead of a description |
| [`page_generic_alt_unrecovered`](#page_generic_alt_unrecovered) | A correction bought for a placeholder `alt` left the placeholder there |
| [`page_duplicate_ids`](#page_duplicate_ids) | One page fragment used the same `id` on more than one element |
| [`page_duplicate_ids_unrecovered`](#page_duplicate_ids_unrecovered) | A correction bought for a duplicate `id` left a duplicate there |
| [`page_links`](#page_links) | The source file's own link annotations were listed in a page's prompt |
| [`page_links_missing`](#page_links_missing) | A link the source annotates is not in the page's HTML |
| [`page_links_unrecovered`](#page_links_unrecovered) | A correction bought for a missing link left it missing |
| [`page_links_unexpected`](#page_links_unexpected) | The page links to a URL **no annotation accounts for** |
| [`page_links_correction_rejected`](#page_links_correction_rejected) | A correction bought for a page that had already passed was refused |
| [`page_soft_hyphens`](#page_soft_hyphens) | Soft hyphens (U+00AD) were taken out of a reply before it became markup |
| [`page_style_attributes`](#page_style_attributes) | `style` attributes were taken out of a reply, and what they were setting |
| [`page_digit_groups`](#page_digit_groups) | A thousands separator split by the printer's alignment space was closed up |
| [`page_split_words`](#page_split_words) | A page wrote one word two ways, `Compos-ite` here and `Composite` there |
| [`page_split_words_unrecovered`](#page_split_words_unrecovered) | A correction bought for a word written two ways left both spellings there |
| [`page_caption_claim`](#page_caption_claim) | A `<figcaption>` makes a claim about the picture its `alt` describes |
| [`specialist_unresolved`](#specialist_unresolved) | A page asked for a specialist **no available agent answers to** |
| [`specialist_declined`](#specialist_declined) | A page asked for a specialist the general pass already covers |
| [`specialist_no_content`](#specialist_no_content) | A specialist ran and returned nothing of its type |
| [`specialist_dispatched`](#specialist_dispatched) | A specialist ran, and whether its fragment reached the page |
| [`specialist_dispatch_failed`](#specialist_dispatch_failed) | A specialist's call threw, so the page stands as it was |
| [`page_recovered`](#page_recovered) | A re-extraction succeeded on a page an earlier run had lost |
| [`extraction_failed`](#extraction_failed) | **No page produced any content**, so the run is ending |
| [`page_verify_ok` / `page_verify_failed`](#page_verify_ok--page_verify_failed) | The Feedback Agent's fidelity verdict on one page |
| [`page_verify_error`](#page_verify_error) | A page's fidelity check could not be obtained |
| [`page_verify_inconsistent`](#page_verify_inconsistent) | The verifier **described** a defect and then passed the page |
| [`page_corrected`](#page_corrected) | What a self-correction pass did |
| [`page_correction_rejected`](#page_correction_rejected) | A correction came back under a quarter the size of the page it was given |
| [`page_correction_failed`](#page_correction_failed) | A self-correction's model call threw |
| [`page_correction_no_output`](#page_correction_no_output) | A self-correction's reply carried no readable HTML |
| [`page_correction_declined`](#page_correction_declined) | The corrector refused problems it was given, saying which and why |
| [`page_correction_recheck`](#page_correction_recheck) | A second verdict on a corrected page |
| [`page_correction_recheck_failed`](#page_correction_recheck_failed) | The measurement-only sample could not be taken |
| [`table_continuations`](#table_continuations) | A table whose caption says it continues the one before it |
| [`table_joined`](#table_joined) | Two halves were merged into one table |
| [`table_join_code_declined`](#table_join_code_declined) | The code merge stood down on this pair, so a Copy Editor call was bought |
| [`table_join_failed`](#table_join_failed) | One pair was left as two tables, and why |
| [`table_joins_capped`](#table_joins_capped) | More continuation pairs than one run will spend requests on |
| [`prose_joined`](#prose_joined) | A sentence the source printed across a page turn was delivered whole |
| [`editor_images`](#editor_images) | How many source images the Copy Editor received this round |
| [`editor_images_refused`](#editor_images_refused) | The payload was refused as too large, so it was re-sent **without** images |
| [`editor_fidelity_observed`](#editor_fidelity_observed) | The Copy Editor reports a disagreement **nobody asked it about** |
| [`editor_links_dropped`](#editor_links_dropped) | An `href` present before that round's correction was missing after it |
| [`internal_links`](#internal_links) | The delivered document has an in-document reference that lands nowhere |
| [`delivered_markup`](#delivered_markup) | The delivered document's own structure disagrees with itself |
| [`delivered_structure`](#delivered_structure) | Four structural defects **no rule in the gate reports** |
| [`delivered_alt`](#delivered_alt) | A placeholder where a description belongs, in the file the caller receives |
| [`editor_markers_changed`](#editor_markers_changed) | A `[not legible]` marker count changed across one correction round |
| [`editor_list_markers_split`](#editor_list_markers_split) | Half of the one list conversion the editor is licensed to make |
| [`editor_truncated`](#editor_truncated) | A correction round's response hit the model's output ceiling |
| [`editor_salvaged`](#editor_salvaged) | The truncated reply was read as far as it got |
| [`editor_salvage_declined`](#editor_salvage_declined) | The reply could not be read as a prefix, and why |
| [`editor_sections`](#editor_sections) | A round that could not be answered whole is being re-made a piece at a time |
| [`editor_section_failed`](#editor_section_failed) | One section could not be corrected |
| [`editor_sections_declined`](#editor_sections_declined) | The round could not be re-made a section at a time, and why |
| [`reader_start`](#reader_start) | The Reader is about to read the document, in this many windows |
| [`reader` / `editor`](#reader--editor) | Per-iteration review-loop progress |
| [`reader_issues_dropped`](#reader_issues_dropped) | Entries in one window's reply that could not be issues |
| [`reader_no_output`](#reader_no_output) | One window was paid for and produced **no verdict** |
| [`lint_unavailable`](#lint_unavailable) | axe-core could not run on a body no `assembly` line covers |
| [`lint_debris`](#lint_debris) | The linted body carried attributes whose **names no valid markup produces** |
| [`editor_patch`](#editor_patch) | What one ordinary correction round's reply did to the body, block by block |
| [`editor_whole_body`](#editor_whole_body) | The reply carried an `html` string and no `edits`, so it was read whole |
| [`editor_no_output`](#editor_no_output) | The Copy Editor's reply carried no usable body |
| [`editor_shrank`](#editor_shrank) | A round came back with **less than half the prose** it was given, and was refused |
| [`editor_navigation`](#editor_navigation) | The structures a reader navigates by, on a reply that was **adopted** |
| [`assembly`](#assembly) | The pages were joined into one body, and the first lint of it |
| [`assembly_anchors`](#assembly_anchors) | What namespacing the pages' `id`s cost |
| [`assembly_words_joined`](#assembly_words_joined) | A word broken at a line end was closed up, and what licensed it |
| [`deprecated_roles_stripped`](#deprecated_roles_stripped) | A deprecated ARIA role was removed from an element that already said it |
| [`invalid_roles_stripped`](#invalid_roles_stripped) | A `role` naming something that is **not an ARIA role at all** was removed |
| [`page_main_stripped`](#page_main_stripped) | A `<main>` a page emitted for its own content was taken out of the body |
| [`page_markers`](#page_markers) | Page-break markers were checked against the document's own numbering |
| [`reader_page_reports_deduped`](#reader_page_reports_deduped) | Reader reports about a page with **no content** were reduced to one per page |
| [`review_converged`](#review_converged) | The loop stopped early because a round changed nothing |
| [`run_signals_failed`](#run_signals_failed) | The quality tally could not be written for this document |
| [`feedback_classified`](#feedback_classified) | A correction was classified and **nothing was recorded** |
| [`feedback_learned`](#feedback_learned) | A lesson was recorded, and how many sessions have reported it |
| [`feedback_agent_missing`](#feedback_agent_missing) | `agents/feedback.md` could not be loaded, so no proposal was made |
| [`feedback_target_missing`](#feedback_target_missing) | The agent the correction was about could not be loaded |
| [`agent_trained`](#agent_trained) | A session-built agent was improved in place, with neither gate run |
| [`regression_gate`](#regression_gate) | The candidate prompt was re-run against the agent's own fixtures |
| [`eval_gate`](#eval_gate) | Candidate against current, over the fixtures **both** could be scored on |
| [`agent_update_blocked`](#agent_update_blocked) | A proposed prompt change was refused โ€” two shapes, told apart by `reason` |
| [`agent_updates_proposed`](#agent_updates_proposed) | A proposal cleared both gates and was written to the session |
| [`agent_update_issue`](#agent_update_issue) | The proposal reached GitHub, as an issue or as a comment on one |
| [`agent_update_issue_failed`](#agent_update_issue_failed) | Filing the proposal threw, and the run carried on |
| [`agent_update_issue_skipped`](#agent_update_issue_skipped) | No GitHub token, so the proposal stayed on disk |
| [`feedback_training_failed`](#feedback_training_failed) | The whole training step threw, and the document still shipped |
| [`run_complete`](#run_complete) | The run's terminal marker, and the state it delivered |
| [`agent_issue`](#agent_issue) | A new-agent suggestion was filed โ€” or its `url` says it was a duplicate |
| [`agent_issue_failed`](#agent_issue_failed) | A suggestion did not get filed, and which of the two calls failed |
| [`contribution_failed`](#contribution_failed) | The filing step threw, **after** `run_complete` |
| [`run_failed`](#run_failed) | The run threw, so there is **no document** |
| [`calibrate_call_failed`](#calibrate_call_failed) | One calibration verifier call threw โ€” a tool's line, never a run's |

### `run_queued` / `run_dequeued`

The run's wait for a concurrency slot: how busy the queue was when it was admitted (`running` of
`limit`, plus `waiting`), and `waited_ms` when it actually started. A large `waited_ms` means the
deployment is saturated, not that this run is slow.

### `run_start`

The run's own opening line: `images`, how many source pages the session has; `feedback`, the text a
re-run was asked to act on, `null` on a first run; and `mode`, one of `full`, `feedback_reextract`
and `feedback_iterative`.

`mode` is not on the session's first line ([`run_queued`](#run_queued--run_dequeued)) because it is
not decided that early. A re-run with saved state
routes its feedback first, so [`feedback_scoped`](#feedback_scoped) sits **above** this line and
decides it: `target: "extraction"` is `feedback_reextract` and `target: "document"` is
`feedback_iterative`. Scoping is a model call, so such a session can have spent money before its own
`run_start` โ€” `feedback_scoped`'s `reason` reads `feedback agent unavailable` on the one path that
routes without buying one. Feedback that arrives with no saved state to build on runs `full`, since
there is nothing to refine โ€” so a `full` run with a [`feedback_rerun`](#feedback_rerun) line above it
is not a first run. [`run_complete`](#run_complete) carries `mode` again.

`images` counts source pages, blank ones and pages that go on to fail included, and it is the only
count of them present on **every** mode: `feedback_iterative` runs no extraction at all, so it has
neither an `extraction_start` nor an `extraction_complete` line to read `pages` off.

This is also where [Diagnostics](#diagnostics-timing--hang-detection) cuts the log. A session's log
accumulates across feedback rounds, so diagnostics slices at the **last** `run_start` and reports
the run after it, ending at that run's `run_complete` or `run_failed`. One case straddles that cut:
a client may POST feedback inside a round's post-delivery window, and with `max_concurrent_runs`
above 1 the next round's `run_start` is then written before the previous round's completion line.

**`phase_durations_ms` is not cut here, and cannot be.** It reads the whole log rather than the slice,
and the `extraction` marker is written *before* this line on every mode, so on a first run the field
covers slightly more than the run and on a session with feedback rounds it mixes them
([`phase`](#phase)).

### `phase`

A phase marker for timing: `phase` is `extraction`, `assembly` or `review`, written as the session's
own `phase` field is set ([Poll status](#poll-status)).
[Diagnostics](#diagnostics-timing--hang-detection) turns these into `phase_durations_ms` by
measuring each to the next, and the last one to the run's terminal line โ€” or to now, on a run still
going.

Three values here and four in [Poll status](#poll-status): `done` is a phase the session record
reaches and never a log line, so the last phase is measured to `run_complete`. A
`feedback_iterative` round has no `assembly` marker, because it assembles nothing โ€” it re-reviews
the body already delivered. So a phase missing from a session's log is not always a phase that
failed.

The inverse holds too, and it is the one to watch: **an `extraction` marker is written on every mode,
`feedback_iterative` included**, before [`run_start`](#run_start) and before the mode is even decided.
So that marker's duration on a round that re-extracted nothing is the time spent routing the feedback
and reviewing, filed under a phase that did not run.

`phase_durations_ms` reads the **whole** log and not the current run's slice, which matters once a
session has taken feedback. Its keys are one per phase NAME, so a later round's marker overwrites an
earlier round's: `extraction` and `review` end up the last round's, since every round writes both.
`assembly` does not, and that is the field to distrust โ€” a `feedback_iterative` round writes no
`assembly` marker, so the value left standing beside that round's numbers is **a previous round's
assembly**, measured from a run that is over.

### `model_call_start` / `model_call`

One completion, from the layer that resolved it: `agent`, `step`, `capability`, `model`,
`provider`, and `api` for a provider that has more than one wire format (Bedrock's `invoke` vs
`converse`, so a comparison of the two says on every line which side produced its numbers).

`step` is the **job** the call was bought for, on both lines, and it is a different question from
`agent`: an agent file is a contract and one contract serves several jobs โ€” the Feedback Agent
checks a freshly extracted page, re-checks a corrected one, routes a user's feedback and classifies
a lesson from it โ€” so the agent name alone cannot price a step, and reading extraction's cost off it
understated the step by a third ([Diagnostics](#diagnostics-timing--hang-detection), which reads
these back as `by_step` and lists the closed set of names). It is on the **start** line because that
is what an in-flight or hung call is asked about, and on a **failed** line because a call that threw
still spent โ€” a truncated editor round paid for a full ceiling of output โ€” and those are exactly the
calls with no answer to attribute them by.

The start marker is written **before** the call, so a hung or in-flight call is a start with no end;
the end line adds `duration_ms`, `ok`, an `error` when it failed, and the token counts flat
(`input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`) โ€” taken
from the result where there is one, and otherwise from what the adapter reported through a callback
as the call ran, so a call that **threw** still says what it spent
([Diagnostics](#diagnostics-timing--hang-detection) reads these back as `tokens`). One line per
completion, not per HTTP request.

`output_ceiling_clamped: true` says this call ran below the output ceiling the deployment
configured, with `output_ceiling_asked` (what `providers.<provider>.max_tokens` says) and
`output_ceiling_stated` (what the model grants): several Bedrock models cap output well below
32000 and **refuse** the request rather than clamping it, so the adapter re-sends at the ceiling
the rejection names and remembers it (issue #249). Present means a config error is live โ€” the
pages arrive, so nothing else downstream shows it, and a deployment can otherwise run for a month
at a ceiling nobody chose with a dense page truncating occasionally.

Read the pair as the remedy: set `max_tokens` to `output_ceiling_stated`, or route that capability
to a model whose ceiling is at least what you asked for. It is on **every** clamped call, not only
the one that discovered it, because the adapter's stderr paragraph is said once per process and
the remembered ceiling outlives the session โ€” a field that copied that dedup would mark one call
in the life of a server and leave every document after the first reading clean.

`output_ceiling_refused: true`, present only on the call that paid for the lesson, is what
separates the condition from its cost: that line's `duration_ms` covers a rejected round-trip as
well as the request that worked. Absent on a deployment whose models accept their ceiling, which
is every deployment today.

`max_output_tokens` is the opposite direction and is **not** a config problem: a ceiling the
caller asked for, below the deployment's, because it already knows roughly how large the answer
should be. Present on the page-correction call only (`correctionCeiling` in
`src/pipeline/extraction.ts`, issue #285) and absent everywhere else, so a truncation on a line
carrying it is the caller's estimate being low and a truncation on a line without it is the
deployment's ceiling โ€” the same two remedies the error message itself distinguishes (**Errors**
below).

It is on the **start** line too, for the same reason `step` is: the calls worth attributing are
the ones with no answer to attribute them by.

### `agent_call`

One agent call that returned, carrying the whole reply in `output` and the prompt version that
produced it. This is the only place a model's raw text is kept โ€” every other line about a call
reports what was *read out of* it โ€” so a question about what an agent actually said is answered here
and nowhere else, and a reply this pipeline then rejected is still on record in full.

| Field | Reads |
| --- | --- |
| `phase` | `extraction`, `assembly` or `review`, matching the [`phase`](#phase) markers |
| `agent` | The agent's **file** name, not its logical name: a table join and a correction round are both the Copy Editor, and they are `copy_editor_table_join.md` and `copy_editor.md`. Read it as a label rather than as a path |
| `agent_sha` | The git blob SHA of the prompt text that was **sent**. `null` at eight call sites |
| `agent_content` | The prompt inline, for a **session-built** agent โ€” one loaded from `tmp/<id>/agents` in preference to the agent library. On **every** line and `null` on all but that one case, so read it as present-and-null rather than omitted |
| `capabilities` | What the agent **declared** it needs (`text`, `vision`, `structured_output`), read off its `## Required capability` section. The one capability that routed this call is `capability` on the [`model_call`](#model_call_start--model_call) beside it, and an agent declaring two does not use both at once |
| `image` | The source image on a per-page call and `null` otherwise โ€” written rather than omitted, like `agent_content` and unlike the count fields elsewhere in this log |

**A call that threw has no line here.** The provider's failure is on `model_call` with `ok: false`, and
this line is written after the call returned โ€” so these lines count answers, not attempts, and a phase's
spend cannot be read off them.

**Two `agent_call` lines cannot be told apart by any field on them**: a training round's two fixture
replays, one per prompt being compared. The rule that separates them, and its two caveats, are in
[design notes โ€” telling a training round's two replays
apart](design-notes.md#telling-a-training-rounds-two-replays-apart). What recovers a prompt at each of
the eight null-SHA sites, and which single one is recoverable nowhere, is [what recovers the text a
prompt actually sent](design-notes.md#what-recovers-the-text-a-prompt-actually-sent).

### `extraction_start`

The page pass is about to run: `pages`, the `concurrency` it will run them at, and what it will
measure while it does โ€” `recheck_sample_size` (`defaults.recheck_sample_size`, the number of
corrected pages to be re-verified, 0 for none) with `recheck_thresholds`, the page orders those
slots sit on. The two measurement fields are here rather than left implicit because their absence
made a log ambiguous three ways: no `page_correction_recheck` in a run can mean the measurement is
off, no page needed correcting, or every corrected page falling below the first threshold, and
those have different remedies (issue #288).

The thresholds are computed from the orders of the pages **this** batch runs, so they say which
pages could have answered and not merely how many.

### `page_lessons_injected`

Lessons learned from earlier feedback were added to the page agent's prompt, so the agent improves
without `agents/page.md` being rewritten (issue #1). `chars` is how much text was added.

This is a per-**run** line rather than a per-page one. It is written once at the top of the page pass,
before any page call, and once more at the top of a re-extraction round โ€” which is why it carries no
`image` and no `page`. What it says is that every page in that round paid for those characters in its
prompt.

`chars` is a size and not a count: it does not say how many lessons were injected, and the lessons
themselves are not on the line. A run with nothing to inject writes no line here at all, so the
absence of this line means the pages were rendered with the shipped prompt.

### `feedback_rerun`

Feedback arrived, and the document it is about was copied aside before this round overwrote it:
`feedback` is the text as submitted, and `prior_output` the path the previous `output.html` was
snapshotted to, relative to the session directory (`history/output-<timestamp>.html`). That copy is
the only record of the document as it was delivered before this round; nothing else keeps it, and
[Fetch the HTML output](#fetch-the-html-output) serves what this round produces.

`prior_output: null` means there was no delivered output to snapshot. Written for **every** re-run,
including one that goes on to run `full` for want of saved state ([`run_start`](#run_start)) โ€” so
this line, and not `mode`, is what says feedback was submitted at all.

### `feedback_scoped`

How a feedback re-run was routed (`document` vs `extraction`, and which pages)

### `first_read_carried`

A document-level feedback re-run kept the document's earlier first-read count for the quality tally
instead of recording its own (the [quality tally](#quality-tally-shared-secret-off-by-default)'s
`first_read`, issue #313).

`carried` is the number kept, `unread` its window count, and `found` what this round's own first
read came to โ€” which is the one this round's `reader` events describe, so without this line a
session's log and `/v1/quality` disagree about it with nothing to say why.

`carried: null` means the session had no first read on record โ€” one delivered before the field
existed โ€” and contributes none. Written on the review-only path only: a re-run that re-extracts
reads fresh extraction output and records it.

### `reextract_start` / `reextract_complete`

Which pages went back to the page agent. `reextract_start` also carries the same
`recheck_sample_size` / `recheck_thresholds` pair as `extraction_start`, spread over the pages
this round re-extracts rather than over the whole document โ€” a round re-running pages 7, 12 and 20
of 25 has its own thresholds, since bands taken from the document's length would all fall below
page 7.

`reextract_complete.pages` is what was actually re-extracted; a `failed` list is pages whose
re-extraction threw and which therefore kept their **prior** content unchanged.

`reextract_complete` also carries `alts_checked` / `alts_generic`, `ids_checked` /
`ids_duplicated` and `words_checked` / `words_split`, and they are over the **whole** document this
round delivers rather than the pages it re-ran โ€” the prior round's pages with the re-extracted ones
substituted in โ€” so they are comparable with `extraction_complete`'s and a session's log does not
read as its alt, id or word corpus shrinking every time a client sends feedback. Comments are
stripped before the two alt counts and before the two word counts, as on `extraction_complete`, and
the two id counts are read off each fragment's parsed tree, where a comment is not an element.

`uncorrected` is over the whole document too, and for the same reason: a round that repairs one
rejected page out of three should read as two left rather than as one page re-run. It follows the
document page by page โ€” a page re-rendered and now accepted leaves the set, one rejected again
stays, one this round never touched keeps the prior round's verdict, and one whose re-extraction
**threw** keeps it too, because it is the prior fragment that ships and so the prior verdict that
describes it.

### `reextract_skipped`

Pages this round meant to re-extract and could not: `pages`, the orders it gave up on, and `reason`,
which is `no source image or prior fragment` โ€” the only value the line carries today.

Re-extracting a page needs both of those. The image is what the agent reads, and the prior fragment is
what it is shown as its own previous answer, so a page missing either is dropped from the round rather
than rendered from nothing. Every page named here keeps the status it arrived with: its prior content,
its prior verdict, and its place in `uncorrected` if it had one.

It is the difference between what the round was asked for and what it ran.
[`reextract_start`](#reextract_start--reextract_complete)'s `pages` is what went to the agent, this
line is the remainder, and the two are disjoint โ€” together they are the set feedback pointed at. This
line is written **above** `reextract_start`, so a reader scanning a round from its start finds the
skips first.

### `page_redrawn`

A draw carried no page **and claimed nothing about the page**, so the same call was made once more
(`page`, `image`, `chars` of the discarded reply, its `shape`, `dropped` where markup arrived, and
`reextract: true` on a feedback round). The fields are `page_no_output`'s, because the triage
question does not change โ€” the draw that lost is the one worth reading โ€” and this line is where a
recovered page's losing draw is recorded. **A page that recovers has this line and no
`page_no_output`; a page that loses twice has this line and then that one.** So every count taken
off `page_no_output`, here and in `pages_failed`, still counts pages given up on rather than draws
discarded.

**Once, and never twice.** The failure this is for is a draw the model can lose, and a page that
loses two in a row is not that page.

**The gate is that the reply asserted nothing, not that it was short.** A reply Iris refused whole is
0 characters of HTML however much page it was carrying, so a character floor would redraw every page
that correctly declared itself blank. Reaching this branch at all is rare: about **1%** of pages
drawn at least once, over every bench round on disk.

A redrawn page's second draw re-runs the four repair seams, so `page_soft_hyphens`,
`page_style_attributes`, `page_digit_groups` and `page_links` can fire twice for one page. The second
draw's lines carry **`redrawn: true`**, because `where` attributes a count to the call it was billed
under and a redraw makes two calls at the same seam: an offline per-occurrence census discounts the
flagged lines rather than counting a discarded draw's markup as page content.

**A provider failure never reaches this line:** a throttle, a stall and a refusal all throw before a
reply exists to read, and that boundary is deliberate.

**A reply the model itself cut short does reach it**, as `truncated_envelope`, and is redrawn โ€” where
a truncated *correction* is not, because a correction's page survives its failure and a first
render's does not. A page that genuinely exceeds the ceiling loses the second draw as well, and its
remedy is `providers.*.max_tokens`.

Two shapes of blank-page declaration this gate cannot read are redrawn anyway, each costing one call
and changing no outcome. Why the gate reads a declaration rather than a size, what the corpus behind
the rule is, and what those two shapes are, is in [design notes โ€” a draw that claimed
nothing](design-notes.md#a-draw-that-claimed-nothing-and-the-corpus-behind-redrawing-once).

### `page_no_output`

The page agent answered, and no HTML could be read out of the answer (`page`, `image`, `chars` of
text, and the `shape` it was in). A reply that claimed nothing about the page has already been
[redrawn once](#page_redrawn) by the time this line is written, so this is the second draw's failure
and the page is now given up on: [`page_extraction_failed`](#page_extraction_failed) follows it and
the page is lost the way any failed page is lost. "The reply could not be read" and "this text is
the page" are different claims, and a reply delivered as content would put a JSON envelope, or an
apology, into the document while the run reported every page delivered.

`shape` names the remedy:

| `shape` | What the reply was | What to do |
| --- | --- | --- |
| `truncated_envelope` | The output ceiling cut it off | Raise `providers.*.max_tokens` |
| `envelope` | Complete, and its escaping defeated the parser | Rare โ€” a reply whose only fault is the page's own unescaped punctuation is repaired before it reaches here. Report it |
| `prose` | The agent answered conversationally | A prompt problem |
| `empty_html` | An envelope read perfectly that carried no page | A prompt problem |
| `empty` | Nothing in the reply at all | A prompt problem |
| `bare_html` | The page's markup rather than the envelope | Read `dropped` โ€” two findings, opposite remedies |

`empty_html` is no `html` key at all, or one whose HTML holds nothing a reader receives and whose
`log` does not say the page is blank โ€” the model answering with no page and not saying why, or
saying it could not read it. **"Nothing a reader receives" is not "an empty string":** a comment, an
empty wrapper and a bare page-break marker are all nothing, so a refused blank declaration reaches
this line whichever way its fragment was written.

`bare_html` is markup the pipeline normally accepts and delivers (`bareHtml` in
`src/pipeline/extraction.ts`). On **this** line it is one of two things with opposite remedies, and
the shape cannot tell them apart: either the markup arrived behind a fence that never closed, so a
page was refused by the parser and the text on the line is that page, or the markup arrived fine and
carried nothing a reader receives, which is a prompt problem exactly as `prose` is. `dropped` is
what separates them โ€” it holds that markup, under the same 200-character bound as on
[`page_blank`](#page_blank), because `chars` is the length of the whole reply and not of the fragment.

A page the agent reports as **blank** is not here: that is [`page_blank`](#page_blank), and not a
failure.

**Where the reply did claim the page was blank and the claim was refused,** four fields say which
check refused it and what it read:

| Field | What it holds |
| --- | --- |
| `blank_vetoed` | The doubt words that refused the claim โ€” the reply says the page could not be *read* |
| `blank_contradicted` | The words that named content on a page the log had just called empty |
| `blank_stated` | `true` where the refused claim was made in the reply's `blank` field rather than left to be read out of its prose |
| `log` | The agent's own sentence |

Without them the line reads as "the model answered with no page", which is the opposite of what
happened, and tracing four such pages back to a single word once meant rerunning the regexes on the
replies by hand (issue #190).

`blank_stated` here is the one thing in these logs that can show the field being misused: a page
whose log says it could not be read is the one page the prompt tells the model never to send the
field for, and a run carrying `blank_stated` only on the line that honoured it could count the field
working and not the field failing (issue #371).

`blank_contradicted` on **this** line means the claim was made in prose. A claim the reply STATED in
its `blank` field is not refused for a contradiction: it lands on [`page_blank`](#page_blank) with
the same field name and a verify call instead (issue #371).

**These are two findings with two remedies, and one line can carry both.** A doubt word means the
page could not be read and wants a better scan. A contradiction means the agent answered with no
page for a page it says has content on it, which wants a re-extraction. They are read independently,
so `"Page is blank. The scan is blurry. There is handwriting on the page."` fills `blank_vetoed`
with `blurry` and `blank_contradicted` with `there is handwriting`. **On a line with both, act on the
doubt first** โ€” a reply that could not read the page is not a reliable witness to what is on it.

Which words count as doubt, as a contradiction, or as a declaration is a long rule, and every clause
of it was bought by a page that had been lost: see
[design notes โ€” reading a blank-page declaration](design-notes.md#reading-a-blank-page-declaration).

### `page_bare_html`

The page agent's reply was **markup rather than the envelope**, so the page was rescued from the
text as it stood (`page`, `image`, `chars` of the whole reply, `html_chars` of the markup taken
from it, and `reextract: true` on a feedback round). The page **shipped**: it is in neither
`pages_failed` nor `pages_blank`, the HTML is usable, and that is why the rescue exists. What it
did not ship is a `"log"` โ€” there was no field to put one in, and `agents/page.md` asks for that
field by name in 26 places, six of which are obligations it discharges there and **nowhere else**:
a page ending mid-sentence, a heading with no parent on the page, a symbol with no key, a
placeholder image source, a language change, an irregular table.

On these pages every one of those is unmet and unreported while the run says every page was
delivered. Not rare, which is why it is now a line of its own: 41 of 300 first page calls across
two multi-vendor bench rounds and 54 of 400 across four deployed rounds of one PDF (13.7%), and 0
of those 41 left any other line behind. It is also the discriminator between the two readings of
an empty log โ€” `log: ""` **with** this line means the reply had no envelope, and without it means
the model sent one and left the field empty, which is a prompt-compliance question rather than a
parse one and has the opposite remedy.

Which of the two the deployed models do is answerable now: over 67 round logs on file, 2,320
`page.md` replies are 2,001 with a non-empty log and 319 bare, and **0** with an envelope whose
log is empty.

`chars` and `html_chars` are both here because they answer different questions โ€” `chars` is what
the reply was billed for and `html_chars` is what the page got, so a rescue that delivered a whole
page and one that salvaged a fragment of a truncated reply are otherwise the same event.

`reextract` marks the feedback round because the 13.7% is a rate over **first** calls and a count
that pools rounds is not comparable with it. The verify step is sent no log section for these
pages, and `agents/feedback.md` tells it to say nothing about the absence: that is a fact about
the reply, not about the page (issue #349).

### `page_blank`

The page agent read the page and reported it empty (`page`, `image`, and its own `log` line), so the
page is delivered as an empty fragment because there was nothing on it to deliver.

**Not a failure and not in `pages_failed`.** The remedies are opposite: a failed page is work to
redo, and a blank page is work already finished.

A reply earns this line when it is a complete envelope whose `html` is present and carries **nothing
a reader receives** โ€” no visible text, and none of the elements that are content with no text in
them (a picture, a grid, a form control) โ€” **and** it says the page is blank, either in the reply's
own `"blank": true` field or, where that field is absent, in a `log` that asserts it in so many
words. `blank_stated: true` says the declaration arrived in the field.

`dropped` carries the markup, bounded to 200 characters, where the declaration was spelled in markup
rather than as an empty `html` โ€” a comment, an empty wrapper, a page-break marker. The fragment
delivered is `""` whichever spelling arrived, so without the field the line would not say which one
did. Prose is content whatever it says: `<p>This page is blank.</p>` is not a declaration, it is
delivered as the page's words.

**No verify call is bought for a blank page.** Its [`page_verify_ok`](#page_verify_ok--page_verify_failed)
line says so with `skipped: "blank"` and `unjudged`. The one exception is a declaration **stated** in
the field whose own log names something on the page: that page is delivered *and* judged, carries
`blank_contradicted` on this line and no `skipped` on its verify line, and the log's claim is quoted
to the verifier in its own words beside the empty fragment โ€” so a log that was right about the
heading it named buys a correction and the reader gets the page.

**This line therefore counts the declarations that were made, not the pages that ended up empty.**
[Diagnostics](#diagnostics-timing--hang-detection) reads the ones that cost a verify call off it as
`pages_blank - pages_skipped_blank`, and a page whose content came back that way is the
[`page_corrected`](#page_corrected) line beside it, with `trigger: "verify"`.

**No page-break marker is delivered for a blank page,** whatever the paper prints, so a marker that
arrives anyway goes to `dropped` with the rest of the fragment. A page whose only printed content
**is** its folio is one of these pages by decision: the folio is never transcribed as text and the
marker it may be carried in is never delivered, so such a sheet has nothing on it a reader receives.

Two limits to know before triaging a run off these lines:

- **A confident wrong declaration about a page whose source file says nothing is not caught.** It is
  delivered as an empty page and this line is the whole of the evidence it leaves, so a run with no
  surprising `page_blank` lines is not evidence that no such page occurred. Where the source file
  *does* carry link annotations for the page, the document contradicts the declaration and
  [`page_links_missing`](#page_links_missing) fires on it as on any other page, buys a re-render
  against the image, and that fragment is verified in turn.
- **The field states and cannot deny.** `"blank": false` is read as no answer at all and the sentence
  decides, exactly as it did before the field existed, so every error the field can make runs in one
  direction. It is read loosely enough for `"true"` as a string and no further: `1`, `"yes"` and
  `"blank"` are silence, because a field loose enough to accept them deletes a page on a typo. And it
  cannot declare a page blank that came back with a page on it.

Where a declaration is refused rather than accepted, the line is
[`page_no_output`](#page_no_output) carrying `blank_vetoed` or `blank_contradicted`, or โ€” on a
feedback re-extraction of a page Iris already holds content for โ€”
[`page_blank_refused`](#page_blank_refused) below. Which words count as a declaration, a doubt or a
contradiction, and what each clause of that rule cost before it existed, is in
[design notes โ€” reading a blank-page declaration](design-notes.md#reading-a-blank-page-declaration).

### `page_blank_refused`

A feedback re-extraction declared a page blank that the document already has content for (`page`,
`image`, `chars_kept`, the agent's `log`, and `dropped` โ€” the markup the declaration was spelled
in, where it was spelled in any), so the declaration is refused and the page keeps that content.

`blank_stated: true` is on this line too where the declaration came from the `blank` field, for
the same reason as on `page_no_output` above: the field can be sent for a page Iris already holds
content for, and a log that recorded the field only where it was believed could not show it (issue
#371). The model was shown its own previous output for the page and then said the paper was empty,
which contradicts what Iris already holds; the page is then handled as any re-extraction that
could not improve it โ€” `page_extraction_failed` with `kept: "prior"`, and the page in
`reextract_complete.failed`.

Nothing else would catch it: the shrink floor guards the *correction* pass, where the comparison
is against that round's own render, so prior โ†’ empty never reaches it. That is also why the test
is what a reader receives rather than whether `html` is empty โ€” a re-extraction answering
`<!-- blank page -->` for a page with content used to walk straight past this refusal and replace
the content with the comment, and 13 renders in the bench corpus are that reply (issue #219). A
page that was **lost** can still come back blank and be recovered: there is no content to
contradict.

### `page_extraction_failed`

One page's own extraction threw (`page`, `image`, `error`). The rest of the document still ran โ€” see
[Partial documents](#partial-documents). `kept: "prior"` marks the feedback-re-extraction case,
where the page keeps the content it already had and the document stays whole. A **truncation**
carries three more fields (#293), the same three as `page_correction_failed` below and for a
stronger reason: there the page survives the failure, while here its content is gone, so the excerpt
is the only record of what the model had written when the ceiling cut it.

`reply_chars` is how far the reply reached, `reply_head` its first 240 characters and `reply_tail`
its last 240 โ€” one budget of the user's text, quoted **entire** under `reply_head` when the
fragment is shorter than both excerpts together, exactly as on `editor_truncated`. Deployment
only: like every excerpt of a document, it stays in the run log and never reaches
`GET /v1/quality`. There is no `ceiling` field here because a first pass carries no cap of its own
โ€” the ceiling is the deployment's, and the error names it.

`truncated: true` with **no** `reply_head` at all is the shape worth watching: a call that spent a
whole ceiling of output and never began the page, which is a reasoning model burning the budget
before the answer, not a page that needed more room (see [Errors](#errors) and `EMPTY_REPLY` in
`src/providers/types.ts`).

### `extraction_complete`

How many page fragments came out (`pages`) and which page numbers failed (`failed`, always
present, `[]` on a whole run).

`uncorrected` is the other set, and the opposite failure: page numbers the fidelity check
**rejected** whose one correction pass repaired nothing, so what the document carries for them is
content Iris named a defect in and never fixed ([Partial documents](#partial-documents),
`@page-uncorrected`, #328). Always present and `[]` where no page shipped that way, for the reason
`failed` is โ€” a field that only appears when it fires cannot tell "every rejected page was repaired"
from "this run predates the count". Disjoint from `failed` by construction: a page whose render
threw never reached a verdict, so it cannot be in this set, and the two counts add rather than
overlap.

It is the roll-up of what `page_verify_failed` and then `page_correction_failed` or `page_corrected`
already say a line at a time โ€” worth having as one field because reading it off those needs a join
per page, though the rule is one line: a page whose verdict failed is in this set exactly when its
`page_corrected` `result` is **not** `kept` ([Partial documents](#partial-documents)).

`alts_checked` / `alts_generic` are the generic-alt rule over the fragments the document is built
from โ€” every non-empty `alt` on an `<img>`, and how many of them are a placeholder rather than a
description (#290). Asked **after** any correction, so a non-zero `alts_generic` is a placeholder
this step could not repair, which is a different statement from the per-page `page_generic_alt`
finding. It is not a statement about the delivered document: the review loop runs afterwards and
can replace a block's `<img>` along with its markup, which is what `delivered_alt` is measured on.

Both are present at zero on every run, for the same reason `failed` is: a class reported only when
it fires cannot distinguish "it never happened" from "the check never ran", and this rule's whole
claim is that it fires on nothing the page agents write.

`alts_checked` is what makes the zero readable โ€” 0 of 0 says nothing about the rule, 0 of 40 says
something. Comments are stripped before either count, as on `page_generic_alt` and
`delivered_alt`.

`ids_checked` / `ids_duplicated` are the same pair for the id rule (#373): how many elements
across those fragments carry a usable `id`, and how many of those ids are used more than once
**within one fragment**. Per fragment and summed, which is the only count this rule can make โ€” two
pages sharing an id is not a defect at this point, since `namespaceAnchors` prefixes each page at
assembly, and pooling the document's ids into one set would report that fix as a failure once per
page. Present at zero on every run, and it matters more here than for the alts: this rule fires on
2 of 1,501 measured page replies, one of them on the model deployed today, so a field that appeared
only when it fired would be indistinguishable from the check not running.

Read off the parsed tree, so markup the HTML parser discards โ€” an orphan `<td>`, anything inside a
comment โ€” owns no id: an over-collected id would be a phantom duplicate, and on this path that
buys a rewrite of a page with nothing wrong with it.

`words_checked` / `words_split` are the third pair, for the split-word rule (#334): how many words
those fragments contain, and how many words a fragment writes **two ways** โ€” one word counted once
however often it appears. Per fragment and summed, and here for a reason of its own rather than
`ids_duplicated`'s: a word written one way on page 3 and the other way on page 40 is not this defect
at all, since a printing breaks a word wherever the column falls, so pooling the document's words
would manufacture contradictions out of the whole corpus's vocabulary.

Unlike the two pairs above, `words_split` is **expected to be non-zero**. On #334's 100-page census
every one of three models contradicted itself somewhere โ€” `kimi-k2.5` on 4 pages, `claude-sonnet-4-6`
on 3, `gpt-5.6-luna` on 2 โ€” so a run reporting 0 of 20,000 is the reading to check the rule against,
not the reading to be reassured by. And a non-zero here after correction is not necessarily a repair
that failed: a page that really does print both spellings is invited to say so and change nothing.

### `page_generic_alt`

A page described an image with a placeholder instead of a description (`page`, `image`, `alts` โ€”
the values as written, duplicates included). Free and exact, run on every page rather than on the
ones a sampled verifier looks at, and fed to the same self-correction pass as a dropped link: the
fix needs the image, so it needs the page agent. This is not a blind spot being covered โ€” the
deployed verifier catches a gutted `alt` 6 times out of 6 โ€” it is a capability being moved off the
model's bill, because the cheaper verifiers it may be swapped for catch it 0โ€“2 times out of 6 and
`axe` catches it never (#290, #246).

The list is closed and matched whole: `alt="logo"` is a finding, `alt="Meta logo"` is not, and
`alt=""` is left alone as a valid statement that an image is decorative. Comments are stripped
before the scan, here as on `delivered_alt`: an `<img>` a page quoted inside a comment is not an
image a reader is offered, and on this path a false finding is not just noise โ€” it buys a rewrite
of a page that had nothing wrong with it.

### `page_generic_alt_unrecovered`

A correction bought for a placeholder `alt` was kept and the placeholder is still there (`page`,
`image`, `alts` โ€” the values that remain). The mirror of
[`page_links_unrecovered`](#page_links_unrecovered) in what it measures, and the reason a
deterministic rule is worth more here than a model that finds the same defect: the check that
raised the complaint can be run again on the answer, exactly and for nothing, so "the rule found
something" and "the rule got it fixed" are separable at no cost. Only logged where the correction
was bought for an alt in the first place โ€” which is where the mirror stops, since the link line is
logged whenever a kept correction leaves a link missing, however that correction was bought.

### `page_duplicate_ids`

One page fragment used the same `id` on more than one element (`page`, `image`, `ids` โ€” each
duplicated id named once and sorted, however many copies it has). Free and exact, on every page,
fed to the same self-correction pass as a dropped link. It is at the page step rather than left to
lint for a reason the other two free rules do not have: this defect **does** have a downstream
reporter and that reporter cannot fix it.

`namespaceAnchors` makes ids unique across the document by prefixing each page's, so a page that
collided with **itself** gets the same prefix on both copies and stays collided โ€” the one case it
declines by name โ€” and lint then names the collision on the assembled document, on a page nobody
will look at again with the image in front of them, usually as `p3-fn-1` rather than under the
name the page wrote (usually and not always: nothing is prefixed unless more than one *page*
claimed the id). Here the model that chose the ids is still holding the picture.

A blank `id` is not this rule's finding: it is invalid markup, but "renumber every copy after the
first" is not its repair.

### `page_duplicate_ids_unrecovered`

A correction bought for a duplicate `id` was kept and a duplicate is still there (`page`, `image`,
`ids` โ€” the ones that remain). The mirror of `page_generic_alt_unrecovered` and free in the same
way. The remaining ids are named rather than counted because, unlike an unrecovered link, this is
not matched against anything the page arrived with: a correction renumbers, so it can clear `fn-1`
and collide on `fn-2`, and that is a repair that **moved** the defect rather than one that failed
to touch it. Only logged where the correction was bought for a duplicate id in the first place.

### `page_links`

The source file's own link annotations were listed in this page's prompt: `image`, `links` โ€” how many
were shown โ€” and `dropped`, how many the page had beyond the cap of 40.

They are in the prompt because the image cannot carry them. A PDF link is an annotation over the page
rather than part of the picture, so a page's targets are given to the agent as text beside it. The URLs
in that list are exact; the anchor text is approximate, since it comes from a separate text extraction
and can be split across lines, clipped short, or differ in spacing from what the image shows.

`dropped` is also the width of a blind spot in the two checks that follow, and both err in the same
direction. [`page_links_missing`](#page_links_missing) compares only the links that were shown, so a
link past the cap is never reported missing. [`page_links_unexpected`](#page_links_unexpected) compares
against **all** of the page's annotations, so one past the cap is never reported as fabricated either.
Neither check blames the agent for a link it was never given.

A page with no annotations writes no line here, and a source that has none at all โ€” images uploaded
directly rather than a PDF โ€” writes none on any page. So the absence of this line is not a page whose
links came out right; it is a page with nothing to be right or wrong about.

### `page_links_missing`

Links the source file annotates that the page's HTML does not contain: `image`, and `links`, the hrefs
that did not arrive.

Checked in code rather than left to the fidelity verifier, because this is the defect that verifier
structurally cannot see: it judges the output against the **image**, and a link's target does not
appear there. A dropped link is invisible to it and a fabricated one is unfalsifiable. The comparison
against the file's own annotations has an exact answer, so it is made in code, for nothing, on every
page โ€” and its result is handed to the same self-correction pass as any other fidelity problem.

Matched by normalized URL and deduplicated: one `<a>` is enough to say a target survived, so a page
that links the same URL under two phrases does not report a miss for a link it has.

A page can be listed here and have **passed** its fidelity check. That is the case this rule exists
for, and it is why the correction it buys is verified in turn โ€” see
[`page_links_correction_rejected`](#page_links_correction_rejected). What a correction was asked to fix
is `trigger` on [`page_corrected`](#page_corrected), which reads `links` when this line is why.

### `page_links_unrecovered`

A self-correction was **kept** on this page and the delivered fragment is still missing links the
source annotates: `image`, and `links`, the hrefs still absent.

The correction pass is single-shot, so this is the delivered document's state rather than an
intermediate one โ€” a link named here is missing from what the caller receives. The line exists to keep
apart two questions one count cannot: whether the free check **found** something, and whether it got
it **fixed**. Only the second is evidence about what the verifier's model is worth.

Only on a **kept** correction, which on [`page_corrected`](#page_corrected) is `result: "kept"` or
`"identical"`. A page whose correction was rejected shipped its original fragment, so its missing links
are on [`page_links_missing`](#page_links_missing) and here on neither.

**Not gated on links having been the reason for the correction**, and that is the one respect in which
it is not the mirror of [`page_generic_alt_unrecovered`](#page_generic_alt_unrecovered), which is. So
this line's pages are not a subset of `page_links_missing`'s: a correction bought for a placeholder
`alt`, a duplicate `id` or a failed fidelity check can **drop** a link the page already had, and that
lands here with no `page_links_missing` line before it. It is the same failure
[`editor_links_dropped`](#editor_links_dropped) records for the Copy Editor, one step earlier in the
run. Which of the two happened is readable: look for a `page_links_missing` line with the same `image`.
With one, a repair was bought and did not take; without one, the correction lost a link that was there.

### `page_links_unexpected`

Absolute URLs the delivered fragment links to that no annotation on this page accounts for: `image`,
and `hrefs`.

**Logged, never corrected**, because two of the three ways to land here are legitimate. A URL printed
visibly in the text may link to itself, which the page prompt explicitly permits. A link the extractor
could not attribute to any text โ€” one over an image, say โ€” is real and simply unmatched. The third is a
model that invented a URL, and that is why the list is kept at all: a fabricated href looks exactly
like a real one in the delivered document, so without this line the failure this feature introduces
has no symptom.

Checked last, on the fragment that actually ships, because a correction pass rewrites anchors and an
href invented there is the one worth seeing. Only hrefs with a scheme count. An in-document reference
like `#fn-1` is the page's own anchor and is reported by [`internal_links`](#internal_links) when it
lands nowhere, and a relative href is not something an annotation could have supplied either way โ€”
counting one here would dilute the single signal this line is for. Only pages that **had** annotations
are checked, since only there is there a ground truth to be outside of.

### `page_links_correction_rejected`

A correction bought for a page that had already passed its fidelity check was refused by the second
verdict, so the page shipped as it was: `image`; `trigger`, what the correction had been asked to fix;
`links`, the hrefs that were missing; `alts`, `ids` or `words` where those were part of the same
call; and `problems`, what the **second** verdict objected to โ€” the field that says why the billed
rewrite lost.

`problems` is that verdict's objections **as prose**, an array of the strings it wrote. That is not
what the field of the same name carries on [`page_corrected`](#page_corrected), which is a count of the
problems the correction was *given*; the two lines can appear for the same `image` in one round, so a
reader summing "problems" across the log without checking which line it came off is adding a list to a
number.

`links` is always present and is `[]` where the refused correction was bought for a placeholder
`alt`, a duplicate `id` or a word written two ways alone, so the field to read for what the call was
for is `trigger`, not this one.

The name is older than the rest of the line, and keeping it is deliberate. It began as the links-only
case, and renaming it now would split one measurement across two event names in a log that is read
across rounds. `trigger` is what says which repair was refused: `links`, `alt`, `ids`, `words`, or
`both`. It cannot read `verify` here, though that value exists on
[`page_corrected`](#page_corrected) โ€” this recheck is only bought for a page whose fidelity check
**passed**, since a page that failed has no standing to protect.

`both` means more than one source fired, and deliberately not which combination. That collapse is a
property of the shared `trigger`, computed once for this line and [`page_corrected`](#page_corrected)
from five sources โ€” where naming the combination would be thirty-one buckets โ€” and only four of the
five can reach *this* line, since `verify` cannot. The per-source detail is already exact on
[`page_links_missing`](#page_links_missing), [`page_generic_alt`](#page_generic_alt),
[`page_duplicate_ids`](#page_duplicate_ids) and [`page_split_words`](#page_split_words), all keyed by
the same `image`. `alts`, `ids` and `words` are on the line anyway, and that is the reason: `both`
cannot say the alt, the duplicate id or the split word was part of what the refused call was asked to
fix. `words` carries one further reading the others do not: a refused correction bought for a split
word may be a rewrite that lost, or a model that declined the problem โ€” which on that check is a
legitimate answer โ€” and was overruled by the recheck.

The correction is billed either way, so this is a line about money that bought nothing โ€” a page that
was known to be good was re-rendered, the rewrite was judged in turn, and the rewrite lost. A verdict
that could not be **obtained** lands on [`page_verify_error`](#page_verify_error) with
`correction_discarded` instead, so no verdict and a failed verdict are the same outcome for the page
and two different lines in the log.

### `page_soft_hyphens`

Soft hyphens (U+00AD) were taken out of a page reply before it became markup Iris keeps (`page`,
`image`, `removed` โ€” every occurrence, not every word โ€” and `where`: the step whose reply carried
them, one of `extract`, `correct`, `specialist`, `specialist_merge`). A word the printing broke
across a column, carried into the HTML as an invisible character:

`agents/page.md` forbids exactly that, with a worked example, and three models from three labs do
it anyway โ€” 63 occurrences on 9 pages of one 100-page arm, reaching 23 of 62 delivered documents,
on pages the fidelity check passed (#334). It renders as nothing, so the page reads as clean while
find-in-page fails: a reader searching a delivered document for `Insurance` does not match
`Insur&shy;ance`, and the words this lands on are table row labels and column headings. The strip
is unconditional because there is no output where the character is the right answer โ€” it needs no
image, no word list and no second model โ€” and it covers the entity spellings (`&shy;`, `&#173;`,
`&#xAD;`) as well as the codepoint, since those render and defeat a search identically.

**Logged only where it fired**, so a run with none of these lines is a run in which no reply
carried one. `where` is what makes the count attributable: the same character from a first render,
from the correction pass and from a specialist are three facts about three different calls โ€” and
`redrawn: true` is present when the reply was a page's [second draw](#page_redrawn), whose markup Iris
discarded, because a redraw makes two `extract` calls for one page. The same flag appears for the same
reason on `page_style_attributes`, `page_digit_groups` and `page_links`. It is
written AFTER `agent_call`, so the reply on record in the round logs is still the model's own โ€”
the census behind this row was a $0 regrade of logs already on disk, and a strip applied before
the log would have left no way to take that measurement or any future one.

### `page_style_attributes`

`style` attributes were taken out of a page reply before it became markup Iris keeps (`page`, `image`,
`where` โ€” the same four steps as above โ€” `stripped`: how many attributes, `spans`: how many `<span>`
elements this strip left holding nothing but whitespace and so removed whole, `cells_emptied`: how many
`<td>`/`<th>` cells were left holding nothing by those removals, and `props`: the CSS property names
those attributes set, deduped and sorted).

`agents/page.md` forbids all styling in as many words โ€” a `style` attribute is not announced, does not
survive being read aloud, and is dropped by anything that reformats the document โ€” and #374 measured 52
of them shipped anyway on one 91-page arm. The strip cannot lose anything a reader was getting, which
is what makes it a repair rather than a re-ask: whatever the declaration was doing, it was doing it
only for someone who could see it.

**`props` is the field to read.** 46 of those 52 are `padding-left`, 40 of them on a single page's row
headings, and that is a table's row groups written in ink instead of in markup โ€” information the page
HAS and the delivered document does not. Removing the attribute does not lose that, but it does make
the page look clean, so the properties are logged: a line saying `padding-left` names a page whose
hierarchy needs the `<tbody>`/`scope="rowgroup"` treatment the prompt asks for, and one saying
`background-color` names a legend swatch that painted nothing and announced nothing. Rebuilding either
is a re-ask against the image and is not something the strip can do โ€” the stated limit of the repair.

`spans` is counted apart from `stripped` because it is a different edit: an element removed rather than
an attribute. It covers only the residue THIS strip creates โ€” a `<span>` whose attributes were all
`style` and whose content is nothing but whitespace โ€” so a `<span></span>` the model wrote empty of its
own accord is left alone, and a `<span class="โ€ฆ" style="โ€ฆ">` keeps its element because it still has an
attribute afterwards. The removal is repeated until it stops changing anything, because a styled span
whose only content is another styled span is invisible to a single pass โ€” the outer one would survive as
the bare `<span></span>` the rule exists to prevent, with `spans` and `cells_emptied` both short by one on
a mark that is just as gone. That nesting is not a shape anything has been seen to write (0 of 69 styled
spans over 1,741 of the bench's kept HTML files), so the repeat is there to keep the counts honest on a
shape they were not looking at, not because it was measured. **What goes is the element, never content:**
a span holding one space hands that
space back, because the same markup is a legend swatch's width in one place and a word boundary in
another, and only one of those two mistakes is visible in the delivered text (`Ohio<span
style="โ€ฆ"> </span>5%` would otherwise be delivered as `Ohio5%`, which the page prints nowhere).

**`cells_emptied` is the one number here that names work rather than housekeeping.** A legend swatch
written as `<td><span style="background:#ccc"></span></td>` leaves `<td></td>`, and `agents/page.md`
calls an empty cell the one encoding a reader cannot undo, because the cell then claims the paper printed
nothing there. The strip does not create that defect โ€” the cell held no text before it either, so a
screen reader announced an empty cell both ways โ€” but it removes the last trace that the page had a mark
there, so a page with this above zero is a page whose mark is unrecoverable without the image. Cells that
were already empty are not counted; the number is what these removals added.

The strip does not read inside an element whose content the parser reads as text (`script`, `style`,
`textarea`, `title`, `xmp`, `iframe`, `noembed`, `noframes`, `plaintext`), because a `<` in there opens
nothing and a page transcribing a report on markup can print a tag's source unescaped. That list is
narrower than `src/pipeline/anchors.ts`'s for the same shape of skip, on purpose: a `<template>` or a
`<select>` interior IS parsed as markup, so a `style` attribute in one is a real attribute and skipping
them would leave a hole rather than close a false positive.

Scoped to the extraction phase like the strip above, but WITHOUT that clause's argument, and the
difference is worth stating. A soft hyphen is an artefact of reading a printing; a `style` attribute is
a model reaching for CSS to hold a shape, which any agent writing markup can do, and the review-phase
agents write markup. #374 measured the page agent because that is what #374 looked at, so what is known
is that it happens there โ€” not that it happens nowhere else. `where` is what would say otherwise.

### `page_digit_groups`

A thousands separator the page reply split with the printer's alignment space โ€” `4, 271` โ€” was closed
up (`page`, `image`, `where`, and `tightened`: how many separators, not how many cells).

The gap is the column being aligned rather than part of the figure, `agents/page.md` says so, and the
models do it anyway: 549 separated groups on 6 pages of one arm, 166 on another, none on a third. What
ships is a number no reader can find and no tool can add up โ€” a search for `4,271` does not match `4,
271`, and a total written that way is two numbers to anything that sums a column. `p028` and `p029` are
the same table transcribed twice, 174 groups spaced on one page and 39 tight on the other, which is
what says this is a per-cell coin flip rather than a page's considered style.

**Scoped to a table cell whose whole content is one figure**, never to the document, and that scope is
the difference between a repair and a corruption: the same pattern loose in prose turns `In 1954, 105
cases were filed` into `In 1954,105 cases`. 546 of the 549 sit in such a cell. The other 3 have a
footnote marker beside the figure, so the cell carries a tag, and they are left exactly as written โ€”
the stated limit, rather than a second looser pattern nothing has measured. A list of years (`1954,
1955`) and a list of short numbers (`1, 2, 3`) are untouched inside a numeric cell too: the group after
the separator has to be exactly three digits.

### `page_split_words`

A page wrote one word two ways (`page`, `image`, and `words` โ€” one entry per word, `"Compos-ite /
Composite"`, each spelling as the page wrote that one, in the order the hyphenated form first
appears). The other half of the hyphen family above, and the half a strip cannot do: this is the
**visible** break (U+002D), where `page_soft_hyphens` is the invisible one.

A page that writes both `Compos-ite` and `Composite` has certainly got one of them wrong, whichever
the paper prints, and that is the whole finding. It needs no word list โ€” the evidence is the page's
own two spellings, so English is never consulted, and #334 measured what happens when it is
(`/usr/share/dict/words` scored `totals`, `states` and `populations` as non-words while failing to
match `manu-facturing` against `manufacturing`, wrong in both directions) โ€” no second model, no
second arm and not the image. So it is free and exact on the same terms as a dropped link.

It also fires where the strip does not, and on **every** arm rather than one. On #334's 100-page
three-arm census the soft-hyphen column is 63 occurrences on 9 pages from `claude-sonnet-4-6` and
zero from `kimi-k2.5`, the page model of the day; this check's own column there โ€” one word written
both ways on one page, the predicate above, not the wider count of retained visible hyphens beside it
โ€” is `kimi-k2.5` **6 words on 4 pages**, `claude-sonnet-4-6` 3 on 3, `gpt-5.6-luna` 2 on 2.

The problem it raises does **not** say which spelling to keep, and that is deliberate to a degree
the census measures: three of Kimi's six are `inter-state` and `non-farm`, forms a 1962
report genuinely prints, so on half the measured cases the hyphen is right and the joined spelling
is the defect. "Join them" would have been the wrong instruction there, and on a page that
legitimately prints both forms it is how a defect gets introduced. Only the agent holding the image
can settle it, and `agents/page.md`'s hyphen rule already tells it how: "Where you cannot tell whose
hyphen it is, keep it".

Read **after** the soft-hyphen strip, and that order is load-bearing rather than tidy:
`Govern<U+00AD>ment` has contiguous letters, so a page carrying the invisible break writes the word
whole as far as any text comparison can tell and the contradiction is hidden (#334 measured this on
the page this rule comes from).

**A lower bound, not a rate.** A page that breaks a word and never writes it whole is invisible
here, since it is the second spelling that makes the first a contradiction. #334's cross-arm
version of this test asks whether *another* arm delivered the word whole, and that is not available
at run time with one arm running. Three further limits, each narrowing in the same direction:
attribute text is not examined at all, `alt` included (`href`, `id` and `class` carry hyphens by
convention, and `id="non-tax"` beside the word `nontax` is not a contradiction about anything a
reader is shown); only unhyphenated words corroborate, so a page writing `Commu-nications` and
`communications-related` and never the bare word finds nothing; and only one internal hyphen counts,
so `Con-struc-tion` and `Trans-porta-tion` โ€” two breaks each, on `p032` of the same census, and
missed by #334's first detector for this same reason โ€” are skipped. That last one is a limit and not
a proof: neither of those words is written whole anywhere on its own page, so admitting them would
have added nothing to this count on **that** corpus, which is a fact about the corpus. One hyphen is
kept because it is the shape of the common case and because it keeps a printed compound like
`state-by-state` from being compared against a `statebystate` nothing writes.

A fifth limit, of a different kind from those four: they are about which tokens get compared, this
one is about a break that never becomes a token. The hyphen must be followed by a letter
immediately, so a fragment that soft-wraps its own source at the break (`Compos-` ending a line,
`ite` beginning the next) is read as two whole words and nothing is a candidate. Under-detection
again, and deliberately not widened to allow whitespace after the hyphen: that needs whitespace out
of the lookup key too, and the same widening then fires across an element boundary
(`<td>Total-</td><td>farm</td>` beside a `Totalfarm`, since every tag renders as a space here), and
it reports a `split` string the document does not contain, which is the one thing a corrector is
asked to go and find. Such a fragment also shows the reader `Compos- ite`, hyphen and space โ€” a
defect on its own terms rather than the contradiction this rule is about.

Skipped means skipped in **both** roles: a word carrying more than one hyphen is neither a candidate
nor evidence that some other word was broken. Both halves matter, and the second is the one a reader
would not assume. The tokeniser consumes a whole hyphen chain as one word, so `up-to-date` does not
contribute a bare `date`, and a page writing `up-to-date` beside `dat-e` reports nothing โ€” the
`joined` spelling in every finding is one the page writes on its own. Such a word still counts
toward `words_checked`, since that field is how many words were looked at.

### `page_split_words_unrecovered`

A correction bought for a word written two ways was kept and a word is still written two ways
(`page`, `image`, `words` โ€” the pairs that remain, recomputed on the delivered fragment rather than
intersected with the list going in, so a correction that joined `Compos-ite` and broke
`col-lections` in the same reply reads as a defect **moved** rather than one untouched). Only logged
where the correction was bought for a split word in the first place.

One reading this line does **not** support that its three siblings do. A link still missing, an alt
still generic and an id still duplicated are failures. A word still written two ways may be the
model declining, and on this check a decline is a legitimate answer: Iris knows the page
contradicts itself and cannot know which spelling the printing carries, so the problem it raises
asks rather than instructs, and ends by saying that a page which really prints both spellings
should say so and change nothing. The request licenses that refusal by name rather than leaving it
implied โ€” see the second mark under
[`page_correction_declined`](#page_correction_declined), which is the sentence that makes this
reading a real channel instead of a hope. This line therefore says the contradiction survived the pass and
nothing about whose fault that is. [`page_correction_declined`](#page_correction_declined), keyed by
the same `image`, is where the model's side of it is, and a cited decline lands in `declined.words`
rather than `declined.code_checked` for exactly this reason.

### `page_caption_claim`

A figure whose `<figcaption>` makes a claim about the picture its `alt` describes, recorded as the
two free caption checks found it (`image`, `page`, `figure`: its place among the fragment's
figures, and `caption`: the caption's visible text, clipped to 200 characters). Written per
figure, from the page's own fragment, **after** any specialist merge and **before** the verify
call, so it reads the same two strings the verifier is about to be shown. Two of the three axes it
covers are already rules in both prompts โ€” a count the page PRINTS against the length of the
description's list (issue #353), and a region the caption calls highest or lowest against the
bands the description sorts places into (v1.12) โ€” and this line is not a third rule.

**Nothing here refuses, corrects, reaches a verdict or reaches the model**: it is the record that
a free check had a subject on this page, and the reason it had no answer. That is what was
missing. v1.12's clause ends by refusing the comparison where the caption's named group is not one
the page itself sorts โ€” supplying "which states are New England" out of a model's own knowledge is
how that check invents the problem it then reports โ€” so a round reporting no region contradiction
was either a round with none or a round in which every subject was refused, and from outside those
two are the same silence (#356).

`quantifier` is the caption's own words where it quantifies a category as a fraction ("About
Half", hedge kept as printed) and `share` is the value those words name (`0.5`); `band` is the
band word a verb asserts of a group (`highest`, `lowest`, `high`, `low` โ€” bare `high` and `low`
only after a ranking verb, since "Unemployment Is High Throughout" sorts no places, while a
superlative counts after a copula too because nothing separates that wording from the plate this
was written for); `enumerations` is how many lists the description sorts anything into; `named` is
up to 6 members of those lists that the caption names, matched case-sensitively and on word
boundaries.

`declined` is why nothing was compared, and each reason belongs to one axis: `proportion` wherever
a caption quantifies in words, `no_enumeration` where the description sorts nothing at all,
`membership` where a band claim's group is nowhere among the bands.

`no_enumeration` and `membership` are exclusive โ€” where there are no bands there is no band for a
region's members to be missing from โ€” and a line with no `declined` at all is a check that was
decidable.

**`share` is the fraction the caption states and not a proportion of anything measured, and no
ratio between the two strings is on this line.** That is the finding rather than a gap: both terms
of such a ratio are readings of the ink rather than transcriptions of the page, `p092` is the
corpus's only subject, and its five reads put the quantified category at 34.7%, 35.6%, 41.7%,
41.9% and 50.0% of the states enumerated โ€” no tolerance separates a miss from an exact hit on the
one plate a tolerance could be fitted to.

The member parse says the same thing independently: over 16 reads of that plate's one unchanged
legend it answers 1, 2 or 3 lists, and on the read yielding "30 and 16" two of the 46 members are
fragments of prose while one state's name is lost to a length bound. So `enumerations` is a shape
and not a denominator, and the arithmetic is left to whoever regrades the log at whatever
tolerance a later corpus can justify. Over 1,302 delivered pages of the rounds on disk this fires
on 66 figures across four plates, and 47 of the 50 region claims among them are subjects the
clause must decline (44 `membership`, 3 `no_enumeration`); the 3 that are decidable are all one
arm describing the map BY region, which is the shape that puts the membership on the page.

Its limits, before a corpus is counted off it: a figure's own title asserting a band with a verb
("Figure 7. Where Tax Effort Is Highest") is a `membership` decline here, since no wording
separates it from a caption's real claim, so the declined counts are an upper bound on how often
the clause had a page to refuse on; it reads a `<figcaption>` and nothing else, so a claim in a
`<p>` beside the figure or in a `<table><caption>` โ€” v1.12's subject too, since the prompts say "a
`<figcaption>` or a sentence in the fragment" โ€” is a decline this cannot count; the members come
from the same enumeration parse `alt_relocated` uses and are tokens rather than necessarily
places; "most", "a majority", "many", "few" and "nearly all" are deliberately not subjects, since
each names an inequality rather than a value and a report that turned one into a number would be
inventing the number it compared; and a group the caption re-typed in another case goes uncounted,
so it reads as declined โ€” the conservative direction for a field whose whole job is to say the
check had nothing to work with.

**Logged only where a claim was found**, so 3 or 4 lines per 91-page arm, and a run with none is a
run whose captions claimed nothing of the kind.

### `specialist_unresolved`

The page agent named a specialist and no available agent answers to that name: `agent`, `image`,
`reason`, and two lists.

This is the first of the five `specialist_*` lines, which are the five exits from one attempt: a page
agent that judges some content beyond its own reach names the specialist it wants, in its own words, and
the pipeline tries to route the page to it. **Every exit writes a line, including the ones that change
nothing** โ€” because without them, "no routing was attempted" and "routing was attempted and the name
did not resolve" are the same observation: a page that came out of the general pass unchanged. Their
counts reconcile against the requests in one run. `agent` names the request on all five, but not in the
same form: here the raw string the model wrote when it normalized to nothing, the normalized type when
it did not resolve to a file, and on the three lines after a successful load, the agent's **filename**.

`reason` distinguishes the two ways to get here. `empty name` means the request normalized to nothing,
and `agent` is then the raw string, since that is what a maintainer has to recognize. `no agent file of
that name` means it normalized fine and no file matched โ€” `chart` for `chartDataAgent.md`, a display
name, a plural. Both are one defect in practice: the model's wording and the library's filenames
disagree, and the specialist silently does not run.

The two lists are the explanation, and they are kept apart on purpose. `candidates` is what **was**
dispatchable โ€” real files, so a near-miss reads as a near-miss without a second run to investigate.
`declined_types` is the standard types, and it is the commoner half of the answer: a suggestion of
"tables" is not a standard type, so it never reaches the decline branch and resolves to no file, and
reporting "table" among `candidates` would claim the opposite of what is true. Had the model written
"table", it would have been **declined**, not dispatched.

The page is delivered as the general pass wrote it, and the fidelity verifier is told so โ€” this exit
sets the caution "No agent of that name was available".

### `specialist_declined`

The page agent asked for a specialist of a type the general page pass already covers, so the request was
refused by policy: `agent` (the normalized type), `image`, and `reason: "standard type"`.

**Not a failure.** The decline is keyed on the standard list rather than on what is on disk, so a
deployment that drops a `table.md` into its agents directory does not get the original defect back โ€” a
standard specialist splicing its fragment over content the general pass already rendered, which is the
duplication the page prompt forbids. Matched case-insensitively, so `Table` declines here rather than
falling through to a file lookup that on a case-insensitive volume would find the very file the rule
exists to refuse.

This is the one exit that answers the request rather than merely not granting it, and so the only one
that sends the verifier no caution. Narrowing the verifier's licence here would apply to the commonest
suggestion shape there is โ€” on a table page, it would mean declining to say a cell reads wrongly โ€” and
it would buy nothing measured: of the 7 requests behind #353, 0 named a standard type.

### `specialist_no_content`

A specialist loaded, ran, and returned nothing of its type: `agent` (its filename) and `image`.

The page ships as the general pass wrote it. Non-blocking, like every failure on this path, so the run
does not stop โ€” but the request went unmet, and the verifier is told that in those words rather than
told no agent existed.

### `specialist_dispatched`

A specialist ran and produced a fragment: `agent` (its filename), `image`, and `merged`.

**`merged: false` on this line is a failure, not a detail.** A fragment that was written and then not
spliced into the page leaves exactly the page a fragment-less run would have left, so the request is
unmet and the delivered page is the general pass's own unaided work. Only `merged: true` met it.

That is the general shape of this family, and it is why the log carries five lines rather than a
boolean. The pipeline keeps two internal signals across these six exits and they disagree on **four**
of them: `dispatched`, which answers "is this suggestion already covered, or should it be filed as a
new-agent issue", and the caution sent to the verifier, which answers whether the request went unmet.
Three of the four disagreements are silent โ€” no content, a throw, and a fragment that would not merge
all count as dispatched while no specialist content reached the page. The fourth is the other way
round: [`specialist_declined`](#specialist_declined) is not dispatched and sends no caution, because
nothing ran and the request was nonetheless answered.

So a count of `specialist_dispatched` lines is not a count of pages that got specialist content; the
`merged` field is.

### `specialist_dispatch_failed`

The specialist call threw: `agent` (its filename), `image`, and `error`.

Non-blocking โ€” the page is delivered as the general pass wrote it and the run continues, so this is a
line about content the pipeline knows it did not get rather than about a stopped round. The verifier is
told the specialist call failed, which is a different statement from no such agent existing, and the
distinction is kept because telling it "no agent of that name was available" about a specialist that
ran and threw is simply false.

### `page_recovered`

A feedback re-extraction succeeded on a page an earlier run had lost, so the document is whole
again for those `pages`. Logged late in the run, once that document has been persisted: a round
that re-extracts the page and then throws in review leaves the earlier document โ€” hole and all โ€”
as the one the session holds.

### `extraction_failed`

**No page produced any content**, so the run is ending rather than delivering a document with no
words in it (`pages`: how many failed, `blank`: how many were reported blank). With `blank: 0` the
`run_failed` line that follows carries the first page's provider error, because that is the
diagnosis. Otherwise the source itself was empty โ€” one blank scan uploaded alone, a rasterization
that yielded white pages โ€” and the error says how many of its pages were blank, which an empty
document could not.

### `page_verify_ok` / `page_verify_failed`

The Feedback Agent's fidelity verdict on one page, checked against its source image. A failure
names its `problems` and buys that page one self-correction pass. A page that passes can still be
re-rendered (a dropped link), so a run's `page` call count is `pages + corrections`, not
`pages + failures`. A failure also carries `kinds`: the distinct kinds of problem the verdict
named, out of `content_missing`, `content_wrong`, `structure_wrong`, `a11y_only` and `alt_quality`
(defined in `agents/feedback.md`, in the order the agent is told to prefer them โ€” content that is
absent is `content_missing` even though it is also a WCAG failure).

It is a **set**, not one label per problem: two missing rows are one page that lost content.
Without it a page that lost three table rows and a page whose alt text was refined from "orange
kayak" to "orange-yellow kayak" wrote the same line, which made `verify_failed` a count of pages
the verifier had an opinion about and nothing more.

`untagged` is how many of that page's `problems` โ€” a count of problems, where the diagnostics
fold's `untagged_pages` counts pages โ€” carried no kind this version recognizes โ€” an agent file
whose VERIFY contract predates the kinds, a session-built or trained one that dropped the field,
or a kind the agent invented. Read it beside `kinds` or a split reads as covering pages it never
saw. A problem is never dropped for being untagged or unrecognizably shaped: a lost label costs a
label, and a lost problem ships the page.

A `page_verify_ok` line carries `unjudged: true` when nothing actually judged the page โ€” no
Feedback Agent loaded, nothing to verify, a reply that would not parse. Verification is
non-blocking, so all three answer "faithful" and the page ships; the field is what separates "the
verifier looked and was satisfied" from "nobody looked", which is otherwise the same line. Omitted
rather than false on a real verdict, and absent from every log written before it existed. The
diagnostics fold counts them as `pages_unjudged`. A fourth case joins those three and is the one
that saves money:

`skipped: "blank"`, a page the agent declared blank, which is not sent to the verifier at all
because an empty fragment has no content to be unfaithful with (issue #294). Not every blank page:
a declaration the reply **stated** in its `blank` field whose own log names something on the page
is judged, and its line carries no `skipped` at all โ€” it is the one blank page a verdict is bought
for, and `blank_contradicted` on its [`page_blank`](#page_blank) line is why (issue #371). It
carries `unjudged: true` too โ€” both are pages nothing looked at and neither may enter a pass rate
โ€” and the extra field is what separates a call that could not be made from one that was not
bought, which is the difference between a broken run and a saving.

Counted as `pages_skipped_blank`, a subset of `pages_unjudged`. The free checks still run on that
page, so a blank page carrying link annotations still fails the link comparison and still buys a
correction. A fifth case is `skipped: "error"`, the second value that same distinction always
described and nothing had ever emitted: the call that could **not be made**, as against the one
that was not bought (issue #364, and `page_verify_error` below for what went wrong). It is
`unjudged` too and stays out of every pass rate for the same reason, but it is the opposite of a
saving โ€” that page was billed for a full ceiling of output and got no verdict for it โ€” so
`pages_skipped_blank` and `pages_verify_error` are counted separately and must not be added.

### `page_verify_error`

A page's fidelity check could not be obtained: the call was made and the provider errored, was
throttled, stalled, or the reply overran its output ceiling. Carries `image`, `page`, the `step`
that failed (`verify`, the check that decides whether a correction is bought, or
`recheck_binding`, the gate on keeping one), the `error` message, and for a truncation the same
evidence `page_correction_failed` carries โ€” `truncated`, `reply_chars`, and both ends of the
reply, which is what separates a verifier that needed the room from one that wrote an essay about
a page it had already judged.

**A verdict that cannot be obtained is not a page that cannot be extracted**, and until issue #364
this pipeline could not say so on the first check:

`verifyAgentOutput` is non-blocking for an absent Feedback Agent and for an unparseable reply, but
a provider error is rethrown, and that first call had nothing to catch it โ€” so a throttled or
over-long *check* propagated out of the page's extraction and shipped a `@page-failed` marker for
a page that had rendered fine. Measured once on a 100-page bench arm: a page extracted as 8,855
characters of HTML, a complete statistical table of 568 words, was delivered as a 156-byte
comment, and $0.5051 of that page's $0.6634 was the call that deleted it.

Three things it cost besides the page, which is why this is its own event rather than a silent
`catch`: the delivered document asserted "the source pages above could not be extracted", which
was false; `pages_failed` and every triage of *why* pages fail recorded a vision failure, so
anyone tuning the page agent on that signal was tuning the wrong agent; and the marker advised
raising `providers.*.max_tokens`, which buys the verifier room to write more about a page it has
already judged โ€” the wrong lever, pushed the wrong way.

The policy it is fixed to is this pipeline's own: a specialist that fails leaves the page as the
general pass wrote it, and a fidelity check that cannot run is nothing to correct, so no
correction is bought and the page ships as extracted. That matches an unconfigured deployment on
any page whose only route to a repair was the verdict, and **not** on a page the link comparison
would have repaired: with no Feedback Agent loaded every call site returns a passing unjudged
verdict, so a links- or alt-triggered correction reaches the binding recheck and is **kept**,
while under a provider failure that recheck throws too and the correction is discarded.

A page with a dropped `href` therefore ships without it here and with it there โ€” the discard
decision below, stated rather than folded into a claim of equivalence. On `recheck_binding` the
line also carries `trigger` and `correction_discarded: true`: that recheck exists to stop a
correction bought for one link or one placeholder alt from damaging a page that had already
**passed**, so where its verdict cannot be obtained the correction is not kept โ€” no verdict is no
licence, and the status quo is a page that passed.

The correction is billed either way, and that field is what puts the discard on the record rather
than leaving it inferred from an absent rejection line. The third verify call, the sampled
recheck, keeps its own older `page_correction_recheck_failed` and is not folded in here: it
decides nothing whether it answers or not, and it has been read across rounds under that name.

### `page_verify_inconsistent`

The verifier **described** a defect and then passed the page (`page`, `image`, `problems`,
`kinds`, `untagged`). A verdict's pass/fail is its `faithful` / `accessible` flags, and a
correction is bought only when a flag is false **and** a problem is named, so a verdict that names
one with both flags true ships the page โ€” and its sentence was not previously anywhere in the log,
since `page_verify_ok` carries no `problems`. Calibrating the verifier against injected defects
found 3 of 30 damaged pages described in full and passed: a swapped pair of paragraphs quoted back
verbatim, an `<h4>` among `<h2>` siblings named as such, `faithful: true` on both.

That is most of the gap between what it perceived (28 of 30) and what it flagged (25), and it is a
different failure from a verifier that cannot see (issue #210). Written on the FIRST verdict only,
the one that decides whether a correction is bought; a recheck's own disagreement is already
readable on its line, which carries both `ok` and `problems`. It decides nothing and costs nothing
โ€” the page ships exactly as it did โ€” because the fix worth having is kind-gated (a
`content_missing`, `content_wrong` or `structure_wrong` problem failing the page whatever the
flags say) and pricing that needs this counted over a fleet; failing on any named problem would
instead buy a correction round for every `alt_quality` suggestion the same agent is asked to
volunteer.

The diagnostics fold counts them as `verification.verify_inconsistent`.

### `page_corrected`

What a self-correction pass did (`trigger`: `verify`, `links`, `alt`, `ids`, `words` or `both`;
`problems`: how many it was given; `kinds`: what the verdict said was wrong going in โ€” the same set
as `page_verify_failed`'s, and empty on the `links`, `alt`, `ids` and `words` triggers, where the
defect was found by code against the file's own annotations, a closed word list, the fragment's own
parsed tree or the page's own two spellings of one word rather than named by the verifier).

`both` means **more than one** source, which is what it has always counted, so no reading of an older
log changes as sources are added. It does not name which combination โ€” `page_links_missing`,
`page_generic_alt`, `page_duplicate_ids` and `page_split_words`, keyed by the same `image`, are where
the per-source detail is exact.

`result` is one of five:

| `result` | What it says |
| --- | --- |
| `kept` | It changed the delivered document |
| `rejected` | Thrown away in favour of the page it was meant to improve |
| `identical` | It changed nothing about the page |
| `empty` | Nothing usable came back |
| `failed` | The model call threw, so nothing came back at all โ€” see [`page_correction_failed`](#page_correction_failed) |

The last three are calls paid for that bought nothing, and `failed` is the expensive one, since a
truncation has already paid for a full ceiling of output.

**A `rejected` has three causes and only two of them have a rejection event.**
[`page_correction_rejected`](#page_correction_rejected) is a reply that came back a fraction of the
page's size; `page_links_correction_rejected` is a second verdict naming something the rewrite had
lost; and โ€” with neither of those beside it โ€” a binding recheck that could not be obtained at all,
which logs `page_verify_error` with `correction_discarded: true` and counts as
`rechecks.binding_error`. Triaging a `rejected` by looking only for the first two finds no event for
the third, which is why it is named here: the first two are a correction judged and found wanting,
and the third was never judged.

**`identical` is decided on the effect, not on string identity**, so a model that returns its own
page re-indented or with `&` for `&amp;` is counted here rather than as `kept`. Such a fragment is
still **adopted** โ€” what ships is decided on string identity, deliberately, so that a change no
signal here observes cannot be silently reverted. `identical` means the page call bought nothing, not
that its output was discarded (that is `rejected`). Two shapes of it are worth telling apart, and
field presence is what tells them apart: with `chars_before` / `chars_after` and all four flags
`false`, the model re-typed the page to no effect; with no sizes and no flags at all, it handed back
the exact string it was given. Same bill, different behaviour.

When it changed something, `text_changed` / `alt_changed` / `attrs_changed` / `structure_changed` and
`chars_before` / `chars_after` say **what** changed โ€” observed on the two fragments, not claimed by
the verdict, so an alt-text refinement, a re-typed `href` and a restored table row are
distinguishable. `text_chars_before` / `text_chars_after` are the same two sizes with the markup
taken out โ€” how much prose a *reader* receives โ€” which is what separates a correction that added
markup to a page that was already complete from one that brought back content the vision pass had
dropped.

**Three more fields are about the image description and the completeness markers.**
`alt_relocated` names one or more NAMED members the correction moved from one enumeration in a
description into a disjoint one โ€” a state that left `darkest` and entered `cross-hatched` (#355).
`alt_added` names a member that ARRIVED: a name the corrected description lists in one of its
categories, named nowhere in the words of the description it corrected โ€” **in a list or out of one**,
since a band of a single member and a mention in running prose are both the earlier reply naming the
place โ€” joining a list at least two of whose members that earlier description had already listed
together (#373's `p084`, whose `below` band went from four members to six by gaining Colorado and
Illinois). `markers_added` names which of the two body markers โ€” `[not legible]`, `[page not fully
transcribed]` โ€” the corrected page has MORE of, which is the completeness claim a correction
appended.

The three share a contract. Each **names** its members rather than counting them, because a boolean
saying something moved somewhere is not a claim anyone can check afterwards. None is a fifth flag: a
relocation is always an `alt_changed` too, and what these add is what KIND of change it was, which
the four booleans and the sizes cannot say. None takes a view on which of the two replies is right,
none decides anything about what ships, and each is **absent rather than empty** where nothing
happened, which is the ordinary case. `alt_relocated` and `alt_added` are **disjoint by
construction** rather than by a rule โ€” a member `alt_added` names the earlier description did not
name at all, and one `alt_relocated` names it listed โ€” so a reader can add them. Each is capped at
**six** names independently, so one line carries at most twelve.

Limits to know before a corpus is counted off them:

- Two lists written as two sentences with no semicolon between them read as **one** list.
- A member merely **dropped** is on neither line. Only `text_chars_before` / `text_chars_after` say a
  description lost prose.
- A correction that changes how many images a fragment has is **skipped**, since descriptions are
  paired by position.
- A name whose own words include "and" or "or" reads as one member wherever the list it sits in also
  uses commas โ€” except as the first half of that list's last item ("Ohio, Health and Human Services
  and Education"), where nothing in the string says which conjunction is the list's and a member is
  silently not seen. In a run written with **no commas at all** the conjunction is the only separator
  there is, so such a name is split there, which costs the name itself and not its neighbours, since
  its two halves always travel together.
- Reading a run-on at all is what lets a word that is not a name onto the line โ€” "the legend runs
  pale and light and medium and dark" separates into band words โ€” so an entry is a **token that
  changed bucket** and not necessarily a place, and a corpus counted off these fields will contain
  some adjectives.
- A description that lost **any** member it no longer names anywhere reports no arrivals at all,
  because a state written out in full ("N.D." becoming "North Dakota") drops one key and adds
  another, and nothing in the two strings says the two are one place โ€” the descriptions this reads
  abbreviate constantly. A member the earlier description listed **twice** has no single written form
  to match, so it too reads as lost. That costs the case where a description genuinely gained one
  state and dropped another in the same pass.
- A member that merely moved between two of the description's own bands is **not** such a loss, since
  the correction still names it โ€” and "still names it", like "named nowhere", is read off the
  **words**, so a state re-banded into a band of one or left in running prose has not been lost
  either.
- A wholly new list of wholly new names is a category the correction **invented**, which is a
  different claim: `structure_changed` and the sizes are what report a description rebuilt.
- `markers_added` counts over the whole fragment, so a marker that arrives in an **attribute** is on
  this line too: inside an `alt`, with `text_changed` **false**, `alt_changed` true and the two text
  sizes **equal**, since nothing a reader reads as prose moved; and inside any other attribute (a
  `title`, an `aria-label`), with `text_changed` and `alt_changed` both false and only
  `attrs_changed` true. The page agent describing an unreadable region inside an image description
  reaches the first, and it is much the commoner of the two. A corpus expecting `text_changed: true`
  beside every `markers_added` line, or sizing the marker off the two text numbers, would read either
  wrong.

Why the two alt fields match the way they do, what their exact test costs, why the six-name budgets
are separate, and why `markers_added` reports additions only, is in [design notes โ€” what a
correction's alt fields can and cannot
see](design-notes.md#what-a-corrections-alt-fields-can-and-cannot-see).

### `page_correction_rejected`

A correction came back at less than a quarter of the size of the page it was given (`page`,
`image`, `trigger`, `reason: "shrank"`, `chars_before`, `chars_after`), so it was refused and the
page it was asked to correct is what ships โ€” paired with `page_corrected` `result: "rejected"`. A
correction is single-shot, so what it returns is what the document would keep; a reply this much
smaller did not correct that page. Applies on **every** trigger, unlike the links path's own
check, and it is decided before either re-verification, so no Feedback Agent call is spent judging
a fragment nothing will deliver.

In the bench logs the two replies that would have hit this were an agent's scratch template and an
abandoned draft, both bound by a parser that took the first `{โ€ฆ}` in a reasoning model's reply
rather than the last (issue #170); the parser now reads the right one, and this is the floor under
that judgement.

### `page_correction_failed`

A self-correction's model call threw (`page`, `image`, `trigger`, `problems`, `kinds`, `error`,
`truncated`, `ceiling`, `ceiling_bound`, `chars_kept`, and on a truncation `reply_chars` /
`reply_head` / `reply_tail` / `shape`), so the page keeps the version it already had โ€” the extraction
that succeeded, verified minutes earlier. **It costs the correction, not the page.**

Paired with `page_corrected` `result: "failed"`. Every error class is survivable here, not only a
ceiling โ€” a throttle, a stall and a truncation all leave behind a page good enough to have been worth
correcting โ€” and **nothing is retried**, because a correction truncating because the *page* is large
will truncate again for a second full ceiling of output.

`truncated: true` says the model wrote an essay where a page was asked for, which is worth reading
beside `page_verify_failed`'s problem list.

`problems` is how much work the call was given and `kinds` is what kind of work, spelled exactly as
`page_corrected` spells it so a failed correction and a kept one can be grouped together. `kinds` is
empty on the `links` and `alt` triggers, where the defect was found by code against the source file's
own annotations and no verdict named anything; a kind there would be a count the verifier never made.

`ceiling` is the output ceiling this call **asked for**, which is usually **this call's own** and not
the deployment's: a correction is capped at twice what the first pass of that page spent โ€” scaled up
if a specialist handed it a document longer than that pass produced โ€” with a 4,000-token floor
(`correctionCeiling` in `src/pipeline/extraction.ts`). **So the remedy on a truncated correction is
that multiple, not `providers.*.max_tokens`.**

It is the number asked for and not the number reached, so it is on **every** failure this line reports
and not only a truncation: read it with `truncated`, which says whether the ceiling is what the call
died of. A throttle or a stall carries a `ceiling` it never got near.

A `ceiling` **larger** than `providers.<provider>.max_tokens` is not a contradiction, and is the one
case where this field is not what the call asked the provider for: a caller may lower a call's ceiling
and never raise it, so the adapter sent the smaller of the two, and a truncation on such a line is the
deployment's ceiling โ€” which is what its error message will name.

`ceiling` is **absent** where the call ran uncapped, and the configuration is the remedy again on such
a line โ€” but it is two causes, not one, and the *page* tells them apart. Either the first pass reported
no token usage, so there was no measurement to take a cap from; or the page rendered **nothing** and
was delivered as blank ([`page_blank`](#page_blank)), whose correction is a re-render of the page from
its image rather than an edit of a page, so nothing its first pass spent bounds it (issue #294 โ€” this
line is reachable there only on the `links` trigger, and it is the repair that catches a page the
source file says was wrongly declared blank, which is why it is not capped at the floor).

`ceiling_bound` says which of `correctionCeiling`'s two terms produced that number, because `ceiling`
alone cannot: `multiple` is twice this page's own first pass, scaled by a specialist's growth where
there was one, and `floor` is the 4,000-token floor binding on a small page whose doubling falls under
it. **Read the term off this field and not off the number** โ€” a page whose doubling lands on 4,000 was
bound by the multiple and `ceiling === 4000` says otherwise. Absent wherever `ceiling` is, and for the
same reason: no cap means no term that produced one.

`chars_kept` is the size of the fragment that ships. On a truncation, four more fields say what the
reply was, which is the evidence `ceiling` only poses a question about:

`reply_chars` is how far the reply reached (the number `editor_truncated` calls `chars`, renamed here
because `chars_kept` is on the same line and a bare `chars` would read as the page's own length โ€” read
it as a ratio against that), `reply_head` its first 240 characters and `reply_tail` its last 240, on
`editor_truncated`'s terms exactly: whitespace folded, a fragment shorter than both excerpts together
quoted **entire** under `reply_head` with no `reply_tail`, and deployment-only โ€” never on
`GET /v1/quality`.

**A tail mid-sentence in content the head has not reached is a page that needed the room; a tail
repeating rows already in the head is a model looping.** The excerpts are absent on every other
failure, which has no reply to quote, and absent on a truncation that returned nothing at all โ€”
`reply_chars: 0` is the zero-character shape [Errors](#errors) describes, where raising anything buys
a larger burn.

On this line uniquely it is `reply_chars` and not the missing `reply_head` that says so, because
`truncated` here is a predicate over the error's *message*: it is also true of a truncation whose class
was lost crossing a boundary, and such a line carries no `reply_chars` at all. **So a bare
`truncated: true` is two shapes** โ€” a reply of zero characters, or a truncation that arrived without
its evidence โ€” and only `reply_chars` separates them.

`shape` is that head-and-tail reading turned into something countable, in `page_no_output`'s
vocabulary:

| `shape` | The reply |
| --- | --- |
| `truncated_envelope` | The reply the prompt asked for, cut |
| `bare_html` | A reply that IS the page's markup |
| `prose` | A reply that never began the page |
| `envelope` | The ceiling landed after the closing brace of a reply the model was still adding to |
| `empty` | Whitespace only โ€” not the zero-character shape, which carries no `shape` at all; `reply_chars` tells them apart |

Only `prose` settles the question by itself โ€” a cap spent on something other than the page buys more
of the same if it is raised ([Errors](#errors)). And `bare_html` says where the reply **began** and not
where the output went: a correction that starts the page and then narrates at it carries the same value
as one that transcribed to its last character, and `reply_tail` is still what tells them apart. Nothing
counts narration off this field. Absent on a truncation that returned nothing, where `reply_chars: 0`
is already the whole of what is known, and absent on every failure with no reply at all, exactly as the
excerpts are.

`blocks_named` has no counterpart here: this call is asked for the page's HTML and not for an edits
list. The fidelity problems the correction was asked to fix are still unfixed and still on record โ€”
keeping the page is not a claim that it was right.

Where the multiple and the floor came from, what one round's truncations could and could not settle,
and why a failed correction is not retried, is in [design notes โ€” the output ceiling a correction asks
for](design-notes.md#the-output-ceiling-a-correction-asks-for).

### `page_correction_no_output`

A self-correction's reply carried no readable HTML (`page`, `image`, `chars`, `shape` โ€” the same
shapes as `page_no_output`), so the page keeps the version it had. That version had already passed
everything except the fidelity problem the correction was asked to fix, which makes it strictly
better than the reply. Paired with `page_corrected` `result: "empty"`, which is the existing
record of a correction call that bought nothing; this line says what came back instead.

`declined` is on the line โ€” a count, absent where it is 0 โ€” where the reply refused a problem
(below) and sent no page with the refusal: the prompt argues against that shape by name, because a
reply with no `html` is a reply the run cannot use, and it is the one the field exists to make
visible. A reader looking at a correction that came back empty needs to know whether the model was
refusing or failing, and the two have different remedies. The count is a flag and not the record;
the reasons are on `page_correction_declined`, keyed by the same `image`.

### `page_correction_declined`

The corrector refused one or more of the problems it was given, saying which and why (`page`,
`image`, `trigger`, `problems` โ€” the whole list it was offered โ€” and `declined`, one entry per
refusal with `problem`, `source` and `why`). Since #373: a correction's only legal move used to be
compliance, so a problem asserting something about the HTML that the HTML itself refutes โ€” an id
used twice that is used once, an attribute missing that is present โ€” was answered by editing a
page that was right, and the edit was indistinguishable in the log from a repair.

The licence is narrow by construction: it covers a claim about **the HTML the corrector was shown,
refuted by that HTML**, and explicitly not a claim about what the *image* shows, which is the
judgement the check was asked to make. **It gates nothing.** No verdict, no `page_corrected`
`result`, no recheck and not the `uncorrected` set are decided here, so a problem declined wrongly
leaves the page exactly where a correction that failed to fix it leaves it โ€” named in
`uncorrected`, with `@page-uncorrected` on the delivered document.

The licence can buy a different *explanation* of a bad page and never silence about it. `problem`
is the number the reply cited, against the numbered list in the request (numbered for this reason:
matching a paraphrase back against the list would attribute a disagreement to the wrong entry),
and is **absent** where the reply cited nothing rather than guessed at.

`source` is which of the five checks raised that problem โ€” `verify`, `links`, `alt`, `ids`, `words`
โ€” read off the bands of the concatenated list, and `null` where no number was cited or the number
falls outside the list. It is the field that makes the risk measurable: a decline over `verify` is a
disagreement with the Feedback Agent's reading and may well be right, while `links`, `alt` and
`ids` were checked in code, so a decline there is a refusal of a fact โ€” and those three entries
are marked `(Iris checked this one in code.)` in the list the corrector reads, with the licence
excluding a marked problem by name, so a `code_checked` decline is a corrector going past what it
was told rather than following it.

`words` does **not** count as `code_checked`, which is the one asymmetry in this field. What Iris
verified there is that the page contains both spellings, and that is not arguable; which spelling is
right is, and the problem's own text says so โ€” it ends by inviting the corrector to say the page
really prints both and change nothing. So a decline citing `words` may be the licence working
exactly as written, and folding it into the field that counts abuse of the licence would put
compliance in the numerator. It gets its own `declined.words` count in
[Diagnostics](#diagnostics-timing--hang-detection) instead.

That asymmetry is in the request too, and it has to be, or the count above measures a channel the
request closes. A split-word entry carries a **different** mark โ€”
`(Iris checked in code that both spellings are on this page, not which one is right.)` โ€” because the
sentence excluding a marked problem from the licence ends "so fix it", which is true of a missing
link, a placeholder alt and a duplicated id and false here. `non-farm`, `co-operation` and
`inter-state` are forms a 1962 report prints, so "fix it" on such a page means joining a word the
page does not print into the delivered document. The request therefore carries one more sentence,
naming that mark: both spellings really are in the HTML, so that much is not in question, but which
one the page prints is for the image to answer, and if the image shows both then no change is to be
made. It is the only place in that request where what the image shows is a reason **not** to act,
and it says so in those words, because the paragraph above it spends four sentences ruling that
reason out everywhere else.

`problems` is the denominator without which none of this can be read โ€” 2 declined of 2 is a
correction refused outright, 2 of 9 is the pass working. Written per **page** and not folded into
a field on `page_corrected`, because a decline is per problem and that line is per page: a reply
that declined one of five problems and fixed the other four is the shape this pass is meant to
produce, and a count on the page's line could not be told apart from a reply that declined all
five. Absent from a run where nothing declined, which is the ordinary case; the diagnostics fold
counts it as `verification.declined`.

### `page_correction_recheck`

A second verdict on a corrected page (`ok`, `problems`), with `problems_before` / `problems_after` โ€”
how many **fidelity** problems the page was sent to be corrected with, and how many this verdict names
โ€” `kinds_before` / `kinds_after`, the same two sides as kinds, and `links_before` / `alt_before` /
`ids_before` / `words_before`, the missing links, placeholder alts, duplicated ids and words written
two ways it was also given.

**The four code-found counts are kept out of `problems_before`**, so that a page with one fidelity
problem and two code-found defects does not read as three-in-one-out. `words_before` is the one whose
family this verdict can also raise, since a visible hyphen is on the page, so `problems_after` may name
a split word `words_before` also counts. **The exact answer to whether any of the four came back is the
matching `_unrecovered` line, not the difference between these two numbers:**
`page_links_unrecovered` says whether the links came back, and `page_generic_alt_unrecovered` /
`page_duplicate_ids_unrecovered` answer the alts and the ids exactly and for free.
`page_corrected`'s `problems` is the correction's whole bill.

The kinds are what turn "the recheck did not pass" into an answer about the correction:
`content_missing` in and `alt_quality` out is a page whose content came back and whose description is
now the complaint, while `content_missing` on both sides is a correction that did not do the one thing
it was asked to. Both are `ok: false` with the same counts. Counts, not a diff โ€” deciding whether two
of the Feedback Agent's prose descriptions are the same problem is fuzzy matching on model output, so
both lists are on the line in full instead.

**`binding: true` is the links path re-verifying a rewrite it may discard; `binding: false` is a
measurement-only sample**, `defaults.recheck_sample_size` pages of the batch (default 1, `0` for none),
which changes nothing about what is delivered. The two are counted apart in `verification.rechecks`,
and a line with `ok: false` has its `problems` reported there as `rechecks.failures` โ€” **not** in
`diagnostics.errors`, which is failures of the run.

**At the default the sample is a count and not a rate.** One draw per run, so `1 of 1 cleared` is
everything it says, and a proportion read off it is not a measurement.
`sampled_ok / sampled` becomes a rate only at a size at or above the page count, which is a census and
costs one Feedback Agent call per correction.

Read the two counts beside the flag:

- On a **`binding: false`** line, a correction pass is single-shot and was never expected to reach zero
  problems, so five-in-one-out and five-in-five-out are both `ok: false` and only these say which
  happened.
- On a **`binding: true`** line the page had **passed**, so `problems_before` is 0 by construction and
  a problem named here is a rewrite of a good page that lost something โ€” not a correction that failed
  to converge.

`extraction_start` / `reextract_start` carry `recheck_sample_size` and `recheck_thresholds`, so a log
with none of these lines in it says which of three things happened: the measurement is off, no page was
corrected, or every correction landed below the first threshold.

`unjudged: true` marks a recheck nothing judged, on the same terms as `page_verify_ok`: `ok` is also
what an unavailable Feedback Agent looks like, and with none loaded every page passes its first check,
so every corrected page's recheck is the binding one and every one of them would otherwise read as a
rewrite checked and found good. `verification.rechecks.binding_unjudged` and `sampled_unjudged` are
those, per population.

What a census of the sample answered, why the four code-found counts are carried apart, and which pages
the sample lands on, is in [design notes โ€” the recheck, and what a sample of one can
say](design-notes.md#the-recheck-and-what-a-sample-of-one-can-say).

### `page_correction_recheck_failed`

The measurement-only sample could not be taken โ€” the extra Feedback Agent call hit a provider
error (`error`). Logged rather than raised: the page ships as it would have with no measurement at
all, and the slot this page claimed stays spent, so a throttled provider is not retried once per
corrected page โ€” and at a census that is one failed call per correction, not one per run. A
`binding` recheck has no such line, because there the verdict decides whether the rewrite is kept.

### `table_continuations`

The assembled document holds a table whose caption says it continues the one before it: `tables`
(how many tables the document has), `pairs` (how many of them are second halves that were located
in the source bytes) and `declined` (how many said so and could not be paired). Logged once per
run, before any join is attempted, so the ratio is readable on a run whose joins then all failed.
A table printed across a page break arrives as two tables with duplicate headers and no connection
between them, and no page agent can fix it: each printed page is its own call, so the agent that
wrote the second half had one image and the other half was not on it (issue #239).

It knew โ€” all 18 continuation captions in the reference corpus say "Continued" โ€” and emitted a
fresh `<table>` because there was nothing to append to.

`declined` is a fact about the bytes rather than about the model: the pair is found in the DOM and
its two source spans are not, which happens when a page delivered an unclosed `<table>` (an
unclosed opener swallows the table after it, so the bytes delimit nothing to splice). Those halves
ship as they arrived.

### `table_joined`

Two halves were merged into one table: `by` (`"code"` or `"editor"`, which path produced the
merge), the merged `caption`, `rows_first` / `rows_second` / `rows_joined`, `chars_before` /
`chars_after` for the two halves against the one table, and โ€” on the editor path only โ€” that
editor's own `editor_log`. On `by: "code"` only, the two halves' own bytes as well
(`halves` / `chars_first` / `chars_second` / `html_first` / `html_second`, exactly as
[`table_join_code_declined`](#table_join_code_declined) carries them and documented there): a
candidate loosening of the free path has to be scored on the pairs it must not break as well as on the
pairs it would newly take, and these are the first population. The presence rule is `by`, which is on
every line, so the population is countable. A paid join does not repeat them because the decline that
bought it is the same pair's bytes, on the line immediately before โ€” and since a pair's identity in
this loop **is** those two strings, the two lines can be matched on the bytes rather than on their
order.

A table printed across three or more pages is joined one pair per pass, so the second pass's first half
**is** the first pass's merged table: `chars_first` on the later line equals `chars_after` on the earlier
one, and the first two pieces' rows are on both. That is what the later pass judged, so a replay of that
line needs it. It also means the retention figures below are a corpus's cost and not a ceiling โ€” the
ceiling is per line, and per document it is the 12 pairs the loop can reach.

**The merge is not a plain concatenation**, because the halves do not always agree on what to
concatenate: a continued page can declare a different column count, the repeated header block can
carry footnote-reference ids an endnote links back to, and the bracketed unit note a continued page
reprints belongs in the joined table exactly once. Where one of those judgements is real the merge is
a Copy Editor call (`copy_editor_table_join.md` in the agent ledger); where it is not, it is made in
code and costs nothing.

**Read `by` rather than the agent ledger to tell the two apart.** A pair joined in code never reaches
the ledger, so a run's `table_joined` count and its `copy_editor_table_join.md` call count are
different numbers on purpose.

**`rows_joined` below `rows_first + rows_second` is not a defect.** The merge drops the duplicated
header block the continued page reprinted, and it may drop one bracketed note row โ€” or, where the
joined caption absorbed note rows, exactly the rows it absorbed. Both paths go through the same
verification, which is what makes this line trustworthy: one table, a caption without the
continuation marker, no column lost, a header block still made of `<th>` cells, every distinct row
label from either half still present as a cell somewhere, and the rows accounted for. What each of
those checks is for, and how much slack the row floor allows, is in
[design notes](design-notes.md#what-is-deterministic-and-what-the-answer-is-checked-against).

### `table_join_code_declined`

The merge was tried in code on this pair and stood down, so a Copy Editor call was bought for it: the
second half's `caption` and the `reason`. Logged on **every** pair the code path did not take, because
the share it takes is what a later round has to be able to re-measure and
[`table_joined`](#table_joined) alone cannot tell a free join from a paid one.

**A decline costs nothing.** The pair goes to the editor exactly as it did before this path existed.

| `reason` | What stood the code path down |
| --- | --- |
| `header_differs` | The second half's header block is not the first half's, so which one describes the joined rows is a reading of the table |
| `columns_differ` | A row of the continued page is wider than the first half already is. Reached where the continued page reprinted no header at all โ€” the case the header comparison cannot see |
| `id_would_be_lost` | An id on the half being dropped has no free counterpart to move onto: a footnote-reference anchor in the repeated header block, or an id on both halves' own `<caption>` or `<table>` element, where keeping one live target means choosing which |
| `id_would_collide` | The join would print one id twice, a defect it would have introduced |
| `note_repeat_unclear` | The continued page opens with a bracketed unit note the first half does not carry, so it is not the reprint rule 6 licenses dropping. "Carry" reads the first half's note rows **and** its caption, since the two halves need not agree on where they print it. The reverse is not a repeat: a note only the continued half carries is a first appearance |
| `note_repeats_exceed_licence` | The join would drop more bracketed note rows as repeats than the row floor forgives, and joining anyway would come back `rows_lost` |
| `caption_unclear` | The continuation marker is not wholly inside one text node, so taking it off means rewriting markup |
| `no_caption_available` | Neither half has a caption, which the verification requires |
| `content_outside_table` | A half's span parses to something beside its own table โ€” the parser fosters a stray `<p>` out of a `<table>` and the joined table's `outerHTML` would not carry it. The one way this path can lose content where a model reply cannot |
| `tfoot_no_tbody` | The first half has no `<tbody>` to append to and has a `<tfoot>`, so the rows would land after the table's own summary |
| `unreadable` | A half holding no `<table>` |
| `read_failed` | A parse **threw** โ€” on a half, or on the merged candidate |
| `verify:<reason>` | A code merge the same verification as [`table_join_failed`](#table_join_failed) refused. A refusal and a throw are different lines: only the throw is `read_failed` |

Every decline carries both halves' header blocks, because a header comparison is evidence about a
`columns_differ` decline too:

| Field | What it holds |
| --- | --- |
| `headers_identical` | String equality on the two **full** signatures, computed at the line rather than left to a reader of the capped `header_first` / `header_second` strings |
| `header_rows_first` / `header_cells_first` / `header_rows_second` / `header_cells_second` | The size of each half's whole header block. `cells` counts every child of a header row, `<td>` included, because the signature does and because a header block returning as `<td>` is its own defect (`header_cells_lost`) |
| `header_first` / `header_second` | The signatures as rule 3 compares them: per header cell, tag name, `colspan` and normalized text, cells joined with `\|` and rows with ` // `. Capped at 1,200 characters with a trailing `โ€ฆ` when cut |

Four things to know before counting these:

- **`cells` is the field that says whether a half declared a header block at all.** A header row
  holding no cells reports `rows: 1, cells: 0` with an empty signature, which a rows-only reading
  takes for a real header.
- **`headers_identical: false` with a zero cell count beside it is a page that reprinted no header,
  not two readings disagreeing.** Rule 3 skips the comparison entirely for such a pair. Count with the
  cell counts, not without them.
- **A signature is not re-splittable into a cell count.** A cell's own text can contain `|`; that is
  what the four counts are for.
- **All seven are absent in two cases and `reason` does not separate them** โ€” do not read absence as
  one reason, or one reason as absence. They are absent for `unreadable`, a half with no `<table>` to
  read them from, and for the `read_failed` that is the **join itself** throwing on a half no parser
  can read. The **other** `read_failed` is the verification throwing while it parses the merged
  candidate, and there all seven are present. A half with a `<table>` but no header block is present
  too, with an empty signature and `0` for both counts.

The halves' own bytes are on the line as well, which is what makes a decline re-scorable:

| Field | What it holds |
| --- | --- |
| `html_first` / `html_second` | The two halves exactly as the source delivered them |
| `halves` | `"logged"` when the bytes are on the line, `"too_large"` when they are not. Always present |
| `chars_first` / `chars_second` | Always present, so what an over-large pair dropped is measurable |

Read the bytes back with `pairFromHalves` (exported from `src/pipeline/tables.ts`) rather than with a
fresh parse, because that rebuilds the pair the round was holding and the free path then returns the
verdict it returned in the round. A rebuilt half's `start` / `end` are offsets into itself, so a merge
produced from a replay must not be spliced into anything.

`halves` is present even when the bytes are, because presence alone cannot be counted: a re-score has
to be able to say *N of M declines replayable* straight off the log, and the bound is a constant in
the code that a reader of an old log has no way to know. A line written before this field is neither
value, and reads as not replayable, which is what it is. The bound is 64,000 characters for the pair
and it **refuses rather than truncates**, unlike the capped header signatures โ€” half a table's bytes
parse to a different table.

**The run log carries page content, not only metadata about it.** These fields hold table markup from
the submitted document verbatim. `GET /v1/sessions/{id}/logs` and the diagnostics route are both
owner-scoped, so the only reader is whoever submitted the document, and `/v1/quality` publishes
aggregates with no log text in them. Worth knowing before a run log is attached to a bug report or
exported somewhere the document itself would not go.

**How often this line fires is not a property of the code.** The free path has taken 24โ€“53% of pairs
across rounds of the same rules on the same corpus โ€” so this line fired on the other 47โ€“76% โ€” with
nothing in the repo changing, because the disagreement is usually between two extractions of one
printed header. Per-run totals are folded into `tables` in
[Diagnostics](#diagnostics-timing--hang-detection) as `code_declined` and
`code_declined_with_halves`. What the variability means for anyone re-measuring the free share, and
why the guards are not loosened, is in
[design notes โ€” joining a table split across a page turn](design-notes.md#joining-a-table-split-across-a-page-turn).

### `table_join_failed`

One pair was left as two tables. **The document keeps both halves byte for byte**, so every failure
here delivers the output the pipeline had before this stage existed โ€” unlike a correction round, a
refusal costs one table's structure and not the document. A pair that failed is not asked again in the
same run, since the next pass would send the same two tables to the same prompt, so one unjoinable
pair does not starve the joinable pair after it.

`reason` is one of two groups. The pair never reached a verified answer:

| `reason` | What happened |
| --- | --- |
| `unmatched_source` / `not_adjacent` | The source bytes cannot delimit the pair (see [`table_continuations`](#table_continuations)). Reported with a caption and nothing else โ€” there are no halves to replay |
| `declined` | The editor judged the halves not to be one table |
| `no_output` | A reply with no HTML in it |
| `truncated` / `call_failed` | A request that did not come back |
| `read_failed` | Markup no parser could read, with `stage: "body"` when it was the document rather than the reply. jsdom parses by recursion and overflows on a body nested a few hundred thousand levels deep, which is reachable because `anchors.ts` delivers a page past 500 levels as written. The document then ships exactly as it arrived rather than failing the phase, the way the lint one step later reports its own overflow as `@lint-unavailable` |

Or the answer came back and the verification refused it. These are checked in this order, and the
order is only which reason a failed pair reports:

| `reason` | What the merge did to the table |
| --- | --- |
| `not_one_table` | The answer is not a single table |
| `no_caption` | The joined table has no caption |
| `still_continued` | The continuation marker is still in the caption |
| `columns_lost` | A column went |
| `header_cells_lost` | The merged header block came back as `<td>`, which axe does not report and which removes the header association from the one table this stage exists to improve. Counted over the cells that describe columns, so a bracketed note row printed inside the block is not one of them |
| `rows_lost` | Rows went, counted against both halves' rows less the collapsed header block and less an allowance that is the **larger** of one row and the note rows the joined **caption** absorbed, never their sum โ€” a note promoted into the caption is content kept |
| `labels_lost:<n>` | `n` row labels went. A bracketed unit note the merge moved from a row into the caption counts as kept, because the label check reads `th,td` and would otherwise refuse the very drop rule 6 licenses |
| `caption_note_lost` | A bracketed note **either** half's caption carried is in neither the joined caption nor a row some half printed |
| `caption_note_struck` | The note is still in the table, as a row a half printed where it printed it, and missing only from the caption rule 4 says to **copy** |
| `note_shipped_twice` | The joined table holds one note in its caption **and** as a row |
| `note_row_lost` | A note **neither** caption carried, printed by a half as a row, gone from the delivered table and not in the caption either |

The four note reasons are last on purpose: a merge that dropped the note *and* lost rows should say
`rows_lost`, because the note is the cheapest of these losses and would otherwise mask the dearest.
Among the four the order is lost, then struck, then doubled, then `note_row_lost`, so that each reason
names what happened to the note rather than the first condition that happens to hold.

Three limits to know before triaging a run off the note reasons:

- **A note the merge kept in any form these cannot see reads as a note lost.** They match a run's
  exact characters and look only in a caption or a note row, so a rewritten delimiter, a reworded note
  or a note moved elsewhere in the table all refuse. Every such refusal is safe โ€” the pair declines and
  both halves ship โ€” and every one costs a join that lost nothing.
- **Both bracket widths are read, ASCII and fullwidth. The parenthesised spelling is not**, because a
  check demanding every parenthesised run survive would demand the survival of `(continued`, which
  rule 4 requires dropped. So a parenthesised note, and a note printed with no delimiter at all, can
  go missing without this seeing it.
- **It is a shape test and not a reading**, so every bracketed run in either caption is owed, not only
  a note of measure. A caption carrying `[Sheet 2 of 3]` is owed too, and two captions carrying
  different runs โ€” `[In millions of dollars]` against `[In thousands]` โ€” can be satisfied by no joined
  caption that does not invent, so that pair declines for good and reports the loss rather than the
  units disagreement that actually happened.

Why the note rules are shaped this way, what each of them cost before it existed, and what the row
floor forgives is in
[design notes โ€” the bracketed unit note](design-notes.md#the-bracketed-unit-note).

### `table_joins_capped`

The document had more continuation pairs than one run will spend requests on (`joined`, `pending`,
`max`), and the `pending` ones ship split. Present only when pairs remain. The cap is not a bound
anything measured comes near โ€” the worst 25-page chunk of the reference corpus has 7 pairs โ€” and
since #276 a pass need not cost anything at all, because a pair the code path takes buys no
request. It bounds **passes**, not spend: a body that keeps producing pairs needs something to
stop on. Pairs are re-read from the body each pass, which is how a table in three pieces closes:
joining the first two leaves a document whose remaining half now follows a joined table.

### `prose_joined`

A sentence the source printed across a page turn was delivered whole (issue #248). `markers`
page-break markers stood between the pages, `candidates` of those turns had the next page opening
with a `<p>` that begins with a lowercase letter, `joined` were mended, `unmarked` of the joins
had no marker between the halves at all (a page printing no number emits none), and `word_splits`
were breaks that fell inside a word. Then one count per refusal:

`declined_interrupted` (something other than a paragraph stands between the halves โ€” a footnote
list, in all 9 of the reference corpus's cases โ€” so the marker is not what interrupts the
sentence; a page that FAILED extraction lands here too, because its fragment is the `@page-failed`
comment and that comment is exactly such a node), `declined_not_continuing` (the paragraph before
ended a sentence, so the lowercase start after it is something else), `declined_page_gap` (a page
between the two returned nothing at all, so the middle of the sentence may be what is missing โ€”
the page that came back empty with no marker, #194, which is dropped from the body and then
visible only as a hole in the numbering), `declined_no_cut` (the continuing sentence begins inside
an inline element that opened earlier, so no cut leaves both halves balanced markup),
`declined_attrs_kept` (the whole paragraph would have moved and it carries an attribute the move
cannot take with it โ€” `id` above all, which something may refer to), `declined_lang_mismatch` (the
two paragraphs disagree about `lang` or `dir`, so the words would arrive in a language nothing
said they were in), `declined_as_written` (one of the pages is being shipped byte for byte,
`skipped_pages`) and `declined_too_far` (more than 500 characters of text would cross the marker โ€”
a paragraph with no sentence boundary in it moves entire, so without the bound a page of
unpunctuated prose would deliver its whole text after the *next* page's anchor, which is not the
few words the direction below was chosen for).

Plus `word_split_examples`, up to five words the break split, cut at 40 characters โ€” text out of
the user's own document, so it stays on the deployment and never reaches `GET /v1/quality`. Absent
unless at least one turn was a candidate, so a document whose pages happen never to break a
sentence adds no line, and `markers` high beside `candidates: 0` is what a caseless script looks
like here: the lowercase test is the measured one and has no signal in Hangul, Chinese, Japanese,
Arabic or Hebrew, so those sentences ship split.

**The words move forward, past the marker**, which is a decision about what a page anchor means
and not a detail: the sentence is then whole with `#page-74` standing immediately before it, so a
reader following that anchor hears a few words of page 73 first โ€” where pulling the next page's
head back instead would land `#page-74` after the sentence it should open on and cost that reader
the start of it. A word the printer broke **keeps its hyphen** and is closed up ("Simi-" + "larly"
ships as "Simi-larly"), because nothing at this seam can tell a line-fill hyphen from a real one
and dropping it would be the one place this pass deleted a character the source printed;
`word_splits` is what makes that answerable with data.

Deterministic, so no model call is spent on it โ€” unlike the table join, which needs one wherever
the two halves' headers disagree about what the table is.

### `editor_images`

How many source images the Copy Editor received this round (`attached` of `of`, plus `pages`). A
`dropped` count means the selection did not fit in one request and was trimmed to the pages issues
actually named. `attached == of` on a multi-page document means at least one issue in that round
carried no page attribution, so the round asked for everything.

### `editor_images_refused`

The provider refused the round's payload as too large, so the same prompt was re-sent **without**
images. The correction still had the whole body and every issue; only a fidelity problem that must
be checked against the source can go unfixed.

### `editor_fidelity_observed`

The Copy Editor, looking at a page image it was sent for some other reason, says the HTML and the
page disagree about something **nobody asked it about**:

`count` observations, the `attached` pages it actually had, and the `observations` themselves โ€”
each a `page`, a sentence, and one of `page_verify_failed`'s five `kind`s (the same taxonomy, so
this can be read against `verify_kinds`; `null` where the reply named a kind this version does not
recognize, and the sentence is kept either way). Reported and **not acted on**: acting would mean
re-reading that page in full, which is a re-extraction and not this loop's job, and an edit made
from one glance at an image reaches a reader as what the page says.

So nothing about the delivered document changes because of this line โ€” it is the only trace, and
it is addressed to a person. Its value is that it is the **only** second opinion on fidelity in
the run: VERIFY checks each page once, with the same model family on the same image as the
transcriber, so its blind spots are the transcriber's by construction, and the Reader cannot see
the source images at all (issue #183).

`unattached` counts observations about a page whose image was **not** in `attached` โ€” the prompt
asks for attached pages only, so those are guesses about a page the model could not see, kept but
counted apart so a mostly-guesswork set can be discounted whole.

`unplaced` counts observations that named no page. Absent on the ordinary round, where the editor
noticed nothing.

### `editor_links_dropped`

An `href` present before that round's correction was missing after it (`iteration`, `hrefs`). A
link's target came from the source **file**, not from a page image, so a dropped one cannot be
recovered by looking again โ€” logged rather than repaired, and counted into `links_dropped_rate`.

### `internal_links`

The delivered document contains an in-document reference that lands nowhere (`refs` fragment links
in all, of which `empty` are `href="#"` and `dangling` name an `id` the document does not have,
plus `ids` โ€” up to 20 of the fragments that failed). Those three are counted per **reference**, so
`refs` is the denominator of the other two and one missing section linked forty times is forty
references a reader can activate to no effect; `ids` alone is the **distinct** set, because the
cap is 20 and one dead target must not spend it.

Measured on the bytes actually written, after every rename and every correction round, because
that is the only place the question "does this reference land" has a final answer; `@`-comment
markers are stripped first, since those quote model prose and an `<a>` inside one is not a link.
Absent on a document where every reference resolves. The two shapes are apart because the remedies
are:

`empty` is a link the page agent wrote knowing it had no target โ€” nothing to rename โ€” while a
`dangling` one had a target that moved, was never transcribed, or is in a part of the document
this run did not hold. The ids are here and not in `links_unresolved_rate` on purpose: a fragment
is text chosen out of the document, so it stays on the deployment while the public tally gets
counts only.

### `delivered_markup`

The delivered document's own structure disagrees with itself (#240): `unbalanced` lists
`element open/close` for every element whose end tag HTML **requires** and whose start and end tag
counts differ (e.g. `table 16/15`), `tables` and `tables_without_body` count the parsed tables and
those holding no row a reader receives as content (no rows at all, none outside a declared
`<thead>`, or โ€” with no header block declared โ€” none that is anything but column headers; a body
of `<th scope="row">` cells is content), and `empty_table_captions` names up to 10 of those
tables.

Absent when both are clean. The two halves are one question asked either side of the parser, which
is why they share a line: an HTML parser repairs malformed markup before axe is handed the
document, so the **bytes** are the only place an unclosed `<table>` is still visible, while a
table with no rows is what survives that repair and reaches a reader. `@`-comment markers are
stripped before counting, since those quote model prose โ€” with them in, one bench document read
`table 25/19` with nothing actually wrong; an unterminated `<!--` is treated as running to the end
of the document, which is what a parser does with one.

Elements with optional end tags (`li`, `tr`, `td`, `p`, `tbody`, โ€ฆ) are excluded:
`<ul><li>a<li>b</ul>` is correct HTML and counting it would bury the real finding. A `parse_error`
key means the table half could not be measured at all, so its zeros are not a clean bill of health
(#164).

### `delivered_structure`

Four structural defects in the delivered document that **no rule in the gate reports** (#255),
each decidable from the HTML alone:

`dangling_idrefs` (an `aria-labelledby`, `aria-describedby` or `<label for>` naming an id that
exists nowhere in the joined document), `dl_without_dd` (a `<dl>` holding terms and no
definitions), `lang_on_void` (`lang` on an `<img>`, `<hr>`, `<br>`, `<input>` โ€ฆ โ€” an element with
no text at all for it to apply to, holding neither a text node nor text in an attribute: HTML
scopes `lang` to an element's contents **and** to its text-bearing attributes, so
`<img alt="Un graphique" lang="fr">` is correct authoring and is not counted, while the same image
with `alt=""` is) and `empty_landmarks` (a `<nav>`, `<aside>`, or NAMED `<section>` with no text
and no image, table or form field in it).

All four counts appear whenever any of them fired, zeros included โ€” on a line that exists, a zero
says that class was looked for in this document and is not in it โ€” plus `dangling_idref_examples`,
`dl_without_dd_examples`, `lang_on_void_examples` and `empty_landmark_elements`, up to five
instances each, written as `element[attribute=value]` and cut at 40 characters. Absent entirely
when all four are clean, which is the ordinary document. Measurement only: nothing is repaired and
no run is failed, for the same reason as `delivered_markup` โ€” these are a class worth seeing, not
a rate anyone can calibrate yet.

Three of the four do reach the deployment-wide tally as `iris:structural-defect`, one row per
document however many instances it had, surfaced as `structural_defect_rate` above; `lang_on_void`
is left out of it on purpose (wasted output, not something a reader loses), and so are the
examples โ€” ids and language tags are text out of the user's own document and stay on the
deployment. Why each is here rather than in the lint, measured against this deployment's own axe
config: a dead `aria-labelledby` or `aria-describedby` is filed by axe as `incomplete` and never
as a violation (`aria-valid-attr-value` is `reviewOnFail`), and a dead `<label for>` reaches the
`label` rule only when the input has no other name, so the gate is CLEAN on all three;
`definition-list` does report a bare `<dl><dt>Term</dt></dl>`, but HTML allows a `<div>` between a
list and its groups and axe passes as soon as one is present, so
`<dl><div><dt>Term</dt></div></dl>` lints clean with every definition missing; `lang` is a global
attribute, so a `lang` on a void element is legal markup and there is nothing for a rule to fail;
and no rule fires on an announced region with nothing in it.

The ARIA rule is not promoted into the gate the way `duplicate-id-aria` is, because the rule is
wider than the finding โ€” `aria-controls` naming an element that appears on activation is genuinely
undecidable statically, and promoting the id would fail runs on it. The scope is the **joined**
document, not a page: a page agent writes one page at a time and a reference to an id defined on
page 40 is correct in the document those pages assemble into, which is the same reason the issue's
`href="#x"` check was left out. An unnamed empty `<section>` is not counted: a `<section>` is
exposed as a `region` only with an accessible name, so an unnamed one is a generic container no
reader is offered โ€” and a name that resolves to nothing is no name, so
`<section aria-labelledby="nope"></section>` is one finding (the dead reference) and not two.

A `<main>` a page emitted for itself is deliberately NOT among these: that one is fixed rather
than counted (`page_main_stripped`, #251).

`parse_error` on the `delivered_markup` line means these four were never measured, so their zeros
are not a clean bill of health (#164).

### `delivered_alt`

A placeholder where a description belongs, in the file the caller receives (#290): `generic`
counts the `alt` values that are only a word for the medium (`image`, `photo`, `logo`, `null`, โ€ฆ),
`checked` is every non-empty `alt` in the document as the denominator, and `examples` names up to
five of the values. Its own line rather than a field on `extraction_complete`, because they answer
different questions: that one reads the fragments the document is assembled **from**, and the
review loop runs afterwards and replaces a top-level block's markup wholesale โ€” `<img>` and its
`alt` included โ€” so a copy-edit round that guts an alt, or writes a placeholder into a block it
was patching for another reason, is invisible there and visible here.

Absent when there is none, like `delivered_markup` and `delivered_structure`, and for the same
reason: the ordinary document needs no line. A missing line still cannot be read as a check that
never ran, because `extraction_complete.alts_checked` is on every run whatever it found. Comments
are stripped before the scan, exactly as `internal_links` and `delivered_markup` strip them off
the same bytes: the `@unresolved` list is model-written prose ABOUT the document and quotes markup
freely, so an `<img>` inside one would report a placeholder on a document whose images are all
described and inflate `checked` besides.

Measurement only โ€” nothing is repaired at delivery, since the repair is to describe the picture
and the page agent is the only component holding it, which is what the correction pass
(`page_generic_alt`) is for. Nothing here reaches the tally.

### `editor_markers_changed`

The count of a `[not legible]` or `[page not fully transcribed]` marker changed across one
correction round (`iteration`, `before`, `after`, plus `fewer` and/or `more`).

`fewer` is expected where the editor read that region off the attached page image, and is a loss
anywhere else โ€” nothing downstream can tell those apart, and no other signal sees it at all, since
the flattened view strips bracketed tokens before comparing words.

`more` is a placeholder written over words the extractor did read, which no instruction in the
loop allows.

### `editor_list_markers_split`

Half of the one list conversion the Copy Editor is licensed to make (`iteration`, `shape`, plus
`before` and `after`, each `{ items, lettered, printed, printed_lettered, doubled }`).

The licence is atomic: a bare `<ol>` whose every item's text opens with one sequence's marker โ€”
`(a)`, `(b)`, `(c)` โ€” may have the `type` those markers show set **and** the markers stripped from
the text. That is one change, and this line fires on either half of it alone:

| `shape` | What happened |
| --- | --- |
| `marker_announced_twice` | An item prints **the marker the list announces**, so a reader hears "a" and then "(a)" โ€” the same defect an extraction can produce, arriving from the review loop instead |
| `text_markers_gone` | **Lettered** markers left the items and the list did not gain them, so a list the page printed `(a)`, `(b)`, `(c)` now prints 1, 2, 3 and no copy of the letters is left in the document. This is the loss |

A complete conversion logs nothing: the markers leaving the text are balanced by the list announcing
them, and no item ends up holding both.

The five counts, all read off the flattened view because that is where a `type` and a transcribed
marker are visible at once:

| Count | Items it counts |
| --- | --- |
| `items` | Every item in the block |
| `lettered` | Those whose **announced** marker is not a digit |
| `printed` | Those whose own text opens with a marker of any shape |
| `printed_lettered` | Those of `printed` whose marker is not a digit |
| `doubled` | Those printing **the marker they are announced with**, compared by value, token against token, case-insensitively |

**`doubled` is decided per item**, so a round that sets the `type` and strips only some of the items is
caught โ€” the item that kept its own marker is the one a reader meets. And
`text_markers_gone` reads `printed_lettered` rather than `printed`, because a digit leaving an item's
text is a repair: an `<ol>` announces 1, 2, 3 by itself, so a digit the text repeats is a second copy
of what the list already says.

**Three limits before counting a corpus off this.** Two are silences about edits the editor may make.
It is **silent where the round changed `items`**, since a deleted item takes its printed marker out
of the count with it. And every count is a **block total**, so one list's correct conversion pays for
another's destruction in the same reply: converting the first `<ol>` properly and stripping the
second one's letters leaves `lettered` risen and `printed_lettered` fallen, which is what a single
correct conversion looks like. The third is the shape of a printed marker โ€” up to three digits, a
roman **number**, or a single letter **closed by** `)` or `]` โ€” so a marker genuinely printed `a.`
with no bracket, or `(aa)` on a list past its twenty-sixth item, is invisible to **both** branches.

This is also the line that says which kind of shrink a `refusal_with_loss` was looking at. The
licensed strip removes visible text, so the block lands in `shrunk` exactly as a real loss does, and
neither that report nor the flattened coverage comparison can tell the two apart on its own.

Why the licence is scoped the way it is, why `doubled` compares values rather than kinds, and what
the marker shape costs, is in [design notes โ€” the flattened view and the one conversion it
polices](design-notes.md#the-flattened-view-and-the-one-conversion-it-polices).

### `editor_truncated`

A correction round's response hit the model's output ceiling (`max_tokens`, `chars` returned, plus
`attached`/`of` images and `after: "images_refused"` when it was the retry that truncated). The
review loop stops after this round either way, but the round itself is not given up on: what the
reply already said is read (`editor_salvaged`), and only the part it never reached is re-made a
section at a time (`editor_sections` below). The whole ceiling of output was billed, so this is
the log's most expensive line. Since #250 the round asks only for the blocks the editor changed,
so a ceiling hit here is no longer what a long document costs: it is either a document whose
changed blocks really do fill a response, or โ€” read `editor_patch` and `editor_whole_body` on the
rounds around it โ€” a model returning the whole document when it was asked for a few blocks of it.

**Which of those two it was is on this line** (#277): `reply_head` is the first 240 characters of
what the model did emit, `reply_tail` the last 240, and `blocks_named` counts the `"block"` keys
it managed. One budget of the user's text, spent one of two ways: a fragment longer than both
excerpts together is quoted at each end, and a shorter one is quoted **entire** under `reply_head`
with no `reply_tail` at all โ€” rather than reported as a head whose middle and end are missing
while `chars` says there was more.

`blocks_named` is a count of a key and not a parse, so a document quoting `"block":` in its own
text counts its own prose; the excerpts are logged beside the count for that reason and not only
for colour. Between them, an `edits` array that genuinely did not fit is distinguishable from a
whole document returned out of habit โ€” a block-size problem and a prompt problem respectively.
Recorded because the round **cannot be asked again**, so the fragment is the only evidence that
will ever exist about why it did not fit, and the round was billed in full; the count is the
answer and the excerpts are how a person checks the count.

`blocks_named` is also the closest thing here to a prediction of what the next line will say: it
counts a key rather than parsing, so it is an upper bound on the edits `editor_salvaged` could
recover, and a `blocks_named` well above that line's `edits` means most of the count was the
model's own prose or an entry the ceiling cut. This is the user's own document coming back, so
like `prose_joined`'s `word_split_examples` it stays in the run log on the deployment and never
reaches `GET /v1/quality`, which gets `editor_truncated_rate` and no text.

### `editor_salvaged`

The truncated reply was read as far as it got, and this is what it turned out to have said (#295).
The contract makes the answer a list of independent block edits, so an entry that arrived complete
is a whole correction to a whole top-level node:

`edits` is how many were used, `applied` how many changed their block, `unchanged` how many named
a block and returned it as it was, `refused` how many could not be used (a duplicate, or a
replacement that ends inside an element โ€” each costs its own block, exactly as on `editor_patch`),
and `markers` / `navigation_lost` read as they do there.

`reached` of `of` is the share of the document this covers, and it is the number to read first:
the blocks before it carry this round's own corrections, made by a call that saw the whole
document and the page images, and the `rest` characters after it are what the section calls are
then asked for.

`closed: true` says the edits list itself finished โ€” the ceiling was reached on the way *out* of
the envelope, in `fidelity_observed` or in trailing prose โ€” so every block was considered, and
unless the claim was then cut back (`lost_at` below) `reached` is the whole document and no
section call is made at all: the one truncation that costs a reader nothing.

`edits: 0` with `closed: true` is that same answer with nothing in it: the list closed **empty**,
meaning the editor considered every block and had no change to make, which is a round that
converges rather than a round that failed โ€” the document is delivered as it entered, unsectioned,
and its marker says it was passed rather than lost. (`edits: 0` cannot appear without `closed`: an
unclosed empty list is `no_complete_edit` below, and a list whose every edit was refused is
`all_refused`.) `lost_at` and `dropped` are a claim that was **cut back** (#317), and are on the
line only when one was: the block the reply emptied or handed back with less content in it than it
had, which is where `reached` now stops, and how many of the reply's edits were left unapplied
because they named that block or a block behind it.

A cut-back claim is the case that used to be refused outright, and the change is a cost decision โ€”
refusing it re-requested every block of the document in section calls to avoid applying one edit,
which on the round that filed it was 6 and 5 calls at $0.2243 each against replies holding 6 and 7
usable edits. What it trades is named on that issue: a move carrying content *backwards* leaves it
in the document twice rather than losing it โ€” and **that duplicate is delivered**, because a
truncated round is the review loop's last round and the section calls see only the remainder, so
nothing later in the run removes it (a feedback re-run is the pass that can).

Which is why the count is here to be read rather than assumed to be zero: a deployment seeing
`lost_at` often is a deployment whose delivered documents may hold duplicated content, and the
remedy for the ceiling itself is still `providers.<name>.max_tokens` or fewer pages per session.

`lost_at` may appear beside `closed: true`, which reads oddly and is real โ€” the patch was complete
and part of it is being re-asked for anyway โ€” and then `rest` is non-zero where a `closed` line
otherwise has none. Read `chars` against `editor_truncated`'s `chars` on the line above (they are
the same number) and against `blocks_named` there. The waste this line exists to end was the
largest measured in the pipeline: 24 truncated editor calls across 10 deployment rounds, $17.23 of
a $158.67 bill, every dollar of it on a response nothing looked at.

### `editor_salvage_declined`

The reply could not be read as a prefix, and why (`reason`): `no_edits_list` (no `edits` array in
it at all โ€” the model answered with the document or with prose about it, which is a prompt problem
and not evidence this document is too big for its ceiling), `no_complete_edit` (an `edits` array
that opened and whose first entry never finished: the contract followed and the ceiling reached
inside the *first* block โ€” one enormous table, typically, and the only one of these that says the
document cannot be answered whole. An empty list that **closed** is not this and is not a decline
at all โ€” see `edits: 0` on `editor_salvaged` above), `unknown_block` (a block number this document
does not have, so the reply is not about this document), `unreadable_edit` (an entry whose `block`
could not be read, which might have named a block past the cut), `out_of_order` (block numbers
that jump backwards, so the blocks *between* two named ones cannot be read as deliberately left
alone and the coverage this rests on is not claimable), `all_refused` (every edit read and none
usable), `loss_before_cut` (the reply gave content up and there is nothing in front of the loss to
keep: `lost_at` names the block it emptied or handed back with less in it than it had, and
`lost_at: 0` โ€” the very first block it claimed โ€” is the common shape. A higher one says the same
thing about a claim that started later: there was nothing usable in front of that block, either
because the reply named no earlier one at all (its first edit was the lossy one, so the blocks in
front of it were only ever covered by silence) or because the edits it did name there were
themselves refused. This contract makes a *move* a pair of edits, and here the cut **is** a
refusal of everything after it, so the source half without its landing half would delete content
nothing downstream can miss. A loss with usable edits in front of it is **not** this and is not a
decline at all โ€” since #317 the claim is cut back to that block and those edits are applied, which
is `lost_at` on `editor_salvaged` above).

The counts that decided it are on the line, with `reached` and `of` where they are known โ€”
`reached` on a decline is the whole claim the reply made, since nothing was applied and there is
no shorter prefix to report. The round then takes the route it took before this existed: the
**whole** body, a section at a time (`editor_sections`, with no `covers` field). The last three
are the strict ones, and being wrong about them costs a longer route rather than a document.

### `editor_sections`

A round that could not be answered whole is being re-made a piece at a time: the body was cut into
`sections` pieces of at most `budget` characters, sized from the `chars` that response actually
returned, and they are corrected `concurrency` at a time. The budget is measured rather than
estimated โ€” nothing here is computed until the ceiling has actually been hit โ€” and it is
deliberately well under what came back, because a correction adds characters.
`covers: "remainder"` says this is the tail of a salvaged round rather than the document:

`chars` on such a line is the size of what the reply never reached, not of the body, and the
blocks before it are already corrected (`editor_salvaged` above). Absent on the whole-document
path, which is what every log before #295 holds.

### `editor_section_failed`

One section could not be corrected (`section` of `of`, and `reason`: `truncated` or `too_large`
for a section whose own response or request did not fit, `no_output` for a reply with no usable
HTML in it, `shrank` for one that parsed but came back with under half the section's prose โ€” the
same floor the ordinary round applies to the body it assembles, with the same four sizes and
`floor` on the line, see `editor_shrank`). A `truncated` section carries the same `reply_head` /
`reply_tail` / `blocks_named` as `editor_truncated` above, on the same terms โ€” deployment only, a
few hundred characters โ€” with one difference in how to read them: a section round asks for the
section's corrected HTML and not for an edits list, so `blocks_named` is 0 on a section that
answered the request it was given, and it is `reply_head` that says whether it was.

A count above 0 here is not noise but the same prompt problem in its other form โ€” the
whole-document contract's shape coming back to a request that never used it. `covers: "remainder"`
where the sections are the sections of the **tail** a truncated reply never reached rather than of
the document โ€” the same marker `editor_sections` carries, on the same terms, because
`section 2 of 3` means two different things without it. That section's **original text** goes back
into the document, so the cost is that section and not the round.

Anything that is not a size failure โ€” a stall, a stream error, a bad key โ€” is not logged here and
still ends the run.

### `editor_sections_declined`

The round could not be re-made a section at a time, and why (`reason`): `unmeasured` (no character
count to size a budget from), `budget_too_small` (the response was cut so early that the sections
would be too small to be worth asking about), `budget_exceeds_body` (the response was *longer than
the document* โ€” a reply that ran away with itself, so the sections would be one section and the
same request), `indivisible` (the piece to correct is over budget and has no top-level boundary to
cut at โ€” one enormous table, say), `too_many_sections` (more requests than one round may spend,
with `sections`, `max` and `budget`).

`covers: "remainder"` means this was the tail of a salvaged round (`editor_salvaged`), and it
changes what the line costs: the blocks the reply reached keep their corrections and only the
remainder goes uncorrected.

`budget_exceeds_body` is not reachable there โ€” a remainder short enough to fit under the budget is
*asked for* in one call, because it is strictly smaller than the request that truncated and
carries no images, which is the whole of that reason's objection. Without `covers`, the round is
discarded as it was before any of this existed: the document that entered it is delivered with
that round's issues unresolved.

### `reader_start`

The Reader is about to read the document: which `iteration`, how many `chunks` it goes out in, and
the `concurrency` those run at.

`concurrency` is `defaults.extraction_concurrency` โ€” the same knob page extraction is bounded by,
because a Reader chunk is the same kind of model call: a run's peak stays where the operator set it
in this phase as in the other, and a deployment that lowered it for a rate-limited provider gets its
reviews bounded too. It falls back to 1 for a directly-constructed context that never set it, which
is what this step did before it ran chunks in parallel at all.

`chunks` is the denominator behind the `window` / `of` pair on the two lines below, and behind the
`@review-unread` comment's "how many windows of how many" in the delivered document: one round can
lose part of a document and read the rest.

### `reader` / `editor`

Per-iteration review-loop progress: the Reader's `issues` count, and whether that round's correction
`changed` the document.

**Which fields are on the line says what shape the round took.** A round answered piece by piece
carries `sections` and `corrected`, which is how a log tells one from an ordinary round
([`editor_patch`](#editor_patch)) and how much of the document the corrections reached. A **salvaged**
round carries `blocks_reached` of `blocks` too โ€” the pair [`editor_salvaged`](#editor_salvaged) calls
`reached` and `of` โ€” and then **`covers: "remainder"`** beside the section counts, because those
sections are the sections of the tail the reply never got to and not of the document (#295). So a
truncated round that was salvaged and sectioned corrected `blocks_reached` blocks with the whole
document in view *and* `corrected` of `sections` pieces of what was left. Without `covers`, the line a
reader greps per round would read as document-wide coverage on the one round where the section counts
are over something smaller.

**A truncated round that rescued nothing has no `editor` line at all**, which is how it is told apart
from a round that ran and changed nothing ([`review_converged`](#review_converged)).

**Three readings of size, all on the line whatever fired.**

| Fields | What they measure |
| --- | --- |
| `chars_before` / `chars_after` | The body that entered the round and the body that left it, whole |
| `text_chars_before` / `text_chars_after` | The same two, with the markup taken out |
| `structure_before` / `structure_after` | Counts of headings, paragraphs, lists, items, terms, definitions, tables, captions, rows, header cells, data cells, images and links in the body on each side |

The first two are the same pair [`page_corrected`](#page_corrected) carries, so a round and a page
correction can be read against each other. Both, because a length alone cannot say whether a round
lost content or lost wrappers: markup-only work leaves the prose pair equal and moves the whole
one, and a round that deleted a paragraph moves both. **The prose pair is the one the floor reads**
(`editor_shrank`) โ€” a reply under half the prose it was given is refused.

`structure_before` / `structure_after` are full counts, because a ratio needs its denominator, and
`h1`-`h6` are folded into one number so that the re-levelling the page rules ask for is not read as
headings lost. Header cells are counted apart from data cells, and `<caption>` is counted; wrappers
(`<section>`, `<div>`) are not, since unwrapping a mis-structured page is one of the corrections this
loop is for.

**Four things to know before counting a corpus off these.**

- **The sizes are the body.** The wrapper and the `@`-comments after `</main>` are added downstream
  and are not what any round returned, and the sizes are taken after the deprecated-role strip, so
  they describe the body that ships.
- **On a sectioned round they are still the whole body's**, which matters to anyone reading them as a
  distribution, since one section's *reply* is a fraction of the body it belongs to.
- **A round whose reply carried nothing usable reports equal sizes by construction**, with
  [`editor_no_output`](#editor_no_output) beside it to say so.
- **The published ratios are not all this line's quantity.** 0.62โ€“2.32 over 265 page corrections is
  delivered-against-given, as here; 0.982โ€“0.984 over three rounds is the *reply* against the body that
  went in, reconstructed from `agent_call`. This line reports 1.000 for those same three rounds.

**The structure counts do not gate, and the residual is unwatched rather than covered.**
[`editor_patch`](#editor_patch)'s `headings_dropped` reads the same heading fold per BLOCK, where the
question is whether one replacement gave up its heading rather than what proportion of the document's
headings are left โ€” which is why a fall can be acted on there when no ratio can be placed here. Its
`navigation_lost` is not that reading: it compares the whole body before against the whole body after.
A
round that rewrote every heading to the *same* level leaves no downward skip, so the re-lint's
`heading-order` is silent on it, `headings` is unchanged and the prose pair is equal: every level
distinction gone with nothing on the line to say so.

Why the floor reads the prose pair rather than the whole-body size or the structure counts, and what
the structure counts were measured to be worth, is in [design notes โ€” what the review loop's structure
counts count](design-notes.md#what-the-review-loops-structure-counts-count).

### `reader_issues_dropped`

One window's reply listed entries that could not be issues, and they were dropped: `iteration`,
`window` of `of`, `dropped` how many went, and `of_entries` how many the list held.

A reply's shape is the model's and not this pipeline's, so none of it is assumed โ€” and this line is
what that check costs, said out loud rather than silently. An entry that is not an object cannot be an issue, and
reading a page list off one throws โ€” the same crash as an `issues` that arrived as a string, one
level in. So it is dropped rather than fatal, on the argument the whole path is built on: a reply
that is partly usable is worth its usable part. `dropped` equal to `of_entries` is a window that
said nothing readable at all, and it has a `reader_no_output` line beside it.

### `reader_no_output`

One window was paid for and produced no verdict: `iteration`, `window` of `of`, `chars` of reply
text, and `reason` โ€” `no_issue_list` for a reply with no `issues` list in it this code can read
(prose, an apology, `{"issues": "none"}`), `no_readable_issue` for one that listed entries and had
none survive the shape check. Two different replies, which is why the reason is a field. Said the way
`editor_no_output` is said, because it is the same event about the other agent: a call that was
bought and gave nothing to act on.

One line per window that failed, because a document is read in windows and only one of them may
have. An **empty** `issues` list is not this event โ€” that is a verdict, and the one the whole loop
is for. The delivered document carries a `@review-unread` comment saying how many windows of how
many, and the tally counts the document (the
[quality tally](#quality-tally-shared-secret-off-by-default)'s `review_unread_rate`), because
otherwise this outcome reads as the best one there is: no issues found, no `iris:unresolved` row, a
document counted clean.

### `lint_unavailable`

axe-core could not run on a body no `assembly` line covers, with the same `lint_error` /
`lint_error_where` / `lint_error_name` / `lint_error_stack` fields that line carries.
`stage: "correction_round"` is the review loop's re-lint of a body an editor round changed, with
the `iteration` that produced it; `stage: "feedback_relint"` is a feedback re-run that skipped
extraction, where there is no assembly to report one. The document ships with **no accessibility
verdict** either way: the loop had no violations to work from, and the delivered HTML says so in
an `@lint-unavailable` comment.

### `lint_debris`

The linted body carried attributes whose **names no valid markup produces**:
`malformed_attributes` (how many, exact), `malformed_attributes_removed` (how many of them the
lint had to take out of its own copy of the document for axe to run at all โ€” absent when none,
which is the ordinary case), and `malformed_attribute_names` (up to three of the names, removed
ones first, each cut at 40 characters). The same fields appear on the `assembly` line; this event
carries `stage: "correction_round"` (plus `iteration`) or `stage: "feedback_relint"`, matching
`lint_unavailable`.

Absent when there were none, so a line carrying it means something. These are evidence about a bug
**one stage earlier**, not a defect in the document: an attribute named `1\"` is what the HTML
parser makes of `aria-label=\"Page 1\"` arriving with its JSON escaping still on it, and the same
leak puts `\"doc-pagebreak\"` in a `role` and `\"page-1\"` in an `id` (#233, #234) โ€” an invalid
role, a marker that announces the wrong text, and a dead target for every reference to it. Those
are findable only by reading the document; this is a number.

A removal is not cosmetic: axe escapes an attribute name it builds a selector from, a name
beginning with a digit escapes to `\39`, jsdom's selector engine splices the name into JavaScript
source where that is an octal escape, and the SyntaxError killed **the entire rule set** โ€” one
such attribute anywhere in a 25-page document and there was no verdict on any of it (#257). So
`malformed_attributes_removed` on a line is a run that would otherwise have had no verdict on any
page. Everything else is counted and **left in place**, because removing an attribute takes the
rules that read it away too: a name that lost a quote (`aria-label"Note"`) is reported by
`aria-valid-attr` โ€” critical, wcag2a โ€” *because* it is malformed, and removing it turns that
document into a clean pass.

The document that ships keeps every byte either way, including the removed ones: what the
attribute was meant to be is not this stage's to decide.

### `editor_patch`

What one ordinary correction round's reply actually did to the body, block by block (#250). The editor
is shown the body as numbered top-level blocks and answers with the blocks it changed, so this line is
the whole accounting.

**Four fields are always there**, so a round that named nothing is still on the record: `blocks` in the
body, `edits` named in the reply, and how many were `applied` and `deleted` (a block emptied with
`"html": ""`). `applied` means applied to the body that goes on, so a block that was refused, or held
back for dropping a heading, is not in it.

**Six more appear only when non-zero**, and a line carrying any of them is a reply that did not follow
the contract in some way:

| Field | What it counts | Effect |
| --- | --- | --- |
| `unchanged` | Blocks returned byte-identical to the one they replace | Delivered as it stood โ€” output paid for to say nothing |
| `markers` | `<!-- @block N -->` comments copied back into a replacement | Stripped out, then applied like any other |
| `unknown` | Block numbers the body does not have: out of range, negative, or not whole | Refused |
| `duplicate` | A second edit for a block already named โ€” the first is kept | Refused |
| `incomplete` | A replacement whose markup does not close what it opens, or that carries an end tag closing nothing | Refused |
| `unreadable` | Entries in the array that are not an edit at all | Refused |

A refusal costs that block and nothing else: it keeps its original text and the rest of the reply is
still applied. `unchanged` and `markers` are on the record as costs rather than rejections, which is
why they are named apart.

**Two readings of what the round took away, at two grains.**

`shrunk` counts applied replacements carrying less of the document than the block they replace, and is
on the line whenever it happened, because that is one of the ordinary ways this contract removes
content the document printed twice. It is read as the **prose**, plus the two things a block holds
that carry no words โ€” `<img>` and `<a>` โ€” plus `headings`, folded across `h1`-`h6`.

`navigation_lost` is `{ "headings": 1, "items": 2, "rows": 1 }`: how many of each stopped existing,
read on the **joined body** rather than block by block, and present only where that body's prose did
**not** shorten. The grain differs because a reorder is a pair of edits under this contract โ€” a heading
moved down past a paragraph is one block giving it up and another taking it โ€” so a sum of per-block
falls would report a document that kept every heading as having lost one.

**Three limits before counting a corpus off `navigation_lost`.**

- **One sanctioned deletion anywhere in the reply silences it for the whole round.** A round that drops
  a reprinted title in one block and demotes a real heading in another logs nothing here. It
  under-collects on purpose.
- **`items` and `rows` do not gate**, because content leaving one of those can land in a different
  structure a reader can still navigate: a `<ul>` rewritten as the `<dl>` the page rules ask for takes
  `items` to 0, and a list mis-extracted as a single-column table, corrected, takes `rows` to 0.
- **A rate quoted off this field alone is a rate over block-patch rounds.** The same reading is
  collected on the other two apply paths โ€” a whole-body reply and the section fallback โ€” under its own
  line ([`editor_navigation`](#editor_navigation), #375); before that it was computed here and only
  here.

**`discarded` names the case where the reply is not applied in part**, and which of three it was:

| `discarded` | What it means |
| --- | --- |
| `all_refused` | Edits were sent and not one could be used |
| `refusal_with_loss` | A refusal in the same reply as a block that gave content up โ€” `deleted` or `shrunk` |
| `headings_lost` | The body this round would have delivered has fewer headings than the body it was given, with its prose no shorter and nothing refused anywhere โ€” and handing back the blocks that dropped them could not be shown to fix it (#331) |

Either way the body is handed back untouched and the next round is a retry (see
[`editor_no_output`](#editor_no_output) for why that is not convergence).

**The heading fall is held back per block, not per round.** `headings_reverted` lists, in ascending
order, the block numbers whose own heading count fell **and** that gave nothing to another edit in the
reply; those blocks keep their original text, the reply is re-applied without them, and the round is
delivered with everything else it corrected. So a line carrying `headings_reverted` with no `discarded`
beside it reads as *`applied` shipped and `headings_reverted` did not* โ€” it is the only place that
difference is visible.

Where the revert was not available, one of these says which attribution failed, since `headings_lost`
has three reasons behind it:

| Field | Why the blocks could not be handed back |
| --- | --- |
| `headings_gained` | A heading arrived somewhere it was not, so a departure cannot be matched to an arrival. Counted, because one arrival beside one fall is a move and eleven is a restructure |
| `headings_dropped` | No heading arrived, but a block that dropped one gave content to another edit in the same reply, so re-seating it would print those words twice. **Every** block whose own count fell, which is the reading of what the model did |
| `headings_abandoned` | Beside `headings_dropped`: the blocks that COULD have been handed back and were refused with the round anyway. Subtracting it leaves the blocks that could not be, so both readings come off one line |
| `headings_recheck: true` | Beside `headings_dropped`: a fall survived the revert that no block still dropping a heading explains. Unreachable by construction, so its firing at all is the finding |

`headings_gained` and `headings_dropped` are mutually exclusive on a line, and `headings_reverted`
present *with* `discarded` is the fourth shape โ€” blocks were handed back and nothing was left to
apply. What is worth reading off a `navigation_lost` `headings` count is its magnitude: one heading
gone is a repeated title resolved a little too thoroughly and 84 is a document flattened.

**One case the headings reading counts and should not**, named rather than compensated for:
`EDITOR_SYSTEM` sanctions "correct labels and table headers", so a field label the extractor emitted as
`<h4>Name</h4>` corrected into a `<label>` inside the same `<form>` block keeps every word and takes
`headings` down. `<label>` and `<legend>` are not counted at all, so no discount reaches it. Since #331
the cost is one block on one round: that `<form>` block is handed back with its `<h4>` intact and every
other correction in the reply is applied and delivered.

[`editor_headings_gated_rate`](#quality-tally-shared-secret-off-by-default) is what says how often it
happens.

Why the gate acts on a reply with nothing else wrong with it, why an arrival is counted at word grain
rather than looked up, why size cannot license the re-seat, and why the re-check is per block, is in
[design notes โ€” a heading fall with nothing else
wrong](design-notes.md#a-heading-fall-with-nothing-else-wrong).

### `editor_whole_body`

The reply carried no `edits` array but did carry an `html` string, so the round was read as the
whole corrected body โ€” the contract every round used before #250 (`blocks` in the body it was
given, `chars` in the reply). Accepted rather than refused because refusing it spends the round,
and on a model that falls back to a familiar shape under load it would spend every round of the
run; a whole body arriving this way goes through the same `editor_shrank` check it always did. It
costs one thing the old contract could not, and `markers` is that cost measured: the document this
model was SHOWN carries a `<!-- @block N -->` line above every top-level element, so the likeliest
whole-body reply is that document retyped, markers and all.

They are stripped before the body is taken and counted here. Adopting them would write Iris's own
request scaffolding into the delivered HTML, and it compounds โ€” a comment is a top-level node, so
the next round would be shown the markers as blocks in their own right, the body would double
every round, and a document that never stops changing never converges. A run where this line
appears on most rounds is a model not following the block contract, which is worth knowing about a
deployment even though the document is fine.

### `editor_no_output`

The Copy Editor's reply carried no usable body (`chars` of text came back), so the round kept the
document it was given. A call paid for and nothing said โ€” which is why it does not end the loop:
the next round is a retry, not a repeat.

### `editor_shrank`

The body a correction round produced came back with **less than half the prose** of the one it was
given, so it was refused and the round kept the body it was given (`chars_before`/`chars_after`,
`text_chars_before`/`text_chars_after`, and the `floor` divisor). `stage: "patch"` is the ordinary
round, measured on the **joined** body rather than on the reply โ€” the reply is a few blocks, and
the question this floor asks is about the document those blocks assemble into โ€” and it carries
`deleted` and `shrunk` of `of` beside the sizes, because that is what a shrink under the block
contract is made of: blocks emptied with `"html": ""`, and blocks returned with less in them than
they had, in a reply where each edit on its own was well-formed.

Both, because this path is only reached when nothing was refused โ€” a refusal beside a block that
gave content up is `discarded` on `editor_patch` before the floor is read โ€” so the commonest reply
that lands here empties nothing and returns blocks holding a fifth of their prose, and
`deleted: 0` alone would say nothing about where the document went. With no `stage` the round
returned a whole body, where the model's `html` is adopted for the document with nothing compared
against what went in and the blast radius is the deliverable โ€” a reply that answered about one
section, or summarised, or quoted the contract back after answering, arrives shaped like a
corrected document (issue #174).

Reported the same way as `editor_no_output` and for the same reason: nothing came back that can be
used as *this* document, so the next round is a retry rather than a repeat and this is not a
`review_converged`. Read on the **prose**, not on the characters and not on the structure counts,
because only the prose pair is stable on a legitimate round: the four rounds that record all three
readings land within 0.6% of their input on it, while unwrapping a mis-structured document keeps
every word and loses half the bytes, and one of those rounds rewrote a 55-item `<dl>` into list
items โ€” a ratio of 0.055 on `terms` โ€” while its prose moved 0.3%.

Bodies with under 1,000 characters of prose are not judged at all: the legitimate deletions are
fixed-size (a `[page not fully transcribed]` marker is 28 characters, a duplicated heading 20โ€“60),
so on a short body the floor would fire on the editor doing its job.

### `editor_navigation`

The structures a reader navigates by, counted on a reply that was **adopted**, for the two apply
paths that do not gate on the count (#375). `stage: "whole_body"` is the pre-#250 contract's reply
taken as the whole corrected document (`editor_whole_body` above); `stage: "section"` is one
section of a sectioned round, with `section` of `of` โ€” and `covers: "remainder"` where those are
the sections of the **tail** a truncated reply never reached rather than of the document
(`editor_sections`, `editor_section_failed`, and `editor_salvaged`'s `covers` are the same marker;
a rate grouped per round off a line without it would read `of: 3` as "the document was cut in
three").

The reading is `navigation_lost` on `editor_patch`, and until #375 it was computed there and
nowhere else โ€” so a `<h2>` rewritten as `<p><strong>` fell silently on both of these paths, and
the only check either had cannot see it by construction:

`editor_shrank`'s floor is a prose floor at half the document, and a demotion keeps every word and
grows the bytes. On the sectioned path that matters most, because a sectioned round is the loop's
**last** round (`editor_sections`), so what falls there ships with no retry behind it. **Nothing
is refused on this line, and that is deliberate rather than pending.** #331's remedy is a block
handed back, and neither path has blocks: a whole-body reply is one string and a section reply
*is* the section, so the only refusal expressible is the whole thing โ€” which is what #331 did in
its first version on the patch path and what it was changed away from, because on the commonest
false positive (a stray `<h4>Name</h4>` corrected into the `<label>` axe's `label` rule asks for)
it threw away every other correction in the reply, every round, until the budget ran out.

Doing that here would be worse: it would cost a whole section's corrections, on the round that has
no successor. What has to come first is the rate, and this line is the rate. **It prints on every
delivered reply**, counts or no counts, because a rate needs its denominator on the record and a
line that appeared only when something fell could not tell a round nothing fell on from a round
that never took this path โ€” so a clean whole-body round is `{"stage": "whole_body"}` and that is a
row in the denominator.

`headings`, `items` and `rows` appear only where each fell, and only where the reply's prose did
**not** shorten; where it did, `shortened: true` is on the line **instead** of the counts, because
the reading is silenced there โ€” a deletion the prompt sanctions takes its own words with it, which
is the ordinary shape of a correction rather than damage โ€” and an empty reading would otherwise
read as "nothing fell" when it means "not asked". The grain is the unit the reply was about, so
the sectioned path reads each section rather than the joined body.

That is sound here and finer than the patch path can manage: sections are corrected independently
and joined, so no heading can move *between* them, and the reorder hazard that forces
`navigation_lost` to be read on a whole body does not exist. It is also cheaper in the one way the
patch path's grain is expensive โ€” there, one sanctioned deletion anywhere in a reply silences the
reading for the whole round; here it silences that section, so a demotion in section 3 is still on
the record. Not folded into `editor_headings_gated_rate` in the
[quality tally](#quality-tally-shared-secret-off-by-default): that rate counts documents where
something **was** handed back, and adding rounds where nothing was would make a signal about a
working guard into a mixture of that and a reading nobody acted on.

### `assembly`

The assembly phase's own line: `pages`, how many page fragments were joined into one body, and the
first accessibility lint of it โ€” `lint_ok`, plus `violations` when there were any to count.

`violations` is **omitted rather than zeroed** when the lint did not run, and that omission is the
point of the field: `violations: 0` read beside `lint_ok: true` was a clean bill of health for a
document axe had never looked at, and anything tallying these lines added it as a real zero (#164).
A lint that could not run carries `lint_error`, and `lint_error_where`, `lint_error_name` and
`lint_error_stack` where they could be said โ€” which step threw, its error class, the first frames โ€”
because the message on its own read "Octal escape sequences are not allowed in strict mode" and named
nothing anyone could reproduce from. The document that provoked it needs no second copy to
reconstruct: this lints `wrapDocument(assembleBody(fragments))`, both pure, and `fragments.json` is
written before this phase runs.

`malformed_attributes`, `malformed_attributes_removed` and `malformed_attribute_names` are the same
three fields [`lint_debris`](#lint_debris) carries, on the same argument. Later lints of the same
document have no `assembly` line to sit on: a correction round's re-lint and a feedback re-run that
skipped assembly report through [`lint_unavailable`](#lint_unavailable) and `lint_debris` with a
`stage` instead.

### `assembly_anchors`

What namespacing the pages' `id`s cost, written **only when the join had to do something**, so an
ordinary run adds no line. `collisions` are the ids more than one page claimed. `ambiguous` are the
references naming one of those, as `page N: #ref`, each repointed at the first claimant โ€” which is
what the un-namespaced document resolved it to, but no page vouches for that being the copy it meant,
so this is the field worth a person's eye. Without this line there is no symptom at all.

`unrepointed` is the subset aimed at no owner, because every page claiming the id already links to
its own copy (#233): the page that wrote the reference transcribed a marker whose note nothing in
this document holds, which is a page worth looking at. Left **bare**, not dead โ€” the link still
resolves, to a note that has its own marker. `pinned_ids` are ids whose FIRST owner was deliberately
left un-namespaced so that a reference on a page that could not be rewritten keeps landing; without
that field, `collisions` would claim an id was namespaced when it was on purpose not. `skipped_pages`
are pages left exactly as written rather than risk losing markup on reserialization, so one may still
carry a collision โ€” lint's `duplicate-id` / `duplicate-id-active` names those โ€” or a reference the
other pages renamed away from.

Whether a reference lands in the bytes that ship is measured on the delivered document
([`internal_links`](#internal_links)), not here.

### `assembly_words_joined`

A word the printing broke at a line end, carried into the markup with its hyphen, **rewritten without
it** โ€” written only when something was rewritten, so an ordinary run adds no line. Each entry reads
`Govern-ment -> Government (document writes government)`: the spelling that stood, what replaced it,
and the unhyphenated occurrence elsewhere in the document that licensed the change.

**This line is the only trace, and it reports a change to delivered TEXT** rather than to markup, which
no other assembly line does. The third field is the evidence: a reader checking this line is asking
whether the document really writes the whole spelling somewhere, and if it does not, the join is wrong
and the log is where that is visible.

**Three conditions have to hold together**, and each stops a different mistake:

1. The joined spelling appears somewhere in the document โ€” without it, `ad-valorem` closes into an
   `advalorem` no page prints.
2. The fragment after the hyphen is not a word the document uses on its own, which is why
   `inter-state`, `non-tax`, `non-farm`, `Mid-east` and `Non-property` are never touched here.
3. The joined spelling is not on the page carrying the hyphen. That page is
   [`page_split_words`](#page_split_words)'s ground, and the words this pass declines stay with it and
   its correction call.

**`words` is capped at 20 spellings; `count` is not.** Bounded on the same reasoning as
[`prose_joined`](#prose_joined)'s examples โ€” the count is the figure, the list is what a reader
spot-checks, and an OCR-garbled submission is what a cap is for โ€” so a truncated list never
understates how much text changed.

Why the three conditions read the document at three different widths, why the repair is written into
prose only, and the one leftover that is a WCAG failure rather than an inconsistency, are in [design
notes โ€” closing a word the printing broke at a line
end](design-notes.md#closing-a-word-the-printing-broke-at-a-line-end).

### `deprecated_roles_stripped`

A deprecated ARIA role was removed from an element whose own role already said it โ€” `roles` (the
set) and `nodes` (how many attributes went), with `stage: "assembly"` for what extraction
produced, `stage: "correction_round"` plus `iteration` for what an editor round introduced, or
`stage: "feedback_prior_body"` for one already in a stored body that a feedback re-run picked up
without re-extracting. ARIA deprecates exactly three roles โ€” `directory`, `doc-biblioentry`,
`doc-endnote` โ€” all folded into list semantics, so an `<li role="doc-endnote">` inside an `<ol>`
is announced identically without it and axe's `aria-deprecated-role` has nothing left to report.

**This line is the only trace.** The delivered document is clean and the lint that would have
named the role now finds nothing, so a run log with this line in it is the page agent's FOOTNOTES
rule not being followed (`stage: "assembly"`) or the Copy Editor introducing markup nobody asked
for (`stage: "correction_round"`) โ€” which is how issue #187 shipped: axe reported the role, the
editor was told, it rewrote five sections, and the role survived. The strip is deliberately
narrow: the role is removed only where the host element already provides it, so a
`<div role="doc-endnote">` is left to fail the gate, because deleting it there would leave nothing
marking the element as a note at all and the remedy is to make it a list item.

**The host table is not the only thing that narrows it**: it shares its attribute locator with the
row below, so the shape that row declines to edit โ€” a `role` whose unquoted value runs into a
quote โ€” is declined here too, and a **repeated** `role` is handled here the same way, by emptying
the attribute rather than deleting it so the second copy cannot be promoted. Both are set out in
that row.

### `invalid_roles_stripped`

A `role` naming something that is **not an ARIA role at all** was removed โ€” `roles` (the set,
spelled as the document spelled them) and `nodes` (how many elements were edited), with the same
three `stage` values as `deprecated_roles_stripped`. The case this was built for is
`role="doc-footnotes"`, which does not exist: DPUB defines `doc-footnote` for one note and
`doc-endnotes` for a collection, and never a plural of the first, so a model following the
footnote rule generalises from the `doc-endnotes` example beside it and invents the name. axe
reports that as `aria-roles` at **critical** โ€” the most severe thing this gate says about any
document Iris produces โ€” and it has reached a delivered `output.html`, on a round where the lint
had degraded to *did not run*.

**This line is the only trace**, on the same argument as the row above, and it is the harder
evidence of the two: `doc-endnotes` is a real role reached for in the wrong place, while a name
like `doc-footnotes` was never in any specification. Unlike the deprecated strip this one is not
narrow, and does not need to be โ€” assistive technology already ignores an invalid role and
announces the element's own, which is exactly what it announces with the attribute gone, so no
element can lose anything by the removal. **That argument holds of a role token and of nothing
else, so the attribute has to be located as a parser locates it** โ€” by walking the start tag's
attributes as `name(=value)?` pairs โ€” and not by searching the tag for something shaped like
`role=`.

A search matches inside another attribute's *value*, and the values on these elements are prose:
an `alt` reading "each user and role = admin, editor or viewer" put `admin,` where a role name
goes and the word was cut out of the accessible name. Nothing can report that loss โ€” the name is
still non-empty and not generic, so the gate sees a clean element โ€” and this very line would have
named the eaten word as an invented role, blaming the page agent for it. The row above shares the
locator and was reachable the same way, on `<ul aria-label="the role=directory column">`, because
`directory` is an ordinary English word and `<ul>` is one of its hosts.

What the removal does not do is NAME the block: a stripped `<section role="doc-footnotes">` is an
anonymous `<section>`, which is compliant, and the page agent's FOOTNOTES rule is the half that
says what to write instead (`<aside>`, `<footer>`, a bare `<ol>`, or
`<section aria-label="Footnotes">`). Which names count as real is asked of axe rather than listed
in Iris, so this strip and the gate are one judgement, and asked case-folded, because the rule
folds a role token and the underlying predicate does not โ€” `role="DOC-ENDNOTES"` is a document the
gate passes and is left alone.

**One shape is declined rather than stripped**, and there the document is passed through untouched
and the gate goes on reporting the role: a `role` whose unquoted value runs into a quote, which is
what the JSON-escaping leak (`<hr role=\"doc-pagebreak\" โ€ฆ>`) delivers. Editing that would cut one
character out of the middle of an attribute; `lint`'s `malformed_attributes` still counts and
names the debris. **A second shape is edited but not deleted: a repeated `role`, where the
attribute is left in place with an empty value.** Deleting it would *promote the second copy* โ€”
`<div role="doc-footnotes" role="main">` computes to generic today, since the parser keeps the
invalid first value and discards the duplicate, and deleting the attribute would leave
`<div role="main">`, an element handed a landmark it never had (`aria-roles` at critical traded
for `landmark-main-is-top-level` and `landmark-no-duplicate-main`).

A removal that puts the next attribute into effect is not the removal this row's argument is
about. `role=""` avoids it without giving the strip up: ARIA treats a value with no valid token as
no role at all, so the element computes to exactly what it computed to before and the gate reads
it clean, while the attribute stays in the position the parser reads. Measured on all four shapes
rather than argued, including `<ol><li role="doc-endnote" role="listitem">` and
`<ul role="directory" role="list">`, which the row above delivers clean by the same mechanism.

### `page_main_stripped`

A `<main>` a page emitted for its own content was taken out of the body, because `wrapDocument`
puts the assembled body inside one and a `main` inside a `main` takes away the landmark a
screen-reader user jumps to in order to skip the furniture (issue #251, 18% of page answers).

`unwrapped` (a bare `<main>`, tags removed and children promoted), `downgraded` (one carrying
attributes, rewritten to a `<div>` keeping them, since unwrapping it would drop the `lang` the
document's root declaration is derived from or an `id` an `href` elsewhere resolves to) `dropped`
(a stray `</main>` closing nothing, deleted) and `declined` (a `<main>` nothing closed, left in
place), with the same three `stage` values as `deprecated_roles_stripped`. **This line is the only
trace of the first three counts**, exactly as with the role strip: the delivered document is
clean, and a run log with this line in it is the page contract's shell sentence not being followed
(`stage: "assembly"`) or the Copy Editor supplying a wrapper it was told not to
(`stage: "correction_round"`).

`declined` is the one that ships: the element's extent is whatever the parser decides, so there is
no correct edit for an unclosed `<main>`, and
`landmark-no-duplicate-main`/`landmark-main-is-top-level` report it in the gate. The unpaired END
tag is not left, for the reason that reverses: a parser discards it, so nothing is being weighed โ€”
and it is the one shape no rule reports, because inside the shell it closes the document's own
`<main>` early and every element after it is delivered outside the landmark with the lint clean.

That escape predates this rewrite; what would be new is a `declined` count promising a violation
nobody can find. A `role="main"` on an element that was never a `<main>` is not counted here at
all and goes straight to the gate, for the reason the role strip stays narrow.

### `page_markers`

Page-break markers were checked against the document's own numbering, and any label naming the
**position of the image in the file** instead of the number the page prints was removed (issue #333).

A label is touched only when it repeats the last integer of its own filename, AND its numbering
system's own modal offset says the folio is something else, AND the markers holding that offset do not
repeat their own filenames. **The label is removed, not corrected, and the `id` stays.**

| Field | Reads |
| --- | --- |
| `markers` | How many page-break markers were found |
| `readable` | Labels that parse as a numeral |
| `unreadable` | A sectioned folio โ€” `A-3`, `M-16` โ€” plus markers carrying no label at all |
| `systems` | One entry per numbering system that produced at least three markers. `arabic: offset -11 on 22 of 23` where an offset was acted on, or one of the two ways nothing was: `arabic: no offset holds 8 markers (best: 3)` where no offset held both a run of three and a majority, and `arabic: offset 0 on 8 of 10, 8 of them repeating their own filename` where the check refused this document |
| `stripped` | One entry per label removed, `page 52: "Page 52" โ†’ 38`, cut at 40 characters, with the derived folio omitted where the derivation computes below 1 |
| `departures` | The shape those removals form, one entry per offset they sat at: `arabic: 1 removed at offset -50, page 2` against `arabic: 6 removed at offset 0, pages 1-6 (every marker in that span)` |
| `off_mode` | Readable labels that disagree with their system's offset and were left alone because they repeat no positional number โ€” a page printing `ix` labelled `Page 9` |
| `undecided` | Labels that DO repeat one where nothing could be concluded: too few markers, no offset holding a run of them, or a document the check refused. This check's blind spot, with a size on it |
| `unchecked` | Readable labels on a page whose filename carries no number at all, so `readable: 25, stripped: []` cannot read as agreement when nothing could be checked |
| `stage` | Only ever `"assembly"` |

**This line prints on clean documents too**, unlike [`assembly_anchors`](#assembly_anchors):
`stripped: []` beside `arabic: offset 14 on 23 of 25` is a document this checked and agreed with, while
no line at all is one it could not decide โ€” and a round measuring whether the defect is fixed cannot
tell those apart from silence.

The labels are text out of the caller's own document, so like [`prose_joined`](#prose_joined)'s
`word_split_examples` this line stays in the run log on the deployment and never reaches
`GET /v1/quality`.

Why the filename's last integer is the only number tested, what the third condition refuses a whole
document on, why `page N of M` is left alone, the three blind spots that read as a clean document, and
why this runs at assembly only, are in [design notes โ€” a page label naming the image's position rather
than the page's
number](design-notes.md#a-page-label-naming-the-images-position-rather-than-the-pages-number).

### `reader_page_reports_deduped`

One round's Reader reports about a page the document has **no content** for were reduced to one
per page: `dropped` (how many reports went), `pages` (which pages the kept ones stand for) and
`reports` (each dropped report's severity and text, folded and bounded), for that `iteration`. Two
kinds of page have no content โ€” one extraction lost (`pages_failed`, a `@page-failed` comment) and
one that is blank in the source (`page_blank`, correctly delivered as an empty page) โ€” and neither
is something a correction round can act on.

The Reader is told so in the index it reads and in its own prompt; this line is what happens when
a sampled model raises them anyway, which it did **once per chunk**, in a different wording each
time, so exact-string dedupe caught none of them (issue #188). Per chunk of the FINAL round, to be
exact: `@unresolved` is written from the last read of the document, so that read's chunk count is
how many copies were delivered โ€” six of one document's 26 on the round that filed the issue. What
the iterations multiplied was the spend, not the list: every round's editor was handed the same
unrepairable reports.

Only the FIRST report of a page is kept, deliberately: an issue whose attribution is entirely
pages with no content can only be about the absence, but the attribution is the Reader's and a
misattributed real issue must not vanish without trace โ€” which is what `reports` is for, since
which report came first is an accident of chunk order. An issue that names any page with content
in it is never touched, and an unattributed report cannot be reached here at all.

### `review_converged`

The loop stopped early because a round changed nothing (`iteration`, the `issues` that round was
given, and the `rounds_left` it did not spend). The editor answered and handed back the document
it was given, so the same request next round would be answered the same way; what ships is that
document with those issues written to `@unresolved`. Expect this on a document whose remaining
issues are the ones the loop is designed not to resolve โ€” an undecidable pair of same-worded
headings, a `[page not fully transcribed]` marker.

Frequent lines here with `issues` the editor *should* be able to fix are the signal worth chasing:
that is the editor declining work, not the loop saving a wasted round.

### `run_signals_failed`

The deployment-wide quality tally could not be written for this document (`error`). The document still
ships: recording is soft, on the same argument the agent-suggestion filing at the end of a run is โ€”
a tally is not worth failing a document someone has already waited for.

Logged loudly for the one reason the tally itself cannot show it: **fewer signals recorded looks
exactly like fewer problems found**, so the silent version of this failure is a quality tally that
reads better over time as recording breaks. The row that goes missing is `iris:rounds`, written for
every delivered document including a flawless one because it is the document count the
[public tally](#public-tally-unauthenticated) and the
[quality tally](#quality-tally-shared-secret-off-by-default) divide by โ€” so a window with these
lines under it is short by however many documents they cover, with those documents' defects gone
from the numerators along with them.

### `feedback_classified`

The classify pass read a correction and **nothing was recorded**: `kind` is what it called the lesson,
and `recorded` is `false`.

There is no line for the other outcome. A lesson that was recorded writes
[`feedback_learned`](#feedback_learned) instead, so `recorded` is `false` on every line of this type โ€”
the line's presence is already the negative case, and the field is not one to filter on.

`kind` is the classifier's own word, or `"unknown"` when the reply could not be read as JSON or named
no kind at all. A `kind` of `generalizable` or `a11y_policy` here is **not** a rejection of the
lesson: those are the two values that qualify for recording, so a line carrying one of them means the
reply named a kind worth keeping and left the `instruction` empty. Any other word is the one-off case:
the correction was judged specific to this document, which is the outcome this path exists for.
`"unknown"` is neither of those โ€” it is a reply that could not be read, reaching the same branch.

Recording is not what gates training. The update-proposal path runs on the same correction either
way; with no recorded lesson, the issue it may file is titled from the model's one-line summary
instead ([`agent_update_issue`](#agent_update_issue)).

### `feedback_learned`

A correction was distilled into a reusable instruction and recorded to the agent's example bank:
`agent` is the file it was recorded against, `kind` is `generalizable` or `a11y_policy`, `instruction`
is the sentence stored, and `count` is how many **distinct sessions** have now reported this same
lesson.

`count` is a session count, not a report count. It is the length of the lesson's session list, so one
session submitting the same feedback twice leaves it at 1.

**Recorded is not in use.** A `generalizable` lesson is injected into later runs only once `count`
reaches **2**; an `a11y_policy` lesson is eligible from the first report. So a line with `kind:
"generalizable"` and `count: 1` is a lesson that is stored and changing nothing yet, and
[`page_lessons_injected`](#page_lessons_injected) is where it starts to cost anything. Eligibility is
not injection either: the bank keeps 20 lessons per agent and injects at most 6, highest `count`
first.

`instruction` is the **stored** wording, which for a `count` above 1 is the wording of the first
session that reported it: a repeat is matched on a normalized form of the instruction, and keeps the
instruction it matched while updating the example around it.

### `feedback_agent_missing`

`agents/feedback.md` could not be loaded, so no agent-update proposal was made this round. `note`
carries the whole of that; there is no other field, and no `agent`.

It says nothing about the lesson bank, and the absence of a lesson has no line of its own: the
classify pass reads the same file and returns silently when it is missing. So a run with this line
recorded nothing either, and this is the only line saying why.

### `feedback_target_missing`

The agent the correction was about could not be loaded: `agent` is the file that was asked for, not
one that was found.

Every run asks for `page.md` โ€” it is the one agent file the orchestrator names โ€” so that is the value
on every line of this type today.

### `agent_trained`

A **session-built** agent was improved in place: `agent` is its file, and `scope` is `session_built`,
the only value.

Neither gate ran, and none of the lines they write are in this round. The regression and eval gates
guard changes to the shared agent library, and this agent is not in it: its file lives in the
session's own `tmp/<id>/agents`, and it is overwritten there. A round with this line has no
[`regression_gate`](#regression_gate), no [`eval_gate`](#eval_gate), no
[`agent_updates_proposed`](#agent_updates_proposed) and no issue.

No run writes this line today, and the reason is a loop โ€” the same one
[`agent_call`](#agent_call) describes for `agent_content`. The branch is taken only for an agent
loaded from that tmp directory in preference to the library, and the only line in `src/` that writes a
file there is this branch. Something outside the pipeline has to seed the directory first.

### `regression_gate`

The candidate prompt was re-run against the agent's stored fixtures before anything was proposed:
`agent` is the file, `cases` how many fixtures were read, `passed` whether all of them held,
`failures` **how many** did not, and `meanCoverage` the mean of the candidate's fixture scores.

`failures` here is a count. On [`agent_update_blocked`](#agent_update_blocked) the field of the same
name is the list of strings behind it, and both lines are written for the same blocked update โ€” so
summing `failures` across the log adds a list to a number.

`cases` counts fixture **files**, not fixtures judged. It is at most 3, the newest three by filename,
and a fixture whose JSON cannot be parsed or whose image is gone contributes to neither `failures` nor
`meanCoverage`. `cases: 3, passed: true` is therefore consistent with nothing having been judged at
all.

`meanCoverage` is `null` when no fixture was scored, which is not a zero: a score of 0 is a fixture
the candidate produced no output for, which is a failure on that fixture rather than an absence of
evidence. The mean is over the candidate alone โ€” the comparison against the prompt in the library is
[`eval_gate`](#eval_gate)'s.

**A missing line does not mean the gate was skipped; it means it had nothing to check.** An agent with
no fixtures directory, or an empty one, passes without writing anything here. So an
[`agent_updates_proposed`](#agent_updates_proposed) with no `regression_gate` above it is a proposal
that was checked against nothing.

The candidate's own text is not recoverable from the log. Its `agent_call` lines carry `agent_sha:
null` and `agent_content: null`, which is [`agent_call`](#agent_call)'s one case where neither field
recovers the prompt that went out.

### `eval_gate`

The candidate prompt scored against the current one over the fixtures **both** could be scored on:
`agent` is the file, as on [`regression_gate`](#regression_gate), `current` and `candidate` are the two
paired means rounded to three decimals, `paired` the fixtures that counted, and `unpaired` those that
could not.

`paired` and `unpaired` are lists of fixture image filenames, not counts.

`current: null` and `candidate: null` mean no fixture was measurable on both sides. That is neither a
regression nor a pass: the update proceeds on [`regression_gate`](#regression_gate)'s verdict alone.

The pairing is what the line is for. Whether a fixture has a score is partly a property of the
prompt: one that produced nothing scores 0, while a fixture too short to judge abstains. So averaging
each side over whatever it happened to measure compared two different fixture sets, and one flake on
the **current** prompt could deflate the bar enough to pass a real regression. Pairing drops
such a fixture from both means instead of moving the threshold, and leaves it visible in `unpaired`,
where on the current side it usually means the shipped agent is flaking.

The gate blocks a drop of more than **0.02** only. A line here whose `candidate` is below its
`current` by less than that is an update that went ahead.

### `agent_update_blocked`

A proposed prompt change was not proposed after all: `agent` is the file it was about.

**Two shapes, and `reason` is what tells them apart.** With no `reason`, this is the regression gate:
`failures` is the list of strings saying what the candidate broke, at most one per fixture, and the
[`regression_gate`](#regression_gate) line above it carries the same information as a count. With
`reason: "eval_regression"`, this is the eval gate: `current`, `candidate` and `paired` repeat the
[`eval_gate`](#eval_gate) line above it, and there is no `failures` field at all โ€” nothing broke, the
candidate simply scored lower.

Nothing is filed on either path, and the document the correction was about is unaffected: it was
delivered before the training step began.

### `agent_updates_proposed`

A proposal cleared both gates and was written to the session's `agent-updates.md` โ€” JSON, despite the
extension: `agents` is a one-element list and `count` is always 1.

The shape reads like a batch and there is never one. A feedback round proposes for the single agent its
correction was about. `count` is not the number of proposals on disk either โ€” the file is merged with
any earlier round's proposals and deduped by agent โ€” so a session that proposed twice writes two of
these lines, each saying 1, over a file holding one entry per agent. Today that entry count is always
1 as well: every proposal is for `page.md`.

### `agent_update_issue`

The proposal was filed on the upstream repo: `url` points at what to look at, `action` is `created` or
`commented`, and `lesson_slug` is the slug the title was deduped by (`null` when nothing could be
slugged).

**`commented` is a success, not a proposal that was dropped.** Every proposal targets the same
`page.md`, so before the lesson slug was part of the title, one open issue silently swallowed every
later lesson from every user. A repeat now lands as a comment on the issue already tracking that
lesson, carrying this session's id and its corroboration count, and `url` anchors that comment rather
than the issue.

The slug prefers the recorded lesson's instruction, because that string is stable across the sessions
that corroborate it. With no recorded lesson ([`feedback_classified`](#feedback_classified)) it is a
slug of the model's one-line summary, which is re-worded every run โ€” so it separates lessons without
reliably matching itself. `lesson_slug: null` is the case with no dedupe left at all: the title falls
back to a bare `Agent update proposal: <agent>`, which can only ever match the one issue already open
under it.

The dedupe is a GitHub search, with the index lag described under
[Contributions](#contributions-automatic): two sessions reporting the same lesson within a minute of
each other can each write `created`.

### `agent_update_issue_failed`

Filing threw and the run carried on: `agent`, `error`, and `hint` when the failure looks like a
permissions problem.

`hint` is present only for a 403 or 404 that reached GitHub and does not look like a rate limit. Its
absence is therefore not a claim that the credential is fine โ€” a 403 whose headers say the rate limit
is exhausted, and any error from a call that never reached GitHub at all, both arrive here without
one.

The document was delivered before this line was written, and the proposal is still on disk in the
session's `agent-updates.md`. Filing is a side effect ([Contributions](#contributions-automatic)).

### `agent_update_issue_skipped`

The deployment has no `github.token`, so nothing was filed: `agent` is the file the proposal was
about, and `reason` is `no github token`, the only value it takes.

The proposal stays in the session's `agent-updates.md`, which is where it was written either way โ€”
what this line says is that it stayed there. A deployment that booted cannot reach this line
(`github.token` is required at startup), so seeing it means the pipeline was driven directly โ€” a
local run or a test โ€” rather than through the API.

### `feedback_training_failed`

The training step threw and the failure was contained: `error` is the message, and the only field.

This is the catch in `src/pipeline/orchestrator.ts` around **both** halves of training โ€” the
classification and the update proposal โ€” so the line does not say which half failed, and the first
may have completed. The lines above it are what say how far it got.

**The session is not failed by it.** The document has been `ready_for_review` since before the step
began, and training is work about a future document rather than this one: there is no
[`run_failed`](#run_failed) line under this one, and [`run_complete`](#run_complete) still follows it.

### `run_complete`

The run's own terminal marker: `iterations` the review loop completed, `unresolved` issues left open
in the delivered document, and the `mode` it ran in ([`run_start`](#run_start)).

`failed_pages` and `uncorrected_pages` are present **only when there were any**: a `failed_pages:
[]` on every successful run would read as a field about failure on the lines that have none. This
line is also the only place either set can be read on **every** mode, since a feedback round that
re-extracts nothing has no `extraction_complete` line to carry them. What the run **delivered** is
the question, not what it attempted (#328). Neither set is what
[Diagnostics](#diagnostics-timing--hang-detection) reports: its `pages_failed` is folded from the
per-page `page_extraction_failed` and `page_recovered` lines, and the uncorrected set has no
diagnostics field at all โ€” so this line is where a client reads it.

Written **after** the feedback-training step that follows delivery, even though the session has been
`ready_for_review` since before it, because the run holds its `max_concurrent_runs` slot until it
returns: a marker written before that training would report the run as shorter than the time it
actually occupied the machine. [Diagnostics](#diagnostics-timing--hang-detection) measures a
finished run's duration up to this line.

There is no `pages` field here. The count of source pages is `images` on [`run_start`](#run_start),
and `pages` on [`extraction_start`](#extraction_start).

It is the run's terminal marker and **not the last line of the log**. The suggestion filing below runs
after it, so [`agent_issue`](#agent_issue), [`agent_issue_failed`](#agent_issue_failed) and
[`contribution_failed`](#contribution_failed) can all appear underneath it.

### `agent_issue`

A new-agent suggestion was filed on the upstream repo: `agent` is the normalized content type, and
`url` the issue.

**`url` is not always a URL.** A suggestion whose title already matches an open issue is skipped
rather than filed, and the field then carries the literal string `(duplicate โ€” skipped)`. That is the
only thing separating the two outcomes on this line; there is no second field for it. The other filing
path comments instead of skipping ([`agent_update_issue`](#agent_update_issue)), because its titles are
not unique per document and skipping lost lessons.

`agent` is a normalized type, and the checks that come before this line leave no trace. A suggestion
naming one of the standard types, one this run has already filed under a different capitalization, or
one the library or the session already has an agent for is dropped before anything is drafted, and
this step writes nothing when that happens. Drafting is a vision call, which is what those checks are
worth.

### `agent_issue_failed`

A new-agent suggestion did not get filed: `agent`, `error`, `stage`, and `hint` on the filing stage
only.

`stage` says which call failed, and the two are held apart deliberately. `stage: "draft"` is the
**model** call that writes the proposed agent markdown; `stage: "file"` is GitHub. A provider error's
message can itself contain `403` โ€” the status is formatted into the text โ€” so under one `try` a
drafting failure would be indistinguishable from a permissions failure, and would collect a hint about
a GitHub App that was never involved. A `stage: "draft"` line therefore never carries `hint`.

On `stage: "file"`, `hint` follows the rule described under
[`agent_update_issue_failed`](#agent_update_issue_failed): a 403 or 404 that reached GitHub and does
not look like a rate limit.

Either way the loop continues to the next suggestion, and neither stage costs the caller the document.

### `contribution_failed`

The suggestion-filing step threw outside its own two catches: `error` is the only field. Like
[`feedback_training_failed`](#feedback_training_failed), it is written by a catch in
`src/pipeline/orchestrator.ts` rather than by the step it is about, and it is named after neither โ€” so
a grep for that step's own lines, which are the `agent_issue` ones, does not find it.

Both of the failures **inside** it are already contained per suggestion
([`agent_issue_failed`](#agent_issue_failed)), so a line here is the step's own work โ€” reading the
agent library to decide which suggestions are new โ€” rather than a draft or a filing.

This line comes **after** [`run_complete`](#run_complete), and it does not fail the session. The
document was delivered well before it; a second feedback round can have started by then.

### `run_failed`

The run threw, so there is no document: `error` is the message, and [Poll status](#poll-status)
hands the client that same string in its own `error` field, which exists on a failed session and
nowhere else.

A page that fails on its own does **not** reach here โ€” the run finishes and delivers the rest
([Partial documents](#partial-documents)), which is what `failed_pages` above is for โ€” so a line
here means nothing was delivered at all.

Where a run does end in extraction, [`extraction_failed`](#extraction_failed) is the line that says
which failure it was, and this `error` is written to match: with `blank: 0` it is the first page's own
provider error, standing for all of them because that is the diagnosis; otherwise it counts the source
pages reported blank, which an empty document could not.

### `calibrate_call_failed`

One verifier call in the calibration harness threw: `image` is the page, `defect` the seeded defect the
copy it was judging carried, and `error` the message.

`defect: null` means the failed call was judging the **undamaged** copy of that page.

**An ordinary run never writes this line.** Calibration is a tool โ€” `src/tools/calibrate.ts` โ€” and not
a phase of a run: nothing else in `src/` imports `src/pipeline/calibration.ts`, so a session's log
cannot contain it.

The failure is not counted as a miss. The call comes back marked unjudged, and every rate the report
prints leaves the unjudged out of its denominator: a defect's catch rate is over what was applied
minus what went unjudged, and the false-positive rate over the clean copies that were judged. The
verdict substituted for the dead call reads `ok: true`, and is not counted as a pass either. What the
line is for is telling a provider to retry apart from a verifier to fix, which is why an unparseable
reply is counted separately from this.

## Diagnostics (timing / hang detection)

A machine-readable health summary distilled from the run log โ€” built for maintainers, human
or AI, to spot what's slow or stuck.

```bash
curl -s -H "$AUTH" "$BASE/sessions/$SID/diagnostics" | jq
```
```json
{
  "session_id": "ses_...",
  "status": "running",
  "phase": "extraction",
  "started_at": "2026-05-22T16:25:01Z",
  "elapsed_ms": 92000,
  "in_flight": {
    "agent": "table", "step": "specialist", "model": "us.anthropic.claude-sonnet-4-6",
    "provider": "bedrock", "capability": "vision",
    "since": "2026-05-22T16:26:12Z", "waiting_ms": 41000
  },
  "in_flight_count": 3,
  "concurrency_factor": 3.8,
  "phase_durations_ms": { "extraction": 60100, "review": 24000 },
  "model_calls": { "count": 7, "failed": 0, "total_ms": 51000, "avg_ms": 7285, "max_ms": 14300 },
  "tokens": { "input": 43200, "output": 19400, "cache_read": 2500, "cache_write": 2500, "calls_reported": 7 },
  "by_agent": { "page": { "count": 2, "total_ms": 28200, "max_ms": 15100,
    "input_tokens": 16400, "output_tokens": 9100,
    "cache_read_input_tokens": 2500, "cache_creation_input_tokens": 2500,
    "models": ["us.anthropic.claude-sonnet-4-6"] } },
  "by_step": { "extract": { "count": 2, "total_ms": 28200, "max_ms": 15100,
    "input_tokens": 16400, "output_tokens": 9100,
    "cache_read_input_tokens": 2500, "cache_creation_input_tokens": 2500,
    "models": ["us.anthropic.claude-sonnet-4-6"] } },
  "slowest_calls": [ { "agent": "table", "step": "specialist", "model": "...", "capability": "vision", "duration_ms": 14300, "ok": true } ],
  "errors": [],
  "verification": {
    "pages_verified": 25, "pages_unjudged": 3, "pages_skipped_blank": 2, "pages_verify_error": 1,
    "verify_failed": 13, "corrections": 14,
    "verify_kinds": { "content_missing": 5, "content_wrong": 2, "structure_wrong": 6,
                      "a11y_only": 3, "alt_quality": 4, "untagged_pages": 1 },
    "verify_untagged_problems": 2,
    "verify_inconsistent": { "pages": 3, "content_missing": 0, "content_wrong": 1,
                             "structure_wrong": 1, "a11y_only": 0, "alt_quality": 1,
                             "content_or_structure": 2, "undecided_pages": 1 },
    "results": { "kept": 12, "rejected": 0, "identical": 2, "empty": 0, "failed": 0 },
    "triggers": { "verify": 13, "links": 1, "alt": 0, "ids": 0, "words": 0, "both": 0 },
    "declined": { "pages": 1, "problems": 1, "problems_offered": 31, "code_checked": 0,
                  "words": 0, "unattributed": 0 },
    "effects": { "alt_only": 4, "text": 8, "attrs": 3, "structure": 6, "text_grew": 5, "text_shrank": 1 },
    "rechecks": {
      "sampled": 1, "sampled_ok": 1, "sampled_unjudged": 0,
      "sampled_problems_before": 3, "sampled_problems_after": 0,
      "binding": 1, "binding_ok": 1, "binding_unjudged": 0, "binding_error": 0,
      "failures": [], "verdicts_omitted": 0
    }
  },
  "tables": {
    "joined_in_code": 5, "joined_by_editor": 8, "code_declined": 11,
    "joined_in_code_with_halves": 5, "code_declined_with_halves": 11,
    "header_compared": 9, "header_differs": 4,
    "failed": 3, "body_unreadable": 0, "capped_pending": 0
  },
  "editor_ceiling": {
    "truncated": 4, "salvaged": 1, "salvaged_closed": 1, "retreated": 0, "declined": 2,
    "decline_reasons": {
      "no_edits_list": 0, "no_complete_edit": 1, "unknown_block": 0, "unreadable_edit": 0,
      "out_of_order": 0, "all_refused": 0, "loss_before_cut": 1, "unrecognized": 0
    }
  },
  "fidelity_observed": {
    "observed": 3, "pages": [2, 5], "unattached_pages": [],
    "kinds": { "content_missing": 2, "content_wrong": 0, "structure_wrong": 0,
               "a11y_only": 0, "alt_quality": 1, "untagged": 0 },
    "unattached": 0, "unplaced": 0
  },
  "pages_failed": [],
  "pages_blank": [17],
  "pages_bare_html": [4, 31]
}
```

The key field for **"is it hung?"** is `in_flight`: a non-null value with a large `waiting_ms` means
a model call started and hasn't returned (the likely culprit). Because pages are extracted in
parallel, several calls can be open at once โ€” `in_flight` reports the **longest-waiting** one and
`in_flight_count` how many are open in total. `concurrency_factor` is total model-call time รท
wall-clock elapsed: ~1 means calls ran serially, and roughly `extraction_concurrency` during a
parallel extraction phase โ€” a value near 1 on a multi-page run means parallelism isn't happening.

`slowest_calls` and `phase_durations_ms` show where time goes; `errors` lists failed calls, plus the
two failures that are not calls โ€” a feedback round's agent training (`feedback_training_failed`) and
its agent-suggestion filing (`contribution_failed`). Both run after the document is delivered and
report rather than raise, since neither may revoke a document the user already has, so this is where
they surface.

**Failures only**, which `errors` was not: a `page_correction_recheck` carries an `ok` of its own
meaning "the verifier named no problem", so every second verdict that named one landed here too โ€” 31
of 31 on disk across 22 rounds, all of them the measurement-only sample, which runs *after* the
correction is kept and changes nothing about what ships. On a four-document round that made two
clean documents read as having errors, and the only thing distinguishing a working measurement from
a truncated call was that the measurement's `message` said `"unknown"` โ€” this entry read `error`,
and that event carries its diagnosis under `problems` (issue #296). A failing verdict is now
reported where its counts are, as `verification.rechecks.failures`, so a non-empty `errors` means
the run is in doubt. Every entry that reaches it carries a real message: the three named above are
built from a caught throw and a failed `model_call` is logged with the provider's own, so
`"unknown"` is what an old log would read as rather than a standing entry on every run that sampled.

`tokens` is what the run **consumed**, and `by_agent` carries the same four counts per agent
(under the names the run log uses: `input_tokens`, `output_tokens`, `cache_read_input_tokens`,
`cache_creation_input_tokens`) โ€” so "which agent is slow" and "which agent is expensive" can be
answered separately, because they are often different agents. `by_step` is the **same calls with
the same seven numbers, keyed by the job the call was bought for** instead of by the agent that
answered it, so summing either gives the same totals and the same `tokens`.

Each row also carries `models`: **which model ids answered those calls**, sorted and deduplicated.
The seven numbers say what a bucket cost, and this says what the cost is a price *of* โ€” the pair
matters on the one knob a deployment turns, since `providers.per_agent` picks a model per agent and
until this field nothing in a finished run said whether a swap had taken effect. A key naming no
dispatched agent is ignored rather than refused (**Configuration**), so the call falls through to
the provider's own model and the run succeeds at the price it would have cost anyway: a cheaper
model that saved nothing and a swap that never happened produced identical diagnostics.

Read `by_agent.<agent>.models` after changing an override โ€” that is the split the override is keyed
by, on a session that has only run since the change: this field folds the whole session log exactly
as the seven numbers do, and a session's log spans its feedback rounds, so a session extracted
before a restart and given feedback after one reports both ids truthfully.

Usually one id; **more than one is not a defect**, because resolution keys on capability as well as
agent, so a provider's `per_capability` block can put one agent on two models on purpose (`page`
extracts with `vision` and merges a specialist fragment with `text`; `feedback` judges with `vision`
and classifies with `text`; the copy editor picks by whether the section it is editing has images).
An **empty** list on a row with calls in it means a log old enough to predate the field โ€” every
`model_call` the router writes carries `model`, on the failure branch as well as the success one,
which is deliberate: a model id that is valid for one provider and named to another resolves happily
and then fails on every call, and that row's model is the whole diagnosis.

Both are reported because they answer different questions and neither substitutes for the other: an
agent name is a *contract*, and one contract serves several jobs. The Feedback Agent judges a
freshly extracted page, re-judges a corrected one, routes a user's feedback and classifies a lesson
from it; the Copy Editor runs a review round **and** merges a table split across a page break. So
the cost of a *step* is not a row in `by_agent` โ€” extraction read as 41% of a document's spend
against a `by_agent` split that books its per-page fidelity check to `feedback`, where its jobs
together are 57.2% โ€” while `providers.per_agent` overrides are keyed by agent, so "which model
should this be on?" is not a question `by_step` can answer.

The step names are a closed set, split finely on purpose because buckets add: **extraction** is
`extract`, `verify`, `correct`, `recheck_binding`, `recheck_sampled`, `specialist`,
`specialist_merge`; **review** is `read`, `edit`, `edit_section`, `table_join`; **a feedback round**
adds `feedback_scope`, `feedback_learn`, `agent_update`, `agent_regression`; and `agent_calibrate`
and `contribute` are maintenance paths a delivered document does not pay for. Every call carries
one, so a `"?"` key means the log predates the field rather than that a call went unattributed.
`in_flight` and `slowest_calls` name the step too, since "what is this run stuck on?" is a question
about the job and not about the contract.

Deliberately no dollar figure: the rate depends on the provider, region and model, all of which are
deployment config, so the token counts are reported and whoever holds the price sheet does the
multiplication. The four counts bill at four different rates and are never summed here; note that
`input` **excludes** tokens read from the cache, so the whole prompt is `input + cache_read +
cache_write`.

The last two are non-zero because Iris asks the model to cache the part of each prompt that does
not change. Three things qualify: the agent's own system prompt, which is identical on every page
of every document; on the fidelity check, the contract of the agent it is judging, which that task
re-states in full on every page and which is the largest single constant Iris sends; and on the
Reader, the index of the document's source pages, which every chunk of every review round is given
and which does not change while the loop runs. The last of those is per-document rather than
per-deployment, so its entry is cold once per session by construction, and it is only asked for
when the index is long enough to be worth a breakpoint โ€” roughly ten pages.
Expect a `cache_write` on the first calls of a run and a `cache_read` on every call after them โ€”
a handful of writes rather than exactly one, because pages are converted concurrently
(`defaults.extraction_concurrency`), so the first few calls of a phase go out together before any
of them has written the entry the others would have read. A request may carry more than one cached
prefix โ€” the fidelity check caches its system prompt and the contract it is judging, and a Reader
call caches its system prompt and the page index โ€” and they share one `cache_creation_input_tokens`
figure on that call's `model_call` line rather than appearing as separate writes. So
on a long document the same prefix is paid for a few times at 1.25ร— instead of 25 times at 1ร—, and
every other call reads it at 0.1ร—. A run that shows `cache_read: 0` with several calls to the same agent is a run that
is paying full price for the same instructions repeatedly โ€” the cases where that is expected are a
model whose id Iris cannot recognize as a Claude model, a model generation older than caching
support (Iris asks from 3.7 on), an agent prompt too short to be cacheable (the platform minimum is
~1k tokens), and a deployment that set `prompt_cache: false` on the provider block.

A cache entry lives five minutes by default, refreshed on every read โ€” so within one run the
prefixes stay warm on their own however long the document takes. A deployment whose runs arrive
in bursts, more than five minutes and less than an hour apart, can hold them for an hour instead
with `providers.<name>.prompt_cache_ttl: 1h`. It is a trade rather than a free upgrade: an
hour-long entry is written at 2ร— instead of 1.25ร—, so it needs a third use to pay for itself
where five minutes needs a second. A deployment converting a document a day should leave it
alone.

**These fields cannot tell you which TTL you got.** The difference between the two is a price
multiplier on a write, not a token count โ€” the same prefix written either way reports the same
`cache_write` โ€” so a broker that silently strips the field reads exactly like one that honours
it, and your provider's own billing is where that question is answered. A value Iris cannot
read (`60m`, `1 hour`) is caught at startup instead, with a warning, and falls back to five
minutes.

`cache_write` is the weaker signal of the two, and a zero there means less than a zero read. It is
reported by the provider, and on an OpenAI-shaped upstream the field it would come from is
undocumented, so a deployment can see reads climbing with writes sitting at 0 forever. That is a
cache working and a counter staying quiet, not a cache half-broken; `cache_read` is what tells you
whether the asking is paying off. Whichever way these two land, the caching changes nothing about
the converted document โ€” it is the same prompt either way, so these are cost fields and not quality
ones.

One caveat on that sum, for whoever is doing the multiplication. It is exact on a provider that
reports the four counts as disjoint sets, which is what the Anthropic-shaped APIs do. On an
OpenAI-shaped one, cache reads are reported *inside* the prompt total and are subtracted back out
here, but whether cache **writes** are also inside it is undocumented โ€” so where they are, they
are counted once as `input` and again as `cache_write`, and the sum is high by that amount.
Over-counting is the deliberate choice: the alternative subtracts a number that may never have
been in the total, which understates the prompt and reports a cache as cheaper than it was.

`calls_reported` is how many of `model_calls.count` reported any usage at all. When it is lower
than `count`, these sums cover only part of the run โ€” a cost derived from them is a floor, not
an estimate. Some upstreams report nothing; a call that stalls knows its prompt size but never
learns its output size. Failed calls **are** counted, because a truncation has already paid for
a full ceiling of output and a stall for its prompt.

`verification` is what the verify-then-correct loop did. Every page is checked against its source
image and a page that fails is re-rendered once, so a run's `page` call count is `pages +
corrections` โ€” on three real 25-page runs the Feedback Agent rejected 58 of 75 pages, which makes
the "correct if needed" pass mandatory in practice and put verification alone at 24% of one
document's bill.

`corrections` and **not** `verify_failed`: a page that passed its check is re-rendered too when the
code finds a link the model dropped, and that costs the same page call, so `triggers` is the split โ€”
`verify` is a page the Feedback Agent rejected, `links` a page that passed and lost a link, `alt` a
page that passed and described an image with a placeholder (#290), `ids` a page that passed and used
one `id` on two elements (#373), `words` a page that passed and wrote one word two ways (#334),
`both` one with more than one of those.

`alt` and `ids` are both expected to be 0 on most runs, and that is the point of counting them: the
alt rule flags nothing this pipeline writes, and the id rule flags 2 of 1,501 measured page replies
โ€” about one page in 750. One of those two is `fnref-1` written twice by `gpt-5.6-luna`, the page
model deployed since 2026-09-10 (#344), so one non-zero `ids` is the measured rate rather than a
finding. Several is the finding: a page agent that has started writing placeholders or reusing ids,
or a regression in one of the rules.

`words` is the one of the four expected to be **non-zero**, and so the one with a cost line: on
#334's 100-page census `kimi-k2.5` wrote one word two ways on 4 of the 92 pages it delivered,
`claude-sonnet-4-6` on 3 of 91 and `gpt-5.6-luna` on 2 of 91 โ€” no arm was clean. So this trigger
buys a page call for a page that had already passed, at a rate near 4% on the highest arm measured
and near 2% on `gpt-5.6-luna`, which is the page model deployed since 2026-09-10 (#344).

It is the first field to read when `corrections` grows and `verify_failed` does not. `verify_failed
/ (pages_verified - pages_unjudged)` is the rejection rate; the raw counts are reported rather than
the percentage, because a rate over three pages is not a measurement.

`declined` is what the corrector said it would **not** do. Since #373 a correction may answer a
problem by refusing it โ€” where the problem asserts something about the HTML it was shown and that
HTML shows otherwise, it says which problem and why and changes nothing โ€” and before that its only
legal move was compliance, so a false claim about a page was answered by editing a page that was
right.

These counts are the only trace the licence leaves, because it **gates nothing**: no verdict, no
`results`, no recheck and not the `uncorrected` set are touched by a decline, so a page whose
problem was declined wrongly ships in exactly the state a correction that failed to fix it ships in
โ€” named in `uncorrected`, with `@page-uncorrected` on the document. What a decline removes is the
edit, not the record.

Two rates, and both denominators are here because neither count reads alone: `pages` against
`corrections`, and `problems` against `problems_offered`, the whole bill every correction in the run
was given (summed from the `page_corrected` lines, not from the declining pages' own bills โ€”
dividing by the numerator's own subject would report a rate the run cannot be wrong about). 2
declined of 2 is a correction refused outright; 2 of 40 is the pass doing what it was built for.

`code_checked` is the field to watch rather than the total, and it is the misuse: a declined
`links`, `alt` or `ids` problem is a refusal of something Iris checked against the source file's own
annotations, a closed word list or the parsed fragment, so it is wrong by construction, where a
declined `verify` problem is a disagreement with a *reading* and may well be right. A non-zero there
is a corrector reading the licence wider than it is written โ€” the risk #373 states against its own
proposal, countable now rather than arguable later.

What makes that reading of the number sound is that the corrector can tell the two apart: the
`links`, `alt` and `ids` problems Iris raised itself are marked `(Iris checked this one in code.)`
in the list it is shown, and the licence excludes a marked problem by name. Without the mark a
corrector following the licence exactly would decline into those bands โ€” their wordings are the
licence's own examples almost verbatim โ€” and this field would be counting compliance.

`words` is the fifth code-checked band, it carries a **different** mark (`(Iris checked in code that
both spellings are on this page, not which one is right.)`) and it is counted **apart** from
`code_checked`, all three for one reason: on a word written two ways, Iris verified that both
spellings are present and cannot know which the printing shows, so the entry is settled in one part
and open in the other. The request says that in its own sentence, since the sentence excluding a
marked problem ends "so fix it" and that would order the corrector to join `non-farm` into a
`nonfarm` the page never prints. A decline there is therefore the licence working rather than being
stretched. Reading it as misuse would put compliance in the one field whose job is to count abuse. A
non-zero `words` is a rate to compare against the [`page_split_words`](#page_split_words) lines โ€”
how often a page really did print both โ€” and not a number to act on by itself.

`unattributed` is a decline that cited no problem number, or cited one the request never listed: not
evidence about a code-checked fact and not nothing either, but a disagreement whose subject cannot
be recovered, which is the first thing to read if `problems` is large and the lines are not making
sense. Zero on every log written before the field existed, and the block prints at zero on a run
where nothing declined โ€” `pages: 0` against a non-zero `problems_offered` is the measurement this
feature is judged on.

`pages_unjudged` is a **subset** of `pages_verified`, not a deduction from it: the pages that reached
verification and came back with no judgement โ€” no Feedback Agent loaded, nothing to verify, a reply
that would not parse, and a page the agent declared **blank**, which is not sent to the verifier at
all. Verification is non-blocking, so all four answer "faithful" and cost the page
nothing, which means a run that lost its Feedback Agent halfway through would otherwise read as a run
with an unusually good pass rate. Zero on every log written before the flag existed, which is the one
case it cannot distinguish rather than one it claims to.

`pages_skipped_blank` is a subset of *that*: the pages nothing looked at because nothing was bought.
A page the page agent declared blank has an empty fragment, and an empty fragment has no content to
be unfaithful with โ€” the verifier used to be shown the source image and an empty code block, and in
36 such judgements on a 100-page corpus (9 blank pages, two page-model arms, two commits) it passed
every one, for $0.0859 per arm: 0.77% of that lineup's bill and a growing share as the models get
cheaper, because a per-image cost does not shrink with them (issue #294).

Read it as the saving โ€” this count times $0.0095, the measured cost of a verify call carrying no
HTML, against $0.0212 for an average page โ€” with one caveat: it counts calls **not bought**, which
is money not spent only where there was a verifier to spend it on. A run with no Feedback Agent
loaded skips the blank page's call too and saves nothing by it. `pages_unjudged == pages_verified`
is *consistent* with such a run but does not identify it: a run whose Feedback Agent loaded and
whose every verify reply failed to parse gives the same equality, and there the calls were bought
and the money spent.

What settles it is the calls themselves โ€” `by_step.verify.count` in this same object is 0 on a run
that bought no verdict at all, whatever `pages_unjudged` says. It is a subset of `pages_unjudged`
and therefore still inside `pages_verified`, so no rate published before it moves; what it adds is
that a skip and a broken Feedback Agent stop being the same two numbers.

The blank page keeps every check that costs nothing: a page carrying link annotations that came back
empty still fails the link comparison, still buys a correction against the image, and that
correction is still verified โ€” so the wrong-blank case a **file** can prove is caught for free. What
is given up is a confident wrong declaration on a page with no annotations, which this call has
never caught (0 of 36) and whose observed cause โ€” a hedged declaration โ€” is refused before it gets
here by the doubt-word veto (`blank_vetoed`), which a stated declaration does not override either.
Its evidence is the `page_blank` line and `pages_blank`.

One blank page is outside this count and is supposed to be: where the reply stated blankness in its
`blank` field and its own log names something on the page, the verdict IS bought โ€”
`blank_contradicted` on `page_blank`, no `skipped` on the page's line โ€” so `pages_blank -
pages_skipped_blank` is the number of declarations that cost a call (issue #371).

`pages_verify_error` is the other subset of `pages_unjudged`, and it is the counterweight to the one
above: the pages whose verify call **was** bought and threw โ€” a throttle, a stall, or a reply that
overran the output ceiling (`page_verify_error` for the evidence, `skipped: "error"` on the page's own
line). Nested identically, so it moves nothing published either. The reason both exist rather than one
"unjudged for a reason" total is that they point opposite ways in money: a blank skip is a call not
made and is a saving, while an error is a call made, billed for a full ceiling of output, and answered
with nothing. On the case that prompted it that was **$0.5051 on one page** โ€” more than twice an
average page's entire bill, and 3.2x the extraction the call was checking (issue #364). So price this
count against a full-page verify call, never the empty-fragment one, and never add the two counts
together. Zero on every log written before the guard existed, and that zero measures nothing: the same
failure used to take the page with it, so those runs recorded it as `page_extraction_failed` and a page
in `pages_failed`. A run whose verifier was being throttled reads, on an older log, as a run whose
vision was failing.

`verify_kinds` is that rejection rate split by what was **wrong**, in pages, out of the five kinds
VERIFY tags each problem with (`agents/feedback.md`). Two benchmark rounds rejected 74 of 94 and 76
of 100 pages with no way to tell content arriving missing from descriptions being polished, which is
what made `verify_failed` unreadable as an accuracy signal. `content_missing` is where an image with
no description at all lands too, because the rule is what a reader LOSES and there the loss is the
content: so a high `content_missing` share is "content is not arriving", which may mean the vision
pass dropped table rows or that meaningful images came back undescribed โ€” two remedies, extraction
fidelity and alt-text prompting, and this field does not separate them. The `problems` text on the
page's own log line does. Not a partition, for the same reason
`effects` is not: a page with a missing row and a thin alt text is counted in `content_missing` and
in `alt_quality`, so these sum to at least `verify_failed` and usually more โ€” read each against
`verify_failed`, never against their own total. Pages rather than problems, so one page naming six
things cannot outweigh six pages naming one each. `untagged_pages` keeps the rest honest: a page whose
problems carried no kind โ€” an older log, an agent file whose contract predates the kinds, a model that
answered in plain strings โ€” is in `verify_failed` and in no kind bucket, and a split read without it
beside them is a split of the tagged share presented as the whole run. A page appears in both when
some of its problems were tagged and some were not, which is why the name says pages: the `untagged`
on the log line it is folded from counts PROBLEMS, and the two are different numbers on one run.
`verify_untagged_problems`, beside `verify_kinds`, is that other unit โ€” a run that lost one tag per
page and a run that lost every tag report the same `untagged_pages`, and only the second means the
split cannot be read at all.

`verify_inconsistent` is the other side of the verdict: pages the verifier **passed** while naming a
problem. Those are not in `verify_failed` and they bought no correction โ€” a page fails only when a
`faithful` / `accessible` flag is false *and* a problem is named โ€” so this is the one place the
sentence such a verdict wrote is counted at all. `pages` is how many, split by kind in pages exactly
as `verify_kinds` splits the failures and just as much not a partition. `content_or_structure` is the
field to read: pages naming at least one `content_missing`, `content_wrong` or `structure_wrong`,
which is precisely the population a kind-gated failure rule would newly fail and newly pay a page call
for. Read the rest as the run working, not failing โ€” an `alt_quality` note on a page that ships is the
Feedback Agent answering a question it was asked to answer. `undecided_pages` is the unknown above
that floor: pages where such a rule has nothing to decide on, because a problem arrived with no kind
this version knows and no content or structure kind was named either. The two are addable โ€”
`content_or_structure` is the least the rule would cost and their sum the most โ€” which is why this
field is **not** the same rule as `verify_kinds`' `untagged_pages`: that one counts a partly-tagged
page as well, because it audits a split and a missing tag makes the split incomplete, whereas a page
already naming `content_missing` is decided here whatever else it left untagged. Expect
`undecided_pages` to be the whole of `pages` on a log from an agent file whose VERIFY contract
predates the kinds. Nothing in the pipeline reads any of this; it exists so that rule can be priced
over a fleet before it changes what a document costs (issue #210). Paired with
`effects` on the `page_corrected` line for the same page, this is what says whether a correction
addressed what was reported: a page flagged `content_missing` whose correction came back
`alt_changed` only did not get fixed.

The fields answer different questions about the same loop. `results` is what the corrections
**cost**: `identical`, `empty` and `failed` are page calls paid for that produced no change at all,
and `failed` is the most expensive of the three โ€” a correction that hit the output ceiling paid for
a full ceiling of tokens before failing, so summing only the first two undercounts the waste by the
worst of it.

`effects` is what they **did**, read off the two fragments rather than taken from the verdict โ€” the
other end of the same question `verify_kinds` answers, and the one that can be checked against it:
what the verifier said was wrong, and what the correction actually changed. It is also the only one
of the two available for a page whose verdict named nothing, since a page that passed its check is
re-rendered too when a link is missing.

`text` and `structure` are not exclusive (a re-render is usually both); `alt_only` is the count that
stands alone, and a run where it dominates is spending a page call per page on image descriptions.
`attrs` is every attribute but `alt`, which is where the cheapest real fixes live โ€” an `href` the
model re-typed, a missing `<th scope>`, an `aria-describedby` โ€” a correction that moves no word and
still matters.

`text_grew` and `text_shrank` split `text` by direction, measured on the prose a reader receives:
how many corrections added words, how many removed them, and โ€” on a log where every line carries the
sizes โ€” by subtraction how many rewrote the same quantity in place. That subtraction is only safe on
a log written entirely since the sizes existed: an older `page_corrected` line still counts under
`text` and lands in neither direction, and a session's log is append-only across rounds, so a
session that takes a feedback round across the upgrade has a mixed one. Compare `text_grew +
text_shrank` against `text` first.

This is what makes a high `verify_failed` rate readable in either direction. Two bench rounds put it
at 71% and 74% of pages, with `attrs` and `structure` touched on nearly every correction โ€” which
reads either as most pages arriving with content missing, or as most pages arriving fine and being
polished, and no count could tell the two apart. A round clustered in `text_grew` is recovering
content the vision pass dropped; one that barely leaves `attrs` and `structure` is buying markup on
pages that were already readable, and the cheaper fix for that is the page prompt rather than a call
per page. There is no threshold โ€” a correction that adds one character counts as `text_grew`,
because any band calling that "cosmetic" would be picked rather than measured, and the magnitudes
are on each `page_corrected` line for anyone with a corpus to calibrate one on.

`rechecks` is whether correction **converges**: `sampled_ok / sampled` is a corrected page that had
FAILED its check, verified a second time to see whether the re-render fixed it. How many pages a run
samples is `defaults.recheck_sample_size` (default 1, `0` off, at or above the page count a census),
and how many it actually took is `sampled` โ€” a slot is spent only if a corrected page reached its
threshold. Read the default as a **count**: `sampled: 1` supports "1 of 1 cleared" and no
percentage, which is the mistake this number was built to invite โ€” 8 runs over 111 corrections
bought 8 verdicts, and two four-draw samples off one corpus read 50% and 25% (issue #288). Accruing
draws over a fleet does not fix that on its own, because the pages are chosen by a rule and not at
random. The rate over corrected pages is what a **census** buys, at one Feedback Agent call per
correction โ€” roughly half again on top of verify's 14.2% of a document's bill โ€” and it is worth
buying once rather than standing in production: replayed over 57 corrected bench pages it says
**26%** of corrected pages clear their recheck, against a 2% floor for re-asking about the page as
it was (19 better, 2 worse, p = 0.000). So a correction usually leaves a named problem behind, and
`sampled_ok` near zero on a small sample is the expected reading rather than a regression.

`sampled_problems_before / sampled_problems_after` is how far the kept corrections got: `sampled_ok`
alone read as pass/fail on a single-shot pass that was never expected to reach zero, so 11 problems
in and 3 out looked exactly like 11 and 11. Fidelity problems on both sides, deliberately โ€” a
correction is also handed the links the code found missing, and this verdict judges the fragment
against the *image*, where a link target does not appear, so counting a link going in and never
being able to count it coming out would bias the ratio toward "the loop converges" on exactly the
pages that have the most to fix. Both are sums over the sampled pages that were actually judged โ€”
`sampled` less `sampled_unjudged`, and less any line too old to carry both counts โ€” so read them as
a ratio rather than a per-page average, and note that `sampled_problems_after: 0` does not mean the
sample passed โ€” a verdict's `ok` is its `faithful` / `accessible` flags, which an agent can set
false while naming nothing.

`binding` is counted apart from the sample and not added to it: those are the links path's own
re-verifications of pages that had already **passed**, kept or discarded on the verdict, so their
ok-rate answers "did a rewrite of a good page stay good" โ€” a different question, and on a link-heavy
PDF there is one per page, enough to swamp the sample if the two were summed. The tally has no
binding `problems_*` pair for the same reason โ€” the event lines do carry the counts, but nothing
sums them here: those pages had passed, so their `problems_before` is 0 by construction, and their
verdict decides whether the rewrite ships at all rather than measuring how far a kept one got.
`sampled_unjudged` and `binding_unjudged` are `pages_unjudged`'s caveat one level down, and subsets
in the same way: a recheck's `ok` is also what an unavailable Feedback Agent looks like, and with
none loaded every page passes its first check, so every corrected page's recheck is the binding one
and every one of them reads as a rewrite checked and found good. Subtract from BOTH sides โ€”
`(binding_ok - binding_unjudged) / (binding - binding_unjudged)`, same shape for sampled โ€” because
an unjudged recheck logs `ok: true` and so is already inside `binding_ok`. That is where these
differ from `pages_unjudged`, which comes off the denominator alone: `verify_failed` can only come
from a `page_verify_failed` line, which an unjudged verdict never writes. The `sampled_problems_*`
pair needs no such correction, because an unjudged sample is left out of it: its `problems_after` is
0 for want of a verdict rather than for want of remaining problems, and summed in it would report a
page nobody judged as a correction that fixed everything it was given.

`binding_error` is the binding recheck that was **bought and threw**, so it produced no verdict at all
(`page_verify_error` with `step: "recheck_binding"`, issue #364). It is **not** a subset of `binding`
and not in any rate above: the three fields beside it are fed from `page_correction_recheck`, which does
not fire when there is no verdict to report, so this population is disjoint from all of them and the
judged-only rate is unaffected. It is here and not in `pages_verify_error` for the reason that field is
nested: that count sits inside `pages_unjudged`, and a page whose binding recheck threw is **not**
unjudged โ€” it has a real first verdict and it passed, so counting it there would put a judged page in
the unjudged total and move the rate the nesting protects. Read it as *a gate that could not be
applied*: the correction was billed, the page had already rendered, passed and been corrected, and the
rewrite is discarded because no verdict is no licence to change a page known to be good. It is the more
expensive of the two verify-error shapes and, without this field, the only one with no number โ€” its
sole other trace is a `page_corrected` `result: "rejected"`, pooled with the shrink floor and with a
rewrite a second verdict genuinely refused. The sampled recheck's own failure
(`page_correction_recheck_failed`) has no counter, and that asymmetry is intended rather than an
oversight: the sample decides nothing whether it answers or not, so it is the one verify failure that
changes nothing about what ships.

`failures` is the failing verdicts themselves and not a count โ€” the counts are `sampled - sampled_ok`
and `binding - binding_ok`. One entry per recheck that named a problem, in the verifier's own prose:

```json
{ "ts": "2026-09-01T07:43:58.408Z", "page": 21, "binding": false,
  "message": "The alt text places Mississippi in the dotted-pattern category ... but on the map MISS. is shown with the solid-dark fill." }
```

Nothing else in `diagnostics.json` holds that prose, and it is the whole answer to "what is still
wrong with the page that shipped" โ€” the counts say a correction did not converge and never say what
it failed to fix. Both populations, told apart by `binding`, because the two failures read
differently: `false` is a page that shipped **still wrong**, and `true` is a rewrite that was
refused so the page shipped as it was (`page_links_correction_rejected`). `null` is a line that did
not say, which the counts above put in neither bucket โ€” kept here anyway, since what that line
failed to say is which rate it belongs in and not what is wrong with the page.

`page` is on the entry because a run can fail several rechecks and each message is about one page.
The `message` is the problems in full, counted when there is more than one (`"2 problems: โ€ฆ | โ€ฆ"`),
since no order is claimed among them and the dropped one is as likely as any to be why the page is
wrong. Failing verdicts only, so an unjudged recheck never appears โ€” it logs `ok: true` and names
nothing. This is where these were meant to be read all along: they were in `errors` under the word
`"unknown"` (issue #296).

`failures` is **bounded**, which nothing else in this payload needs to be: every other field here is
a count, and these entries are model prose, so they are the one part that grows with what the
documents needed. At most **20** verdicts, each `message` cut at **600** characters with a `โ€ฆ`
marking the cut (so a cut message is 601 characters, the mark being extra), and `verdicts_omitted`
says how many the cap left out โ€” a capped list is never a short one read as whole.

The two populations reach that cap at very different rates, and the sampled one effectively never
does: `recheck_sample_size` is 1 by default, so a run supplies at most one sampled failure. The
binding recheck is **not** sampled โ€” it runs on every page that passed its check and had a link or
alt rewritten โ€” so a link-heavy document can refuse more than twenty rewrites inside one round on
default config, and the cap engages there. That is the run worth capping: twenty refusals plus a
count of the rest says the rewrite path is losing content systematically as well as fifty verbatim
would. `GET /v1/sessions/{id}/logs` holds every verdict in full, uncut and uncapped.

`rejected: 0` over a whole round is the expected reading of a healthy one, not a gate that accepts
everything. The only rejection that applies on every trigger is the shrink floor โ€” a correction that
came back at less than a quarter of the page it was given โ€” which catches a parser or ceiling
failure, not a bad rewrite. (Before it existed, `rejected` was reachable on the `links` trigger
alone, so a round whose corrections were all verify-driven could not produce one at any rate of
badness; two bench rounds of 145 corrections read `rejected: 0` for that reason.) A correction that
is merely **wrong** is kept, and `rechecks.sampled_problems_*` is where that shows up.

Nothing else in here gates anything, deliberately. A verify-driven correction is accepted exactly as
it was before these fields existed, including one whose sampled recheck failed. Discarding it would
not restore a good page โ€” it would ship the fragment that had already failed the same verifier, so
the choice is between a page with fewer named problems and a page with more. The sample is also a
subset of the batch at any size below a census, so binding it would put a gate on page 4 that page 5
never sees; and it stays non-binding **at** a census too, since a knob that changed what ships as it
was turned up would make every rate it collected a measurement of a different pipeline. Whether to
re-render until a page passes, or to run a cheaper verifier, is a policy question, and the rate it
needs is now buyable (`defaults.recheck_sample_size`) rather than only inferable from a bench replay. Like `model_calls`, the counts sum over every run a session has had, so a
feedback round that re-extracts three pages adds three more verifications.

`tables` is what the table-join stage did with the pairs it found, folded from
[`table_joined`](#table_joined), [`table_join_code_declined`](#table_join_code_declined) and
[`table_join_failed`](#table_join_failed). `joined_in_code` is the pairs merged with no model call and
`joined_by_editor` the pairs a Copy Editor call was bought for; `code_declined` is every pair the free
path stood down on, each of which bought that call. A decline is **not** a failure โ€” the pair goes to
the editor exactly as it did before the free path existed.

`code_declined_with_halves` is how many of those declines carry the two halves' bytes, so a looser rule
can be scored on them without buying a round (see
[`table_join_code_declined`](#table_join_code_declined)). It is expected to **equal**
`code_declined` โ€” the bound that drops a pair is 2.5x the largest this corpus has produced โ€” and it is
published for that reason: a bound nothing reaches is a bound whose biting would otherwise be invisible,
and the difference between the two numbers is the part of a re-score that would have no evidence behind
it. A log written before the bytes were logged reads `0` here, which is what it is.

`joined_in_code_with_halves` is the same number for the free joins, and it is here because the two
populations answer one question between them: a loosening is scored on the declines it means to recover
**and** on the free joins it must not break, so a payload that could report the first as replayable and
not the second could only ever measure the upside. Paid joins are in neither count and cannot be โ€” their
bytes are on the decline line that bought the call, which `code_declined_with_halves` counts.

Three separate counts say a reader met a table cut in two, and they are separate because their remedies
are:

- `failed` โ€” pairs that were tried and lost. `table_join_failed` lines with no `stage`.
- `capped_pending` โ€” pairs never attempted, because `MAX_TABLE_JOINS` (12) was already spent on this
  document. The remedy is a higher cap, where `failed`'s is a better join, so pooling them would hide
  which one a round needs. Summed from `table_joins_capped`'s `pending`.
- `body_unreadable` โ€” the one **run-level** `table_join_failed` line, `stage: "body"`, meaning the
  assembled body would not parse and so no pair was joined at all. It is one line however many pairs
  the document had, which is exactly why it is not folded into `failed`: doing so would report
  `failed: 1` for a run where every pair stayed split.

A `table_join_failed` line whose `stage` is neither absent nor `"body"` lands in none of the three, on
the same principle as `by` outside `code`/`editor` โ€” a total that is visibly short beats a bucket
filled by guesswork.

Read the free share as a **range, not a rate**, which is why no share is published here. The same code
on the same 100-page corpus took 9 of 17 pairs, then 4 of 17, then 5 of 16 โ€” 53%, 24% and 31% โ€” with
this stage, `agents/` and the model byte-identical, a $0.72-per-100-pages swing in a step that is
11.5% of the bill (issue #326). The cause is upstream of the join: two extractions of one printed
header agree 48โ€“61% of the time, so a pair's fate depends on how steadily the header was read. Any
change credited with moving the free share by less than about 2x is inside that spread.

`header_compared` and `header_differs` are that instability, read for free off the declines. The
denominator is `header_compared` and **not** `code_declined`, because a continued page that reprinted
no header has nothing to compare: such a line reports the two signatures as unequal โ€” one of them holds
no cells โ€” and counting it either way would be an invention. The test is `header_cells_first > 0 &&
header_cells_second > 0`, on the counts rather than on the signature text, because a header block of
two empty rows renders as the signature `" // "`, which is not an empty string; only the cell count
answers whether a half declared a header at all. So `header_compared: 0` under a run with
declines means *no pair could show this*, not *every header was stable*. It is also deliberately not
`code_declined` filtered to `header_differs`: that reason is one guard's verdict, and #326 watched the
width check and the id rule stand down on pairs that had joined for free a round earlier, so a pair
declined for `id_would_be_lost` whose headers also disagree is evidence of the same thing. A non-zero
`header_differs` is the expected state of a healthy run; what is worth reading is its direction across
rounds of the same corpus.

`editor_ceiling` is what happened to the correction rounds whose reply hit the model's output ceiling,
folded from [`editor_truncated`](#editor_truncated), [`editor_salvaged`](#editor_salvaged) and
[`editor_salvage_declined`](#editor_salvage_declined) (issue #317). It exists because that is the most
expensive thing the pipeline does by accident: the Copy Editor is around a third of a round's model
bill, and a truncated window costs about 5x one that fits โ€” the discarded whole-document attempt is
paid for in full, and then the remainder is asked for a section at a time. The salvage that recovers
part of that money reported itself only on those three log lines, so its hit rate meant reading
`log.jsonl` by hand.

Counts of **rounds, not documents**, on the same denominator as `model_calls`: a document reviewed in
three rounds can truncate three times, and the counts sum over every round the session has had. The
per-document reading is the [quality tally](#quality-tally-shared-secret-off-by-default)'s
(`editor_truncated_rate`, `editor_truncated_lost_rate`), and it is a different question โ€” that pair is
deployment-wide, and cannot say whether a truncation was rescued or refused, which reason refused it,
or whether a retreat happened.

- `truncated` โ€” rounds whose whole-document reply hit the ceiling. Every one of them reached the
  salvage, so it is the denominator for the rest.
- `salvaged` โ€” the reply was read as far as it got and that part **ships**. The prefix was corrected by
  the call that saw every block and every attached page image, and only the remainder was re-asked.
- `salvaged_closed` โ€” of those, the ones whose edits list had already finished (`closed`): a complete
  patch that hit the ceiling on its way out of the envelope. The cheapest shape this can take โ€” **one
  call and no sections, unless `retreated` also counted the same round**, where the claim was cut back
  behind the end of the document and the remainder was sectioned after all. So `salvaged_closed` alone
  does not say the round cost one call; read it against `retreated`, and see that field for why the two
  are not disjoint.
- `retreated` โ€” of those, the ones where a block before the cut gave content up, so the claim was cut
  back to it (`lost_at`). Not a cost signal: the retreat knowingly accepts a **duplicate**, because an
  edit carrying content backwards across the cut leaves the landing edit applied and the source block
  untouched, and a truncated round is the review loop's last round, so nothing downstream removes it.
  The remedy is a feedback re-run, which is a person's action โ€” which is what makes this the one field
  here worth an alert. Not disjoint from `salvaged_closed`: a complete patch can still be cut back, and
  then part of it is re-asked for anyway.
- `declined` โ€” the salvage kept nothing and the whole body went to the section fallback. Not a failure
  of the salvage; every one of these is a reply it was right to refuse.

`truncated - (salvaged + declined)` is **not always 0**, and the shortfall is real rather than an
accounting error. The salvage answers nothing at all for a truncation that returned no text (the
ceiling was spent before the reply began) or for an error that matched by message and lost its
prototype on the way. Neither writes a line, because neither is a reply there is anything to say
about, and a bucket filled from the absence of evidence would be worse than a visible gap.

`decline_reasons` splits `declined` over the seven refusals, and these **sum to** `declined` โ€” which is
why `unrecognized` is there rather than a silently short total as under `tables`: there is a published
total to check the split against, so a `reason` from a later build must be visible in it. The seven are
not interchangeable and pooling them would send a reader to the wrong remedy. `loss_before_cut` and
`all_refused` are the salvage working โ€” a reply whose corrections cannot be kept. `no_complete_edit` is
a document holding one block bigger than the ceiling, which is the case the section fallback exists
for. `no_edits_list` is a prompt that was not followed, `out_of_order` a reply not written in one pass
through the document, and `unknown_block` / `unreadable_edit` a reply that may not be about this
document at all.

Note `lost_at` appears on a `loss_before_cut` decline as well, and a decline is deliberately **not**
counted in `retreated`: nothing was applied there, so no duplicate can have shipped. No share is
published for any of this, for the reason `truncated` is small โ€” and what the ceiling cost is already
here rather than restated: `by_step.edit` is the whole-document attempts including the discarded one,
and `by_step.edit_section` the fallback calls it bought.

`fidelity_observed` sits outside `verification` because it is not part of that loop and does not
gate anything: it is what the **Copy Editor** noticed about a page it happened to be looking at,
folded from [`editor_fidelity_observed`](#editor_fidelity_observed). Everything under `verification`
is the one fidelity check each page gets, and that check's weakness is structural rather than a
matter of rate โ€” the verifier is the same model family looking at the same image as the transcriber,
so a page whose text it misread once it can misread twice, and a page it declared blank it will
declare blank again. Nothing else in the run had standing to disagree. The Reader never sees a
source image; the editor does, for the pages the Reader's issues name, and now has a field to say so
in (issue #183).

So read this as **evidence, not a rate**: the denominator is "pages an unrelated issue happened to
attach an image for", which is not a sample of anything, and `observed: 0` on a run means nobody
noticed something in passing, not that the document is faithful. What it is good for is the
direction of a disagreement between the two โ€” `kinds` uses the same five as `verify_kinds` on
purpose, so a run whose editor reports `content_missing` on pages whose VERIFY passed is saying the
check missed content, which is the failure mode no count in `verification` can see.

`pages` is the distinct pages observations were filed about, so one page reported in three rounds is
one page and three observations; `observed` is the observations. `unattached` and `unplaced` are the
ones to discount first โ€” an observation about a page whose image was not attached is a guess about a
page the model could not see, and one that named no page cannot be checked at all. `pages` includes
the guessed pages, because it is where a person should look and a guess that turns out to be right
is worth the look; `unattached_pages` is the subset the editor could **not** see, so the difference
between the two is the set that was backed by an image in front of the model.

Attachment is judged per round, so a page attached in round 1 and reported in round 2 without its
image counts as a guess โ€” and a log line that does not say what was attached puts its pages in
`pages` and none in `unattached_pages`, leaving its own `unattached` count as the only statement
that some were guesses.

`untagged` in `kinds` is the usual companion: an observation whose kind this version does not
recognize is counted there and in no other bucket, and the kinds are not a partition, so read each
against `observed`. None of this changes the delivered document โ€” an observation is addressed to a
person, and acting on one would mean re-extracting that page.

`pages_failed` is the set of source pages the delivered document has no content for, because their
own extraction threw ([Partial documents](#partial-documents)). It has its own field
because a run that reaches `ready_for_review` without one of its pages is otherwise
indistinguishable here from one that delivered the whole document: the failed model call
underneath shows up in `errors` exactly as a retried-and-recovered one does, and `status` says the
run succeeded โ€” which it did, on 24 of 25 pages.

It reports the document's current state, not the session's history: a session's log accumulates
across feedback rounds, so a page lost in round 1 and re-extracted in round 3 (`page_recovered`)
leaves this list, while one that failed again is still in it.

`pages_blank` is the other reason a source page contributes nothing to the document, and the
opposite one: the agent read the page and reported it empty ([`page_blank`](#page_blank)). Kept
apart from `pages_failed` because what a reader should do about the two is opposite โ€” a failed
page is work to redo, a blank page is work already finished โ€” and because the alternative was
measured: six of 100 bench pages were blank versos reported as lost source pages, which made three
of four documents read as partial when all four were complete (issue #179). Nothing is subtracted
for them: `images` on [`run_start`](#run_start) counts source images, blank ones included, so
`images - pages_blank.length` is how many produced markup. The two sets are disjoint, and follow the
document the same way โ€” a page
that failed in round 1 and came back blank in round 3 has been answered, so it leaves
`pages_failed` and arrives here.

`pages_bare_html` is a third state, and unlike those two it is not about a page that contributed
nothing: these pages contributed their content and not their `log`. The reply was markup rather
than the envelope, so it was rescued as it stood ([`page_bare_html`](#page_bare_html)) and carried
no `"log"` field at all โ€” and `agents/page.md` discharges six kinds of obligation in that field
and nowhere else, so on these pages a mid-sentence cut, an orphan heading, an unkeyed symbol, a
placeholder image source, a language change and an irregular table all go unrecorded while the run
reports every page delivered. It was 13.7% of pages across four deployed rounds of one PDF (issue
#349). Named for the reply's SHAPE rather than for the consequence, because that is the narrower
claim: an enveloped reply that merely leaves `"log"` empty also has no log and has a different
remedy, and is not counted here โ€” though over 67 round logs on file that shape is 0 of 2,320 page
replies, so today this field is the whole population of pages with no log. It follows the document
like the other two: a page re-extracted with a proper envelope has a log and leaves the set, one
that came back bare stays, and a round that threw keeps the prior fragment and so keeps the page.
No one page is ANSWERED two of these ways: a blank declaration needs an envelope with a `log`
asserting the page is empty, and a page that failed has no fragment of its own at all. The sets
are not quite disjoint as memberships, though, and the exception is worth knowing before you
subtract one from another: `page_bare_html` is emitted while the page is being rendered, and the
verify call that follows is unwrapped, so a bare page whose verifier takes a provider error is in
`pages_bare_html` and `pages_failed` both. Nothing on file has done it.

## Partial documents

A page's extraction can fail on its own (a model call that hits the output ceiling, a stalled
stream, a reply with no readable HTML in it โ€” `page_no_output`). That page fails; the run does not. Every other page is still rendered, verified,
assembled and reviewed, and the document is delivered.

A page that carries nothing is **not** one of these. A blank verso is a page the agent can answer
completely, and it answers with a `log` line saying the page is blank and an `html` holding nothing a
reader receives โ€” an empty string, or, in 33 of the 78 such replies in the bench logs, a comment, an
empty paragraph or a bare page-break marker (`page_blank`, `pages_blank`, issue #219); that page
contributes nothing to the document because there was nothing on it, the document is whole, and none
of the markers below are written for it. A page whose only printed content is its own number is such a
page: the folio is the one thing on a sheet this pipeline never delivers, so a page that prints nothing
else has nothing on it a reader receives (issue #222).

**Unless no page produced any content.** Then the run ends `failed` โ€” a document containing none of
the source's words is not a partial success, and an error is more use than an empty file. Where
every page failed, the `error` is the first page's own provider error, which names the ceiling and
the knob to raise. Where the pages were reported **blank**, it says how many of them were: a source
whose every page is empty is a statement about the source, and delivering `<main></main>` from a run
reporting success would leave that unsaid. `GET .../output` answers `409`, as it does for any failed
run.

The failed page is **not** silently dropped. Two comments say so, in different places and for
different reasons:

```html
<main>
<!-- @page-failed 7: bedrock: response hit the 32000-token output ceiling and was truncated (87851 chars returned). ... -->
</main>
<!-- @page-failed 7
  This document is incomplete: the source pages above could not be extracted and
  none of their content is here. See the run log (page_extraction_failed) or the
  session's diagnostics (pages_failed) for why.
-->
```

The one **inside** `<main>` sits where the page's content would have been, so it says *where* the
hole is โ€” but it is part of the body handed to the Copy Editor each review round, and a round that
rewrites the document may drop it. The one **after** `</main>` is injected once the review loop is
finished, out of the editor's reach, so the document cannot end up claiming to be whole. Comments
rather than visible prose, for the same reason as `@unresolved`: everything visible in a delivered
document is meant to be text that was on the page. Both are invisible to a reader, inert to axe and
to the screen-reader flattening, and findable by tooling.

Three places report it, in increasing order of convenience: `page_extraction_failed` in the run log
per page, `failed_pages` on the `run_complete` line (present only when there were any), and
`pages_failed` in [diagnostics](#diagnostics-timing--hang-detection). A client that cares whether it
received a whole document should check the last of those, not `status`.

On a feedback re-extraction the same failure is non-destructive instead: the page keeps the content
it already had, because a page Iris could not improve is not a page it lost. That case is
`page_extraction_failed` with `kept: "prior"`, and it is deliberately **not** counted in
`pages_failed` or `failed_pages` โ€” the document is whole, it is just not improved. Look for it in
`reextract_complete.failed`.

A missing page stays missing across feedback rounds, and every round's document says so: the set is
a property of the document, not of the run that lost the page. Sending feedback that names the
failed page is what fixes it โ€” re-extracting it successfully removes it from the set and logs
`page_recovered`. Nothing else does, so `failed_pages` on round 3's `run_complete` still names a page
lost in round 1 if it is still not there.

Pages missing from the delivered document are also excluded from the regression fixtures captured on
`POST .../close`: a fixture asserts that some HTML is the *right* output for a page image, and a
failure comment is not.

### A page that is here and is known to be wrong

The opposite failure, and the one Iris knows the most about and used to say the least about (issue
#328). These pages **are** in the document: they were rendered, the fidelity check rejected them
naming what was wrong, one self-correction pass was bought, and it repaired nothing. So what those
pages carry is content Iris named a defect in and never fixed. Not necessarily the rejected bytes:
the marker is written after the review loop, and the Copy Editor may have rewritten a block on one
of these pages since. What no later round can have done is **answer the check** โ€” nothing after
extraction asks whether a page is faithful to its source, and the editor, which is the one step
that does see a source image after that point (for the pages the Reader's issues name), is told to
*report* a discrepancy it notices rather than edit from one reading of an image
([`editor_fidelity_observed`](#editor_fidelity_observed)). The rejection therefore stands whatever
the markup became, and only a re-extraction can lift it.

```html
<!-- @page-uncorrected 5, 31
  The content of the source pages above IS in this document, and it never passed Iris's
  own fidelity check: the check named what was wrong with each of them, one correction
  pass was made against the source image, and it repaired nothing. No later step checks a
  page against its source again, so nothing after that point can have put right what the
  check named. ...
-->
```

Written once after `</main>`, out of the Copy Editor's reach, like `@page-failed`. It is the more
useful of the two declarations, because a page with no content is obviously incomplete to anyone who
opens the file while a page whose statistical table lost its six aggregate rows looks finished and no
longer adds up.

The correction ends without repairing the page in five ways, and for the delivered document they are
one fact โ€” the page the verifier named problems in โ€” so the marker does not distinguish them. The
log does: `page_correction_failed` is the call that threw, and `page_corrected`'s `result` is
`empty` (it answered with no HTML), `identical` (it answered with the page it was given, **or** with
a different string carrying the same page โ€” re-indented, or `&` written `&amp;` โ€” which is adopted
and is the fifth way) or `rejected` (its answer came back at under a quarter of that page's size and
was refused as a deletion). Which makes the rule readable off the log without a per-value table: a
page whose verdict failed is in this set exactly when its `page_corrected` `result` is **not**
`kept`. `page_verify_failed` on the same image says what was wrong. The set itself is on
`extraction_complete.uncorrected` (or `reextract_complete.uncorrected`) and, present only when there
were any, on `uncorrected_pages` on the `run_complete` line โ€” which is the one place it can be read
on **every** mode, because a feedback round that re-extracts nothing runs no extraction and so logs
neither of the other two. There is no diagnostics list of these pages โ€” `pages_failed` is the
no-content set and these pages are deliberately not in it;
[Diagnostics](#diagnostics-timing--hang-detection)'s `verification.results` and
`verification.triggers` count how many corrections ended each way without naming pages, so the run
log is where you go for the page.

It is **not** the claim "this page might be wrong". A correction the pass did adopt is not listed,
even though replaying the check over 57 corrected pages put their pass rate at 26% (issue #288): that
is a repaired page rather than the rejected one, the verifier rejects 71โ€“74% of first renders, and a
marker that fired on most of a document's pages would tell a reader nothing. What the **absence** of
this marker means is exactly that no page shipped as the fragment its own verifier rejected โ€” not
that every page was checked after correction, which is a sample (`page_correction_recheck`) and not a
gate.

Like a missing page, it follows the document across feedback rounds rather than the run: re-extracting
the page is the only thing that takes it out of the set, because re-rendering from the image is the
only way a page whose correction failed gets a second answer. And like a missing page it is kept out
of the regression fixtures captured on `POST .../close` โ€” accepting a session is a human saying the
*document* is good enough to close, which is not the same claim as this page being the correct output
for its image, and filing it would gate every future page-agent update on reproducing markup this run
had already declared wrong.

## List sessions

```bash
curl -s -H "$AUTH" "$BASE/sessions?limit=20"
curl -s -H "$AUTH" "$BASE/sessions?status=ready_for_review"
```
```json
{ "sessions": [ { "session_id": "ses_...", "status": "ready_for_review",
  "image_count": 2, "created_at": "...", "updated_at": "..." } ],
  "next_cursor": "2026-05-22T18:00:00.000Z|ses_01HXYZ..." }
```
`limit` is `20` by default and capped at `100`. Anything not an integer of at least 1 โ€”
`0`, negative, fractional, non-numeric โ€” is the default, not an error: one rule, so two
equally invalid values can't get page sizes differing by a factor of twenty.

**This lists the deployment's sessions, not yours.** There is one identity here, so every caller
who can reach this endpoint sees every session anyone uploaded โ€” including documents you did not
upload, and yours to whoever asks next. That is the cost of having no sign-in, and it is why an
operator handling anything private should gate the deployment with `server.api_token` and treat the
list as shared among everyone holding that secret.

Nothing here reveals a document's contents: a row is a `session_id`, a status, a page count and two
timestamps. But a `session_id` is all `GET /v1/sessions/{id}/output` needs.

If you only want your own work, keep the `session_id` that `POST /v1/sessions` returned; polling,
output, feedback and close all work with it and none of them needs this list.

Paginate by passing `cursor=<next_cursor>` **verbatim** โ€” it encodes both halves of the
sort key (`created_at|session_id`), because `created_at` alone is not unique: sessions
created in the same millisecond tie on it, and paging on a non-unique key skips and
repeats rows at page boundaries. Treat it as opaque; the shape is documented so a paging
bug is readable in a request log, not so clients can construct one.

"Verbatim" means the *value*, not the URL: **percent-encode it when you build the query
string** (`%7C` for the `|`). A raw `|` is not a legal query character per RFC 3986 โ€” curl,
browsers and Express all accept it, but a strict URI type (`java.net.URI`) or a strict proxy
will reject the request, and the error will not point back here. Use whatever your client
calls `--data-urlencode`; the examples below do.

`next_cursor` is `null` on the last page โ€” including when that page is full. Stop when it
is `null` rather than when a page comes back short.

**Send the cursor back byte-for-byte.** It is validated against the exact format the store
writes (UTC ISO-8601 with milliseconds), and anything else is a `400 invalid_request` โ€” not
a silent restart from page one. That includes values which are perfectly good timestamps
for the same instant: `2026-05-22T18:00:00Z` (milliseconds dropped) and
`2026-05-22T19:00:00.000+01:00` (an offset instead of `Z`) are both rejected, because the
cursor is compared as a *string* and either one sorts above every stored value. If you
round-trip cursors through a date type, you will reformat them; keep them as strings.

One exception, and the only case where a page can still lose rows: a cursor from *before*
this endpoint became compound is a bare timestamp with no `|`, and it is still accepted
rather than 400'd โ€” so a client paginating across the deploy that introduced the compound
cursor keeps working, but that one request skips any sessions tied on that timestamp.
It clears itself on the next page, since the cursor it hands back is compound. If a client
reports a gap during an upgrade window, this is why; re-listing from the start is the fix.

```bash
# Walk every page.
cursor=""
while :; do
  page=$(curl -s -H "$AUTH" --get ${cursor:+--data-urlencode "cursor=$cursor"} "$BASE/sessions?limit=50")
  echo "$page" | jq -r '.sessions[].session_id'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done
```

## Close the session (finalize + clean up)

Locks the output and deletes `tmp/<id>/`. Requires `status` = `ready_for_review` (else `409`).
Contributions are handled automatically during the run (see below), so close does not open PRs.

```bash
curl -s -X POST -H "$AUTH" "$BASE/sessions/$SID/close"
```
```json
{ "session_id": "ses_...", "status": "closed" }
```

## Contributions (automatic)

Every session gives something back, and there is no opt-out. This is why the API requires a GitHub
token at all.

Two things get filed as GitHub issues on the upstream repo, server-side during the run. Each is
identified by its title prefix rather than by a label, because GitHub silently drops labels set by a
filer without push access โ€” which is most filers here:

- **New agent suggestions** (`New agent suggestion: <type>`) โ€” when the extractor meets content a
  dedicated specialist agent would handle better than the general pass, Iris drafts that agent and
  files it with the code + context.
- **Agent improvements** (`Agent update proposal: <agent> โ€” <lesson>`) โ€” when your `/feedback`
  produces a change that generalizes beyond your document, and it survives the agent's regression
  fixtures. The title carries a short slug of the lesson as well as the agent, and the issue body
  carries the lesson, how many sessions have reported it, and your feedback verbatim.

Both are filed with the deployment's own token, not yours โ€” you do not have one, and what that costs
is spelled out at the end of this section. There is no PR/fork flow: `/close` returns no `prs_opened`
and requests accept no `skip_prs`.

Both dedupe against an open issue with the same title, found by searching GitHub โ€” a new agent
suggestion skips, an agent improvement **comments on the existing issue** with your session and the
updated proposal. The improvement path has to do more than skip because its title is not unique per
document: every proposal targets the same `page.md`, so before the lesson slug was in the title, one
open issue silently discarded every later lesson from every user for as long as it stayed open. A
lesson never disappears now โ€” worst case it lands as a comment on a related issue.

That search is the only dedupe, and GitHub's search index is not immediate โ€” two sessions that report
the same thing within a minute or two of each other can each file one. Deliberate: a duplicate costs
a maintainer one click, and hard-failing the check would cost you your document.

Filing never fails your run โ€” a contribution is a side effect, and a GitHub outage must not cost
you a document you already paid for. It is logged as `agent_issue_failed` instead, with a hint
naming the likely cause when the failure looks like a permissions problem (usually: the GitHub App
is not installed on `upstream_repo`).

**Everything is filed under the deployment's own account** (`github.token`) โ€” there is no per-user
identity to credit. So the issue says what your session found, and not who found it. If attribution
matters to you, file it yourself against `upstream_repo`; the issue Iris opens is a normal issue and
you can comment on it.

## Errors

All errors share one shape:
```json
{ "error": { "code": "invalid_state", "message": "Human-readable description", "details": {} } }
```
Common codes: `unauthorized` (401 โ€” see [Authenticate](#authenticate) for the three things it can
mean), `session_not_found` (404), `invalid_state` (409), `invalid_request` (400), `rate_limited`
(429, carries `Retry-After` โ€” see [Rate limits](#rate-limits-how-often-you-may-ask)),
`upload_too_large` (413), `server_error` (500).

A run that fails reports why in the `error` field of `GET /v1/sessions/{id}`. One worth
recognizing:

```
openrouter: response hit the 32000-token output ceiling and was truncated
(31998 chars returned). Raise providers.openrouter.max_tokens.
```

The model stopped at the output ceiling rather than at the end of its answer, so the HTML it
returned is cut mid-tag. Iris never assembles such a fragment โ€” a truncated page still parses, so it
would otherwise be delivered as though the missing content were never in the source. Which page it
costs and whether it costs the run is [Partial documents](#partial-documents): one page's failure
costs that page, and the run only ends `failed` (with this as its `error`) when every page failed.
Raise `max_tokens` on that provider block and re-run. Dense full-page tables and forms are the usual
trigger.

Two ceilings are **not** that one, and both say so in the same message rather than leaving the
advice above to be followed:
`That ceiling is <model>'s own, below the 32000 in providers.bedrock.max_tokens` is a model that
refuses the ceiling the deployment asked for and needs a different model rather than a different
setting ([`output_ceiling_clamped`](#model_call_start--model_call)), and
`That ceiling is this call's own` is a ceiling **Iris** asked for, on a call whose answer has a
size it can predict. Only the page-correction call does that today: it is handed a page and asked
to return it with named problems fixed, so it is capped at twice what the first pass of that page
spent (with a floor, `correctionCeiling` in `src/pipeline/extraction.ts`). Before the cap, one
such call ran to the full 32,000 tokens on a page whose render cost 6,233, and the reply was
discarded for being truncated โ€” a bill 5.13x the first pass for output nothing read. The remedy is
that caller's multiple, and `step` on the `model_call` line says which caller it was; raising
`max_tokens` moves nothing.

A **third** shape wears the same stop reason and is not a size problem at all:
`(0 chars returned)`, where the ceiling was reached with no reply written. That message carries
its own sentence too โ€” `No text was returned at all, so raising that ceiling is not the remedy:
look at the model's reasoning behaviour and at the size of what it was asked to produce. The
ceiling was spent before the reply began, and a larger one buys more of whatever consumed it.` A
response cut mid-document is an answer too long for its ceiling, and a larger one โ€” whichever of
the two above the call died of โ€” is the fix. Zero characters means the whole ceiling went
somewhere other than the text โ€” reasoning a model streams as its own channel, which Iris counts as
output tokens and not as reply ([`model_call`](#model_call_start--model_call)) โ€” so raising the
number is a bet that the thinking finishes inside the new ceiling, and a lost bet is billed for
the whole of the new one. One extract call in a 100-page benchmark round spent 32,000 output
tokens this way and returned nothing (issue #293). It is a reasoning model's failure, so the thing
to change is the model or the size of the request; on the two page paths the run log says which
shape it was โ€” `reply_chars: 0` is this one (`page_extraction_failed`, `page_correction_failed`).
Read that field and not the absence of `reply_head`: on `page_extraction_failed` the two say the
same thing, but on `page_correction_failed` `truncated` is a predicate over the error's message
and a bare `truncated: true` there has a second cause, which [that event's own
section](#page_correction_failed) spells out.

The order of the two sentences in that message is load-bearing, which is why it is quoted here whole:
a page's `@page-failed` marker carries only the message's first 300 characters, and the advice this
shape exists to withdraw โ€” `Raise providers.<provider>.max_tokens.` โ€” is inside that cut. So the
instruction has to lead and the explanation has to follow, or the operator reading a lost page's
marker gets the advice without the take-back. `test/bedrock-output-ceiling.test.ts` pins it; the
margin is a handful of characters, so lengthening that sentence means re-measuring the cut.

A reply that arrives whole and still cannot be read is the neighbouring case, and reads as
`page agent returned no HTML (prose, 412 chars)` โ€” [`page_no_output`](#page_no_output), with the
`shape` that says which remedy applies. Where HTML did arrive and carried nothing a reader
receives, the same line says that instead โ€” `page agent returned no page in 19 chars of HTML` โ€”
because a comment or a bare page-break marker is not the model answering with no HTML, and the
first reading of these reported the reply's whole length under a message that said none of it was
markup. It costs the same as the ceiling does (that page, not the run) and for the same reason: a
page whose content is a JSON envelope or an apology is a document that lies about being complete,
which is worse than one page short and saying so.

The same ceiling reached by a **correction** round is contained differently, because there the whole
document is what did not fit: whatever the reply managed before the cut is applied and the part it
never reached is re-made a section at a time, the loop then stops, and the delivered document says
so in an `@editor-truncated` comment ([Fetch the HTML output](#fetch-the-html-output), and
[`editor_truncated`](#editor_truncated) / [`editor_salvaged`](#editor_salvaged) /
[`editor_sections`](#editor_sections)). The remedy is the same knob.

## Prove it works

```bash
./test/e2e.sh      # boots mocks + Iris, runs all of the above via curl, asserts each step
```

This is the same script CI runs on every PR, and its output is handed to the automated reviewer as
evidence rather than re-run by it (see
[Automated code review](ci.md#automated-code-review)). So a change that breaks a request or
response documented above surfaces as a blocking review finding quoting the failure โ€” which is why
this file is in the reviewer's scope: the API docs are part of the contract, and docs that now
contradict the code are treated as a real defect.