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
|