diff --git "a/platform/core/grid_events.py" "b/platform/core/grid_events.py" --- "a/platform/core/grid_events.py" +++ "b/platform/core/grid_events.py" @@ -1,2147 +1,2228 @@ -"""core/grid_events.py — THE WRITE SEAM, lifted out of Streamlit (EXIT-1a, X1, 2026-07-30). - -This is the grid's event handler: the code that takes an UNTRUSTED event object from the -browser component and decides what may be persisted. It was `app.py:_cl_handle_grid_event` / -`_cl_handle_one` and moved here as a PURE MOVE — no behavior change inside a move (MA principle -5). Every body below is the app.py body verbatim; the ONLY mechanical rewrites are the -Streamlit couplings the contract names: - - st.session_state['_cl_grid_seen_ids'] -> ctx.seen_ids (adapter-owned dedup state) - st.session_state['_cl_doc_payload'] -> ctx.out.doc (EventResult.doc) - st.session_state['_cohort_toast'] -> ctx.out.toast (EventResult.toast) - st.session_state['cl_table_workspace'] -> ctx.fallback_ws (store-unavailable fallback) - is_admin() (the bare call inside doc_delete, and _cl_table_workspace's admin=None default) - -> ctx.admin - -There is NO `import streamlit` in this file and there must never be one — `verify_no_streamlit.py` -enforces that with a `sys.meta_path` block, which is the only proof that survives refactoring. -(That gate was `verify_seam.py` until EXIT-6 retired it with `app.py`; the block, its negative -control and the AST walk moved over intact.) - -ONE ADAPTER, ONE HANDLER — it was TWO until 2026-08-04: - * ⛔ the Streamlit adapter is GONE. `app.py::_cl_handle_grid_event` built an EventCtx whose - `seen_ids` and `fallback_ws` were session-state-backed dicts and applied the Result to session - state. EXIT-6 deleted it. The host-neutrality this file was written for is what let that - happen without touching a line of the handler — which is the argument for the design, recorded - at the moment it paid out. - * the HTTP adapter — `aios-web/api` builds an EventCtx with `fallback_ws=None`. A None - fallback with the store unavailable is a **503**, never a silent in-memory workspace: an - API request has no durable session dict to fall back to, so pretending to persist would - lose the write with a 200 on it. - -⚠ LAYERING, stated honestly. `core/` is not supposed to import `modules/` (ARCHITECTURE §1's -one-directional rule). This handler genuinely straddles: it is the WRITE half of the customer -table and its persistence verbs live in `modules.customer_data` / `modules.cohort`. The -contracted path for the strangler is `core/grid_events.py` (both adapters import it from here), -so the import stays and the violation is RECORDED rather than hidden. It is the one file in -`core/` that points up; when EXIT-5 ports the surfaces, the persistence verbs are what move -down, and this note is the marker for that work. -""" -import datetime as dt -import json as _json_ev # wave 23 (C7): the json field's parse wall -from dataclasses import dataclass, field -from typing import MutableMapping, Optional - -import core.store as store -import core.table_store as _tstore # wave-9 I17: the shared-view authorisation predicates - -#: ⭐ WAVE-27 item 5 (C7) — how many view ids one rail arrangement may name. Generous on purpose: -#: it bounds a hostile payload, not the user (the rail holds saved views + cohort projections + -#: shared views, and a busy Customer grid already carries dozens). An order longer than this is -#: not a person dragging rows. -MAX_VIEW_ORDER = 500 -import core.user_tables as _ut_limits # wave 23 (C7): MAX_JSON_CELL — one ceiling, one definer -import core.users as users -import harness.telemetry as _tel # _tel.error() = THE error sink (all caught errors log) -import modules.cohort as cohort_mod -import modules.customer_data as cl_mod - -#: C5 (owner item 12): customer documents. The cap is enforced HOST-side on the DECODED byte -#: count — the client checks the same number first, but a client check only spares the user a -#: pointless upload; this is what bounds what the tenant's dataset can be made to hold. -#: MOVED from app.py:1410–1412 with the handler (app.py re-imports these during the strangler). -_DOCS_KEY = 'customer_docs' -_DOC_MAX_BYTES = 5 * 1024 * 1024 -_DOC_MAX_PER_ROW = 50 - -#: The props of a field def the CLIENT actually draws — the wave-6 no-blip comparison set. -#: Host-only stamps (createdBy, permissions) are deliberately absent: the client renders -#: nothing from its own copy of those, and a permissions refusal is answered explicitly. -_FIELD_ECHO_PROPS = ('label', 'type', 'note', 'options', 'colorCodeOptions', - 'optionColors', 'max', 'formula', 'agg', 'scope', 'format', 'measure') - - -class StoreUnavailable(RuntimeError): - """The store is down AND the caller offered no durable fallback (HTTP adapter → 503). - - The Streamlit adapter never sees this: it passes a session-state-backed `fallback_ws`, so - its store-down path degrades to the same captioned in-memory workspace it always had. An - API caller has no such dict, and the honest answer to "persist this" with nowhere to - persist it is an error — not a 200 over a write that evaporates. - """ - - -@dataclass -class EventResult: - """What the HANDLER needs to tell its adapter — the three things it cannot do itself. - - `rerender` is the old return value with its old meaning: "this page must re-render from - server state", NOT "handled" (see `handle_one`'s docstring — the distinction is the - difference between a snappy grid and a sluggish one). - """ - rerender: bool = False - doc: Optional[dict] = None # doc_fetch's answer — the bridge has no response channel - toast: Optional[str] = None # add_to_list's confirmation - - -@dataclass -class EventCtx: - """Everything the handler is allowed to know, and nothing about how it is being served. - - ⚠ `allowed_pids` is a SECURITY parameter: it is the pool this caller may touch at all, and - every row-scoped event (overlay_patch, doc_*, cohort membership, add_to_list, memberPids) - is intersected with it. `admin` and `uname` are likewise the HOST's statement of identity — - never anything the browser said about itself. - - `seen_ids` is ADAPTER-OWNED dedup state (the event log resends its recent window on every - emit, so consumed ids must stay consumed): Streamlit passes a session-state dict, HTTP - passes the caller's per-session mapping. - - `fallback_ws` is the store-unavailable workspace home, the OUTER dict keyed by username - (exactly what `st.session_state.setdefault('cl_table_workspace', {})` is). HTTP passes - None, and None + store down raises StoreUnavailable (→ 503). - - `out` is the sink for the two parked answers, so `handle_one` can keep returning a bare - bool exactly as it did in app.py while doc/toast still escape. (Additive amendment to X1's - field list, 2026-07-30 — defaulted; recorded in the S1 mailbox.) - """ - uname: str - allowed_pids: frozenset - fields: list - measure_keys: frozenset = frozenset() - resolved_ids: frozenset = frozenset() - cohort_ids: frozenset = frozenset() - measure_offer: tuple = () - visible_views: tuple = () - admin: bool = False - #: ⛔ WAVE 15 C-PERM — the fields this caller's PERMANENT PERMISSIONS hide, transitive - #: closure included (`core.perm_scope.hidden_keys`). A SECURITY parameter in the same class - #: as `allowed_pids`, and here for the same reason: the read paths strip a hidden field from - #: both wires, so a caller cannot SEE it — but without this they could still WRITE it by - #: naming the key, because `overlay_keys` only asks whether the column exists. Empty means - #: "nothing hidden", which is every legacy record and therefore every caller today. - hidden_keys: frozenset = frozenset() - scope_key: str = 'customer' - #: Wave 16 C-TOPIC — the TABLE OPS this ctx reads and writes (a `core.table_store.make` - #: object). None = the CUSTOMER table (`modules.customer_data.TABLE_OPS`), which keeps - #: every existing caller byte-identical. The PRODUCT surface passes - #: `modules.product_data.TABLE_OPS` so its views/fields/overlays land in the product - #: bucket — never the customer one, whose pids are a different id space entirely. - table: Optional[object] = None - #: ⭐ WAVE 25 (R6b, closes DEBT D-16) — THE TENANT STORE HANDLE, and it is a RESIDENCY - #: parameter in the same class as `allowed_pids` is a security one. Everything this handler - #: could not reach through `ctx.table` used to go through the MODULE-LEVEL `core.store`, - #: i.e. tenant #0's dataset repo: the documents family, `_store_of(ctx).available()`, and — through - #: `_tops`' fallback — the whole customer and product table workspace. Owner, R6b: - #: *"Tenant 0 should not intertwine anywhere."* - #: - #: None keeps the module default, which is byte-identical for tenant #0 (empty prefix, - #: shared repo) and is what every non-HTTP caller still gets. The HTTP adapters pass - #: `session.runtime`, so a Nurilab write resolves against Nurilab. - st: Optional[object] = None - seen_ids: Optional[MutableMapping] = None - fallback_ws: Optional[MutableMapping] = None - out: EventResult = field(default_factory=EventResult) - - def __post_init__(self): - # A caller that forgets `seen_ids` gets per-call dedup rather than a crash — the SET is - # what makes re-delivery idempotent, and its absence must not be the thing that decides - # whether an event is processed at all. - if self.seen_ids is None: - self.seen_ids = {} - - -def _store_of(ctx): - """THE store handle this ctx reads and writes through (wave 25, R6b / D-16). ONE resolver, - so "whose repo does this land in" has exactly one answer per event. - - Order, and each step is deliberate: the ctx's own tenant handle · else the handle the TABLE - OPS object was built with (`table_store.make(..., st=…)` keeps it on `.st`, which is how the - `ut_` path has been tenant-correct since wave 18) · else the module default. - - ⛔ THE MODULE DEFAULT IS TENANT #0, AND THAT IS THE BUG D-16 NAMES — not a safe base case. - It is kept because it is byte-identical for tenant #0 (empty prefix, shared repo) and every - non-HTTP caller still relies on it; what changed is that the HTTP adapters now pass a real - handle, so the fallback is no longer what serves a Nurilab request. `verify_scopes` proves - that with a negative control: force this to fall through and the residency legs go RED. - """ - st = getattr(ctx, 'st', None) - if st is not None: - return st - tbl = getattr(ctx, 'table', None) - inner = getattr(tbl, 'st', None) if tbl is not None else None - return inner if inner is not None else store - - -def _blobs(ctx): - """The BYTE store for this ctx — `_store_of`'s sibling, split out because it is NOT yet - equivalent and pretending otherwise would hide a live residency hole. - - ⚠ `TenantRuntime` exposes `get/put/update/exists/available` and **no byte methods** - (`harness/runtime.py`), so a tenant with its OWN repo has nowhere tenant-scoped to put a - document's BYTES. The metadata half of D-16 is fixed here; the byte half needs - `upload_bytes`/`download_bytes`/`delete_path` on the runtime, which is SESSION A's file — - asked for in the mailbox and booked as debt rather than papered over with a fallback that - reads as intentional. The moment those methods exist this resolver picks them up with no - other change, which is why it is written as a capability test and not as a version check. - """ - st = _store_of(ctx) - return st if hasattr(st, 'upload_bytes') else store - - - -def _ag_overview_id(): - """`aios_grid.IG_OVERVIEW_ID`, resolved defensively. - - Function-local import, this module's convention for the layer above it. Falls back to the - literal rather than raising: a store blip must not turn "refuse to delete" into "allow", and - an unguarded delete here is the one that reads as success and undoes itself. - """ - try: - import aios_grid as _ago - return _ago.IG_OVERVIEW_ID - except Exception: - return 'tpl_overview' - -def _tops(ctx): - """The table-ops object this ctx operates on — the customer table unless the caller chose - another topic. ONE resolution point so 'which bucket does this write land in' has exactly - one answer per event. - - ⛔ WAVE 25 (R6b) — THIS FALLBACK IS STILL TENANT #0's, DELIBERATELY, AND IT IS THE ONE PIECE - OF THE RESIDENCY FIX THAT DID NOT LAND. `cl_mod.TABLE_OPS` is a module-level singleton built - with no store handle, so the customer and product table workspaces (views, fields, overlays, - folders) resolve to tenant #0 for every caller. Scoping it here was written, measured and - then REVERTED, because it would have been worse than the bug: - - the WRITE door (`routes_grid._ctx`) is this session's file and now carries a handle; - the READ door is `routes_customers._ctx_for` / `routes_products` — files in NO session's - fence this wave — and they carry none. - - Moving only the write side means a prefix tenant saves a view into `t//…` and reads - back from the unprefixed bucket forever: their own view vanishes the moment they save it. - A latent split is worse than a stated residency error, and read and write must move in ONE - change. Booked as an ask + debt with that exact shape. Tenant #0 is unaffected either way - (empty namespace, default repo), which is why nothing today would have shown it. - - ⛔ Untouched by this wave regardless: the EMIT GUARD is D-40, deferred by ruling A-15. - """ - if ctx.table is not None: - return ctx.table - return cl_mod.TABLE_OPS - - -def _cohorts(ctx): - """The COHORT store this ctx operates on — `_tops`'s sibling, and here for the same reason. - - ⭐ WAVE 19 / R9: a cohort belongs to its database. Every cohort verb below used to name - `cohort_mod` directly, so a list built on the Product surface landed in `customer_cohorts` - beside somebody's real customer lists — with PRODUCT pids in it (CRC32 hashes of SKU codes), - which the customer page would then try to resolve against partner ids. Wave 16 answered that - by REFUSING cohorts on other topics (`with_cohorts=False`); R9 says scope-parameterize - instead. `modules.cohort.scoped` returns the module itself for the customer topic, so the - customer path is byte-unchanged. - """ - return cohort_mod.scoped(ctx.scope_key) - - -#: The keys a stored document entry may put on the wire. ⛔ `path` IS NOT ONE OF THEM — it is the -#: byte address inside the tenant's repo, of no use to a browser and a needless disclosure of the -#: store's layout. `canDelete` is not stored either: it is a fact about the READER, computed per -#: request below, so one person's answer can never be cached into another's. -_DOC_WIRE_KEYS = ('id', 'name', 'mime', 'size', 'by', 'ts') - - -def docs_for(pids, scope_key='customer', uname='', admin=False, st=None): - """`{"": [CustomerDoc, ...]}` for a SCOPED row set — the producer `payload.docs` lost. - - ⭐⭐ WAVE 30 / CONTRACT C4 (D-138). The write door has been live and UNREACHABLE since EXIT-6: - `doc_add`/`doc_fetch`/`doc_delete` all still work, and the comment inside that handler says - exactly why nobody noticed — *"the React shell cannot reach it yet (`payload.docs` is set only - by app.py)"*. `app.py` was deleted, and the producer went with it. Every client half survived: - `RecordDetail`, `SwipeView` and `Documents` are complete, and all six `onDoc*` handlers in - `CustomerGrid.tsx` are written as `payload?.docs ? … : undefined`, so an ABSENT `docs` key is - what has been switching the whole feature off. An empty dict is truthy and turns it on with an - honest empty state. - ⛔ THIS IS THE SIXTH INSTANCE OF THE REACHABILITY CLASS and the one `verify_reachability.py` - cannot see — a payload key assembled through helpers and dict merges is not a text-matchable - literal ([[reachable-is-not-the-same-as-built]], [[artifact-with-no-importer]]). - - ⛔ ONE READER, NOT A MATCHING PAIR. `routes_tables` (ut_*) and `routes_customers` (the customer - scope) both call THIS — they do not each serialise. Two surfaces answering one question - separately is how the same defect got reintroduced in the opposite direction inside a single - commit ([[one-question-two-normalizers]]). - - ⚠ `pids` IS REQUIRED and is the row set the CALLER has already scoped — `shared_overlay.cells`' - argument, for the same reason: there is no "every document in the tenant" call to reach for. - A record with no documents gets `[]`, never a missing key, so a client can tell "none" from - "not served". - """ - wanted = {str(int(p)) for p in (pids or [])} - if not wanted: - return {} - scope = str(scope_key or 'customer').strip().lower() - key = _DOCS_KEY if scope in cohort_mod.LEGACY_SCOPES else f'{scope}_docs' - try: - stored = (st if st is not None else store).get(key) or {} - except Exception: # noqa: BLE001 - stored = {} # a display read degrades; it never breaks the grid - out = {} - for pid in wanted: - row = stored.get(pid) - docs = [] - for d in (row if isinstance(row, list) else []): - if not isinstance(d, dict) or not d.get('id'): - continue - wire = {k: d.get(k) for k in _DOC_WIRE_KEYS} - # The delete wall, mirrored onto the wire so the button is absent rather than - # offered-and-refused. `doc_delete` enforces the same test server-side; this is the - # DISPLAY of that rule, never a substitute for it. - wire['canDelete'] = bool(admin or (d.get('by') and d.get('by') == uname)) - docs.append(wire) - out[pid] = docs - return out - - -def _docs_key(ctx): - """The DOCUMENTS bucket this ctx operates on — `_cohorts`'s sibling (wave 19, item 12's - silent third case). - - ⛔ `_DOCS_KEY` STAYS the module constant with its shipped value: app.py re-imports it for the - Streamlit docs payload and `verify_seam.py` asserts the two resolve to the same string. This - resolves the CUSTOMER topic to exactly that constant and gives every other database its own - bucket, so the pid keys inside one bucket all come from one id space. - """ - scope = str(getattr(ctx, 'scope_key', '') or 'customer').strip().lower() - if scope in cohort_mod.LEGACY_SCOPES: - return _DOCS_KEY - return f'{scope}_docs' - - -# ------------------------------------------------------------------ the workspace family (X1 amd.) -def table_workspace(ctx, allowed_pids=None, consume_corrections=True): - """Durable Airtable-style view/schema/overlay state, with an honest session fallback. - - Wave-9 I17: the returned `views` are this user's OWN views MERGED with every view SHARED - with them. Own views win a same-id collision — a personal view is the user's own object and - must never be shadowed by somebody else's share. - - ⚠ `allowed_pids` is a SECURITY parameter, not a convenience. A shared view carries - `memberPids`, which the writer's scope validated on the way IN — but the READER may have a - narrower scope. Handing a Fisch-scoped user a view whose member list was built by a - full-access user would leak the other BU's customers straight through the strict-isolation - rule. So membership is re-filtered against the READER on the way OUT. Pass it wherever the - result reaches a renderer or an event handler; omitting it drops memberPids entirely rather - than trusting the stored list (fail-closed: a smaller view, never a wider one). - - MOVED from app.py:5323 (`_cl_table_workspace`). `admin` was a parameter defaulting to a live - `is_admin()` call; it now always comes from `ctx.admin`, which is identical at the one call - site because app.py computes `admin = bool(is_admin())` once per render. `allowed_pids` - stays an EXPLICIT argument because the folder leg deliberately omits it. - """ - uname = ctx.uname - if _store_of(ctx).available(): - ws = _tops(ctx).workspace( - uname, consume_corrections=bool(consume_corrections)) - try: - shared = _tops(ctx).shared_views(uname, bool(ctx.admin)) - except Exception as _se: - _tel.error('sharedviews:read', _se) - shared = {} - # ⭐ WAVE 21 (item 9, C1): the R10 registry's named-user grants, PROJECTED at last. - # Wave 20 wrote view grants and no reader ever consulted them — "Share view…" recorded - # a row and changed nothing on the receiver's screen. - try: - granted = _granted_views(ctx, uname) - except Exception as _ge: - _tel.error('sharedviews:granted', _ge) - granted = {} - if shared or granted: - merged = dict(granted) - merged.update(shared) # the everyone-bucket outranks a grant - merged.update(ws.get('views') or {}) # own views win the id collision - ws['views'] = {vid: scope_view(v, uname, allowed_pids) - for vid, v in merged.items()} - return ws - # Store down. Streamlit degrades to its captioned session workspace exactly as before; an - # API caller has no durable dict and must be told, not quietly served memory. - if ctx.fallback_ws is None: - raise StoreUnavailable('table workspace unavailable: no store and no session fallback') - return ctx.fallback_ws.setdefault( - uname, {'views': {}, 'fields': {}, 'overlays': {}}) - - -def _granted_views(ctx, uname): - """{view_id: STAMPED view} for every view GRANTED to `uname` by name (wave 21, item 9, C1). - - The R10 registry stores bare ids, tenant-scoped through the SAME store handle this topic's - bucket uses; the record itself is fetched out of the OWNER's personal stratum of THIS - bucket. An id granted on another topic simply finds nothing here and contributes nothing - (fail-closed). Each projected view is stamped `shared` / `sharedRole` / `owner` so the - client can synthesise the "Shared with me" group and disable edit affordances for - role=view — the stamps live on the PROJECTION only, never written back to the store. - `role_for` is asked with is_admin=False deliberately: the stamp reports the GRANT (the - registry listed only explicit entries), not the caller's rank. - """ - import core.shares as shares - tops = _tops(ctx) - st = getattr(tops, 'st', None) - ids = (shares.shared_with(uname, kind='view', st=st) or {}).get('view') or [] - out = {} - for vid in ids: - found = tops.find_view(vid) if hasattr(tops, 'find_view') else None - if not found: - continue - owner, view = found - if owner == uname: - continue - role = shares.role_for('view', vid, uname, st=st) - if role not in ('view', 'edit'): - continue - v = dict(view) - v['shared'] = True - v['sharedRole'] = role - v['owner'] = owner - out[str(vid)] = v - - # ⭐ D-37 (2026-08-05) — THE FOLDER LEG. W20's R10 said "a folder share = its views ride - # along"; W21 wired the view kind only, so a granted folder was accepted, stored and listed - # under "Shared with me" while changing NOTHING on the receiver's screen. Two waves of a - # control that reported success and did nothing — the same defect class the view leg above - # was written to close, surviving in its sibling. - # - # ⚠ A DIRECT VIEW GRANT WINS over the folder it happens to sit in, and the ordering above is - # what implements that: the direct pass has already filled `out`, so the folder pass skips - # ids it finds there. The specific grant is the more deliberate statement of intent, and it - # may carry a DIFFERENT role — a folder shared read-only must not downgrade a view somebody - # was explicitly given edit on, nor upgrade one they were not. - try: - folder_ids = (shares.shared_with(uname, kind='folder', st=st) or {}).get('folder') or [] - except Exception: - folder_ids = [] - for fid in folder_ids: - found = tops.find_folder(fid) if hasattr(tops, 'find_folder') else None - if not found: - continue # a folder on another topic contributes nothing here - owner, folder, inside = found - if owner == uname: - continue - role = shares.role_for('folder', fid, uname, st=st) - if role not in ('view', 'edit'): - continue - for vid, view in inside.items(): - if str(vid) in out: - continue - v = dict(view) - v['shared'] = True - v['sharedRole'] = role - v['owner'] = owner - # The folder's NAME rides so the client can group these together later; today it - # renders in the same "Shared with me" group the view leg feeds. Stamps live on the - # PROJECTION only and are never written back, exactly as above. - v['sharedFolder'] = str(folder.get('name') or '')[:80] - out[str(vid)] = v - return out - - -def scope_view(view, uname, allowed_pids): - """Re-scope a view to the READER. A no-op for a view the reader created. - - Two things travel inside a shared view that are SCOPE, not presentation: - 1. `memberPids` — a customer list. Re-filtered against the reader's permitted pool. - 2. cohort conditions — a cohort id is a permission handle owned by ONE user. For a reader - who does not own it the leaf is UNANSWERABLE, and partial evaluation of an unanswerable - leaf WIDENS `is none of` ([[cg-cohort-set-conditions]]) — i.e. it would show MORE rows, - not fewer. So the whole filter tree is dropped for a foreign reader rather than - evaluated with a hole in it. A visibly unfiltered shared view is recoverable; a - silently widened one is a leak. - """ - if not isinstance(view, dict) or view.get('createdBy') == uname: - return view - out = dict(view) - cfg = dict(out.get('config') or {}) - mp = cfg.get('memberPids') - if isinstance(mp, list): - cfg['memberPids'] = ([p for p in mp if p in (allowed_pids or ())] - if allowed_pids is not None else []) - if tree_has_cohort(cfg.get('filters')): - cfg['filters'] = [] - cfg['_scopedOut'] = 'cohort' # the client says so rather than pretending - out['config'] = cfg - return out - - -def tree_has_cohort(node): - """True when a filter tree references the Cohorts column anywhere (any depth).""" - import aios_grid as _agc - col = _agc.COHORT_COLUMN - if isinstance(node, list): - return any(tree_has_cohort(n) for n in node) - if not isinstance(node, dict): - return False - if node.get('colId') == col or node.get('field') == col: - return True - return tree_has_cohort(node.get('conditions') or node.get('children')) - - -def measure_leaves(nodes): - """Every leaf of a filter tree that carries a date WINDOW — i.e. every measure condition. - - Keyed off the window rather than the column name on purpose: a window is the one thing no - column condition has, so this cannot mistake `revenue_ytd > 5000` for a measure. - """ - out = [] - for node in nodes or []: - if not isinstance(node, dict): - continue - if isinstance(node.get('children'), list): - out.extend(measure_leaves(node['children'])) - elif node.get('window') is not None: - out.append(node) - return out - - -# ------------------------------------------------------------------ private handler helpers -def _field_echo_equal(accepted, raw): - """Does the ACCEPTED def match what the client already renders? (wave-6 item 3) - - The no-blip law: a field_upsert whose acceptance changed NOTHING the client draws needs no - re-render — the client is the ORIGIN of the change, its optimistic copy is already right, - and the rerun it would buy costs a full payload re-serialisation plus an iframe remount - (the "blip" the owner named). STRICT per-prop equality after collapsing the empty forms - ('' / [] / {} / absent all render identically): any normalisation a validator applied is a - real difference the client must be SHOWN. Fail-closed — unsure means not equal means rerun. - """ - empty = ('', [], {}) - for k in _FIELD_ECHO_PROPS: - a, b = accepted.get(k), raw.get(k) if isinstance(raw, dict) else None - if k == 'scope': - # 'global' and absent are the SAME rendering (an unscoped field) — the event may - # say 'global' explicitly while the stored def says it by omission. - a = None if a == 'global' else a - b = None if b == 'global' else b - if (None if a in empty else a) != (None if b in empty else b): - return False - return True - - -def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120): - """Return a durable, case-insensitively unique field/view display name.""" - limit = max(1, int(max_len)) - - def _clean(value): - return ' '.join(str(value or '').split()) - - base = (_clean(wanted) or _clean(fallback) or 'Untitled')[:limit].rstrip() - taken = {_clean(value).casefold() for value in existing if _clean(value)} - if base.casefold() not in taken: - return base - index = 2 - while True: - suffix = f' {index}' - stem = base[:max(0, limit - len(suffix))].rstrip() - candidate = f'{stem}{suffix}' if stem else str(index)[-limit:] - if candidate.casefold() not in taken: - return candidate - index += 1 - - -def _known_usernames(): - """Real account names, for C4's fail-closed `users` grant. None = "could not resolve". - - None means DO NOT FILTER, deliberately. Returning an empty set on a transient registry - error would silently collapse a user's stored 'specific users' grant to 'personal' on the - very next autosave — destroying a setting because a read blipped. The grant is inert today - anyway (views are per-user; nothing reads it to allow cross-user access), so the honest - trade is: never invent access, never destroy a stored intent either. - """ - try: - reg = set(users.registry() or {}) - except Exception as _ue: - _tel.error('viewperms:users', _ue) - return None - # ⚠ EMPTY IS "UNRESOLVED", NOT "NOBODY EXISTS". registry() returns {} rather than raising - # when the user store cannot be read (a store blip logs `store:get:users` and hands back an - # empty dict) — and a working app ALWAYS has at least the bootstrap admin. Treating {} as a - # real answer would filter every name out of a stored grant and collapse it to 'personal' - # on the next autosave. Caught by testing this in BARE mode with no store token. - return reg or None - - -# ------------------------------------------------------------------ THE SEAM -def handle_events(events, ctx): - """The event LOG entry point → one EventResult. The `[-24:]` window lives here. - - ⚠ THE VALUE IS AN EVENT LOG `{'events': [...]}`, NOT ONE EVENT (2026-07-27). A component - has exactly one value slot, and two quick emits share it: creating a field emits - `field_upsert` and the insertColumn autosave emits `view_upsert` 420ms later — with the - server still inside the first rerun, the second write clobbered the slot before any run - read it, and the field creation VANISHED with no error anywhere (measured live; the same - race sat latent under overlay-field creation, masked by localStorage). The client now - sends its recent window of events; each is processed exactly once, keyed by its id. The - single-event shape is still accepted — it is what an older client in a stale tab sends. - - Accepts everything the component value can be: the log dict, a bare list of events (what - the HTTP body's `events` array unwraps to), or one legacy single-event object. - - `EventResult.rerender` is True when ANY processed event demands a re-render from server - state — the same OR-fold `_cl_handle_grid_event` did. - """ - if isinstance(events, dict) and isinstance(events.get('events'), list): - seq = events['events'][-24:] - elif isinstance(events, list): - seq = events[-24:] - else: - seq = [events] - rerun = False - for one in seq: - rerun = handle_one(one, ctx) or rerun - ctx.out.rerender = rerun - return ctx.out - - -def handle_one(event, ctx): - """Validate ONE untrusted component event. - - Odoo-source fields are never accepted as cell updates. View and schema objects are - user-scoped; row patches additionally require the pid to be in the currently permitted - Customer List pool. - - RETURN VALUE = "this page must re-render from server state", NOT "handled". - The distinction is the difference between a snappy grid and a sluggish one. - Persisting is not by itself a reason to rerun: Streamlit has ALREADY re-run the - script once simply to deliver the component value, and the client is the ORIGIN - of the change, so its own state is correct before the server hears about it. An - extra st.rerun() therefore buys nothing and costs a second full page run. - Measured on the 1,550-row book (2026-07-25): one filter tweak produced TWO - page_customer_data runs ~2.8s apart, each re-serialising the whole ~576 KB row - payload — and the handler on the second run is a guaranteed no-op, because the - event-id dedupe two lines below has already consumed that id. - - THE WAVE-6 NO-BLIP LAW (item 3, 2026-07-27) — True ONLY when the next render - genuinely differs from what the client already draws: - field_upsert — False when the accepted def ECHOES the client's copy - (_field_echo_equal); True on any validator mutation, any - permissions refusal, and ALWAYS when a measure window changed - (values must be re-resolved server-side). - overlay_patch — False when every requested update was accepted verbatim; - True the moment anything was refused or truncated. - field_delete — False: the client removed the column optimistically and the - store scrub is invisible to the next render. - field_duplicate — True only when host-held VALUES must flow back (custom_ cell - copies, measure_ recomputes); formula/created_time clones - compute client-side. - view_delete — False (the deleted view simply stops being served). - view_upsert — unchanged: False, except an unresolved measure condition. - cohort_* / add_to_list — True: the lists payload is server truth, and these are - not the hot path. - - The id dedupe is a SET (not a last-id slot): the event log resends the whole recent - window on every emit, so every id must stay consumed, not just the latest one. - """ - # The ctx fields the moved body reads as plain locals — bound once here so every branch - # below is textually the app.py body (which is what makes this move reviewable). - uname = ctx.uname - allowed_pids = ctx.allowed_pids - fields = ctx.fields - measure_keys = ctx.measure_keys - resolved_ids = ctx.resolved_ids - cohort_ids = ctx.cohort_ids - measure_offer = ctx.measure_offer - visible_views = ctx.visible_views - admin = ctx.admin - scope_key = ctx.scope_key - - if not isinstance(event, dict): - return False - event_id = str(event.get('id') or '')[:180] - seen = ctx.seen_ids - if not event_id or event_id in seen: - return False - seen[event_id] = True - if len(seen) > 400: # a session-lifetime set, kept bounded - for stale in list(seen)[:200]: - seen.pop(stale, None) - kind = event.get('type') - field_by_key = {f['key']: f for f in fields} - valid_keys = set(field_by_key) - overlay_keys = {f['key'] for f in fields if f.get('source') == 'overlay'} - # Event validation is not a render payload. It must not consume a one-shot label - # correction before the browser has had a chance to receive it. - ws = table_workspace( - ctx, allowed_pids=allowed_pids, consume_corrections=False) - - def _session_ready(): - ws.setdefault('views', {}) - ws.setdefault('fields', {}) - ws.setdefault('overlays', {}) - - if kind == 'view_select': - # Owner item 3 (2026-07-31): remember WHERE THE USER IS so a fresh browser resumes - # there instead of the system default view. Presentation state only — the read side - # re-validates the id against what the caller may see, so nothing here needs an - # authorisation wall beyond the length cap. Never a re-render: the client is already - # on that view. - vid = str(event.get('viewId') or '').strip()[:120] - if vid and _store_of(ctx).available(): - try: - _tops(ctx).save_active_view(uname, vid) - except Exception as _ae: - _tel.error('grid:view-select', _ae) - return False - - if kind == 'record_layout': - # Wave 2026-08-02 (C-LAYOUT): the per-user record-detail field order. Presentation - # state for one surface; grid column order is untouched. Unknown keys are pruned - # against the LIVE field set, dupes collapse, and the wire re-validates at serve - # time — so a client racing a field delete degrades to default order, never an error. - raw_order = event.get('order') - if not isinstance(raw_order, list): - return False - order, seen = [], set() - for k in raw_order[:200]: - k = str(k or '').strip()[:80] - if k and k in valid_keys and k not in seen: - seen.add(k) - order.append(k) - if _store_of(ctx).available(): - _tops(ctx).save_record_layout(uname, order) - else: - _session_ready() - if order: - ws['recordLayout'] = {'order': order} - else: - ws.pop('recordLayout', None) - # Autosave hot path, like view_select: the client already renders the order it asked - # for, and the wire echoes it back on the next workspace read. - return False - - if kind == 'view_upsert': - raw = event.get('view') - if not isinstance(raw, dict) or not isinstance(raw.get('config'), dict): - return False - view_id = str(raw.get('id') or '').strip()[:120] - name = str(raw.get('name') or '').strip()[:120] - requested_name = name - if not view_id or not name: - return False - cfg = raw['config'] - # The filter TREE (leaf conditions and/or nested condition groups) is - # validated by the module-agnostic grid contract helper — imported lazily - # here for the same reason the page does it: a missing embed build must - # never break page import. - import aios_grid as _ag - # A MEASURE key is valid in a FILTER and nowhere else (CG-8): it may not be ordered, - # shown, grouped or coloured by, because it is not a column of this table. Hence the - # widened key set here and the plain `valid_keys` everywhere below. - # `cohort_ids` is a PERMISSION list, not a convenience: a cohort rule naming anything - # else is dropped here. Leaving it in would persist a condition the engine can only - # answer with "nothing", so `Cohort is not [a list I deleted]` would show an empty table - # forever with nothing on screen explaining why. - filters = _ag.clean_filter_tree(cfg.get('filters'), - valid_keys | set(measure_keys), - cohort_ids=set(cohort_ids)) - sorts = [] - for rule in list(cfg.get('sorts') or [])[:20]: - if (isinstance(rule, dict) and rule.get('colId') in valid_keys - and rule.get('dir') in ('asc', 'desc')): - sorts.append({'colId': rule['colId'], 'dir': rule['dir']}) - order = list(dict.fromkeys( - key for key in list(cfg.get('order') or []) if key in valid_keys)) - order += [field['key'] for field in fields if field['key'] not in order] - visible = list(dict.fromkeys( - key for key in list(cfg.get('visible') or []) if key in valid_keys)) - widths = {} - raw_widths = cfg.get('widths') if isinstance(cfg.get('widths'), dict) else {} - for key, value in raw_widths.items(): - if key in valid_keys: - try: - widths[key] = max(60, min(600, round(float(value)))) - except (TypeError, ValueError): - pass - # C-LOCK (wave 2026-08-02, item 12's mechanism): a view may be LOCKED to one cohort. - # `cohort_ids` is the same permission list the cohort filter leaf uses — a lock naming - # a cohort this session cannot see is DROPPED at write, exactly like a cohort rule. - # (Readers of a SHARED locked view resolve the id against their OWN visible sets; an - # unresolvable lock matches NOTHING client-side — the cohort-leaf law, never a widen.) - cohort_lock = str(cfg.get('cohortLock') or '').strip()[:120] - # ⛔ WAVE 17 R1 (C-LOCKV guard a) — A PROJECTED LOCKED VIEW RE-STAMPS ITS OWN LOCK. - # A locked view IS its set: its id and its lock are the same fact. The client rebuilds - # `config` from scratch on every autosave (a column resize is enough), so taking the - # lock from the browser would mean one omitted key turns "the 40 accounts we agreed to - # call" into the whole book, under the same name, with nothing going red. The read side - # re-stamps too (aios_grid.views_from_defs) — this keeps the STORE from accumulating the - # wrong record in the first place. - _is_locked_view = view_id in set(cohort_ids) - if _is_locked_view: - cohort_lock = view_id - config = { - 'filters': filters, - # conjunction joining the ROOT-level conditions; anything but an - # explicit 'or' means 'and' (legacy views had an implicit AND). - 'filterConj': 'or' if cfg.get('filterConj') == 'or' else 'and', - **({'cohortLock': cohort_lock} - if cohort_lock and cohort_lock in set(cohort_ids) else {}), - # ⭐⭐ WAVE 32 · R5 / CONTRACT C4 — "Mark important" (raised by SESSION C as ASK C-12). - # - # ⛔ WITHOUT THIS LINE THE MARK IS A LIE THAT SURVIVES UNTIL RELOAD. This dict is - # REBUILT FROM AN ALLOWLIST on every autosave — a column resize is enough — so a key - # the client sends and this literal does not name is silently dropped. The badge would - # paint, the write would answer 200, and the mark would be gone on the next read: the - # exact shape T24's done-when says "persists across a reload" to catch, and - # [[a-migration-that-runs-on-the-next-write]] on a write path. - # - # ⚠ UNCONDITIONAL, NOT SPREAD-CONDITIONAL LIKE `cohortLock` ABOVE, and the asymmetry is - # the whole point: R5's mark must be REMOVABLE. A key written only when present means - # unmarking sends `important: false`, the allowlist drops it, and the stored `true` - # survives — a mark you can set and never clear. - # ⚠ `is True`, not truthy: the client sends a real boolean, and a string `"false"` must - # not become a mark. - # ⚠ NO PERMISSION GATE, deliberately. R5 makes this a personal legibility mark, not a - # lock; `_may_administer` already decides who may write this view at all, and adding a - # second wall here would be the two-normalizers shape this wave is already fixing - # elsewhere. - 'important': cfg.get('important') is True, - 'sorts': sorts, - 'groupBy': cfg.get('groupBy') if cfg.get('groupBy') in valid_keys else None, - 'colorBy': cfg.get('colorBy') if cfg.get('colorBy') in valid_keys else None, - 'rowHeightMode': (cfg.get('rowHeightMode') - if cfg.get('rowHeightMode') in ('short', 'medium', 'tall') - else 'short'), - 'order': order, - 'visible': visible, - 'widths': widths, - 'memberPids': [pid for pid in list(cfg.get('memberPids') or []) - if isinstance(pid, int) and pid in allowed_pids], - } - # Wave-6 item 10: a non-grid display mode rides the view config, validated - # structurally (mode whitelist, refs must be this table's fields). - display = _ag._clean_display(cfg.get('display'), valid_keys) - if display: - config['display'] = display - # Wave-6 item 11 (pin): how many leading columns stay frozen while scrolling - # sideways. Clamped 1..8 — 0 would unfreeze the identity column the whole - # surface pivots on; absent = the legacy single frozen column. - if cfg.get('frozenCount') is not None: - try: - config['frozenCount'] = max(1, min(8, int(cfg['frozenCount']))) - except (TypeError, ValueError): - pass - # ── wave-9 C3 (lock) + C4 (permissions) ──────────────────────────────────────────── - # The PRIOR stored view decides both: whether this is a create or an update (C4's - # default splits on exactly that) and who is allowed to change the lock (C3). - prior = (ws.get('views') or {}).get(view_id) or {} - # ⚠ THE SHARED WALL (wave-9 I17). `ws` is already visibility-filtered, so a view the - # caller may not SEE reads as absent — which would look like a CREATE and let anybody - # overwrite somebody else's shared view by guessing its id. Ask the store for the raw - # record and refuse before deciding anything else. Fail-closed on both branches. - _raw_shared = _tops(ctx).shared_view(view_id) if _store_of(ctx).available() else None - if _raw_shared is not None and not prior: - return False # exists, not visible to this caller: refuse - if _raw_shared is not None and not _tstore._may_edit(_raw_shared, uname, admin): - return False # visible but not editable by this caller - is_new = not prior - # C4: the creator is HOST-STAMPED and never read from the browser — the same rule as - # the field-level createdBy. An existing view keeps whoever made it. - creator = prior.get('createdBy') or uname - # Only the creator or an admin may change the PERMISSIONS themselves. A collaborator who - # could rewrite them could grant themselves sole ownership of someone else's view, or - # quietly widen a users-scoped view to everyone — privilege escalation by edit. - if not is_new and not _tstore._may_administer(prior, uname, admin): - raw = dict(raw) - raw['permissions'] = prior.get('permissions') - raw['locked'] = prior.get('locked') - # ── I12/C3 BELT (2026-07-31): a LOCKED view's DISPLAY MODE is frozen HOST-SIDE too. - # The client's switcher already refuses (setDisplayMode), and its comment has claimed - # "the host refuses too" since wave 9 — this makes that claim true for direct API - # callers. ⚠ MIRRORS types.ts `isModeFrozen` EXACTLY: SYSTEM views and the undeletable - # list:/cohort: projections are EXEMPT — their `locked` is legacy "undeletable", not - # mode-frozen, and freezing them would trap the default view in whatever mode it last - # saved (the owner's stuck-in-Map report is that trap, client-made). An upsert that - # UNLOCKS in the same write (authorised above) may change display freely. - _mode_frozen = (prior.get('locked') and raw.get('locked') is not False - and prior.get('kind') != 'system' - and view_id != 'all-customers') - if _mode_frozen: - if prior.get('config', {}).get('display'): - config['display'] = dict(prior['config']['display']) - else: - config.pop('display', None) - # ⚠ A SYSTEM VIEW IS PER-USER STATE AND MUST NEVER BE SHARED. Caught in the wave-9 - # integration pass, before deploy: `all-customers` is every user's DEFAULT view and it - # carries their own column widths, visible fields and row height. C4's read-default - # ('collaborative' when permissions are absent, so nobody's saved view is - # retro-restricted) is right for a view somebody AUTHORED — but applied to the system - # view it routed `all-customers` into the shared bucket, and the first user to resize a - # column would have published their layout to everyone. Verified in the store: - # shared_table_views() was returning ['all-customers']. - # System views are pinned personal, so they stay in each user's own workspace exactly as - # before this wave. `kind` is host-validated two lines below; read it here from `raw` and - # treat the stored kind as authoritative for an existing view. - _kind = (prior.get('kind') if not is_new - else (raw.get('kind') if raw.get('kind') in ('system', 'list', 'custom') - else 'custom')) - if _kind == 'system' or view_id == 'all-customers': - perms = {'edit': 'personal'} - else: - perms = _ag.clean_view_permissions( - raw.get('permissions'), - default='personal' if is_new else 'collaborative', - known_users=_known_usernames()) - # C3: only the creator or an admin may SET or CLEAR the lock; anyone else keeps whatever - # is already stored. Enforced here rather than hidden in the client. This composes with - # C4 and does not override it: C4 governs who may edit the CONTENTS, C3 who may freeze - # the display MODE. - # ⚠ Inert in practice TODAY — a user can only ever write their own per-user workspace, - # so `uname == creator` always holds. Correct plumbing for shared views, not a live gate. - may_lock = admin or uname == creator - locked = bool(raw.get('locked')) if may_lock else bool(prior.get('locked')) - # C3: the LOCK's actual job — a locked view's display mode is frozen, so a locked Kanban - # stays Kanban. This half IS live per-user and is the part the owner asked for. Filters, - # sorts and columns stay editable by design. - if _mode_frozen: - was = (prior.get('config') or {}).get('display') - now = config.get('display') - if (was or {}).get('mode') != (now or {}).get('mode'): - if was: - config['display'] = was - else: - config.pop('display', None) - # WAVE 17 R1 (C-LOCKV guard b) — ONE THING, ONE NAME. Renaming a locked view renames - # the SET it is, in the store that owns it; the view record must not grow a second title - # that the read side would then have to arbitrate. Routed rather than duplicated. - if _is_locked_view and _store_of(ctx).available(): - _sets = _cohorts(ctx) # R9: the SET lives in this topic's bucket - _cur = (_sets.all_for(uname).get(view_id) or {}).get('name') - if name and name != _cur: - try: - _sets.rename(uname, view_id, name) - except ValueError as _e: - _tel.error('cohort:rename-via-view', _e) - view = {'id': view_id, 'name': name, - # ⚠ DERIVED FROM THE ID, never taken from the browser: `kind` is what paints the - # lock mark, so a client claiming it for an ordinary view would be claiming a - # guarantee the engine is not making about that view's rows. - 'kind': _ag.LOCKED_VIEW_KIND if _is_locked_view - else raw.get('kind') if raw.get('kind') in ('system', 'list', 'custom') - else 'custom', - 'locked': locked, - 'createdBy': creator, - 'permissions': perms, - 'note': str(raw.get('note') or '')[:2000], - 'config': config} - # System/list projections are not all persisted in this table store, but custom views - # may not impersonate their visible names. Persisted view names are allocated - # tenant-globally by TableStore inside the write transaction. - _reserved_view_names = [ - value.get('name') for value in visible_views - if (isinstance(value, dict) and value.get('id') != view_id - and value.get('kind') in ('system', 'list')) - ] - # Wave 21 (item 3, R6): the reserved SYSTEM name is PER-TOPIC — a ut_ database's system - # view is "All records", and reserving the customer literal there uniquified a user's - # honest "All customers" view name against a string that never appears on that topic. - _scope = str(getattr(ctx, 'scope_key', '') or 'customer').strip().lower() - _sys_name = ('All products' if _scope == 'product' - else 'All records' if _scope.startswith('ut_') else 'All customers') - if view_id != 'all-customers': - _reserved_view_names.append(_sys_name) - # ⭐ WAVE 21 (item 9, C1) — THE FOREIGN-VIEW WALL. A granted view is projected into the - # receiver's rail, so their autosave can now emit an upsert carrying an id they do not - # own. Silently saving that into the RECEIVER's stratum would FORK the view (own views - # win the merge, so the receiver sees their private copy forever and calls sharing - # broken). Role `edit` (or admin) writes THROUGH to the owner's record — config only, - # never identity or reach: `createdBy`/`permissions` are pinned from the stored record. - # Role `view` (or no grant) refuses with a toast instead of forking. - # - # ⛔⛔ WAVE 33 · T13 (owner item 3) — A PINNED PER-USER ID IS NOT A FOREIGN VIEW, AND - # TREATING IT AS ONE IS A CROSS-USER WRITE THAT REPORTS SUCCESS. - # - # `find_view` scans EVERY personal stratum and returns the FIRST match. `all-customers` - # and `tpl_overview` are PINNED ids that every user of a table has their own copy of — - # so the moment ANY colleague autosaves one (a column resize is enough), the wall reads - # "somebody else owns this id" for everybody else. MEASURED, on this exact path, after - # one colleague resized a column on the Overview: - # · a NON-ADMIN marking it is REFUSED, with a toast blaming sharing — a view nobody - # ever shared, on their own database; - # · an ADMIN marking it answers 200 and writes the mark into the OTHER USER'S - # stratum. The admin's own workspace still serves the un-marked view, so the mark - # "does not work"; the colleague silently gains a mark they never made. - # That is item 3's second mechanism, and it is a residency bug wearing a sharing bug's - # error message. - # - # ⚠ THIS FUNCTION ALREADY STATES THE RULE THE WALL BREAKS, seventy lines up: "A SYSTEM - # VIEW IS PER-USER STATE AND MUST NEVER BE SHARED", enforced there by pinning - # `perms = {'edit': 'personal'}`. A per-user view can therefore never BE a grant, which - # is the only thing this wall exists to protect — so exempting it narrows nothing. - _pinned_per_user = (_kind == 'system' or view_id in ('all-customers', _ag_overview_id())) - _save_as = uname - if _store_of(ctx).available() and not _pinned_per_user: - _foreign = _tops(ctx).find_view(view_id) - if _foreign and _foreign[0] != uname: - _o_uname, _stored = _foreign - if not admin: - import core.shares as _shares - if _shares.role_for('view', view_id, uname, - st=getattr(_tops(ctx), 'st', None)) != 'edit': - ctx.out.toast = ('That view is shared with you read-only — ask its ' - 'owner for edit access.') - return False - view['createdBy'] = _stored.get('createdBy', _o_uname) - if _stored.get('permissions') is not None: - view['permissions'] = _stored.get('permissions') - else: - view.pop('permissions', None) - _save_as = _o_uname - if _store_of(ctx).available(): - view = (_tops(ctx).save_view( - _save_as, view, reserved_names=_reserved_view_names, is_admin=admin) or view) - else: - _session_ready() - _view_names = list(_reserved_view_names) - _view_names.extend( - value.get('name') - for candidate_id, value in ws['views'].items() - if candidate_id != view_id and isinstance(value, dict) - ) - view['name'] = _unique_name(view.get('name'), _view_names) - ws['views'][view_id] = view - name_changed = view.get('name') != requested_name - # PERSISTED, but deliberately NO rerun — see the return-value contract in - # this function's docstring. This is the hot path: every filter, sort, - # group, colour, column-resize and column-toggle autosaves through here. - # - # CG-8 is the ONE exception, and it is self-limiting. A measure condition is answered - # server-side, so if this view carries one whose answer was NOT in the payload we just - # shipped, the client is showing a pending marker with nothing coming. Ask for exactly - # one more run — and only then, so the common case (a column filter) still costs zero. - # If the top-of-run read of the component's own value already covered it, this is False - # and nothing extra happens; if that read is ever stale, this is what stops the - # condition hanging unresolved. - # - # ⚠ COMPLETE rules only (wave-6 item 3, found by the live roundtrip on this rev): - # picking a measure FIELD mints a valueless rule whose autosave used to land here as - # "unresolved" and trigger a rerun that could resolve NOTHING — `rule_complete` refuses - # half-asked questions — so the only effect was a remount that closed the builder - # popover mid-build (the wave-5 "gate flake" runs, now explained). A rule still being - # typed stays client-side PENDING by design; the run that matters fires when its VALUE - # arrives and the question becomes answerable. - from harness import measure_filter as _mfc - unresolved = {str(r.get('id') or '') for r in measure_leaves(filters) - if _mfc.rule_complete(r)} - set(resolved_ids) - return name_changed or bool(unresolved - {''}) - - if kind == 'view_delete': - view_id = str(event.get('viewId') or '')[:120] - # ⭐ WAVE-27 item 27 (R8) — `IG_OVERVIEW_ID` joins the system view here, and the comment - # eight lines down already explains why in the LOCKED-view case: dropping the saved - # config leaves the PROJECTION, which rebuilds the row on the very next render. Overview - # is injected on every read of an Instagram database, so a delete would look like it - # worked and the view would be back after a reload — which is precisely the - # reads-as-success failure the client's own `UNDELETABLE_VIEW_IDS` note describes. - if not view_id or view_id in ('all-customers', _ag_overview_id()): - return False - # WAVE 17 R1 (C-LOCKV guard c) — deleting a LOCKED view deletes the set it is. Without - # this the store would drop the saved config and the projection would rebuild the view - # on the very next render: the row would come back, minus the user's sort and columns, - # which reads as a corruption rather than as a refusal. Own sets only (`all_for`), the - # same wall `cohort_delete` rides. - if _store_of(ctx).available() and view_id in _cohorts(ctx).all_for(uname): - _cohorts(ctx).delete(uname, view_id) - _tops(ctx).delete_view(uname, view_id) - # True, unlike an ordinary view_delete: the lists payload IS server truth and the - # client cannot know the membership channel changed. - return True - # Wave-9 I17: deleting a SHARED view is narrower than editing it — creator or admin - # only. A collaborator can change what a shared view shows; they cannot destroy - # everybody else's copy of it, because there is only one copy. - if _store_of(ctx).available(): - _raw_shared = _tops(ctx).shared_view(view_id) - if _raw_shared is not None and not _tstore._may_administer( - _raw_shared, uname, admin): - return False - # ⭐ WAVE 21 (item 9, C1) — the foreign wall, delete half. Contract: a grant of - # `edit` may delete (there is only one copy and the owner handed over its content); - # `view` or no grant refuses. The dead grant record is emptied afterwards so the - # receiver's "Shared with me" heals instead of listing a ghost id forever. - _foreign = _tops(ctx).find_view(view_id) - if _foreign and _foreign[0] != uname: - import core.shares as _shares - _sst = getattr(_tops(ctx), 'st', None) - if not admin and _shares.role_for('view', view_id, uname, - st=_sst) != 'edit': - ctx.out.toast = 'That view is shared with you read-only.' - return False - _tops(ctx).delete_view(_foreign[0], view_id) - try: - _shares.set_grants('view', view_id, [], st=_sst) - except Exception: - pass - return False - _tops(ctx).delete_view(uname, view_id) - else: - _session_ready() - ws['views'].pop(view_id, None) - # Wave-6 item 3: the client removed the view optimistically; a deleted view simply - # stops being served, so the next render differs in nothing the client still shows. - return False - - if kind == 'field_upsert': - raw = event.get('field') - if not isinstance(raw, dict): - return False - key = str(raw.get('key') or '')[:80] - import aios_grid as _ag2 - if key.startswith(_ag2.MEASURE_FIELD_PREFIX): - # A FORMULA-MEASURE column (owner item 7). Validated against THIS caller's measure - # offer — the same admission the condition builder uses — so a field can only name a - # measure this user could also filter by. Fail closed on anything else. - field = _ag2.clean_measure_field(raw, {m['key']: m for m in measure_offer}) - if field is None: - return False - fmt = _ag2._clean_format(raw.get('format'), field.get('type')) - if fmt: - field['format'] = fmt - elif key in field_by_key and not key.startswith('custom_'): - base = field_by_key[key] - field = {**base, 'note': str(raw.get('note') or '')[:2000]} - # ⭐ W29-T83 — THE COLUMN SUMMARY ON A SOURCE-BACKED COLUMN. This branch rebuilds the - # accepted def from the CONTRACT and adds only what a user may override, and `agg` was - # not in that list — so Average on Customer's `Overdue days` was accepted with a 200 - # and dropped here, before it ever reached the store. Validated against the shared - # vocabulary rather than a literal (see the custom branch below for what a literal - # costs). An ABSENT or unknown `agg` clears it: the client sends the whole field on - # every edit, so a missing key is a user who unset the summary, and `{**base}` has - # already re-applied whatever the contract itself declares. - _agg = raw.get('agg') - if _agg in _ag2.FIELD_AGGS: - field['agg'] = _agg - else: - field.pop('agg', None) - # Wave-5 item 10: a DISPLAY format on any base field (presets included) — how a - # number/date reads, per user. Rendering-only; validated fail-closed per type. - fmt = _ag2._clean_format(raw.get('format'), base.get('type')) - if fmt: - field['format'] = fmt - else: - field.pop('format', None) # cleared -> back to the type's default render - # PRESET measure fields (wave-2 item 8): the browser may change the PERIOD (and the - # auto-renamed label) of revenue_ytd / revenue_ly / orders_24m — nothing else. The - # whitelist IS the base contract: only a field that shipped with preset+measure can - # take a window here, and the measure KEY is pinned to the base's (fail closed). - if (base.get('preset') and isinstance(base.get('measure'), dict) - and isinstance(raw.get('measure'), dict)): - window = _ag2._clean_window(raw['measure'].get('window')) - if window is not None: - field['measure'] = {'key': base['measure']['key'], 'window': window} - if isinstance(raw.get('label'), str) and raw['label'].strip(): - field['label'] = str(raw['label'])[:120] - elif key.startswith('custom_') and raw.get('type') in _ag2.CUSTOM_FIELD_TYPES: - ftype = raw['type'] - # ⭐⭐ 2026-08-09 — THE RELATIONAL PAIR HAS NO HOME IN A PER-USER OVERLAY, and this - # door used to accept one silently. It builds the stored field from a fixed key list - # and adds only the bags it knows (`options`, `max`, `formula`, `automation`, `code`) - # — so a `link`/`rollup` arriving here was stored WITHOUT its bag, in one user's - # stratum, where `compute_relation_cells` (which walks `user_tables` definitions) - # never sees it. Measured on nurilab as `custom_video_views_90j26`: a Rollup column - # that rendered, could not be filled by anything, and reported no error. - # ⛔ REFUSED RATHER THAN REPAIRED. These two belong to the table's SHARED schema - # (`POST /tables/{key}/fields`), which is where the client now sends them; storing a - # second, private, non-computing copy here would be a second definition of the same - # column. A refusal repaints, so a stale client says something rather than nothing. - if ftype in ('link', 'rollup'): - return False - readonly = ftype in _ag2.READONLY_CUSTOM_TYPES - # The editable family must SAY it is overlay (the client's own contract); the - # read-only pair (formula / created_time) is stored under the cohort column's - # mechanism instead, so no cell write can ever be accepted for it. - if not readonly and raw.get('source') != 'overlay': - return False - field = {'key': key, 'label': str(raw.get('label') or 'Untitled')[:120], - 'type': ftype, 'source': 'odoo' if readonly else 'overlay', - 'default': True, 'custom': True, - 'note': str(raw.get('note') or '')[:2000]} - if readonly: - field['derived'] = True - # FILTERABLE since wave 6 (owner item 6): formula/created_time values live - # client-side, and this table's counts are client-mode — the client engine - # answers conditions on them soundly. Mirrors fields_from_workspace. - field['filterable'] = True - # ⛔ W29-T83 — THE VOCABULARY, NOT THE LITERAL `'sum'`. This is the write gatekeeper - # for an overlay column's summary and it accepted exactly one name, while the READ - # projection (`aios_grid.py:679`) passes all six — so a user who chose Median got a - # 200, a menu that said Median, and no summary after the next reload. The same shape - # as the `agg:"sum"` defect this repo already shipped, where one door kept it and the - # other dropped it ([[read-path-cannot-witness-write-path]]). - _agg = raw.get('agg') - if _agg in _ag2.FIELD_AGGS and not readonly: - field['agg'] = _agg - # A `select`/`multiselect` carries its own vocabulary; a `user` does NOT — its - # choices are the tenant's accounts, resolved fresh on every render, so persisting - # a snapshot here would go stale the moment somebody joins or leaves. - if ftype in ('select', 'multiselect'): - field['options'] = _ag2._clean_options(raw.get('options')) - if not field['options']: - return False # a select with no choices can never hold a value - field.update(_ag2._choice_appearance(raw, field['options'])) - if ftype == 'rating': - field['max'] = _ag2._clean_rating_max(raw.get('max')) - if ftype == 'formula': - # WRITE-time is where refs must exist (fail closed); read-time leaves them - # alone so a later-deleted ref blanks the CELLS, not the column. - formula = _ag2._clean_formula(raw.get('formula'), valid_keys) - if formula is None: - return False - field['formula'] = formula - if ftype == 'automation': - # C5-AUTOFIELD (wave 18): persist the validated config bag. No `return False` - # when absent — an unconfigured automation column is a legitimate intermediate - # state (create the column, then configure it in the gear). - # ⛔ C8 (wave 22): a CONFIGURED bag must name an existing flow — the write door - # reads the tenant's definition ids and `_clean_automation` refuses a bag - # without one (or naming a deleted one). Refused = the whole field write is - # refused, loudly (repaint), because storing the field while dropping its bag - # would be a column that silently never runs. - if isinstance(raw.get('automation'), dict): - try: - _flow_ids = frozenset((ctx.table.st if ctx.table is not None - else _tops(ctx).st).get('automations') or {}) - except Exception: - _flow_ids = frozenset() - auto = _ag2._clean_automation(raw.get('automation'), valid_keys, - flow_ids=_flow_ids) - if auto is None: - return False - field['automation'] = auto - # ⭐ WAVE-27 item 13 (R13): the code column's language. OPTIONAL, like the - # `automation` bag above and unlike `formula` — an unconfigured code column is a - # legitimate intermediate state (create it, pick the language in the gear), and it - # stores and renders as plain text meanwhile. `_clean_code` never refuses over an - # unknown language; it falls back to `plain`, because the value chooses a - # HIGHLIGHTER, and dropping a user's column to punish a typo in one would be the - # disproportionate half of fail-closed. - if ftype == 'code' and isinstance(raw.get('code'), dict): - code_bag = _ag2._clean_code(raw.get('code')) - if code_bag: - field['code'] = code_bag - fmt = _ag2._clean_format(raw.get('format'), ftype) - if fmt: - field['format'] = fmt - else: - return False - # Wave-5 item 1 — createdBy + permissions on the CREATED strata (custom_/measure_). - # `createdBy` is stamped HOST-SIDE at create and preserved on every later write: the - # client never supplies it, so a browser cannot claim someone else's field. A - # permissions CHANGE is accepted only from the creator or an admin; a legacy field - # with no createdBy is admin-only (fail closed, both directions). The table workspace - # is per-user today, so this wall becomes load-bearing when sharing arrives — but the - # admin path and the legacy path are enforceable (and NC'd) right now. - needs_values = False - refused_perms = False - if key.startswith('custom_') or key.startswith(_ag2.MEASURE_FIELD_PREFIX): - prior = ws.get('fields', {}).get(key) - prior = prior if isinstance(prior, dict) else None - if prior is None: - field['createdBy'] = uname - # Wave-6 item 9: scope is chosen at CREATE, on the cohort page only — - # 'cohort' marks the field cohort-specific; 'global' or absent stays - # unscoped, so an old bundle that sends no scope keeps today's behavior. - if scope_key == 'cohort' and raw.get('scope') == 'cohort': - field['scope'] = 'cohort' - else: - if isinstance(prior.get('createdBy'), str) and prior['createdBy'].strip(): - field['createdBy'] = prior['createdBy'] - # Scope is PRESERVED like createdBy: the client copy never moves an - # existing field between scopes. - if prior.get('scope') == 'cohort': - field['scope'] = 'cohort' - want = _ag2._clean_permissions(raw.get('permissions')) - have = _ag2._clean_permissions((prior or {}).get('permissions')) - owner = field.get('createdBy') - if want is not None and (admin or (owner and owner == uname)): - field['permissions'] = want - else: - if have is not None: - field['permissions'] = have - # Asked for a permissions change and did not get it: the client's - # optimistic copy is a lie that must be repainted (wave-6 item 3). - refused_perms = want is not None and want != have - # A NEW measure column, or an existing one whose window moved: the VALUES - # are host-computed, so the next render genuinely differs. - if key.startswith(_ag2.MEASURE_FIELD_PREFIX): - needs_values = prior is None or prior.get('measure') != field.get('measure') - # Canonical names are reserved outside the durable workspace. Persisted field names are - # allocated by TableStore from the transaction's current state, not this stale snapshot. - _workspace_field_keys = set((ws.get('fields') or {})) - _reserved_field_names = [ - value.get('label') for candidate_key, value in field_by_key.items() - if (candidate_key != key and candidate_key not in _workspace_field_keys - and isinstance(value, dict)) - ] - if _store_of(ctx).available(): - field = (_tops(ctx).save_field( - uname, field, reserved_names=_reserved_field_names, - correction_id=event_id) or field) - else: - _session_ready() - _field_names = list(_reserved_field_names) - _field_names.extend( - value.get('label') - for candidate_key, value in ws['fields'].items() - if candidate_key != key and isinstance(value, dict) - ) - requested_label = ' '.join( - str(field.get('label') or 'Untitled').split())[:120].rstrip() - field.pop('labelCorrectedFrom', None) - field.pop('labelCorrectionId', None) - field['label'] = _unique_name(requested_label, _field_names) - if field['label'] != requested_label and event_id: - field['labelCorrectedFrom'] = requested_label - field['labelCorrectionId'] = str(event_id)[:180] - # ⚠ W29-T83 — THE SAME PREDICATE the store uses, called rather than restated. This - # fallback carried its own shorter copy (note only), so a format or measure override - # survived one path and not the other, and `agg` survived neither. - if _tstore.source_override_is_empty(field): - ws['fields'].pop(key, None) - else: - ws['fields'][key] = field - if needs_values or refused_perms: - return True - # THE NO-BLIP RETURN (wave-6 item 3): an accepted def that echoes what the client - # already renders needs no rerun — see _field_echo_equal and the docstring's law. - return not _field_echo_equal(field, raw) - - if kind in ('cohort_add', 'cohort_remove', 'cohort_rename', 'cohort_delete'): - # Cohort membership/lifecycle edits from the grid's cohort panel (wave-2 item 2 — - # replaces the retired Manage popover, so rename/delete keep a surface). Fail-closed: - # only the caller's OWN cohorts (all_for), and member pids are intersected with the - # caller's permitted pool — the same guard add_to_list rides. - if not _store_of(ctx).available(): - return False - sets = _cohorts(ctx) # R9: this DATABASE's lists, never another's - cohort_id = str(event.get('cohortId') or '').strip()[:120] - if not cohort_id or cohort_id not in sets.all_for(uname): - return False - try: - if kind == 'cohort_rename': - name = str(event.get('name') or '').strip()[:120] - if not name: - return False - sets.rename(uname, cohort_id, name) - elif kind == 'cohort_delete': - sets.delete(uname, cohort_id) - else: - raw_pids = event.get('pids') - if not isinstance(raw_pids, list): - return False - pids = [p for p in raw_pids if isinstance(p, int) and p in allowed_pids] - if not pids: - return False - if kind == 'cohort_add': - sets.add_members(uname, cohort_id, pids) - else: - sets.remove_members(uname, cohort_id, pids) - except ValueError as e: - _tel.error('cohort:panel-edit', e) - return False - # True: the lists payload (names, membership, counts) must reflect this on the next - # render — the panel is showing server truth, not local optimism. - return True - - if kind in ('doc_add', 'doc_delete', 'doc_fetch'): - # DOCUMENTS on a row (owner item 12, contract C5). Metadata in the store key - # `customer_docs`; the BYTES live at their own path in the tenant dataset, never in a - # JSON blob — see core.store.upload_bytes for why that distinction is load-bearing. - # - # ⭐ WAVE 19 / R9 — item 1's SILENT THIRD CASE, and the one nobody reported because the - # React shell cannot reach it yet (`payload.docs` is set only by app.py). Both homes are - # keyed by pid and both were topic-blind: the metadata bucket AND the byte path - # `docs//…`. Two topics' pid spaces overlap by construction (a CRC32 hash lands - # anywhere an Odoo partner id can), so a product's file could occupy the path of a - # customer's — one upload silently shadowing another tenant-visible document. Per-topic - # bucket AND per-topic prefix; the customer topic keeps both of its shipped names. - import base64 as _b64c - import re as _re_mod - if not _store_of(ctx).available(): - return False - docs_key = _docs_key(ctx) - docs_dir = 'docs' if docs_key == _DOCS_KEY else f'docs/{docs_key[:-5]}' - pid = event.get('pid') - doc_id = str(event.get('docId') or '').strip()[:80] - # The pid wall is the SAME one every other row-scoped event rides: a caller may only - # touch rows inside their own permitted pool. Without it, documents would be the - # one surface where a scoped agent could read another book's rows. - if not isinstance(pid, int) or pid not in allowed_pids or not doc_id: - return False - # ⭐ WAVE 25 (R6b / D-16) — THE DOCUMENTS FAMILY WAS THE LAST MODULE-GLOBAL READER HERE. - # Every line below said `store.…`, so an R2 tenant's document metadata (and its bytes) - # landed in tenant #0's repo. Wave 19 made this family per-TOPIC; per-TENANT is the axis - # it was still missing, and the two failures look identical from the app. - _st_docs = _store_of(ctx) - meta_all = _st_docs.get(docs_key) or {} - row = list((meta_all.get(str(pid)) or [])) - - if kind == 'doc_add': - raw_b64 = event.get('data_b64') - name = str(event.get('name') or 'document')[:200] - if not isinstance(raw_b64, str) or not raw_b64: - return False - try: - blob = _b64c.b64decode(raw_b64, validate=True) - except Exception: - return False # not base64 -> not a file we will store - # Re-check the DECODED size host-side. The client checks it too, but a client check - # is a courtesy: this is the number that bounds what the tenant's repo can be made - # to hold by anyone who can reach the endpoint. - if not blob or len(blob) > _DOC_MAX_BYTES: - return False - if len(row) >= _DOC_MAX_PER_ROW or any(d.get('id') == doc_id for d in row): - return False - safe = _re_mod.sub(r'[^A-Za-z0-9._-]', '_', name)[:80] or 'file' - path = f'{docs_dir}/{pid}/{doc_id}_{safe}' - try: - _blobs(ctx).upload_bytes( - path, blob, message=f'doc {doc_id} for {scope_key} {pid}') - except Exception as _ue: - _tel.error('docs:upload', _ue) - return False - row.append({'id': doc_id, 'name': name, - 'mime': str(event.get('mime') or 'application/octet-stream')[:120], - 'size': len(blob), 'by': uname, - 'ts': dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), - 'path': path}) - elif kind == 'doc_delete': - hit = next((d for d in row if d.get('id') == doc_id), None) - if not hit: - return False - # THE WALL: the uploader or an admin. Enforced here regardless of what the client - # chose to render — `canDelete` in the payload only hides a button. - if hit.get('by') != uname and not admin: - return False - _blobs(ctx).delete_path(hit.get('path') or '') - row = [d for d in row if d.get('id') != doc_id] - else: # doc_fetch - hit = next((d for d in row if d.get('id') == doc_id), None) - if not hit: - return False - blob = _blobs(ctx).download_bytes(hit.get('path') or '') - if blob is None: - return False - # The bridge is a fire-and-forget event LOG with no response channel, so the answer - # is PARKED on the Result and rides the next render's payload (C5). - ctx.out.doc = { - 'pid': pid, 'docId': doc_id, 'name': hit.get('name') or 'document', - 'mime': hit.get('mime') or 'application/octet-stream', - 'data_b64': _b64c.b64encode(blob).decode('ascii')} - return True # re-render to deliver it - - def _write(cur): - cur[str(pid)] = row - return cur - # ⚠ `docs_key`, NOT `_DOCS_KEY`. The read above and the byte path were both made - # per-topic and this line was not — so a product document's BYTES landed correctly under - # `docs/product/…` while its METADATA went into `customer_docs` beside the customer rows, - # keyed by a CRC32 hash. Found by `verify_scopes.py`'s own leg on its first run, which is - # the argument for asserting the ABSENCE from the legacy bucket and not just the presence - # in the new one: every "it saved" check here was green. - _st_docs.update(docs_key, _write, flush='async') - return True - - if kind in ('folder_create', 'folder_rename', 'folder_delete', 'folder_duplicate', - 'item_move', 'folder_reorder'): - # FOLDERS over the Views / Cohorts sidebars (owner item 11, contract C4). All five - # events touch ONE home — the table workspace's `folders` + `itemFolders` — so a cohort - # can be filed without the cohort STORE learning about folders at all. - import aios_grid as _agf - if not _store_of(ctx).available(): - return False - surface = str(event.get('surface') or '') - if surface not in _agf.FOLDER_SURFACES: - return False - # The ids this user may actually file: their own views, their own cohorts. Fail-closed — - # an item_move naming somebody else's cohort is dropped by clean_item_folders anyway, - # but refusing here means we never write it in the first place. - def _own_ids(sfc, cur_ws): - if sfc == 'views': - return set((cur_ws.get('views') or {})) - return set(_cohorts(ctx).all_for(uname)) - - def _mutate(cur_ws): - folders = _agf.clean_folders(cur_ws.get('folders')) - placed = dict(cur_ws.get('itemFolders') or {}) - lst = list(folders.get(surface) or []) - # ⚠ the wire field is `folderId` on EVERY folder event (CLIENT's types.ts is the - # built contract; the split doc's first draft said `id`). Converged here rather - # than asking the client to re-emit — see the C4 reconciliation note in the doc. - fid = str(event.get('folderId') or '').strip()[:80] - if kind == 'folder_create': - name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME] - if not fid or not name or len(lst) >= _agf.MAX_FOLDERS: - return None - if any(f['id'] == fid for f in lst): - return None - row = {'id': fid, 'name': name, 'order': len(lst)} - icon = _agf.clean_folder_icon(event.get('icon')) # wave-9 I15 (C5) - if icon: - row['icon'] = icon - lst.append(row) - elif kind == 'folder_rename': - name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME] - hit = next((f for f in lst if f['id'] == fid), None) - if not hit or not name: - return None - hit['name'] = name - # wave-9 I15 (C5): the rename pane is also where the icon is changed. An event - # that carries NO icon key leaves the existing one alone (a rename must not - # silently strip a chosen icon); an explicit null clears it back to default. - if 'icon' in event: - icon = _agf.clean_folder_icon(event.get('icon')) - if icon: - hit['icon'] = icon - else: - hit.pop('icon', None) - elif kind == 'folder_delete': - if not any(f['id'] == fid for f in lst): - return None - lst = [f for f in lst if f['id'] != fid] - # CONTENTS MOVE TO ROOT, never delete. Dropping the placements is exactly that: - # an item with no placement renders at the top level. - placed[surface] = {k: v for k, v in (placed.get(surface) or {}).items() - if v != fid} - elif kind == 'folder_reorder': - # ⭐ WAVE 20 (owner item 19, contract C-FOLDER-REORDER) — the user's own folder - # ORDER in the rail. Views could already be dragged between folders; the folders - # themselves could not be dragged past each other. - # - # THE WIRE CARRIES THE FULL ORDER, NEVER A DELTA, and that is the contract's - # doing: a partial list cannot say where an UNNAMED folder went, so a "moved B - # after C" message would leave every other folder's position to be re-derived by - # two sides that can disagree. The client sends the list it is looking at. - # - # ⚠ IDS THIS USER DOES NOT OWN ARE IGNORED, NOT REJECTED. A stale tab can emit an - # order containing a folder that has since been deleted; dropping the whole event - # would make the rail un-reorderable until reload, while dropping the unknown id - # reorders exactly what still exists. Anything the payload omits keeps its - # relative position AFTER the named ones — an order that silently deleted a - # folder it merely failed to mention would be a data loss dressed as a sort. - order = [str(i).strip()[:80] for i in (event.get('order') or [])] - if not order: - return None - known = {f['id']: f for f in lst} - seen, ranked = set(), [] - for fid_ in order: - if fid_ in known and fid_ not in seen: - seen.add(fid_) - ranked.append(known[fid_]) - ranked.extend(f for f in lst if f['id'] not in seen) - if len(ranked) != len(lst): # cannot happen; refuse rather than truncate - return None - for i, f in enumerate(ranked): - f['order'] = i - lst = ranked - elif kind == 'folder_duplicate': - new_id = str(event.get('newId') or '').strip()[:80] - src = next((f for f in lst if f['id'] == fid), None) - if not src or not new_id or len(lst) >= _agf.MAX_FOLDERS: - return None - if any(f['id'] == new_id for f in lst): - return None - lst.append({'id': new_id, 'name': f"{src['name']} copy"[:_agf.MAX_FOLDER_NAME], - 'order': len(lst)}) - # ⚠ The folder's CONTENTS are duplicated by the client emitting the ordinary - # per-item duplicate events it already owns (view_upsert with a fresh id, the - # cohort copy path) and then item_move-ing them here. Copying views/cohorts - # inside this handler would be a SECOND implementation of "duplicate an item", - # and the two would drift the first time either one changed. - else: # item_move - item_id = str(event.get('itemId') or '').strip()[:120] - target = event.get('folderId') - if not item_id or item_id not in _own_ids(surface, cur_ws): - return None - cur = dict(placed.get(surface) or {}) - if target is None: - # ⚠ "UNFILE" — the client says nothing about where it went. Popping is right - # here and always was: no placement = render at the top level, and that is the - # state a brand-new item is already in. - cur.pop(item_id, None) # back to the root - elif str(target) == _agf.ROOT_PLACEMENT: - # ⭐⭐ WAVE 32 · OWNER ITEM 20 (`W32-T27`, ASK C-16) — "FILED AT ROOT", STORED. - # - # ⛔ THE THIRD CASE, AND ITS ABSENCE IS THE WHOLE DEFECT. Popping (above) and - # this branch produce the SAME rendering for an ordinary view, which is why - # nobody missed it for nine waves — but they are different FACTS, and for a - # SHARED view the difference is the entire feature: "never filed" sends it to - # the Shared group, "the receiver dragged it out" must not. Absence cannot hold - # two facts, so the second one gets a value. - # - # ⛔ AND IT IS DELIBERATELY EXEMPT FROM THE FOLDER-EXISTS TEST BELOW. This id - # names no folder by design; running it through `any(f['id'] == tid ...)` would - # refuse every root filing, which is the same silent no-op this ticket exists - # to remove. `aios_grid.clean_item_folders` carries the matching exemption on - # the READ side — the two are one change (see its note). - cur[item_id] = _agf.ROOT_PLACEMENT - else: - tid = str(target)[:80] - if not any(f['id'] == tid for f in lst): - return None - cur[item_id] = tid - placed[surface] = cur - folders[surface] = lst - clean = _agf.clean_folders(folders) - return clean, _agf.clean_item_folders( - placed, clean, - {sfc: _own_ids(sfc, cur_ws) for sfc in _agf.FOLDER_SURFACES}) - - # ⚠ NO allowed_pids ON PURPOSE — this is app.py's bare `_cl_table_workspace(uname)` - # call at the old :6268, preserved exactly. Omitting the pool drops memberPids from - # any foreign shared view (fail-closed), which is harmless here because the folder - # legs read only view IDS and the folder maps. Passing the pool would be a behavior - # change inside a pure move. - _res = _mutate(table_workspace(ctx, consume_corrections=False)) - if _res is None: - return False # refused: nothing is written - _tops(ctx).save_folders(uname, _res[0], _res[1]) - # True: the sidebars render from server state (which folder holds what), not from local - # optimism — the same contract the cohort panel's membership edits ride. - return True - - if kind == 'view_reorder': - # ⭐ WAVE-27 item 5 (contract C7) — the user drags views up and down the rail. - # - # THE WIRE CARRIES THE FULL ORDER, NEVER A DELTA — the `folder_reorder` contract above, - # verbatim, and for its reason: a partial list cannot say where an unnamed view went. - # - # ⚠ NO OWNERSHIP FILTER, and that is the difference from `item_move`. Filing a view into - # a folder writes a placement keyed by a view this user must own; ARRANGING the rail is - # about the reader's own screen, and the rail legitimately contains views this user does - # not own — the system view, cohort projections, R8's injected Overview, and anything - # SHARED with them. Refusing ids they do not own would make exactly those un-draggable, - # which is the half of sharing people notice. - # - # ⚠ Unresolvable ids are harmless by construction rather than by filtering here: the READ - # side (`aios_grid.views_from_defs`) ranks by this list and appends anything unranked, so - # an id naming a deleted view simply never matches. Validating against today's view set - # at WRITE time would also drop an id whose view is merely not visible in this request. - raw_order = event.get('order') - if not isinstance(raw_order, list): - return False # a non-list order is malformed, not an empty arrangement - order, seen_v = [], set() - for vid in raw_order[:MAX_VIEW_ORDER]: - vid = str(vid or '').strip()[:120] - if vid and vid not in seen_v: - seen_v.add(vid) - order.append(vid) - # ⚠ THE SESSION FALLBACK IS `record_layout`'s, COPIED DELIBERATELY, and it is not just a - # testability convenience. Both keys are per-user PRESENTATION state for one surface, and - # both should keep working while the store is briefly unavailable — a rail that refuses - # to reorder because a write failed is a frozen control with no explanation. It also - # makes the round trip reachable from `verify_grid_events`, which is headless and where - # `store.available()` is False: the store-backed branch of every OTHER stratum writer is - # covered only by the live pass, and this one did not have to be. - if _store_of(ctx).available(): - _tops(ctx).save_view_order(uname, order) - else: - _session_ready() - if order: - ws['viewOrder'] = order - else: - ws.pop('viewOrder', None) # absent = server order, never a stored [] - # True: the rail renders from server state, like the folder sidebars it sits beside. - return True - - if kind == 'field_delete': - # Delete a USER-CREATED column outright (owner gap closed 2026-07-27). The prefix IS - # the permission: only the created strata (`custom_` overlay fields, `measure_` formula - # columns) are deletable — a base field's key fails the prefix test no matter what the - # browser claims, so the table's contract cannot be deleted from the client. The store - # scrubs the field's stored cell values with it (delete_table_field); views naming the - # key self-heal on their next autosave, the rule every stale colId already rides. - key = str(event.get('key') or '')[:80] - import aios_grid as _ag3 - if not (key.startswith('custom_') or key.startswith(_ag3.MEASURE_FIELD_PREFIX)): - return False - if _store_of(ctx).available(): - _tops(ctx).delete_field(uname, key) - else: - _session_ready() - ws['fields'].pop(key, None) - for _row in ws['overlays'].values(): - if isinstance(_row, dict): - _row.pop(key, None) - # Wave-6 item 3: the client dropped the column optimistically; the store scrub is - # invisible to the next render (the values leave WITH the column). - return False - - if kind == 'choice_rename': - # ⭐ WAVE 20 (owner item 15, contract C-RENAME) — RENAMING AN OPTION CARRIES ITS VALUES. - # - # Owner: *"if I change the option from 'Aaron' to 'Aron' the existing field that has the - # Selected 'Aaron' doesn't change to 'Aron'."* Correct, and it is a data-model fact rather - # than an oversight: a select cell stores the option's LABEL, so renaming the option in - # the field definition orphans every cell holding the old string. They do not render as - # the new name; they render as a value that is no longer an option. - # - # ⛔ THE WIRE CARRIES AN EXPLICIT {from, to} MAPPING, NEVER A DIFF OF THE CHOICE LISTS. - # Diffing is what makes this unsafe: rename A→B while also DELETING C and adding D, and a - # differ sees two removals and two additions with no way to know which pairs up. It would - # cheerfully rewrite every C cell to D. The client knows which row of its editor the user - # typed in; that intent travels, and nothing here has to guess it. - # - # THREE PLACES HOLD THE OLD STRING, and missing any one is its own visible bug: - # 1. the CELLS (`overlays`) — the owner's complaint; - # 2. the FIELD's own `choices` list — else the picker still offers the old name; - # 3. every VIEW that FILTERS or COLOURS BY that value — a saved view filtering - # `Agent is "Aaron"` silently matches NOTHING the moment the value moves, which is - # the failure that looks like data loss ([[aios-shared-views]]). - key = str(event.get('key') or '')[:80] - pairs = [] - for r in (event.get('renames') or ()): - if not isinstance(r, dict): - continue - a, b = str(r.get('from') or '')[:200], str(r.get('to') or '')[:200] - if a and b and a != b: - pairs.append((a, b)) - field = field_by_key.get(key) - if not key or not pairs or not isinstance(field, dict): - return False - if str(field.get('type') or '') not in ('select', 'multiselect', 'status'): - return False - # Only a field this caller may edit. A rename is a write to the field CONTRACT, so it - # rides the same wall `field_upsert` does rather than a looser one of its own. - if key in (ctx.hidden_keys or frozenset()): - return False - mapping = dict(pairs) - - def _rename_in(ws_): - fields_ = ws_.setdefault('fields', {}) - fld = fields_.get(key) - if isinstance(fld, dict) and isinstance(fld.get('choices'), list): - seen, out = set(), [] - for c in fld['choices']: - if isinstance(c, dict): - nm = mapping.get(str(c.get('name') or ''), None) - if nm is not None: - c = {**c, 'name': nm} - ident = str(c.get('name') or '') - else: - c = mapping.get(str(c), str(c)) - ident = str(c) - # A rename ONTO an existing option MERGES rather than duplicating it: the - # user typed a name that already exists, and two identical options is a - # picker bug. Their cells merge too, which is what the value rewrite below - # does anyway. - if ident in seen: - continue - seen.add(ident) - out.append(c) - fld['choices'] = out - fields_[key] = fld - for row in (ws_.get('overlays') or {}).values(): - if not isinstance(row, dict) or key not in row: - continue - val = row[key] - if isinstance(val, list): # multiselect - merged, out2 = set(), [] - for v in val: - nv = mapping.get(str(v), str(v)) - if nv not in merged: - merged.add(nv) - out2.append(nv) - row[key] = out2 - elif isinstance(val, str) and val in mapping: - row[key] = mapping[val] - # Views: filter rule values + colour-by keys naming the old option. - for view in (ws_.get('views') or {}).values(): - if not isinstance(view, dict): - continue - cfg = view.get('config') if isinstance(view.get('config'), dict) else view - - def _walk(nodes): - for n in nodes or (): - if not isinstance(n, dict): - continue - if isinstance(n.get('children'), list): - _walk(n['children']) - continue - if str(n.get('colId') or '') != key: - continue - v = n.get('value') - if isinstance(v, str) and v in mapping: - n['value'] = mapping[v] - elif isinstance(v, list): - n['value'] = [mapping.get(str(x), x) for x in v] - - _walk(cfg.get('filters') or []) - # ⚠ `colorBy` NEEDS NO REWRITE, and the reason is worth stating because the - # opposite looks obvious: it stores a COLUMN KEY (a string, validated against - # `valid_keys` at `view_upsert`), not a {value: colour} map. Per-value colours - # ride the CHOICE OBJECTS themselves, which this function renames in place — - # `{**c, 'name': nm}` keeps every other key on the choice, colour included — so - # a renamed option keeps its swatch for free. An earlier version of this handler - # tried to rewrite `colorBy['rules']` and crashed the whole event pipeline with - # `TypeError: unhashable type: 'dict'` on the FIRST view it touched. - return ws_ - - if _store_of(ctx).available(): - _tops(ctx).rename_choice_values(uname, _rename_in) - else: - _session_ready() - _rename_in(ws) - # True = the host must re-read: the client dropped its optimistic copy of the CHOICES, - # but the rewritten cells and view filters only exist server-side until the next payload. - return True - - if kind == 'field_duplicate': - # Wave-5 item 1. The DESTINATION key is client-generated (the established pattern — - # the client must know it synchronously to place the clone right of the source), and - # both keys must sit in the SAME created stratum: base odoo fields offer no Duplicate, - # because duplicating the source-of-truth into an editable copy would need a - # value-snapshot mechanism nobody asked for. Overlay VALUES are copied only for - # `custom_` sources (a measure clone recomputes; a formula clone re-evaluates). - # The duplicator becomes the clone's creator — it is a NEW field they made. - src_key = str(event.get('sourceKey') or '')[:80] - new_key = str(event.get('key') or '')[:80] - import aios_grid as _ag5 - prefix = ('custom_' if src_key.startswith('custom_') - else _ag5.MEASURE_FIELD_PREFIX - if src_key.startswith(_ag5.MEASURE_FIELD_PREFIX) else None) - if (prefix is None or not new_key.startswith(prefix) or new_key == src_key - or new_key in field_by_key): - return False - src = ws.get('fields', {}).get(src_key) - if not isinstance(src, dict): - return False - clone = dict(src) - clone['key'] = new_key - label = str(event.get('label') or '').strip()[:120] - requested_label = label or (str(src.get('label') or 'Untitled')[:110] + ' copy') - _workspace_field_keys = set((ws.get('fields') or {})) - _reserved_field_names = [ - value.get('label') for candidate_key, value in field_by_key.items() - if (candidate_key != new_key and candidate_key not in _workspace_field_keys - and isinstance(value, dict)) - ] - clone['label'] = requested_label - clone['createdBy'] = uname - # Wave-6 item 9: the clone inherits the source's scope via dict(src); on the cohort - # page the duplicate surface may explicitly place it instead (same create-time rule - # as field_upsert — a page that is not the cohort page cannot mint a cohort field). - if scope_key == 'cohort' and event.get('scope') in ('cohort', 'global'): - if event['scope'] == 'cohort': - clone['scope'] = 'cohort' - else: - clone.pop('scope', None) - if _store_of(ctx).available(): - clone = (_tops(ctx).duplicate_field( - uname, src_key, new_key, clone, - reserved_names=_reserved_field_names, - correction_id=event_id) or clone) - else: - _session_ready() - _field_names = list(_reserved_field_names) - _field_names.extend( - value.get('label') - for candidate_key, value in ws['fields'].items() - if candidate_key != new_key and isinstance(value, dict) - ) - clone.pop('labelCorrectedFrom', None) - clone.pop('labelCorrectionId', None) - clone['label'] = _unique_name(requested_label, _field_names) - if clone['label'] != requested_label and event_id: - clone['labelCorrectedFrom'] = requested_label - clone['labelCorrectionId'] = str(event_id)[:180] - ws['fields'][new_key] = clone - if src_key.startswith('custom_'): - for _row in ws['overlays'].values(): - if isinstance(_row, dict) and src_key in _row: - _row[new_key] = _row[src_key] - # Wave-6 item 3: True only when host-held VALUES must flow back — copied overlay - # cells (custom_ sources) or host-computed measure values. formula/created_time - # clones compute client-side, so the optimistic insert is already complete. - label_changed = clone['label'] != requested_label - return label_changed or src.get('type') not in ('formula', 'created_time') - - if kind == 'add_to_list': - # "Add to list" (owner item 6): a view's current matches become fixed cohort members. - # - # ⚠ `pids` arrives from the BROWSER. It is intersected with `allowed_pids` — the pool this - # user may actually see — before anything is stored. The client computing the set is a - # correctness decision (it is the only engine that already knows the answer, and asking a - # second engine the same question is the drift verify_filter_engine.py exists to prevent); - # it is NOT a trust decision. - raw_pids = event.get('pids') - if not isinstance(raw_pids, list): - return False - pids = [p for p in raw_pids if isinstance(p, int) and p in allowed_pids] - if not pids: - return False - cohort_id = str(event.get('cohortId') or '').strip()[:120] - name = str(event.get('name') or '').strip()[:120] - if not _store_of(ctx).available(): - return False - # ⭐ WAVE 19 / R9 — THE BUG THIS RULING NAMES. This branch is where a list is BORN, and it - # named `cohort_mod` directly: a list created from the Product surface was written into - # `customer_cohorts` with CRC32 product pids in it, then rendered on the Customer page as - # a list of customers nobody recognised. The set now belongs to the database it was - # created on, and `ctx.scope_key` is the only thing that decides which. - sets = _cohorts(ctx) - try: - if not cohort_id: - if not name: - return False - cohort_id = sets.create(uname, name, pids) - else: - if cohort_id not in sets.all_for(uname): - return False # not this user's cohort — fail closed, do not create - sets.add_members(uname, cohort_id, pids) - except ValueError as e: - _tel.error('cohort:add_to_list', e) - return False - # True: the Cohort page's list and counts must reflect this, and the toast below only - # renders on a fresh run. - # - # ⚠ The NOUN is the TOPIC's, not the literal "customer" this line carried since wave 2. - # "Added 12 customers" over a set of SKUs is the surface stating, in words, that the list - # went somewhere it did not — the user-visible half of the same leak. - _noun = sets.noun - ctx.out.toast = ( - f"Added {len(pids):,} {_noun}{'' if len(pids) == 1 else 's'} to " - f"{name or 'the list'}.") - return True - - if kind == 'overlay_patch': - pid = event.get('pid') - if not isinstance(pid, int) or pid not in allowed_pids: - return False - updates = {} - refused = False - stage_moved = False - row_events = [] # C3 (wave 22): admitted writes, emitted AFTER they apply - raw_updates = event.get('updates') if isinstance(event.get('updates'), dict) else {} - for key, value in raw_updates.items(): - if key not in overlay_keys or not isinstance(value, (str, int, float)): - refused = True - continue - # C-PERM: a field this caller's permissions HIDE cannot be written, only refused. - # `overlay_keys` asks whether the column exists, not whether this person may touch - # it — so without this line a restricted user who knows a hidden key could PATCH a - # value they are not allowed to read back. Refused LOUDLY (repaint) so the client - # reverts rather than showing a value the store never took. - if key in (ctx.hidden_keys or frozenset()): - refused = True - continue - # Wave-5 item 1: `permissions.edit` is enforced HERE, per key — the client hiding - # its editor is courtesy, this is the wall. Fail-closed: a restricted field with - # no readable createdBy admits only admins. - perms = (field_by_key.get(key) or {}).get('permissions') or {} - edit = perms.get('edit') - if edit == 'admins' and not admin: - refused = True - continue - if edit == 'creator' and not ( - admin or (field_by_key.get(key) or {}).get('createdBy') == uname): - refused = True - continue - # C5-AUTOFIELD (wave 18): an automation cell is MACHINE-WRITTEN. The client marks - # the type read-only (READONLY_CELL_TYPES) but that is courtesy — this is the wall. - # Without it the UI hides the editor while the API still takes a PATCH, which is - # exactly the shape a "read-only" type must not have. - if (field_by_key.get(key) or {}).get('type') == 'automation': - refused = True - continue - # C2 (wave 22): a STAGE FIELD is engine-written EXCEPT at a review gate, where a - # human may advance a card to a member of the review stage's `next` and nothing - # else. The law rides the field definition (`automation.humanMoves` / `arrive`, - # stamped by the engine that derives the stages) because this module must not - # import the engine — the engine states the law, this door enforces it - # mechanically. An ADMITTED move writes THROUGH to the definition row, the shared - # truth the engine and every viewer read: parked in this user's overlay stratum it - # would be a board position only its author could see. Everything else about the - # field is refused — the same wall the automation-cell type gets, extended, not - # replaced. - if str(ctx.scope_key or '').startswith('ut_'): - import core.user_tables as _ut_mod - _tdef = _ut_mod.get(ctx.scope_key, st=ctx.table.st) if ctx.table is not None \ - else None - _fdef = next((f for f in ((_tdef or {}).get('fields') or []) - if f.get('key') == key), None) - # C7 (wave 22): a METRIC cell is engine-computed from the master series — a - # human write would be an invented measurement, refused the way an automation - # cell is (the wall, not the courtesy). - # ⭐ 2026-08-07 — EXTENDED TO THE RELATIONAL PAIR, and read through the field - # layer's own predicate rather than re-tested here. A `rollup` cell is an - # aggregate the server computes; a DERIVED `link` cell is a join the server - # resolves. Both would be overwritten by the next refresh anyway, so accepting - # the write would not merely be wrong — it would look like it worked and then - # silently revert, which is worse than a refusal. - # ⚠ An ORDINARY (user-picked) link is deliberately NOT refused: `is_computed_cell` - # answers per FIELD, not per type, because one kind is read-only in one mode and - # editable in the other. - if _ut_mod.is_computed_cell(_fdef): - refused = True - continue - _auto = (_fdef or {}).get('automation') - # An ordinary Link is a shared database relationship. Validate its selected - # target ids and write it through to the definition row; a personal overlay - # would be invisible to reciprocal links and Rollups. - if (_fdef or {}).get('type') == 'link': - _linked = _ut_mod.patch_link_cell( - ctx.scope_key, pid, key, value, st=ctx.table.st) - if _linked is None: - refused = True - continue - if _linked != str(value): - refused = True - row_events.append((key, _linked)) - continue - # ⭐ WAVE 25 (C3 + amendment C3-A1, owner rulings R7/R6) — THE PROFILE CELL. - # Two things happen here and neither can live anywhere else: - # - # 1. IT WRITES THROUGH TO THE DEFINITION ROW, the stage field's mechanism two - # branches up, for the identical reason. MEASURED before it was written: an - # ordinary `ut_*` cell edit lands in the TYPIST'S OWN overlay stratum, and - # the automation engine reads `t['rows']` — so a handle somebody typed - # would be invisible to the enrich action and to every other viewer of the - # same database. The flag would be decorative. - # 2. BLANKING IT CLEARS THAT ROW'S PRESET CELLS IN THE SAME WRITE (R6) — - # `patch_profile_cell` does both inside ONE store update, because a blank - # that commits without its clear leaves a row with no handle and a live - # follower count, i.e. stale numbers attributed to nobody. - # - # ⛔ NOT VIA THE EMIT GUARD (HARD RULE 5 / D-40). Nothing here emits; this is - # `user_tables`' own write door, reached from the branch that already knows the - # scope is `ut_`. ⚠ `editRole` is deliberately NOT consulted: it governs the - # SCHEMA, and C3 is explicit that a profile field's VALUES stay fully editable — - # that is the whole point of item 5a. - if isinstance((_fdef or {}).get('profile'), dict): - _pw = _ut_mod.patch_profile_cell( - ctx.scope_key, pid, key, value, st=ctx.table.st) - if _pw is None: - # Not a handle we can enrich. Refused LOUDLY so the client repaints to - # the stored truth instead of showing a value the store never took. - refused = True - continue - # A normalisation (`@Name` -> `name`, a pasted URL -> its handle) is a real - # difference from what the client is drawing, so it must repaint. An - # untouched value echoes and stays on the no-blip path. - if _pw['handle'] != str(value): - refused = True - if _pw['cleared']: - stage_moved = True # cells the client never typed changed: re-read - row_events.append((key, _pw['handle'])) - continue - # ⭐ OWNER ITEM 4 (2026-08-06) — A COLUMN AN AUTOMATION FILLS IS NOT A COLUMN A - # PERSON TYPES IN. Owner, verbatim: *"I should not be able to edit any of the - # Pre-set fields on instagram automation, we talked about this extensively."* - # - # ⛔ IT HAD NEVER BEEN ENFORCED ANYWHERE. `editRole: 'admins'` rides on every - # preset column and reads exactly like this wall, which is why it was believed to - # be one — but it governs the SCHEMA (`user_tables.py:535`): who may rename or - # retype the column. The VALUES stayed open to anyone with grid access, and the - # owner typed into one to prove it. The metric branch above is the same rule for - # the same reason, written for a different column kind; this is its sibling. - # - # ⚠ REACHED ONLY AFTER stage AND profile have `continue`d, and the order is the - # rule rather than an accident: both of those columns carry the identical tag and - # both are cells a human is SUPPOSED to drive — the stage because moving a card - # IS the decision, the profile because it is where enrichment gets its subject. - # Refusing on the tag alone would freeze every kanban. - # - # `flowId` and not merely `automation`: a user-configured `automation` COLUMN - # carries its own gear bag (kind/source/urlField/settings) and no flowId, and it - # is not this ruling's subject. - if isinstance(_auto, dict) and (_auto.get('flowId') or _auto.get('preset')): - refused = True - continue - # ⭐ WAVE 23 (C7) — the json wall, BEFORE the generic 10 000-char truncation, which - # would otherwise cut a document in half and store the halves as valid text. A json - # column promises its readers a parseable document; a write that is not one is - # REFUSED with the cell unchanged rather than silently kept as a string nothing can - # open. Blank stays legal — an empty cell is "no document", not a malformed one. - if (field_by_key.get(key) or {}).get('type') == 'json': - raw_j = value if isinstance(value, str) else _json_ev.dumps(value) - if not str(raw_j).strip(): - updates[key] = '' - continue - if len(str(raw_j)) > _ut_limits.MAX_JSON_CELL: - refused = True - continue - try: - _json_ev.loads(raw_j) - except (ValueError, TypeError): - refused = True - continue - updates[key] = str(raw_j) - continue - clean = str(value)[:10000] - if isinstance(value, str) and clean != value: - # Truncated: the stored truth is shorter than the client's copy. (An - # int/float arriving as its string form renders identically — not a refusal.) - refused = True - definition = field_by_key.get(key) or {} - if definition.get('type') in ('select', 'multiselect') and clean: - options = [str(v) for v in definition.get('options') or []] - canonical = {v.strip().lower(): v for v in options} - if definition.get('type') == 'select': - normalized = canonical.get(clean.strip().lower()) - if normalized is None: - refused = True - continue - else: - normalized_parts = [] - seen = set() - invalid = False - for part in clean.split(','): - match = canonical.get(part.strip().lower()) - if match is None: - invalid = True - break - if match.lower() not in seen: - seen.add(match.lower()) - normalized_parts.append(match) - if invalid: - refused = True - continue - normalized = ','.join(normalized_parts) - if normalized != clean: - refused = True - clean = normalized - updates[key] = clean - if updates: - if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None: - # User-table cells are collaborative record data. Views/layout remain personal, - # but record values live in the definition rows so Links, Rollups, automations, - # and every viewer observe one truth. - import core.user_tables as _ut_values - _ut_values.patch_cells(ctx.scope_key, pid, updates, st=ctx.table.st) - elif _store_of(ctx).available(): - _tops(ctx).patch_overlay(uname, pid, updates) - else: - _session_ready() - ws['overlays'].setdefault(str(pid), {}).update(updates) - # C3 (wave 22): the admitted writes become ROW EVENTS — emitted AFTER they applied, on - # the ut scopes the event triggers watch this wave, through the seam in user_tables so - # this module never imports the engine. Human door only, which IS the loop law. - if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None: - import core.user_tables as _ut_ev - for _k, _v in list(updates.items()) + row_events: - _ut_ev.emit_row_event({'type': 'event_field', 'table': str(ctx.scope_key), - 'rowId': str(pid), 'field': _k, 'after': _v, - 'st': ctx.table.st, 'user': uname}) - # Wave-6 item 3: the client's cell already shows the typed value — rerun ONLY when - # some of what it shows was refused or clamped and must be repainted to the truth. - # A stage move reruns too: its `arrive` side-effects (the tracked flag) changed cells - # the client never typed. - return refused or stage_moved - - return False +"""core/grid_events.py — THE WRITE SEAM, lifted out of Streamlit (EXIT-1a, X1, 2026-07-30). + +This is the grid's event handler: the code that takes an UNTRUSTED event object from the +browser component and decides what may be persisted. It was `app.py:_cl_handle_grid_event` / +`_cl_handle_one` and moved here as a PURE MOVE — no behavior change inside a move (MA principle +5). Every body below is the app.py body verbatim; the ONLY mechanical rewrites are the +Streamlit couplings the contract names: + + st.session_state['_cl_grid_seen_ids'] -> ctx.seen_ids (adapter-owned dedup state) + st.session_state['_cl_doc_payload'] -> ctx.out.doc (EventResult.doc) + st.session_state['_cohort_toast'] -> ctx.out.toast (EventResult.toast) + st.session_state['cl_table_workspace'] -> ctx.fallback_ws (store-unavailable fallback) + is_admin() (the bare call inside doc_delete, and _cl_table_workspace's admin=None default) + -> ctx.admin + +There is NO `import streamlit` in this file and there must never be one — `verify_no_streamlit.py` +enforces that with a `sys.meta_path` block, which is the only proof that survives refactoring. +(That gate was `verify_seam.py` until EXIT-6 retired it with `app.py`; the block, its negative +control and the AST walk moved over intact.) + +ONE ADAPTER, ONE HANDLER — it was TWO until 2026-08-04: + * ⛔ the Streamlit adapter is GONE. `app.py::_cl_handle_grid_event` built an EventCtx whose + `seen_ids` and `fallback_ws` were session-state-backed dicts and applied the Result to session + state. EXIT-6 deleted it. The host-neutrality this file was written for is what let that + happen without touching a line of the handler — which is the argument for the design, recorded + at the moment it paid out. + * the HTTP adapter — `aios-web/api` builds an EventCtx with `fallback_ws=None`. A None + fallback with the store unavailable is a **503**, never a silent in-memory workspace: an + API request has no durable session dict to fall back to, so pretending to persist would + lose the write with a 200 on it. + +⚠ LAYERING, stated honestly. `core/` is not supposed to import `modules/` (ARCHITECTURE §1's +one-directional rule). This handler genuinely straddles: it is the WRITE half of the customer +table and its persistence verbs live in `modules.customer_data` / `modules.cohort`. The +contracted path for the strangler is `core/grid_events.py` (both adapters import it from here), +so the import stays and the violation is RECORDED rather than hidden. It is the one file in +`core/` that points up; when EXIT-5 ports the surfaces, the persistence verbs are what move +down, and this note is the marker for that work. +""" +import datetime as dt +import json as _json_ev # wave 23 (C7): the json field's parse wall +from dataclasses import dataclass, field +# ⚠ ALIASED ON PURPOSE. `field` is imported above for the dataclasses that need it, and this file +# uses `field` as a LOCAL VARIABLE in a dozen handlers (`field = {'key': key, …}`). A +# `field(default_factory=list)` written inside one of those functions would resolve to the local +# dict and raise. The alias is unshadowable, so D-291's channel cannot be broken by a rename. +from dataclasses import field as dataclass_field +from typing import List, MutableMapping, Optional + +import core.store as store +import core.table_store as _tstore # wave-9 I17: the shared-view authorisation predicates + +#: ⭐ WAVE-27 item 5 (C7) — how many view ids one rail arrangement may name. Generous on purpose: +#: it bounds a hostile payload, not the user (the rail holds saved views + cohort projections + +#: shared views, and a busy Customer grid already carries dozens). An order longer than this is +#: not a person dragging rows. +MAX_VIEW_ORDER = 500 +import core.user_tables as _ut_limits # wave 23 (C7): MAX_JSON_CELL — one ceiling, one definer +import core.users as users +import harness.telemetry as _tel # _tel.error() = THE error sink (all caught errors log) +import modules.cohort as cohort_mod +import modules.customer_data as cl_mod + +#: C5 (owner item 12): customer documents. The cap is enforced HOST-side on the DECODED byte +#: count — the client checks the same number first, but a client check only spares the user a +#: pointless upload; this is what bounds what the tenant's dataset can be made to hold. +#: MOVED from app.py:1410–1412 with the handler (app.py re-imports these during the strangler). +_DOCS_KEY = 'customer_docs' +_DOC_MAX_BYTES = 5 * 1024 * 1024 +_DOC_MAX_PER_ROW = 50 + +#: The props of a field def the CLIENT actually draws — the wave-6 no-blip comparison set. +#: Host-only stamps (createdBy, permissions) are deliberately absent: the client renders +#: nothing from its own copy of those, and a permissions refusal is answered explicitly. +_FIELD_ECHO_PROPS = ('label', 'type', 'note', 'options', 'colorCodeOptions', + 'optionColors', 'max', 'formula', 'agg', 'scope', 'format', 'measure') + + +class StoreUnavailable(RuntimeError): + """The store is down AND the caller offered no durable fallback (HTTP adapter → 503). + + The Streamlit adapter never sees this: it passes a session-state-backed `fallback_ws`, so + its store-down path degrades to the same captioned in-memory workspace it always had. An + API caller has no such dict, and the honest answer to "persist this" with nowhere to + persist it is an error — not a 200 over a write that evaporates. + """ + + +@dataclass +class EventResult: + """What the HANDLER needs to tell its adapter — the three things it cannot do itself. + + `rerender` is the old return value with its old meaning: "this page must re-render from + server state", NOT "handled" (see `handle_one`'s docstring — the distinction is the + difference between a snappy grid and a sluggish one). + """ + rerender: bool = False + doc: Optional[dict] = None # doc_fetch's answer — the bridge has no response channel + toast: Optional[str] = None # add_to_list's confirmation + #: ⭐⭐ D-291 — WHY A WRITE WAS DECLINED. Until 2026-08-17 this door had NO disclosure channel + #: at all: `field_upsert` carries EIGHT distinct `return False` refusals and every one of them + #: answered `{"results":[{"id":"…","rerender":false}]}` — byte-identical to a success. + #: ⛔ THE COST WAS NOT THE MISSING MESSAGE, IT WAS THAT EVERY THEORY BECAME UNFALSIFIABLE. + #: D-275 (a custom field on the product grid answers 200 and is absent from a fresh read) sat + #: open across TWO waves because no amount of reading the source can say WHICH of the eight + #: fired on a given request — and the register's own advice is that reporting the cause IS the + #: diagnostic ([[report-the-cause-before-you-fix-it]]). D-276 is the precedent: shipping the + #: report first produced, in one deploy, a measurement that REFUTED a clean by-elimination + #: argument about the cause. + #: ⚠ SAFE BY CONSTRUCTION, and this is the check to re-run if a new refusal is added: every + #: entry describes the caller's OWN submitted event. It never names another user, another + #: tenant, or a row the caller cannot see — the failure mode D-276's `visible` flag exists to + #: avoid one layer up. + refusals: List[dict] = dataclass_field(default_factory=list) + + +@dataclass +class EventCtx: + """Everything the handler is allowed to know, and nothing about how it is being served. + + ⚠ `allowed_pids` is a SECURITY parameter: it is the pool this caller may touch at all, and + every row-scoped event (overlay_patch, doc_*, cohort membership, add_to_list, memberPids) + is intersected with it. `admin` and `uname` are likewise the HOST's statement of identity — + never anything the browser said about itself. + + `seen_ids` is ADAPTER-OWNED dedup state (the event log resends its recent window on every + emit, so consumed ids must stay consumed): Streamlit passes a session-state dict, HTTP + passes the caller's per-session mapping. + + `fallback_ws` is the store-unavailable workspace home, the OUTER dict keyed by username + (exactly what `st.session_state.setdefault('cl_table_workspace', {})` is). HTTP passes + None, and None + store down raises StoreUnavailable (→ 503). + + `out` is the sink for the two parked answers, so `handle_one` can keep returning a bare + bool exactly as it did in app.py while doc/toast still escape. (Additive amendment to X1's + field list, 2026-07-30 — defaulted; recorded in the S1 mailbox.) + """ + uname: str + allowed_pids: frozenset + fields: list + measure_keys: frozenset = frozenset() + resolved_ids: frozenset = frozenset() + cohort_ids: frozenset = frozenset() + measure_offer: tuple = () + visible_views: tuple = () + admin: bool = False + #: ⛔ WAVE 15 C-PERM — the fields this caller's PERMANENT PERMISSIONS hide, transitive + #: closure included (`core.perm_scope.hidden_keys`). A SECURITY parameter in the same class + #: as `allowed_pids`, and here for the same reason: the read paths strip a hidden field from + #: both wires, so a caller cannot SEE it — but without this they could still WRITE it by + #: naming the key, because `overlay_keys` only asks whether the column exists. Empty means + #: "nothing hidden", which is every legacy record and therefore every caller today. + hidden_keys: frozenset = frozenset() + scope_key: str = 'customer' + #: Wave 16 C-TOPIC — the TABLE OPS this ctx reads and writes (a `core.table_store.make` + #: object). None = the CUSTOMER table (`modules.customer_data.TABLE_OPS`), which keeps + #: every existing caller byte-identical. The PRODUCT surface passes + #: `modules.product_data.TABLE_OPS` so its views/fields/overlays land in the product + #: bucket — never the customer one, whose pids are a different id space entirely. + table: Optional[object] = None + #: ⭐ WAVE 25 (R6b, closes DEBT D-16) — THE TENANT STORE HANDLE, and it is a RESIDENCY + #: parameter in the same class as `allowed_pids` is a security one. Everything this handler + #: could not reach through `ctx.table` used to go through the MODULE-LEVEL `core.store`, + #: i.e. tenant #0's dataset repo: the documents family, `_store_of(ctx).available()`, and — through + #: `_tops`' fallback — the whole customer and product table workspace. Owner, R6b: + #: *"Tenant 0 should not intertwine anywhere."* + #: + #: None keeps the module default, which is byte-identical for tenant #0 (empty prefix, + #: shared repo) and is what every non-HTTP caller still gets. The HTTP adapters pass + #: `session.runtime`, so a Nurilab write resolves against Nurilab. + st: Optional[object] = None + seen_ids: Optional[MutableMapping] = None + fallback_ws: Optional[MutableMapping] = None + out: EventResult = field(default_factory=EventResult) + + def __post_init__(self): + # A caller that forgets `seen_ids` gets per-call dedup rather than a crash — the SET is + # what makes re-delivery idempotent, and its absence must not be the thing that decides + # whether an event is processed at all. + if self.seen_ids is None: + self.seen_ids = {} + + +def _store_of(ctx): + """THE store handle this ctx reads and writes through (wave 25, R6b / D-16). ONE resolver, + so "whose repo does this land in" has exactly one answer per event. + + Order, and each step is deliberate: the ctx's own tenant handle · else the handle the TABLE + OPS object was built with (`table_store.make(..., st=…)` keeps it on `.st`, which is how the + `ut_` path has been tenant-correct since wave 18) · else the module default. + + ⛔ THE MODULE DEFAULT IS TENANT #0, AND THAT IS THE BUG D-16 NAMES — not a safe base case. + It is kept because it is byte-identical for tenant #0 (empty prefix, shared repo) and every + non-HTTP caller still relies on it; what changed is that the HTTP adapters now pass a real + handle, so the fallback is no longer what serves a Nurilab request. `verify_scopes` proves + that with a negative control: force this to fall through and the residency legs go RED. + """ + st = getattr(ctx, 'st', None) + if st is not None: + return st + tbl = getattr(ctx, 'table', None) + inner = getattr(tbl, 'st', None) if tbl is not None else None + return inner if inner is not None else store + + +def _blobs(ctx): + """The BYTE store for this ctx — `_store_of`'s sibling, split out because it is NOT yet + equivalent and pretending otherwise would hide a live residency hole. + + ⚠ `TenantRuntime` exposes `get/put/update/exists/available` and **no byte methods** + (`harness/runtime.py`), so a tenant with its OWN repo has nowhere tenant-scoped to put a + document's BYTES. The metadata half of D-16 is fixed here; the byte half needs + `upload_bytes`/`download_bytes`/`delete_path` on the runtime, which is SESSION A's file — + asked for in the mailbox and booked as debt rather than papered over with a fallback that + reads as intentional. The moment those methods exist this resolver picks them up with no + other change, which is why it is written as a capability test and not as a version check. + """ + st = _store_of(ctx) + return st if hasattr(st, 'upload_bytes') else store + + + +def _ag_overview_id(): + """`aios_grid.IG_OVERVIEW_ID`, resolved defensively. + + Function-local import, this module's convention for the layer above it. Falls back to the + literal rather than raising: a store blip must not turn "refuse to delete" into "allow", and + an unguarded delete here is the one that reads as success and undoes itself. + """ + try: + import aios_grid as _ago + return _ago.IG_OVERVIEW_ID + except Exception: + return 'tpl_overview' + +def _tops(ctx): + """The table-ops object this ctx operates on — the customer table unless the caller chose + another topic. ONE resolution point so 'which bucket does this write land in' has exactly + one answer per event. + + ⛔ WAVE 25 (R6b) — THIS FALLBACK IS STILL TENANT #0's, DELIBERATELY, AND IT IS THE ONE PIECE + OF THE RESIDENCY FIX THAT DID NOT LAND. `cl_mod.TABLE_OPS` is a module-level singleton built + with no store handle, so the customer and product table workspaces (views, fields, overlays, + folders) resolve to tenant #0 for every caller. Scoping it here was written, measured and + then REVERTED, because it would have been worse than the bug: + + the WRITE door (`routes_grid._ctx`) is this session's file and now carries a handle; + the READ door is `routes_customers._ctx_for` / `routes_products` — files in NO session's + fence this wave — and they carry none. + + Moving only the write side means a prefix tenant saves a view into `t//…` and reads + back from the unprefixed bucket forever: their own view vanishes the moment they save it. + A latent split is worse than a stated residency error, and read and write must move in ONE + change. Booked as an ask + debt with that exact shape. Tenant #0 is unaffected either way + (empty namespace, default repo), which is why nothing today would have shown it. + + ⛔ Untouched by this wave regardless: the EMIT GUARD is D-40, deferred by ruling A-15. + """ + if ctx.table is not None: + return ctx.table + return cl_mod.TABLE_OPS + + +def _cohorts(ctx): + """The COHORT store this ctx operates on — `_tops`'s sibling, and here for the same reason. + + ⭐ WAVE 19 / R9: a cohort belongs to its database. Every cohort verb below used to name + `cohort_mod` directly, so a list built on the Product surface landed in `customer_cohorts` + beside somebody's real customer lists — with PRODUCT pids in it (CRC32 hashes of SKU codes), + which the customer page would then try to resolve against partner ids. Wave 16 answered that + by REFUSING cohorts on other topics (`with_cohorts=False`); R9 says scope-parameterize + instead. `modules.cohort.scoped` returns the module itself for the customer topic, so the + customer path is byte-unchanged. + """ + return cohort_mod.scoped(ctx.scope_key) + + +#: The keys a stored document entry may put on the wire. ⛔ `path` IS NOT ONE OF THEM — it is the +#: byte address inside the tenant's repo, of no use to a browser and a needless disclosure of the +#: store's layout. `canDelete` is not stored either: it is a fact about the READER, computed per +#: request below, so one person's answer can never be cached into another's. +_DOC_WIRE_KEYS = ('id', 'name', 'mime', 'size', 'by', 'ts') + + +def docs_for(pids, scope_key='customer', uname='', admin=False, st=None): + """`{"": [CustomerDoc, ...]}` for a SCOPED row set — the producer `payload.docs` lost. + + ⭐⭐ WAVE 30 / CONTRACT C4 (D-138). The write door has been live and UNREACHABLE since EXIT-6: + `doc_add`/`doc_fetch`/`doc_delete` all still work, and the comment inside that handler says + exactly why nobody noticed — *"the React shell cannot reach it yet (`payload.docs` is set only + by app.py)"*. `app.py` was deleted, and the producer went with it. Every client half survived: + `RecordDetail`, `SwipeView` and `Documents` are complete, and all six `onDoc*` handlers in + `CustomerGrid.tsx` are written as `payload?.docs ? … : undefined`, so an ABSENT `docs` key is + what has been switching the whole feature off. An empty dict is truthy and turns it on with an + honest empty state. + ⛔ THIS IS THE SIXTH INSTANCE OF THE REACHABILITY CLASS and the one `verify_reachability.py` + cannot see — a payload key assembled through helpers and dict merges is not a text-matchable + literal ([[reachable-is-not-the-same-as-built]], [[artifact-with-no-importer]]). + + ⛔ ONE READER, NOT A MATCHING PAIR. `routes_tables` (ut_*) and `routes_customers` (the customer + scope) both call THIS — they do not each serialise. Two surfaces answering one question + separately is how the same defect got reintroduced in the opposite direction inside a single + commit ([[one-question-two-normalizers]]). + + ⚠ `pids` IS REQUIRED and is the row set the CALLER has already scoped — `shared_overlay.cells`' + argument, for the same reason: there is no "every document in the tenant" call to reach for. + A record with no documents gets `[]`, never a missing key, so a client can tell "none" from + "not served". + """ + wanted = {str(int(p)) for p in (pids or [])} + if not wanted: + return {} + scope = str(scope_key or 'customer').strip().lower() + key = _DOCS_KEY if scope in cohort_mod.LEGACY_SCOPES else f'{scope}_docs' + try: + stored = (st if st is not None else store).get(key) or {} + except Exception: # noqa: BLE001 + stored = {} # a display read degrades; it never breaks the grid + out = {} + for pid in wanted: + row = stored.get(pid) + docs = [] + for d in (row if isinstance(row, list) else []): + if not isinstance(d, dict) or not d.get('id'): + continue + wire = {k: d.get(k) for k in _DOC_WIRE_KEYS} + # The delete wall, mirrored onto the wire so the button is absent rather than + # offered-and-refused. `doc_delete` enforces the same test server-side; this is the + # DISPLAY of that rule, never a substitute for it. + wire['canDelete'] = bool(admin or (d.get('by') and d.get('by') == uname)) + docs.append(wire) + out[pid] = docs + return out + + +def _docs_key(ctx): + """The DOCUMENTS bucket this ctx operates on — `_cohorts`'s sibling (wave 19, item 12's + silent third case). + + ⛔ `_DOCS_KEY` STAYS the module constant with its shipped value: app.py re-imports it for the + Streamlit docs payload and `verify_seam.py` asserts the two resolve to the same string. This + resolves the CUSTOMER topic to exactly that constant and gives every other database its own + bucket, so the pid keys inside one bucket all come from one id space. + """ + scope = str(getattr(ctx, 'scope_key', '') or 'customer').strip().lower() + if scope in cohort_mod.LEGACY_SCOPES: + return _DOCS_KEY + return f'{scope}_docs' + + +# ------------------------------------------------------------------ the workspace family (X1 amd.) +def table_workspace(ctx, allowed_pids=None, consume_corrections=True): + """Durable Airtable-style view/schema/overlay state, with an honest session fallback. + + Wave-9 I17: the returned `views` are this user's OWN views MERGED with every view SHARED + with them. Own views win a same-id collision — a personal view is the user's own object and + must never be shadowed by somebody else's share. + + ⚠ `allowed_pids` is a SECURITY parameter, not a convenience. A shared view carries + `memberPids`, which the writer's scope validated on the way IN — but the READER may have a + narrower scope. Handing a Fisch-scoped user a view whose member list was built by a + full-access user would leak the other BU's customers straight through the strict-isolation + rule. So membership is re-filtered against the READER on the way OUT. Pass it wherever the + result reaches a renderer or an event handler; omitting it drops memberPids entirely rather + than trusting the stored list (fail-closed: a smaller view, never a wider one). + + MOVED from app.py:5323 (`_cl_table_workspace`). `admin` was a parameter defaulting to a live + `is_admin()` call; it now always comes from `ctx.admin`, which is identical at the one call + site because app.py computes `admin = bool(is_admin())` once per render. `allowed_pids` + stays an EXPLICIT argument because the folder leg deliberately omits it. + """ + uname = ctx.uname + if _store_of(ctx).available(): + ws = _tops(ctx).workspace( + uname, consume_corrections=bool(consume_corrections)) + try: + shared = _tops(ctx).shared_views(uname, bool(ctx.admin)) + except Exception as _se: + _tel.error('sharedviews:read', _se) + shared = {} + # ⭐ WAVE 21 (item 9, C1): the R10 registry's named-user grants, PROJECTED at last. + # Wave 20 wrote view grants and no reader ever consulted them — "Share view…" recorded + # a row and changed nothing on the receiver's screen. + try: + granted = _granted_views(ctx, uname) + except Exception as _ge: + _tel.error('sharedviews:granted', _ge) + granted = {} + if shared or granted: + merged = dict(granted) + merged.update(shared) # the everyone-bucket outranks a grant + merged.update(ws.get('views') or {}) # own views win the id collision + ws['views'] = {vid: scope_view(v, uname, allowed_pids) + for vid, v in merged.items()} + return ws + # Store down. Streamlit degrades to its captioned session workspace exactly as before; an + # API caller has no durable dict and must be told, not quietly served memory. + if ctx.fallback_ws is None: + raise StoreUnavailable('table workspace unavailable: no store and no session fallback') + return ctx.fallback_ws.setdefault( + uname, {'views': {}, 'fields': {}, 'overlays': {}}) + + +def _granted_views(ctx, uname): + """{view_id: STAMPED view} for every view GRANTED to `uname` by name (wave 21, item 9, C1). + + The R10 registry stores bare ids, tenant-scoped through the SAME store handle this topic's + bucket uses; the record itself is fetched out of the OWNER's personal stratum of THIS + bucket. An id granted on another topic simply finds nothing here and contributes nothing + (fail-closed). Each projected view is stamped `shared` / `sharedRole` / `owner` so the + client can synthesise the "Shared with me" group and disable edit affordances for + role=view — the stamps live on the PROJECTION only, never written back to the store. + `role_for` is asked with is_admin=False deliberately: the stamp reports the GRANT (the + registry listed only explicit entries), not the caller's rank. + """ + import core.shares as shares + tops = _tops(ctx) + st = getattr(tops, 'st', None) + ids = (shares.shared_with(uname, kind='view', st=st) or {}).get('view') or [] + out = {} + for vid in ids: + found = tops.find_view(vid) if hasattr(tops, 'find_view') else None + if not found: + continue + owner, view = found + if owner == uname: + continue + role = shares.role_for('view', vid, uname, st=st) + if role not in ('view', 'edit'): + continue + v = dict(view) + v['shared'] = True + v['sharedRole'] = role + v['owner'] = owner + out[str(vid)] = v + + # ⭐ D-37 (2026-08-05) — THE FOLDER LEG. W20's R10 said "a folder share = its views ride + # along"; W21 wired the view kind only, so a granted folder was accepted, stored and listed + # under "Shared with me" while changing NOTHING on the receiver's screen. Two waves of a + # control that reported success and did nothing — the same defect class the view leg above + # was written to close, surviving in its sibling. + # + # ⚠ A DIRECT VIEW GRANT WINS over the folder it happens to sit in, and the ordering above is + # what implements that: the direct pass has already filled `out`, so the folder pass skips + # ids it finds there. The specific grant is the more deliberate statement of intent, and it + # may carry a DIFFERENT role — a folder shared read-only must not downgrade a view somebody + # was explicitly given edit on, nor upgrade one they were not. + try: + folder_ids = (shares.shared_with(uname, kind='folder', st=st) or {}).get('folder') or [] + except Exception: + folder_ids = [] + for fid in folder_ids: + found = tops.find_folder(fid) if hasattr(tops, 'find_folder') else None + if not found: + continue # a folder on another topic contributes nothing here + owner, folder, inside = found + if owner == uname: + continue + role = shares.role_for('folder', fid, uname, st=st) + if role not in ('view', 'edit'): + continue + for vid, view in inside.items(): + if str(vid) in out: + continue + v = dict(view) + v['shared'] = True + v['sharedRole'] = role + v['owner'] = owner + # The folder's NAME rides so the client can group these together later; today it + # renders in the same "Shared with me" group the view leg feeds. Stamps live on the + # PROJECTION only and are never written back, exactly as above. + v['sharedFolder'] = str(folder.get('name') or '')[:80] + out[str(vid)] = v + return out + + +def scope_view(view, uname, allowed_pids): + """Re-scope a view to the READER. A no-op for a view the reader created. + + Two things travel inside a shared view that are SCOPE, not presentation: + 1. `memberPids` — a customer list. Re-filtered against the reader's permitted pool. + 2. cohort conditions — a cohort id is a permission handle owned by ONE user. For a reader + who does not own it the leaf is UNANSWERABLE, and partial evaluation of an unanswerable + leaf WIDENS `is none of` ([[cg-cohort-set-conditions]]) — i.e. it would show MORE rows, + not fewer. So the whole filter tree is dropped for a foreign reader rather than + evaluated with a hole in it. A visibly unfiltered shared view is recoverable; a + silently widened one is a leak. + """ + if not isinstance(view, dict) or view.get('createdBy') == uname: + return view + out = dict(view) + cfg = dict(out.get('config') or {}) + mp = cfg.get('memberPids') + if isinstance(mp, list): + cfg['memberPids'] = ([p for p in mp if p in (allowed_pids or ())] + if allowed_pids is not None else []) + if tree_has_cohort(cfg.get('filters')): + cfg['filters'] = [] + cfg['_scopedOut'] = 'cohort' # the client says so rather than pretending + out['config'] = cfg + return out + + +def tree_has_cohort(node): + """True when a filter tree references the Cohorts column anywhere (any depth).""" + import aios_grid as _agc + col = _agc.COHORT_COLUMN + if isinstance(node, list): + return any(tree_has_cohort(n) for n in node) + if not isinstance(node, dict): + return False + if node.get('colId') == col or node.get('field') == col: + return True + return tree_has_cohort(node.get('conditions') or node.get('children')) + + +def measure_leaves(nodes): + """Every leaf of a filter tree that carries a date WINDOW — i.e. every measure condition. + + Keyed off the window rather than the column name on purpose: a window is the one thing no + column condition has, so this cannot mistake `revenue_ytd > 5000` for a measure. + """ + out = [] + for node in nodes or []: + if not isinstance(node, dict): + continue + if isinstance(node.get('children'), list): + out.extend(measure_leaves(node['children'])) + elif node.get('window') is not None: + out.append(node) + return out + + +# ------------------------------------------------------------------ private handler helpers +def _field_echo_equal(accepted, raw): + """Does the ACCEPTED def match what the client already renders? (wave-6 item 3) + + The no-blip law: a field_upsert whose acceptance changed NOTHING the client draws needs no + re-render — the client is the ORIGIN of the change, its optimistic copy is already right, + and the rerun it would buy costs a full payload re-serialisation plus an iframe remount + (the "blip" the owner named). STRICT per-prop equality after collapsing the empty forms + ('' / [] / {} / absent all render identically): any normalisation a validator applied is a + real difference the client must be SHOWN. Fail-closed — unsure means not equal means rerun. + """ + empty = ('', [], {}) + for k in _FIELD_ECHO_PROPS: + a, b = accepted.get(k), raw.get(k) if isinstance(raw, dict) else None + if k == 'scope': + # 'global' and absent are the SAME rendering (an unscoped field) — the event may + # say 'global' explicitly while the stored def says it by omission. + a = None if a == 'global' else a + b = None if b == 'global' else b + if (None if a in empty else a) != (None if b in empty else b): + return False + return True + + +def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120): + """Return a durable, case-insensitively unique field/view display name.""" + limit = max(1, int(max_len)) + + def _clean(value): + return ' '.join(str(value or '').split()) + + base = (_clean(wanted) or _clean(fallback) or 'Untitled')[:limit].rstrip() + taken = {_clean(value).casefold() for value in existing if _clean(value)} + if base.casefold() not in taken: + return base + index = 2 + while True: + suffix = f' {index}' + stem = base[:max(0, limit - len(suffix))].rstrip() + candidate = f'{stem}{suffix}' if stem else str(index)[-limit:] + if candidate.casefold() not in taken: + return candidate + index += 1 + + +def _known_usernames(): + """Real account names, for C4's fail-closed `users` grant. None = "could not resolve". + + None means DO NOT FILTER, deliberately. Returning an empty set on a transient registry + error would silently collapse a user's stored 'specific users' grant to 'personal' on the + very next autosave — destroying a setting because a read blipped. The grant is inert today + anyway (views are per-user; nothing reads it to allow cross-user access), so the honest + trade is: never invent access, never destroy a stored intent either. + """ + try: + reg = set(users.registry() or {}) + except Exception as _ue: + _tel.error('viewperms:users', _ue) + return None + # ⚠ EMPTY IS "UNRESOLVED", NOT "NOBODY EXISTS". registry() returns {} rather than raising + # when the user store cannot be read (a store blip logs `store:get:users` and hands back an + # empty dict) — and a working app ALWAYS has at least the bootstrap admin. Treating {} as a + # real answer would filter every name out of a stored grant and collapse it to 'personal' + # on the next autosave. Caught by testing this in BARE mode with no store token. + return reg or None + + +# ------------------------------------------------------------------ THE SEAM +#: ⭐⭐ D-291's WRITER. Every refusal in `field_upsert` goes through here so the caller learns WHY +#: instead of receiving a success-shaped answer. +#: +#: ⛔ IT RETURNS **True**, NOT False, AND THAT HALF IS A SECOND DEFECT THIS CLOSES. `handle_one`'s +#: return value means *"this page must re-render from server state"* — so a refusal that returned +#: False left the browser's OPTIMISTIC column on screen indefinitely. The user typed a column, saw +#: it appear, and it was never stored; only a reload revealed it had never existed. The file already +#: states the rule for the one refusal that got it right (`refused_perms`): *"the client's optimistic +#: copy is a lie that must be repainted"*. Now every refusal obeys it. +#: +#: `code` is a stable machine token (the client may branch on it); `message` is the sentence a +#: person reads. ⚠ The message must name what the CALLER can change — a refusal a user cannot act +#: on is only marginally better than silence. +def _refuse(ctx, code, message, key=''): + """Record a NAMED refusal on the result and demand a repaint. Always returns True.""" + try: + out = getattr(ctx, 'out', None) + if out is not None and hasattr(out, 'refusals'): + if len(out.refusals) < 24: # one per event in the window; never unbounded + out.refusals.append({ + 'event': 'field_upsert', + 'key': str(key or '')[:80], + 'code': str(code)[:40], + 'message': str(message)[:300], + }) + except Exception: # noqa: BLE001 + # ⛔ REPORTING MUST NEVER BE THE THING THAT BREAKS THE WRITE PATH. A refusal that raises + # while explaining itself would turn a declined field into a 500 — strictly worse than the + # silence this replaces. + pass + return True + + +def handle_events(events, ctx): + """The event LOG entry point → one EventResult. The `[-24:]` window lives here. + + ⚠ THE VALUE IS AN EVENT LOG `{'events': [...]}`, NOT ONE EVENT (2026-07-27). A component + has exactly one value slot, and two quick emits share it: creating a field emits + `field_upsert` and the insertColumn autosave emits `view_upsert` 420ms later — with the + server still inside the first rerun, the second write clobbered the slot before any run + read it, and the field creation VANISHED with no error anywhere (measured live; the same + race sat latent under overlay-field creation, masked by localStorage). The client now + sends its recent window of events; each is processed exactly once, keyed by its id. The + single-event shape is still accepted — it is what an older client in a stale tab sends. + + Accepts everything the component value can be: the log dict, a bare list of events (what + the HTTP body's `events` array unwraps to), or one legacy single-event object. + + `EventResult.rerender` is True when ANY processed event demands a re-render from server + state — the same OR-fold `_cl_handle_grid_event` did. + """ + if isinstance(events, dict) and isinstance(events.get('events'), list): + seq = events['events'][-24:] + elif isinstance(events, list): + seq = events[-24:] + else: + seq = [events] + rerun = False + for one in seq: + rerun = handle_one(one, ctx) or rerun + ctx.out.rerender = rerun + return ctx.out + + +def handle_one(event, ctx): + """Validate ONE untrusted component event. + + Odoo-source fields are never accepted as cell updates. View and schema objects are + user-scoped; row patches additionally require the pid to be in the currently permitted + Customer List pool. + + RETURN VALUE = "this page must re-render from server state", NOT "handled". + The distinction is the difference between a snappy grid and a sluggish one. + Persisting is not by itself a reason to rerun: Streamlit has ALREADY re-run the + script once simply to deliver the component value, and the client is the ORIGIN + of the change, so its own state is correct before the server hears about it. An + extra st.rerun() therefore buys nothing and costs a second full page run. + Measured on the 1,550-row book (2026-07-25): one filter tweak produced TWO + page_customer_data runs ~2.8s apart, each re-serialising the whole ~576 KB row + payload — and the handler on the second run is a guaranteed no-op, because the + event-id dedupe two lines below has already consumed that id. + + THE WAVE-6 NO-BLIP LAW (item 3, 2026-07-27) — True ONLY when the next render + genuinely differs from what the client already draws: + field_upsert — False when the accepted def ECHOES the client's copy + (_field_echo_equal); True on any validator mutation, any + permissions refusal, and ALWAYS when a measure window changed + (values must be re-resolved server-side). + overlay_patch — False when every requested update was accepted verbatim; + True the moment anything was refused or truncated. + field_delete — False: the client removed the column optimistically and the + store scrub is invisible to the next render. + field_duplicate — True only when host-held VALUES must flow back (custom_ cell + copies, measure_ recomputes); formula/created_time clones + compute client-side. + view_delete — False (the deleted view simply stops being served). + view_upsert — unchanged: False, except an unresolved measure condition. + cohort_* / add_to_list — True: the lists payload is server truth, and these are + not the hot path. + + The id dedupe is a SET (not a last-id slot): the event log resends the whole recent + window on every emit, so every id must stay consumed, not just the latest one. + """ + # The ctx fields the moved body reads as plain locals — bound once here so every branch + # below is textually the app.py body (which is what makes this move reviewable). + uname = ctx.uname + allowed_pids = ctx.allowed_pids + fields = ctx.fields + measure_keys = ctx.measure_keys + resolved_ids = ctx.resolved_ids + cohort_ids = ctx.cohort_ids + measure_offer = ctx.measure_offer + visible_views = ctx.visible_views + admin = ctx.admin + scope_key = ctx.scope_key + + if not isinstance(event, dict): + return False + event_id = str(event.get('id') or '')[:180] + seen = ctx.seen_ids + if not event_id or event_id in seen: + return False + seen[event_id] = True + if len(seen) > 400: # a session-lifetime set, kept bounded + for stale in list(seen)[:200]: + seen.pop(stale, None) + kind = event.get('type') + field_by_key = {f['key']: f for f in fields} + valid_keys = set(field_by_key) + overlay_keys = {f['key'] for f in fields if f.get('source') == 'overlay'} + # Event validation is not a render payload. It must not consume a one-shot label + # correction before the browser has had a chance to receive it. + ws = table_workspace( + ctx, allowed_pids=allowed_pids, consume_corrections=False) + + def _session_ready(): + ws.setdefault('views', {}) + ws.setdefault('fields', {}) + ws.setdefault('overlays', {}) + + if kind == 'view_select': + # Owner item 3 (2026-07-31): remember WHERE THE USER IS so a fresh browser resumes + # there instead of the system default view. Presentation state only — the read side + # re-validates the id against what the caller may see, so nothing here needs an + # authorisation wall beyond the length cap. Never a re-render: the client is already + # on that view. + vid = str(event.get('viewId') or '').strip()[:120] + if vid and _store_of(ctx).available(): + try: + _tops(ctx).save_active_view(uname, vid) + except Exception as _ae: + _tel.error('grid:view-select', _ae) + return False + + if kind == 'record_layout': + # Wave 2026-08-02 (C-LAYOUT): the per-user record-detail field order. Presentation + # state for one surface; grid column order is untouched. Unknown keys are pruned + # against the LIVE field set, dupes collapse, and the wire re-validates at serve + # time — so a client racing a field delete degrades to default order, never an error. + raw_order = event.get('order') + if not isinstance(raw_order, list): + return False + order, seen = [], set() + for k in raw_order[:200]: + k = str(k or '').strip()[:80] + if k and k in valid_keys and k not in seen: + seen.add(k) + order.append(k) + if _store_of(ctx).available(): + _tops(ctx).save_record_layout(uname, order) + else: + _session_ready() + if order: + ws['recordLayout'] = {'order': order} + else: + ws.pop('recordLayout', None) + # Autosave hot path, like view_select: the client already renders the order it asked + # for, and the wire echoes it back on the next workspace read. + return False + + if kind == 'view_upsert': + raw = event.get('view') + if not isinstance(raw, dict) or not isinstance(raw.get('config'), dict): + return False + view_id = str(raw.get('id') or '').strip()[:120] + name = str(raw.get('name') or '').strip()[:120] + requested_name = name + if not view_id or not name: + return False + cfg = raw['config'] + # The filter TREE (leaf conditions and/or nested condition groups) is + # validated by the module-agnostic grid contract helper — imported lazily + # here for the same reason the page does it: a missing embed build must + # never break page import. + import aios_grid as _ag + # A MEASURE key is valid in a FILTER and nowhere else (CG-8): it may not be ordered, + # shown, grouped or coloured by, because it is not a column of this table. Hence the + # widened key set here and the plain `valid_keys` everywhere below. + # `cohort_ids` is a PERMISSION list, not a convenience: a cohort rule naming anything + # else is dropped here. Leaving it in would persist a condition the engine can only + # answer with "nothing", so `Cohort is not [a list I deleted]` would show an empty table + # forever with nothing on screen explaining why. + filters = _ag.clean_filter_tree(cfg.get('filters'), + valid_keys | set(measure_keys), + cohort_ids=set(cohort_ids)) + sorts = [] + for rule in list(cfg.get('sorts') or [])[:20]: + if (isinstance(rule, dict) and rule.get('colId') in valid_keys + and rule.get('dir') in ('asc', 'desc')): + sorts.append({'colId': rule['colId'], 'dir': rule['dir']}) + order = list(dict.fromkeys( + key for key in list(cfg.get('order') or []) if key in valid_keys)) + order += [field['key'] for field in fields if field['key'] not in order] + visible = list(dict.fromkeys( + key for key in list(cfg.get('visible') or []) if key in valid_keys)) + widths = {} + raw_widths = cfg.get('widths') if isinstance(cfg.get('widths'), dict) else {} + for key, value in raw_widths.items(): + if key in valid_keys: + try: + widths[key] = max(60, min(600, round(float(value)))) + except (TypeError, ValueError): + pass + # C-LOCK (wave 2026-08-02, item 12's mechanism): a view may be LOCKED to one cohort. + # `cohort_ids` is the same permission list the cohort filter leaf uses — a lock naming + # a cohort this session cannot see is DROPPED at write, exactly like a cohort rule. + # (Readers of a SHARED locked view resolve the id against their OWN visible sets; an + # unresolvable lock matches NOTHING client-side — the cohort-leaf law, never a widen.) + cohort_lock = str(cfg.get('cohortLock') or '').strip()[:120] + # ⛔ WAVE 17 R1 (C-LOCKV guard a) — A PROJECTED LOCKED VIEW RE-STAMPS ITS OWN LOCK. + # A locked view IS its set: its id and its lock are the same fact. The client rebuilds + # `config` from scratch on every autosave (a column resize is enough), so taking the + # lock from the browser would mean one omitted key turns "the 40 accounts we agreed to + # call" into the whole book, under the same name, with nothing going red. The read side + # re-stamps too (aios_grid.views_from_defs) — this keeps the STORE from accumulating the + # wrong record in the first place. + _is_locked_view = view_id in set(cohort_ids) + if _is_locked_view: + cohort_lock = view_id + config = { + 'filters': filters, + # conjunction joining the ROOT-level conditions; anything but an + # explicit 'or' means 'and' (legacy views had an implicit AND). + 'filterConj': 'or' if cfg.get('filterConj') == 'or' else 'and', + **({'cohortLock': cohort_lock} + if cohort_lock and cohort_lock in set(cohort_ids) else {}), + # ⭐⭐ WAVE 32 · R5 / CONTRACT C4 — "Mark important" (raised by SESSION C as ASK C-12). + # + # ⛔ WITHOUT THIS LINE THE MARK IS A LIE THAT SURVIVES UNTIL RELOAD. This dict is + # REBUILT FROM AN ALLOWLIST on every autosave — a column resize is enough — so a key + # the client sends and this literal does not name is silently dropped. The badge would + # paint, the write would answer 200, and the mark would be gone on the next read: the + # exact shape T24's done-when says "persists across a reload" to catch, and + # [[a-migration-that-runs-on-the-next-write]] on a write path. + # + # ⚠ UNCONDITIONAL, NOT SPREAD-CONDITIONAL LIKE `cohortLock` ABOVE, and the asymmetry is + # the whole point: R5's mark must be REMOVABLE. A key written only when present means + # unmarking sends `important: false`, the allowlist drops it, and the stored `true` + # survives — a mark you can set and never clear. + # ⚠ `is True`, not truthy: the client sends a real boolean, and a string `"false"` must + # not become a mark. + # ⚠ NO PERMISSION GATE, deliberately. R5 makes this a personal legibility mark, not a + # lock; `_may_administer` already decides who may write this view at all, and adding a + # second wall here would be the two-normalizers shape this wave is already fixing + # elsewhere. + 'important': cfg.get('important') is True, + 'sorts': sorts, + 'groupBy': cfg.get('groupBy') if cfg.get('groupBy') in valid_keys else None, + 'colorBy': cfg.get('colorBy') if cfg.get('colorBy') in valid_keys else None, + 'rowHeightMode': (cfg.get('rowHeightMode') + if cfg.get('rowHeightMode') in ('short', 'medium', 'tall') + else 'short'), + 'order': order, + 'visible': visible, + 'widths': widths, + 'memberPids': [pid for pid in list(cfg.get('memberPids') or []) + if isinstance(pid, int) and pid in allowed_pids], + } + # Wave-6 item 10: a non-grid display mode rides the view config, validated + # structurally (mode whitelist, refs must be this table's fields). + display = _ag._clean_display(cfg.get('display'), valid_keys) + if display: + config['display'] = display + # Wave-6 item 11 (pin): how many leading columns stay frozen while scrolling + # sideways. Clamped 1..8 — 0 would unfreeze the identity column the whole + # surface pivots on; absent = the legacy single frozen column. + if cfg.get('frozenCount') is not None: + try: + config['frozenCount'] = max(1, min(8, int(cfg['frozenCount']))) + except (TypeError, ValueError): + pass + # ── wave-9 C3 (lock) + C4 (permissions) ──────────────────────────────────────────── + # The PRIOR stored view decides both: whether this is a create or an update (C4's + # default splits on exactly that) and who is allowed to change the lock (C3). + prior = (ws.get('views') or {}).get(view_id) or {} + # ⚠ THE SHARED WALL (wave-9 I17). `ws` is already visibility-filtered, so a view the + # caller may not SEE reads as absent — which would look like a CREATE and let anybody + # overwrite somebody else's shared view by guessing its id. Ask the store for the raw + # record and refuse before deciding anything else. Fail-closed on both branches. + _raw_shared = _tops(ctx).shared_view(view_id) if _store_of(ctx).available() else None + if _raw_shared is not None and not prior: + return False # exists, not visible to this caller: refuse + if _raw_shared is not None and not _tstore._may_edit(_raw_shared, uname, admin): + return False # visible but not editable by this caller + is_new = not prior + # C4: the creator is HOST-STAMPED and never read from the browser — the same rule as + # the field-level createdBy. An existing view keeps whoever made it. + creator = prior.get('createdBy') or uname + # Only the creator or an admin may change the PERMISSIONS themselves. A collaborator who + # could rewrite them could grant themselves sole ownership of someone else's view, or + # quietly widen a users-scoped view to everyone — privilege escalation by edit. + if not is_new and not _tstore._may_administer(prior, uname, admin): + raw = dict(raw) + raw['permissions'] = prior.get('permissions') + raw['locked'] = prior.get('locked') + # ── I12/C3 BELT (2026-07-31): a LOCKED view's DISPLAY MODE is frozen HOST-SIDE too. + # The client's switcher already refuses (setDisplayMode), and its comment has claimed + # "the host refuses too" since wave 9 — this makes that claim true for direct API + # callers. ⚠ MIRRORS types.ts `isModeFrozen` EXACTLY: SYSTEM views and the undeletable + # list:/cohort: projections are EXEMPT — their `locked` is legacy "undeletable", not + # mode-frozen, and freezing them would trap the default view in whatever mode it last + # saved (the owner's stuck-in-Map report is that trap, client-made). An upsert that + # UNLOCKS in the same write (authorised above) may change display freely. + _mode_frozen = (prior.get('locked') and raw.get('locked') is not False + and prior.get('kind') != 'system' + and view_id != 'all-customers') + if _mode_frozen: + if prior.get('config', {}).get('display'): + config['display'] = dict(prior['config']['display']) + else: + config.pop('display', None) + # ⚠ A SYSTEM VIEW IS PER-USER STATE AND MUST NEVER BE SHARED. Caught in the wave-9 + # integration pass, before deploy: `all-customers` is every user's DEFAULT view and it + # carries their own column widths, visible fields and row height. C4's read-default + # ('collaborative' when permissions are absent, so nobody's saved view is + # retro-restricted) is right for a view somebody AUTHORED — but applied to the system + # view it routed `all-customers` into the shared bucket, and the first user to resize a + # column would have published their layout to everyone. Verified in the store: + # shared_table_views() was returning ['all-customers']. + # System views are pinned personal, so they stay in each user's own workspace exactly as + # before this wave. `kind` is host-validated two lines below; read it here from `raw` and + # treat the stored kind as authoritative for an existing view. + _kind = (prior.get('kind') if not is_new + else (raw.get('kind') if raw.get('kind') in ('system', 'list', 'custom') + else 'custom')) + if _kind == 'system' or view_id == 'all-customers': + perms = {'edit': 'personal'} + else: + perms = _ag.clean_view_permissions( + raw.get('permissions'), + default='personal' if is_new else 'collaborative', + known_users=_known_usernames()) + # C3: only the creator or an admin may SET or CLEAR the lock; anyone else keeps whatever + # is already stored. Enforced here rather than hidden in the client. This composes with + # C4 and does not override it: C4 governs who may edit the CONTENTS, C3 who may freeze + # the display MODE. + # ⚠ Inert in practice TODAY — a user can only ever write their own per-user workspace, + # so `uname == creator` always holds. Correct plumbing for shared views, not a live gate. + may_lock = admin or uname == creator + locked = bool(raw.get('locked')) if may_lock else bool(prior.get('locked')) + # C3: the LOCK's actual job — a locked view's display mode is frozen, so a locked Kanban + # stays Kanban. This half IS live per-user and is the part the owner asked for. Filters, + # sorts and columns stay editable by design. + if _mode_frozen: + was = (prior.get('config') or {}).get('display') + now = config.get('display') + if (was or {}).get('mode') != (now or {}).get('mode'): + if was: + config['display'] = was + else: + config.pop('display', None) + # WAVE 17 R1 (C-LOCKV guard b) — ONE THING, ONE NAME. Renaming a locked view renames + # the SET it is, in the store that owns it; the view record must not grow a second title + # that the read side would then have to arbitrate. Routed rather than duplicated. + if _is_locked_view and _store_of(ctx).available(): + _sets = _cohorts(ctx) # R9: the SET lives in this topic's bucket + _cur = (_sets.all_for(uname).get(view_id) or {}).get('name') + if name and name != _cur: + try: + _sets.rename(uname, view_id, name) + except ValueError as _e: + _tel.error('cohort:rename-via-view', _e) + view = {'id': view_id, 'name': name, + # ⚠ DERIVED FROM THE ID, never taken from the browser: `kind` is what paints the + # lock mark, so a client claiming it for an ordinary view would be claiming a + # guarantee the engine is not making about that view's rows. + 'kind': _ag.LOCKED_VIEW_KIND if _is_locked_view + else raw.get('kind') if raw.get('kind') in ('system', 'list', 'custom') + else 'custom', + 'locked': locked, + 'createdBy': creator, + 'permissions': perms, + 'note': str(raw.get('note') or '')[:2000], + 'config': config} + # System/list projections are not all persisted in this table store, but custom views + # may not impersonate their visible names. Persisted view names are allocated + # tenant-globally by TableStore inside the write transaction. + _reserved_view_names = [ + value.get('name') for value in visible_views + if (isinstance(value, dict) and value.get('id') != view_id + and value.get('kind') in ('system', 'list')) + ] + # Wave 21 (item 3, R6): the reserved SYSTEM name is PER-TOPIC — a ut_ database's system + # view is "All records", and reserving the customer literal there uniquified a user's + # honest "All customers" view name against a string that never appears on that topic. + _scope = str(getattr(ctx, 'scope_key', '') or 'customer').strip().lower() + _sys_name = ('All products' if _scope == 'product' + else 'All records' if _scope.startswith('ut_') else 'All customers') + if view_id != 'all-customers': + _reserved_view_names.append(_sys_name) + # ⭐ WAVE 21 (item 9, C1) — THE FOREIGN-VIEW WALL. A granted view is projected into the + # receiver's rail, so their autosave can now emit an upsert carrying an id they do not + # own. Silently saving that into the RECEIVER's stratum would FORK the view (own views + # win the merge, so the receiver sees their private copy forever and calls sharing + # broken). Role `edit` (or admin) writes THROUGH to the owner's record — config only, + # never identity or reach: `createdBy`/`permissions` are pinned from the stored record. + # Role `view` (or no grant) refuses with a toast instead of forking. + # + # ⛔⛔ WAVE 33 · T13 (owner item 3) — A PINNED PER-USER ID IS NOT A FOREIGN VIEW, AND + # TREATING IT AS ONE IS A CROSS-USER WRITE THAT REPORTS SUCCESS. + # + # `find_view` scans EVERY personal stratum and returns the FIRST match. `all-customers` + # and `tpl_overview` are PINNED ids that every user of a table has their own copy of — + # so the moment ANY colleague autosaves one (a column resize is enough), the wall reads + # "somebody else owns this id" for everybody else. MEASURED, on this exact path, after + # one colleague resized a column on the Overview: + # · a NON-ADMIN marking it is REFUSED, with a toast blaming sharing — a view nobody + # ever shared, on their own database; + # · an ADMIN marking it answers 200 and writes the mark into the OTHER USER'S + # stratum. The admin's own workspace still serves the un-marked view, so the mark + # "does not work"; the colleague silently gains a mark they never made. + # That is item 3's second mechanism, and it is a residency bug wearing a sharing bug's + # error message. + # + # ⚠ THIS FUNCTION ALREADY STATES THE RULE THE WALL BREAKS, seventy lines up: "A SYSTEM + # VIEW IS PER-USER STATE AND MUST NEVER BE SHARED", enforced there by pinning + # `perms = {'edit': 'personal'}`. A per-user view can therefore never BE a grant, which + # is the only thing this wall exists to protect — so exempting it narrows nothing. + _pinned_per_user = (_kind == 'system' or view_id in ('all-customers', _ag_overview_id())) + _save_as = uname + if _store_of(ctx).available() and not _pinned_per_user: + _foreign = _tops(ctx).find_view(view_id) + if _foreign and _foreign[0] != uname: + _o_uname, _stored = _foreign + if not admin: + import core.shares as _shares + if _shares.role_for('view', view_id, uname, + st=getattr(_tops(ctx), 'st', None)) != 'edit': + ctx.out.toast = ('That view is shared with you read-only — ask its ' + 'owner for edit access.') + return False + view['createdBy'] = _stored.get('createdBy', _o_uname) + if _stored.get('permissions') is not None: + view['permissions'] = _stored.get('permissions') + else: + view.pop('permissions', None) + _save_as = _o_uname + if _store_of(ctx).available(): + view = (_tops(ctx).save_view( + _save_as, view, reserved_names=_reserved_view_names, is_admin=admin) or view) + else: + _session_ready() + _view_names = list(_reserved_view_names) + _view_names.extend( + value.get('name') + for candidate_id, value in ws['views'].items() + if candidate_id != view_id and isinstance(value, dict) + ) + view['name'] = _unique_name(view.get('name'), _view_names) + ws['views'][view_id] = view + name_changed = view.get('name') != requested_name + # PERSISTED, but deliberately NO rerun — see the return-value contract in + # this function's docstring. This is the hot path: every filter, sort, + # group, colour, column-resize and column-toggle autosaves through here. + # + # CG-8 is the ONE exception, and it is self-limiting. A measure condition is answered + # server-side, so if this view carries one whose answer was NOT in the payload we just + # shipped, the client is showing a pending marker with nothing coming. Ask for exactly + # one more run — and only then, so the common case (a column filter) still costs zero. + # If the top-of-run read of the component's own value already covered it, this is False + # and nothing extra happens; if that read is ever stale, this is what stops the + # condition hanging unresolved. + # + # ⚠ COMPLETE rules only (wave-6 item 3, found by the live roundtrip on this rev): + # picking a measure FIELD mints a valueless rule whose autosave used to land here as + # "unresolved" and trigger a rerun that could resolve NOTHING — `rule_complete` refuses + # half-asked questions — so the only effect was a remount that closed the builder + # popover mid-build (the wave-5 "gate flake" runs, now explained). A rule still being + # typed stays client-side PENDING by design; the run that matters fires when its VALUE + # arrives and the question becomes answerable. + from harness import measure_filter as _mfc + unresolved = {str(r.get('id') or '') for r in measure_leaves(filters) + if _mfc.rule_complete(r)} - set(resolved_ids) + return name_changed or bool(unresolved - {''}) + + if kind == 'view_delete': + view_id = str(event.get('viewId') or '')[:120] + # ⭐ WAVE-27 item 27 (R8) — `IG_OVERVIEW_ID` joins the system view here, and the comment + # eight lines down already explains why in the LOCKED-view case: dropping the saved + # config leaves the PROJECTION, which rebuilds the row on the very next render. Overview + # is injected on every read of an Instagram database, so a delete would look like it + # worked and the view would be back after a reload — which is precisely the + # reads-as-success failure the client's own `UNDELETABLE_VIEW_IDS` note describes. + if not view_id or view_id in ('all-customers', _ag_overview_id()): + return False + # WAVE 17 R1 (C-LOCKV guard c) — deleting a LOCKED view deletes the set it is. Without + # this the store would drop the saved config and the projection would rebuild the view + # on the very next render: the row would come back, minus the user's sort and columns, + # which reads as a corruption rather than as a refusal. Own sets only (`all_for`), the + # same wall `cohort_delete` rides. + if _store_of(ctx).available() and view_id in _cohorts(ctx).all_for(uname): + _cohorts(ctx).delete(uname, view_id) + _tops(ctx).delete_view(uname, view_id) + # True, unlike an ordinary view_delete: the lists payload IS server truth and the + # client cannot know the membership channel changed. + return True + # Wave-9 I17: deleting a SHARED view is narrower than editing it — creator or admin + # only. A collaborator can change what a shared view shows; they cannot destroy + # everybody else's copy of it, because there is only one copy. + if _store_of(ctx).available(): + _raw_shared = _tops(ctx).shared_view(view_id) + if _raw_shared is not None and not _tstore._may_administer( + _raw_shared, uname, admin): + return False + # ⭐ WAVE 21 (item 9, C1) — the foreign wall, delete half. Contract: a grant of + # `edit` may delete (there is only one copy and the owner handed over its content); + # `view` or no grant refuses. The dead grant record is emptied afterwards so the + # receiver's "Shared with me" heals instead of listing a ghost id forever. + _foreign = _tops(ctx).find_view(view_id) + if _foreign and _foreign[0] != uname: + import core.shares as _shares + _sst = getattr(_tops(ctx), 'st', None) + if not admin and _shares.role_for('view', view_id, uname, + st=_sst) != 'edit': + ctx.out.toast = 'That view is shared with you read-only.' + return False + _tops(ctx).delete_view(_foreign[0], view_id) + try: + _shares.set_grants('view', view_id, [], st=_sst) + except Exception: + pass + return False + _tops(ctx).delete_view(uname, view_id) + else: + _session_ready() + ws['views'].pop(view_id, None) + # Wave-6 item 3: the client removed the view optimistically; a deleted view simply + # stops being served, so the next render differs in nothing the client still shows. + return False + + if kind == 'field_upsert': + raw = event.get('field') + if not isinstance(raw, dict): + return _refuse(ctx, 'malformed', + 'the event carried no field object') + key = str(raw.get('key') or '')[:80] + import aios_grid as _ag2 + if key.startswith(_ag2.MEASURE_FIELD_PREFIX): + # A FORMULA-MEASURE column (owner item 7). Validated against THIS caller's measure + # offer — the same admission the condition builder uses — so a field can only name a + # measure this user could also filter by. Fail closed on anything else. + field = _ag2.clean_measure_field(raw, {m['key']: m for m in measure_offer}) + if field is None: + return _refuse( + ctx, 'measure_not_offered', + 'this measure is not one you can filter by on this page, so a column ' + 'cannot be built from it', key) + fmt = _ag2._clean_format(raw.get('format'), field.get('type')) + if fmt: + field['format'] = fmt + elif key in field_by_key and not key.startswith('custom_'): + base = field_by_key[key] + field = {**base, 'note': str(raw.get('note') or '')[:2000]} + # ⭐ W29-T83 — THE COLUMN SUMMARY ON A SOURCE-BACKED COLUMN. This branch rebuilds the + # accepted def from the CONTRACT and adds only what a user may override, and `agg` was + # not in that list — so Average on Customer's `Overdue days` was accepted with a 200 + # and dropped here, before it ever reached the store. Validated against the shared + # vocabulary rather than a literal (see the custom branch below for what a literal + # costs). An ABSENT or unknown `agg` clears it: the client sends the whole field on + # every edit, so a missing key is a user who unset the summary, and `{**base}` has + # already re-applied whatever the contract itself declares. + _agg = raw.get('agg') + if _agg in _ag2.FIELD_AGGS: + field['agg'] = _agg + else: + field.pop('agg', None) + # Wave-5 item 10: a DISPLAY format on any base field (presets included) — how a + # number/date reads, per user. Rendering-only; validated fail-closed per type. + fmt = _ag2._clean_format(raw.get('format'), base.get('type')) + if fmt: + field['format'] = fmt + else: + field.pop('format', None) # cleared -> back to the type's default render + # PRESET measure fields (wave-2 item 8): the browser may change the PERIOD (and the + # auto-renamed label) of revenue_ytd / revenue_ly / orders_24m — nothing else. The + # whitelist IS the base contract: only a field that shipped with preset+measure can + # take a window here, and the measure KEY is pinned to the base's (fail closed). + if (base.get('preset') and isinstance(base.get('measure'), dict) + and isinstance(raw.get('measure'), dict)): + window = _ag2._clean_window(raw['measure'].get('window')) + if window is not None: + field['measure'] = {'key': base['measure']['key'], 'window': window} + if isinstance(raw.get('label'), str) and raw['label'].strip(): + field['label'] = str(raw['label'])[:120] + elif key.startswith('custom_') and raw.get('type') in _ag2.CUSTOM_FIELD_TYPES: + ftype = raw['type'] + # ⭐⭐ 2026-08-09 — THE RELATIONAL PAIR HAS NO HOME IN A PER-USER OVERLAY, and this + # door used to accept one silently. It builds the stored field from a fixed key list + # and adds only the bags it knows (`options`, `max`, `formula`, `automation`, `code`) + # — so a `link`/`rollup` arriving here was stored WITHOUT its bag, in one user's + # stratum, where `compute_relation_cells` (which walks `user_tables` definitions) + # never sees it. Measured on nurilab as `custom_video_views_90j26`: a Rollup column + # that rendered, could not be filled by anything, and reported no error. + # ⛔ REFUSED RATHER THAN REPAIRED. These two belong to the table's SHARED schema + # (`POST /tables/{key}/fields`), which is where the client now sends them; storing a + # second, private, non-computing copy here would be a second definition of the same + # column. A refusal repaints, so a stale client says something rather than nothing. + if ftype in ('link', 'rollup'): + return _refuse( + ctx, 'wrong_door', + 'a link or rollup column belongs to the table schema, not to a personal ' + 'overlay - create it from the Fields panel instead', key) + readonly = ftype in _ag2.READONLY_CUSTOM_TYPES + # The editable family must SAY it is overlay (the client's own contract); the + # read-only pair (formula / created_time) is stored under the cohort column's + # mechanism instead, so no cell write can ever be accepted for it. + if not readonly and raw.get('source') != 'overlay': + return _refuse( + ctx, 'source_not_overlay', + "an editable column must declare source 'overlay'; this one declared " + '%r' % (raw.get('source'),), key) + field = {'key': key, 'label': str(raw.get('label') or 'Untitled')[:120], + 'type': ftype, 'source': 'odoo' if readonly else 'overlay', + 'default': True, 'custom': True, + 'note': str(raw.get('note') or '')[:2000]} + if readonly: + field['derived'] = True + # FILTERABLE since wave 6 (owner item 6): formula/created_time values live + # client-side, and this table's counts are client-mode — the client engine + # answers conditions on them soundly. Mirrors fields_from_workspace. + field['filterable'] = True + # ⛔ W29-T83 — THE VOCABULARY, NOT THE LITERAL `'sum'`. This is the write gatekeeper + # for an overlay column's summary and it accepted exactly one name, while the READ + # projection (`aios_grid.py:679`) passes all six — so a user who chose Median got a + # 200, a menu that said Median, and no summary after the next reload. The same shape + # as the `agg:"sum"` defect this repo already shipped, where one door kept it and the + # other dropped it ([[read-path-cannot-witness-write-path]]). + _agg = raw.get('agg') + if _agg in _ag2.FIELD_AGGS and not readonly: + field['agg'] = _agg + # A `select`/`multiselect` carries its own vocabulary; a `user` does NOT — its + # choices are the tenant's accounts, resolved fresh on every render, so persisting + # a snapshot here would go stale the moment somebody joins or leaves. + if ftype in ('select', 'multiselect'): + field['options'] = _ag2._clean_options(raw.get('options')) + if not field['options']: + # a select with no choices can never hold a value + return _refuse( + ctx, 'no_options', + 'a select column needs at least one choice', key) + field.update(_ag2._choice_appearance(raw, field['options'])) + if ftype == 'rating': + field['max'] = _ag2._clean_rating_max(raw.get('max')) + if ftype == 'formula': + # WRITE-time is where refs must exist (fail closed); read-time leaves them + # alone so a later-deleted ref blanks the CELLS, not the column. + formula = _ag2._clean_formula(raw.get('formula'), valid_keys) + if formula is None: + # ⭐ THE ONE D-275 IS MOST LIKELY TO BE. `valid_keys` is THIS page's column + # set, so a formula naming a column that exists on another grid is refused + # here and was, until now, indistinguishable from a save. + return _refuse( + ctx, 'formula_refs', + 'the formula is empty or names a column this table does not have', + key) + field['formula'] = formula + if ftype == 'automation': + # C5-AUTOFIELD (wave 18): persist the validated config bag. No `return False` + # when absent — an unconfigured automation column is a legitimate intermediate + # state (create the column, then configure it in the gear). + # ⛔ C8 (wave 22): a CONFIGURED bag must name an existing flow — the write door + # reads the tenant's definition ids and `_clean_automation` refuses a bag + # without one (or naming a deleted one). Refused = the whole field write is + # refused, loudly (repaint), because storing the field while dropping its bag + # would be a column that silently never runs. + if isinstance(raw.get('automation'), dict): + try: + _flow_ids = frozenset((ctx.table.st if ctx.table is not None + else _tops(ctx).st).get('automations') or {}) + except Exception: + _flow_ids = frozenset() + auto = _ag2._clean_automation(raw.get('automation'), valid_keys, + flow_ids=_flow_ids) + if auto is None: + return _refuse( + ctx, 'automation_flow', + 'the automation this column names does not exist', key) + field['automation'] = auto + # ⭐ WAVE-27 item 13 (R13): the code column's language. OPTIONAL, like the + # `automation` bag above and unlike `formula` — an unconfigured code column is a + # legitimate intermediate state (create it, pick the language in the gear), and it + # stores and renders as plain text meanwhile. `_clean_code` never refuses over an + # unknown language; it falls back to `plain`, because the value chooses a + # HIGHLIGHTER, and dropping a user's column to punish a typo in one would be the + # disproportionate half of fail-closed. + if ftype == 'code' and isinstance(raw.get('code'), dict): + code_bag = _ag2._clean_code(raw.get('code')) + if code_bag: + field['code'] = code_bag + fmt = _ag2._clean_format(raw.get('format'), ftype) + if fmt: + field['format'] = fmt + else: + # ⛔ THE FALL-THROUGH, AND IT IS THE WIDEST OF THE EIGHT: the key is neither a base + # contract field (a note/format override) nor a `custom_`/`measure_` key, so there is + # no stratum it could be written to. A client sending an unprefixed new key lands + # here — and until D-291 that answered exactly like a save. + return _refuse( + ctx, 'unknown_key', + "a new column's key must start with 'custom_'; a base column can only take a " + 'note or a format override', key) + # Wave-5 item 1 — createdBy + permissions on the CREATED strata (custom_/measure_). + # `createdBy` is stamped HOST-SIDE at create and preserved on every later write: the + # client never supplies it, so a browser cannot claim someone else's field. A + # permissions CHANGE is accepted only from the creator or an admin; a legacy field + # with no createdBy is admin-only (fail closed, both directions). The table workspace + # is per-user today, so this wall becomes load-bearing when sharing arrives — but the + # admin path and the legacy path are enforceable (and NC'd) right now. + needs_values = False + refused_perms = False + if key.startswith('custom_') or key.startswith(_ag2.MEASURE_FIELD_PREFIX): + prior = ws.get('fields', {}).get(key) + prior = prior if isinstance(prior, dict) else None + if prior is None: + field['createdBy'] = uname + # Wave-6 item 9: scope is chosen at CREATE, on the cohort page only — + # 'cohort' marks the field cohort-specific; 'global' or absent stays + # unscoped, so an old bundle that sends no scope keeps today's behavior. + if scope_key == 'cohort' and raw.get('scope') == 'cohort': + field['scope'] = 'cohort' + else: + if isinstance(prior.get('createdBy'), str) and prior['createdBy'].strip(): + field['createdBy'] = prior['createdBy'] + # Scope is PRESERVED like createdBy: the client copy never moves an + # existing field between scopes. + if prior.get('scope') == 'cohort': + field['scope'] = 'cohort' + want = _ag2._clean_permissions(raw.get('permissions')) + have = _ag2._clean_permissions((prior or {}).get('permissions')) + owner = field.get('createdBy') + if want is not None and (admin or (owner and owner == uname)): + field['permissions'] = want + else: + if have is not None: + field['permissions'] = have + # Asked for a permissions change and did not get it: the client's + # optimistic copy is a lie that must be repainted (wave-6 item 3). + refused_perms = want is not None and want != have + # A NEW measure column, or an existing one whose window moved: the VALUES + # are host-computed, so the next render genuinely differs. + if key.startswith(_ag2.MEASURE_FIELD_PREFIX): + needs_values = prior is None or prior.get('measure') != field.get('measure') + # Canonical names are reserved outside the durable workspace. Persisted field names are + # allocated by TableStore from the transaction's current state, not this stale snapshot. + _workspace_field_keys = set((ws.get('fields') or {})) + _reserved_field_names = [ + value.get('label') for candidate_key, value in field_by_key.items() + if (candidate_key != key and candidate_key not in _workspace_field_keys + and isinstance(value, dict)) + ] + if _store_of(ctx).available(): + field = (_tops(ctx).save_field( + uname, field, reserved_names=_reserved_field_names, + correction_id=event_id) or field) + else: + _session_ready() + _field_names = list(_reserved_field_names) + _field_names.extend( + value.get('label') + for candidate_key, value in ws['fields'].items() + if candidate_key != key and isinstance(value, dict) + ) + requested_label = ' '.join( + str(field.get('label') or 'Untitled').split())[:120].rstrip() + field.pop('labelCorrectedFrom', None) + field.pop('labelCorrectionId', None) + field['label'] = _unique_name(requested_label, _field_names) + if field['label'] != requested_label and event_id: + field['labelCorrectedFrom'] = requested_label + field['labelCorrectionId'] = str(event_id)[:180] + # ⚠ W29-T83 — THE SAME PREDICATE the store uses, called rather than restated. This + # fallback carried its own shorter copy (note only), so a format or measure override + # survived one path and not the other, and `agg` survived neither. + if _tstore.source_override_is_empty(field): + ws['fields'].pop(key, None) + else: + ws['fields'][key] = field + if needs_values or refused_perms: + return True + # THE NO-BLIP RETURN (wave-6 item 3): an accepted def that echoes what the client + # already renders needs no rerun — see _field_echo_equal and the docstring's law. + return not _field_echo_equal(field, raw) + + if kind in ('cohort_add', 'cohort_remove', 'cohort_rename', 'cohort_delete'): + # Cohort membership/lifecycle edits from the grid's cohort panel (wave-2 item 2 — + # replaces the retired Manage popover, so rename/delete keep a surface). Fail-closed: + # only the caller's OWN cohorts (all_for), and member pids are intersected with the + # caller's permitted pool — the same guard add_to_list rides. + if not _store_of(ctx).available(): + return False + sets = _cohorts(ctx) # R9: this DATABASE's lists, never another's + cohort_id = str(event.get('cohortId') or '').strip()[:120] + if not cohort_id or cohort_id not in sets.all_for(uname): + return False + try: + if kind == 'cohort_rename': + name = str(event.get('name') or '').strip()[:120] + if not name: + return False + sets.rename(uname, cohort_id, name) + elif kind == 'cohort_delete': + sets.delete(uname, cohort_id) + else: + raw_pids = event.get('pids') + if not isinstance(raw_pids, list): + return False + pids = [p for p in raw_pids if isinstance(p, int) and p in allowed_pids] + if not pids: + return False + if kind == 'cohort_add': + sets.add_members(uname, cohort_id, pids) + else: + sets.remove_members(uname, cohort_id, pids) + except ValueError as e: + _tel.error('cohort:panel-edit', e) + return False + # True: the lists payload (names, membership, counts) must reflect this on the next + # render — the panel is showing server truth, not local optimism. + return True + + if kind in ('doc_add', 'doc_delete', 'doc_fetch'): + # DOCUMENTS on a row (owner item 12, contract C5). Metadata in the store key + # `customer_docs`; the BYTES live at their own path in the tenant dataset, never in a + # JSON blob — see core.store.upload_bytes for why that distinction is load-bearing. + # + # ⭐ WAVE 19 / R9 — item 1's SILENT THIRD CASE, and the one nobody reported because the + # React shell cannot reach it yet (`payload.docs` is set only by app.py). Both homes are + # keyed by pid and both were topic-blind: the metadata bucket AND the byte path + # `docs//…`. Two topics' pid spaces overlap by construction (a CRC32 hash lands + # anywhere an Odoo partner id can), so a product's file could occupy the path of a + # customer's — one upload silently shadowing another tenant-visible document. Per-topic + # bucket AND per-topic prefix; the customer topic keeps both of its shipped names. + import base64 as _b64c + import re as _re_mod + if not _store_of(ctx).available(): + return False + docs_key = _docs_key(ctx) + docs_dir = 'docs' if docs_key == _DOCS_KEY else f'docs/{docs_key[:-5]}' + pid = event.get('pid') + doc_id = str(event.get('docId') or '').strip()[:80] + # The pid wall is the SAME one every other row-scoped event rides: a caller may only + # touch rows inside their own permitted pool. Without it, documents would be the + # one surface where a scoped agent could read another book's rows. + if not isinstance(pid, int) or pid not in allowed_pids or not doc_id: + return False + # ⭐ WAVE 25 (R6b / D-16) — THE DOCUMENTS FAMILY WAS THE LAST MODULE-GLOBAL READER HERE. + # Every line below said `store.…`, so an R2 tenant's document metadata (and its bytes) + # landed in tenant #0's repo. Wave 19 made this family per-TOPIC; per-TENANT is the axis + # it was still missing, and the two failures look identical from the app. + _st_docs = _store_of(ctx) + meta_all = _st_docs.get(docs_key) or {} + row = list((meta_all.get(str(pid)) or [])) + + if kind == 'doc_add': + raw_b64 = event.get('data_b64') + name = str(event.get('name') or 'document')[:200] + if not isinstance(raw_b64, str) or not raw_b64: + return False + try: + blob = _b64c.b64decode(raw_b64, validate=True) + except Exception: + return False # not base64 -> not a file we will store + # Re-check the DECODED size host-side. The client checks it too, but a client check + # is a courtesy: this is the number that bounds what the tenant's repo can be made + # to hold by anyone who can reach the endpoint. + if not blob or len(blob) > _DOC_MAX_BYTES: + return False + if len(row) >= _DOC_MAX_PER_ROW or any(d.get('id') == doc_id for d in row): + return False + safe = _re_mod.sub(r'[^A-Za-z0-9._-]', '_', name)[:80] or 'file' + path = f'{docs_dir}/{pid}/{doc_id}_{safe}' + try: + _blobs(ctx).upload_bytes( + path, blob, message=f'doc {doc_id} for {scope_key} {pid}') + except Exception as _ue: + _tel.error('docs:upload', _ue) + return False + row.append({'id': doc_id, 'name': name, + 'mime': str(event.get('mime') or 'application/octet-stream')[:120], + 'size': len(blob), 'by': uname, + 'ts': dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), + 'path': path}) + elif kind == 'doc_delete': + hit = next((d for d in row if d.get('id') == doc_id), None) + if not hit: + return False + # THE WALL: the uploader or an admin. Enforced here regardless of what the client + # chose to render — `canDelete` in the payload only hides a button. + if hit.get('by') != uname and not admin: + return False + _blobs(ctx).delete_path(hit.get('path') or '') + row = [d for d in row if d.get('id') != doc_id] + else: # doc_fetch + hit = next((d for d in row if d.get('id') == doc_id), None) + if not hit: + return False + blob = _blobs(ctx).download_bytes(hit.get('path') or '') + if blob is None: + return False + # The bridge is a fire-and-forget event LOG with no response channel, so the answer + # is PARKED on the Result and rides the next render's payload (C5). + ctx.out.doc = { + 'pid': pid, 'docId': doc_id, 'name': hit.get('name') or 'document', + 'mime': hit.get('mime') or 'application/octet-stream', + 'data_b64': _b64c.b64encode(blob).decode('ascii')} + return True # re-render to deliver it + + def _write(cur): + cur[str(pid)] = row + return cur + # ⚠ `docs_key`, NOT `_DOCS_KEY`. The read above and the byte path were both made + # per-topic and this line was not — so a product document's BYTES landed correctly under + # `docs/product/…` while its METADATA went into `customer_docs` beside the customer rows, + # keyed by a CRC32 hash. Found by `verify_scopes.py`'s own leg on its first run, which is + # the argument for asserting the ABSENCE from the legacy bucket and not just the presence + # in the new one: every "it saved" check here was green. + _st_docs.update(docs_key, _write, flush='async') + return True + + if kind in ('folder_create', 'folder_rename', 'folder_delete', 'folder_duplicate', + 'item_move', 'folder_reorder'): + # FOLDERS over the Views / Cohorts sidebars (owner item 11, contract C4). All five + # events touch ONE home — the table workspace's `folders` + `itemFolders` — so a cohort + # can be filed without the cohort STORE learning about folders at all. + import aios_grid as _agf + if not _store_of(ctx).available(): + return False + surface = str(event.get('surface') or '') + if surface not in _agf.FOLDER_SURFACES: + return False + # The ids this user may actually file: their own views, their own cohorts. Fail-closed — + # an item_move naming somebody else's cohort is dropped by clean_item_folders anyway, + # but refusing here means we never write it in the first place. + def _own_ids(sfc, cur_ws): + if sfc == 'views': + return set((cur_ws.get('views') or {})) + return set(_cohorts(ctx).all_for(uname)) + + def _mutate(cur_ws): + folders = _agf.clean_folders(cur_ws.get('folders')) + placed = dict(cur_ws.get('itemFolders') or {}) + lst = list(folders.get(surface) or []) + # ⚠ the wire field is `folderId` on EVERY folder event (CLIENT's types.ts is the + # built contract; the split doc's first draft said `id`). Converged here rather + # than asking the client to re-emit — see the C4 reconciliation note in the doc. + fid = str(event.get('folderId') or '').strip()[:80] + if kind == 'folder_create': + name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME] + if not fid or not name or len(lst) >= _agf.MAX_FOLDERS: + return None + if any(f['id'] == fid for f in lst): + return None + row = {'id': fid, 'name': name, 'order': len(lst)} + icon = _agf.clean_folder_icon(event.get('icon')) # wave-9 I15 (C5) + if icon: + row['icon'] = icon + lst.append(row) + elif kind == 'folder_rename': + name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME] + hit = next((f for f in lst if f['id'] == fid), None) + if not hit or not name: + return None + hit['name'] = name + # wave-9 I15 (C5): the rename pane is also where the icon is changed. An event + # that carries NO icon key leaves the existing one alone (a rename must not + # silently strip a chosen icon); an explicit null clears it back to default. + if 'icon' in event: + icon = _agf.clean_folder_icon(event.get('icon')) + if icon: + hit['icon'] = icon + else: + hit.pop('icon', None) + elif kind == 'folder_delete': + if not any(f['id'] == fid for f in lst): + return None + lst = [f for f in lst if f['id'] != fid] + # CONTENTS MOVE TO ROOT, never delete. Dropping the placements is exactly that: + # an item with no placement renders at the top level. + placed[surface] = {k: v for k, v in (placed.get(surface) or {}).items() + if v != fid} + elif kind == 'folder_reorder': + # ⭐ WAVE 20 (owner item 19, contract C-FOLDER-REORDER) — the user's own folder + # ORDER in the rail. Views could already be dragged between folders; the folders + # themselves could not be dragged past each other. + # + # THE WIRE CARRIES THE FULL ORDER, NEVER A DELTA, and that is the contract's + # doing: a partial list cannot say where an UNNAMED folder went, so a "moved B + # after C" message would leave every other folder's position to be re-derived by + # two sides that can disagree. The client sends the list it is looking at. + # + # ⚠ IDS THIS USER DOES NOT OWN ARE IGNORED, NOT REJECTED. A stale tab can emit an + # order containing a folder that has since been deleted; dropping the whole event + # would make the rail un-reorderable until reload, while dropping the unknown id + # reorders exactly what still exists. Anything the payload omits keeps its + # relative position AFTER the named ones — an order that silently deleted a + # folder it merely failed to mention would be a data loss dressed as a sort. + order = [str(i).strip()[:80] for i in (event.get('order') or [])] + if not order: + return None + known = {f['id']: f for f in lst} + seen, ranked = set(), [] + for fid_ in order: + if fid_ in known and fid_ not in seen: + seen.add(fid_) + ranked.append(known[fid_]) + ranked.extend(f for f in lst if f['id'] not in seen) + if len(ranked) != len(lst): # cannot happen; refuse rather than truncate + return None + for i, f in enumerate(ranked): + f['order'] = i + lst = ranked + elif kind == 'folder_duplicate': + new_id = str(event.get('newId') or '').strip()[:80] + src = next((f for f in lst if f['id'] == fid), None) + if not src or not new_id or len(lst) >= _agf.MAX_FOLDERS: + return None + if any(f['id'] == new_id for f in lst): + return None + lst.append({'id': new_id, 'name': f"{src['name']} copy"[:_agf.MAX_FOLDER_NAME], + 'order': len(lst)}) + # ⚠ The folder's CONTENTS are duplicated by the client emitting the ordinary + # per-item duplicate events it already owns (view_upsert with a fresh id, the + # cohort copy path) and then item_move-ing them here. Copying views/cohorts + # inside this handler would be a SECOND implementation of "duplicate an item", + # and the two would drift the first time either one changed. + else: # item_move + item_id = str(event.get('itemId') or '').strip()[:120] + target = event.get('folderId') + if not item_id or item_id not in _own_ids(surface, cur_ws): + return None + cur = dict(placed.get(surface) or {}) + if target is None: + # ⚠ "UNFILE" — the client says nothing about where it went. Popping is right + # here and always was: no placement = render at the top level, and that is the + # state a brand-new item is already in. + cur.pop(item_id, None) # back to the root + elif str(target) == _agf.ROOT_PLACEMENT: + # ⭐⭐ WAVE 32 · OWNER ITEM 20 (`W32-T27`, ASK C-16) — "FILED AT ROOT", STORED. + # + # ⛔ THE THIRD CASE, AND ITS ABSENCE IS THE WHOLE DEFECT. Popping (above) and + # this branch produce the SAME rendering for an ordinary view, which is why + # nobody missed it for nine waves — but they are different FACTS, and for a + # SHARED view the difference is the entire feature: "never filed" sends it to + # the Shared group, "the receiver dragged it out" must not. Absence cannot hold + # two facts, so the second one gets a value. + # + # ⛔ AND IT IS DELIBERATELY EXEMPT FROM THE FOLDER-EXISTS TEST BELOW. This id + # names no folder by design; running it through `any(f['id'] == tid ...)` would + # refuse every root filing, which is the same silent no-op this ticket exists + # to remove. `aios_grid.clean_item_folders` carries the matching exemption on + # the READ side — the two are one change (see its note). + cur[item_id] = _agf.ROOT_PLACEMENT + else: + tid = str(target)[:80] + if not any(f['id'] == tid for f in lst): + return None + cur[item_id] = tid + placed[surface] = cur + folders[surface] = lst + clean = _agf.clean_folders(folders) + return clean, _agf.clean_item_folders( + placed, clean, + {sfc: _own_ids(sfc, cur_ws) for sfc in _agf.FOLDER_SURFACES}) + + # ⚠ NO allowed_pids ON PURPOSE — this is app.py's bare `_cl_table_workspace(uname)` + # call at the old :6268, preserved exactly. Omitting the pool drops memberPids from + # any foreign shared view (fail-closed), which is harmless here because the folder + # legs read only view IDS and the folder maps. Passing the pool would be a behavior + # change inside a pure move. + _res = _mutate(table_workspace(ctx, consume_corrections=False)) + if _res is None: + return False # refused: nothing is written + _tops(ctx).save_folders(uname, _res[0], _res[1]) + # True: the sidebars render from server state (which folder holds what), not from local + # optimism — the same contract the cohort panel's membership edits ride. + return True + + if kind == 'view_reorder': + # ⭐ WAVE-27 item 5 (contract C7) — the user drags views up and down the rail. + # + # THE WIRE CARRIES THE FULL ORDER, NEVER A DELTA — the `folder_reorder` contract above, + # verbatim, and for its reason: a partial list cannot say where an unnamed view went. + # + # ⚠ NO OWNERSHIP FILTER, and that is the difference from `item_move`. Filing a view into + # a folder writes a placement keyed by a view this user must own; ARRANGING the rail is + # about the reader's own screen, and the rail legitimately contains views this user does + # not own — the system view, cohort projections, R8's injected Overview, and anything + # SHARED with them. Refusing ids they do not own would make exactly those un-draggable, + # which is the half of sharing people notice. + # + # ⚠ Unresolvable ids are harmless by construction rather than by filtering here: the READ + # side (`aios_grid.views_from_defs`) ranks by this list and appends anything unranked, so + # an id naming a deleted view simply never matches. Validating against today's view set + # at WRITE time would also drop an id whose view is merely not visible in this request. + raw_order = event.get('order') + if not isinstance(raw_order, list): + return False # a non-list order is malformed, not an empty arrangement + order, seen_v = [], set() + for vid in raw_order[:MAX_VIEW_ORDER]: + vid = str(vid or '').strip()[:120] + if vid and vid not in seen_v: + seen_v.add(vid) + order.append(vid) + # ⚠ THE SESSION FALLBACK IS `record_layout`'s, COPIED DELIBERATELY, and it is not just a + # testability convenience. Both keys are per-user PRESENTATION state for one surface, and + # both should keep working while the store is briefly unavailable — a rail that refuses + # to reorder because a write failed is a frozen control with no explanation. It also + # makes the round trip reachable from `verify_grid_events`, which is headless and where + # `store.available()` is False: the store-backed branch of every OTHER stratum writer is + # covered only by the live pass, and this one did not have to be. + if _store_of(ctx).available(): + _tops(ctx).save_view_order(uname, order) + else: + _session_ready() + if order: + ws['viewOrder'] = order + else: + ws.pop('viewOrder', None) # absent = server order, never a stored [] + # True: the rail renders from server state, like the folder sidebars it sits beside. + return True + + if kind == 'field_delete': + # Delete a USER-CREATED column outright (owner gap closed 2026-07-27). The prefix IS + # the permission: only the created strata (`custom_` overlay fields, `measure_` formula + # columns) are deletable — a base field's key fails the prefix test no matter what the + # browser claims, so the table's contract cannot be deleted from the client. The store + # scrubs the field's stored cell values with it (delete_table_field); views naming the + # key self-heal on their next autosave, the rule every stale colId already rides. + key = str(event.get('key') or '')[:80] + import aios_grid as _ag3 + if not (key.startswith('custom_') or key.startswith(_ag3.MEASURE_FIELD_PREFIX)): + return False + if _store_of(ctx).available(): + _tops(ctx).delete_field(uname, key) + else: + _session_ready() + ws['fields'].pop(key, None) + for _row in ws['overlays'].values(): + if isinstance(_row, dict): + _row.pop(key, None) + # Wave-6 item 3: the client dropped the column optimistically; the store scrub is + # invisible to the next render (the values leave WITH the column). + return False + + if kind == 'choice_rename': + # ⭐ WAVE 20 (owner item 15, contract C-RENAME) — RENAMING AN OPTION CARRIES ITS VALUES. + # + # Owner: *"if I change the option from 'Aaron' to 'Aron' the existing field that has the + # Selected 'Aaron' doesn't change to 'Aron'."* Correct, and it is a data-model fact rather + # than an oversight: a select cell stores the option's LABEL, so renaming the option in + # the field definition orphans every cell holding the old string. They do not render as + # the new name; they render as a value that is no longer an option. + # + # ⛔ THE WIRE CARRIES AN EXPLICIT {from, to} MAPPING, NEVER A DIFF OF THE CHOICE LISTS. + # Diffing is what makes this unsafe: rename A→B while also DELETING C and adding D, and a + # differ sees two removals and two additions with no way to know which pairs up. It would + # cheerfully rewrite every C cell to D. The client knows which row of its editor the user + # typed in; that intent travels, and nothing here has to guess it. + # + # THREE PLACES HOLD THE OLD STRING, and missing any one is its own visible bug: + # 1. the CELLS (`overlays`) — the owner's complaint; + # 2. the FIELD's own `choices` list — else the picker still offers the old name; + # 3. every VIEW that FILTERS or COLOURS BY that value — a saved view filtering + # `Agent is "Aaron"` silently matches NOTHING the moment the value moves, which is + # the failure that looks like data loss ([[aios-shared-views]]). + key = str(event.get('key') or '')[:80] + pairs = [] + for r in (event.get('renames') or ()): + if not isinstance(r, dict): + continue + a, b = str(r.get('from') or '')[:200], str(r.get('to') or '')[:200] + if a and b and a != b: + pairs.append((a, b)) + field = field_by_key.get(key) + if not key or not pairs or not isinstance(field, dict): + return False + if str(field.get('type') or '') not in ('select', 'multiselect', 'status'): + return False + # Only a field this caller may edit. A rename is a write to the field CONTRACT, so it + # rides the same wall `field_upsert` does rather than a looser one of its own. + if key in (ctx.hidden_keys or frozenset()): + return False + mapping = dict(pairs) + + def _rename_in(ws_): + fields_ = ws_.setdefault('fields', {}) + fld = fields_.get(key) + if isinstance(fld, dict) and isinstance(fld.get('choices'), list): + seen, out = set(), [] + for c in fld['choices']: + if isinstance(c, dict): + nm = mapping.get(str(c.get('name') or ''), None) + if nm is not None: + c = {**c, 'name': nm} + ident = str(c.get('name') or '') + else: + c = mapping.get(str(c), str(c)) + ident = str(c) + # A rename ONTO an existing option MERGES rather than duplicating it: the + # user typed a name that already exists, and two identical options is a + # picker bug. Their cells merge too, which is what the value rewrite below + # does anyway. + if ident in seen: + continue + seen.add(ident) + out.append(c) + fld['choices'] = out + fields_[key] = fld + for row in (ws_.get('overlays') or {}).values(): + if not isinstance(row, dict) or key not in row: + continue + val = row[key] + if isinstance(val, list): # multiselect + merged, out2 = set(), [] + for v in val: + nv = mapping.get(str(v), str(v)) + if nv not in merged: + merged.add(nv) + out2.append(nv) + row[key] = out2 + elif isinstance(val, str) and val in mapping: + row[key] = mapping[val] + # Views: filter rule values + colour-by keys naming the old option. + for view in (ws_.get('views') or {}).values(): + if not isinstance(view, dict): + continue + cfg = view.get('config') if isinstance(view.get('config'), dict) else view + + def _walk(nodes): + for n in nodes or (): + if not isinstance(n, dict): + continue + if isinstance(n.get('children'), list): + _walk(n['children']) + continue + if str(n.get('colId') or '') != key: + continue + v = n.get('value') + if isinstance(v, str) and v in mapping: + n['value'] = mapping[v] + elif isinstance(v, list): + n['value'] = [mapping.get(str(x), x) for x in v] + + _walk(cfg.get('filters') or []) + # ⚠ `colorBy` NEEDS NO REWRITE, and the reason is worth stating because the + # opposite looks obvious: it stores a COLUMN KEY (a string, validated against + # `valid_keys` at `view_upsert`), not a {value: colour} map. Per-value colours + # ride the CHOICE OBJECTS themselves, which this function renames in place — + # `{**c, 'name': nm}` keeps every other key on the choice, colour included — so + # a renamed option keeps its swatch for free. An earlier version of this handler + # tried to rewrite `colorBy['rules']` and crashed the whole event pipeline with + # `TypeError: unhashable type: 'dict'` on the FIRST view it touched. + return ws_ + + if _store_of(ctx).available(): + _tops(ctx).rename_choice_values(uname, _rename_in) + else: + _session_ready() + _rename_in(ws) + # True = the host must re-read: the client dropped its optimistic copy of the CHOICES, + # but the rewritten cells and view filters only exist server-side until the next payload. + return True + + if kind == 'field_duplicate': + # Wave-5 item 1. The DESTINATION key is client-generated (the established pattern — + # the client must know it synchronously to place the clone right of the source), and + # both keys must sit in the SAME created stratum: base odoo fields offer no Duplicate, + # because duplicating the source-of-truth into an editable copy would need a + # value-snapshot mechanism nobody asked for. Overlay VALUES are copied only for + # `custom_` sources (a measure clone recomputes; a formula clone re-evaluates). + # The duplicator becomes the clone's creator — it is a NEW field they made. + src_key = str(event.get('sourceKey') or '')[:80] + new_key = str(event.get('key') or '')[:80] + import aios_grid as _ag5 + prefix = ('custom_' if src_key.startswith('custom_') + else _ag5.MEASURE_FIELD_PREFIX + if src_key.startswith(_ag5.MEASURE_FIELD_PREFIX) else None) + if (prefix is None or not new_key.startswith(prefix) or new_key == src_key + or new_key in field_by_key): + return False + src = ws.get('fields', {}).get(src_key) + if not isinstance(src, dict): + return False + clone = dict(src) + clone['key'] = new_key + label = str(event.get('label') or '').strip()[:120] + requested_label = label or (str(src.get('label') or 'Untitled')[:110] + ' copy') + _workspace_field_keys = set((ws.get('fields') or {})) + _reserved_field_names = [ + value.get('label') for candidate_key, value in field_by_key.items() + if (candidate_key != new_key and candidate_key not in _workspace_field_keys + and isinstance(value, dict)) + ] + clone['label'] = requested_label + clone['createdBy'] = uname + # Wave-6 item 9: the clone inherits the source's scope via dict(src); on the cohort + # page the duplicate surface may explicitly place it instead (same create-time rule + # as field_upsert — a page that is not the cohort page cannot mint a cohort field). + if scope_key == 'cohort' and event.get('scope') in ('cohort', 'global'): + if event['scope'] == 'cohort': + clone['scope'] = 'cohort' + else: + clone.pop('scope', None) + if _store_of(ctx).available(): + clone = (_tops(ctx).duplicate_field( + uname, src_key, new_key, clone, + reserved_names=_reserved_field_names, + correction_id=event_id) or clone) + else: + _session_ready() + _field_names = list(_reserved_field_names) + _field_names.extend( + value.get('label') + for candidate_key, value in ws['fields'].items() + if candidate_key != new_key and isinstance(value, dict) + ) + clone.pop('labelCorrectedFrom', None) + clone.pop('labelCorrectionId', None) + clone['label'] = _unique_name(requested_label, _field_names) + if clone['label'] != requested_label and event_id: + clone['labelCorrectedFrom'] = requested_label + clone['labelCorrectionId'] = str(event_id)[:180] + ws['fields'][new_key] = clone + if src_key.startswith('custom_'): + for _row in ws['overlays'].values(): + if isinstance(_row, dict) and src_key in _row: + _row[new_key] = _row[src_key] + # Wave-6 item 3: True only when host-held VALUES must flow back — copied overlay + # cells (custom_ sources) or host-computed measure values. formula/created_time + # clones compute client-side, so the optimistic insert is already complete. + label_changed = clone['label'] != requested_label + return label_changed or src.get('type') not in ('formula', 'created_time') + + if kind == 'add_to_list': + # "Add to list" (owner item 6): a view's current matches become fixed cohort members. + # + # ⚠ `pids` arrives from the BROWSER. It is intersected with `allowed_pids` — the pool this + # user may actually see — before anything is stored. The client computing the set is a + # correctness decision (it is the only engine that already knows the answer, and asking a + # second engine the same question is the drift verify_filter_engine.py exists to prevent); + # it is NOT a trust decision. + raw_pids = event.get('pids') + if not isinstance(raw_pids, list): + return False + pids = [p for p in raw_pids if isinstance(p, int) and p in allowed_pids] + if not pids: + return False + cohort_id = str(event.get('cohortId') or '').strip()[:120] + name = str(event.get('name') or '').strip()[:120] + if not _store_of(ctx).available(): + return False + # ⭐ WAVE 19 / R9 — THE BUG THIS RULING NAMES. This branch is where a list is BORN, and it + # named `cohort_mod` directly: a list created from the Product surface was written into + # `customer_cohorts` with CRC32 product pids in it, then rendered on the Customer page as + # a list of customers nobody recognised. The set now belongs to the database it was + # created on, and `ctx.scope_key` is the only thing that decides which. + sets = _cohorts(ctx) + try: + if not cohort_id: + if not name: + return False + cohort_id = sets.create(uname, name, pids) + else: + if cohort_id not in sets.all_for(uname): + return False # not this user's cohort — fail closed, do not create + sets.add_members(uname, cohort_id, pids) + except ValueError as e: + _tel.error('cohort:add_to_list', e) + return False + # True: the Cohort page's list and counts must reflect this, and the toast below only + # renders on a fresh run. + # + # ⚠ The NOUN is the TOPIC's, not the literal "customer" this line carried since wave 2. + # "Added 12 customers" over a set of SKUs is the surface stating, in words, that the list + # went somewhere it did not — the user-visible half of the same leak. + _noun = sets.noun + ctx.out.toast = ( + f"Added {len(pids):,} {_noun}{'' if len(pids) == 1 else 's'} to " + f"{name or 'the list'}.") + return True + + if kind == 'overlay_patch': + pid = event.get('pid') + if not isinstance(pid, int) or pid not in allowed_pids: + return False + updates = {} + refused = False + stage_moved = False + row_events = [] # C3 (wave 22): admitted writes, emitted AFTER they apply + raw_updates = event.get('updates') if isinstance(event.get('updates'), dict) else {} + for key, value in raw_updates.items(): + if key not in overlay_keys or not isinstance(value, (str, int, float)): + refused = True + continue + # C-PERM: a field this caller's permissions HIDE cannot be written, only refused. + # `overlay_keys` asks whether the column exists, not whether this person may touch + # it — so without this line a restricted user who knows a hidden key could PATCH a + # value they are not allowed to read back. Refused LOUDLY (repaint) so the client + # reverts rather than showing a value the store never took. + if key in (ctx.hidden_keys or frozenset()): + refused = True + continue + # Wave-5 item 1: `permissions.edit` is enforced HERE, per key — the client hiding + # its editor is courtesy, this is the wall. Fail-closed: a restricted field with + # no readable createdBy admits only admins. + perms = (field_by_key.get(key) or {}).get('permissions') or {} + edit = perms.get('edit') + if edit == 'admins' and not admin: + refused = True + continue + if edit == 'creator' and not ( + admin or (field_by_key.get(key) or {}).get('createdBy') == uname): + refused = True + continue + # C5-AUTOFIELD (wave 18): an automation cell is MACHINE-WRITTEN. The client marks + # the type read-only (READONLY_CELL_TYPES) but that is courtesy — this is the wall. + # Without it the UI hides the editor while the API still takes a PATCH, which is + # exactly the shape a "read-only" type must not have. + if (field_by_key.get(key) or {}).get('type') == 'automation': + refused = True + continue + # C2 (wave 22): a STAGE FIELD is engine-written EXCEPT at a review gate, where a + # human may advance a card to a member of the review stage's `next` and nothing + # else. The law rides the field definition (`automation.humanMoves` / `arrive`, + # stamped by the engine that derives the stages) because this module must not + # import the engine — the engine states the law, this door enforces it + # mechanically. An ADMITTED move writes THROUGH to the definition row, the shared + # truth the engine and every viewer read: parked in this user's overlay stratum it + # would be a board position only its author could see. Everything else about the + # field is refused — the same wall the automation-cell type gets, extended, not + # replaced. + if str(ctx.scope_key or '').startswith('ut_'): + import core.user_tables as _ut_mod + _tdef = _ut_mod.get(ctx.scope_key, st=ctx.table.st) if ctx.table is not None \ + else None + _fdef = next((f for f in ((_tdef or {}).get('fields') or []) + if f.get('key') == key), None) + # C7 (wave 22): a METRIC cell is engine-computed from the master series — a + # human write would be an invented measurement, refused the way an automation + # cell is (the wall, not the courtesy). + # ⭐ 2026-08-07 — EXTENDED TO THE RELATIONAL PAIR, and read through the field + # layer's own predicate rather than re-tested here. A `rollup` cell is an + # aggregate the server computes; a DERIVED `link` cell is a join the server + # resolves. Both would be overwritten by the next refresh anyway, so accepting + # the write would not merely be wrong — it would look like it worked and then + # silently revert, which is worse than a refusal. + # ⚠ An ORDINARY (user-picked) link is deliberately NOT refused: `is_computed_cell` + # answers per FIELD, not per type, because one kind is read-only in one mode and + # editable in the other. + if _ut_mod.is_computed_cell(_fdef): + refused = True + continue + _auto = (_fdef or {}).get('automation') + # An ordinary Link is a shared database relationship. Validate its selected + # target ids and write it through to the definition row; a personal overlay + # would be invisible to reciprocal links and Rollups. + if (_fdef or {}).get('type') == 'link': + _linked = _ut_mod.patch_link_cell( + ctx.scope_key, pid, key, value, st=ctx.table.st) + if _linked is None: + refused = True + continue + if _linked != str(value): + refused = True + row_events.append((key, _linked)) + continue + # ⭐ WAVE 25 (C3 + amendment C3-A1, owner rulings R7/R6) — THE PROFILE CELL. + # Two things happen here and neither can live anywhere else: + # + # 1. IT WRITES THROUGH TO THE DEFINITION ROW, the stage field's mechanism two + # branches up, for the identical reason. MEASURED before it was written: an + # ordinary `ut_*` cell edit lands in the TYPIST'S OWN overlay stratum, and + # the automation engine reads `t['rows']` — so a handle somebody typed + # would be invisible to the enrich action and to every other viewer of the + # same database. The flag would be decorative. + # 2. BLANKING IT CLEARS THAT ROW'S PRESET CELLS IN THE SAME WRITE (R6) — + # `patch_profile_cell` does both inside ONE store update, because a blank + # that commits without its clear leaves a row with no handle and a live + # follower count, i.e. stale numbers attributed to nobody. + # + # ⛔ NOT VIA THE EMIT GUARD (HARD RULE 5 / D-40). Nothing here emits; this is + # `user_tables`' own write door, reached from the branch that already knows the + # scope is `ut_`. ⚠ `editRole` is deliberately NOT consulted: it governs the + # SCHEMA, and C3 is explicit that a profile field's VALUES stay fully editable — + # that is the whole point of item 5a. + if isinstance((_fdef or {}).get('profile'), dict): + _pw = _ut_mod.patch_profile_cell( + ctx.scope_key, pid, key, value, st=ctx.table.st) + if _pw is None: + # Not a handle we can enrich. Refused LOUDLY so the client repaints to + # the stored truth instead of showing a value the store never took. + refused = True + continue + # A normalisation (`@Name` -> `name`, a pasted URL -> its handle) is a real + # difference from what the client is drawing, so it must repaint. An + # untouched value echoes and stays on the no-blip path. + if _pw['handle'] != str(value): + refused = True + if _pw['cleared']: + stage_moved = True # cells the client never typed changed: re-read + row_events.append((key, _pw['handle'])) + continue + # ⭐ OWNER ITEM 4 (2026-08-06) — A COLUMN AN AUTOMATION FILLS IS NOT A COLUMN A + # PERSON TYPES IN. Owner, verbatim: *"I should not be able to edit any of the + # Pre-set fields on instagram automation, we talked about this extensively."* + # + # ⛔ IT HAD NEVER BEEN ENFORCED ANYWHERE. `editRole: 'admins'` rides on every + # preset column and reads exactly like this wall, which is why it was believed to + # be one — but it governs the SCHEMA (`user_tables.py:535`): who may rename or + # retype the column. The VALUES stayed open to anyone with grid access, and the + # owner typed into one to prove it. The metric branch above is the same rule for + # the same reason, written for a different column kind; this is its sibling. + # + # ⚠ REACHED ONLY AFTER stage AND profile have `continue`d, and the order is the + # rule rather than an accident: both of those columns carry the identical tag and + # both are cells a human is SUPPOSED to drive — the stage because moving a card + # IS the decision, the profile because it is where enrichment gets its subject. + # Refusing on the tag alone would freeze every kanban. + # + # `flowId` and not merely `automation`: a user-configured `automation` COLUMN + # carries its own gear bag (kind/source/urlField/settings) and no flowId, and it + # is not this ruling's subject. + if isinstance(_auto, dict) and (_auto.get('flowId') or _auto.get('preset')): + refused = True + continue + # ⭐ WAVE 23 (C7) — the json wall, BEFORE the generic 10 000-char truncation, which + # would otherwise cut a document in half and store the halves as valid text. A json + # column promises its readers a parseable document; a write that is not one is + # REFUSED with the cell unchanged rather than silently kept as a string nothing can + # open. Blank stays legal — an empty cell is "no document", not a malformed one. + if (field_by_key.get(key) or {}).get('type') == 'json': + raw_j = value if isinstance(value, str) else _json_ev.dumps(value) + if not str(raw_j).strip(): + updates[key] = '' + continue + if len(str(raw_j)) > _ut_limits.MAX_JSON_CELL: + refused = True + continue + try: + _json_ev.loads(raw_j) + except (ValueError, TypeError): + refused = True + continue + updates[key] = str(raw_j) + continue + clean = str(value)[:10000] + if isinstance(value, str) and clean != value: + # Truncated: the stored truth is shorter than the client's copy. (An + # int/float arriving as its string form renders identically — not a refusal.) + refused = True + definition = field_by_key.get(key) or {} + if definition.get('type') in ('select', 'multiselect') and clean: + options = [str(v) for v in definition.get('options') or []] + canonical = {v.strip().lower(): v for v in options} + if definition.get('type') == 'select': + normalized = canonical.get(clean.strip().lower()) + if normalized is None: + refused = True + continue + else: + normalized_parts = [] + seen = set() + invalid = False + for part in clean.split(','): + match = canonical.get(part.strip().lower()) + if match is None: + invalid = True + break + if match.lower() not in seen: + seen.add(match.lower()) + normalized_parts.append(match) + if invalid: + refused = True + continue + normalized = ','.join(normalized_parts) + if normalized != clean: + refused = True + clean = normalized + updates[key] = clean + if updates: + if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None: + # User-table cells are collaborative record data. Views/layout remain personal, + # but record values live in the definition rows so Links, Rollups, automations, + # and every viewer observe one truth. + import core.user_tables as _ut_values + _ut_values.patch_cells(ctx.scope_key, pid, updates, st=ctx.table.st) + elif _store_of(ctx).available(): + _tops(ctx).patch_overlay(uname, pid, updates) + else: + _session_ready() + ws['overlays'].setdefault(str(pid), {}).update(updates) + # C3 (wave 22): the admitted writes become ROW EVENTS — emitted AFTER they applied, on + # the ut scopes the event triggers watch this wave, through the seam in user_tables so + # this module never imports the engine. Human door only, which IS the loop law. + if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None: + import core.user_tables as _ut_ev + for _k, _v in list(updates.items()) + row_events: + _ut_ev.emit_row_event({'type': 'event_field', 'table': str(ctx.scope_key), + 'rowId': str(pid), 'field': _k, 'after': _v, + 'st': ctx.table.st, 'user': uname}) + # Wave-6 item 3: the client's cell already shows the typed value — rerun ONLY when + # some of what it shows was refused or clamped and must be repainted to the truth. + # A stage move reruns too: its `arrive` side-effects (the tracked flag) changed cells + # the client never typed. + return refused or stage_moved + + return False