diff --git "a/api/routes_customers.py" "b/api/routes_customers.py" --- "a/api/routes_customers.py" +++ "b/api/routes_customers.py" @@ -1,1427 +1,1545 @@ -"""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 math -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 _customer_table(session): - import core.table_store as table_store - return table_store.make(_shared_key(), st=session.runtime) - - -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 field_permissions, shared_overlay - try: - field_permissions.migrate_legacy_fields( - _shared_key(), st=st, grant_topic="customer_data", shared_key=_shared_key()) - 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, session=None): - """`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)} - projected = [] - for k, f in defs.items(): - if k in have: - continue - item = dict(f, source="overlay", shared=True) - if session is not None: - from core import shares - role = shares.role_for( - "field", shares.field_oid("customer_data", k), session.uname, - is_admin=session.admin, st=session.runtime) - if role: - item["sharedRole"] = role - projected.append(item) - return list(fields or ()) + projected - - -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. - # - # ⭐⭐ OWNER I16 — `st=rt` IS WHAT MAKES A WALL ON A USER-GENERATED COLUMN MEAN ANYTHING - # HERE. *"Permission Filters must be able to filter on user-generated Fields too."* The - # sentence above is exactly why it was needed: the canonical list has no `custom_` column in - # it and these rows are PRE-OVERLAY, so such a leaf denied every row while the editor - # reported the rule saved. With the handle, `perm_scope._enrich_for_wall` merges the - # tenant-wide value for the named column onto a COPY of each row and declares it for the - # evaluator. Nothing else about this call changes, and a caller with no handle still gets - # the wall exactly as it was. - # - # ⛔ THE SAME HANDLE GOES TO `allowed_pids` BELOW, AND THE PAIR IS NOT OPTIONAL. That is the - # WRITE wall to this one's READ wall; lending it here alone would make a user-generated rule - # narrow what an account SEES while leaving what it may PATCH untouched. - rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, aios_grid.FIELDS, - st=rt) - 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, session=session) - # 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, table=_customer_table(session), st=session.runtime, - 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. - - ⛔ AND `st` IS PART OF "SAME FUNCTION, SAME ORDER" (owner I16). `grid_assembly` lends the - tenant handle so a wall naming a user-generated column can be answered at all; without the - identical argument here that rule would narrow the READ and not the WRITE — the drift this - docstring already refuses, wearing a new argument's clothes. - """ - 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, st=session.runtime) - 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) - - -def _split_route_updates(session, updates): - """Split a cell PATCH into `(route order updates, everything else)`. - - ⛔ MATCHED ON THE DECLARED `kind`, NEVER ON THE `route_` PREFIX — the rule `route_order_delete` - and the column menu both state. A prefix is a naming convention; the kind is a declaration. - """ - defs = shared_fields(st=session.runtime) or {} - route, rest = {}, {} - for key, value in (updates or {}).items(): - defn = defs.get(key) - if isinstance(defn, dict) and defn.get("kind") == ROUTE_KIND: - route[key] = value - else: - rest[key] = value - return route, rest - - -def _patch_route_ranks(session, ctx, pid, pool, updates): - """Write visit numbers into the SHARED stratum, keeping the column a permutation. - - ⭐⭐ OWNER ITEM 7 (2026-08-23) — THE AUTO-DEDUPE, IN THE OWNER'S OWN WORDS: *"when you edit a - number from say 13 to 14, what ever was record 14 should automatically change to 13 so its - auto dedupe that way."* That is a TRANSPOSITION, and it is the whole rule: the number you - typed goes on your record, and the number you displaced goes to whoever was holding it. Two - rows move, the set of numbers in the column is unchanged, and no other record is renumbered. - - Three cases, and each one is the same rule read honestly: - - · the target number is FREE -> plain assignment. A gap is not a duplicate, and the owner's - rule is about duplicates. - · the target is HELD and this record already had a number -> the two swap. The named case. - · the target is HELD and this record had NO number (it was not on the route) -> there is no - old number to hand over, so the displaced record goes to the end (`max + 1`). Still one - other row moved, still no duplicate, and nothing loses its place on the route silently. - - Blanking a cell takes the record OFF the route and frees its number. It renumbers nothing: - re-solving is what closes the gaps, and doing it here would silently rewrite a day somebody - is driving. - - ⛔⛔ THE WALLS ARE `grid_events`' OWN, CALLED AND NOT COPIED, AND THAT IS THE PRICE OF - STEPPING OUT OF `overlay_patch`. `_may_edit_field_value` is where the shared-field Share role, - the creator rule and the admin rule already meet; the hidden-key wall is applied first for the - reason `overlay_patch` applies it, namely that `hidden_keys` decides what this caller may READ - and a column they cannot read is not one they may write by naming its key. A second spelling - of either here would be a second one to keep in step — which is the whole reason - `patch_customer` routes everything else through `grid_events` rather than writing the store. - - ⚠ UNIQUENESS IS MAINTAINED OVER THE ROWS THIS CALLER CAN SEE, and that is a limit worth - stating rather than hiding. `shared_overlay.cells` takes the scoped pool BY SIGNATURE (there is - deliberately no "every shared cell in the tenant" call), so the holder of a displaced number is - looked for inside this session's book. In practice the whole column lives there anyway -- - `route_order_write` REFUSES a body carrying a pid outside the writer's pool, so every number in - a route order was written by somebody whose book contained all of them. - """ - from core import grid_events, shared_overlay - - defs = shared_fields(st=session.runtime) or {} - hidden = _hidden_for(session) - #: `{key: what the store holds for THIS record now}`, for every key asked about — refused ones - #: included. See the `_stored` note below. - accepted = {} - #: The keys that actually moved. `patch_customer` reports everything else as REFUSED, so this - #: has to be separate from `accepted`: a refused key still reports a value, and it is the - #: value the caller did NOT ask for. - taken, swaps = set(), [] - - def _held(): - """`{pid: rank}` for this column, over the rows this caller can see. - - Re-read per key rather than hoisted, because a multi-key patch writes between iterations - and the second key must see the first one's result. - """ - out = {} - for raw_pid, cells in (shared_cells(pool, st=session.runtime) or {}).items(): - if not isinstance(cells, dict): - continue - cell = cells.get(key) - if cell in (None, ""): - continue - try: - out[int(raw_pid)] = int(str(cell).strip()) - except (TypeError, ValueError): - continue - return out - - for key, raw in updates.items(): - defn = defs.get(key) or {} - held = _held() - - def _stored(): - """⛔⛔ A REFUSAL REPORTS WHAT THE CELL ACTUALLY HOLDS, AND THAT IS NOT TIDINESS. - - `patchTopicRow` adopts `body.updates` into the browser's optimistic copy and rolls - back only on a non-2xx. A refused key that is simply ABSENT from `updates` is a key - the client never hears about: the response is 200, the rollback never fires, `adopt` - has nothing to write, and the typed text goes on painting a value the store rejected - until something unrelated forces a refetch. On a `select` the option normaliser made - this unreachable; item 7 made the column an `int`, so `14.5` or a stray letter is now - an ordinary typo away. `patchTopicRow`'s own docstring already states the rule — - keep only what the server actually took — and this is that rule with its hole closed. - """ - n = held.get(pid) - return "" if n is None else str(n) - - if key in hidden: - accepted[key] = _stored() - continue - if not grid_events._may_edit_field_value(ctx, key, defn): - accepted[key] = _stored() - continue - # An empty cell is "not on this route". Legal, and the only way to take a stop off the - # day without re-solving the whole column. - if raw is None or (isinstance(raw, str) and not raw.strip()): - shared_overlay.put_rows(_shared_key(), {pid: {key: ""}}, st=session.runtime) - accepted[key] = "" - taken.add(key) - continue - # ⛔ A BOOL IS AN `int` IN PYTHON, so `True` would store as visit number 1. Excluded by - # name here exactly as `route_order_write` excludes it, rather than by hoping. - if isinstance(raw, bool): - accepted[key] = _stored() - continue - try: - want = int(str(raw).strip()) - except (TypeError, ValueError): - accepted[key] = _stored() - continue - if want < 1: - accepted[key] = _stored() - continue - - prior = held.get(pid) - if prior == want: - accepted[key] = str(want) - taken.add(key) - continue - writes = {pid: {key: str(want)}} - holder = next((p for p, n in held.items() if n == want and p != pid), None) - if holder is not None: - moved = prior if prior is not None else max(held.values()) + 1 - writes[holder] = {key: str(moved)} - swaps.append({"pid": holder, "field": key, "value": str(moved)}) - shared_overlay.put_rows(_shared_key(), writes, st=session.runtime) - accepted[key] = str(want) - taken.add(key) - return accepted, swaps, taken - - -@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), table=_customer_table(session), - st=session.runtime) - - # ⭐⭐ OWNER ITEM 7 (2026-08-23) — A ROUTE RANK IS EDITED HERE, NOT THROUGH `overlay_patch`. - # - # ⚠ AND **NOT** BECAUSE THE ORDINARY PATH WRITES THE WRONG STRATUM. It does not: - # `table_store.patch_overlay` has split tenant-wide keys off to `shared_overlay` since W38-T20 - # (D-423), so a route cell already lands where every reader looks. What the ordinary path - # cannot do is the half the owner actually asked for — *"make sure that none of the number can - # be a duplicate ... when you edit a number from say 13 to 14, what ever was record 14 should - # automatically change to 13"*. That is a write to a SECOND record, decided by the first one's - # OLD value, and nothing in `overlay_patch` has a reason to look at either. - # - # ⛔ IT ALSO OWNS THE VALIDATION, because item 7 changed the column's type. As a `select` the - # declared choices refused anything that was not a rank; as an `int` `overlay_patch` stores - # whatever string it is handed, and "third" in a visit-order column is not a slightly wrong - # value — it is a stop with no place in the sequence. - # - # ⛔ INTERCEPTED SERVER-SIDE, NEVER IN THE CLIENT. Every path that can change a cell — typing, - # paste, fill, undo, the record drawer — comes through this one door; a client-side branch - # would have to be repeated at each of them and would be wrong at the first one nobody - # remembered. - route_updates, updates = _split_route_updates(session, updates) - - if updates: - 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") - - route_accepted, route_swaps, route_taken = ({}, [], set()) - if route_updates: - route_accepted, route_swaps, route_taken = _patch_route_ranks( - session, ctx, pid, pool, route_updates) - - # 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, **route_accepted}} - # ⚠ `route_taken` AND NOT `route_accepted`. A refused rank still reports a value — the one - # the store holds — so membership of `updates` no longer means the write landed. - refused = [k for k in refused if k not in route_updates] - refused += [k for k in route_updates if k not in route_taken] - if refused: - out["refused"] = sorted(refused) - # ⭐ THE SWAP CHANGED A ROW THE CLIENT NEVER TYPED IN, so it has to be told. The optimistic - # copy in the browser covers `pid` only; the record that gave up the number it held would go - # on painting the old one until something else forced a refetch. `patchTopicRow` reads this - # key and drops the rows cache. - if route_swaps: - out["routeSwaps"] = route_swaps - 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 _occupied(defs, base_fields=()): - """The keys and case-folded labels a new route column must not land on.""" - occupied_keys = {str(key) for key in (defs or {})} - occupied_labels = set() - for field in list((defs or {}).values()) + list(base_fields or ()): - if not isinstance(field, dict): - continue - label = " ".join(str(field.get("label") or "").split()).casefold() - if label: - occupied_labels.add(label) - key = str(field.get("key") or "").strip() - if key: - occupied_keys.add(key) - return occupied_keys, occupied_labels - - -def _next_route_label(defs, base_fields=()): - """Return the next human route-field label without colliding with a visible field. - - ⭐ OWNER ITEM 5 (2026-08-23) — THE WORD IS "ROUTE", NOT "DESTINATION". Owner: *"Instead of - calling it 'Destination 1' etc. for when we saved the route to a field, we just call it - 'Route 1' so it's Route 1, Route 2, Route 3."* A saved column holds the ORDER of a whole day, - so "Destination 1" read as the first stop rather than as the first route, which is exactly - backwards from what the numbers in it mean. - - ⛔ NO MIGRATION, AND THAT IS DELIBERATE. Columns already minted as `Destination N` keep their - label: rewriting a tenant-wide column name that other people's saved views point at is not a - rename this door was asked for. The owner renames one through `route_order_rename` below, per - owner item 6 of the same instruction. - - ⚠ THE KEY IS STILL DERIVED FROM THE LABEL, so the default now slugs to `route_route_1`. The - redundant segment is NOT tidied away: `ROUTE_KEY_PREFIX` is what keeps a route column out of - the namespace this database owns, and stripping it here would make the labels "Route 1" and - "1" collide on one key. The key is never on screen. - """ - occupied_keys, occupied_labels = _occupied(defs, base_fields) - n = 1 - while True: - label = f"Route {n}" - if label.casefold() not in occupied_labels and _route_slug(label) not in occupied_keys: - return label - n += 1 - - -def _clean_route_depot(raw): - """The optional origin stored once on a route-order definition, never on a customer cell.""" - if raw is None: - return None - if not isinstance(raw, dict): - raise err(400, "bad_depot", "a depot is an address with latitude and longitude, or null") - address = " ".join(str(raw.get("address") or "").split())[:200] - lat, lon = raw.get("lat"), raw.get("lon") - if (not address or isinstance(lat, bool) or isinstance(lon, bool) or - not isinstance(lat, (int, float)) or not isinstance(lon, (int, float))): - raise err(400, "bad_depot", "a depot needs an address and numeric latitude and longitude") - lat, lon = float(lat), float(lon) - if not (math.isfinite(lat) and math.isfinite(lon) and abs(lat) <= 90 and abs(lon) <= 180): - raise err(400, "bad_depot", "the depot latitude or longitude is outside the map") - return {"address": address, "lat": lat, "lon": lon} - - -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} - - -def _own_route_fork(session: Session, key: str): - """This caller's PRIVATE copy of a route column, if a per-user write ever forked one. - - ⛔⛔ THE FORK IS REAL, AND IT IS WHAT BROKE DELETE. Traced through `grid_events.field_upsert` - rather than assumed: `shared_field` is `bool(shared_prior.get('custom'))` and a route - definition carries no `custom` key, so the shared branch does not take it; the key is neither - `custom_` nor `measure_`; it IS in the merged contract, so the `key in field_by_key` branch - does — and that branch's own body is what writes `note`, `format` and `agg`, landing them in - THIS USER'S field definitions through `TableStore.save_field`. `_merge_shared_fields` then - SKIPS the tenant-wide definition, because a key the contract already declares wins, and from - that moment the person is reading a private copy of a shared column. - - ⚠ WHICH DOORS STILL FORK, AS OF 2026-08-23, because "the fork is fixed" would be too broad. - The Description and the Name are CLOSED: owner item 6 routed `onNote` and `onRename` to - `route_order_rename` below. The Edit-field pane's **Format** row (`onFormat`, unconditional) - and its **Summary** row (`onAggregate`, whose `isUserTable` arm is false on this registry - topic) both still reach `saveField`, so either one still mints a fork. They are left open on - purpose: closing them needs this door to accept `format` and `agg`, which is a wider change - than the delete the owner reported. This function is what keeps that recoverable rather than - permanent. - - ⛔ WHY THAT MADE THE DELETE 404 RATHER THAN MERELY MISBEHAVE. The first delete found the - tenant-wide definition, dropped it and answered 200 — and the column came straight back on the - next read, because the fork was still declaring it. The second attempt found nothing in - `shared_fields` and answered *"that column is not a route order column on this database"* - about a column sitting on screen. Owner, 2026-08-23: *"I can't even delete the Field route - now?"* Both halves are answered here: a fork is FOUND, and `route_order_delete` drops it WITH - the definition rather than leaving it to redeclare the column. - - ⚠ `kind` IS THE TEST, NEVER MERE PRESENCE. `TableStore.workspace` merges the tenant-wide - column summary over this stratum and will mint a bare `{'agg': ...}` entry for a key the user - has never touched (W36-T25, whose own comment says it must be able to CREATE an entry). - Reading presence would call that stub a fork and scrub a column nobody had forked. - """ - try: - ws = _customer_table(session).workspace(session.uname, consume_corrections=False) - except Exception: # noqa: BLE001 - return None - entry = (ws.get("fields") or {}).get(key) - if not isinstance(entry, dict) or entry.get("kind") != ROUTE_KIND: - return None - return entry - - -@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"), - "depot": route.get("depot") if isinstance(route.get("depot"), dict) else None, - "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), - }) - # ⭐⭐ OWNER ITEM 5 — THE DEFAULT NAME IS ALLOCATED **HERE**, NEVER ON THE CLIENT. - # - # The panel prompts for a route name and pre-fills it. Computing that pre-fill from the - # `fields` list above would compute it from the columns this session may SEE: `_route_defs` - # drops every route column the per-field wall hides, so a user with no grant on - # `route_route_1` would be offered "Route 1" as their default, send it, and be refused - # `field_key_taken` on a name the app itself put in the box. Allocated over the WHOLE shared - # stratum, the suggestion can never name a column that already exists. - # - # ⚠ IT LEAKS NOTHING. What travels is the first FREE name, which is a fact about absence. - import aios_grid - return {"fields": out, - "nextLabel": _next_route_label(shared_fields(st=session.runtime) or {}, - aios_grid.FIELDS)} - - -@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?, depot?}` - - When `label` is omitted for a new column, the door allocates the next unused `Destination N` - label. This keeps route creation one-click while preserving unique, readable field names. - - ⛔ 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] - import aios_grid - defs = shared_fields(st=session.runtime) or {} - requested_key = str(body.get("field") or "").strip() - if requested_key: - key = requested_key - elif label: - key = _route_slug(label) - else: - label = _next_route_label(defs, aios_grid.FIELDS) - key = _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") - 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") - - 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") - - if "depot" in body: - depot = _clean_route_depot(body.get("depot")) - else: - prior_route = (existing or {}).get("route") if isinstance(existing, dict) else {} - depot = prior_route.get("depot") if isinstance(prior_route, dict) else None - - 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, - # ⭐⭐ OWNER ITEM 7 (2026-08-23) — THE SAVED ROUTE IS A **NUMBER** COLUMN. - # - # Owner: *"the Route field once saved should be saved as a number Field. Make sure we can - # edit it, BUT also make sure that none of the number can be a duplicate, so its like a - # number order field."* It used to be a `select` whose declared choices were the strings - # "1".."N", which sorted and rendered as a list rather than as an order and forced a - # re-solve to widen the vocabulary before a stop could be numbered past N. - # - # ⛔ UNIQUENESS IS NOT A PROPERTY OF THE TYPE, SO IT LIVES AT THE WRITE DOOR. `int` has no - # "no duplicates" flag anywhere in this contract; `_patch_route_ranks` in `patch_customer` - # is what keeps the column a permutation, by SWAPPING rather than by refusing. - # - # ⚠ EXISTING COLUMNS ARE NOT MIGRATED, they are UPGRADED BY RE-SOLVE. This dict is - # rebuilt whole on every write, so the first re-solve of a `Destination N` column turns it - # into a number column; one nobody re-solves keeps working as the select it was, and the - # write door reads the KIND rather than the type, so both edit identically. - "type": "int", - "source": "overlay", - "shared": True, - "kind": ROUTE_KIND, - # ⭐⭐ OWNER, 2026-08-23 — A NEW ROUTE READS AS **PRIVATE**, NEVER "Shared with everyone". - # - # Owner: *"Right now the Route is 'Shared with everyone' in terms of the Field status when - # i check the Route Field. It should always default to private first."* - # - # ⛔ A CLASSIFICATION FIX, NOT A TIGHTENING, AND THE DIFFERENCE IS THE WHOLE POINT. This - # column was ALREADY private in the only sense that governs a reader: `FIELD_GRANT_MARK` - # plus the empty-entry grant claimed below means creator-and-admin and nobody else. What - # was wrong was the WORD ON SCREEN. `FieldsHidePanel` sections the field list by - # `types.fieldEditMode`, which is `cleanFieldPermissions(field.permissions, - # "collaborative")` — so a definition carrying NO permissions bag fell to that fallback - # and was filed under "Shared with everyone" while being shared with nobody at all. - # - # ⚠ AND IT MOVES NO WALL, CHECKED RATHER THAN REASONED. `grid_events._may_edit_field_value` - # short-circuits on `definition.get('shared') or definition.get('granted')` BEFORE it reads - # `stored_permissions`, so the creator's own rank edits and `_patch_route_ranks`' swap are - # decided by `_field_share_role` and not by this bag; the client twin `mayEditField` takes - # the same branch in the same order. `field_permissions.migrate_legacy_fields` only ever - # rewrites a PER-USER field carrying `custom: True`, which a route definition is not, so it - # leaves this key alone. `fieldEditMode` has exactly one other consumer: none. - # - # ⛔ NO MIGRATION, AND IT IS THE SAME DELIBERATE CHOICE `_next_route_label` MAKES ABOUT - # `Destination N`. This dict is rebuilt whole on every write, so a column minted before - # today gains the bag on its next RE-SOLVE and not one moment sooner — until then it - # keeps reading "Shared with everyone" in the Hide fields panel while being shared with - # nobody. Backfilling every stored route definition on a read is a write nobody asked - # for, on a tenant-wide stratum, triggered by opening a page; re-solving is one click and - # it is the click the owner is already making. Stated here rather than left to be - # rediscovered as "the fix did not work". - "permissions": {"edit": "personal"}, - # ⭐⭐ 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), - "depot": depot, - "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: str(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"], - "depot": defn["route"]["depot"], - "solvedAt": stamp} - return out - - -@router.delete("/customers/route-order/{field_key}") -def route_order_delete(field_key: str, - session: Session = Depends(module_gate(MODULE))): - """Remove a Destination column and every visit number in it. - - ⛔⛔ WHY THIS DOOR HAD TO EXIST. `route_order_write` mints a TENANT-WIDE column and nothing - could ever remove it: the grid's own Delete is offered for a per-user `custom_` column - (`menuField.custom && !menuField.shared`) or for a `ut_*` definition field, and a route order - is neither — it is a SHARED column on a registry topic. So `Destination 1` was permanent for - the whole workspace, which is [[reachable-is-not-the-same-as-built]] from the other end: - `shared_overlay.drop_field` was complete and correct and no route on this topic called it. - - ⛔ CREATOR-OR-ADMIN, the same wall `routes_tables.delete_shared_field` applies and for the - same reason: writing a cell changes a value, dropping the column deletes that value for every - account at once. It is deliberately NOT the looser wall `route_order_write` uses for CREATE. - - ⛔ AND A CALLER WHO CANNOT SEE THE COLUMN GETS THE ANSWER A NONEXISTENT KEY GETS. The refusals - below would otherwise teach a stranger that a route order exists on this database and who - planned it, which is exactly what the per-field wall withholds — the choice already argued at - `route_order_write`'s `field_key_taken` and in `routes_shares._can_see_object`. - """ - from core import shared_overlay - import core.perm_scope as perm_scope - import core.table_store as table_store - import aios_grid - - key = str(field_key or "").strip() - all_defs = shared_fields(st=session.runtime) or {} - defn = all_defs.get(key) if isinstance(all_defs.get(key), dict) else None - # ⭐⭐ OWNER, 2026-08-23 — A PRIVATE FORK IS ALSO A COLUMN TO DELETE. See `_own_route_fork` - # above: once any per-user write has forked this key, the fork is what the person is looking - # at and it OUTLIVES a drop of the tenant-wide definition. Answering 404 about a column that - # is on screen is exactly the refusal the owner hit. - fork = _own_route_fork(session, key) - subject = defn or fork - unknown = err(404, "unknown_field", - "that column is not a route order column on this database") - if not subject: - raise unknown - # The wall FIRST, so a hidden column is indistinguishable from an absent one. - # ⚠ ASKED ABOUT THE TENANT-WIDE DEFINITION ONLY, and that is not a hole. The wall reads - # `FIELD_GRANT_MARK` off the MERGED contract; with the definition already gone there is no - # marked column left for it to hide, and a fork lives in this caller's OWN stratum, which no - # grant has ever governed. Not asking when there is nothing to ask about beats asking of a - # contract that no longer carries the key and reading the empty answer as "not hidden". - if defn and key in perm_scope.hidden_keys( - session.user, MODULE, - _merge_shared_fields(list(aios_grid.FIELDS), all_defs), st=session.runtime): - raise unknown - # ⛔ KIND-CHECKED, NOT PREFIX-CHECKED. `ROUTE_KEY_PREFIX` is a naming convention and a - # convention is not a declaration (the comment on `ROUTE_KIND` says so); a door that deleted - # by prefix would happily drop a shared column somebody else's feature owns. - if subject.get("kind") != ROUTE_KIND: - raise err(400, "not_a_route_column", - "that column is shared but it is not a route order column, so this door will " - "not remove it") - owner = str(subject.get("createdBy") or "") - if not session.admin and owner != session.uname: - raise err(403, "forbidden", - f"a route order column can be removed by the person who planned it or by an " - f"administrator. This one was planned by {owner or 'somebody else'}, and " - f"removing it would delete the visit numbers for every account at once") - - dropped = bool(defn) and shared_overlay.drop_field(_shared_key(), key, st=session.runtime) - # ⛔⛔ AND THE FORK GOES IN THE SAME CALL. Dropping only the tenant-wide definition is what - # made the first delete look like it had worked and then undo itself: the fork still declares - # the column, `_merge_shared_fields` still yields to it, and the next paint brings it back. - # `delete_field` scrubs this user's overlay values under the key too, which is right: the - # tenant-wide cells went with `drop_field` above, and a private leftover would resurface under - # whatever column later took the key. - if fork is not None: - try: - table_store.make(_shared_key(), st=session.runtime).delete_field(session.uname, key) - except Exception: # noqa: BLE001 - # The definition is already gone, so a failed fork scrub must not turn a delete that - # succeeded into a 500. Worst case the column lingers for this one account until its - # next write, which is recoverable; a 500 over completed work is not. - pass - # ⭐⭐ THE GRANT DIES WITH THE COLUMN. `route_order_write` claims - # `shares.field_oid(SHARE_TOPIC, key)` on create, so skipping this would leave a grant record - # pointing at nothing — a ghost in every receiver's "Shared with me" that 404s on open, and - # worse, one that silently re-arms on the next column to take the key, because `drop_field` - # scrubs the cells precisely so the key CAN be re-used. - # ⚠ Same order and same tolerance as `routes_tables.delete_shared_field`: the column is - # already gone, so a failed release must not turn a completed delete into a 500. - try: - import core.shares as shares - shares.drop_objects([("field", shares.field_oid(SHARE_TOPIC, key))], st=session.runtime) - except Exception: # noqa: BLE001 - pass - return {"ok": True, "field": key, "dropped": bool(dropped or fork is not None)} - - -@router.patch("/customers/route-order/{field_key}") -def route_order_rename(field_key: str, body: dict = Body(default=None), - session: Session = Depends(module_gate(MODULE))): - """Rename a route order column, or write its description. `{label?, note?}`. - - ⭐⭐ OWNER ITEM 6 (2026-08-23) — "Edit field" MUST BE ABLE TO RENAME THIS COLUMN. - - A route order is a SHARED column on the customers REGISTRY topic. The grid's Edit-field pane - offers a Name box only when the host supplies `onRename`, and the host supplies it only for - `isUserSchemaField` — a per-user `custom_` column, or a `ut_*` definition field. A route order - is neither, so the pane showed a DISABLED Name box reading *"A source field keeps its name and - type from the data source"* about a column the person had created themselves an hour earlier. - That is the same shape as owner item 3's missing Delete, one stratum over. - - ⛔⛔ THE KEY IS FROZEN. `_route_slug` derives the store key from the label AT CREATION, and - only there. Re-slugging on a rename would leave every written visit number sitting under the - old key in `shared_overlay`, every saved view's `colId` pointing at a column that no longer - exists, and every field grant naming an oid nobody can reach — a rename that looks perfect on - a fresh column and quietly empties a used one. The label moves; nothing else does. - - ⛔ CREATOR-OR-ADMIN, and a caller who cannot SEE the column gets the answer a nonexistent key - gets. Both walls are `route_order_delete`'s, verbatim and for its reasons: this changes what - every account in the workspace reads, and a refusal that said "forbidden" would confirm to a - stranger that a route order exists here and who planned it. - - ⛔⛔ THE DESCRIPTION RIDES THIS DOOR TOO, AND IT HAD TO. The Edit-field pane writes a - description through `onNote`, which is `saveField` — a PER-USER `field_upsert`. Traced through - `grid_events`: a route key is not `custom`-marked in the shared stratum, so the `shared_field` - branch does not take it; it IS in the merged contract, so the `key in field_by_key` branch - does, and it stores `{**base, note}` in THIS USER'S field definitions. `_merge_shared_fields` - then skips the shared definition, because a key the contract already declares wins — so that - user is left reading a private copy of a tenant-wide column, frozen at the label it had when - they typed the description, and a later rename through this door is invisible to them. - Pre-existing, and unreachable enough to have gone unnoticed; owner item 6 makes that pane the - place people go for route columns, so it stops being unreachable on the same day. - """ - from core import shared_overlay - import core.perm_scope as perm_scope - import aios_grid - - body = body if isinstance(body, dict) else {} - key = str(field_key or "").strip() - label = " ".join(str(body.get("label") or "").split())[:120] - # ⚠ `"note" in body` AND NOT a truthiness test: an empty string is how a description is - # CLEARED, and a door that read it as "unchanged" would leave one nobody can delete. - note = str(body.get("note") or "")[:2000] if "note" in body else None - if not label and note is None: - raise err(400, "bad_request", "a route order column needs a name") - if "label" in body and not label: - raise err(400, "bad_request", "a route order column needs a name") - - all_defs = shared_fields(st=session.runtime) or {} - defn = all_defs.get(key) if isinstance(all_defs.get(key), dict) else None - unknown = err(404, "unknown_field", - "that column is not a route order column on this database") - if not defn: - raise unknown - if key in perm_scope.hidden_keys( - session.user, MODULE, - _merge_shared_fields(list(aios_grid.FIELDS), all_defs), st=session.runtime): - raise unknown - if defn.get("kind") != ROUTE_KIND: - raise err(400, "not_a_route_column", - "that column is shared but it is not a route order column, so this door will " - "not rename it") - owner = str(defn.get("createdBy") or "") - if not session.admin and owner != session.uname: - raise err(403, "forbidden", - f"a route order column can be renamed by the person who planned it or by an " - f"administrator. This one was planned by {owner or 'somebody else'}, and its " - f"name is what every account in the workspace reads") - - # ⚠ THE COLLISION CHECK LOOKS AT LABELS AND NOT AT KEYS, because the key is frozen: this - # rename can never take another column's key, only its NAME. Two columns wearing one name on - # the same grid is the confusion `_next_route_label` exists to prevent at creation, so the - # rename door refuses it too. The column's OWN current label is excluded, so re-saving an - # unchanged name is a no-op rather than a refusal. - _keys, occupied_labels = _occupied( - {k: v for k, v in all_defs.items() if k != key}, aios_grid.FIELDS) - if label and label.casefold() in occupied_labels: - raise err(400, "field_label_taken", - "this database already has a column with that name") - - patch = dict(defn) - if label: - patch["label"] = label - if note is not None: - patch["note"] = note - shared_overlay.put_field(_shared_key(), key, patch, st=session.runtime) - return {"ok": True, "field": key, "label": patch.get("label") or key, - "note": patch.get("note") or ""} +"""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 math +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 _customer_table(session): + import core.table_store as table_store + return table_store.make(_shared_key(), st=session.runtime) + + +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 field_permissions, shared_overlay + try: + field_permissions.migrate_legacy_fields( + _shared_key(), st=st, grant_topic="customer_data", shared_key=_shared_key()) + 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, session=None): + """`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)} + projected = [] + for k, f in defs.items(): + if k in have: + continue + item = dict(f, source="overlay", shared=True) + if session is not None: + from core import shares + role = shares.role_for( + "field", shares.field_oid("customer_data", k), session.uname, + is_admin=session.admin, st=session.runtime) + if role: + item["sharedRole"] = role + projected.append(item) + return list(fields or ()) + projected + + +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. + # + # ⭐⭐ OWNER I16 — `st=rt` IS WHAT MAKES A WALL ON A USER-GENERATED COLUMN MEAN ANYTHING + # HERE. *"Permission Filters must be able to filter on user-generated Fields too."* The + # sentence above is exactly why it was needed: the canonical list has no `custom_` column in + # it and these rows are PRE-OVERLAY, so such a leaf denied every row while the editor + # reported the rule saved. With the handle, `perm_scope._enrich_for_wall` merges the + # tenant-wide value for the named column onto a COPY of each row and declares it for the + # evaluator. Nothing else about this call changes, and a caller with no handle still gets + # the wall exactly as it was. + # + # ⛔ THE SAME HANDLE GOES TO `allowed_pids` BELOW, AND THE PAIR IS NOT OPTIONAL. That is the + # WRITE wall to this one's READ wall; lending it here alone would make a user-generated rule + # narrow what an account SEES while leaving what it may PATCH untouched. + rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, aios_grid.FIELDS, + st=rt) + 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, session=session) + # ⭐⭐ W41-T01 / RULING R5 / CONTRACTS C1 + C8 — **THE THREE BADGES, STAMPED ONCE, HERE.** + # + # C1 makes `field_permissions.field_class` the ONE producer of `{origin, audience, sharedBy, + # values, owner}`; C8 fixes the wire key as `class`. This is the only line on the customer + # topic that calls it. + # + # ⛔ IN `grid_assembly` AND NOT IN `_merge_shared_fields`, WHICH IS THE OTHER CANDIDATE AND IS + # THE WRONG ONE FOR TWO SEPARATE REASONS. First, that helper cannot badge EVERY column: it + # returns `fields` untouched when `defs` is empty and otherwise only builds the PROJECTED + # tenant-wide additions, so the canonical contract and every `custom_`/`measure_` column would + # leave it unbadged — and the done-when is every field. Second, it is called from five places + # (`_hidden_for`, `_route_defs`, and the route write/delete/rename doors) that want a field + # list purely to ask the WALL a question and throw it away; badging there is a registry read + # per column on paths that never reach a client. `grid_assembly` is the single assembly BOTH + # doors serve verbatim — `_payload` returns `g["fields"]` on `/customers` and `routes_grid` + # assigns `workspace["fields"] = g["fields"]` on `/workspace` — so stamping here is the only + # position in which the two wires cannot disagree about a column's badges. + # + # ⚠ AFTER THE MERGE AND BEFORE THE WALL, and the position is load-bearing in both directions. + # After, or the tenant-wide columns (Supplier's twins, every route column) are not in the list + # to badge. Before, so `hidden_keys` below deletes the badge along with the column it belongs + # to: a reader who may not see a column never receives a `class` bag describing who owns it or + # who it is shared with. Stamping after the wall would leave the badge on nothing; stamping + # somewhere later would put it past the narrowing entirely. + # + # ⚠ `values_shared=set(_defs)` REUSES THE DICT READ ONCE AT THE TOP OF THIS ASSEMBLY, so the + # third badge costs no store read at all, and `field_classes` opens `object_shares` ONCE for + # the whole list rather than once per column (see its docstring — that is the D-214 shape). + # ⚠ THE TWO KEYS ARE DIFFERENT STRINGS: `_shared_key()` names the BUCKET, `"customer_data"` + # names the registry TOPIC — the same pair `shared_fields` and `_merge_shared_fields` already + # pass, and swapping them finds no grant for any column. + # + # ⛔ A NEW DICT PER COLUMN, NEVER AN IN-PLACE STAMP. These entries are per-request copies today + # (`aios_grid.fields_from_workspace` does `field = dict(base)`, `_merge_shared_fields` does + # `dict(f, ...)`) — but `class` carries THIS viewer's `sharedBy` and `owner`, so an aliased + # definition would be one account's answer served to the next reader of the same cached + # structure. The copy makes that impossible to reintroduce rather than merely untrue now, which + # is the same rule `_payload` states over `rows_src`. + # ⛔ AND ONLY `class`. C8's `usage`, `agg` and `descriptionEdited` belong to other tickets, and + # C8 is explicit that an absent key means "not built yet", never "false". + try: + from core import field_permissions as _fp + _classes = _fp.field_classes(fields, session.uname, table_key=_shared_key(), + grant_topic=SHARE_TOPIC, values_shared=set(_defs), st=rt) + fields = [dict(f, **{"class": _classes[f["key"]]}) + if isinstance(f, dict) and _classes.get(f.get("key")) else f + for f in fields] + # ⭐⭐ C8's `descriptionEdited`, ON THE SAME PASS AND FOR A MEASURED REASON. W41-T11 shipped + # the producer (`user_tables.description_edited`, R19's custody mark scoped to the + # description) and stopped at its own one-file fence; an audit across the lane branches + # afterwards found `filter-kit/fieldClass.ts::descriptionEditedOf` ALREADY READING THIS KEY + # with nothing sending it. A consumer waiting on a producer is the same dead feature as the + # reverse, and both halves were green. + # ⚠ IT RIDES THE `class` TRY DELIBERATELY. Both are display reads on the same list at the + # same position, so one lenient block is one failure mode instead of two, and a tenant whose + # registry will not open loses both badges together rather than half a row. + from core import user_tables as _ut_desc + fields = [dict(f, descriptionEdited=_ut_desc.description_edited(f)) + if isinstance(f, dict) else f + for f in fields] + except Exception: # noqa: BLE001 + # Lenient like every other display read on this assembly. C8's absent-key polarity is what + # makes this degrade safe: a client renders NOTHING for a missing `class` rather than a + # wrong badge, so a registry that will not open costs the badges and not the grid. + pass + # 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, table=_customer_table(session), st=session.runtime, + 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. + + ⛔ AND `st` IS PART OF "SAME FUNCTION, SAME ORDER" (owner I16). `grid_assembly` lends the + tenant handle so a wall naming a user-generated column can be answered at all; without the + identical argument here that rule would narrow the READ and not the WRITE — the drift this + docstring already refuses, wearing a new argument's clothes. + """ + 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, st=session.runtime) + 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) + + +def _split_route_updates(session, updates): + """Split a cell PATCH into `(route order updates, everything else)`. + + ⛔ MATCHED ON THE DECLARED `kind`, NEVER ON THE `route_` PREFIX — the rule `route_order_delete` + and the column menu both state. A prefix is a naming convention; the kind is a declaration. + """ + defs = shared_fields(st=session.runtime) or {} + route, rest = {}, {} + for key, value in (updates or {}).items(): + defn = defs.get(key) + if isinstance(defn, dict) and defn.get("kind") == ROUTE_KIND: + route[key] = value + else: + rest[key] = value + return route, rest + + +def _patch_route_ranks(session, ctx, pid, pool, updates): + """Write visit numbers into the SHARED stratum, keeping the column a permutation. + + ⭐⭐ OWNER ITEM 7 (2026-08-23) — THE AUTO-DEDUPE, IN THE OWNER'S OWN WORDS: *"when you edit a + number from say 13 to 14, what ever was record 14 should automatically change to 13 so its + auto dedupe that way."* That is a TRANSPOSITION, and it is the whole rule: the number you + typed goes on your record, and the number you displaced goes to whoever was holding it. Two + rows move, the set of numbers in the column is unchanged, and no other record is renumbered. + + Three cases, and each one is the same rule read honestly: + + · the target number is FREE -> plain assignment. A gap is not a duplicate, and the owner's + rule is about duplicates. + · the target is HELD and this record already had a number -> the two swap. The named case. + · the target is HELD and this record had NO number (it was not on the route) -> there is no + old number to hand over, so the displaced record goes to the end (`max + 1`). Still one + other row moved, still no duplicate, and nothing loses its place on the route silently. + + Blanking a cell takes the record OFF the route and frees its number. It renumbers nothing: + re-solving is what closes the gaps, and doing it here would silently rewrite a day somebody + is driving. + + ⛔⛔ THE WALLS ARE `grid_events`' OWN, CALLED AND NOT COPIED, AND THAT IS THE PRICE OF + STEPPING OUT OF `overlay_patch`. `_may_edit_field_value` is where the shared-field Share role, + the creator rule and the admin rule already meet; the hidden-key wall is applied first for the + reason `overlay_patch` applies it, namely that `hidden_keys` decides what this caller may READ + and a column they cannot read is not one they may write by naming its key. A second spelling + of either here would be a second one to keep in step — which is the whole reason + `patch_customer` routes everything else through `grid_events` rather than writing the store. + + ⚠ UNIQUENESS IS MAINTAINED OVER THE ROWS THIS CALLER CAN SEE, and that is a limit worth + stating rather than hiding. `shared_overlay.cells` takes the scoped pool BY SIGNATURE (there is + deliberately no "every shared cell in the tenant" call), so the holder of a displaced number is + looked for inside this session's book. In practice the whole column lives there anyway -- + `route_order_write` REFUSES a body carrying a pid outside the writer's pool, so every number in + a route order was written by somebody whose book contained all of them. + """ + from core import grid_events, shared_overlay + + defs = shared_fields(st=session.runtime) or {} + hidden = _hidden_for(session) + #: `{key: what the store holds for THIS record now}`, for every key asked about — refused ones + #: included. See the `_stored` note below. + accepted = {} + #: The keys that actually moved. `patch_customer` reports everything else as REFUSED, so this + #: has to be separate from `accepted`: a refused key still reports a value, and it is the + #: value the caller did NOT ask for. + taken, swaps = set(), [] + + def _held(): + """`{pid: rank}` for this column, over the rows this caller can see. + + Re-read per key rather than hoisted, because a multi-key patch writes between iterations + and the second key must see the first one's result. + """ + out = {} + for raw_pid, cells in (shared_cells(pool, st=session.runtime) or {}).items(): + if not isinstance(cells, dict): + continue + cell = cells.get(key) + if cell in (None, ""): + continue + try: + out[int(raw_pid)] = int(str(cell).strip()) + except (TypeError, ValueError): + continue + return out + + for key, raw in updates.items(): + defn = defs.get(key) or {} + held = _held() + + def _stored(): + """⛔⛔ A REFUSAL REPORTS WHAT THE CELL ACTUALLY HOLDS, AND THAT IS NOT TIDINESS. + + `patchTopicRow` adopts `body.updates` into the browser's optimistic copy and rolls + back only on a non-2xx. A refused key that is simply ABSENT from `updates` is a key + the client never hears about: the response is 200, the rollback never fires, `adopt` + has nothing to write, and the typed text goes on painting a value the store rejected + until something unrelated forces a refetch. On a `select` the option normaliser made + this unreachable; item 7 made the column an `int`, so `14.5` or a stray letter is now + an ordinary typo away. `patchTopicRow`'s own docstring already states the rule — + keep only what the server actually took — and this is that rule with its hole closed. + """ + n = held.get(pid) + return "" if n is None else str(n) + + if key in hidden: + accepted[key] = _stored() + continue + if not grid_events._may_edit_field_value(ctx, key, defn): + accepted[key] = _stored() + continue + # An empty cell is "not on this route". Legal, and the only way to take a stop off the + # day without re-solving the whole column. + if raw is None or (isinstance(raw, str) and not raw.strip()): + shared_overlay.put_rows(_shared_key(), {pid: {key: ""}}, st=session.runtime) + accepted[key] = "" + taken.add(key) + continue + # ⛔ A BOOL IS AN `int` IN PYTHON, so `True` would store as visit number 1. Excluded by + # name here exactly as `route_order_write` excludes it, rather than by hoping. + if isinstance(raw, bool): + accepted[key] = _stored() + continue + try: + want = int(str(raw).strip()) + except (TypeError, ValueError): + accepted[key] = _stored() + continue + if want < 1: + accepted[key] = _stored() + continue + + prior = held.get(pid) + if prior == want: + accepted[key] = str(want) + taken.add(key) + continue + writes = {pid: {key: str(want)}} + holder = next((p for p, n in held.items() if n == want and p != pid), None) + if holder is not None: + moved = prior if prior is not None else max(held.values()) + 1 + writes[holder] = {key: str(moved)} + swaps.append({"pid": holder, "field": key, "value": str(moved)}) + shared_overlay.put_rows(_shared_key(), writes, st=session.runtime) + accepted[key] = str(want) + taken.add(key) + return accepted, swaps, taken + + +@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), table=_customer_table(session), + st=session.runtime) + + # ⭐⭐ OWNER ITEM 7 (2026-08-23) — A ROUTE RANK IS EDITED HERE, NOT THROUGH `overlay_patch`. + # + # ⚠ AND **NOT** BECAUSE THE ORDINARY PATH WRITES THE WRONG STRATUM. It does not: + # `table_store.patch_overlay` has split tenant-wide keys off to `shared_overlay` since W38-T20 + # (D-423), so a route cell already lands where every reader looks. What the ordinary path + # cannot do is the half the owner actually asked for — *"make sure that none of the number can + # be a duplicate ... when you edit a number from say 13 to 14, what ever was record 14 should + # automatically change to 13"*. That is a write to a SECOND record, decided by the first one's + # OLD value, and nothing in `overlay_patch` has a reason to look at either. + # + # ⛔ IT ALSO OWNS THE VALIDATION, because item 7 changed the column's type. As a `select` the + # declared choices refused anything that was not a rank; as an `int` `overlay_patch` stores + # whatever string it is handed, and "third" in a visit-order column is not a slightly wrong + # value — it is a stop with no place in the sequence. + # + # ⛔ INTERCEPTED SERVER-SIDE, NEVER IN THE CLIENT. Every path that can change a cell — typing, + # paste, fill, undo, the record drawer — comes through this one door; a client-side branch + # would have to be repeated at each of them and would be wrong at the first one nobody + # remembered. + route_updates, updates = _split_route_updates(session, updates) + + if updates: + 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") + + route_accepted, route_swaps, route_taken = ({}, [], set()) + if route_updates: + route_accepted, route_swaps, route_taken = _patch_route_ranks( + session, ctx, pid, pool, route_updates) + + # 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, **route_accepted}} + # ⚠ `route_taken` AND NOT `route_accepted`. A refused rank still reports a value — the one + # the store holds — so membership of `updates` no longer means the write landed. + refused = [k for k in refused if k not in route_updates] + refused += [k for k in route_updates if k not in route_taken] + if refused: + out["refused"] = sorted(refused) + # ⭐ THE SWAP CHANGED A ROW THE CLIENT NEVER TYPED IN, so it has to be told. The optimistic + # copy in the browser covers `pid` only; the record that gave up the number it held would go + # on painting the old one until something else forced a refetch. `patchTopicRow` reads this + # key and drops the rows cache. + if route_swaps: + out["routeSwaps"] = route_swaps + 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" + +#: ⭐⭐ W41-T05 / CONTRACT C3 — THE DESCRIPTION EVERY ROUTE COLUMN IS BORN WITH. +#: +#: `shared_overlay.mint_field` refuses a tenant-wide column that has no description, and the +#: reason is the rule: this column lands in the field list of people who did not make it and +#: cannot ask what its integers mean. W41-T04 recorded the gap here by name (*"a `note` on the +#: definitions minted by `routes_customers::route_order_write`"*) as one of the two call sites +#: that had to start supplying one before the triple could become a hard refusal. +#: +#: ⚠ IT IS A DEFAULT, NOT A LOCK. `route_order_rename` writes a user-authored `note` onto the +#: same definition and an EMPTY STRING is how a person CLEARS one, so the write door below +#: carries the stored value forward by PRESENCE rather than by truthiness. A truthy test would +#: resurrect this sentence on the next re-solve for anybody who had deliberately deleted it. +ROUTE_FIELD_NOTE = ("The visit order of a saved route. Each number is that customer's position " + "in the day, counting from 1, and it changes only when somebody plans the " + "route again.") + +#: ⛔⛔ 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 _occupied(defs, base_fields=()): + """The keys and case-folded labels a new route column must not land on.""" + occupied_keys = {str(key) for key in (defs or {})} + occupied_labels = set() + for field in list((defs or {}).values()) + list(base_fields or ()): + if not isinstance(field, dict): + continue + label = " ".join(str(field.get("label") or "").split()).casefold() + if label: + occupied_labels.add(label) + key = str(field.get("key") or "").strip() + if key: + occupied_keys.add(key) + return occupied_keys, occupied_labels + + +def _next_route_label(defs, base_fields=()): + """Return the next human route-field label without colliding with a visible field. + + ⭐ OWNER ITEM 5 (2026-08-23) — THE WORD IS "ROUTE", NOT "DESTINATION". Owner: *"Instead of + calling it 'Destination 1' etc. for when we saved the route to a field, we just call it + 'Route 1' so it's Route 1, Route 2, Route 3."* A saved column holds the ORDER of a whole day, + so "Destination 1" read as the first stop rather than as the first route, which is exactly + backwards from what the numbers in it mean. + + ⛔ NO MIGRATION, AND THAT IS DELIBERATE. Columns already minted as `Destination N` keep their + label: rewriting a tenant-wide column name that other people's saved views point at is not a + rename this door was asked for. The owner renames one through `route_order_rename` below, per + owner item 6 of the same instruction. + + ⚠ THE KEY IS STILL DERIVED FROM THE LABEL, so the default now slugs to `route_route_1`. The + redundant segment is NOT tidied away: `ROUTE_KEY_PREFIX` is what keeps a route column out of + the namespace this database owns, and stripping it here would make the labels "Route 1" and + "1" collide on one key. The key is never on screen. + """ + occupied_keys, occupied_labels = _occupied(defs, base_fields) + n = 1 + while True: + label = f"Route {n}" + if label.casefold() not in occupied_labels and _route_slug(label) not in occupied_keys: + return label + n += 1 + + +def _clean_route_depot(raw): + """The optional origin stored once on a route-order definition, never on a customer cell.""" + if raw is None: + return None + if not isinstance(raw, dict): + raise err(400, "bad_depot", "a depot is an address with latitude and longitude, or null") + address = " ".join(str(raw.get("address") or "").split())[:200] + lat, lon = raw.get("lat"), raw.get("lon") + if (not address or isinstance(lat, bool) or isinstance(lon, bool) or + not isinstance(lat, (int, float)) or not isinstance(lon, (int, float))): + raise err(400, "bad_depot", "a depot needs an address and numeric latitude and longitude") + lat, lon = float(lat), float(lon) + if not (math.isfinite(lat) and math.isfinite(lon) and abs(lat) <= 90 and abs(lon) <= 180): + raise err(400, "bad_depot", "the depot latitude or longitude is outside the map") + return {"address": address, "lat": lat, "lon": lon} + + +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} + + +def _own_route_fork(session: Session, key: str): + """This caller's PRIVATE copy of a route column, if a per-user write ever forked one. + + ⛔⛔ THE FORK IS REAL, AND IT IS WHAT BROKE DELETE. Traced through `grid_events.field_upsert` + rather than assumed: `shared_field` is `bool(shared_prior.get('custom'))` and a route + definition carries no `custom` key, so the shared branch does not take it; the key is neither + `custom_` nor `measure_`; it IS in the merged contract, so the `key in field_by_key` branch + does — and that branch's own body is what writes `note`, `format` and `agg`, landing them in + THIS USER'S field definitions through `TableStore.save_field`. `_merge_shared_fields` then + SKIPS the tenant-wide definition, because a key the contract already declares wins, and from + that moment the person is reading a private copy of a shared column. + + ⚠ WHICH DOORS STILL FORK, AS OF 2026-08-23, because "the fork is fixed" would be too broad. + The Description and the Name are CLOSED: owner item 6 routed `onNote` and `onRename` to + `route_order_rename` below. The Edit-field pane's **Format** row (`onFormat`, unconditional) + and its **Summary** row (`onAggregate`, whose `isUserTable` arm is false on this registry + topic) both still reach `saveField`, so either one still mints a fork. They are left open on + purpose: closing them needs this door to accept `format` and `agg`, which is a wider change + than the delete the owner reported. This function is what keeps that recoverable rather than + permanent. + + ⛔ WHY THAT MADE THE DELETE 404 RATHER THAN MERELY MISBEHAVE. The first delete found the + tenant-wide definition, dropped it and answered 200 — and the column came straight back on the + next read, because the fork was still declaring it. The second attempt found nothing in + `shared_fields` and answered *"that column is not a route order column on this database"* + about a column sitting on screen. Owner, 2026-08-23: *"I can't even delete the Field route + now?"* Both halves are answered here: a fork is FOUND, and `route_order_delete` drops it WITH + the definition rather than leaving it to redeclare the column. + + ⚠ `kind` IS THE TEST, NEVER MERE PRESENCE. `TableStore.workspace` merges the tenant-wide + column summary over this stratum and will mint a bare `{'agg': ...}` entry for a key the user + has never touched (W36-T25, whose own comment says it must be able to CREATE an entry). + Reading presence would call that stub a fork and scrub a column nobody had forked. + """ + try: + ws = _customer_table(session).workspace(session.uname, consume_corrections=False) + except Exception: # noqa: BLE001 + return None + entry = (ws.get("fields") or {}).get(key) + if not isinstance(entry, dict) or entry.get("kind") != ROUTE_KIND: + return None + return entry + + +@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"), + "depot": route.get("depot") if isinstance(route.get("depot"), dict) else None, + "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), + }) + # ⭐⭐ OWNER ITEM 5 — THE DEFAULT NAME IS ALLOCATED **HERE**, NEVER ON THE CLIENT. + # + # The panel prompts for a route name and pre-fills it. Computing that pre-fill from the + # `fields` list above would compute it from the columns this session may SEE: `_route_defs` + # drops every route column the per-field wall hides, so a user with no grant on + # `route_route_1` would be offered "Route 1" as their default, send it, and be refused + # `field_key_taken` on a name the app itself put in the box. Allocated over the WHOLE shared + # stratum, the suggestion can never name a column that already exists. + # + # ⚠ IT LEAKS NOTHING. What travels is the first FREE name, which is a fact about absence. + import aios_grid + return {"fields": out, + "nextLabel": _next_route_label(shared_fields(st=session.runtime) or {}, + aios_grid.FIELDS)} + + +@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?, depot?}` + + When `label` is omitted for a new column, the door allocates the next unused `Destination N` + label. This keeps route creation one-click while preserving unique, readable field names. + + ⛔ THE DEFINITION IS WRITTEN FIRST AND THE GRANT CLAIMED SECOND — inside + `shared_overlay.mint_field` since W41-T05, which is where the `set_grants` call a reader looks + for in this function now lives. It 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] + import aios_grid + defs = shared_fields(st=session.runtime) or {} + requested_key = str(body.get("field") or "").strip() + if requested_key: + key = requested_key + elif label: + key = _route_slug(label) + else: + label = _next_route_label(defs, aios_grid.FIELDS) + key = _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") + 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") + + 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") + + if "depot" in body: + depot = _clean_route_depot(body.get("depot")) + else: + prior_route = (existing or {}).get("route") if isinstance(existing, dict) else {} + depot = prior_route.get("depot") if isinstance(prior_route, dict) else None + + 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, + # ⭐⭐ OWNER ITEM 7 (2026-08-23) — THE SAVED ROUTE IS A **NUMBER** COLUMN. + # + # Owner: *"the Route field once saved should be saved as a number Field. Make sure we can + # edit it, BUT also make sure that none of the number can be a duplicate, so its like a + # number order field."* It used to be a `select` whose declared choices were the strings + # "1".."N", which sorted and rendered as a list rather than as an order and forced a + # re-solve to widen the vocabulary before a stop could be numbered past N. + # + # ⛔ UNIQUENESS IS NOT A PROPERTY OF THE TYPE, SO IT LIVES AT THE WRITE DOOR. `int` has no + # "no duplicates" flag anywhere in this contract; `_patch_route_ranks` in `patch_customer` + # is what keeps the column a permutation, by SWAPPING rather than by refusing. + # + # ⚠ EXISTING COLUMNS ARE NOT MIGRATED, they are UPGRADED BY RE-SOLVE. This dict is + # rebuilt whole on every write, so the first re-solve of a `Destination N` column turns it + # into a number column; one nobody re-solves keeps working as the select it was, and the + # write door reads the KIND rather than the type, so both edit identically. + "type": "int", + "source": "overlay", + "shared": True, + "kind": ROUTE_KIND, + # ⭐⭐ W41-T05 — THE DESCRIPTION, CARRIED BY **PRESENCE** AND NOT BY TRUTHINESS. + # `ROUTE_FIELD_NOTE` says why the column cannot be born without one and why `""` has to + # survive a re-solve: `route_order_rename` clears a description by storing exactly that, + # and `existing.get("note") or ROUTE_FIELD_NOTE` would hand the default straight back to + # the person who had just deleted it. `"note" in existing` is the question with an + # answer; truthiness is a different question wearing the same shape. + "note": (existing["note"] if isinstance(existing, dict) and "note" in existing + else ROUTE_FIELD_NOTE), + # ⭐⭐ OWNER, 2026-08-23 — A NEW ROUTE READS AS **PRIVATE**, NEVER "Shared with everyone". + # + # Owner: *"Right now the Route is 'Shared with everyone' in terms of the Field status when + # i check the Route Field. It should always default to private first."* + # + # ⛔ A CLASSIFICATION FIX, NOT A TIGHTENING, AND THE DIFFERENCE IS THE WHOLE POINT. This + # column was ALREADY private in the only sense that governs a reader: `FIELD_GRANT_MARK` + # plus the empty-entry grant claimed below means creator-and-admin and nobody else. What + # was wrong was the WORD ON SCREEN. `FieldsHidePanel` sections the field list by + # `types.fieldEditMode`, which is `cleanFieldPermissions(field.permissions, + # "collaborative")` — so a definition carrying NO permissions bag fell to that fallback + # and was filed under "Shared with everyone" while being shared with nobody at all. + # + # ⚠ AND IT MOVES NO WALL, CHECKED RATHER THAN REASONED. `grid_events._may_edit_field_value` + # short-circuits on `definition.get('shared') or definition.get('granted')` BEFORE it reads + # `stored_permissions`, so the creator's own rank edits and `_patch_route_ranks`' swap are + # decided by `_field_share_role` and not by this bag; the client twin `mayEditField` takes + # the same branch in the same order. `field_permissions.migrate_legacy_fields` only ever + # rewrites a PER-USER field carrying `custom: True`, which a route definition is not, so it + # leaves this key alone. `fieldEditMode` has exactly one other consumer: none. + # + # ⛔ NO MIGRATION, AND IT IS THE SAME DELIBERATE CHOICE `_next_route_label` MAKES ABOUT + # `Destination N`. This dict is rebuilt whole on every write, so a column minted before + # today gains the bag on its next RE-SOLVE and not one moment sooner — until then it + # keeps reading "Shared with everyone" in the Hide fields panel while being shared with + # nobody. Backfilling every stored route definition on a read is a write nobody asked + # for, on a tenant-wide stratum, triggered by opening a page; re-solving is one click and + # it is the click the owner is already making. Stated here rather than left to be + # rediscovered as "the fix did not work". + "permissions": {"edit": "personal"}, + # ⭐⭐ 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), + "depot": depot, + "solvedAt": stamp, + }, + } + # ⭐⭐ W41-T05 / CONTRACT C3 — A BIRTH GOES THROUGH `mint_field`, A RE-SOLVE THROUGH + # `put_field`, AND THE SPLIT IS THE WHOLE FIX. C3 names this door as the one bypass in the + # tree: it reached around the column menu into the raw definition writer because + # `aios_grid._field_extras` would have stripped the `route` bag. W41-T04 widened the + # allowlist to carry that bag, so the bypass buys nothing any more, and `mint_field` is the + # entrance that REFUSES a column born without a creator, a grant list and a description. + # + # ⛔⛔ EVERY MEMBER OF THE TRIPLE STAYS IN THE LITERAL ABOVE, AND THAT IS NOT REDUNDANCY. + # `mint_field` stamps `createdBy`, `granted` and `note` itself, but it runs on the MINT ONLY; + # a re-solve goes to `put_field`, whose allowlist makes an omitted key CLEAR the stored one + # ("an allowlisted key follows the caller exactly"). Leaning on the mint for the mark would + # give a perfect create and a re-solve that silently UNMARKS the column and re-opens the very + # leak this ticket closes. The literal is what makes both paths land the same definition. + # + # ⚠ THE EMPTY GRANT LIST IS THE POINT, and `mint_field` keeps it: `set_grants` stores a record + # with an owner and no entries, so "shared with nobody" is a STORED fact and a different one + # from "never shared". Without the owner the column is unmanageable — `may_administer` fails + # closed on an ownerless record, so nobody could ever share it. The definition still lands + # BEFORE the claim (inside `mint_field`, same order, same reason): a marked and unclaimed + # column is admin-and-creator only, which is recoverable; the other order is not. + if existing is None: + try: + shared_overlay.mint_field( + _shared_key(), key, defn, + created_by=defn["createdBy"], grants=[], description=defn["note"], + grant_topic=SHARE_TOPIC, st=session.runtime) + except ValueError as exc: + # ⛔ `mint_field` REFUSES rather than overwriting, and only two things can raise here. + # The triple is supplied one line up, so the reachable refusal is "that key already + # exists" — which `existing` above said it did not. The two disagree in exactly one + # case: `existing` is TYPE-checked (`isinstance(..., dict)`) while `is_shared` is a + # bare membership test, so a junk non-dict entry parked under the key reads as absent + # to one and present to the other. It answers with the taken-name 400 this door + # already has rather than a 500, and anything else re-raises rather than being + # relabelled as a name collision it is not. + if not shared_overlay.is_shared(_shared_key(), key, st=session.runtime): + raise + raise err(400, "field_key_taken", + "that column name is already in use on this database") from exc + else: + shared_overlay.put_field(_shared_key(), key, defn, st=session.runtime) + + # ⛔ 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: str(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"], + "depot": defn["route"]["depot"], + "solvedAt": stamp} + return out + + +@router.delete("/customers/route-order/{field_key}") +def route_order_delete(field_key: str, + session: Session = Depends(module_gate(MODULE))): + """Remove a Destination column and every visit number in it. + + ⛔⛔ WHY THIS DOOR HAD TO EXIST. `route_order_write` mints a TENANT-WIDE column and nothing + could ever remove it: the grid's own Delete is offered for a per-user `custom_` column + (`menuField.custom && !menuField.shared`) or for a `ut_*` definition field, and a route order + is neither — it is a SHARED column on a registry topic. So `Destination 1` was permanent for + the whole workspace, which is [[reachable-is-not-the-same-as-built]] from the other end: + `shared_overlay.drop_field` was complete and correct and no route on this topic called it. + + ⛔ CREATOR-OR-ADMIN, the same wall `routes_tables.delete_shared_field` applies and for the + same reason: writing a cell changes a value, dropping the column deletes that value for every + account at once. It is deliberately NOT the looser wall `route_order_write` uses for CREATE. + + ⛔ AND A CALLER WHO CANNOT SEE THE COLUMN GETS THE ANSWER A NONEXISTENT KEY GETS. The refusals + below would otherwise teach a stranger that a route order exists on this database and who + planned it, which is exactly what the per-field wall withholds — the choice already argued at + `route_order_write`'s `field_key_taken` and in `routes_shares._can_see_object`. + """ + from core import shared_overlay + import core.perm_scope as perm_scope + import core.table_store as table_store + import aios_grid + + key = str(field_key or "").strip() + all_defs = shared_fields(st=session.runtime) or {} + defn = all_defs.get(key) if isinstance(all_defs.get(key), dict) else None + # ⭐⭐ OWNER, 2026-08-23 — A PRIVATE FORK IS ALSO A COLUMN TO DELETE. See `_own_route_fork` + # above: once any per-user write has forked this key, the fork is what the person is looking + # at and it OUTLIVES a drop of the tenant-wide definition. Answering 404 about a column that + # is on screen is exactly the refusal the owner hit. + fork = _own_route_fork(session, key) + subject = defn or fork + unknown = err(404, "unknown_field", + "that column is not a route order column on this database") + if not subject: + raise unknown + # The wall FIRST, so a hidden column is indistinguishable from an absent one. + # ⚠ ASKED ABOUT THE TENANT-WIDE DEFINITION ONLY, and that is not a hole. The wall reads + # `FIELD_GRANT_MARK` off the MERGED contract; with the definition already gone there is no + # marked column left for it to hide, and a fork lives in this caller's OWN stratum, which no + # grant has ever governed. Not asking when there is nothing to ask about beats asking of a + # contract that no longer carries the key and reading the empty answer as "not hidden". + if defn and key in perm_scope.hidden_keys( + session.user, MODULE, + _merge_shared_fields(list(aios_grid.FIELDS), all_defs), st=session.runtime): + raise unknown + # ⛔ KIND-CHECKED, NOT PREFIX-CHECKED. `ROUTE_KEY_PREFIX` is a naming convention and a + # convention is not a declaration (the comment on `ROUTE_KIND` says so); a door that deleted + # by prefix would happily drop a shared column somebody else's feature owns. + if subject.get("kind") != ROUTE_KIND: + raise err(400, "not_a_route_column", + "that column is shared but it is not a route order column, so this door will " + "not remove it") + owner = str(subject.get("createdBy") or "") + if not session.admin and owner != session.uname: + raise err(403, "forbidden", + f"a route order column can be removed by the person who planned it or by an " + f"administrator. This one was planned by {owner or 'somebody else'}, and " + f"removing it would delete the visit numbers for every account at once") + + dropped = bool(defn) and shared_overlay.drop_field(_shared_key(), key, st=session.runtime) + # ⛔⛔ AND THE FORK GOES IN THE SAME CALL. Dropping only the tenant-wide definition is what + # made the first delete look like it had worked and then undo itself: the fork still declares + # the column, `_merge_shared_fields` still yields to it, and the next paint brings it back. + # `delete_field` scrubs this user's overlay values under the key too, which is right: the + # tenant-wide cells went with `drop_field` above, and a private leftover would resurface under + # whatever column later took the key. + if fork is not None: + try: + table_store.make(_shared_key(), st=session.runtime).delete_field(session.uname, key) + except Exception: # noqa: BLE001 + # The definition is already gone, so a failed fork scrub must not turn a delete that + # succeeded into a 500. Worst case the column lingers for this one account until its + # next write, which is recoverable; a 500 over completed work is not. + pass + # ⭐⭐ THE GRANT DIES WITH THE COLUMN. `route_order_write` claims + # `shares.field_oid(SHARE_TOPIC, key)` on create, so skipping this would leave a grant record + # pointing at nothing — a ghost in every receiver's "Shared with me" that 404s on open, and + # worse, one that silently re-arms on the next column to take the key, because `drop_field` + # scrubs the cells precisely so the key CAN be re-used. + # ⚠ Same order and same tolerance as `routes_tables.delete_shared_field`: the column is + # already gone, so a failed release must not turn a completed delete into a 500. + try: + import core.shares as shares + shares.drop_objects([("field", shares.field_oid(SHARE_TOPIC, key))], st=session.runtime) + except Exception: # noqa: BLE001 + pass + return {"ok": True, "field": key, "dropped": bool(dropped or fork is not None)} + + +@router.patch("/customers/route-order/{field_key}") +def route_order_rename(field_key: str, body: dict = Body(default=None), + session: Session = Depends(module_gate(MODULE))): + """Rename a route order column, or write its description. `{label?, note?}`. + + ⭐⭐ OWNER ITEM 6 (2026-08-23) — "Edit field" MUST BE ABLE TO RENAME THIS COLUMN. + + A route order is a SHARED column on the customers REGISTRY topic. The grid's Edit-field pane + offers a Name box only when the host supplies `onRename`, and the host supplies it only for + `isUserSchemaField` — a per-user `custom_` column, or a `ut_*` definition field. A route order + is neither, so the pane showed a DISABLED Name box reading *"A source field keeps its name and + type from the data source"* about a column the person had created themselves an hour earlier. + That is the same shape as owner item 3's missing Delete, one stratum over. + + ⛔⛔ THE KEY IS FROZEN. `_route_slug` derives the store key from the label AT CREATION, and + only there. Re-slugging on a rename would leave every written visit number sitting under the + old key in `shared_overlay`, every saved view's `colId` pointing at a column that no longer + exists, and every field grant naming an oid nobody can reach — a rename that looks perfect on + a fresh column and quietly empties a used one. The label moves; nothing else does. + + ⛔ CREATOR-OR-ADMIN, and a caller who cannot SEE the column gets the answer a nonexistent key + gets. Both walls are `route_order_delete`'s, verbatim and for its reasons: this changes what + every account in the workspace reads, and a refusal that said "forbidden" would confirm to a + stranger that a route order exists here and who planned it. + + ⛔⛔ THE DESCRIPTION RIDES THIS DOOR TOO, AND IT HAD TO. The Edit-field pane writes a + description through `onNote`, which is `saveField` — a PER-USER `field_upsert`. Traced through + `grid_events`: a route key is not `custom`-marked in the shared stratum, so the `shared_field` + branch does not take it; it IS in the merged contract, so the `key in field_by_key` branch + does, and it stores `{**base, note}` in THIS USER'S field definitions. `_merge_shared_fields` + then skips the shared definition, because a key the contract already declares wins — so that + user is left reading a private copy of a tenant-wide column, frozen at the label it had when + they typed the description, and a later rename through this door is invisible to them. + Pre-existing, and unreachable enough to have gone unnoticed; owner item 6 makes that pane the + place people go for route columns, so it stops being unreachable on the same day. + """ + from core import shared_overlay + import core.perm_scope as perm_scope + import aios_grid + + body = body if isinstance(body, dict) else {} + key = str(field_key or "").strip() + label = " ".join(str(body.get("label") or "").split())[:120] + # ⚠ `"note" in body` AND NOT a truthiness test: an empty string is how a description is + # CLEARED, and a door that read it as "unchanged" would leave one nobody can delete. + note = str(body.get("note") or "")[:2000] if "note" in body else None + if not label and note is None: + raise err(400, "bad_request", "a route order column needs a name") + if "label" in body and not label: + raise err(400, "bad_request", "a route order column needs a name") + + all_defs = shared_fields(st=session.runtime) or {} + defn = all_defs.get(key) if isinstance(all_defs.get(key), dict) else None + unknown = err(404, "unknown_field", + "that column is not a route order column on this database") + if not defn: + raise unknown + if key in perm_scope.hidden_keys( + session.user, MODULE, + _merge_shared_fields(list(aios_grid.FIELDS), all_defs), st=session.runtime): + raise unknown + if defn.get("kind") != ROUTE_KIND: + raise err(400, "not_a_route_column", + "that column is shared but it is not a route order column, so this door will " + "not rename it") + owner = str(defn.get("createdBy") or "") + if not session.admin and owner != session.uname: + raise err(403, "forbidden", + f"a route order column can be renamed by the person who planned it or by an " + f"administrator. This one was planned by {owner or 'somebody else'}, and its " + f"name is what every account in the workspace reads") + + # ⚠ THE COLLISION CHECK LOOKS AT LABELS AND NOT AT KEYS, because the key is frozen: this + # rename can never take another column's key, only its NAME. Two columns wearing one name on + # the same grid is the confusion `_next_route_label` exists to prevent at creation, so the + # rename door refuses it too. The column's OWN current label is excluded, so re-saving an + # unchanged name is a no-op rather than a refusal. + _keys, occupied_labels = _occupied( + {k: v for k, v in all_defs.items() if k != key}, aios_grid.FIELDS) + if label and label.casefold() in occupied_labels: + raise err(400, "field_label_taken", + "this database already has a column with that name") + + patch = dict(defn) + if label: + patch["label"] = label + if note is not None: + patch["note"] = note + shared_overlay.put_field(_shared_key(), key, patch, st=session.runtime) + return {"ok": True, "field": key, "label": patch.get("label") or key, + "note": patch.get("note") or ""}