diff --git "a/api/routes_customers.py" "b/api/routes_customers.py" --- "a/api/routes_customers.py" +++ "b/api/routes_customers.py" @@ -1,834 +1,834 @@ -"""routes_customers.py — X2's read + write of the customer table, BU-SCOPED (EXIT-3b / EXIT-2a). - -Two things happen here that did not happen in the pre-wave `main.py`: - - 1. **ROWS ARE SCOPED TO THE SESSION.** The old `/api/customers` served `cl.pool()` — the whole - book, to anyone holding the shared APP_PASSWORD. Now the pool is built with - `(team_id, agent_name)` derived from the USER RECORD, so a Royal-only user never receives a - Fisch row and an agent-linked login never receives another rep's book. The scope is applied - at the QUERY, not as a post-filter, so there is no moment at which the other BU's rows exist - in this response. - - 2. **THE OVERLAY FORK IS GONE.** `aios-web/api/data/overlay.json` was a SECOND writable home - for the same user-owned fields the Streamlit app keeps in the tenant store — two truths, and - whichever process you asked last was right. Reads and writes now both go through - `modules.customer_data`'s table-workspace functions: ONE store (C1c, ARCHITECTURE §1a rule 3). - -⚠ ONE STORE IS NOT YET ONE CACHE (strangler-period, booked honestly). `core.store.get()` is -cache-first per PROCESS, so a write from the Streamlit container is invisible to a running API -container until its cache is refreshed, and vice versa. Deleting the fork removes the second -SOURCE OF TRUTH; it does not make the two runtimes coherent. The fix is X4/Postgres (task C-4, -owner-blocked on B-3), and no test here may claim read-your-writes ACROSS runtimes — a TestClient -proof is single-process and would report green on exactly the thing that is still broken. -""" -import time - -from fastapi import APIRouter, Body, Depends - -import scope_cache -from deps import Session, err, module_gate, perms - -router = APIRouter(prefix="/api/v1") - -#: The surface these routes serve. Both legs are gated on it, so a user without the grant gets a -#: 403 rather than an empty table that looks like "you have no customers". -MODULE = "customer_data" - -_CACHE_TTL = 900 # the pool build is slow (Odoo + reconciliation); 15 min, as before - - -def _pool_rows(session: Session): - """The reconciled Odoo pool for this session's SCOPE — the slow part, and the only part that - may be shared between users. - - ⛔ WHAT MAY BE CACHED HERE, AND WHY THE LINE IS EXACTLY HERE. The cache key is - `(team_id, agent)` and the cached value is the raw pool: Odoo-source columns only. That is - genuinely scope-shaped — two users with the same BU and the same book are asking the same - question, and `cl.pool()` is expensive (an Odoo pull plus reconciliation). - - The FULL PAYLOAD is NOT cacheable on this key, and caching it here was a real defect I shipped - and then removed. `fields` comes from `fields_from_workspace(ws)` — that user's own `custom_` - and `measure_` columns — and every overlay cell comes from `ws['overlays']`; the table - workspace is read as `data[username]` and written as `patch_table_overlay(uname, …)`, i.e. - PER USER. So two Royal-only users with no agent link share `(6, None)` and the second one - would have been served the FIRST one's private notes and private columns. The scope key was - right for the pool and wrong for everything wrapped around it. - - It survived a green 129-check battery because every fixture user had a DISTINCT - `(team_id, agent)` pair, so no two of them ever collided on the key — the test set could not - express the bug. `verify_api.py` now carries a same-scope second user for exactly this. - """ - rt = session.runtime - team_id, agent = _team_agent(session) - return _pool_for(rt, team_id, agent) - - -def _pool_for(rt, team_id, agent): - """The cached pool for an explicit scope — session-free so the prewarm thread and the - stale-refresh path can call it. STALE-WHILE-REFRESH (scope_cache): once a copy exists no - request blocks on the 10–30s Odoo rebuild again; only a scope's FIRST-ever build does. - - DEBT-2 (2026-08-04): while the tenant's RESOLVED Odoo source is PAUSED this path never - goes live — it serves the in-process copy at any age, else the persisted pause-time - snapshot (restart-safe), else answers 503. Never a WIDER scope's snapshot: handing a - scoped user the consolidated rows would widen their book, which is worse than an error.""" - import modules.customer_data as cl - import routes_keychain - - key = ("pool", team_id, agent) - - if routes_keychain.odoo_paused(rt): - hit = rt.pool_cache.get(key) - if hit: - return hit[1] - snap = routes_keychain.load_pool_snapshot(rt, team_id, agent) - if snap is not None: - rt.pool_cache[key] = snap # seed, so the memo stamps stay coherent - return snap[1] - raise err(503, "connector_paused", - "this data source is paused and no snapshot exists for your scope — " - "an admin can resume it under Settings → Connectors") - - def _build(): - # ⚠ THE SCOPE GOES INTO THE BUILDER. `pool(agent_name, team_id)` is the same reconciled - # builder the Streamlit page uses — passing the scope here is what makes the isolation a - # property of the QUERY instead of a filter someone can forget to apply downstream. - return cl.pool(agent, team_id) - - def _evict(): - # Bounded: a scope cache that only ever grows is a memory leak in a shared process. - if len(rt.pool_cache) > 16: - for stale in sorted(rt.pool_cache, key=lambda k: rt.pool_cache[k][0])[:8]: - rt.pool_cache.pop(stale, None) - - return scope_cache.get(rt.pool_cache, key, _CACHE_TTL, _build, _evict) - - -def warm_default(rt): - """Boot prewarm: the consolidated pool `(None, None)` — the scope every admin and every - all-BU account lands on. Called from main.py's prewarm thread only.""" - _pool_for(rt, None, None) - - -def _team_agent(session: Session): - """The `(team_id, agent)` this session's POOL is built with — ONE derivation point, used by - `_pool_rows` and `grid_assembly` alike, so the cache key and the query can never disagree. - - ⛔ WAVE 15 R1: THIS NOW COMES FROM THE PERMANENT FILTER (C-PERM amendment 3). `team_id` is - not a row filter — `customer_data._pool_build` passes it into `cust._cust_rev` three times - and into `_cadence_bulk`, so it decides what `rev`/`ly`/`ltm`/`aov`/`status` MEAN. Enforcing - a BU purely as a post-filter would keep the row list right and silently consolidate every - number. So the pushdown survives as a DERIVATION OF the declared filter rather than a second - wall beside it, and `perm_scope.derive_pool_scope` falls back to the legacy `bus`/`agent` - derivation for any record the migration has not reached yet. - """ - import core.perm_scope as perm_scope - return perm_scope.derive_pool_scope(session.user, MODULE) - - -def _pool_stamp(rt, team_id, agent): - """The cached pool's build timestamp — the DATA STAMP in every measure-memo key, so a pool - refresh invalidates the memoised answers exactly when the underlying rows changed.""" - entry = rt.pool_cache.get(("pool", team_id, agent)) - return entry[0] if isinstance(entry, tuple) and entry else 0 - - -def _measure_err(tag, e): - try: - import harness.telemetry as _tel - _tel.error(f"api:{tag}", e) - except Exception: - pass - - -# ══════════════════════════ THE TENANT-WIDE STRATUM, ON THE CUSTOMER TOPIC (W38-T20 / D-425) ══ -# -# ⛔⛔ WHY THIS FILE GREW A SHARED STRATUM AT ALL. `core/shared_overlay.py` has been generic since -# W29-T62, `routes_products.py` has merged it since W30-T36 and `routes_tables.py` since W38-T16 — -# and the CUSTOMER topic had neither a write door nor a read merge. Every user-created column here -# lives in `data[username]`, so two accounts looking at "the same" column are looking at two -# columns. That is fine for a private note and fatal for a ROUTE ORDER: a visit sequence one rep -# can see and their colleague cannot is not a plan, it is a rumour. -# -# ⛔ ONE SPELLING OF THE BUCKET. `modules.customer_data.TABLE_KEY` is the per-user workspace key -# and `shared_overlay.bucket()` derives `__shared` from it. Resolving it here rather than -# writing the string means the write door and the read merge cannot disagree about where the -# values live — which is exactly the failure T16 found on the materialised `ut_*` tables, where -# `patch_shared_cell` wrote into a bucket no reader ever opened. - - -def _shared_key(): - """The store key this topic's per-user AND tenant-wide strata are both named from.""" - import modules.customer_data as cl - return cl.TABLE_KEY - - -def shared_fields(st=None): - """`{field_key: Field}` — the columns this topic shares tenant-wide. - - Unscoped on purpose, exactly as `shared_overlay.fields` is: a shared column's EXISTENCE is - tenant-wide by definition. WHO MAY SEE IT is a separate question, answered one layer up by - `perm_scope.hidden_keys` (the per-field grant wall T16 landed), and WHOSE ROWS by `cells`. - """ - from core import shared_overlay - try: - return shared_overlay.fields(_shared_key(), st=st) - except Exception: # noqa: BLE001 - # Lenient like every other display read: an unreachable store degrades to "nothing is - # shared yet", never to a 500 on a grid that would otherwise render. The WALL does not - # degrade with it — `field_grant_hidden` hides a marked column it cannot resolve. - return {} - - -def shared_cells(pids, st=None): - """`{"": {key: value}}` for the rows named by `pids`, and ONLY those. - - ⛔ `pids` IS THE ROW WALL, PASSED AND NEVER DEFAULTED. `shared_overlay.cells` refuses an - "everything" read by signature for this reason; the set handed in is the one `grid_assembly` - has already narrowed with `apply_row_scope`, so a cell belonging to the other BU has nothing - to attach itself to. - """ - from core import shared_overlay - try: - return shared_overlay.cells(_shared_key(), list(pids or ()), st=st) - except Exception: # noqa: BLE001 - return {} - - -def _merge_shared_fields(fields, defs): - """`fields` PLUS the tenant-wide columns this topic declares — `routes_tables._ut_shared_fields` - on the customer topic. - - ⚠ MERGED BEFORE THE WALL, NEVER AFTER. `hidden_keys` is a TRANSITIVE closure, so it must run - on the WHOLE contract: a formula over a shared column that reads a hidden one sits outside the - closure's reach otherwise and carries the hidden value out wearing a second name. It is also - the only order in which `field_grant_hidden` can ever see the `granted` marker at all — merge - afterwards and the per-field wall is inert while every test still passes. - ⚠ A key the canonical contract already declares WINS. A shared column is an ADDITION to this - database's contract, never a redefinition of a column it already has. - """ - if not defs: - return fields - have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)} - return list(fields or ()) + [dict(f, source="overlay") - for k, f in defs.items() if k not in have] - - -def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "", - consume_corrections: bool = True): - """ONE assembly of this session's grid state, shared by `/customers`, `/workspace` and the - events route (2026-07-31 — the standalone measure gap, owner item 1). - - What it adds over the pre-wave hand-rolled `_payload` loop, and why it replaced it: - - * rows go through `aios_grid.rows_from_pool` — the SAME builder the embedded host uses, - so `lat`/`lon` (the Map view's data), `_created` and every derived column ride each row - by construction instead of by a second loop that drifts. The hand loop was written to - mirror the pre-Map contract and silently dropped the coordinates: the shell's Map view - had nothing to plot ("the Map no longer works"). - * `derived` carries the cohort column's cells AND the measure columns' values, resolved - through `core.measure_resolve` (EXIT-5's extraction of the `_cl_measure_*` family). - Without them every measure column the owner built rendered BLANK in the shell. - * `measures` (the offer) and `measure_sets` (condition answers) are computed here so the - events route can finally validate measure fields/conditions instead of refusing them - (an empty `measure_offer` made `clean_measure_field` reject every create over HTTP). - - Memos live on the TENANT RUNTIME (`rt.measure_memo` / `rt.mset_memo`) — bounded by the - module's own clear-past-cap rule, keyed on (stamp, scope, pool identity, question), nothing - user-shaped in them. - """ - import aios_grid - from core import grid_events, measure_resolve - - import core.perm_scope as perm_scope - - rt = session.runtime - team_id, agent = _team_agent(session) - rows_src = _pool_for(rt, team_id, agent) - # ⛔ THE ROW WALL, APPLIED BEFORE `pids` IS TAKEN. Everything downstream is bounded by that - # frozenset — `allowed_pids` for the workspace, cohort membership, measure resolution — so - # scoping here means a row this account may not see never enters ANY of them, rather than - # being filtered out of one payload and surviving in another. - # - # Evaluated against the CANONICAL field list, not the per-user assembled one, for two - # reasons: the assembled list is not built yet (it needs `pids`), and a permanent filter may - # only ever name a canonical field anyway — `routes_admin._clean_perms` validates it against - # exactly this schema and 400s otherwise. `permits()` denies on anything it cannot answer. - rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, aios_grid.FIELDS) - pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None) - # ⭐ W38-T20 — THE COLUMN DEFINITIONS ARE READ **ONCE** PER ASSEMBLY AND THREADED, because - # `core.store.get` deep-copies whatever it hands back on every call. Three consumers want this - # dict on one request (the write ctx's wall, the read merge, and the closure), and letting each - # take its own copy is the shape D-214 spent a whole ticket removing one document over. - _defs = shared_fields(st=rt) - ws = grid_events.table_workspace( - _ctx_for(session, pids, defs=_defs), allowed_pids=pids, - consume_corrections=consume_corrections) - # ⭐⭐ W38-T20 / D-425 — THE TENANT-WIDE CELLS, LAYERED OVER THE PER-USER ONES, IN THE - # ASSEMBLY SO EVERY CONSUMER SEES ONE TRUTH. `routes_grid`'s /workspace route serves - # `workspace["overlays"] = g["ws"].get("overlays")` verbatim and `_payload` hands the same - # dict to `rows_from_pool`, so merging HERE reaches both without touching either file. - # - # ⚠ SAFE TO MUTATE, and checked rather than assumed (the same check `product_assembly` - # records): `table_workspace` reads through `store.get`, which deep-copies, so `ws` is a - # detached copy and nothing writes it back. A shared value can never leak INTO the per-user - # bucket by way of this merge. - # ⚠ SHARED WINS PER KEY. The whole point of the stratum is that every reader sees the same - # number, so a per-user leftover under the same key is stale by construction. It is also what - # makes D-423 recoverable rather than permanent: a pre-fix per-user edit is shadowed, not - # promoted. - _shared = shared_cells(pids, st=rt) - if _shared: - _ov = dict(ws.get("overlays") or {}) - for _pid, _cells in _shared.items(): - _ov[_pid] = {**(_ov.get(_pid) or {}), **_cells} - ws["overlays"] = _ov - workspace, fields, views, lists = aios_grid.workspace_wire( - ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key) - # ⭐⭐ W38-T20 — AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this - # position is load-bearing twice, once for the transitive closure and once because it is the - # only order in which the per-field grant marker is ever presented to `hidden_keys`. - fields = _merge_shared_fields(fields, _defs) - # THE FIELD WALL — a TRANSITIVE closure (C-PERM amendment 5), so hiding a field also hides - # every formula computed FROM it. Formulas evaluate in the browser from `{ref}`s, so - # shipping a dependent formula while withholding its input either leaks the input through - # the formula's value or silently computes a wrong one; only removing both is coherent. - # Applied AFTER workspace_wire because custom + measure columns are what it must cover. - # ⭐ W38-T20 — `st=rt` IS NOT TIDINESS. `perm_scope.field_grant_hidden` resolves a marked - # column against `object_shares`, and without a tenant handle it reads the module-default - # bucket: on any tenant but #0 that finds no grant, and no grant on a MARKED column means - # HIDDEN. So an unthreaded `st` UNDER-shares (a grantee cannot see their own column) rather - # than over-shares — visible and reportable, but still wrong, and `visible_fields`' own note - # requires the two calls to agree about it. - hidden = perm_scope.hidden_keys(session.user, MODULE, fields, st=rt) - if hidden: - fields = [f for f in fields if f.get("key") not in hidden] - # The field LIST and the ROW payload are two different wires. Narrowing only the first - # would leave the value sitting in the second, where anything can read it. - rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src] - # ⛔⛔ AND THE OVERLAY DICT IS A THIRD WIRE, WHICH IS NEW THIS TICKET AND WAS A HOLE - # BEFORE IT. `rows_from_pool` iterates `fields`, so a narrowed contract already keeps a - # hidden key off a ROW — but `/workspace` serves `ws["overlays"]` RAW, and that dict is - # where both the per-user cells and (as of the merge above) the tenant-wide ones sit. - # One narrowing cannot speak for a wire it never touches; `routes_tables` writes the same - # sentence over its own `shared_cells`, and `routes_odoo_tables` over its `overlays`. - # ⚠ `ws` AND NOT `workspace`: `routes_grid.workspace` copies the dict ACROSS - # (`workspace["overlays"] = g["ws"].get("overlays")`) after this returns, so narrowing the - # source is what reaches the wire. Narrowing the copy would be narrowing a key that gets - # overwritten a moment later. - _ov = ws.get("overlays") or {} - if _ov: - ws["overlays"] = {pid: {k: v for k, v in (cells or {}).items() if k not in hidden} - for pid, cells in _ov.items()} - - today = time.strftime("%Y-%m-%d") - stamp = _pool_stamp(rt, team_id, agent) - # ⭐⭐ W38-T19 — THE METRICS CAPABILITY, AND THIS GRAIN NEEDS **THREE** GUARDS WHERE THE - # OTHER TWO NEED ONE. `routes_products` and `routes_tables` funnel their cells and their - # condition answers through helpers that short-circuit on an empty `offer`, so emptying the - # offer there stops the whole feature. `core.measure_resolve` does not take an offer at all: - # `condition_sets` and `column_values` both re-derive their work from the caller's OWN saved - # views and field list. So gating only the offer here would take the Metric kind off the - # picker and refuse new creates while an EXISTING Metric column kept computing and an - # EXISTING measure condition kept resolving — a revoked capability still answering, on the - # grain with the most of them. Three calls, one predicate. - may_metrics = perm_scope.may_metrics(session.user, MODULE) - measures = measure_resolve.offer(team_id, on_error=_measure_err) if may_metrics else [] - measure_sets = measure_resolve.condition_sets( - [v.get("config") or {} for v in (views or [])], None, team_id, pids, today, stamp, - rt.mset_memo, on_error=_measure_err) if may_metrics else {} - # The derived channel: cohort membership cells + measure column values, ONE dict — the - # same read-only channel the embed host hands to rows_from_pool. - derived = aios_grid.cohort_cells(lists) - # ⭐ W33-T43 / owner item 12 ("One unique ID per database always"). The customer grid carried - # NO Odoo id column at all, while its retiring twin `ut_odoo_customers` carried `partner_id` - # as its join key — so the merge would have lost the one value every Odoo document joins on. - # - # ⛔ IT IS DERIVED, NOT A POOL COLUMN, AND THAT IS THE WHOLE POINT: a customer row's `pid` IS - # the `res.partner` id (`modules/customer_data.pool` mints it that way and the identity is - # asserted against Odoo in that module). Adding it to the pool would be a SECOND source for - # one fact, which is the class of defect item 12 is about. This channel exists for exactly - # this — a value the host knows per render and the pool has no business storing. - for pid in pids: - derived.setdefault(pid, {})["partner_id"] = pid - # ⭐⭐ W38-T19 — SKIPPED ENTIRELY WHEN THE CAPABILITY IS REVOKED, rather than filtered after. - # `column_values` selects its own subjects (`isinstance(f.get('measure'), dict)`) off the - # field list, so there is no argument that could narrow it; not calling it is the narrowing. - # ⚠ THE COLUMN STAYS AND ITS CELLS GO BLANK, which is this channel's OWN documented degrade - # ("blank is could not compute, 0 is a real zero") and is what the product and user-table - # grains already do under an empty offer. Deleting the column instead would be a second, - # louder behaviour for the same fact on one surface out of three, and it would destroy a - # definition the admin can restore with one tick. - for pid, cells in (measure_resolve.column_values( - fields, team_id, pids, today, stamp, rt.measure_memo, - on_error=_measure_err) if may_metrics else {}).items(): - derived.setdefault(pid, {}).update(cells) - - return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace, - "fields": fields, "views": views, "lists": lists, "derived": derived, - "measures": measures, "measure_sets": measure_sets, "today": today, - "team_id": team_id} - - -def _payload(session: Session): - """`{fields, rows, today, docs, pulled_at}` — X2's shape, which `verify_fields_contract.py` - referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by - construction); see `grid_assembly` for what that fixed. - - ⭐ `docs` joined the shape in wave 30 (W30-T37 / contract C4). Named here rather than left to - the reader because a docstring that still lists the OLD shape is a stale comment on correct - code — this repo's D-73 — and it is the first thing anyone greps to learn the payload. - - ⚠ `rows_src` is the SHARED cached list — `rows_from_pool` reads it and builds NEW dicts, - never mutating a cached row (the same-scope-second-user leak rule). - """ - import aios_grid - - g = grid_assembly(session) - rows = aios_grid.rows_from_pool( - g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"]) - # ⭐ C4 / D-138 (W30-T37) — THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door - # (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is - # complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key. - # All six `onDoc*` handlers in `CustomerGrid.tsx` read `payload?.docs ? … : undefined`, so an - # ABSENT key — not a broken one — is what has been switching the whole feature off. - # - # ⛔ IMPORTED, NEVER RE-SERIALISED. `core.grid_events.docs_for` is the ONE serialiser and - # `routes_tables` (the `ut_*` scope) calls the SAME function with the same argument order. - # A matching pair here is precisely how the wave-29 close-out reintroduced its own defect in - # the opposite direction inside a single commit ([[one-question-two-normalizers]]). - # ⚠ `g["pids"]` is the row set this session is ALREADY scoped to — `docs_for` has no - # "every document in the tenant" mode to reach for, deliberately. - from core import grid_events as _ge - return {"fields": g["fields"], "rows": rows, - # `today` rides the payload because every relative date condition must resolve against - # the TENANT's day, never the browser's — a client that falls back to its own clock - # disagrees with the server for everyone west of it. - "today": g["today"], - "docs": _ge.docs_for(g["pids"], scope_key="customer", uname=session.uname, - admin=session.admin, st=session.runtime), - "pulled_at": time.strftime("%Y-%m-%d %H:%M")} - - -def _ctx_for(session: Session, pids, defs=None): - """An EventCtx for the READ path — no fallback workspace, so a store outage is a 503 rather - than a phantom in-memory workspace an API request cannot persist. - - `defs` (W38-T20) is this topic's tenant-wide column definitions when the caller already holds - them; absent, they are read. One read per assembly rather than one per question asked of it. - """ - from core import grid_events - return grid_events.EventCtx( - uname=session.uname, allowed_pids=frozenset(pids or ()), fields=[], - # C-PERM: the write wall's field half. Computed from the CANONICAL contract PLUS the - # tenant-wide columns, because `fields=[]` here — the closure only needs the schema, not - # this user's column list, and a runtime column is part of that schema now. - hidden_keys=_hidden_for(session, defs=defs), - admin=session.admin, fallback_ws=None, seen_ids={}) - - - -def _hidden_for(session: Session, defs=None): - """The fields this session's permissions hide — the write wall's half of C-PERM. - - Read paths strip these from both wires so they cannot be SEEN; this is what stops them - being WRITTEN by a caller who knows the key. Evaluated against the canonical contract, the - same schema `routes_admin._clean_perms` validates a hiddenFields entry against. - - ⭐⭐ W38-T20 — AND THE TENANT-WIDE COLUMNS ARE PART OF THAT CONTRACT NOW, WHICH IS A WALL AND - NOT A COMPLETENESS TIDY-UP. `patch_customer` builds its ctx with `fields=payload["fields"]`, - which carries the merged shared columns, so `grid_events.handle_one` would happily accept an - `overlay_patch` naming one. The canonical list cannot mention them (they are created at - runtime), so a wall computed from `aios_grid.FIELDS` alone answers "not hidden" for every - grant-governed column and the write door is open to a reader who was never granted it. - ⚠ Widening the field list can only ever ADD to the hidden set, never remove from it: the - closure hides what it is told to hide plus whatever depends on it. - """ - import aios_grid - import core.perm_scope as perm_scope - - if defs is None: - defs = shared_fields(st=session.runtime) - return perm_scope.hidden_keys( - session.user, MODULE, _merge_shared_fields(list(aios_grid.FIELDS), defs), - st=session.runtime) - -def allowed_pids(session: Session): - """The pids this session may touch — the POOL's own ids, so the write wall and the read scope - can never disagree. - - Reads `_pool_rows` rather than `_payload`: the wall only needs identities, and going through - the full payload would pay for a workspace read and a row assembly on every write. - - ⛔ THE PERMANENT FILTER APPLIES HERE TOO, AND FORGETTING IT IS A WRITE-WITHOUT-READ HOLE. - `_pool_rows` is built with the DERIVED pushdown, which expresses only what a `(team_id, - agent)` pair can express. Any part of the wall the pushdown cannot carry — `revenue > 1000`, - a nested group, a condition on any other column — leaves the pool WIDER than the filter. Read - paths close that gap with `apply_row_scope`; without the same call here the write wall would - be the wider set, and a restricted user could PATCH a row this API will not show them. - Same function, same order as `grid_assembly`, so the two walls cannot drift. - """ - import core.perm_scope as perm_scope - import aios_grid - - rows = perm_scope.apply_row_scope(_pool_rows(session), session.user, MODULE, - aios_grid.FIELDS) - return frozenset(r["pid"] for r in rows if r.get("pid") is not None) - - -@router.get("/customers") -def customers(session: Session = Depends(module_gate(MODULE))): - return _payload(session) - - -@router.patch("/customers/{pid}") -def patch_customer(pid: int, body: dict = Body(default=None), - session: Session = Depends(module_gate(MODULE))): - """Write the EDITABLE overlay stratum only — Odoo stays read-only, forever. - - Routed through `core.grid_events.handle_one` as an `overlay_patch` event rather than writing - the store directly: that handler is where the per-key `permissions.edit` wall, the pid wall - and the truncation rules live, and a second implementation of those would be a second set of - them to keep in step. The response reports what was ACCEPTED, which is not always what was - asked for. - """ - from core import grid_events - - updates = dict(body or {}) - if not updates: - raise err(400, "empty_patch", "no fields to update") - pool = allowed_pids(session) - if pid not in pool: - # 403, not 404: the pid may well exist — it is simply not in this session's book, and - # saying "no such customer" would confirm the opposite to anyone who guessed right. - raise err(403, "out_of_scope", "that customer is not in your book") - payload = _payload(session) - ctx = grid_events.EventCtx( - uname=session.uname, allowed_pids=pool, fields=payload["fields"], - admin=session.admin, fallback_ws=None, seen_ids={}, - hidden_keys=_hidden_for(session)) - try: - grid_events.handle_one( - {"id": f"patch:{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") - - # What actually landed, read back from the store rather than echoed from the request: a - # refused key or a truncated value must not be reported as accepted. - stored = (grid_events.table_workspace(_ctx_for(session, pool), allowed_pids=None) - .get("overlays") or {}).get(str(pid)) or {} - accepted = {k: stored.get(k) for k in updates if k in stored} - refused = sorted(k for k in updates if k not in accepted or stored.get(k) != str(updates[k])) - # No cache to patch: the overlay stratum is re-read from the store on every `_payload`, so - # read-your-writes within this runtime is a property of the design rather than of a - # write-through step somebody has to remember. (It was a write-through step while the whole - # payload was cached on a scope key — the arrangement that leaked one user's notes to - # another. See `_pool_rows`.) Cross-RUNTIME coherence is still not claimed: the module - # docstring says why, and Postgres is the fix. - out = {"ok": True, "pid": pid, "updates": accepted} - if refused: - out["refused"] = refused - return out - - -# ══════════════════════════════════ THE ROUTE-ORDER COLUMN (W38-T20 / ruling R7 / contract C1) ══ -# -# ⛔⛔ WHY THIS IS A DOOR HERE AND NOT A KIND IN THE COLUMN MENU. `ColumnMenu.onCreate` is the only -# persistence the menu has, and it lands a PER-USER `custom_` column through -# `aios_grid._field_extras`, which is a strict allowlist: a route-order bag created that way is -# silently stripped and its values are private to their author. That is the wave-19 `image` -# failure verbatim ("created, named, configured, gone", recorded in `_clean_geocode`'s own -# docstring) plus a done-when clause that cannot hold, because a colleague reading a per-user -# stratum gets a clean 200 with nothing in it. The `geocode` pseudo-kind is the precedent for the -# PICKER; its persistence half does not transfer. -# -# ⭐ SO THE COLUMN IS BORN SHARED. `shared_overlay.put_field` stores the definition verbatim (no -# allowlist), which is what lets the input fingerprint ride the DEFINITION rather than the rows — -# `shared_overlay._value` RAISES on a dict, so `{order, inputsHash}` could never be one cell, and -# one solve fingerprints the whole cohort identically anyway, so per-row would be the same string -# written N times. -# -# ⭐ AND THE NUMBER ON THE RECORD IS THE INVERSE OF THE PLANNER'S ANSWER. `mapProjection.planRoute` -# returns `order`, where `order[i]` is WHICH STOP is visited i-th; the cell holds the RANK. The -# client inverts it with `routeRanks` (gated in `map.test.ts` at the desktop shape, over a fixture -# chosen so the two differ); this door then refuses anything that is not a clean 1..N, so a -# truncated or double-posted body cannot land as a half-route. - -#: The key prefix every route-order column wears. `custom_` and `measure_` are the two existing -#: created-column namespaces and both are PER USER; this one is tenant-wide, so it takes its own -#: rather than borrowing a prefix whose readers assume a per-user home. -ROUTE_KEY_PREFIX = "route_" - -#: The marker on the stored definition that says WHAT this column is. Read by the GET below and by -#: the client; never inferred from the key, because a prefix is a naming convention and a -#: convention is not a declaration. -ROUTE_KIND = "route_order" - -#: ⛔⛔ THE TOPIC A FIELD GRANT IS NAMED UNDER, AND IT IS **NOT** THE STORE BUCKET. Two namespaces -#: meet on this column and they are spelled differently: -#: -#: the STRATUM lives at `shared_overlay.bucket(customer_data.TABLE_KEY)` -#: = `customer_table_workspace__shared` -#: the GRANT is named `shares.field_oid(, key)` -#: = `customer_data:` -#: -#: `perm_scope.hidden_keys(user, module, fields)` hands its `module` argument straight through to -#: `field_grant_hidden`, which builds the oid from it. On a `ut_*` database the module argument IS -#: the table key, so W38-T16 never had to tell them apart; on a REGISTRY topic they differ, and a -#: door that claims the grant under the bucket name writes a record the wall will never look for. -#: MEASURED, not reasoned: the first run of this ticket's gate did exactly that, and user B was -#: refused a column that had been shared with them through the real share door, with a 200 at -#: every step ([[one-question-two-normalizers]]). -SHARE_TOPIC = MODULE - - -def _route_slug(label): - """A stable store key from a human label. Lower case, non-alphanumerics collapsed to `_`.""" - import re as _re - slug = _re.sub(r"[^a-z0-9]+", "_", str(label or "").strip().lower()).strip("_") - return f"{ROUTE_KEY_PREFIX}{slug[:48]}" if slug else "" - - -def _route_defs(session: Session): - """This topic's route-order columns MINUS the ones this session was not granted. - - ⛔ THE WALL IS THE SAME ONE THE GRID USES, NOT A SECOND OPINION. `hidden_keys` is where T16 - put the per-field grant check, so filtering on it here means the listing, the grid contract - and the row payload agree by construction. A column a reader cannot see must not appear here - either: the done-when says *does not see the field at all*, and a picker that names a column - whose values are withheld has already leaked its existence. - """ - import aios_grid - import core.perm_scope as perm_scope - - all_defs = shared_fields(st=session.runtime) or {} - defs = {k: v for k, v in all_defs.items() - if isinstance(v, dict) and v.get("kind") == ROUTE_KIND} - if not defs: - return {} - hide = perm_scope.hidden_keys( - session.user, MODULE, _merge_shared_fields(list(aios_grid.FIELDS), all_defs), - st=session.runtime) - return {k: v for k, v in defs.items() if k not in hide} - - -@router.get("/customers/route-order") -def route_order_list(session: Session = Depends(module_gate(MODULE))): - """The route-order columns this session may see, with the fingerprint each was solved from. - - ⭐⭐ THE FINGERPRINT IS WHY THIS ROUTE EXISTS RATHER THAN THE CLIENT READING `fields`. - Staleness is DERIVED, never stored: what is written down is the INPUT FINGERPRINT the numbers - were produced from, so *is this order still current* is a question asked at READ time against - what is on screen NOW, and can never itself be out of date. A stored `stale: true` is a fact - about a moment that has already passed. - """ - out = [] - for key, defn in sorted(_route_defs(session).items()): - route = defn.get("route") if isinstance(defn.get("route"), dict) else {} - out.append({ - "key": key, - "label": defn.get("label") or key, - "inputsHash": str(route.get("inputsHash") or ""), - "roundTrip": bool(route.get("roundTrip")), - "startPid": route.get("startPid"), - "stops": route.get("stops"), - "solvedAt": route.get("solvedAt") or "", - "solvedBy": defn.get("createdBy") or "", - # Who may re-solve it. The same creator-or-admin wall the write door enforces, said on - # the way out so the client can grey the control instead of discovering a 403. - "mine": bool(session.admin - or str(defn.get("createdBy") or "") == session.uname), - }) - return {"fields": out} - - -@router.post("/customers/route-order") -def route_order_write(body: dict = Body(default=None), - session: Session = Depends(module_gate(MODULE))): - """Create (or re-solve) a tenant-wide route-order column and fill it, in ONE call. - - `{label, field?, ranks: {"": }, inputsHash, roundTrip?, startPid?, stops?}` - - ⛔ THE DEFINITION IS WRITTEN FIRST AND THE GRANT CLAIMED SECOND, which is `patch_shared_cell`'s - order and it is deliberate: the window where a column is MARKED and UNCLAIMED fails CLOSED - (governed, nobody granted, so only an admin and the creator see it, and an admin can share - it). The other order would leave a grant record pointing at nothing. - - ⛔ RE-SOLVING IS CREATOR-OR-ADMIN, WHICH CREATING IS NOT. Writing these numbers changes what - every account in the workspace reads, at once, for the whole cohort. That is the wall - `routes_tables.delete_shared_field` already applies to the destructive half of this stratum, - and a new door does not get to inherit the loose half of an asymmetry somebody has flagged. - A permitted teammate READS the numbers; they do not silently re-plan somebody's day. - """ - from core import shared_overlay - import core.perm_scope as perm_scope - from routes_grid import MAX_BULK_ROWS - - body = body if isinstance(body, dict) else {} - label = " ".join(str(body.get("label") or "").split())[:120] - key = str(body.get("field") or "").strip() or _route_slug(label) - if not key: - raise err(400, "bad_request", "a name is required for the route order column") - if not key.startswith(ROUTE_KEY_PREFIX): - raise err(400, "bad_field_key", - f"a route order column's key starts with '{ROUTE_KEY_PREFIX}', so it cannot " - f"collide with a column this database already owns") - import aios_grid - if key in {f.get("key") for f in aios_grid.FIELDS}: - raise err(400, "field_key_taken", - "this database already has a column with that key") - - defs = shared_fields(st=session.runtime) or {} - existing = defs.get(key) if isinstance(defs.get(key), dict) else None - if existing is not None: - # ⛔⛔ A REFUSAL MUST NOT DESCRIBE A COLUMN THE CALLER CANNOT SEE. The two refusals below - # name the column's KIND and its CREATOR, which is exactly the information the field wall - # exists to withhold: a stranger who guesses the key would otherwise learn that a route - # order exists on this database and who planned it. `routes_shares._can_see_object` makes - # the same choice for the same reason (a non-grantee gets the answer a non-existent id - # gets). The name is already taken either way, so the honest refusal says only that. - if key in perm_scope.hidden_keys( - session.user, MODULE, - _merge_shared_fields(list(aios_grid.FIELDS), defs), st=session.runtime): - raise err(400, "field_key_taken", - "that column name is already in use on this database") - if existing.get("kind") != ROUTE_KIND: - raise err(400, "not_a_route_column", - "that column is shared but it is not a route order column, so re-solving " - "it would overwrite values this door did not write") - owner = str(existing.get("createdBy") or "") - if not session.admin and owner != session.uname: - raise err(403, "forbidden", - f"a route order can be re-solved by the person who planned it or by an " - f"administrator. This one was planned by {owner or 'somebody else'}, and " - f"re-solving it would change the visit numbers for every account at once") - - raw = body.get("ranks") - if not isinstance(raw, dict) or not raw: - raise err(400, "bad_ranks", 'expected {ranks: {"": }}') - # ⛔ REPORTED, NEVER TRUNCATED (standing rule 1's second sentence, and `MAX_BULK_ROWS`' own - # note). The ceiling is `routes_grid`'s so there is ONE of them, not two that drift. - if len(raw) > MAX_BULK_ROWS: - raise err(400, "too_many_rows", - f"at most {MAX_BULK_ROWS} records per route; this one carried {len(raw)}") - - pool = allowed_pids(session) - ranks, not_in_pool, bad_value = {}, [], [] - for raw_pid, value in raw.items(): - try: - pid = int(raw_pid) - except (TypeError, ValueError): - not_in_pool.append(str(raw_pid)[:40]) - continue - # ⚠ THE POOL IS THE WALL, and it is the SAME predicate the read path applies - # (`apply_row_scope` inside `allowed_pids`), so a caller cannot number a record they - # could not be shown, including one in the other business unit. - if pid not in pool: - not_in_pool.append(str(raw_pid)[:40]) - continue - # ⛔ A BOOL IS AN `int` IN PYTHON and `True` would store as a visit number 1. Excluded by - # name rather than by hoping nobody sends one. - if isinstance(value, bool) or not isinstance(value, int) or value < 1: - bad_value.append(str(raw_pid)[:40]) - continue - ranks[pid] = value - - # ⛔⛔ A PARTLY HONOURED ROUTE IS NOT A ROUTE, AND THIS REFUSES THE WHOLE CALL RATHER THAN - # WRITING THE PART IT COULD. Caught by this ticket's own gate: a body carrying one record - # outside the caller's book still produced a clean 1..N over what was left, so the door minted - # a permanent tenant-wide column and filled it with a SHORTER route than the one that was - # solved. Every number in it was plausible and the day was wrong. - # ⚠ REPORTED, WITH THE SAMPLE, which is standing rule 1's second sentence: the refusal names - # what it could not take, so the caller can fix it rather than guess. - if not_in_pool: - raise err(400, "rows_not_in_your_book", - f"{len(not_in_pool)} of those records are not in your book, so the route " - f"cannot be written as it was solved. First few: " - f"{', '.join(sorted(not_in_pool)[:5])}") - if bad_value: - raise err(400, "rows_not_a_visit_number", - f"a visit number is a whole number from 1 upwards; {len(bad_value)} records " - f"carried something else. First few: {', '.join(sorted(bad_value)[:5])}") - if not ranks: - raise err(400, "no_rows_in_your_book", - "none of those records are in your book, so there is nothing to number") - # ⛔ A ROUTE IS A SEQUENCE, SO THE NUMBERS ARE 1..N WITH NO REPEAT AND NO GAP. A body that - # arrives truncated, doubled or partly applied would otherwise land as a plausible half - # route: every row carrying a number, and the day in the wrong order. - seq = sorted(ranks.values()) - if seq != list(range(1, len(seq) + 1)): - raise err(400, "not_a_sequence", - f"a route order is the numbers 1 to {len(seq)}, each used once. This one " - f"carried {len(seq)} records numbered up to {seq[-1]} with " - f"{len(seq) - len(set(seq))} repeated") - - stamp = time.strftime("%Y-%m-%d %H:%M") - defn = { - "key": key, - "label": label or (existing or {}).get("label") or key, - # ⛔ `int`, NEVER `text`. A text column sorts 1, 10, 11, 2 — fully populated, entirely - # plausible, wrong, and named by no gate. `patch_shared_cell` defaults to text; this door - # cannot. - "type": "int", - "source": "overlay", - "shared": True, - "kind": ROUTE_KIND, - # ⭐⭐ THE PER-FIELD GRANT MARKER (T16). It is an EXPLICIT write-once declaration and it is - # what makes the wall fail CLOSED: a grant wall has the opposite absence-polarity to a - # deny wall, so keying visibility on "does a grant record exist" would publish this column - # to the whole tenant on one unreadable read of `object_shares`. Stamped, never inferred. - perm_scope.FIELD_GRANT_MARK: True, - "createdBy": (existing or {}).get("createdBy") or session.uname, - # ⭐ THE FINGERPRINT RIDES THE DEFINITION, ONCE. One `planRoute` run solves the whole - # cohort, so this string is identical for every record in it; per row it would be the same - # value written N times, and `shared_overlay._value` raises on a dict anyway. - "route": { - "inputsHash": str(body.get("inputsHash") or "")[:64], - "roundTrip": bool(body.get("roundTrip")), - "startPid": int(body["startPid"]) if isinstance(body.get("startPid"), int) - and not isinstance(body.get("startPid"), bool) else None, - "stops": len(ranks), - "solvedAt": stamp, - }, - } - shared_overlay.put_field(_shared_key(), key, defn, st=session.runtime) - if existing is None: - try: - import core.shares as shares - # ⚠ THE EMPTY ENTRY LIST IS THE POINT. `set_grants` keeps a record with an owner and - # no entries, so "shared with nobody" is STORED and is a different fact from "never - # shared". Without the owner the column is unmanageable: `may_administer` fails closed - # on an ownerless record, so nobody could ever share it. - shares.set_grants("field", shares.field_oid(SHARE_TOPIC, key), [], - owner=session.uname, st=session.runtime) - except Exception: # noqa: BLE001 - # The mark is already written, so a failed claim fails CLOSED: governed, nobody - # granted, admin-and-creator only. Recoverable. The other order is not. - pass - - # ⛔ AND THE RECORDS THAT LOST THEIR NUMBER ARE CLEARED. A re-solve over a SMALLER cohort - # would otherwise leave the previous run's ranks sitting on the records that dropped out — - # plausible integers, from a route nobody is driving. `""` is the house spelling of an empty - # overlay cell (`rows_from_pool` defaults an absent one to exactly that), so this needs no new - # vocabulary and no tombstone nobody else reads. - stale = {} - for raw_pid, cells in (shared_cells(pool, st=session.runtime) or {}).items(): - if not isinstance(cells, dict) or cells.get(key) in (None, ""): - continue - try: - gone = int(raw_pid) - except (TypeError, ValueError): - continue - if gone not in ranks: - stale[gone] = {key: ""} - written = shared_overlay.put_rows( - _shared_key(), {**{p: {key: v} for p, v in ranks.items()}, **stale}, - st=session.runtime) - - out = {"ok": True, "field": key, "label": defn["label"], "type": "int", - "stops": len(ranks), "cleared": len(stale), - "rows_written": len(written), "inputsHash": defn["route"]["inputsHash"], - "solvedAt": stamp} - return out +"""routes_customers.py — X2's read + write of the customer table, BU-SCOPED (EXIT-3b / EXIT-2a). + +Two things happen here that did not happen in the pre-wave `main.py`: + + 1. **ROWS ARE SCOPED TO THE SESSION.** The old `/api/customers` served `cl.pool()` — the whole + book, to anyone holding the shared APP_PASSWORD. Now the pool is built with + `(team_id, agent_name)` derived from the USER RECORD, so a Royal-only user never receives a + Fisch row and an agent-linked login never receives another rep's book. The scope is applied + at the QUERY, not as a post-filter, so there is no moment at which the other BU's rows exist + in this response. + + 2. **THE OVERLAY FORK IS GONE.** `aios-web/api/data/overlay.json` was a SECOND writable home + for the same user-owned fields the Streamlit app keeps in the tenant store — two truths, and + whichever process you asked last was right. Reads and writes now both go through + `modules.customer_data`'s table-workspace functions: ONE store (C1c, ARCHITECTURE §1a rule 3). + +⚠ ONE STORE IS NOT YET ONE CACHE (strangler-period, booked honestly). `core.store.get()` is +cache-first per PROCESS, so a write from the Streamlit container is invisible to a running API +container until its cache is refreshed, and vice versa. Deleting the fork removes the second +SOURCE OF TRUTH; it does not make the two runtimes coherent. The fix is X4/Postgres (task C-4, +owner-blocked on B-3), and no test here may claim read-your-writes ACROSS runtimes — a TestClient +proof is single-process and would report green on exactly the thing that is still broken. +""" +import time + +from fastapi import APIRouter, Body, Depends + +import scope_cache +from deps import Session, err, module_gate, perms + +router = APIRouter(prefix="/api/v1") + +#: The surface these routes serve. Both legs are gated on it, so a user without the grant gets a +#: 403 rather than an empty table that looks like "you have no customers". +MODULE = "customer_data" + +_CACHE_TTL = 900 # the pool build is slow (Odoo + reconciliation); 15 min, as before + + +def _pool_rows(session: Session): + """The reconciled Odoo pool for this session's SCOPE — the slow part, and the only part that + may be shared between users. + + ⛔ WHAT MAY BE CACHED HERE, AND WHY THE LINE IS EXACTLY HERE. The cache key is + `(team_id, agent)` and the cached value is the raw pool: Odoo-source columns only. That is + genuinely scope-shaped — two users with the same BU and the same book are asking the same + question, and `cl.pool()` is expensive (an Odoo pull plus reconciliation). + + The FULL PAYLOAD is NOT cacheable on this key, and caching it here was a real defect I shipped + and then removed. `fields` comes from `fields_from_workspace(ws)` — that user's own `custom_` + and `measure_` columns — and every overlay cell comes from `ws['overlays']`; the table + workspace is read as `data[username]` and written as `patch_table_overlay(uname, …)`, i.e. + PER USER. So two Royal-only users with no agent link share `(6, None)` and the second one + would have been served the FIRST one's private notes and private columns. The scope key was + right for the pool and wrong for everything wrapped around it. + + It survived a green 129-check battery because every fixture user had a DISTINCT + `(team_id, agent)` pair, so no two of them ever collided on the key — the test set could not + express the bug. `verify_api.py` now carries a same-scope second user for exactly this. + """ + rt = session.runtime + team_id, agent = _team_agent(session) + return _pool_for(rt, team_id, agent) + + +def _pool_for(rt, team_id, agent): + """The cached pool for an explicit scope — session-free so the prewarm thread and the + stale-refresh path can call it. STALE-WHILE-REFRESH (scope_cache): once a copy exists no + request blocks on the 10–30s Odoo rebuild again; only a scope's FIRST-ever build does. + + DEBT-2 (2026-08-04): while the tenant's RESOLVED Odoo source is PAUSED this path never + goes live — it serves the in-process copy at any age, else the persisted pause-time + snapshot (restart-safe), else answers 503. Never a WIDER scope's snapshot: handing a + scoped user the consolidated rows would widen their book, which is worse than an error.""" + import modules.customer_data as cl + import routes_keychain + + key = ("pool", team_id, agent) + + if routes_keychain.odoo_paused(rt): + hit = rt.pool_cache.get(key) + if hit: + return hit[1] + snap = routes_keychain.load_pool_snapshot(rt, team_id, agent) + if snap is not None: + rt.pool_cache[key] = snap # seed, so the memo stamps stay coherent + return snap[1] + raise err(503, "connector_paused", + "this data source is paused and no snapshot exists for your scope — " + "an admin can resume it under Settings → Connectors") + + def _build(): + # ⚠ THE SCOPE GOES INTO THE BUILDER. `pool(agent_name, team_id)` is the same reconciled + # builder the Streamlit page uses — passing the scope here is what makes the isolation a + # property of the QUERY instead of a filter someone can forget to apply downstream. + return cl.pool(agent, team_id) + + def _evict(): + # Bounded: a scope cache that only ever grows is a memory leak in a shared process. + if len(rt.pool_cache) > 16: + for stale in sorted(rt.pool_cache, key=lambda k: rt.pool_cache[k][0])[:8]: + rt.pool_cache.pop(stale, None) + + return scope_cache.get(rt.pool_cache, key, _CACHE_TTL, _build, _evict) + + +def warm_default(rt): + """Boot prewarm: the consolidated pool `(None, None)` — the scope every admin and every + all-BU account lands on. Called from main.py's prewarm thread only.""" + _pool_for(rt, None, None) + + +def _team_agent(session: Session): + """The `(team_id, agent)` this session's POOL is built with — ONE derivation point, used by + `_pool_rows` and `grid_assembly` alike, so the cache key and the query can never disagree. + + ⛔ WAVE 15 R1: THIS NOW COMES FROM THE PERMANENT FILTER (C-PERM amendment 3). `team_id` is + not a row filter — `customer_data._pool_build` passes it into `cust._cust_rev` three times + and into `_cadence_bulk`, so it decides what `rev`/`ly`/`ltm`/`aov`/`status` MEAN. Enforcing + a BU purely as a post-filter would keep the row list right and silently consolidate every + number. So the pushdown survives as a DERIVATION OF the declared filter rather than a second + wall beside it, and `perm_scope.derive_pool_scope` falls back to the legacy `bus`/`agent` + derivation for any record the migration has not reached yet. + """ + import core.perm_scope as perm_scope + return perm_scope.derive_pool_scope(session.user, MODULE) + + +def _pool_stamp(rt, team_id, agent): + """The cached pool's build timestamp — the DATA STAMP in every measure-memo key, so a pool + refresh invalidates the memoised answers exactly when the underlying rows changed.""" + entry = rt.pool_cache.get(("pool", team_id, agent)) + return entry[0] if isinstance(entry, tuple) and entry else 0 + + +def _measure_err(tag, e): + try: + import harness.telemetry as _tel + _tel.error(f"api:{tag}", e) + except Exception: + pass + + +# ══════════════════════════ THE TENANT-WIDE STRATUM, ON THE CUSTOMER TOPIC (W38-T20 / D-425) ══ +# +# ⛔⛔ WHY THIS FILE GREW A SHARED STRATUM AT ALL. `core/shared_overlay.py` has been generic since +# W29-T62, `routes_products.py` has merged it since W30-T36 and `routes_tables.py` since W38-T16 — +# and the CUSTOMER topic had neither a write door nor a read merge. Every user-created column here +# lives in `data[username]`, so two accounts looking at "the same" column are looking at two +# columns. That is fine for a private note and fatal for a ROUTE ORDER: a visit sequence one rep +# can see and their colleague cannot is not a plan, it is a rumour. +# +# ⛔ ONE SPELLING OF THE BUCKET. `modules.customer_data.TABLE_KEY` is the per-user workspace key +# and `shared_overlay.bucket()` derives `__shared` from it. Resolving it here rather than +# writing the string means the write door and the read merge cannot disagree about where the +# values live — which is exactly the failure T16 found on the materialised `ut_*` tables, where +# `patch_shared_cell` wrote into a bucket no reader ever opened. + + +def _shared_key(): + """The store key this topic's per-user AND tenant-wide strata are both named from.""" + import modules.customer_data as cl + return cl.TABLE_KEY + + +def shared_fields(st=None): + """`{field_key: Field}` — the columns this topic shares tenant-wide. + + Unscoped on purpose, exactly as `shared_overlay.fields` is: a shared column's EXISTENCE is + tenant-wide by definition. WHO MAY SEE IT is a separate question, answered one layer up by + `perm_scope.hidden_keys` (the per-field grant wall T16 landed), and WHOSE ROWS by `cells`. + """ + from core import shared_overlay + try: + return shared_overlay.fields(_shared_key(), st=st) + except Exception: # noqa: BLE001 + # Lenient like every other display read: an unreachable store degrades to "nothing is + # shared yet", never to a 500 on a grid that would otherwise render. The WALL does not + # degrade with it — `field_grant_hidden` hides a marked column it cannot resolve. + return {} + + +def shared_cells(pids, st=None): + """`{"": {key: value}}` for the rows named by `pids`, and ONLY those. + + ⛔ `pids` IS THE ROW WALL, PASSED AND NEVER DEFAULTED. `shared_overlay.cells` refuses an + "everything" read by signature for this reason; the set handed in is the one `grid_assembly` + has already narrowed with `apply_row_scope`, so a cell belonging to the other BU has nothing + to attach itself to. + """ + from core import shared_overlay + try: + return shared_overlay.cells(_shared_key(), list(pids or ()), st=st) + except Exception: # noqa: BLE001 + return {} + + +def _merge_shared_fields(fields, defs): + """`fields` PLUS the tenant-wide columns this topic declares — `routes_tables._ut_shared_fields` + on the customer topic. + + ⚠ MERGED BEFORE THE WALL, NEVER AFTER. `hidden_keys` is a TRANSITIVE closure, so it must run + on the WHOLE contract: a formula over a shared column that reads a hidden one sits outside the + closure's reach otherwise and carries the hidden value out wearing a second name. It is also + the only order in which `field_grant_hidden` can ever see the `granted` marker at all — merge + afterwards and the per-field wall is inert while every test still passes. + ⚠ A key the canonical contract already declares WINS. A shared column is an ADDITION to this + database's contract, never a redefinition of a column it already has. + """ + if not defs: + return fields + have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)} + return list(fields or ()) + [dict(f, source="overlay") + for k, f in defs.items() if k not in have] + + +def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "", + consume_corrections: bool = True): + """ONE assembly of this session's grid state, shared by `/customers`, `/workspace` and the + events route (2026-07-31 — the standalone measure gap, owner item 1). + + What it adds over the pre-wave hand-rolled `_payload` loop, and why it replaced it: + + * rows go through `aios_grid.rows_from_pool` — the SAME builder the embedded host uses, + so `lat`/`lon` (the Map view's data), `_created` and every derived column ride each row + by construction instead of by a second loop that drifts. The hand loop was written to + mirror the pre-Map contract and silently dropped the coordinates: the shell's Map view + had nothing to plot ("the Map no longer works"). + * `derived` carries the cohort column's cells AND the measure columns' values, resolved + through `core.measure_resolve` (EXIT-5's extraction of the `_cl_measure_*` family). + Without them every measure column the owner built rendered BLANK in the shell. + * `measures` (the offer) and `measure_sets` (condition answers) are computed here so the + events route can finally validate measure fields/conditions instead of refusing them + (an empty `measure_offer` made `clean_measure_field` reject every create over HTTP). + + Memos live on the TENANT RUNTIME (`rt.measure_memo` / `rt.mset_memo`) — bounded by the + module's own clear-past-cap rule, keyed on (stamp, scope, pool identity, question), nothing + user-shaped in them. + """ + import aios_grid + from core import grid_events, measure_resolve + + import core.perm_scope as perm_scope + + rt = session.runtime + team_id, agent = _team_agent(session) + rows_src = _pool_for(rt, team_id, agent) + # ⛔ THE ROW WALL, APPLIED BEFORE `pids` IS TAKEN. Everything downstream is bounded by that + # frozenset — `allowed_pids` for the workspace, cohort membership, measure resolution — so + # scoping here means a row this account may not see never enters ANY of them, rather than + # being filtered out of one payload and surviving in another. + # + # Evaluated against the CANONICAL field list, not the per-user assembled one, for two + # reasons: the assembled list is not built yet (it needs `pids`), and a permanent filter may + # only ever name a canonical field anyway — `routes_admin._clean_perms` validates it against + # exactly this schema and 400s otherwise. `permits()` denies on anything it cannot answer. + rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, aios_grid.FIELDS) + pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None) + # ⭐ W38-T20 — THE COLUMN DEFINITIONS ARE READ **ONCE** PER ASSEMBLY AND THREADED, because + # `core.store.get` deep-copies whatever it hands back on every call. Three consumers want this + # dict on one request (the write ctx's wall, the read merge, and the closure), and letting each + # take its own copy is the shape D-214 spent a whole ticket removing one document over. + _defs = shared_fields(st=rt) + ws = grid_events.table_workspace( + _ctx_for(session, pids, defs=_defs), allowed_pids=pids, + consume_corrections=consume_corrections) + # ⭐⭐ W38-T20 / D-425 — THE TENANT-WIDE CELLS, LAYERED OVER THE PER-USER ONES, IN THE + # ASSEMBLY SO EVERY CONSUMER SEES ONE TRUTH. `routes_grid`'s /workspace route serves + # `workspace["overlays"] = g["ws"].get("overlays")` verbatim and `_payload` hands the same + # dict to `rows_from_pool`, so merging HERE reaches both without touching either file. + # + # ⚠ SAFE TO MUTATE, and checked rather than assumed (the same check `product_assembly` + # records): `table_workspace` reads through `store.get`, which deep-copies, so `ws` is a + # detached copy and nothing writes it back. A shared value can never leak INTO the per-user + # bucket by way of this merge. + # ⚠ SHARED WINS PER KEY. The whole point of the stratum is that every reader sees the same + # number, so a per-user leftover under the same key is stale by construction. It is also what + # makes D-423 recoverable rather than permanent: a pre-fix per-user edit is shadowed, not + # promoted. + _shared = shared_cells(pids, st=rt) + if _shared: + _ov = dict(ws.get("overlays") or {}) + for _pid, _cells in _shared.items(): + _ov[_pid] = {**(_ov.get(_pid) or {}), **_cells} + ws["overlays"] = _ov + workspace, fields, views, lists = aios_grid.workspace_wire( + ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key) + # ⭐⭐ W38-T20 — AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this + # position is load-bearing twice, once for the transitive closure and once because it is the + # only order in which the per-field grant marker is ever presented to `hidden_keys`. + fields = _merge_shared_fields(fields, _defs) + # THE FIELD WALL — a TRANSITIVE closure (C-PERM amendment 5), so hiding a field also hides + # every formula computed FROM it. Formulas evaluate in the browser from `{ref}`s, so + # shipping a dependent formula while withholding its input either leaks the input through + # the formula's value or silently computes a wrong one; only removing both is coherent. + # Applied AFTER workspace_wire because custom + measure columns are what it must cover. + # ⭐ W38-T20 — `st=rt` IS NOT TIDINESS. `perm_scope.field_grant_hidden` resolves a marked + # column against `object_shares`, and without a tenant handle it reads the module-default + # bucket: on any tenant but #0 that finds no grant, and no grant on a MARKED column means + # HIDDEN. So an unthreaded `st` UNDER-shares (a grantee cannot see their own column) rather + # than over-shares — visible and reportable, but still wrong, and `visible_fields`' own note + # requires the two calls to agree about it. + hidden = perm_scope.hidden_keys(session.user, MODULE, fields, st=rt) + if hidden: + fields = [f for f in fields if f.get("key") not in hidden] + # The field LIST and the ROW payload are two different wires. Narrowing only the first + # would leave the value sitting in the second, where anything can read it. + rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src] + # ⛔⛔ AND THE OVERLAY DICT IS A THIRD WIRE, WHICH IS NEW THIS TICKET AND WAS A HOLE + # BEFORE IT. `rows_from_pool` iterates `fields`, so a narrowed contract already keeps a + # hidden key off a ROW — but `/workspace` serves `ws["overlays"]` RAW, and that dict is + # where both the per-user cells and (as of the merge above) the tenant-wide ones sit. + # One narrowing cannot speak for a wire it never touches; `routes_tables` writes the same + # sentence over its own `shared_cells`, and `routes_odoo_tables` over its `overlays`. + # ⚠ `ws` AND NOT `workspace`: `routes_grid.workspace` copies the dict ACROSS + # (`workspace["overlays"] = g["ws"].get("overlays")`) after this returns, so narrowing the + # source is what reaches the wire. Narrowing the copy would be narrowing a key that gets + # overwritten a moment later. + _ov = ws.get("overlays") or {} + if _ov: + ws["overlays"] = {pid: {k: v for k, v in (cells or {}).items() if k not in hidden} + for pid, cells in _ov.items()} + + today = time.strftime("%Y-%m-%d") + stamp = _pool_stamp(rt, team_id, agent) + # ⭐⭐ W38-T19 — THE METRICS CAPABILITY, AND THIS GRAIN NEEDS **THREE** GUARDS WHERE THE + # OTHER TWO NEED ONE. `routes_products` and `routes_tables` funnel their cells and their + # condition answers through helpers that short-circuit on an empty `offer`, so emptying the + # offer there stops the whole feature. `core.measure_resolve` does not take an offer at all: + # `condition_sets` and `column_values` both re-derive their work from the caller's OWN saved + # views and field list. So gating only the offer here would take the Metric kind off the + # picker and refuse new creates while an EXISTING Metric column kept computing and an + # EXISTING measure condition kept resolving — a revoked capability still answering, on the + # grain with the most of them. Three calls, one predicate. + may_metrics = perm_scope.may_metrics(session.user, MODULE) + measures = measure_resolve.offer(team_id, on_error=_measure_err) if may_metrics else [] + measure_sets = measure_resolve.condition_sets( + [v.get("config") or {} for v in (views or [])], None, team_id, pids, today, stamp, + rt.mset_memo, on_error=_measure_err) if may_metrics else {} + # The derived channel: cohort membership cells + measure column values, ONE dict — the + # same read-only channel the embed host hands to rows_from_pool. + derived = aios_grid.cohort_cells(lists) + # ⭐ W33-T43 / owner item 12 ("One unique ID per database always"). The customer grid carried + # NO Odoo id column at all, while its retiring twin `ut_odoo_customers` carried `partner_id` + # as its join key — so the merge would have lost the one value every Odoo document joins on. + # + # ⛔ IT IS DERIVED, NOT A POOL COLUMN, AND THAT IS THE WHOLE POINT: a customer row's `pid` IS + # the `res.partner` id (`modules/customer_data.pool` mints it that way and the identity is + # asserted against Odoo in that module). Adding it to the pool would be a SECOND source for + # one fact, which is the class of defect item 12 is about. This channel exists for exactly + # this — a value the host knows per render and the pool has no business storing. + for pid in pids: + derived.setdefault(pid, {})["partner_id"] = pid + # ⭐⭐ W38-T19 — SKIPPED ENTIRELY WHEN THE CAPABILITY IS REVOKED, rather than filtered after. + # `column_values` selects its own subjects (`isinstance(f.get('measure'), dict)`) off the + # field list, so there is no argument that could narrow it; not calling it is the narrowing. + # ⚠ THE COLUMN STAYS AND ITS CELLS GO BLANK, which is this channel's OWN documented degrade + # ("blank is could not compute, 0 is a real zero") and is what the product and user-table + # grains already do under an empty offer. Deleting the column instead would be a second, + # louder behaviour for the same fact on one surface out of three, and it would destroy a + # definition the admin can restore with one tick. + for pid, cells in (measure_resolve.column_values( + fields, team_id, pids, today, stamp, rt.measure_memo, + on_error=_measure_err) if may_metrics else {}).items(): + derived.setdefault(pid, {}).update(cells) + + return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace, + "fields": fields, "views": views, "lists": lists, "derived": derived, + "measures": measures, "measure_sets": measure_sets, "today": today, + "team_id": team_id} + + +def _payload(session: Session): + """`{fields, rows, today, docs, pulled_at}` — X2's shape, which `verify_fields_contract.py` + referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by + construction); see `grid_assembly` for what that fixed. + + ⭐ `docs` joined the shape in wave 30 (W30-T37 / contract C4). Named here rather than left to + the reader because a docstring that still lists the OLD shape is a stale comment on correct + code — this repo's D-73 — and it is the first thing anyone greps to learn the payload. + + ⚠ `rows_src` is the SHARED cached list — `rows_from_pool` reads it and builds NEW dicts, + never mutating a cached row (the same-scope-second-user leak rule). + """ + import aios_grid + + g = grid_assembly(session) + rows = aios_grid.rows_from_pool( + g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"]) + # ⭐ C4 / D-138 (W30-T37) — THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door + # (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is + # complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key. + # All six `onDoc*` handlers in `CustomerGrid.tsx` read `payload?.docs ? … : undefined`, so an + # ABSENT key — not a broken one — is what has been switching the whole feature off. + # + # ⛔ IMPORTED, NEVER RE-SERIALISED. `core.grid_events.docs_for` is the ONE serialiser and + # `routes_tables` (the `ut_*` scope) calls the SAME function with the same argument order. + # A matching pair here is precisely how the wave-29 close-out reintroduced its own defect in + # the opposite direction inside a single commit ([[one-question-two-normalizers]]). + # ⚠ `g["pids"]` is the row set this session is ALREADY scoped to — `docs_for` has no + # "every document in the tenant" mode to reach for, deliberately. + from core import grid_events as _ge + return {"fields": g["fields"], "rows": rows, + # `today` rides the payload because every relative date condition must resolve against + # the TENANT's day, never the browser's — a client that falls back to its own clock + # disagrees with the server for everyone west of it. + "today": g["today"], + "docs": _ge.docs_for(g["pids"], scope_key="customer", uname=session.uname, + admin=session.admin, st=session.runtime), + "pulled_at": time.strftime("%Y-%m-%d %H:%M")} + + +def _ctx_for(session: Session, pids, defs=None): + """An EventCtx for the READ path — no fallback workspace, so a store outage is a 503 rather + than a phantom in-memory workspace an API request cannot persist. + + `defs` (W38-T20) is this topic's tenant-wide column definitions when the caller already holds + them; absent, they are read. One read per assembly rather than one per question asked of it. + """ + from core import grid_events + return grid_events.EventCtx( + uname=session.uname, allowed_pids=frozenset(pids or ()), fields=[], + # C-PERM: the write wall's field half. Computed from the CANONICAL contract PLUS the + # tenant-wide columns, because `fields=[]` here — the closure only needs the schema, not + # this user's column list, and a runtime column is part of that schema now. + hidden_keys=_hidden_for(session, defs=defs), + admin=session.admin, fallback_ws=None, seen_ids={}) + + + +def _hidden_for(session: Session, defs=None): + """The fields this session's permissions hide — the write wall's half of C-PERM. + + Read paths strip these from both wires so they cannot be SEEN; this is what stops them + being WRITTEN by a caller who knows the key. Evaluated against the canonical contract, the + same schema `routes_admin._clean_perms` validates a hiddenFields entry against. + + ⭐⭐ W38-T20 — AND THE TENANT-WIDE COLUMNS ARE PART OF THAT CONTRACT NOW, WHICH IS A WALL AND + NOT A COMPLETENESS TIDY-UP. `patch_customer` builds its ctx with `fields=payload["fields"]`, + which carries the merged shared columns, so `grid_events.handle_one` would happily accept an + `overlay_patch` naming one. The canonical list cannot mention them (they are created at + runtime), so a wall computed from `aios_grid.FIELDS` alone answers "not hidden" for every + grant-governed column and the write door is open to a reader who was never granted it. + ⚠ Widening the field list can only ever ADD to the hidden set, never remove from it: the + closure hides what it is told to hide plus whatever depends on it. + """ + import aios_grid + import core.perm_scope as perm_scope + + if defs is None: + defs = shared_fields(st=session.runtime) + return perm_scope.hidden_keys( + session.user, MODULE, _merge_shared_fields(list(aios_grid.FIELDS), defs), + st=session.runtime) + +def allowed_pids(session: Session): + """The pids this session may touch — the POOL's own ids, so the write wall and the read scope + can never disagree. + + Reads `_pool_rows` rather than `_payload`: the wall only needs identities, and going through + the full payload would pay for a workspace read and a row assembly on every write. + + ⛔ THE PERMANENT FILTER APPLIES HERE TOO, AND FORGETTING IT IS A WRITE-WITHOUT-READ HOLE. + `_pool_rows` is built with the DERIVED pushdown, which expresses only what a `(team_id, + agent)` pair can express. Any part of the wall the pushdown cannot carry — `revenue > 1000`, + a nested group, a condition on any other column — leaves the pool WIDER than the filter. Read + paths close that gap with `apply_row_scope`; without the same call here the write wall would + be the wider set, and a restricted user could PATCH a row this API will not show them. + Same function, same order as `grid_assembly`, so the two walls cannot drift. + """ + import core.perm_scope as perm_scope + import aios_grid + + rows = perm_scope.apply_row_scope(_pool_rows(session), session.user, MODULE, + aios_grid.FIELDS) + return frozenset(r["pid"] for r in rows if r.get("pid") is not None) + + +@router.get("/customers") +def customers(session: Session = Depends(module_gate(MODULE))): + return _payload(session) + + +@router.patch("/customers/{pid}") +def patch_customer(pid: int, body: dict = Body(default=None), + session: Session = Depends(module_gate(MODULE))): + """Write the EDITABLE overlay stratum only — Odoo stays read-only, forever. + + Routed through `core.grid_events.handle_one` as an `overlay_patch` event rather than writing + the store directly: that handler is where the per-key `permissions.edit` wall, the pid wall + and the truncation rules live, and a second implementation of those would be a second set of + them to keep in step. The response reports what was ACCEPTED, which is not always what was + asked for. + """ + from core import grid_events + + updates = dict(body or {}) + if not updates: + raise err(400, "empty_patch", "no fields to update") + pool = allowed_pids(session) + if pid not in pool: + # 403, not 404: the pid may well exist — it is simply not in this session's book, and + # saying "no such customer" would confirm the opposite to anyone who guessed right. + raise err(403, "out_of_scope", "that customer is not in your book") + payload = _payload(session) + ctx = grid_events.EventCtx( + uname=session.uname, allowed_pids=pool, fields=payload["fields"], + admin=session.admin, fallback_ws=None, seen_ids={}, + hidden_keys=_hidden_for(session)) + try: + grid_events.handle_one( + {"id": f"patch:{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") + + # What actually landed, read back from the store rather than echoed from the request: a + # refused key or a truncated value must not be reported as accepted. + stored = (grid_events.table_workspace(_ctx_for(session, pool), allowed_pids=None) + .get("overlays") or {}).get(str(pid)) or {} + accepted = {k: stored.get(k) for k in updates if k in stored} + refused = sorted(k for k in updates if k not in accepted or stored.get(k) != str(updates[k])) + # No cache to patch: the overlay stratum is re-read from the store on every `_payload`, so + # read-your-writes within this runtime is a property of the design rather than of a + # write-through step somebody has to remember. (It was a write-through step while the whole + # payload was cached on a scope key — the arrangement that leaked one user's notes to + # another. See `_pool_rows`.) Cross-RUNTIME coherence is still not claimed: the module + # docstring says why, and Postgres is the fix. + out = {"ok": True, "pid": pid, "updates": accepted} + if refused: + out["refused"] = refused + return out + + +# ══════════════════════════════════ THE ROUTE-ORDER COLUMN (W38-T20 / ruling R7 / contract C1) ══ +# +# ⛔⛔ WHY THIS IS A DOOR HERE AND NOT A KIND IN THE COLUMN MENU. `ColumnMenu.onCreate` is the only +# persistence the menu has, and it lands a PER-USER `custom_` column through +# `aios_grid._field_extras`, which is a strict allowlist: a route-order bag created that way is +# silently stripped and its values are private to their author. That is the wave-19 `image` +# failure verbatim ("created, named, configured, gone", recorded in `_clean_geocode`'s own +# docstring) plus a done-when clause that cannot hold, because a colleague reading a per-user +# stratum gets a clean 200 with nothing in it. The `geocode` pseudo-kind is the precedent for the +# PICKER; its persistence half does not transfer. +# +# ⭐ SO THE COLUMN IS BORN SHARED. `shared_overlay.put_field` stores the definition verbatim (no +# allowlist), which is what lets the input fingerprint ride the DEFINITION rather than the rows — +# `shared_overlay._value` RAISES on a dict, so `{order, inputsHash}` could never be one cell, and +# one solve fingerprints the whole cohort identically anyway, so per-row would be the same string +# written N times. +# +# ⭐ AND THE NUMBER ON THE RECORD IS THE INVERSE OF THE PLANNER'S ANSWER. `mapProjection.planRoute` +# returns `order`, where `order[i]` is WHICH STOP is visited i-th; the cell holds the RANK. The +# client inverts it with `routeRanks` (gated in `map.test.ts` at the desktop shape, over a fixture +# chosen so the two differ); this door then refuses anything that is not a clean 1..N, so a +# truncated or double-posted body cannot land as a half-route. + +#: The key prefix every route-order column wears. `custom_` and `measure_` are the two existing +#: created-column namespaces and both are PER USER; this one is tenant-wide, so it takes its own +#: rather than borrowing a prefix whose readers assume a per-user home. +ROUTE_KEY_PREFIX = "route_" + +#: The marker on the stored definition that says WHAT this column is. Read by the GET below and by +#: the client; never inferred from the key, because a prefix is a naming convention and a +#: convention is not a declaration. +ROUTE_KIND = "route_order" + +#: ⛔⛔ THE TOPIC A FIELD GRANT IS NAMED UNDER, AND IT IS **NOT** THE STORE BUCKET. Two namespaces +#: meet on this column and they are spelled differently: +#: +#: the STRATUM lives at `shared_overlay.bucket(customer_data.TABLE_KEY)` +#: = `customer_table_workspace__shared` +#: the GRANT is named `shares.field_oid(, key)` +#: = `customer_data:` +#: +#: `perm_scope.hidden_keys(user, module, fields)` hands its `module` argument straight through to +#: `field_grant_hidden`, which builds the oid from it. On a `ut_*` database the module argument IS +#: the table key, so W38-T16 never had to tell them apart; on a REGISTRY topic they differ, and a +#: door that claims the grant under the bucket name writes a record the wall will never look for. +#: MEASURED, not reasoned: the first run of this ticket's gate did exactly that, and user B was +#: refused a column that had been shared with them through the real share door, with a 200 at +#: every step ([[one-question-two-normalizers]]). +SHARE_TOPIC = MODULE + + +def _route_slug(label): + """A stable store key from a human label. Lower case, non-alphanumerics collapsed to `_`.""" + import re as _re + slug = _re.sub(r"[^a-z0-9]+", "_", str(label or "").strip().lower()).strip("_") + return f"{ROUTE_KEY_PREFIX}{slug[:48]}" if slug else "" + + +def _route_defs(session: Session): + """This topic's route-order columns MINUS the ones this session was not granted. + + ⛔ THE WALL IS THE SAME ONE THE GRID USES, NOT A SECOND OPINION. `hidden_keys` is where T16 + put the per-field grant check, so filtering on it here means the listing, the grid contract + and the row payload agree by construction. A column a reader cannot see must not appear here + either: the done-when says *does not see the field at all*, and a picker that names a column + whose values are withheld has already leaked its existence. + """ + import aios_grid + import core.perm_scope as perm_scope + + all_defs = shared_fields(st=session.runtime) or {} + defs = {k: v for k, v in all_defs.items() + if isinstance(v, dict) and v.get("kind") == ROUTE_KIND} + if not defs: + return {} + hide = perm_scope.hidden_keys( + session.user, MODULE, _merge_shared_fields(list(aios_grid.FIELDS), all_defs), + st=session.runtime) + return {k: v for k, v in defs.items() if k not in hide} + + +@router.get("/customers/route-order") +def route_order_list(session: Session = Depends(module_gate(MODULE))): + """The route-order columns this session may see, with the fingerprint each was solved from. + + ⭐⭐ THE FINGERPRINT IS WHY THIS ROUTE EXISTS RATHER THAN THE CLIENT READING `fields`. + Staleness is DERIVED, never stored: what is written down is the INPUT FINGERPRINT the numbers + were produced from, so *is this order still current* is a question asked at READ time against + what is on screen NOW, and can never itself be out of date. A stored `stale: true` is a fact + about a moment that has already passed. + """ + out = [] + for key, defn in sorted(_route_defs(session).items()): + route = defn.get("route") if isinstance(defn.get("route"), dict) else {} + out.append({ + "key": key, + "label": defn.get("label") or key, + "inputsHash": str(route.get("inputsHash") or ""), + "roundTrip": bool(route.get("roundTrip")), + "startPid": route.get("startPid"), + "stops": route.get("stops"), + "solvedAt": route.get("solvedAt") or "", + "solvedBy": defn.get("createdBy") or "", + # Who may re-solve it. The same creator-or-admin wall the write door enforces, said on + # the way out so the client can grey the control instead of discovering a 403. + "mine": bool(session.admin + or str(defn.get("createdBy") or "") == session.uname), + }) + return {"fields": out} + + +@router.post("/customers/route-order") +def route_order_write(body: dict = Body(default=None), + session: Session = Depends(module_gate(MODULE))): + """Create (or re-solve) a tenant-wide route-order column and fill it, in ONE call. + + `{label, field?, ranks: {"": }, inputsHash, roundTrip?, startPid?, stops?}` + + ⛔ THE DEFINITION IS WRITTEN FIRST AND THE GRANT CLAIMED SECOND, which is `patch_shared_cell`'s + order and it is deliberate: the window where a column is MARKED and UNCLAIMED fails CLOSED + (governed, nobody granted, so only an admin and the creator see it, and an admin can share + it). The other order would leave a grant record pointing at nothing. + + ⛔ RE-SOLVING IS CREATOR-OR-ADMIN, WHICH CREATING IS NOT. Writing these numbers changes what + every account in the workspace reads, at once, for the whole cohort. That is the wall + `routes_tables.delete_shared_field` already applies to the destructive half of this stratum, + and a new door does not get to inherit the loose half of an asymmetry somebody has flagged. + A permitted teammate READS the numbers; they do not silently re-plan somebody's day. + """ + from core import shared_overlay + import core.perm_scope as perm_scope + from routes_grid import MAX_BULK_ROWS + + body = body if isinstance(body, dict) else {} + label = " ".join(str(body.get("label") or "").split())[:120] + key = str(body.get("field") or "").strip() or _route_slug(label) + if not key: + raise err(400, "bad_request", "a name is required for the route order column") + if not key.startswith(ROUTE_KEY_PREFIX): + raise err(400, "bad_field_key", + f"a route order column's key starts with '{ROUTE_KEY_PREFIX}', so it cannot " + f"collide with a column this database already owns") + import aios_grid + if key in {f.get("key") for f in aios_grid.FIELDS}: + raise err(400, "field_key_taken", + "this database already has a column with that key") + + defs = shared_fields(st=session.runtime) or {} + existing = defs.get(key) if isinstance(defs.get(key), dict) else None + if existing is not None: + # ⛔⛔ A REFUSAL MUST NOT DESCRIBE A COLUMN THE CALLER CANNOT SEE. The two refusals below + # name the column's KIND and its CREATOR, which is exactly the information the field wall + # exists to withhold: a stranger who guesses the key would otherwise learn that a route + # order exists on this database and who planned it. `routes_shares._can_see_object` makes + # the same choice for the same reason (a non-grantee gets the answer a non-existent id + # gets). The name is already taken either way, so the honest refusal says only that. + if key in perm_scope.hidden_keys( + session.user, MODULE, + _merge_shared_fields(list(aios_grid.FIELDS), defs), st=session.runtime): + raise err(400, "field_key_taken", + "that column name is already in use on this database") + if existing.get("kind") != ROUTE_KIND: + raise err(400, "not_a_route_column", + "that column is shared but it is not a route order column, so re-solving " + "it would overwrite values this door did not write") + owner = str(existing.get("createdBy") or "") + if not session.admin and owner != session.uname: + raise err(403, "forbidden", + f"a route order can be re-solved by the person who planned it or by an " + f"administrator. This one was planned by {owner or 'somebody else'}, and " + f"re-solving it would change the visit numbers for every account at once") + + raw = body.get("ranks") + if not isinstance(raw, dict) or not raw: + raise err(400, "bad_ranks", 'expected {ranks: {"": }}') + # ⛔ REPORTED, NEVER TRUNCATED (standing rule 1's second sentence, and `MAX_BULK_ROWS`' own + # note). The ceiling is `routes_grid`'s so there is ONE of them, not two that drift. + if len(raw) > MAX_BULK_ROWS: + raise err(400, "too_many_rows", + f"at most {MAX_BULK_ROWS} records per route; this one carried {len(raw)}") + + pool = allowed_pids(session) + ranks, not_in_pool, bad_value = {}, [], [] + for raw_pid, value in raw.items(): + try: + pid = int(raw_pid) + except (TypeError, ValueError): + not_in_pool.append(str(raw_pid)[:40]) + continue + # ⚠ THE POOL IS THE WALL, and it is the SAME predicate the read path applies + # (`apply_row_scope` inside `allowed_pids`), so a caller cannot number a record they + # could not be shown, including one in the other business unit. + if pid not in pool: + not_in_pool.append(str(raw_pid)[:40]) + continue + # ⛔ A BOOL IS AN `int` IN PYTHON and `True` would store as a visit number 1. Excluded by + # name rather than by hoping nobody sends one. + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + bad_value.append(str(raw_pid)[:40]) + continue + ranks[pid] = value + + # ⛔⛔ A PARTLY HONOURED ROUTE IS NOT A ROUTE, AND THIS REFUSES THE WHOLE CALL RATHER THAN + # WRITING THE PART IT COULD. Caught by this ticket's own gate: a body carrying one record + # outside the caller's book still produced a clean 1..N over what was left, so the door minted + # a permanent tenant-wide column and filled it with a SHORTER route than the one that was + # solved. Every number in it was plausible and the day was wrong. + # ⚠ REPORTED, WITH THE SAMPLE, which is standing rule 1's second sentence: the refusal names + # what it could not take, so the caller can fix it rather than guess. + if not_in_pool: + raise err(400, "rows_not_in_your_book", + f"{len(not_in_pool)} of those records are not in your book, so the route " + f"cannot be written as it was solved. First few: " + f"{', '.join(sorted(not_in_pool)[:5])}") + if bad_value: + raise err(400, "rows_not_a_visit_number", + f"a visit number is a whole number from 1 upwards; {len(bad_value)} records " + f"carried something else. First few: {', '.join(sorted(bad_value)[:5])}") + if not ranks: + raise err(400, "no_rows_in_your_book", + "none of those records are in your book, so there is nothing to number") + # ⛔ A ROUTE IS A SEQUENCE, SO THE NUMBERS ARE 1..N WITH NO REPEAT AND NO GAP. A body that + # arrives truncated, doubled or partly applied would otherwise land as a plausible half + # route: every row carrying a number, and the day in the wrong order. + seq = sorted(ranks.values()) + if seq != list(range(1, len(seq) + 1)): + raise err(400, "not_a_sequence", + f"a route order is the numbers 1 to {len(seq)}, each used once. This one " + f"carried {len(seq)} records numbered up to {seq[-1]} with " + f"{len(seq) - len(set(seq))} repeated") + + stamp = time.strftime("%Y-%m-%d %H:%M") + defn = { + "key": key, + "label": label or (existing or {}).get("label") or key, + # ⛔ `int`, NEVER `text`. A text column sorts 1, 10, 11, 2 — fully populated, entirely + # plausible, wrong, and named by no gate. `patch_shared_cell` defaults to text; this door + # cannot. + "type": "int", + "source": "overlay", + "shared": True, + "kind": ROUTE_KIND, + # ⭐⭐ THE PER-FIELD GRANT MARKER (T16). It is an EXPLICIT write-once declaration and it is + # what makes the wall fail CLOSED: a grant wall has the opposite absence-polarity to a + # deny wall, so keying visibility on "does a grant record exist" would publish this column + # to the whole tenant on one unreadable read of `object_shares`. Stamped, never inferred. + perm_scope.FIELD_GRANT_MARK: True, + "createdBy": (existing or {}).get("createdBy") or session.uname, + # ⭐ THE FINGERPRINT RIDES THE DEFINITION, ONCE. One `planRoute` run solves the whole + # cohort, so this string is identical for every record in it; per row it would be the same + # value written N times, and `shared_overlay._value` raises on a dict anyway. + "route": { + "inputsHash": str(body.get("inputsHash") or "")[:64], + "roundTrip": bool(body.get("roundTrip")), + "startPid": int(body["startPid"]) if isinstance(body.get("startPid"), int) + and not isinstance(body.get("startPid"), bool) else None, + "stops": len(ranks), + "solvedAt": stamp, + }, + } + shared_overlay.put_field(_shared_key(), key, defn, st=session.runtime) + if existing is None: + try: + import core.shares as shares + # ⚠ THE EMPTY ENTRY LIST IS THE POINT. `set_grants` keeps a record with an owner and + # no entries, so "shared with nobody" is STORED and is a different fact from "never + # shared". Without the owner the column is unmanageable: `may_administer` fails closed + # on an ownerless record, so nobody could ever share it. + shares.set_grants("field", shares.field_oid(SHARE_TOPIC, key), [], + owner=session.uname, st=session.runtime) + except Exception: # noqa: BLE001 + # The mark is already written, so a failed claim fails CLOSED: governed, nobody + # granted, admin-and-creator only. Recoverable. The other order is not. + pass + + # ⛔ AND THE RECORDS THAT LOST THEIR NUMBER ARE CLEARED. A re-solve over a SMALLER cohort + # would otherwise leave the previous run's ranks sitting on the records that dropped out — + # plausible integers, from a route nobody is driving. `""` is the house spelling of an empty + # overlay cell (`rows_from_pool` defaults an absent one to exactly that), so this needs no new + # vocabulary and no tombstone nobody else reads. + stale = {} + for raw_pid, cells in (shared_cells(pool, st=session.runtime) or {}).items(): + if not isinstance(cells, dict) or cells.get(key) in (None, ""): + continue + try: + gone = int(raw_pid) + except (TypeError, ValueError): + continue + if gone not in ranks: + stale[gone] = {key: ""} + written = shared_overlay.put_rows( + _shared_key(), {**{p: {key: v} for p, v in ranks.items()}, **stale}, + st=session.runtime) + + out = {"ok": True, "field": key, "label": defn["label"], "type": "int", + "stops": len(ranks), "cleared": len(stale), + "rows_written": len(written), "inputsHash": defn["route"]["inputsHash"], + "solvedAt": stamp} + return out