diff --git "a/platform/core/table_store.py" "b/platform/core/table_store.py" --- "a/platform/core/table_store.py" +++ "b/platform/core/table_store.py" @@ -1,797 +1,1143 @@ -"""The generic per-user TABLE WORKSPACE store — the persistence half of the table-page factory. - -One durable store key holds one table OBJECT's per-user Airtable-style state: - - {username: {'views': {view_id: SavedView}, - 'fields': {field_key: Field}, # notes + custom_ + measure_ strata - 'overlays': {str(pid): {field_key: value}}}} - -`make(table_key)` returns the six operations a table page's host loop needs, closed over that -key. The Customer table's ops (`modules/customer_data.py`, key 'customer_table_workspace') are -these exact functions — the logic MOVED here 2026-07-27 so that duplicating the Customer table -pattern to a new object is a registry row + a config, not a copy of the store plumbing -(owner directive: the table-page factory). - -A LIST (membership/formula semantics) is deliberately a different store from a VIEW -(presentation/query state) — see modules/customer_data.py's customer_lists key. -""" -import core.store as store - -#: Wave-9 I17 — the SHARED bucket. Views whose permissions make them visible to anyone but -#: their creator live here instead of in a personal workspace, under a key that cannot collide -#: with a username (usernames come from core/users.py and are never dunder-wrapped; `is_shared` -#: guards it anyway). ONE HOME PER VIEW, never both: a view moved to personal is REMOVED from -#: here, and a view shared is removed from its creator's workspace. Two homes would mean two -#: divergent copies the moment either was edited. -SHARED_KEY = '__shared__' - -#: ⭐⭐ W36-T25 — THE COLUMN SUMMARY IS THE DATABASE'S, NOT ONE ACCOUNT'S. -#: -#: Owner item 1, second half, verbatim: *"When i filter Avi on my computer sum works for the Sales -#: last 365 days column but not when my colleague filter it — The sum of the field is not showing -#: at the bottom even when i zoom out. It only works on my screen??"* Measured while scouting it: -#: `save_field` writes the WHOLE field payload into `data[username]`, so a column summary — which -#: is a statement about the COLUMN, identical for every reader by construction — was stored once -#: per account. Set it, and the totals row exists for you and for nobody else. `computeAggs` then -#: paints no totals row at all for the colleague (`showTotals` is false when no field carries an -#: `agg`), which is exactly "not showing at the bottom". -#: -#: ⛔ ONLY `agg` MOVES, AND THE LINE IS NOT ARBITRARY. Width, column order, the note, the display -#: format and every `custom_`/`measure_` definition stay per user, because each of those is a -#: statement about how ONE PERSON reads the column. "Sum this column" is a statement about what -#: the column MEANS, and two accounts disagreeing about it is the defect, not a preference. -#: -#: ⚠ IT LIVES IN THE `__shared__` MEMBER OF THIS SAME BUCKET rather than in -#: `core/shared_overlay.py`'s `__shared` document, and the reason is transactional: a summary -#: is written by the same `save_field` call that writes the note beside it, and the two must land -#: or fail together. `__shared__` is already this store's tenant-wide member (shared VIEWS live -#: there) and is guarded against colliding with a username by `is_shared` and by `core/users.py`'s -#: never-dunder rule, so there is no second bucket, no second flush and no second failure mode. -SHARED_FIELD_KEYS = ('agg',) - - -def _may_see(view, viewer, is_admin=False): - """Visibility for ONE shared view, fail-closed. - - 'collaborative' = everyone who can already open the module (the caller has gated that). - 'users' = the named users, plus the creator, plus admins — an admin who could not - see a view could not administer it either. - Anything unrecognised returns False rather than defaulting open: an unreadable permission - must never widen access ([[aios-permissioning]] — no fail-open defaults). - """ - if not isinstance(view, dict): - return False - if view.get('createdBy') == viewer or is_admin: - return True - perms = view.get('permissions') or {} - edit = perms.get('edit') - if edit == 'collaborative': - return True - if edit == 'users': - return viewer in set(perms.get('users') or ()) - return False # 'personal', absent, or junk - - -def _may_edit(view, viewer, is_admin=False): - """Who may WRITE a shared view. Same set as visibility today — the owner's item asks 'who - can edit' and lists who 'can have access', i.e. seeing and editing are one grant. Kept as a - separate function so they can diverge (a future read-only share) without hunting callers.""" - return _may_see(view, viewer, is_admin) - - -def _may_administer(view, viewer, is_admin=False): - """Who may change a view's PERMISSIONS, or delete it: the creator or an admin ONLY. - - Deliberately narrower than _may_edit. If a collaborator could rewrite `permissions` they - could grant themselves sole ownership of somebody else's view, or quietly widen a - users-scoped view to everyone — the classic privilege-escalation-by-edit hole. - """ - if not isinstance(view, dict): - return False - return bool(is_admin) or view.get('createdBy') == viewer - - -def is_shared(view): - """A view belongs in the shared bucket when its permissions reach beyond its creator.""" - return ((view or {}).get('permissions') or {}).get('edit') in ('collaborative', 'users') - - -def _shared_fields(data): - """The tenant-wide field stratum of one workspace document — `{field_key: {'agg': …}}`. - - ⚠ TOTAL AND FAIL-SOFT: a document written before W36-T25 has no `__shared__` member at all, - and one written by a future version may have something else in it. Either way this answers an - empty mapping rather than raising, because the caller is a READ that must still serve the - workspace — a column with no summary is the state every column was in yesterday. - """ - shared = (data or {}).get(SHARED_KEY) - fields = (shared or {}).get('fields') if isinstance(shared, dict) else None - return fields if isinstance(fields, dict) else {} - - -def split_shared(payload): - """`(per_user, shared)` — one field payload divided at the strata boundary. - - ⭐ W36-T25. `shared` carries only `SHARED_FIELD_KEYS` that are actually SET; `per_user` is the - payload without them. Split HERE rather than at each write door so the three doors that store - a field (`save_field`, `duplicate_field`, and the delete that must clear it) cannot come apart - about where a summary lives — which is the whole reason this module exists rather than being - copied per topic. - """ - payload = dict(payload or {}) - shared = {} - for key in SHARED_FIELD_KEYS: - value = str(payload.pop(key, '') or '').strip() - if value: - shared[key] = value - return payload, shared - - -def source_override_is_empty(payload): - """Does this stored definition of a SOURCE (non-custom) column carry any user state? - - A cleared note on an immutable source field returns to the canonical schema instead of - leaving a meaningless override row. Custom fields remain even with an empty note — and so - does a PRESET field carrying a measure-window override (wave-2 item 8), a DISPLAY-format - override (wave-5 item 10), and, since W29-T83, a COLUMN SUMMARY. - - ⛔ EVERY CLAUSE IS A SETTING A USER MADE, and each one omitted is a setting that silently - stops surviving a session. `agg` was missing: choosing Average on Customer's `Overdue days` - built an override whose only content was that summary, so this rule threw the whole row away - on write while the menu went on reading "Summary: Average" from the client's own optimistic - copy until the next login — a discarded WRITE wearing the face of a failed read - ([[lost-write-looks-like-failed-read]]). Measured on `bac40c2`; a `ut_*` table, which stores - its definitions through another door entirely, kept it. - - ⚠ ONE RULE, TWO CALLERS — here and `grid_events`' store-less fallback. Two copies of a - discard rule is how one of them keeps a write the other bins ([[one-evaluator-per-question]]). - ⚠ A CLEARED summary still drops the row, which is the intent: with nothing else set, the - column goes back to whatever the contract declares for it. - """ - payload = payload or {} - if payload.get('custom'): - return False - return (not str(payload.get('note') or '').strip() - and not isinstance(payload.get('measure'), dict) - and not isinstance(payload.get('format'), dict) - and not str(payload.get('agg') or '').strip()) - - -def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120): - """Allocate one human-facing name inside a store.update transaction. - - Keys/ids remain structural identity. Names compare case-insensitively after collapsing - whitespace, because those variants are indistinguishable in the UI. This helper belongs - in the store layer: allocating from a pre-write snapshot lets two concurrent requests both - choose the same free name before either write lands. - """ - 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 - - -class TableStore: - """The six store operations for one table object's workspace, closed over its store key. - - `st` (wave 18, C3-UT) is the STORE HANDLE — anything exposing `get(name)` / - `update(name, fn, flush=)`. Default = `core.store` (tenant #0, every existing caller, - zero behaviour change). The API passes the session's `TenantRuntime`, whose accessors - apply the tenant prefix / repo binding — which is what makes a user table created by a - Nurilab admin land in Nurilab's store instead of Royal's. - """ - - def __init__(self, table_key, st=None): - self.table_key = table_key - self._st = st if st is not None else store - - @property - def st(self): - """The bound store handle — for SIBLING registries (core/shares) that must read the - same tenant's buckets this workspace lives in (wave 21, C1).""" - return self._st - - def find_view(self, view_id): - """`(owner_username, view)` for a view living in ANY personal stratum, else None. - - ⭐ Wave 21 (item 9, C1): the R10 grant registry names bare ids, so projecting a granted - view means locating the OWNER's record inside this topic's bucket. Personal strata - only — the `__shared__` bucket has its own read path (`shared_views`), and serving one - view from two finders is how two copies drift.""" - vid = str(view_id or '').strip() - if not vid: - return None - try: - data = self._st.get(self.table_key) or {} - except Exception: - return None - for username, ws in data.items(): - if username == SHARED_KEY or not isinstance(ws, dict): - continue - v = (ws.get('views') or {}).get(vid) - if isinstance(v, dict): - return str(username), dict(v) - return None - - def find_folder(self, folder_id): - """`(owner_username, folder_row, {view_id: view})` for a VIEWS folder living in any - personal stratum, else None. `find_view`'s sibling, and here for the same reason. - - ⭐ D-37 (wave 20's R10 remainder, closed 2026-08-05): the grant registry accepts kind - `folder` and has since wave 20, but only the VIEW kind was ever projected — so "share - this folder with Karen" recorded a row, listed under Shared with me, and put nothing on - Karen's screen. Projecting a folder means two lookups the view path does not need: WHO - owns it, and WHICH views are filed in it. Folder membership lives in the owner's - `itemFolders` map (item id -> folder id), never on the view record, so the views are - found by asking that map rather than by reading a list off the folder. - - Views only (`folders['views']`): the cohort surface has its own store and its own - sharing question, and answering both here would make one function mean two things. - """ - fid = str(folder_id or '').strip() - if not fid: - return None - try: - data = self._st.get(self.table_key) or {} - except Exception: - return None - for username, ws in data.items(): - if username == SHARED_KEY or not isinstance(ws, dict): - continue - rows = (ws.get('folders') or {}).get('views') or [] - hit = next((f for f in rows - if isinstance(f, dict) and str(f.get('id') or '') == fid), None) - if not hit: - continue - # ⚠ `itemFolders` IS KEYED BY SURFACE FIRST (`{'views': {itemId: folderId}, …}`) — - # reading item ids off the top level finds the surface names instead and matches - # nothing, so the projection silently returns an EMPTY folder and the feature looks - # exactly as broken as it was before the fix. Caught by this change's own gate, - # which is the entire argument for writing one. - placed = (ws.get('itemFolders') or {}).get('views') or {} - views = ws.get('views') or {} - inside = {str(vid): dict(v) for vid, v in views.items() - if isinstance(v, dict) and str(placed.get(str(vid)) or '') == fid} - return str(username), dict(hit), inside - return None - - # ---------------------------------------------------------------- read - def workspace(self, username, consume_corrections=True): - """One user's durable workspace: always the full three-strata shape.""" - try: - data = self._st.get(self.table_key) or {} - ws = data.get(username, {}) or {} - # A collision acknowledgement is protocol state, not part of a field definition. - # Consume it with the first fresh workspace payload after the correcting write, - # then splice a bounded copy into that payload only. Keeping it out of `fields` - # prevents an old request id surviving forever and overriding a later rename. - corrections = {} - if consume_corrections and ws.get('fieldCorrections'): - def _take(current): - current_ws = current.get(username) or {} - pending = current_ws.get('fieldCorrections') or {} - corrections.update({ - str(key)[:80]: dict(value) - for key, value in pending.items() - if isinstance(value, dict) - }) - current_ws.pop('fieldCorrections', None) - return current - - data = self._st.update(self.table_key, _take, flush='async') - ws = (data or {}).get(username, {}) or {} - except Exception: - data = {} - ws = {} - corrections = {} - fields = { - key: dict(value) if isinstance(value, dict) else value - for key, value in (ws.get('fields') or {}).items() - } - # ⭐⭐ W36-T25 — THE TENANT-WIDE COLUMN SUMMARY, MERGED OVER THIS USER'S STRATUM. - # ⛔ IT MUST BE ABLE TO CREATE AN ENTRY, not only decorate one, and that is the whole - # reason this is a merge rather than a lookup: the colleague who never touched the column - # has NO per-user record for it, so a decorate-only pass would have left them with exactly - # the blank totals row the owner reported. `aios_grid.workspace_wire` reads `meta['agg']` - # off whatever is here, base column or custom one alike. - for key, shared in _shared_fields(data).items(): - agg = str((shared or {}).get('agg') or '').strip() - if not agg: - continue - entry = fields.get(key) - fields[key] = {**entry, 'agg': agg} if isinstance(entry, dict) else {'agg': agg} - for key, ack in corrections.items(): - field = fields.get(key) - accepted_label = str(ack.get('label') or '')[:120] - requested_label = str(ack.get('labelCorrectedFrom') or '')[:120] - correction_id = str(ack.get('labelCorrectionId') or '')[:180] - # A newer field write clears/replaces the pending ack in the SAME transaction. - # The label check is an extra belt against ever attaching a stale ack to a newer - # definition if a future store implementation weakens that ordering. - if (isinstance(field, dict) and accepted_label - and str(field.get('label') or '') == accepted_label - and requested_label and correction_id): - field['labelCorrectedFrom'] = requested_label - field['labelCorrectionId'] = correction_id - out = { - 'views': dict(ws.get('views') or {}), - 'fields': fields, - 'overlays': dict(ws.get('overlays') or {}), - # wave-8 I11 (C4): folders over the saved views / cohorts sidebars. A FOURTH - # stratum rather than a key on each item — see aios_grid.clean_folders for why - # (a cohort lives in another store, and filing is an organising act, not part of - # what a view is). Absent for every workspace saved before this wave, which is - # exactly "no folders yet". - 'folders': dict(ws.get('folders') or {}), - 'itemFolders': dict(ws.get('itemFolders') or {}), - } - # 2026-07-31 (owner item 3): WHERE THE USER LEFT OFF survives a new browser. The - # client's localStorage copy wins when present; this is the server's answer for a - # fresh profile, which used to fall all the way to the system default view. - if ws.get('activeViewId'): - out['activeViewId'] = str(ws['activeViewId']) - # Wave 2026-08-02 (C-LAYOUT): the per-user record-detail field order. A fifth - # stratum, absent until the user first reorders — exactly "default order". - if isinstance(ws.get('recordLayout'), dict): - out['recordLayout'] = dict(ws['recordLayout']) - return out - - # ---------------------------------------------------------------- write - def _update(self, username, change, shared=None): - """Apply `change(ws)` to this user's stratum, and `shared(shared_fields)` to the - tenant-wide one, in ONE transaction. - - ⭐ W36-T25 — `shared` IS A SECOND CALLBACK RATHER THAN A SECOND `update`, and that is the - whole reason it exists here instead of at the caller. `save_field` writes a note (per - user) and a column summary (tenant-wide) from ONE payload; two transactions would let the - summary land while the note did not, and the store's own commit is asynchronous, so the - window is real rather than theoretical. One `update`, one flush, one failure mode. - """ - def _up(data): - ws = data.setdefault(username, {}) - ws.setdefault('views', {}) - ws.setdefault('fields', {}) - ws.setdefault('overlays', {}) - ws.setdefault('folders', {}) - ws.setdefault('itemFolders', {}) - change(ws) - if shared is not None: - shared(data.setdefault(SHARED_KEY, {}).setdefault('fields', {})) - return data - # flush='async' (wave-7 W3): this is THE hot path — every autosaved filter tweak, - # column note and typed overlay cell lands here inside the component round-trip, and - # the historical synchronous hub commit cost seconds per edit. The mutation applies to - # the in-process cache (read-your-writes for every subsequent render); the hub write - # coalesces in the background. Registry/auth writes elsewhere stay flush='sync'. - return self._st.update(self.table_key, _up, flush='async') - - def rename_choice_values(self, username, change): - """Apply an arbitrary workspace rewrite (wave 20, item 15 / C-RENAME). - - ⚠ NAMED FOR ITS ONE CALLER RATHER THAN EXPOSED AS A GENERIC `mutate`, deliberately. A - public "do anything to the workspace" method is an invitation to put write logic in - callers instead of here, and every OTHER method on this class exists precisely because - that logic belongs in one place. Renaming a choice is the one operation that must touch - three strata AT ONCE — the field's `choices`, the cells in `overlays`, and the views that - filter or colour by the old value — inside a SINGLE transaction, because a rename that - updated the cells and not the filters would leave a saved view matching nothing. - - `change(ws)` receives the whole workspace with every stratum pre-created (see `_update`). - """ - return self._update(username, change) - - def save_active_view(self, username, view_id): - """Remember which view this user last opened (owner item 3, 2026-07-31). - - Presentation state, not authorisation: the READ side re-validates the id against what - the caller may actually see, so a stale or foreign id degrades to the default view - rather than granting anything. Stored per user like every other stratum. - """ - vid = str(view_id or '').strip()[:120] - if not vid or username == SHARED_KEY: - return - - def _set(ws): - ws['activeViewId'] = vid - self._update(username, _set) - - def save_record_layout(self, username, order): - """The per-user RECORD-DETAIL field order (wave 2026-08-02, C-LAYOUT). - - Presentation state for ONE surface — the record modal. Deliberately not view config: - the owner's ask is per-user, not per-view, and it must never reorder grid columns. - The event handler validated keys against the live field set; the wire re-validates at - serve time (aios_grid.workspace_wire), so a deleted field cannot outlive itself here. - An empty order clears the stratum back to "default order". - """ - if username == SHARED_KEY: - return - clean, seen = [], set() - for key in (order or [])[:200]: - key = str(key or '').strip()[:80] - if key and key not in seen: - seen.add(key) - clean.append(key) - - def _set(ws): - if clean: - ws['recordLayout'] = {'order': clean} - else: - ws.pop('recordLayout', None) - - self._update(username, _set) - - def save_folders(self, username, folders, item_folders): - """Replace the folder stratum wholesale (wave-8 I11). - - Wholesale rather than per-folder because the caller has ALREADY validated the complete - picture through aios_grid.clean_folders / clean_item_folders, and those two are - interdependent: a placement is only legal while its folder exists, so committing them - separately would leave a window where a reader sees an item filed into a folder that is - not there yet. One write, one consistent state. - """ - def _set(ws): - ws['folders'] = dict(folders or {}) - ws['itemFolders'] = dict(item_folders or {}) - self._update(username, _set) - - def save_view_order(self, username, order): - """⭐ WAVE-27 item 5 (contract C7) — this user's own ORDER for the views rail. - - Wholesale, like `save_folders` above and for the same reason: the client sends the full - list it is looking at, not a delta, because a partial order cannot say where an UNNAMED - view went. - - ⛔ PER USER, and it belongs in this stratum rather than on the view records themselves. - `aios_grid`'s own folder note argues it out for placements and every word applies: an - arrangement is a per-user ORGANISING act, not part of what a view IS — so keeping it out - of the view config means duplicating, sharing or exporting a view does not drag one - person's rail position along with it. It also means a SHARED view can sit in a different - place for each person who can see it, which is the only coherent answer once two people - share one view. - - An empty list CLEARS the arrangement (back to server order) rather than storing `[]`. - """ - def _set(ws): - clean = [] - seen = set() - for vid in (order or []): - vid = str(vid).strip()[:120] - if vid and vid not in seen: - seen.add(vid) - clean.append(vid) - if clean: - ws['viewOrder'] = clean - else: - ws.pop('viewOrder', None) - self._update(username, _set) - - def shared_views(self, viewer, is_admin=False): - """Every SHARED view this viewer may see, by id (wave-9 I17). - - Read-only and independent of the viewer's own workspace: the caller merges. Returns - only what `_may_see` allows, so a caller cannot accidentally render somebody else's - personal view by forgetting to filter. - """ - try: - bucket = ((self._st.get(self.table_key) or {}).get(SHARED_KEY) or {}).get('views') or {} - except Exception: - return {} - return {vid: dict(v) for vid, v in bucket.items() - if _may_see(v, viewer, is_admin)} - - def shared_view(self, view_id): - """One shared view RAW — no visibility filter. For authorisation decisions only: a - caller must know a view exists and who owns it before it can decide whether the actor - may touch it. Never hand the result to a renderer without checking `_may_see`.""" - try: - return ((self._st.get(self.table_key) or {}).get(SHARED_KEY) or {} - ).get('views', {}).get(str(view_id)) - except Exception: - return None - - def save_view(self, username, view, shared=None, reserved_names=(), is_admin=False): - """Upsert a SavedView into its ONE home — personal workspace or the shared bucket. - - `shared` defaults to reading the view's own permissions (`is_shared`). Whichever home - it lands in, the view is REMOVED from the other, so a view can never exist as two - copies that diverge on the next edit. - - ⚠ AUTHORISATION IS THE CALLER'S JOB and must happen BEFORE this is called — this layer - moves data and does not know who is asking. `_cl_handle_one` is the wall. - """ - view_id = str((view or {}).get('id') or '').strip() - if not view_id: - raise ValueError('view id is required') - if username == SHARED_KEY: - raise ValueError('reserved username') - to_shared = is_shared(view) if shared is None else bool(shared) - requested = dict(view) - accepted = {} - - def _up(data): - # View names are tenant-global: every personal workspace plus the shared bucket. - # This deliberately includes views the actor cannot see. The only disclosed fact - # is that a display name is already taken, while the categorical "no duplicate - # view names" contract remains true when a personal view is later shared. - names = list(reserved_names or ()) - for workspace in data.values(): - if not isinstance(workspace, dict): - continue - names.extend( - value.get('name') - for candidate_id, value in (workspace.get('views') or {}).items() - if candidate_id != view_id and isinstance(value, dict) - ) - payload = dict(requested) - payload['name'] = _unique_name(payload.get('name'), names) - accepted.clear() - accepted.update(payload) - if to_shared: - bucket = data.setdefault(SHARED_KEY, {}) - bucket.setdefault('views', {})[view_id] = payload - # it may have lived in the creator's workspace before being shared - owner = data.get(payload.get('createdBy') or username) or {} - (owner.get('views') or {}).pop(view_id, None) - else: - ws = data.setdefault(username, {}) - ws.setdefault('views', {})[view_id] = payload - (data.get(SHARED_KEY, {}).get('views') or {}).pop(view_id, None) - return data - - self._st.update(self.table_key, _up, flush='async') - return dict(accepted) - - def delete_view(self, username, view_id): - """Delete a custom/list view override. The system all-rows view is guarded by caller. - - Removes from BOTH homes: the caller has already authorised the delete, and leaving a - stale copy in the other bucket would resurrect the view on the next read. - """ - vid = str(view_id) - - def _up(data): - (data.get(username, {}).get('views') or {}).pop(vid, None) - (data.get(SHARED_KEY, {}).get('views') or {}).pop(vid, None) - return data - - self._st.update(self.table_key, _up, flush='async') - - def save_field(self, username, field, reserved_names=(), correction_id=None): - """Persist a column note or a user-created (custom_/measure_) field definition. - - ⭐⭐ W36-T25 — THE COLUMN SUMMARY GOES TO THE TENANT-WIDE STRATUM, EVERYTHING ELSE STAYS - PER USER, in ONE transaction. See `SHARED_FIELD_KEYS` above for the owner's report and for - why the line falls where it does. The returned `accepted` still carries the summary: the - caller is echoing back the field it just stored, and dropping a key from that echo would - tell the client its write was refused ([[read-path-cannot-witness-write-path]]). - """ - key = str((field or {}).get('key') or '').strip() - if not key: - raise ValueError('field key is required') - requested = dict(field) - accepted = {} - - def _save(ws): - names = list(reserved_names or ()) - names.extend( - value.get('label') - for candidate_key, value in (ws.get('fields') or {}).items() - if candidate_key != key and isinstance(value, dict) - ) - payload = dict(requested) - payload.pop('labelCorrectedFrom', None) - payload.pop('labelCorrectionId', None) - requested_label = ' '.join( - str(payload.get('label') or 'Untitled').split())[:120].rstrip() - payload['label'] = _unique_name(requested_label, names) - corrections = ws.setdefault('fieldCorrections', {}) - corrections.pop(key, None) - if payload['label'] != requested_label and correction_id: - corrections[key] = { - 'label': payload['label'], - 'labelCorrectedFrom': requested_label, - 'labelCorrectionId': str(correction_id)[:180], - } - if not corrections: - ws.pop('fieldCorrections', None) - accepted.clear() - accepted.update(payload) - # ⭐⭐ W36-T25 — SPLIT AFTER the label allocation and the correction bookkeeping, so - # both still see the whole payload, and BEFORE the per-user write. - mine, shared_now = split_shared(payload) - shared_write.clear() - shared_write.update(shared_now) - # ⚠ EMPTINESS IS JUDGED ON THE PER-USER HALF. A source column whose ONLY state was a - # summary now has no per-user state at all, and leaving an `{}` override behind is the - # meaningless row `source_override_is_empty` exists to prevent. - if source_override_is_empty(mine): - ws['fields'].pop(key, None) - else: - ws['fields'][key] = mine - - shared_write = {} - - def _share(shared_fields): - # ⛔ A CLEARED SUMMARY MUST REMOVE THE ROW, not leave an empty one: `workspace` treats - # any entry it finds as a live tenant-wide summary, so an `{'agg': ''}` husk would be - # skipped today and become a resurrection hazard the moment the read grows a second - # shared key. Absence is the only honest spelling of "nobody set one". - if shared_write: - shared_fields[key] = {**(shared_fields.get(key) or {}), **shared_write} - else: - shared_fields.pop(key, None) - - self._update(username, _save, shared=_share) - return dict(accepted) - - def duplicate_field(self, username, source_key, new_key, field, - reserved_names=(), correction_id=None): - """Clone a user-created field in ONE store transaction (wave-5 item 1): the new - definition plus — for `custom_` overlay sources only — every stored cell value under - the source key. One transaction, because a def without its values (or values without a - def) is exactly the orphan state delete_field exists to prevent, in reverse. - The caller validated both keys (same created stratum) and stamped the clone's - createdBy; this layer only moves data.""" - source_key = str(source_key or '').strip() - new_key = str(new_key or '').strip() - if not source_key or not new_key or source_key == new_key: - raise ValueError('duplicate_field needs two distinct keys') - requested = dict(field) - accepted = {} - - def _dup(ws): - names = list(reserved_names or ()) - names.extend( - value.get('label') - for candidate_key, value in (ws.get('fields') or {}).items() - if candidate_key != new_key and isinstance(value, dict) - ) - payload = dict(requested) - payload.pop('labelCorrectedFrom', None) - payload.pop('labelCorrectionId', None) - requested_label = ' '.join( - str(payload.get('label') or 'Untitled').split())[:120].rstrip() - payload['label'] = _unique_name(requested_label, names) - corrections = ws.setdefault('fieldCorrections', {}) - corrections.pop(new_key, None) - if payload['label'] != requested_label and correction_id: - corrections[new_key] = { - 'label': payload['label'], - 'labelCorrectedFrom': requested_label, - 'labelCorrectionId': str(correction_id)[:180], - } - if not corrections: - ws.pop('fieldCorrections', None) - accepted.clear() - accepted.update(payload) - # ⭐ W36-T25: a CLONE carries the original's summary, and a summary is the database's - # (see `SHARED_FIELD_KEYS`). Storing it per user here would give the clone a different - # residency from every other column — one door out of three disagreeing about where a - # thing lives is how `save_field` and this function drift. - mine, shared_now = split_shared(payload) - shared_write.clear() - shared_write.update(shared_now) - ws['fields'][new_key] = mine - if source_key.startswith('custom_'): - for row in ws['overlays'].values(): - if isinstance(row, dict) and source_key in row: - row[new_key] = row[source_key] - - shared_write = {} - - def _share(shared_fields): - if shared_write: - shared_fields[new_key] = {**(shared_fields.get(new_key) or {}), **shared_write} - else: - shared_fields.pop(new_key, None) - - self._update(username, _dup, shared=_share) - return dict(accepted) - - def delete_field(self, username, key): - """Delete a USER-CREATED field definition outright (owner gap closed 2026-07-27). - - Only the created strata ever reach here (`custom_` overlay fields, `measure_` formula - columns — the caller enforces the prefix). The stored overlay VALUES for the key are - scrubbed with it: a deleted column's cells must not linger as orphan data that would - silently resurface if the key were ever reused. Views referencing the key self-heal on - their next autosave (an unknown colId is dropped) — the rule every stale key rides. - - ⭐⭐ W36-T25 — AND THE TENANT-WIDE SUMMARY GOES WITH IT, for exactly the reason the - paragraph above gives about cells: an orphan `agg` under a deleted key is state nobody can - see and nobody can clear, and it would attach itself to the next column that happens to - take the key back. ⚠ This is the ONE stratum a per-user delete may reach across accounts, - and it is safe because the summary was never this user's to begin with — deleting the - COLUMN is a tenant-wide act already. - """ - key = str(key or '').strip() - if not key: - return - - def _drop(ws): - ws['fields'].pop(key, None) - for row in ws['overlays'].values(): - if isinstance(row, dict): - row.pop(key, None) - - self._update(username, _drop, shared=lambda shared_fields: shared_fields.pop(key, None)) - - def _tenant_wide_keys(self): - """The columns of THIS table whose values live in the tenant-wide stratum. - - ⭐ THE CHEAP HALF. A table that shares nothing reads one small per-key file (cache-first - per process) and answers the empty set, so `patch_overlay` behaves exactly as it did - before W38-T20 for every database that has no shared column. It is deliberately not - memoised on the instance: `modules/customer_data.py` and `modules/product_data.py` both - hold a MODULE-LEVEL `TABLE_OPS`, so a per-instance cache would serve one request's answer - to the next, and this one decides WHERE a value is written. - ⚠ LENIENT LIKE EVERY OTHER STRATUM READ, and the failure direction is the safe one: an - unreachable shared bucket routes the write to the PER-USER stratum, which is the - pre-ticket behaviour, rather than dropping it. - """ - try: - import core.shared_overlay as shared_overlay - return set(shared_overlay.fields(self.table_key, st=self._st) or ()) - except Exception: # noqa: BLE001 - return set() - - def patch_overlay(self, username, pid, updates): - """Patch only the external editable stratum; never writes to the source system. - - ⭐⭐ W38-T20 / D-423 — A CELL IN A TENANT-WIDE COLUMN GOES TO THE TENANT-WIDE STRATUM, - AND WITHOUT THIS SPLIT THE EDIT SILENTLY DISAPPEARS. The read path layers the shared - stratum OVER the per-user one (it has to: that is what makes every reader see the same - number). This method wrote PER USER. So the sequence was: type a new value, see it accept, - come back, and read the shared value again — the owner's *"I went back and it all got - reseted"*, with a successful 200 at every step and nothing in any log. - - ⛔ IT IS DECIDED BY WHERE THE COLUMN LIVES, NOT BY WHO IS WRITING OR THROUGH WHICH ROUTE. - `modules/product_data._ProductTableStore` has done exactly this since W30-T36 against its - CANONICAL list; the only reason it needed a subclass is that its shared columns are - declared in a contract file. Columns created at runtime cannot be, so the general form - asks the stratum itself. Both doors (`PATCH /customers/{pid}` and `POST /grid/events`) - arrive here through `grid_events._tops(ctx)`, which is why the split belongs at the STORE - and not at either route: intercepting at one leaves the other writing into the shadow. - - ⛔ NO PERMISSION IS ANSWERED HERE. `shared_overlay`'s header is explicit that it is not a - wall, and neither is this: whether this session may write this key is settled upstream by - `EventCtx.hidden_keys`, which `routes_customers._hidden_for` now computes over the MERGED - contract precisely so a grant-governed column is refused before it reaches this line. - """ - clean = dict(updates or {}) - if not clean: - return - wide = self._tenant_wide_keys() - shared = {k: v for k, v in clean.items() if k in wide} - personal = {k: v for k, v in clean.items() if k not in wide} - if shared: - import core.shared_overlay as shared_overlay - # ⚠ `st=self._st`, NEVER the module default. The two strata must resolve to the SAME - # tenant handle, or a value written by one is invisible to the other and the user's - # edit vanishes the moment they save it (`_ProductTableStore` records the same rule). - shared_overlay.put_cells(self.table_key, pid, shared, st=self._st) - if not personal: - return - - def _patch(ws): - ws['overlays'].setdefault(str(int(pid)), {}).update(personal) - - self._update(username, _patch) - - -def make(table_key, st=None): - return TableStore(table_key, st=st) +"""The generic per-user TABLE WORKSPACE store — the persistence half of the table-page factory. + +One durable store key holds one table OBJECT's per-user Airtable-style state: + + {username: {'views': {view_id: SavedView}, + 'fields': {field_key: Field}, # notes + custom_ + measure_ strata + 'overlays': {str(pid): {field_key: value}}}} + +`make(table_key)` returns the six operations a table page's host loop needs, closed over that +key. The Customer table's ops (`modules/customer_data.py`, key 'customer_table_workspace') are +these exact functions — the logic MOVED here 2026-07-27 so that duplicating the Customer table +pattern to a new object is a registry row + a config, not a copy of the store plumbing +(owner directive: the table-page factory). + +A LIST (membership/formula semantics) is deliberately a different store from a VIEW +(presentation/query state) — see modules/customer_data.py's customer_lists key. +""" +import core.store as store + +#: Wave-9 I17 — the SHARED bucket. Views whose permissions make them visible to anyone but +#: their creator live here instead of in a personal workspace, under a key that cannot collide +#: with a username (usernames come from core/users.py and are never dunder-wrapped; `is_shared` +#: guards it anyway). ONE HOME PER VIEW, never both: a view moved to personal is REMOVED from +#: here, and a view shared is removed from its creator's workspace. Two homes would mean two +#: divergent copies the moment either was edited. +SHARED_KEY = '__shared__' + +#: ⭐⭐ W36-T25 — THE COLUMN SUMMARY IS THE DATABASE'S, NOT ONE ACCOUNT'S. +#: +#: Owner item 1, second half, verbatim: *"When i filter Avi on my computer sum works for the Sales +#: last 365 days column but not when my colleague filter it — The sum of the field is not showing +#: at the bottom even when i zoom out. It only works on my screen??"* Measured while scouting it: +#: `save_field` writes the WHOLE field payload into `data[username]`, so a column summary — which +#: is a statement about the COLUMN, identical for every reader by construction — was stored once +#: per account. Set it, and the totals row exists for you and for nobody else. `computeAggs` then +#: paints no totals row at all for the colleague (`showTotals` is false when no field carries an +#: `agg`), which is exactly "not showing at the bottom". +#: +#: ⛔ ONLY `agg` MOVES, AND THE LINE IS NOT ARBITRARY. Width, column order, the note, the display +#: format and every `custom_`/`measure_` definition stay per user, because each of those is a +#: statement about how ONE PERSON reads the column. "Sum this column" is a statement about what +#: the column MEANS, and two accounts disagreeing about it is the defect, not a preference. +#: +#: ⚠ IT LIVES IN THE `__shared__` MEMBER OF THIS SAME BUCKET rather than in +#: `core/shared_overlay.py`'s `__shared` document, and the reason is transactional: a summary +#: is written by the same `save_field` call that writes the note beside it, and the two must land +#: or fail together. `__shared__` is already this store's tenant-wide member (shared VIEWS live +#: there) and is guarded against colliding with a username by `is_shared` and by `core/users.py`'s +#: never-dunder rule, so there is no second bucket, no second flush and no second failure mode. +SHARED_FIELD_KEYS = ('agg',) + + +def _may_see(view, viewer, is_admin=False): + """Visibility for ONE shared view, fail-closed. + + 'collaborative' = everyone who can already open the module (the caller has gated that). + 'users' = the named users, plus the creator, plus admins — an admin who could not + see a view could not administer it either. + Anything unrecognised returns False rather than defaulting open: an unreadable permission + must never widen access ([[aios-permissioning]] — no fail-open defaults). + """ + if not isinstance(view, dict): + return False + if view.get('createdBy') == viewer or is_admin: + return True + perms = view.get('permissions') or {} + edit = perms.get('edit') + if edit == 'collaborative': + return True + if edit == 'users': + return viewer in set(perms.get('users') or ()) + return False # 'personal', absent, or junk + + +def _may_edit(view, viewer, is_admin=False): + """Who may WRITE a shared view. Same set as visibility today — the owner's item asks 'who + can edit' and lists who 'can have access', i.e. seeing and editing are one grant. Kept as a + separate function so they can diverge (a future read-only share) without hunting callers.""" + return _may_see(view, viewer, is_admin) + + +def _may_administer(view, viewer, is_admin=False): + """Who may change a view's PERMISSIONS, or delete it: the creator or an admin ONLY. + + Deliberately narrower than _may_edit. If a collaborator could rewrite `permissions` they + could grant themselves sole ownership of somebody else's view, or quietly widen a + users-scoped view to everyone — the classic privilege-escalation-by-edit hole. + """ + if not isinstance(view, dict): + return False + return bool(is_admin) or view.get('createdBy') == viewer + + +def is_shared(view): + """A view belongs in the shared bucket when its permissions reach beyond its creator.""" + return ((view or {}).get('permissions') or {}).get('edit') in ('collaborative', 'users') + + +def _shared_fields(data): + """The tenant-wide field stratum of one workspace document — `{field_key: {'agg': …}}`. + + ⚠ TOTAL AND FAIL-SOFT: a document written before W36-T25 has no `__shared__` member at all, + and one written by a future version may have something else in it. Either way this answers an + empty mapping rather than raising, because the caller is a READ that must still serve the + workspace — a column with no summary is the state every column was in yesterday. + """ + shared = (data or {}).get(SHARED_KEY) + fields = (shared or {}).get('fields') if isinstance(shared, dict) else None + return fields if isinstance(fields, dict) else {} + + +def assert_annotation_only(before, after): + """⭐⭐ W41-T04 / CONTRACT C3 — THIS STRATUM ANNOTATES COLUMNS. IT DOES NOT MINT THEM. + + ⛔ WHY IT EXISTS. `_update(username, change, shared=...)` writes the `__shared__` member's + `fields` map, and it does so WITHOUT passing through `core.shared_overlay.put_field`. That + makes it, on paper, a second writer of a tenant-wide field entry with rules of its own, which + is exactly the shape C3 forbids. In practice it cannot mint one: both callers derive their + payload from `split_shared`, which keeps only `SHARED_FIELD_KEYS`, so what lands is + `{'agg': ...}` and never a `label`, a `type` or a `createdBy`. But that was a property of two + callers rather than a guarantee of the door, and a property nothing checks is the next edit's + to delete. This turns it into a guarantee: whatever callback `_update` is handed, the entry it + leaves behind can only be a column SUMMARY. + + ⚠ IT DOES NOT FORBID CREATING AN ENTRY. A canonical column getting its first summary + legitimately puts a key here that was not here before, and `source_override_is_empty` can + leave that entry as the column's only stored state. What it forbids is the entry ever carrying + anything a column DEFINITION is made of. Minting is `shared_overlay.mint_field`. + + ⚠ ONLY CHANGED ENTRIES ARE JUDGED, so a document written before this rule keeps whatever it + holds. A stray already in the store is not this call's doing and raising on it would refuse + every later write to a workspace that has one. + """ + stray = sorted({name + for key, entry in (after or {}).items() + if isinstance(entry, dict) and entry != (before or {}).get(key) + for name in entry if name not in SHARED_FIELD_KEYS}) + if stray: + raise ValueError( + 'table_store: the tenant-wide field stratum carries a column summary only, and this ' + 'write adds ' + ', '.join(stray) + '. A column definition is created through ' + 'core.shared_overlay.mint_field, which requires a creator, a grant list and a ' + 'description.') + return True + + +def split_shared(payload): + """`(per_user, shared)` — one field payload divided at the strata boundary. + + ⭐ W36-T25. `shared` carries only `SHARED_FIELD_KEYS` that are actually SET; `per_user` is the + payload without them. Split HERE rather than at each write door so the three doors that store + a field (`save_field`, `duplicate_field`, and the delete that must clear it) cannot come apart + about where a summary lives — which is the whole reason this module exists rather than being + copied per topic. + """ + payload = dict(payload or {}) + shared = {} + for key in SHARED_FIELD_KEYS: + value = str(payload.pop(key, '') or '').strip() + if value: + shared[key] = value + return payload, shared + + +def source_override_is_empty(payload): + """Does this stored definition of a SOURCE (non-custom) column carry any user state? + + A cleared note on an immutable source field returns to the canonical schema instead of + leaving a meaningless override row. Custom fields remain even with an empty note — and so + does a PRESET field carrying a measure-window override (wave-2 item 8), a DISPLAY-format + override (wave-5 item 10), and, since W29-T83, a COLUMN SUMMARY. + + ⛔ EVERY CLAUSE IS A SETTING A USER MADE, and each one omitted is a setting that silently + stops surviving a session. `agg` was missing: choosing Average on Customer's `Overdue days` + built an override whose only content was that summary, so this rule threw the whole row away + on write while the menu went on reading "Summary: Average" from the client's own optimistic + copy until the next login — a discarded WRITE wearing the face of a failed read + ([[lost-write-looks-like-failed-read]]). Measured on `bac40c2`; a `ut_*` table, which stores + its definitions through another door entirely, kept it. + + ⚠ ONE RULE, TWO CALLERS — here and `grid_events`' store-less fallback. Two copies of a + discard rule is how one of them keeps a write the other bins ([[one-evaluator-per-question]]). + ⚠ A CLEARED summary still drops the row, which is the intent: with nothing else set, the + column goes back to whatever the contract declares for it. + """ + payload = payload or {} + if payload.get('custom'): + return False + return (not str(payload.get('note') or '').strip() + and not isinstance(payload.get('measure'), dict) + and not isinstance(payload.get('format'), dict) + and not str(payload.get('agg') or '').strip()) + + +def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120): + """Allocate one human-facing name inside a store.update transaction. + + Keys/ids remain structural identity. Names compare case-insensitively after collapsing + whitespace, because those variants are indistinguishable in the UI. This helper belongs + in the store layer: allocating from a pre-write snapshot lets two concurrent requests both + choose the same free name before either write lands. + """ + 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 + + +# ============================================================================================= +# ⭐⭐ W41-T12 · RULING R1 — A FOLDER TREE IS LEGAL OR IT IS REFUSED, AND THIS IS WHERE. +# +# Wave 41 turns the folder rail from ONE flat level ("One level, deliberately", the header of +# `customer-grid/folders.ts`) into a real tree: `GridFolder` grows `parent?: string | null` +# (contract C9) and a view lives in EXACTLY ONE folder, Windows-exact (R1). Two rules bound it, +# and the owner set the number himself, mid-session, verbatim: *"Actually let's do 10 layer max. +# I don't want the Folder path be abused to break the app."* +# +# ⛔ THIS IS THE WALL, NOT A CONVENIENCE, and the distinction is the whole ticket. +# `aios_grid.clean_folders` sanitises what a client sent, and the rail checks depth before it +# lets go of a drag — but neither is reachable from a hand-rolled POST to `/api/v1/grid/events`, +# which is the ONE write door. A cycle stored here is not a cosmetic defect: every reader that +# walks `parent` to paint a breadcrumb, resolve a path or compute a depth loops forever on it, +# which is exactly the abuse the owner named. +# +# ⚠ DELIBERATELY INDEPENDENT OF `aios_grid`. Importing the sanitiser to do the validating would +# collapse the wall and the convenience into one implementation, and the wall would then inherit +# every future loosening of the convenience. The two constants they share are restated below +# with a note saying they must agree, rather than imported. +# ============================================================================================= + +#: R1 — how many folders a legal chain may hold. A ROOT folder is depth 1, so this permits at +#: most NINE `parent` hops: depth 10 is legal, depth 11 is refused. Stated in hops as well as in +#: levels because the client's convenience check lives in another module, and an off-by-one +#: there means the rail permits a drag that this wall then refuses, with no way for the user to +#: tell which of the two is wrong. +MAX_FOLDER_DEPTH = 10 + +#: The reserved placement meaning "filed at the root, on purpose" (wave 32, owner item 20). +#: ⛔ IT NAMES NO FOLDER BY DESIGN and is therefore EXEMPT from the folder-exists test below. +#: Running it through that test would refuse every root filing and quietly kill a shipped +#: feature, which is the same silent no-op this wave exists to remove. Spelled `ROOT_PLACEMENT` +#: in `aios_grid` and `ROOT_FOLDER_ID` in `customer-grid/folders.ts`; one constant wearing three +#: spellings is [[a-constant-two-features-share]], and the third is here only because this +#: module may not import the first. +ROOT_FOLDER_ID = '__root__' + +#: Ids are truncated exactly as `aios_grid.clean_folders` / `clean_item_folders` truncate them. +#: ⚠ THE LENGTHS MUST AGREE: a `parent` cut shorter than the `id` it names stops matching it, +#: and the child silently reparents to the root instead of raising anything. +_MAX_FOLDER_ID = 80 +_MAX_ITEM_ID = 120 +_MAX_FOLDER_NAME = 80 + + +class FolderTreeError(ValueError): + """The folder tree handed to `save_folders` is illegal, and NOTHING was written. + + ⛔ IT RAISES RATHER THAN RETURNING FALSE, and that is the entire reason the class exists. + The one caller chain is `core/grid_events.py` -> `api/routes_grid.py`, where a falsy return + becomes HTTP 200 with no toast: the user drags a folder into its own child, the rail springs + back, and the app says nothing at all. A refusal a caller can mistake for success is the + defect class this wave is fixing elsewhere, so this one cannot be mistaken for anything. + + Subclasses `ValueError` so that a broad handler already wrapping this layer keeps behaving, + while `except FolderTreeError` gets the offending folder without parsing a string: + + `folder_id` / `folder_name` WHICH folder broke the rule, ready for a toast + `reason` 'cycle' or 'too_deep' + `surface` 'views' or 'cohorts'; internal, never user-facing copy + `depth` the illegal depth, on 'too_deep' only + + `str(exc)` IS user-facing copy and honours contract C7: no em dash, no en dash, rewritten + rather than merely stripped, and it names the folder the user has to move. + """ + + def __init__(self, message, *, folder_id='', folder_name='', reason='', surface='', + depth=None): + super().__init__(message) + self.message = message + self.folder_id = folder_id + self.folder_name = folder_name + self.reason = reason + self.surface = surface + self.depth = depth + + +def _folder_label(row): + """What to CALL a folder in a refusal the user is going to read: its name, or its id when it + has no name. `clean_folders` guarantees a name; this function belongs to the wall, and the + wall guarantees itself.""" + row = row if isinstance(row, dict) else {} + name = ' '.join(str(row.get('name') or '').split())[:_MAX_FOLDER_NAME] + return name or str(row.get('id') or '').strip()[:_MAX_FOLDER_ID] or 'this folder' + + +def validate_folder_tree(folders, item_folders): + """R1 + C9 — the legal folder tree behind `save_folders`, or a raise. Pure: no store, no I/O. + + Returns `(folders, item_folders, repairs)`. The first two are SAFE TO STORE; `repairs` lists + every change made to the payload, so a caller can tell the user what moved instead of + letting a folder reappear somewhere else with no explanation. + + THE THREE OUTCOMES, and the line between them is the ticket's: + + REFUSE, by raising `FolderTreeError` — a cycle of any length, including a folder parented + to itself, and any chain deeper than `MAX_FOLDER_DEPTH`. Both name the folder. + REPAIR, recorded in `repairs` — a `parent` naming a folder that is not there, a placement + naming a folder that is not there, a malformed row. ⚠ A DANGLING PARENT IS NOT REFUSED, + and the reason is that DELETING A FOLDER CREATES ONE: refusing would leave the user with + a rail that can never be saved again, and `aios_grid.clean_item_folders` already answers + the same question the same way for placements ("prune, never invent"). The orphan falls + to the root, where it is visible and one drag from home. + KEEP, byte for byte — everything else. ⛔ A payload carrying no `parent` anywhere is + TODAY'S PRODUCTION SHAPE and must come back out of here identical to how it went in, + with zero repairs and no raise. Every folder in it is a root, at depth 1. + + ⚠ CYCLES ARE CHECKED, ACROSS EVERY SURFACE, BEFORE ANY DEPTH IS. An eleven-folder loop is + over the cap AND a cycle; reporting it as "too deep" would send the user hunting for a level + to delete when what they actually have is a loop, and deleting a level would not fix it. + + ⚠ THE TWO SURFACES ARE TWO SEPARATE TREES here ('views' and 'cohorts'), so a `parent` is + resolved only against its own surface. `aios_grid.workspace_wire` merges cohort folders into + the views surface at SERVE time, long after this runs, and a merge cannot introduce a cycle + it did not already contain: it only ever appends folders whose ids are not already taken. + """ + repairs = [] + out_folders = {} + known = {} # surface -> {folder_id: the row we will store} + parent_of = {} # surface -> {folder_id: parent_id or None} + + # ---- pass 1: shape. Ids, duplicates, and a `parent` resolved against the finished set. + for raw_surface, rows in (folders or {}).items(): + surface = str(raw_surface) + if not isinstance(rows, list): + # Unusable, and storing it would hand the same crash to the next reader instead. + repairs.append({'reason': 'surface_not_a_list', 'surface': surface}) + continue + clean_rows, by_id = [], {} + for row in rows: + if not isinstance(row, dict): + repairs.append({'reason': 'row_not_a_folder', 'surface': surface}) + continue + fid = str(row.get('id') or '').strip()[:_MAX_FOLDER_ID] + if not fid: + # `clean_folders`' words: an unidentified folder cannot be shown or edited. + repairs.append({'reason': 'folder_without_id', 'surface': surface}) + continue + if fid in by_id: + # ⛔ Refused rather than merged: two rows under one id make "who is my parent" + # ambiguous, and an ambiguous parent graph is not a tree at all. + repairs.append({'reason': 'duplicate_folder_id', 'surface': surface, + 'folder_id': fid}) + continue + clean = dict(row) # every other key rides through untouched (order, icon…) + clean['id'] = fid + clean_rows.append(clean) + by_id[fid] = clean + out_folders[surface] = clean_rows + known[surface] = by_id + + # ⚠ A SECOND LOOP, and it has to be: a child may be listed BEFORE its parent, so + # "does this parent exist" is only answerable once the whole surface has been read. + links = {} + for fid, row in by_id.items(): + if 'parent' not in row: + links[fid] = None # absent means root; leave the key absent + continue + raw = row.get('parent') + if raw is None: + links[fid] = None + continue + if not isinstance(raw, str): + row['parent'] = None + links[fid] = None + repairs.append({'reason': 'parent_not_an_id', 'surface': surface, + 'folder_id': fid}) + continue + pid = raw.strip()[:_MAX_FOLDER_ID] + if not pid: + row['parent'] = None + links[fid] = None + continue + if pid == ROOT_FOLDER_ID: + # The root sentinel is a legal way to say "top level", not a missing folder. + row['parent'] = None + links[fid] = None + repairs.append({'reason': 'parent_is_root_sentinel', 'surface': surface, + 'folder_id': fid}) + continue + if pid not in by_id: + row['parent'] = None + links[fid] = None + repairs.append({'reason': 'parent_missing', 'surface': surface, + 'folder_id': fid, 'parent_id': pid}) + continue + row['parent'] = pid + links[fid] = pid + parent_of[surface] = links + + # ---- pass 2: cycles, every surface, before any depth is computed. + for surface, links in parent_of.items(): + by_id = known.get(surface) or {} + settled = set() # proven to reach a root without looping + for start in links: + if start in settled: + continue + path, walked = set(), [] + node = start + while node is not None and node not in settled: + if node in path: + row = by_id.get(node) or {} + label = _folder_label(row) + raise FolderTreeError( + f'The folder "{label}" is inside itself, so this folder tree cannot be ' + f'saved. Move it out of its own subfolder and try again.', + folder_id=node, folder_name=str(row.get('name') or ''), + reason='cycle', surface=surface) + path.add(node) + walked.append(node) + node = links.get(node) + settled.update(walked) + + # ---- pass 3: depth. Safe to walk now, because no chain loops. + for surface, links in parent_of.items(): + by_id = known.get(surface) or {} + depth_of = {} + for start in links: + if start in depth_of: + continue + chain = [] + node = start + while node is not None and node not in depth_of: + chain.append(node) + node = links.get(node) + base = depth_of[node] if node is not None else 0 + for step, fid in enumerate(reversed(chain), start=1): # ancestor first + depth_of[fid] = base + step + over = [fid for fid, d in depth_of.items() if d > MAX_FOLDER_DEPTH] + if over: + # ⚠ NAME THE SHALLOWEST OFFENDER, not an arbitrary one. In a 15-deep chain every + # folder from 11 down is over the cap, but only the FIRST one is the one the user + # has to move, and moving it lifts every descendant back inside the cap with it. + fid = min(over, key=lambda k: (depth_of[k], list(depth_of).index(k))) + row = by_id.get(fid) or {} + label = _folder_label(row) + depth = depth_of[fid] + raise FolderTreeError( + f'The folder "{label}" would sit {depth} levels deep. Folders nest up to ' + f'{MAX_FOLDER_DEPTH} levels, so move it higher up and try again.', + folder_id=fid, folder_name=str(row.get('name') or ''), + reason='too_deep', surface=surface, depth=depth) + + # ---- pass 4: placements. ONE folder per item, and the folder has to exist. + out_placed = {} + for raw_surface, placements in (item_folders or {}).items(): + surface = str(raw_surface) + if not isinstance(placements, dict): + repairs.append({'reason': 'placements_not_a_map', 'surface': surface}) + continue + by_id = known.get(surface) or {} + kept = {} + for item_id, target in placements.items(): + key = str(item_id or '').strip()[:_MAX_ITEM_ID] + if not key: + repairs.append({'reason': 'placement_without_item', 'surface': surface}) + continue + if not isinstance(target, str): + repairs.append({'reason': 'placement_not_an_id', 'surface': surface, + 'item_id': key}) + continue + fid = target.strip()[:_MAX_FOLDER_ID] + if fid != ROOT_FOLDER_ID and fid not in by_id: + # Dropped, not refused: this is how a DELETED folder's contents fall back to the + # root rather than wedging every later save. `clean_item_folders`' posture. + repairs.append({'reason': 'placement_missing_folder', 'surface': surface, + 'item_id': key, 'folder_id': fid}) + continue + if key in kept and kept[key] != fid: + # ⭐ R1, THE OTHER HALF: a view lives in EXACTLY ONE folder. A `{item: folder}` + # map is single-valued by construction, so the only way to reach here is two + # distinct long ids colliding on the same truncated key. The FIRST wins, and the + # second is reported rather than silently overwriting it. + repairs.append({'reason': 'item_in_two_folders', 'surface': surface, + 'item_id': key, 'folder_id': fid}) + continue + kept[key] = fid + out_placed[surface] = kept + + return out_folders, out_placed, repairs + + +class TableStore: + """The six store operations for one table object's workspace, closed over its store key. + + `st` (wave 18, C3-UT) is the STORE HANDLE — anything exposing `get(name)` / + `update(name, fn, flush=)`. Default = `core.store` (tenant #0, every existing caller, + zero behaviour change). The API passes the session's `TenantRuntime`, whose accessors + apply the tenant prefix / repo binding — which is what makes a user table created by a + Nurilab admin land in Nurilab's store instead of Royal's. + """ + + def __init__(self, table_key, st=None): + self.table_key = table_key + self._st = st if st is not None else store + + @property + def st(self): + """The bound store handle — for SIBLING registries (core/shares) that must read the + same tenant's buckets this workspace lives in (wave 21, C1).""" + return self._st + + def find_view(self, view_id): + """`(owner_username, view)` for a view living in ANY personal stratum, else None. + + ⭐ Wave 21 (item 9, C1): the R10 grant registry names bare ids, so projecting a granted + view means locating the OWNER's record inside this topic's bucket. Personal strata + only — the `__shared__` bucket has its own read path (`shared_views`), and serving one + view from two finders is how two copies drift.""" + vid = str(view_id or '').strip() + if not vid: + return None + try: + data = self._st.get(self.table_key) or {} + except Exception: + return None + for username, ws in data.items(): + if username == SHARED_KEY or not isinstance(ws, dict): + continue + v = (ws.get('views') or {}).get(vid) + if isinstance(v, dict): + return str(username), dict(v) + return None + + def find_folder(self, folder_id): + """`(owner_username, folder_row, {view_id: view})` for a VIEWS folder living in any + personal stratum, else None. `find_view`'s sibling, and here for the same reason. + + ⭐ D-37 (wave 20's R10 remainder, closed 2026-08-05): the grant registry accepts kind + `folder` and has since wave 20, but only the VIEW kind was ever projected — so "share + this folder with Karen" recorded a row, listed under Shared with me, and put nothing on + Karen's screen. Projecting a folder means two lookups the view path does not need: WHO + owns it, and WHICH views are filed in it. Folder membership lives in the owner's + `itemFolders` map (item id -> folder id), never on the view record, so the views are + found by asking that map rather than by reading a list off the folder. + + Views only (`folders['views']`): the cohort surface has its own store and its own + sharing question, and answering both here would make one function mean two things. + """ + fid = str(folder_id or '').strip() + if not fid: + return None + try: + data = self._st.get(self.table_key) or {} + except Exception: + return None + for username, ws in data.items(): + if username == SHARED_KEY or not isinstance(ws, dict): + continue + rows = (ws.get('folders') or {}).get('views') or [] + hit = next((f for f in rows + if isinstance(f, dict) and str(f.get('id') or '') == fid), None) + if not hit: + continue + # ⚠ `itemFolders` IS KEYED BY SURFACE FIRST (`{'views': {itemId: folderId}, …}`) — + # reading item ids off the top level finds the surface names instead and matches + # nothing, so the projection silently returns an EMPTY folder and the feature looks + # exactly as broken as it was before the fix. Caught by this change's own gate, + # which is the entire argument for writing one. + placed = (ws.get('itemFolders') or {}).get('views') or {} + views = ws.get('views') or {} + inside = {str(vid): dict(v) for vid, v in views.items() + if isinstance(v, dict) and str(placed.get(str(vid)) or '') == fid} + return str(username), dict(hit), inside + return None + + # ---------------------------------------------------------------- read + def workspace(self, username, consume_corrections=True): + """One user's durable workspace: always the full three-strata shape.""" + try: + data = self._st.get(self.table_key) or {} + ws = data.get(username, {}) or {} + # A collision acknowledgement is protocol state, not part of a field definition. + # Consume it with the first fresh workspace payload after the correcting write, + # then splice a bounded copy into that payload only. Keeping it out of `fields` + # prevents an old request id surviving forever and overriding a later rename. + corrections = {} + if consume_corrections and ws.get('fieldCorrections'): + def _take(current): + current_ws = current.get(username) or {} + pending = current_ws.get('fieldCorrections') or {} + corrections.update({ + str(key)[:80]: dict(value) + for key, value in pending.items() + if isinstance(value, dict) + }) + current_ws.pop('fieldCorrections', None) + return current + + data = self._st.update(self.table_key, _take, flush='async') + ws = (data or {}).get(username, {}) or {} + except Exception: + data = {} + ws = {} + corrections = {} + fields = { + key: dict(value) if isinstance(value, dict) else value + for key, value in (ws.get('fields') or {}).items() + } + # ⭐⭐ W36-T25 — THE TENANT-WIDE COLUMN SUMMARY, MERGED OVER THIS USER'S STRATUM. + # ⛔ IT MUST BE ABLE TO CREATE AN ENTRY, not only decorate one, and that is the whole + # reason this is a merge rather than a lookup: the colleague who never touched the column + # has NO per-user record for it, so a decorate-only pass would have left them with exactly + # the blank totals row the owner reported. `aios_grid.workspace_wire` reads `meta['agg']` + # off whatever is here, base column or custom one alike. + for key, shared in _shared_fields(data).items(): + agg = str((shared or {}).get('agg') or '').strip() + if not agg: + continue + entry = fields.get(key) + fields[key] = {**entry, 'agg': agg} if isinstance(entry, dict) else {'agg': agg} + for key, ack in corrections.items(): + field = fields.get(key) + accepted_label = str(ack.get('label') or '')[:120] + requested_label = str(ack.get('labelCorrectedFrom') or '')[:120] + correction_id = str(ack.get('labelCorrectionId') or '')[:180] + # A newer field write clears/replaces the pending ack in the SAME transaction. + # The label check is an extra belt against ever attaching a stale ack to a newer + # definition if a future store implementation weakens that ordering. + if (isinstance(field, dict) and accepted_label + and str(field.get('label') or '') == accepted_label + and requested_label and correction_id): + field['labelCorrectedFrom'] = requested_label + field['labelCorrectionId'] = correction_id + out = { + 'views': dict(ws.get('views') or {}), + 'fields': fields, + 'overlays': dict(ws.get('overlays') or {}), + # wave-8 I11 (C4): folders over the saved views / cohorts sidebars. A FOURTH + # stratum rather than a key on each item — see aios_grid.clean_folders for why + # (a cohort lives in another store, and filing is an organising act, not part of + # what a view is). Absent for every workspace saved before this wave, which is + # exactly "no folders yet". + 'folders': dict(ws.get('folders') or {}), + 'itemFolders': dict(ws.get('itemFolders') or {}), + } + # 2026-07-31 (owner item 3): WHERE THE USER LEFT OFF survives a new browser. The + # client's localStorage copy wins when present; this is the server's answer for a + # fresh profile, which used to fall all the way to the system default view. + if ws.get('activeViewId'): + out['activeViewId'] = str(ws['activeViewId']) + # Wave 2026-08-02 (C-LAYOUT): the per-user record-detail field order. A fifth + # stratum, absent until the user first reorders — exactly "default order". + if isinstance(ws.get('recordLayout'), dict): + out['recordLayout'] = dict(ws['recordLayout']) + return out + + # ---------------------------------------------------------------- write + def _update(self, username, change, shared=None): + """Apply `change(ws)` to this user's stratum, and `shared(shared_fields)` to the + tenant-wide one, in ONE transaction. + + ⭐ W36-T25 — `shared` IS A SECOND CALLBACK RATHER THAN A SECOND `update`, and that is the + whole reason it exists here instead of at the caller. `save_field` writes a note (per + user) and a column summary (tenant-wide) from ONE payload; two transactions would let the + summary land while the note did not, and the store's own commit is asynchronous, so the + window is real rather than theoretical. One `update`, one flush, one failure mode. + """ + def _up(data): + ws = data.setdefault(username, {}) + ws.setdefault('views', {}) + ws.setdefault('fields', {}) + ws.setdefault('overlays', {}) + ws.setdefault('folders', {}) + ws.setdefault('itemFolders', {}) + change(ws) + if shared is not None: + shared_fields = data.setdefault(SHARED_KEY, {}).setdefault('fields', {}) + before = {name: dict(entry) for name, entry in shared_fields.items() + if isinstance(entry, dict)} + shared(shared_fields) + assert_annotation_only(before, shared_fields) + return data + # flush='async' (wave-7 W3): this is THE hot path — every autosaved filter tweak, + # column note and typed overlay cell lands here inside the component round-trip, and + # the historical synchronous hub commit cost seconds per edit. The mutation applies to + # the in-process cache (read-your-writes for every subsequent render); the hub write + # coalesces in the background. Registry/auth writes elsewhere stay flush='sync'. + return self._st.update(self.table_key, _up, flush='async') + + def rename_choice_values(self, username, change): + """Apply an arbitrary workspace rewrite (wave 20, item 15 / C-RENAME). + + ⚠ NAMED FOR ITS ONE CALLER RATHER THAN EXPOSED AS A GENERIC `mutate`, deliberately. A + public "do anything to the workspace" method is an invitation to put write logic in + callers instead of here, and every OTHER method on this class exists precisely because + that logic belongs in one place. Renaming a choice is the one operation that must touch + three strata AT ONCE — the field's `choices`, the cells in `overlays`, and the views that + filter or colour by the old value — inside a SINGLE transaction, because a rename that + updated the cells and not the filters would leave a saved view matching nothing. + + `change(ws)` receives the whole workspace with every stratum pre-created (see `_update`). + """ + return self._update(username, change) + + def save_active_view(self, username, view_id): + """Remember which view this user last opened (owner item 3, 2026-07-31). + + Presentation state, not authorisation: the READ side re-validates the id against what + the caller may actually see, so a stale or foreign id degrades to the default view + rather than granting anything. Stored per user like every other stratum. + """ + vid = str(view_id or '').strip()[:120] + if not vid or username == SHARED_KEY: + return + + def _set(ws): + ws['activeViewId'] = vid + self._update(username, _set) + + def save_record_layout(self, username, order): + """The per-user RECORD-DETAIL field order (wave 2026-08-02, C-LAYOUT). + + Presentation state for ONE surface — the record modal. Deliberately not view config: + the owner's ask is per-user, not per-view, and it must never reorder grid columns. + The event handler validated keys against the live field set; the wire re-validates at + serve time (aios_grid.workspace_wire), so a deleted field cannot outlive itself here. + An empty order clears the stratum back to "default order". + """ + if username == SHARED_KEY: + return + clean, seen = [], set() + for key in (order or [])[:200]: + key = str(key or '').strip()[:80] + if key and key not in seen: + seen.add(key) + clean.append(key) + + def _set(ws): + if clean: + ws['recordLayout'] = {'order': clean} + else: + ws.pop('recordLayout', None) + + self._update(username, _set) + + def save_folders(self, username, folders, item_folders): + """Replace the folder stratum wholesale (wave-8 I11), once the tree is proven legal. + + Wholesale rather than per-folder because the caller has ALREADY sanitised the complete + picture through aios_grid.clean_folders / clean_item_folders, and those two are + interdependent: a placement is only legal while its folder exists, so committing them + separately would leave a window where a reader sees an item filed into a folder that is + not there yet. One write, one consistent state. + + ⭐⭐ W41-T12 (R1 + C9) — AND IT VALIDATES NOW. That first paragraph used to say the + caller had already VALIDATED, and until wave 41 the shape was flat enough for that to be + harmless. `parent` changes it: a depth cap and a cycle refusal that live only in the + caller are a convenience, the same two here are a wall, and nesting is the first folder + feature where the difference can hang a reader instead of merely looking wrong. + `validate_folder_tree` above carries the rules and the reasoning. + + ⛔ VALIDATION RUNS BEFORE `_update`, NEVER INSIDE ITS CALLBACK. `_update` pre-creates + five strata on the cached document and commits with flush='async', so a raise from + inside the callback would abandon a half-touched transaction whose commit is already in + flight. Raising first means a refused tree leaves the store exactly as it was. + + RETURNS the accepted picture and every repair made along the way: + `{'folders': …, 'itemFolders': …, 'repairs': [{'reason': …, …}]}`. It returned None + before; the return value is what lets a caller say WHY a folder came back at the root. + RAISES `FolderTreeError` on a cycle or an over-deep chain, having written nothing. + """ + tree, placed, repairs = validate_folder_tree(folders, item_folders) + + def _set(ws): + ws['folders'] = tree + ws['itemFolders'] = placed + self._update(username, _set) + # Fresh containers: the caller inspecting its receipt must not be able to reach into + # the document the store is holding and edit it from underneath the cache. + return {'folders': {s: list(rows) for s, rows in tree.items()}, + 'itemFolders': {s: dict(p) for s, p in placed.items()}, + 'repairs': repairs} + + def save_view_order(self, username, order): + """⭐ WAVE-27 item 5 (contract C7) — this user's own ORDER for the views rail. + + Wholesale, like `save_folders` above and for the same reason: the client sends the full + list it is looking at, not a delta, because a partial order cannot say where an UNNAMED + view went. + + ⛔ PER USER, and it belongs in this stratum rather than on the view records themselves. + `aios_grid`'s own folder note argues it out for placements and every word applies: an + arrangement is a per-user ORGANISING act, not part of what a view IS — so keeping it out + of the view config means duplicating, sharing or exporting a view does not drag one + person's rail position along with it. It also means a SHARED view can sit in a different + place for each person who can see it, which is the only coherent answer once two people + share one view. + + An empty list CLEARS the arrangement (back to server order) rather than storing `[]`. + """ + def _set(ws): + clean = [] + seen = set() + for vid in (order or []): + vid = str(vid).strip()[:120] + if vid and vid not in seen: + seen.add(vid) + clean.append(vid) + if clean: + ws['viewOrder'] = clean + else: + ws.pop('viewOrder', None) + self._update(username, _set) + + def shared_views(self, viewer, is_admin=False): + """Every SHARED view this viewer may see, by id (wave-9 I17). + + Read-only and independent of the viewer's own workspace: the caller merges. Returns + only what `_may_see` allows, so a caller cannot accidentally render somebody else's + personal view by forgetting to filter. + """ + try: + bucket = ((self._st.get(self.table_key) or {}).get(SHARED_KEY) or {}).get('views') or {} + except Exception: + return {} + return {vid: dict(v) for vid, v in bucket.items() + if _may_see(v, viewer, is_admin)} + + def shared_view(self, view_id): + """One shared view RAW — no visibility filter. For authorisation decisions only: a + caller must know a view exists and who owns it before it can decide whether the actor + may touch it. Never hand the result to a renderer without checking `_may_see`.""" + try: + return ((self._st.get(self.table_key) or {}).get(SHARED_KEY) or {} + ).get('views', {}).get(str(view_id)) + except Exception: + return None + + def save_view(self, username, view, shared=None, reserved_names=(), is_admin=False): + """Upsert a SavedView into its ONE home — personal workspace or the shared bucket. + + `shared` defaults to reading the view's own permissions (`is_shared`). Whichever home + it lands in, the view is REMOVED from the other, so a view can never exist as two + copies that diverge on the next edit. + + ⚠ AUTHORISATION IS THE CALLER'S JOB and must happen BEFORE this is called — this layer + moves data and does not know who is asking. `_cl_handle_one` is the wall. + """ + view_id = str((view or {}).get('id') or '').strip() + if not view_id: + raise ValueError('view id is required') + if username == SHARED_KEY: + raise ValueError('reserved username') + to_shared = is_shared(view) if shared is None else bool(shared) + requested = dict(view) + accepted = {} + + def _up(data): + # View names are tenant-global: every personal workspace plus the shared bucket. + # This deliberately includes views the actor cannot see. The only disclosed fact + # is that a display name is already taken, while the categorical "no duplicate + # view names" contract remains true when a personal view is later shared. + names = list(reserved_names or ()) + for workspace in data.values(): + if not isinstance(workspace, dict): + continue + names.extend( + value.get('name') + for candidate_id, value in (workspace.get('views') or {}).items() + if candidate_id != view_id and isinstance(value, dict) + ) + payload = dict(requested) + payload['name'] = _unique_name(payload.get('name'), names) + accepted.clear() + accepted.update(payload) + if to_shared: + bucket = data.setdefault(SHARED_KEY, {}) + bucket.setdefault('views', {})[view_id] = payload + # it may have lived in the creator's workspace before being shared + owner = data.get(payload.get('createdBy') or username) or {} + (owner.get('views') or {}).pop(view_id, None) + else: + ws = data.setdefault(username, {}) + ws.setdefault('views', {})[view_id] = payload + (data.get(SHARED_KEY, {}).get('views') or {}).pop(view_id, None) + return data + + self._st.update(self.table_key, _up, flush='async') + return dict(accepted) + + def delete_view(self, username, view_id): + """Delete a custom/list view override. The system all-rows view is guarded by caller. + + Removes from BOTH homes: the caller has already authorised the delete, and leaving a + stale copy in the other bucket would resurrect the view on the next read. + """ + vid = str(view_id) + + def _up(data): + (data.get(username, {}).get('views') or {}).pop(vid, None) + (data.get(SHARED_KEY, {}).get('views') or {}).pop(vid, None) + return data + + self._st.update(self.table_key, _up, flush='async') + + def save_field(self, username, field, reserved_names=(), correction_id=None): + """Persist a column note or a user-created (custom_/measure_) field definition. + + ⭐⭐ W36-T25 — THE COLUMN SUMMARY GOES TO THE TENANT-WIDE STRATUM, EVERYTHING ELSE STAYS + PER USER, in ONE transaction. See `SHARED_FIELD_KEYS` above for the owner's report and for + why the line falls where it does. The returned `accepted` still carries the summary: the + caller is echoing back the field it just stored, and dropping a key from that echo would + tell the client its write was refused ([[read-path-cannot-witness-write-path]]). + """ + key = str((field or {}).get('key') or '').strip() + if not key: + raise ValueError('field key is required') + requested = dict(field) + accepted = {} + + def _save(ws): + names = list(reserved_names or ()) + names.extend( + value.get('label') + for candidate_key, value in (ws.get('fields') or {}).items() + if candidate_key != key and isinstance(value, dict) + ) + payload = dict(requested) + payload.pop('labelCorrectedFrom', None) + payload.pop('labelCorrectionId', None) + requested_label = ' '.join( + str(payload.get('label') or 'Untitled').split())[:120].rstrip() + payload['label'] = _unique_name(requested_label, names) + corrections = ws.setdefault('fieldCorrections', {}) + corrections.pop(key, None) + if payload['label'] != requested_label and correction_id: + corrections[key] = { + 'label': payload['label'], + 'labelCorrectedFrom': requested_label, + 'labelCorrectionId': str(correction_id)[:180], + } + if not corrections: + ws.pop('fieldCorrections', None) + accepted.clear() + accepted.update(payload) + # ⭐⭐ W36-T25 — SPLIT AFTER the label allocation and the correction bookkeeping, so + # both still see the whole payload, and BEFORE the per-user write. + mine, shared_now = split_shared(payload) + shared_write.clear() + shared_write.update(shared_now) + # ⚠ EMPTINESS IS JUDGED ON THE PER-USER HALF. A source column whose ONLY state was a + # summary now has no per-user state at all, and leaving an `{}` override behind is the + # meaningless row `source_override_is_empty` exists to prevent. + if source_override_is_empty(mine): + ws['fields'].pop(key, None) + else: + ws['fields'][key] = mine + + shared_write = {} + + def _share(shared_fields): + # ⛔ A CLEARED SUMMARY MUST REMOVE THE ROW, not leave an empty one: `workspace` treats + # any entry it finds as a live tenant-wide summary, so an `{'agg': ''}` husk would be + # skipped today and become a resurrection hazard the moment the read grows a second + # shared key. Absence is the only honest spelling of "nobody set one". + if shared_write: + shared_fields[key] = {**(shared_fields.get(key) or {}), **shared_write} + else: + shared_fields.pop(key, None) + + self._update(username, _save, shared=_share) + return dict(accepted) + + def duplicate_field(self, username, source_key, new_key, field, + reserved_names=(), correction_id=None): + """Clone a user-created field in ONE store transaction (wave-5 item 1): the new + definition plus — for `custom_` overlay sources only — every stored cell value under + the source key. One transaction, because a def without its values (or values without a + def) is exactly the orphan state delete_field exists to prevent, in reverse. + The caller validated both keys (same created stratum) and stamped the clone's + createdBy; this layer only moves data.""" + source_key = str(source_key or '').strip() + new_key = str(new_key or '').strip() + if not source_key or not new_key or source_key == new_key: + raise ValueError('duplicate_field needs two distinct keys') + requested = dict(field) + accepted = {} + + def _dup(ws): + names = list(reserved_names or ()) + names.extend( + value.get('label') + for candidate_key, value in (ws.get('fields') or {}).items() + if candidate_key != new_key and isinstance(value, dict) + ) + payload = dict(requested) + payload.pop('labelCorrectedFrom', None) + payload.pop('labelCorrectionId', None) + requested_label = ' '.join( + str(payload.get('label') or 'Untitled').split())[:120].rstrip() + payload['label'] = _unique_name(requested_label, names) + corrections = ws.setdefault('fieldCorrections', {}) + corrections.pop(new_key, None) + if payload['label'] != requested_label and correction_id: + corrections[new_key] = { + 'label': payload['label'], + 'labelCorrectedFrom': requested_label, + 'labelCorrectionId': str(correction_id)[:180], + } + if not corrections: + ws.pop('fieldCorrections', None) + accepted.clear() + accepted.update(payload) + # ⭐ W36-T25: a CLONE carries the original's summary, and a summary is the database's + # (see `SHARED_FIELD_KEYS`). Storing it per user here would give the clone a different + # residency from every other column — one door out of three disagreeing about where a + # thing lives is how `save_field` and this function drift. + mine, shared_now = split_shared(payload) + shared_write.clear() + shared_write.update(shared_now) + ws['fields'][new_key] = mine + if source_key.startswith('custom_'): + for row in ws['overlays'].values(): + if isinstance(row, dict) and source_key in row: + row[new_key] = row[source_key] + + shared_write = {} + + def _share(shared_fields): + if shared_write: + shared_fields[new_key] = {**(shared_fields.get(new_key) or {}), **shared_write} + else: + shared_fields.pop(new_key, None) + + self._update(username, _dup, shared=_share) + return dict(accepted) + + def delete_field(self, username, key): + """Delete a USER-CREATED field definition outright (owner gap closed 2026-07-27). + + Only the created strata ever reach here (`custom_` overlay fields, `measure_` formula + columns — the caller enforces the prefix). The stored overlay VALUES for the key are + scrubbed with it: a deleted column's cells must not linger as orphan data that would + silently resurface if the key were ever reused. Views referencing the key self-heal on + their next autosave (an unknown colId is dropped) — the rule every stale key rides. + + ⭐⭐ W36-T25 — AND THE TENANT-WIDE SUMMARY GOES WITH IT, for exactly the reason the + paragraph above gives about cells: an orphan `agg` under a deleted key is state nobody can + see and nobody can clear, and it would attach itself to the next column that happens to + take the key back. ⚠ This is the ONE stratum a per-user delete may reach across accounts, + and it is safe because the summary was never this user's to begin with — deleting the + COLUMN is a tenant-wide act already. + """ + key = str(key or '').strip() + if not key: + return + + def _drop(ws): + ws['fields'].pop(key, None) + for row in ws['overlays'].values(): + if isinstance(row, dict): + row.pop(key, None) + + self._update(username, _drop, shared=lambda shared_fields: shared_fields.pop(key, None)) + + def _tenant_wide_keys(self): + """The columns of THIS table whose values live in the tenant-wide stratum. + + ⭐ THE CHEAP HALF. A table that shares nothing reads one small per-key file (cache-first + per process) and answers the empty set, so `patch_overlay` behaves exactly as it did + before W38-T20 for every database that has no shared column. It is deliberately not + memoised on the instance: `modules/customer_data.py` and `modules/product_data.py` both + hold a MODULE-LEVEL `TABLE_OPS`, so a per-instance cache would serve one request's answer + to the next, and this one decides WHERE a value is written. + ⚠ LENIENT LIKE EVERY OTHER STRATUM READ, and the failure direction is the safe one: an + unreachable shared bucket routes the write to the PER-USER stratum, which is the + pre-ticket behaviour, rather than dropping it. + """ + try: + import core.shared_overlay as shared_overlay + return set(shared_overlay.fields(self.table_key, st=self._st) or ()) + except Exception: # noqa: BLE001 + return set() + + def patch_overlay(self, username, pid, updates): + """Patch only the external editable stratum; never writes to the source system. + + ⭐⭐ W38-T20 / D-423 — A CELL IN A TENANT-WIDE COLUMN GOES TO THE TENANT-WIDE STRATUM, + AND WITHOUT THIS SPLIT THE EDIT SILENTLY DISAPPEARS. The read path layers the shared + stratum OVER the per-user one (it has to: that is what makes every reader see the same + number). This method wrote PER USER. So the sequence was: type a new value, see it accept, + come back, and read the shared value again — the owner's *"I went back and it all got + reseted"*, with a successful 200 at every step and nothing in any log. + + ⛔ IT IS DECIDED BY WHERE THE COLUMN LIVES, NOT BY WHO IS WRITING OR THROUGH WHICH ROUTE. + `modules/product_data._ProductTableStore` has done exactly this since W30-T36 against its + CANONICAL list; the only reason it needed a subclass is that its shared columns are + declared in a contract file. Columns created at runtime cannot be, so the general form + asks the stratum itself. Both doors (`PATCH /customers/{pid}` and `POST /grid/events`) + arrive here through `grid_events._tops(ctx)`, which is why the split belongs at the STORE + and not at either route: intercepting at one leaves the other writing into the shadow. + + ⛔ NO PERMISSION IS ANSWERED HERE. `shared_overlay`'s header is explicit that it is not a + wall, and neither is this: whether this session may write this key is settled upstream by + `EventCtx.hidden_keys`, which `routes_customers._hidden_for` now computes over the MERGED + contract precisely so a grant-governed column is refused before it reaches this line. + """ + clean = dict(updates or {}) + if not clean: + return + wide = self._tenant_wide_keys() + shared = {k: v for k, v in clean.items() if k in wide} + personal = {k: v for k, v in clean.items() if k not in wide} + if shared: + import core.shared_overlay as shared_overlay + # ⚠ `st=self._st`, NEVER the module default. The two strata must resolve to the SAME + # tenant handle, or a value written by one is invisible to the other and the user's + # edit vanishes the moment they save it (`_ProductTableStore` records the same rule). + shared_overlay.put_cells(self.table_key, pid, shared, st=self._st) + if not personal: + return + + def _patch(ws): + ws['overlays'].setdefault(str(int(pid)), {}).update(personal) + + self._update(username, _patch) + + +def make(table_key, st=None): + return TableStore(table_key, st=st)