File size: 80,889 Bytes
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9486685
20bf676
 
9486685
 
 
 
 
 
20bf676
9486685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20bf676
 
9486685
20bf676
 
 
 
9486685
20bf676
9486685
 
 
 
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20bf676
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
051f280
 
e1b3e71
051f280
 
 
 
e1b3e71
 
 
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
051f280
 
 
e1b3e71
051f280
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
e1b3e71
 
20bf676
051f280
20bf676
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
20bf676
 
 
051f280
 
 
 
 
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
 
 
 
20bf676
 
 
051f280
 
 
20bf676
051f280
20bf676
 
 
051f280
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
20bf676
e1b3e71
 
20bf676
 
 
e1b3e71
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665e5ea
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9486685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20bf676
9486685
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
20bf676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""routes_tables.py β€” USER TABLES over the wire (wave 18, contract C3-UT).



The runtime-database primitive: `core/user_tables.py` (wave-9 C6, host-only until now) served

through the SAME grid machinery every other topic rides β€” `table_store` for the per-user

workspace strata, `aios_grid.workspace_wire` for the wire shape, `core.grid_events` for every

durable write except rows. Rows are the one genuinely new channel: the events seam has no row

event types (the user_tables docstring's `row_add` gate was described, never built), so row

add/delete/patch are REST endpoints here, walled by `user_tables.is_user_table` +

`user_tables.may_open` β€” a connector-backed table can never accept an invented row.



TENANCY: every store touch goes through `session.runtime` (the tenant's store handle), so a

Nurilab admin's tables live under Nurilab's prefix/repo, and the isolation gate's proof #3

covers them for free.



VISIBILITY is `user_tables.may_open` β€” creator or admin, fail-closed. There is no module grant

to check because a user table is not a module; the per-table wall is the whole wall, and it is

applied in `_defn_or_refuse` before any payload is built.

"""
import threading
import json
import time

from fastapi import Body, Depends
from fastapi import APIRouter

from deps import Session, err, require_session

router = APIRouter(prefix="/api/v1")


def _ut():
    import core.user_tables as user_tables
    return user_tables


def _ops(session, table_key):
    import core.table_store as table_store
    return table_store.make(f"{table_key}_table_workspace", st=session.runtime)


#: WAVE 27 item 2 (contract C2 / amendment A2) β€” the relation refresh is COALESCED and runs OFF
#: the request path. `{tenant: True}` while a pass is queued; the worker holds the lock.
_REL_LOCK = threading.Lock()
_REL_DIRTY = {}
_REL_RUNNING = {}


def _refresh_relations(session):
    """Mark this tenant's Links/Rollups stale and refresh them AFTER the response, once.



    β›” WHY THIS IS NOT A DIRECT CALL ANY MORE, and the numbers are the argument. The owner's

    item 2 was "adding a new record visually takes too long, I need to be able to spam it", and

    this function was the largest single cost inside `POST /tables/{key}/rows`:

    `engine.refresh_relations` DEEP-COPIES every table and every row in the tenant before it can

    decide whether anything needs doing (`automation_engine.py:9383-9388`), and when something

    does it commits with `flush="sync"` β€” a store round trip, i.e. an HF Dataset commit on the

    default backend. Every added record paid a whole-tenant snapshot plus a synchronous commit

    before the 201 came back.



    β›” AND BACKGROUNDING ALONE WOULD HAVE BEEN A WORSE BUG. Twenty rapid adds would queue twenty

    whole-tenant snapshots, each one re-reading a store the previous one just wrote β€” the spam

    the item asks us to support is exactly the load that would melt it. So this COALESCES: at

    most one pass runs, and at most one is queued behind it.



    ⚠ THE DIRTY FLAG IS THE LOAD-BEARING PART, not the lock. A write landing WHILE a pass is in

    flight must still get a pass afterwards, because the in-flight one snapshotted before that

    write existed. Without the flag the LAST add in a burst is precisely the one whose rollups

    never update β€” the failure nobody would notice until a total was quietly wrong.



    Eventual consistency is the accepted trade and was already the documented posture: the tick

    repairs materialised cells regardless, and answering 503 here would invite the browser to

    repeat a mutation that already succeeded.

    """
    tenant = str(getattr(session, "tenant", "") or "")
    rt = session.runtime
    with _REL_LOCK:
        _REL_DIRTY[tenant] = True
        if _REL_RUNNING.get(tenant):
            return                     # a worker is live; it will see the flag and loop
        _REL_RUNNING[tenant] = True

    def _worker():
        import automation_engine as engine
        try:
            while True:
                with _REL_LOCK:
                    if not _REL_DIRTY.get(tenant):
                        _REL_RUNNING.pop(tenant, None)
                        return
                    _REL_DIRTY.pop(tenant, None)
                try:
                    engine.refresh_relations(rt, log=lambda *_args: None)
                except Exception as exc:                          # noqa: BLE001
                    # The source write already landed and the response is already sent. Log and
                    # let the tick repair it; never retry in a tight loop.
                    print(f"[tables] relation refresh deferred: {type(exc).__name__}: {exc}")
        finally:
            # Belt for an unexpected raise on the bookkeeping itself: a tenant left marked
            # RUNNING would never refresh again for the life of the process.
            with _REL_LOCK:
                if _REL_RUNNING.get(tenant) and not _REL_DIRTY.get(tenant):
                    _REL_RUNNING.pop(tenant, None)

    threading.Thread(target=_worker, daemon=True,
                     name=f"rel-refresh:{tenant or 'default'}").start()


def _defn_or_refuse(session, table_key, st=None):
    """The per-table wall: 404 for a key that does not exist, 403 for one this session may not

    open. 404-before-403 leaks nothing useful β€” ut keys are guessable slugs, and 'exists but

    not yours' is exactly what may_open is for.



    ⭐ `st` LETS A CALLER LEND THE SNAPSHOT IT IS ALREADY HOLDING (W31 QA), the same `lend()` the

    nav and `/tables` use since W31-T10/T12. The WALL is untouched β€” the same `may_open`, asked

    about the same table β€” it is simply not re-reading a 28.5 MB document to ask it.

    """
    ut = _ut()
    # ⭐⭐ W31 QA β€” THE SWEEP, AND IT IS ONE LINE BECAUSE IT IS DONE HERE RATHER THAN PER ROUTE.
    # Owner: *"this is just one case, I need you to check and apply the fix everywhere too."*
    # This guard has FOURTEEN call sites and cost TWO whole-document deep copies at every one
    # (`get`, then `may_open`) β€” on tenant #0 that is 2 x 28.5 MB (D-185) to answer two questions
    # about ONE table. Lending here fixes every caller at once, including the eight routes the
    # sweep enumerated (`PATCH /shared/{pid}` Β· `DELETE /shared/fields/{k}` Β· `GET|POST /rows` Β·
    # `POST /rows/import` Β· `POST|PATCH /fields` Β· `PATCH /rows/{pid}`).
    # β›” WHY IT IS SAFE HERE AND WOULD NOT BE IN THE ROUTES: a lend is a PRE-WRITE snapshot. The
    # guard runs before any mutation and returns only the DEFINITION, so the snapshot never
    # survives to serve a read-back. Blanket-replacing `st=session.runtime` inside the routes
    # would hand that stale snapshot to `patch_row`'s and `add_row`'s post-write read-back, which
    # is [[refetch-eats-its-own-write]] with the sign flipped β€” a write that reports the value it
    # replaced. The remaining in-route reads are deliberately untouched and booked instead.
    st = st if st is not None else ut.lend(session.runtime)
    defn = ut.get(table_key, st=st)
    if not defn:
        raise err(404, "unknown_table", "that database does not exist")
    if not ut.may_open(table_key, session.uname, session.admin, st=st):
        raise err(403, "forbidden", "that database belongs to another user")
    return defn


def _records_or_refuse(session, table_key, st=None):
    """The human record-write wall for a database the automation engine owns."""
    # One lend for BOTH questions this wall asks β€” the definition wall and the record-mode wall β€”
    # so the two are answered from one read instead of two. Same reasoning as `_defn_or_refuse`.
    st = st if st is not None else _ut().lend(session.runtime)
    defn = _defn_or_refuse(session, table_key, st=st)
    if not _ut().records_mutable(table_key, st=st):
        raise err(403, "records_read_only",
                  "records in this automation-owned database are read-only β€” add Instagram "
                  "handles in a Profile database and let enrichment populate this database")
    return defn


#: β›” THE PER-USER CANDIDATE WALL IS RETIRED (WAVE 27, DEBT D-72). **THE TENANT IS THE UNIT, NOT
#: THE USER** β€” one profile is ONE row, and everyone who may open the database sees all of it.
#:
#: WHY IT HAD TO GO, and it is not a preference: wave 26's R4 made the candidate identity
#: `(platform, handle)` and `_merge_candidates` stamps only the FIRST finder, later finders never
#: overwriting. Combined with a wall that then showed a non-admin only `created_by == me`, the
#: two were SILENT DATA LOSS β€” the second finder's row was merged away into the first finder's,
#: and the wall then hid the survivor from the person who just found it. They searched, they
#: paid, and the screen said nothing arrived.
#:
#: ⚠ AND THE PAIR WAS MUTUALLY MASKING, which is why it stayed green for a wave: the wall named
#: `ut_ig_candidates`, and a READ-ONLY census of all four tenant stores
#: (`ops/w26_candidate_census.py`) proved that table exists in NO tenant β€” since wave 25's R2 the
#: write target is whatever database the user points the Create-record action at. So the wall
#: governed a table nobody writes, and fixing EITHER half alone would have armed the other
#: ([[defects-that-mask-each-other]]).
#:
#: The register offered two exits and R4 already implied this one. Restoring per-user visibility
#: instead would have required R4's merge to stop crossing users β€” a bigger change, against the
#: ruling, to bring back a wall that never governed anything real.
#:
#: β›” DO NOT RE-ADD THIS BY INFERRING THE RULE FROM A `created_by` COLUMN. Every
#: automation-written table has one (a scraped row says `automation`), so inference would hide
#: every scraped row from every non-admin β€” the same disappearance defect, one table wide instead
#: of one table narrow. `created_by` survives as W26/R4's informational "Found by" stamp ONLY.


def _too_big():
    """`routes_odoo_tables.TooBigToMaterialise`, imported lazily β€” one name, two policies below."""
    import routes_odoo_tables
    return routes_odoo_tables.TooBigToMaterialise


def _read_through_rows(table_key, field_keys, rt=None):
    """The mirror's rows for one read-through grid, projected to this table's declared columns.



    ⭐⭐ W31-T45 / D-169 β€” `rt` IS THE SESSION'S TENANT RUNTIME AND IT IS PASSED THROUGH (D's ask,

    `mailbox/D.md` D-2). `_defn_or_refuse` above answers *"may this SESSION open this DATABASE"*;

    the guard `whole_pool` fires on `rt` answers a DIFFERENT question β€” *"is the DuckDB file this

    process has open THIS TENANT's"* β€” and R2 gives GTM Lab connected tables in its own document,

    which is exactly the shape that satisfies the first wall while failing the second.

    `datastore.ro_con()` reads a process-global `DB_PATH` and one Space process serves every

    tenant, so the two walls are not substitutes for each other.



    β›” ONE FETCH, TWO POLICIES β€” and the split is the whole of W31-T20. Both `scoped_pool` (which

    owes a caller every row) and `scoped_pids` (which owes only the row SET) reach the mirror

    through this function, so the pid set the cheap path answers with is IDENTICAL to the pool's

    by construction rather than by a second query that agrees today. A pid-only `SELECT` would be

    cheaper and would also be a SECOND statement of what a row of this table is

    ([[one-question-two-normalizers]]) β€” the two callers differ in what they do with the REFUSAL,

    never in how they ask.



    Raises `TooBigToMaterialise` when the population exceeds one window; the caller decides

    whether that is a 409 or an unresolved pid scope.

    """
    import routes_odoo_tables

    rows_src = [{k: v for k, v in r.items() if k in field_keys or k == "pid"}
                for r in routes_odoo_tables.whole_pool(table_key, rt=rt)]
    rows_src.sort(key=lambda r: r["pid"])
    return rows_src


#: ⭐⭐ W31-T20 / D-174 β€” R6's SECOND SENTENCE FOR A PID SCOPE THAT CANNOT BE RESOLVED.
#:
#: Wave 30 un-capped the ROW door and left the WORKSPACE envelope, so `GET /workspace?scope=
#: ut_odoo_gl_lines` answered `409 window_required` six times out of six on the live deploy and the
#: grid painted an ERROR PAGE with a Retry button β€” a whole shipped feature nobody could open.
#: The cause: an envelope needs no row, but it asked for every one of 963,783 of them to derive a
#: pid set it uses for exactly two things (cohort membership and a shared view's `memberPids`).
#:
#: β›” SO THE ENVELOPE STOPS ASKING, AND SAYS SO. An empty pid set is not "no rows" β€” it is
#: "membership is unresolved on this grid", which is a different claim and has to be made out
#: loud, on the wire, or it is the silent truncation R6 is actually about. Both consequences are
#: fail-closed: a stored cohort's members are reported MISSING by `aios_grid.workspace_wire`
#: rather than silently dropped, and any WRITE that names a pid is refused by `routes_grid`.
_PID_SCOPE_LIMIT = {
    "subject": "pids", "effect": "unresolved",
    "recommendation": "cohorts and shared-view membership are resolved per page on this grid; "
                      "filter and read it a window at a time (`/odoo-tables/{key}/rows`), where "
                      "every total is a SQL count over the whole table",
}


def scoped_pool(session: Session, table_key: str):
    """`(pids, rows_src, fields_base, defn)` β€” THE USER-TABLE WALL, on its own.



    `routes_products.scoped_pool`'s sibling, extracted for the same reason (wave 19, item 12): a

    caller that only needs "which rows of this database may this session touch" β€” record comments

    β€” must ask through `_defn_or_refuse` (404/403, the whole wall) rather than growing a second

    idea of what a user table's pool is.

    """
    defn = _defn_or_refuse(session, table_key)
    fields_base = [dict(f) for f in (defn.get("fields") or [])]
    field_keys = {f["key"] for f in fields_base}
    # ⭐⭐ W30-T31 / D-87 β€” A READ-THROUGH DATABASE HAS NO ROWS HERE, SO THEY COME FROM THE MIRROR.
    #
    # This is the one function that turns "what is stored" into "what this session may see", which
    # is exactly why the read-through arm belongs HERE and nowhere else: the rows route, the events
    # route, the comments wall and the assembly all reach rows through it, so they all convert
    # together or not at all. β›” Reading `defn["rows"]` for such a table would find `{}` and serve
    # an EMPTY GRID β€” correct-looking, wrong, and silent.
    # ⚠ The wall above has already run. This adds no scope of its own and takes none away.
    if not _ut().materialises(table_key, st=session.runtime, defn=defn):
        try:
            rows_src = _read_through_rows(table_key, field_keys, rt=session.runtime)
        except _too_big() as e:
            # R6's second sentence: a limit that cannot be met is a SENTENCE, never a short grid.
            # ⚠ THE ROWS PATH STILL REFUSES, AND THAT IS CORRECT (W31-T20 changed the ENVELOPE, not
            # this): a caller that asked for every row of a 963,783-row grid cannot be served a
            # short one. `scoped_pids` below takes the same refusal and answers a different
            # question with it, because an envelope needs no row.
            raise err(409, "window_required", str(e))
        except RuntimeError as e:
            raise err(503, "store_not_ready", str(e))
        return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn
    rows_src = []
    for rid, row in (defn.get("rows") or {}).items():
        if not str(rid).isdigit():
            continue
        # WAVE 27 / D-72: no per-row owner filter. The table-level wall above
        # (`_defn_or_refuse` -> `may_open`) is the WHOLE wall β€” see the retirement note on
        # PER_USER_TABLES' former home. A row this session can reach is a row the tenant owns.
        r = {k: v for k, v in (row or {}).items() if k in field_keys}
        r["pid"] = int(rid)
        rows_src.append(r)
    rows_src.sort(key=lambda r: r["pid"])
    return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn


@router.patch("/tables/{table_key}/shared/{pid}")
def patch_shared_cell(table_key: str, pid: int, body: dict = Body(default=None),

                      session: Session = Depends(require_session)):
    """Write a cell into the TENANT-WIDE overlay β€” the product door `core/shared_overlay.py` has

    been waiting for since it shipped (W29-T62, wave 30 T28).



    ⭐ WHY A SEPARATE STRATUM AT ALL, restated because it is the whole feature and it is not

    "sharing would be nice": a user-created column and its values live PER USER, so a shared view

    filtering on one names a column other accounts do not have β€” and an unknown column is an

    INACTIVE condition in the tri-state engine, which IGNORES it and therefore WIDENS. The buy

    list would silently show the whole catalogue to everyone but its author. A column whose value

    is the same for every reader is the precondition for editing it at all.



    β›” THE WALL IS `_defn_or_refuse`, AND THE STRATUM IS NOT ONE. `shared_overlay` refuses no

    reader and no writer by design; "may this session open this surface" is answered HERE, where

    the session is. Do not push the question down there.

    ⚠ A tenant-wide write is not a private one: every account that may open this database sees it.

    That is the point, and it is why this door declares the column too β€” a value with no

    definition is a cell nobody can find.

    """
    body = body if isinstance(body, dict) else {}
    key = str(body.get("field") or "").strip()
    if not key:
        raise err(400, "bad_request", "a field key is required")
    _defn_or_refuse(session, table_key)
    from core import shared_overlay
    if not shared_overlay.is_shared(table_key, key, st=session.runtime):
        shared_overlay.put_field(table_key, key, {
            "key": key, "label": str(body.get("label") or key), "source": "overlay",
            "type": str(body.get("type") or "text"), "shared": True,
            "createdBy": session.uname}, st=session.runtime)
    try:
        # ⚠ `put_cell`, not `put_cells` β€” this door writes exactly ONE cell, and the singular is
        # the API that says so. It delegates to the plural, so both stay reachable through the one
        # caller; before this, the singular had no caller at all and `verify_reachability` LENS 2
        # named it (the same lens that found `drop_field` had no door either).
        stored = {key: shared_overlay.put_cell(table_key, pid, key, body.get("value"),
                                               st=session.runtime)}
    except ValueError as e:
        # A non-scalar RAISES in the stratum rather than being dropped; relay it as the answer.
        raise err(400, "bad_value", str(e))
    return {"ok": True, "pid": pid, "cells": stored,
            "fields": list(shared_overlay.fields(table_key, st=session.runtime))}


@router.delete("/tables/{table_key}/shared/fields/{field_key}")
def delete_shared_field(table_key: str, field_key: str,

                        session: Session = Depends(require_session)):
    """Remove a TENANT-WIDE column and every value in it.



    β›” WHY THIS EXISTS AT ALL, said plainly: W30-T28 shipped the door that CREATES a shared column

    and none that removes one, so a column anybody added was permanent for the whole tenant. The

    reachability gate found it from the other end β€” `shared_overlay.drop_field` was complete,

    correct, gated, and callable by nothing but its own gate ([[reachable-is-not-the-same-as-built]]).



    β›” AND THIS ONE IS CREATOR-OR-ADMIN, WHICH THE WRITE DOOR IS NOT. Writing a cell changes a

    value; dropping the column deletes that value for EVERY account at once, so it is the

    destructive-op wall this repo already uses for a database delete β€” not `editRole`, which

    governs renaming and is not a value wall ([[schema-role-is-not-a-value-wall]]).

    ⚠ `createdBy` is stamped by the write door above; a column stored before that stamp existed

    is admin-only, which is the safe direction.

    """
    _defn_or_refuse(session, table_key)
    from core import shared_overlay
    defn = (shared_overlay.fields(table_key, st=session.runtime) or {}).get(str(field_key))
    if not defn:
        raise err(404, "unknown_field", "that column is not a shared column on this database")
    owner = str(defn.get("createdBy") or "")
    if not session.admin and owner != session.uname:
        raise err(403, "forbidden",
                  f"a tenant-wide column can be removed by its creator or an admin β€” this one "
                  f"was added by {owner or 'somebody else'}, and dropping it would delete the "
                  f"value for every account")
    dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
    return {"ok": True, "dropped": bool(dropped),
            "fields": list(shared_overlay.fields(table_key, st=session.runtime))}


def scoped_pids(session: Session, table_key: str, limits=None):
    """`(pids, fields_base, defn)` β€” the SAME wall and the SAME row set as `scoped_pool`, without

    building a row.



    ⭐⭐ WAVE 30 / W30-T30 β€” THIS IS WHY ONE HIDE-FIELDS CHECKBOX WAS EXPENSIVE. A view write

    (`view_upsert`) reaches `grid_events_route`, which built a FULL assembly purely to validate

    it: `scoped_pool` allocates a fresh dict per row and then sorts them β€” ~33k order rows, on

    every toggle β€” and the six keys the events route actually reads from that assembly

    (`fields`, `pids`, `measures`, `measure_sets`, `lists`, `views`) contain no row at all.

    `rows_src` was computed and discarded.



    β›” THE PID SET IS IDENTICAL, NOT MERELY EQUIVALENT, and that is the whole safety argument:

    `scoped_pool` derives its pids as `frozenset(r["pid"] for r in rows_src)` over exactly the

    row ids that pass `str(rid).isdigit()`, which is this comprehension with a dict build in the

    middle. The row WALL is unchanged β€” a narrower or wider set here would be a permission

    change, and this is a performance change.



    ⚠ It does NOT make the write cheap on its own: `_defn_or_refuse` still costs two whole-document

    deep copies (`ut.get` then `may_open`), which is D-87 and W30-T31. This removes the row pass.



    ⭐⭐ W31-T20 / D-174 β€” `limits` IS AN OUT-PARAMETER, AND IT IS THE POINT OF THE TICKET. Pass a

    list and this function APPENDS R6's sentence to it when the pid set could not be resolved (a

    read-through grid whose population exceeds one window). The pid set is then EMPTY, and every

    consumer of an empty pid set is fail-closed β€” but "fail-closed and unannounced" is exactly the

    silent limit R6's second sentence forbids, so a caller that renders an envelope or admits a

    write is expected to carry the sentence through. Omitting the list means the caller accepts an

    unannounced empty scope, which is only ever right for a caller that does not use the pids.

    """
    defn = _defn_or_refuse(session, table_key)
    fields_base = [dict(f) for f in (defn.get("fields") or [])]
    # ⚠ W30-T31: on a read-through database the stored `rows` is `{}` by construction, so the
    # comprehension below would answer an EMPTY pid set β€” and the promise this function makes is
    # that its set is IDENTICAL to `scoped_pool`'s, not merely cheaper. It reaches the mirror
    # through the SAME fetch that function uses rather than growing a second idea of the row set;
    # the saving W30-T30 bought stays on every materialised table, which is all of the big ones.
    if not _ut().materialises(table_key, st=session.runtime, defn=defn):
        try:
            rows = _read_through_rows(table_key, {f["key"] for f in fields_base},
                                      rt=session.runtime)
            return frozenset(r["pid"] for r in rows), fields_base, defn
        except _too_big() as e:
            # β›” THE REFUSAL BECOMES AN ANSWER HERE, WHICH IT MUST NOT ON THE ROWS PATH. Turning
            # this into a 409 is what made both line grids unopenable: the envelope was refused
            # over rows it never renders. The scope is empty and SAID to be empty.
            if limits is not None:
                limits.append({**_PID_SCOPE_LIMIT, "cause": str(e)})
            return frozenset(), fields_base, defn
        except RuntimeError as e:
            raise err(503, "store_not_ready", str(e))
    pids = frozenset(int(rid) for rid in (defn.get("rows") or {}) if str(rid).isdigit())
    return pids, fields_base, defn


def ut_write_ctx(session: Session, table_key: str):
    """The g-dict a WRITE needs β€” same keys as `ut_assembly`, no rows.



    Returns the six keys `routes_grid.grid_events_route` reads, so the events route consumes this

    or a full assembly interchangeably. `rows_src` is `[]` on purpose rather than absent: a caller

    that starts needing rows should fail on an empty list it can see, not on a KeyError.

    """
    import aios_grid
    from core import grid_events

    limits = []
    pids, fields_base, defn = scoped_pids(session, table_key, limits=limits)
    ctx = grid_events.EventCtx(
        uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=frozenset(),
        admin=session.admin, fallback_ws=None, seen_ids={}, st=session.runtime,
        scope_key=table_key, table=_ops(session, table_key))
    ws = grid_events.table_workspace(ctx, allowed_pids=pids, consume_corrections=False)
    workspace, fields, views, lists = aios_grid.workspace_wire(
        ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
        fields_base=fields_base)
    return {"rows_src": [], "pids": pids, "ws": ws, "workspace": workspace,
            "fields": fields, "views": views, "lists": lists,
            "derived": aios_grid.cohort_cells(lists),
            "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
            # ⭐ W31-T20 β€” the write door reads this to refuse a PID-BEARING event loudly rather
            # than letting `allowed_pids` swallow it as a no-op. See `routes_grid`'s ut_ branch.
            "limits": limits, "defn": defn}


def ut_assembly(session: Session, table_key: str, storage_key: str = "",

                consume_corrections: bool = True, with_rows: bool = True):
    """The user-table mirror of `grid_assembly` / `product_assembly` β€” SAME g-dict keys, so

    `/workspace` and the events route consume any of the three interchangeably.



    Honest absence: `measures`/`measure_sets` are EMPTY β€” `core.measure_resolve` is

    customer-grain, so there is nothing to offer over user rows.



    ⭐ WAVE 19 / R9 β€” `lists` IS NO LONGER EMPTY. "For ANY database new/old": a user table gets

    cohorts like every other database, out of its OWN bucket (`ut_<slug>_cohorts`), holding its

    own row ids. The wave-18 refusal was correct while there was one customer-keyed bucket and

    wrong the moment the store learned about topics.



    ⭐⭐ W31-T20 / D-174 β€” `with_rows=False` BUILDS THE ENVELOPE AND NOT THE TABLE, and the

    caller that wants it is `/workspace`, which renders no row at all (the grid fetches rows from

    `/tables/{key}/rows` or `/odoo-tables/{key}/rows` beside it). Two things follow:

      * the read-through line grains become OPENABLE β€” `scoped_pool` refused their envelope over

        963,783 rows nobody was going to look at, which is D-174 in one sentence;

      * every materialised `ut_*` database stops allocating a dict per row and sorting them on a

        route whose payload has no rows in it β€” `ut_odoo_orders` was rebuilding 32,826 of them per

        database switch (owner item 7).

    β›” NOTHING IS VALIDATED LESS. `scoped_pids` runs the SAME `_defn_or_refuse` wall and answers

    the identical pid set; the flag removes work, never a check β€” the shape W30-T30 already proved

    on the write door.

    """
    import aios_grid
    from core import grid_events

    limits = []
    if with_rows:
        pids, rows_src, fields_base, defn = scoped_pool(session, table_key)
    else:
        pids, fields_base, defn = scoped_pids(session, table_key, limits=limits)
        rows_src = []

    ops = _ops(session, table_key)
    ctx = grid_events.EventCtx(
        uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=frozenset(),
        admin=session.admin, fallback_ws=None, seen_ids={},
        # R6b (D-16): the tenant handle rides every ctx this layer builds, not just the ones
        # that happen to carry a scoped `table`.
        st=session.runtime,
        scope_key=table_key, table=ops)
    ws = grid_events.table_workspace(ctx, allowed_pids=pids,
                                     consume_corrections=consume_corrections)
    workspace, fields, views, lists = aios_grid.workspace_wire(
        ws, session.uname, set(pids), defs={}, scope_key=table_key,
        storage_key=storage_key, fields_base=fields_base)

    return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
            "fields": fields, "views": views, "lists": lists,
            # R9: the Cohorts column's cells from this table's own lists.
            "derived": aios_grid.cohort_cells(lists),
            "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
            # ⚠ ALWAYS PRESENT, EMPTY WHEN THERE IS NOTHING TO SAY β€” a key a consumer has to test
            # for is a key a consumer forgets to test for, and this one carries a refusal.
            "limits": limits, "defn": defn}


def ut_label(defn, key, meta=None):
    """THE name of a user table, resolved ONCE (wave 20, item 6a).



    `nav_meta`'s rename wins, then the definition's own label, then the key. Every surface that

    shows a database name reads through here, because the alternative is what wave 20 found: the

    rail showed the renamed name (it reads `nav_meta`) while the automation editor's picker

    showed the original (it reads the definition), and neither looked broken.



    ⚠ `set_label` now writes the DEFINITION too, so the two agree at the source. This resolver

    stays because it makes every row already stored β€” renamed before that fix landed β€” read

    correctly today, without a migration.

    """
    return ((meta or {}).get(key, {}).get("name")
            or (defn or {}).get("label") or key)


def nav_meta(session):
    """The tenant's nav_meta bucket, read defensively. A store blip must not take a list down."""
    try:
        got = session.runtime.get("nav_meta")
        return got if isinstance(got, dict) else {}
    except Exception:                                             # noqa: BLE001
        return {}


@router.get("/tables")
def list_tables(session: Session = Depends(require_session)):
    """This session's user tables β€” the list the '+ New database' surface renders.



    ⭐⭐ W31-T12 (contract C1, D-175's third instance) β€” ONE DOCUMENT READ, NOT `1 + 2N`. This

    route was never ticketed and has the same shape `/nav` and `/automations` were fixed for:

    `all_tables` once, then `may_open` per key (another whole-document deep copy each, 28.6 MB on

    tenant #0 measured, 703 ms warm) and `records_mutable` per key on top of that β€” so a tenant

    with ten databases paid twenty-one copies to list them. The wall is UNCHANGED and still asked

    about every table; it is handed the document this function already holds. See

    `user_tables.lend`'s own note for why inlining the predicate is the one fix that is not

    available.

    """
    ut = _ut()
    meta = nav_meta(session)
    out = []
    tables = ut.all_tables(st=session.runtime)
    lent = ut.lend(session.runtime, **{ut.STORE_KEY: tables})
    for key, t in sorted(tables.items(),
                         key=lambda kv: (ut_label(kv[1], kv[0], meta) or "").lower()):
        if not ut.may_open(key, session.uname, session.admin, st=lent):
            continue
        out.append({"key": key, "label": ut_label(t, key, meta),
                    "source": t.get("source") or "Blank",
                    "recordsMutable": ut.records_mutable(key, st=lent),
                    "createdBy": t.get("createdBy") or "",
                    "created": t.get("created") or "",
                    "fields": [dict(f) for f in (t.get("fields") or [])],
                    "rowCount": len(t.get("rows") or {})})
    return {"tables": out}


#: ⭐⭐ THE ROLLUP SOURCE OFFER β€” what makes the read-through rollup a FEATURE rather than a
#: capability. Owner 2026-08-09: *"Full field editor: pick topic β†’ metric β†’ window."*
#:
#: β›” THE ENGINE SHIPPED WITH NO WRITER. `_clean_rollup` has accepted a `source` bag since
#: 2026-08-09 and `rollup_sql` answers it in one grouped query, but the ONLY thing in the product
#: that ever produced one was a hard-coded field in `odoo_relational.py`. Nothing in the client
#: could make one, so the whole read-through path was reachable by editing Python β€” the
#: [[artifact-with-no-importer]] shape, twice burned in this repo already.
#:
#: ⚠ DERIVED FROM THE MODEL FILES, NEVER A HAND-WRITTEN LIST. `model/topics/*.yml` and
#: `model/metrics/*.yml` already state which measures belong to which topic and which dims that
#: topic can group by; a second list here would be a second definition of the same fact, and the
#: two would drift the day somebody adds a metric. Same argument `rollup_sql` makes for binding
#: to a metric KEY instead of carrying SQL.
_ROLLUP_CACHE = {}


def _rollup_source_offer():
    """`{topics:[…], windows:[…]}` β€” every (topic, measure, dim) the engine can actually answer.



    β›” ONLY COMBINATIONS THAT CAN RESOLVE ARE OFFERED, because a rollup that refuses at COMPUTE

    time refuses silently β€” the cells are simply left blank, hours later, on a column that looks

    configured. Two exclusions do real work:

      * a topic with NO dims cannot be grouped at all, so it can never key a parent row;

      * a CROSS-TOPIC metric (`aov`, `margin_pct`, `returns_pct` β€” `agg: ratio`/`derived` whose

        inputs live elsewhere) is refused by `store_query` the moment a `group_by` is present:

        *"cross-topic measures are scalar-only"*. Offering one would mint a column that can only

        ever error.

    Each dim also declares HOW it keys β€” by an Odoo id or by its own value β€” because that is what

    the user is matching their own column against, and `payment_state` (a value) and

    `partner` (an id) are matched to very different columns.

    """
    if _ROLLUP_CACHE.get("offer"):
        return _ROLLUP_CACHE["offer"]
    from harness import semantic as sem
    from harness import windows as W
    ut = _ut()

    topics, metrics = sem.topics(), sem.metrics()
    by_topic = {}
    for key, m in metrics.items():
        # A ratio/derived metric whose parts sit on another topic cannot be grouped β€” see above.
        if m.get("agg") in ("ratio", "derived"):
            continue
        by_topic.setdefault(m["topic"], []).append(
            {"key": key, "label": m.get("label") or key, "format": m.get("format") or "usd",
             "description": m.get("description") or ""})

    out = []
    for tkey, t in sorted(topics.items()):
        dims = ((t.get("store") or {}).get("dims") or {})
        measures = by_topic.get(tkey) or []
        if not dims or not measures:
            continue
        out.append({
            "key": tkey,
            "label": t.get("label") or tkey,
            "grain": t.get("grain") or "",
            "dims": [{"key": dkey,
                      "label": d.get("label") or dkey,
                      # `store_query` emits `<dim>_id` only when the dim carries a display name
                      # alongside the key; otherwise the value IS the key. `rollup_sql` handles
                      # both, and the editor says which so the user matches the right column.
                      "keyedBy": "id" if d.get("name_col") else "value"}
                     for dkey, d in dims.items()],
            "measures": sorted(measures, key=lambda m: m["label"].lower()),
        })

    # ⚠ THE WINDOW LIST IS `core.user_tables`' OWN, not `harness.windows`'. `ROLLUP_SOURCE_WINDOWS`
    # is the validator's closed set and is deliberately NARROWER (it omits the parameterised kinds
    # like `last_n_days`, which have nowhere in the bag to carry their `n`). Offering a kind the
    # validator refuses would let the editor build a field the save door rejects.
    windows = [{"key": k, "label": W.WINDOW_LABELS.get(k, k).format(n="N")}
               for k in ut.ROLLUP_SOURCE_WINDOWS]
    offer = {"topics": out, "windows": windows}
    _ROLLUP_CACHE["offer"] = offer
    return offer


@router.get("/tables/rollup-sources")
def rollup_sources(session: Session = Depends(require_session)):
    """The topic β†’ metric β†’ dim β†’ window offer the rollup field editor renders.



    ⚠ DECLARED ABOVE EVERY `/tables/{table_key}/…` ROUTE, and kept there deliberately. FastAPI

    matches in declaration order, so the day somebody adds a bare `GET /tables/{table_key}` below

    this line it still resolves; added ABOVE it, this endpoint would silently start arriving as

    `table_key='rollup-sources'` and 404 from the table wall. There is no such route today β€”

    this is the cheap ordering that keeps it from mattering.



    β›” TENANT-SCOPED, AND IT WAS NOT WHEN FIRST WRITTEN. `sem.topics()` reads the GLOBAL model

    files, so nurilab and gtmlab were served the full Odoo offer β€” they would have seen "Live

    Odoo data" in the field editor and been able to build a column that can only ever be blank,

    because there is no mirror behind it. Three comments (here, in `apiBridge` and on the

    `rollupSourceOffer` prop) each asserted that a tenant with nothing connected receives `[]`,

    and the mode switch's "render only when there is a choice" guard is built on that promise.

    ⚠ `odoo_relational.is_royal` is the authority, reused rather than re-decided: it is already

    what `refresh` consults to decide whether these tables may exist at all, and a second copy of

    the rule would be a second answer the day a tenant gains a mirror.

    """
    import odoo_relational
    if not odoo_relational.is_royal(session.tenant):
        return {"topics": [], "windows": []}
    return _rollup_source_offer()


@router.post("/tables", status_code=201)
def create_table(body: dict = Body(default=None),

                 session: Session = Depends(require_session)):
    ut = _ut()
    body = body or {}
    label = str(body.get("label") or "").strip()
    if not label:
        raise err(400, "bad_label", "give the database a name")
    if not session.runtime.available():
        raise err(503, "store_unavailable",
                  "the tenant store is unavailable β€” nothing was created")
    source = body.get("source")
    try:
        key = ut.create(label, session.uname, fields=body.get("fields"),
                        source=source, st=session.runtime)
    except Exception:
        raise err(503, "store_unavailable",
                  "the tenant store refused the write β€” nothing was created")
    if not key:
        raise err(400, "refused",
                  f"could not create it β€” the name may be empty or this tenant already has "
                  f"{ut.MAX_TABLES} databases")
    return {"key": key}


@router.patch("/tables/{table_key}")
def patch_table(table_key: str, body: dict = Body(default=None),

                session: Session = Depends(require_session)):
    """Rename a database β€” IN ITS DEFINITION (wave 20, item 6a).



    ⚠ THE RENAME DOOR IN THE NAV WRITES `nav_meta` AND MUST ALSO CALL THIS. `nav_meta` is the

    nav's display layer; the definition is what the automation editor's database picker, the

    schema drawer and every future reader see. A rename that lands in only one of them leaves a

    picker that is confidently wrong rather than obviously stale. Posted to the wave doc as an

    amendment for whoever owns that door.

    """
    defn = _defn_or_refuse(session, table_key)
    ut = _ut()
    if not (session.admin or defn.get("createdBy") == session.uname):
        raise err(403, "forbidden", "only the database's creator or an admin can rename it")
    label = ut.set_label(table_key, (body or {}).get("label"), st=session.runtime)
    if not label:
        raise err(400, "bad_label", "give the database a name")
    return {"key": table_key, "label": label}


@router.get("/tables/{table_key}/footprint")
def table_footprint(table_key: str, session: Session = Depends(require_session)):
    """What dies with this database β€” the confirm dialog's disclosure (wave 21, item 6a / C3).



    Counts drill to the SAME buckets `user_tables.delete` cleans; a dialog listing categories

    without numbers would break [[no-unverifiable-aggregates]] at the scariest moment. Walled

    like the delete itself: only someone who could delete may case the joint."""
    defn = _defn_or_refuse(session, table_key)
    if not (session.admin or defn.get("createdBy") == session.uname):
        raise err(403, "forbidden", "only the database's creator or an admin can delete it")
    s = session.runtime
    views, fields = set(), len(defn.get("fields") or [])
    try:
        bucket = s.get(f"{table_key}_table_workspace") or {}
        for _u, ws in bucket.items():
            if isinstance(ws, dict):
                views |= set((ws.get("views") or {}).keys())
                fields += len(ws.get("fields") or {})     # per-user custom/measure strata
    except Exception:
        pass
    import core.shares as shares
    g = shares.grants("database", table_key, st=s)
    auto = []
    try:
        import automation_engine as engine
        for aid, d in (engine.all_definitions(s) or {}).items():
            if (d.get("config") or {}).get("targetTable") == str(table_key):
                auto.append({"id": str(aid), "name": d.get("name") or str(aid)})
    except Exception:
        pass
    return {"rows": len(defn.get("rows") or {}), "fields": fields, "views": len(views),
            "sharedUsers": len(g.get("entries") or []),
            "automations": sorted(auto, key=lambda a: a["name"].lower())}


@router.delete("/tables/{table_key}")
def delete_table(table_key: str, session: Session = Depends(require_session)):
    """CREATOR OR ADMIN β€” checked explicitly (wave 21, item 6a / C3).



    β›” The wave-20 docstring said "the same actors may_open admits" and that stopped being the

    creator-or-admin set the day de5037f taught `may_open` to admit share GRANTEES: a view-role

    grantee could reach this route and delete the database somebody shared with them. The wall

    is now the definition's own `createdBy`, the same check the rename route always had.



    Deletion cleans the artifact families server-side (`user_tables.delete` lists them) and

    DISABLES bound automations with a status note β€” never deletes them. The client's confirm

    dialog disclosed `/footprint` first; the server cannot tell a click from a plan, so the

    dialog is a product requirement, not a formality."""
    defn = _defn_or_refuse(session, table_key)
    if not (session.admin or defn.get("createdBy") == session.uname):
        raise err(403, "forbidden", "only the database's creator or an admin can delete it")
    try:
        import automation_engine as engine
        engine.disable_for_table(session.runtime, table_key)
    except Exception:
        pass
    try:
        _ut().delete(table_key, st=session.runtime)
    except Exception:
        raise err(503, "store_unavailable", "the delete did not land β€” try again")
    return {"ok": True}


#: ⭐ 2026-08-07 β€” tenants whose Instagram tables THIS PROCESS has already brought forward.
_IG_FORWARDED = set()


def _ig_forward(session):
    """Bring this tenant's Instagram tables onto the current schema, at most once per process.



    β›” WHY A MIGRATION RUNS ON A READ AT ALL, when the module's own rule is that it rides the WRITE

    path. `ut_ensure` calling it is right for a schema an automation is about to append to, and

    useless for a change a PERSON is waiting to see: the owner's report was *"the first field is

    still blank"*, and "re-save the automation and it will fix itself" is not an answer to that.

    The write path stays exactly as it was β€” this is a second door to the same idempotent call,

    not a replacement for it.



    ⚠ BOUNDED THREE WAYS, because a write on a read is otherwise how a grid gets slow: once per

    tenant per process; `migrate_ig_tables` returns without a write when every table is already

    current (the common case after the first read); and a failure is SWALLOWED β€” a migration must

    never be the reason a database will not open.



    ⚠ THE TENANT IS MARKED BEFORE THE ATTEMPT, deliberately. A migration that raises must not be

    retried on every subsequent read of every table for the life of the process β€” the write path is

    still the backstop, so the cost of skipping is a delay, while the cost of retrying is a failing

    store call on the hot path of a grid that is trying to render.

    """
    tenant = str(getattr(session, "tenant", "") or "")
    if tenant in _IG_FORWARDED:
        return
    _IG_FORWARDED.add(tenant)
    try:
        import automation_engine as engine
        engine.migrate_ig_tables(session.runtime, log=lambda *_a: None)
    except Exception as e:                                            # noqa: BLE001
        print(f"[tables] ig forward-migration skipped: {type(e).__name__}: {e}")



#: Above this many characters a `json` cell is replaced by a stand-in in the LIST envelope. Sized
#: so an ordinary config document (a few hundred bytes) is untouched while a vendor response is
#: not β€” the shape this exists for is one already-paid provider payload per row.
JSON_LIST_MAX = 400


def _thin_json(fields, merged, table_key=""):
    """Replace oversized `json` cells with a stand-in for the LIST response. Pure; returns a copy.



    ⚠ THE STAND-IN IS ITSELF VALID JSON and carries the byte count, so the grid preview reads

    `{...} 3 keys` rather than a broken brace, and a reader can see the column holds something

    large rather than something empty. `_truncated` is what the viewer keys its fetch on.



    ⭐ THE STAND-IN CARRIES ITS OWN `_url`, which is what keeps this change small AND correct: the

    viewer needs no table key, no record id and no new props threaded down through three

    components to find the document β€” the route that removed the value says where it went. One

    writer of that address instead of a server rule and a client rule that must agree forever.

    """
    json_keys = [str(f.get("key")) for f in (fields or [])
                 if str(f.get("type") or "") == "json"]
    if not json_keys:
        return merged
    out = {}
    for pid, cells in (merged or {}).items():
        row = cells
        for key in json_keys:
            raw = cells.get(key)
            if isinstance(raw, str) and len(raw) > JSON_LIST_MAX:
                if row is cells:
                    row = dict(cells)
                row[key] = json.dumps({
                    "_truncated": True, "bytes": len(raw),
                    "_url": f"/api/v1/tables/{table_key}/rows/{pid}/fields/{key}"})
        out[pid] = row
    return out


@router.get("/tables/{table_key}/rows")
def table_rows(table_key: str, session: Session = Depends(require_session)):
    """The `/customers`-shaped envelope for one user table: `{fields, rows, today, pulled_at,

    identity}` β€” so the client's generic topic fetch consumes it with zero new parsing.



    ⚠ THE MERGE ORDER IS THE CONTRACT. `rows_from_pool` sources an overlay-typed field's cell

    from the OVERLAY stratum only (that is what makes a custom column render standalone) β€” a

    user table's base values live in its DEFINITION rows, so they are layered UNDER the user's

    overlay edits here: base first, overlay wins. Without this every base cell reads empty

    (found by this route's own gate check, not by luck)."""
    import aios_grid

    _ig_forward(session)
    g = ut_assembly(session, table_key)
    # β›” THE OVERLAY WAS NEVER ACTUALLY MERGED, and the docstring above has described the merge
    # this line does not perform since the route was written (owner item 3, 2026-08-09:
    # *"Using the swipe, fast doesn't register the CHANGE. I went back and it all got reseted"*).
    #
    # `merged` was built from `rows_src` ALONE β€” the DEFINITION rows. But `rows_from_pool`
    # sources an overlay-typed field's cell from this dict, and a CUSTOM column on a `ut_*` table
    # is overlay-typed by construction, so it looked up a key that could not be there and every
    # such cell rendered blank.
    #
    # ⭐ THE WRITES WERE NEVER LOST β€” MEASURED. `ws['overlays']` holds
    # `{"1": {"custom_geography_yf6vi": "Jakarta"}, ...}` for ten rows: the owner's swipes landed
    # in the store exactly as they should. Only the READ-BACK dropped them, which is why the
    # value survived the gesture, vanished on reload, and looked like "it reset itself" β€” and
    # why `patch_row`'s `_took()` then reported a perfectly good write as `refused`.
    #
    # ⚠ OVERLAY WINS, base underneath β€” the order the docstring already specifies. A definition
    # value must not shadow an edit the user has made on top of it.
    # ⚠ AND IT IS THIS USER'S OWN OVERLAY (`table_workspace` is keyed by `ctx.uname`), so this
    # widens what a caller can SEE by exactly their own edits and nothing else.
    _overlays = (g.get("ws") or {}).get("overlays") or {}
    merged = {}
    for _r in g["rows_src"]:
        _pid = str(_r["pid"])
        _cells = {k: v for k, v in _r.items() if k != "pid"}
        _ov = _overlays.get(_pid)
        if isinstance(_ov, dict):
            _cells.update(_ov)
        merged[_pid] = _cells
    # ⭐⭐ 2026-08-10 (owner: *"wth is going on, why does it take forever to load now?"*) β€” THE
    # JSON DOCUMENTS DO NOT RIDE THE LIST.
    #
    # MEASURED on nurilab, and the numbers are the whole argument: `source_payload` is **95.6%
    # to 98.5%** of every IG grid's bytes β€” `ut_ig_post_snapshots` shipped **11.6 MB of a 12.2 MB
    # response**, `ut_ig_snapshots` 1.85 MB of 2.03 MB, the profile table 1.83 MB of 2.11 MB. The
    # grid renders those cells as a SIXTY-CHARACTER preview (`display.jsonPreview`), so the whole
    # vendor response crossed the wire, was parsed by the browser and held in memory purely so a
    # clipped first line could be drawn.
    #
    # ⚠ IT IS NOT A CAP AND NOTHING IS LOST. The full document is served by
    # `GET /tables/{key}/rows/{pid}/fields/{fkey}`, which the JSON viewer fetches when it opens β€”
    # the one place a person actually reads it, for the one row they opened. The cell that rides
    # the list is a VALID small document saying what it stands for, so the preview renders
    # honestly instead of showing half a truncated brace.
    # β›” ONLY `json` COLUMNS, and only over the threshold: a small document still travels whole,
    # so a tenant using `json` for a short config sees no change at all.
    rows = aios_grid.rows_from_pool(
        g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
    # ⭐ R6's SECOND SENTENCE, ON THE WIRE (W30-T29). *"if there is lag or it can't be done, you
    # need to explicitly tell me why and recommend a fix."* A ceiling that still applies to this
    # database says so here, with its cause and the recommendation, rather than waiting to be
    # discovered as a refused paste. `None` for a connected source, and an EMPTY LIST is the
    # honest answer for a table nothing limits β€” never an absent key, which a client cannot tell
    # apart from an older server.
    _report = _ut().limit_report(table_key, st=session.runtime)
    # ⭐ C4 / D-138 β€” THE DOCUMENTS PRODUCER. Absent since EXIT-6 deleted `app.py`, which was the
    # only thing that ever set this key; the write door never stopped working and every client
    # half is complete, but all six `onDoc*` handlers read `payload?.docs ? … : undefined`, so a
    # missing key has been silently switching the feature off. β›” ONE shared serialiser with the
    # customer scope β€” `grid_events.docs_for` β€” never a twin here.
    from core import grid_events as _ge
    return {"fields": g["fields"], "rows": rows, "today": g["today"],
            "docs": _ge.docs_for(g["pids"], scope_key=table_key, uname=session.uname,
                                 admin=session.admin, st=session.runtime),
            "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
            "identity": {"pid": "pid"},
            "scope": {"table": table_key, "rowCount": len(rows)},
            "limits": [_report] if _report else [],
            "recordsMutable": _ut().records_mutable(table_key, st=session.runtime)}


@router.get("/tables/{table_key}/rows/{pid}/fields/{fkey}")
def table_cell(table_key: str, pid: str, fkey: str,

               session: Session = Depends(require_session)):
    """ONE cell, whole β€” the other half of `_thin_json`.



    β›” WITHOUT THIS THE THINNING WOULD BE A CAP, and a cap on data somebody already paid a vendor

    for is exactly what this product refuses everywhere else. The list ships a stand-in; the JSON

    viewer opens this for the one row a person is actually reading.



    ⚠ SAME PERMISSION WALL AS THE LIST, reached the same way (`ut_assembly` resolves the session's

    view of the table), so this cannot become a side door onto a table the caller may not open β€”

    which is the failure a "just fetch the raw cell" helper invites.

    ⚠ THE OVERLAY WINS, exactly as it does in `table_rows`: a user who has typed over a cell must

    read back what they typed, not the definition value underneath it.

    """
    g = ut_assembly(session, table_key)
    field = next((f for f in (g.get("fields") or []) if str(f.get("key")) == str(fkey)), None)
    if field is None:
        raise err(404, "unknown field", f"{fkey!r} is not a column on this database")
    row = next((r for r in g["rows_src"] if str(r.get("pid")) == str(pid)), None)
    if row is None:
        raise err(404, "unknown record", f"no record {pid!r} in this database")
    overlay = ((g.get("ws") or {}).get("overlays") or {}).get(str(pid)) or {}
    value = overlay.get(fkey, row.get(fkey))
    return {"table": table_key, "pid": str(pid), "field": str(fkey),
            "value": "" if value is None else str(value)}


@router.post("/tables/{table_key}/rows", status_code=201)
def add_row(table_key: str, body: dict = Body(default=None),

            session: Session = Depends(require_session)):
    """Append a row β€” or RESTORE one under its old id (contract C-ADDROW / C-UNDO).



    ⚠ THE ANSWER IS ALWAYS THE ID THAT WAS STORED, never the one that was asked for. An undo

    that requested `rid: 7` and got 12 because 7 had been re-used must find that out from the

    response rather than assume; the client re-anchors on what came back.

    """
    _records_or_refuse(session, table_key)
    ut = _ut()
    values = (body or {}).get("values") or {}
    if not isinstance(values, dict):
        raise err(400, "bad_values", "values must be an object of {fieldKey: value}")
    try:
        rid = ut.add_row(table_key, values, session.uname, st=session.runtime,
                         rid=(body or {}).get("rid"))
    except Exception:
        raise err(503, "store_unavailable", "the row was not saved β€” the store refused")
    if rid is None:
        # C3 (wave 25): `add_row` also refuses a profile cell that is not a handle, so the cap
        # sentence alone would misdirect β€” the reader would go and count rows. Ask the same
        # validator the law used rather than re-deciding here (one rule, two voices).
        pf = ut.profile_field(table_key, st=session.runtime)
        if pf and pf["key"] in values:
            _h, ok = ut.normalize_profile(values[pf["key"]], pf["profile"].get("source"))
            if not ok:
                raise err(400, "refused",
                          f"{str(values[pf['key']])[:80]!r} is not an Instagram profile β€” "
                          f"{pf.get('label') or pf['key']!r} takes a handle (@name) or a "
                          f"profile link (instagram.com/name)")
        raise err(400, "refused",
                  f"row refused β€” the table may be at its {ut.MAX_ROWS}-row cap")
    _refresh_relations(session)
    return {"rid": rid, "pid": int(rid)}


@router.post("/tables/{table_key}/rows/import", status_code=201)
def import_rows(table_key: str, body: dict = Body(default=None),

                session: Session = Depends(require_session)):
    """⭐⭐ WAVE-29 T25 (owner item 6) β€” the IMPORT door: N mapped rows, ONE store write.



    Body: `{"rows": [{fieldKey: value, ...}, ...]}` β€” already MAPPED by the client's dialog, so a

    spreadsheet column name never reaches the store. APPEND-ONLY in v1: every row is a new record,

    nothing is matched or overwritten, and the dialog says so before the button is pressed.



    β›” COMPUTED COLUMNS ARE REFUSED HERE, NOT FILTERED. `is_computed_cell` is the same predicate

    the cell wall uses (one evaluator for one question), and a rollup or formula key arriving in

    an import is not a stray to be tidied away β€” it means the client offered a target it should

    not have, and silently dropping it would leave the user looking for a column of values that

    never arrived. The refusal names the column.



    β›” ATOMIC. `add_rows` writes nothing unless the whole batch fits under `MAX_ROWS` and every

    profile cell validates, because a half-imported file is the worst outcome available: the user

    cannot tell which rows landed without reconciling the spreadsheet by hand.

    """
    _records_or_refuse(session, table_key)
    ut = _ut()
    rows_in = (body or {}).get("rows")
    if not isinstance(rows_in, list) or not rows_in:
        raise err(400, "bad_rows", "rows must be a non-empty array of {fieldKey: value} objects")
    if any(not isinstance(r, dict) for r in rows_in):
        raise err(400, "bad_rows", "every row must be an object of {fieldKey: value}")
    defn = _defn_or_refuse(session, table_key)
    by_key = {f["key"]: f for f in (defn.get("fields") or [])}
    asked = {k for r in rows_in for k in r}
    unknown = sorted(k for k in asked if k not in by_key)
    if unknown:
        raise err(400, "unknown_field",
                  f"this database has no column {unknown[0]!r}")
    computed = sorted(k for k in asked if ut.is_computed_cell(by_key[k]))
    if computed:
        label = by_key[computed[0]].get("label") or computed[0]
        raise err(400, "computed_field",
                  f"{label!r} is worked out from other columns, so it cannot be imported into")
    # β›” W29-T81 β€” THE TYPE WALL, AND IT LIVES HERE BECAUSE THE ONLY OTHER ONE IS IN THE BROWSER.
    # `coerceClipboardValue` refuses "seventeen-ish" at an `int` column in the dialog; curl, a
    # second client, or a future importer met no wall at all and the string landed verbatim in a
    # typed column, where the grid then painted it as a fabricated `0`. Refused whole and BEFORE
    # `add_rows`, matching this door's own atomicity: a half-imported file is the outcome every
    # rule on this route exists to prevent. The sentence names the row and the column, because
    # "invalid value" sends somebody hunting through 2,000 lines of spreadsheet.
    for index, row in enumerate(rows_in):
        for key, value in row.items():
            why = ut.cell_type_refusal(by_key[key], value)
            if why:
                raise err(400, "bad_value", f"row {index + 1}: {why} β€” nothing was imported")
    try:
        made = ut.add_rows(table_key, rows_in, session.uname, st=session.runtime)
    except Exception:
        raise err(503, "store_unavailable", "nothing was imported β€” the store refused")
    if made is None:
        raise err(400, "refused",
                  f"nothing was imported β€” {len(rows_in)} rows would take this database past "
                  f"its {ut.MAX_ROWS}-row cap, or a profile column rejected a value")
    _refresh_relations(session)
    return {"imported": len(made), "pids": [int(r) for r in made]}


# ---------------------------------------------------------------------------------------------
# THE SHARED FIELD SCHEMA (contract C-FIELD, owner ruling R2)
# ---------------------------------------------------------------------------------------------
# R2: a `ut_*` table's fields are the TABLE'S schema β€” everyone with access sees the same
# columns, the creator or an admin edits them, and a per-field `editRole` can open ONE column's
# definition to everyone without handing over the table. This supersedes wave 17's "fields are
# per-user" law for this path only; the connector scopes keep their own model.
#
# ⚠ WHY IT MATTERS BEYOND TIDINESS: a grid add-field on a `ut_` scope used to land in the
# per-user workspace overlay, which is why the automation editor's "Automation column" picker
# could not see a column the user had just created β€” it reads the DEFINITION. Same defect shape
# as the rename (item 6a): two places to look, and the surfaces disagreed silently.

def _field_or_refuse(session, table_key, fkey=""):
    """The schema wall. `_defn_or_refuse` first (404/403 on the table), then the per-field rule."""
    defn = _defn_or_refuse(session, table_key)
    ut = _ut()
    if not ut.is_user_table(table_key, st=session.runtime):
        raise err(400, "not_a_user_table",
                  "only a user-created database has an editable schema β€” a connected source "
                  "owns its own columns")
    if fkey and not ut.may_edit_field(table_key, fkey, session.uname, session.admin,
                                      st=session.runtime):
        field = next((f for f in (defn.get("fields") or [])
                      if f.get("key") == str(fkey)), None)
        if isinstance((field or {}).get("automation"), dict) \
                and field["automation"].get("preset") is True:
            # ⚠ THIS BRANCH NARRATES `may_edit_field`, it does not re-decide (the `and not
            # ut.may_edit_field` above is the wall). Said out loud because the sentence itself
            # went stale on 2026-08-09: preset ROLLUPS became editable by owner ruling, so a
            # blanket "pre-set fields are locked" would now be the server explaining a refusal
            # it did not make β€” `preset_editable` is the one predicate that answers this.
            raise err(403, "preset_field_locked",
                      "this is a pre-set column, so its name and type are fixed; you may sort, "
                      "filter or hide it, edit any Rollup column, and add your own columns")
        raise err(403, "forbidden", "that column can only be changed by the database's creator "
                                    "or an admin")
    if not fkey and not (session.admin or defn.get("createdBy") == session.uname):
        raise err(403, "forbidden", "only the database's creator or an admin can add a column")
    return defn


@router.post("/tables/{table_key}/fields", status_code=201)
def add_field(table_key: str, body: dict = Body(default=None),

              session: Session = Depends(require_session)):
    _field_or_refuse(session, table_key)
    ut = _ut()
    field = ut.add_field(table_key, body or {}, st=session.runtime)
    if not field:
        # ⭐ D-46 CLOSED (wave 23) β€” the C8 flow law gets its OWN sentence. `add_field` answers
        # None for every refusal, so this route said "check the name and type" to somebody whose
        # name and type were fine and whose automation column named a flow that does not exist.
        # A refusal that misdirects is worse than a bare 400: it sends the reader to look at the
        # one thing that was never wrong. Checked HERE, in the route's own words, because the
        # law itself stays enforced in `user_tables.flow_bound` β€” this narrates it, never
        # re-implements it (a second copy of the rule is how two doors start disagreeing).
        raise err(400, "refused",
                  _refusal_sentence(ut, session, body or {}, table_key=table_key))
    if field.get("type") == "link":
        synced = ut.sync_reciprocal_link(table_key, field["key"], st=session.runtime)
        field = synced.get("field") or field
    # ⭐⭐ 2026-08-09 β€” `rollup` REFRESHES TOO, and the omission was invisible until this route
    # became reachable for one. It was gated on `link` alone, while `patch_field` and
    # `delete_field` next door refresh unconditionally β€” so a newly created Rollup got its first
    # fold from `_store_resync_loop`, which sleeps 1800 s BEFORE its first pass (D-107's shape).
    # The user would have created the column, watched a 201 come back, and read a blank cell for
    # half an hour: *"the Rollup doesn't work"*, arriving through the door opened to fix it.
    # ⚠ Still conditional rather than unconditional: a pass deep-copies every table and row in
    # the tenant, and adding a text column has nothing to fold. The condition is now "is this
    # field relational", which is the question that was always meant.
    if field.get("type") in ("link", "rollup"):
        _refresh_relations(session)
    return {"field": field}


def _refusal_sentence(ut, session, body, table_key="", fkey=""):
    """Why was this column refused? The specific reason when we can name one, the general list

    otherwise β€” never a specific-sounding guess."""
    # ⭐ C3 (wave 25, R7): the one-profile-per-table refusal NAMES THE EXISTING COLUMN, which is
    # what the contract asks for and what makes it actionable β€” "at most one" sends the reader
    # hunting through a 40-column schema for a flag they cannot see from the header.
    if isinstance((body or {}).get("profile"), dict):
        # ⚠ THE TYPE IS NAMED FIRST, and the order is the point. Both refusals can be true at
        # once (an `int` profile column on a table that already has a profile column), and the
        # TYPE is the one that is wrong about what the caller just sent β€” unconditionally, no
        # matter what else is on the table. Answering "you already have one" to somebody whose
        # real mistake was the column type sends them to fix the wrong thing, which is the
        # misdirection D-46 closed one door over.
        if str((body or {}).get("type") or "text").strip().lower() != "text":
            return ("a profile column is a flag on an ordinary TEXT column β€” it validates what "
                    "is typed into it, which it can only do for text")
        existing = ut.profile_field(table_key, st=session.runtime) if table_key else None
        if existing and existing.get("key") != str(fkey):
            return (f"this database already has a profile column β€” "
                    f"{existing.get('label') or existing.get('key')!r}. A database has at most "
                    f"one, so the automation knows which handle to enrich; edit that column, or "
                    f"take the flag off it first")
    bag = (body or {}).get("automation")
    if isinstance(bag, dict):
        flow = str(bag.get("flowId") or "").strip()
        if not flow:
            return ("an automation column has to name the automation that fills it β€” pick a "
                    "flow, or make this an ordinary column")
        if not ut.flow_bound(bag, st=session.runtime):
            return (f"this column names automation {flow!r}, which does not exist in this "
                    f"workspace β€” it may have been deleted; pick a flow that is still there")
    kind = str((body or {}).get("type") or "").strip()
    if kind and kind not in ut.UT_FIELD_TYPES:
        return (f"{kind!r} is not a column type here (types: "
                f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
    return (f"the column was refused β€” check the name and type, or the table may be at its "
            f"{ut.MAX_FIELDS}-column cap (types: {', '.join(sorted(ut.UT_FIELD_TYPES))})")


@router.patch("/tables/{table_key}/fields/{fkey}")
def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),

                session: Session = Depends(require_session)):
    """Edit one column's definition, and MIGRATE its values when options are renamed.



    β›” A CHOICE RENAME IS AN EXPLICIT MAPPING, NEVER A DIFF (contract C-RENAME). `{renames:

    [{from, to}]}` arrives alongside the new options list, because a diff cannot tell "renamed

    Blue to Navy" from "deleted Blue, added Navy" β€” and guessing wrong empties the column and

    every saved view that filtered on it.

    """
    _field_or_refuse(session, table_key, fkey)
    ut = _ut()
    body = body or {}
    migrated = None
    renames = body.get("renames")
    if renames:
        try:
            migrated = ut.rename_choice_values(table_key, fkey, renames, st=session.runtime)
        except Exception:
            raise err(503, "store_unavailable", "the rename did not land β€” try again")
        # The per-user workspace strata and any view filter naming the old value are the OTHER
        # half of C-RENAME and belong to `core.table_store`. Called only if it is there: an
        # enumerator's mirror waits for its counterpart rather than guessing at its shape, and a
        # missing counterpart must not lose the half that DID land.
        try:
            import core.table_store as table_store
            fn = getattr(table_store, "rename_choice_values", None)
            if callable(fn):
                fn(table_key, fkey, renames, st=session.runtime)
                migrated = dict(migrated or {}, workspace=True)
        except Exception:                                         # noqa: BLE001
            migrated = dict(migrated or {}, workspace=False)
    field = ut.patch_field(table_key, fkey, body, st=session.runtime)
    if not field:
        # C3: the same narrator as `add_field`. Flagging a SECOND column is the same act as
        # adding one, so it must get the same sentence naming the column that already holds the
        # flag β€” a patch that answered "check the name and type" would send the reader to the
        # one thing that was never wrong (the D-46 lesson, one door over).
        raise err(400, "refused",
                  _refusal_sentence(ut, session, body, table_key=table_key, fkey=fkey))
    synced = ut.sync_reciprocal_link(table_key, fkey, st=session.runtime)
    field = synced.get("field") or field
    _refresh_relations(session)
    out = {"field": field}
    if migrated is not None:
        out["migrated"] = migrated
    return out


@router.delete("/tables/{table_key}/fields/{fkey}")
def delete_field(table_key: str, fkey: str, session: Session = Depends(require_session)):
    _field_or_refuse(session, table_key, fkey)
    if not _ut().delete_field(table_key, fkey, st=session.runtime):
        raise err(400, "refused",
                  "that column could not be removed β€” a database must keep at least one")
    _refresh_relations(session)
    return {"deleted": fkey}


@router.delete("/tables/{table_key}/rows/{rid}")
def delete_row(table_key: str, rid: str, session: Session = Depends(require_session)):
    # ⭐⭐ W31 QA β€” ONE SNAPSHOT FOR THE WHOLE DELETE, and the owner reported what it cost.
    # Owner, verbatim (2026-08-13): *"it still takes forever to delete a record from TT Profile."*
    # A single DELETE was FIVE whole-document deep copies before the commit even began β€” three in
    # the guard (`get` + `may_open` + `records_mutable`) and two more inside
    # `core.user_tables.delete_row` (`is_user_table` + `records_mutable` again). MEASURED: 2 reads
    # cost 45 ms on an 0.8 MB fixture, and tenant #0's `user_tables` document is **28.5 MB**
    # (D-185), so the guard alone was seconds of copying to answer questions about one row.
    # ⚠ THE LEND IS READ-ONLY AND THE WRITE STILL GOES THROUGH THE REAL RUNTIME β€” `_Lent`
    # `__getattr__`-passes `update` straight to it, and `_drop` runs against the LIVE document
    # under the store lock, so a lent snapshot can never be the thing written back.
    # ⚠ `flush='sync'` IS DELIBERATELY UNTOUCHED (D-118): nobody spams a delete, and an eventually
    # consistent delete is indistinguishable from one that did not work. This makes the guard
    # cheap; it does not make the commit optimistic.
    lent = _ut().lend(session.runtime)
    _records_or_refuse(session, table_key, st=lent)
    try:
        ok = _ut().delete_row(table_key, rid, st=lent)
    except Exception:
        raise err(503, "store_unavailable", "the delete did not land β€” try again")
    if not ok:
        raise err(400, "refused", "rows can only be deleted from user-created databases")
    _refresh_relations(session)
    return {"ok": True}


@router.patch("/tables/{table_key}/rows/{pid}")
def patch_row(table_key: str, pid: int, body: dict = Body(default=None),

              session: Session = Depends(require_session)):
    """Cell edits β€” the products PATCH on the user-table ctx. Routed through

    `core.grid_events.handle_one` so truncation and permission rules stay ONE implementation;

    the accepted values are read BACK from the bucket, never echoed from the request."""
    from core import grid_events

    updates = dict(body or {})
    if not updates:
        raise err(400, "empty_patch", "no fields to update")
    _records_or_refuse(session, table_key)
    g = ut_assembly(session, table_key, consume_corrections=False)
    if pid not in g["pids"]:
        raise err(403, "out_of_scope", "that row is not in this database")
    ctx = grid_events.EventCtx(
        uname=session.uname, allowed_pids=g["pids"], fields=g["fields"],
        admin=session.admin, fallback_ws=None, seen_ids={}, hidden_keys=frozenset(),
        st=session.runtime,                       # R6b (D-16)
        scope_key=table_key, table=_ops(session, table_key))
    try:
        grid_events.handle_one(
            {"id": f"patch:{table_key}:{pid}:{time.time_ns()}", "type": "overlay_patch",
             "pid": pid, "updates": updates}, ctx)
    except grid_events.StoreUnavailable:
        raise err(503, "store_unavailable",
                  "the tenant store is unavailable β€” your change was not saved")
    # ⚠ THE READ-BACK IS THE DEFINITION ROW, AND ON A `ut_` SCOPE THAT IS THE WHOLE OF IT.
    #
    # β›” CORRECTED, wave-29 T22 (owner item 2a): this note used to say "THE READ-BACK SPANS BOTH
    # STRATA, and it has to (wave 25, C3-A1)" while the two lines under it read exactly one bucket.
    # It was true of the wave-25 world it was written in β€” an ordinary cell landed in the caller's
    # OVERLAY and only a PROFILE cell wrote through β€” and it stayed after `grid_events` began
    # routing EVERY accepted cell on a `ut_` scope to `user_tables.patch_cells`
    # (`grid_events.py:1979-1984`). There is no second stratum this read is missing; a docstring
    # claiming otherwise is what makes the next reader look for a merge bug that is not here.
    # ⚠ Legacy overlay values, written before that routing existed, are still merged for DISPLAY
    # by `table_rows` (:587-595) β€” display only, and deliberately not re-asserted here: `_took`
    # asks whether THIS write landed, and this write goes to the definition.
    stored = dict(((_ut().get(table_key, st=session.runtime) or {}).get("rows") or {})
                  .get(str(pid)) or {})
    accepted = {k: stored.get(k) for k in updates if k in stored}

    def _took(k):
        """Did the cell TAKE this write? Normally that is "stored == asked".



        ⚠ A PROFILE COLUMN CANONICALISES, so "stored != asked" is its NORMAL success: `@Nurilab`

        and `instagram.com/nurilab` both store `nurilab`. Reporting those as refused would tell

        the client to roll back a write that landed. But it cannot simply be exempted either β€”

        a junk handle leaves the OLD value sitting in `stored`, which would then read as

        accepted. So the question asked is the exact one: **is what is stored the canonical form

        of what was asked?** Anything else is a genuine refusal.

        """
        if k not in accepted:
            return False
        want = str(updates[k])
        if stored.get(k) == want:
            return True
        pf = _ut().profile_field(table_key, st=session.runtime)
        if pf and pf["key"] == k:
            handle, ok = _ut().normalize_profile(want, pf["profile"].get("source"))
            return bool(ok) and stored.get(k) == handle
        return False

    refused = sorted(k for k in updates if not _took(k))
    out = {"pid": pid, "updates": accepted}
    if refused:
        out["refused"] = refused
    # ⭐ R6: the cells the SERVER changed that the client never typed β€” the preset cells a
    # profile blank cleared. Without this the grid keeps painting a stale follower count under
    # an empty handle until something else forces a refetch, which is the NO-BLIP LAW's other
    # half: the client may keep only what the server actually took, and must be TOLD what else
    # moved. Derived by diffing this row against what was asked for, so it cannot drift from
    # whatever the clear rule decides to touch.
    also = {k: v for k, v in stored.items() if k not in updates and str(v or "") == ""}
    cleared = sorted(k for k in also if k in _ut().PROFILE_PRESET_KEYS)
    if cleared:
        out["cleared"] = cleared
    # ⭐⭐ R9's SECOND RE-ARM DOOR β€” the one call that makes `engine.clear_gone` live (wave 28,
    # amendment A5; SESSION B built and gated the function and correctly declared it INERT until
    # this line existed, citing [[flag-shipped-without-its-writer]]).
    #
    # β›” R9 makes a `not_found` handle a TOMBSTONE, not a 30-day backoff: nothing re-buys a dead
    # account on a timer any more. Door 1 β€” correcting the handle β€” needs no wiring, because the
    # verdict is keyed on `(platform, handle)` and a corrected handle simply is not the verdict we
    # recorded. THIS is door 2: a human re-typing the SAME handle, which is how somebody says "try
    # it again, the account is back". Without this call that person has no way back at all, and
    # the failure costs nothing and raises nothing β€” so no spend-shaped test would ever find it.
    #
    # ⚠ GATED ON `updates`, NOT ON `accepted`: re-typing the identical value is the whole case this
    # door exists for, and a no-op write can be filtered out of `accepted`. What matters is that a
    # human touched the handle cell.
    # ⚠ NOT a bare `except: pass`. A swallowed AttributeError here would be exactly the optional-
    # prop silence this wiring exists to prevent β€” if the engine ever loses `clear_gone`, that must
    # be readable in the log rather than degrade into "the re-arm quietly stopped working".
    _pf = _ut().profile_field(table_key, st=session.runtime)
    if _pf and _pf["key"] in updates:
        try:
            import automation_engine as _engine
            _engine.clear_gone(session.runtime, table_key, stored.get(_pf["key"]))
        except Exception as e:                                    # noqa: BLE001
            print(f"[aios-api] clear_gone failed: {type(e).__name__}: {e}")
    _refresh_relations(session)
    return out