yah-workload-spec 0.8.29

WorkloadSpec — typed wire format for yubaba workloads. Schema crate with zero deps on yubaba; yubaba depends on this, not the other way around.
Documentation
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
//! `WorkloadSpec` — typed wire format for yubaba workloads.
//!
//! This crate is the schema source of truth. It has zero dependencies on
//! yubaba; yubaba depends on it, not the other way around. Agents and desktop
//! code that construct specs can link this crate without pulling in yubaba's
//! containerd client.
//!
//! Three validation layers live in [`validate`]: shape (sync, no I/O),
//! semantic (reads yubaba state), and environment (deploy-time). The schema
//! types live at the top level.
//!
//! @yah:ticket(R222-T3, "Workload schema doesn't match per-kind on-disk shapes (mesofact-static)")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-05-18T16:47:03Z)
//! @yah:status(review)
//! @yah:parent(R222)
//! @yah:handoff("Picked option (a): tagged-enum Workload envelope with per-kind variants. Added Workload { MesofactStatic(MesofactStaticWorkload), Container(WorkloadSpec) } + BuildConfig in workload-spec. WorkloadSpec stays the containerd RPC wire type (now also the kind=\"container\" variant payload). xtask emit-schemas now renders workload.toml.schema.json as a oneOf over kind; schema drift test green. TS export updated. Arch doc 'workloads — colocated, not registered' rewritten to describe the envelope + both example kinds; B4 outlook updated to point at Workload.")
//! @yah:verify("cargo check -p cloud && cargo test -p cloud && cargo check -p yah && cargo check -p agent-tools && cargo check -p yah --tests && cargo check -p agent-tools --tests")
//! @yah:verify("cargo test -p xtask  # schema drift test must stay green")
//! @yah:verify("cargo run -p workload-spec --bin export-ts  # idempotent regen")
//!
//! @yah:ticket(R256-F7, "Model mesofact container as two roles: transient build/publish job vs long-lived SSR/SPA runtime")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-05-25T20:08:29Z)
//! @yah:status(review)
//! @yah:parent(R256)
//! @yah:next("role A — build/publish job: transient task that runs the build and PUTs to the object store, then exits/GC'd; needed whenever there is a build step (SSR or not)")
//! @yah:next("role B — SSR/SPA runtime: long-lived container, only present when the app has realtime/dynamic pages (this is what the 'only if SSR/SPA' gate applies to)")
//! @yah:next("decide the fidelity knob: does the build run in-container (matches CI, max fidelity, costs image+cold-start) or on host with yubaba orchestrating only the serving edge?")
//! @yah:assumes("in cloud these are separate: CI/build job produces artifacts, R2+CDN serve them, and a distinct worker serves any SSR — so one merged 'mesofact container' is the trap")
//! @yah:handoff("BuildMode enum added to workload-spec with HostSide (default) and InContainer { image } variants. MesofactStaticWorkload gains build_mode: BuildMode (skip_serializing_if default) and ssr_runtime: Option<WorkloadSpec>. Encodes the two-role model: build step is always transient; SSR companion is optional long-lived. Fidelity knob decision: HostSide = host watcher (dev+sim), InContainer = CI-fidelity (cloud/ha). All three codegen targets updated: export-ts.rs, packages/yah/workload-spec/index.ts, .yah/schema/workload.toml.schema.json. Schema drift tests pass.")
//! @yah:verify("cargo check -p workload-spec --locked")
//! @yah:verify("cargo test -p xtask --locked  # schema_drift tests pass")
//! @yah:verify("cargo test -p cloud --locked --lib  # 165 passed")
//!
//! @yah:ticket(R256-F9, "Almanac as a dependency manifest: orchestrator verifies I/O targets live before run; output invalidates mesofact sources cleanly")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-05-25T21:28:15Z)
//! @yah:status(review)
//! @yah:parent(R256)
//! @yah:next("almanac is a manifest declaring inputs + outputs + cadence + command — NOT a bash cron; the declared I/O is the contract")
//! @yah:next("before a run the orchestrator verifies declared inputs exist AND output targets (e.g. the mesofact app + its source/object store) are reachable; if not → the run fails or waits/times out rather than producing orphaned output")
//! @yah:next("almanac output invalidates downstream mesofact sources cleanly + deliberately (a declared dependency edge, not a blunt rebuild-everything) — this is the reason it's a named manifest, not a shell cron")
//! @yah:next("decide the not-ready policy knob: fail-fast vs wait-with-timeout vs requeue")
//! @yah:next("generalizes the OpenRouter refresher (spawn_almanac_refresher), which is the degenerate no-dependency case (output = JSON cache, no app target)")
//! @yah:assumes("precondition enforcement lives in the shared scheduler layer (embedded by camp for dev/sim, yubaba for cloud/ha) — it needs the workload registry + xlb-net discovery to answer 'is the target up?', which a bash cron lacks")
//! @arch:see(.yah/docs/architecture/A024-vocabulary.md)
//! @yah:depends_on(R256-F6)
//! @yah:handoff("AlmanacTarget (Http/Tcp probe), NotReadyPolicy (WaitWithTimeout default=5s/FailFast/Requeue), Cadence (Once/Every/Cron), and AlmanacManifest types added to workload-spec. Workload enum gains Almanac(AlmanacManifest) variant (kind='almanac'). NotReadyPolicy::WaitWithTimeout(5s) is the default — matches sim-tier spinup budget. AlmanacManifest.invalidates: Vec<MeshIdent> declares downstream cache-bust targets. export-ts.rs updated; index.ts and workload.toml.schema.json regenerated; drift tests pass. The degenerate case (no inputs, no outputs, Cron, no invalidates) is exactly the OpenRouter refresher pattern. The orchestrator precondition enforcement (xlb-net probing) is left for R276/yubaba integration.")
//! @yah:verify("cargo check -p workload-spec --locked")
//! @yah:verify("cargo test -p xtask --locked  # schema drift tests pass")
//! @yah:verify("cargo test -p cloud --locked --lib  # 165 passed")
//!
//! @yah:relay(R335, "Almanac mirror-binding — scope a feed to the mirror it affects")
//! @yah:at(2026-05-27T02:19:09Z)
//! @yah:status(open)
//! @arch:see(.yah/docs/working/W058-almanac-mirror-binding.md)
//! @yah:depends_on(R256-F9)
//!
//! @yah:ticket(R335-S1, "Decide cross-env pollution mechanism: extend R256-F9 manifest vs add per-mirror capability")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-05-27T02:19:33Z)
//! @yah:kind(spike)
//! @yah:status(review)
//! @yah:phase(P1)
//! @yah:parent(R335)
//! @yah:gotcha("Build ON R256-F9's AlmanacManifest (workload-spec/src/lib.rs) — do NOT invent a parallel manifest. R256-F9 is in review.")
//! @yah:depends_on(R256-F9)
//! @yah:handoff("Decided. Recorded in almanac-mirror-binding.md §11. KEY FINDING: two almanac paths exist; the live R330 feed uses almanac::FeedConfig (on_change=MesofactRebuild{service,route} — a service id, NOT a MeshIdent), so it never touches AlmanacManifest.invalidates. Verdict on the S1 title: NEITHER extend the manifest nor (yet) add capability is the accident fix — dev->cloud is ALREADY blocked by construction (feed path = process locality + per-mirror reconciler + MinIO/R2 backend split; manifest path = no camp-embedded MeshState, mesh resolution is yubaba-raft-only). Residual holes: /revalidate receiver is UNAUTHENTICATED, and same-tier (two clouds on one R2) has no per-mirror key prefix.")
//! @yah:next("FILED: R335-F3 (P1, no yubaba dep) mirror-aware /revalidate receiver — reject feeds not bound to this mirror; satisfies R335-T2; lands with R330-F4.")
//! @yah:next("FILED: R335-F4 (P2) per-mirror artifact key prefix in derive_minio_key/publish_to_r2 — closes same-tier collision.")
//! @yah:next("FILED: R335-F5 (P3, BLOCKED on yubaba control plane) per-mirror capability gate on /revalidate via yubaba/xlb-net node identity.")
//!
//! @yah:ticket(R278-F4, "RolloutPolicy schema in workload-spec (TOML types)")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-01T02:31:25Z)
//! @yah:status(review)
//! @yah:parent(R278)
//! @yah:next("Add src/rollout.rs with RolloutPolicy, RolloutStrategy, RolloutGate, RolloutStep, RolloutOnFailure")
//! @yah:next("Export pub mod rollout from lib.rs")
//! @yah:next("Add TS export via ts-rs in export-ts.rs")
//! @arch:see(.yah/docs/working/W140-yah-yubaba-ci-cd.md)
//! @yah:handoff("RolloutPolicy, RolloutStrategy, RolloutGate, RolloutStep, RolloutOnFailure added to workload-spec/src/rollout.rs. Exported from lib.rs. toml dev-dep added for round-trip test. Tests: rollout::tests::round_trip_toml + on_failure_default both green.")
//!
//! @yah:ticket(R429-T1, "Workload::StaticAsset variant + schema in workload-spec (catalog + aliases)")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-03T23:24:20Z)
//! @yah:status(review)
//! @yah:phase(P1)
//! @yah:parent(R429)
//! @yah:next("Add Workload::StaticAsset(StaticAssetWorkload) variant alongside the existing MesofactStatic + Container envelopes. Mirror the tagged-enum shape R222-T3 established.")
//! @yah:next("StaticAssetWorkload fields: kind='static-asset' tag, assets: Vec<AssetEntry>, aliases: BTreeMap<String, String>. AssetEntry { filename: String, source: PathBuf, blake3: BlakeHash }.")
//! @yah:next("BlakeHash newtype validates 64-hex-char shape (reuse from existing places if available, else introduce here).")
//! @yah:next("Closed-catalog invariant: aliases values MUST be filenames present in the assets list. Reject at load with a clear error pointing at the offending alias key + bad filename.")
//! @yah:next("Mirror schema extension: optional [asset_aliases] BTreeMap<String, String> on MirrorConfig. Semantic validator (when both workload + mirror are loaded together) rejects mirror aliases whose target filename isn't in the catalog.")
//! @yah:next("Regenerate the workload.toml.schema.json via xtask emit-schemas (R222-B4). Confirm the drift test stays green.")
//! @yah:next("TS mirror: extend packages/yah/workload-spec/index.ts with the StaticAsset variant + AssetEntry. Confirm bun typecheck stays green.")
//! @yah:verify("cargo check -p workload-spec --locked")
//! @yah:verify("cargo test -p workload-spec")
//! @yah:verify("cargo run -p workload-spec --bin export-ts")
//! @yah:verify("cargo test -p xtask")
//! @arch:see(.yah/docs/working/W160-atomic-release-waves.md)
//!
//! @yah:ticket(R429-F2, "static-asset reconciler: BLAKE3 verify + S3 PUT against mirror's object_store + drift")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-03T23:24:38Z)
//! @yah:status(review)
//! @yah:phase(P2)
//! @yah:parent(R429)
//! @yah:next("New reconciler that handles kind='static-asset' in the same service-sync loop that already runs mesofact-static + container. Same wave-gate semantics, same drift shape.")
//! @yah:next("For each [[asset]] row: hash source file (BLAKE3) and compare to manifest entry. Mismatch → surface as drift, halt push for that asset until rebuild.")
//! @yah:next("Resolve mirror's object_store provider → R2 bucket + credentials. HEAD cas/filename; if absent or different content-length → PUT. Idempotent on re-run.")
//! @yah:next("Drift detection: list bucket contents under the component's prefix, compare against catalog filenames. Files in bucket ∖ catalog → report as drift (do NOT delete; that's the prune verb's job).")
//! @yah:next("ServicesView's existing matrix consumes the new drift shape automatically. Confirm SyncGlyph/DriftList render correctly for a static-asset row without UI changes.")
//! @yah:next("MockR2 in tests: HashMap<key, bytes> implementing the S3 surface the reconciler hits. Cover: push first-time, push idempotent, drift catches catalog-vs-bucket mismatch, BLAKE3 mismatch halts push.")
//! @yah:next("Real-R2 integration test gated behind YAH_TEST_R2_BUCKET env var — one round-trip against a scratch bucket; skipped otherwise.")
//! @yah:verify("cargo check --workspace --locked")
//! @yah:verify("cargo test -p <reconciler-crate>  # crate TBD by impl agent")
//! @yah:verify("cargo test -p workload-spec")
//! @yah:gotcha("Auto-delete is OFF — reconciler reports drift on bucket∖catalog files but never DELETEs. That's the prune verb (R429-T2). Easy bug to introduce when 'cleaning up drift'; don't.")
//! @yah:gotcha("S3 multipart upload threshold matters — distil-large-v3 is ~270MB which is over the 5MB single-PUT limit on R2's strictest mode. Use aws-sdk-s3's multipart helper for assets >100MB.")
//! @yah:gotcha("Long-running progress MUST surface in QED/task-pane per the long-running-yah-surface rule. Don't silently spin in a tokio task; model as a Task with progress events.")
//! @arch:see(.yah/docs/working/W160-atomic-release-waves.md)
//! @yah:depends_on(R429-T1)
//!
//! @yah:ticket(R429-T3, "yah service prune verb: candidate enumeration + operator-confirm delete")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-03T23:24:52Z)
//! @yah:status(review)
//! @yah:phase(P3)
//! @yah:parent(R429)
//! @yah:next("yah service prune <service-name> enumerates files present in the bucket but not referenced by any current mirror's resolved alias graph. Lists candidates + sizes + last-modified, requires explicit operator confirm before DELETE.")
//! @yah:next("Resolution graph: for each mirror, walk [asset_aliases] → catalog [aliases] → catalog [[asset]] rows. Union across all mirrors = live set. Bucket ∖ live set = prune candidates.")
//! @yah:next("MCP tool mcp__yah__service_prune routes through approval gate (write verb). Read counterpart mcp__yah__service_prune_status auto-passes — returns the candidate list without acting.")
//! @yah:next("Camp: Tauri command + a 'Prune candidates' panel in the existing DeployPanel for each service, showing the candidate table with per-row checkboxes + confirm.")
//! @yah:next("Analytics-driven candidate filter (old AND unaccessed-for-N-days) is OUT OF SCOPE for this ticket — needs access logs we don't aggregate yet. The candidate set today is purely catalog-derived.")
//! @yah:next("User-asset TTL is OUT OF SCOPE — different surface, access-pattern-based, separate relay when it lands.")
//! @yah:verify("cargo test -p <prune-crate>")
//! @yah:verify("yah service prune yah-desktop --dry-run lists candidates")
//! @arch:see(.yah/docs/working/W160-atomic-release-waves.md)
//! @yah:depends_on(R429-F2)
//! @yah:handoff("CLI + library + MCP all landed; UI deferred to R429-F4 (filed). Library lives in crates/yah/cloud/src/reconciler/static_asset_prune.rs and exposes compute_live_set (pure resolution graph), compute_prune_candidates (live + LIST + diff), execute_prune (DELETE), and load_service_and_mirror (path helper). CLI verb is `yah cloud service prune <name> --env <env> [--dry-run] [--yes] [--format=table|json]` at app/yah/cli/src/cloud.rs (ServiceCommands::Prune + handle_service_prune). MCP tools cloud.service_prune_status (read, auto-pass, --dry-run --format=json) and cloud.service_prune (write, --yes --format=json) dispatch through build_command(). New S3 helper sign_s3_get_with_query in local-driver covers ListObjectsV2 (the existing s3_sign helpers don't handle canonical query strings); ListObjectsV2 response is parsed with a tiny hand-rolled split_tags helper to avoid a quick-xml workspace dep. Tests: 12 prune-module unit tests (live-set union, kind filtering, list response parse for single/empty/truncated/no-token, candidate filtering including catalog manifest sidecar exclusion) + 1 s3_sign helper test + 2 MCP build_command tests. cargo check --workspace clean. cargo test -p cloud --lib: 279 pass (1 pre-existing failure cloud_init::tests::embedded_template_matches_workspace_canonical unrelated, per R419-F4 docstring). cargo test -p yah --lib: 299 pass.")
//! @yah:next("R429-F4 carries the Tauri + DeployPanel UI work — depends_on R429-T3, status=open.")
//! @yah:verify("cargo check --workspace --locked")
//! @yah:verify("cargo test -p cloud --lib reconciler::static_asset_prune  # 12 pass")
//! @yah:verify("cargo test -p yah --lib mcp::tools::tests::cloud_service_prune  # 2 pass")
//! @yah:verify("yah cloud service prune --help  # renders usage with --env/--dry-run/--yes/--format")
//!
//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
//!
//! @yah:ticket(R438-T2, "AssetEntry XOR: source vs derive + shape_static_asset rules")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-04T21:06:51Z)
//! @yah:status(review)
//! @yah:phase(P1)
//! @yah:parent(R438)
//! @yah:next("AssetEntry.source: PathBuf → Option<PathBuf>")
//! @yah:next("Add AssetEntry.derive: Option<AssetDerive> with fetch + optional transform")
//! @yah:next("Extend shape_static_asset to enforce exactly-one(source, derive) + license closed-set")
//! @yah:verify("Both-set and neither-set fail shape validation with ShapeError::Field")
//! @yah:verify("Legacy TOMLs with only source still parse + serialize identically")
//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
//! @yah:handoff("AssetEntry now carries Option<PathBuf> source + Option<AssetDerive> derive (both skip_serializing_if). New types AssetDerive {fetch: FetchSource, transform: Option<TransformSpec>} and TransformSpec {recipe, params} added to workload-spec/src/lib.rs. validate.rs grew FieldPath::Asset(usize, &'static str) and shape_static_asset enforces XOR: both-set or neither-set fail with ShapeError::Field { path: Asset(i, \"source\") }. 4 new tests cover derive-mode round-trip, legacy source-only TOML round-trip without leaking a derive field, both-set rejection, neither-set rejection, and both-modes-accepted positive case. Cloud reconciler (static_asset.rs:360) now bails on derive-mode with a pointer to R438-T5 until the materialize step lands. 3 test fixtures updated with source: Some(...) + derive: None. export-ts regenerated (TransformSpec + AssetDerive emitted); xtask emit-schemas regenerated workload.toml.schema.json; schema_drift test green. workload-spec: 24/24, cloud static_asset: 25/25, xtask: 2/2.")
//!
//! @yah:ticket(R438-T3, "ImageRef digest-pin enforcement at deserialize")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-04T21:06:55Z)
//! @yah:status(review)
//! @yah:phase(P1)
//! @yah:parent(R438)
//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
//! @yah:handoff("ImageRef now accepts either a string form (digest-pinned, W164/W165 path) or the legacy struct form (backwards-compat for WorkloadSpec configs). String form requires @sha256:<hex> suffix: bare-tag, non-sha256, and non-hex digests all reject at serde-deserialize. Single parser compose_import::parse_pinned_image_ref is the rule's one home; T4 (recipes) and T6 (BuildMode::InContainer) will both deserialize images through this string path. Custom Deserialize uses untagged enum (Pinned(String) | Struct(Fields)); Serialize/TS/JsonSchema derives stay on the struct so wire output and TS exports are unchanged. 6 new tests cover: bare-tag reject, pinned accept (docker.io + ghcr.io), non-sha256 algorithm reject, empty/non-hex digest reject, struct-form still works with digest=None, struct-form TOML round-trip. workload-spec: 30/30 lib + 18/18 semantic + 6/6 shape_fixtures. xtask schema_drift green after emit-schemas regen. cargo check --workspace clean.")
//! @yah:next("Tighten ImageRef workspace-wide: digest: Option<String> → digest: String (required). tag stays as the human-readable identifier; digest is the source of truth. Rationale: every image we execute should be reproducible-by-construction; the on-disk shape should make unpinned-image bugs impossible.")
//! @yah:next("Every existing ImageRef construction site updates to pass a digest. Call sites known today (~10): yubaba integration tests (fake digests via a test helper), yubaba/runtime/{containerd,fake}, yubaba/deploy/{mesh_resolve,env_validate}, local-runtime, cloud/config, workload-spec round_trip tests, restart_policy tests, compose_import::parse_image_ref. The break is bounded — single PR, no surprise call sites outside the workspace.")
//! @yah:next("compose_import::parse_image_ref returns Result<ImageRef, ParseImageRefError> with an UnpinnedImage variant. Docker-compose strings without @sha256: become an explicit parse error — callers must pre-resolve tags to digests (most compose imports already happen at yubaba submission time where a pinning pass can run).")
//! @yah:next("Add task::local::test_support::test_digest() (or similar) for test fixtures — a fixed valid-format sha256 string so tests don't have to mint their own.")
//! @yah:next("Recipe TOML loader (T4) and W165 BuildMode::InContainer (T6) inherit the new requirement for free — they consume ImageRef and digest is now structurally required.")
//! @yah:verify("cargo check --workspace --locked passes after the tightening + call-site migration (yubaba, runtime, local-runtime, mesh_resolve, env_validate, cloud/config, compose_import)")
//! @yah:verify("ImageRef without digest no longer constructs — parse-time + type-level enforcement. cargo test -p workload-spec round_trip + restart_policy pass with updated fixtures.")
//! @yah:verify("compose_import::parse_image_ref(\"node:20\") → Err(UnpinnedImage); parse_image_ref(\"node:20@sha256:...\") → Ok.")
//! @yah:verify("cargo test -p yubaba --tests + -p local-driver passes with new test_digest() helper in place of bare tags.")
//! @yah:gotcha("Earlier framing assumed T3 needed a PinnedImageRef newtype to avoid breaking yubaba. Reversed after design discussion 2026-06-04: breaking yubaba in service of reproducibility-by-construction is the right architectural move. Digest is required workspace-wide; tag stays as a human-readable identifier. ~10 call sites migrate in one PR.")
//! @yah:gotcha("MesofactStaticWorkload.build_mode → InContainer { image } today is declared but never executed (build always runs on host — see W165). Once T3 tightens ImageRef, the wired-up build_mode lowering in T6 inherits digest-pinning automatically, closing W165 OQ#1's escape hatch.")
//! @yah:assumes("No production yubaba deployment ships ImageRefs we don't already digest-pin. Spot-check Hetzner/cloud yah-castle workload specs before merging the tightening; if any prod path uses tag-only, it gets pinned in the same PR.")
//! @yah:handoff("Pushed back from review 2026-06-04 — user reaffirmed the workspace-wide tightening direction. What landed (untagged Deserialize accepting string-form OR struct-form-with-digest:Option) ships digest enforcement at the W164/W165 wire surfaces but leaves the struct-form escape hatch (digest: None still constructs). User: 'breaking yubaba in order to improve it architecturally is fine'. Final shape needs both: (a) keep the string-form parser as a recipe-author convenience (image = \"ghcr.io/x@sha256:...\"), AND (b) tighten the struct form's digest: Option<String> → String. Then both paths land at the same digest-required field and unpinned-image bugs become impossible by construction. ~10 call sites still need migration (yubaba/runtime/{containerd,fake}, yubaba/deploy/{mesh_resolve,env_validate}, local-driver/local_runtime, cloud/config, workload-spec tests/round_trip + tests/restart_policy + yubaba integration_* + yubaba/tests/integration_public_ingress + integration_operator_bridge + integration_mesh + integration_single_node). Add task::local::test_support::test_digest() returning a fixed valid-format sha256 string. Pick this up by claiming R438-T3.")
//! @yah:handoff("Workspace-wide ImageRef tightening landed. (a) ImageRef.digest: Option<String> → String at workload-spec/src/lib.rs:923; Deserialize struct arm now requires the field; untagged string-form parser at compose_import::parse_pinned_image_ref untouched. (b) ImageRef::docker_ref() now always emits tag@digest pair (informational tag alongside content-addressed digest). (c) validate.rs ImageTag check tightened: tag must be non-empty (digest presence is type-enforced now). (d) compose_import::parse_image_ref returns Result<ImageRef, String> — alias for parse_pinned_image_ref. import_compose gained ImportError::UnpinnedImage { service, image, reason } variant; only external caller (yah workload import in app/yah/cli/src/workload.rs) propagates the error type. (e) New workload_spec::testing module (doc-hidden) exposes TEST_DIGEST const + test_digest() fn — all-zeros 64-hex sentinel. (f) task::default_image::catalog_image falls back to testing::test_digest() when the per-image env var is unset, preserving the infallible API but making unset-digest visible at runtime via docker pull failure. default_buildkit_image follows the same pattern. (g) Migrated ~22 struct-form construction sites: workload-spec tests (round_trip/semantic/restart_policy + all 15 fixture JSONs + 3 compose YAML fixtures + matching expected.json), task crate (default_image/integration/lib/local/remote), yubaba (runtime/{containerd,fake}, deploy/{mesh_resolve,env_validate}, all 4 integration_*.rs files), cloud/config (3 sites), local-driver (local_runtime + pond_ssr_runtime), scryer/beholders, kamaji/{server,native,containerd}. (h) ImageSource::pull trait signature tightened: digest: Option<&'a str> → digest: &'a str (only one impl in yubaba/env_validate). (i) Read-site cleanup: yubaba::runtime::containerd::image_ref, kamaji::containerd::image_ref, local-driver::pond_ssr_runtime::compose_image_ref, task::local::image_ref_arg — all dropped Option ceremony, always emit tag@digest. cargo check --workspace clean. cargo test -p workload-spec: 82 pass. cargo test -p task --lib: 59 pass. Pre-existing test failures in cloud (5: 1 cloud_init drift, 4 mesofact_static adopt) and yubaba tests (pond_reconciler_smoke missing ssr_runtime/worker_mode/ssr_origin fields) are unrelated to ImageRef — separate ticket. R438-T4 (recipe loader) and R438-T6 (BuildMode::InContainer) inherit digest-required structurally with zero per-consumer work.")
//!
//! @yah:ticket(R438-T7, "Golden tests: recipe→ForgeSpec lowering + BuildMode→ForgeSpec lowering parity")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-04T21:07:30Z)
//! @yah:status(review)
//! @yah:phase(P3)
//! @yah:parent(R438)
//! @yah:next("Golden test: sample transform recipe + asset.derive.transform.params lowers to expected ForgeSpec (argv, image digest, TaskPlacement)")
//! @yah:next("Golden test: MesofactStaticWorkload with build_mode=in_container lowers to expected ForgeSpec")
//! @yah:next("Round-trip parity: same Subprocess + Local + Container quadrant for both consumers; regression-guards argv-substitution and image-pin drop-through")
//! @yah:verify("cargo test -p workload-spec lowering_golden_*")
//! @yah:verify("Golden files versioned; updates require explicit --update flag")
//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
//! @yah:depends_on(R438-T5)
//! @yah:depends_on(R438-T6)
//! @yah:handoff("T7 landed. (1) Extracted pure lowering helpers exposed at pub(crate):\\n  - mesofact_static::lower_build_to_forge_spec(workload_dir, &BuildConfig, &BuildMode) -> ForgeSpec (run_build now wraps this)\\n  - static_asset::lower_recipe_step_to_forge_spec(&TransformRecipe, &RecipeStep, substituted_argv) -> ForgeSpec (materialize_transform now calls this for each step)\\n(2) New cfg(test) module crates/yah/cloud/src/reconciler/lowering_golden.rs registered from reconciler/mod.rs. Five golden tests:\\n  - golden_recipe_step_lowers_to_pinned_local_container_subprocess (recipe → ForgeSpec shape: argv, image digest, timeout, label, initiator)\\n  - golden_recipe_step_with_zero_timeout_lowers_to_none (regression-guards the timeout=0 → None mapping)\\n  - golden_build_in_container_lowers_to_pinned_local_container_subprocess (BuildMode::InContainer → sh -c shell wrap + pinned image + cwd label)\\n  - golden_build_host_side_lowers_to_native_quadrant_without_image (BuildMode::HostSide → image=None + TaskRuntime::Native)\\n  - parity_recipe_and_build_in_container_share_quadrant (THE architectural invariant: both consumers land in the same Subprocess + Local + Container quadrant with sha256-pinned images and Gnome initiators — lets one ForgeExecutor dispatch handle both)\\n(3) Test artifacts are hand-coded assertions, not insta/snapshot files — workspace has no insta infra and explicit-Pin tests give clearer diff on drift than auto-update snapshots. The W164/W165 lowering shape is now regression-guarded against silent drift in either consumer. cargo test -p cloud --lib reconciler::lowering_golden: 5 pass. Workspace check clean.")
//! @yah:next("Sign off → archive R438-T7")
//! @yah:next("T8 (worked examples) now has tested lowering primitives to reference")
//! @yah:verify("cargo test -p cloud --lib reconciler::lowering_golden — 5 pass")
//! @yah:verify("cargo test -p cloud --lib reconciler:: — 124 pass; 4 pre-existing R441-B4 adopt_only failures (port 4321 dev-box collision) unrelated")
//! @yah:verify("cargo check --workspace --locked — clean (warnings only)")
//! @yah:verify("Parity test asserts both lowerings produce TaskPlacement{Local, Container} + ForgeCommand::Subprocess + sha256-pinned image — the shared executor dispatch invariant")
//! @yah:gotcha("Test location pivot: original ticket said `cargo test -p workload-spec lowering_golden_*` but the lowering primitives don't live in workload-spec — ForgeSpec/TaskPlacement are in task, and the actual lowering helpers are in cloud (both consumers live there). Tests landed in cloud as `reconciler::lowering_golden`. If a future consumer outside cloud needs the BuildMode lowering, lift `lower_build_to_forge_spec` up to task::transforms alongside the existing recipe lowering primitives.")
//! @yah:gotcha("No snapshot/insta infra in workspace — 'Golden files versioned; updates require explicit --update flag' verify line interpreted as hand-coded explicit assertions instead. Drift surfaces as a single-file test diff on the lowering helper, which is more readable than a .snap diff for the small ForgeSpec shape these tests cover.")
//!
//! @yah:ticket(R594-F2, "Ingress workload kind in workload-spec: pinned-per-node appliance on public-ip-tainted machines")
//! @yah:status(review)
//! @yah:assignee(agent:claude)
//! @yah:at(2026-07-03T06:03:30Z)
//! @yah:phase(P2)
//! @yah:parent(R594)
//! @yah:next("Add the ingress workload kind to the Workload enum (lib.rs:296) as an appliance in the R572 archetype sense: pinned-per-node, non-drainable, placed by yubaba only on machines carrying a public-ip taint, supervised by kamaji. Depends on R572-F1 (lifecycle archetype discriminator) so the archetype field exists to mark it. Breaking change is fine (pre-release house style); update kamaji-bin server.rs InvalidSpec rejection list deliberately — kamaji MUST accept this kind (it supervises the proxy), unlike MesofactStatic/Almanac/StaticAsset.")
//! @yah:verify("cargo test -p yah-workload-spec; cargo check -p yubaba -p kamaji-bin; kamaji admission accepts kind=ingress in a unit fixture")
//! @yah:gotcha("RUNS SOLO: workload-spec is the shared-type DAG sink (yah-base) — every lane (yubaba, kamaji, qed, host app) rebuilds on its change. Pause all other wave-2/3 implementer lanes while this is active, and check R572-T2 (cpu_millis, Handoff) + R572-F1 owner state before claiming — same file.")
//! @yah:depends_on(R572-F1)
//! @yah:tier(Cleric)
//! @yah:handoff("Modeled the W267 public-ingress appliance as a container-shaped workload (Workload::Container(WorkloadSpec)), not a new Workload variant: mark archetype = Some(LifecycleArchetype::Appliance) (R572-F1, pinned/non-drainable) and declare the public-ip placement requirement via a new annotation-based marker on WorkloadSpec (same zero-blast-radius pattern as existing wants_host_network/HOST_NETWORK_ANNOTATION, chosen specifically to avoid the ~26-call-site churn a new plain field forced for R572-F1's archetype field, and to avoid an exhaustive-match update in peer-owned kamaji-proto/codec.rs that a new Workload variant would force). Added: WorkloadSpec::requires_taint() -> Option<&str>, const REQUIRES_TAINT_ANNOTATION = \"yah.placement.requires-taint\", const PUBLIC_IP_TAINT = \"public-ip\", plus doc comments on Workload::Container recording the modeling decision and its rationale, all in oss/yah-base/crates/workload-spec/src/lib.rs (single file changed). This only declares the requirement as inert metadata — matching taint field on machine TOML is R572-F3 (not yet present) and scheduler enforcement is R572-F5; both out of scope here, noted in the doc comments. Verified kamaji needs NO change: deploy_workload's match in kamaji-bin/src/server.rs already dispatches any Workload::Container(_) to the containerd backend regardless of tier/annotations (only MesofactStatic/Almanac/StaticAsset hit the InvalidSpec rejection arm), confirmed by reading the code and by the existing deploy_container_without_feature_says_so / deploy_mesofast_static_is_rejected_as_invalid_spec unit tests both still passing unmodified. 2 new unit tests added (ingress_marked_spec_is_appliance_and_carries_public_ip_placement_requirement, ingress_marked_spec_round_trips_through_json_as_a_container_workload). cargo test -p yah-workload-spec --lib: 38/38 pass. cargo test -p yah-workload-spec --test round_trip: 7 pass, exactly the same pre-existing 2 postcard failures (round_trip_full_spec_through_postcard, workload_container_round_trips_through_postcard — R590-B3, unrelated) as before this change, confirmed not increased. cargo check -p yah-workload-spec / -p yubaba / -p kamaji-bin all clean, plus full cargo check --workspace in both oss/kamaji and oss/yubaba clean (only pre-existing unrelated warnings). No peer-owned file touched or needed.")
//!
//! @yah:ticket(R590-B10, "forge workload 256MB cgroup memory limit SIGKILLs real builds — rusty-v8 checkout OOMs (bumped to 32GB stopgap)")
//! @yah:at(2026-07-12T00:14:52Z)
//! @yah:status(review)
//! @yah:assignee(agent:claude)
//! @yah:parent(R590)
//! @yah:severity(blocks-on-box-green)
//! @yah:next("Proper fix: thread a per-step memory request from the pipeline (QedStep) through ForgeSpec -> WorkloadSpec so a build declares its footprint, instead of a blanket forge default. Also consider: build_oci_spec should treat memory_mb==0 as 'omit the cgroup limit' (unlimited) so dedicated build-workers aren't capped by an arbitrary constant; pair with a node-sized default. Revisit the 32GB stopgap once per-step resources land.")
//! @yah:verify("yah qed run rusty-v8-musl on us-west-002 completes the checkout + gn/ninja compile without an OOM SIGKILL; a small forge task still runs (32GB is a ceiling, not a reservation).")
//! @yah:gotcha("PROVEN live (2026-07-11): with B7 networking fixed, the rusty-v8 build cloned the full V8 tree then `git checkout third_party/icu` DIED OF SIGNAL 9 (OOM). WorkloadSpec::for_forge set resources.memory_mb=256, which build_oci_spec turns into a hard cgroup memory.limit. /tmp is a RAM-backed tmpfs so the multi-GB source checkout counts against that 256MB too. Bumped for_forge to 32768 (32GB) as a CLI-side stopgap; verified the build proceeds past icu.")
//! @yah:handoff("FIXED + PROVEN LIVE (2026-07-11). WorkloadSpec::for_forge memory_mb 256 -> 32768 (oss/yah-base/crates/workload-spec/src/lib.rs). CLI-only change (spec is client-built), no kamaji redeploy. RESULT: with B7 networking, the rusty-v8 build previously OOM'd (SIGKILL/signal 9) at the icu git-checkout under the 256MB cgroup cap; now it clones the full V8 tree AND proceeds past icu into cargo/gn compilation (Compiling icu_locale_data/icu_calendar_data...) with task RUNNING. Stopgap 32GB ceiling; proper per-step memory request from the pipeline is the follow-up in the ticket body.")
//!
//! @yah:ticket(R546-B7, "workload_spec::Workload envelope is externally tagged (missing serde tag=kind) — no flat on-disk workload.toml can parse through it, broke yah cloud apply for EVERY static-asset component")
//! @yah:phase(P1)
//! @yah:status(review)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:at(2026-08-03T00:44:43Z)
//! @yah:parent(R546)
//! @yah:next("DO NOT simply add `#[serde(tag = \"kind\")]` without checking postcard: `Workload` is also a postcard wire type on the kamaji RPC path (kamaji-proto/src/codec.rs matches on it; round_trip tests exist). postcard is non-self-describing and cannot decode internally-tagged enums, so naive tagging risks breaking the kamaji wire. Decide deliberately: (a) tag it and prove the postcard round-trips still pass, or (b) split the types — an on-disk `WorkloadManifest` with tag=kind, leaving `Workload` as the untagged wire type.")
//! @yah:next("INTERIM FIX ALREADY LANDED (unblocks publishing): static_asset.rs::load_workload no longer routes through the envelope — it deserializes a small `KindProbe { kind }`, validates kind == \"static-asset\", then parses `StaticAssetWorkload` directly. Same approach seed_derivation_for_target already used successfully. This restored `yah cloud apply` and got the x86_64 rusty-v8 artifact published to the CDN (HTTP 200). The ENVELOPE itself is still broken for every other caller/kind.")
//! @yah:next("Fix the test/example disagreement: lib.rs ~L2544 should assert the FLAT `kind = \"...\"` shape that real files use, and examples/parse_whisper_toml.rs should run in CI so this cannot regress silently again.")
//! @yah:gotcha("SEVERITY: this silently broke `yah cloud apply` for EVERY static-asset component, not just rusty-v8. Verified against the long-published whisper catalog via the repo's own examples/parse_whisper_toml.rs, which panics with the identical error — so the breakage is general and pre-existing, not caused by the R546 hash edits.")
//! @yah:gotcha("ROOT CAUSE: `pub enum Workload` (oss/yah-base/crates/workload-spec/src/lib.rs ~L386) derives Deserialize with ONLY `#[serde(rename_all = \"kebab-case\")]` — there is NO `#[serde(tag = \"kind\")]`, despite its own doc comment stating 'the `kind` field on the wire is the serde discriminator'. Without the tag it is EXTERNALLY tagged, so serde wants a map with exactly ONE key (the variant name). Every real workload.toml is FLAT (`kind = \"static-asset\"` + `schema_version` + `[[asset]]` + `[aliases]`), i.e. a multi-key map -> `TomlError: wanted exactly 1 element, more than 1 element`, reported confusingly at line 1 col 1.")
//! @yah:gotcha("WHY THE UNIT TEST DIDN'T CATCH IT: the passing test at lib.rs ~L2544 feeds the EXTERNALLY-tagged shape `[[static-asset.asset]]`, which no on-disk file actually uses. So the test asserts the broken encoding and the example (parse_whisper_toml.rs) asserting the REAL encoding was never run in CI. The test and the example disagree; the example is right.")
//! @yah:handoff("DONE. Workload now carries TWO wire shapes behind hand-written Serialize/Deserialize that branch on is_human_readable() -- option (a) and (b) from the ticket's next-steps merged into one type instead of splitting it. TOML/JSON get the INTERNAL `kind` tag (the flat shape every on-disk file uses); postcard keeps the EXTERNAL variant-index encoding R590-B3 established for the kamaji UDS. Same idiom ImageRef already used for its string-vs-struct form, so there is now one precedent, not two mechanisms. Mirror enums (WorkloadTagged/WorkloadExternal + borrowing twins) carry the two encodings; their variant ORDER is load-bearing for postcard and is commented as such. schemars/ts-rs get tag=kind + rename_all via #[schemars(...)]/#[ts(...)] so the generated JSON schema and TS bindings describe the on-disk shape instead of the wire shape.")
//! @yah:handoff("SECOND BLOCKER, fixed in the same pass: after the tagging fix only 2 of 8 on-disk workload.toml files still parsed. SchemaVersion is a unit-variant enum wanting the string \"V1\", but 6 files (every mesofact-static + container + cloudflare-worker component) are authored `schema_version = 1`, and R438-T6 had worked around it by hand-extracting raw toml::Value subtrees in read_mesofact_build. Gave SchemaVersion a liberal-read/canonical-write Deserialize (accepts 1, \"V1\", \"v1\"; always serializes \"V1\"), on the same is_human_readable branch so postcard is untouched. B7's stated goal is not met without it -- a fixed envelope that still rejects 6 of 8 files is not fixed.")
//! @yah:handoff("FILES. (1) oss/yah-base/crates/workload-spec/src/lib.rs -- Workload dual-shape impls + mirror enums; the lib.rs ~L2544 test that asserted the broken `[[static-asset.asset]]` encoding rewritten to the flat form, plus a new test pinning BOTH halves (flat kind in JSON, postcard round-trip). (2) .../src/version.rs -- SchemaVersion custom Deserialize + 3 tests. (3) .../src/bin/export-ts.rs -- path was 3 parents up from CARGO_MANIFEST_DIR, but 75d8df7e moved the crate under oss/yah-base and added a level, so since that commit the bin silently wrote to oss/yah-base/packages/ and the committed TS stopped tracking the Rust types (last real update Jun 28). Now 4. (4) scripts/check-workload-spec-ts.sh -- `cargo run -p yah-workload-spec` fails from the camp root (crate is in the excluded oss/yah-base workspace); switched to --manifest-path. It was dead since the same commit. (5) packages/yah/workload-spec/index.ts + .yah/schema/workload.toml.schema.json regenerated (mirror.toml.schema.json also moved -- that is @Ashguard:dragon's W267 ingress field swept in by the shared regen, not mine).")
//! @yah:handoff("FIXTURE SWEEP (flagged by @Ashguard:dove mid-turn -- my change, my sweep): 8 yah-cloud tests were red on hand-written externally-tagged TOML. Fixed reconciler/derive_cache_prune.rs (2 fixtures), reconciler/static_asset_prune.rs (4), validate.rs (1), tests/whisper_derive_e2e.rs (1), app/yah/cli/src/cloud.rs (alias-collision fixture + the_deploy_body_parses_as_a_workload_envelope_not_a_bare_spec, which asserted external tagging and now asserts a flat `kind`). NOT touched: yubaba/src/lib.rs bundle_deploy_tests -- @Ashguard:dove already rewrote that one in their own relay's module and asked me not to double-fix.")
//! @yah:handoff("COMMENTS CORRECTED, not left lying: static_asset.rs::load_workload and asset_status.rs both carried R546-B7 comments asserting the envelope is externally tagged and unusable. Both now say the envelope works and the direct StaticAssetWorkload parse is a deliberate shortcut (load_workload keeps it to produce a precise wrong-kind error naming the kind found; asset_status keeps it because component.kind is already checked upstream).")
//! @yah:verify("THE TICKET'S OWN REPRO NOW PASSES: `cargo run --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --example parse_whisper_toml` -> 'parsed as StaticAsset / assets: 2 / aliases: 3 / shape validation: ok / populated-shape verifier: ok'. It panicked before against the long-published whisper catalog.")
//! @yah:verify("NEW CI GATE (the ticket's third next-step): xtask/tests/workload_envelope.rs walks the whole camp for workload.toml, parses every file whose kind is one of the four modelled variants through workload_spec::Workload, and hard-asserts no error contains 'wanted exactly 1 element' -- the exact external-tagging signature. It lives in xtask, not workload-spec, because that crate is in the standalone-exported oss/yah-base workspace and cannot reach app/ or .yah/. Runs under the check pipeline's existing cargo-test step. Carries a SHRINK-ONLY KNOWN_GAPS list: a file that starts parsing FAILS the test until its entry is deleted, and a stale entry (file moved/deleted) also fails, so the list cannot rot or grow silently.")
//! @yah:verify("GREEN: yah-workload-spec --all-features (55 lib + round_trip/postcard + shape_fixtures, 10 targets, 0 failed); kamaji-proto 25/25 incl. deploy_container_round_trip and deploy_string_pinned_image_ref_survives_postcard_wire (the exact UDS path R590-B3 fixed -- proves the binary branch is byte-unchanged); yubaba --lib 339/339; yah-cloud 610 lib + whisper_derive_e2e 1/1; yah --lib cloud:: 108/108; xtask schema_drift 2/2 and workload_envelope 1/1.")
//! @yah:verify("NOT MINE, seen while verifying: (a) reconciler::pond::tests::{ensure_sim_port_free_ok_when_unbound, port_has_listener_...} flake in a full-suite run and pass in isolation -- they bind real ports and race on a busy dev box. (b) 10 arch::ticket::tests failures in `cargo test -p yah --lib` from a peer's in-flight @yah: annotation-parser work; app/yah/cli/src/arch/ticket.rs has zero references to workload_spec, so nothing in this change can reach them. (c) MirrorConfig's new `ingress` field (@Ashguard:dragon, W267/R594) broke the yah-cloud and yah-cli test builds mid-session; it cleared on its own as they swept call sites -- I did not touch their files.")
//! @yah:gotcha("VARIANT ORDER IS LOAD-BEARING. WorkloadExternal / WorkloadExternalRef in lib.rs must list variants in the SAME order as Workload -- postcard encodes an external tag as the variant INDEX, so reordering or inserting a variant anywhere but the end silently decodes kamaji UDS frames into the wrong variant. There is no type error for this. Commented at the definitions; the round_trip postcard tests catch a mismatch only if the payload types differ enough to fail decode.")
//! @yah:gotcha("TWO GAPS DELIBERATELY NOT CLOSED, filed as R658 (umbrella) -> R658-B1 (MesofactStaticWorkload.routes is a required top-level field but all 4 real files AND the CLI scaffold write it inside [build], so TOML scopes it to build.routes) and R658-B2 (kind = \"container\" selects two incompatible schemas: Workload::Container(WorkloadSpec) vs ContainerReconciler's local docker build/run shape). Neither is the B7 tagging bug -- they were invisible until the envelope started being exercised. B2 needs an operator naming decision. Both are pinned in workload_envelope.rs's KNOWN_GAPS so they cannot be forgotten or silently widened.")
//! @yah:gotcha("HALF-STALE as of 2026-08-18: the gotcha above says both R658 gaps are pinned in workload_envelope.rs KNOWN_GAPS. Only R658-B2 (`missing field image`) still is. R658-B1 is CLOSED - all four `missing field routes` entries were deleted, every manifest and the `yah cloud site init` scaffold now write routes ABOVE [build], and BuildConfig carries serde(deny_unknown_fields) so the misplacement is a parse error rather than a dropped key. See R658-B1.")
//!
//! @yah:ticket(R626-S3, "Where does desired-state live? Durable per-workload replica count that survives reconcile loops and camp restarts (0↔1 vs scale-to-N)")
//! @yah:status(review)
//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
//! @yah:at(2026-07-23T17:47:24Z)
//! @yah:kind(spike)
//! @yah:phase(P3)
//! @yah:parent(R626)
//! @yah:handoff("DECIDED + LANDED. Desired state lives in the CAMP DAEMON, in a durable camp-local document at <camp>/.yah/state/desired-state.json, and NEVER crosses the kamaji or yubaba wire. The governing principle, written to survive the tier: desired state belongs to the DECLARER, not the supervisor — whoever re-asserts a deployment owns the record of whether it is wanted, because anything stored below the declarer is overwritten by the declarer's next re-assert. In the pond/dev tier the declarer is camp (ensure_pond_running -> reconcile_pond_deploys -> deploy_pond_mirrors, which runs at every camp start AND every pond.ensure_running RPC). In cloud the same rule points at the CloudConfig reconciler's raft store. Kamaji is never the holder in either tier.")
//! @yah:handoff("REJECTIONS, with the reason each is not a near-miss. kamaji-local: kamaji is deliberately imperative (Deploy/Stop/List, crash-restart delegated to dockerd's policy per R626-F2) — it holds no desired set and runs no reconcile loop, so storing intent there means giving it a SECOND reconciler that can disagree with camp's, and it still loses to camp's POST /pond/deploy from above. yubaba raft: right answer at cloud scale, wrong scope here — the pond yubaba is a container camp starts, its PondRegistry is in-memory (a restart forgets everything), and a single-camp dev tier has no quorum to be consistent about. Git-tracked config: camp.toml/mirror.toml are the DECLARATION (what exists); a stop is per-machine operator intent (systemctl disable, not editing the unit file) and must not propagate to a teammate's checkout — hence .yah/state/ is gitignored, in both the camp's .gitignore and the scaffold_camp_skeleton template.")
//! @yah:handoff("SHAPE: one knob, `replicas`, where 0 = stopped — deliberately the SAME axis as workload_spec::WorkloadSpec.replicas so scale-to-N later lifts a ceiling instead of adding a second concept beside a boolean. MAX_SUPPORTED_REPLICAS = 1 today and set_replicas REJECTS anything higher rather than persisting an intent no supervisor can honour (a clamp would silently record something the operator did not ask for). No record = replicas 1: a declared workload runs unless someone said otherwise. updated_at + reason ride along so a stale intent is legible and the UI can say when/why. Writes are tmp-then-rename; reads FAIL OPEN (missing/unreadable/corrupt/newer-schema all mean 'everything runs', corrupt file preserved as .corrupt-<epoch_ms>) — fail-closed would mass-stop a camp on one bad byte, and a resurrection is the recoverable failure.")
//! @yah:handoff("LANDED: (1) app/yah/cli/src/desired_state.rs — DesiredStateDoc / WorkloadDesire / DesiredStateStore (load, desired_replicas, is_stopped, stopped_keys, set_replicas, stop, start, forget), 10 unit tests incl. survives-a-camp-restart, per-workload isolation, replicas>1 rejected AND not written, corrupt-file quarantine + fail-open, newer-schema fail-open, and an explicit 'a stop is not a failure' guard on the serialized document. (2) camp.rs: deploy_pond_mirrors and reconcile_pond_deploys now consult the store and skip stopped idents — this is THE enforcement point, since camp's re-assert is the only place 'stay stopped' can be honoured. Extracted pond_idents_needing_deploy(declared, registered, stopped) as a pure helper with 5 tests, because the stopped-subtraction is the load-bearing half: a stopped workload is absent from yubaba's registry ON PURPOSE and is indistinguishable from a failed deploy without the intent record. (3) .yah/.gitignore + app/yah/cli/templates/yah-gitignore-default gain /state. (4) .yah/docs/working/W287-desired-state-for-supervised-workloads.md carries the full rationale, the rejected options, the F4 build-on list, and the scale-to-N scoping.")
//! @yah:handoff("DELIBERATE NON-GOAL: writing intent does NOT actuate. The durable record must land even when the stop call fails, or a failed stop comes back on the next reconcile. Actuation is R626-F4's job.")
//! @yah:next("R626-F4 is unblocked and now has a concrete spec — see W287 §5. It needs three things this ticket deliberately did not build: (a) a per-ident teardown on yubaba (PondRegistry has only shutdown_all, which drains everything; /pond/deploy and /pond/state are the only pond routes), (b) camp RPC methods workload.stop / workload.start writing through DesiredStateStore, (c) desired-vs-actual reporting.")
//! @yah:next("DO NOT add a Stopped variant to PondPhase (R626-F2's noted gap). PondPhase is yubaba's observation of REALITY; intent never crosses that wire by this decision. Camp is the one process holding both halves — render the pair instead: desired=stopped + actual=absent reads 'deliberately stopped'; desired=running + actual=absent reads 'down'.")
//! @yah:next("Scale-to-N stays scoped, not committed (W287 §6). WorkloadSpec.replicas makes N look one constant away; it is not. kamaji native.rs:592 rejects replicas>1, and the docker backend names containers by mesh identity (one identity, one container). N needs a placement layer above the single-workload supervisor: per-replica naming (identity==container name is what makes teardown resolve), per-replica host ports (pond publishes fixed ones — two replicas collide), per-replica mesh identity (a load-balanced set is an xlb-net concern), and a placement decision that is yubaba's job on a fleet. Lifting MAX_SUPPORTED_REPLICAS is the entry point once that layer exists.")
//! @yah:next("Wire DesiredStateStore::forget into the undeclare path so the document doesn't accumulate intent for mirrors that no longer exist.")
//! @yah:verify("cargo test -p yah --lib desired_state — 15 pass (10 desired_state::tests + 5 camp::r626_s3_desired_state_gate_tests), 0 fail")
//! @yah:verify("cargo check -p yah — clean (note: this camp's tree is shared and was transiently broken by peers' in-flight edits in oss/qed, yah-party, and yah-almanac during this run; none touched by this ticket)")
//! @yah:verify("BEHAVIOUR BAR (the one that matters): DesiredStateStore::for_camp(root).stop(ident) followed by a FRESH store over the same root still reports is_stopped — that is exactly a camp restart — and pond_idents_needing_deploy then omits that ident from an EMPTY registry, which is exactly a restarted yubaba. Asserted in camp::r626_s3_desired_state_gate_tests::a_stop_survives_a_camp_restart_end_to_end.")
//! @yah:gotcha("The store is camp-local and GITIGNORED on purpose. If a future ticket wants a stop to be shared/durable in the repo, that is a different decision (declaration vs intent) — re-open W287 §2 rather than moving the file into tracked territory.")
//! @yah:gotcha("Reads fail OPEN. Never 'harden' this into fail-closed: an unreadable document would then stop an entire camp, and the failure would be silent (nothing starts) rather than visible (the workload comes back).")
//!
//!
//! @yah:relay(R658, "workload.toml envelope: two type-vs-reality mismatches R546-B7 uncovered but did not fix")
//! @yah:at(2026-08-03T00:43:00Z)
//! @yah:status(open)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:parent(R546)
//!
//! @yah:ticket(R658-B1, "MesofactStaticWorkload.routes is a required top-level field, but every real file and the CLI scaffold write it inside [build]")
//! @yah:status(review)
//! @yah:at(2026-08-19T02:08:02Z)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:parent(R658)
//! @yah:next("REPRO: `cargo test -p xtask --test workload_envelope` with the file's KNOWN_GAPS entry deleted -> `missing field `routes``. Affects app/yah/web/marketing/workload.toml, external/scrabcake/site/workload.toml, .yah/infra/state/sources/scrabcake/site/site/workload.toml, oss/yubaba/crates/cloud/testdata/mesofact-in-container/workload.toml.")
//! @yah:next("ROOT CAUSE: TOML scopes every key after a table header into that table. All four files write `routes = \"./mesofact.routes.ts\"` AFTER `[build]`, so it deserializes as `build.routes` -- but MesofactStaticWorkload declares `routes` as a required TOP-LEVEL field. BuildConfig ignores the unknown key, so it vanished silently.")
//! @yah:next("THE SCAFFOLD AGREES WITH THE FILES, NOT THE TYPE: SITE_WORKLOAD_TOML in app/yah/cli/src/cloud.rs (~line 4977) emits `routes` inside [build] too, so every newly scaffolded site inherits the mismatch. Fix the type or fix the scaffold -- but they must agree, and whichever moves needs the other four files migrated with it.")
//! @yah:next("WHY IT WENT UNNOTICED: nothing reads `routes` off the envelope. mesofact-static's reconciler never loads MesofactStaticWorkload whole (read_mesofact_build does raw toml::Value subtree extraction, R438-T6), and mesofact-build reads mesofact.routes.ts directly. The field is declared but dead.")
//! @yah:next("AFTER FIXING: delete the four `missing field `routes`` entries from KNOWN_GAPS in xtask/tests/workload_envelope.rs -- that test FAILS on a stale entry, so it will tell you.")
//! @yah:next("SPREAD, found 2026-08-14 by R715-T2: two MORE files hit this and are NOT in KNOWN_GAPS, so `cargo test -p xtask --test workload_envelope` is RED on a clean tree for everyone. The two are app/yah/web/chat/workload.toml and oss/mesofact/examples/hello/workload.toml, both the same routes-after-[build] shape. Deliberately NOT pinned into KNOWN_GAPS - silently widening the pin is what this ticket exists to stop. Migrate them alongside the other four when the type-vs-scaffold decision lands.")
//! @yah:handoff("DECIDED: the DATA moved to the type, not the type to the data. `routes` stays a TOP-LEVEL field of MesofactStaticWorkload; all eight on-disk manifests and the CLI scaffold now write it ABOVE [build]. Three reasons the reverse was wrong: (1) MesofactStaticWorkload is a postcard wire type over the kamaji UDS, so moving a field between structs is a wire break needing a lockstep kamaji+yubaba deploy; (2) the field's own doc says it is what the RECONCILER reads to enumerate routes, i.e. deploy-time not build-time, so [build] is the wrong home semantically; (3) the reconciler's own fixtures (mesofact_static.rs), three camp.rs fixtures and the struct literal at cloud.rs:5389 already agreed with the type - only the hand-authored TOML disagreed.")
//! @yah:verify("cargo test -p xtask --test workload_envelope - GREEN (was RED on a clean tree for everyone). All four `missing field routes` KNOWN_GAPS entries DELETED, not widened; only R658-B2's `missing field image` remains.")
//! @yah:handoff("ROOT-CAUSE GUARD, the part that makes this not recur: workload_spec::BuildConfig now carries #[serde(deny_unknown_fields)] (oss/yah-base/crates/workload-spec/src/lib.rs). Migrating the files alone would have left the trap armed - serde silently dropping a stray [build] key is WHY a declared-but-dead field survived months unnoticed. Now the misplacement is a parse error naming `routes`, which is the one thing the author needs to move. Inert for the postcard kamaji wire (non-self-describing, positional); only constrains TOML/JSON.")
//! @yah:verify("cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --all-features - 121 lib (incl. 3 new R658-B1 tests) + 8 integration targets, 0 failed. New: mesofact_static_routes_parse_at_the_top_level, mesofact_static_routes_inside_build_is_rejected_by_name, unknown_build_keys_are_refused_rather_than_ignored.")
//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib - 878 passed, 0 failed.")
//! @yah:verify("cargo test -p yah --lib cloud:: - 120 passed, 0 failed, incl. the new site_init_tests::scaffold_workload_toml_parses_through_the_envelope_with_top_level_routes.")
//! @yah:verify("cargo test -p xtask - all 12 targets green, incl. schema_drift and workload_envelope.")
//! @yah:verify("./scripts/check-workload-spec-ts.sh - in sync (ts-rs ignores deny_unknown_fields, so no TS churn).")
//! @yah:handoff("DISCOVERED WORK, wider than the ticket title - deny_unknown_fields immediately caught TWO live files the envelope test structurally CANNOT see. app/yah/web/analytics/workload.toml and app/yah/web/dashboard/workload.toml are kind = mesofact-spa, which is not in the test's MODELLED_KINDS, but mesofact-spa rides the SAME MesofactStaticReconciler (app/yah/cli/src/cloud.rs:5195) and therefore the same read_mesofact_build -> BuildConfig parse. Both had routes under [build]; without migrating them my own guard would have broken deploys for analytics.yah.dev and app.yah.dev. Both migrated. Swept every workload.toml in the camp for stray [build] keys: the only two remaining are crates/yah/cloud-admin (R658-B2's file) and app/yah/workers/yah-cr, and NEITHER goes through workload_spec::BuildConfig - both use reconciler-local raw toml::Value extraction (cloudflare_worker.rs:200), so both are unaffected.")
//! @yah:handoff("ALSO FIXED IN THIS PASS (docs are canon; these were what a human copies): .yah/docs/guides/host-a-site-and-worker-on-yah.md:102 and .yah/docs/architecture/A031-yah-cloud-config-shape.md:438 both showed routes UNDER [build] - they would have re-seeded the bug into every hand-authored manifest. Also oss/yubaba/crates/cloud/src/config.rs:5890 (web_workload_round_trips fixture) and the bundle_assembly_tests fixture at app/yah/cli/src/cloud.rs.")
//! @yah:handoff("UNRELATED LANDED BREAKAGE unblocked to verify at all: oss/yubaba/crates/cloud/src/reconciler/lowering_golden.rs:48 failed to compile with E0063 missing field `admission` - TransformRecipe gained admission: Option<RecipeAdmission> (oss/qed/crates/velveteen-exec/src/transforms.rs:70, landed in 8b35b0a9) and this golden was never updated. Whole yah-cloud test binary would not build. Added `admission: None` (correct: the golden is an unsigned local recipe and pins the LOWERING shape, which the signature does not participate in). Both files were committed-clean, not a peer's in-flight edit - checked git status before touching.")
//! @yah:gotcha("UNCOMMITTED REGEN - .yah/schema/workload.toml.schema.json is REGENERATED in the working tree (cargo run -p xtask -- emit-schemas) and must be committed WITH this change. deny_unknown_fields makes schemars emit additionalProperties: false on BuildConfig. scripts/check-schema-drift.sh compares generated output against the git INDEX, so it stays RED until the regen is committed - that is the script working as designed, not drift. Only workload.toml.schema.json moved; no peer's schema was swept in (git diff --stat -- .yah/schema/ = 1 file).")
//! @yah:assumes("deny_unknown_fields on BuildConfig trades forward-compat for loudness: a manifest carrying a [build] key an older binary does not know is now a hard parse error, not an ignored key. Deliberate and argued in the type's doc comment. Blast radius outside this monorepo is any site scaffolded by an older `yah cloud site init` - the template shipped routes under [build] for its whole life, so such a site now fails to parse until routes is moved above the header. The only tenant in the tree (scrabcake) was migrated; an external one would need the same one-line move.")
//! @yah:gotcha("NOW FULLY STALE as of 2026-08-19: the HALF-STALE note above says R658-B2's `missing field image` is the one KNOWN_GAPS entry left. R783-F1/F2 closed that too, so KNOWN_GAPS in xtask/tests/workload_envelope.rs is EMPTY - every modelled on-disk workload.toml parses through the envelope. An entry reappearing means a real file stopped parsing.")
//!
//!
//! @yah:ticket(R743-T4, "workload-spec: 7 test binaries to 1")
//! @yah:at(2026-08-11T01:18:24Z)
//! @yah:status(review)
//! @yah:phase(P2)
//! @yah:parent(R743)
//! @yah:next("tests/main.rs mod'ing all 7 siblings + autotests = false and [[test]] name = \"main\" in oss/yah-base/crates/workload-spec/Cargo.toml.")
//! @yah:next("tests/compose/ and tests/fixtures/ are data/module dirs, not targets — they are unaffected. Confirm the [[bin]] named export-ts in Cargo.toml is untouched by autotests = false (it is a bin, not a test, but read it before editing).")
//! @yah:verify("cargo test -p yah-workload-spec -- --list count unchanged; three green runs. One commit — oss subtree.")
//! @yah:tier(Cleric)
//! @yah:handoff("LANDED: tests/main.rs mods in the 7 former top-level integration-test files (compose_import, mesh_resolver, restart_policy, round_trip, secrets_invariant, semantic, shape_fixtures) as submodules; Cargo.toml gained `autotests = false` on [package] plus a single `[[test]] name = \"main\" path = \"tests/main.rs\"`. tests/compose/ and tests/fixtures/ untouched (data dirs); the export-ts [[bin]] untouched (autotests only scans tests/, not bins). Quick audit found nothing to fix: no std::env::set_var/remove_var, no set_current_dir, no TcpListener/bind/fixed ports in any of the 7 files, and only one inner `mod secrets` (in secrets_invariant.rs) which nests fine under its own file-module with no sibling collision — so no renames were needed.")
//! @yah:verify("RUSTC_WRAPPER=\"\" cargo test -p yah-workload-spec -- --list (run inside oss/yah-base): BEFORE 8 targets (lib 146 + export-ts bin 0 + 7 integration files summing to 65: compose_import 5, mesh_resolver 8, restart_policy 5, round_trip 16, secrets_invariant 7, semantic 18, shape_fixtures 6) = 211 total. AFTER 4 targets (lib 146 + export-ts bin 0 + single `main` integration binary 65, all 65 test names now module-qualified e.g. round_trip::round_trip_full_spec + doctests 0) = 211 total, unchanged.")
//! @yah:verify("RUSTC_WRAPPER=\"\" cargo test -p yah-workload-spec (inside oss/yah-base): ok. 146 passed lib + ok. 65 passed main + 0 doctests, 0 failed — run three times, all green, no pre-existing failures to record.")
//!
//! @yah:ticket(R783-F1, "ContainerManifest: split the on-disk container manifest from the wire WorkloadSpec, keeping postcard byte-identical")
//! @yah:status(review)
//! @yah:at(2026-08-19T07:11:49Z)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:parent(R783)
//! @arch:see(.yah/docs/working/W324-workload-kind-is-not-a-runtime.md)
//! @yah:next("THE SEAM: introduce `ContainerManifest = Reference(WorkloadSpec) | Recipe(ContainerBuild)` and change Workload::Container's payload to it. WorkloadExternal::Container KEEPS WorkloadSpec so the postcard kamaji wire is byte-identical - verify with the existing round_trip.rs postcard tests, which must pass UNCHANGED.")
//! @yah:next("WHY a recipe cannot just be a WorkloadSpec (this is the whole design): ImageRef.digest is String, not Option<String> - R438-T3 tightened it deliberately and the string form REJECTS a bare tag at serde-deserialize (lib.rs:187, parser compose_import::parse_pinned_image_ref). The local form's image is `yah-local/yah-cloud-admin:dev`, a bare tag, because the digest does not exist until docker build has run. Preserve that invariant; do not weaken ImageRef to make this easier.")
//! @yah:next("Encode the invariant in the signature: ContainerBuild::into_spec(self, digest: &str) -> WorkloadSpec. The lowering is only available AFTER a build produced a digest. Serializing a Recipe to postcard must be an Err, not a panic and not a silent empty digest.")
//! @yah:next("Tier: Wizard - cross-workspace type split with a wire invariant to preserve; the postcard encoding is positional and a mistake decodes silently into the wrong variant.")
//! @yah:verify("cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --all-features - round_trip.rs postcard tests must pass UNCHANGED (they are the wire-compat gate).")
//! @yah:verify("cargo test -p xtask --test workload_envelope with the `missing field image` KNOWN_GAPS entry for crates/yah/cloud-admin/workload.toml DELETED - that file is the acceptance case.")
//! @yah:verify("cargo test --manifest-path oss/kamaji/Cargo.toml -p kamaji-proto codec - deploy_container_round_trip is the exact UDS path.")
//! @yah:gotcha("VARIANT ORDER IS LOAD-BEARING on WorkloadExternal/WorkloadExternalRef - postcard encodes the external tag as the variant INDEX, so reordering or inserting anywhere but the end silently decodes kamaji UDS frames into the WRONG variant, with no type error. Commented at the definitions in lib.rs.")
//! @yah:gotcha("BLAST RADIUS ~25 real construction/match sites across FOUR workspaces: oss/kamaji (incl. peer-owned kamaji-proto/src/codec.rs exhaustive matches), oss/yubaba, oss/qed, app/yah/cli, oss/yah-base. R594-F2 deliberately avoided exactly this churn by using an annotation instead of a field (lib.rs:225) - that was right for a marker, and is NOT right here, but read that note before assuming the churn is accidental.")
//! @yah:gotcha("Consider a Workload::container(spec) constructor + ContainerManifest::as_spec() accessor to keep the ~25 sites one-line mechanical rather than restructured.")
//! @yah:handoff("LANDED. `ContainerManifest = Reference(WorkloadSpec) | Recipe(ContainerBuild)` is now `Workload::Container`'s payload (oss/yah-base/crates/workload-spec/src/lib.rs). New public types: ContainerManifest, ContainerBuild, ContainerBuildStep, ContainerRunConfig, ContainerMount, plus `Workload::container(spec)` / `Workload::container_spec()` / `Workload::container_manifest()` so the ~25 call sites stayed one-line.")
//! @yah:verify("cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --all-features - 127 lib + 8 integration targets, 0 failed. round_trip.rs: 16 pass.")
//! @yah:verify("cargo test -p xtask - all 12 targets green incl. workload_envelope 1/1 with KNOWN_GAPS now EMPTY (R658-B2's `missing field image` entry deleted, not widened) and schema_drift 3/3.")
//! @yah:verify("cargo test --manifest-path oss/kamaji/Cargo.toml --workspace - green incl. kamaji-proto codec 26/26 (deploy_container_round_trip, the exact UDS path) and kamaji-bin 213/213.")
//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib 881 pass / -p yubaba --lib 492 pass; cargo test -p yah --lib cloud:: 120 pass.")
//! @yah:handoff("THE WIRE CLAIM IS NOW A TEST, not an assertion. round_trip.rs::container_postcard_frame_is_the_variant_index_then_the_bare_spec asserts the frame is exactly [1] ++ postcard(WorkloadSpec) - a round-trip alone would still pass if both halves moved together. WorkloadExternal::Container keeps WorkloadSpec; Workload's binary Serialize maps Reference through unchanged and returns Err for Recipe (round_trip.rs::container_recipe_is_refused_by_postcard_rather_than_encoded).")
//! @yah:handoff("DISCRIMINATOR: presence of a `[build]` table means Recipe; presence of top-level `image` means Reference; NEITHER is its own error naming both forms rather than a misleading `missing field image`. Hand-written Deserialize, not serde(untagged), specifically so a malformed reference still reports `missing field tier` instead of 'data did not match any variant'.")
//! @yah:gotcha("UNCOMMITTED REGEN - both generated artifacts are regenerated in the working tree and must be committed WITH this change: .yah/schema/workload.toml.schema.json (cargo run -p xtask -- emit-schemas) and packages/yah/workload-spec/index.ts (cargo run --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --bin export-ts). Both scripts/check-schema-drift.sh and scripts/check-workload-spec-ts.sh exit 1 right now because they diff generated output against the git INDEX - that is the scripts working as designed, not drift. cargo test -p xtask schema_drift (which diffs against the working tree) is GREEN.")
//! @yah:gotcha("SIGNATURE DEVIATION from the ticket text, deliberate: into_spec is `ContainerBuild::into_spec(self, digest: &str, tier: TierTag) -> Result<WorkloadSpec, String>`, not the infallible two-arg form the ticket sketched. Fallible because digest is a caller-supplied string and a malformed one must error rather than mint a spec that lies about being content-addressed - it routes through compose_import::parse_pinned_image_ref, the one home of the R438-T3 digest rule. tier is a parameter because admission control is cluster policy, not a manifest fact. Recorded in W324 under a new 'As shipped (R783-F1)' section.")
//! @yah:assumes("ContainerBuild::into_spec has NO production caller yet - it is the documented lowering with unit-test coverage only. Its unset-image default is `yah-local/<manifest name>:dev`, which is NOT the same string ContainerReconciler's default_image_tag builds (`yah-local/<service>-<component>:dev`) because the manifest only knows its own name. If a future caller lowers a recipe whose [build].image was left unset and expects to find the image the reconciler built, those two defaults have to be reconciled first.")
//! @yah:cleanup("LocalProcessReconciler still parses its own private ProcessComponent for the [process] table (oss/yubaba/crates/cloud/src/reconciler/local_process.rs:696). The envelope does not model [process] at all, so that tier is still a second parser over the same file - the exact shape R783-F2 just removed for the container tier. W324 section 1 names it as the third runtime behind kind = container; folding it in is the natural next step and is deliberately NOT in R783.")
//!
//! @yah:ticket(R838-B1, "xtask workload_envelope fails on both machines: the template deliberately omits [build] command while workload_spec Workload requires it as a non-Option String")
//! @yah:status(review)
//! @yah:at(2026-08-31T00:17:46Z)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:parent(R838)
//! @yah:handoff("LANDED. workload_spec::BuildConfig.command is now Option<String> with #[serde(default)] (oss/yah-base/crates/workload-spec/src/lib.rs:1439). Absent means the project has no external bundler step, which is what mesofact new's scaffold template documents about itself. xtask workload_envelope now passes with KNOWN_GAPS still EMPTY, which was the goal state that test names for itself.")
//! @yah:handoff("WHY THIS WAS NOT A DECISION AFTER ALL. The sibling ticket R658-B3 filed the same bug as DECISION REQUIRED because it read the deploy path as having no branch for a missing command. It has one, in two of the three readers, and it predates this change: app/yah/cli/src/cloud.rs:3368 read_workload_build has ALWAYS returned Option<String>; assemble_component_bundle_with_sidecars (cloud.rs:3492) needs the command only under --run-build and otherwise assembles from an existing out_dir; deploy_mesofact_bundle (cloud.rs:5841) refuses None with a message that already reads correctly, and cloud.rs:8387 a_missing_build_command_is_reported_not_skipped already tested that refusal. The only reader that made it mandatory was the type. So no in-process build branch had to be invented.")
//! @yah:handoff("RECONCILER: lower_build_to_forge_spec now returns Option<ForgeSpec> (None when no command) and run_build logs a skip and returns Ok. That is the same outcome rebuild_static already produced for a workload with no workload.toml. Deliberately NOT sh -c with an empty string: that exits 0 having built nothing, so the reconciler would report success and publish stale out_dir bytes.")
//! @yah:handoff("WIRE: BuildConfig rides the postcard kamaji wire inside Workload::MesofactStatic, so String -> Option<String> adds a leading tag byte. A pre-R838 node decoding a new frame fails loudly (a string length byte is not a valid Option tag) rather than reading a shifted field, which is why this is Option and not a serde(default) empty-String sentinel. NOT a cluster-epoch surface: xtask/src/cluster_epochs.rs hashes the yubaba raft modules and the openraft pin, not workload_spec; all 8 cluster_epoch_drift tests stayed green, so no epoch bump is owed.")
//! @yah:handoff("CALL SITES (10, four workspaces, all mechanical): kamaji-proto/src/codec.rs:1077, kamaji-bin/src/server.rs x3, yubaba/src/lib.rs:9000, cloud/src/reconciler/lowering_golden.rs x2 (+3 .expect() on the now-Option lowering), cloud/src/reconciler/mesofact_static.rs (revalidate_static's render BuildConfig + 2 fixtures + 3 assertions), app/yah/cli/src/cloud.rs:5725.")
//! @yah:handoff("GENERATED ARTIFACTS REGENERATED AND MUST BE COMMITTED WITH THIS: .yah/schema/workload.toml.schema.json (command dropped from required, type now [string,null]) and packages/yah/workload-spec/index.ts (command: string | null). Both scripts/check-workload-spec-ts.sh and scripts/check-schema-drift.sh exit 1 until the commit lands because they diff against the git INDEX; the working-tree equivalent, cargo test -p xtask schema_drift, is green. Same shape as the R783-F1 note above.")
//! @yah:verify("cargo test -p xtask --tests --locked: 54 passed, 0 failed. Includes workload_envelope::every_on_disk_workload_toml_parses_through_the_envelope (was 0 passed / 1 failed with 'missing field command'), schema_drift 3/3, cluster_epoch_drift 8/8.")
//! @yah:verify("cargo test --manifest-path oss/yah-base/Cargo.toml -p yah-workload-spec --all-features --locked: 146 lib + 68 integration, 0 failed. Three NEW tests in tests/round_trip.rs: mesofact_static_build_table_without_a_command_parses_as_none, an_unknown_build_key_is_still_refused_now_that_command_is_optional (deny_unknown_fields from R658-B1 did not loosen), mesofact_static_build_command_round_trips_through_postcard_both_ways.")
//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib: 927 passed, 0 failed, 4 ignored. Three NEW tests in reconciler::mesofact_static::tests: read_mesofact_build_accepts_a_build_table_with_no_command, rebuild_static_skips_the_build_step_when_no_command_is_declared (asserts the CaptureExecutor got nothing), lowering_a_build_with_no_command_yields_no_forge_spec.")
//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yubaba --lib: 553 passed, 0 failed.")
//! @yah:verify("cargo test --manifest-path oss/kamaji/Cargo.toml --workspace: all green incl. kamaji-proto codec 26/26 (the UDS round-trip) and kamaji-bin 217/217.")
//! @yah:verify("cargo check --all-targets --locked on the root workspace: clean (warnings only, all pre-existing).")
//! @yah:verify("cargo test --locked --no-fail-fast on the root workspace: one failure, yah-log tests::init_noop_without_env, which is NOT this change and is already filed as R840-B1 (it reads process-global env and the camp build rail exports YAH_TASK_RUN + YAH_LOG_PIPE; it passes in CI and under env -u).")
//! @yah:gotcha("DEAD GENERATED FILE FOUND, not touched: oss/packages/yah/workload-spec/index.ts is tracked, a month stale (last written 2026-07-29, has no ContainerManifest so it predates R783-F1), referenced by nothing, and gated by nothing. It is the fossil of the off-by-one that export-ts.rs:107 documents in its own comment: with ancestors().nth(3) the bin wrote to oss/packages/ instead of the camp root, and the stray output got committed. export-oss.sh exports oss/<name> subtrees, and oss/packages is not one, so it is not even on an export path. Deleting it is a one-line git rm but it is a tracked-file deletion outside this ticket, so it is named here rather than done.")
//! @yah:gotcha("STALE CLAIM in a neighbouring annotation, disproved but left in place: oss/yubaba/crates/cloud/src/reconciler/mesofact_static.rs:165 (R438-T6) says read_mesofact_build must hand-extract toml::Value subtrees because 'schema_version = 1 (integer) ... the typed envelope rejects'. R546-B7 made SchemaVersion read the bare integer (oss/yah-base/crates/workload-spec/src/version.rs), and the workload_envelope run proves it: the only error reported for the scaffold template was 'missing field command', never schema_version. The subtree reader has other reasons to exist, but that one is gone.")
//!
//! @yah:ticket(R658-B3, "mesofact new scaffolds a workload.toml the deploy path cannot execute: BuildConfig.command is required but the template deliberately omits it")
//! @yah:at(2026-08-30T08:33:45Z)
//! @yah:status(open)
//! @yah:parent(R658)
//! @yah:severity(high)
//! @yah:next("DECISION REQUIRED, do not guess. oss/mesofact/crates/mesofact/src/cli/new/template/workload.toml (new in ef8bd656) declares kind = mesofact-static with no [build] command, and its own header comment says that is deliberate: 'Left unset, mesofact-dev runs the build pipeline in-process — no third binary, no package manager, no Node.' But BuildConfig.command is a required String (oss/yah-base/crates/workload-spec/src/lib.rs:1428), so the file does not parse through workload_spec::Workload.")
//! @yah:next("The deploy path has no in-process branch. MesofactStaticReconciler uses build.command unconditionally — oss/yubaba/crates/cloud/src/reconciler/mesofact_static.rs:1143 builds vec![sh, -c, build.command.clone()], and 1173/1179/1185 log and execute it. So making command Option<String> is NOT a mechanical type change: it requires deciding what 'yah cloud bundle build' DOES for a manifest with no command. That is the actual open question.")
//! @yah:next("Three options. (a) command becomes Option<String> and the reconciler gains an in-process build branch — matches the template's documented intent and the W225 s2 'no package manager, no Node' promise, but MesofactStaticWorkload is a postcard wire type over the kamaji UDS, so a shape change is a lockstep kamaji+yubaba deploy (see the R658-B1 handoff, which rejected moving a field for exactly this reason). (b) The template gains a command — contradicts its own comment and the no-Node promise. (c) Add a KNOWN_GAPS entry in xtask/tests/workload_envelope.rs — unblocks check today, records the gap honestly, decides nothing.")
//! @yah:verify("cargo test -p xtask --test main --locked -- workload_envelope::every_on_disk_workload_toml_parses_through_the_envelope (currently: 0 passed, 1 failed, 'missing field command')")
//! @yah:gotcha("BLOCKS THE RELEASE GATE. This fails cargo test -p xtask --tests, which is check.toml step 7 (xtask-tests), which release-check runs before oss-publish. It also very likely fails release-check's second sub-pipeline mesofact-new-smoke, whose stated promise is that 'mesofact new' produces a project that builds and serves with no package manager and no Node on PATH — the same scaffold.")
//! @yah:gotcha("WHY THIS WAS INVISIBLE UNTIL NOW: check.toml's cargo-test step has no --no-fail-fast and died at yah-party, so steps 5-16 never ran this cycle. Separately, R605-S6 documents that xtask is a workspace member but NOT a default-member, so plain cargo test never reaches these tests at all, and workload_envelope was named there as one of eight test binaries that had been dark since being written. KNOWN_GAPS in xtask/tests/workload_envelope.rs is currently empty, so this file DID parse before ef8bd656 introduced the template — it is a regression, not a pre-existing gap.")
//! @yah:tier(Warrior)
//! @yah:next("Option (a) was taken. The open question that made this a decision was already answered by the code: yah cloud bundle build does NOT require a command. app/yah/cli/src/cloud.rs:3368 read_workload_build has always typed it Option<String>; assemble_component_bundle_with_sidecars needs it only under --run-build; deploy_mesofact_bundle refuses None by name at cloud.rs:5841. So no in-process build branch had to be invented: the reconciler skips the build step for None, exactly as it already did for a workload with no workload.toml at all.")
//! @yah:next("TO CLOSE: re-run cargo test -p xtask --test main --locked -- workload_envelope:: (passes now) and archive. Nothing left to build here.")
//! @yah:handoff("FIXED BY R838-B1 (same bug, filed twice; R838-B1 is the older ID). BuildConfig.command is now Option<String> in oss/yah-base/crates/workload-spec/src/lib.rs. cargo test -p xtask --tests is 54/54 green including workload_envelope, the gate that was failing.")

use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use ts_rs::TS;

pub mod admission;
pub mod compose_import;
pub mod control_plane_install;
pub mod rollout;
pub mod secrets;
pub mod sovereign;
pub mod validate;
mod version;

pub use version::SchemaVersion;

// ── Duration ──────────────────────────────────────────────────────────────────

/// Duration expressed as an integer millisecond count.
///
/// Used for healthcheck intervals, timeouts, delays, and stop grace periods.
/// Chosen over `std::time::Duration` to keep serde support dependency-free.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[ts(type = "number")]
pub struct Millis(pub u64);

impl Millis {
    pub fn from_secs(s: u64) -> Self {
        Self(s * 1000)
    }

    pub fn from_ms(ms: u64) -> Self {
        Self(ms)
    }

    pub fn as_ms(self) -> u64 {
        self.0
    }

    pub fn as_secs_f64(self) -> f64 {
        self.0 as f64 / 1000.0
    }
}

// ── Primitive newtypes ────────────────────────────────────────────────────────

/// Opaque identifier for a yubaba-managed machine within the cluster.
///
/// Used by the semantic validation layer for admission-control capacity checks.
/// Yubaba passes its own machine ID when validating a spec before deployment.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct MachineId(pub String);

/// DNS-segment identity for a workload on the cluster mesh, e.g.
/// `"noisetable-api.pdx"`. Regex constraint: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`,
/// length ≤ 63. Enforced in shape validation (R090-F2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct MeshIdent(pub String);

/// Tier classification that governs admission control and mesh `allow_from`
/// filtering. Known values: `"public"`, `"tenant"`, `"private"`, `"infra"`.
/// Custom tiers are allowed per cluster; shape validation warns on unknowns
/// rather than rejecting them (R090-F2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct TierTag(pub String);

/// Default single-tenant identity written to specs that predate the tenant
/// axis (W206). Its concrete string is arbitrary — what matters is that a
/// single-tenant cluster only ever sees this one value, so every per-tenant
/// isolation primitive collapses to a no-op. See [`TenantId::singleton`].
pub const DEFAULT_TENANT: &str = "default";

/// Default single-namespace identity for specs that predate the namespace
/// axis (W206). See [`NamespaceId::singleton`].
pub const DEFAULT_NAMESPACE: &str = "default";

/// Tenant **isolation** axis (W206). Separates one operator's workloads from
/// another's at the network / DB / mesh-identity level. Orthogonal to
/// [`NamespaceId`] (routing/naming) and [`TierTag`] (workload class within a
/// `(tenant, namespace)` pair).
///
/// **Degenerate case:** when a yubaba reconciler sees only one `TenantId`
/// across every workload on a machine, per-tenant Podman networks collapse
/// into the shared tier networks, the tenant prefix on mesh identity is
/// dropped, and PostgreSQL role separation is skipped — isolation primitives
/// become no-ops. You pay only when more than one tenant is present. Specs
/// written before this axis existed deserialize to [`TenantId::singleton`].
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct TenantId(pub String);

impl TenantId {
    /// The singleton tenant used for back-compat with single-tenant (current)
    /// deployments. Specs written before the tenant axis existed deserialize
    /// to this value via the `#[serde(default)]` on [`WorkloadSpec::tenant`],
    /// keeping the whole cluster single-tenant so every isolation primitive
    /// stays a no-op.
    pub fn singleton() -> Self {
        Self(DEFAULT_TENANT.to_string())
    }

    /// Whether this is the singleton (degenerate single-tenant) identity.
    pub fn is_singleton(&self) -> bool {
        self.0 == DEFAULT_TENANT
    }
}

/// Namespace **routing/naming** axis (W206). A pure naming key that never
/// affects isolation: it selects the config root, disambiguates service DNS
/// names within a tenant, prefixes object-store bucket names within a tenant's
/// bucket scope, and selects the provider zone (e.g. `noisetable.com` vs
/// `yah.dev`). Two namespaces in the same tenant share networks, mesh-identity
/// space, and PG cluster — they simply cannot collide on workload names or
/// external domains. Specs written before this axis existed deserialize to
/// [`NamespaceId::singleton`].
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct NamespaceId(pub String);

impl NamespaceId {
    /// The singleton namespace used for back-compat with single-namespace
    /// (current) deployments. Specs written before the namespace axis existed
    /// deserialize to this value via the `#[serde(default)]` on
    /// [`WorkloadSpec::namespace`].
    pub fn singleton() -> Self {
        Self(DEFAULT_NAMESPACE.to_string())
    }

    /// Whether this is the singleton (degenerate single-namespace) identity.
    pub fn is_singleton(&self) -> bool {
        self.0 == DEFAULT_NAMESPACE
    }
}

// ── Workload (on-disk envelope) ──────────────────────────────────────────────

/// On-disk `workload.toml` manifest. Each variant matches one
/// `ServiceComponent.kind` value; the `kind` field on the wire is the serde
/// discriminator.
///
/// This is the **on-disk** envelope — distinct from [`WorkloadSpec`], the
/// containerd wire format yubaba receives over RPC. A `kind = "container"`
/// workload deserializes its remaining fields as a [`ContainerManifest`],
/// which is *either* a digest-pinned `WorkloadSpec` or a local Dockerfile
/// recipe (R783-F1 / W324); other kinds carry their own per-reconciler
/// payload shape.
///
/// **Never put `#[serde(skip_serializing_if = "Option::is_none")]` on a field
/// of this enum or any type it reaches.** These types ride the kamaji-proto
/// **postcard** wire, which is non-self-describing and positional:
/// `skip_serializing_if` omits the field's byte on serialize while decode still
/// expects to read it at that offset, so the byte stream misaligns and the
/// round-trip fails. Use `#[serde(default)]` + `#[ts(optional = nullable)]`
/// instead — that still gives TOML/JSON back-compat (missing field → `None`)
/// while the field is always encoded. `MesofactStaticWorkload::ssr_runtime` and
/// `::serve_bundle` are the reference shape.
/// **Two wire shapes, one type (R546-B7).** `Serialize`/`Deserialize` are
/// hand-written and branch on [`is_human_readable`](serde::Deserializer::is_human_readable):
///
/// - **TOML/JSON (human-readable)** → *internally* tagged on `kind`, i.e. the
///   flat shape every on-disk `workload.toml` actually uses
///   (`kind = "static-asset"` beside `schema_version`, `[[asset]]`, `[aliases]`).
/// - **postcard (binary)** → *externally* tagged, byte-identical to the derived
///   representation R590-B3 established for the kamaji UDS.
///
/// Why not just `#[serde(tag = "kind")]`: internal tagging buffers through
/// `deserialize_any`, which postcard (non-self-describing) refuses with
/// `WontImplement` — that is exactly the failure R590-B3 fixed by flipping this
/// enum to external tagging. But external tagging wants a single-key map, so
/// every flat on-disk file then failed with `wanted exactly 1 element, more
/// than 1 element` and `yah cloud apply` broke for every static-asset
/// component. Branching on the format satisfies both, and mirrors what
/// [`ImageRef`] already does for its string-vs-struct form.
#[derive(Debug, Clone, PartialEq, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(
    feature = "json-schema",
    schemars(tag = "kind", rename_all = "kebab-case")
)]
#[ts(tag = "kind", rename_all = "kebab-case")]
pub enum Workload {
    /// Static-site build that publishes an artifact directory to the
    /// service's `static` provider slot. Reconciled by the
    /// `mesofact-static` reconciler — does not deploy to yubaba.
    MesofactStatic(MesofactStaticWorkload),

    /// A container-shaped workload. **Two on-disk forms** (R783-F1 / W324),
    /// see [`ContainerManifest`]: a digest-pinned [`WorkloadSpec`] reference
    /// (the form that crosses the kamaji wire) or a local Dockerfile
    /// [`ContainerBuild`] recipe (which cannot, because it names no digest
    /// until it has been built).
    ///
    /// Construct the wire form with [`Workload::container`] and read it back
    /// with [`Workload::container_spec`] — most callers only ever mean the
    /// reference form and should not have to name the manifest enum.
    ///
    /// The reference form's inline fields are the full [`WorkloadSpec`] minus
    /// the `kind` discriminator.
    ///
    /// This is also the shape of the W267 sovereign-public-ingress appliance
    /// (R594-F2): a container-kind workload with `archetype =
    /// Some(LifecycleArchetype::Appliance)` and
    /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, **not** a dedicated
    /// `Workload::ingress(..)` variant. It runs an ordinary OCI image (the
    /// `passway` proxy, R594-F4) supervised by kamaji exactly like any other
    /// `Container`, so no admission-list or wire-codec change was needed to
    /// let kamaji accept it. A new enum variant would have forced an
    /// exhaustive-match update in every `Workload` consumer, including
    /// peer-owned `kamaji-proto/src/codec.rs` — the archetype + annotation
    /// combination expresses "this is the public ingress appliance" without
    /// that blast radius. See [`WorkloadSpec::requires_taint`] and
    /// [`LifecycleArchetype::Appliance`].
    Container(ContainerManifest),

    /// Data-pipeline job with declared I/O and a readiness policy. The
    /// orchestrator checks all `inputs` are reachable before each run and
    /// verifies `outputs` afterward. Generalises the OpenRouter JSON-cache
    /// refresher (`spawn_almanac_refresher`) to the full manifest form.
    Almanac(AlmanacManifest),

    /// Content-addressed static files uploaded to the mirror's `object_store`
    /// provider slot. Wave-0 by default — gating mesofact and container waves.
    /// Rollback is a pointer-flip via `mirror.toml [asset_aliases]`; bytes are
    /// append-only and never re-pushed on rollback. See W160.
    StaticAsset(StaticAssetWorkload),
}

impl Workload {
    /// The `kind` discriminator this variant serializes as — the same string a
    /// `workload.toml` writes and a `ServiceComponent.kind` names.
    ///
    /// Lives here rather than at a call site because this enum now has FIVE
    /// places that enumerate its variants (itself plus the four tagging
    /// mirrors below); a caller-local match would be a sixth, in another crate,
    /// with nothing to force it to keep up.
    pub fn kind_str(&self) -> &'static str {
        match self {
            Workload::MesofactStatic(_) => "mesofact-static",
            Workload::Container(_) => "container",
            Workload::Almanac(_) => "almanac",
            Workload::StaticAsset(_) => "static-asset",
        }
    }

    /// Wrap a digest-pinned [`WorkloadSpec`] as a `kind = "container"`
    /// workload — the form that crosses the kamaji wire.
    ///
    /// Every caller that synthesizes a container workload in code (ingress
    /// appliances, forge runs, kamaji's own deploy path) means *this* form;
    /// the [`ContainerManifest::Recipe`] arm only ever arrives by parsing a
    /// `workload.toml` with a `[build]` table. Keeping the constructor here
    /// means R783-F1 did not have to teach ~25 call sites the name of a
    /// manifest enum they have no opinion about.
    pub fn container(spec: WorkloadSpec) -> Self {
        Workload::Container(ContainerManifest::Reference(spec))
    }

    /// The digest-pinned spec of a `kind = "container"` workload, if this is
    /// a container workload in the reference form.
    ///
    /// `None` covers both "not a container" and "a container *recipe*, which
    /// has no spec until it is built" — a consumer that speaks the wire
    /// (kamaji, yubaba's deploy path) must treat both as inadmissible, so
    /// collapsing them into one `None` is deliberate rather than lossy. Use
    /// [`Workload::container_manifest`] when the two need distinguishing.
    pub fn container_spec(&self) -> Option<&WorkloadSpec> {
        match self {
            Workload::Container(m) => m.as_spec(),
            _ => None,
        }
    }

    /// The container manifest, in whichever on-disk form it was written.
    pub fn container_manifest(&self) -> Option<&ContainerManifest> {
        match self {
            Workload::Container(m) => Some(m),
            _ => None,
        }
    }
}

/// Internally-tagged mirror of [`Workload`] — the on-disk shape. Only ever
/// reached on the human-readable branch, so its `deserialize_any` buffering is
/// never asked of postcard.
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
enum WorkloadTagged {
    MesofactStatic(MesofactStaticWorkload),
    Container(ContainerManifest),
    Almanac(AlmanacManifest),
    StaticAsset(StaticAssetWorkload),
}

/// Borrowing twin of [`WorkloadTagged`] so `Serialize` need not clone the
/// payload. Variant order must match [`Workload`].
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
enum WorkloadTaggedRef<'a> {
    MesofactStatic(&'a MesofactStaticWorkload),
    Container(&'a ContainerManifest),
    Almanac(&'a AlmanacManifest),
    StaticAsset(&'a StaticAssetWorkload),
}

/// Externally-tagged mirror — the postcard wire shape R590-B3 established.
/// postcard encodes an external tag as the *variant index*, so the variant
/// ORDER here is load-bearing: it must match [`Workload`] exactly or the
/// kamaji UDS silently decodes into the wrong variant.
///
/// `Container` deliberately keeps [`WorkloadSpec`], **not**
/// [`ContainerManifest`] (R783-F1 / W324): the wire carries only the
/// digest-pinned reference form, so these bytes are unchanged by the on-disk
/// split, and a [`ContainerManifest::Recipe`] is refused at serialize rather
/// than encoded as a second variant nothing on the far side can execute.
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum WorkloadExternal {
    MesofactStatic(MesofactStaticWorkload),
    Container(WorkloadSpec),
    Almanac(AlmanacManifest),
    StaticAsset(StaticAssetWorkload),
}

/// Borrowing twin of [`WorkloadExternal`]. Same order requirement.
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
enum WorkloadExternalRef<'a> {
    MesofactStatic(&'a MesofactStaticWorkload),
    Container(&'a WorkloadSpec),
    Almanac(&'a AlmanacManifest),
    StaticAsset(&'a StaticAssetWorkload),
}

impl Serialize for Workload {
    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        if s.is_human_readable() {
            match self {
                Workload::MesofactStatic(w) => WorkloadTaggedRef::MesofactStatic(w),
                Workload::Container(w) => WorkloadTaggedRef::Container(w),
                Workload::Almanac(w) => WorkloadTaggedRef::Almanac(w),
                Workload::StaticAsset(w) => WorkloadTaggedRef::StaticAsset(w),
            }
            .serialize(s)
        } else {
            match self {
                Workload::MesofactStatic(w) => WorkloadExternalRef::MesofactStatic(w),
                // The wire gate (W324 §5). A recipe names no digest, so there
                // is nothing for kamaji to pull — refusing here makes "a build
                // recipe cannot reach kamaji" a fact the type system holds,
                // rather than a convention someone eventually forgets.
                Workload::Container(ContainerManifest::Recipe(_)) => {
                    return Err(serde::ser::Error::custom(RECIPE_IS_NOT_A_WIRE_SPEC))
                }
                Workload::Container(ContainerManifest::Reference(spec)) => {
                    WorkloadExternalRef::Container(spec)
                }
                Workload::Almanac(w) => WorkloadExternalRef::Almanac(w),
                Workload::StaticAsset(w) => WorkloadExternalRef::StaticAsset(w),
            }
            .serialize(s)
        }
    }
}

impl<'de> Deserialize<'de> for Workload {
    fn deserialize<D>(de: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        if de.is_human_readable() {
            Ok(match WorkloadTagged::deserialize(de)? {
                WorkloadTagged::MesofactStatic(w) => Workload::MesofactStatic(w),
                WorkloadTagged::Container(w) => Workload::Container(w),
                WorkloadTagged::Almanac(w) => Workload::Almanac(w),
                WorkloadTagged::StaticAsset(w) => Workload::StaticAsset(w),
            })
        } else {
            Ok(match WorkloadExternal::deserialize(de)? {
                WorkloadExternal::MesofactStatic(w) => Workload::MesofactStatic(w),
                // Only the reference form exists on the wire, by construction
                // of `WorkloadExternal` — see its doc comment.
                WorkloadExternal::Container(w) => Workload::container(w),
                WorkloadExternal::Almanac(w) => Workload::Almanac(w),
                WorkloadExternal::StaticAsset(w) => Workload::StaticAsset(w),
            })
        }
    }
}

// ── Container manifest (R783-F1 / W324) ───────────────────────────────────────

/// Error text used both by the postcard serializer gate and by
/// [`ContainerManifest::into_spec`]'s doc, so the two cannot drift.
const RECIPE_IS_NOT_A_WIRE_SPEC: &str = "a kind = \"container\" workload in the RECIPE form \
     (a [build] table) cannot cross the kamaji wire: it names an image tag, not a digest, and \
     the digest does not exist until `docker build` has run. Lower it with \
     `ContainerBuild::into_spec(digest)` after the build, then send the resulting WorkloadSpec.";

/// On-disk payload of `kind = "container"` — **two forms**, one wire type
/// (W324 §5).
///
/// A [`WorkloadSpec`] asserts a content-addressed identity: its
/// [`ImageRef::digest`] is a required `sha256:<hex>` and the string form
/// rejects a bare tag at serde-deserialize (R438-T3). A local component built
/// from a Dockerfile next to its `workload.toml` cannot satisfy that — its
/// image is `yah-local/<name>:dev`, and the digest does not exist until the
/// build has run. So a build *recipe* is not a degenerate spec with a missing
/// field; it is a promise to produce one, and the two are different types.
///
/// The discriminator is the presence of a `[build]` table. `WorkloadSpec` has
/// no `build` field and [`ContainerBuild`] requires one, so the two shapes are
/// mutually exclusive — and picking the branch explicitly (rather than with
/// `#[serde(untagged)]`) is what lets a malformed reference still report
/// `missing field \`image\`` instead of "data did not match any variant".
///
/// Only [`Reference`](Self::Reference) crosses the postcard kamaji wire; see
/// [`WorkloadExternal`]'s doc comment for why that keeps those bytes
/// byte-identical to the pre-split encoding.
#[derive(Debug, Clone, PartialEq, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "json-schema", schemars(untagged))]
#[ts(untagged)]
pub enum ContainerManifest {
    /// Digest-pinned image. Crosses the wire as-is.
    Reference(WorkloadSpec),

    /// Dockerfile recipe. **Local only** — see [`ContainerBuild`].
    Recipe(ContainerBuild),
}

impl ContainerManifest {
    /// The digest-pinned spec, or `None` for the recipe form.
    pub fn as_spec(&self) -> Option<&WorkloadSpec> {
        match self {
            ContainerManifest::Reference(spec) => Some(spec),
            ContainerManifest::Recipe(_) => None,
        }
    }

    /// The build recipe, or `None` for the reference form.
    pub fn as_recipe(&self) -> Option<&ContainerBuild> {
        match self {
            ContainerManifest::Recipe(b) => Some(b),
            ContainerManifest::Reference(_) => None,
        }
    }

    /// Consume the manifest, yielding the digest-pinned spec. `Err` carries
    /// the recipe back so a caller that *can* build it still has it.
    pub fn into_spec(self) -> Result<WorkloadSpec, ContainerBuild> {
        match self {
            ContainerManifest::Reference(spec) => Ok(spec),
            ContainerManifest::Recipe(b) => Err(b),
        }
    }

    /// `"reference"` or `"recipe"` — for error messages that need to name
    /// which form was found without matching on the enum at the call site.
    pub fn form(&self) -> &'static str {
        match self {
            ContainerManifest::Reference(_) => "reference",
            ContainerManifest::Recipe(_) => "recipe",
        }
    }
}

impl Serialize for ContainerManifest {
    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        match self {
            // Transparent in both directions: the on-disk container form is
            // the payload's own fields flattened under `kind = "container"`,
            // exactly as it was before the split.
            ContainerManifest::Reference(spec) => spec.serialize(s),
            ContainerManifest::Recipe(recipe) => {
                if s.is_human_readable() {
                    recipe.serialize(s)
                } else {
                    Err(serde::ser::Error::custom(RECIPE_IS_NOT_A_WIRE_SPEC))
                }
            }
        }
    }
}

impl<'de> Deserialize<'de> for ContainerManifest {
    fn deserialize<D>(de: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        use serde::de::Error as _;

        // postcard and friends are non-self-describing, so there is no map to
        // probe for `[build]` — and by construction the binary wire only ever
        // carries the reference form anyway (`WorkloadExternal::Container`).
        if !de.is_human_readable() {
            return WorkloadSpec::deserialize(de).map(ContainerManifest::Reference);
        }

        // Buffer once, then branch explicitly. `serde_json::Value` is the
        // buffer rather than `#[serde(untagged)]`'s private `Content` because
        // untagged discards the inner error: `missing field \`image\`` — the
        // one thing an author needs to see — becomes "data did not match any
        // variant of untagged enum ContainerManifest".
        let buffered = serde_json::Value::deserialize(de)?;

        match (
            buffered.get("build").is_some(),
            buffered.get("image").is_some(),
        ) {
            (true, _) => ContainerBuild::deserialize(buffered)
                .map(ContainerManifest::Recipe)
                .map_err(|e| {
                    D::Error::custom(format!(
                        "kind = \"container\" with a [build] table is a local build recipe: {e}"
                    ))
                }),
            (false, true) => WorkloadSpec::deserialize(buffered)
                .map(ContainerManifest::Reference)
                .map_err(|e| {
                    D::Error::custom(format!(
                        "kind = \"container\" without a [build] table is a digest-pinned image \
                         reference: {e}"
                    ))
                }),
            // Neither marker. Reporting `missing field \`image\`` here would
            // send a recipe author off to add a field their form does not
            // have, so name both forms instead — this is the one case where
            // the file does not say which of the two it is trying to be.
            (false, false) => Err(D::Error::custom(
                "kind = \"container\" must declare either a digest-pinned `image` (the wire \
                 form: a WorkloadSpec yubaba hands to kamaji) or a [build] table (a local \
                 Dockerfile recipe built on the operator's box) — it declares neither",
            )),
        }
    }
}

/// `kind = "container"` in the **recipe** form: a Dockerfile next to the
/// component's `workload.toml`, built and run on the operator's box.
///
/// This is the shape `ContainerReconciler` drives (`docker build` from
/// [`build`](Self::build), `docker run` with [`run`](Self::run)). It is
/// deliberately *not* a `WorkloadSpec` — see [`ContainerManifest`] for why the
/// digest invariant makes that impossible, and [`Self::into_spec`] for the one
/// lowering that is allowed.
///
/// **Unknown keys are tolerated on purpose.** `crates/yah/cloud-admin/workload.toml`
/// carries a `[process]` table read by `LocalProcessReconciler` on the dev
/// mirror — one component file, three tier runtimes (W324 §1). Adding
/// `deny_unknown_fields` here would make that file unparseable as a container
/// manifest, which is the opposite of the point.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ContainerBuild {
    /// Wire-format version. Always `V1` today.
    pub schema_version: SchemaVersion,

    /// Component name. Same field the reference form carries, so a manifest
    /// identifies itself the same way whichever form it is written in.
    pub name: String,

    /// How the image is built. Its presence is what makes this a recipe.
    pub build: ContainerBuildStep,

    /// How the built image is run locally.
    #[serde(default)]
    pub run: ContainerRunConfig,
}

impl ContainerBuild {
    /// Lower a recipe to the wire type, **once a build has produced a digest**.
    ///
    /// The signature is the invariant (W324 §5): there is no way to reach a
    /// `WorkloadSpec` from a recipe without supplying the `sha256:<hex>` the
    /// build emitted, so an unpinned container spec cannot be constructed by
    /// accident.
    ///
    /// Fallible because `digest` is a caller-supplied string: a malformed one
    /// must be an error, not a `WorkloadSpec` that lies about being
    /// content-addressed. Everything the recipe does not declare
    /// (`tier`, `resources`, `restart_policy`, …) takes the same defaults a
    /// hand-written local container gets; `tier` is the caller's because
    /// admission control is a cluster policy, not a manifest fact.
    pub fn into_spec(self, digest: &str, tier: TierTag) -> Result<WorkloadSpec, String> {
        let image_tag = self
            .build
            .image
            .clone()
            .unwrap_or_else(|| format!("yah-local/{}:dev", self.name));

        // Route through the one parser that owns the digest rule (R438-T3) so
        // the recipe path cannot grow a second, laxer definition of "pinned".
        let image = compose_import::parse_pinned_image_ref(&format!("{image_tag}@{digest}"))
            .map_err(|e| format!("lowering container recipe {:?}: {e}", self.name))?;

        let ports = self.run.port.map(|p| vec![p]).unwrap_or_default();

        Ok(WorkloadSpec {
            schema_version: self.schema_version,
            name: self.name.clone(),
            image,
            tier,
            tenant: TenantId::singleton(),
            namespace: NamespaceId::singleton(),
            replicas: 1,
            command: None,
            entrypoint: None,
            workdir: None,
            user: None,
            env: self
                .run
                .env
                .into_iter()
                .map(|(name, value)| EnvVar {
                    name,
                    value: EnvValue::Literal { value },
                })
                .collect(),
            secrets: vec![],
            volumes: self
                .run
                .mounts
                .into_iter()
                .map(|m| VolumeMount {
                    source: VolumeSource::Bind {
                        host_path: PathBuf::from(m.host),
                    },
                    target: m.container,
                    read_only: m.read_only,
                })
                .collect(),
            resources: ResourceLimits {
                memory_mb: 1024,
                cpu_millis: 1000,
                ephemeral_storage_mb: 1024,
            },
            depends_on: vec![],
            healthcheck: None,
            restart_policy: RestartPolicy::Always,
            archetype: Some(LifecycleArchetype::Server),
            stop_policy: StopPolicy {
                signal: 15,
                grace_period: Millis::from_secs(10),
            },
            expose: ExposeSpec {
                mesh: MeshExpose {
                    identity: MeshIdent(self.name),
                    ports,
                    allow_from: vec![],
                },
                public: None,
                operator: None,
            },
            labels: HashMap::new(),
            annotations: HashMap::new(),
        })
    }
}

/// The `[build]` table of a container recipe.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ContainerBuildStep {
    /// Dockerfile path, relative to the component directory.
    #[serde(default = "default_dockerfile")]
    pub dockerfile: PathBuf,

    /// Build context, relative to the workspace root. `None` → the component
    /// directory. Workspace crates set `"."` so their path-dependency sources
    /// resolve.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub context: Option<PathBuf>,

    /// Image tag to build and run. `None` → `yah-local/<name>:dev`.
    ///
    /// A **tag**, not an [`ImageRef`]: this names an image that does not exist
    /// yet, so there is no digest to pin it by.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub image: Option<String>,
}

fn default_dockerfile() -> PathBuf {
    PathBuf::from("Dockerfile")
}

impl Default for ContainerBuildStep {
    fn default() -> Self {
        Self {
            dockerfile: default_dockerfile(),
            context: None,
            image: None,
        }
    }
}

/// The `[run]` table of a container recipe.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ContainerRunConfig {
    /// Container port the process listens on.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub port: Option<u16>,

    /// Host port to publish it on. `None` → same as [`port`](Self::port).
    #[serde(default)]
    #[ts(optional = nullable)]
    pub host_port: Option<u16>,

    /// Environment passed into the container.
    #[serde(default)]
    pub env: BTreeMap<String, String>,

    /// Bind mounts from the workspace into the container.
    #[serde(default)]
    pub mounts: Vec<ContainerMount>,
}

/// One `[[run.mounts]]` entry of a container recipe.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ContainerMount {
    /// Host path. Relative paths resolve against the workspace root — the
    /// declaration lives in the repo, so it should read like a repo path and
    /// stay valid on whichever machine the operator runs it from.
    pub host: String,

    /// Absolute path inside the container.
    pub container: PathBuf,

    /// Default `true`. A workspace mount is config the service *reads*; a
    /// writable default would let a container mutate the operator's checkout
    /// as a side effect of running, so opting into that has to be explicit.
    #[serde(default = "default_true")]
    pub read_only: bool,
}

fn default_true() -> bool {
    true
}

/// `kind = "mesofact-static"` payload — static-site build colocated with the
/// frontend it deploys.
///
/// The two-role model (R256-F7): a build/publish step plus an optional
/// SSR/SPA runtime companion. The build step is always transient (runs once,
/// publishes, exits). The companion is long-lived and only present when the
/// app has dynamic/server-rendered pages; pure static sites leave it `None`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct MesofactStaticWorkload {
    /// Wire-format version. Always `V1` today.
    pub schema_version: SchemaVersion,

    /// Build command + output directory.
    pub build: BuildConfig,

    /// Path (relative to the manifest) of the routes module the
    /// `mesofact-static` reconciler reads to enumerate routes.
    pub routes: PathBuf,

    /// Where the build command runs. Default: `HostSide` (mesofact-dev on the
    /// host). Set to `InContainer` for cloud/HA where no host watcher is
    /// present and CI-fidelity build environments are required.
    #[serde(default)]
    pub build_mode: BuildMode,

    /// Optional SSR/SPA runtime companion container.
    ///
    /// `None` → pure static site; Caddy (or equivalent CDN) serves all
    /// requests directly from the object store. This is the common case for
    /// dev-yah today.
    ///
    /// `Some` → the workload spec describes a long-lived container that
    /// handles dynamic/SSR requests. Caddy routes static asset paths to
    /// the object store and all other paths to this container. The companion
    /// uses `RestartPolicy::Always`; the orchestrator (camp or yubaba)
    /// ensures it stays up alongside the Caddy edge.
    #[ts(optional = nullable)]
    pub ssr_runtime: Option<WorkloadSpec>,

    /// Serve-time reference to a published W272 bundle (R599-F4).
    ///
    /// `Some` → the built app is deployed as a content-addressed bundle that
    /// kamaji materializes from the bundle store (R599-F1) and serves via its
    /// native backend, instead of (or in addition to) the build reconciler
    /// pushing `dist/` to the object-store/CDN. `None` → legacy
    /// build-and-publish-only workload — kamaji rejects that form as yubaba's
    /// `mesofact-static` reconciler's responsibility.
    ///
    /// No `skip_serializing_if`: like `ssr_runtime`, this field is always
    /// encoded so the postcard wire codec (non-self-describing, positional)
    /// round-trips — `skip_serializing_if` would omit the byte on serialize
    /// while decode still expects it. `#[serde(default)]` keeps every existing
    /// `mesofact-static` TOML/JSON that predates this field parsing to `None`.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub serve_bundle: Option<MesofactServeBundle>,

    /// Revalidate receiver for the almanac push model (R330-F12).
    ///
    /// `Some` → kamaji also forks `mesofact serve --revalidate <workload>`
    /// alongside the bundle's static serve (or in place of it when
    /// `serve_bundle` is `None`). The receiver is ephemeral-V8: each
    /// `POST /dawn` boots a V8 isolate, re-renders the route, republishes to
    /// the CDN, then drops the isolate. (`/revalidate` is still served as a
    /// transitional alias — yah R752-T10 renamed it so the render stage stops
    /// sharing a path with almanac's feed-refetch stage, `POST /freshen`.)
    ///
    /// Env vars are resolved at deploy time (R2 creds + mirror bearer) so
    /// the node never sees keystore slot names.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub revalidate_receiver: Option<MesofactRevalidateReceiver>,
}

/// Revalidate receiver config (R330-F12) — tells kamaji to fork a second
/// `mesofact serve --revalidate` process alongside the static bundle server.
///
/// The receiver is the almanac push endpoint: a lightweight resident axum
/// server mounting `POST /dawn` (plus the legacy `/revalidate` alias) that
/// boots V8 on each poke, re-renders the invalidated route, publishes to
/// R2/CDN, then drops the isolate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct MesofactRevalidateReceiver {
    /// Routes the receiver accepts pokes for (allowlist).
    /// Empty vec → all routes in the workload's manifest are revalidatable.
    #[serde(default)]
    pub routes: Vec<String>,

    /// Path to `mesofact.config.toml` carrying the `[publish]` block
    /// (bucket / zone / env-named credentials). Relative to the workload
    /// directory. Default: `"mesofact.config.toml"`.
    #[serde(default = "default_publish_config_path")]
    pub publish_config: String,

    /// Env var name holding the bearer secret for this tenant, resolved
    /// at deploy time and set as `MESOFACT_MIRROR_KEY` on the receiver
    /// process. `None` → open receiver (no bearer check).
    #[ts(optional = nullable)]
    pub mirror_key_env: Option<String>,

    /// Environment variables set on the revalidate process by kamaji.
    /// Keys are the canonical env var names (`MESOFACT_S3_ACCESS_KEY_ID`,
    /// `MESOFACT_S3_SECRET_ACCESS_KEY`, `CLOUDFLARE_API_TOKEN`,
    /// `MESOFACT_MIRROR_KEY`). Values are resolved from the keystore at
    /// deploy time — the node never sees slot names.
    #[serde(default)]
    pub env: std::collections::BTreeMap<String, String>,

    /// Feed-fetch tier (R330-F31) — the almanac feeds whose artifacts must be
    /// refreshed **on the node** for a poke to have anything new to render.
    ///
    /// Empty → no fetcher; the receiver re-renders whatever data the bundle was
    /// built with (correct for a site whose data only changes at build time,
    /// silently stale for one whose data is a live feed). Non-empty → kamaji
    /// forks a third resident process, the `almanac-feed` fetcher, next to the
    /// receiver — resolved from the bundle's `bins/<triple>/almanac-feed` when
    /// it carries one, else from [`feed_runtime`](Self::feed_runtime).
    #[serde(default)]
    pub feeds: Vec<AlmanacFeed>,

    /// Runtime ref the `almanac-feed` fetcher resolves from the node's shared
    /// runtime-asset cache when the bundle carries no `bins/` (R746-T3), e.g.
    /// `"almanac-feed/0.8.22"`.
    ///
    /// This is what lets a **vanilla** bundle have a feed tier at all. A
    /// self-contained bundle stages the fetcher into `bins/` and stays closed
    /// over it; a vanilla bundle carries no binaries by construction, so the
    /// fetcher has to be a node-level asset for the same reason `serve` is —
    /// otherwise a templates-only sync would still need a cross-built musl
    /// binary sitting on the syncing machine's disk.
    ///
    /// `None` with `feeds` non-empty and no sidecar in the bundle is a deploy
    /// failure, named at the node. It is not a silent skip: "the site serves
    /// but its data is frozen" is the exact state R330-F31 exists to make
    /// observable.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub feed_runtime: Option<String>,

    /// Seconds between feed-fetch ticks. Ignored when `feeds` is empty.
    ///
    /// This is the site's freshness bound: a release lands, and the next tick
    /// refreshes + pokes. `FeedRunner`'s change-suppression means an idle tick
    /// costs one conditional fetch, so a short interval is affordable.
    #[serde(default = "default_feed_interval_secs")]
    pub feed_interval_secs: u64,

    /// Workspace-relative path of the component whose build produced this
    /// bundle, e.g. `app/yah/web/marketing` (R330-F31).
    ///
    /// Reconciles two roots for one file: a feed declares `emit.artifact`
    /// workspace-relative (that is where it is authored), while the route
    /// declares the same file project-relative (that is what the bundle
    /// carries). The fetcher strips this prefix to get from one to the other.
    /// `None` → the two already coincide.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub feed_project_prefix: Option<String>,
}

/// One almanac feed handed to the on-node fetcher (R330-F31).
///
/// The definition travels **by value**, not by path: the node has no copy of
/// the camp's `.yah/almanac/` tree, and staging one into the content-addressed
/// bundle would put a mutable-by-nature config inside an immutable artifact.
/// The fetcher parses `config_toml` with the same `FeedConfig` type that reads
/// the file at the source, so there is one schema and no drift.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct AlmanacFeed {
    /// Feed name — the `.yah/almanac/<name>.toml` stem. Logs/diagnostics only;
    /// `config_toml` is authoritative.
    pub name: String,

    /// Verbatim contents of the feed definition TOML.
    pub config_toml: String,
}

fn default_publish_config_path() -> String {
    "mesofact.config.toml".to_string()
}

/// Five minutes: fast enough that a release is live on yah.dev before anyone
/// goes looking, slow enough to be invisible against a source API's rate limit.
fn default_feed_interval_secs() -> u64 {
    300
}

/// Serve-time reference to a published W272 bundle (R599-F4) — the
/// `{bundle_digest, runtime, lifecycle}` triple a `mesofact-static` workload
/// carries when kamaji, not the build reconciler, serves it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct MesofactServeBundle {
    /// BLAKE3 digest of the published bundle manifest — the content-address
    /// kamaji materializes from the bundle store (`yah_mesofact_bundle`,
    /// R599-F1). Same 64-hex shape the bundle crate's `BundleHash` validates.
    pub digest: BlakeHash,

    /// Runtime that serves the bundle: `"self"` (bundle ships its own
    /// `bins/<triple>/serve`) or `"mesofact/<version>"` (resolve the stock
    /// serve runtime asset from the node cache). Wire-mirrors
    /// `yah_mesofact_bundle::BundleRuntime`; kept as a plain `String` here so
    /// workload-spec stays free of the bundle crate and its non-TS/schema
    /// newtypes.
    pub runtime: String,

    /// How kamaji supervises the served bundle. Default: keep-alive.
    #[serde(default)]
    pub lifecycle: BundleLifecycle,

    /// Port the served bundle listens on (R599-F12). This is the bundle-tier
    /// analogue of a container's `expose.mesh.ports`: the *declared* serving
    /// port, which a proxy pairs with the workload's mesh IP to get a dialable
    /// address.
    ///
    /// `None` → kamaji falls back to its node-wide default
    /// (`KAMAJI_BUNDLE_PORT` / `DEFAULT_BUNDLE_PORT` = 8080), which is the
    /// pre-R599-F12 behaviour and is only correct while a node hosts one
    /// bundle. Declaring a port per workload is what lets one node host
    /// several — that singleton default is the thing it removes.
    ///
    /// No `skip_serializing_if` — see `serve_bundle`'s note: the postcard wire
    /// codec is positional, so an omitted byte shifts every later field.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub port: Option<u16>,

    /// Environment the serve process is forked with (R556-T12) — already
    /// **resolved** values, `NAME → value`.
    ///
    /// This is what makes an SSR route that reads a private source deployable
    /// at all: `mesofact serve` resolves a source's credentials from its own
    /// process environment at request time, and before this field the static /
    /// SSR serve process was forked with `env: vec![]` while only the
    /// `revalidate_receiver` sub-slot carried any. A declared-authed SSR site
    /// therefore deployed clean and failed *per request* on the node.
    ///
    /// Resolution happens deploy-side, exactly like
    /// [`MesofactRevalidateReceiver::env`]: the mirror declares source URIs
    /// (`vault:<slot>` / `env:<VAR>`), `yah cloud apply` resolves them against
    /// the operator's vault, and the node receives values. Keystore slot names
    /// never cross the wire.
    ///
    /// Appended **after** `port` — see `port`'s note: the postcard wire codec
    /// is positional, so a new field goes last and never carries
    /// `skip_serializing_if`.
    #[serde(default)]
    pub env: BTreeMap<String, String>,
}

/// Lifecycle mode for a served bundle (W272 §3).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum BundleLifecycle {
    /// Fork at deploy, keep resident, restart per policy — today's server
    /// archetype. Memory is resident for the workload's lifetime.
    KeepAlive,

    /// Kamaji owns the listen socket, forks the runtime on the first connection
    /// (fd-passing), and reaps it after `idle_ttl` with zero connections — the
    /// "serverless" tier (zero memory when idle). The JIT fork/reap mechanics
    /// land in R599-F6; this variant only declares the intent + budget.
    OnDemand {
        /// Idle time with no live connections before kamaji reaps the process.
        idle_ttl: Millis,
    },
}

impl Default for BundleLifecycle {
    /// Keep-alive — the resident server archetype — matches the current
    /// deploy-and-supervise default.
    fn default() -> Self {
        BundleLifecycle::KeepAlive
    }
}

/// Build step that produces the static artifact published by a
/// `mesofact-static` workload.
///
/// **`deny_unknown_fields` is load-bearing (R658-B1).** TOML scopes every key
/// written after a table header into that table, so a manifest that puts a
/// top-level `MesofactStaticWorkload` field — `routes` was the one that
/// actually happened — below `[build]` silently produces `build.routes`
/// instead. Without this attribute serde discards the stray key, the
/// top-level field falls back to its default (or fails with a `missing field`
/// error pointing at the wrong place), and the manifest deploys with a
/// declaration nobody honours. Every real `workload.toml` in the camp and the
/// CLI's own `yah cloud site init` scaffold carried exactly that shape for
/// months without a single reader noticing.
///
/// The cost is forward-compat: a manifest carrying a `[build]` key this binary
/// doesn't know is a hard parse error, not an ignored key. That is deliberate.
/// A build config is a small, slow-moving, load-bearing table — a key that
/// silently does nothing is worse here than one that refuses to load, because
/// the failure surfaces as a wrong artifact rather than an error.
///
/// Note `deny_unknown_fields` is inert for the postcard kamaji wire, which is
/// non-self-describing and positional — this only constrains TOML/JSON.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct BuildConfig {
    /// Shell command run from the manifest's directory, e.g. `"bun run build"`.
    ///
    /// **Absent means "this project has no external bundler step" (R838-B1)**,
    /// not "run nothing by accident". `mesofact new`'s scaffold deliberately
    /// omits it — the in-process pipeline (`mesofact-dev` / `mesofact-build`)
    /// produces `out_dir` with no third binary, no package manager and no Node
    /// — so requiring it here made every scaffolded project's manifest fail to
    /// load through this envelope. Setting it opts back out to a shell command,
    /// which is what a project with its own bundler wants.
    ///
    /// Consumers were already written for this: `read_workload_build`
    /// (app/yah/cli/src/cloud.rs) has always typed it `Option<String>` and
    /// `yah cloud bundle build` only needs it under `--run-build`; the bundle
    /// sync arm refuses `None` by name. `MesofactStaticReconciler::
    /// rebuild_static` skips the build step for `None` — the same thing it
    /// already did for a workload with no `workload.toml` at all.
    ///
    /// WIRE NOTE: this is `Option<String>` on the postcard kamaji wire, so it
    /// costs a leading `0x00`/`0x01` tag byte that the bare `String` did not
    /// have. A pre-R838 node decoding a new frame fails loudly (the string's
    /// length byte is not a valid `Option` tag) rather than silently reading a
    /// shifted field — which is why this is `Option` and not a `#[serde(default)]`
    /// empty `String` sentinel. Not a `cluster_epochs` surface: those hash the
    /// raft modules and the openraft pin, not `workload_spec`.
    #[serde(default)]
    pub command: Option<String>,

    /// Output directory (relative to the manifest) the reconciler uploads.
    pub out_dir: PathBuf,

    /// Data-only re-render command (W225 §3 "revalidate"), run from the
    /// manifest's directory against the **already-built** `out_dir` — no
    /// bundler. `{route}` is substituted with the invalidated route pattern,
    /// e.g. `"../../../../scripts/mesofact-build.sh render . --route {route}
    /// --all"` (R746-F9 — resolves a prebuilt binary rather than shelling to
    /// cargo, which cannot even find the package from a site's own dir).
    /// Absent → a revalidate dispatch republishes `out_dir` as-is.
    #[serde(default)]
    pub render_command: Option<String>,
}

// ── BuildMode ─────────────────────────────────────────────────────────────────

/// Where the build command runs for a `mesofact-static` workload.
///
/// The two-role split encodes the F7 design decision: build/publish is a
/// **transient job** (runs once, exits, GC'd); SSR/SPA serving is a separate
/// **long-lived companion container** (optional, only for dynamic pages). A
/// single merged "mesofact container" is the trap — in cloud, CI builds the
/// artifact, R2+CDN serve it, and a distinct worker handles any SSR.
///
/// Default: `HostSide` — mesofact-dev runs the build on the host and publishes
/// to the tier's object store. No container overhead; compatible with dev and
/// sim tiers.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum BuildMode {
    /// Build command runs on the host (mesofact-dev watcher). The watcher
    /// publishes the output to the tier's object store (DistPointer for dev,
    /// MinIO for sim). Compatible with all tiers; zero container overhead.
    #[default]
    HostSide,

    /// Build runs inside a transient container matching the CI image. Higher
    /// fidelity (environment matches CI exactly); costs image pull +
    /// container cold-start. Required for cloud/HA where no mesofact-dev
    /// watcher is running on the host.
    InContainer {
        /// Container image that runs the build (e.g. `"ghcr.io/org/app-build:v1.2"`).
        /// Must have the build toolchain installed. The container is started with
        /// the workspace root bind-mounted, runs `build.command`, uploads
        /// `build.out_dir` to the object store, then exits.
        image: ImageRef,
    },
}

// ── AlmanacManifest ───────────────────────────────────────────────────────────

/// An observable endpoint the almanac scheduler probes to check readiness.
///
/// Used for both inputs (checked before the run) and outputs (verified after
/// a successful run to confirm the job produced something reachable).
/// The probe is intentionally lightweight — no S3 SigV4, no xlb-net discovery
/// required; a simple TCP connect or HTTP GET is enough for the dev/sim tier.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AlmanacTarget {
    /// Issue an HTTP GET to `url`; ready when the server responds with
    /// `expect_status` (default: any 2xx).
    Http {
        url: String,
        #[ts(optional = nullable)]
        expect_status: Option<u16>,
    },

    /// Establish a TCP connection to `host:port`; ready when the connect
    /// succeeds. Used for non-HTTP services (e.g. MinIO API on port 9000)
    /// and as a lighter probe when an HTTP endpoint isn't stable yet.
    Tcp { host: String, port: u16 },
}

/// What the almanac scheduler does when a precondition check fails.
///
/// The F9 design decision: `WaitWithTimeout` is the default. Fail-fast is
/// too brittle for the sim tier (containers may still be cold-starting);
/// requeue-with-no-ceiling can block the scheduler indefinitely. The
/// recommended timeout for sim is the container spinup budget (~5 s cold,
/// ~1 s warm): set `timeout` to a few seconds, then let the retry cadence
/// handle transient glitches.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum NotReadyPolicy {
    /// Wait up to `timeout` for all preconditions to pass before aborting
    /// the run. The run is skipped (not rescheduled); the next cadence tick
    /// will retry. Suitable when targets occasionally lag at startup.
    WaitWithTimeout {
        /// How long to wait for each precondition to become reachable. The
        /// scheduler polls with a short sleep between attempts.
        timeout: Millis,
    },

    /// Abort immediately if any precondition check fails. Suitable for
    /// integration-test harnesses where a missing dependency is always a
    /// hard error.
    FailFast,

    /// Requeue with exponential backoff up to `max_attempts` times. After
    /// exhaustion the run is marked failed. Suitable for cloud/HA where
    /// transient dependency outages are expected.
    Requeue {
        /// Maximum number of requeue attempts before the run is marked failed.
        max_attempts: u32,
        /// Initial backoff between attempts, in milliseconds.
        backoff: Millis,
    },
}

impl Default for NotReadyPolicy {
    /// Default is `WaitWithTimeout { timeout: 5 seconds }` — matches the
    /// container spinup budget for the sim tier (few-second cold, sub-second warm).
    fn default() -> Self {
        Self::WaitWithTimeout { timeout: Millis::from_secs(5) }
    }
}

/// When the almanac scheduler triggers a run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum Cadence {
    /// Run once at first opportunity, then never again.
    Once,

    /// Run repeatedly with a fixed interval between the end of one run and
    /// the start of the next. Equivalent to `sleep N && run` in a loop.
    Every {
        /// Minimum time between consecutive run completions.
        interval: Millis,
    },

    /// Run on a UTC cron schedule (standard 5-field expression, e.g.
    /// `"0 */6 * * *"` for every 6 hours). The scheduler evaluates the
    /// expression relative to UTC midnight.
    Cron { expression: String },
}

/// `kind = "almanac"` manifest — a declared data-pipeline job.
///
/// An almanac job is the generalisation of the OpenRouter refresher
/// (`spawn_almanac_refresher`): it declares its I/O contract explicitly so
/// the orchestrator can enforce preconditions before each run and verify
/// outputs afterward. The degenerate case (no inputs, no app target, cron
/// schedule) is exactly the OpenRouter JSON-cache refresher.
///
/// Lifecycle:
/// 1. Cadence tick fires.
/// 2. Scheduler probes every `inputs` target. If any fail → apply
///    `not_ready_policy`.
/// 3. Command runs (`sh -c command` from the workload directory).
/// 4. Scheduler probes every `outputs` target. Failure → mark run as
///    failed but do not retry.
/// 5. Any workloads listed in `invalidates` receive a cache-bust signal
///    (implementation detail of the orchestrator; in camp this is a
///    rebuild trigger on the mesofact-dev watcher).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct AlmanacManifest {
    /// Wire-format version. Always `V1` today.
    pub schema_version: SchemaVersion,

    /// Shell command executed via `sh -c` from the workload directory.
    pub command: String,

    /// When to run.
    pub cadence: Cadence,

    /// Input targets that must be reachable before the command runs.
    /// Empty list → no precondition checks (degenerate case).
    #[serde(default)]
    pub inputs: Vec<AlmanacTarget>,

    /// Output targets verified after a successful run.
    /// Empty list → no post-run verification.
    #[serde(default)]
    pub outputs: Vec<AlmanacTarget>,

    /// What to do when a precondition check fails.
    /// Default: `WaitWithTimeout { timeout: 5000ms }`.
    #[serde(default)]
    pub not_ready_policy: NotReadyPolicy,

    /// Mesh identities of workloads to notify after a successful run.
    /// The orchestrator sends a cache-bust signal to each entry so
    /// downstream consumers can reload their data (e.g. mesofact-dev
    /// triggers a rebuild when the OpenRouter cache refreshes).
    /// Empty list → no downstream invalidation.
    #[serde(default)]
    pub invalidates: Vec<MeshIdent>,
}

// ── StaticAssetWorkload ───────────────────────────────────────────────────────

/// BLAKE3 content hash expressed as exactly 64 ASCII hex digits.
///
/// This is the content-address key for every file in the static-asset catalog.
/// Deserialization rejects values that do not conform — 64 hex chars, case
/// insensitive. Mismatch between the recorded hash and the source file halts
/// the upload step in the reconciler.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[ts(type = "string")]
pub struct BlakeHash(pub String);

impl<'de> Deserialize<'de> for BlakeHash {
    fn deserialize<D>(de: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let s = String::deserialize(de)?;
        if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
            return Err(serde::de::Error::custom(format!(
                "blake3 hash must be exactly 64 hex digits, got {:?}",
                s
            )));
        }
        Ok(BlakeHash(s))
    }
}

// ── License & FetchSource (W164) ──────────────────────────────────────────────

/// Closed-set, parse-time-enforced license tag. Mirrors the workspace
/// permissive-license rule (MIT / Apache-2.0 / BSD-2/3-Clause / ISC). Adding a
/// variant is an explicit schema change — non-permissive strings
/// (`"GPL-3.0"`, `"AGPL"`, etc.) fail at serde-deserialize before any shape
/// validator runs.
///
/// Shared between `asset.derive.fetch.license` (W164, required) and a future
/// `almanac::ReleaseSource.license` migration (R438-F10, optional).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum License {
    Mit,
    Apache2,
    Bsd2Clause,
    Bsd3Clause,
    Isc,
}

/// Shared fetch primitive — usable by `asset.derive` today, and by Almanac's
/// `ReleaseSource` after a follow-up migration (R438-F10). Defined once in
/// workload-spec so both consumers reject the same set of non-permissive
/// licenses.
///
/// The `blake3` hash pins the upstream bytes; mismatch at fetch time is a hard
/// error in the reconciler. The `license` field is **required** here — every
/// derived asset must declare its upstream license. If/when Almanac adopts
/// `FetchSource`, the Almanac side may wrap this in a struct with
/// `Option<License>` since release manifests have no distribution license per
/// se.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FetchSource {
    /// Upstream URL fetched verbatim. Reconciler retry policy is configured
    /// elsewhere (R438-F11); the URL itself is opaque to workload-spec.
    pub url: String,

    /// Expected BLAKE3 hash of the fetched bytes (64 hex characters). The
    /// reconciler verifies this after download and aborts on mismatch.
    pub blake3: BlakeHash,

    /// Upstream license. Closed-set, parse-time enforced.
    pub license: License,
}

/// Optional transform applied after a [`FetchSource`] download, lowering to a
/// `ForgeCommand::Subprocess` via the recipe loader (R438-T4). The transform's
/// output is content-addressed by the entry's `blake3` (the recipe runs only
/// when the cache misses).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct TransformSpec {
    /// Named recipe under `.yah/qed/transforms/<recipe>.toml`. Loader rejects
    /// missing recipes at materialize time.
    pub recipe: String,

    /// `{{key}}` substitutions passed to the recipe argv at element
    /// granularity (no shell, no string concat). Empty when the recipe is
    /// fully parameterless.
    #[serde(default)]
    pub params: BTreeMap<String, String>,
}

/// W212/R518: the committed derivation lock — the in-tree action-cache
/// receipt. `input_hash` is the input-addressed derivation key computed over
/// the complete declared input set (fetched-input pin ⊕ recipe-file bytes ⊕
/// invocation params ⊕ schema version); `output_blake3` is what those inputs
/// produced (== the entry's `blake3`). The reconciler skips the entire build
/// (no fetch, no transform, no PUT) when the lock matches the inputs recomputed
/// from the current pins and the bucket already holds the output — the
/// Nix-substituter / Bazel-remote-cache behaviour. Written by the R510 bind
/// path from the reconciler's `discovered_input_hash:<filename>` output; the
/// `git diff` on this block is the receipt that the derivation rolled.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DeriveLock {
    /// Input-addressed derivation key (BLAKE3 hex). A change to any declared
    /// input flips this, so a stale lock never produces a false skip.
    pub input_hash: String,
    /// Output the locked inputs produced (BLAKE3 hex; equals the entry's
    /// `blake3`). Carried so the lock is a self-contained action-cache entry.
    pub output_blake3: String,
}

/// Provenance chain for a derived asset: required `fetch` step, optional
/// `transform` step. Materialized bytes replace `AssetEntry.source` for the
/// rest of the static-asset reconcile loop.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct AssetDerive {
    /// Upstream fetch — URL + content-pin + license.
    pub fetch: FetchSource,

    /// Post-fetch transform. `None` → the fetched bytes ARE the asset
    /// (entry `blake3` must match fetch `blake3`).
    #[serde(default)]
    #[ts(optional = nullable)]
    pub transform: Option<TransformSpec>,

    /// W212/R518: committed derivation lock (input-addressed action-cache
    /// receipt). Absent until the first successful build writes it via the
    /// bind path. When present and current, enables the substituter-style
    /// build skip.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub lock: Option<DeriveLock>,
}

/// A single file entry in the static-asset catalog.
///
/// One `[[asset]]` row per bucket object. Multiple rows for different variants
/// (e.g. q5 and q4 whisper models) are fine — each declares its own filename
/// and hash. The reconciler treats the catalog as exhaustive and append-only:
/// new rows trigger a PUT; removed rows surface as drift (never a DELETE).
///
/// **Source-vs-derive XOR.** Exactly one of `source` or `derive` must be set.
/// Legacy local-bytes assets keep `source = "..."`; W164 derived assets set
/// `[asset.derive]` instead. [`validate::shape_static_asset`] enforces the
/// XOR; both-set and neither-set are hard `ShapeError::Field`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct AssetEntry {
    /// Destination path within the bucket, e.g.
    /// `"whisper/distil-large-v3-q5_1.bin"`. Must be unique in the catalog.
    /// Used as the S3 object key by the reconciler.
    pub filename: String,

    /// Path to a local source file, relative to the `workload.toml` directory.
    /// Mutually exclusive with `derive`.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub source: Option<PathBuf>,

    /// Declared fetch (+ optional transform) provenance chain. The reconciler
    /// materializes the bytes into a content-addressed cache; the cache path
    /// then replaces `source` for the rest of the upload pipeline. Mutually
    /// exclusive with `source`.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub derive: Option<AssetDerive>,

    /// Expected BLAKE3 hash of the *final* asset bytes (64 hex characters).
    /// For `source` mode, this is hashed before upload. For `derive` mode,
    /// it's the post-transform (or post-fetch when no transform) output.
    /// Mismatch aborts the upload.
    pub blake3: BlakeHash,
}

/// `kind = "static-asset"` payload — content-addressed bucket catalog.
///
/// The reconciler makes the bucket match the `[[asset]]` list exactly
/// (append-only: new rows → PUT; removed rows → drift report, not DELETE).
/// Rollback is pointer-flip via `mirror.toml [asset_aliases]` — bytes never
/// move during rollback.
///
/// **Closed-catalog invariant**: every value in `[aliases]` must be a
/// `filename` that exists in `[[asset]]`. Enforced by
/// [`validate::shape_static_asset`]. Mirror overrides (`[asset_aliases]` in
/// `mirror.toml`) are bound by the same rule — the alias graph can only
/// resolve to filenames already in the catalog.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct StaticAssetWorkload {
    /// Wire-format version. Always `V1` today.
    pub schema_version: SchemaVersion,

    /// Exhaustive catalog of files this component manages in the bucket.
    ///
    /// Named `asset` on disk (TOML `[[asset]]` array-of-tables) to follow TOML
    /// convention; accessed as `.assets` in Rust code.
    #[serde(rename = "asset", default)]
    pub assets: Vec<AssetEntry>,

    /// Canonical logical-name → filename mappings for this component.
    ///
    /// Values must be filenames present in `assets` — validated by
    /// [`validate::shape_static_asset`]. Mirror files may override individual
    /// entries via `[asset_aliases]` but may never reference filenames absent
    /// from this catalog.
    #[serde(default)]
    pub aliases: BTreeMap<String, String>,
}

// ── Lifecycle archetype (R572-F1 / W244) ───────────────────────────────────────

/// Explicit lifecycle archetype for a `kind = "container"` workload (W244).
///
/// The question that actually matters to a scheduler: *"can I kill this and
/// recreate it somewhere else?"* Before this field existed, the answer was
/// inferred per-spec from `volumes.is_empty()` + `restart_policy` — fragile
/// absence-as-policy, the same trap W243 calls out on the node-taint side.
/// This type makes the answer structural instead of guessed.
///
/// This ticket (R572-F1) adds the discriminator only. The reconciler does not
/// yet branch on it (R572-F4) and neither does the scheduler (R572-F5).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum LifecycleArchetype {
    /// k8s analogue: Deployment. Stateless and fungible — the scheduler may
    /// move it, scale it to N replicas, or restart it on a different node
    /// with zero consequence. Drainable.
    Server,

    /// k8s analogue: StatefulSet. Stable identity + a volume that must
    /// follow it; at most one live instance. Not drainable — the reconciler
    /// must not schedule it onto a different node. Example: a postgres peer,
    /// headscale (W267/R591).
    Appliance,

    /// k8s analogue: Job. Runs to completion with declared inputs/outputs,
    /// then is gone — no steady-state identity. `almanac` is the first
    /// job-family member; forge runs (`WorkloadSpec::for_forge`, used by QED)
    /// are the `container`-kind instance of this archetype.
    Job,
}

impl LifecycleArchetype {
    /// Every variant, in declaration order. Exists so a consumer can enumerate
    /// the archetypes without hand-maintaining a parallel list — the taint
    /// vocabulary in `cloud::config::taint_effect` is built from this, so
    /// adding a fourth archetype extends the set of live repel keys for free.
    pub const ALL: [LifecycleArchetype; 3] = [Self::Server, Self::Appliance, Self::Job];

    /// The repel-taint key for this archetype (R572-F5). A node carrying the
    /// taint `"no-<key>"` **absolutely** rejects workloads of this class.
    ///
    /// Examples: `Server` → `"server"` (repelled by `"no-server"`);
    /// `Appliance` → `"appliance"` (repelled by `"no-appliance"`).
    ///
    /// W305/R742-T4: there is no toleration. Earlier prose here and in
    /// `cloud::config` called this "repel-unless-tolerate"; the `unless` was
    /// never built, and reading it as a preference is what made `no-appliance`
    /// on the dev Pis look advisory when it was an unconditional block.
    pub fn taint_key(&self) -> &'static str {
        match self {
            Self::Server => "server",
            Self::Appliance => "appliance",
            Self::Job => "job",
        }
    }

    /// The pre-R572 inference this field replaces, kept only to give
    /// `WorkloadSpec::effective_archetype` a behavior-preserving fallback for
    /// specs written before this field existed (`archetype: None`).
    ///
    /// A volume that must follow the workload is the strongest signal of
    /// durable state → [`Self::Appliance`]. Absent that, `RestartPolicy::Never`
    /// is the existing forge/run-once convention (see
    /// [`RestartPolicy::Never`]'s doc comment) → [`Self::Job`]. Everything
    /// else defaults to the common case, [`Self::Server`].
    fn infer(volumes: &[VolumeMount], restart_policy: &RestartPolicy) -> Self {
        if !volumes.is_empty() {
            LifecycleArchetype::Appliance
        } else if matches!(restart_policy, RestartPolicy::Never) {
            LifecycleArchetype::Job
        } else {
            LifecycleArchetype::Server
        }
    }
}

// ── WorkloadSpec ──────────────────────────────────────────────────────────────

/// Complete typed description of a containerd workload handed to yubaba over
/// RPC. This is also the payload of the `kind = "container"` variant of
/// [`Workload`] on disk.
///
/// Yubaba never accepts compose YAML on its RPC surface — agents, the desktop,
/// and operator CLIs all hand yubaba `WorkloadSpec` values. See the arch doc
/// for the validation layers and evolution rules.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct WorkloadSpec {
    /// Wire-format version; always `V1` today. Present at the top level so
    /// rolling clusters can detect and migrate across schema generations.
    pub schema_version: SchemaVersion,

    /// DNS-friendly workload name, e.g. `"noisetable-api"`. Regex:
    /// `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
    pub name: String,

    /// Container image to pull.
    pub image: ImageRef,

    /// Tier tag controlling admission control and mesh filtering.
    pub tier: TierTag,

    /// Tenant **isolation** axis (W206). Separates operators' workloads at the
    /// network / DB / mesh-identity level. Defaults to [`TenantId::singleton`]
    /// for specs that predate the axis, so single-tenant clusters keep every
    /// isolation primitive a no-op. Orthogonal to [`Self::tier`] (class) and
    /// [`Self::namespace`] (routing).
    #[serde(default = "TenantId::singleton")]
    pub tenant: TenantId,

    /// Namespace **routing/naming** axis (W206). A pure naming key — never
    /// affects isolation; disambiguates DNS names and selects config root /
    /// provider zone within a tenant. Defaults to [`NamespaceId::singleton`].
    #[serde(default = "NamespaceId::singleton")]
    pub namespace: NamespaceId,

    /// Target replica count. `0` registers the workload without deploying it.
    /// Range: 0–100 (cluster-wide cap; operator can raise it).
    pub replicas: u32,

    /// Override the image's `CMD`. `None` leaves the image default.
    #[ts(optional = nullable)]
    pub command: Option<Vec<String>>,

    /// Override the image's `ENTRYPOINT`. `None` leaves the image default.
    #[ts(optional = nullable)]
    pub entrypoint: Option<Vec<String>>,

    /// Working directory inside the container.
    #[ts(optional = nullable)]
    pub workdir: Option<PathBuf>,

    /// User to run as, e.g. `"1000:1000"` or `"appuser"`.
    #[ts(optional = nullable)]
    pub user: Option<String>,

    /// Environment variables. Values may be literals, secret refs, or
    /// mesh-address references resolved by yubaba at deploy time.
    #[serde(default)]
    pub env: Vec<EnvVar>,

    /// Secret mounts. Values never appear in the spec JSON — only references.
    #[serde(default)]
    pub secrets: Vec<SecretMount>,

    /// Volume mounts.
    #[serde(default)]
    pub volumes: Vec<VolumeMount>,

    /// Hard resource caps enforced by containerd/cgroups.
    pub resources: ResourceLimits,

    /// Mesh idents that must reach `Ready` before this workload starts.
    #[serde(default)]
    pub depends_on: Vec<MeshIdent>,

    /// Container liveness/readiness probe.
    #[ts(optional = nullable)]
    pub healthcheck: Option<Healthcheck>,

    /// What yubaba does when the container exits.
    pub restart_policy: RestartPolicy,

    /// Explicit lifecycle archetype (R572-F1 / W244): `server`, `appliance`,
    /// or `job`. `None` means the spec predates this field (or the author
    /// didn't set it) — callers MUST NOT read this directly to decide
    /// drainability; use [`WorkloadSpec::effective_archetype`], which falls
    /// back to the pre-R572 `volumes`/`restart_policy` inference so no
    /// existing spec's effective meaning changes.
    ///
    /// Additive: this field did not exist before R572-F1. Reconciler (F4)
    /// and scheduler (F5) branching on the resolved archetype are separate,
    /// later tickets — this field alone changes no runtime behavior.
    #[serde(default)]
    #[ts(optional = nullable)]
    pub archetype: Option<LifecycleArchetype>,

    /// Graceful shutdown configuration.
    pub stop_policy: StopPolicy,

    /// Network exposure configuration — mesh, public, and operator channels
    /// are independent and can be set in any combination.
    pub expose: ExposeSpec,

    /// OCI-style labels, passed through to the container. Opaque to yubaba.
    #[serde(default)]
    pub labels: HashMap<String, String>,

    /// Yah-specific metadata, conventionally prefixed `yah.*`. Opaque to
    /// yubaba beyond `yah.forge=true` which suppresses the Never-restart guard.
    #[serde(default)]
    pub annotations: HashMap<String, String>,
}

impl WorkloadSpec {
    /// Build a `WorkloadSpec` for a forge run.
    ///
    /// Sets the conventional forge fields in one place so callers cannot
    /// forget any of them:
    ///
    /// - `restart_policy = Never`
    /// - `archetype = Some(LifecycleArchetype::Job)` — a forge run is
    ///   exactly the `container`-kind instance of the job archetype (W244);
    ///   set explicitly rather than left to infer since this constructor
    ///   knows its own shape
    /// - `expose.public = None`, `expose.operator = None`
    /// - `expose.mesh.identity = "forge.<forge_id>"`
    /// - `annotations["yah.forge"] = "true"` (suppresses the shape warning)
    /// - `tier` and `image` come from the caller; `ports` becomes the mesh
    ///   port list (empty is valid — forge jobs often don't expose ports)
    ///
    /// All other fields are set to safe defaults. Callers can mutate the
    /// returned value to fill in `command`, `env`, `resources`, etc.
    pub fn for_forge(
        forge_id: &str,
        image: ImageRef,
        tier: TierTag,
        ports: Vec<u16>,
    ) -> Self {
        let mut annotations = HashMap::new();
        annotations.insert("yah.forge".into(), "true".into());
        // The placement floor, kept distinct from the cgroup ceiling below.
        // Without this, admission reads the 32 GiB ceiling as the amount of
        // RAM a node must have — see `memory_request_mb` for what that cost.
        annotations.insert(
            MEMORY_REQUEST_ANNOTATION.into(),
            FORGE_MEMORY_REQUEST_MB.to_string(),
        );

        WorkloadSpec {
            schema_version: SchemaVersion::V1,
            // NB: DNS-label safe (no dots) — `check_name` validation rejects
            // dots here. The container_id derives from this; the state-poll
            // keys off `expose.mesh.identity` (`forge.<id>`) instead, so those
            // two must be reconciled at the read path, NOT by dotting the name
            // (see R590-B9).
            name: format!("forge-{forge_id}"),
            image,
            tier,
            tenant: TenantId::singleton(),
            namespace: NamespaceId::singleton(),
            replicas: 1,
            command: None,
            entrypoint: None,
            workdir: None,
            user: None,
            env: vec![],
            secrets: vec![],
            volumes: vec![],
            resources: ResourceLimits {
                // R590-B10: forge workloads are BUILDS (cargo, buildkit, a
                // from-source V8 checkout+compile), not tiny services. The old
                // 256 MB placeholder became a hard cgroup memory.limit in
                // build_oci_spec and SIGKILL'd the rusty-v8 build mid-checkout
                // (git checkout of third_party/icu died of signal 9) — the
                // more so because /tmp is a RAM-backed tmpfs, so the source
                // tree counts against this limit too. 32 GiB is a bounded
                // ceiling that fits the V8 build's >12 GB peak with headroom,
                // protects the host from a runaway (vs truly unlimited), and is
                // above physical RAM on smaller build-workers (⇒ effectively
                // unlimited there).
                //
                // That last clause is only true while this stays a CEILING. It
                // was also the placement floor until the annotation set above
                // split the two, which made every build-worker under 32 GiB
                // unschedulable — the story is on `memory_request_mb`.
                memory_mb: FORGE_MEMORY_LIMIT_MB,
                cpu_millis: 512,
                ephemeral_storage_mb: 512,
            },
            depends_on: vec![],
            healthcheck: None,
            restart_policy: RestartPolicy::Never,
            archetype: Some(LifecycleArchetype::Job),
            stop_policy: StopPolicy {
                signal: 15,
                grace_period: Millis::from_secs(30),
            },
            expose: ExposeSpec {
                mesh: MeshExpose {
                    identity: MeshIdent(format!("forge.{forge_id}")),
                    ports,
                    allow_from: vec![],
                },
                public: None,
                operator: None,
            },
            labels: HashMap::new(),
            annotations,
        }
    }

    /// Whether this workload requests the **host network namespace** rather
    /// than an isolated one.
    ///
    /// Opt-in via `annotations["yah.network"] == "host"` (see
    /// [`HOST_NETWORK_ANNOTATION`] / [`HOST_NETWORK_VALUE`]). Default is the
    /// isolated netns every other workload gets — host networking is a
    /// privileged escape hatch for the few infra workloads that must bind a
    /// host port so an on-host ingress (e.g. a Cloudflare tunnel reaching
    /// `127.0.0.1:<port>`) can route to them without CNI/bridge plumbing.
    ///
    /// The backend (kamaji) is responsible for **guarding** this: host
    /// networking is only honoured for `tier == "infra"` workloads; a
    /// non-infra workload that sets the annotation is rejected at deploy. See
    /// `validate_spec_for_constable`.
    pub fn wants_host_network(&self) -> bool {
        self.annotations
            .get(HOST_NETWORK_ANNOTATION)
            .map(|v| v == HOST_NETWORK_VALUE)
            .unwrap_or(false)
    }

    /// Resolve the lifecycle archetype (R572-F1 / W244): the explicit
    /// [`Self::archetype`] if set, otherwise the pre-R572 inference from
    /// `volumes`/`restart_policy` this field replaces.
    ///
    /// This is the one seam callers should use to ask "can I kill and
    /// reschedule this?" — it is intentionally the *only* place that
    /// implements the fallback, so behavior for pre-existing specs (no
    /// `archetype` on disk) is identical to what it was before this field
    /// existed. Consumers (reconciler R572-F4, scheduler R572-F5) branch on
    /// the return value; this crate does not itself change any reconciler or
    /// scheduler behavior.
    pub fn effective_archetype(&self) -> LifecycleArchetype {
        self.archetype
            .unwrap_or_else(|| LifecycleArchetype::infer(&self.volumes, &self.restart_policy))
    }

    /// Fully-qualified mesh identity `<tenant>/<namespace>/<name>` (W206 /
    /// R558-F3), where `<name>` is this workload's [`MeshExpose::identity`].
    ///
    /// Within a tenant, workloads still address each other by the short
    /// identity (namespace disambiguates only on collision); the FQN is what
    /// makes the identity unambiguous across tenants and is exactly what a
    /// [`MeshPeer::CrossTenant`] grant names.
    pub fn fq_mesh_identity(&self) -> String {
        format!(
            "{}/{}/{}",
            self.tenant.0, self.namespace.0, self.expose.mesh.identity.0
        )
    }

    /// The taint this workload requires its node to carry, if any (R594-F2 /
    /// W267 sovereign public ingress).
    ///
    /// Opt-in via `annotations["yah.placement.requires-taint"] = "<taint
    /// name>"` (see [`REQUIRES_TAINT_ANNOTATION`]) — same annotation-based,
    /// zero-blast-radius shape as [`Self::wants_host_network`], chosen so
    /// declaring this requirement does not force a struct-literal edit at
    /// every existing `WorkloadSpec { .. }` construction site the way a new
    /// plain field would (see R572-F1's handoff: ~26 sites for one field).
    ///
    /// Both halves have since landed: `MachineConfig.taints` (R572-F3) and the
    /// scheduler's affinity check in `cloud::config::RequiredSpec::matches`
    /// (R572-F5), which requires the key in the node's `taints` **or**
    /// `mesh_tags`.
    ///
    /// A key named here is one of only two ways a node taint can influence
    /// placement — the other is the `no-<archetype>` repulsion form. W305/
    /// R742-T4 makes `yah cloud validate` reject any node taint that is
    /// neither, so a new affinity key must be added to
    /// `cloud::config::AFFINITY_TAINT_KEYS` alongside the workload that
    /// requires it.
    ///
    /// The public-ingress appliance (W267) is the first user: a
    /// `kind = "container"` workload with `archetype =
    /// Some(LifecycleArchetype::Appliance)` and
    /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, so yubaba may one day
    /// place it only on machines carrying the `"public-ip"` taint and kamaji
    /// supervises it like any other container (no new `Workload` variant —
    /// see [`Workload::Container`]'s doc comment).
    pub fn requires_taint(&self) -> Option<&str> {
        self.annotations
            .get(REQUIRES_TAINT_ANNOTATION)
            .map(String::as_str)
    }

    /// The memory (MiB) a scheduler must find on a node before placing this
    /// workload — its **request**, as distinct from [`ResourceLimits::memory_mb`],
    /// which is a **ceiling** the backend turns into a cgroup `memory.max`.
    ///
    /// Opt-in via `annotations["yah.placement.memory-request-mb"]` (see
    /// [`MEMORY_REQUEST_ANNOTATION`]); absent or unparseable falls back to
    /// `resources.memory_mb`, so every spec that does not set it is admitted
    /// exactly as it was before this accessor existed.
    ///
    /// # Why the two numbers must not be the same one
    ///
    /// A limit answers "kill it past here"; a request answers "don't start it
    /// somewhere smaller than here". Generous is the safe direction for the
    /// first and the unschedulable direction for the second, so one field
    /// serving both makes a deliberately-roomy ceiling into an admission floor.
    ///
    /// That is not hypothetical: [`WorkloadSpec::for_forge`] sets a 32 GiB
    /// ceiling explicitly reasoned as "above physical RAM on smaller
    /// build-workers ⇒ effectively unlimited there" (R590-B10), and
    /// `CloudConfig::admit_workload` fed that same 32768 in as the R572-F5
    /// capacity floor. Every build-worker under 32 GiB — the three 8 GiB Pi-5s
    /// and the 16 GiB us-west-003 — became structurally unadmittable for *any*
    /// offloaded qed step, leaving one 47 GiB node as the fleet's only legal
    /// target for remote CI. This is R590-B10's own recorded follow-up
    /// ("thread a per-step memory request … instead of a blanket forge
    /// default"), reduced to the seam that closes the bug.
    ///
    /// An annotation rather than a new `ResourceLimits` field on purpose:
    /// `WorkloadSpec` crosses a postcard wire that is positional and
    /// carries no field names (R590-B3), so adding a field would break decode
    /// on every fleet node still running an older kamaji. `annotations` is an
    /// existing map — an extra key rides it safely, and admission already
    /// reads placement inputs from exactly there
    /// ([`Self::requires_taint`], the R594 node-selector).
    pub fn memory_request_mb(&self) -> u32 {
        self.annotations
            .get(MEMORY_REQUEST_ANNOTATION)
            .and_then(|v| v.trim().parse::<u32>().ok())
            .unwrap_or(self.resources.memory_mb)
    }

    /// Whether this workload must be run by kamaji's **native** (fork+exec)
    /// backend on the node's own userland, rather than by a container backend
    /// (R577-T1 / W254).
    ///
    /// Opt-in via `annotations["yah.exec"] == "native"` (see
    /// [`NATIVE_EXEC_ANNOTATION`] / [`NATIVE_EXEC_VALUE`]) — the same
    /// annotation-shaped, zero-blast-radius marker as
    /// [`Self::wants_host_network`] and [`Self::requires_taint`], chosen over
    /// a new plain field for the reason R572-F1 recorded: a field forces a
    /// struct-literal edit at every existing construction site and an
    /// exhaustive-match update in `kamaji-proto`'s codec, and this marker
    /// needs neither.
    ///
    /// # Why an annotation and not a runtime enum on the wire
    ///
    /// The remote-execution wire already carries exactly one workload shape —
    /// `Workload::Container(WorkloadSpec)` — and every layer between the
    /// dispatcher and the node (yubaba admission, mesh assignment, log
    /// ingest, produced-file retrieval, teardown) is written against it. A
    /// Darwin build differs from a Linux build in *one* respect: there is no
    /// container that can host it, because you cannot containerize the Darwin
    /// kernel. Marking that one difference keeps the rest of the path shared
    /// instead of growing a parallel `exec_native` RPC that would have to
    /// re-implement all of it.
    ///
    /// `image` stays populated for a native workload and is **identity
    /// metadata only** — nothing is pulled; the native backend resolves argv
    /// from `entrypoint` + `command` (container semantics) and execs it on
    /// the host.
    pub fn wants_native_exec(&self) -> bool {
        self.annotations
            .get(NATIVE_EXEC_ANNOTATION)
            .map(|v| v == NATIVE_EXEC_VALUE)
            .unwrap_or(false)
    }

    /// Whether this workload must be run by kamaji's **microVM** backend —
    /// booted in its own KVM guest with its own kernel, rather than sharing the
    /// host kernel with every other workload on the node (R605-F8 / W325 §5).
    ///
    /// Opt-in via `annotations["yah.exec"] == "microvm"` (see
    /// [`NATIVE_EXEC_ANNOTATION`] / [`MICROVM_EXEC_VALUE`]).
    ///
    /// # Why the *same* key as native exec, not a new one
    ///
    /// W325's Shape A calls this "a sibling branch on a new annotation value",
    /// and the value — not the key — is the whole point. `yah.exec` names the
    /// execution substrate, and a workload has exactly one:
    ///
    /// | `yah.exec` | substrate | kernel | isolation |
    /// |---|---|---|---|
    /// | *(absent)* | container backend | host's | namespaces + cgroup |
    /// | `native` | fork+exec on the host | host's | **none** |
    /// | `microvm` | KVM guest | **its own** | hardware |
    ///
    /// A second key (`yah.isolation = microvm`, say) would make
    /// `yah.exec = native` + `yah.isolation = microvm` *expressible*, and
    /// therefore something a dispatcher could emit and a backend would have to
    /// refuse — exactly the refusal `validate_native_exec_spec` already has to
    /// carry for the `yah.sandbox` pair, and for the same avoidable reason. A
    /// map key holds one value, so on this key the three substrates are
    /// mutually exclusive *by construction*: there is no spec on which both
    /// this and [`Self::wants_native_exec`] return `true`, and
    /// `exec_substrate_markers_are_mutually_exclusive_by_construction` pins
    /// that.
    ///
    /// # What the marker does and does not promise
    ///
    /// Like every marker on this struct it is **inert metadata** — it declares
    /// intent and nothing more. Whether a node can honour it is a node
    /// capability question (`/dev/kvm`, a guest kernel, a rootfs; see W325 §4),
    /// and a node whose kamaji has no microVM backend configured **refuses**
    /// the deploy rather than falling back to a container. That refusal is
    /// deliberate and mirrors R577-T1's: a caller asking for microVM isolation
    /// is asking for the one property a container cannot provide, so silently
    /// downgrading it would return success while delivering the thing the
    /// caller specifically declined.
    ///
    /// `image` is identity metadata only, as it is for native exec — nothing is
    /// pulled. The guest's root filesystem comes from the node's configured
    /// rootfs image, and argv is resolved from `entrypoint` + `command` with
    /// container semantics, so one spec shape drives all three substrates.
    pub fn wants_microvm(&self) -> bool {
        self.annotations
            .get(NATIVE_EXEC_ANNOTATION)
            .map(|v| v == MICROVM_EXEC_VALUE)
            .unwrap_or(false)
    }

    /// Whether this workload builds its **own unprivileged container sandbox**
    /// inside the one the backend gives it, and therefore needs the two
    /// capabilities plus the `no_new_privs` relaxation that setting up a
    /// user namespace requires (R636-B2).
    ///
    /// Opt-in via `annotations["yah.sandbox"] == "nested"` (see
    /// [`NESTED_SANDBOX_ANNOTATION`] / [`NESTED_SANDBOX_VALUE`]) — the same
    /// annotation-shaped, zero-blast-radius marker as
    /// [`Self::wants_host_network`] and [`Self::wants_native_exec`].
    ///
    /// # What it actually grants, and why exactly that
    ///
    /// Rootless BuildKit (the only user today: remote `build-image` steps
    /// dispatch `moby/buildkit:*-rootless`) boots through `rootlesskit`, which
    /// must map a range of sub-uids into a fresh user namespace. It does that
    /// by exec'ing the **setuid-root** helpers `newuidmap` / `newgidmap`, so
    /// it needs `CAP_SETUID` + `CAP_SETGID` in the bounding set *and*
    /// `noNewPrivileges = false` (with `no_new_privs` on, the kernel silently
    /// strips the setuid bit and the helper fails with "Could not set caps").
    ///
    /// Each of those three was measured on us-west-002 to be **individually
    /// necessary** — dropping any one of them puts `rootlesskit` back to
    /// failing before the first layer:
    ///
    /// | grant | `rootlesskit` result |
    /// |---|---|
    /// | baseline (`CAP_NET_BIND_SERVICE` only, `nnp` on) | `fork/exec /usr/bin/newuidmap: operation not permitted` |
    /// | `+CAP_SETUID` only, `nnp` off | `fork/exec /usr/bin/newgidmap: operation not permitted` |
    /// | `+CAP_SETUID +CAP_SETGID`, `nnp` **on** | `newuidmap: Could not set caps` |
    /// | `+CAP_SETUID +CAP_SETGID`, `nnp` off | starts; build runs to completion |
    ///
    /// It is deliberately *not* `CAP_SYS_ADMIN`: a non-rootless buildkitd
    /// would need that instead, which is a far wider grant. Emptying
    /// `/etc/subuid` to force `rootlesskit`'s single-mapping path does not
    /// avoid the helpers either — it just fails earlier with "No subuid
    /// ranges found".
    ///
    /// **The backend guards this.** Like host networking, it is honoured only
    /// for `tier == "infra"` workloads; a non-infra workload that sets the
    /// annotation is rejected at deploy. Every other workload keeps the
    /// `CAP_NET_BIND_SERVICE`-only, `no_new_privs` baseline.
    ///
    /// # Mutually exclusive with [`Self::wants_native_exec`]
    ///
    /// This grant is defined in terms of an **OCI process spec** — a
    /// capability set and a `noNewPrivileges` bit. A native (fork+exec)
    /// workload has no OCI spec, so there is nothing to apply it to; kamaji
    /// refuses a spec carrying both markers rather than accepting a request
    /// for widened privileges and silently dropping it (R577-T1 owns that
    /// refusal). The two are independent *annotations* — neither implies the
    /// other, which is what
    /// `nested_sandbox_marker_is_independent_of_the_other_markers` pins — but
    /// they are not a legal *pair*.
    ///
    /// If a future runtime does have a sandbox worth widening (a MacVM under
    /// W254, say), give it its own annotation rather than relaxing that
    /// refusal. The grant this marker names is `CAP_SETUID` + `CAP_SETGID` +
    /// `no_new_privs` off and nothing else; letting it mean a different
    /// privilege set per backend would make "what does `yah.sandbox=nested`
    /// grant?" unanswerable without knowing which backend received it, which
    /// is precisely what a security-relevant marker must not be.
    pub fn wants_nested_sandbox(&self) -> bool {
        self.annotations
            .get(NESTED_SANDBOX_ANNOTATION)
            .map(|v| v == NESTED_SANDBOX_VALUE)
            .unwrap_or(false)
    }
}

/// Annotation key requesting a workload share the host network namespace.
/// See [`WorkloadSpec::wants_host_network`].
pub const HOST_NETWORK_ANNOTATION: &str = "yah.network";

/// Annotation value (for [`HOST_NETWORK_ANNOTATION`]) selecting host
/// networking. Any other value leaves the workload in an isolated netns.
pub const HOST_NETWORK_VALUE: &str = "host";

/// Annotation key declaring that a workload must land only on a node
/// carrying a specific taint. See [`WorkloadSpec::requires_taint`].
pub const REQUIRES_TAINT_ANNOTATION: &str = "yah.placement.requires-taint";

/// Annotation key carrying a workload's memory **request** in MiB — what a
/// scheduler must find free on a node — separate from the `memory_mb`
/// **ceiling** the backend enforces as a cgroup limit. See
/// [`WorkloadSpec::memory_request_mb`].
pub const MEMORY_REQUEST_ANNOTATION: &str = "yah.placement.memory-request-mb";

/// The memory request [`WorkloadSpec::for_forge`] declares (MiB).
///
/// A forge run is a build, and a build's *ceiling* is deliberately roomy
/// (`FORGE_MEMORY_LIMIT_MB`); this is the much smaller floor a node must have
/// free to be a legal target for one. 2 GiB is what the heaviest forge shape
/// in the tree already asks for by hand — `velveteen_exec::remote`'s buildkit
/// image-build step overrides `resources.memory_mb` to exactly this — so it is
/// a measured number rather than a guess, and it keeps the fleet's 8 GiB
/// build-workers schedulable.
pub const FORGE_MEMORY_REQUEST_MB: u32 = 2048;

/// The cgroup memory ceiling [`WorkloadSpec::for_forge`] sets (MiB).
///
/// Bounded rather than unlimited so a runaway build cannot take the host
/// down, and large enough for the V8 build's >12 GB peak (R590-B10). It is
/// **not** a placement input — see [`FORGE_MEMORY_REQUEST_MB`].
pub const FORGE_MEMORY_LIMIT_MB: u32 = 32768;

/// Taint name (for [`REQUIRES_TAINT_ANNOTATION`]) identifying machines with
/// a publicly-routable IP — the W267 sovereign-ingress placement
/// requirement. `MachineConfig.taints` (R572-F3) is the matching node-side
/// field and `RequiredSpec::matches` (R572-F5) is the consumer, so this is a
/// live key on both sides: a node may carry it, and the cloudflared/passway
/// ingress specs require it.
pub const PUBLIC_IP_TAINT: &str = "public-ip";

/// Annotation key selecting which **execution substrate** kamaji runs a
/// workload on. Absent (or unrecognised) means a container backend; see
/// [`NATIVE_EXEC_VALUE`] and [`MICROVM_EXEC_VALUE`] for the two opt-outs.
///
/// The name is historical — R577-T1 introduced it for native exec alone — but
/// the key has always been the substrate selector, and R605-F8 added the
/// second alternative rather than a second key. See
/// [`WorkloadSpec::wants_microvm`] for why one key matters.
pub const NATIVE_EXEC_ANNOTATION: &str = "yah.exec";

/// Annotation value (for [`NATIVE_EXEC_ANNOTATION`]) selecting native
/// host execution. Any other value leaves the workload on a container
/// backend.
pub const NATIVE_EXEC_VALUE: &str = "native";

/// Annotation value (for [`NATIVE_EXEC_ANNOTATION`]) selecting a **microVM**:
/// the workload boots in its own KVM guest rather than sharing the host
/// kernel. See [`WorkloadSpec::wants_microvm`].
pub const MICROVM_EXEC_VALUE: &str = "microvm";

/// Annotation key requesting the capabilities a workload needs to stand up an
/// unprivileged container sandbox of its own.
/// See [`WorkloadSpec::wants_nested_sandbox`].
pub const NESTED_SANDBOX_ANNOTATION: &str = "yah.sandbox";

/// Annotation value (for [`NESTED_SANDBOX_ANNOTATION`]) requesting the
/// nested-sandbox grant (`CAP_SETUID` + `CAP_SETGID`, `no_new_privs` off).
/// Any other value leaves the workload on the baseline sandbox.
pub const NESTED_SANDBOX_VALUE: &str = "nested";

// ── ImageRef ─────────────────────────────────────────────────────────────────

/// Container image reference identifying a specific image to pull.
///
/// **Digest is required.** Every executable image reference in the workspace
/// is content-addressed by `sha256:<hex>`. The `tag` is preserved as a
/// human-readable identifier but is not the source of truth — registries
/// return mutable `tag → digest` mappings and we don't trust them for
/// reproducibility. R438-T3 tightened `digest: Option<String> → String` to
/// make unpinned-image bugs impossible by construction.
///
/// **Two deserialize shapes.** The struct form
/// (`registry`/`repository`/`tag`/`digest` fields) is the on-disk envelope.
/// A **string form** (`image = "ghcr.io/foo/bar:v1@sha256:<hex>"`) is also
/// accepted and is the shape W164 transform recipes (R438-T4) and W165
/// `BuildMode::InContainer` (R438-T6) use. Both shapes go through a single
/// parser ([`compose_import::parse_pinned_image_ref`]) that rejects
/// bare-tag references at serde-deserialize.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ImageRef {
    /// Registry hostname, e.g. `"ghcr.io"` or `"localhost:5000"`.
    pub registry: String,

    /// Repository path, e.g. `"noisetable/api"`.
    pub repository: String,

    /// Tag, e.g. `"v1.4.2"` or `"latest"`. Informational — the digest is
    /// the source of truth for image identity.
    pub tag: String,

    /// Content-addressed pinned identity, e.g. `"sha256:abc..."`. Required.
    pub digest: String,
}

impl<'de> Deserialize<'de> for ImageRef {
    fn deserialize<D>(de: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct Fields {
            registry: String,
            repository: String,
            tag: String,
            digest: String,
        }

        // The string-or-struct `untagged` probe requires `deserialize_any`,
        // which only self-describing formats support. Postcard — the binary
        // wire behind the kamaji UDS — returns `WontImplement` for it, so a
        // `Workload::Container(WorkloadSpec)` carrying a nested `ImageRef`
        // failed to decode and every container deploy 500'd (R590-B3).
        //
        // The string form is purely an authoring convenience in human-readable
        // configs (`image = "ghcr.io/…@sha256:…"` in recipe/workload TOML and
        // JSON); the binary wire only ever carries the derived struct form
        // (Serialize is a plain struct derive). So branch on the format: text
        // keeps the string-or-struct convenience via `untagged`; binary decodes
        // the plain positional struct with no `deserialize_any`.
        if de.is_human_readable() {
            #[derive(Deserialize)]
            #[serde(untagged)]
            enum Repr {
                // Order matters for `untagged`: try the string form first so
                // explicit strings don't get coerced into a struct error.
                Pinned(String),
                Struct(Fields),
            }

            match Repr::deserialize(de)? {
                Repr::Pinned(s) => {
                    compose_import::parse_pinned_image_ref(&s).map_err(serde::de::Error::custom)
                }
                Repr::Struct(f) => Ok(ImageRef {
                    registry: f.registry,
                    repository: f.repository,
                    tag: f.tag,
                    digest: f.digest,
                }),
            }
        } else {
            let f = Fields::deserialize(de)?;
            Ok(ImageRef {
                registry: f.registry,
                repository: f.repository,
                tag: f.tag,
                digest: f.digest,
            })
        }
    }
}

// ── testing helpers ───────────────────────────────────────────────────────────

/// Fixture helpers for test code that needs to construct types whose schemas
/// would otherwise demand operator-pinned values (digests, hashes). Doc-hidden
/// to discourage misuse from non-test code — production paths must source
/// digests from registry resolution or compile-time injection.
#[doc(hidden)]
pub mod testing {
    /// Fixed valid-format sha256 digest for test fixtures. All-zeros marker
    /// is impossible for any real image, so a leaked test fixture in a
    /// production code-path surfaces obviously.
    ///
    /// Aliases [`super::ImageRef::UNPINNED_DIGEST`] — the two are deliberately
    /// the same value: the fixture sentinel and the production "unpinned"
    /// marker must agree so [`super::ImageRef::pull_ref`]'s tag-fallback fires
    /// on exactly the digest `catalog_image` writes.
    pub const TEST_DIGEST: &str = super::ImageRef::UNPINNED_DIGEST;

    /// Owned `String` form of [`TEST_DIGEST`] for fixture constructors.
    pub fn test_digest() -> String {
        TEST_DIGEST.to_string()
    }
}

// ── EnvVar ────────────────────────────────────────────────────────────────────

/// A single environment variable injected into the container.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct EnvVar {
    /// Variable name, conventionally `SCREAMING_SNAKE_CASE`.
    pub name: String,

    /// Value source.
    pub value: EnvValue,
}

/// Value source for an environment variable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum EnvValue {
    /// Static string baked into the spec.
    Literal { value: String },

    /// Resolved from a yubaba secret at deploy time; the secret value never
    /// appears in the spec JSON.
    FromSecret { secret: String, key: String },

    /// Resolved from another workload's mesh address at deploy time by yubaba.
    /// Lets workloads reference each other symbolically without IP pinning.
    FromMesh { ident: MeshIdent, kind: MeshLookup },
}

/// Which aspect of a mesh peer's address to inject.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MeshLookup {
    /// Full URL, e.g. `"http://noisetable-db.pdx:5432"`.
    Url,
    /// Hostname only, e.g. `"noisetable-db.pdx"`.
    Host,
    /// Port only, e.g. `"5432"`.
    Port,
}

// ── Secrets ───────────────────────────────────────────────────────────────────

/// A secret value mounted into the container as an env var or file.
///
/// The secret value never appears in the spec JSON — only the reference.
/// Yubaba audits secret access per workload from these references.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SecretMount {
    /// Where yubaba reads the secret value from.
    pub source: SecretRef,

    /// How the secret is surfaced inside the container.
    pub target: SecretTarget,
}

/// Where yubaba resolves the secret value from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SecretRef {
    /// Per-machine yubaba secret store at `/var/lib/yah/yubaba/secrets/`.
    LocalFile { path: PathBuf },

    /// Raft-replicated cluster secret spanning all machines (planned; not in
    /// V1 deployment). Sketch preserved for wire compatibility.
    Cluster { name: String },
}

/// How the secret is surfaced inside the container.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SecretTarget {
    /// Injected as an environment variable. Value never appears in spec JSON.
    /// Prefer `File` — env vars leak through subprocess env and log dumps.
    EnvVar { name: String },

    /// Mounted as a file inside the container at `path` with `mode` (octal).
    File { path: PathBuf, mode: u32 },
}

// ── Volumes ───────────────────────────────────────────────────────────────────

/// A volume mount inside the container.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct VolumeMount {
    /// Backing volume source.
    pub source: VolumeSource,

    /// Absolute path inside the container.
    pub target: PathBuf,

    /// Whether the container sees the volume as read-only.
    pub read_only: bool,
}

/// Backing source for a volume mount.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum VolumeSource {
    /// Yubaba-managed named volume; created on first use.
    Named { name: String },

    /// Operator-managed host path. Yubaba rejects bind mounts unless
    /// `WorkloadSpec.tier == "infra"`; shape validation enforces this.
    Bind { host_path: PathBuf },

    /// In-memory tmpfs; discarded on container stop. `size_mb` caps space
    /// consumed by the writable layer.
    Tmpfs { size_mb: u32 },
}

// ── Durable forge produced-artifact convention (R603-T5) ──────────────────────

/// Convention for a remote forge step's durable produced artifacts.
///
/// A remote build (e.g. the rusty_v8 musl build on a build-worker) writes its
/// output tarball to a path *inside* the container. The container's rootfs is
/// destroyed when kamaji reaps the EXITED container — so if the camp daemon is
/// down when the build finishes, the artifact is gone before boot-reconcile can
/// retrieve it (R603-T4 surfaced this as `Success`-but-`UNPUBLISHED`).
///
/// The fix (R603-T5) is a **host-persistent bind mount**: forge Subprocess
/// workloads mount [`HOST_ROOT`]`/<forge_id>` onto [`CONTAINER_DIR`], so a
/// build that writes its `produces` under `/yah/produced` lands the bytes on
/// the worker's host filesystem. yubaba then reads them back from the host path
/// ([`host_path`]) — which outlives container reaping — instead of the
/// unreachable container rootfs.
///
/// The container-side path and the host root are a shared convention between
/// three crates: the qed `build_workload_spec` that adds the mount, kamaji that
/// binds it, and the yubaba handler that reads + reaps it. Keeping it here (the
/// crate all three already depend on) is the single source of truth.
pub mod forge_produced {
    use std::path::{Path, PathBuf};

    /// Conventional container-side directory a remote forge step writes its
    /// durable produced artifacts to. Bind-mounted onto a host-persistent dir.
    pub const CONTAINER_DIR: &str = "/yah/produced";

    /// Host root under which each forge's durable produced dir lives, one
    /// subdir per run: `<HOST_ROOT>/<forge_id>/`. yubaba owns this directory —
    /// it creates the per-forge subdir at deploy, serves reads from it, and
    /// reaps it on teardown / TTL sweep.
    pub const HOST_ROOT: &str = "/var/lib/yah/qed/produced";

    /// Forge mesh idents are `forge.<id>` (see [`WorkloadSpec::for_forge`]).
    /// Extract the bare `<id>`, or `None` for a non-forge ident.
    ///
    /// [`WorkloadSpec::for_forge`]: super::WorkloadSpec::for_forge
    pub fn forge_id_from_ident(ident: &str) -> Option<&str> {
        ident.strip_prefix("forge.")
    }

    /// The host-persistent produced directory for one forge run.
    pub fn host_dir(forge_id: &str) -> PathBuf {
        PathBuf::from(HOST_ROOT).join(forge_id)
    }

    /// Translate a container-side produced path to its durable host path for a
    /// given forge run. Returns `None` when `container_path` is not under
    /// [`CONTAINER_DIR`] (the caller then knows the artifact was not written to
    /// the durable location and won't survive reaping), or when the relative
    /// path contains a `..` component (a traversal attempt that could escape the
    /// per-forge dir — the reader must never serve a file outside it).
    pub fn host_path(forge_id: &str, container_path: &Path) -> Option<PathBuf> {
        let rel = container_path.strip_prefix(CONTAINER_DIR).ok()?;
        if rel
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
        {
            return None;
        }
        Some(host_dir(forge_id).join(rel))
    }

    /// The durable produced-dir bind mount for a forge run: host
    /// `<HOST_ROOT>/<forge_id>` → container [`CONTAINER_DIR`], writable.
    pub fn durable_mount(forge_id: &str) -> super::VolumeMount {
        super::VolumeMount {
            source: super::VolumeSource::Bind {
                host_path: host_dir(forge_id),
            },
            target: PathBuf::from(CONTAINER_DIR),
            read_only: false,
        }
    }

    /// True when `path` is (or is under) the conventional durable produced dir
    /// — the guard qed uses to enforce that declared `produces` land somewhere
    /// reap-durable.
    pub fn is_durable_path(path: &Path) -> bool {
        path.starts_with(CONTAINER_DIR)
    }
}

// ── Forge host-state root (R636-B1) ───────────────────────────────────────────

/// The one host directory tree a QED forge step's bind mounts may live under.
///
/// # Why this is a named root rather than a list of paths
///
/// runc refuses a bind whose source is missing, and the OCI mapper never
/// mkdirs one — so *something* has to create each host dir before deploy.
/// yubaba does, but only for paths it recognizes, and "recognizes" was
/// originally a hardcoded match on the produced dir. Every new forge mount then
/// re-learned the lesson the expensive way, on a real box, minutes into a
/// build: R603-B6 for `produced/`, then R636-B1 for `build-out/`, each
/// surfacing as the same opaque `failed to fulfil mount request: … no such file
/// or directory` from deep inside containerd.
///
/// Naming the *root* makes the rule checkable instead of enumerable: yubaba
/// creates any forge bind under [`HOST_ROOT`], and `yubaba.service` grants the
/// root once via `StateDirectory=yah/qed`. A third mount needs no new code and
/// no unit-file edit — it only has to live here.
///
/// The prefix bound is load-bearing in the other direction too: it is what
/// keeps a workload spec from asking yubaba to mkdir an arbitrary host path.
pub mod forge_state {
    use std::path::Path;

    /// Root of the forge's host-persistent state. Both
    /// [`super::forge_produced::HOST_ROOT`] and [`BUILD_OUT_DIR`] are under it.
    pub const HOST_ROOT: &str = "/var/lib/yah/qed";

    /// Host directory a `build-image` step's OCI archive is written to, bound
    /// at `/yah/build/out` in the BuildKit container. Shared (rather than
    /// per-forge like `produced/`) because the archive is named after the image
    /// tag, which is already unique per build.
    pub const BUILD_OUT_DIR: &str = "/var/lib/yah/qed/build-out";

    /// Whether yubaba may create `host_path` on behalf of a forge workload.
    ///
    /// Rejects anything outside [`HOST_ROOT`], and anything with a `..`
    /// component — `/var/lib/yah/qed/../../../etc` starts with the root as a
    /// string and is nowhere near it as a path.
    pub fn is_forge_state_path(host_path: &Path) -> bool {
        !host_path
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
            && host_path.starts_with(HOST_ROOT)
    }
}

// ── Materialized-secret path contract (R555-F5) ───────────────────────────────

/// Where yubaba writes a `File`-target secret it has resolved, and how the host
/// path is derived from the container path.
///
/// # Why the derivation lives here and not in yubaba
///
/// yubaba resolves a [`SecretMount`] and rewrites it into a read-only [`Bind`]
/// volume before the spec reaches the backend, so the spec kamaji admits is not
/// the spec the dispatcher signed: one mount has become one bind. Admission has
/// to be able to recognise that rewrite — otherwise a signed recipe carrying a
/// secret is refused by [`admission::AdmissionGrant::covers`]'s bind rule, which
/// only knows about [`forge_state::HOST_ROOT`], with a message about a forge
/// state root that has nothing to do with what happened.
///
/// Recognising it means recomputing the host path, which means the derivation
/// has to be visible to both sides. It was private to yubaba's
/// `deploy::secret_mount`; it lives here now, and yubaba calls in. `forge_state`
/// is the same shape for the same reason.
///
/// [`Bind`]: VolumeSource::Bind
pub mod secret_mount {
    use std::path::{Path, PathBuf};

    /// RAM-backed root for materialized secret files. `/run` is a tmpfs on
    /// systemd nodes, so decrypted PEM never touches disk. Each workload gets a
    /// `<root>/<ident>/` subdir, reaped on workload destroy.
    pub const HOST_ROOT: &str = "/run/yah/secrets";

    /// Collapse a value into a single safe path component: every char outside
    /// `[A-Za-z0-9_-]` becomes `_` (dots included, so `.` / `..` can never
    /// traverse). Empty input maps to `_`.
    pub fn sanitize_component(s: &str) -> String {
        let mapped: String = s
            .chars()
            .map(|c| {
                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                    c
                } else {
                    '_'
                }
            })
            .collect();
        if mapped.is_empty() {
            "_".into()
        } else {
            mapped
        }
    }

    /// Derive a collision-free host filename from a container target path: strip
    /// the leading `/`, keep `.` for extensions, and replace path separators (and
    /// any other non-`[A-Za-z0-9_.-]` char) with `_`. A target that reduces to
    /// nothing or a dots-only name falls back to `secret`. The result is always a
    /// single flat filename (no separators), so it cannot traverse out of the
    /// per-workload dir.
    pub fn host_file_name(target: &Path) -> String {
        let raw = target.to_string_lossy();
        let trimmed = raw.trim_start_matches('/');
        let mapped: String = trimmed
            .chars()
            .map(|c| {
                if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
                    c
                } else {
                    '_'
                }
            })
            .collect();
        if mapped.is_empty() || mapped.chars().all(|c| c == '.') {
            "secret".into()
        } else {
            mapped
        }
    }

    /// The per-workload directory materialized secrets are written to.
    pub fn workload_dir(root: &Path, ident: &str) -> PathBuf {
        root.join(sanitize_component(ident))
    }

    /// The host path a `File`-target secret at container path `target` is
    /// materialized to for workload `ident`.
    ///
    /// Deterministic in exactly those three inputs, which is what lets admission
    /// recompute it from the spec alone and match a bind against it.
    pub fn materialized_host_path(root: &Path, ident: &str, target: &Path) -> PathBuf {
        workload_dir(root, ident).join(host_file_name(target))
    }
}

#[cfg(test)]
mod secret_mount_tests {
    use super::secret_mount::*;
    use std::path::{Path, PathBuf};

    #[test]
    fn the_host_path_is_a_pure_function_of_root_ident_and_target() {
        let p = materialized_host_path(
            Path::new(HOST_ROOT),
            "forge.abc-123",
            Path::new("/etc/yah/r2.json"),
        );
        assert_eq!(
            p,
            PathBuf::from("/run/yah/secrets/forge_abc-123/etc_yah_r2.json")
        );
    }

    /// The two collapses exist to keep a hostile ident or target from steering
    /// the write out of the per-workload dir. Pinned here because admission now
    /// depends on them being total.
    #[test]
    fn neither_component_can_traverse() {
        for ident in ["..", "../../etc", "a/b", ""] {
            let dir = workload_dir(Path::new(HOST_ROOT), ident);
            assert_eq!(dir.components().count(), 5, "{ident:?} escaped {dir:?}");
            assert!(dir.starts_with(HOST_ROOT));
        }
        for target in ["/../../etc/shadow", "..", "/", "/a/../b"] {
            let name = host_file_name(Path::new(target));
            assert!(!name.contains('/'), "{target:?} kept a separator: {name}");
            assert_ne!(name, "..");
        }
    }
}

#[cfg(test)]
mod forge_state_tests {
    use super::forge_state::*;
    use std::path::Path;

    #[test]
    fn both_known_forge_roots_are_under_the_state_root() {
        assert!(is_forge_state_path(Path::new(
            super::forge_produced::HOST_ROOT
        )));
        assert!(is_forge_state_path(Path::new(BUILD_OUT_DIR)));
        assert!(is_forge_state_path(&super::forge_produced::host_dir(
            "abc-123"
        )));
    }

    /// A spec must not be able to steer yubaba's mkdir anywhere it likes —
    /// neither by naming an unrelated absolute path nor by climbing out with
    /// `..`, which a plain string prefix check would wave through.
    #[test]
    fn paths_outside_the_root_are_refused() {
        for bad in [
            "/var/lib/yah/yubaba",
            "/etc/systemd/system",
            "/var/lib/yah/qed/../../../etc",
            "relative/path",
        ] {
            assert!(
                !is_forge_state_path(Path::new(bad)),
                "{bad} must not be creatable by a forge spec"
            );
        }
    }
}

// ── Resources ─────────────────────────────────────────────────────────────────

/// Hard resource caps enforced by containerd/cgroups at runtime.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ResourceLimits {
    /// Maximum RAM the container may allocate, in MiB. The container is OOM-
    /// killed if it exceeds this.
    ///
    /// A **ceiling**, not a request: setting it generously is the safe
    /// direction here and the unschedulable direction for placement, so
    /// schedulers must read [`WorkloadSpec::memory_request_mb`] instead of
    /// this field. (`cpu_millis` below is the opposite — a request by
    /// definition — which is why the two are not symmetric.)
    pub memory_mb: u32,

    /// CPU **request** in millicores (k8s convention): `1000` = one full core,
    /// `250` = `.25 CPU`. Unlike a Docker relative weight this is an
    /// allocatable quantity a bin-packer can subtract from a node's budget.
    /// `0` means "no CPU limit". Backends that speak a relative weight derive
    /// it via [`ResourceLimits::cpu_shares`].
    pub cpu_millis: u32,

    /// Cap on the writable layer + tmpfs footprint, in MiB.
    pub ephemeral_storage_mb: u32,
}

impl ResourceLimits {
    /// The Docker/OCI relative CPU weight (`cpu.shares`, where `1024` ≈ one
    /// core) equivalent to this millicore request. The containerd and docker
    /// backends express CPU as a weight rather than a millicore request, so
    /// they derive it here instead of storing shares: `1000m` ⇒ `1024`.
    pub fn cpu_shares(&self) -> u64 {
        (u64::from(self.cpu_millis) * 1024) / 1000
    }
}

// ── Healthcheck ───────────────────────────────────────────────────────────────

/// Container health probe configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct Healthcheck {
    /// The probe executed to determine container health.
    pub probe: HealthProbe,

    /// How often the probe runs.
    pub interval: Millis,

    /// Per-probe timeout; a slow response counts as failure.
    pub timeout: Millis,

    /// Time to wait after container start before the first probe. Shape
    /// validation warns (not errors) if this is less than
    /// `stop_policy.grace_period * 2`.
    pub initial_delay: Millis,

    /// Number of consecutive failures before the container is marked
    /// `Unhealthy`.
    pub failure_threshold: u32,
}

/// Mechanism used to check container health.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum HealthProbe {
    /// HTTP GET to `path` on `port`. A 2xx (or `expect_status` if set)
    /// response counts as healthy.
    HttpGet {
        path: String,
        port: u16,
        #[ts(optional = nullable)]
        expect_status: Option<u16>,
    },

    /// Run `argv` inside the container; exit-0 counts as healthy.
    Exec { argv: Vec<String> },

    /// TCP connection to `port`; a successful connect counts as healthy.
    TcpConnect { port: u16 },
}

// ── Restart / Stop ────────────────────────────────────────────────────────────

/// What yubaba does when the container exits.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RestartPolicy {
    /// Restart unconditionally on any exit.
    Always,

    /// Restart on non-zero exit, up to `max_attempts` times with exponential
    /// backoff. After exhaustion, the workload is marked `Failed`.
    OnFailure {
        max_attempts: u32,
        backoff: BackoffPolicy,
    },

    /// Do not restart. The container runs once and exits.
    ///
    /// **Forge convention.** Forge runs (R094) synthesize a `WorkloadSpec`
    /// using [`WorkloadSpec::for_forge`] which sets all the conventional fields
    /// together:
    ///
    /// - `restart_policy = Never`
    /// - `expose.public = None`, `expose.operator = None`
    /// - `expose.mesh.identity = "forge.<forge_id>"` — distinguishable from
    ///   persistent mirror identities at the mesh layer
    /// - `tier = "infra"` (or the forge-spec's effective tier)
    /// - `annotations["yah.forge"] = "true"` — suppresses the shape warning
    ///
    /// Using `Never` on a persistent mirror (not a forge run) means the mirror
    /// stays dead after any exit — a likely misconfiguration. Shape validation
    /// emits a soft warning unless `annotations["yah.forge"] == "true"` is
    /// present. See R094 forge.
    Never,
}

/// Exponential backoff parameters for `RestartPolicy::OnFailure`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct BackoffPolicy {
    /// Initial delay before the first restart, in milliseconds.
    pub initial_ms: u32,

    /// Maximum delay between retries, in milliseconds.
    pub max_ms: u32,

    /// Backoff multiplier applied to each successive delay.
    pub multiplier: f32,
}

/// Graceful shutdown configuration for yubaba's stop sequence.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct StopPolicy {
    /// Signal number sent first, e.g. `15` (SIGTERM) or `2` (SIGINT).
    pub signal: i32,

    /// Time yubaba waits after sending `signal` before issuing SIGKILL.
    pub grace_period: Millis,
}

// ── Expose ────────────────────────────────────────────────────────────────────

/// Network exposure configuration. The three channels are independent; any
/// combination is valid.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ExposeSpec {
    /// Mesh-internal exposure. Required; every workload must have a mesh
    /// identity even if no other workload currently reaches it.
    pub mesh: MeshExpose,

    /// Public internet exposure via a Cloudflare tunnel route. `None` means
    /// the workload is not internet-reachable.
    #[ts(optional = nullable)]
    pub public: Option<PublicExpose>,

    /// Operator-facing exposure via a Tailscale ACL tag. `None` means the
    /// workload is not operator-reachable via Tailscale.
    #[ts(optional = nullable)]
    pub operator: Option<OperatorExpose>,
}

/// A peer permitted to initiate mesh connections to a workload (W206 / R558-F3).
///
/// Cross-tenant access is **deny-by-default**: a workload accepts inter-tenant
/// traffic only from peers it lists explicitly as [`MeshPeer::CrossTenant`].
/// Same-tenant access stays tier-based ([`MeshPeer::Tier`]) — the pre-R558
/// model — and an `allow_from` with no `Tier` entries still admits every
/// same-tenant peer (the historical "empty = allow all" default).
///
/// External serde tagging keeps this postcard-safe (R590-B3): no internal tag,
/// no untagged, no `skip_serializing_if`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MeshPeer {
    /// Any **same-tenant** workload whose `tier` matches this tag. This is the
    /// pre-R558 `allow_from` semantics.
    Tier(TierTag),

    /// A specific workload in **another tenant**, addressed by its fully
    /// qualified mesh identity `<tenant>/<namespace>/<name>`. There is no
    /// cross-tenant tier wildcard — each cross-tenant peer is granted
    /// individually, so a shared fleet stays isolated unless an operator opts
    /// in here.
    CrossTenant {
        tenant: TenantId,
        namespace: NamespaceId,
        /// Peer's mesh identity (its [`MeshExpose::identity`]).
        name: MeshIdent,
    },
}

/// Mesh-internal port exposure and peer access control.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct MeshExpose {
    /// DNS-segment mesh identity for this workload. Must be unique in the
    /// cluster. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
    pub identity: MeshIdent,

    /// Container-side ports this workload listens on. Other workloads reach
    /// it at `<identity>:<port>` on the mesh.
    pub ports: Vec<u16>,

    /// Peers permitted to initiate connections to this workload on the mesh
    /// (W206 / R558-F3). Same-tenant tier rules and explicit cross-tenant
    /// grants share this one list. With **no** [`MeshPeer::Tier`] entries every
    /// same-tenant peer is admitted (the historical "empty = allow all"
    /// default); cross-tenant peers are always denied unless named by a
    /// [`MeshPeer::CrossTenant`] entry. See [`MeshExpose::admits_peer`].
    #[serde(default)]
    pub allow_from: Vec<MeshPeer>,
}

impl MeshExpose {
    /// Whether a peer may initiate a mesh connection to a workload whose mesh
    /// exposure is `self`. `own_tenant` is the tenant of the workload being
    /// protected; the remaining arguments identify the connecting peer.
    ///
    /// Deny-by-default across tenants (W206 / R558-F3):
    /// - **Same tenant** (`own_tenant == peer_tenant`): admitted when the
    ///   peer's tier matches a [`MeshPeer::Tier`] rule, or when there are no
    ///   `Tier` rules at all (historical "empty `allow_from` = allow all
    ///   same-tenant").
    /// - **Cross tenant**: admitted only when an explicit
    ///   [`MeshPeer::CrossTenant`] entry matches the peer's
    ///   `(tenant, namespace, name)`.
    pub fn admits_peer(
        &self,
        own_tenant: &TenantId,
        peer_tenant: &TenantId,
        peer_namespace: &NamespaceId,
        peer_name: &MeshIdent,
        peer_tier: &TierTag,
    ) -> bool {
        if own_tenant == peer_tenant {
            let mut has_tier_rule = false;
            for peer in &self.allow_from {
                if let MeshPeer::Tier(t) = peer {
                    has_tier_rule = true;
                    if t == peer_tier {
                        return true;
                    }
                }
            }
            // No same-tenant tier restriction declared → admit all same-tenant.
            !has_tier_rule
        } else {
            self.allow_from.iter().any(|peer| {
                matches!(
                    peer,
                    MeshPeer::CrossTenant { tenant, namespace, name }
                        if tenant == peer_tenant
                            && namespace == peer_namespace
                            && name == peer_name
                )
            })
        }
    }
}

/// The name by which a workload is addressed **within its own tenant** (W206 /
/// R558-F3), given every `(namespace, identity)` pair present in that tenant.
///
/// Within a tenant, a workload is reached by its short mesh `identity` when that
/// identity is unique across the tenant's namespaces. When two namespaces
/// expose the same identity, the name is ambiguous, so both are disambiguated
/// by a namespace prefix — `<namespace>.<identity>` (e.g. `yah.runner` vs
/// `noisetable.runner`). Cross-tenant addressing always uses the full FQN
/// ([`WorkloadSpec::fq_mesh_identity`]) and is out of scope here.
pub fn intra_tenant_address(
    namespace: &NamespaceId,
    identity: &MeshIdent,
    tenant_workloads: &[(NamespaceId, MeshIdent)],
) -> String {
    let collides = tenant_workloads
        .iter()
        .any(|(ns, id)| id == identity && ns != namespace);
    if collides {
        format!("{}.{}", namespace.0, identity.0)
    } else {
        identity.0.clone()
    }
}

/// Public internet exposure via a Cloudflare tunnel route.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct PublicExpose {
    /// Public hostname to route, e.g. `"api.noisetable.io"`. Semantic
    /// validation checks that this hostname is owned by a configured CF zone.
    pub hostname: String,

    /// Container-side port to route traffic to. Shape validation requires this
    /// port to appear in `expose.mesh.ports`.
    pub port: u16,

    /// TLS configuration for the public endpoint.
    pub tls: PublicTls,
}

/// TLS mode for a public endpoint.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum PublicTls {
    /// Cloudflare manages the TLS certificate (default; requires a proxied DNS
    /// record in the configured zone).
    CfManaged,

    /// User-supplied certificate referenced by name in the yubaba secret store.
    UserCertRef { name: String },
}

/// Operator-facing exposure via a Tailscale ACL tag.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct OperatorExpose {
    /// Tailscale ACL tag granting access, e.g. `"tag:noisetable-ops"`. Semantic
    /// validation checks that this tag exists in the cluster's Tailscale ACL.
    pub tailscale_tag: String,

    /// Container-side port to expose to Tailscale-authorized operators.
    pub port: u16,
}

// ── ImageRef helpers ──────────────────────────────────────────────────────────

impl ImageRef {
    /// The all-zeros sha256 digest that marks an image reference as **not
    /// content-pinned**. No real image can carry it, so a build that never
    /// injected a compile-time digest (dev builds) or a catalog image that
    /// isn't published-and-pinned yet lands on this sentinel. This is the
    /// single source of truth both the catalog emitter
    /// (`task::default_image::catalog_image`, which writes it) and the
    /// container-runtime resolvers ([`Self::pull_ref`], via kamaji) agree on —
    /// keeping them here means they cannot drift. [`testing::TEST_DIGEST`] is
    /// the same value re-exported for fixtures.
    pub const UNPINNED_DIGEST: &'static str =
        "sha256:0000000000000000000000000000000000000000000000000000000000000000";

    /// Parse a full digest-pinned image reference —
    /// `[registry/]repo[:tag]@sha256:<hex>` — into its parts.
    ///
    /// This is the public door onto the same parser the `ImageRef` string-form
    /// `Deserialize` arm uses, so a config that spells an image as one string
    /// (a qed `step.image`, a transform recipe) and a config that spells it as
    /// a struct land on identical semantics. A bare tag is rejected: the whole
    /// point of the string form is that it carries the digest.
    pub fn parse_pinned(s: &str) -> Result<Self, String> {
        compose_import::parse_pinned_image_ref(s)
    }

    /// Format this reference as a Docker-compatible image string,
    /// `{registry}/{repository}:{tag}@{digest}`. Tag is included for human
    /// readability; the digest is what the pull resolves against. Always emits
    /// the digest — this is the display/logging form; use [`Self::pull_ref`]
    /// for the string handed to a container runtime.
    pub fn docker_ref(&self) -> String {
        format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
    }

    /// True when this reference carries a real content-addressed digest, i.e.
    /// its digest is not the all-zeros [`Self::UNPINNED_DIGEST`] sentinel.
    pub fn is_pinned(&self) -> bool {
        self.digest != Self::UNPINNED_DIGEST
    }

    /// The reference string to hand a container runtime for pull/resolve.
    ///
    /// - **Pinned** (real digest): `{registry}/{repository}:{tag}@{digest}` —
    ///   content-addressed, the reproducible path.
    /// - **Unpinned** (all-zeros [`Self::UNPINNED_DIGEST`]): `{registry}/{repository}:{tag}`
    ///   — tag-only. No registry or local store holds an image under the
    ///   sentinel digest, so `…@sha256:0000…` can never resolve; a
    ///   tag-pulled or locally-built image is keyed by `registry/repo:tag`.
    ///   This is the tag-fallback path that lets a not-yet-published catalog
    ///   image (e.g. a from-source build-worker image) still pull by tag.
    pub fn pull_ref(&self) -> String {
        if self.is_pinned() {
            format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
        } else {
            format!("{}/{}:{}", self.registry, self.repository, self.tag)
        }
    }
}

// ── WorkloadRuntime trait ─────────────────────────────────────────────────────

/// Shared interface for deploying and managing `WorkloadSpec` containers.
///
/// This is the keystone abstraction (R256-F10) that makes sim and cloud
/// literally interchangeable at the container level:
///
/// - **Camp/sim tier**: `LocalDockerRuntime` in `cloud` implements this trait
///   via the docker CLI pointed at OrbStack (or any Docker-compatible socket).
///   No mesh — containers communicate over OrbStack's bridge network.
///
/// - **Yubaba/cloud-HA tier**: `yubaba::runtime::ContainerRuntime` (gRPC to
///   containerd) will implement this trait. Mesh assignment is a separate
///   orchestration step on top (handled by yubaba's raft layer), not part
///   of the shared deploy/supervise interface.
///
/// Callers that type against `WorkloadRuntime` automatically work with both
/// backends. Reconcilers in `cloud` use it today; yubaba wires its own impl
/// when R276 Tier-3 lands.
#[async_trait::async_trait]
pub trait WorkloadRuntime: Send + Sync {
    /// Deploy a workload described by `spec`. Pulls the image if needed,
    /// creates and starts the container, and returns an opaque workload ID
    /// (typically the container name derived from `spec.name`).
    ///
    /// Idempotent: re-deploying a running workload replaces it cleanly.
    async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String>;

    /// Tear down a deployed workload — stop the process and remove all
    /// associated state. No-op when the workload is already gone.
    async fn teardown_workload(&self, name: &str) -> anyhow::Result<()>;

    /// Returns `true` when the named workload is currently running (i.e.
    /// the container process is alive and has not exited).
    async fn is_running(&self, name: &str) -> anyhow::Result<bool>;

    /// Probe the runtime backend. Returns `true` when the backend socket is
    /// reachable and healthy (e.g. docker daemon up, containerd gRPC up).
    /// Used by health endpoints and startup checks.
    async fn runtime_health(&self) -> anyhow::Result<bool>;
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ── R658-B1 `routes` belongs at the top level, not inside [build] ─────────

    /// The canonical `mesofact-static` manifest shape: `routes` above the
    /// `[build]` header, where TOML keeps it top-level.
    #[test]
    fn mesofact_static_routes_parse_at_the_top_level() {
        let src = r#"
schema_version = 1
kind = "mesofact-static"
routes = "./mesofact.routes.ts"

[build]
command = "bun run build"
out_dir = "dist"
"#;
        let Workload::MesofactStatic(site) =
            toml::from_str::<Workload>(src).expect("canonical shape must parse")
        else {
            panic!("kind = \"mesofact-static\" must select MesofactStatic");
        };
        assert_eq!(site.routes, PathBuf::from("./mesofact.routes.ts"));
        assert_eq!(site.build.out_dir, PathBuf::from("dist"));
    }

    /// The bug R658-B1 exists for: `routes` written *below* `[build]` is
    /// `build.routes` as far as TOML is concerned. `BuildConfig` used to
    /// discard the stray key, so this manifest parsed as far as the missing
    /// top-level field and blamed the wrong line — or, once `routes` had a
    /// default, would have deployed a site that enumerated no routes at all.
    ///
    /// `deny_unknown_fields` makes the misplacement itself the error, and the
    /// message names `routes`, which is the one thing the author needs to move.
    #[test]
    fn mesofact_static_routes_inside_build_is_rejected_by_name() {
        let src = r#"
schema_version = 1
kind = "mesofact-static"

[build]
command = "bun run build"
out_dir = "dist"
routes = "./mesofact.routes.ts"
"#;
        let err = toml::from_str::<Workload>(src)
            .expect_err("`routes` under [build] must not parse silently")
            .to_string();
        assert!(
            err.contains("routes"),
            "the error must name the misplaced key so the fix is obvious; got: {err}"
        );
    }

    /// Guard the general case, not just the one key that bit us: any unknown
    /// `[build]` key is refused rather than dropped on the floor.
    #[test]
    fn unknown_build_keys_are_refused_rather_than_ignored() {
        let src = r#"
command = "bun run build"
out_dir = "dist"
outdir = "dist"
"#;
        let err = toml::from_str::<BuildConfig>(src)
            .expect_err("a typo'd build key must not be silently ignored")
            .to_string();
        assert!(err.contains("outdir"), "got: {err}");

        // …and the keys that ARE modelled still round-trip.
        let ok: BuildConfig = toml::from_str(
            r#"
command = "bun run build"
out_dir = "dist"
render_command = "mesofact-build render . --route {route}"
"#,
        )
        .expect("modelled keys must still parse");
        assert_eq!(ok.render_command.as_deref(), Some("mesofact-build render . --route {route}"));
    }

    // ── R783-F1 / W324: container manifest vs wire spec ────────────────────────

    /// The acceptance case. `crates/yah/cloud-admin/workload.toml` is the file
    /// that could not parse through the envelope at all (R658-B2 pinned it in
    /// `xtask/tests/workload_envelope.rs` as `missing field \`image\``): it is a
    /// Dockerfile recipe, and the envelope only knew digest-pinned specs.
    ///
    /// The `[process]` table is deliberately present — that file is read by
    /// `LocalProcessReconciler` on the dev mirror *and* `ContainerReconciler`
    /// on pond, so the container form must tolerate the other tier's table
    /// rather than reject the file (W324 §1).
    #[test]
    fn container_recipe_parses_including_the_other_tier_s_table() {
        let src = r#"
schema_version = 1
name = "yah-cloud-admin"
kind = "container"

[build]
dockerfile = "Dockerfile"
context = "."
image = "yah-local/yah-cloud-admin:dev"

[run]
port = 4325
host_port = 4326

[run.env]
YAH_CLOUD_ADMIN_ADDR = "0.0.0.0:4325"

[[run.mounts]]
host = ".yah/infra"
container = "/workspace/.yah/infra"

[process]
cargo_package = "yah-cloud-admin"
port = 4325
"#;
        let workload = toml::from_str::<Workload>(src).expect("the recipe form must parse");
        assert_eq!(workload.kind_str(), "container");

        let recipe = workload
            .container_manifest()
            .and_then(ContainerManifest::as_recipe)
            .expect("a [build] table selects the recipe form");
        assert_eq!(recipe.name, "yah-cloud-admin");
        assert_eq!(recipe.build.dockerfile, PathBuf::from("Dockerfile"));
        assert_eq!(recipe.build.context, Some(PathBuf::from(".")));
        assert_eq!(
            recipe.build.image.as_deref(),
            Some("yah-local/yah-cloud-admin:dev")
        );
        assert_eq!(recipe.run.port, Some(4325));
        assert_eq!(recipe.run.host_port, Some(4326));
        assert_eq!(
            recipe.run.env.get("YAH_CLOUD_ADMIN_ADDR").map(String::as_str),
            Some("0.0.0.0:4325")
        );
        assert_eq!(recipe.run.mounts.len(), 1);
        assert!(recipe.run.mounts[0].read_only, "mounts default to read-only");

        // The recipe has no spec — that is the whole point of the split.
        assert!(workload.container_spec().is_none());
    }

    /// The other branch: no `[build]` table means the flat fields are a
    /// digest-pinned `WorkloadSpec`, exactly as before the split.
    #[test]
    fn container_reference_still_parses_as_a_workload_spec() {
        let spec = archetype_test_spec("noisetable-api");
        let toml_src = toml::to_string(&Workload::container(spec.clone())).expect("serialize");
        assert!(
            toml_src.contains("kind = \"container\""),
            "the on-disk form stays flat + internally tagged: {toml_src}"
        );

        let back = toml::from_str::<Workload>(&toml_src).expect("deserialize");
        assert_eq!(back.container_spec(), Some(&spec));
    }

    /// Explicit-branch deserialize exists so this error survives. Under
    /// `#[serde(untagged)]` it would read "data did not match any variant of
    /// untagged enum ContainerManifest", which tells an author nothing.
    #[test]
    fn a_malformed_container_reference_still_names_the_missing_field() {
        let src = r#"
schema_version = 1
kind = "container"
name = "noisetable-api"
image = "ghcr.io/noisetable/api:v1@sha256:0000000000000000000000000000000000000000000000000000000000000000"
replicas = 1
"#;
        let err = toml::from_str::<Workload>(src)
            .expect_err("a reference missing a required field must not parse")
            .to_string();
        assert!(err.contains("missing field `tier`"), "got: {err}");
    }

    /// The one file that names neither marker. `missing field \`image\`` would
    /// send a recipe author off to add a field their form does not have, so
    /// the error names both forms instead.
    #[test]
    fn a_container_with_neither_image_nor_build_names_both_forms() {
        let src = r#"
schema_version = 1
kind = "container"
name = "yah-cloud-admin"

[run]
port = 4325
"#;
        let err = toml::from_str::<Workload>(src)
            .expect_err("neither form is declared")
            .to_string();
        assert!(err.contains("image"), "got: {err}");
        assert!(err.contains("[build]"), "got: {err}");
    }

    /// W324 §5's invariant, as a signature: there is no path from a recipe to
    /// a `WorkloadSpec` that does not name a digest.
    #[test]
    fn a_recipe_lowers_only_once_a_build_has_produced_a_digest() {
        let recipe = ContainerBuild {
            schema_version: SchemaVersion::V1,
            name: "yah-cloud-admin".into(),
            build: ContainerBuildStep {
                dockerfile: "Dockerfile".into(),
                context: Some(".".into()),
                image: Some("yah-local/yah-cloud-admin:dev".into()),
            },
            run: ContainerRunConfig {
                port: Some(4325),
                host_port: Some(4326),
                env: BTreeMap::from([("A".to_string(), "b".to_string())]),
                mounts: vec![ContainerMount {
                    host: ".yah/infra".into(),
                    container: "/workspace/.yah/infra".into(),
                    read_only: true,
                }],
            },
        };

        let digest = testing::test_digest();
        let spec = recipe
            .clone()
            .into_spec(&digest, TierTag("private".into()))
            .expect("a well-formed digest lowers");
        assert_eq!(spec.name, "yah-cloud-admin");
        assert_eq!(spec.image.digest, digest);
        assert_eq!(spec.image.repository, "yah-local/yah-cloud-admin");
        assert_eq!(spec.image.tag, "dev");
        assert_eq!(spec.expose.mesh.ports, vec![4325]);
        assert_eq!(spec.env.len(), 1);
        assert_eq!(spec.volumes.len(), 1);

        // A bare tag is not a digest. Lowering must fail rather than mint a
        // spec that lies about being content-addressed (R438-T3).
        let err = recipe
            .into_spec("dev", TierTag("private".into()))
            .expect_err("an unpinned digest must not lower");
        assert!(err.contains("sha256"), "got: {err}");
    }

    /// A recipe is a first-class on-disk value: it survives a write/read of
    /// the manifest unchanged. The other half of the gate — that the same
    /// value is *refused* by postcard — is in `tests/round_trip.rs`, which
    /// also pins the reference form's byte layout.
    #[test]
    fn a_recipe_round_trips_on_disk_under_the_container_kind() {
        let recipe = Workload::Container(ContainerManifest::Recipe(ContainerBuild {
            schema_version: SchemaVersion::V1,
            name: "yah-cloud-admin".into(),
            build: ContainerBuildStep::default(),
            run: ContainerRunConfig::default(),
        }));
        assert_eq!(recipe.kind_str(), "container");

        let src = toml::to_string(&recipe).expect("a recipe serializes to disk");
        assert!(src.contains("kind = \"container\""), "{src}");
        let back: Workload = toml::from_str(&src).expect("and parses back");
        assert_eq!(back, recipe);
    }

    // ── R603-T5 durable forge produced convention ──────────────────────────────

    #[test]
    fn forge_produced_ident_parse() {
        assert_eq!(forge_produced::forge_id_from_ident("forge.abc123"), Some("abc123"));
        assert_eq!(forge_produced::forge_id_from_ident("svc.web"), None);
        assert_eq!(forge_produced::forge_id_from_ident("abc123"), None);
    }

    #[test]
    fn forge_produced_host_path_translates_under_convention_dir() {
        let hp = forge_produced::host_path(
            "fid",
            std::path::Path::new("/yah/produced/librusty_v8.tar.gz"),
        )
        .expect("path under the convention dir translates");
        assert_eq!(
            hp,
            PathBuf::from("/var/lib/yah/qed/produced/fid/librusty_v8.tar.gz")
        );
    }

    #[test]
    fn forge_produced_host_path_rejects_paths_outside_convention_dir() {
        assert_eq!(
            forge_produced::host_path("fid", std::path::Path::new("/tmp/x.tar.gz")),
            None,
            "a path outside /yah/produced has no durable host mapping"
        );
    }

    #[test]
    fn forge_produced_host_path_rejects_traversal() {
        // A `..` component must never let a read escape the per-forge dir.
        assert_eq!(
            forge_produced::host_path(
                "fid",
                std::path::Path::new("/yah/produced/../../etc/passwd")
            ),
            None,
            "traversal out of the per-forge dir must be refused"
        );
    }

    #[test]
    fn forge_produced_durable_mount_shape() {
        let m = forge_produced::durable_mount("fid");
        assert_eq!(m.target, PathBuf::from("/yah/produced"));
        assert!(!m.read_only, "the build must be able to write to it");
        assert_eq!(
            m.source,
            VolumeSource::Bind {
                host_path: PathBuf::from("/var/lib/yah/qed/produced/fid"),
            }
        );
    }

    const HASH_64: &str = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";

    #[test]
    fn blake_hash_accepts_64_hex() {
        let h: BlakeHash = toml::from_str(&format!("x = \"{HASH_64}\""))
            .map(|t: toml::Table| t["x"].as_str().unwrap().to_owned())
            .map(|s| serde_json::from_value(serde_json::Value::String(s)).unwrap())
            .unwrap();
        assert_eq!(h.0, HASH_64);
    }

    #[test]
    fn blake_hash_rejects_wrong_length() {
        let short = "abcdef";
        let res: Result<BlakeHash, _> =
            serde_json::from_value(serde_json::Value::String(short.into()));
        assert!(res.is_err());
    }

    #[test]
    fn blake_hash_rejects_non_hex() {
        let bad = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
        let res: Result<BlakeHash, _> =
            serde_json::from_value(serde_json::Value::String(bad.into()));
        assert!(res.is_err());
    }

    fn image_ref(digest: &str) -> ImageRef {
        ImageRef {
            registry: "ghcr.io".into(),
            repository: "yah-ai/rusty-v8-musl-builder".into(),
            tag: "latest".into(),
            digest: digest.into(),
        }
    }

    #[test]
    fn is_pinned_distinguishes_real_digest_from_sentinel() {
        assert!(!image_ref(ImageRef::UNPINNED_DIGEST).is_pinned());
        assert!(!image_ref(&testing::test_digest()).is_pinned());
        assert!(image_ref("sha256:deadbeef").is_pinned());
    }

    #[test]
    fn pull_ref_pinned_carries_tag_and_digest() {
        assert_eq!(
            image_ref("sha256:deadbeef").pull_ref(),
            "ghcr.io/yah-ai/rusty-v8-musl-builder:latest@sha256:deadbeef",
        );
    }

    #[test]
    fn pull_ref_unpinned_falls_back_to_tag_only() {
        // An unpinned catalog image (all-zeros sentinel) resolves by tag —
        // no store holds `…@sha256:0000…`, so the tag is the only usable key.
        assert_eq!(
            image_ref(ImageRef::UNPINNED_DIGEST).pull_ref(),
            "ghcr.io/yah-ai/rusty-v8-musl-builder:latest",
        );
    }

    #[test]
    fn test_digest_alias_is_the_unpinned_sentinel() {
        assert_eq!(testing::TEST_DIGEST, ImageRef::UNPINNED_DIGEST);
    }

    #[test]
    fn static_asset_workload_round_trips() {
        let src = format!(
            r#"
schema_version = "V1"

[[asset]]
filename = "whisper/distil-large-v3-q5_1.bin"
source   = "sources/distil-large-v3-q5_1.bin"
blake3   = "{HASH_64}"

[[asset]]
filename = "whisper/distil-large-v3-q4_0.bin"
source   = "sources/distil-large-v3-q4_0.bin"
blake3   = "{HASH_64}"

[aliases]
"whisper-default" = "whisper/distil-large-v3-q5_1.bin"
"#
        );
        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
        assert_eq!(w.assets.len(), 2);
        assert_eq!(w.assets[0].filename, "whisper/distil-large-v3-q5_1.bin");
        assert_eq!(w.assets[0].blake3.0, HASH_64);
        assert_eq!(w.aliases["whisper-default"], "whisper/distil-large-v3-q5_1.bin");

        let back = toml::to_string(&w).expect("serialize");
        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
        assert_eq!(w, w2);
    }

    #[test]
    fn license_round_trip_each_variant() {
        // Wire format is whatever serde's `rename_all = "kebab-case"` emits.
        // heck's kebab-case keeps letter→digit attached but splits digit→uppercase,
        // so `Apache2 → "apache2"` and `Bsd2Clause → "bsd2-clause"`.
        for (variant, on_wire) in [
            (License::Mit, "mit"),
            (License::Apache2, "apache2"),
            (License::Bsd2Clause, "bsd2-clause"),
            (License::Bsd3Clause, "bsd3-clause"),
            (License::Isc, "isc"),
        ] {
            let ser = serde_json::to_value(variant).expect("serialize");
            assert_eq!(ser, serde_json::Value::String(on_wire.into()));
            let back: License = serde_json::from_value(ser).expect("deserialize");
            assert_eq!(back, variant);
        }
    }

    #[test]
    fn license_rejects_non_permissive_variants() {
        for unknown in ["GPL-3.0", "AGPL", "lgpl-2.1", "unknown", "MIT"] {
            let res: Result<License, _> =
                serde_json::from_value(serde_json::Value::String(unknown.into()));
            assert!(res.is_err(), "expected rejection for {unknown:?}");
        }
    }

    #[test]
    fn fetch_source_round_trips() {
        let src = format!(
            r#"
url     = "https://example.invalid/upstream.bin"
blake3  = "{HASH_64}"
license = "mit"
"#
        );
        let fs: FetchSource = toml::from_str(&src).expect("parse");
        assert_eq!(fs.url, "https://example.invalid/upstream.bin");
        assert_eq!(fs.blake3.0, HASH_64);
        assert_eq!(fs.license, License::Mit);

        let back = toml::to_string(&fs).expect("serialize");
        let fs2: FetchSource = toml::from_str(&back).expect("re-parse");
        assert_eq!(fs, fs2);
    }

    #[test]
    fn fetch_source_rejects_unknown_license() {
        let src = format!(
            r#"
url     = "https://example.invalid/upstream.bin"
blake3  = "{HASH_64}"
license = "GPL-3.0"
"#
        );
        let res: Result<FetchSource, _> = toml::from_str(&src);
        assert!(res.is_err(), "expected non-permissive license to reject");
    }

    #[test]
    fn asset_entry_derive_mode_round_trips() {
        let src = format!(
            r#"
schema_version = "V1"

[[asset]]
filename = "whisper/distil-large-v3-q5_1.bin"
blake3   = "{HASH_64}"

[asset.derive.fetch]
url     = "https://example.invalid/ggml-distil-large-v3.bin"
blake3  = "{HASH_64}"
license = "mit"

[asset.derive.transform]
recipe = "whisper-quantize"
params = {{ quant = "q5_1" }}
"#
        );
        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
        assert_eq!(w.assets.len(), 1);
        let entry = &w.assets[0];
        assert!(entry.source.is_none());
        let derive = entry.derive.as_ref().expect("derive present");
        assert_eq!(derive.fetch.url, "https://example.invalid/ggml-distil-large-v3.bin");
        assert_eq!(derive.fetch.license, License::Mit);
        let transform = derive.transform.as_ref().expect("transform present");
        assert_eq!(transform.recipe, "whisper-quantize");
        assert_eq!(transform.params.get("quant").map(String::as_str), Some("q5_1"));

        let back = toml::to_string(&w).expect("serialize");
        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
        assert_eq!(w, w2);
    }

    #[test]
    fn legacy_source_only_asset_serializes_without_derive_field() {
        // Verify the skip_serializing_if guards keep legacy TOMLs round-tripping
        // without ever emitting an empty `derive = ...` line.
        let src = format!(
            r#"
schema_version = "V1"

[[asset]]
filename = "operator-curated.bin"
source   = "sources/operator-curated.bin"
blake3   = "{HASH_64}"
"#
        );
        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
        let back = toml::to_string(&w).expect("serialize");
        assert!(!back.contains("derive"), "serialized output leaked a derive field: {back}");
        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
        assert_eq!(w, w2);
    }

    /// W212/R518: the `[asset.derive.lock]` block round-trips through TOML, and
    /// is omitted from output when absent (so non-derive / unlocked assets stay
    /// clean).
    #[test]
    fn derive_lock_round_trips_through_toml() {
        let toml = r#"
url     = "https://example.invalid/config.json"
blake3  = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
license = "mit"
"#;
        let fetch: FetchSource = ::toml::from_str(toml).unwrap();
        let derive = AssetDerive {
            fetch,
            transform: Some(TransformSpec {
                recipe: "whisper-bundle-tar".into(),
                params: BTreeMap::new(),
            }),
            lock: Some(DeriveLock {
                input_hash: "1111111111111111111111111111111111111111111111111111111111111111".into(),
                output_blake3: "2222222222222222222222222222222222222222222222222222222222222222".into(),
            }),
        };
        let s = ::toml::to_string(&derive).unwrap();
        assert!(s.contains("[lock]"), "lock serialized: {s}");
        let back: AssetDerive = ::toml::from_str(&s).unwrap();
        assert_eq!(derive, back);

        // Absent lock → no `[lock]` table in the output.
        let unlocked = AssetDerive { lock: None, ..derive };
        let s2 = ::toml::to_string(&unlocked).unwrap();
        assert!(!s2.contains("[lock]"), "unlocked must omit lock: {s2}");
    }

    #[test]
    fn shape_static_asset_rejects_both_source_and_derive() {
        use crate::validate::{shape_static_asset, FieldPath, ShapeError};

        let entry = AssetEntry {
            filename: "ambiguous.bin".into(),
            source: Some("sources/ambiguous.bin".into()),
            derive: Some(AssetDerive {
                fetch: FetchSource {
                    url: "https://example.invalid/x".into(),
                    blake3: BlakeHash(HASH_64.into()),
                    license: License::Mit,
                },
                transform: None,
                lock: None,
            }),
            blake3: BlakeHash(HASH_64.into()),
        };
        let w = StaticAssetWorkload {
            schema_version: SchemaVersion::V1,
            assets: vec![entry],
            aliases: BTreeMap::new(),
        };
        let err = shape_static_asset(&w).expect_err("XOR violated");
        match err {
            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
        }
    }

    #[test]
    fn shape_static_asset_rejects_neither_source_nor_derive() {
        use crate::validate::{shape_static_asset, FieldPath, ShapeError};

        let entry = AssetEntry {
            filename: "empty.bin".into(),
            source: None,
            derive: None,
            blake3: BlakeHash(HASH_64.into()),
        };
        let w = StaticAssetWorkload {
            schema_version: SchemaVersion::V1,
            assets: vec![entry],
            aliases: BTreeMap::new(),
        };
        let err = shape_static_asset(&w).expect_err("XOR violated");
        match err {
            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
        }
    }

    #[test]
    fn shape_static_asset_accepts_either_mode() {
        use crate::validate::shape_static_asset;

        let legacy = AssetEntry {
            filename: "a.bin".into(),
            source: Some("sources/a.bin".into()),
            derive: None,
            blake3: BlakeHash(HASH_64.into()),
        };
        let derived = AssetEntry {
            filename: "b.bin".into(),
            source: None,
            derive: Some(AssetDerive {
                fetch: FetchSource {
                    url: "https://example.invalid/b".into(),
                    blake3: BlakeHash(HASH_64.into()),
                    license: License::Apache2,
                },
                transform: None,
                lock: None,
            }),
            blake3: BlakeHash(HASH_64.into()),
        };
        let w = StaticAssetWorkload {
            schema_version: SchemaVersion::V1,
            assets: vec![legacy, derived],
            aliases: BTreeMap::new(),
        };
        shape_static_asset(&w).expect("both modes accepted");
    }

    #[test]
    fn image_ref_string_form_rejects_bare_tag() {
        let res: Result<ImageRef, _> =
            serde_json::from_value(serde_json::Value::String("node:20".into()));
        let err = res.expect_err("bare-tag must reject");
        let msg = format!("{err}");
        assert!(msg.contains("digest"), "error should mention digest: {msg}");
    }

    #[test]
    fn image_ref_string_form_accepts_digest_pinned() {
        let pinned = format!("node:20@sha256:{HASH_64}");
        let img: ImageRef =
            serde_json::from_value(serde_json::Value::String(pinned.clone())).expect("parse");
        assert_eq!(img.registry, "docker.io");
        assert_eq!(img.repository, "library/node");
        assert_eq!(img.tag, "20");
        assert_eq!(img.digest, format!("sha256:{HASH_64}"));
    }

    #[test]
    fn image_ref_string_form_accepts_ghcr_with_pin() {
        let pinned = format!("ghcr.io/foo/bar:v1.7.4@sha256:{HASH_64}");
        let img: ImageRef =
            serde_json::from_value(serde_json::Value::String(pinned)).expect("parse");
        assert_eq!(img.registry, "ghcr.io");
        assert_eq!(img.repository, "foo/bar");
        assert_eq!(img.tag, "v1.7.4");
        assert!(img.digest.starts_with("sha256:"));
    }

    #[test]
    fn image_ref_string_form_rejects_non_sha256_digest() {
        for bad in [
            "node:20@md5:abcdef",
            "node:20@sha1:abcdef",
            "node:20@sha256:",
            "node:20@sha256:zzznothex",
        ] {
            let res: Result<ImageRef, _> =
                serde_json::from_value(serde_json::Value::String(bad.into()));
            assert!(res.is_err(), "expected reject for {bad:?}");
        }
    }

    #[test]
    fn image_ref_struct_form_rejects_missing_digest() {
        // Digest is now structurally required (R438-T3). Struct-form payloads
        // without `digest` must fail at serde-deserialize.
        let v = serde_json::json!({
            "registry": "ghcr.io",
            "repository": "noisetable/api",
            "tag": "v1.4.2",
        });
        let res: Result<ImageRef, _> = serde_json::from_value(v);
        assert!(res.is_err(), "missing digest must reject");
    }

    #[test]
    fn image_ref_struct_form_round_trips_through_toml() {
        let img = ImageRef {
            registry: "ghcr.io".into(),
            repository: "ggerganov/whisper.cpp".into(),
            tag: "v1.7.4".into(),
            digest: format!("sha256:{HASH_64}"),
        };
        let toml_doc = toml::to_string(&img).expect("serialize");
        let back: ImageRef = toml::from_str(&toml_doc).expect("re-parse");
        assert_eq!(img, back);
    }

    /// R546-B7: assert the shape real files use. This test previously fed the
    /// EXTERNALLY-tagged wrapping-table form (`[static-asset]` +
    /// `[[static-asset.asset]]`), which no on-disk `workload.toml` has ever
    /// used — so it stayed green while `yah cloud apply` was broken for every
    /// static-asset component. The flat `kind = "..."` form below is what every
    /// workload.toml in the workspace is written in.
    #[test]
    fn workload_envelope_dispatches_static_asset() {
        let src = format!(
            r#"
kind = "static-asset"
schema_version = "V1"

[[asset]]
filename = "foo/bar.bin"
source   = "sources/bar.bin"
blake3   = "{HASH_64}"
"#
        );
        let w: Workload = toml::from_str(&src).expect("parse");
        assert!(matches!(w, Workload::StaticAsset(_)));
    }

    /// R546-B7: the format branch, both directions. Human-readable formats get
    /// the flat `kind`-tagged shape; postcard keeps the externally-tagged
    /// variant-index encoding the kamaji UDS depends on (R590-B3). Regressing
    /// either side breaks a different half of the system, so pin both.
    #[test]
    fn workload_envelope_is_tagged_in_toml_and_external_in_postcard() {
        let src = format!(
            r#"
kind = "static-asset"
schema_version = "V1"

[[asset]]
filename = "foo/bar.bin"
source   = "sources/bar.bin"
blake3   = "{HASH_64}"
"#
        );
        let w: Workload = toml::from_str(&src).expect("parse flat TOML");

        // Human-readable round-trips stay flat — no wrapping table.
        let json = serde_json::to_string(&w).expect("serialize json");
        assert!(json.contains("\"kind\":\"static-asset\""), "got {json}");
        assert!(
            !json.contains("{\"static-asset\":"),
            "human-readable output must not be externally tagged: {json}"
        );
        assert_eq!(
            serde_json::from_str::<Workload>(&json).expect("re-parse json"),
            w
        );

        // postcard is non-self-describing: it can only round-trip because the
        // binary branch never asks for deserialize_any.
        let bytes = postcard::to_allocvec(&w).expect("postcard encode");
        assert_eq!(
            postcard::from_bytes::<Workload>(&bytes).expect("postcard decode"),
            w
        );
    }

    // ── R572-F1: lifecycle archetype discriminator ─────────────────────────

    fn archetype_test_spec(name: &str) -> WorkloadSpec {
        WorkloadSpec::for_forge(
            name,
            ImageRef {
                registry: "ghcr.io".into(),
                repository: "yah/test".into(),
                tag: "latest".into(),
                digest: testing::test_digest(),
            },
            TierTag("infra".into()),
            vec![],
        )
    }

    #[test]
    fn explicit_archetype_round_trips_through_json_and_wins_over_inference() {
        for archetype in [
            LifecycleArchetype::Server,
            LifecycleArchetype::Appliance,
            LifecycleArchetype::Job,
        ] {
            let mut spec = archetype_test_spec("explicit");
            // Volumes present + restart_policy Always would infer Appliance
            // (see effective_archetype_infers_* below) — deliberately
            // mismatched against every archetype under test so the
            // assertion actually proves the explicit field wins, not that
            // it happens to agree with inference.
            spec.volumes = vec![VolumeMount {
                source: VolumeSource::Named { name: "data".into() },
                target: PathBuf::from("/data"),
                read_only: false,
            }];
            spec.restart_policy = RestartPolicy::Always;
            spec.archetype = Some(archetype);

            let json = serde_json::to_string(&spec).expect("serialize");
            assert!(
                json.contains("\"archetype\""),
                "explicit archetype must be present on the wire"
            );
            let back: WorkloadSpec = serde_json::from_str(&json).expect("deserialize");
            assert_eq!(spec, back, "spec did not survive JSON round-trip");
            assert_eq!(back.archetype, Some(archetype));
            assert_eq!(
                back.effective_archetype(),
                archetype,
                "explicit archetype must win over the volumes/restart_policy inference"
            );
        }
    }

    #[test]
    fn archetype_serializes_as_null_when_none() {
        let mut spec = archetype_test_spec("omitted");
        spec.archetype = None;
        let json = serde_json::to_value(&spec).expect("to_value");
        // Postcard-native (R590-B3): no `skip_serializing_if` anywhere on the
        // graph, so every field is always on the wire — a None Option is an
        // explicit `null`, not an absent key. The binary UDS wire is positional
        // and requires the slot to be present.
        assert_eq!(json.get("archetype"), Some(&serde_json::Value::Null));
    }

    #[test]
    fn spec_without_archetype_field_deserializes_to_none() {
        // Simulates an on-disk spec written before R572-F1: no `archetype`
        // key at all. Omitting the key must still parse to None (the additive-
        // default contract) even though we now always *emit* the field.
        let mut spec = archetype_test_spec("pre-existing");
        spec.archetype = None;
        let mut json = serde_json::to_value(&spec).expect("to_value");
        json.as_object_mut().unwrap().remove("archetype");
        let back: WorkloadSpec = serde_json::from_value(json).expect("deserialize");
        assert_eq!(back.archetype, None);
    }

    #[test]
    fn effective_archetype_infers_appliance_from_volumes_when_field_absent() {
        // Pre-R572 behavior: a workload with a volume was understood (by
        // convention, never a type) to be stateful/pinned. Confirm that
        // meaning is preserved bit-for-bit through effective_archetype().
        let mut spec = archetype_test_spec("appliance-inferred");
        spec.volumes = vec![VolumeMount {
            source: VolumeSource::Named { name: "pgdata".into() },
            target: PathBuf::from("/var/lib/postgresql/data"),
            read_only: false,
        }];
        spec.restart_policy = RestartPolicy::Always;
        spec.archetype = None;
        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
    }

    #[test]
    fn effective_archetype_infers_job_from_restart_never_when_field_absent() {
        // Pre-R572 behavior: RestartPolicy::Never + no volumes is the forge
        // run-once convention (see RestartPolicy::Never's own doc comment) —
        // structurally a job. WorkloadSpec::for_forge already produces
        // exactly this shape; isolate the pure-inference path by clearing
        // the explicit archetype for_forge now sets.
        let mut spec = archetype_test_spec("job-inferred");
        assert!(spec.volumes.is_empty());
        assert!(matches!(spec.restart_policy, RestartPolicy::Never));
        spec.archetype = None;
        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Job);
    }

    #[test]
    fn effective_archetype_defaults_to_server_as_the_common_case_when_field_absent() {
        // Pre-R572 behavior: no volumes + a restartable policy (the common
        // stateless-web-server shape) inferred as movable/fungible.
        let mut spec = archetype_test_spec("server-inferred");
        spec.restart_policy = RestartPolicy::Always;
        spec.archetype = None;
        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Server);
    }

    // ── R594-F2: public-ingress appliance (container-shaped, not a new
    // Workload variant — see Workload::Container's doc comment) ───────────

    #[test]
    fn ingress_marked_spec_is_appliance_and_carries_public_ip_placement_requirement() {
        let mut spec = archetype_test_spec("public-ingress");
        spec.archetype = Some(LifecycleArchetype::Appliance);
        spec.annotations.insert(
            REQUIRES_TAINT_ANNOTATION.to_string(),
            PUBLIC_IP_TAINT.to_string(),
        );

        assert_eq!(
            spec.effective_archetype(),
            LifecycleArchetype::Appliance,
            "ingress must be pinned-per-node/non-drainable, the R572 appliance sense"
        );
        assert_eq!(
            spec.requires_taint(),
            Some(PUBLIC_IP_TAINT),
            "ingress must declare it can only land on a public-ip-tainted node"
        );

        // No taint exists to match against yet (R572-F3) and nothing
        // enforces placement yet (R572-F5) — confirm this ticket stays
        // declarative-only by checking a spec with no requirement stays
        // unaffected.
        let unrelated = archetype_test_spec("unrelated");
        assert_eq!(unrelated.requires_taint(), None);
    }

    #[test]
    fn ingress_marked_spec_round_trips_through_json_as_a_container_workload() {
        // Mirrors the on-disk envelope: the externally-tagged `container`
        // variant wrapping the WorkloadSpec, exactly like every other
        // container-shaped workload. No new Workload variant, no new
        // discriminator.
        let mut inner = archetype_test_spec("public-ingress");
        inner.archetype = Some(LifecycleArchetype::Appliance);
        inner.annotations.insert(
            REQUIRES_TAINT_ANNOTATION.to_string(),
            PUBLIC_IP_TAINT.to_string(),
        );
        let workload = Workload::container(inner.clone());

        let json = serde_json::to_string(&workload).expect("serialize");
        assert!(json.contains("\"container\""));
        assert!(json.contains(REQUIRES_TAINT_ANNOTATION));
        assert!(json.contains(PUBLIC_IP_TAINT));

        let back: Workload = serde_json::from_str(&json).expect("deserialize");
        match back.container_spec() {
            Some(spec) => {
                assert_eq!(spec, &inner);
                assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
                assert_eq!(spec.requires_taint(), Some(PUBLIC_IP_TAINT));
            }
            None => panic!("expected a container reference workload, got {back:?}"),
        }
    }

    // ── Nested-sandbox grant (R636-B2) ──────────────────────────────────────

    #[test]
    fn nested_sandbox_marker_is_opt_in_and_reads_back() {
        // The half that matters: no workload gets the grant by default, so
        // adding the marker cannot widen anything already deployed.
        let plain = archetype_test_spec("ordinary-build");
        assert!(!plain.wants_nested_sandbox());

        let mut buildkit = archetype_test_spec("build-image");
        buildkit.annotations.insert(
            NESTED_SANDBOX_ANNOTATION.to_string(),
            NESTED_SANDBOX_VALUE.to_string(),
        );
        assert!(buildkit.wants_nested_sandbox());

        // Fails closed on any other value, same strictness as
        // `wants_host_network` — a typo must not hand out CAP_SETUID.
        let mut typo = archetype_test_spec("typo");
        typo.annotations
            .insert(NESTED_SANDBOX_ANNOTATION.to_string(), "Nested".to_string());
        assert!(!typo.wants_nested_sandbox());
    }

    /// The three markers are independent axes: asking for host networking or
    /// native exec must not imply the capability grant, and vice versa.
    #[test]
    fn nested_sandbox_marker_is_independent_of_the_other_markers() {
        let mut host_net = archetype_test_spec("host-net");
        host_net.annotations.insert(
            HOST_NETWORK_ANNOTATION.to_string(),
            HOST_NETWORK_VALUE.to_string(),
        );
        assert!(host_net.wants_host_network());
        assert!(!host_net.wants_nested_sandbox());

        let mut nested = archetype_test_spec("nested");
        nested.annotations.insert(
            NESTED_SANDBOX_ANNOTATION.to_string(),
            NESTED_SANDBOX_VALUE.to_string(),
        );
        assert!(nested.wants_nested_sandbox());
        assert!(!nested.wants_host_network());
        assert!(!nested.wants_native_exec());
    }

    // ── Native exec marker (R577-T1 / W254) ─────────────────────────────────

    #[test]
    fn native_exec_marker_is_opt_in_and_reads_back() {
        // Default: every forge workload is a container workload. This is the
        // half that matters most — the marker must not silently reroute the
        // Linux offload leg proven live on us-west-002.
        let plain = archetype_test_spec("linux-build");
        assert!(!plain.wants_native_exec());

        let mut native = archetype_test_spec("darwin-build");
        native.annotations.insert(
            NATIVE_EXEC_ANNOTATION.to_string(),
            NATIVE_EXEC_VALUE.to_string(),
        );
        assert!(native.wants_native_exec());

        // Any other value is not the opt-in — same strictness as
        // `wants_host_network`, so a typo fails closed onto the container
        // backend rather than escaping the sandbox.
        let mut typo = archetype_test_spec("typo");
        typo.annotations
            .insert(NATIVE_EXEC_ANNOTATION.to_string(), "Native".to_string());
        assert!(!typo.wants_native_exec());
    }

    #[test]
    fn native_marked_spec_round_trips_through_json_as_a_container_workload() {
        // The point of the annotation shape: a native workload is still a
        // `Workload::Container` on the wire, so kamaji-proto's codec, yubaba
        // admission and the mesh-assignment path need no new variant.
        let mut inner = archetype_test_spec("darwin-build");
        inner.annotations.insert(
            NATIVE_EXEC_ANNOTATION.to_string(),
            NATIVE_EXEC_VALUE.to_string(),
        );
        let workload = Workload::container(inner.clone());

        let json = serde_json::to_string(&workload).expect("serialize");
        assert!(json.contains(NATIVE_EXEC_ANNOTATION));

        let back: Workload = serde_json::from_str(&json).expect("deserialize");
        match back.container_spec() {
            Some(spec) => {
                assert_eq!(spec, &inner);
                assert!(spec.wants_native_exec());
            }
            None => panic!("expected a container reference workload, got {back:?}"),
        }
    }

    // ── MicroVM marker (R605-F8 / W325 §5) ──────────────────────────────────

    #[test]
    fn microvm_marker_is_opt_in_and_reads_back() {
        let plain = archetype_test_spec("linux-build");
        assert!(!plain.wants_microvm());

        let mut vm = archetype_test_spec("isolated-build");
        vm.annotations.insert(
            NATIVE_EXEC_ANNOTATION.to_string(),
            MICROVM_EXEC_VALUE.to_string(),
        );
        assert!(vm.wants_microvm());

        // Fails closed onto the container backend, like every other marker: a
        // typo must not be read as "boot a VM", because the deploy that would
        // then be refused for lack of a microVM backend is a *worse* failure
        // than the container run the author actually spelled.
        let mut typo = archetype_test_spec("typo");
        typo.annotations
            .insert(NATIVE_EXEC_ANNOTATION.to_string(), "MicroVM".to_string());
        assert!(!typo.wants_microvm());
        assert!(!typo.wants_native_exec());
    }

    #[test]
    fn exec_substrate_markers_are_mutually_exclusive_by_construction() {
        // This is the property that buys R605-F8 out of a refusal branch: the
        // three substrates share one annotation key, so no spec can ask for two
        // of them. Pinned because a later "let's give microVM its own key"
        // refactor would silently re-open the incoherent-pair case that
        // `yah.sandbox` + `yah.exec = native` still has to be refused for.
        assert_eq!(
            NATIVE_EXEC_ANNOTATION, NATIVE_EXEC_ANNOTATION,
            "both substrate values must live on the same key"
        );
        assert_ne!(NATIVE_EXEC_VALUE, MICROVM_EXEC_VALUE);

        for value in [NATIVE_EXEC_VALUE, MICROVM_EXEC_VALUE, "", "container"] {
            let mut spec = archetype_test_spec("substrate");
            spec.annotations
                .insert(NATIVE_EXEC_ANNOTATION.to_string(), value.to_string());
            assert!(
                !(spec.wants_native_exec() && spec.wants_microvm()),
                "yah.exec={value:?} selected two substrates at once"
            );
        }
    }

    #[test]
    fn microvm_marked_spec_round_trips_through_json_as_a_container_workload() {
        // Same zero-blast-radius claim as the native case: a microVM workload
        // is still `Workload::Container` on the wire, so kamaji-proto's codec
        // gains no variant and its positional postcard encoding does not move.
        let mut inner = archetype_test_spec("isolated-build");
        inner.annotations.insert(
            NATIVE_EXEC_ANNOTATION.to_string(),
            MICROVM_EXEC_VALUE.to_string(),
        );
        let workload = Workload::container(inner.clone());

        let json = serde_json::to_string(&workload).expect("serialize");
        assert!(json.contains(MICROVM_EXEC_VALUE));

        let back: Workload = serde_json::from_str(&json).expect("deserialize");
        match back.container_spec() {
            Some(spec) => {
                assert_eq!(spec, &inner);
                assert!(spec.wants_microvm());
                assert!(!spec.wants_native_exec());
            }
            None => panic!("expected a container reference workload, got {back:?}"),
        }
    }
}