fsanyoto commited on
Commit
56ab5de
Β·
verified Β·
1 Parent(s): 8cf992a

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
api/routes_customers.py CHANGED
@@ -164,16 +164,17 @@ def _shared_key():
164
  return cl.TABLE_KEY
165
 
166
 
167
- def shared_fields(st=None):
168
  """`{field_key: Field}` β€” the columns this topic shares tenant-wide.
169
 
170
  Unscoped on purpose, exactly as `shared_overlay.fields` is: a shared column's EXISTENCE is
171
  tenant-wide by definition. WHO MAY SEE IT is a separate question, answered one layer up by
172
  `perm_scope.hidden_keys` (the per-field grant wall T16 landed), and WHOSE ROWS by `cells`.
173
  """
174
- from core import shared_overlay
175
- try:
176
- return shared_overlay.fields(_shared_key(), st=st)
 
177
  except Exception: # noqa: BLE001
178
  # Lenient like every other display read: an unreachable store degrades to "nothing is
179
  # shared yet", never to a 500 on a grid that would otherwise render. The WALL does not
@@ -211,8 +212,8 @@ def _merge_shared_fields(fields, defs):
211
  if not defs:
212
  return fields
213
  have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
214
- return list(fields or ()) + [dict(f, source="overlay")
215
- for k, f in defs.items() if k not in have]
216
 
217
 
218
  def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "",
 
164
  return cl.TABLE_KEY
165
 
166
 
167
+ def shared_fields(st=None):
168
  """`{field_key: Field}` β€” the columns this topic shares tenant-wide.
169
 
170
  Unscoped on purpose, exactly as `shared_overlay.fields` is: a shared column's EXISTENCE is
171
  tenant-wide by definition. WHO MAY SEE IT is a separate question, answered one layer up by
172
  `perm_scope.hidden_keys` (the per-field grant wall T16 landed), and WHOSE ROWS by `cells`.
173
  """
174
+ from core import field_permissions, shared_overlay
175
+ try:
176
+ field_permissions.migrate_legacy_fields(_shared_key(), st=st)
177
+ return shared_overlay.fields(_shared_key(), st=st)
178
  except Exception: # noqa: BLE001
179
  # Lenient like every other display read: an unreachable store degrades to "nothing is
180
  # shared yet", never to a 500 on a grid that would otherwise render. The WALL does not
 
212
  if not defs:
213
  return fields
214
  have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
215
+ return list(fields or ()) + [dict(f, source="overlay", shared=True)
216
+ for k, f in defs.items() if k not in have]
217
 
218
 
219
  def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "",
api/routes_grid.py CHANGED
@@ -176,13 +176,15 @@ def workspace(scope: str = "customer",
176
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
177
  try:
178
  from core import users as _users
179
- workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
 
180
  workspace["userAvatars"] = {k: v for k, v in
181
  _users.avatar_map(tenant=session.tenant).items()
182
  if str(v).startswith("data:image/")}
183
- except Exception:
184
- workspace["userOptions"] = []
185
- workspace["userAvatars"] = {}
 
186
  workspace["scopeKey"] = scope
187
  # ⭐ W31-T20 β€” R6's SECOND SENTENCE REACHES THE BROWSER. On a read-through grid too large
188
  # for one window the pid set is empty, so cohort membership and a shared view's
@@ -225,13 +227,15 @@ def workspace(scope: str = "customer",
225
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
226
  try:
227
  from core import users as _users
228
- workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
 
229
  workspace["userAvatars"] = {k: v for k, v in
230
  _users.avatar_map(tenant=session.tenant).items()
231
  if str(v).startswith("data:image/")}
232
- except Exception:
233
- workspace["userOptions"] = []
234
- workspace["userAvatars"] = {}
 
235
  workspace["scopeKey"] = scope
236
  return {"workspace": workspace}
237
 
@@ -296,16 +300,18 @@ def workspace(scope: str = "customer",
296
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
297
  try:
298
  from core import users as _users
299
- workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
 
300
  # Wave 14 C-AVATAR β€” the options vocabulary's companion: display name -> data URL.
301
  # Absent entries fall back to the client's initials disc; a non-data: value is
302
  # dropped (defence in depth beside the write-side wall in routes_auth).
303
  workspace["userAvatars"] = {k: v for k, v in
304
  _users.avatar_map(tenant=session.tenant).items()
305
  if str(v).startswith("data:image/")}
306
- except Exception:
307
- workspace["userOptions"] = []
308
- workspace["userAvatars"] = {}
 
309
 
310
  # THE SURFACE STAMP β€” a MIRROR of the host's own (`_table_grid`'s hide/cohort stamps).
311
  # ⚠ Deliberately NOT `hideViews`: `cohortMode` is what swaps the Views panel for the cohort
 
176
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
177
  try:
178
  from core import users as _users
179
+ workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
180
+ workspace["permissionUserOptions"] = _users.assignable_identities(tenant=session.tenant)
181
  workspace["userAvatars"] = {k: v for k, v in
182
  _users.avatar_map(tenant=session.tenant).items()
183
  if str(v).startswith("data:image/")}
184
+ except Exception:
185
+ workspace["userOptions"] = []
186
+ workspace["permissionUserOptions"] = []
187
+ workspace["userAvatars"] = {}
188
  workspace["scopeKey"] = scope
189
  # ⭐ W31-T20 β€” R6's SECOND SENTENCE REACHES THE BROWSER. On a read-through grid too large
190
  # for one window the pid set is empty, so cohort membership and a shared view's
 
227
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
228
  try:
229
  from core import users as _users
230
+ workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
231
+ workspace["permissionUserOptions"] = _users.assignable_identities(tenant=session.tenant)
232
  workspace["userAvatars"] = {k: v for k, v in
233
  _users.avatar_map(tenant=session.tenant).items()
234
  if str(v).startswith("data:image/")}
235
+ except Exception:
236
+ workspace["userOptions"] = []
237
+ workspace["permissionUserOptions"] = []
238
+ workspace["userAvatars"] = {}
239
  workspace["scopeKey"] = scope
240
  return {"workspace": workspace}
241
 
 
300
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
301
  try:
302
  from core import users as _users
303
+ workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
304
+ workspace["permissionUserOptions"] = _users.assignable_identities(tenant=session.tenant)
305
  # Wave 14 C-AVATAR β€” the options vocabulary's companion: display name -> data URL.
306
  # Absent entries fall back to the client's initials disc; a non-data: value is
307
  # dropped (defence in depth beside the write-side wall in routes_auth).
308
  workspace["userAvatars"] = {k: v for k, v in
309
  _users.avatar_map(tenant=session.tenant).items()
310
  if str(v).startswith("data:image/")}
311
+ except Exception:
312
+ workspace["userOptions"] = []
313
+ workspace["permissionUserOptions"] = []
314
+ workspace["userAvatars"] = {}
315
 
316
  # THE SURFACE STAMP β€” a MIRROR of the host's own (`_table_grid`'s hide/cohort stamps).
317
  # ⚠ Deliberately NOT `hideViews`: `cohortMode` is what swaps the Views panel for the cohort
api/routes_products.py CHANGED
@@ -336,10 +336,11 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
336
  from core import grid_events
337
 
338
  pids, team_id, rows_src, fields_base = scoped_pool(session)
 
339
 
340
  ctx = grid_events.EventCtx(
341
  uname=session.uname, allowed_pids=pids, fields=[],
342
- hidden_keys=perm_scope.hidden_keys(session.user, MODULE, fields_base),
343
  admin=session.admin, fallback_ws=None, seen_ids={},
344
  scope_key="product", table=pd.table_ops(session.runtime))
345
  ws = grid_events.table_workspace(ctx, allowed_pids=pids,
@@ -364,10 +365,18 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
364
  ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
365
  fields_base=fields_base)
366
 
367
- hidden = perm_scope.hidden_keys(session.user, MODULE, fields)
 
 
 
 
368
  if hidden:
369
  fields = [f for f in fields if f.get("key") not in hidden]
370
  rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src]
 
 
 
 
371
 
372
  today = time.strftime("%Y-%m-%d")
373
  stamp = _measure_stamp(session.runtime, team_id)
@@ -440,8 +449,7 @@ def patch_product(pid: int, body: dict = Body(default=None),
440
  uname=session.uname, allowed_pids=g["pids"], fields=g["fields"],
441
  admin=session.admin, fallback_ws=None, seen_ids={},
442
  hidden_keys=perm_scope.hidden_keys(
443
- session.user, MODULE,
444
- pd_fields(consolidated=g["team_id"] is None, st=session.runtime)),
445
  scope_key="product", table=pd.table_ops(session.runtime))
446
  try:
447
  grid_events.handle_one(
@@ -572,6 +580,9 @@ def pd_fields(consolidated=True, st=None):
572
  import aios_grid
573
  import modules.product_data as pd
574
 
 
 
 
575
  doc = json.loads((Path(aios_grid.__file__).resolve().parent /
576
  "aios_grid_fields.json").read_text(encoding="utf-8"))
577
  fields = list((doc.get("product_data") or {}).get("fields") or [])
@@ -589,7 +600,7 @@ def pd_fields(consolidated=True, st=None):
589
  "key": PRODUCT_IMAGE_KEY, "label": "Image", "type": "image",
590
  "source": "overlay", "default": True, "custom": True,
591
  "preset": False, "shared": True, "createdBy": pd.IMAGE_FIELD_CREATOR,
592
- "permissions": {"edit": "everyone"},
593
  "note": "The product's picture. Empty shows the SKU's own master image; upload one "
594
  "from the record panel to override it.",
595
  }
 
336
  from core import grid_events
337
 
338
  pids, team_id, rows_src, fields_base = scoped_pool(session)
339
+ shared_defs = pd.shared_fields(st=session.runtime)
340
 
341
  ctx = grid_events.EventCtx(
342
  uname=session.uname, allowed_pids=pids, fields=[],
343
+ hidden_keys=perm_scope.hidden_keys(session.user, MODULE, fields_base, st=session.runtime),
344
  admin=session.admin, fallback_ws=None, seen_ids={},
345
  scope_key="product", table=pd.table_ops(session.runtime))
346
  ws = grid_events.table_workspace(ctx, allowed_pids=pids,
 
365
  ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
366
  fields_base=fields_base)
367
 
368
+ have = {field.get("key") for field in fields if isinstance(field, dict)}
369
+ fields = list(fields) + [dict(field, source="overlay", shared=True)
370
+ for key, field in shared_defs.items() if key not in have]
371
+
372
+ hidden = perm_scope.hidden_keys(session.user, MODULE, fields, st=session.runtime)
373
  if hidden:
374
  fields = [f for f in fields if f.get("key") not in hidden]
375
  rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src]
376
+ ws["overlays"] = {
377
+ str(pid): {key: value for key, value in (row or {}).items() if key not in hidden}
378
+ for pid, row in (ws.get("overlays") or {}).items()
379
+ }
380
 
381
  today = time.strftime("%Y-%m-%d")
382
  stamp = _measure_stamp(session.runtime, team_id)
 
449
  uname=session.uname, allowed_pids=g["pids"], fields=g["fields"],
450
  admin=session.admin, fallback_ws=None, seen_ids={},
451
  hidden_keys=perm_scope.hidden_keys(
452
+ session.user, MODULE, g["fields"], st=session.runtime),
 
453
  scope_key="product", table=pd.table_ops(session.runtime))
454
  try:
455
  grid_events.handle_one(
 
580
  import aios_grid
581
  import modules.product_data as pd
582
 
583
+ if st is not None:
584
+ pd.shared_fields(st=st)
585
+
586
  doc = json.loads((Path(aios_grid.__file__).resolve().parent /
587
  "aios_grid_fields.json").read_text(encoding="utf-8"))
588
  fields = list((doc.get("product_data") or {}).get("fields") or [])
 
600
  "key": PRODUCT_IMAGE_KEY, "label": "Image", "type": "image",
601
  "source": "overlay", "default": True, "custom": True,
602
  "preset": False, "shared": True, "createdBy": pd.IMAGE_FIELD_CREATOR,
603
+ "permissions": {"edit": "collaborative"},
604
  "note": "The product's picture. Empty shows the SKU's own master image; upload one "
605
  "from the record panel to override it.",
606
  }
api/routes_tables.py CHANGED
@@ -736,13 +736,16 @@ def _ut_shared_fields(session, table_key, fields):
736
  ⚠ A key the definition already declares WINS. A shared column is an addition to a database's
737
  contract, never a redefinition of a column that database already has.
738
  """
739
- from core import shared_overlay
740
- defs = shared_overlay.fields(table_key, st=session.runtime) or {}
 
 
 
741
  if not defs:
742
  return fields
743
  have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
744
- return list(fields or ()) + [dict(f, source="overlay")
745
- for k, f in defs.items() if k not in have]
746
 
747
 
748
  def _ut_shared_cells(session, table_key, pids):
 
736
  ⚠ A key the definition already declares WINS. A shared column is an addition to a database's
737
  contract, never a redefinition of a column that database already has.
738
  """
739
+ from core import field_permissions, shared_overlay
740
+ field_permissions.migrate_legacy_fields(
741
+ f"{table_key}_table_workspace", st=session.runtime,
742
+ grant_topic=table_key, shared_key=table_key)
743
+ defs = shared_overlay.fields(table_key, st=session.runtime) or {}
744
  if not defs:
745
  return fields
746
  have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
747
+ return list(fields or ()) + [dict(f, source="overlay", shared=True)
748
+ for k, f in defs.items() if k not in have]
749
 
750
 
751
  def _ut_shared_cells(session, table_key, pids):
platform/aios_grid.py CHANGED
@@ -314,13 +314,12 @@ def _clean_format(raw, ftype):
314
  return out or None
315
 
316
 
317
- def _clean_permissions(raw):
318
- """`{edit: 'everyone' | 'creator' | 'admins'}` or None (wave-5 item 1). WHO may set it is
319
- the host handler's business (creator/admin, enforced fail-closed there); this validates
320
- only the shape, like every other property here."""
321
- if isinstance(raw, dict) and raw.get("edit") in ("everyone", "creator", "admins"):
322
- return {"edit": raw["edit"]}
323
- return None
324
 
325
 
326
  #: β›” THE CHARSET MUST ADMIT EVERY TOKEN THE CLIENT ENGINE PARSES, or a legal formula is
 
314
  return out or None
315
 
316
 
317
+ def _clean_permissions(raw):
318
+ """Canonical field edit permissions; legacy values remain readable during migration."""
319
+ from core.field_permissions import clean_permissions
320
+ if not isinstance(raw, dict):
321
+ return None
322
+ return clean_permissions(raw, fallback="personal")
 
323
 
324
 
325
  #: β›” THE CHARSET MUST ADMIT EVERY TOKEN THE CLIENT ENGINE PARSES, or a legal formula is
platform/core/field_permissions.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical permissions and migration for user-created field definitions.
2
+
3
+ Field definitions used to carry ``everyone|creator|admins`` while their definitions and
4
+ values remained in one user's workspace. That combination made "Everyone" a visual promise
5
+ that could never be true. This module is the single normalization point for the new model:
6
+ personal, collaborative, or specific users.
7
+ """
8
+
9
+ import core.shared_overlay as shared_overlay
10
+ import core.shares as shares
11
+
12
+
13
+ FIELD_EDIT_MODES = ("personal", "collaborative", "users")
14
+ MAX_FIELD_USERS = 50
15
+ _ALIASES = {"everyone": "collaborative", "creator": "personal", "admins": "personal"}
16
+
17
+
18
+ def clean_permissions(raw, fallback="personal", known_users=None):
19
+ """Return a small, canonical field permission bag or ``None`` for malformed input."""
20
+ if fallback not in FIELD_EDIT_MODES:
21
+ fallback = "personal"
22
+ if not isinstance(raw, dict):
23
+ return {"edit": fallback}
24
+ edit = _ALIASES.get(str(raw.get("edit") or "").strip().lower(),
25
+ str(raw.get("edit") or "").strip().lower())
26
+ if edit not in FIELD_EDIT_MODES:
27
+ edit = fallback
28
+ if edit != "users":
29
+ return {"edit": edit}
30
+ allowed = None if known_users is None else {str(u).strip().lower() for u in known_users if str(u).strip()}
31
+ result = []
32
+ seen = set()
33
+ for value in (raw.get("users") if isinstance(raw.get("users"), list) else [])[:MAX_FIELD_USERS]:
34
+ user = str(value or "").strip().lower()
35
+ if not user or user in seen or (allowed is not None and user not in allowed):
36
+ continue
37
+ seen.add(user)
38
+ result.append(user)
39
+ return {"edit": "users", "users": result} if result else {"edit": "personal"}
40
+
41
+
42
+ def stored_permissions(field, fallback="collaborative", known_users=None):
43
+ """Read a stored field without retroactively making old fields private."""
44
+ if not isinstance(field, dict):
45
+ return {"edit": fallback}
46
+ return clean_permissions(field.get("permissions"), fallback=fallback,
47
+ known_users=known_users)
48
+
49
+
50
+ def grant_entries(permissions):
51
+ """Translate the field model into the registry's explicit edit grants."""
52
+ mode = (permissions or {}).get("edit")
53
+ if mode == "collaborative":
54
+ return [{"user": shares.EVERYONE, "role": "edit"}]
55
+ if mode == "users":
56
+ return [{"user": user, "role": "edit"}
57
+ for user in (permissions.get("users") or [])]
58
+ return []
59
+
60
+
61
+ def _field_is_migratable(field):
62
+ return (isinstance(field, dict) and field.get("custom") is True
63
+ and field.get("source") == "overlay")
64
+
65
+
66
+ def migrate_legacy_fields(table_key, st=None, known_users=None, grant_topic=None, shared_key=None):
67
+ """Promote legacy shared-looking custom fields into the shared field stratum.
68
+
69
+ The operation is intentionally idempotent and copy-before-remove. A failed request can
70
+ therefore leave a private shadow behind, but it cannot lose the creator's values. The next
71
+ read retries the promotion and the shared definition wins.
72
+ """
73
+ key = str(table_key or "").strip()
74
+ grant_key = str(grant_topic or key).strip()
75
+ shared_key = str(shared_key or key).strip()
76
+ if not key or st is None:
77
+ return {"promoted": 0, "normalized": 0}
78
+ try:
79
+ document = st.get(key) or {}
80
+ except Exception:
81
+ return {"promoted": 0, "normalized": 0}
82
+ if not isinstance(document, dict):
83
+ return {"promoted": 0, "normalized": 0}
84
+
85
+ promoted = 0
86
+ normalized = 0
87
+ remove = {}
88
+ for owner, workspace in document.items():
89
+ if owner == "__shared__" or not isinstance(workspace, dict):
90
+ continue
91
+ fields = workspace.get("fields") or {}
92
+ if not isinstance(fields, dict):
93
+ continue
94
+ for field_key, original in list(fields.items()):
95
+ if not _field_is_migratable(original):
96
+ continue
97
+ permissions = stored_permissions(original, fallback="collaborative",
98
+ known_users=known_users)
99
+ if permissions["edit"] == "personal":
100
+ if original.get("permissions") != permissions:
101
+ def _stamp(data, owner=owner, field_key=field_key, permissions=permissions):
102
+ ws = data.get(owner) or {}
103
+ field = (ws.get("fields") or {}).get(field_key)
104
+ if isinstance(field, dict):
105
+ field["permissions"] = permissions
106
+ return data
107
+ st.update(key, _stamp, flush="sync")
108
+ normalized += 1
109
+ continue
110
+
111
+ existing = shared_overlay.fields(shared_key, st=st).get(field_key)
112
+ if isinstance(existing, dict):
113
+ # A completed promotion is authoritative. Remove a duplicate personal copy
114
+ # only when it has the same creator; a collision from another owner fails closed.
115
+ if str(existing.get("createdBy") or "").lower() != str(original.get("createdBy") or owner).lower():
116
+ continue
117
+ shared = existing
118
+ else:
119
+ shared = dict(original)
120
+ shared.update({"shared": True, "granted": True,
121
+ "permissions": permissions,
122
+ "createdBy": original.get("createdBy") or owner,
123
+ "source": "overlay", "custom": True})
124
+ shared_overlay.put_field(shared_key, field_key, shared, st=st)
125
+ promoted += 1
126
+
127
+ rows = {}
128
+ overlays = workspace.get("overlays") or {}
129
+ if isinstance(overlays, dict):
130
+ for pid, values in overlays.items():
131
+ if isinstance(values, dict) and field_key in values:
132
+ rows[str(pid)] = {field_key: values[field_key]}
133
+ if rows:
134
+ shared_overlay.put_rows(shared_key, rows, st=st)
135
+ shares.set_grants("field", shares.field_oid(grant_key, field_key),
136
+ grant_entries(permissions),
137
+ owner=str(shared.get("createdBy") or owner), st=st)
138
+ remove.setdefault(owner, set()).add(field_key)
139
+
140
+ if remove:
141
+ def _remove(data):
142
+ for owner, keys in remove.items():
143
+ ws = data.get(owner) or {}
144
+ field_defs = ws.get("fields") or {}
145
+ overlays = ws.get("overlays") or {}
146
+ for field_key in keys:
147
+ field_defs.pop(field_key, None)
148
+ if isinstance(overlays, dict):
149
+ for values in overlays.values():
150
+ if isinstance(values, dict):
151
+ values.pop(field_key, None)
152
+ return data
153
+ st.update(key, _remove, flush="sync")
154
+ return {"promoted": promoted, "normalized": normalized}
155
+
156
+
157
+ def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None):
158
+ """Move one personal field and its values into the shared stratum."""
159
+ owner = str(owner or "").strip().lower()
160
+ key = str(field.get("key") or "").strip()
161
+ if not owner or not key or st is None:
162
+ return dict(field)
163
+ shared = dict(field)
164
+ shared.update({"shared": True, "granted": True, "source": "overlay", "custom": True})
165
+ permissions = stored_permissions(shared, fallback="collaborative")
166
+ shared["permissions"] = permissions
167
+ shared["createdBy"] = str(shared.get("createdBy") or owner).strip().lower()
168
+ shared_overlay.put_field(shared_key, key, shared, st=st)
169
+ document = st.get(workspace_key) or {}
170
+ workspace = document.get(owner) if isinstance(document, dict) else {}
171
+ rows = {}
172
+ for pid, values in ((workspace or {}).get("overlays") or {}).items():
173
+ if isinstance(values, dict) and key in values:
174
+ rows[str(pid)] = {key: values[key]}
175
+ if rows:
176
+ shared_overlay.put_rows(shared_key, rows, st=st)
177
+ shares.set_grants("field", shares.field_oid(grant_topic, key),
178
+ grant_entries(permissions), owner=shared["createdBy"], st=st)
179
+
180
+ def _remove(data):
181
+ ws = data.get(owner) or {}
182
+ (ws.get("fields") or {}).pop(key, None)
183
+ for values in (ws.get("overlays") or {}).values():
184
+ if isinstance(values, dict):
185
+ values.pop(key, None)
186
+ return data
187
+ st.update(workspace_key, _remove, flush="sync")
188
+ return shared
189
+
190
+
191
+ def demote_field(workspace_key, shared_key, grant_topic, field, st=None):
192
+ """Move one shared field back to its creator's personal workspace."""
193
+ if not isinstance(field, dict) or st is None:
194
+ return dict(field or {})
195
+ key = str(field.get("key") or "").strip()
196
+ owner = str(field.get("createdBy") or "").strip().lower()
197
+ if not key or not owner:
198
+ return dict(field)
199
+ personal = dict(field)
200
+ personal.pop("shared", None)
201
+ personal.pop("granted", None)
202
+ personal["permissions"] = {"edit": "personal"}
203
+ personal["createdBy"] = owner
204
+ document = st.get(workspace_key) or {}
205
+ shared_values = shared_overlay._read(shared_key, st=st).get("cells") or {}
206
+
207
+ def _add(data):
208
+ ws = data.setdefault(owner, {})
209
+ ws.setdefault("fields", {})[key] = personal
210
+ overlays = ws.setdefault("overlays", {})
211
+ for pid, values in shared_values.items():
212
+ if isinstance(values, dict) and key in values:
213
+ overlays.setdefault(str(pid), {})[key] = values[key]
214
+ return data
215
+ st.update(workspace_key, _add, flush="sync")
216
+ shared_overlay.drop_field(shared_key, key, st=st)
217
+ shares.drop_objects([("field", shares.field_oid(grant_topic, key))], st=st)
218
+ return personal
platform/core/grid_events.py CHANGED
@@ -1186,17 +1186,26 @@ def handle_one(event, ctx):
1186
  'the event carried no field object')
1187
  key = str(raw.get('key') or '')[:80]
1188
  import aios_grid as _ag2
 
1189
  # Runtime-created tenant-wide fields have a definition outside the
1190
  # per-user workspace. Product Image is the first such editable field;
1191
  # keep the opt-in marker so existing shared schema fields retain their
1192
  # own definition doors.
1193
  shared_prior = None
1194
  shared_field = False
 
 
1195
  if ctx.table is not None:
1196
  try:
1197
  import core.shared_overlay as _shared_overlay
 
 
 
 
 
 
1198
  shared_prior = (_shared_overlay.fields(
1199
- ctx.table.table_key, st=ctx.table.st) or {}).get(key)
1200
  shared_field = bool(isinstance(shared_prior, dict)
1201
  and shared_prior.get('custom'))
1202
  except Exception:
@@ -1404,17 +1413,20 @@ def handle_one(event, ctx):
1404
  # existing field between scopes.
1405
  if prior.get('scope') == 'cohort':
1406
  field['scope'] = 'cohort'
1407
- want = _ag2._clean_permissions(raw.get('permissions'))
1408
- have = _ag2._clean_permissions((prior or {}).get('permissions'))
 
 
 
1409
  owner = field.get('createdBy')
1410
- if want is not None and (admin or (owner and owner == uname)):
 
1411
  field['permissions'] = want
1412
  else:
1413
- if have is not None:
1414
- field['permissions'] = have
1415
  # Asked for a permissions change and did not get it: the client's
1416
  # optimistic copy is a lie that must be repainted (wave-6 item 3).
1417
- refused_perms = want is not None and want != have
1418
  # A NEW measure column, or an existing one whose window moved: the VALUES
1419
  # are host-computed, so the next render genuinely differs.
1420
  if key.startswith(_ag2.MEASURE_FIELD_PREFIX):
@@ -1427,14 +1439,31 @@ def handle_one(event, ctx):
1427
  if (candidate_key != key and candidate_key not in _workspace_field_keys
1428
  and isinstance(value, dict))
1429
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
1430
  if shared_field:
1431
  # A shared definition is the source of truth for every tenant
1432
  # member. Persisting this through TableStore.save_field would put
1433
  # the permission back into the creator's private stratum and make
1434
  # the successful UI save invisible to everybody else.
1435
  import core.shared_overlay as _shared_overlay
1436
- field = _shared_overlay.put_field(ctx.table.table_key, key, field,
1437
  st=ctx.table.st)
 
 
 
 
1438
  elif _store_of(ctx).available():
1439
  field = (_tops(ctx).save_field(
1440
  uname, field, reserved_names=_reserved_field_names,
@@ -2090,13 +2119,14 @@ def handle_one(event, ctx):
2090
  # Wave-5 item 1: `permissions.edit` is enforced HERE, per key β€” the client hiding
2091
  # its editor is courtesy, this is the wall. Fail-closed: a restricted field with
2092
  # no readable createdBy admits only admins.
2093
- perms = (field_by_key.get(key) or {}).get('permissions') or {}
 
 
2094
  edit = perms.get('edit')
2095
- if edit == 'admins' and not admin:
2096
- refused = True
2097
- continue
2098
- if edit == 'creator' and not (
2099
- admin or (field_by_key.get(key) or {}).get('createdBy') == uname):
2100
  refused = True
2101
  continue
2102
  # C5-AUTOFIELD (wave 18): an automation cell is MACHINE-WRITTEN. The client marks
 
1186
  'the event carried no field object')
1187
  key = str(raw.get('key') or '')[:80]
1188
  import aios_grid as _ag2
1189
+ from core import field_permissions as _fp
1190
  # Runtime-created tenant-wide fields have a definition outside the
1191
  # per-user workspace. Product Image is the first such editable field;
1192
  # keep the opt-in marker so existing shared schema fields retain their
1193
  # own definition doors.
1194
  shared_prior = None
1195
  shared_field = False
1196
+ shared_storage_key = None
1197
+ grant_topic = None
1198
  if ctx.table is not None:
1199
  try:
1200
  import core.shared_overlay as _shared_overlay
1201
+ table_key = str(ctx.table.table_key)
1202
+ shared_storage_key = (table_key[:-len('_table_workspace')]
1203
+ if table_key.endswith('_table_workspace') else table_key)
1204
+ grant_topic = ('product_data' if table_key == 'product_table_workspace'
1205
+ else 'customer_data' if table_key == 'customer_data'
1206
+ else shared_storage_key)
1207
  shared_prior = (_shared_overlay.fields(
1208
+ shared_storage_key, st=ctx.table.st) or {}).get(key)
1209
  shared_field = bool(isinstance(shared_prior, dict)
1210
  and shared_prior.get('custom'))
1211
  except Exception:
 
1413
  # existing field between scopes.
1414
  if prior.get('scope') == 'cohort':
1415
  field['scope'] = 'cohort'
1416
+ has_permission_request = isinstance(raw.get('permissions'), dict)
1417
+ have = (_fp.stored_permissions(prior, fallback='collaborative')
1418
+ if prior is not None else {'edit': 'personal'})
1419
+ want = (_fp.clean_permissions(raw.get('permissions'), fallback=have['edit'])
1420
+ if has_permission_request else have)
1421
  owner = field.get('createdBy')
1422
+ may_change_permissions = bool(admin or (owner and owner.lower() == uname.lower()))
1423
+ if may_change_permissions:
1424
  field['permissions'] = want
1425
  else:
1426
+ field['permissions'] = have
 
1427
  # Asked for a permissions change and did not get it: the client's
1428
  # optimistic copy is a lie that must be repainted (wave-6 item 3).
1429
+ refused_perms = has_permission_request and want != have
1430
  # A NEW measure column, or an existing one whose window moved: the VALUES
1431
  # are host-computed, so the next render genuinely differs.
1432
  if key.startswith(_ag2.MEASURE_FIELD_PREFIX):
 
1439
  if (candidate_key != key and candidate_key not in _workspace_field_keys
1440
  and isinstance(value, dict))
1441
  ]
1442
+ desired_shared = field.get('permissions', {}).get('edit') in ('collaborative', 'users')
1443
+ if shared_field and not desired_shared and shared_storage_key:
1444
+ # Demotion is a real data move: restore the creator's private definition and values
1445
+ # before dropping the tenant-wide copy, then remove its grant record.
1446
+ field = _fp.demote_field(ctx.table.table_key, shared_storage_key, grant_topic,
1447
+ field, st=ctx.table.st)
1448
+ shared_field = False
1449
+ if not shared_field and desired_shared and shared_storage_key and ctx.table is not None:
1450
+ # Promotion makes the permission choice true for the whole tenant, rather than merely
1451
+ # storing a collaborative label in Anna's private workspace.
1452
+ field = _fp.promote_field(ctx.table.table_key, shared_storage_key, grant_topic,
1453
+ field.get('createdBy') or uname, field, st=ctx.table.st)
1454
+ shared_field = True
1455
  if shared_field:
1456
  # A shared definition is the source of truth for every tenant
1457
  # member. Persisting this through TableStore.save_field would put
1458
  # the permission back into the creator's private stratum and make
1459
  # the successful UI save invisible to everybody else.
1460
  import core.shared_overlay as _shared_overlay
1461
+ field = _shared_overlay.put_field(shared_storage_key, key, field,
1462
  st=ctx.table.st)
1463
+ import core.shares as _shares
1464
+ _shares.set_grants('field', _shares.field_oid(grant_topic, key),
1465
+ _fp.grant_entries(field.get('permissions')),
1466
+ owner=field.get('createdBy') or uname, st=ctx.table.st)
1467
  elif _store_of(ctx).available():
1468
  field = (_tops(ctx).save_field(
1469
  uname, field, reserved_names=_reserved_field_names,
 
2119
  # Wave-5 item 1: `permissions.edit` is enforced HERE, per key β€” the client hiding
2120
  # its editor is courtesy, this is the wall. Fail-closed: a restricted field with
2121
  # no readable createdBy admits only admins.
2122
+ from core import field_permissions as _fp_patch
2123
+ definition = field_by_key.get(key) or {}
2124
+ perms = _fp_patch.stored_permissions(definition, fallback='collaborative')
2125
  edit = perms.get('edit')
2126
+ owner = str(definition.get('createdBy') or '').strip().lower()
2127
+ permitted = (admin or (owner and owner == uname) or edit == 'collaborative' or
2128
+ (edit == 'users' and uname in (perms.get('users') or [])))
2129
+ if not permitted:
 
2130
  refused = True
2131
  continue
2132
  # C5-AUTOFIELD (wave 18): an automation cell is MACHINE-WRITTEN. The client marks
platform/core/users.py CHANGED
@@ -486,7 +486,7 @@ def allowed_bus_labels(user):
486
  return (['All'] + labels) if len(labels) > 1 else labels
487
 
488
 
489
- def assignable_people(tenant=None):
490
  """Display names for `user`-typed overlay columns β€” the tenant's ACTIVE accounts.
491
 
492
  Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
@@ -509,7 +509,14 @@ def assignable_people(tenant=None):
509
  changes β€” and a future caller that forgets gets an empty picker somebody notices instead of a
510
  leak nobody does.
511
  """
512
- return sorted({str(u.get('name') or n) for n, u in _tenant_accounts(tenant)})
 
 
 
 
 
 
 
513
 
514
 
515
  def set_avatar(username, data_url):
 
486
  return (['All'] + labels) if len(labels) > 1 else labels
487
 
488
 
489
+ def assignable_people(tenant=None):
490
  """Display names for `user`-typed overlay columns β€” the tenant's ACTIVE accounts.
491
 
492
  Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
 
509
  changes β€” and a future caller that forgets gets an empty picker somebody notices instead of a
510
  leak nobody does.
511
  """
512
+ return sorted({str(u.get('name') or n) for n, u in _tenant_accounts(tenant)})
513
+
514
+
515
+ def assignable_identities(tenant=None):
516
+ """Canonical username/display-name pairs for durable permission pickers."""
517
+ return [{"username": str(username).strip().lower(),
518
+ "name": str(record.get("name") or username)}
519
+ for username, record in sorted(_tenant_accounts(tenant), key=lambda item: item[0])]
520
 
521
 
522
  def set_avatar(username, data_url):
platform/modules/product_data.py CHANGED
@@ -194,7 +194,7 @@ def image_field_definition(st=None):
194
  'preset': False,
195
  'shared': True,
196
  'createdBy': IMAGE_FIELD_CREATOR,
197
- 'permissions': {'edit': 'everyone'},
198
  'note': "The product's picture. Empty shows the SKU's own master image; upload one "
199
  'from the record panel to override it.',
200
  }
@@ -202,11 +202,19 @@ def image_field_definition(st=None):
202
  if isinstance(existing, dict):
203
  merged = dict(default)
204
  merged.update(existing)
 
205
  # Old deployments may have a stored Image definition without the
206
  # creator/custom markers. Repair the metadata without disturbing
207
  # existing permissions, grants, or cells.
208
  if merged != existing:
209
  shared_overlay.put_field(TABLE_KEY, IMAGE_FIELD_KEY, merged, st=st)
 
 
 
 
 
 
 
210
  return merged
211
 
212
  stored = shared_overlay.put_field(TABLE_KEY, IMAGE_FIELD_KEY, default, st=st)
@@ -215,7 +223,8 @@ def image_field_definition(st=None):
215
  record = shares.grants('field', shares.field_oid('product_data', IMAGE_FIELD_KEY),
216
  st=st)
217
  if not record.get('owner'):
218
- shares.set_grants('field', shares.field_oid('product_data', IMAGE_FIELD_KEY), [],
 
219
  owner=IMAGE_FIELD_CREATOR, st=st)
220
  except Exception:
221
  # The definition is already safe and owner-stamped. A missing grant
@@ -286,6 +295,14 @@ TABLE_OPS = _ProductTableStore(TABLE_KEY)
286
  def table_ops(st=None):
287
  """Bind product workspace operations to the caller's tenant runtime."""
288
  return _ProductTableStore(TABLE_KEY, st=st if st is not None else TABLE_OPS.st)
 
 
 
 
 
 
 
 
289
 
290
 
291
  def shared_cells(pids, st=None):
 
194
  'preset': False,
195
  'shared': True,
196
  'createdBy': IMAGE_FIELD_CREATOR,
197
+ 'permissions': {'edit': 'collaborative'},
198
  'note': "The product's picture. Empty shows the SKU's own master image; upload one "
199
  'from the record panel to override it.',
200
  }
 
202
  if isinstance(existing, dict):
203
  merged = dict(default)
204
  merged.update(existing)
205
+ merged['permissions'] = {'edit': 'collaborative'}
206
  # Old deployments may have a stored Image definition without the
207
  # creator/custom markers. Repair the metadata without disturbing
208
  # existing permissions, grants, or cells.
209
  if merged != existing:
210
  shared_overlay.put_field(TABLE_KEY, IMAGE_FIELD_KEY, merged, st=st)
211
+ try:
212
+ import core.shares as shares
213
+ shares.set_grants('field', shares.field_oid('product_data', IMAGE_FIELD_KEY),
214
+ [{'user': shares.EVERYONE, 'role': 'edit'}],
215
+ owner=IMAGE_FIELD_CREATOR, st=st)
216
+ except Exception:
217
+ pass
218
  return merged
219
 
220
  stored = shared_overlay.put_field(TABLE_KEY, IMAGE_FIELD_KEY, default, st=st)
 
223
  record = shares.grants('field', shares.field_oid('product_data', IMAGE_FIELD_KEY),
224
  st=st)
225
  if not record.get('owner'):
226
+ shares.set_grants('field', shares.field_oid('product_data', IMAGE_FIELD_KEY),
227
+ [{'user': shares.EVERYONE, 'role': 'edit'}],
228
  owner=IMAGE_FIELD_CREATOR, st=st)
229
  except Exception:
230
  # The definition is already safe and owner-stamped. A missing grant
 
295
  def table_ops(st=None):
296
  """Bind product workspace operations to the caller's tenant runtime."""
297
  return _ProductTableStore(TABLE_KEY, st=st if st is not None else TABLE_OPS.st)
298
+
299
+
300
+ def shared_fields(st=None):
301
+ """Return and lazily migrate tenant-wide product field definitions."""
302
+ from core import field_permissions
303
+ field_permissions.migrate_legacy_fields(TABLE_KEY, st=st, grant_topic='product_data')
304
+ image_field_definition(st=st)
305
+ return shared_overlay.fields(TABLE_KEY, st=st if st is not None else TABLE_OPS.st)
306
 
307
 
308
  def shared_cells(pids, st=None):
web/src/customer-grid/ColumnMenu.tsx CHANGED
@@ -11,19 +11,35 @@ import { FieldSelectButton } from "./FieldSelect";
11
  import { CODE_LANGUAGE_LABELS, CODE_LANGUAGES, CREATABLE_TYPES, choiceOptions, choiceRenames,
12
  codeLanguageOf, directionLabel, isDerivedLink, isMachineOwned,
13
  isProfileField, parseOptions, ratingMax, ROLLUP_FN_LABELS, ROLLUP_FNS,
14
- ROLLUP_REF_OPS } from "./types";
 
15
  import type { AggName, Field, FieldFormat, FieldScope, FieldType, Measure, RollupCondition, RollupFn,
16
- RollupRefOp, RollupSource, Viewer } from "./types";
17
  import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
18
  import type { WindowSpec } from "./windows";
19
  import { normalizeWindow, windowLabel } from "./windows";
20
  import { WindowPicker } from "../filter-kit";
21
- import { TYPE_LABELS } from "./iconShapes";
22
  import { AGG_LABELS, aggOptions } from "./aggregations";
23
  import { validateFormula } from "./formulaEngine";
24
  import { BRAND_SWATCHES, defaultOptionColor, nearestSwatch, normalizeOptionColor,
25
  OPTION_PALETTE } from "./choiceColors";
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  export interface ColumnMenuState {
28
  fieldKey: string;
29
  anchor: AnchorRect;
@@ -261,7 +277,7 @@ interface ColumnMenuProps {
261
  onDuplicate?: () => void;
262
  /** Wave-5 item 1 β€” save a permissions change. Supplied iff the VIEWER may change them
263
  * (isAdmin || createdBy === viewer.name, and only on creatable strata). */
264
- onPermissions?: (edit: "everyone" | "creator" | "admins") => void;
265
  /** Wave-5 item 10 β€” save a display format. Supplied for number/currency/formula and
266
  * date/created_time fields. */
267
  onFormat?: (format: FieldFormat) => void;
@@ -280,6 +296,9 @@ interface ColumnMenuProps {
280
  /** Assignable people, from the host's real user list. Empty = the host did not supply one,
281
  * and "Assignee" is offered without choices rather than with invented ones. */
282
  userOptions?: string[];
 
 
 
283
  /**
284
  * CG-8's measures, reused as the vocabulary of a FORMULA-MEASURE column (owner item 7):
285
  * `Sales Β· the last 90 days` as a column the user creates. Empty = the semantic store is
@@ -2039,6 +2058,7 @@ export default function ColumnMenu({
2039
  onGroupByField,
2040
  onClearGroup,
2041
  userOptions = [],
 
2042
  measures = [],
2043
  tableKey,
2044
  }: ColumnMenuProps) {
@@ -2153,9 +2173,9 @@ export default function ColumnMenu({
2153
  * time, and each emit is a full host round trip that would repaint the column half-ready. */
2154
  const [periodDraft, setPeriodDraft] = useState<WindowSpec | null>(null);
2155
  /** Permissions pane draft. */
2156
- const [permDraft, setPermDraft] = useState<"everyone" | "creator" | "admins">(
2157
- field.permissions?.edit ?? "everyone"
2158
- );
2159
  /** Format pane draft β€” seeded from the field, saved whole. */
2160
  const [fmtDraft, setFmtDraft] = useState<FieldFormat>(() => ({ ...(field.format ?? {}) }));
2161
  /** Item 9c β€” the create forms' Scope draft. 'cohort' is the DEFAULT per the contract. */
@@ -3622,34 +3642,54 @@ export default function ColumnMenu({
3622
  >
3623
  <PaneHead title="Edit field permissions" sub={field.label} onClose={onClose} />
3624
  <div className="cg-column-create">
3625
- <div className="cg-field-hint">
3626
- Who can edit this field's values{field.createdBy ? ` (created by ${field.createdBy})` : ""}.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3627
  </div>
3628
- {(
3629
- [
3630
- ["everyone", "Everyone"],
3631
- ["creator", "Only the creator"],
3632
- ["admins", "Only admins"],
3633
- ] as const
3634
- ).map(([value, text]) => (
3635
- <label key={value} className="cg-radio-row">
3636
- <input
3637
- type="radio"
3638
- name="cg-perm"
3639
- data-overlay-autofocus={value === "everyone" ? true : undefined}
3640
- checked={permDraft === value}
3641
- onChange={() => setPermDraft(value)}
3642
- />
3643
- <span>{text}</span>
3644
- </label>
3645
- ))}
3646
  <div className="cg-form-actions">
3647
  <button
3648
  type="button"
3649
  className="cg-btn cg-btn--primary"
3650
- disabled={permDraft === (field.permissions?.edit ?? "everyone")}
 
3651
  onClick={() => {
3652
- onPermissions?.(permDraft);
3653
  onClose();
3654
  }}
3655
  >
 
11
  import { CODE_LANGUAGE_LABELS, CODE_LANGUAGES, CREATABLE_TYPES, choiceOptions, choiceRenames,
12
  codeLanguageOf, directionLabel, isDerivedLink, isMachineOwned,
13
  isProfileField, parseOptions, ratingMax, ROLLUP_FN_LABELS, ROLLUP_FNS,
14
+ ROLLUP_REF_OPS, FIELD_EDIT_BLURBS, FIELD_EDIT_LABELS, FIELD_EDIT_MODES,
15
+ cleanFieldPermissions } from "./types";
16
  import type { AggName, Field, FieldFormat, FieldScope, FieldType, Measure, RollupCondition, RollupFn,
17
+ RollupRefOp, RollupSource, Viewer, FieldEditMode, FieldPermissions } from "./types";
18
  import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
19
  import type { WindowSpec } from "./windows";
20
  import { normalizeWindow, windowLabel } from "./windows";
21
  import { WindowPicker } from "../filter-kit";
22
+ import { FOLDER_TONE_PAINT, TYPE_LABELS } from "./iconShapes";
23
  import { AGG_LABELS, aggOptions } from "./aggregations";
24
  import { validateFormula } from "./formulaEngine";
25
  import { BRAND_SWATCHES, defaultOptionColor, nearestSwatch, normalizeOptionColor,
26
  OPTION_PALETTE } from "./choiceColors";
27
 
28
+ const FIELD_PERM_MARK: Record<FieldEditMode, string[]> = {
29
+ personal: ["M8 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM4.8 12.6c0-1.9 1.4-3.1 3.2-3.1s3.2 1.2 3.2 3.1"],
30
+ collaborative: ["M5.9 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z", "M10.6 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"],
31
+ users: ["M6 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z", "M2.8 12.6c0-1.9 1.4-3.1 3.2-3.1s3.2 1.2 3.2 3.1", "M10.6 9.4l1.5 1.6 2.6-3"],
32
+ };
33
+ function FieldPermMark({ mode }: { mode: FieldEditMode }) {
34
+ const tone = mode === "personal" ? "neutral" : mode === "collaborative" ? "blue" : "green";
35
+ return <svg className="cg-perm-mark" width={16} height={16} viewBox="0 0 16 16" aria-hidden>
36
+ {FIELD_PERM_MARK[mode].map((d, i) => (
37
+ <path key={i} d={d} fill="none" stroke={FOLDER_TONE_PAINT[tone].stroke}
38
+ strokeWidth={1.35} strokeLinecap="round" strokeLinejoin="round" />
39
+ ))}
40
+ </svg>;
41
+ }
42
+
43
  export interface ColumnMenuState {
44
  fieldKey: string;
45
  anchor: AnchorRect;
 
277
  onDuplicate?: () => void;
278
  /** Wave-5 item 1 β€” save a permissions change. Supplied iff the VIEWER may change them
279
  * (isAdmin || createdBy === viewer.name, and only on creatable strata). */
280
+ onPermissions?: (permissions: FieldPermissions) => void;
281
  /** Wave-5 item 10 β€” save a display format. Supplied for number/currency/formula and
282
  * date/created_time fields. */
283
  onFormat?: (format: FieldFormat) => void;
 
296
  /** Assignable people, from the host's real user list. Empty = the host did not supply one,
297
  * and "Assignee" is offered without choices rather than with invented ones. */
298
  userOptions?: string[];
299
+ /** Account identities used by field permissions. This is deliberately separate from
300
+ * user-valued cell choices, which are display labels rather than durable usernames. */
301
+ permissionUserOptions?: { username: string; name: string }[];
302
  /**
303
  * CG-8's measures, reused as the vocabulary of a FORMULA-MEASURE column (owner item 7):
304
  * `Sales Β· the last 90 days` as a column the user creates. Empty = the semantic store is
 
2058
  onGroupByField,
2059
  onClearGroup,
2060
  userOptions = [],
2061
+ permissionUserOptions = [],
2062
  measures = [],
2063
  tableKey,
2064
  }: ColumnMenuProps) {
 
2173
  * time, and each emit is a full host round trip that would repaint the column half-ready. */
2174
  const [periodDraft, setPeriodDraft] = useState<WindowSpec | null>(null);
2175
  /** Permissions pane draft. */
2176
+ const initialFieldPermissions = cleanFieldPermissions(field.permissions, "collaborative");
2177
+ const [permDraft, setPermDraft] = useState<FieldEditMode>(initialFieldPermissions.edit);
2178
+ const [permUsers, setPermUsers] = useState<string[]>(initialFieldPermissions.users ?? []);
2179
  /** Format pane draft β€” seeded from the field, saved whole. */
2180
  const [fmtDraft, setFmtDraft] = useState<FieldFormat>(() => ({ ...(field.format ?? {}) }));
2181
  /** Item 9c β€” the create forms' Scope draft. 'cohort' is the DEFAULT per the contract. */
 
3642
  >
3643
  <PaneHead title="Edit field permissions" sub={field.label} onClose={onClose} />
3644
  <div className="cg-column-create">
3645
+ <div className="cg-perm" role="radiogroup" aria-label="Who can edit this field">
3646
+ <span className="cg-perm-title">Who can edit</span>
3647
+ {FIELD_EDIT_MODES.map((mode) => (
3648
+ <label key={mode} className="cg-radio-row cg-perm-row">
3649
+ <input
3650
+ type="radio"
3651
+ name="cg-field-perm"
3652
+ data-overlay-autofocus={mode === "personal" ? true : undefined}
3653
+ checked={permDraft === mode}
3654
+ onChange={() => setPermDraft(mode)}
3655
+ />
3656
+ <FieldPermMark mode={mode} />
3657
+ <span className="cg-perm-label">
3658
+ {FIELD_EDIT_LABELS[mode]}
3659
+ <span className="cg-perm-blurb">{FIELD_EDIT_BLURBS[mode]}</span>
3660
+ </span>
3661
+ </label>
3662
+ ))}
3663
+ {permDraft === "users" && (
3664
+ <div className="cg-perm-users">
3665
+ {permissionUserOptions.length === 0 ? (
3666
+ <span className="cg-perm-empty">No other accounts to pick. This will save as Personal.</span>
3667
+ ) : permissionUserOptions.map((person) => (
3668
+ <label key={person.username} className="cg-perm-user">
3669
+ <input
3670
+ type="checkbox"
3671
+ checked={permUsers.includes(person.username.toLowerCase())}
3672
+ onChange={(event) => setPermUsers((current) => event.target.checked
3673
+ ? [...current, person.username.toLowerCase()].slice(0, 50)
3674
+ : current.filter((user) => user !== person.username.toLowerCase()))}
3675
+ />
3676
+ <span>{person.name || person.username}</span>
3677
+ </label>
3678
+ ))}
3679
+ {permissionUserOptions.length > 0 && permUsers.length === 0 && (
3680
+ <span className="cg-perm-empty">Pick at least one person, or this saves as Personal.</span>
3681
+ )}
3682
+ </div>
3683
+ )}
3684
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3685
  <div className="cg-form-actions">
3686
  <button
3687
  type="button"
3688
  className="cg-btn cg-btn--primary"
3689
+ disabled={JSON.stringify({ edit: permDraft, users: permDraft === "users" ? permUsers : undefined }) ===
3690
+ JSON.stringify(cleanFieldPermissions(field.permissions, "collaborative"))}
3691
  onClick={() => {
3692
+ onPermissions?.(cleanFieldPermissions({ edit: permDraft, users: permUsers }, "personal"));
3693
  onClose();
3694
  }}
3695
  >
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -7831,7 +7831,7 @@ function CustomerGridSurface({
7831
  onPermissions={
7832
  // Creatable strata only; ColumnMenu further gates on the viewer (creator/admin).
7833
  menuField.custom
7834
- ? (edit) => saveField({ ...menuField, permissions: { edit } })
7835
  : undefined
7836
  }
7837
  onFormat={(format) => saveField({ ...menuField, format })}
@@ -7885,7 +7885,8 @@ function CustomerGridSurface({
7885
  }
7886
  onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
7887
  onClearGroup={() => updateConfig({ ...config, groupBy: null })}
7888
- userOptions={payload?.userOptions ?? []}
 
7889
  measures={measures}
7890
  /* ⭐⭐ W39-T17 β€” THE TABLE HALF OF A FIELD SHARE'S OID, and it is deliberately NOT the
7891
  `storageKey` const above. That one carries two spellings the share door cannot use:
 
7831
  onPermissions={
7832
  // Creatable strata only; ColumnMenu further gates on the viewer (creator/admin).
7833
  menuField.custom
7834
+ ? (permissions) => saveField({ ...menuField, permissions })
7835
  : undefined
7836
  }
7837
  onFormat={(format) => saveField({ ...menuField, format })}
 
7885
  }
7886
  onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
7887
  onClearGroup={() => updateConfig({ ...config, groupBy: null })}
7888
+ userOptions={payload?.userOptions ?? []}
7889
+ permissionUserOptions={payload?.permissionUserOptions ?? []}
7890
  measures={measures}
7891
  /* ⭐⭐ W39-T17 β€” THE TABLE HALF OF A FIELD SHARE'S OID, and it is deliberately NOT the
7892
  `storageKey` const above. That one carries two spellings the share door cannot use:
web/src/customer-grid/types.ts CHANGED
@@ -1566,10 +1566,50 @@ export function clampFrozenCount(n: unknown): number | undefined {
1566
  * Wave-5 item 1 β€” who is looking. Host-computed; a payload without one behaves as before for
1567
  * unrestricted fields, and FAIL-CLOSED for anything permission-restricted.
1568
  */
1569
- export interface Viewer {
1570
- name: string;
1571
- isAdmin: boolean;
1572
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1573
 
1574
  /**
1575
  * May this viewer EDIT this field's cell values? The client-side COURTESY check β€” it decides
@@ -1595,11 +1635,15 @@ export function mayEditField(field: Field, viewer: Viewer | undefined): boolean
1595
  // the VALUES stayed open to anyone with grid access. The stage and profile columns are
1596
  // excluded inside `isMachineWritten` β€” both are cells a human is *supposed* to drive.
1597
  if (isMachineWritten(field)) return false;
1598
- const edit = field.permissions?.edit ?? "everyone";
1599
- if (edit === "everyone") return true;
1600
- if (!viewer) return false;
1601
- if (viewer.isAdmin) return true;
1602
- return edit === "creator" && field.createdBy === viewer.name;
 
 
 
 
1603
  }
1604
 
1605
  /**
@@ -2073,7 +2117,7 @@ export interface Field {
2073
  * everyone. The client HIDES what the viewer may not do (menu entries, editable cells); the
2074
  * HOST check is the wall β€” fail-closed, enforced on overlay_patch and field_upsert.
2075
  */
2076
- permissions?: { edit: "everyone" | "creator" | "admins" };
2077
  /**
2078
  * Wave-5 item 10 β€” display format, fail-closed per type (unknown keys dropped host-side):
2079
  * number/currency: { thousands, decimals (0..4), abbrev ("34.0M") }
@@ -3582,11 +3626,11 @@ export interface ViewPermissions {
3582
  users?: string[];
3583
  }
3584
 
3585
- export const VIEW_EDIT_LABELS: Record<ViewEditMode, string> = {
3586
- personal: "Personal",
3587
- collaborative: "Collaborative",
3588
- users: "Specific users",
3589
- };
3590
 
3591
  /** What each choice actually means, in the create prompt. A permission the user cannot
3592
  * predict the effect of is a permission they will set wrong. */
@@ -3892,7 +3936,8 @@ export interface GridWorkspace {
3892
  measureSets?: Record<string, number[]>;
3893
  derived?: Record<string, Record<string, string | number | null>>;
3894
  viewer?: Viewer;
3895
- userOptions?: string[];
 
3896
  /**
3897
  * C-AVATAR (wave 14, item 11) β€” username β†’ avatar data URL, served BESIDE `userOptions` so the
3898
  * grid's `user` cells, the record modal and every people picker read one map rather than three
@@ -3974,7 +4019,8 @@ export interface CustomersPayload {
3974
  limits?: GridLimit[];
3975
  /** Assignable people for `user` fields β€” supplied by the host from the tenant's real user
3976
  * list, never invented client-side, so an assignee is always someone who can log in. */
3977
- userOptions?: string[];
 
3978
  /**
3979
  * The TENANT'S today, as an ISO date β€” the anchor every relative date resolves against.
3980
  *
 
1566
  * Wave-5 item 1 β€” who is looking. Host-computed; a payload without one behaves as before for
1567
  * unrestricted fields, and FAIL-CLOSED for anything permission-restricted.
1568
  */
1569
+ export interface Viewer {
1570
+ name: string;
1571
+ isAdmin: boolean;
1572
+ }
1573
+
1574
+ export const FIELD_EDIT_MODES = ["personal", "collaborative", "users"] as const;
1575
+ export type FieldEditMode = (typeof FIELD_EDIT_MODES)[number];
1576
+ export interface FieldPermissions {
1577
+ edit: FieldEditMode;
1578
+ users?: string[];
1579
+ }
1580
+ export type StoredFieldPermissions = {
1581
+ edit: FieldEditMode | "everyone" | "creator" | "admins";
1582
+ users?: string[];
1583
+ };
1584
+ export const FIELD_EDIT_LABELS: Record<FieldEditMode, string> = {
1585
+ personal: "Personal",
1586
+ collaborative: "Collaborative",
1587
+ users: "Specific",
1588
+ };
1589
+ export const FIELD_EDIT_BLURBS: Record<FieldEditMode, string> = {
1590
+ personal: "Only you can change this field.",
1591
+ collaborative: "Anyone who can see this table can change it.",
1592
+ users: "Only the people you pick can change it.",
1593
+ };
1594
+
1595
+ export function cleanFieldPermissions(raw: unknown, fallback: FieldEditMode): FieldPermissions {
1596
+ const bag = raw && typeof raw === "object" && !Array.isArray(raw)
1597
+ ? raw as Record<string, unknown> : {};
1598
+ const aliases: Record<string, FieldEditMode> = {
1599
+ everyone: "collaborative", creator: "personal", admins: "personal",
1600
+ personal: "personal", collaborative: "collaborative", users: "users",
1601
+ };
1602
+ const edit = aliases[String(bag.edit ?? "").toLowerCase()] ?? fallback;
1603
+ if (edit !== "users") return { edit };
1604
+ const users = Array.isArray(bag.users)
1605
+ ? [...new Set(bag.users.map((u) => String(u ?? "").trim().toLowerCase()).filter(Boolean))].slice(0, 50)
1606
+ : [];
1607
+ return users.length ? { edit, users } : { edit: "personal" };
1608
+ }
1609
+
1610
+ export function fieldEditMode(field: Pick<Field, "permissions">): FieldEditMode {
1611
+ return cleanFieldPermissions(field.permissions, "collaborative").edit;
1612
+ }
1613
 
1614
  /**
1615
  * May this viewer EDIT this field's cell values? The client-side COURTESY check β€” it decides
 
1635
  // the VALUES stayed open to anyone with grid access. The stage and profile columns are
1636
  // excluded inside `isMachineWritten` β€” both are cells a human is *supposed* to drive.
1637
  if (isMachineWritten(field)) return false;
1638
+ const permissions = cleanFieldPermissions(field.permissions, "collaborative");
1639
+ const edit = permissions.edit;
1640
+ if (!viewer) return false;
1641
+ if (viewer.isAdmin) return true;
1642
+ if (edit === "collaborative") return true;
1643
+ return field.createdBy === viewer.name ||
1644
+ (edit === "users" && (permissions.users ?? []).some(
1645
+ (user) => user.toLowerCase() === viewer.name.toLowerCase()
1646
+ ));
1647
  }
1648
 
1649
  /**
 
2117
  * everyone. The client HIDES what the viewer may not do (menu entries, editable cells); the
2118
  * HOST check is the wall β€” fail-closed, enforced on overlay_patch and field_upsert.
2119
  */
2120
+ permissions?: StoredFieldPermissions;
2121
  /**
2122
  * Wave-5 item 10 β€” display format, fail-closed per type (unknown keys dropped host-side):
2123
  * number/currency: { thousands, decimals (0..4), abbrev ("34.0M") }
 
3626
  users?: string[];
3627
  }
3628
 
3629
+ export const VIEW_EDIT_LABELS: Record<ViewEditMode, string> = {
3630
+ personal: "Personal",
3631
+ collaborative: "Collaborative",
3632
+ users: "Specific",
3633
+ };
3634
 
3635
  /** What each choice actually means, in the create prompt. A permission the user cannot
3636
  * predict the effect of is a permission they will set wrong. */
 
3936
  measureSets?: Record<string, number[]>;
3937
  derived?: Record<string, Record<string, string | number | null>>;
3938
  viewer?: Viewer;
3939
+ userOptions?: string[];
3940
+ permissionUserOptions?: { username: string; name: string }[];
3941
  /**
3942
  * C-AVATAR (wave 14, item 11) β€” username β†’ avatar data URL, served BESIDE `userOptions` so the
3943
  * grid's `user` cells, the record modal and every people picker read one map rather than three
 
4019
  limits?: GridLimit[];
4020
  /** Assignable people for `user` fields β€” supplied by the host from the tenant's real user
4021
  * list, never invented client-side, so an assignee is always someone who can log in. */
4022
+ userOptions?: string[];
4023
+ permissionUserOptions?: { username: string; name: string }[];
4024
  /**
4025
  * The TENANT'S today, as an ISO date β€” the anchor every relative date resolves against.
4026
  *
web/src/customer-grid/useCustomerData.ts CHANGED
@@ -111,8 +111,9 @@ function withWorkspace(payload: CustomersPayload, ws: GridWorkspace): CustomersP
111
  const extra = ws.limits.filter((l) => l && l.subject && !seen.has(l.subject));
112
  if (extra.length) out.limits = [...(payload.limits ?? []), ...extra];
113
  }
114
- if (ws.viewer && typeof ws.viewer.name === "string") out.viewer = ws.viewer;
115
- if (Array.isArray(ws.userOptions)) out.userOptions = ws.userOptions;
 
116
  const derived = ws.derived;
117
  if (derived && typeof derived === "object" && Object.keys(derived).length > 0) {
118
  out.rows = payload.rows.map((r) => {
 
111
  const extra = ws.limits.filter((l) => l && l.subject && !seen.has(l.subject));
112
  if (extra.length) out.limits = [...(payload.limits ?? []), ...extra];
113
  }
114
+ if (ws.viewer && typeof ws.viewer.name === "string") out.viewer = ws.viewer;
115
+ if (Array.isArray(ws.userOptions)) out.userOptions = ws.userOptions;
116
+ if (Array.isArray(ws.permissionUserOptions)) out.permissionUserOptions = ws.permissionUserOptions;
117
  const derived = ws.derived;
118
  if (derived && typeof derived === "object" && Object.keys(derived).length > 0) {
119
  out.rows = payload.rows.map((r) => {
web/src/filter-kit/FieldsHidePanel.tsx CHANGED
@@ -25,7 +25,7 @@ import type { Field } from "../customer-grid/types";
25
  // miniature, where what a user searches and what they read answer the same question
26
  // differently. It lives in `types.ts` so the node harness can run it: this panel is JSX and
27
  // its search path only fires once somebody types, which no static render can do.
28
- import { fieldLabel, isPresetField } from "../customer-grid/types";
29
  import { FieldTypeIcon } from "../customer-grid/icons";
30
  import { TYPE_LABELS } from "../customer-grid/iconShapes";
31
 
@@ -85,6 +85,14 @@ export function FieldsHidePanel({
85
  const [overKey, setOverKey] = useState<string | null>(null);
86
  const q = query.trim().toLowerCase();
87
  const shown = q ? fields.filter((f) => fieldLabel(f).toLowerCase().includes(q)) : fields;
 
 
 
 
 
 
 
 
88
  /**
89
  * Item 23 β€” reorder is OFF while a search is narrowing the list. Dropping "City" above
90
  * "State" in a list that is hiding the eight fields between them means nothing the user can
@@ -134,7 +142,10 @@ export function FieldsHidePanel({
134
  onChange={(e) => setQuery(e.target.value)}
135
  />
136
  <div className="cg-pop-list">
137
- {shown.map((f) => {
 
 
 
138
  const locked = lockedKey !== undefined && f.key === lockedKey;
139
  const on = !hidden.has(f.key);
140
  const deletable = !!onDeleteField && !!deletableKeys?.has(f.key);
@@ -303,7 +314,9 @@ export function FieldsHidePanel({
303
  )}
304
  </label>
305
  );
306
- })}
 
 
307
  {shown.length === 0 && (
308
  <div className="cg-builder-empty">No field matches your search.</div>
309
  )}
 
25
  // miniature, where what a user searches and what they read answer the same question
26
  // differently. It lives in `types.ts` so the node harness can run it: this panel is JSX and
27
  // its search path only fires once somebody types, which no static render can do.
28
+ import { fieldEditMode, fieldLabel, isPresetField } from "../customer-grid/types";
29
  import { FieldTypeIcon } from "../customer-grid/icons";
30
  import { TYPE_LABELS } from "../customer-grid/iconShapes";
31
 
 
85
  const [overKey, setOverKey] = useState<string | null>(null);
86
  const q = query.trim().toLowerCase();
87
  const shown = q ? fields.filter((f) => fieldLabel(f).toLowerCase().includes(q)) : fields;
88
+ const sections = [
89
+ { key: "personal", title: "", fields: shown.filter((field) =>
90
+ (!field.custom && !field.shared) || fieldEditMode(field) === "personal") },
91
+ { key: "me", title: "Shared with me", fields: shown.filter((field) =>
92
+ (field.custom || field.shared) && fieldEditMode(field) === "users") },
93
+ { key: "everyone", title: "Shared with everyone", fields: shown.filter((field) =>
94
+ (field.custom || field.shared) && fieldEditMode(field) === "collaborative") },
95
+ ];
96
  /**
97
  * Item 23 β€” reorder is OFF while a search is narrowing the list. Dropping "City" above
98
  * "State" in a list that is hiding the eight fields between them means nothing the user can
 
142
  onChange={(e) => setQuery(e.target.value)}
143
  />
144
  <div className="cg-pop-list">
145
+ {sections.map((section) => (
146
+ <div key={section.key} className="cg-fields-section">
147
+ {section.title && <div className="cg-fields-section-title">{section.title}</div>}
148
+ {section.fields.map((f) => {
149
  const locked = lockedKey !== undefined && f.key === lockedKey;
150
  const on = !hidden.has(f.key);
151
  const deletable = !!onDeleteField && !!deletableKeys?.has(f.key);
 
314
  )}
315
  </label>
316
  );
317
+ })}
318
+ </div>
319
+ ))}
320
  {shown.length === 0 && (
321
  <div className="cg-builder-empty">No field matches your search.</div>
322
  )}
web/src/index.css CHANGED
@@ -1911,13 +1911,26 @@ body > .cg-overlay {
1911
  border-radius: var(--lp-r-md);
1912
  background: var(--lp-surface-2);
1913
  }
1914
- .cg-fields-footer .cg-link-btn:hover {
1915
  /* on the grey tray the blue-tint hover reads as a selection; white reads as "this is the
1916
  button", which is what a hover on a tray needs to say. */
1917
- background: var(--lp-surface);
1918
- }
1919
-
1920
- /* search */
 
 
 
 
 
 
 
 
 
 
 
 
 
1921
  .cg-search {
1922
  position: relative;
1923
  display: inline-flex;
 
1911
  border-radius: var(--lp-r-md);
1912
  background: var(--lp-surface-2);
1913
  }
1914
+ .cg-fields-footer .cg-link-btn:hover {
1915
  /* on the grey tray the blue-tint hover reads as a selection; white reads as "this is the
1916
  button", which is what a hover on a tray needs to say. */
1917
+ background: var(--lp-surface);
1918
+ }
1919
+ .cg-fields-section + .cg-fields-section {
1920
+ margin-top: 10px;
1921
+ padding-top: 8px;
1922
+ border-top: 1px solid var(--lp-line);
1923
+ }
1924
+ .cg-fields-section-title {
1925
+ padding: 0 10px 5px;
1926
+ color: var(--lp-muted);
1927
+ font-size: var(--lp-fs-3xs);
1928
+ font-weight: 700;
1929
+ letter-spacing: .04em;
1930
+ text-transform: uppercase;
1931
+ }
1932
+
1933
+ /* search */
1934
  .cg-search {
1935
  position: relative;
1936
  display: inline-flex;