File size: 50,010 Bytes
bf8519f 051f280 bf8519f 051f280 bf8519f e1b3e71 051f280 bf8519f e1b3e71 bf8519f | 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 | """routes_grid.py β X2's write seam over HTTP: the SECOND adapter on `core.grid_events` (EXIT-1c).
`POST /api/v1/grid/events` takes the component's event objects VERBATIM β the same objects the
Streamlit host receives through the component value slot, unchanged β and runs them through the
same `core.grid_events.handle_events` the Streamlit adapter runs. That is the whole point of
EXIT-1a: one implementation of every permission wall, two transports. A route that re-validated
anything here would be a second wall to keep in step, and the two would drift on the first change.
DEDUP IS PER REQUEST, and that is a deliberate limit, not an oversight. The Streamlit adapter's
`seen_ids` lives in `st.session_state` β genuinely per user session β because the client resends
its recent 24-event window on every emit inside one page session. A stateless API has no such
dict, and inventing a per-user server-side one would be exactly the resident per-tenant state
EXIT-4a exists to remove. So: ids are deduped WITHIN a request body (the resend window's whole
purpose β a batch that repeats an id processes it once), and a genuinely replayed request is
handled by the operations being idempotent. The one event where a replay is observable is
`add_to_list` (it would add the same pids to the same cohort twice β a set union, so the
membership is unchanged, but the toast count repeats). Noted rather than papered over; a
server-side idempotency key belongs with D2's session mirror (C-2).
STORE DOWN = 503. `fallback_ws` is None on this adapter, so `core.grid_events` raises
`StoreUnavailable` rather than writing to an in-memory workspace no API request could ever read
back. A 200 over a write that evaporated is the failure this rule exists to prevent.
"""
import datetime as dt
import time
from fastapi import APIRouter, Body, Depends
from deps import Session, err, module_gate, perms, require_session
router = APIRouter(prefix="/api/v1")
MODULE = "customer_data"
#: The client's own resend window (`app.py`'s `[-24:]`). A body larger than this is not a
#: legitimate client β refuse it rather than doing 500 store writes on one request.
_MAX_EVENTS = 24
def _ctx(session: Session, fields, pids, **kw):
from core import grid_events
import aios_grid
import core.perm_scope as perm_scope
# Wave 16 C-TOPIC: the ctx is TOPIC-SHAPED. The product scope swaps all three of the
# things a write is validated against β the module the permission wall reads, the canonical
# contract the hidden-field closure runs over, and the TABLE OPS the write lands in.
# Getting any one of them from the other topic is the "validated against the wrong field
# contract" near-miss the wave-15 routes_products header warned about.
scope_key = str(kw.get("scope_key") or "")
if scope_key == "product":
import modules.product_data as pd
from routes_products import MODULE as _PMOD, pd_fields
module, canonical = _PMOD, pd_fields(consolidated=True)
kw.setdefault("table", pd.TABLE_OPS)
hidden = perm_scope.hidden_keys(session.user, module, canonical)
elif scope_key.startswith("ut_"):
# Wave 18 C3-UT: a user table has no module in the permission wall (its wall is
# `user_tables.may_open`, already applied by the assembly this route ran first), so
# the hidden-field closure is EMPTY rather than borrowed from another topic's contract.
import core.table_store as table_store
kw.setdefault("table",
table_store.make(f"{scope_key}_table_workspace", st=session.runtime))
hidden = frozenset()
else:
module, canonical = MODULE, aios_grid.FIELDS
hidden = perm_scope.hidden_keys(session.user, module, canonical)
return grid_events.EventCtx(
uname=session.uname, allowed_pids=pids, fields=fields, admin=session.admin,
# C-PERM: the write wall's field half, on the EVENTS transport too. This route takes the
# component's event objects verbatim, so a hidden key would otherwise arrive here with
# nothing between it and the store.
hidden_keys=hidden,
# β WAVE 25 (R6b, closes D-16) β THE TENANT HANDLE, on EVERY scope. This is the line
# D-16's exit condition names, and without it the rest of the fix is inert: the handler
# falls back to the module-global `core.store`, which is tenant #0's repo, so a Nurilab
# user's documents, cohorts and β through `_tops` β their whole customer/product table
# workspace were written into Royal Imports' dataset. Note it is set for the CUSTOMER
# and PRODUCT branches too, not only `ut_`: those two never passed a scoped `table`, so
# they were the ones actually resolving to tenant #0 on every request.
st=session.runtime,
fallback_ws=None, seen_ids={}, **kw)
#: The surfaces the ONE grid serves. `cohort` is the same table over hand-curated SETS rather
#: than over the whole scoped pool β `app.py:page_cohort` calls the same `_table_grid` with
#: `cohort_mode=True, scope_key='cohort'`. `product` (wave 16 C-TOPIC) is the SKU table:
#: same engine, its own pool, field contract and workspace BUCKET (routes_products).
_SCOPES = ("customer", "cohort", "product")
def _scope_or_400(raw):
"""β REFUSE AN UNKNOWN SCOPE, never default it. A typo silently served as `customer` would
hand a user the whole book on a page they opened to see one cohort β the widening direction,
which is the one that must fail closed.
Wave 18 (C3-UT): a `ut_`-prefixed scope names a USER TABLE and passes through here β
existence and the per-table wall are enforced by `routes_tables.ut_assembly` (404/403),
which every consumer of such a scope goes through. Passing an unknown ut key therefore
still fails closed, just one layer down where the store can actually be consulted."""
scope = (raw or "customer").strip().lower()
if scope.startswith("ut_"):
return scope
if scope not in _SCOPES:
raise err(400, "bad_scope",
f"scope must be one of {', '.join(_SCOPES)} β refusing to guess")
return scope
@router.get("/workspace")
def workspace(scope: str = "customer",
session: Session = Depends(require_session)):
"""The durable table workspace for this session: views, fields, overlays, folders.
β WAVE 21 (C4): the wall is TOPIC-SHAPED, so the DEPENDENCY is session-only and each scope
asserts its own gate below. The old `module_gate("customer_data")` dependency 403'd a
`ut_*` workspace for any tenant whose catalogue omits the customer module (loopable/
nurilab/gtmlab ship modules w/o it) β and the client then fell back to the shared
localStorage bucket + demo data, which is exactly the "new database shows RI's customer
fields" defect. A user table's real wall is `user_tables.may_open`, enforced inside
`ut_assembly` (404/403, one layer down where the store can be consulted).
β WHY THIS EXISTS (S1βS2, 2026-07-30 β an X2 AMENDMENT, see the split doc). X2 fixes
`/customers` at the exact shape `verify_fields_contract.py` referees, so the workspace cannot
ride along in it without forking the thing that gate keeps single-sourced. Without a
workspace route the standalone shell's write path would be WRITE-ONLY: events persist
server-side and nothing reads them back on reload, so a saved view looks lost to the user
even though the store has it. This route closes that read-back gap at its own URL.
`allowed_pids` is passed so a SHARED view's `memberPids` are re-scoped to THIS reader β the
wave-9 leak rule. Omitting it would hand a Fisch-scoped user a member list built by a
full-access user.
"""
from core import grid_events
from routes_customers import grid_assembly
scope = _scope_or_400(scope)
# Wave 21 C4 β the per-scope gate the dependency no longer asserts: customer/cohort need
# the customer module; product asserts PRODUCT_MODULE in its branch; ut_* needs only a
# session (its wall is the table's own, inside the assembly).
if not (scope == "product" or scope.startswith("ut_")):
session.require(MODULE)
# ββ Wave 18 C3-UT: a USER TABLE'S workspace β the third topic through the one wire. The
# storage key has no (bu, agent) because a user table has no Odoo scope; per-user by the
# table's own wall (creator or admin, `user_tables.may_open`).
if scope.startswith("ut_"):
from routes_tables import ut_assembly
storage_key = f"{session.tenant}:{scope}:{session.uname}"
try:
# ββ W31-T20 / D-174 β `with_rows=False`. THIS ROUTE RENDERS NO ROW: the grid fetches
# them from `/tables/{key}/rows` or `/odoo-tables/{key}/rows` in the same paint, and
# `useCustomerData.load` fires both calls in one `Promise.all`. Building the table here
# bought nothing and cost everything β it is why `?scope=ut_odoo_gl_lines` answered
# `409 window_required` six times out of six on the live deploy while the ROWS door for
# the same table served a page in ~150 ms, i.e. a grid that could not be opened at all.
g = ut_assembly(session, scope, storage_key=storage_key, with_rows=False)
except grid_events.StoreUnavailable:
raise err(503, "store_unavailable", "the tenant store is unavailable")
workspace = g["workspace"]
workspace["overlays"] = g["ws"].get("overlays") or {}
# The field contract rides the cheap re-read on EVERY topic β see the customer branch
# below for why (a user table's first cohort changes its contract too: `_cohorts(ctx)`
# is scope-parameterized, so `ut_` surfaces have their own sets).
workspace["fields"] = g["fields"]
workspace["measures"] = g["measures"]
workspace["measureSets"] = g["measure_sets"]
# β `g["derived"]`, NOT `{}` (2026-08-04). R9 gave every topic its own cohort sets, and
# `ut_assembly` has been building this topic's cohort CELLS since β but this route threw
# them away, so the Locked-views column arrived on the cheap re-read with permanently
# empty values. A column that exists and can never have one is worse than an absent
# column: it reads as "this record is in no locked view", which is a claim.
workspace["derived"] = {str(pid): cells for pid, cells in (g["derived"] or {}).items()}
workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
try:
from core import users as _users
workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
workspace["userAvatars"] = {k: v for k, v in
_users.avatar_map(tenant=session.tenant).items()
if str(v).startswith("data:image/")}
except Exception:
workspace["userOptions"] = []
workspace["userAvatars"] = {}
workspace["scopeKey"] = scope
# β W31-T20 β R6's SECOND SENTENCE REACHES THE BROWSER. On a read-through grid too large
# for one window the pid set is empty, so cohort membership and a shared view's
# `memberPids` are UNRESOLVED here (both fail closed, and `workspace_wire` already stamps
# `missing` on a shortened cohort). An unannounced empty scope is the silent limit R6
# forbids; this is the announcement, and W31-T22 is the ticket that renders it.
workspace["limits"] = g.get("limits") or []
return {"workspace": workspace}
# ββ Wave 16 C-TOPIC: the PRODUCT surface has its own assembly (own pool, own field
# contract, own workspace BUCKET) and the contract's own storage key. It branches before
# the customer derivation because the customer storage key embeds (bu, agent) while the
# product one is deliberately `all:all` β one product workspace per user (the wave doc's
# C-TOPIC line), since BU narrows the product VALUES, not which workspace you own.
if scope == "product":
from routes_products import MODULE as PRODUCT_MODULE, product_assembly
# β THE GATE CHANGES WITH THE SCOPE (wave 21 C4: the dependency is session-only now,
# so this line IS the product wall β not a second one on top of customer_data).
session.require(PRODUCT_MODULE)
storage_key = f"{session.tenant}:product-list:{session.uname}:all:all"
try:
g = product_assembly(session, scope=scope, storage_key=storage_key)
except grid_events.StoreUnavailable:
raise err(503, "store_unavailable", "the tenant store is unavailable")
workspace = g["workspace"]
workspace["overlays"] = g["ws"].get("overlays") or {}
# R9 made cohorts per-topic, so the PRODUCT surface has its own sets and its own
# first-cohort contract change. Same reason as the customer branch below.
workspace["fields"] = g["fields"]
# The measure channel is EMPTY on this topic (customer-grain descope) β stated
# explicitly so the client's pickers grey rather than guess.
workspace["measures"] = g["measures"]
workspace["measureSets"] = g["measure_sets"]
# `g["derived"]` β the same correction as the user-table branch above, for the same
# reason: `product_assembly` builds this topic's cohort cells and this route discarded
# them. The measure half stays empty on this topic by descope, which is a different
# statement and one the offer already makes.
workspace["derived"] = {str(pid): cells for pid, cells in (g["derived"] or {}).items()}
workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
try:
from core import users as _users
workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
workspace["userAvatars"] = {k: v for k, v in
_users.avatar_map(tenant=session.tenant).items()
if str(v).startswith("data:image/")}
except Exception:
workspace["userOptions"] = []
workspace["userAvatars"] = {}
workspace["scopeKey"] = scope
return {"workspace": workspace}
# The storage key mirrors the host's own convention byte-for-byte (app.py's `_table_grid`
# call sites) with the session's TENANT in the host's hardcoded slot, so localStorage state
# carries across embed β standalone on the same browser.
# Wave 15 R1 β the SAME derivation the pool uses (`routes_customers._team_agent`), so the
# storage key cannot drift from the scope it names. Value-identical for every migrated
# record (verify_perm_scope section D proves the derivation reproduces the legacy scope
# exactly), so nobody's saved localStorage state moves when the migration runs.
import core.perm_scope as perm_scope
team_id, agent = perm_scope.derive_pool_scope(session.user, MODULE)
bu = team_id if team_id is not None else "all"
if scope == "cohort":
storage_key = f"{session.tenant}:cohort:{session.uname}:{bu}"
else:
storage_key = f"{session.tenant}:customer-list:{session.uname}:{bu}:{agent or 'all'}"
# β THE WIRE SHAPE IS THE SHARED PROJECTION (`aios_grid.workspace_wire`), not the store
# shape. The first version returned the store dict with no `storageKey` β and the client
# validator requires one, so the standalone shell DISCARDED the whole workspace: saved views
# never rendered, `cohortMode` never arrived, and the Cohort route silently drew the Customer
# surface. One projection for both servers is the fix that cannot drift.
try:
g = grid_assembly(session, scope=scope, storage_key=storage_key)
except grid_events.StoreUnavailable:
raise err(503, "store_unavailable", "the tenant store is unavailable")
workspace = g["workspace"]
# ADDITIVE to the wire: the caller's own overlay cells. The grid reads overlays from the
# /customers rows, but this route is the cheap read-back a write can be verified against
# (verify_api's overlay probe) without paying the pool call. Per-user by construction β
# `table_workspace` is this session's workspace.
workspace["overlays"] = g["ws"].get("overlays") or {}
# β THE FIELD CONTRACT, 2026-08-04 β and it is NOT decoration.
#
# `fields_from_workspace(ws, cohorts=bool(cohort_lists))` appends the derived "Locked
# views" column ONLY when the caller owns at least one cohort. So a user's FIRST cohort
# CHANGES THE FIELD CONTRACT β and the rows call that used to be the only carrier of
# `fields` is deliberately never re-fetched on a write (it is a 15-minute-cached Odoo
# pull; this route is the cheap re-read). The client therefore had no way to learn about
# that column short of a remount, which is the second half of the owner's "it only shows
# up when I switch modules and come back".
#
# β `g["fields"]` is the SAME list `_payload` serves on `/customers` β same assembly,
# same permission wall (`hidden_keys` already applied) β so the two wires cannot disagree
# about what a column is. Taking it from anywhere else would fork the contract that
# verify_fields_contract.py exists to keep single-sourced.
workspace["fields"] = g["fields"]
# ββ the STANDALONE measure channel (owner item 1, 2026-07-31) ββββββββββββββββββββββββββββ
# The embed receives these as top-level render args; standalone lifts them off THIS route
# (useCustomerData merges them into the payload slots the grid already reads). They ride
# the workspace rather than /customers because a durable write re-reads exactly this route
# (WORKSPACE_STALE), so a new measure column populates without refetching the heavy pool.
workspace["measures"] = g["measures"]
workspace["measureSets"] = g["measure_sets"]
# Derived cells (cohort column + measure columns), keyed by pid. JSON object keys are
# strings; the client indexes with String(pid).
workspace["derived"] = {str(pid): cells for pid, cells in g["derived"].items()}
# Who is looking (permissions verdicts) + the assignee choices for `user`-typed columns β
# the two other host-only render args the shell was missing (fail-closed without them:
# restricted fields uneditable, assignee picker empty).
workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
try:
from core import users as _users
workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
# Wave 14 C-AVATAR β the options vocabulary's companion: display name -> data URL.
# Absent entries fall back to the client's initials disc; a non-data: value is
# dropped (defence in depth beside the write-side wall in routes_auth).
workspace["userAvatars"] = {k: v for k, v in
_users.avatar_map(tenant=session.tenant).items()
if str(v).startswith("data:image/")}
except Exception:
workspace["userOptions"] = []
workspace["userAvatars"] = {}
# THE SURFACE STAMP β a MIRROR of the host's own (`_table_grid`'s hide/cohort stamps).
# β Deliberately NOT `hideViews`: `cohortMode` is what swaps the Views panel for the cohort
# list, and two switches for one behaviour would drift.
workspace["scopeKey"] = scope
if scope == "cohort":
workspace["cohortMode"] = True
# The create pane offers "this cohort table only" (default) vs "all customer tables".
workspace["scopeChoice"] = True
return {"workspace": workspace}
# βββββββββββββββββββββββββββββββββββββββββββββββ the TIME-SERIES channel (C-TS, 2026-08-02)
_TS_BUCKETS = ("week", "month", "quarter", "year")
_TS_MAX_BUCKETS = 120
_TS_MAX_FIELDS = 12
_TS_MAX_PIDS = 5000
_TS_MAX_LAST_N = 120
#: C-TSWIN (wave 14): sliding windows cost ONE aggregate query per (metric, bucket) β the
#: price of "bucket N == the grid cell at bucket N's end". The product cap keeps a legal
#: 120-bucket Γ 12-metric ask from becoming 1,440 queries in one request; typical panels
#: (12 Γ 3) sit two orders of magnitude under it. Dated amendment in the split doc.
_TS_MAX_CELLS = 720
def _ts_start_of(bucket, d):
"""The calendar START of the bucket holding `d` (week = Monday, the vocabulary rule)."""
if bucket == "week":
return d - dt.timedelta(days=d.weekday())
if bucket == "month":
return d.replace(day=1)
if bucket == "quarter":
return d.replace(month=((d.month - 1) // 3) * 3 + 1, day=1)
return d.replace(month=1, day=1)
def _ts_next(bucket, d):
if bucket == "week":
return d + dt.timedelta(days=7)
if bucket == "year":
return d.replace(year=d.year + 1)
step = 1 if bucket == "month" else 3
m = d.month + step
return dt.date(d.year + (m - 1) // 12, (m - 1) % 12 + 1, 1)
def _ts_prev(bucket, d):
if bucket == "week":
return d - dt.timedelta(days=7)
if bucket == "year":
return d.replace(year=d.year - 1)
step = 1 if bucket == "month" else 3
y, m = d.year, d.month - step
while m < 1:
m += 12
y -= 1
return dt.date(y, m, 1)
def _ts_label(bucket, start):
if bucket == "week":
return f"Wk of {start.strftime('%b %d')}"
if bucket == "month":
return start.strftime("%b %Y")
if bucket == "quarter":
return f"Q{(start.month - 1) // 3 + 1} {start.year}"
return str(start.year)
@router.post("/grid/timeseries")
def grid_timeseries(body: dict = Body(default=None), scope: str = "customer",
session: Session = Depends(module_gate(MODULE))):
"""Pooled measure values per calendar bucket β the time-series view's data channel.
C-TSWIN (wave 14, ruling R1): AS-OF semantics. Each metric's OWN stored window is
re-resolved per bucket with `today := min(bucket end, real today)` β `ytd` is cumulative
from Jan 1, `last_90_days` trailing, `all_time` cumulative ever; bucket N equals what the
grid's measure column would show if today were bucket N's end. Fixed-range (`custom`)
windows cannot slide and are dropped per field as `window_fixed`. Rows carry
`window: {kind, label}` so a cumulative row cannot be misread as periodic, and there is
NO `total` column β sliding windows overlap, so a sum of columns would double-count.
The client sends the pids its view currently matches (the filter IS the scope); the server
intersects them with the session's book, so the request can only ever NARROW. Values are
POOLED aggregates computed by the semantic layer's own expression (the additivity law: an
average is computed at pool grain, never averaged over per-customer answers). A bucket
with no rows is 0 for a sum/count and null for anything else; a bucket that has not
STARTED yet is null for every kind β an unstarted period's YTD is unanswered, not 0.
"""
from core import measure_resolve
from harness import windows as _wn
from routes_customers import _pool_stamp, _team_agent, allowed_pids
# Wave 16 C-TOPIC: the channel is CUSTOMER-GRAIN (measure_resolve's own grain), so the
# product surface is refused in words rather than answered with an id-space accident β
# product pids are CRC32 hashes and would mostly fall outside the customer book anyway,
# but "mostly" is not a wall. The client hides the mode for this topic; this is the
# server's half of the same refusal.
_sc = _scope_or_400(scope)
if _sc == "product" or _sc.startswith("ut_"):
raise err(400, "bad_timeseries",
"this surface has no time-series channel β measures are customer-grain")
body = body or {}
bucket = body.get("bucket")
if bucket not in _TS_BUCKETS:
raise err(400, "bad_timeseries", "bucket must be one of week, month, quarter, year")
raw_fields = body.get("fields")
if not isinstance(raw_fields, list) or not raw_fields:
raise err(400, "bad_timeseries", "fields must be a non-empty list of field keys")
if len(raw_fields) > _TS_MAX_FIELDS:
raise err(400, "bad_timeseries", f"at most {_TS_MAX_FIELDS} fields per request")
raw_pids = body.get("pids")
if not isinstance(raw_pids, list) or not raw_pids:
raise err(400, "bad_timeseries", "pids must be a non-empty list")
if len(raw_pids) > _TS_MAX_PIDS:
raise err(400, "bad_timeseries", f"at most {_TS_MAX_PIDS} pids per request")
try:
wanted = {int(p) for p in raw_pids}
except (TypeError, ValueError):
raise err(400, "bad_timeseries", "pids must be integers")
pool = wanted & {int(p) for p in allowed_pids(session)}
if not pool:
# 403, not an empty 200: the caller asked about customers outside their book, and an
# all-zero series over nobody would read as "no activity", not "not yours".
raise err(403, "out_of_scope", "none of those customers are in your book")
span = body.get("span")
if not isinstance(span, dict):
raise err(400, "bad_timeseries", "span must be {'lastN': n} or {'from': .., 'to': ..}")
today = time.strftime("%Y-%m-%d")
t = dt.date.fromisoformat(today)
n = span.get("lastN")
starts = []
if n is not None:
if not isinstance(n, int) or isinstance(n, bool) or not (1 <= n <= _TS_MAX_LAST_N):
raise err(400, "bad_timeseries", f"lastN must be 1..{_TS_MAX_LAST_N}")
cur = _ts_start_of(bucket, t)
starts = [cur]
for _ in range(n - 1):
cur = _ts_prev(bucket, cur)
starts.append(cur)
starts.reverse()
else:
try:
d_from = dt.date.fromisoformat(str(span.get("from")))
d_to = dt.date.fromisoformat(str(span.get("to")))
except (TypeError, ValueError):
raise err(400, "bad_timeseries", "span.from/to must be ISO dates (YYYY-MM-DD)")
if d_from > d_to:
d_from, d_to = d_to, d_from
cur = _ts_start_of(bucket, d_from)
while cur <= d_to:
starts.append(cur)
if len(starts) > _TS_MAX_BUCKETS:
raise err(400, "bad_timeseries",
f"that span is more than {_TS_MAX_BUCKETS} {bucket} buckets - "
f"narrow it")
cur = _ts_next(bucket, cur)
if not starts:
raise err(400, "bad_timeseries", "the span holds no buckets")
ends = [_ts_next(bucket, s) - dt.timedelta(days=1) for s in starts]
rt = session.runtime
if not rt.available():
raise err(503, "store_unavailable", "the tenant store is unavailable")
import modules.customer_data as cl_mod
# Read-only route: it must not consume a one-shot label-correction ack the browser has
# not seen yet (the same rule event validation follows).
ws = cl_mod.table_workspace(session.uname, consume_corrections=False)
fdefs = ws.get("fields") or {}
keys, dropped, seen = [], [], set()
for k in raw_fields:
k = str(k or "")
if not k or k in seen:
continue
seen.add(k)
fd = fdefs.get(k)
if not isinstance(fd, dict) or not isinstance(fd.get("measure"), dict):
# Named, never silent: v1 eligibility is measure-backed fields only (C-TS).
dropped.append({"field": k, "reason": "not_a_measure_field"})
continue
keys.append(k)
# β‘ WAVE 17 R9 (amendment 2026-08-03, GRID's cross-fence ask) β A SHEET WITH NO MEASURE
# FIELDS IS NO LONGER A 400. It answers with the BUCKET GRID and no rows.
#
# Why this is the honest direction and not a loosening: the bucket starts and their labels
# are SERVER math (`_ts_start_of` / `_ts_next` / `_ts_label`), and R9 has the client
# synthesizing snapshot rows for preset columns that have no window to slide. Those rows
# must be painted under the SAME headings as everything else, so the client needs the
# columns even when the server has no series to put in them. Refusing the whole request
# meant the panel could offer nothing at all on this tenant β the shipped contract has zero
# measure-backed presets (`aios_grid.py`: "this branch is currently MEMBERLESS"), which is
# what item 5 is actually about.
#
# Nothing is invented by this: `rows` is empty and `meta.dropped` NAMES every field and why.
# A 400 is still returned for a request that is malformed (bad bucket, bad span, no fields
# at all) β this only stops treating "I asked about columns you cannot serve" as an error.
mfields, slid_keys = [], []
for k in keys:
m = dict(fdefs[k]["measure"])
if (m.get("window") or {}).get("kind") == "custom":
# C-TSWIN: a fixed date range cannot slide across buckets β named, never silent,
# the same posture as not_a_measure_field.
dropped.append({"field": k, "reason": "window_fixed"})
continue
mfields.append({"key": k, "measure": m})
slid_keys.append(k)
# Same amendment as above: every metric being fixed-range is a sheet with no SERIES, not a
# broken request. The columns still stand, and `window_fixed` still names each refusal.
if len(starts) * len(mfields) > _TS_MAX_CELLS:
raise err(400, "bad_timeseries",
"that ask is too wide - narrow the span or pick fewer metrics")
team_id, agent = _team_agent(session)
stamp = _pool_stamp(rt, team_id, agent)
problems = []
bucket_bounds = [(s.isoformat(), e.isoformat()) for s, e in zip(starts, ends)]
# No slidable metrics = nothing to resolve. Skipping the call rather than asking the
# resolver about an empty list keeps the memo free of a meaningless key.
answers = measure_resolve.series_values(
mfields, bucket, bucket_bounds,
team_id, frozenset(pool), today, stamp, rt.series_memo,
on_error=lambda tag, e: problems.append(str(e)[:200])) if mfields else {}
columns = []
for s, e in zip(starts, ends):
col = {"key": s.isoformat(), "label": _ts_label(bucket, s),
"from": s.isoformat(), "to": e.isoformat()}
if e > t:
col["partial"] = True
columns.append(col)
rows = []
for k in slid_keys:
ans = answers.get(k)
if ans is None:
dropped.append({"field": k, "reason": "unresolvable"})
continue
vals_by = ans.get("values") or {}
agg_kind = str(ans.get("agg") or "sum")
zero_fill = agg_kind in ("sum", "count")
vals = []
for s in starts:
if s > t:
# C-TSWIN: an unstarted period is unanswered for EVERY agg kind β a future
# month's YTD zero-filled to 0 would read as "the year reset".
vals.append(None)
else:
vals.append(vals_by.get(s.isoformat(), 0 if zero_fill else None))
wspec = (fdefs[k].get("measure") or {}).get("window")
wnorm = _wn.normalize(wspec) or {}
rows.append({"field": k, "label": str(fdefs[k].get("label") or k)[:120],
"agg": agg_kind, "values": vals,
"window": {"kind": str(wnorm.get("kind") or ""),
"label": _wn.label(wspec)}})
meta = {"pool": len(pool), "today": today, "bucket": bucket}
if dropped:
meta["dropped"] = dropped
if problems:
meta["problems"] = problems[:5]
return {"columns": columns, "rows": rows, "meta": meta}
#: C-CAL caps (wave 17, owner item 9 / ruling R4). A month of days, a handful of metrics.
#: Every one of these is a 400 WITH ITS REASON, never a silent trim: a calendar that quietly
#: answered 20 of the 31 days asked about would paint eleven blank cells that look like days
#: with no activity.
_CAL_MAX_GROUPS = 31
_CAL_MAX_FIELDS = 6
_CAL_MAX_CELLS = 186
@router.post("/grid/calendar_metrics")
def grid_calendar_metrics(body: dict = Body(default=None), scope: str = "customer",
session: Session = Depends(module_gate(MODULE))):
"""Per-DAY measure values with each metric's own window slid to that day (C-CAL / R4).
β WHY THIS EXISTS AT ALL. The calendar's summary cells used to aggregate the row VALUES the
grid already held β which for a measure column means "every member's YTD **as of today**",
summed and printed under a date in March. The number was arithmetically fine and semantically
a lie: it answered a question about today while sitting in a cell labelled with another day.
R4: a metric in a day cell is computed AS OF THAT DAY.
The shape differs from the time-series channel in exactly one way, and it is the reason this
is a separate route rather than a parameter: **every group carries its OWN pid set**. A
calendar day holds the records the date field placed there, so day-to-day the subject
changes. One `allowed_pids` for the whole request β the TS channel's shape β would compute
each day over everybody, which is a different question again.
Static (non-measure) fields are NOT served here. They have no window to slide, so the
client's own per-day aggregation over row values stays correct for them; sending them would
invite a second implementation of arithmetic that already works.
"""
from core import measure_resolve
from harness import windows as _wn
from routes_customers import _pool_stamp, _team_agent, allowed_pids
# The same refusal the TS channel makes, for the same reason: measures are customer-grain
# and product pids are CRC32 hashes of SKU codes. "Mostly outside the book" is not a wall.
_sc_cal = _scope_or_400(scope)
if _sc_cal == "product" or _sc_cal.startswith("ut_"):
raise err(400, "bad_calendar_metrics",
"the product surface has no measure channel yet β measures are customer-grain")
body = body or {}
raw_groups = body.get("groups")
if not isinstance(raw_groups, list) or not raw_groups:
raise err(400, "bad_calendar_metrics",
"groups must be a non-empty list of {key: 'YYYY-MM-DD', pids: [...]}")
if len(raw_groups) > _CAL_MAX_GROUPS:
raise err(400, "bad_calendar_metrics",
f"at most {_CAL_MAX_GROUPS} days per request (one month)")
raw_fields = body.get("fields")
if not isinstance(raw_fields, list) or not raw_fields:
raise err(400, "bad_calendar_metrics", "fields must be a non-empty list of field keys")
if len(raw_fields) > _CAL_MAX_FIELDS:
raise err(400, "bad_calendar_metrics", f"at most {_CAL_MAX_FIELDS} metrics per request")
if len(raw_groups) * len(raw_fields) > _CAL_MAX_CELLS:
raise err(400, "bad_calendar_metrics",
"that ask is too wide - fewer days or fewer metrics")
book = {int(p) for p in allowed_pids(session)}
groups, seen_days = [], set()
for g in raw_groups:
if not isinstance(g, dict):
raise err(400, "bad_calendar_metrics", "every group must be an object")
day = str(g.get("key") or "")
try:
d = dt.date.fromisoformat(day)
except (TypeError, ValueError):
raise err(400, "bad_calendar_metrics",
"every group key must be an ISO date (YYYY-MM-DD)")
if day in seen_days:
raise err(400, "bad_calendar_metrics", f"day {day} appears twice")
seen_days.add(day)
raw_pids = g.get("pids")
if not isinstance(raw_pids, list):
raise err(400, "bad_calendar_metrics", "every group needs a pids list")
try:
wanted = {int(p) for p in raw_pids}
except (TypeError, ValueError):
raise err(400, "bad_calendar_metrics", "pids must be integers")
# NARROW-ONLY, per group. The client sends what its calendar placed; the server can
# only ever remove from that, never add.
groups.append((day, d, frozenset(wanted & book)))
if sum(len(p) for _, _, p in groups) > _TS_MAX_PIDS:
raise err(400, "bad_calendar_metrics",
f"at most {_TS_MAX_PIDS} customer references per request")
rt = session.runtime
if not rt.available():
raise err(503, "store_unavailable", "the tenant store is unavailable")
import modules.customer_data as cl_mod
ws = cl_mod.table_workspace(session.uname, consume_corrections=False)
fdefs = ws.get("fields") or {}
keys, dropped, seen = [], [], set()
for k in raw_fields:
k = str(k or "")
if not k or k in seen:
continue
seen.add(k)
fd = fdefs.get(k)
if not isinstance(fd, dict) or not isinstance(fd.get("measure"), dict):
# The TS channel's vocabulary, deliberately reused rather than re-coined: the client
# already knows how to say these three words to a reader.
dropped.append({"field": k, "reason": "not_a_measure_field"})
continue
if ((fd["measure"].get("window") or {}).get("kind") == "custom"):
dropped.append({"field": k, "reason": "window_fixed"})
continue
keys.append(k)
if not keys:
raise err(400, "bad_calendar_metrics",
"none of the requested fields are measure fields with a window that can "
"slide to a day")
today = time.strftime("%Y-%m-%d")
t = dt.date.fromisoformat(today)
team_id, agent = _team_agent(session)
stamp = _pool_stamp(rt, team_id, agent)
problems = []
values = {k: {} for k in keys}
for day, d, pids in groups:
# A day that has not happened is unanswered for EVERY aggregate kind β the unstarted
# bucket law at day grain. Answering 0 would say "we sold nothing", which is a claim
# about a day nobody has lived through yet.
if d > t or not pids:
for k in keys:
values[k][day] = None
continue
answers = measure_resolve.series_values(
[{"key": k, "measure": dict(fdefs[k]["measure"])} for k in keys],
"day", [(day, day)], team_id, pids, today, stamp, rt.series_memo,
on_error=lambda tag, e: problems.append(str(e)[:200]))
for k in keys:
ans = answers.get(k)
if ans is None:
values[k][day] = None
continue
vals_by = ans.get("values") or {}
zero_fill = str(ans.get("agg") or "sum") in ("sum", "count")
values[k][day] = vals_by.get(day, 0 if zero_fill else None)
for k in keys:
if all(v is None for v in values[k].values()):
# Every day unanswerable is a FIELD-level failure, and saying so is the difference
# between "no activity that month" and "this metric could not be computed".
dropped.append({"field": k, "reason": "unresolvable"})
out = {"values": values, "today": today,
"windows": {k: {"kind": str((_wn.normalize(
(fdefs[k].get("measure") or {}).get("window")) or {}).get("kind") or ""),
"label": _wn.label((fdefs[k].get("measure") or {}).get("window"))}
for k in keys}}
if dropped:
out["dropped"] = dropped
if problems:
out["problems"] = problems[:5]
return out
@router.post("/grid/events")
def grid_events_route(body: dict = Body(default=None),
session: Session = Depends(require_session)):
"""`{events: [<component event objects, verbatim>]}` β `{results, doc?, toast?}`.
Wave 21 C4: session-only dependency, per-scope gate below β the write door must admit the
same sessions the read door (`/workspace`) admits, or a tenant without the customer module
can SEE its own user tables and not write to them."""
from core import grid_events
from routes_customers import grid_assembly
events = (body or {}).get("events")
if events is None and isinstance(body, dict) and body.get("type"):
events = [body] # a single event object, the legacy shape
if not isinstance(events, list):
raise err(400, "bad_events", "expected {events: [...]}")
if len(events) > _MAX_EVENTS:
raise err(400, "too_many_events",
f"at most {_MAX_EVENTS} events per request (the client's resend window)")
# Validated with the SAME predicate as the read route. An unrecognised scopeKey used to be
# passed through verbatim, and `core.grid_events` only ever compares it to 'cohort' β so a
# typo degraded silently to customer-scope behaviour on a WRITE. Read and write must agree on
# what a scope is, or the surface you read is not the surface you wrote.
scope = _scope_or_400((body or {}).get("scopeKey"))
# Wave 21 C4 β same per-scope gate as /workspace (read and write doors must agree).
if not (scope == "product" or scope.startswith("ut_")):
session.require(MODULE)
# This assembly validates the write; it is not a payload the browser will render. Leave
# one-shot field-name correction acks queued for the subsequent /workspace refresh.
# Wave 16 C-TOPIC: the PRODUCT topic gets the product assembly β product field contract,
# product pids, and (below) the product TABLE OPS, so a product event is validated against
# and lands in the product bucket. The measure/cohort context is honestly EMPTY there:
# `clean_measure_field` refuses measure creates on this surface by construction (the
# customer-grain descope), which is the fail-closed shape, not an accident.
if scope == "product":
from routes_products import MODULE as PRODUCT_MODULE, product_assembly
session.require(PRODUCT_MODULE) # the product wall (dependency is session-only, C4)
g = product_assembly(session, consume_corrections=False)
elif scope.startswith("ut_"):
# Wave 18 C3-UT β the user-table wall (creator/admin) is inside the assembly; the
# measure/cohort context is honestly EMPTY (customer-grain machinery, no meaning here).
# ββ WAVE 30 / W30-T30 (owner item 2: *"when I click hide fields it crashβ¦ no matter the
# size"*). This used to be `ut_assembly(...)`, which builds the whole table to validate a
# write it then throws away: `scoped_pool` allocates a dict per row and sorts them, so ONE
# hide-fields checkbox rebuilt ~33k order rows before the event was even dispatched.
# β NOTHING IS VALIDATED LESS. The comment above still holds β this assembly is the
# permission wall and the admission context β and `ut_write_ctx` returns the SAME six keys
# this route reads, with a pid set derived from exactly the row ids `scoped_pool` would
# have kept. What it does not do is materialise the rows nobody here looks at.
from routes_tables import ut_write_ctx
g = ut_write_ctx(session, scope)
# ββ W31-T20 / D-174 β A WRITE THAT NEEDS THE ROW SET REFUSES OUT LOUD, and this is the
# half that is easy to skip because the code already "fails closed" without it. When a
# read-through grid's population exceeds one window the pid set is EMPTY, and every
# pid-bearing handler in `grid_events` then answers `False` β `overlay_patch` and
# `add_to_list` both `return False` for a pid not in `allowed_pids`. On the wire that is
# `rerender: false` and HTTP 200: a write the user watched succeed, that did nothing
# ([[lost-write-looks-like-failed-read]]). Schema-only events β hide a field, save a view,
# rename a column β name no pid and are untouched, which is D-170.
# β THE TEST IS THE EVENT'S OWN KEYS, not a kind list: a new pid-bearing event type would
# otherwise inherit the silent no-op the day it is added.
if g.get("limits"):
named = [e for e in events
if isinstance(e, dict) and (e.get("pid") is not None or e.get("pids"))]
if named:
lim = g["limits"][0]
raise err(409, "pid_scope_unresolved",
f"this database is served through the connector mirror and its rows "
f"cannot be listed in one window, so a change addressed to particular "
f"records ({len(named)} of {len(events)} here) cannot be admitted β "
f"{lim.get('cause') or 'the row set is unresolved'}. "
f"{lim.get('recommendation') or ''}".strip())
else:
g = grid_assembly(session, scope=scope, consume_corrections=False)
# β THE MEASURE CONTEXT IS NOT OPTIONAL (2026-07-31). Without `measure_offer`,
# `clean_measure_field` had an empty admission list and every measure-column create over
# HTTP was silently refused; without `measure_keys`, `clean_filter_tree` stripped every
# measure CONDITION out of a saved view. The embed always passed these; the API adapter
# simply had not been given them β the standalone shell could read measures it could
# never write.
ctx = _ctx(session, g["fields"], g["pids"], scope_key=scope,
measure_keys=frozenset(m["key"] for m in g["measures"]),
resolved_ids=frozenset(g["measure_sets"]),
cohort_ids=frozenset(c["id"] for c in g["lists"]),
measure_offer=tuple(g["measures"]),
visible_views=tuple(g["views"]))
# Per-event results so the client can tell which of a batch landed β the component's own
# bridge has no response channel at all, so this is strictly more than the embed gets.
results = []
try:
for one in events:
eid = str(one.get("id") or "") if isinstance(one, dict) else ""
rerender = grid_events.handle_one(one, ctx)
results.append({"id": eid, "rerender": bool(rerender)})
except grid_events.StoreUnavailable:
raise err(503, "store_unavailable",
"the tenant store is unavailable β none of your changes were saved")
out = {"results": results, "rerender": any(r["rerender"] for r in results)}
if ctx.out.doc is not None:
# β C4 / W30-T27 β `docPayload` IS THE NAME THE CLIENT ALREADY DECLARES. `types.ts` has
# carried `docPayload?: {pid, docId, name, mime, data_b64}` since C5, and `Documents.tsx`
# matches it against the fetch it is waiting on β while this route has been answering
# `doc`, which `apiBridge.ts` deliberately drops. One object, emitted under the name the
# consumer looks for, so F's wiring needs no translation step to get wrong.
# β `doc` stays for one wave: nothing in the client reads it, but a gate might, and
# removing a key to save six bytes is not worth a red nobody predicted.
out["doc"] = out["docPayload"] = ctx.out.doc
if ctx.out.toast is not None:
out["toast"] = ctx.out.toast
# ββ β owner item 2 (2026-08-03): THE NEW MEASURE COLUMN'S VALUES, ONE ROUND TRIP SOONER ββ
#
# Creating a measure column cost the browser TWO sequential trips before a single number
# appeared: this one to persist the field, then a whole `/workspace` to compute it. The
# second cannot start until the first lands (the resolver reads the PERSISTED field), so the
# wait was structural, not slow code β the owner's "it takes some time for the data to
# populate". The values are computed here instead, immediately after the write, and ride
# this response.
#
# β IT COSTS NOTHING EXTRA TO COMPUTE. The expensive part is one DuckDB aggregate over the
# book, and `rt.measure_memo` is keyed on (pool stamp, scope, pool, measure, window) β so
# the `/workspace` re-read that still follows HITS the memo instead of doing this work. The
# query happens once either way; only its position moved.
#
# β NARROW ON PURPOSE. Gated to an actual measure-column write, so an overlay edit or a
# cohort add β the overwhelming majority of events β never pays for a second assembly.
#
# β AND IT IS A SHORTCUT, NOT A PATH. Any failure is swallowed: `WORKSPACE_STALE` still
# fires from `rerender`, and the re-read still delivers these values exactly as it does
# today. Nothing depends on this having worked.
if out["rerender"] and scope in ("customer", "cohort") and any(
isinstance(e, dict) and e.get("type") == "field_upsert"
and str(((e.get("field") or {}) if isinstance(e.get("field"), dict) else {})
.get("key") or "").startswith("measure_")
for e in events):
try:
fresh = grid_assembly(session, scope=scope, consume_corrections=False)
out["derived"] = {str(pid): cells for pid, cells in fresh["derived"].items()}
except Exception:
pass
# β NOTHING IS INVALIDATED HERE, on purpose. The runtime cache holds ONLY the scope-shaped
# Odoo pool (see `routes_customers._pool_rows`), and no event on this route can change an
# Odoo column β Odoo is read-only. Everything an event DOES change (overlays, fields, views,
# folders, cohorts) is re-read from the store on the next request. An earlier version cleared
# `pool_cache` after an `overlay_patch`, which threw away an expensive Odoo pull to refresh
# data that was never in it.
return out
|