File size: 127,050 Bytes
665e5ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665e5ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665e5ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
 
665e5ea
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665e5ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""core/grid_events.py β€” THE WRITE SEAM, lifted out of Streamlit (EXIT-1a, X1, 2026-07-30).



This is the grid's event handler: the code that takes an UNTRUSTED event object from the

browser component and decides what may be persisted. It was `app.py:_cl_handle_grid_event` /

`_cl_handle_one` and moved here as a PURE MOVE β€” no behavior change inside a move (MA principle

5). Every body below is the app.py body verbatim; the ONLY mechanical rewrites are the

Streamlit couplings the contract names:



    st.session_state['_cl_grid_seen_ids']   -> ctx.seen_ids       (adapter-owned dedup state)

    st.session_state['_cl_doc_payload']     -> ctx.out.doc        (EventResult.doc)

    st.session_state['_cohort_toast']       -> ctx.out.toast      (EventResult.toast)

    st.session_state['cl_table_workspace']  -> ctx.fallback_ws    (store-unavailable fallback)

    is_admin()  (the bare call inside doc_delete, and _cl_table_workspace's admin=None default)

                                            -> ctx.admin



There is NO `import streamlit` in this file and there must never be one β€” `verify_no_streamlit.py`

enforces that with a `sys.meta_path` block, which is the only proof that survives refactoring.

(That gate was `verify_seam.py` until EXIT-6 retired it with `app.py`; the block, its negative

control and the AST walk moved over intact.)



ONE ADAPTER, ONE HANDLER β€” it was TWO until 2026-08-04:

  * β›” the Streamlit adapter is GONE. `app.py::_cl_handle_grid_event` built an EventCtx whose

    `seen_ids` and `fallback_ws` were session-state-backed dicts and applied the Result to session

    state. EXIT-6 deleted it. The host-neutrality this file was written for is what let that

    happen without touching a line of the handler β€” which is the argument for the design, recorded

    at the moment it paid out.

  * the HTTP adapter β€” `aios-web/api` builds an EventCtx with `fallback_ws=None`. A None

    fallback with the store unavailable is a **503**, never a silent in-memory workspace: an

    API request has no durable session dict to fall back to, so pretending to persist would

    lose the write with a 200 on it.



⚠ LAYERING, stated honestly. `core/` is not supposed to import `modules/` (ARCHITECTURE §1's

one-directional rule). This handler genuinely straddles: it is the WRITE half of the customer

table and its persistence verbs live in `modules.customer_data` / `modules.cohort`. The

contracted path for the strangler is `core/grid_events.py` (both adapters import it from here),

so the import stays and the violation is RECORDED rather than hidden. It is the one file in

`core/` that points up; when EXIT-5 ports the surfaces, the persistence verbs are what move

down, and this note is the marker for that work.

"""
import datetime as dt
import json as _json_ev                 # wave 23 (C7): the json field's parse wall
from dataclasses import dataclass, field
from typing import MutableMapping, Optional

import core.store as store
import core.table_store as _tstore     # wave-9 I17: the shared-view authorisation predicates

#: ⭐ WAVE-27 item 5 (C7) β€” how many view ids one rail arrangement may name. Generous on purpose:
#: it bounds a hostile payload, not the user (the rail holds saved views + cohort projections +
#: shared views, and a busy Customer grid already carries dozens). An order longer than this is
#: not a person dragging rows.
MAX_VIEW_ORDER = 500
import core.user_tables as _ut_limits  # wave 23 (C7): MAX_JSON_CELL β€” one ceiling, one definer
import core.users as users
import harness.telemetry as _tel       # _tel.error() = THE error sink (all caught errors log)
import modules.cohort as cohort_mod
import modules.customer_data as cl_mod

#: C5 (owner item 12): customer documents. The cap is enforced HOST-side on the DECODED byte
#: count β€” the client checks the same number first, but a client check only spares the user a
#: pointless upload; this is what bounds what the tenant's dataset can be made to hold.
#: MOVED from app.py:1410–1412 with the handler (app.py re-imports these during the strangler).
_DOCS_KEY = 'customer_docs'
_DOC_MAX_BYTES = 5 * 1024 * 1024
_DOC_MAX_PER_ROW = 50

#: The props of a field def the CLIENT actually draws β€” the wave-6 no-blip comparison set.
#: Host-only stamps (createdBy, permissions) are deliberately absent: the client renders
#: nothing from its own copy of those, and a permissions refusal is answered explicitly.
_FIELD_ECHO_PROPS = ('label', 'type', 'note', 'options', 'colorCodeOptions',
                     'optionColors', 'max', 'formula', 'agg', 'scope', 'format', 'measure')


class StoreUnavailable(RuntimeError):
    """The store is down AND the caller offered no durable fallback (HTTP adapter β†’ 503).



    The Streamlit adapter never sees this: it passes a session-state-backed `fallback_ws`, so

    its store-down path degrades to the same captioned in-memory workspace it always had. An

    API caller has no such dict, and the honest answer to "persist this" with nowhere to

    persist it is an error β€” not a 200 over a write that evaporates.

    """


@dataclass
class EventResult:
    """What the HANDLER needs to tell its adapter β€” the three things it cannot do itself.



    `rerender` is the old return value with its old meaning: "this page must re-render from

    server state", NOT "handled" (see `handle_one`'s docstring β€” the distinction is the

    difference between a snappy grid and a sluggish one).

    """
    rerender: bool = False
    doc: Optional[dict] = None       # doc_fetch's answer β€” the bridge has no response channel
    toast: Optional[str] = None      # add_to_list's confirmation


@dataclass
class EventCtx:
    """Everything the handler is allowed to know, and nothing about how it is being served.



    ⚠ `allowed_pids` is a SECURITY parameter: it is the pool this caller may touch at all, and

    every row-scoped event (overlay_patch, doc_*, cohort membership, add_to_list, memberPids)

    is intersected with it. `admin` and `uname` are likewise the HOST's statement of identity β€”

    never anything the browser said about itself.



    `seen_ids` is ADAPTER-OWNED dedup state (the event log resends its recent window on every

    emit, so consumed ids must stay consumed): Streamlit passes a session-state dict, HTTP

    passes the caller's per-session mapping.



    `fallback_ws` is the store-unavailable workspace home, the OUTER dict keyed by username

    (exactly what `st.session_state.setdefault('cl_table_workspace', {})` is). HTTP passes

    None, and None + store down raises StoreUnavailable (β†’ 503).



    `out` is the sink for the two parked answers, so `handle_one` can keep returning a bare

    bool exactly as it did in app.py while doc/toast still escape. (Additive amendment to X1's

    field list, 2026-07-30 β€” defaulted; recorded in the S1 mailbox.)

    """
    uname: str
    allowed_pids: frozenset
    fields: list
    measure_keys: frozenset = frozenset()
    resolved_ids: frozenset = frozenset()
    cohort_ids: frozenset = frozenset()
    measure_offer: tuple = ()
    visible_views: tuple = ()
    admin: bool = False
    #: β›” WAVE 15 C-PERM β€” the fields this caller's PERMANENT PERMISSIONS hide, transitive
    #: closure included (`core.perm_scope.hidden_keys`). A SECURITY parameter in the same class
    #: as `allowed_pids`, and here for the same reason: the read paths strip a hidden field from
    #: both wires, so a caller cannot SEE it β€” but without this they could still WRITE it by
    #: naming the key, because `overlay_keys` only asks whether the column exists. Empty means
    #: "nothing hidden", which is every legacy record and therefore every caller today.
    hidden_keys: frozenset = frozenset()
    scope_key: str = 'customer'
    #: Wave 16 C-TOPIC β€” the TABLE OPS this ctx reads and writes (a `core.table_store.make`
    #: object). None = the CUSTOMER table (`modules.customer_data.TABLE_OPS`), which keeps
    #: every existing caller byte-identical. The PRODUCT surface passes
    #: `modules.product_data.TABLE_OPS` so its views/fields/overlays land in the product
    #: bucket β€” never the customer one, whose pids are a different id space entirely.
    table: Optional[object] = None
    #: ⭐ WAVE 25 (R6b, closes DEBT D-16) β€” THE TENANT STORE HANDLE, and it is a RESIDENCY
    #: parameter in the same class as `allowed_pids` is a security one. Everything this handler
    #: could not reach through `ctx.table` used to go through the MODULE-LEVEL `core.store`,
    #: i.e. tenant #0's dataset repo: the documents family, `_store_of(ctx).available()`, and β€” through
    #: `_tops`' fallback β€” the whole customer and product table workspace. Owner, R6b:
    #: *"Tenant 0 should not intertwine anywhere."*
    #:
    #: None keeps the module default, which is byte-identical for tenant #0 (empty prefix,
    #: shared repo) and is what every non-HTTP caller still gets. The HTTP adapters pass
    #: `session.runtime`, so a Nurilab write resolves against Nurilab.
    st: Optional[object] = None
    seen_ids: Optional[MutableMapping] = None
    fallback_ws: Optional[MutableMapping] = None
    out: EventResult = field(default_factory=EventResult)

    def __post_init__(self):
        # A caller that forgets `seen_ids` gets per-call dedup rather than a crash β€” the SET is
        # what makes re-delivery idempotent, and its absence must not be the thing that decides
        # whether an event is processed at all.
        if self.seen_ids is None:
            self.seen_ids = {}


def _store_of(ctx):
    """THE store handle this ctx reads and writes through (wave 25, R6b / D-16). ONE resolver,

    so "whose repo does this land in" has exactly one answer per event.



    Order, and each step is deliberate: the ctx's own tenant handle Β· else the handle the TABLE

    OPS object was built with (`table_store.make(..., st=…)` keeps it on `.st`, which is how the

    `ut_` path has been tenant-correct since wave 18) Β· else the module default.



    β›” THE MODULE DEFAULT IS TENANT #0, AND THAT IS THE BUG D-16 NAMES β€” not a safe base case.

    It is kept because it is byte-identical for tenant #0 (empty prefix, shared repo) and every

    non-HTTP caller still relies on it; what changed is that the HTTP adapters now pass a real

    handle, so the fallback is no longer what serves a Nurilab request. `verify_scopes` proves

    that with a negative control: force this to fall through and the residency legs go RED.

    """
    st = getattr(ctx, 'st', None)
    if st is not None:
        return st
    tbl = getattr(ctx, 'table', None)
    inner = getattr(tbl, 'st', None) if tbl is not None else None
    return inner if inner is not None else store


def _blobs(ctx):
    """The BYTE store for this ctx β€” `_store_of`'s sibling, split out because it is NOT yet

    equivalent and pretending otherwise would hide a live residency hole.



    ⚠ `TenantRuntime` exposes `get/put/update/exists/available` and **no byte methods**

    (`harness/runtime.py`), so a tenant with its OWN repo has nowhere tenant-scoped to put a

    document's BYTES. The metadata half of D-16 is fixed here; the byte half needs

    `upload_bytes`/`download_bytes`/`delete_path` on the runtime, which is SESSION A's file β€”

    asked for in the mailbox and booked as debt rather than papered over with a fallback that

    reads as intentional. The moment those methods exist this resolver picks them up with no

    other change, which is why it is written as a capability test and not as a version check.

    """
    st = _store_of(ctx)
    return st if hasattr(st, 'upload_bytes') else store



def _ag_overview_id():
    """`aios_grid.IG_OVERVIEW_ID`, resolved defensively.



    Function-local import, this module's convention for the layer above it. Falls back to the

    literal rather than raising: a store blip must not turn "refuse to delete" into "allow", and

    an unguarded delete here is the one that reads as success and undoes itself.

    """
    try:
        import aios_grid as _ago
        return _ago.IG_OVERVIEW_ID
    except Exception:
        return 'tpl_overview'

def _tops(ctx):
    """The table-ops object this ctx operates on β€” the customer table unless the caller chose

    another topic. ONE resolution point so 'which bucket does this write land in' has exactly

    one answer per event.



    β›” WAVE 25 (R6b) β€” THIS FALLBACK IS STILL TENANT #0's, DELIBERATELY, AND IT IS THE ONE PIECE

    OF THE RESIDENCY FIX THAT DID NOT LAND. `cl_mod.TABLE_OPS` is a module-level singleton built

    with no store handle, so the customer and product table workspaces (views, fields, overlays,

    folders) resolve to tenant #0 for every caller. Scoping it here was written, measured and

    then REVERTED, because it would have been worse than the bug:



        the WRITE door (`routes_grid._ctx`) is this session's file and now carries a handle;

        the READ door is `routes_customers._ctx_for` / `routes_products` β€” files in NO session's

        fence this wave β€” and they carry none.



    Moving only the write side means a prefix tenant saves a view into `t/<slug>/…` and reads

    back from the unprefixed bucket forever: their own view vanishes the moment they save it.

    A latent split is worse than a stated residency error, and read and write must move in ONE

    change. Booked as an ask + debt with that exact shape. Tenant #0 is unaffected either way

    (empty namespace, default repo), which is why nothing today would have shown it.



    β›” Untouched by this wave regardless: the EMIT GUARD is D-40, deferred by ruling A-15.

    """
    if ctx.table is not None:
        return ctx.table
    return cl_mod.TABLE_OPS


def _cohorts(ctx):
    """The COHORT store this ctx operates on β€” `_tops`'s sibling, and here for the same reason.



    ⭐ WAVE 19 / R9: a cohort belongs to its database. Every cohort verb below used to name

    `cohort_mod` directly, so a list built on the Product surface landed in `customer_cohorts`

    beside somebody's real customer lists β€” with PRODUCT pids in it (CRC32 hashes of SKU codes),

    which the customer page would then try to resolve against partner ids. Wave 16 answered that

    by REFUSING cohorts on other topics (`with_cohorts=False`); R9 says scope-parameterize

    instead. `modules.cohort.scoped` returns the module itself for the customer topic, so the

    customer path is byte-unchanged.

    """
    return cohort_mod.scoped(ctx.scope_key)


#: The keys a stored document entry may put on the wire. β›” `path` IS NOT ONE OF THEM β€” it is the
#: byte address inside the tenant's repo, of no use to a browser and a needless disclosure of the
#: store's layout. `canDelete` is not stored either: it is a fact about the READER, computed per
#: request below, so one person's answer can never be cached into another's.
_DOC_WIRE_KEYS = ('id', 'name', 'mime', 'size', 'by', 'ts')


def docs_for(pids, scope_key='customer', uname='', admin=False, st=None):
    """`{"<pid>": [CustomerDoc, ...]}` for a SCOPED row set β€” the producer `payload.docs` lost.



    ⭐⭐ WAVE 30 / CONTRACT C4 (D-138). The write door has been live and UNREACHABLE since EXIT-6:

    `doc_add`/`doc_fetch`/`doc_delete` all still work, and the comment inside that handler says

    exactly why nobody noticed β€” *"the React shell cannot reach it yet (`payload.docs` is set only

    by app.py)"*. `app.py` was deleted, and the producer went with it. Every client half survived:

    `RecordDetail`, `SwipeView` and `Documents` are complete, and all six `onDoc*` handlers in

    `CustomerGrid.tsx` are written as `payload?.docs ? … : undefined`, so an ABSENT `docs` key is

    what has been switching the whole feature off. An empty dict is truthy and turns it on with an

    honest empty state.

    β›” THIS IS THE SIXTH INSTANCE OF THE REACHABILITY CLASS and the one `verify_reachability.py`

    cannot see β€” a payload key assembled through helpers and dict merges is not a text-matchable

    literal ([[reachable-is-not-the-same-as-built]], [[artifact-with-no-importer]]).



    β›” ONE READER, NOT A MATCHING PAIR. `routes_tables` (ut_*) and `routes_customers` (the customer

    scope) both call THIS β€” they do not each serialise. Two surfaces answering one question

    separately is how the same defect got reintroduced in the opposite direction inside a single

    commit ([[one-question-two-normalizers]]).



    ⚠ `pids` IS REQUIRED and is the row set the CALLER has already scoped β€” `shared_overlay.cells`'

    argument, for the same reason: there is no "every document in the tenant" call to reach for.

    A record with no documents gets `[]`, never a missing key, so a client can tell "none" from

    "not served".

    """
    wanted = {str(int(p)) for p in (pids or [])}
    if not wanted:
        return {}
    scope = str(scope_key or 'customer').strip().lower()
    key = _DOCS_KEY if scope in cohort_mod.LEGACY_SCOPES else f'{scope}_docs'
    try:
        stored = (st if st is not None else store).get(key) or {}
    except Exception:                                                 # noqa: BLE001
        stored = {}                       # a display read degrades; it never breaks the grid
    out = {}
    for pid in wanted:
        row = stored.get(pid)
        docs = []
        for d in (row if isinstance(row, list) else []):
            if not isinstance(d, dict) or not d.get('id'):
                continue
            wire = {k: d.get(k) for k in _DOC_WIRE_KEYS}
            # The delete wall, mirrored onto the wire so the button is absent rather than
            # offered-and-refused. `doc_delete` enforces the same test server-side; this is the
            # DISPLAY of that rule, never a substitute for it.
            wire['canDelete'] = bool(admin or (d.get('by') and d.get('by') == uname))
            docs.append(wire)
        out[pid] = docs
    return out


def _docs_key(ctx):
    """The DOCUMENTS bucket this ctx operates on β€” `_cohorts`'s sibling (wave 19, item 12's

    silent third case).



    β›” `_DOCS_KEY` STAYS the module constant with its shipped value: app.py re-imports it for the

    Streamlit docs payload and `verify_seam.py` asserts the two resolve to the same string. This

    resolves the CUSTOMER topic to exactly that constant and gives every other database its own

    bucket, so the pid keys inside one bucket all come from one id space.

    """
    scope = str(getattr(ctx, 'scope_key', '') or 'customer').strip().lower()
    if scope in cohort_mod.LEGACY_SCOPES:
        return _DOCS_KEY
    return f'{scope}_docs'


# ------------------------------------------------------------------ the workspace family (X1 amd.)
def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
    """Durable Airtable-style view/schema/overlay state, with an honest session fallback.



    Wave-9 I17: the returned `views` are this user's OWN views MERGED with every view SHARED

    with them. Own views win a same-id collision β€” a personal view is the user's own object and

    must never be shadowed by somebody else's share.



    ⚠ `allowed_pids` is a SECURITY parameter, not a convenience. A shared view carries

    `memberPids`, which the writer's scope validated on the way IN β€” but the READER may have a

    narrower scope. Handing a Fisch-scoped user a view whose member list was built by a

    full-access user would leak the other BU's customers straight through the strict-isolation

    rule. So membership is re-filtered against the READER on the way OUT. Pass it wherever the

    result reaches a renderer or an event handler; omitting it drops memberPids entirely rather

    than trusting the stored list (fail-closed: a smaller view, never a wider one).



    MOVED from app.py:5323 (`_cl_table_workspace`). `admin` was a parameter defaulting to a live

    `is_admin()` call; it now always comes from `ctx.admin`, which is identical at the one call

    site because app.py computes `admin = bool(is_admin())` once per render. `allowed_pids`

    stays an EXPLICIT argument because the folder leg deliberately omits it.

    """
    uname = ctx.uname
    if _store_of(ctx).available():
        ws = _tops(ctx).workspace(
            uname, consume_corrections=bool(consume_corrections))
        try:
            shared = _tops(ctx).shared_views(uname, bool(ctx.admin))
        except Exception as _se:
            _tel.error('sharedviews:read', _se)
            shared = {}
        # ⭐ WAVE 21 (item 9, C1): the R10 registry's named-user grants, PROJECTED at last.
        # Wave 20 wrote view grants and no reader ever consulted them β€” "Share view…" recorded
        # a row and changed nothing on the receiver's screen.
        try:
            granted = _granted_views(ctx, uname)
        except Exception as _ge:
            _tel.error('sharedviews:granted', _ge)
            granted = {}
        if shared or granted:
            merged = dict(granted)
            merged.update(shared)                       # the everyone-bucket outranks a grant
            merged.update(ws.get('views') or {})        # own views win the id collision
            ws['views'] = {vid: scope_view(v, uname, allowed_pids)
                           for vid, v in merged.items()}
        return ws
    # Store down. Streamlit degrades to its captioned session workspace exactly as before; an
    # API caller has no durable dict and must be told, not quietly served memory.
    if ctx.fallback_ws is None:
        raise StoreUnavailable('table workspace unavailable: no store and no session fallback')
    return ctx.fallback_ws.setdefault(
        uname, {'views': {}, 'fields': {}, 'overlays': {}})


def _granted_views(ctx, uname):
    """{view_id: STAMPED view} for every view GRANTED to `uname` by name (wave 21, item 9, C1).



    The R10 registry stores bare ids, tenant-scoped through the SAME store handle this topic's

    bucket uses; the record itself is fetched out of the OWNER's personal stratum of THIS

    bucket. An id granted on another topic simply finds nothing here and contributes nothing

    (fail-closed). Each projected view is stamped `shared` / `sharedRole` / `owner` so the

    client can synthesise the "Shared with me" group and disable edit affordances for

    role=view β€” the stamps live on the PROJECTION only, never written back to the store.

    `role_for` is asked with is_admin=False deliberately: the stamp reports the GRANT (the

    registry listed only explicit entries), not the caller's rank.

    """
    import core.shares as shares
    tops = _tops(ctx)
    st = getattr(tops, 'st', None)
    ids = (shares.shared_with(uname, kind='view', st=st) or {}).get('view') or []
    out = {}
    for vid in ids:
        found = tops.find_view(vid) if hasattr(tops, 'find_view') else None
        if not found:
            continue
        owner, view = found
        if owner == uname:
            continue
        role = shares.role_for('view', vid, uname, st=st)
        if role not in ('view', 'edit'):
            continue
        v = dict(view)
        v['shared'] = True
        v['sharedRole'] = role
        v['owner'] = owner
        out[str(vid)] = v

    # ⭐ D-37 (2026-08-05) β€” THE FOLDER LEG. W20's R10 said "a folder share = its views ride
    # along"; W21 wired the view kind only, so a granted folder was accepted, stored and listed
    # under "Shared with me" while changing NOTHING on the receiver's screen. Two waves of a
    # control that reported success and did nothing β€” the same defect class the view leg above
    # was written to close, surviving in its sibling.
    #
    # ⚠ A DIRECT VIEW GRANT WINS over the folder it happens to sit in, and the ordering above is
    # what implements that: the direct pass has already filled `out`, so the folder pass skips
    # ids it finds there. The specific grant is the more deliberate statement of intent, and it
    # may carry a DIFFERENT role β€” a folder shared read-only must not downgrade a view somebody
    # was explicitly given edit on, nor upgrade one they were not.
    try:
        folder_ids = (shares.shared_with(uname, kind='folder', st=st) or {}).get('folder') or []
    except Exception:
        folder_ids = []
    for fid in folder_ids:
        found = tops.find_folder(fid) if hasattr(tops, 'find_folder') else None
        if not found:
            continue                      # a folder on another topic contributes nothing here
        owner, folder, inside = found
        if owner == uname:
            continue
        role = shares.role_for('folder', fid, uname, st=st)
        if role not in ('view', 'edit'):
            continue
        for vid, view in inside.items():
            if str(vid) in out:
                continue
            v = dict(view)
            v['shared'] = True
            v['sharedRole'] = role
            v['owner'] = owner
            # The folder's NAME rides so the client can group these together later; today it
            # renders in the same "Shared with me" group the view leg feeds. Stamps live on the
            # PROJECTION only and are never written back, exactly as above.
            v['sharedFolder'] = str(folder.get('name') or '')[:80]
            out[str(vid)] = v
    return out


def scope_view(view, uname, allowed_pids):
    """Re-scope a view to the READER. A no-op for a view the reader created.



    Two things travel inside a shared view that are SCOPE, not presentation:

      1. `memberPids` β€” a customer list. Re-filtered against the reader's permitted pool.

      2. cohort conditions β€” a cohort id is a permission handle owned by ONE user. For a reader

         who does not own it the leaf is UNANSWERABLE, and partial evaluation of an unanswerable

         leaf WIDENS `is none of` ([[cg-cohort-set-conditions]]) β€” i.e. it would show MORE rows,

         not fewer. So the whole filter tree is dropped for a foreign reader rather than

         evaluated with a hole in it. A visibly unfiltered shared view is recoverable; a

         silently widened one is a leak.

    """
    if not isinstance(view, dict) or view.get('createdBy') == uname:
        return view
    out = dict(view)
    cfg = dict(out.get('config') or {})
    mp = cfg.get('memberPids')
    if isinstance(mp, list):
        cfg['memberPids'] = ([p for p in mp if p in (allowed_pids or ())]
                             if allowed_pids is not None else [])
    if tree_has_cohort(cfg.get('filters')):
        cfg['filters'] = []
        cfg['_scopedOut'] = 'cohort'      # the client says so rather than pretending
    out['config'] = cfg
    return out


def tree_has_cohort(node):
    """True when a filter tree references the Cohorts column anywhere (any depth)."""
    import aios_grid as _agc
    col = _agc.COHORT_COLUMN
    if isinstance(node, list):
        return any(tree_has_cohort(n) for n in node)
    if not isinstance(node, dict):
        return False
    if node.get('colId') == col or node.get('field') == col:
        return True
    return tree_has_cohort(node.get('conditions') or node.get('children'))


def measure_leaves(nodes):
    """Every leaf of a filter tree that carries a date WINDOW β€” i.e. every measure condition.



    Keyed off the window rather than the column name on purpose: a window is the one thing no

    column condition has, so this cannot mistake `revenue_ytd > 5000` for a measure.

    """
    out = []
    for node in nodes or []:
        if not isinstance(node, dict):
            continue
        if isinstance(node.get('children'), list):
            out.extend(measure_leaves(node['children']))
        elif node.get('window') is not None:
            out.append(node)
    return out


# ------------------------------------------------------------------ private handler helpers
def _field_echo_equal(accepted, raw):
    """Does the ACCEPTED def match what the client already renders? (wave-6 item 3)



    The no-blip law: a field_upsert whose acceptance changed NOTHING the client draws needs no

    re-render β€” the client is the ORIGIN of the change, its optimistic copy is already right,

    and the rerun it would buy costs a full payload re-serialisation plus an iframe remount

    (the "blip" the owner named). STRICT per-prop equality after collapsing the empty forms

    ('' / [] / {} / absent all render identically): any normalisation a validator applied is a

    real difference the client must be SHOWN. Fail-closed β€” unsure means not equal means rerun.

    """
    empty = ('', [], {})
    for k in _FIELD_ECHO_PROPS:
        a, b = accepted.get(k), raw.get(k) if isinstance(raw, dict) else None
        if k == 'scope':
            # 'global' and absent are the SAME rendering (an unscoped field) β€” the event may
            # say 'global' explicitly while the stored def says it by omission.
            a = None if a == 'global' else a
            b = None if b == 'global' else b
        if (None if a in empty else a) != (None if b in empty else b):
            return False
    return True


def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120):
    """Return a durable, case-insensitively unique field/view display name."""
    limit = max(1, int(max_len))

    def _clean(value):
        return ' '.join(str(value or '').split())

    base = (_clean(wanted) or _clean(fallback) or 'Untitled')[:limit].rstrip()
    taken = {_clean(value).casefold() for value in existing if _clean(value)}
    if base.casefold() not in taken:
        return base
    index = 2
    while True:
        suffix = f' {index}'
        stem = base[:max(0, limit - len(suffix))].rstrip()
        candidate = f'{stem}{suffix}' if stem else str(index)[-limit:]
        if candidate.casefold() not in taken:
            return candidate
        index += 1


def _known_usernames():
    """Real account names, for C4's fail-closed `users` grant. None = "could not resolve".



    None means DO NOT FILTER, deliberately. Returning an empty set on a transient registry

    error would silently collapse a user's stored 'specific users' grant to 'personal' on the

    very next autosave β€” destroying a setting because a read blipped. The grant is inert today

    anyway (views are per-user; nothing reads it to allow cross-user access), so the honest

    trade is: never invent access, never destroy a stored intent either.

    """
    try:
        reg = set(users.registry() or {})
    except Exception as _ue:
        _tel.error('viewperms:users', _ue)
        return None
    # ⚠ EMPTY IS "UNRESOLVED", NOT "NOBODY EXISTS". registry() returns {} rather than raising
    # when the user store cannot be read (a store blip logs `store:get:users` and hands back an
    # empty dict) β€” and a working app ALWAYS has at least the bootstrap admin. Treating {} as a
    # real answer would filter every name out of a stored grant and collapse it to 'personal'
    # on the next autosave. Caught by testing this in BARE mode with no store token.
    return reg or None


# ------------------------------------------------------------------ THE SEAM
def handle_events(events, ctx):
    """The event LOG entry point β†’ one EventResult. The `[-24:]` window lives here.



    ⚠ THE VALUE IS AN EVENT LOG `{'events': [...]}`, NOT ONE EVENT (2026-07-27). A component

    has exactly one value slot, and two quick emits share it: creating a field emits

    `field_upsert` and the insertColumn autosave emits `view_upsert` 420ms later β€” with the

    server still inside the first rerun, the second write clobbered the slot before any run

    read it, and the field creation VANISHED with no error anywhere (measured live; the same

    race sat latent under overlay-field creation, masked by localStorage). The client now

    sends its recent window of events; each is processed exactly once, keyed by its id. The

    single-event shape is still accepted β€” it is what an older client in a stale tab sends.



    Accepts everything the component value can be: the log dict, a bare list of events (what

    the HTTP body's `events` array unwraps to), or one legacy single-event object.



    `EventResult.rerender` is True when ANY processed event demands a re-render from server

    state β€” the same OR-fold `_cl_handle_grid_event` did.

    """
    if isinstance(events, dict) and isinstance(events.get('events'), list):
        seq = events['events'][-24:]
    elif isinstance(events, list):
        seq = events[-24:]
    else:
        seq = [events]
    rerun = False
    for one in seq:
        rerun = handle_one(one, ctx) or rerun
    ctx.out.rerender = rerun
    return ctx.out


def handle_one(event, ctx):
    """Validate ONE untrusted component event.



    Odoo-source fields are never accepted as cell updates. View and schema objects are

    user-scoped; row patches additionally require the pid to be in the currently permitted

    Customer List pool.



    RETURN VALUE = "this page must re-render from server state", NOT "handled".

    The distinction is the difference between a snappy grid and a sluggish one.

    Persisting is not by itself a reason to rerun: Streamlit has ALREADY re-run the

    script once simply to deliver the component value, and the client is the ORIGIN

    of the change, so its own state is correct before the server hears about it. An

    extra st.rerun() therefore buys nothing and costs a second full page run.

    Measured on the 1,550-row book (2026-07-25): one filter tweak produced TWO

    page_customer_data runs ~2.8s apart, each re-serialising the whole ~576 KB row

    payload β€” and the handler on the second run is a guaranteed no-op, because the

    event-id dedupe two lines below has already consumed that id.



    THE WAVE-6 NO-BLIP LAW (item 3, 2026-07-27) β€” True ONLY when the next render

    genuinely differs from what the client already draws:

      field_upsert    β€” False when the accepted def ECHOES the client's copy

                        (_field_echo_equal); True on any validator mutation, any

                        permissions refusal, and ALWAYS when a measure window changed

                        (values must be re-resolved server-side).

      overlay_patch   β€” False when every requested update was accepted verbatim;

                        True the moment anything was refused or truncated.

      field_delete    β€” False: the client removed the column optimistically and the

                        store scrub is invisible to the next render.

      field_duplicate β€” True only when host-held VALUES must flow back (custom_ cell

                        copies, measure_ recomputes); formula/created_time clones

                        compute client-side.

      view_delete     β€” False (the deleted view simply stops being served).

      view_upsert     β€” unchanged: False, except an unresolved measure condition.

      cohort_* / add_to_list β€” True: the lists payload is server truth, and these are

                        not the hot path.



    The id dedupe is a SET (not a last-id slot): the event log resends the whole recent

    window on every emit, so every id must stay consumed, not just the latest one.

    """
    # The ctx fields the moved body reads as plain locals β€” bound once here so every branch
    # below is textually the app.py body (which is what makes this move reviewable).
    uname = ctx.uname
    allowed_pids = ctx.allowed_pids
    fields = ctx.fields
    measure_keys = ctx.measure_keys
    resolved_ids = ctx.resolved_ids
    cohort_ids = ctx.cohort_ids
    measure_offer = ctx.measure_offer
    visible_views = ctx.visible_views
    admin = ctx.admin
    scope_key = ctx.scope_key

    if not isinstance(event, dict):
        return False
    event_id = str(event.get('id') or '')[:180]
    seen = ctx.seen_ids
    if not event_id or event_id in seen:
        return False
    seen[event_id] = True
    if len(seen) > 400:                            # a session-lifetime set, kept bounded
        for stale in list(seen)[:200]:
            seen.pop(stale, None)
    kind = event.get('type')
    field_by_key = {f['key']: f for f in fields}
    valid_keys = set(field_by_key)
    overlay_keys = {f['key'] for f in fields if f.get('source') == 'overlay'}
    # Event validation is not a render payload. It must not consume a one-shot label
    # correction before the browser has had a chance to receive it.
    ws = table_workspace(
        ctx, allowed_pids=allowed_pids, consume_corrections=False)

    def _session_ready():
        ws.setdefault('views', {})
        ws.setdefault('fields', {})
        ws.setdefault('overlays', {})

    if kind == 'view_select':
        # Owner item 3 (2026-07-31): remember WHERE THE USER IS so a fresh browser resumes
        # there instead of the system default view. Presentation state only β€” the read side
        # re-validates the id against what the caller may see, so nothing here needs an
        # authorisation wall beyond the length cap. Never a re-render: the client is already
        # on that view.
        vid = str(event.get('viewId') or '').strip()[:120]
        if vid and _store_of(ctx).available():
            try:
                _tops(ctx).save_active_view(uname, vid)
            except Exception as _ae:
                _tel.error('grid:view-select', _ae)
        return False

    if kind == 'record_layout':
        # Wave 2026-08-02 (C-LAYOUT): the per-user record-detail field order. Presentation
        # state for one surface; grid column order is untouched. Unknown keys are pruned
        # against the LIVE field set, dupes collapse, and the wire re-validates at serve
        # time β€” so a client racing a field delete degrades to default order, never an error.
        raw_order = event.get('order')
        if not isinstance(raw_order, list):
            return False
        order, seen = [], set()
        for k in raw_order[:200]:
            k = str(k or '').strip()[:80]
            if k and k in valid_keys and k not in seen:
                seen.add(k)
                order.append(k)
        if _store_of(ctx).available():
            _tops(ctx).save_record_layout(uname, order)
        else:
            _session_ready()
            if order:
                ws['recordLayout'] = {'order': order}
            else:
                ws.pop('recordLayout', None)
        # Autosave hot path, like view_select: the client already renders the order it asked
        # for, and the wire echoes it back on the next workspace read.
        return False

    if kind == 'view_upsert':
        raw = event.get('view')
        if not isinstance(raw, dict) or not isinstance(raw.get('config'), dict):
            return False
        view_id = str(raw.get('id') or '').strip()[:120]
        name = str(raw.get('name') or '').strip()[:120]
        requested_name = name
        if not view_id or not name:
            return False
        cfg = raw['config']
        # The filter TREE (leaf conditions and/or nested condition groups) is
        # validated by the module-agnostic grid contract helper β€” imported lazily
        # here for the same reason the page does it: a missing embed build must
        # never break page import.
        import aios_grid as _ag
        # A MEASURE key is valid in a FILTER and nowhere else (CG-8): it may not be ordered,
        # shown, grouped or coloured by, because it is not a column of this table. Hence the
        # widened key set here and the plain `valid_keys` everywhere below.
        # `cohort_ids` is a PERMISSION list, not a convenience: a cohort rule naming anything
        # else is dropped here. Leaving it in would persist a condition the engine can only
        # answer with "nothing", so `Cohort is not [a list I deleted]` would show an empty table
        # forever with nothing on screen explaining why.
        filters = _ag.clean_filter_tree(cfg.get('filters'),
                                        valid_keys | set(measure_keys),
                                        cohort_ids=set(cohort_ids))
        sorts = []
        for rule in list(cfg.get('sorts') or [])[:20]:
            if (isinstance(rule, dict) and rule.get('colId') in valid_keys
                    and rule.get('dir') in ('asc', 'desc')):
                sorts.append({'colId': rule['colId'], 'dir': rule['dir']})
        order = list(dict.fromkeys(
            key for key in list(cfg.get('order') or []) if key in valid_keys))
        order += [field['key'] for field in fields if field['key'] not in order]
        visible = list(dict.fromkeys(
            key for key in list(cfg.get('visible') or []) if key in valid_keys))
        widths = {}
        raw_widths = cfg.get('widths') if isinstance(cfg.get('widths'), dict) else {}
        for key, value in raw_widths.items():
            if key in valid_keys:
                try:
                    widths[key] = max(60, min(600, round(float(value))))
                except (TypeError, ValueError):
                    pass
        # C-LOCK (wave 2026-08-02, item 12's mechanism): a view may be LOCKED to one cohort.
        # `cohort_ids` is the same permission list the cohort filter leaf uses β€” a lock naming
        # a cohort this session cannot see is DROPPED at write, exactly like a cohort rule.
        # (Readers of a SHARED locked view resolve the id against their OWN visible sets; an
        # unresolvable lock matches NOTHING client-side β€” the cohort-leaf law, never a widen.)
        cohort_lock = str(cfg.get('cohortLock') or '').strip()[:120]
        # β›” WAVE 17 R1 (C-LOCKV guard a) β€” A PROJECTED LOCKED VIEW RE-STAMPS ITS OWN LOCK.
        # A locked view IS its set: its id and its lock are the same fact. The client rebuilds
        # `config` from scratch on every autosave (a column resize is enough), so taking the
        # lock from the browser would mean one omitted key turns "the 40 accounts we agreed to
        # call" into the whole book, under the same name, with nothing going red. The read side
        # re-stamps too (aios_grid.views_from_defs) β€” this keeps the STORE from accumulating the
        # wrong record in the first place.
        _is_locked_view = view_id in set(cohort_ids)
        if _is_locked_view:
            cohort_lock = view_id
        config = {
            'filters': filters,
            # conjunction joining the ROOT-level conditions; anything but an
            # explicit 'or' means 'and' (legacy views had an implicit AND).
            'filterConj': 'or' if cfg.get('filterConj') == 'or' else 'and',
            **({'cohortLock': cohort_lock}
               if cohort_lock and cohort_lock in set(cohort_ids) else {}),
            # ⭐⭐ WAVE 32 Β· R5 / CONTRACT C4 β€” "Mark important" (raised by SESSION C as ASK C-12).
            #
            # β›” WITHOUT THIS LINE THE MARK IS A LIE THAT SURVIVES UNTIL RELOAD. This dict is
            # REBUILT FROM AN ALLOWLIST on every autosave β€” a column resize is enough β€” so a key
            # the client sends and this literal does not name is silently dropped. The badge would
            # paint, the write would answer 200, and the mark would be gone on the next read: the
            # exact shape T24's done-when says "persists across a reload" to catch, and
            # [[a-migration-that-runs-on-the-next-write]] on a write path.
            #
            # ⚠ UNCONDITIONAL, NOT SPREAD-CONDITIONAL LIKE `cohortLock` ABOVE, and the asymmetry is
            # the whole point: R5's mark must be REMOVABLE. A key written only when present means
            # unmarking sends `important: false`, the allowlist drops it, and the stored `true`
            # survives β€” a mark you can set and never clear.
            # ⚠ `is True`, not truthy: the client sends a real boolean, and a string `"false"` must
            # not become a mark.
            # ⚠ NO PERMISSION GATE, deliberately. R5 makes this a personal legibility mark, not a
            # lock; `_may_administer` already decides who may write this view at all, and adding a
            # second wall here would be the two-normalizers shape this wave is already fixing
            # elsewhere.
            'important': cfg.get('important') is True,
            'sorts': sorts,
            'groupBy': cfg.get('groupBy') if cfg.get('groupBy') in valid_keys else None,
            'colorBy': cfg.get('colorBy') if cfg.get('colorBy') in valid_keys else None,
            'rowHeightMode': (cfg.get('rowHeightMode')
                              if cfg.get('rowHeightMode') in ('short', 'medium', 'tall')
                              else 'short'),
            'order': order,
            'visible': visible,
            'widths': widths,
            'memberPids': [pid for pid in list(cfg.get('memberPids') or [])
                           if isinstance(pid, int) and pid in allowed_pids],
        }
        # Wave-6 item 10: a non-grid display mode rides the view config, validated
        # structurally (mode whitelist, refs must be this table's fields).
        display = _ag._clean_display(cfg.get('display'), valid_keys)
        if display:
            config['display'] = display
        # Wave-6 item 11 (pin): how many leading columns stay frozen while scrolling
        # sideways. Clamped 1..8 β€” 0 would unfreeze the identity column the whole
        # surface pivots on; absent = the legacy single frozen column.
        if cfg.get('frozenCount') is not None:
            try:
                config['frozenCount'] = max(1, min(8, int(cfg['frozenCount'])))
            except (TypeError, ValueError):
                pass
        # ── wave-9 C3 (lock) + C4 (permissions) ────────────────────────────────────────────
        # The PRIOR stored view decides both: whether this is a create or an update (C4's
        # default splits on exactly that) and who is allowed to change the lock (C3).
        prior = (ws.get('views') or {}).get(view_id) or {}
        # ⚠ THE SHARED WALL (wave-9 I17). `ws` is already visibility-filtered, so a view the
        # caller may not SEE reads as absent β€” which would look like a CREATE and let anybody
        # overwrite somebody else's shared view by guessing its id. Ask the store for the raw
        # record and refuse before deciding anything else. Fail-closed on both branches.
        _raw_shared = _tops(ctx).shared_view(view_id) if _store_of(ctx).available() else None
        if _raw_shared is not None and not prior:
            return False                       # exists, not visible to this caller: refuse
        if _raw_shared is not None and not _tstore._may_edit(_raw_shared, uname, admin):
            return False                       # visible but not editable by this caller
        is_new = not prior
        # C4: the creator is HOST-STAMPED and never read from the browser β€” the same rule as
        # the field-level createdBy. An existing view keeps whoever made it.
        creator = prior.get('createdBy') or uname
        # Only the creator or an admin may change the PERMISSIONS themselves. A collaborator who
        # could rewrite them could grant themselves sole ownership of someone else's view, or
        # quietly widen a users-scoped view to everyone β€” privilege escalation by edit.
        if not is_new and not _tstore._may_administer(prior, uname, admin):
            raw = dict(raw)
            raw['permissions'] = prior.get('permissions')
            raw['locked'] = prior.get('locked')
        # ── I12/C3 BELT (2026-07-31): a LOCKED view's DISPLAY MODE is frozen HOST-SIDE too.
        # The client's switcher already refuses (setDisplayMode), and its comment has claimed
        # "the host refuses too" since wave 9 β€” this makes that claim true for direct API
        # callers. ⚠ MIRRORS types.ts `isModeFrozen` EXACTLY: SYSTEM views and the undeletable
        # list:/cohort: projections are EXEMPT β€” their `locked` is legacy "undeletable", not
        # mode-frozen, and freezing them would trap the default view in whatever mode it last
        # saved (the owner's stuck-in-Map report is that trap, client-made). An upsert that
        # UNLOCKS in the same write (authorised above) may change display freely.
        _mode_frozen = (prior.get('locked') and raw.get('locked') is not False
                        and prior.get('kind') != 'system'
                        and view_id != 'all-customers')
        if _mode_frozen:
            if prior.get('config', {}).get('display'):
                config['display'] = dict(prior['config']['display'])
            else:
                config.pop('display', None)
        # ⚠ A SYSTEM VIEW IS PER-USER STATE AND MUST NEVER BE SHARED. Caught in the wave-9
        # integration pass, before deploy: `all-customers` is every user's DEFAULT view and it
        # carries their own column widths, visible fields and row height. C4's read-default
        # ('collaborative' when permissions are absent, so nobody's saved view is
        # retro-restricted) is right for a view somebody AUTHORED β€” but applied to the system
        # view it routed `all-customers` into the shared bucket, and the first user to resize a
        # column would have published their layout to everyone. Verified in the store:
        # shared_table_views() was returning ['all-customers'].
        # System views are pinned personal, so they stay in each user's own workspace exactly as
        # before this wave. `kind` is host-validated two lines below; read it here from `raw` and
        # treat the stored kind as authoritative for an existing view.
        _kind = (prior.get('kind') if not is_new
                 else (raw.get('kind') if raw.get('kind') in ('system', 'list', 'custom')
                       else 'custom'))
        if _kind == 'system' or view_id == 'all-customers':
            perms = {'edit': 'personal'}
        else:
            perms = _ag.clean_view_permissions(
                raw.get('permissions'),
                default='personal' if is_new else 'collaborative',
                known_users=_known_usernames())
        # C3: only the creator or an admin may SET or CLEAR the lock; anyone else keeps whatever
        # is already stored. Enforced here rather than hidden in the client. This composes with
        # C4 and does not override it: C4 governs who may edit the CONTENTS, C3 who may freeze
        # the display MODE.
        # ⚠ Inert in practice TODAY β€” a user can only ever write their own per-user workspace,
        # so `uname == creator` always holds. Correct plumbing for shared views, not a live gate.
        may_lock = admin or uname == creator
        locked = bool(raw.get('locked')) if may_lock else bool(prior.get('locked'))
        # C3: the LOCK's actual job β€” a locked view's display mode is frozen, so a locked Kanban
        # stays Kanban. This half IS live per-user and is the part the owner asked for. Filters,
        # sorts and columns stay editable by design.
        if _mode_frozen:
            was = (prior.get('config') or {}).get('display')
            now = config.get('display')
            if (was or {}).get('mode') != (now or {}).get('mode'):
                if was:
                    config['display'] = was
                else:
                    config.pop('display', None)
        # WAVE 17 R1 (C-LOCKV guard b) β€” ONE THING, ONE NAME. Renaming a locked view renames
        # the SET it is, in the store that owns it; the view record must not grow a second title
        # that the read side would then have to arbitrate. Routed rather than duplicated.
        if _is_locked_view and _store_of(ctx).available():
            _sets = _cohorts(ctx)                     # R9: the SET lives in this topic's bucket
            _cur = (_sets.all_for(uname).get(view_id) or {}).get('name')
            if name and name != _cur:
                try:
                    _sets.rename(uname, view_id, name)
                except ValueError as _e:
                    _tel.error('cohort:rename-via-view', _e)
        view = {'id': view_id, 'name': name,
                # ⚠ DERIVED FROM THE ID, never taken from the browser: `kind` is what paints the
                # lock mark, so a client claiming it for an ordinary view would be claiming a
                # guarantee the engine is not making about that view's rows.
                'kind': _ag.LOCKED_VIEW_KIND if _is_locked_view
                else raw.get('kind') if raw.get('kind') in ('system', 'list', 'custom')
                else 'custom',
                'locked': locked,
                'createdBy': creator,
                'permissions': perms,
                'note': str(raw.get('note') or '')[:2000],
                'config': config}
        # System/list projections are not all persisted in this table store, but custom views
        # may not impersonate their visible names. Persisted view names are allocated
        # tenant-globally by TableStore inside the write transaction.
        _reserved_view_names = [
            value.get('name') for value in visible_views
            if (isinstance(value, dict) and value.get('id') != view_id
                and value.get('kind') in ('system', 'list'))
        ]
        # Wave 21 (item 3, R6): the reserved SYSTEM name is PER-TOPIC β€” a ut_ database's system
        # view is "All records", and reserving the customer literal there uniquified a user's
        # honest "All customers" view name against a string that never appears on that topic.
        _scope = str(getattr(ctx, 'scope_key', '') or 'customer').strip().lower()
        _sys_name = ('All products' if _scope == 'product'
                     else 'All records' if _scope.startswith('ut_') else 'All customers')
        if view_id != 'all-customers':
            _reserved_view_names.append(_sys_name)
        # ⭐ WAVE 21 (item 9, C1) β€” THE FOREIGN-VIEW WALL. A granted view is projected into the
        # receiver's rail, so their autosave can now emit an upsert carrying an id they do not
        # own. Silently saving that into the RECEIVER's stratum would FORK the view (own views
        # win the merge, so the receiver sees their private copy forever and calls sharing
        # broken). Role `edit` (or admin) writes THROUGH to the owner's record β€” config only,
        # never identity or reach: `createdBy`/`permissions` are pinned from the stored record.
        # Role `view` (or no grant) refuses with a toast instead of forking.
        _save_as = uname
        if _store_of(ctx).available():
            _foreign = _tops(ctx).find_view(view_id)
            if _foreign and _foreign[0] != uname:
                _o_uname, _stored = _foreign
                if not admin:
                    import core.shares as _shares
                    if _shares.role_for('view', view_id, uname,
                                        st=getattr(_tops(ctx), 'st', None)) != 'edit':
                        ctx.out.toast = ('That view is shared with you read-only β€” ask its '
                                         'owner for edit access.')
                        return False
                view['createdBy'] = _stored.get('createdBy', _o_uname)
                if _stored.get('permissions') is not None:
                    view['permissions'] = _stored.get('permissions')
                else:
                    view.pop('permissions', None)
                _save_as = _o_uname
        if _store_of(ctx).available():
            view = (_tops(ctx).save_view(
                _save_as, view, reserved_names=_reserved_view_names, is_admin=admin) or view)
        else:
            _session_ready()
            _view_names = list(_reserved_view_names)
            _view_names.extend(
                value.get('name')
                for candidate_id, value in ws['views'].items()
                if candidate_id != view_id and isinstance(value, dict)
            )
            view['name'] = _unique_name(view.get('name'), _view_names)
            ws['views'][view_id] = view
        name_changed = view.get('name') != requested_name
        # PERSISTED, but deliberately NO rerun β€” see the return-value contract in
        # this function's docstring. This is the hot path: every filter, sort,
        # group, colour, column-resize and column-toggle autosaves through here.
        #
        # CG-8 is the ONE exception, and it is self-limiting. A measure condition is answered
        # server-side, so if this view carries one whose answer was NOT in the payload we just
        # shipped, the client is showing a pending marker with nothing coming. Ask for exactly
        # one more run β€” and only then, so the common case (a column filter) still costs zero.
        # If the top-of-run read of the component's own value already covered it, this is False
        # and nothing extra happens; if that read is ever stale, this is what stops the
        # condition hanging unresolved.
        #
        # ⚠ COMPLETE rules only (wave-6 item 3, found by the live roundtrip on this rev):
        # picking a measure FIELD mints a valueless rule whose autosave used to land here as
        # "unresolved" and trigger a rerun that could resolve NOTHING β€” `rule_complete` refuses
        # half-asked questions β€” so the only effect was a remount that closed the builder
        # popover mid-build (the wave-5 "gate flake" runs, now explained). A rule still being
        # typed stays client-side PENDING by design; the run that matters fires when its VALUE
        # arrives and the question becomes answerable.
        from harness import measure_filter as _mfc
        unresolved = {str(r.get('id') or '') for r in measure_leaves(filters)
                      if _mfc.rule_complete(r)} - set(resolved_ids)
        return name_changed or bool(unresolved - {''})

    if kind == 'view_delete':
        view_id = str(event.get('viewId') or '')[:120]
        # ⭐ WAVE-27 item 27 (R8) β€” `IG_OVERVIEW_ID` joins the system view here, and the comment
        # eight lines down already explains why in the LOCKED-view case: dropping the saved
        # config leaves the PROJECTION, which rebuilds the row on the very next render. Overview
        # is injected on every read of an Instagram database, so a delete would look like it
        # worked and the view would be back after a reload β€” which is precisely the
        # reads-as-success failure the client's own `UNDELETABLE_VIEW_IDS` note describes.
        if not view_id or view_id in ('all-customers', _ag_overview_id()):
            return False
        # WAVE 17 R1 (C-LOCKV guard c) β€” deleting a LOCKED view deletes the set it is. Without
        # this the store would drop the saved config and the projection would rebuild the view
        # on the very next render: the row would come back, minus the user's sort and columns,
        # which reads as a corruption rather than as a refusal. Own sets only (`all_for`), the
        # same wall `cohort_delete` rides.
        if _store_of(ctx).available() and view_id in _cohorts(ctx).all_for(uname):
            _cohorts(ctx).delete(uname, view_id)
            _tops(ctx).delete_view(uname, view_id)
            # True, unlike an ordinary view_delete: the lists payload IS server truth and the
            # client cannot know the membership channel changed.
            return True
        # Wave-9 I17: deleting a SHARED view is narrower than editing it β€” creator or admin
        # only. A collaborator can change what a shared view shows; they cannot destroy
        # everybody else's copy of it, because there is only one copy.
        if _store_of(ctx).available():
            _raw_shared = _tops(ctx).shared_view(view_id)
            if _raw_shared is not None and not _tstore._may_administer(
                    _raw_shared, uname, admin):
                return False
            # ⭐ WAVE 21 (item 9, C1) β€” the foreign wall, delete half. Contract: a grant of
            # `edit` may delete (there is only one copy and the owner handed over its content);
            # `view` or no grant refuses. The dead grant record is emptied afterwards so the
            # receiver's "Shared with me" heals instead of listing a ghost id forever.
            _foreign = _tops(ctx).find_view(view_id)
            if _foreign and _foreign[0] != uname:
                import core.shares as _shares
                _sst = getattr(_tops(ctx), 'st', None)
                if not admin and _shares.role_for('view', view_id, uname,
                                                  st=_sst) != 'edit':
                    ctx.out.toast = 'That view is shared with you read-only.'
                    return False
                _tops(ctx).delete_view(_foreign[0], view_id)
                try:
                    _shares.set_grants('view', view_id, [], st=_sst)
                except Exception:
                    pass
                return False
            _tops(ctx).delete_view(uname, view_id)
        else:
            _session_ready()
            ws['views'].pop(view_id, None)
        # Wave-6 item 3: the client removed the view optimistically; a deleted view simply
        # stops being served, so the next render differs in nothing the client still shows.
        return False

    if kind == 'field_upsert':
        raw = event.get('field')
        if not isinstance(raw, dict):
            return False
        key = str(raw.get('key') or '')[:80]
        import aios_grid as _ag2
        if key.startswith(_ag2.MEASURE_FIELD_PREFIX):
            # A FORMULA-MEASURE column (owner item 7). Validated against THIS caller's measure
            # offer β€” the same admission the condition builder uses β€” so a field can only name a
            # measure this user could also filter by. Fail closed on anything else.
            field = _ag2.clean_measure_field(raw, {m['key']: m for m in measure_offer})
            if field is None:
                return False
            fmt = _ag2._clean_format(raw.get('format'), field.get('type'))
            if fmt:
                field['format'] = fmt
        elif key in field_by_key and not key.startswith('custom_'):
            base = field_by_key[key]
            field = {**base, 'note': str(raw.get('note') or '')[:2000]}
            # ⭐ W29-T83 β€” THE COLUMN SUMMARY ON A SOURCE-BACKED COLUMN. This branch rebuilds the
            # accepted def from the CONTRACT and adds only what a user may override, and `agg` was
            # not in that list β€” so Average on Customer's `Overdue days` was accepted with a 200
            # and dropped here, before it ever reached the store. Validated against the shared
            # vocabulary rather than a literal (see the custom branch below for what a literal
            # costs). An ABSENT or unknown `agg` clears it: the client sends the whole field on
            # every edit, so a missing key is a user who unset the summary, and `{**base}` has
            # already re-applied whatever the contract itself declares.
            _agg = raw.get('agg')
            if _agg in _ag2.FIELD_AGGS:
                field['agg'] = _agg
            else:
                field.pop('agg', None)
            # Wave-5 item 10: a DISPLAY format on any base field (presets included) β€” how a
            # number/date reads, per user. Rendering-only; validated fail-closed per type.
            fmt = _ag2._clean_format(raw.get('format'), base.get('type'))
            if fmt:
                field['format'] = fmt
            else:
                field.pop('format', None)      # cleared -> back to the type's default render
            # PRESET measure fields (wave-2 item 8): the browser may change the PERIOD (and the
            # auto-renamed label) of revenue_ytd / revenue_ly / orders_24m β€” nothing else. The
            # whitelist IS the base contract: only a field that shipped with preset+measure can
            # take a window here, and the measure KEY is pinned to the base's (fail closed).
            if (base.get('preset') and isinstance(base.get('measure'), dict)
                    and isinstance(raw.get('measure'), dict)):
                window = _ag2._clean_window(raw['measure'].get('window'))
                if window is not None:
                    field['measure'] = {'key': base['measure']['key'], 'window': window}
                    if isinstance(raw.get('label'), str) and raw['label'].strip():
                        field['label'] = str(raw['label'])[:120]
        elif key.startswith('custom_') and raw.get('type') in _ag2.CUSTOM_FIELD_TYPES:
            ftype = raw['type']
            # ⭐⭐ 2026-08-09 β€” THE RELATIONAL PAIR HAS NO HOME IN A PER-USER OVERLAY, and this
            # door used to accept one silently. It builds the stored field from a fixed key list
            # and adds only the bags it knows (`options`, `max`, `formula`, `automation`, `code`)
            # β€” so a `link`/`rollup` arriving here was stored WITHOUT its bag, in one user's
            # stratum, where `compute_relation_cells` (which walks `user_tables` definitions)
            # never sees it. Measured on nurilab as `custom_video_views_90j26`: a Rollup column
            # that rendered, could not be filled by anything, and reported no error.
            # β›” REFUSED RATHER THAN REPAIRED. These two belong to the table's SHARED schema
            # (`POST /tables/{key}/fields`), which is where the client now sends them; storing a
            # second, private, non-computing copy here would be a second definition of the same
            # column. A refusal repaints, so a stale client says something rather than nothing.
            if ftype in ('link', 'rollup'):
                return False
            readonly = ftype in _ag2.READONLY_CUSTOM_TYPES
            # The editable family must SAY it is overlay (the client's own contract); the
            # read-only pair (formula / created_time) is stored under the cohort column's
            # mechanism instead, so no cell write can ever be accepted for it.
            if not readonly and raw.get('source') != 'overlay':
                return False
            field = {'key': key, 'label': str(raw.get('label') or 'Untitled')[:120],
                     'type': ftype, 'source': 'odoo' if readonly else 'overlay',
                     'default': True, 'custom': True,
                     'note': str(raw.get('note') or '')[:2000]}
            if readonly:
                field['derived'] = True
                # FILTERABLE since wave 6 (owner item 6): formula/created_time values live
                # client-side, and this table's counts are client-mode β€” the client engine
                # answers conditions on them soundly. Mirrors fields_from_workspace.
                field['filterable'] = True
            # β›” W29-T83 β€” THE VOCABULARY, NOT THE LITERAL `'sum'`. This is the write gatekeeper
            # for an overlay column's summary and it accepted exactly one name, while the READ
            # projection (`aios_grid.py:679`) passes all six β€” so a user who chose Median got a
            # 200, a menu that said Median, and no summary after the next reload. The same shape
            # as the `agg:"sum"` defect this repo already shipped, where one door kept it and the
            # other dropped it ([[read-path-cannot-witness-write-path]]).
            _agg = raw.get('agg')
            if _agg in _ag2.FIELD_AGGS and not readonly:
                field['agg'] = _agg
            # A `select`/`multiselect` carries its own vocabulary; a `user` does NOT β€” its
            # choices are the tenant's accounts, resolved fresh on every render, so persisting
            # a snapshot here would go stale the moment somebody joins or leaves.
            if ftype in ('select', 'multiselect'):
                field['options'] = _ag2._clean_options(raw.get('options'))
                if not field['options']:
                    return False        # a select with no choices can never hold a value
                field.update(_ag2._choice_appearance(raw, field['options']))
            if ftype == 'rating':
                field['max'] = _ag2._clean_rating_max(raw.get('max'))
            if ftype == 'formula':
                # WRITE-time is where refs must exist (fail closed); read-time leaves them
                # alone so a later-deleted ref blanks the CELLS, not the column.
                formula = _ag2._clean_formula(raw.get('formula'), valid_keys)
                if formula is None:
                    return False
                field['formula'] = formula
            if ftype == 'automation':
                # C5-AUTOFIELD (wave 18): persist the validated config bag. No `return False`
                # when absent β€” an unconfigured automation column is a legitimate intermediate
                # state (create the column, then configure it in the gear).
                # β›” C8 (wave 22): a CONFIGURED bag must name an existing flow β€” the write door
                # reads the tenant's definition ids and `_clean_automation` refuses a bag
                # without one (or naming a deleted one). Refused = the whole field write is
                # refused, loudly (repaint), because storing the field while dropping its bag
                # would be a column that silently never runs.
                if isinstance(raw.get('automation'), dict):
                    try:
                        _flow_ids = frozenset((ctx.table.st if ctx.table is not None
                                               else _tops(ctx).st).get('automations') or {})
                    except Exception:
                        _flow_ids = frozenset()
                    auto = _ag2._clean_automation(raw.get('automation'), valid_keys,
                                                  flow_ids=_flow_ids)
                    if auto is None:
                        return False
                    field['automation'] = auto
            # ⭐ WAVE-27 item 13 (R13): the code column's language. OPTIONAL, like the
            # `automation` bag above and unlike `formula` β€” an unconfigured code column is a
            # legitimate intermediate state (create it, pick the language in the gear), and it
            # stores and renders as plain text meanwhile. `_clean_code` never refuses over an
            # unknown language; it falls back to `plain`, because the value chooses a
            # HIGHLIGHTER, and dropping a user's column to punish a typo in one would be the
            # disproportionate half of fail-closed.
            if ftype == 'code' and isinstance(raw.get('code'), dict):
                code_bag = _ag2._clean_code(raw.get('code'))
                if code_bag:
                    field['code'] = code_bag
            fmt = _ag2._clean_format(raw.get('format'), ftype)
            if fmt:
                field['format'] = fmt
        else:
            return False
        # Wave-5 item 1 β€” createdBy + permissions on the CREATED strata (custom_/measure_).
        # `createdBy` is stamped HOST-SIDE at create and preserved on every later write: the
        # client never supplies it, so a browser cannot claim someone else's field. A
        # permissions CHANGE is accepted only from the creator or an admin; a legacy field
        # with no createdBy is admin-only (fail closed, both directions). The table workspace
        # is per-user today, so this wall becomes load-bearing when sharing arrives β€” but the
        # admin path and the legacy path are enforceable (and NC'd) right now.
        needs_values = False
        refused_perms = False
        if key.startswith('custom_') or key.startswith(_ag2.MEASURE_FIELD_PREFIX):
            prior = ws.get('fields', {}).get(key)
            prior = prior if isinstance(prior, dict) else None
            if prior is None:
                field['createdBy'] = uname
                # Wave-6 item 9: scope is chosen at CREATE, on the cohort page only β€”
                # 'cohort' marks the field cohort-specific; 'global' or absent stays
                # unscoped, so an old bundle that sends no scope keeps today's behavior.
                if scope_key == 'cohort' and raw.get('scope') == 'cohort':
                    field['scope'] = 'cohort'
            else:
                if isinstance(prior.get('createdBy'), str) and prior['createdBy'].strip():
                    field['createdBy'] = prior['createdBy']
                # Scope is PRESERVED like createdBy: the client copy never moves an
                # existing field between scopes.
                if prior.get('scope') == 'cohort':
                    field['scope'] = 'cohort'
            want = _ag2._clean_permissions(raw.get('permissions'))
            have = _ag2._clean_permissions((prior or {}).get('permissions'))
            owner = field.get('createdBy')
            if want is not None and (admin or (owner and owner == uname)):
                field['permissions'] = want
            else:
                if have is not None:
                    field['permissions'] = have
                # Asked for a permissions change and did not get it: the client's
                # optimistic copy is a lie that must be repainted (wave-6 item 3).
                refused_perms = want is not None and want != have
            # A NEW measure column, or an existing one whose window moved: the VALUES
            # are host-computed, so the next render genuinely differs.
            if key.startswith(_ag2.MEASURE_FIELD_PREFIX):
                needs_values = prior is None or prior.get('measure') != field.get('measure')
        # Canonical names are reserved outside the durable workspace. Persisted field names are
        # allocated by TableStore from the transaction's current state, not this stale snapshot.
        _workspace_field_keys = set((ws.get('fields') or {}))
        _reserved_field_names = [
            value.get('label') for candidate_key, value in field_by_key.items()
            if (candidate_key != key and candidate_key not in _workspace_field_keys
                and isinstance(value, dict))
        ]
        if _store_of(ctx).available():
            field = (_tops(ctx).save_field(
                uname, field, reserved_names=_reserved_field_names,
                correction_id=event_id) or field)
        else:
            _session_ready()
            _field_names = list(_reserved_field_names)
            _field_names.extend(
                value.get('label')
                for candidate_key, value in ws['fields'].items()
                if candidate_key != key and isinstance(value, dict)
            )
            requested_label = ' '.join(
                str(field.get('label') or 'Untitled').split())[:120].rstrip()
            field.pop('labelCorrectedFrom', None)
            field.pop('labelCorrectionId', None)
            field['label'] = _unique_name(requested_label, _field_names)
            if field['label'] != requested_label and event_id:
                field['labelCorrectedFrom'] = requested_label
                field['labelCorrectionId'] = str(event_id)[:180]
            # ⚠ W29-T83 β€” THE SAME PREDICATE the store uses, called rather than restated. This
            # fallback carried its own shorter copy (note only), so a format or measure override
            # survived one path and not the other, and `agg` survived neither.
            if _tstore.source_override_is_empty(field):
                ws['fields'].pop(key, None)
            else:
                ws['fields'][key] = field
        if needs_values or refused_perms:
            return True
        # THE NO-BLIP RETURN (wave-6 item 3): an accepted def that echoes what the client
        # already renders needs no rerun β€” see _field_echo_equal and the docstring's law.
        return not _field_echo_equal(field, raw)

    if kind in ('cohort_add', 'cohort_remove', 'cohort_rename', 'cohort_delete'):
        # Cohort membership/lifecycle edits from the grid's cohort panel (wave-2 item 2 β€”
        # replaces the retired Manage popover, so rename/delete keep a surface). Fail-closed:
        # only the caller's OWN cohorts (all_for), and member pids are intersected with the
        # caller's permitted pool β€” the same guard add_to_list rides.
        if not _store_of(ctx).available():
            return False
        sets = _cohorts(ctx)                          # R9: this DATABASE's lists, never another's
        cohort_id = str(event.get('cohortId') or '').strip()[:120]
        if not cohort_id or cohort_id not in sets.all_for(uname):
            return False
        try:
            if kind == 'cohort_rename':
                name = str(event.get('name') or '').strip()[:120]
                if not name:
                    return False
                sets.rename(uname, cohort_id, name)
            elif kind == 'cohort_delete':
                sets.delete(uname, cohort_id)
            else:
                raw_pids = event.get('pids')
                if not isinstance(raw_pids, list):
                    return False
                pids = [p for p in raw_pids if isinstance(p, int) and p in allowed_pids]
                if not pids:
                    return False
                if kind == 'cohort_add':
                    sets.add_members(uname, cohort_id, pids)
                else:
                    sets.remove_members(uname, cohort_id, pids)
        except ValueError as e:
            _tel.error('cohort:panel-edit', e)
            return False
        # True: the lists payload (names, membership, counts) must reflect this on the next
        # render β€” the panel is showing server truth, not local optimism.
        return True

    if kind in ('doc_add', 'doc_delete', 'doc_fetch'):
        # DOCUMENTS on a row (owner item 12, contract C5). Metadata in the store key
        # `customer_docs`; the BYTES live at their own path in the tenant dataset, never in a
        # JSON blob β€” see core.store.upload_bytes for why that distinction is load-bearing.
        #
        # ⭐ WAVE 19 / R9 β€” item 1's SILENT THIRD CASE, and the one nobody reported because the
        # React shell cannot reach it yet (`payload.docs` is set only by app.py). Both homes are
        # keyed by pid and both were topic-blind: the metadata bucket AND the byte path
        # `docs/<pid>/…`. Two topics' pid spaces overlap by construction (a CRC32 hash lands
        # anywhere an Odoo partner id can), so a product's file could occupy the path of a
        # customer's β€” one upload silently shadowing another tenant-visible document. Per-topic
        # bucket AND per-topic prefix; the customer topic keeps both of its shipped names.
        import base64 as _b64c
        import re as _re_mod
        if not _store_of(ctx).available():
            return False
        docs_key = _docs_key(ctx)
        docs_dir = 'docs' if docs_key == _DOCS_KEY else f'docs/{docs_key[:-5]}'
        pid = event.get('pid')
        doc_id = str(event.get('docId') or '').strip()[:80]
        # The pid wall is the SAME one every other row-scoped event rides: a caller may only
        # touch rows inside their own permitted pool. Without it, documents would be the
        # one surface where a scoped agent could read another book's rows.
        if not isinstance(pid, int) or pid not in allowed_pids or not doc_id:
            return False
        # ⭐ WAVE 25 (R6b / D-16) β€” THE DOCUMENTS FAMILY WAS THE LAST MODULE-GLOBAL READER HERE.
        # Every line below said `store.…`, so an R2 tenant's document metadata (and its bytes)
        # landed in tenant #0's repo. Wave 19 made this family per-TOPIC; per-TENANT is the axis
        # it was still missing, and the two failures look identical from the app.
        _st_docs = _store_of(ctx)
        meta_all = _st_docs.get(docs_key) or {}
        row = list((meta_all.get(str(pid)) or []))

        if kind == 'doc_add':
            raw_b64 = event.get('data_b64')
            name = str(event.get('name') or 'document')[:200]
            if not isinstance(raw_b64, str) or not raw_b64:
                return False
            try:
                blob = _b64c.b64decode(raw_b64, validate=True)
            except Exception:
                return False              # not base64 -> not a file we will store
            # Re-check the DECODED size host-side. The client checks it too, but a client check
            # is a courtesy: this is the number that bounds what the tenant's repo can be made
            # to hold by anyone who can reach the endpoint.
            if not blob or len(blob) > _DOC_MAX_BYTES:
                return False
            if len(row) >= _DOC_MAX_PER_ROW or any(d.get('id') == doc_id for d in row):
                return False
            safe = _re_mod.sub(r'[^A-Za-z0-9._-]', '_', name)[:80] or 'file'
            path = f'{docs_dir}/{pid}/{doc_id}_{safe}'
            try:
                _blobs(ctx).upload_bytes(
                    path, blob, message=f'doc {doc_id} for {scope_key} {pid}')
            except Exception as _ue:
                _tel.error('docs:upload', _ue)
                return False
            row.append({'id': doc_id, 'name': name,
                        'mime': str(event.get('mime') or 'application/octet-stream')[:120],
                        'size': len(blob), 'by': uname,
                        'ts': dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
                        'path': path})
        elif kind == 'doc_delete':
            hit = next((d for d in row if d.get('id') == doc_id), None)
            if not hit:
                return False
            # THE WALL: the uploader or an admin. Enforced here regardless of what the client
            # chose to render β€” `canDelete` in the payload only hides a button.
            if hit.get('by') != uname and not admin:
                return False
            _blobs(ctx).delete_path(hit.get('path') or '')
            row = [d for d in row if d.get('id') != doc_id]
        else:                                    # doc_fetch
            hit = next((d for d in row if d.get('id') == doc_id), None)
            if not hit:
                return False
            blob = _blobs(ctx).download_bytes(hit.get('path') or '')
            if blob is None:
                return False
            # The bridge is a fire-and-forget event LOG with no response channel, so the answer
            # is PARKED on the Result and rides the next render's payload (C5).
            ctx.out.doc = {
                'pid': pid, 'docId': doc_id, 'name': hit.get('name') or 'document',
                'mime': hit.get('mime') or 'application/octet-stream',
                'data_b64': _b64c.b64encode(blob).decode('ascii')}
            return True                          # re-render to deliver it

        def _write(cur):
            cur[str(pid)] = row
            return cur
        # ⚠ `docs_key`, NOT `_DOCS_KEY`. The read above and the byte path were both made
        # per-topic and this line was not β€” so a product document's BYTES landed correctly under
        # `docs/product/…` while its METADATA went into `customer_docs` beside the customer rows,
        # keyed by a CRC32 hash. Found by `verify_scopes.py`'s own leg on its first run, which is
        # the argument for asserting the ABSENCE from the legacy bucket and not just the presence
        # in the new one: every "it saved" check here was green.
        _st_docs.update(docs_key, _write, flush='async')
        return True

    if kind in ('folder_create', 'folder_rename', 'folder_delete', 'folder_duplicate',
                'item_move', 'folder_reorder'):
        # FOLDERS over the Views / Cohorts sidebars (owner item 11, contract C4). All five
        # events touch ONE home β€” the table workspace's `folders` + `itemFolders` β€” so a cohort
        # can be filed without the cohort STORE learning about folders at all.
        import aios_grid as _agf
        if not _store_of(ctx).available():
            return False
        surface = str(event.get('surface') or '')
        if surface not in _agf.FOLDER_SURFACES:
            return False
        # The ids this user may actually file: their own views, their own cohorts. Fail-closed β€”
        # an item_move naming somebody else's cohort is dropped by clean_item_folders anyway,
        # but refusing here means we never write it in the first place.
        def _own_ids(sfc, cur_ws):
            if sfc == 'views':
                return set((cur_ws.get('views') or {}))
            return set(_cohorts(ctx).all_for(uname))

        def _mutate(cur_ws):
            folders = _agf.clean_folders(cur_ws.get('folders'))
            placed = dict(cur_ws.get('itemFolders') or {})
            lst = list(folders.get(surface) or [])
            # ⚠ the wire field is `folderId` on EVERY folder event (CLIENT's types.ts is the
            # built contract; the split doc's first draft said `id`). Converged here rather
            # than asking the client to re-emit β€” see the C4 reconciliation note in the doc.
            fid = str(event.get('folderId') or '').strip()[:80]
            if kind == 'folder_create':
                name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME]
                if not fid or not name or len(lst) >= _agf.MAX_FOLDERS:
                    return None
                if any(f['id'] == fid for f in lst):
                    return None
                row = {'id': fid, 'name': name, 'order': len(lst)}
                icon = _agf.clean_folder_icon(event.get('icon'))    # wave-9 I15 (C5)
                if icon:
                    row['icon'] = icon
                lst.append(row)
            elif kind == 'folder_rename':
                name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME]
                hit = next((f for f in lst if f['id'] == fid), None)
                if not hit or not name:
                    return None
                hit['name'] = name
                # wave-9 I15 (C5): the rename pane is also where the icon is changed. An event
                # that carries NO icon key leaves the existing one alone (a rename must not
                # silently strip a chosen icon); an explicit null clears it back to default.
                if 'icon' in event:
                    icon = _agf.clean_folder_icon(event.get('icon'))
                    if icon:
                        hit['icon'] = icon
                    else:
                        hit.pop('icon', None)
            elif kind == 'folder_delete':
                if not any(f['id'] == fid for f in lst):
                    return None
                lst = [f for f in lst if f['id'] != fid]
                # CONTENTS MOVE TO ROOT, never delete. Dropping the placements is exactly that:
                # an item with no placement renders at the top level.
                placed[surface] = {k: v for k, v in (placed.get(surface) or {}).items()
                                   if v != fid}
            elif kind == 'folder_reorder':
                # ⭐ WAVE 20 (owner item 19, contract C-FOLDER-REORDER) β€” the user's own folder
                # ORDER in the rail. Views could already be dragged between folders; the folders
                # themselves could not be dragged past each other.
                #
                # THE WIRE CARRIES THE FULL ORDER, NEVER A DELTA, and that is the contract's
                # doing: a partial list cannot say where an UNNAMED folder went, so a "moved B
                # after C" message would leave every other folder's position to be re-derived by
                # two sides that can disagree. The client sends the list it is looking at.
                #
                # ⚠ IDS THIS USER DOES NOT OWN ARE IGNORED, NOT REJECTED. A stale tab can emit an
                # order containing a folder that has since been deleted; dropping the whole event
                # would make the rail un-reorderable until reload, while dropping the unknown id
                # reorders exactly what still exists. Anything the payload omits keeps its
                # relative position AFTER the named ones β€” an order that silently deleted a
                # folder it merely failed to mention would be a data loss dressed as a sort.
                order = [str(i).strip()[:80] for i in (event.get('order') or [])]
                if not order:
                    return None
                known = {f['id']: f for f in lst}
                seen, ranked = set(), []
                for fid_ in order:
                    if fid_ in known and fid_ not in seen:
                        seen.add(fid_)
                        ranked.append(known[fid_])
                ranked.extend(f for f in lst if f['id'] not in seen)
                if len(ranked) != len(lst):          # cannot happen; refuse rather than truncate
                    return None
                for i, f in enumerate(ranked):
                    f['order'] = i
                lst = ranked
            elif kind == 'folder_duplicate':
                new_id = str(event.get('newId') or '').strip()[:80]
                src = next((f for f in lst if f['id'] == fid), None)
                if not src or not new_id or len(lst) >= _agf.MAX_FOLDERS:
                    return None
                if any(f['id'] == new_id for f in lst):
                    return None
                lst.append({'id': new_id, 'name': f"{src['name']} copy"[:_agf.MAX_FOLDER_NAME],
                            'order': len(lst)})
                # ⚠ The folder's CONTENTS are duplicated by the client emitting the ordinary
                # per-item duplicate events it already owns (view_upsert with a fresh id, the
                # cohort copy path) and then item_move-ing them here. Copying views/cohorts
                # inside this handler would be a SECOND implementation of "duplicate an item",
                # and the two would drift the first time either one changed.
            else:                                   # item_move
                item_id = str(event.get('itemId') or '').strip()[:120]
                target = event.get('folderId')
                if not item_id or item_id not in _own_ids(surface, cur_ws):
                    return None
                cur = dict(placed.get(surface) or {})
                if target is None:
                    # ⚠ "UNFILE" β€” the client says nothing about where it went. Popping is right
                    # here and always was: no placement = render at the top level, and that is the
                    # state a brand-new item is already in.
                    cur.pop(item_id, None)          # back to the root
                elif str(target) == _agf.ROOT_PLACEMENT:
                    # ⭐⭐ WAVE 32 Β· OWNER ITEM 20 (`W32-T27`, ASK C-16) β€” "FILED AT ROOT", STORED.
                    #
                    # β›” THE THIRD CASE, AND ITS ABSENCE IS THE WHOLE DEFECT. Popping (above) and
                    # this branch produce the SAME rendering for an ordinary view, which is why
                    # nobody missed it for nine waves β€” but they are different FACTS, and for a
                    # SHARED view the difference is the entire feature: "never filed" sends it to
                    # the Shared group, "the receiver dragged it out" must not. Absence cannot hold
                    # two facts, so the second one gets a value.
                    #
                    # β›” AND IT IS DELIBERATELY EXEMPT FROM THE FOLDER-EXISTS TEST BELOW. This id
                    # names no folder by design; running it through `any(f['id'] == tid ...)` would
                    # refuse every root filing, which is the same silent no-op this ticket exists
                    # to remove. `aios_grid.clean_item_folders` carries the matching exemption on
                    # the READ side β€” the two are one change (see its note).
                    cur[item_id] = _agf.ROOT_PLACEMENT
                else:
                    tid = str(target)[:80]
                    if not any(f['id'] == tid for f in lst):
                        return None
                    cur[item_id] = tid
                placed[surface] = cur
            folders[surface] = lst
            clean = _agf.clean_folders(folders)
            return clean, _agf.clean_item_folders(
                placed, clean,
                {sfc: _own_ids(sfc, cur_ws) for sfc in _agf.FOLDER_SURFACES})

        # ⚠ NO allowed_pids ON PURPOSE β€” this is app.py's bare `_cl_table_workspace(uname)`
        # call at the old :6268, preserved exactly. Omitting the pool drops memberPids from
        # any foreign shared view (fail-closed), which is harmless here because the folder
        # legs read only view IDS and the folder maps. Passing the pool would be a behavior
        # change inside a pure move.
        _res = _mutate(table_workspace(ctx, consume_corrections=False))
        if _res is None:
            return False                      # refused: nothing is written
        _tops(ctx).save_folders(uname, _res[0], _res[1])
        # True: the sidebars render from server state (which folder holds what), not from local
        # optimism β€” the same contract the cohort panel's membership edits ride.
        return True

    if kind == 'view_reorder':
        # ⭐ WAVE-27 item 5 (contract C7) β€” the user drags views up and down the rail.
        #
        # THE WIRE CARRIES THE FULL ORDER, NEVER A DELTA β€” the `folder_reorder` contract above,
        # verbatim, and for its reason: a partial list cannot say where an unnamed view went.
        #
        # ⚠ NO OWNERSHIP FILTER, and that is the difference from `item_move`. Filing a view into
        # a folder writes a placement keyed by a view this user must own; ARRANGING the rail is
        # about the reader's own screen, and the rail legitimately contains views this user does
        # not own β€” the system view, cohort projections, R8's injected Overview, and anything
        # SHARED with them. Refusing ids they do not own would make exactly those un-draggable,
        # which is the half of sharing people notice.
        #
        # ⚠ Unresolvable ids are harmless by construction rather than by filtering here: the READ
        # side (`aios_grid.views_from_defs`) ranks by this list and appends anything unranked, so
        # an id naming a deleted view simply never matches. Validating against today's view set
        # at WRITE time would also drop an id whose view is merely not visible in this request.
        raw_order = event.get('order')
        if not isinstance(raw_order, list):
            return False        # a non-list order is malformed, not an empty arrangement
        order, seen_v = [], set()
        for vid in raw_order[:MAX_VIEW_ORDER]:
            vid = str(vid or '').strip()[:120]
            if vid and vid not in seen_v:
                seen_v.add(vid)
                order.append(vid)
        # ⚠ THE SESSION FALLBACK IS `record_layout`'s, COPIED DELIBERATELY, and it is not just a
        # testability convenience. Both keys are per-user PRESENTATION state for one surface, and
        # both should keep working while the store is briefly unavailable β€” a rail that refuses
        # to reorder because a write failed is a frozen control with no explanation. It also
        # makes the round trip reachable from `verify_grid_events`, which is headless and where
        # `store.available()` is False: the store-backed branch of every OTHER stratum writer is
        # covered only by the live pass, and this one did not have to be.
        if _store_of(ctx).available():
            _tops(ctx).save_view_order(uname, order)
        else:
            _session_ready()
            if order:
                ws['viewOrder'] = order
            else:
                ws.pop('viewOrder', None)     # absent = server order, never a stored []
        # True: the rail renders from server state, like the folder sidebars it sits beside.
        return True

    if kind == 'field_delete':
        # Delete a USER-CREATED column outright (owner gap closed 2026-07-27). The prefix IS
        # the permission: only the created strata (`custom_` overlay fields, `measure_` formula
        # columns) are deletable β€” a base field's key fails the prefix test no matter what the
        # browser claims, so the table's contract cannot be deleted from the client. The store
        # scrubs the field's stored cell values with it (delete_table_field); views naming the
        # key self-heal on their next autosave, the rule every stale colId already rides.
        key = str(event.get('key') or '')[:80]
        import aios_grid as _ag3
        if not (key.startswith('custom_') or key.startswith(_ag3.MEASURE_FIELD_PREFIX)):
            return False
        if _store_of(ctx).available():
            _tops(ctx).delete_field(uname, key)
        else:
            _session_ready()
            ws['fields'].pop(key, None)
            for _row in ws['overlays'].values():
                if isinstance(_row, dict):
                    _row.pop(key, None)
        # Wave-6 item 3: the client dropped the column optimistically; the store scrub is
        # invisible to the next render (the values leave WITH the column).
        return False

    if kind == 'choice_rename':
        # ⭐ WAVE 20 (owner item 15, contract C-RENAME) β€” RENAMING AN OPTION CARRIES ITS VALUES.
        #
        # Owner: *"if I change the option from 'Aaron' to 'Aron' the existing field that has the
        # Selected 'Aaron' doesn't change to 'Aron'."* Correct, and it is a data-model fact rather
        # than an oversight: a select cell stores the option's LABEL, so renaming the option in
        # the field definition orphans every cell holding the old string. They do not render as
        # the new name; they render as a value that is no longer an option.
        #
        # β›” THE WIRE CARRIES AN EXPLICIT {from, to} MAPPING, NEVER A DIFF OF THE CHOICE LISTS.
        # Diffing is what makes this unsafe: rename A→B while also DELETING C and adding D, and a
        # differ sees two removals and two additions with no way to know which pairs up. It would
        # cheerfully rewrite every C cell to D. The client knows which row of its editor the user
        # typed in; that intent travels, and nothing here has to guess it.
        #
        # THREE PLACES HOLD THE OLD STRING, and missing any one is its own visible bug:
        #   1. the CELLS (`overlays`) β€” the owner's complaint;
        #   2. the FIELD's own `choices` list β€” else the picker still offers the old name;
        #   3. every VIEW that FILTERS or COLOURS BY that value β€” a saved view filtering
        #      `Agent is "Aaron"` silently matches NOTHING the moment the value moves, which is
        #      the failure that looks like data loss ([[aios-shared-views]]).
        key = str(event.get('key') or '')[:80]
        pairs = []
        for r in (event.get('renames') or ()):
            if not isinstance(r, dict):
                continue
            a, b = str(r.get('from') or '')[:200], str(r.get('to') or '')[:200]
            if a and b and a != b:
                pairs.append((a, b))
        field = field_by_key.get(key)
        if not key or not pairs or not isinstance(field, dict):
            return False
        if str(field.get('type') or '') not in ('select', 'multiselect', 'status'):
            return False
        # Only a field this caller may edit. A rename is a write to the field CONTRACT, so it
        # rides the same wall `field_upsert` does rather than a looser one of its own.
        if key in (ctx.hidden_keys or frozenset()):
            return False
        mapping = dict(pairs)

        def _rename_in(ws_):
            fields_ = ws_.setdefault('fields', {})
            fld = fields_.get(key)
            if isinstance(fld, dict) and isinstance(fld.get('choices'), list):
                seen, out = set(), []
                for c in fld['choices']:
                    if isinstance(c, dict):
                        nm = mapping.get(str(c.get('name') or ''), None)
                        if nm is not None:
                            c = {**c, 'name': nm}
                        ident = str(c.get('name') or '')
                    else:
                        c = mapping.get(str(c), str(c))
                        ident = str(c)
                    # A rename ONTO an existing option MERGES rather than duplicating it: the
                    # user typed a name that already exists, and two identical options is a
                    # picker bug. Their cells merge too, which is what the value rewrite below
                    # does anyway.
                    if ident in seen:
                        continue
                    seen.add(ident)
                    out.append(c)
                fld['choices'] = out
                fields_[key] = fld
            for row in (ws_.get('overlays') or {}).values():
                if not isinstance(row, dict) or key not in row:
                    continue
                val = row[key]
                if isinstance(val, list):        # multiselect
                    merged, out2 = set(), []
                    for v in val:
                        nv = mapping.get(str(v), str(v))
                        if nv not in merged:
                            merged.add(nv)
                            out2.append(nv)
                    row[key] = out2
                elif isinstance(val, str) and val in mapping:
                    row[key] = mapping[val]
            # Views: filter rule values + colour-by keys naming the old option.
            for view in (ws_.get('views') or {}).values():
                if not isinstance(view, dict):
                    continue
                cfg = view.get('config') if isinstance(view.get('config'), dict) else view

                def _walk(nodes):
                    for n in nodes or ():
                        if not isinstance(n, dict):
                            continue
                        if isinstance(n.get('children'), list):
                            _walk(n['children'])
                            continue
                        if str(n.get('colId') or '') != key:
                            continue
                        v = n.get('value')
                        if isinstance(v, str) and v in mapping:
                            n['value'] = mapping[v]
                        elif isinstance(v, list):
                            n['value'] = [mapping.get(str(x), x) for x in v]

                _walk(cfg.get('filters') or [])
                # ⚠ `colorBy` NEEDS NO REWRITE, and the reason is worth stating because the
                # opposite looks obvious: it stores a COLUMN KEY (a string, validated against
                # `valid_keys` at `view_upsert`), not a {value: colour} map. Per-value colours
                # ride the CHOICE OBJECTS themselves, which this function renames in place β€”
                # `{**c, 'name': nm}` keeps every other key on the choice, colour included β€” so
                # a renamed option keeps its swatch for free. An earlier version of this handler
                # tried to rewrite `colorBy['rules']` and crashed the whole event pipeline with
                # `TypeError: unhashable type: 'dict'` on the FIRST view it touched.
            return ws_

        if _store_of(ctx).available():
            _tops(ctx).rename_choice_values(uname, _rename_in)
        else:
            _session_ready()
            _rename_in(ws)
        # True = the host must re-read: the client dropped its optimistic copy of the CHOICES,
        # but the rewritten cells and view filters only exist server-side until the next payload.
        return True

    if kind == 'field_duplicate':
        # Wave-5 item 1. The DESTINATION key is client-generated (the established pattern β€”
        # the client must know it synchronously to place the clone right of the source), and
        # both keys must sit in the SAME created stratum: base odoo fields offer no Duplicate,
        # because duplicating the source-of-truth into an editable copy would need a
        # value-snapshot mechanism nobody asked for. Overlay VALUES are copied only for
        # `custom_` sources (a measure clone recomputes; a formula clone re-evaluates).
        # The duplicator becomes the clone's creator β€” it is a NEW field they made.
        src_key = str(event.get('sourceKey') or '')[:80]
        new_key = str(event.get('key') or '')[:80]
        import aios_grid as _ag5
        prefix = ('custom_' if src_key.startswith('custom_')
                  else _ag5.MEASURE_FIELD_PREFIX
                  if src_key.startswith(_ag5.MEASURE_FIELD_PREFIX) else None)
        if (prefix is None or not new_key.startswith(prefix) or new_key == src_key
                or new_key in field_by_key):
            return False
        src = ws.get('fields', {}).get(src_key)
        if not isinstance(src, dict):
            return False
        clone = dict(src)
        clone['key'] = new_key
        label = str(event.get('label') or '').strip()[:120]
        requested_label = label or (str(src.get('label') or 'Untitled')[:110] + ' copy')
        _workspace_field_keys = set((ws.get('fields') or {}))
        _reserved_field_names = [
            value.get('label') for candidate_key, value in field_by_key.items()
            if (candidate_key != new_key and candidate_key not in _workspace_field_keys
                and isinstance(value, dict))
        ]
        clone['label'] = requested_label
        clone['createdBy'] = uname
        # Wave-6 item 9: the clone inherits the source's scope via dict(src); on the cohort
        # page the duplicate surface may explicitly place it instead (same create-time rule
        # as field_upsert β€” a page that is not the cohort page cannot mint a cohort field).
        if scope_key == 'cohort' and event.get('scope') in ('cohort', 'global'):
            if event['scope'] == 'cohort':
                clone['scope'] = 'cohort'
            else:
                clone.pop('scope', None)
        if _store_of(ctx).available():
            clone = (_tops(ctx).duplicate_field(
                uname, src_key, new_key, clone,
                reserved_names=_reserved_field_names,
                correction_id=event_id) or clone)
        else:
            _session_ready()
            _field_names = list(_reserved_field_names)
            _field_names.extend(
                value.get('label')
                for candidate_key, value in ws['fields'].items()
                if candidate_key != new_key and isinstance(value, dict)
            )
            clone.pop('labelCorrectedFrom', None)
            clone.pop('labelCorrectionId', None)
            clone['label'] = _unique_name(requested_label, _field_names)
            if clone['label'] != requested_label and event_id:
                clone['labelCorrectedFrom'] = requested_label
                clone['labelCorrectionId'] = str(event_id)[:180]
            ws['fields'][new_key] = clone
            if src_key.startswith('custom_'):
                for _row in ws['overlays'].values():
                    if isinstance(_row, dict) and src_key in _row:
                        _row[new_key] = _row[src_key]
        # Wave-6 item 3: True only when host-held VALUES must flow back β€” copied overlay
        # cells (custom_ sources) or host-computed measure values. formula/created_time
        # clones compute client-side, so the optimistic insert is already complete.
        label_changed = clone['label'] != requested_label
        return label_changed or src.get('type') not in ('formula', 'created_time')

    if kind == 'add_to_list':
        # "Add to list" (owner item 6): a view's current matches become fixed cohort members.
        #
        # ⚠ `pids` arrives from the BROWSER. It is intersected with `allowed_pids` β€” the pool this
        # user may actually see β€” before anything is stored. The client computing the set is a
        # correctness decision (it is the only engine that already knows the answer, and asking a
        # second engine the same question is the drift verify_filter_engine.py exists to prevent);
        # it is NOT a trust decision.
        raw_pids = event.get('pids')
        if not isinstance(raw_pids, list):
            return False
        pids = [p for p in raw_pids if isinstance(p, int) and p in allowed_pids]
        if not pids:
            return False
        cohort_id = str(event.get('cohortId') or '').strip()[:120]
        name = str(event.get('name') or '').strip()[:120]
        if not _store_of(ctx).available():
            return False
        # ⭐ WAVE 19 / R9 β€” THE BUG THIS RULING NAMES. This branch is where a list is BORN, and it
        # named `cohort_mod` directly: a list created from the Product surface was written into
        # `customer_cohorts` with CRC32 product pids in it, then rendered on the Customer page as
        # a list of customers nobody recognised. The set now belongs to the database it was
        # created on, and `ctx.scope_key` is the only thing that decides which.
        sets = _cohorts(ctx)
        try:
            if not cohort_id:
                if not name:
                    return False
                cohort_id = sets.create(uname, name, pids)
            else:
                if cohort_id not in sets.all_for(uname):
                    return False        # not this user's cohort β€” fail closed, do not create
                sets.add_members(uname, cohort_id, pids)
        except ValueError as e:
            _tel.error('cohort:add_to_list', e)
            return False
        # True: the Cohort page's list and counts must reflect this, and the toast below only
        # renders on a fresh run.
        #
        # ⚠ The NOUN is the TOPIC's, not the literal "customer" this line carried since wave 2.
        # "Added 12 customers" over a set of SKUs is the surface stating, in words, that the list
        # went somewhere it did not β€” the user-visible half of the same leak.
        _noun = sets.noun
        ctx.out.toast = (
            f"Added {len(pids):,} {_noun}{'' if len(pids) == 1 else 's'} to "
            f"{name or 'the list'}.")
        return True

    if kind == 'overlay_patch':
        pid = event.get('pid')
        if not isinstance(pid, int) or pid not in allowed_pids:
            return False
        updates = {}
        refused = False
        stage_moved = False
        row_events = []            # C3 (wave 22): admitted writes, emitted AFTER they apply
        raw_updates = event.get('updates') if isinstance(event.get('updates'), dict) else {}
        for key, value in raw_updates.items():
            if key not in overlay_keys or not isinstance(value, (str, int, float)):
                refused = True
                continue
            # C-PERM: a field this caller's permissions HIDE cannot be written, only refused.
            # `overlay_keys` asks whether the column exists, not whether this person may touch
            # it β€” so without this line a restricted user who knows a hidden key could PATCH a
            # value they are not allowed to read back. Refused LOUDLY (repaint) so the client
            # reverts rather than showing a value the store never took.
            if key in (ctx.hidden_keys or frozenset()):
                refused = True
                continue
            # Wave-5 item 1: `permissions.edit` is enforced HERE, per key β€” the client hiding
            # its editor is courtesy, this is the wall. Fail-closed: a restricted field with
            # no readable createdBy admits only admins.
            perms = (field_by_key.get(key) or {}).get('permissions') or {}
            edit = perms.get('edit')
            if edit == 'admins' and not admin:
                refused = True
                continue
            if edit == 'creator' and not (
                    admin or (field_by_key.get(key) or {}).get('createdBy') == uname):
                refused = True
                continue
            # C5-AUTOFIELD (wave 18): an automation cell is MACHINE-WRITTEN. The client marks
            # the type read-only (READONLY_CELL_TYPES) but that is courtesy β€” this is the wall.
            # Without it the UI hides the editor while the API still takes a PATCH, which is
            # exactly the shape a "read-only" type must not have.
            if (field_by_key.get(key) or {}).get('type') == 'automation':
                refused = True
                continue
            # C2 (wave 22): a STAGE FIELD is engine-written EXCEPT at a review gate, where a
            # human may advance a card to a member of the review stage's `next` and nothing
            # else. The law rides the field definition (`automation.humanMoves` / `arrive`,
            # stamped by the engine that derives the stages) because this module must not
            # import the engine β€” the engine states the law, this door enforces it
            # mechanically. An ADMITTED move writes THROUGH to the definition row, the shared
            # truth the engine and every viewer read: parked in this user's overlay stratum it
            # would be a board position only its author could see. Everything else about the
            # field is refused β€” the same wall the automation-cell type gets, extended, not
            # replaced.
            if str(ctx.scope_key or '').startswith('ut_'):
                import core.user_tables as _ut_mod
                _tdef = _ut_mod.get(ctx.scope_key, st=ctx.table.st) if ctx.table is not None \
                    else None
                _fdef = next((f for f in ((_tdef or {}).get('fields') or [])
                              if f.get('key') == key), None)
                # C7 (wave 22): a METRIC cell is engine-computed from the master series β€” a
                # human write would be an invented measurement, refused the way an automation
                # cell is (the wall, not the courtesy).
                # ⭐ 2026-08-07 β€” EXTENDED TO THE RELATIONAL PAIR, and read through the field
                # layer's own predicate rather than re-tested here. A `rollup` cell is an
                # aggregate the server computes; a DERIVED `link` cell is a join the server
                # resolves. Both would be overwritten by the next refresh anyway, so accepting
                # the write would not merely be wrong β€” it would look like it worked and then
                # silently revert, which is worse than a refusal.
                # ⚠ An ORDINARY (user-picked) link is deliberately NOT refused: `is_computed_cell`
                # answers per FIELD, not per type, because one kind is read-only in one mode and
                # editable in the other.
                if _ut_mod.is_computed_cell(_fdef):
                    refused = True
                    continue
                _auto = (_fdef or {}).get('automation')
                # An ordinary Link is a shared database relationship. Validate its selected
                # target ids and write it through to the definition row; a personal overlay
                # would be invisible to reciprocal links and Rollups.
                if (_fdef or {}).get('type') == 'link':
                    _linked = _ut_mod.patch_link_cell(
                        ctx.scope_key, pid, key, value, st=ctx.table.st)
                    if _linked is None:
                        refused = True
                        continue
                    if _linked != str(value):
                        refused = True
                    row_events.append((key, _linked))
                    continue
                # ⭐ WAVE 25 (C3 + amendment C3-A1, owner rulings R7/R6) β€” THE PROFILE CELL.
                # Two things happen here and neither can live anywhere else:
                #
                #   1. IT WRITES THROUGH TO THE DEFINITION ROW, the stage field's mechanism two
                #      branches up, for the identical reason. MEASURED before it was written: an
                #      ordinary `ut_*` cell edit lands in the TYPIST'S OWN overlay stratum, and
                #      the automation engine reads `t['rows']` β€” so a handle somebody typed
                #      would be invisible to the enrich action and to every other viewer of the
                #      same database. The flag would be decorative.
                #   2. BLANKING IT CLEARS THAT ROW'S PRESET CELLS IN THE SAME WRITE (R6) β€”
                #      `patch_profile_cell` does both inside ONE store update, because a blank
                #      that commits without its clear leaves a row with no handle and a live
                #      follower count, i.e. stale numbers attributed to nobody.
                #
                # β›” NOT VIA THE EMIT GUARD (HARD RULE 5 / D-40). Nothing here emits; this is
                # `user_tables`' own write door, reached from the branch that already knows the
                # scope is `ut_`. ⚠ `editRole` is deliberately NOT consulted: it governs the
                # SCHEMA, and C3 is explicit that a profile field's VALUES stay fully editable β€”
                # that is the whole point of item 5a.
                if isinstance((_fdef or {}).get('profile'), dict):
                    _pw = _ut_mod.patch_profile_cell(
                        ctx.scope_key, pid, key, value, st=ctx.table.st)
                    if _pw is None:
                        # Not a handle we can enrich. Refused LOUDLY so the client repaints to
                        # the stored truth instead of showing a value the store never took.
                        refused = True
                        continue
                    # A normalisation (`@Name` -> `name`, a pasted URL -> its handle) is a real
                    # difference from what the client is drawing, so it must repaint. An
                    # untouched value echoes and stays on the no-blip path.
                    if _pw['handle'] != str(value):
                        refused = True
                    if _pw['cleared']:
                        stage_moved = True    # cells the client never typed changed: re-read
                    row_events.append((key, _pw['handle']))
                    continue
                # ⭐ OWNER ITEM 4 (2026-08-06) β€” A COLUMN AN AUTOMATION FILLS IS NOT A COLUMN A
                # PERSON TYPES IN. Owner, verbatim: *"I should not be able to edit any of the
                # Pre-set fields on instagram automation, we talked about this extensively."*
                #
                # β›” IT HAD NEVER BEEN ENFORCED ANYWHERE. `editRole: 'admins'` rides on every
                # preset column and reads exactly like this wall, which is why it was believed to
                # be one β€” but it governs the SCHEMA (`user_tables.py:535`): who may rename or
                # retype the column. The VALUES stayed open to anyone with grid access, and the
                # owner typed into one to prove it. The metric branch above is the same rule for
                # the same reason, written for a different column kind; this is its sibling.
                #
                # ⚠ REACHED ONLY AFTER stage AND profile have `continue`d, and the order is the
                # rule rather than an accident: both of those columns carry the identical tag and
                # both are cells a human is SUPPOSED to drive β€” the stage because moving a card
                # IS the decision, the profile because it is where enrichment gets its subject.
                # Refusing on the tag alone would freeze every kanban.
                #
                # `flowId` and not merely `automation`: a user-configured `automation` COLUMN
                # carries its own gear bag (kind/source/urlField/settings) and no flowId, and it
                # is not this ruling's subject.
                if isinstance(_auto, dict) and (_auto.get('flowId') or _auto.get('preset')):
                    refused = True
                    continue
            # ⭐ WAVE 23 (C7) β€” the json wall, BEFORE the generic 10 000-char truncation, which
            # would otherwise cut a document in half and store the halves as valid text. A json
            # column promises its readers a parseable document; a write that is not one is
            # REFUSED with the cell unchanged rather than silently kept as a string nothing can
            # open. Blank stays legal β€” an empty cell is "no document", not a malformed one.
            if (field_by_key.get(key) or {}).get('type') == 'json':
                raw_j = value if isinstance(value, str) else _json_ev.dumps(value)
                if not str(raw_j).strip():
                    updates[key] = ''
                    continue
                if len(str(raw_j)) > _ut_limits.MAX_JSON_CELL:
                    refused = True
                    continue
                try:
                    _json_ev.loads(raw_j)
                except (ValueError, TypeError):
                    refused = True
                    continue
                updates[key] = str(raw_j)
                continue
            clean = str(value)[:10000]
            if isinstance(value, str) and clean != value:
                # Truncated: the stored truth is shorter than the client's copy. (An
                # int/float arriving as its string form renders identically β€” not a refusal.)
                refused = True
            definition = field_by_key.get(key) or {}
            if definition.get('type') in ('select', 'multiselect') and clean:
                options = [str(v) for v in definition.get('options') or []]
                canonical = {v.strip().lower(): v for v in options}
                if definition.get('type') == 'select':
                    normalized = canonical.get(clean.strip().lower())
                    if normalized is None:
                        refused = True
                        continue
                else:
                    normalized_parts = []
                    seen = set()
                    invalid = False
                    for part in clean.split(','):
                        match = canonical.get(part.strip().lower())
                        if match is None:
                            invalid = True
                            break
                        if match.lower() not in seen:
                            seen.add(match.lower())
                            normalized_parts.append(match)
                    if invalid:
                        refused = True
                        continue
                    normalized = ','.join(normalized_parts)
                if normalized != clean:
                    refused = True
                    clean = normalized
            updates[key] = clean
        if updates:
            if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None:
                # User-table cells are collaborative record data. Views/layout remain personal,
                # but record values live in the definition rows so Links, Rollups, automations,
                # and every viewer observe one truth.
                import core.user_tables as _ut_values
                _ut_values.patch_cells(ctx.scope_key, pid, updates, st=ctx.table.st)
            elif _store_of(ctx).available():
                _tops(ctx).patch_overlay(uname, pid, updates)
            else:
                _session_ready()
                ws['overlays'].setdefault(str(pid), {}).update(updates)
        # C3 (wave 22): the admitted writes become ROW EVENTS β€” emitted AFTER they applied, on
        # the ut scopes the event triggers watch this wave, through the seam in user_tables so
        # this module never imports the engine. Human door only, which IS the loop law.
        if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None:
            import core.user_tables as _ut_ev
            for _k, _v in list(updates.items()) + row_events:
                _ut_ev.emit_row_event({'type': 'event_field', 'table': str(ctx.scope_key),
                                       'rowId': str(pid), 'field': _k, 'after': _v,
                                       'st': ctx.table.st, 'user': uname})
        # Wave-6 item 3: the client's cell already shows the typed value β€” rerun ONLY when
        # some of what it shows was refused or clamped and must be repainted to the truth.
        # A stage move reruns too: its `arrive` side-effects (the tracked flag) changed cells
        # the client never typed.
        return refused or stage_moved

    return False