loopable / platform /core /grid_events.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
127 kB
"""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/<slug>/…` 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):
"""`{"<pid>": [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.
_save_as = uname
if _store_of(ctx).available():
_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/<pid>/…`. 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