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

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
api/routes_customers.py CHANGED
@@ -158,10 +158,15 @@ def _measure_err(tag, e):
158
  # `patch_shared_cell` wrote into a bucket no reader ever opened.
159
 
160
 
161
- def _shared_key():
162
  """The store key this topic's per-user AND tenant-wide strata are both named from."""
163
- import modules.customer_data as cl
164
- return cl.TABLE_KEY
 
 
 
 
 
165
 
166
 
167
  def shared_fields(st=None):
@@ -173,7 +178,8 @@ def shared_fields(st=None):
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
@@ -197,7 +203,7 @@ def shared_cells(pids, st=None):
197
  return {}
198
 
199
 
200
- def _merge_shared_fields(fields, defs):
201
  """`fields` PLUS the tenant-wide columns this topic declares β€” `routes_tables._ut_shared_fields`
202
  on the customer topic.
203
 
@@ -212,8 +218,20 @@ def _merge_shared_fields(fields, defs):
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 = "",
@@ -290,7 +308,7 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
290
  # ⭐⭐ W38-T20 β€” AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this
291
  # position is load-bearing twice, once for the transitive closure and once because it is the
292
  # only order in which the per-field grant marker is ever presented to `hidden_keys`.
293
- fields = _merge_shared_fields(fields, _defs)
294
  # THE FIELD WALL β€” a TRANSITIVE closure (C-PERM amendment 5), so hiding a field also hides
295
  # every formula computed FROM it. Formulas evaluate in the browser from `{ref}`s, so
296
  # shipping a dependent formula while withholding its input either leaks the input through
@@ -420,13 +438,14 @@ def _ctx_for(session: Session, pids, defs=None):
420
  them; absent, they are read. One read per assembly rather than one per question asked of it.
421
  """
422
  from core import grid_events
423
- return grid_events.EventCtx(
424
- uname=session.uname, allowed_pids=frozenset(pids or ()), fields=[],
425
  # C-PERM: the write wall's field half. Computed from the CANONICAL contract PLUS the
426
  # tenant-wide columns, because `fields=[]` here β€” the closure only needs the schema, not
427
  # this user's column list, and a runtime column is part of that schema now.
428
- hidden_keys=_hidden_for(session, defs=defs),
429
- admin=session.admin, fallback_ws=None, seen_ids={})
 
430
 
431
 
432
 
@@ -505,10 +524,11 @@ def patch_customer(pid: int, body: dict = Body(default=None),
505
  # saying "no such customer" would confirm the opposite to anyone who guessed right.
506
  raise err(403, "out_of_scope", "that customer is not in your book")
507
  payload = _payload(session)
508
- ctx = grid_events.EventCtx(
509
- uname=session.uname, allowed_pids=pool, fields=payload["fields"],
510
- admin=session.admin, fallback_ws=None, seen_ids={},
511
- hidden_keys=_hidden_for(session))
 
512
  try:
513
  grid_events.handle_one(
514
  {"id": f"patch:{pid}:{time.time_ns()}", "type": "overlay_patch",
 
158
  # `patch_shared_cell` wrote into a bucket no reader ever opened.
159
 
160
 
161
+ def _shared_key():
162
  """The store key this topic's per-user AND tenant-wide strata are both named from."""
163
+ import modules.customer_data as cl
164
+ return cl.TABLE_KEY
165
+
166
+
167
+ def _customer_table(session):
168
+ import core.table_store as table_store
169
+ return table_store.make(_shared_key(), st=session.runtime)
170
 
171
 
172
  def shared_fields(st=None):
 
178
  """
179
  from core import field_permissions, shared_overlay
180
  try:
181
+ field_permissions.migrate_legacy_fields(
182
+ _shared_key(), st=st, grant_topic="customer_data", shared_key=_shared_key())
183
  return shared_overlay.fields(_shared_key(), st=st)
184
  except Exception: # noqa: BLE001
185
  # Lenient like every other display read: an unreachable store degrades to "nothing is
 
203
  return {}
204
 
205
 
206
+ def _merge_shared_fields(fields, defs, session=None):
207
  """`fields` PLUS the tenant-wide columns this topic declares β€” `routes_tables._ut_shared_fields`
208
  on the customer topic.
209
 
 
218
  if not defs:
219
  return fields
220
  have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
221
+ projected = []
222
+ for k, f in defs.items():
223
+ if k in have:
224
+ continue
225
+ item = dict(f, source="overlay", shared=True)
226
+ if session is not None:
227
+ from core import shares
228
+ role = shares.role_for(
229
+ "field", shares.field_oid("customer_data", k), session.uname,
230
+ is_admin=session.admin, st=session.runtime)
231
+ if role:
232
+ item["sharedRole"] = role
233
+ projected.append(item)
234
+ return list(fields or ()) + projected
235
 
236
 
237
  def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "",
 
308
  # ⭐⭐ W38-T20 β€” AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this
309
  # position is load-bearing twice, once for the transitive closure and once because it is the
310
  # only order in which the per-field grant marker is ever presented to `hidden_keys`.
311
+ fields = _merge_shared_fields(fields, _defs, session=session)
312
  # THE FIELD WALL β€” a TRANSITIVE closure (C-PERM amendment 5), so hiding a field also hides
313
  # every formula computed FROM it. Formulas evaluate in the browser from `{ref}`s, so
314
  # shipping a dependent formula while withholding its input either leaks the input through
 
438
  them; absent, they are read. One read per assembly rather than one per question asked of it.
439
  """
440
  from core import grid_events
441
+ return grid_events.EventCtx(
442
+ uname=session.uname, allowed_pids=frozenset(pids or ()), fields=[],
443
  # C-PERM: the write wall's field half. Computed from the CANONICAL contract PLUS the
444
  # tenant-wide columns, because `fields=[]` here β€” the closure only needs the schema, not
445
  # this user's column list, and a runtime column is part of that schema now.
446
+ hidden_keys=_hidden_for(session, defs=defs),
447
+ admin=session.admin, table=_customer_table(session), st=session.runtime,
448
+ fallback_ws=None, seen_ids={})
449
 
450
 
451
 
 
524
  # saying "no such customer" would confirm the opposite to anyone who guessed right.
525
  raise err(403, "out_of_scope", "that customer is not in your book")
526
  payload = _payload(session)
527
+ ctx = grid_events.EventCtx(
528
+ uname=session.uname, allowed_pids=pool, fields=payload["fields"],
529
+ admin=session.admin, fallback_ws=None, seen_ids={},
530
+ hidden_keys=_hidden_for(session), table=_customer_table(session),
531
+ st=session.runtime)
532
  try:
533
  grid_events.handle_one(
534
  {"id": f"patch:{pid}:{time.time_ns()}", "type": "overlay_patch",
api/routes_grid.py CHANGED
@@ -54,7 +54,7 @@ def _ctx(session: Session, fields, pids, **kw):
54
 
55
  module, canonical = _PMOD, pd_fields(consolidated=True, st=session.runtime)
56
  kw.setdefault("table", pd.table_ops(session.runtime))
57
- hidden = perm_scope.hidden_keys(session.user, module, canonical)
58
  elif scope_key.startswith("ut_"):
59
  # Wave 18 C3-UT: a user table has no module in the permission wall (its wall is
60
  # `user_tables.may_open`, already applied by the assembly this route ran first), so
@@ -64,9 +64,12 @@ def _ctx(session: Session, fields, pids, **kw):
64
  kw.setdefault("table",
65
  table_store.make(f"{scope_key}_table_workspace", st=session.runtime))
66
  hidden = frozenset()
67
- else:
68
- module, canonical = MODULE, aios_grid.FIELDS
69
- hidden = perm_scope.hidden_keys(session.user, module, canonical)
 
 
 
70
  return grid_events.EventCtx(
71
  uname=session.uname, allowed_pids=pids, fields=fields, admin=session.admin,
72
  # C-PERM: the write wall's field half, on the EVENTS transport too. This route takes the
 
54
 
55
  module, canonical = _PMOD, pd_fields(consolidated=True, st=session.runtime)
56
  kw.setdefault("table", pd.table_ops(session.runtime))
57
+ hidden = perm_scope.hidden_keys(session.user, module, canonical, st=session.runtime)
58
  elif scope_key.startswith("ut_"):
59
  # Wave 18 C3-UT: a user table has no module in the permission wall (its wall is
60
  # `user_tables.may_open`, already applied by the assembly this route ran first), so
 
64
  kw.setdefault("table",
65
  table_store.make(f"{scope_key}_table_workspace", st=session.runtime))
66
  hidden = frozenset()
67
+ else:
68
+ module, canonical = MODULE, aios_grid.FIELDS
69
+ import core.table_store as table_store
70
+ kw.setdefault("table", table_store.make(
71
+ "customer_table_workspace", st=session.runtime))
72
+ hidden = perm_scope.hidden_keys(session.user, module, canonical, st=session.runtime)
73
  return grid_events.EventCtx(
74
  uname=session.uname, allowed_pids=pids, fields=fields, admin=session.admin,
75
  # C-PERM: the write wall's field half, on the EVENTS transport too. This route takes the
api/routes_products.py CHANGED
@@ -366,8 +366,19 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
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:
 
366
  fields_base=fields_base)
367
 
368
  have = {field.get("key") for field in fields if isinstance(field, dict)}
369
+ projected = []
370
+ from core import shares
371
+ for key, field in shared_defs.items():
372
+ if key in have:
373
+ continue
374
+ item = dict(field, source="overlay", shared=True)
375
+ role = shares.role_for(
376
+ "field", shares.field_oid("product_data", key), session.uname,
377
+ is_admin=session.admin, st=session.runtime)
378
+ if role:
379
+ item["sharedRole"] = role
380
+ projected.append(item)
381
+ fields = list(fields) + projected
382
 
383
  hidden = perm_scope.hidden_keys(session.user, MODULE, fields, st=session.runtime)
384
  if hidden:
api/routes_shares.py CHANGED
@@ -85,10 +85,57 @@ def _kind_or_400(raw):
85
 
86
  #: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
87
  #: route is not told which topic β€” so resolving one means asking each.
88
- _BUILTIN_TOPICS = ("customer", "product")
89
-
90
-
91
- def _topics(session):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  """Every topic whose workspace could hold a view or folder for this tenant.
93
 
94
  ⚠ `all_defs`, never `all_tables` β€” the latter is the whole 28.6 MB row payload (~703 ms on
@@ -121,15 +168,13 @@ def _owns_object(session, kind, oid):
121
  # a brand-new column has no grant record, so `put_share` falls to this predicate β€” and
122
  # without it the column's own creator is answered `404 no_object` on the first attempt to
123
  # share the thing they just made.
124
- table_key, field_key = shares.split_field_oid(oid)
125
- if not table_key:
126
- return False
127
- try:
128
- import core.shared_overlay as shared_overlay
129
- defn = (shared_overlay.fields(table_key, st=session.runtime) or {}).get(field_key)
130
- except Exception: # noqa: BLE001
131
- return False
132
- return bool(defn) and str(defn.get("createdBy") or "") == str(session.uname)
133
  if kind == "database":
134
  # ⚠ `may_open` is THE resolver for a user table (its own docstring says so) and already
135
  # admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
@@ -242,8 +287,12 @@ def my_shares(session: Session = Depends(require_session)):
242
 
243
 
244
  @router.get("/share/{kind}/{oid}")
245
- def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
246
- kind = _kind_or_400(kind)
 
 
 
 
247
  rec = shares.grants(kind, oid, st=session.runtime)
248
  role = shares.role_for(kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
249
  may_admin = shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
@@ -298,10 +347,14 @@ def _people(tenant):
298
 
299
 
300
  @router.put("/share/{kind}/{oid}")
301
- def put_share(kind: str, oid: str, body: dict = Body(default=None),
302
- session: Session = Depends(require_session)):
303
- kind = _kind_or_400(kind)
304
- body = body or {}
 
 
 
 
305
  rec = shares.grants(kind, oid, st=session.runtime)
306
  # An object with NO grant record yet has no owner β€” the first person to share it claims it.
307
  # That is safe because reaching this route at all means passing the surface's own wall, and
@@ -328,19 +381,24 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None),
328
  "revoking is expressed")
329
  _entries_or_400(session, entries)
330
  if kind == "field":
331
- # A field grant is a visibility wall, and the wall must be declared
332
- # on the stored field before the grant registry changes. Without this
333
- # marker `perm_scope.field_grant_hidden` correctly treats the field as
334
- # an ungoverned legacy column and the recipient still sees it even
335
- # after the dialog reports success.
336
- from core import shared_overlay
337
  table_key, field_key = shares.split_field_oid(oid)
338
- defn = (shared_overlay.fields(table_key, st=session.runtime) or {}).get(field_key)
 
339
  if not isinstance(defn, dict):
340
  raise err(404, "no_object", "no such field, or it is not shared with this account")
 
 
 
 
341
  stamped = dict(defn)
 
342
  stamped["granted"] = True
343
- shared_overlay.put_field(table_key, field_key, stamped, st=session.runtime)
 
344
  out = shares.set_grants(kind, oid, entries, owner=rec["owner"] or session.uname,
345
  st=session.runtime)
346
  _notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
@@ -426,25 +484,27 @@ def _object_ref(session, kind, oid):
426
  # answers `route=None` β€” and `_notify_new_grantees` returns EARLY. The grant lands and
427
  # the receiver is never told, which is the silent half of owner item 18 reopened one
428
  # kind over.
429
- from routes_alerts import route_for_topic
430
- table_key, field_key = shares.split_field_oid(oid)
431
- if not table_key:
432
- return ("A column", None, "")
433
- try:
434
- import core.shared_overlay as shared_overlay
435
- defn = (shared_overlay.fields(table_key, st=session.runtime) or {}).get(field_key)
436
- except Exception: # noqa: BLE001
437
- defn = None
438
- label = str((defn or {}).get("label") or "").strip() or field_key
439
  # ⚠ TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
440
  # by whatever the calling door already held: a `ut_*` database uses its bare key,
441
  # while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
442
  # `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
443
  # `customer_data`), so the suffix comes off before it is asked β€” rather than a second
444
  # route table being written here, which is how the two come apart.
445
- _WS = "_table_workspace"
446
- scope = table_key[:-len(_WS)] if table_key.endswith(_WS) else table_key
447
- return (label, route_for_topic(scope) or None, "")
 
 
448
  if kind == "database":
449
  import core.user_tables as ut
450
  defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
 
85
 
86
  #: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
87
  #: route is not told which topic β€” so resolving one means asking each.
88
+ _BUILTIN_TOPICS = ("customer", "product")
89
+
90
+
91
+ def _field_storage_keys(table_key):
92
+ """Resolve the client-facing field topic to its durable stores and grant topic."""
93
+ raw = str(table_key or "").strip()
94
+ if raw in ("customer_data", "customer_table_workspace"):
95
+ return "customer_table_workspace", "customer_table_workspace", "customer_data"
96
+ if raw in ("product_data", "product_table_workspace"):
97
+ return "product_table_workspace", "product_table_workspace", "product_data"
98
+ if raw.startswith("ut_"):
99
+ bare = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
100
+ return f"{bare}_table_workspace", bare, bare
101
+ workspace = raw if raw.endswith("_table_workspace") else f"{raw}_table_workspace"
102
+ shared = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
103
+ return workspace, shared, shared
104
+
105
+
106
+ def _field_definition(session, table_key, field_key):
107
+ """Return the shared/private definition and the keys used by its write paths."""
108
+ workspace_key, shared_key, grant_topic = _field_storage_keys(table_key)
109
+ try:
110
+ from core import shared_overlay
111
+ shared = (shared_overlay.fields(shared_key, st=session.runtime) or {}).get(field_key)
112
+ if isinstance(shared, dict):
113
+ return shared, True, workspace_key, shared_key, grant_topic
114
+ import core.table_store as table_store
115
+ private = (table_store.make(workspace_key, st=session.runtime)
116
+ .workspace(session.uname).get("fields") or {}).get(field_key)
117
+ if isinstance(private, dict):
118
+ return private, False, workspace_key, shared_key, grant_topic
119
+ except Exception: # noqa: BLE001
120
+ pass
121
+ return None, False, workspace_key, shared_key, grant_topic
122
+
123
+
124
+ def _field_owner(session, definition, already_shared):
125
+ """Resolve the creator for the share claim wall.
126
+
127
+ A private field is already namespaced by the caller's own workspace. Older field records
128
+ from before the host-side creator stamp therefore remain safely claimable by that workspace
129
+ owner, while a shared definition with no creator stays admin-only because its storage is
130
+ tenant-wide and cannot identify an owner from residency alone.
131
+ """
132
+ owner = str((definition or {}).get("createdBy") or "").strip()
133
+ if owner:
134
+ return owner
135
+ return str(session.uname or "").strip() if not already_shared else ""
136
+
137
+
138
+ def _topics(session):
139
  """Every topic whose workspace could hold a view or folder for this tenant.
140
 
141
  ⚠ `all_defs`, never `all_tables` β€” the latter is the whole 28.6 MB row payload (~703 ms on
 
168
  # a brand-new column has no grant record, so `put_share` falls to this predicate β€” and
169
  # without it the column's own creator is answered `404 no_object` on the first attempt to
170
  # share the thing they just made.
171
+ table_key, field_key = shares.split_field_oid(oid)
172
+ if not table_key:
173
+ return False
174
+ defn, _shared, _workspace, _shared_key, _grant_topic = _field_definition(
175
+ session, table_key, field_key)
176
+ owner = _field_owner(session, defn, _shared)
177
+ return bool(defn) and owner.lower() == str(session.uname).strip().lower()
 
 
178
  if kind == "database":
179
  # ⚠ `may_open` is THE resolver for a user table (its own docstring says so) and already
180
  # admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
 
287
 
288
 
289
  @router.get("/share/{kind}/{oid}")
290
+ def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
291
+ kind = _kind_or_400(kind)
292
+ if kind == "field":
293
+ table_key, field_key = shares.split_field_oid(oid)
294
+ if table_key and field_key:
295
+ oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
296
  rec = shares.grants(kind, oid, st=session.runtime)
297
  role = shares.role_for(kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
298
  may_admin = shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
 
347
 
348
 
349
  @router.put("/share/{kind}/{oid}")
350
+ def put_share(kind: str, oid: str, body: dict = Body(default=None),
351
+ session: Session = Depends(require_session)):
352
+ kind = _kind_or_400(kind)
353
+ if kind == "field":
354
+ table_key, field_key = shares.split_field_oid(oid)
355
+ if table_key and field_key:
356
+ oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
357
+ body = body or {}
358
  rec = shares.grants(kind, oid, st=session.runtime)
359
  # An object with NO grant record yet has no owner β€” the first person to share it claims it.
360
  # That is safe because reaching this route at all means passing the surface's own wall, and
 
381
  "revoking is expressed")
382
  _entries_or_400(session, entries)
383
  if kind == "field":
384
+ # A field grant is a visibility and edit wall. Promote a private custom
385
+ # field exactly once, then keep the requested Share field role as the
386
+ # authoritative override for the legacy permissions bag.
387
+ from core import field_permissions, shared_overlay
 
 
388
  table_key, field_key = shares.split_field_oid(oid)
389
+ defn, already_shared, workspace_key, shared_key, grant_topic = _field_definition(
390
+ session, table_key, field_key)
391
  if not isinstance(defn, dict):
392
  raise err(404, "no_object", "no such field, or it is not shared with this account")
393
+ if not already_shared:
394
+ defn = field_permissions.promote_field(
395
+ workspace_key, shared_key, grant_topic,
396
+ session.uname, defn, st=session.runtime)
397
  stamped = dict(defn)
398
+ stamped["shared"] = True
399
  stamped["granted"] = True
400
+ shared_overlay.put_field(shared_key, field_key, stamped, st=session.runtime)
401
+ oid = shares.field_oid(grant_topic, field_key)
402
  out = shares.set_grants(kind, oid, entries, owner=rec["owner"] or session.uname,
403
  st=session.runtime)
404
  _notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
 
484
  # answers `route=None` β€” and `_notify_new_grantees` returns EARLY. The grant lands and
485
  # the receiver is never told, which is the silent half of owner item 18 reopened one
486
  # kind over.
487
+ from routes_alerts import route_for_topic
488
+ table_key, field_key = shares.split_field_oid(oid)
489
+ if not table_key:
490
+ return ("A column", None, "")
491
+ try:
492
+ defn, _shared, _workspace, shared_key, _grant_topic = _field_definition(
493
+ session, table_key, field_key)
494
+ except Exception: # noqa: BLE001
495
+ defn = None
496
+ label = str((defn or {}).get("label") or "").strip() or field_key
497
  # ⚠ TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
498
  # by whatever the calling door already held: a `ut_*` database uses its bare key,
499
  # while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
500
  # `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
501
  # `customer_data`), so the suffix comes off before it is asked β€” rather than a second
502
  # route table being written here, which is how the two come apart.
503
+ _WS = "_table_workspace"
504
+ scope = {"customer_data": "customer", "product_data": "product"}.get(table_key)
505
+ if scope is None:
506
+ scope = table_key[:-len(_WS)] if table_key.endswith(_WS) else table_key
507
+ return (label, route_for_topic(scope) or None, "")
508
  if kind == "database":
509
  import core.user_tables as ut
510
  defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
api/routes_tables.py CHANGED
@@ -744,8 +744,19 @@ def _ut_shared_fields(session, table_key, fields):
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):
 
744
  if not defs:
745
  return fields
746
  have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
747
+ from core import shares
748
+ projected = []
749
+ for k, f in defs.items():
750
+ if k in have:
751
+ continue
752
+ item = dict(f, source="overlay", shared=True)
753
+ role = shares.role_for(
754
+ "field", shares.field_oid(table_key, k), session.uname,
755
+ is_admin=session.admin, st=session.runtime)
756
+ if role:
757
+ item["sharedRole"] = role
758
+ projected.append(item)
759
+ return list(fields or ()) + projected
760
 
761
 
762
  def _ut_shared_cells(session, table_key, pids):
platform/core/grid_events.py CHANGED
@@ -261,6 +261,77 @@ def _tops(ctx):
261
  return cl_mod.TABLE_OPS
262
 
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  def _cohorts(ctx):
265
  """The COHORT store this ctx operates on β€” `_tops`'s sibling, and here for the same reason.
266
 
@@ -1199,11 +1270,7 @@ def handle_one(event, ctx):
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)
@@ -1211,6 +1278,11 @@ def handle_one(event, ctx):
1211
  except Exception:
1212
  shared_prior = None
1213
  if shared_field:
 
 
 
 
 
1214
  # Pin the type/source/key to the stored definition. Only the
1215
  # user-authored settings below are allowed to change, so a browser
1216
  # cannot turn an image field into another schema type while saving
@@ -1396,9 +1468,17 @@ def handle_one(event, ctx):
1396
  # admin path and the legacy path are enforceable (and NC'd) right now.
1397
  needs_values = False
1398
  refused_perms = False
 
 
1399
  if shared_field or key.startswith('custom_') or key.startswith(_ag2.MEASURE_FIELD_PREFIX):
1400
  prior = shared_prior if shared_field else ws.get('fields', {}).get(key)
1401
  prior = prior if isinstance(prior, dict) else None
 
 
 
 
 
 
1402
  if prior is None and not shared_field:
1403
  field['createdBy'] = uname
1404
  # Wave-6 item 9: scope is chosen at CREATE, on the cohort page only β€”
@@ -1422,6 +1502,7 @@ def handle_one(event, ctx):
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
@@ -1440,18 +1521,13 @@ def handle_one(event, ctx):
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
@@ -1460,10 +1536,11 @@ def handle_one(event, ctx):
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,
@@ -2116,16 +2193,11 @@ def handle_one(event, ctx):
2116
  if key in (ctx.hidden_keys or frozenset()):
2117
  refused = True
2118
  continue
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
 
261
  return cl_mod.TABLE_OPS
262
 
263
 
264
+ def _field_share_keys(ctx):
265
+ """Return (shared storage key, grant topic) for this grid's field registry."""
266
+ raw = str(getattr(getattr(ctx, 'table', None), 'table_key', '') or '')
267
+ scope = str(getattr(ctx, 'scope_key', '') or '')
268
+ if raw == 'product_table_workspace' or scope == 'product':
269
+ return 'product_table_workspace', 'product_data'
270
+ if raw == 'customer_table_workspace' or scope in ('customer', 'cohort'):
271
+ return 'customer_table_workspace', 'customer_data'
272
+ if raw.startswith('ut_'):
273
+ bare = raw[:-len('_table_workspace')] if raw.endswith('_table_workspace') else raw
274
+ return bare, bare
275
+ if raw.endswith('_table_workspace'):
276
+ return raw, raw[:-len('_table_workspace')]
277
+ return raw, raw
278
+
279
+
280
+ def _field_share_role(ctx, key, definition):
281
+ """Return the specific Share field role, if this definition is governed by it."""
282
+ if not isinstance(definition, dict) or not (definition.get('shared') or
283
+ definition.get('granted')):
284
+ return None
285
+ try:
286
+ import core.shares as _shares
287
+ _storage_key, topic = _field_share_keys(ctx)
288
+ return _shares.role_for(
289
+ 'field', _shares.field_oid(topic, key), ctx.uname,
290
+ is_admin=ctx.admin, st=ctx.st or getattr(ctx.table, 'st', None))
291
+ except Exception:
292
+ return None
293
+
294
+
295
+ def _may_edit_field_definition(ctx, key, definition):
296
+ """The server wall for rename/type/settings edits on a field."""
297
+ if not isinstance(definition, dict):
298
+ return bool(ctx.admin)
299
+ if ctx.admin:
300
+ return True
301
+ owner = str(definition.get('createdBy') or '').strip().lower()
302
+ if owner and owner == str(ctx.uname or '').strip().lower():
303
+ return True
304
+ if definition.get('shared') or definition.get('granted'):
305
+ return _field_share_role(ctx, key, definition) in ('owner', 'edit')
306
+ if not owner:
307
+ return False
308
+ from core import field_permissions as _fp
309
+ perms = _fp.stored_permissions(definition, fallback='collaborative')
310
+ edit = perms.get('edit')
311
+ return edit == 'collaborative' or (
312
+ edit == 'users' and str(ctx.uname).lower() in {
313
+ str(user).lower() for user in (perms.get('users') or [])})
314
+
315
+
316
+ def _may_edit_field_value(ctx, key, definition):
317
+ """The value-write wall; ordinary source-overlay fields remain collaborative by default."""
318
+ if not isinstance(definition, dict):
319
+ return bool(ctx.admin)
320
+ if ctx.admin:
321
+ return True
322
+ owner = str(definition.get('createdBy') or '').strip().lower()
323
+ if owner and owner == str(ctx.uname or '').strip().lower():
324
+ return True
325
+ if definition.get('shared') or definition.get('granted'):
326
+ return _field_share_role(ctx, key, definition) in ('owner', 'edit')
327
+ from core import field_permissions as _fp
328
+ perms = _fp.stored_permissions(definition, fallback='collaborative')
329
+ edit = perms.get('edit')
330
+ return edit == 'collaborative' or (
331
+ edit == 'users' and str(ctx.uname).lower() in {
332
+ str(user).lower() for user in (perms.get('users') or [])})
333
+
334
+
335
  def _cohorts(ctx):
336
  """The COHORT store this ctx operates on β€” `_tops`'s sibling, and here for the same reason.
337
 
 
1270
  try:
1271
  import core.shared_overlay as _shared_overlay
1272
  table_key = str(ctx.table.table_key)
1273
+ shared_storage_key, grant_topic = _field_share_keys(ctx)
 
 
 
 
1274
  shared_prior = (_shared_overlay.fields(
1275
  shared_storage_key, st=ctx.table.st) or {}).get(key)
1276
  shared_field = bool(isinstance(shared_prior, dict)
 
1278
  except Exception:
1279
  shared_prior = None
1280
  if shared_field:
1281
+ if not _may_edit_field_definition(ctx, key, shared_prior):
1282
+ return _refuse(
1283
+ ctx, 'field_read_only',
1284
+ 'you can only change this field if you created it or have Can edit access',
1285
+ key)
1286
  # Pin the type/source/key to the stored definition. Only the
1287
  # user-authored settings below are allowed to change, so a browser
1288
  # cannot turn an image field into another schema type while saving
 
1468
  # admin path and the legacy path are enforceable (and NC'd) right now.
1469
  needs_values = False
1470
  refused_perms = False
1471
+ permission_sync = False
1472
+ promoted_this_save = False
1473
  if shared_field or key.startswith('custom_') or key.startswith(_ag2.MEASURE_FIELD_PREFIX):
1474
  prior = shared_prior if shared_field else ws.get('fields', {}).get(key)
1475
  prior = prior if isinstance(prior, dict) else None
1476
+ if prior is not None and not shared_field \
1477
+ and not _may_edit_field_definition(ctx, key, prior):
1478
+ return _refuse(
1479
+ ctx, 'field_read_only',
1480
+ 'you can only change this field if you created it or have Can edit access',
1481
+ key)
1482
  if prior is None and not shared_field:
1483
  field['createdBy'] = uname
1484
  # Wave-6 item 9: scope is chosen at CREATE, on the cohort page only β€”
 
1502
  may_change_permissions = bool(admin or (owner and owner.lower() == uname.lower()))
1503
  if may_change_permissions:
1504
  field['permissions'] = want
1505
+ permission_sync = has_permission_request
1506
  else:
1507
  field['permissions'] = have
1508
  # Asked for a permissions change and did not get it: the client's
 
1521
  and isinstance(value, dict))
1522
  ]
1523
  desired_shared = field.get('permissions', {}).get('edit') in ('collaborative', 'users')
 
 
 
 
 
 
1524
  if not shared_field and desired_shared and shared_storage_key and ctx.table is not None:
1525
  # Promotion makes the permission choice true for the whole tenant, rather than merely
1526
  # storing a collaborative label in Anna's private workspace.
1527
  field = _fp.promote_field(ctx.table.table_key, shared_storage_key, grant_topic,
1528
  field.get('createdBy') or uname, field, st=ctx.table.st)
1529
  shared_field = True
1530
+ promoted_this_save = True
1531
  if shared_field:
1532
  # A shared definition is the source of truth for every tenant
1533
  # member. Persisting this through TableStore.save_field would put
 
1536
  import core.shared_overlay as _shared_overlay
1537
  field = _shared_overlay.put_field(shared_storage_key, key, field,
1538
  st=ctx.table.st)
1539
+ if permission_sync or promoted_this_save:
1540
+ import core.shares as _shares
1541
+ _shares.set_grants('field', _shares.field_oid(grant_topic, key),
1542
+ _fp.grant_entries(field.get('permissions')),
1543
+ owner=field.get('createdBy') or uname, st=ctx.table.st)
1544
  elif _store_of(ctx).available():
1545
  field = (_tops(ctx).save_field(
1546
  uname, field, reserved_names=_reserved_field_names,
 
2193
  if key in (ctx.hidden_keys or frozenset()):
2194
  refused = True
2195
  continue
2196
+ # Wave-5 item 1 plus Share field precedence: the client hiding its editor is
2197
+ # courtesy, this is the wall. A shared field's registry role overrides the legacy
2198
+ # permissions bag, so Can view remains read-only inside an editable view.
 
2199
  definition = field_by_key.get(key) or {}
2200
+ permitted = _may_edit_field_value(ctx, key, definition)
 
 
 
 
2201
  if not permitted:
2202
  refused = True
2203
  continue
platform/core/user_tables.py CHANGED
@@ -2467,9 +2467,11 @@ def clean_machine_fields(fields):
2467
  return clean, refused
2468
 
2469
 
2470
- def may_edit_field(table_key, fkey, viewer, is_admin=False, st=None):
2471
- """May `viewer` change THIS column's definition? Creator/admin always; others only when the
2472
- field itself says `editRole: 'everyone'`. Fail-closed on an unknown field."""
 
 
2473
  table = get(table_key, st) or {}
2474
  field = next((f for f in (table.get('fields') or []) if f.get('key') == str(fkey)), None)
2475
  # Instagram's pre-set schema is product contract, not tenant configuration. Even an admin
@@ -2479,12 +2481,21 @@ def may_edit_field(table_key, fkey, viewer, is_admin=False, st=None):
2479
  and field['automation'].get('preset') is True \
2480
  and not preset_editable(field):
2481
  return False
2482
- if may_open(table_key, viewer, is_admin, st) and (
2483
- bool(is_admin) or table.get('createdBy') == viewer):
2484
- return True
2485
- for f in (table.get('fields') or []):
2486
- if f.get('key') == str(fkey):
2487
- return f.get('editRole') == 'everyone'
 
 
 
 
 
 
 
 
 
2488
  return False
2489
 
2490
 
 
2467
  return clean, refused
2468
 
2469
 
2470
+ def may_edit_field(table_key, fkey, viewer, is_admin=False, st=None):
2471
+ """May `viewer` change THIS column's definition? Creator/admin always; others only when the
2472
+ field itself says `editRole: 'everyone'`, unless Share field has a specific role. The
2473
+ field-specific share role is authoritative for shared fields. Fail-closed on an unknown
2474
+ field."""
2475
  table = get(table_key, st) or {}
2476
  field = next((f for f in (table.get('fields') or []) if f.get('key') == str(fkey)), None)
2477
  # Instagram's pre-set schema is product contract, not tenant configuration. Even an admin
 
2481
  and field['automation'].get('preset') is True \
2482
  and not preset_editable(field):
2483
  return False
2484
+ if may_open(table_key, viewer, is_admin, st) and (
2485
+ bool(is_admin) or table.get('createdBy') == viewer):
2486
+ return True
2487
+ for f in (table.get('fields') or []):
2488
+ if f.get('key') == str(fkey):
2489
+ try:
2490
+ import core.shares as shares
2491
+ role = shares.role_for(
2492
+ 'field', shares.field_oid(table_key, fkey), viewer,
2493
+ is_admin=is_admin, st=st)
2494
+ if role is not None:
2495
+ return role in ('owner', 'edit')
2496
+ except Exception:
2497
+ pass
2498
+ return f.get('editRole') == 'everyone'
2499
  return False
2500
 
2501
 
web/src/customer-grid/ColumnMenu.tsx CHANGED
@@ -11,35 +11,19 @@ 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, 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;
@@ -275,9 +259,6 @@ interface ColumnMenuProps {
275
  /** Wave-5 item 1 β€” Duplicate field. Supplied for creatable strata only (base Odoo fields
276
  * offer no Duplicate β€” cloning the source of truth into an editable copy is out of scope). */
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,9 +277,6 @@ interface ColumnMenuProps {
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
@@ -355,7 +333,7 @@ type CreatePosition = "left" | "right" | "end";
355
  * choices/stars/formula config), and the Change-field control that swaps the column to show
356
  * another field.
357
  */
358
- type MenuPane = "menu" | "note" | "edit" | "permissions" | "format" | "summary";
359
 
360
  /**
361
  * How each creatable type reads in the menu. DERIVED from CREATABLE_TYPES rather than listed
@@ -2048,7 +2026,6 @@ export default function ColumnMenu({
2048
  onDelete,
2049
  onEnrich,
2050
  onDuplicate,
2051
- onPermissions,
2052
  onFormat,
2053
  onAggregate,
2054
  onSort,
@@ -2058,11 +2035,15 @@ export default function ColumnMenu({
2058
  onGroupByField,
2059
  onClearGroup,
2060
  userOptions = [],
2061
- permissionUserOptions = [],
2062
  measures = [],
2063
  tableKey,
2064
  }: ColumnMenuProps) {
2065
- const [pane, setPane] = useState<MenuPane>(schemaLocked ? "menu" : initialPane ?? "menu");
 
 
 
 
 
2066
  const [note, setNote] = useState(field.note ?? "");
2067
  const [position, setPosition] = useState<CreatePosition | null>(initialPosition ?? null);
2068
  /** Delete is DESTRUCTIVE (a custom field's stored values go with it) β€” first click arms,
@@ -2172,10 +2153,6 @@ export default function ColumnMenu({
2172
  * behind an Apply rather than emitted per keystroke: a custom range is typed one date at a
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. */
@@ -2560,6 +2537,7 @@ export default function ColumnMenu({
2560
  // not. Both ride the same PATCH; the label is part of the definition either way.
2561
  const editNameDirty =
2562
  (!!onRename || !!onFieldConfig) && cleanRename !== "" && cleanRename !== field.label;
 
2563
  const editOptionsChanged =
2564
  needsOptions(editType) &&
2565
  editOptions.join("\n") !== choiceOptions(field).join("\n");
@@ -2643,11 +2621,11 @@ export default function ColumnMenu({
2643
  // is refused by the server, so Save is refused here instead: a 400 arriving after the click
2644
  // is the same "named, configured, gone" surprise one layer later.
2645
  (editType !== "ai_enrich" || aiEnrich.prompt.trim() !== "") &&
2646
- (editingRelational
2647
- ? (editRollupValid && (editRelationalTouched || editNameDirty) &&
2648
- (field.type !== "link" || editLinkTable !== ""))
2649
- : (editNameDirty || editRetypeTouched || editFormulaTouched ||
2650
- (canPeriod && periodChanged)));
2651
  /**
2652
  * Item 15 β€” the renames, by ROW IDENTITY. A row that kept its id and changed its label was
2653
  * renamed; a row with an id nobody has seen is new; an id that is gone was deleted. Empty
@@ -2664,7 +2642,7 @@ export default function ColumnMenu({
2664
  : [];
2665
  const applyEdit = () => {
2666
  if (!canSaveEdit) return;
2667
- if (editingRelational && onFieldConfig) {
2668
  // β›” THE WHOLE BAG, EVERY SAVE. `_clean_rollup` is a pure cleaner over what it receives β€”
2669
  // it does not merge with the stored bag β€” so a partial patch is a REBUILD, and an omitted
2670
  // `limit` would silently become "no limit" rather than "unchanged".
@@ -2675,6 +2653,7 @@ export default function ColumnMenu({
2675
  : { link: { table: editLinkTable,
2676
  ...(editLinkSingle ? { single: true } : {}) } }),
2677
  });
 
2678
  onClose();
2679
  return;
2680
  }
@@ -2740,6 +2719,7 @@ export default function ColumnMenu({
2740
  // instead of it: renaming "Sales YTD" to "Sales" and re-pointing it at the last 90 days
2741
  // is one edit to the user and must not silently drop half of itself.
2742
  if (canPeriod && periodChanged && draftWindow) onPeriod!(draftWindow);
 
2743
  onClose();
2744
  };
2745
 
@@ -3118,7 +3098,7 @@ export default function ColumnMenu({
3118
  dataKind="column-menu"
3119
  >
3120
  <PaneHead title="Edit field" sub={typeLine} onClose={onClose} />
3121
- <div className="cg-column-create">
3122
  {onRename || editingRelational ? (
3123
  <label>
3124
  <span>Name</span>
@@ -3145,6 +3125,25 @@ export default function ColumnMenu({
3145
  </span>
3146
  </label>
3147
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3148
  {/* C5-AUTOFIELD β€” an EXISTING automation column edits its CONFIG, not its type.
3149
  ⚠ This guard is load-bearing, not tidiness. `automation` is excluded from
3150
  RETYPE_TYPES (see its note), so rendering the picker for one would show a
@@ -3570,140 +3569,6 @@ export default function ColumnMenu({
3570
  );
3571
  }
3572
 
3573
- // ------------------------------------------------------------------ NOTE pane (item 2)
3574
- // "Edit field description" edits the USER's note β€” the canonical `description` is the
3575
- // contract's default and is shown as context, never written from here.
3576
- if (pane === "note") {
3577
- return (
3578
- <AnchoredOverlay
3579
- anchor={state.anchor}
3580
- className="cg-column-menu"
3581
- onDismiss={onClose}
3582
- role="dialog"
3583
- ariaLabel={`Edit description for ${field.label}`}
3584
- initialFocus="[data-overlay-autofocus]"
3585
- dataKind="column-menu"
3586
- >
3587
- <PaneHead title="Edit field description" sub={field.label} onClose={onClose} />
3588
- <div className="cg-column-create">
3589
- {field.description && (
3590
- <div className="cg-field-hint cg-desc-default">
3591
- Default description: {field.description}
3592
- </div>
3593
- )}
3594
- <label>
3595
- <span>Your description</span>
3596
- <textarea
3597
- className="cg-input"
3598
- data-overlay-autofocus
3599
- autoFocus
3600
- rows={4}
3601
- value={note}
3602
- placeholder={
3603
- field.description
3604
- ? "Write your own to replace the default…"
3605
- : "Explain how this field should be used…"
3606
- }
3607
- onChange={(event) => setNote(event.target.value)}
3608
- />
3609
- </label>
3610
- <div className="cg-form-actions">
3611
- <button
3612
- type="button"
3613
- className="cg-btn cg-btn--primary"
3614
- disabled={note === (field.note ?? "")}
3615
- onClick={() => {
3616
- onNote(note);
3617
- onClose();
3618
- }}
3619
- >
3620
- Save
3621
- </button>
3622
- <button type="button" className="cg-btn" onClick={() => setPane("menu")}>
3623
- Back
3624
- </button>
3625
- </div>
3626
- </div>
3627
- </AnchoredOverlay>
3628
- );
3629
- }
3630
-
3631
- // ------------------------------------------------------------- PERMISSIONS pane (item 1)
3632
- if (pane === "permissions") {
3633
- return (
3634
- <AnchoredOverlay
3635
- anchor={state.anchor}
3636
- className="cg-column-menu"
3637
- onDismiss={onClose}
3638
- role="dialog"
3639
- ariaLabel={`Edit permissions for ${field.label}`}
3640
- initialFocus="[data-overlay-autofocus]"
3641
- dataKind="column-menu"
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
- >
3696
- Save
3697
- </button>
3698
- <button type="button" className="cg-btn" onClick={() => setPane("menu")}>
3699
- Back
3700
- </button>
3701
- </div>
3702
- </div>
3703
- </AnchoredOverlay>
3704
- );
3705
- }
3706
-
3707
  // ------------------------------------------------------------------ FORMAT pane (item 10)
3708
  /**
3709
  * ⭐⭐ WAVE-29 T33 (owner item 17) β€” THE COLUMN SUMMARY PANE.
@@ -3875,11 +3740,6 @@ export default function ColumnMenu({
3875
  // ------------------------------------------------------------------ the MENU pane
3876
  // Wave-5 items 1/2/3: a flat action list. Editors open their OWN windows (the panes above);
3877
  // nothing edits inline here. Conditional entries render IFF they apply to the current view.
3878
- const canPermissions =
3879
- !!onPermissions &&
3880
- !!viewer &&
3881
- (viewer.isAdmin || (field.createdBy != null && field.createdBy === viewer.name));
3882
-
3883
  /**
3884
  * ⭐⭐ W39-T17 β€” THE SHARE ID FOR THIS COLUMN, or `null` if one cannot honestly be built.
3885
  *
@@ -3922,7 +3782,7 @@ export default function ColumnMenu({
3922
 
3923
  /**
3924
  * ⭐⭐ W39-T17 / R8 β€” MAY THIS VIEWER DELETE THIS COLUMN? The client half of the server's wall,
3925
- * spelled with the SAME comparison `canPermissions` above already uses rather than a second
3926
  * one: `createdBy` is a login username while the people picker deals in display names
3927
  * ([[login-resolves-email-writes-do-not]]), and one spelling means a mismatch lands identically
3928
  * in both places instead of differently in each.
@@ -3966,7 +3826,7 @@ export default function ColumnMenu({
3966
  is one line that STATES the period and opens that pane, because the window is the
3967
  first thing you want to know about a metric column and the menu had become the only
3968
  place it was written down. A row, not a control: the menu pane edits nothing inline. */}
3969
- {canPeriod && !schemaLocked && (
3970
  <button
3971
  type="button"
3972
  className="cg-column-periodline"
@@ -3988,7 +3848,7 @@ export default function ColumnMenu({
3988
  the one window for the field's name, type (with per-type choices/stars/formula)
3989
  and the Change-field control. Offered on EVERY field β€” what a read-only field
3990
  cannot change, the pane says honestly instead of hiding the door. */}
3991
- {!schemaLocked && (
3992
  <button type="button" data-overlay-autofocus onClick={() => {
3993
  setRenameDraft(field.label);
3994
  setPane("edit");
@@ -4018,12 +3878,11 @@ export default function ColumnMenu({
4018
  }}
4019
  >
4020
  {/* β›” AN INLINE NODE, NOT A `MenuIcon` NAME, and that is forced rather than chosen.
4021
- `MenuLabel`'s prop is `MenuIconName | ReactNode` and `ReactNode` admits any
4022
- string, so `icon="share"` would COMPILE and paint an empty 16px box forever
4023
- (`MENU_ICONS` has no such key, and `icons.tsx` is not this ticket's file). The
4024
- three-node graph is the mark every product uses for this verb; borrowing the
4025
- padlock that "Edit field permissions" already wears below would put two identical
4026
- glyphs on two different questions. */}
4027
  <MenuLabel
4028
  icon={
4029
  <svg width={16} height={16} viewBox="0 0 16 16" fill="none" aria-hidden>
@@ -4063,19 +3922,8 @@ export default function ColumnMenu({
4063
 
4064
  <div className="cg-menu-sep" role="separator" aria-hidden />
4065
 
4066
- {/* Group 2 β€” the rest of the def-editing family: description Β· permissions Β· format.
4067
- Rename and Change field live INSIDE Edit field now (owner item 8) β€” their old
4068
- rows are gone, not duplicated. */}
4069
- {!schemaLocked && (
4070
- <button type="button" onClick={() => setPane("note")}>
4071
- <MenuLabel icon="description" text="Edit field description" />
4072
- </button>
4073
- )}
4074
- {!schemaLocked && canPermissions && (
4075
- <button type="button" onClick={() => setPane("permissions")}>
4076
- <MenuLabel icon="permissions" text="Edit field permissions" />
4077
- </button>
4078
- )}
4079
  {!schemaLocked && formatKind && (
4080
  <button type="button" onClick={() => setPane("format")}>
4081
  <MenuLabel icon="format" text="Field format" />
 
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, mayEditFieldDefinition } 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;
 
259
  /** Wave-5 item 1 β€” Duplicate field. Supplied for creatable strata only (base Odoo fields
260
  * offer no Duplicate β€” cloning the source of truth into an editable copy is out of scope). */
261
  onDuplicate?: () => void;
 
 
 
262
  /** Wave-5 item 10 β€” save a display format. Supplied for number/currency/formula and
263
  * date/created_time fields. */
264
  onFormat?: (format: FieldFormat) => void;
 
277
  /** Assignable people, from the host's real user list. Empty = the host did not supply one,
278
  * and "Assignee" is offered without choices rather than with invented ones. */
279
  userOptions?: string[];
 
 
 
280
  /**
281
  * CG-8's measures, reused as the vocabulary of a FORMULA-MEASURE column (owner item 7):
282
  * `Sales Β· the last 90 days` as a column the user creates. Empty = the semantic store is
 
333
  * choices/stars/formula config), and the Change-field control that swaps the column to show
334
  * another field.
335
  */
336
+ type MenuPane = "menu" | "edit" | "format" | "summary";
337
 
338
  /**
339
  * How each creatable type reads in the menu. DERIVED from CREATABLE_TYPES rather than listed
 
2026
  onDelete,
2027
  onEnrich,
2028
  onDuplicate,
 
2029
  onFormat,
2030
  onAggregate,
2031
  onSort,
 
2035
  onGroupByField,
2036
  onClearGroup,
2037
  userOptions = [],
 
2038
  measures = [],
2039
  tableKey,
2040
  }: ColumnMenuProps) {
2041
+ const canEditDefinition = !schemaLocked && mayEditFieldDefinition(field, viewer);
2042
+ const [pane, setPane] = useState<MenuPane>(
2043
+ schemaLocked || (initialPane === "edit" && !canEditDefinition)
2044
+ ? "menu"
2045
+ : initialPane ?? "menu"
2046
+ );
2047
  const [note, setNote] = useState(field.note ?? "");
2048
  const [position, setPosition] = useState<CreatePosition | null>(initialPosition ?? null);
2049
  /** Delete is DESTRUCTIVE (a custom field's stored values go with it) β€” first click arms,
 
2153
  * behind an Apply rather than emitted per keystroke: a custom range is typed one date at a
2154
  * time, and each emit is a full host round trip that would repaint the column half-ready. */
2155
  const [periodDraft, setPeriodDraft] = useState<WindowSpec | null>(null);
 
 
 
 
2156
  /** Format pane draft β€” seeded from the field, saved whole. */
2157
  const [fmtDraft, setFmtDraft] = useState<FieldFormat>(() => ({ ...(field.format ?? {}) }));
2158
  /** Item 9c β€” the create forms' Scope draft. 'cohort' is the DEFAULT per the contract. */
 
2537
  // not. Both ride the same PATCH; the label is part of the definition either way.
2538
  const editNameDirty =
2539
  (!!onRename || !!onFieldConfig) && cleanRename !== "" && cleanRename !== field.label;
2540
+ const editNoteDirty = note !== (field.note ?? "");
2541
  const editOptionsChanged =
2542
  needsOptions(editType) &&
2543
  editOptions.join("\n") !== choiceOptions(field).join("\n");
 
2621
  // is refused by the server, so Save is refused here instead: a 400 arriving after the click
2622
  // is the same "named, configured, gone" surprise one layer later.
2623
  (editType !== "ai_enrich" || aiEnrich.prompt.trim() !== "") &&
2624
+ (editingRelational
2625
+ ? (editRollupValid && (editRelationalTouched || editNameDirty || editNoteDirty) &&
2626
+ (field.type !== "link" || editLinkTable !== ""))
2627
+ : (editNameDirty || editRetypeTouched || editFormulaTouched ||
2628
+ (canPeriod && periodChanged) || editNoteDirty));
2629
  /**
2630
  * Item 15 β€” the renames, by ROW IDENTITY. A row that kept its id and changed its label was
2631
  * renamed; a row with an id nobody has seen is new; an id that is gone was deleted. Empty
 
2642
  : [];
2643
  const applyEdit = () => {
2644
  if (!canSaveEdit) return;
2645
+ if (editingRelational && onFieldConfig && (editRelationalTouched || editNameDirty)) {
2646
  // β›” THE WHOLE BAG, EVERY SAVE. `_clean_rollup` is a pure cleaner over what it receives β€”
2647
  // it does not merge with the stored bag β€” so a partial patch is a REBUILD, and an omitted
2648
  // `limit` would silently become "no limit" rather than "unchanged".
 
2653
  : { link: { table: editLinkTable,
2654
  ...(editLinkSingle ? { single: true } : {}) } }),
2655
  });
2656
+ if (editNoteDirty) onNote(note);
2657
  onClose();
2658
  return;
2659
  }
 
2719
  // instead of it: renaming "Sales YTD" to "Sales" and re-pointing it at the last 90 days
2720
  // is one edit to the user and must not silently drop half of itself.
2721
  if (canPeriod && periodChanged && draftWindow) onPeriod!(draftWindow);
2722
+ if (editNoteDirty) onNote(note);
2723
  onClose();
2724
  };
2725
 
 
3098
  dataKind="column-menu"
3099
  >
3100
  <PaneHead title="Edit field" sub={typeLine} onClose={onClose} />
3101
+ <div className="cg-column-create cg-field-edit">
3102
  {onRename || editingRelational ? (
3103
  <label>
3104
  <span>Name</span>
 
3125
  </span>
3126
  </label>
3127
  )}
3128
+ <label className="cg-edit-description">
3129
+ <span>Description</span>
3130
+ {field.description && (
3131
+ <span className="cg-field-hint cg-desc-default">
3132
+ Default description: {field.description}
3133
+ </span>
3134
+ )}
3135
+ <textarea
3136
+ className="cg-input"
3137
+ rows={3}
3138
+ value={note}
3139
+ placeholder={
3140
+ field.description
3141
+ ? "Write your own to replace the default…"
3142
+ : "Explain how this field should be used…"
3143
+ }
3144
+ onChange={(event) => setNote(event.target.value)}
3145
+ />
3146
+ </label>
3147
  {/* C5-AUTOFIELD β€” an EXISTING automation column edits its CONFIG, not its type.
3148
  ⚠ This guard is load-bearing, not tidiness. `automation` is excluded from
3149
  RETYPE_TYPES (see its note), so rendering the picker for one would show a
 
3569
  );
3570
  }
3571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3572
  // ------------------------------------------------------------------ FORMAT pane (item 10)
3573
  /**
3574
  * ⭐⭐ WAVE-29 T33 (owner item 17) β€” THE COLUMN SUMMARY PANE.
 
3740
  // ------------------------------------------------------------------ the MENU pane
3741
  // Wave-5 items 1/2/3: a flat action list. Editors open their OWN windows (the panes above);
3742
  // nothing edits inline here. Conditional entries render IFF they apply to the current view.
 
 
 
 
 
3743
  /**
3744
  * ⭐⭐ W39-T17 β€” THE SHARE ID FOR THIS COLUMN, or `null` if one cannot honestly be built.
3745
  *
 
3782
 
3783
  /**
3784
  * ⭐⭐ W39-T17 / R8 β€” MAY THIS VIEWER DELETE THIS COLUMN? The client half of the server's wall,
3785
+ * spelled with the same creator/admin identity rule as the server rather than a second
3786
  * one: `createdBy` is a login username while the people picker deals in display names
3787
  * ([[login-resolves-email-writes-do-not]]), and one spelling means a mismatch lands identically
3788
  * in both places instead of differently in each.
 
3826
  is one line that STATES the period and opens that pane, because the window is the
3827
  first thing you want to know about a metric column and the menu had become the only
3828
  place it was written down. A row, not a control: the menu pane edits nothing inline. */}
3829
+ {canPeriod && canEditDefinition && !schemaLocked && (
3830
  <button
3831
  type="button"
3832
  className="cg-column-periodline"
 
3848
  the one window for the field's name, type (with per-type choices/stars/formula)
3849
  and the Change-field control. Offered on EVERY field β€” what a read-only field
3850
  cannot change, the pane says honestly instead of hiding the door. */}
3851
+ {canEditDefinition && (
3852
  <button type="button" data-overlay-autofocus onClick={() => {
3853
  setRenameDraft(field.label);
3854
  setPane("edit");
 
3878
  }}
3879
  >
3880
  {/* β›” AN INLINE NODE, NOT A `MenuIcon` NAME, and that is forced rather than chosen.
3881
+ `MenuLabel`'s prop is `MenuIconName | ReactNode` and `ReactNode` admits any
3882
+ string, so `icon="share"` would COMPILE and paint an empty 16px box forever
3883
+ (`MENU_ICONS` has no such key, and `icons.tsx` is not this ticket's file). The
3884
+ three-node graph is the mark every product uses for this verb; it keeps sharing
3885
+ visually distinct from field editing. */}
 
3886
  <MenuLabel
3887
  icon={
3888
  <svg width={16} height={16} viewBox="0 0 16 16" fill="none" aria-hidden>
 
3922
 
3923
  <div className="cg-menu-sep" role="separator" aria-hidden />
3924
 
3925
+ {/* Group 2 β€” display format. Description now lives in Edit field directly below Name,
3926
+ so there is one definition editor instead of two nearby doors. */}
 
 
 
 
 
 
 
 
 
 
 
3927
  {!schemaLocked && formatKind && (
3928
  <button type="button" onClick={() => setPane("format")}>
3929
  <MenuLabel icon="format" text="Field format" />
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -6739,7 +6739,6 @@ function CustomerGridSurface({
6739
  viewer={viewer}
6740
  onToggleLock={toggleViewLock}
6741
  onToggleImportant={toggleViewImportant}
6742
- userOptions={payload?.userOptions}
6743
  />
6744
  )}
6745
  {/* ⭐⭐ W37-T29 / CONTRACT C6 (rulings R2, R10) β€” E's CHAT, DOCKED AND OPENABLE.
@@ -7828,12 +7827,6 @@ function CustomerGridSurface({
7828
  // Creatable strata only β€” a base Odoo field offers no Duplicate (contract).
7829
  menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
7830
  }
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 })}
7838
  /**
7839
  * ⭐ WAVE-29 T33 (owner item 17) β€” the column summary, through whichever door owns this
@@ -7886,23 +7879,20 @@ function CustomerGridSurface({
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:
7893
- the query-preview key (`query:<binding>`, a projection rather than this database's
7894
- own columns) and the local fallback `${LOCAL_KEY_PREFIX}${scope}`, which this file's
7895
- own note at the declaration calls "a key the server never issues".
7896
- β›” AND THE FALLBACK IS THE DANGEROUS ONE, not merely the untidy one:
7897
- `core.shares.split_field_oid` fails closed on an id it cannot resolve,
7898
- `routes_shares._object_ref` answers `route=None`, and `_notify_new_grantees` returns
7899
- early β€” the grant lands and the receiver is NEVER TOLD. So this reads the SERVER's
7900
- key directly and passes `undefined` when there is none, which withdraws the Share
7901
- row rather than offering one that resolves to nothing.
7902
- ⚠ Only this instance takes it. The "+" render below opens straight into the create
7903
- form and never paints the action list, so the row it feeds is unreachable there β€”
7904
- unlike `linkTargets`, which both create paths genuinely need. */
7905
- tableKey={isQueryPreview ? undefined : payload?.workspace?.storageKey}
7906
  />
7907
  )}
7908
 
 
6739
  viewer={viewer}
6740
  onToggleLock={toggleViewLock}
6741
  onToggleImportant={toggleViewImportant}
 
6742
  />
6743
  )}
6744
  {/* ⭐⭐ W37-T29 / CONTRACT C6 (rulings R2, R10) β€” E's CHAT, DOCKED AND OPENABLE.
 
7827
  // Creatable strata only β€” a base Odoo field offers no Duplicate (contract).
7828
  menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
7829
  }
 
 
 
 
 
 
7830
  onFormat={(format) => saveField({ ...menuField, format })}
7831
  /**
7832
  * ⭐ WAVE-29 T33 (owner item 17) β€” the column summary, through whichever door owns this
 
7879
  onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
7880
  onClearGroup={() => updateConfig({ ...config, groupBy: null })}
7881
  userOptions={payload?.userOptions ?? []}
 
7882
  measures={measures}
7883
+ /* ⭐⭐ W39-T17 β€” field shares use the registry's canonical topic key, not the
7884
+ per-user browser storage key carried by `workspace.storageKey`. These are the same
7885
+ server-issued names used by the field-grant wall and the shared overlay write path.
7886
+ Query previews have no durable field share target, so the action is omitted. */
7887
+ tableKey={
7888
+ isQueryPreview
7889
+ ? undefined
7890
+ : scope === "product"
7891
+ ? "product_data"
7892
+ : scope?.startsWith("ut_")
7893
+ ? scope
7894
+ : "customer_data"
7895
+ }
 
 
7896
  />
7897
  )}
7898
 
web/src/customer-grid/ViewSidebar.tsx CHANGED
@@ -9,7 +9,6 @@ import type {
9
  FolderIcon,
10
  GridFolder,
11
  SavedView,
12
- ViewEditMode,
13
  ViewPermissions,
14
  Viewer,
15
  } from "./types";
@@ -18,12 +17,7 @@ import {
18
  DEFAULT_FOLDER_TONE,
19
  FOLDER_SHAPES,
20
  FOLDER_TONES,
21
- MAX_VIEW_USERS,
22
- VIEW_EDIT_BLURBS,
23
- VIEW_EDIT_LABELS,
24
- VIEW_EDIT_MODES,
25
  cleanFolderIcon,
26
- cleanViewPermissions,
27
  isModeFrozen,
28
  isUndeletableView,
29
  mayEditView,
@@ -37,7 +31,6 @@ import {
37
  MODE_LABELS,
38
  MODE_TONE,
39
  } from "./iconShapes";
40
- import { FOLDER_TONE_PAINT } from "./iconShapes";
41
  import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
42
  import { ROOT_FOLDER_ID } from "./folders";
43
  // ⭐ W32-T23 (owner item 17) β€” THE BELL, from the shared layer rather than redrawn here.
@@ -59,12 +52,10 @@ import {
59
  } from "./folders";
60
 
61
  /* ─────────────────────────────────────────────────────────────────────────────────────────
62
- ⭐ WAVE 27 Β· OWNER ITEM 1 β€” a MARK on each "Who can edit" row.
63
 
64
- The three choices used to be three radio buttons and three sentences, which is a wall of
65
- prose at the exact moment a user is deciding who else can change their work. A mark is the
66
- part you recognise on the second visit, and Airtable's own share step leads every row with
67
- one.
68
 
69
  β›” WHY THE PATHS LIVE HERE AND NOT IN `icons.tsx`, stated so a later wave does not "tidy" it
70
  back: `icons.tsx` renders `MODE_SHAPES` from `iconShapes.ts`, which the glide CANVAS also
@@ -80,46 +71,8 @@ import {
80
  never a literal, so these can never be the one place in the product with its own colours.
81
  Outline-only, the register the owner set in 2026-07-29: "it doesn't have a full color, only
82
  the linings". */
83
- const PERM_TONE: Record<ViewEditMode, "neutral" | "blue" | "green"> = {
84
- personal: "neutral",
85
- collaborative: "blue",
86
- users: "green",
87
- };
88
-
89
  /** head + shoulders, at `x`. One drawing, three uses β€” a second hand-drawn person is how two
90
  * marks in one panel end up different heights. */
91
- const person = (x: number) =>
92
- `M${x} 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z` +
93
- `M${x - 3.2} 12.6c0-1.9 1.4-3.1 3.2-3.1s3.2 1.2 3.2 3.1`;
94
-
95
- const PERM_MARK: Record<ViewEditMode, string[]> = {
96
- // One person: only you.
97
- personal: [person(8)],
98
- // Two, side by side and overlapping: anyone who can see the table.
99
- collaborative: [person(5.9), person(10.6)],
100
- // One person and a tick: the people you picked.
101
- users: [person(6), "M10.6 9.4l1.5 1.6 2.6-3"],
102
- };
103
-
104
- function PermMark({ mode }: { mode: ViewEditMode }) {
105
- const paint = FOLDER_TONE_PAINT[PERM_TONE[mode]];
106
- return (
107
- <svg className="cg-perm-mark" width={16} height={16} viewBox="0 0 16 16" aria-hidden>
108
- {PERM_MARK[mode].map((d, i) => (
109
- <path
110
- key={i}
111
- d={d}
112
- fill="none"
113
- stroke={paint.stroke}
114
- strokeWidth={1.35}
115
- strokeLinecap="round"
116
- strokeLinejoin="round"
117
- />
118
- ))}
119
- </svg>
120
- );
121
- }
122
-
123
  /* WAVE 20 item 18 (C-SHARE) β€” the system folder every view shared WITH you lands in until
124
  you file it somewhere of your own. It is the only folder id this component recognises by
125
  name, and as of wave 21 (item 9) it is DECLARED IN `folders.ts` beside the synthesis that
@@ -131,9 +84,7 @@ interface ViewSidebarProps {
131
  activeViewId: string;
132
  saveState: "saved" | "saving";
133
  onSelect: (id: string) => void;
134
- /** I14 + I17 β€” the flyout names the display mode and the prompt names who may edit, so
135
- * creation carries both. `permissions` is always explicit (C4: absent on create means
136
- * personal, so an omission would silently contradict the form). */
137
  onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void;
138
  /**
139
  * ⭐⭐ W37-T41 β€” may this surface hold a CUSTOM VIEW? The row is dropped when it cannot.
@@ -284,10 +235,6 @@ interface ViewSidebarProps {
284
  surface. They carried the transient-lock selection for the retired Cohorts section; a
285
  locked view is now an ordinary member of `views` and is selected by `onSelect` like any
286
  other, which is the whole point of R1. */
287
- /** I17 (C4) β€” the tenant's real account list, for the "Specific users" picker. The HOST
288
- * re-validates every name against core/users.py and drops what it cannot resolve, so this
289
- * list is a convenience, never the authority. */
290
- userOptions?: string[];
291
  /**
292
  * ⭐⭐ W36-T05 (owner item 6 Β· R3 Β· R14) β€” THE VIEW AGENT, to the LEFT of this rail.
293
  *
@@ -358,7 +305,6 @@ export default function ViewSidebar({
358
  viewer,
359
  onToggleLock,
360
  onToggleImportant,
361
- userOptions = [],
362
  }: ViewSidebarProps) {
363
  /**
364
  * I14 β€” creating a view now always knows WHICH KIND of view is being created, because the
@@ -368,10 +314,6 @@ export default function ViewSidebar({
368
  /** I14 β€” the "+ Create new…" flyout's anchor. */
369
  const [createMenu, setCreateMenu] = useState<HTMLButtonElement | null>(null);
370
  const [name, setName] = useState("");
371
- /** I17 (C4) β€” the create prompt's who-can-edit draft. Seeded PERSONAL, matching the host's
372
- * create-side default, so the form and the store agree before the user touches anything. */
373
- const [permEdit, setPermEdit] = useState<ViewEditMode>("personal");
374
- const [permUsers, setPermUsers] = useState<string[]>([]);
375
  const [menu, setMenu] = useState<{
376
  viewId: string;
377
  anchor: HTMLButtonElement;
@@ -686,16 +628,10 @@ export default function ViewSidebar({
686
  const create = () => {
687
  const value = name.trim();
688
  if (!value || !creating) return;
689
- // C4: permissions are sent EXPLICITLY on create. Omitting them means 'personal'
690
- // host-side, so a user who picked "Collaborative" and got a personal view would have no
691
- // way to tell β€” the form said one thing and the store did the other.
692
- // Cleaned through the same rules the host applies, so an empty "Specific users" grant
693
- // collapses to 'personal' HERE too and the confirmation the user sees is the truth.
694
- onCreate(value, creating, cleanViewPermissions(
695
- { edit: permEdit, users: permUsers }, "personal", userOptions));
696
  setName("");
697
- setPermEdit("personal");
698
- setPermUsers([]);
699
  setCreating(null);
700
  // WAVE 20 item 20 β€” the two steps share ONE panel, so the anchor outlives step 1 and
701
  // has to be released here. Left set, the flyout would spring back to the type chooser
@@ -712,8 +648,6 @@ export default function ViewSidebar({
712
  setCreating(null);
713
  setCreateMenu(null);
714
  setName("");
715
- setPermEdit("personal");
716
- setPermUsers([]);
717
  };
718
 
719
  const createFolder = () => {
@@ -1091,65 +1025,10 @@ export default function ViewSidebar({
1091
  if (event.key === "Escape") closeCreate();
1092
  }}
1093
  />
1094
- {/* I17 (C4) β€” the owner's prompt ORDER: type (picked in step 1, named above)
1095
- β†’ who can edit β†’ the user picker, and the picker only when it applies. */}
1096
- <div className="cg-perm" role="radiogroup" aria-label="Who can edit this view">
1097
- <span className="cg-perm-title">Who can edit</span>
1098
- {VIEW_EDIT_MODES.map((m) => (
1099
- <label key={m} className="cg-radio-row cg-perm-row">
1100
- <input
1101
- type="radio"
1102
- name="cg-view-perm"
1103
- checked={permEdit === m}
1104
- onChange={() => setPermEdit(m)}
1105
- />
1106
- {/* ⭐ ITEM 1 β€” the mark is its OWN COLUMN, between the radio and the words.
1107
- ⚠ Not inside `.cg-perm-label`: that element is a flex COLUMN (title over
1108
- blurb), so a mark placed in it becomes a THIRD ROW under the sentence
1109
- rather than a leading glyph β€” the wrong-parent trap
1110
- ([[wrong-parent-not-broken-control]]) in its cheapest form. */}
1111
- <PermMark mode={m} />
1112
- <span className="cg-perm-label">
1113
- {VIEW_EDIT_LABELS[m]}
1114
- <span className="cg-perm-blurb">{VIEW_EDIT_BLURBS[m]}</span>
1115
- </span>
1116
- </label>
1117
- ))}
1118
- {permEdit === "users" && (
1119
- <div className="cg-perm-users">
1120
- {userOptions.length === 0 ? (
1121
- // Never a silent empty box: an empty grant collapses to Personal
1122
- // host-side, so say that rather than letting the user think they
1123
- // shared it.
1124
- <span className="cg-perm-empty">
1125
- No other accounts to pick. This will save as Personal.
1126
- </span>
1127
- ) : (
1128
- userOptions.map((u) => (
1129
- <label key={u} className="cg-perm-user">
1130
- <input
1131
- type="checkbox"
1132
- checked={permUsers.includes(u)}
1133
- onChange={(e) =>
1134
- setPermUsers((cur) =>
1135
- e.target.checked
1136
- ? [...cur, u].slice(0, MAX_VIEW_USERS)
1137
- : cur.filter((x) => x !== u)
1138
- )
1139
- }
1140
- />
1141
- <span>{u}</span>
1142
- </label>
1143
- ))
1144
- )}
1145
- {userOptions.length > 0 && permUsers.length === 0 && (
1146
- <span className="cg-perm-empty">
1147
- Pick at least one person, or this saves as Personal.
1148
- </span>
1149
- )}
1150
- </div>
1151
- )}
1152
- </div>
1153
  <div className="cg-form-actions">
1154
  <button
1155
  type="button"
@@ -2128,8 +2007,8 @@ export default function ViewSidebar({
2128
  >
2129
  {/* ⭐ WAVE 33 Β· T15 (D-206) β€” A STAR, NOT A PADLOCK.
2130
  β›” This row shipped wearing MenuIcon `permissions` / `unlock` β€” a padlock that
2131
- "Share view", "Share folder", "Lock to a locked view" and "Edit field
2132
- permissions" ALSO wear, untitled, in these same menus. Marking a view important
2133
  is none of the three locks (DESIGN.md Β§4): it freezes no record, no column and
2134
  no row set. So the answer is to drop the lock, not to title it β€” a title naming
2135
  a lock that does not exist would be believed.
 
9
  FolderIcon,
10
  GridFolder,
11
  SavedView,
 
12
  ViewPermissions,
13
  Viewer,
14
  } from "./types";
 
17
  DEFAULT_FOLDER_TONE,
18
  FOLDER_SHAPES,
19
  FOLDER_TONES,
 
 
 
 
20
  cleanFolderIcon,
 
21
  isModeFrozen,
22
  isUndeletableView,
23
  mayEditView,
 
31
  MODE_LABELS,
32
  MODE_TONE,
33
  } from "./iconShapes";
 
34
  import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
35
  import { ROOT_FOLDER_ID } from "./folders";
36
  // ⭐ W32-T23 (owner item 17) β€” THE BELL, from the shared layer rather than redrawn here.
 
52
  } from "./folders";
53
 
54
  /* ─────────────────────────────────────────────────────────────────────────────────────────
55
+ Share-view ownership note for the create door.
56
 
57
+ View creation now stays private and delegates audience/edit choices to the single Share view
58
+ dialog. Keep this note aligned with that ownership boundary if the sharing surface changes.
 
 
59
 
60
  β›” WHY THE PATHS LIVE HERE AND NOT IN `icons.tsx`, stated so a later wave does not "tidy" it
61
  back: `icons.tsx` renders `MODE_SHAPES` from `iconShapes.ts`, which the glide CANVAS also
 
71
  never a literal, so these can never be the one place in the product with its own colours.
72
  Outline-only, the register the owner set in 2026-07-29: "it doesn't have a full color, only
73
  the linings". */
 
 
 
 
 
 
74
  /** head + shoulders, at `x`. One drawing, three uses β€” a second hand-drawn person is how two
75
  * marks in one panel end up different heights. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  /* WAVE 20 item 18 (C-SHARE) β€” the system folder every view shared WITH you lands in until
77
  you file it somewhere of your own. It is the only folder id this component recognises by
78
  name, and as of wave 21 (item 9) it is DECLARED IN `folders.ts` beside the synthesis that
 
84
  activeViewId: string;
85
  saveState: "saved" | "saving";
86
  onSelect: (id: string) => void;
87
+ /** The create door persists a private default; Share view owns audience and edit choices. */
 
 
88
  onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void;
89
  /**
90
  * ⭐⭐ W37-T41 β€” may this surface hold a CUSTOM VIEW? The row is dropped when it cannot.
 
235
  surface. They carried the transient-lock selection for the retired Cohorts section; a
236
  locked view is now an ordinary member of `views` and is selected by `onSelect` like any
237
  other, which is the whole point of R1. */
 
 
 
 
238
  /**
239
  * ⭐⭐ W36-T05 (owner item 6 Β· R3 Β· R14) β€” THE VIEW AGENT, to the LEFT of this rail.
240
  *
 
305
  viewer,
306
  onToggleLock,
307
  onToggleImportant,
 
308
  }: ViewSidebarProps) {
309
  /**
310
  * I14 β€” creating a view now always knows WHICH KIND of view is being created, because the
 
314
  /** I14 β€” the "+ Create new…" flyout's anchor. */
315
  const [createMenu, setCreateMenu] = useState<HTMLButtonElement | null>(null);
316
  const [name, setName] = useState("");
 
 
 
 
317
  const [menu, setMenu] = useState<{
318
  viewId: string;
319
  anchor: HTMLButtonElement;
 
628
  const create = () => {
629
  const value = name.trim();
630
  if (!value || !creating) return;
631
+ // Views start private. The Share view dialog is the single place where people choose the
632
+ // audience and whether each recipient can view or edit, so creation and sharing cannot drift.
633
+ onCreate(value, creating, { edit: "personal" });
 
 
 
 
634
  setName("");
 
 
635
  setCreating(null);
636
  // WAVE 20 item 20 β€” the two steps share ONE panel, so the anchor outlives step 1 and
637
  // has to be released here. Left set, the flyout would spring back to the type chooser
 
648
  setCreating(null);
649
  setCreateMenu(null);
650
  setName("");
 
 
651
  };
652
 
653
  const createFolder = () => {
 
1025
  if (event.key === "Escape") closeCreate();
1026
  }}
1027
  />
1028
+ <p className="cg-create-share-note">
1029
+ This view starts private. After you create it, use Share view to choose who can
1030
+ view or edit it.
1031
+ </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  <div className="cg-form-actions">
1033
  <button
1034
  type="button"
 
2007
  >
2008
  {/* ⭐ WAVE 33 Β· T15 (D-206) β€” A STAR, NOT A PADLOCK.
2009
  β›” This row shipped wearing MenuIcon `permissions` / `unlock` β€” a padlock that
2010
+ "Share view", "Share folder" and "Lock to a locked view" ALSO wear, untitled,
2011
+ in these same menus. Marking a view important
2012
  is none of the three locks (DESIGN.md Β§4): it freezes no record, no column and
2013
  no row set. So the answer is to drop the lock, not to title it β€” a title naming
2014
  a lock that does not exist would be believed.
web/src/customer-grid/types.ts CHANGED
@@ -1581,6 +1581,7 @@ 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",
@@ -1617,12 +1618,14 @@ export function fieldEditMode(field: Pick<Field, "permissions">): FieldEditMode
1617
  * the wall. Rules, in order:
1618
  * - a cell that is read-only by NATURE (formula/created_time) or by stratum (source odoo)
1619
  * is not editable for anyone;
1620
- * - permissions absent = everyone (every pre-wave-5 field);
 
 
1621
  * - a RESTRICTED field with no viewer info fails CLOSED;
1622
  * - 'admins' = isAdmin; 'creator' = the stamped creator, plus admins (an admin locked out
1623
  * of a leaver's field would need a database edit to fix anything).
1624
  */
1625
- export function mayEditField(field: Field, viewer: Viewer | undefined): boolean {
1626
  if (field.source !== "overlay" || READONLY_CELL_TYPES.has(field.type)) return false;
1627
  // ⭐ 2026-08-07 β€” the DERIVED half of `link`. Read per-field rather than by type, because the
1628
  // other half of the same type is a relation a person builds by hand. The host wall is
@@ -1639,6 +1642,12 @@ export function mayEditField(field: Field, viewer: Viewer | undefined): boolean
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(
@@ -1692,7 +1701,27 @@ export function isMachineOwned(field: Field): boolean {
1692
  * always did β€” a column YOU added beside the Odoo ones is yours even though its neighbours are not.
1693
  */
1694
  export function isPresetField(field: Field): boolean {
1695
- return field.preset === true || (field.source === "odoo" && !field.custom) || isMachineOwned(field);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1696
  }
1697
 
1698
  /**
@@ -2008,6 +2037,8 @@ export interface Field {
2008
  custom?: boolean;
2009
  /** True when the definition and its cell values live in the tenant-wide field stratum. */
2010
  shared?: boolean;
 
 
2011
  /**
2012
  * Owner item 8 (2026-07-27): a PRE-SET measure field β€” the canonical contract converted a
2013
  * frozen derived column (`revenue_ytd`, `revenue_ly`, `orders_24m`) into a measure whose
@@ -2100,9 +2131,9 @@ export interface Field {
2100
  max?: number;
2101
  /**
2102
  * Wave-5 items 6/7 β€” the CANONICAL default description (ships with the contract,
2103
- * aios_grid_fields.json). The user's per-field `note` OVERRIDES it: the header (i) and every
2104
- * hover show `note || description`, and "Edit field description" edits the NOTE (user
2105
- * stratum) β€” the canonical default is never writable from the client.
2106
  */
2107
  description?: string;
2108
  /**
@@ -2113,9 +2144,10 @@ export interface Field {
2113
  */
2114
  createdBy?: string;
2115
  /**
2116
- * Wave-5 item 1 β€” who may EDIT this field's values (and change these permissions). Absent =
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
  /**
 
1581
  edit: FieldEditMode | "everyone" | "creator" | "admins";
1582
  users?: string[];
1583
  };
1584
+ export type FieldShareRole = "view" | "edit" | "owner";
1585
  export const FIELD_EDIT_LABELS: Record<FieldEditMode, string> = {
1586
  personal: "Personal",
1587
  collaborative: "Collaborative",
 
1618
  * the wall. Rules, in order:
1619
  * - a cell that is read-only by NATURE (formula/created_time) or by stratum (source odoo)
1620
  * is not editable for anyone;
1621
+ * - permissions absent = everyone (every pre-wave-5 field);
1622
+ * - a field Share role is the field-specific override: `view` blocks edits and `edit` permits
1623
+ * them for a shared field. This is deliberately evaluated before the legacy permission bag.
1624
  * - a RESTRICTED field with no viewer info fails CLOSED;
1625
  * - 'admins' = isAdmin; 'creator' = the stamped creator, plus admins (an admin locked out
1626
  * of a leaver's field would need a database edit to fix anything).
1627
  */
1628
+ export function mayEditField(field: Field, viewer: Viewer | undefined): boolean {
1629
  if (field.source !== "overlay" || READONLY_CELL_TYPES.has(field.type)) return false;
1630
  // ⭐ 2026-08-07 β€” the DERIVED half of `link`. Read per-field rather than by type, because the
1631
  // other half of the same type is a relation a person builds by hand. The host wall is
 
1642
  const edit = permissions.edit;
1643
  if (!viewer) return false;
1644
  if (viewer.isAdmin) return true;
1645
+ if (field.shared) {
1646
+ // A view can be editable while one of its fields is intentionally view-only. The field's
1647
+ // own Share field role is the narrower, more specific rule and therefore wins here.
1648
+ if (field.createdBy && field.createdBy.toLowerCase() === viewer.name.toLowerCase()) return true;
1649
+ return field.sharedRole === "edit" || field.sharedRole === "owner";
1650
+ }
1651
  if (edit === "collaborative") return true;
1652
  return field.createdBy === viewer.name ||
1653
  (edit === "users" && (permissions.users ?? []).some(
 
1701
  * always did β€” a column YOU added beside the Odoo ones is yours even though its neighbours are not.
1702
  */
1703
  export function isPresetField(field: Field): boolean {
1704
+ if (field.preset === true) return true;
1705
+ return (field.source === "odoo" && !field.custom) || isMachineOwned(field);
1706
+ }
1707
+
1708
+ /**
1709
+ * May this viewer open the field-definition editor? This is separate from cell editability:
1710
+ * formula, rollup and other computed fields can have an editable definition even though their
1711
+ * values are not human-writable. Shared fields use the same specific Share field role as
1712
+ * `mayEditField`; legacy permission bags remain a compatibility fallback for old private fields.
1713
+ */
1714
+ export function mayEditFieldDefinition(field: Field, viewer: Viewer | undefined): boolean {
1715
+ if (!viewer) return false;
1716
+ if (viewer.isAdmin) return true;
1717
+ if (field.createdBy && field.createdBy.toLowerCase() === viewer.name.toLowerCase()) return true;
1718
+ if (field.shared) return field.sharedRole === "edit" || field.sharedRole === "owner";
1719
+ if (!field.createdBy) return false;
1720
+ const permissions = cleanFieldPermissions(field.permissions, "collaborative");
1721
+ return permissions.edit === "collaborative" ||
1722
+ (permissions.edit === "users" && (permissions.users ?? []).some(
1723
+ (user) => user.toLowerCase() === viewer.name.toLowerCase()
1724
+ ));
1725
  }
1726
 
1727
  /**
 
2037
  custom?: boolean;
2038
  /** True when the definition and its cell values live in the tenant-wide field stratum. */
2039
  shared?: boolean;
2040
+ /** Host-computed effective Share field role for this viewer. */
2041
+ sharedRole?: FieldShareRole;
2042
  /**
2043
  * Owner item 8 (2026-07-27): a PRE-SET measure field β€” the canonical contract converted a
2044
  * frozen derived column (`revenue_ytd`, `revenue_ly`, `orders_24m`) into a measure whose
 
2131
  max?: number;
2132
  /**
2133
  * Wave-5 items 6/7 β€” the CANONICAL default description (ships with the contract,
2134
+ * aios_grid_fields.json). The user's per-field `note` OVERRIDES it: the header (i) and every
2135
+ * hover show `note || description`, and the Description control in Edit field edits the NOTE
2136
+ * (user stratum) β€” the canonical default is never writable from the client.
2137
  */
2138
  description?: string;
2139
  /**
 
2144
  */
2145
  createdBy?: string;
2146
  /**
2147
+ * Legacy Wave-5 compatibility bag for who may edit this field's values. New Share field
2148
+ * grants are authoritative when present; this bag remains only for older private fields.
2149
+ * The client HIDES what the viewer may not do (menu entries, editable cells); the HOST check
2150
+ * is the wall β€” fail-closed, enforced on overlay_patch and field_upsert.
2151
  */
2152
  permissions?: StoredFieldPermissions;
2153
  /**
web/src/index.css CHANGED
@@ -1155,10 +1155,22 @@ body {
1155
  .cg-column-create label + label {
1156
  margin-top: 7px;
1157
  }
1158
- .cg-column-create .cg-input,
1159
- .cg-column-create .cg-select {
1160
- width: 100%;
1161
- }
 
 
 
 
 
 
 
 
 
 
 
 
1162
 
1163
  /* glide-data-grid mounts overlay editors into this portal. */
1164
  #portal {
@@ -1848,15 +1860,12 @@ body > .cg-overlay {
1848
  padding: 2px 0 0;
1849
  max-width: 220px;
1850
  }
1851
- /* R7 (2026-08-02) β€” the RADIO rows never got the treatment their checkbox twins have had
1852
- since wave 2, and they are the ones that list FIELD LABELS (the Group-by popover): a
1853
- two-line "Estimated missed revenue, last 12 months" pushed the row below it out of the
1854
- rhythm and made the radio look mis-aligned with its own text.
1855
- ⚠ `:not([class])` is load-bearing, not tidiness. `.cg-radio-row > span` alone is (0,1,1)
1856
- and therefore OUTRANKS `.cg-perm-label` (0,1,0) β€” the view-permission rows whose span
1857
- deliberately stacks a LABEL over a two-line blurb. That rule would have clipped every
1858
- explanation in the permissions pane to one ellipsised line. Caught by walking every
1859
- `cg-radio-row` call site rather than by an assertion; all eleven others are bare spans. */
1860
  .cg-radio-row > span:not([class]) {
1861
  flex: 1 1 auto;
1862
  min-width: 0;
@@ -5386,9 +5395,8 @@ a.cg-map-ctl-b { text-decoration: none; }
5386
  guessed: that flyout is ~215px wide on a ~31px row pitch with ~16px marks, against our
5387
  178px / 30px / 15px. The type stays `--lp-fs-xs`: the owner asked for room, not for a
5388
  larger font, and SC-1 fixes this surface's size. Room comes from the box, not the text. */
5389
- /* [W27-C] ⭐ WAVE 27 Β· OWNER ITEM 1 β€” WIDE ENOUGH FOR THE LONGEST BLURB, ON ONE LINE.
5390
- The owner's complaint was step 2: each "Who can edit" choice carries a sentence saying what it
5391
- does, and at 212px all three wrapped, so a three-choice question read as six lines of grey.
5392
 
5393
  β›” MEASURED, NOT GUESSED. Advance widths summed from the EMBEDDED Inter (the `@font-face`
5394
  data URI at the top of this file β€” the font users actually see, which is the whole reason it
@@ -5399,8 +5407,8 @@ a.cg-map-ctl-b { text-decoration: none; }
5399
  The box has to carry 235.40px of text plus everything left of it:
5400
  10 flyout padding + 2 border + 4 form padding + 16 row padding
5401
  + 15 radio + 9 gap + 16 mark + 9 gap = 81px
5402
- 235.40 + 81 = 316.4, taken to 320 for subpixel accumulation. Re-measure if a blurb changes;
5403
- `VIEW_EDIT_BLURBS` in types.ts is the source of all three strings.
5404
 
5405
  β›” `max-width` IS RESTATED AND THAT IS LOAD-BEARING, not tidiness: `.cg-view-menu` (the class
5406
  this panel also wears) caps every menu at 260px, so `width: 320px` ALONE would have been
@@ -5520,59 +5528,6 @@ a.cg-map-ctl-b { text-decoration: none; }
5520
  .cg-modes-locked .cg-mode-icon,
5521
  .cg-modes-locked .cg-lock-mark { flex: 0 0 auto; }
5522
 
5523
- /* I17 (C4) β€” the "Who can edit" step of the create prompt. Each choice carries what it
5524
- actually DOES: a permission whose effect the user cannot predict is one they will set
5525
- wrong, and this one defaults to the restrictive option. */
5526
- .cg-perm {
5527
- display: flex;
5528
- flex-direction: column;
5529
- gap: 2px;
5530
- margin: 6px 0 2px;
5531
- }
5532
- .cg-perm-title {
5533
- font-size: var(--lp-fs-2xs);
5534
- font-weight: 600;
5535
- color: var(--lp-muted);
5536
- padding-bottom: 2px;
5537
- }
5538
- .cg-perm-row { align-items: flex-start; }
5539
- .cg-perm-label {
5540
- display: flex;
5541
- flex-direction: column;
5542
- gap: 1px;
5543
- font-size: var(--lp-fs-2xs);
5544
- }
5545
- .cg-perm-blurb {
5546
- color: var(--lp-muted);
5547
- font-size: var(--lp-fs-3xs);
5548
- line-height: 1.35;
5549
- }
5550
- .cg-perm-users {
5551
- display: flex;
5552
- flex-direction: column;
5553
- gap: 2px;
5554
- max-height: 148px;
5555
- overflow-y: auto;
5556
- margin: 2px 0 0 18px;
5557
- padding: 4px 6px;
5558
- border: 1px solid var(--lp-line);
5559
- border-radius: var(--lp-r-md);
5560
- }
5561
- .cg-perm-user {
5562
- display: flex;
5563
- align-items: center;
5564
- gap: 6px;
5565
- font-size: var(--lp-fs-2xs);
5566
- cursor: pointer;
5567
- }
5568
- /* Stated in the yellow-deep weight, not a pastel: this is a warning that what you picked is
5569
- NOT what will be saved, and a 1.9:1 pastel would make it decorative. */
5570
- .cg-perm-empty {
5571
- color: var(--lp-yellow-deep);
5572
- font-size: var(--lp-fs-3xs);
5573
- line-height: 1.35;
5574
- }
5575
-
5576
  /* I2 β€” the cell hover tip. Inherits everything from `.cg-header-tip` (including
5577
  `pointer-events: none`, which is the CONTRACT and not a style: a tip that can take the
5578
  pointer swallows the next click). Only the wrapping differs: a cut-off Note is one long
@@ -10715,13 +10670,19 @@ a.cg-map-ctl-b { text-decoration: none; }
10715
  border: 0;
10716
  background: transparent;
10717
  }
10718
- .cg-create-flyout .cg-create-form > label:first-child {
10719
- display: block;
10720
- margin-bottom: 4px;
10721
- font-size: var(--lp-fs-3xs);
10722
- font-weight: 600;
10723
- color: var(--lp-muted);
10724
- }
 
 
 
 
 
 
10725
  /* ⚠ THE PANEL IS A MENU, AND `.cg-view-menu button` (0,1,1) OUT-RANKS `.cg-btn` (0,1,0).
10726
  Moving the form inside the flyout therefore repainted its Create/Cancel as full-width,
10727
  left-aligned, borderless MENU ROWS β€” the panel's own row style applied to a form's
@@ -10750,36 +10711,6 @@ a.cg-map-ctl-b { text-decoration: none; }
10750
  .cg-create-flyout .cg-create-form .cg-btn--primary:hover:not(:disabled) {
10751
  background: var(--lp-primary-hover);
10752
  }
10753
- /* The who-can-edit rows are `<label>`s, and `.cg-view-create label` (0,1,1) stacks every
10754
- label in a create form into a COLUMN β€” so the radio sat above its own words rather than
10755
- beside them. Pre-dates this wave (the same two rules met in the rail-inline form), but
10756
- item 20 makes these three rows the whole content of a small panel, where a radio floating
10757
- over its label is the first thing you see. Scoped to the flyout: nothing else that reads
10758
- `.cg-view-create label` moves. */
10759
- .cg-create-flyout .cg-perm-row {
10760
- flex-direction: row;
10761
- align-items: flex-start;
10762
- gap: 9px;
10763
- }
10764
-
10765
- /* [W27-C] ⭐ WAVE 27 Β· OWNER ITEM 1 β€” the permission MARK's column.
10766
- The row is a flex line: radio | mark | (label over blurb). `flex: 0 0 auto` is the same rule
10767
- the create rows' own marks carry two hundred lines up β€” without it a long blurb shrinks the
10768
- ICON before it wraps the text, and a 9px person is a smudge.
10769
- The 1px nudge lands the 16px mark on the cap-height of the 12px label beside it; `flex-start`
10770
- on the row aligns it to the TOP, which is where it belongs when the blurb is under the name. */
10771
- .cg-perm-mark {
10772
- flex: 0 0 auto;
10773
- margin-top: 1px;
10774
- }
10775
- /* The user list belongs to the row above it, and it says so by indenting past the controls
10776
- rather than by drawing a second box. It was 18px, measured against a row that started
10777
- `radio | label`; item 1 inserts a 16px mark and a 9px gap between those two, so the same
10778
- visual relationship is 18 + 25. Left as an indent rather than an exact alignment to the text
10779
- column (57px): at that depth the checkbox list starts closer to the middle of the panel than
10780
- to its left edge, which reads as a different section rather than a continuation of one. */
10781
- .cg-create-flyout .cg-perm-users { margin-left: 43px; }
10782
-
10783
  /* ── Item 19 (C-FOLDER-REORDER) β€” dragging a FOLDER to reorder ──────────────
10784
  A private drag MIME (`application/x-loopable-fold`) keeps this apart from the
10785
  view drag, which carries `text/plain`; the indicator is an insertion RULE at
 
1155
  .cg-column-create label + label {
1156
  margin-top: 7px;
1157
  }
1158
+ .cg-column-create .cg-input,
1159
+ .cg-column-create .cg-select {
1160
+ width: 100%;
1161
+ }
1162
+ /* The definition editor mixes labels, type blocks and toggle rows. A label-to-label rule is
1163
+ not enough here: the type picker is a block, so it used to sit directly against the first
1164
+ toggle below it. Keep every top-level editor section visibly separated while leaving nested
1165
+ option controls to their own layout rules. */
1166
+ .cg-field-edit > * + * { margin-top: 10px; }
1167
+ .cg-field-edit > .cg-form-actions { margin-top: 2px; }
1168
+ .cg-field-edit > .cg-edit-swap { margin-top: 14px; }
1169
+ .cg-field-edit .cg-type-block { gap: 7px; }
1170
+ .cg-field-edit .cg-edit-description textarea {
1171
+ min-height: 76px;
1172
+ resize: vertical;
1173
+ }
1174
 
1175
  /* glide-data-grid mounts overlay editors into this portal. */
1176
  #portal {
 
1860
  padding: 2px 0 0;
1861
  max-width: 220px;
1862
  }
1863
+ /* R7 (2026-08-02) β€” the RADIO rows never got the treatment their checkbox twins have had
1864
+ since wave 2, and they are the ones that list FIELD LABELS (the Group-by popover): a
1865
+ two-line "Estimated missed revenue, last 12 months" pushed the row below it out of the
1866
+ rhythm and made the radio look mis-aligned with its own text.
1867
+ ⚠ `:not([class])` is load-bearing, not tidiness. It keeps this rule scoped to the simple
1868
+ field-label rows; richer controls own their own layout. */
 
 
 
1869
  .cg-radio-row > span:not([class]) {
1870
  flex: 1 1 auto;
1871
  min-width: 0;
 
5395
  guessed: that flyout is ~215px wide on a ~31px row pitch with ~16px marks, against our
5396
  178px / 30px / 15px. The type stays `--lp-fs-xs`: the owner asked for room, not for a
5397
  larger font, and SC-1 fixes this surface's size. Room comes from the box, not the text. */
5398
+ /* Share-view follow-up guidance. The create step is intentionally private; audience and edit
5399
+ choices belong to the Share view dialog rather than to this flyout.
 
5400
 
5401
  β›” MEASURED, NOT GUESSED. Advance widths summed from the EMBEDDED Inter (the `@font-face`
5402
  data URI at the top of this file β€” the font users actually see, which is the whole reason it
 
5407
  The box has to carry 235.40px of text plus everything left of it:
5408
  10 flyout padding + 2 border + 4 form padding + 16 row padding
5409
  + 15 radio + 9 gap + 16 mark + 9 gap = 81px
5410
+ 235.40 + 81 = 316.4, taken to 320 for subpixel accumulation. The flyout remains roomy for
5411
+ the type picker and its private-first follow-up note.
5412
 
5413
  β›” `max-width` IS RESTATED AND THAT IS LOAD-BEARING, not tidiness: `.cg-view-menu` (the class
5414
  this panel also wears) caps every menu at 260px, so `width: 320px` ALONE would have been
 
5528
  .cg-modes-locked .cg-mode-icon,
5529
  .cg-modes-locked .cg-lock-mark { flex: 0 0 auto; }
5530
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5531
  /* I2 β€” the cell hover tip. Inherits everything from `.cg-header-tip` (including
5532
  `pointer-events: none`, which is the CONTRACT and not a style: a tip that can take the
5533
  pointer swallows the next click). Only the wrapping differs: a cut-off Note is one long
 
10670
  border: 0;
10671
  background: transparent;
10672
  }
10673
+ .cg-create-flyout .cg-create-form > label:first-child {
10674
+ display: block;
10675
+ margin-bottom: 4px;
10676
+ font-size: var(--lp-fs-3xs);
10677
+ font-weight: 600;
10678
+ color: var(--lp-muted);
10679
+ }
10680
+ .cg-create-share-note {
10681
+ margin: 9px 0 2px;
10682
+ color: var(--lp-muted);
10683
+ font-size: var(--lp-fs-3xs);
10684
+ line-height: 1.45;
10685
+ }
10686
  /* ⚠ THE PANEL IS A MENU, AND `.cg-view-menu button` (0,1,1) OUT-RANKS `.cg-btn` (0,1,0).
10687
  Moving the form inside the flyout therefore repainted its Create/Cancel as full-width,
10688
  left-aligned, borderless MENU ROWS β€” the panel's own row style applied to a form's
 
10711
  .cg-create-flyout .cg-create-form .cg-btn--primary:hover:not(:disabled) {
10712
  background: var(--lp-primary-hover);
10713
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10714
  /* ── Item 19 (C-FOLDER-REORDER) β€” dragging a FOLDER to reorder ──────────────
10715
  A private drag MIME (`application/x-loopable-fold`) keeps this apart from the
10716
  view drag, which carries `text/plain`; the indicator is an insertion RULE at
web/src/shell/ShareDialog.tsx CHANGED
@@ -39,10 +39,14 @@ const KIND_WORD: Record<ShareKind, string> = {
39
  field: "field",
40
  };
41
 
 
 
 
 
 
42
  /** What each role MEANS on each kind, in the reader's own terms. A role picker
43
- * whose options are two nouns makes the reader guess; these are the sentences
44
- * the view rail already uses ("Anyone who can see this table can change it"),
45
- * extended to the two new kinds rather than re-invented for them. */
46
  const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
47
  view: {
48
  view: "Can open this view. Cannot rename, refilter or delete it.",
@@ -56,21 +60,9 @@ const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
56
  view: "Can open this database and read its records.",
57
  edit: "Can add, edit and delete its records.",
58
  },
59
- /**
60
- * ⭐⭐ W39-T17 β€” A COLUMN GRANT IS A VISIBILITY GRANT, AND THE BLURBS SAY ONLY THAT.
61
- *
62
- * β›” MEASURED IN THE SERVER, NOT ASSUMED. `core.perm_scope.field_grant_hidden` is the ONLY
63
- * consumer of a `field`-kind grant, and it tests `role is None` β€” nothing else. So `view` and
64
- * `edit` are the SAME capability on a column today: both lift the column out of the hidden set
65
- * and neither confers value editing, which `types.mayEditField` decides from the field's OWN
66
- * `permissions.edit` (everyone / creator / admins). A blurb promising "can change its values"
67
- * would be a sentence the product does not honour, so the `edit` line names the real wall
68
- * instead. Booked as a finding; the picker is the shared one and narrowing it is not this
69
- * ticket's mandate.
70
- */
71
  field: {
72
- view: "Can see this column on the records they can already open.",
73
- edit: "Can see this column. Changing its values still follows the field's own permissions.",
74
  },
75
  };
76
 
@@ -116,7 +108,21 @@ export default function ShareDialog({
116
  setError("");
117
  void (async () => {
118
  try {
119
- const res = await fetch(path, { credentials: CREDENTIALS });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  if (!res.ok) {
121
  // 4xx text is policy the reader needs; a 5xx's internals are not theirs.
122
  if (!dead) setError(res.status >= 500
@@ -231,16 +237,21 @@ export default function ShareDialog({
231
  Who can reach <strong>{label}</strong>, and what they can do with it.{" "}
232
  {kind === "database"
233
  ? "Sharing a database shares all of it: every record and every column. There is no per-row or per-column limit on a database grant."
234
- : kind === "field"
235
- /* ⭐⭐ W39-T17 β€” A COLUMN'S OWN SENTENCE, because the general one is BACKWARDS for it.
236
- "Everyone here can already open the surface it lives on" is true of the DATABASE and
237
- false of the column: `routes_tables.patch_shared_cell` stamps every grid-created
238
- shared column `granted: True`, and `perm_scope.field_grant_hidden` then strips it
239
- from every payload but its creator's and an admin's. So this door is not a widening
240
- of something already visible, it is the ONLY way anybody else sees the column. */
241
- ? "A shared column starts out hidden from everybody else on this database. This is what lets the people you choose see it and its values."
242
- : "Sharing never widens past this workspace: everyone here can already open the surface it lives on."}
243
  </p>
 
 
 
 
 
 
 
 
 
 
 
244
 
245
  {!state && !error ? (
246
  <div className="shell-share-wait">
@@ -350,19 +361,9 @@ export default function ShareDialog({
350
  void save(next).then((ok) => {
351
  if (ok) {
352
  onToast(
353
- /* β›”β›” W39-T17 β€” THE ROLE MUST NOT BE READ BACK AS A CAPABILITY ON A
354
- COLUMN. Interpolated blind, this said "alice can now edit this
355
- field", and that is false: `core.perm_scope.field_grant_hidden` is
356
- the only consumer of a field grant and it tests `role is None`, so
357
- both roles buy the SAME thing (the column stops being hidden) and
358
- neither one lets anybody write a cell β€” `types.mayEditField` decides
359
- that from the field's own `permissions.edit`. A toast is the one
360
- sentence a person reads after granting, so it is the worst place to
361
- promise a capability the product does not honour. It names the
362
- visibility that actually changed instead. See `ROLE_BLURB.field`. */
363
- kind === "field"
364
- ? `${nameOf(pick)} can now see this field.`
365
- : `${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`
366
  );
367
  setPick("");
368
  }
 
39
  field: "field",
40
  };
41
 
42
+ // A newly-created field is posted through the standalone event queue. The
43
+ // share dialog can open before that POST reaches the API, so give field lookups
44
+ // a short, bounded chance to observe the definition before showing a 404.
45
+ const FIELD_SHARE_RETRY_MS = [150, 300, 600, 1000] as const;
46
+
47
  /** What each role MEANS on each kind, in the reader's own terms. A role picker
48
+ * whose options are two nouns makes the reader guess; these sentences describe
49
+ * the effective capability at the point where the role is chosen. */
 
50
  const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
51
  view: {
52
  view: "Can open this view. Cannot rename, refilter or delete it.",
 
60
  view: "Can open this database and read its records.",
61
  edit: "Can add, edit and delete its records.",
62
  },
 
 
 
 
 
 
 
 
 
 
 
 
63
  field: {
64
+ view: "Can see this column. Cannot change its values or field settings.",
65
+ edit: "Can change this column's values and field settings when the view is editable.",
66
  },
67
  };
68
 
 
108
  setError("");
109
  void (async () => {
110
  try {
111
+ let res: Response;
112
+ for (let attempt = 0; ; attempt += 1) {
113
+ res = await fetch(path, { credentials: CREDENTIALS });
114
+ if (
115
+ res.status !== 404 ||
116
+ kind !== "field" ||
117
+ attempt >= FIELD_SHARE_RETRY_MS.length
118
+ ) {
119
+ break;
120
+ }
121
+ await new Promise<void>((resolve) => {
122
+ window.setTimeout(resolve, FIELD_SHARE_RETRY_MS[attempt]);
123
+ });
124
+ if (dead) return;
125
+ }
126
  if (!res.ok) {
127
  // 4xx text is policy the reader needs; a 5xx's internals are not theirs.
128
  if (!dead) setError(res.status >= 500
 
237
  Who can reach <strong>{label}</strong>, and what they can do with it.{" "}
238
  {kind === "database"
239
  ? "Sharing a database shares all of it: every record and every column. There is no per-row or per-column limit on a database grant."
240
+ : kind === "field"
241
+ ? "A shared column starts out hidden from everybody else on this database. This is what lets the people you choose see it and its values."
242
+ : "Sharing never widens past this workspace: everyone here can already open the surface it lives on."}
 
 
 
 
 
 
243
  </p>
244
+ {kind === "view" ? (
245
+ <p className="shell-share-note">
246
+ View access is the general rule. A field's own Share field access takes precedence, so
247
+ a field shared as Can view stays read-only even inside an editable view.
248
+ </p>
249
+ ) : kind === "field" ? (
250
+ <p className="shell-share-note">
251
+ Field access takes precedence over the view. Can view keeps values and field settings
252
+ read-only; Can edit allows changes when the view is editable.
253
+ </p>
254
+ ) : null}
255
 
256
  {!state && !error ? (
257
  <div className="shell-share-wait">
 
361
  void save(next).then((ok) => {
362
  if (ok) {
363
  onToast(
364
+ kind === "field"
365
+ ? `${nameOf(pick)} can now ${pickRole === "edit" ? "edit" : "view"} this field.`
366
+ : `${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`
 
 
 
 
 
 
 
 
 
 
367
  );
368
  setPick("");
369
  }
web/src/ui/icons.tsx CHANGED
@@ -98,7 +98,7 @@ export function BellIcon({ size = 16, className }: { size?: number; className?:
98
  *
99
  * β›” WHAT D-206 IS ACTUALLY ABOUT, corrected against the source before this was drawn: the row does
100
  * NOT render `LockMark`. It renders `MenuIcon "permissions"` β€” a *different* padlock β€” and so do
101
- * "Share view", "Share folder", "Lock to a locked view" and "Edit field permissions". Five rows,
102
  * one padlock, five meanings, no titles, in menus a person reads at a glance. All three real
103
  * `LockMark` sites already name their lock in a `title` (DESIGN.md Β§4), so the offending row was
104
  * outside that rule's literal scope while breaking exactly the thing it protects.
 
98
  *
99
  * β›” WHAT D-206 IS ACTUALLY ABOUT, corrected against the source before this was drawn: the row does
100
  * NOT render `LockMark`. It renders `MenuIcon "permissions"` β€” a *different* padlock β€” and so do
101
+ * "Share view", "Share folder" and "Lock to a locked view". Four rows,
102
  * one padlock, five meanings, no titles, in menus a person reads at a glance. All three real
103
  * `LockMark` sites already name their lock in a `title` (DESIGN.md Β§4), so the offending row was
104
  * outside that rule's literal scope while breaking exactly the thing it protects.