File size: 20,250 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
bf8519f
 
 
e1b3e71
 
 
 
bf8519f
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
e1b3e71
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
"""routes_customers.py β€” X2's read + write of the customer table, BU-SCOPED (EXIT-3b / EXIT-2a).

Two things happen here that did not happen in the pre-wave `main.py`:

  1. **ROWS ARE SCOPED TO THE SESSION.** The old `/api/customers` served `cl.pool()` β€” the whole
     book, to anyone holding the shared APP_PASSWORD. Now the pool is built with
     `(team_id, agent_name)` derived from the USER RECORD, so a Royal-only user never receives a
     Fisch row and an agent-linked login never receives another rep's book. The scope is applied
     at the QUERY, not as a post-filter, so there is no moment at which the other BU's rows exist
     in this response.

  2. **THE OVERLAY FORK IS GONE.** `aios-web/api/data/overlay.json` was a SECOND writable home
     for the same user-owned fields the Streamlit app keeps in the tenant store β€” two truths, and
     whichever process you asked last was right. Reads and writes now both go through
     `modules.customer_data`'s table-workspace functions: ONE store (C1c, ARCHITECTURE Β§1a rule 3).

⚠ ONE STORE IS NOT YET ONE CACHE (strangler-period, booked honestly). `core.store.get()` is
cache-first per PROCESS, so a write from the Streamlit container is invisible to a running API
container until its cache is refreshed, and vice versa. Deleting the fork removes the second
SOURCE OF TRUTH; it does not make the two runtimes coherent. The fix is X4/Postgres (task C-4,
owner-blocked on B-3), and no test here may claim read-your-writes ACROSS runtimes β€” a TestClient
proof is single-process and would report green on exactly the thing that is still broken.
"""
import time

from fastapi import APIRouter, Body, Depends

import scope_cache
from deps import Session, err, module_gate, perms

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

#: The surface these routes serve. Both legs are gated on it, so a user without the grant gets a
#: 403 rather than an empty table that looks like "you have no customers".
MODULE = "customer_data"

_CACHE_TTL = 900       # the pool build is slow (Odoo + reconciliation); 15 min, as before


def _pool_rows(session: Session):
    """The reconciled Odoo pool for this session's SCOPE β€” the slow part, and the only part that
    may be shared between users.

    β›” WHAT MAY BE CACHED HERE, AND WHY THE LINE IS EXACTLY HERE. The cache key is
    `(team_id, agent)` and the cached value is the raw pool: Odoo-source columns only. That is
    genuinely scope-shaped β€” two users with the same BU and the same book are asking the same
    question, and `cl.pool()` is expensive (an Odoo pull plus reconciliation).

    The FULL PAYLOAD is NOT cacheable on this key, and caching it here was a real defect I shipped
    and then removed. `fields` comes from `fields_from_workspace(ws)` β€” that user's own `custom_`
    and `measure_` columns β€” and every overlay cell comes from `ws['overlays']`; the table
    workspace is read as `data[username]` and written as `patch_table_overlay(uname, …)`, i.e.
    PER USER. So two Royal-only users with no agent link share `(6, None)` and the second one
    would have been served the FIRST one's private notes and private columns. The scope key was
    right for the pool and wrong for everything wrapped around it.

    It survived a green 129-check battery because every fixture user had a DISTINCT
    `(team_id, agent)` pair, so no two of them ever collided on the key β€” the test set could not
    express the bug. `verify_api.py` now carries a same-scope second user for exactly this.
    """
    rt = session.runtime
    team_id, agent = _team_agent(session)
    return _pool_for(rt, team_id, agent)


def _pool_for(rt, team_id, agent):
    """The cached pool for an explicit scope β€” session-free so the prewarm thread and the
    stale-refresh path can call it. STALE-WHILE-REFRESH (scope_cache): once a copy exists no
    request blocks on the 10–30s Odoo rebuild again; only a scope's FIRST-ever build does.

    DEBT-2 (2026-08-04): while the tenant's RESOLVED Odoo source is PAUSED this path never
    goes live β€” it serves the in-process copy at any age, else the persisted pause-time
    snapshot (restart-safe), else answers 503. Never a WIDER scope's snapshot: handing a
    scoped user the consolidated rows would widen their book, which is worse than an error."""
    import modules.customer_data as cl
    import routes_keychain

    key = ("pool", team_id, agent)

    if routes_keychain.odoo_paused(rt):
        hit = rt.pool_cache.get(key)
        if hit:
            return hit[1]
        snap = routes_keychain.load_pool_snapshot(rt, team_id, agent)
        if snap is not None:
            rt.pool_cache[key] = snap          # seed, so the memo stamps stay coherent
            return snap[1]
        raise err(503, "connector_paused",
                  "this data source is paused and no snapshot exists for your scope β€” "
                  "an admin can resume it under Settings β†’ Connectors")

    def _build():
        # ⚠ THE SCOPE GOES INTO THE BUILDER. `pool(agent_name, team_id)` is the same reconciled
        # builder the Streamlit page uses β€” passing the scope here is what makes the isolation a
        # property of the QUERY instead of a filter someone can forget to apply downstream.
        return cl.pool(agent, team_id)

    def _evict():
        # Bounded: a scope cache that only ever grows is a memory leak in a shared process.
        if len(rt.pool_cache) > 16:
            for stale in sorted(rt.pool_cache, key=lambda k: rt.pool_cache[k][0])[:8]:
                rt.pool_cache.pop(stale, None)

    return scope_cache.get(rt.pool_cache, key, _CACHE_TTL, _build, _evict)


def warm_default(rt):
    """Boot prewarm: the consolidated pool `(None, None)` β€” the scope every admin and every
    all-BU account lands on. Called from main.py's prewarm thread only."""
    _pool_for(rt, None, None)


def _team_agent(session: Session):
    """The `(team_id, agent)` this session's POOL is built with β€” ONE derivation point, used by
    `_pool_rows` and `grid_assembly` alike, so the cache key and the query can never disagree.

    β›” WAVE 15 R1: THIS NOW COMES FROM THE PERMANENT FILTER (C-PERM amendment 3). `team_id` is
    not a row filter β€” `customer_data._pool_build` passes it into `cust._cust_rev` three times
    and into `_cadence_bulk`, so it decides what `rev`/`ly`/`ltm`/`aov`/`status` MEAN. Enforcing
    a BU purely as a post-filter would keep the row list right and silently consolidate every
    number. So the pushdown survives as a DERIVATION OF the declared filter rather than a second
    wall beside it, and `perm_scope.derive_pool_scope` falls back to the legacy `bus`/`agent`
    derivation for any record the migration has not reached yet.
    """
    import core.perm_scope as perm_scope
    return perm_scope.derive_pool_scope(session.user, MODULE)


def _pool_stamp(rt, team_id, agent):
    """The cached pool's build timestamp β€” the DATA STAMP in every measure-memo key, so a pool
    refresh invalidates the memoised answers exactly when the underlying rows changed."""
    entry = rt.pool_cache.get(("pool", team_id, agent))
    return entry[0] if isinstance(entry, tuple) and entry else 0


def _measure_err(tag, e):
    try:
        import harness.telemetry as _tel
        _tel.error(f"api:{tag}", e)
    except Exception:
        pass


def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "",
                  consume_corrections: bool = True):
    """ONE assembly of this session's grid state, shared by `/customers`, `/workspace` and the
    events route (2026-07-31 β€” the standalone measure gap, owner item 1).

    What it adds over the pre-wave hand-rolled `_payload` loop, and why it replaced it:

      * rows go through `aios_grid.rows_from_pool` β€” the SAME builder the embedded host uses,
        so `lat`/`lon` (the Map view's data), `_created` and every derived column ride each row
        by construction instead of by a second loop that drifts. The hand loop was written to
        mirror the pre-Map contract and silently dropped the coordinates: the shell's Map view
        had nothing to plot ("the Map no longer works").
      * `derived` carries the cohort column's cells AND the measure columns' values, resolved
        through `core.measure_resolve` (EXIT-5's extraction of the `_cl_measure_*` family).
        Without them every measure column the owner built rendered BLANK in the shell.
      * `measures` (the offer) and `measure_sets` (condition answers) are computed here so the
        events route can finally validate measure fields/conditions instead of refusing them
        (an empty `measure_offer` made `clean_measure_field` reject every create over HTTP).

    Memos live on the TENANT RUNTIME (`rt.measure_memo` / `rt.mset_memo`) β€” bounded by the
    module's own clear-past-cap rule, keyed on (stamp, scope, pool identity, question), nothing
    user-shaped in them.
    """
    import aios_grid
    from core import grid_events, measure_resolve

    import core.perm_scope as perm_scope

    rt = session.runtime
    team_id, agent = _team_agent(session)
    rows_src = _pool_for(rt, team_id, agent)
    # β›” THE ROW WALL, APPLIED BEFORE `pids` IS TAKEN. Everything downstream is bounded by that
    # frozenset β€” `allowed_pids` for the workspace, cohort membership, measure resolution β€” so
    # scoping here means a row this account may not see never enters ANY of them, rather than
    # being filtered out of one payload and surviving in another.
    #
    # Evaluated against the CANONICAL field list, not the per-user assembled one, for two
    # reasons: the assembled list is not built yet (it needs `pids`), and a permanent filter may
    # only ever name a canonical field anyway β€” `routes_admin._clean_perms` validates it against
    # exactly this schema and 400s otherwise. `permits()` denies on anything it cannot answer.
    rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, aios_grid.FIELDS)
    pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
    ws = grid_events.table_workspace(
        _ctx_for(session, pids), allowed_pids=pids,
        consume_corrections=consume_corrections)
    workspace, fields, views, lists = aios_grid.workspace_wire(
        ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key)
    # 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.
    hidden = perm_scope.hidden_keys(session.user, MODULE, fields)
    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]

    today = time.strftime("%Y-%m-%d")
    stamp = _pool_stamp(rt, team_id, agent)
    measures = measure_resolve.offer(team_id, on_error=_measure_err)
    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)
    # 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)
    for pid, cells in measure_resolve.column_values(
            fields, team_id, pids, today, stamp, rt.measure_memo,
            on_error=_measure_err).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):
    """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."""
    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 because
        # `fields=[]` here β€” the closure only needs the schema, not this user's column list.
        hidden_keys=_hidden_for(session),
        admin=session.admin, fallback_ws=None, seen_ids={})



def _hidden_for(session: Session):
    """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.
    """
    import aios_grid
    import core.perm_scope as perm_scope

    return perm_scope.hidden_keys(session.user, MODULE, aios_grid.FIELDS)

def allowed_pids(session: Session):
    """The pids this session may touch β€” the POOL's own ids, so the write wall and the read scope
    can never disagree.

    Reads `_pool_rows` rather than `_payload`: the wall only needs identities, and going through
    the full payload would pay for a workspace read and a row assembly on every write.

    β›” THE PERMANENT FILTER APPLIES HERE TOO, AND FORGETTING IT IS A WRITE-WITHOUT-READ HOLE.
    `_pool_rows` is built with the DERIVED pushdown, which expresses only what a `(team_id,
    agent)` pair can express. Any part of the wall the pushdown cannot carry β€” `revenue > 1000`,
    a nested group, a condition on any other column β€” leaves the pool WIDER than the filter. Read
    paths close that gap with `apply_row_scope`; without the same call here the write wall would
    be the wider set, and a restricted user could PATCH a row this API will not show them.
    Same function, same order as `grid_assembly`, so the two walls cannot drift.
    """
    import core.perm_scope as perm_scope
    import aios_grid

    rows = perm_scope.apply_row_scope(_pool_rows(session), session.user, MODULE,
                                      aios_grid.FIELDS)
    return frozenset(r["pid"] for r in rows if r.get("pid") is not None)


@router.get("/customers")
def customers(session: Session = Depends(module_gate(MODULE))):
    return _payload(session)


@router.patch("/customers/{pid}")
def patch_customer(pid: int, body: dict = Body(default=None),
                   session: Session = Depends(module_gate(MODULE))):
    """Write the EDITABLE overlay stratum only β€” Odoo stays read-only, forever.

    Routed through `core.grid_events.handle_one` as an `overlay_patch` event rather than writing
    the store directly: that handler is where the per-key `permissions.edit` wall, the pid wall
    and the truncation rules live, and a second implementation of those would be a second set of
    them to keep in step. The response reports what was ACCEPTED, which is not always what was
    asked for.
    """
    from core import grid_events

    updates = dict(body or {})
    if not updates:
        raise err(400, "empty_patch", "no fields to update")
    pool = allowed_pids(session)
    if pid not in pool:
        # 403, not 404: the pid may well exist β€” it is simply not in this session's book, and
        # saying "no such customer" would confirm the opposite to anyone who guessed right.
        raise err(403, "out_of_scope", "that customer is not in your book")
    payload = _payload(session)
    ctx = grid_events.EventCtx(
        uname=session.uname, allowed_pids=pool, fields=payload["fields"],
        admin=session.admin, fallback_ws=None, seen_ids={},
        hidden_keys=_hidden_for(session))
    try:
        grid_events.handle_one(
            {"id": f"patch:{pid}:{time.time_ns()}", "type": "overlay_patch",
             "pid": pid, "updates": updates}, ctx)
    except grid_events.StoreUnavailable:
        raise err(503, "store_unavailable",
                  "the tenant store is unavailable β€” your change was not saved")

    # What actually landed, read back from the store rather than echoed from the request: a
    # refused key or a truncated value must not be reported as accepted.
    stored = (grid_events.table_workspace(_ctx_for(session, pool), allowed_pids=None)
              .get("overlays") or {}).get(str(pid)) or {}
    accepted = {k: stored.get(k) for k in updates if k in stored}
    refused = sorted(k for k in updates if k not in accepted or stored.get(k) != str(updates[k]))
    # No cache to patch: the overlay stratum is re-read from the store on every `_payload`, so
    # read-your-writes within this runtime is a property of the design rather than of a
    # write-through step somebody has to remember. (It was a write-through step while the whole
    # payload was cached on a scope key β€” the arrangement that leaked one user's notes to
    # another. See `_pool_rows`.) Cross-RUNTIME coherence is still not claimed: the module
    # docstring says why, and Postgres is the fix.
    out = {"ok": True, "pid": pid, "updates": accepted}
    if refused:
        out["refused"] = refused
    return out