fsanyoto commited on
Commit
1f123e1
Β·
verified Β·
1 Parent(s): fee4db3

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
Files changed (46) hide show
  1. RELEASES.json +1 -1
  2. VERSION +1 -1
  3. api/aios_session.py +124 -124
  4. api/deps.py +3 -0
  5. api/routes_admin.py +594 -33
  6. api/routes_auth.py +38 -3
  7. api/routes_automation.py +0 -0
  8. api/routes_shares.py +505 -410
  9. platform/aios_grid_fields.json +739 -565
  10. platform/core/data_binding.py +25 -0
  11. platform/core/field_permissions.py +213 -5
  12. platform/core/grid_events.py +171 -2
  13. platform/core/perm_scope.py +0 -0
  14. platform/core/shares.py +414 -296
  15. platform/core/users.py +566 -529
  16. platform/harness/semantic.py +0 -0
  17. platform/model/topics/odoo_customers.yml +223 -213
  18. platform/model/topics/odoo_products.yml +339 -191
  19. platform/modules/customer_data.py +73 -1
  20. platform/modules/inventory.py +46 -3
  21. platform/modules/product_data.py +0 -0
  22. platform/modules/products.py +109 -2
  23. web/public/sample_customers.json +574 -558
  24. web/src/customer-grid/ColumnMenu.tsx +196 -59
  25. web/src/customer-grid/CustomerGrid.tsx +0 -0
  26. web/src/customer-grid/RecordDetail.tsx +0 -0
  27. web/src/customer-grid/ViewSidebar.tsx +0 -0
  28. web/src/customer-grid/cells.ts +815 -807
  29. web/src/customer-grid/display.ts +950 -777
  30. web/src/customer-grid/export.ts +160 -11
  31. web/src/customer-grid/folders.ts +572 -492
  32. web/src/customer-grid/iconShapes.ts +840 -805
  33. web/src/customer-grid/overlayPlacement.ts +156 -37
  34. web/src/customer-grid/rowStar.css +87 -80
  35. web/src/customer-grid/types.ts +189 -1
  36. web/src/customer-grid/useGridColumns.ts +483 -422
  37. web/src/filter-kit/FieldsHidePanel.tsx +111 -18
  38. web/src/filter-kit/FilterBuilderPanel.tsx +45 -3
  39. web/src/filter-kit/ops.ts +426 -400
  40. web/src/index.css +0 -0
  41. web/src/settings/DatabasePermsPane.tsx +174 -0
  42. web/src/settings/ModulePermsList.tsx +101 -11
  43. web/src/settings/PermsEditor.tsx +0 -0
  44. web/src/settings/perms.css +173 -4
  45. web/src/settings/permsModel.ts +0 -0
  46. web/src/shell/Shell.tsx +0 -0
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "v52 (8263493)",
3
  "releases": [
4
  {
5
  "version": "v52",
 
1
  {
2
+ "current": "0b2ab48",
3
  "releases": [
4
  {
5
  "version": "v52",
VERSION CHANGED
@@ -1 +1 @@
1
- v52 (8263493)
 
1
+ 0b2ab48
api/aios_session.py CHANGED
@@ -1,124 +1,124 @@
1
- """aios_session.py β€” X3: the signed, STATELESS session cookie (EXIT-3a, 2026-07-30).
2
-
3
- aios_session = base64url(json {t,u,e,i,x}) . base64url(HMAC-SHA256)
4
-
5
- `t` tenant slug Β· `u` username Β· `e` the user's revocation epoch Β· `i` idle deadline Β·
6
- `x` absolute expiry. Signed with `AIOS_SESSION_SECRET` using stdlib `hmac` β€” no new dependency,
7
- and nothing to store server-side, which is the whole point: EXIT-4 requires a process that holds
8
- no per-tenant state, and a session TABLE is per-tenant state. D2's Postgres sessions-MIRROR (an
9
- audit view, not the source of truth) arrives with C-2 and is deliberately deferred.
10
-
11
- WHY STATELESS STILL REVOKES. A cookie nobody can delete sounds like a cookie nobody can revoke.
12
- The answer is the `epoch` integer on the user record (`core.users.epoch` / `bump_epoch`): the
13
- cookie carries the epoch it was minted under, every verification re-reads the account's current
14
- epoch, and a password change or a deactivation bumps it β€” so every outstanding cookie for that
15
- user dies at once, without a session table.
16
-
17
- ⚠ NO FALLBACK SECRET, EVER. With `AIOS_SESSION_SECRET` unset this module mints a RANDOM
18
- per-process key: sessions then die on restart, which is an inconvenience. A hardcoded default
19
- would instead let anyone who has read this repo forge a cookie for any user of any tenant β€” an
20
- inconvenience is the correct trade against a forgery key. `EPHEMERAL_SECRET` records which
21
- happened so the app can say so at startup and a gate can assert it.
22
- """
23
- import base64
24
- import hmac
25
- import json
26
- import os
27
- import secrets
28
- import time
29
- from hashlib import sha256
30
-
31
- COOKIE_NAME = "aios_session"
32
- IDLE_SECONDS = 8 * 60 * 60 # D2: reissued on every authenticated request
33
- ABSOLUTE_SECONDS = 30 * 24 * 60 * 60 # D2: a hard ceiling no amount of activity extends
34
-
35
- _env_secret = os.environ.get("AIOS_SESSION_SECRET") or ""
36
- EPHEMERAL_SECRET = not _env_secret
37
- _SECRET = _env_secret.encode("utf-8") if _env_secret else secrets.token_bytes(32)
38
-
39
-
40
- def _b64(raw: bytes) -> str:
41
- return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
42
-
43
-
44
- def _unb64(txt: str) -> bytes:
45
- return base64.urlsafe_b64decode(txt + "=" * (-len(txt) % 4))
46
-
47
-
48
- def _sign(payload: bytes) -> str:
49
- return _b64(hmac.new(_SECRET, payload, sha256).digest())
50
-
51
-
52
- def mint(tenant, username, epoch, now=None, absolute_expiry=None):
53
- """A fresh cookie value. `absolute_expiry` carries over on reissue so refreshing the idle
54
- window can never extend the 30-day ceiling β€” that would make the ceiling unreachable."""
55
- now = int(now if now is not None else time.time())
56
- claims = {"t": str(tenant), "u": str(username), "e": int(epoch),
57
- "i": now + IDLE_SECONDS,
58
- "x": int(absolute_expiry if absolute_expiry is not None
59
- else now + ABSOLUTE_SECONDS)}
60
- payload = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")
61
- return f"{_b64(payload)}.{_sign(payload)}", claims
62
-
63
-
64
- def read(value, now=None):
65
- """Parse + verify a cookie value β†’ claims dict, or None.
66
-
67
- None for EVERY failure mode β€” bad shape, bad signature, expired, unparseable. The caller
68
- answers 401 and must not learn which: an error that distinguishes "signature wrong" from
69
- "expired" tells a forger whether their key is right.
70
- """
71
- if not value or not isinstance(value, str) or value.count(".") != 1:
72
- return None
73
- body, sig = value.split(".", 1)
74
- try:
75
- payload = _unb64(body)
76
- except Exception:
77
- return None
78
- # compare_digest on the SIGNATURE, always β€” a byte-wise early exit is a timing oracle.
79
- if not hmac.compare_digest(sig, _sign(payload)):
80
- return None
81
- try:
82
- claims = json.loads(payload.decode("utf-8"))
83
- except Exception:
84
- return None
85
- if not isinstance(claims, dict):
86
- return None
87
- if not isinstance(claims.get("u"), str) or not isinstance(claims.get("t"), str):
88
- return None
89
- if not claims["u"] or not claims["t"]:
90
- return None
91
- try:
92
- idle, absolute, epoch = int(claims["i"]), int(claims["x"]), int(claims["e"])
93
- except (KeyError, TypeError, ValueError):
94
- return None
95
- now = int(now if now is not None else time.time())
96
- if now >= idle or now >= absolute:
97
- return None
98
- claims["e"], claims["i"], claims["x"] = epoch, idle, absolute
99
- return claims
100
-
101
-
102
- def is_secure_request(request):
103
- """Secure flag ON unless this is plain-HTTP localhost. Set unconditionally in production;
104
- unset on a local http:// dev origin, where a Secure cookie is simply never sent back and
105
- login would appear to succeed and then not work."""
106
- host = (request.headers.get("host") or "").split(":")[0].lower()
107
- proto = (request.headers.get("x-forwarded-proto")
108
- or request.url.scheme or "http").split(",")[0].strip().lower()
109
- if proto == "https":
110
- return True
111
- return host not in ("localhost", "127.0.0.1", "[::1]", "::1")
112
-
113
-
114
- def set_cookie(response, request, value):
115
- response.set_cookie(
116
- COOKIE_NAME, value, httponly=True, samesite="lax",
117
- secure=is_secure_request(request), max_age=ABSOLUTE_SECONDS, path="/")
118
-
119
-
120
- def clear_cookie(response, request):
121
- # Same attributes as when it was set: a browser matches on path/secure/samesite when
122
- # deleting, and a mismatched delete silently leaves the cookie in place.
123
- response.delete_cookie(COOKIE_NAME, path="/", httponly=True, samesite="lax",
124
- secure=is_secure_request(request))
 
1
+ """aios_session.py β€” X3: the signed, STATELESS session cookie (EXIT-3a, 2026-07-30).
2
+
3
+ aios_session = base64url(json {t,u,e,i,x}) . base64url(HMAC-SHA256)
4
+
5
+ `t` tenant slug Β· `u` username Β· `e` the user's revocation epoch Β· `i` idle deadline Β·
6
+ `x` absolute expiry. Signed with `AIOS_SESSION_SECRET` using stdlib `hmac` β€” no new dependency,
7
+ and nothing to store server-side, which is the whole point: EXIT-4 requires a process that holds
8
+ no per-tenant state, and a session TABLE is per-tenant state. D2's Postgres sessions-MIRROR (an
9
+ audit view, not the source of truth) arrives with C-2 and is deliberately deferred.
10
+
11
+ WHY STATELESS STILL REVOKES. A cookie nobody can delete sounds like a cookie nobody can revoke.
12
+ The answer is the `epoch` integer on the user record (`core.users.epoch` / `bump_epoch`): the
13
+ cookie carries the epoch it was minted under, every verification re-reads the account's current
14
+ epoch, and a password change, a deactivation, or (wave 40, R2) any LOGIN bumps it β€” so every outstanding cookie for that
15
+ user dies at once, without a session table.
16
+
17
+ ⚠ NO FALLBACK SECRET, EVER. With `AIOS_SESSION_SECRET` unset this module mints a RANDOM
18
+ per-process key: sessions then die on restart, which is an inconvenience. A hardcoded default
19
+ would instead let anyone who has read this repo forge a cookie for any user of any tenant β€” an
20
+ inconvenience is the correct trade against a forgery key. `EPHEMERAL_SECRET` records which
21
+ happened so the app can say so at startup and a gate can assert it.
22
+ """
23
+ import base64
24
+ import hmac
25
+ import json
26
+ import os
27
+ import secrets
28
+ import time
29
+ from hashlib import sha256
30
+
31
+ COOKIE_NAME = "aios_session"
32
+ IDLE_SECONDS = 8 * 60 * 60 # D2: reissued on every authenticated request
33
+ ABSOLUTE_SECONDS = 30 * 24 * 60 * 60 # D2: a hard ceiling no amount of activity extends
34
+
35
+ _env_secret = os.environ.get("AIOS_SESSION_SECRET") or ""
36
+ EPHEMERAL_SECRET = not _env_secret
37
+ _SECRET = _env_secret.encode("utf-8") if _env_secret else secrets.token_bytes(32)
38
+
39
+
40
+ def _b64(raw: bytes) -> str:
41
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
42
+
43
+
44
+ def _unb64(txt: str) -> bytes:
45
+ return base64.urlsafe_b64decode(txt + "=" * (-len(txt) % 4))
46
+
47
+
48
+ def _sign(payload: bytes) -> str:
49
+ return _b64(hmac.new(_SECRET, payload, sha256).digest())
50
+
51
+
52
+ def mint(tenant, username, epoch, now=None, absolute_expiry=None):
53
+ """A fresh cookie value. `absolute_expiry` carries over on reissue so refreshing the idle
54
+ window can never extend the 30-day ceiling β€” that would make the ceiling unreachable."""
55
+ now = int(now if now is not None else time.time())
56
+ claims = {"t": str(tenant), "u": str(username), "e": int(epoch),
57
+ "i": now + IDLE_SECONDS,
58
+ "x": int(absolute_expiry if absolute_expiry is not None
59
+ else now + ABSOLUTE_SECONDS)}
60
+ payload = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")
61
+ return f"{_b64(payload)}.{_sign(payload)}", claims
62
+
63
+
64
+ def read(value, now=None):
65
+ """Parse + verify a cookie value β†’ claims dict, or None.
66
+
67
+ None for EVERY failure mode β€” bad shape, bad signature, expired, unparseable. The caller
68
+ answers 401 and must not learn which: an error that distinguishes "signature wrong" from
69
+ "expired" tells a forger whether their key is right.
70
+ """
71
+ if not value or not isinstance(value, str) or value.count(".") != 1:
72
+ return None
73
+ body, sig = value.split(".", 1)
74
+ try:
75
+ payload = _unb64(body)
76
+ except Exception:
77
+ return None
78
+ # compare_digest on the SIGNATURE, always β€” a byte-wise early exit is a timing oracle.
79
+ if not hmac.compare_digest(sig, _sign(payload)):
80
+ return None
81
+ try:
82
+ claims = json.loads(payload.decode("utf-8"))
83
+ except Exception:
84
+ return None
85
+ if not isinstance(claims, dict):
86
+ return None
87
+ if not isinstance(claims.get("u"), str) or not isinstance(claims.get("t"), str):
88
+ return None
89
+ if not claims["u"] or not claims["t"]:
90
+ return None
91
+ try:
92
+ idle, absolute, epoch = int(claims["i"]), int(claims["x"]), int(claims["e"])
93
+ except (KeyError, TypeError, ValueError):
94
+ return None
95
+ now = int(now if now is not None else time.time())
96
+ if now >= idle or now >= absolute:
97
+ return None
98
+ claims["e"], claims["i"], claims["x"] = epoch, idle, absolute
99
+ return claims
100
+
101
+
102
+ def is_secure_request(request):
103
+ """Secure flag ON unless this is plain-HTTP localhost. Set unconditionally in production;
104
+ unset on a local http:// dev origin, where a Secure cookie is simply never sent back and
105
+ login would appear to succeed and then not work."""
106
+ host = (request.headers.get("host") or "").split(":")[0].lower()
107
+ proto = (request.headers.get("x-forwarded-proto")
108
+ or request.url.scheme or "http").split(",")[0].strip().lower()
109
+ if proto == "https":
110
+ return True
111
+ return host not in ("localhost", "127.0.0.1", "[::1]", "::1")
112
+
113
+
114
+ def set_cookie(response, request, value):
115
+ response.set_cookie(
116
+ COOKIE_NAME, value, httponly=True, samesite="lax",
117
+ secure=is_secure_request(request), max_age=ABSOLUTE_SECONDS, path="/")
118
+
119
+
120
+ def clear_cookie(response, request):
121
+ # Same attributes as when it was set: a browser matches on path/secure/samesite when
122
+ # deleting, and a mismatched delete silently leaves the cookie in place.
123
+ response.delete_cookie(COOKIE_NAME, path="/", httponly=True, samesite="lax",
124
+ secure=is_secure_request(request))
api/deps.py CHANGED
@@ -551,8 +551,11 @@ def _user_for(claims):
551
  # The emergency master is TENANT #0's break-glass, not the platform's: a synthetic
552
  # all-access identity minted into another tenant's session would be the exact
553
  # cross-tenant widening the rule above closes.
 
 
554
  return {"username": "admin", "name": "Administrator", "role": "admin",
555
  "bus": "all", "modules": "all", "tenant": "royal-imports",
 
556
  "epoch": int(claims.get("e") or 0)}
557
  return None
558
 
 
551
  # The emergency master is TENANT #0's break-glass, not the platform's: a synthetic
552
  # all-access identity minted into another tenant's session would be the exact
553
  # cross-tenant widening the rule above closes.
554
+ # ⚠ WAVE 40 (R2): the same mark `users.verify`'s master branch carries. The two dicts are
555
+ # documented mirrors of each other, so the key rides on both or they are not mirrors.
556
  return {"username": "admin", "name": "Administrator", "role": "admin",
557
  "bus": "all", "modules": "all", "tenant": "royal-imports",
558
+ "emergency_master": True,
559
  "epoch": int(claims.get("e") or 0)}
560
  return None
561
 
api/routes_admin.py CHANGED
@@ -607,9 +607,7 @@ def _module_fields(key, session=None):
607
  f"{key!r} is not a database in this workspace")
608
  src = defn.get("fields") or []
609
  else:
610
- providers = {"customer_data": lambda: aios_grid.FIELDS,
611
- "product_data": aios_grid.product_fields}
612
- provider = providers.get(key)
613
  if provider is None:
614
  # Reached only by a caller that skipped `_perm_modules`. Loud, because the alternative
615
  # (`[]`) is an empty option list read as an ANSWER β€” wave 26 item 24, `if ([])` is
@@ -617,7 +615,32 @@ def _module_fields(key, session=None):
617
  raise err(400, "ungoverned_module",
618
  f"{key!r} has no permission-editor field schema. The editor governs "
619
  f"{sorted(providers)} and this tenant's own databases")
620
- src = provider()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  known = _server_side_vocabularies()
622
  out = []
623
  for f in src:
@@ -631,10 +654,145 @@ def _module_fields(key, session=None):
631
  out.append({"key": f["key"], "label": f.get("label") or f["key"],
632
  "type": f.get("type") or "text",
633
  **({"options": list(opts)} if isinstance(opts, list) and opts else {}),
634
- **({"pinned": True} if f.get("pinned") else {})})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
  return out
636
 
637
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  def _server_side_vocabularies():
639
  """`{field key: [choice, …]}` for choice columns whose vocabulary is CLOSED and known here,
640
  but which the grid contract does not declare.
@@ -654,23 +812,318 @@ def _server_side_vocabularies():
654
  for the same reason `aios_grid`'s is: this route should not pull the analytics stack at
655
  module load.
656
 
657
- ⚠ TWO COLUMNS ARE DELIBERATELY ABSENT, and R6's second sentence says to name them rather than
658
- let them look handled:
659
- Β· `category` (product) β€” an OPEN vocabulary read off Odoo's product categories. There is no
660
- closed list to declare, and discovering it would mean a pool read on an admin route.
661
- Β· `odoo_status` (customer) β€” WAS bare for the same reason and is NOT any more: lane E
662
- answered `ASK D-15` by declaring `options: ['Active','Archived']` on the CONTRACT, which
663
- is the right home (the grid picks a declared list up for free) and is why this function
664
- never needed an arm for it. Recorded because the ask, not a guess, is what settled it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
  """
666
- try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
  import modules.inventory as inventory
668
- labels = list(inventory.COVERAGE_LABELS or ())
669
- except Exception:
670
- # A missing analytics import must not take the permission editor down; the dropdown
671
- # degrades to today's empty one rather than the page to a 500.
672
- return {}
673
- return {"stock_bucket": labels} if labels else {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
 
675
 
676
  def _clean_perms(v, governed_keys=None, session=None):
@@ -724,11 +1177,59 @@ def _clean_perms(v, governed_keys=None, session=None):
724
  for key, raw in v.items():
725
  if not isinstance(raw, dict):
726
  raise err(400, "bad_perms", f"{key}: each entry must be an object")
727
- valid_keys = {f["key"] for f in _module_fields(key, session=session)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
  hidden = raw.get("hiddenFields") or []
729
  if not isinstance(hidden, list):
730
  raise err(400, "bad_perms", f"{key}: hiddenFields must be a list")
731
- bad = sorted({str(h) for h in hidden} - valid_keys)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732
  if bad:
733
  # A hiddenFields entry naming nothing hides nothing β€” and reads as a restriction
734
  # that is not there.
@@ -746,15 +1247,50 @@ def _clean_perms(v, governed_keys=None, session=None):
746
  if conj not in ("and", "or"):
747
  raise err(400, "bad_filter", f"{key}: conj must be 'and' or 'or'")
748
  nodes = tree["nodes"]
749
- cleaned = aios_grid.clean_filter_tree(nodes, valid_keys, cohort_ids=None)
750
- if _leaf_count(cleaned) != _leaf_count(nodes):
751
- raise err(400, "bad_filter",
752
- f"{key}: the filter contains conditions this module cannot evaluate "
753
- f"(an unknown field, an unknown operator, or a cohort leaf β€” cohorts "
754
- f"are per-user and cannot be a permanent rule). Refused rather than "
755
- f"saved with the bad conditions silently removed, which would store a "
756
- f"weaker wall than the one on screen.")
757
- clean_tree = {"conj": conj, "nodes": cleaned}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
758
  # ⭐⭐ W38-T19 β€” `metrics` IS THE FOURTH FIELD OF AN ENTRY, AND ITS DEFAULT IS GRANT.
759
  # `raw.get("metrics", True)` rather than a required key: a PUT composed by an older
760
  # client, or a copy of a record written before this ticket, must not read as a
@@ -762,7 +1298,7 @@ def _clean_perms(v, governed_keys=None, session=None):
762
  # the validator and the wall cannot disagree about what an absent key means.
763
  out[key] = {"access": bool(raw.get("access", True)),
764
  "filter": clean_tree,
765
- "hiddenFields": sorted({str(h) for h in hidden}),
766
  "metrics": bool(raw.get("metrics", True))}
767
  _refuse_unshapeable_bu(out)
768
  return out
@@ -1052,6 +1588,31 @@ def get_perms(username: str, session: Session = Depends(admin_gate)):
1052
  # ⭐⭐ W36-T22 / C2 β€” EVERY listed database, not an `enforced` subset of them. See
1053
  # `perms.tenant_governable_modules` for why the flag is deleted rather than defaulted.
1054
  governed = [m["key"] for m in modules]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1055
  perms_out = {}
1056
  for k in governed:
1057
  e = stored.get(k)
@@ -1116,7 +1677,7 @@ def get_perms(username: str, session: Session = Depends(admin_gate)):
1116
  # item 11's *"EVERY database should be able to be toggleable by admin"*, and it is
1117
  # the SAME resolver `_clean_perms` validates against, so the picker cannot offer a
1118
  # column the validator will reject.
1119
- "fields_by_module": {k: _module_fields(k, session=session) for k in governed}}
1120
 
1121
 
1122
  @router.put("/admin/users/{username}/perms")
 
607
  f"{key!r} is not a database in this workspace")
608
  src = defn.get("fields") or []
609
  else:
610
+ provider = _static_field_providers().get(key)
 
 
611
  if provider is None:
612
  # Reached only by a caller that skipped `_perm_modules`. Loud, because the alternative
613
  # (`[]`) is an empty option list read as an ANSWER β€” wave 26 item 24, `if ([])` is
 
615
  raise err(400, "ungoverned_module",
616
  f"{key!r} has no permission-editor field schema. The editor governs "
617
  f"{sorted(providers)} and this tenant's own databases")
618
+ src = list(provider())
619
+ import core.perm_scope as perm_scope
620
+ # ⭐⭐ W40-T05 / OWNER I16 β€” THE USER-GENERATED COLUMNS JOIN THE TOPIC'S VOCABULARY.
621
+ # *"Permission Filters must be able to filter on user-generated Fields too."* The two topic
622
+ # providers above are the STATIC contract (`aios_grid.FIELDS` = 35, `product_fields()` = 40
623
+ # including the built-in Image column), so until this line NO column a user created could
624
+ # ever reach the picker β€” measured, both topics, zero `custom_` keys in either. The `ut_*`
625
+ # arm never needed it: a user table's stored definition IS its user-generated schema.
626
+ #
627
+ # β›” ADDITIVE AND DEDUPED, so a declared column always wins its own key β€” the same rule the
628
+ # metric rows are appended under below. A user column shadowing a contract column would let
629
+ # an admin hide or filter something other than what they read off the label.
630
+ #
631
+ # ⚠ NO SESSION MEANS TODAY'S ANSWER, AND THAT IS CORRECT RATHER THAN DEGRADED. A workspace
632
+ # bucket is per-tenant data with no tenant-blind read, and two callers arrive without one:
633
+ # `_fold_legacy_scope` on the live PUT path and `verify_api`'s W30a probe. Both get the
634
+ # pre-set list they have always had, and the legacy fold cannot start dropping a leaf it used
635
+ # to keep, because `_prune_to_module` only ever narrows the LEGACY tree (`dba`/`agent`) to
636
+ # keys the contract already carries.
637
+ if key in _FIELD_PROVIDER_KEYS:
638
+ _have = {f.get("key") for f in src if isinstance(f, dict)}
639
+ for _uf in (perm_scope.user_generated_fields(
640
+ key, st=getattr(session, "runtime", None)) or ()):
641
+ if isinstance(_uf, dict) and _uf.get("key") and _uf["key"] not in _have:
642
+ _have.add(_uf["key"])
643
+ src.append(_uf)
644
  known = _server_side_vocabularies()
645
  out = []
646
  for f in src:
 
654
  out.append({"key": f["key"], "label": f.get("label") or f["key"],
655
  "type": f.get("type") or "text",
656
  **({"options": list(opts)} if isinstance(opts, list) and opts else {}),
657
+ **({"pinned": True} if f.get("pinned") else {}),
658
+ # ⭐⭐ CONTRACT C3 / AM-2 β€” THE FOUR MEMBERSHIP BOOLEANS, ON EVERY ROW.
659
+ # READ OFF THE FIELD'S OWN DECLARATION, never inferred from its key, because
660
+ # the declaration is the authority the GRID already uses:
661
+ # `aios_grid.clean_measure_field` writes `custom: True` on a user-created
662
+ # column, the product contract declares `shared: true` on its nine
663
+ # tenant-wide-value columns, and `filterable: False` is how a column states
664
+ # the engine cannot filter it (`est_missed` is the one that does).
665
+ #
666
+ # ⚠ `shared` IS INFORMATIONAL AND MUST NEVER BECOME THE HIDE RULE. AM-2's
667
+ # rule is `metric || !custom`. Nine declared PRODUCT columns carry
668
+ # `shared: true` (`first_cost`, `supplier`, `notes`, ...) and every one of
669
+ # them has to stay hideable β€” `verify_api`'s legacy-fold leg hides
670
+ # `first_cost` by name. A client that excluded rows on `shared` would revoke
671
+ # the admin's control over nine pre-set columns, which is I15's own defect
672
+ # one word over.
673
+ "custom": bool(f.get("custom")),
674
+ "shared": (bool(f.get("shared"))
675
+ or f.get(perm_scope.FIELD_GRANT_MARK) is True),
676
+ "filterable": f.get("filterable") is not False,
677
+ "metric": False})
678
+ # ⭐⭐ I13 β€” THE METRIC ROWS, APPENDED LAST so no declared column is ever displaced, and
679
+ # skipped on a key collision so a declared column always wins its own key.
680
+ taken = {f["key"] for f in out}
681
+ out.extend(m for m in _metric_fields(key) if m["key"] not in taken)
682
+ return out
683
+
684
+
685
+ def _metric_fields(key):
686
+ """One `measure_`-namespaced pseudo-field per measure BOUND to `key`'s entity topic.
687
+
688
+ ⭐⭐ I13 β€” THE ROW THE OWNER ASKED FOR: *"Fold 'Metrics fields (lookback measures over
689
+ this database)' into the 'Hide fields' checkboxes, one row per metric, named
690
+ 'Metric - Revenue', 'Metric - Order' and so on, so a user can check the ones the
691
+ permissioning is limited to."* Until this existed the entire capability was ONE boolean
692
+ (`perm_scope.may_metrics`), so an administrator could revoke every metric on a database or
693
+ none of them.
694
+
695
+ ⭐⭐ AND IT IS WHY I15 HAD NO CONTROL TO OFFER. *"Shantal has access to gross profit."*
696
+ "Gross profit" is the measure `margin` β€” label "Gross margin $", defined at
697
+ `platform/model/metrics/sales.yml::margin` and bound onto this grid by
698
+ `platform/model/topics/odoo_products.yml::measures`. **It is not a column in
699
+ `aios_grid_fields.json` and never was**, so a picker built from the declared contract alone
700
+ could not list it however carefully that contract was read. No key literally named
701
+ `gross_profit` reaches this grid at all.
702
+
703
+ β›” THE OFFER IS READ, NEVER RESTATED. `semantic.entity_measures` is the ONE catalogue, and
704
+ it REFUSES a key it cannot prove β€” a half-synced mirror, a metric with no `empty:` family,
705
+ a format with no grid type β€” with the cause available through `entity_measure_refusals`.
706
+ Re-measured 2026-08-24 in this tree, after W40-T10 bound the channel split: the product topic
707
+ declares **27 key-slots across five bindings** and the catalogue offers **24**;
708
+ `stock_in`/`stock_out`/`stock_net` are refused because `stock_move` has not finished syncing.
709
+ Hardcoding a declared list would offer three columns that render blank today AND would stop
710
+ growing the day a binding is added. The offer widens on its own when the mirror catches up,
711
+ because this asks rather than remembers β€” and the eighteen channel rows below
712
+ ("Metric - Revenue (Fisch)", "Metric - Revenue (Amazon)", …) arrived here with NO change to
713
+ this function for exactly that reason.
714
+
715
+ ⭐⭐ W40-T10 / owner item 26 (R7) β€” AND THAT IS WHY THE CHANNEL RIDES THE MEASURE KEY. A
716
+ per-metric channel spelled as a member of the stored field spec would be invisible here:
717
+ this row is minted from `m["key"]` and `m["label"]`, so a channel carried anywhere else
718
+ would give an admin eighteen columns they can neither tell apart nor hide separately.
719
+ `semantic._one_binding_offer` therefore namespaces the key and appends "(Fisch)" to the
720
+ label, and both halves land in this vocabulary for free.
721
+
722
+ β›” THE TOPIC IS DERIVED, NEVER MAPPED. `semantic.topic_for_grid` reads the `grid:` key the
723
+ topic file already declares, which is the reason its own docstring gives for existing: a
724
+ `{module: topic}` literal in a route would be a second statement of one fact and would
725
+ drift the day a topic is renamed. It answers for every arm β€” `product_data` ->
726
+ `odoo_products`, `customer_data` -> `odoo_customers` (which binds none), `ut_odoo_agents` ->
727
+ `odoo_agents` (which binds seven) β€” so this needs NO per-module branch, and a database
728
+ with no bound measure simply receives no rows.
729
+
730
+ β›”β›” IT CANNOT RAISE, AND THAT IS LOAD-BEARING RATHER THAN DEFENSIVE. Two callers reach
731
+ `_module_fields` with NO session and outside any store fixture: `_fold_legacy_scope`, on the
732
+ live migration path of a PUT, and `verify_api`'s W30a probe, which runs ABOVE the line that
733
+ installs the fake store. A raise on either is not a red gate but a CRASHED one
734
+ [[gate-must-go-red-not-crash]], and on the route it is a 500 on the administrator's own save.
735
+ `entity_measures` already degrades to `[]` when the mirror cannot be read; this makes the
736
+ whole derivation degrade with it, so the payload simply carries no metric rows.
737
+
738
+ β›” `filterable: False`, AND IT IS A MEASUREMENT RATHER THAN A PREFERENCE. A pseudo-key is
739
+ not a column any row carries β€” a real Metric column is keyed `measure_<slug>_<rand>` by
740
+ `CustomerGrid.createField`. Measured: `clean_filter_tree` ACCEPTS a leaf on `measure_margin`
741
+ once the key is admitted, and `harness.filter_eval.permits` then answers **False for every
742
+ product row**, because the row has no such key. That is exactly the "deny-everything trap
743
+ wearing a saved-successfully toast" `_module_fields`' own docstring forbids. The governed way
744
+ to filter on a measure is the measure CONDITION channel (`measure_sets`, CG-8), not a field
745
+ leaf, so these rows join the HIDE vocabulary only.
746
+
747
+ β›” THE PREFIX IS `aios_grid.MEASURE_FIELD_PREFIX`, IMPORTED. Spelling "measure_" here would
748
+ be a second copy of a constant two features already share
749
+ [[constant-two-features-share]], and `verify_fields_contract` gates that the client keys its
750
+ real measure columns off the same one.
751
+ """
752
+ try:
753
+ import aios_grid
754
+ import harness.semantic as semantic
755
+ topic = semantic.topic_for_grid(key)
756
+ if not topic:
757
+ return []
758
+ offer = semantic.entity_measures(topic)
759
+ except Exception: # noqa: BLE001
760
+ return []
761
+ out = []
762
+ for m in offer or ():
763
+ if not isinstance(m, dict):
764
+ continue
765
+ mkey = str(m.get("key") or "").strip()
766
+ if not mkey:
767
+ continue
768
+ # ⭐ THE LABEL IS THE MEASURE'S OWN, prefixed. A hand-written second vocabulary here
769
+ # would read "Metric - Gross profit" while the grid column read "Gross margin $", and an
770
+ # admin cannot hide what they cannot recognise. ASCII hyphen, exactly as the owner wrote
771
+ # it β€” CLAUDE.md rule 2 bars a dash in copy that reaches a screen.
772
+ label = str(m.get("label") or mkey).strip()
773
+ mtype = m.get("type")
774
+ out.append({"key": f"{aios_grid.MEASURE_FIELD_PREFIX}{mkey}",
775
+ "label": f"Metric - {label}",
776
+ "type": mtype if mtype in aios_grid.MEASURE_FIELD_TYPES else "currency",
777
+ "custom": False,
778
+ "shared": False,
779
+ "filterable": False,
780
+ "metric": True})
781
  return out
782
 
783
 
784
+ #: The value every blank Odoo connector attribute displays as. The writer is
785
+ #: `modules/customers._partner_attrs` (`... or '(none)'`) and the client mirror is
786
+ #: `customer-grid/types.ts::CONNECTOR_BLANK`. It is a DELIBERATE, documented, visible value, so a
787
+ #: choice vocabulary that omitted it would deny an admin any rule about the blank rows.
788
+ BLANK = "(none)"
789
+
790
+ #: `_server_side_vocabularies`' per-process memo. `None` until a COMPLETE build succeeds; a build
791
+ #: with any failed arm is returned but NOT stored, so a transient Odoo outage cannot pin a short
792
+ #: answer for the life of the worker.
793
+ _VOCAB_CACHE = None
794
+
795
+
796
  def _server_side_vocabularies():
797
  """`{field key: [choice, …]}` for choice columns whose vocabulary is CLOSED and known here,
798
  but which the grid contract does not declare.
 
812
  for the same reason `aios_grid`'s is: this route should not pull the analytics stack at
813
  module load.
814
 
815
+ ⭐⭐ W40-T06 / OWNER I11+I23 β€” NINE MORE CHOICE COLUMNS, AND WHY THE OLD REFUSAL EXPIRED.
816
+ Ruling R5 retyped ten Odoo customer columns off `text`, so nine of them now reach the branch
817
+ above and D-236 (`category` renders an EMPTY dropdown here) would have been multiplied by
818
+ nine rather than fixed. `category` was refused in this docstring because "discovering it
819
+ would mean a pool read on an admin route". That reason was WRONG about the cost, not about
820
+ the principle: none of the reads below is a pool read. Each is either a `read_group` over
821
+ `res.partner` (one grouped row per distinct value) or a whole small master table, and the
822
+ dearest of them was MEASURED at 1.8s cold, once per process.
823
+
824
+ β›”β›” A VOCABULARY MUST MATCH THE CELL, NOT THE RAW SOURCE, and the pricelist is the proof.
825
+ `modules/customers._partner_attrs` writes `O.m2o_name(property_product_pricelist)`, which is
826
+ Odoo's DISPLAY name: the cell reads "Fisch (USD)". `product.pricelist.name` reads "Fisch".
827
+ A vocabulary built from `name` would therefore offer five options, and every one of them
828
+ would match ZERO rows. So every arm below reproduces the writer's own expression:
829
+ `display_name` for an m2o, `.strip().title()` for `city`, the `', '.join(...)` for the m2m
830
+ `agent`, and `'(none)'` wherever `_partner_attrs` collapses a blank (the set of fields that
831
+ do is documented in the contract JSON's own `_comment`).
832
+ MEASURED against real cells over all 5,282 partners, 2026-08-23: country 20 offered / 20
833
+ observed, state 64 / 64, agent 14 / 13, city 1,254 / 1,245, pricelist 5 / 4,
834
+ payment_terms 15 / 10, tags 6 / 5. ⚠ Read the direction: NOT ONE observed cell value is
835
+ missing from what is offered. The overshoot is partners outside the grid's own 24 month
836
+ population, and it is the SAFE direction. A missing option is the dangerous one, because it
837
+ silently denies the admin any rule about those rows.
838
+
839
+ ⚠ `tags` IS A MULTISELECT, so it is offered as MEMBERS, not as joined cells.
840
+ `choiceVocabulary` splits a multiselect cell on commas, so "Fisch, Royal" must arrive as
841
+ "Fisch" and "Royal". `agent` stays a SINGLE select per R5 and therefore keeps its joined
842
+ combinations. β›” NOTE FOR THE OWNER, not acted on here: `agent` is comma joined multi value
843
+ exactly as `tags` is (13 observed cells are combinations such as "Avi Ash, Martin
844
+ Pasternak"), so R5's own argument for making Tags a multiselect applies to it too. R5 rules
845
+ single select, so single select is what this builds.
846
+
847
+ β›” DEGRADE BY OMITTING THE KEY, NEVER BY RETURNING `[]`. `FilterBuilderPanel::choicesFor`
848
+ treats a supplied list as authoritative INCLUDING when it is empty, so an empty list asserts
849
+ "this column has no values". Every arm is therefore isolated in its own try/except and a
850
+ failed read leaves its key OUT, exactly as the original single-arm version did.
851
+
852
+ ⚠ CACHED PER PROCESS, because `_module_fields` runs on every admin GET and on every PUT via
853
+ `_fold_legacy_scope`. Only SUCCESSES are cached, so a transient Odoo outage cannot pin an
854
+ empty answer for the life of the worker. STALE WHEN: someone adds or renames a pricelist,
855
+ payment term, partner tag or product category; a customer moves city, state or country; an
856
+ agent is assigned or unassigned; a new salesperson takes their first order. None of those is
857
+ observable from here, and the refresh is a process restart, which a deploy already does.
858
+
859
+ ⚠ ONE COLUMN IS STILL DELIBERATELY ABSENT, and R6's second sentence says to name it rather
860
+ than let it look handled:
861
+ Β· `odoo_status` (customer) β€” WAS bare and is NOT any more: lane E answered `ASK D-15` by
862
+ declaring `options: ['Active','Archived']` on the CONTRACT, which is the right home (the
863
+ grid picks a declared list up for free) and is why this function never needed an arm for
864
+ it. Recorded because the ask, not a guess, is what settled it.
865
  """
866
+ global _VOCAB_CACHE
867
+ if _VOCAB_CACHE is not None:
868
+ return dict(_VOCAB_CACHE)
869
+
870
+ out = {}
871
+
872
+ def arm(fn):
873
+ """Run one source. A failure omits its keys and never touches the others."""
874
+ try:
875
+ for key, values in (fn() or {}).items():
876
+ clean = sorted({str(v) for v in values if str(v or "").strip()})
877
+ if clean:
878
+ out[key] = clean
879
+ return True
880
+ except Exception: # noqa: BLE001
881
+ return False
882
+
883
+ def _stock_bucket():
884
+ # IMPORTED, NEVER RESTATED: the same constant `_bucket()` mints from, so a renamed band
885
+ # cannot leave a second vocabulary behind [[constant-two-features-share]]. Lazy for the
886
+ # same reason `aios_grid`'s import is: no analytics stack at module load.
887
  import modules.inventory as inventory
888
+ return {"stock_bucket": list(inventory.COVERAGE_LABELS or ())}
889
+
890
+ def _categories():
891
+ import modules.inventory as inventory
892
+ # W40-T06: the root maps to None now, so "All" cannot reach either list. The sentinel is
893
+ # what `products.catalogue` and `sales._product_cat` substitute for that None.
894
+ cats = sorted({v for v in inventory._cat_main_map().values() if v})
895
+ return {"category": cats + ["(uncategorized)"],
896
+ "top_category": cats + ["(uncategorized)", BLANK]}
897
+
898
+ def _suppliers():
899
+ """The product `supplier` column's observed values.
900
+
901
+ β›”β›” THIS ARM EXISTS BECAUSE W40-T06 CREATED THE HOLE IT FILLS. `supplier` was `text`
902
+ until I18 made it a multiselect, and a choice column with no declared options and no
903
+ entry here is precisely D-236's empty dropdown. The flip would otherwise have ADDED a
904
+ tenth blank picker while this ticket was removing nine.
905
+
906
+ β›”β›” DECISION OWED, AND IT IS VISIBLE ON SCREEN: 18 of the 88 distinct suppliers carry a
907
+ COMMA INSIDE THE COMPANY NAME ("CANDLE ARTISANS, INC", "BAOBEI INT'L CO.,LTD"), spanning
908
+ 371 product codes. A multiselect cell is by contract a comma-joined SET, and
909
+ `types.ts::choiceVocabulary` SPLITS it, so the grid's own row-derived picker will offer
910
+ 26 fragments ("CANDLE ARTISANS", "INC") where 18 real suppliers should be. That is a
911
+ data fact about supplier names, not a bug in the splitter: unlike `tags`, `supplier` is
912
+ a SINGLE value that merely contains punctuation. This function serves the WHOLE values,
913
+ which is what the cells actually hold and what an admin means to filter on. The kind
914
+ itself is the owner's to settle (`select` would make the collision disappear); I18 says
915
+ multi-select, so multi-select is what the contract carries.
916
+
917
+ ⚠ SEED VALUES, NOT LIVE CELLS. `supplier` is an OVERLAY column a user may retype per
918
+ record, and those edits live in the tenant's workspace, which this route cannot read
919
+ tenant-blind. So a supplier somebody typed by hand is missing from this list until the
920
+ mastersheet carries it. Cheap and safe: `supplier_master()` is a local file cached per
921
+ process, and it degrades to `{}` rather than raising.
922
+ """
923
+ import modules.product_data as product_data
924
+ return {"supplier": [(v.get("supplier") or "").strip()
925
+ for v in (product_data.supplier_master() or {}).values()]}
926
+
927
+ def _masters():
928
+ import core.odoo as O
929
+ got = {}
930
+ for key, model in (("pricelist", "product.pricelist"),
931
+ ("payment_terms", "account.payment.term"),
932
+ ("tags", "res.partner.category")):
933
+ # display_name, never name: see the pricelist proof in the docstring.
934
+ got[key] = [c["display_name"] for c in O.search_read(model, [], ["display_name"])]
935
+ if key != "pricelist": # measured: every customer carries a pricelist
936
+ got[key] = got[key] + [BLANK]
937
+ return got
938
+
939
+ def _partner_dims():
940
+ import core.odoo as O
941
+ rg = lambda field: O.read_group("res.partner", [], ["id"], [field], lazy=False)
942
+ return {
943
+ "country": [O.m2o_name(r.get("country_id")) or BLANK for r in rg("country_id")],
944
+ "state": [O.m2o_name(r.get("state_id")) or BLANK for r in rg("state_id")],
945
+ # `.strip().title()` is `_partner_attrs`' own expression. Raw mirror values would
946
+ # offer "NEW YORK" where the cell says "New York": no match, in both directions.
947
+ "city": [(r.get("city") or "").strip().title() or BLANK for r in rg("city")],
948
+ }
949
+
950
+ def _agents():
951
+ import core.odoo as O
952
+ rows = O.search_read("res.partner", [], ["agent_ids"])
953
+ ids = {i for r in rows for i in (r.get("agent_ids") or [])}
954
+ names = {p["id"]: p["name"]
955
+ for p in O.search_read("res.partner", [("id", "in", list(ids))], ["name"])}
956
+ return {"agent": [", ".join(n for n in (names.get(i) for i in (r.get("agent_ids") or []))
957
+ if n) or BLANK for r in rows]}
958
+
959
+ def _salespeople():
960
+ # The ORDER TAKER over LTM (`sale.order.user_id`), which is what the column holds, NOT
961
+ # `res.users`: `modules/customer_data._salesperson_attrs` is the writer, and it reads the
962
+ # order's taker. Grouped, so the result is one row per distinct person.
963
+ import core.odoo as O
964
+ import core.periods as P
965
+ import modules.sales as S
966
+ mf, mt = P.ltm(P.today())
967
+ rows = O.read_group("sale.order", S.order_domain(str(mf), str(mt), None),
968
+ ["amount_untaxed:sum"], ["user_id"], lazy=False)
969
+ return {"salesperson": [O.m2o_name(r.get("user_id")) or BLANK for r in rows]}
970
+
971
+ complete = all([arm(source) for source in
972
+ (_stock_bucket, _categories, _suppliers, _masters, _partner_dims,
973
+ _agents, _salespeople)])
974
+ if complete:
975
+ _VOCAB_CACHE = dict(out)
976
+ return out
977
+
978
+
979
+ def _prunable_vocabulary(key, session=None, fields=None):
980
+ """The field keys `key` HAS right now β€” or `None` when that is not trustworthy enough to
981
+ prune a stored permission filter against.
982
+
983
+ β›”β›” THE RETURN IS A SAFETY DEVICE, NOT A CONVENIENCE. The cascade below DELETES a filter leaf
984
+ naming a key that is not in this set, so a set that is short by one column revokes a live
985
+ permission rule β€” permanently, silently, and in the WIDENING direction. Three separate ways
986
+ to be short are refused here rather than papered over:
987
+
988
+ * a TOPIC whose workspace bucket could not be read (`user_generated_fields` answers `None`,
989
+ which is exactly why it distinguishes that from `[]`). This is the case that would fire
990
+ under ordinary store contention, and it is the dangerous one: measured in this tree, a
991
+ busy DuckDB already takes `_module_fields('product_data')` from 45 rows to 39.
992
+ * a SURFACE (Assistant, Agents), whose vocabulary is `[]` by design. Pruning against an
993
+ empty set would drop every leaf of anything stored there.
994
+ * any key whose schema read RAISES β€” an unknown module, a `ut_*` database whose definition
995
+ the store cannot serve.
996
+
997
+ ⚠ `fields` is threaded so the ONE `_module_fields` call a caller has already paid for is
998
+ reused. Recomputing it would double a read that costs a store round-trip per module on a
999
+ route that already makes one per governed database.
1000
+ """
1001
+ import core.perm_scope as perm_scope
1002
+
1003
+ key = str(key or "")
1004
+ if key in _FIELD_PROVIDER_KEYS:
1005
+ if perm_scope.user_generated_fields(key, st=getattr(session, "runtime", None)) is None:
1006
+ return None
1007
+ elif not key.startswith("ut_"):
1008
+ return None
1009
+ if fields is None:
1010
+ try:
1011
+ fields = _module_fields(key, session=session)
1012
+ except Exception: # noqa: BLE001
1013
+ return None
1014
+ return {f["key"] for f in fields if isinstance(f, dict) and f.get("key")} or None
1015
+
1016
+
1017
+ def _prune_stale_filters(block, session=None):
1018
+ """`(block, {module: [dropped keys]})` β€” owner I16's cascade over a whole stored perms block.
1019
+
1020
+ ⭐⭐ *"If the field is deleted, its permission filter goes with it."* Run at the ADMIN DOOR,
1021
+ on the record, never in the wall: `perm_scope.prune_filter_to_fields`' own docstring carries
1022
+ the reason (`verify_perm_scope`'s "a wall naming a DELETED column denies every row" leg must
1023
+ stay green, and it asserts on `apply_row_scope` directly).
1024
+
1025
+ ⚠ NON-DESTRUCTIVE and entry-wise: an entry with no filter, an entry whose vocabulary will not
1026
+ resolve, and an entry with nothing stale all come back as the object that went in. Only an
1027
+ entry that actually lost a leaf is rebuilt, so a caller can use the returned map as its
1028
+ "is a write needed" test rather than comparing documents.
1029
+ """
1030
+ import core.perm_scope as perm_scope
1031
+
1032
+ out, dropped = dict(block or {}), {}
1033
+ for key, entry in (block or {}).items():
1034
+ if not isinstance(entry, dict) or not entry.get("filter"):
1035
+ continue
1036
+ vocab = _prunable_vocabulary(key, session=session)
1037
+ if vocab is None:
1038
+ continue
1039
+ tree, gone = perm_scope.prune_filter_to_fields(entry["filter"], vocab)
1040
+ if gone:
1041
+ out[key] = dict(entry, filter=tree)
1042
+ dropped[key] = gone
1043
+ return out, dropped
1044
+
1045
+
1046
+ def _static_field_providers():
1047
+ """The STATIC contract behind each topic grid, read by TWO callers.
1048
+
1049
+ `_module_fields` builds the picker from it and then APPENDS the user-generated columns;
1050
+ `_row_wall_blind_keys` needs the same set to tell those two apart. A second copy of this map
1051
+ would let them disagree about which columns are the database's own, and the disagreement
1052
+ would show up as a permission filter refused on a column that was never user-generated.
1053
+ """
1054
+ import aios_grid
1055
+
1056
+ return {"customer_data": lambda: aios_grid.FIELDS,
1057
+ "product_data": aios_grid.product_fields}
1058
+
1059
+
1060
+ def _leaf_col_ids(nodes):
1061
+ """Every `colId` a filter tree names, at any depth. Groups carry `children`; leaves do not."""
1062
+ found = set()
1063
+ for node in nodes or ():
1064
+ if not isinstance(node, dict):
1065
+ continue
1066
+ if isinstance(node.get("children"), list):
1067
+ found |= _leaf_col_ids(node["children"])
1068
+ elif node.get("colId"):
1069
+ found.add(str(node["colId"]))
1070
+ return found
1071
+
1072
+
1073
+ def _row_wall_blind_keys(key, session=None):
1074
+ """User-generated columns of a TOPIC grid, which the row wall structurally cannot answer.
1075
+
1076
+ THE DEFECT THIS REFUSES. W40-T05 taught `_module_fields` to append
1077
+ `perm_scope.user_generated_fields`, so the permission editor now OFFERS a column a user made
1078
+ (owner instruction 16), and this validator accepted a wall naming it. But on the two topic
1079
+ grids the wall runs at `routes_customers.py:277` / `:495` and `routes_products.py:118` as
1080
+ `apply_row_scope(rows, user, MODULE, <the STATIC contract>)`, over PRE-OVERLAY rows: the
1081
+ column is neither declared in that field list nor present on the row, and
1082
+ `filter_eval.permits` DENIES every leaf it cannot answer.
1083
+
1084
+ Measured on the integrated head, one leaf {colId: fld_region, op: eq, value: West}, two rows:
1085
+
1086
+ static contract + pre-overlay rows (THE LIVE CALL SITE) -> [] every row denied
1087
+ column declared + rows carrying the value -> ['1'] correct
1088
+ column declared + pre-overlay rows -> []
1089
+ a DECLARED odoo column, same shape -> ['1'] the evaluator is fine
1090
+
1091
+ So an administrator saves a rule, is told it saved, and that account opens an empty grid with
1092
+ nothing on screen saying why. Fail-CLOSED, and one click undoes it, but silent.
1093
+
1094
+ REFUSED AT THE WRITE, WHERE A PERSON IS PRESENT TO FIX IT, which is the same call
1095
+ `_refuse_unshapeable_bu` makes further down this file for the same reason. The alternative was
1096
+ to move the wall after the overlay merge, and that is not a small change: `apply_row_scope`
1097
+ runs before `pids` is taken precisely so a row this account may not see never enters the
1098
+ workspace, the cohorts or the measures. Reshaping that at QA time to fix a silent-deny would
1099
+ trade a visible defect for an invisible one.
1100
+
1101
+ NARROW BY CONSTRUCTION, three ways. Only the two TOPIC grids: a `ut_*` database enforces
1102
+ correctly today, because its stored definition IS its user-generated schema and its rows carry
1103
+ the values. Only the FILTER: `hiddenFields` still accepts these columns, because hiding is
1104
+ applied to the assembled field list, which does have them. And only columns the static
1105
+ contract does NOT declare, so a user column shadowing a real one cannot make the real one
1106
+ unfilterable.
1107
+
1108
+ `user_generated_fields` answers `None` when the vocabulary cannot be read, and `None` is
1109
+ handled by NOT narrowing, which is correct rather than lax: on that same answer
1110
+ `_module_fields` appended nothing, so there is nothing in `filter_keys` to take out.
1111
+
1112
+ THE PROPER FIX IS BOOKED, NOT DONE: teach the three doors to wall AFTER the overlay merge with
1113
+ a field list that declares the column. Until then instruction 16 is delivered for `ut_*`
1114
+ databases and REPORTED as unavailable on the two Odoo topics, which is what CLAUDE.md rule 1
1115
+ demands of a limit that cannot yet be removed.
1116
+ """
1117
+ if key not in _FIELD_PROVIDER_KEYS:
1118
+ return set()
1119
+ import core.perm_scope as perm_scope
1120
+
1121
+ provider = _static_field_providers().get(key)
1122
+ declared = {f.get("key") for f in (provider() if provider else ())
1123
+ if isinstance(f, dict)}
1124
+ found = perm_scope.user_generated_fields(key, st=getattr(session, "runtime", None))
1125
+ return {f["key"] for f in (found or ())
1126
+ if isinstance(f, dict) and f.get("key") and f["key"] not in declared}
1127
 
1128
 
1129
  def _clean_perms(v, governed_keys=None, session=None):
 
1177
  for key, raw in v.items():
1178
  if not isinstance(raw, dict):
1179
  raise err(400, "bad_perms", f"{key}: each entry must be an object")
1180
+ # ⭐⭐ AM-2 β€” ONE SOURCE, TWO VOCABULARIES, AND THE VALIDATOR READS BOTH. The
1181
+ # picker and this validator still call the SAME function, which is the property
1182
+ # `_module_fields`' docstring exists to protect; what changed is that a field now states
1183
+ # which vocabulary it belongs to, and each consumer reads its own membership instead of
1184
+ # the whole list.
1185
+ #
1186
+ # β›” THE SPLIT IS WHY THE METRIC ROWS ARE SAFE TO ADD. `hiddenFields` is validated
1187
+ # against EVERY key (a metric row is hideable β€” that is I13's whole point), while the
1188
+ # permanent FILTER is validated against `filterable` only. Without the second set,
1189
+ # admitting `measure_margin` would make {"colId": "measure_margin", ...} a storable
1190
+ # permanent wall, and `permits()` answers False for every row that has no such key:
1191
+ # measured, not feared. `clean_filter_tree` takes bare strings and cannot see the flag,
1192
+ # so the narrowing has to happen HERE, at the call.
1193
+ fields_here = _module_fields(key, session=session)
1194
+ valid_keys = {f["key"] for f in fields_here}
1195
+ filter_keys = {f["key"] for f in fields_here if f.get("filterable")}
1196
+ # W40-T05 x W40-T18, see `_row_wall_blind_keys`. A user-generated column of a TOPIC grid
1197
+ # is HIDEABLE (it stays in `valid_keys`) and NOT FILTERABLE, because the row wall on those
1198
+ # two doors runs over pre-overlay rows and denies every leaf it cannot answer.
1199
+ row_wall_blind = _row_wall_blind_keys(key, session)
1200
+ filter_keys -= row_wall_blind
1201
  hidden = raw.get("hiddenFields") or []
1202
  if not isinstance(hidden, list):
1203
  raise err(400, "bad_perms", f"{key}: hiddenFields must be a list")
1204
+ submitted = {str(h) for h in hidden}
1205
+ # β›”β›” A METRIC TICK IS ADMITTED ON ITS PREFIX, NOT ON THE LIVE OFFER, AND THAT CLOSES A
1206
+ # GET/PUT SKEW RATHER THAN WEAKENING THE GUARD.
1207
+ #
1208
+ # `_metric_fields` asks `semantic.entity_measures`, which REFUSES every key while the
1209
+ # mirror cannot be read β€” measured twice in this tree on 2026-08-23, when another
1210
+ # process held the DuckDB and the offer came back empty. That answer is deliberately not
1211
+ # cached (`_ENTITY_OFFER_CACHE` skips an indeterminate one), so the state recurs freely.
1212
+ # Without this line the sequence is: the editor GETs a payload carrying
1213
+ # `measure_margin`, the admin ticks "Metric - Gross margin $", the PUT lands a moment
1214
+ # later while the store is busy, `valid_keys` has 39 entries instead of 45, and the
1215
+ # administrator is told their own tick *"names unknown fields"*. That is this function's
1216
+ # ONE SOURCE invariant running backwards β€” the validator rejecting exactly what the
1217
+ # picker offered.
1218
+ #
1219
+ # ⭐ AND ADMITTING IT IS CORRECT, not merely convenient, because the ENFORCEMENT keys off
1220
+ # the measure NAME and not off the offer: `perm_scope._measure_bound_keys` hides every
1221
+ # column whose `measure.key` matches, whether or not the catalogue is answering right now.
1222
+ # So a tick stored during a warm-up walls the right columns the moment they render. A
1223
+ # metric key that never comes back hides nothing, which is the harmless direction; the
1224
+ # refusal is the harmful one.
1225
+ #
1226
+ # ⚠ NARROW BY CONSTRUCTION: only the `measure_` namespace is admitted this way, and only
1227
+ # with a non-empty suffix. Every other unknown key is still refused, because a
1228
+ # `hiddenFields` entry naming nothing reads as a restriction that is not there.
1229
+ _mpre = aios_grid.MEASURE_FIELD_PREFIX
1230
+ metric_ticks = {h for h in submitted
1231
+ if h.startswith(_mpre) and len(h) > len(_mpre)}
1232
+ bad = sorted(submitted - valid_keys - metric_ticks)
1233
  if bad:
1234
  # A hiddenFields entry naming nothing hides nothing β€” and reads as a restriction
1235
  # that is not there.
 
1247
  if conj not in ("and", "or"):
1248
  raise err(400, "bad_filter", f"{key}: conj must be 'and' or 'or'")
1249
  nodes = tree["nodes"]
1250
+ # β›”β›” THE CASCADE DOES **NOT** RUN ON THE SUBMITTED TREE, AND W40-T05 SHIPPED THE
1251
+ # OPPOSITE FOR AN HOUR. It pruned here first, so that an administrator with a stale
1252
+ # tab would not be told their own saved rule was a client error. Sympathetic, and
1253
+ # WRONG: `verify_api`'s leg *"β›” a filter naming an unknown FIELD -> 400, never
1254
+ # saved-minus-the-bad-leaf"* went red on the very case it exists for β€” a PUT naming
1255
+ # `ghost` was accepted and stored minus that leaf. This validator is a SECURITY door,
1256
+ # and the sentence the refusal below already carried says why: a wall saved with its
1257
+ # bad conditions silently removed is WEAKER THAN THE ONE ON SCREEN.
1258
+ #
1259
+ # ⭐ SO THE CASCADE LIVES ON THE **STORED** RECORD ONLY, in `get_perms` (see
1260
+ # `_prunable_vocabulary`'s own docstring). That is the honest split, and it is enough
1261
+ # for I16: the leaf disappears when the record is next read, which is the same page
1262
+ # load a stale tab has to do anyway. A leaf in a payload the client just SENT is
1263
+ # refused, loudly, whatever its history β€” the server cannot tell a deleted column from
1264
+ # a typo in a submission, and only one of those two guesses is safe.
1265
+ # β›” AN EMPTIED FILTER IS `None`, NEVER `{"conj": "and", "nodes": []}`. Measured:
1266
+ # `filter_eval.permits` returns True on an empty node list, so an empty-but-present
1267
+ # tree admits every row β€” while `perm_scope.wall_declared` and `row_scope_applies`
1268
+ # both read it as TRUTHY and answer that a wall applies. A door would then build rows
1269
+ # in order to filter them against nothing, and the editor would paint a rule that is
1270
+ # not there. This also normalises a client that PUTs `{"nodes": []}` to mean "no
1271
+ # filter", which is what it has always meant on screen.
1272
+ if nodes:
1273
+ # Named before the generic refusal below, because "an unknown field" is exactly
1274
+ # what this is NOT: the picker offered it one request ago. The admin needs to be
1275
+ # told which column and why, or they will simply try again.
1276
+ blind = sorted(_leaf_col_ids(nodes) & row_wall_blind)
1277
+ if blind:
1278
+ raise err(400, "bad_filter",
1279
+ f"{key}: a permission filter cannot use {blind}. Those columns are "
1280
+ f"stored per record on top of this database rather than in it, and "
1281
+ f"the row wall runs before they are merged, so the rule would hide "
1282
+ f"EVERY row from this account with nothing on screen saying why. "
1283
+ f"Hide the column instead, or filter on one of the database's own "
1284
+ f"columns.")
1285
+ cleaned = aios_grid.clean_filter_tree(nodes, filter_keys, cohort_ids=None)
1286
+ if _leaf_count(cleaned) != _leaf_count(nodes):
1287
+ raise err(400, "bad_filter",
1288
+ f"{key}: the filter contains conditions this module cannot evaluate "
1289
+ f"(an unknown field, an unknown operator, or a cohort leaf β€” cohorts "
1290
+ f"are per-user and cannot be a permanent rule). Refused rather than "
1291
+ f"saved with the bad conditions silently removed, which would store "
1292
+ f"a weaker wall than the one on screen.")
1293
+ clean_tree = {"conj": conj, "nodes": cleaned}
1294
  # ⭐⭐ W38-T19 β€” `metrics` IS THE FOURTH FIELD OF AN ENTRY, AND ITS DEFAULT IS GRANT.
1295
  # `raw.get("metrics", True)` rather than a required key: a PUT composed by an older
1296
  # client, or a copy of a record written before this ticket, must not read as a
 
1298
  # the validator and the wall cannot disagree about what an absent key means.
1299
  out[key] = {"access": bool(raw.get("access", True)),
1300
  "filter": clean_tree,
1301
+ "hiddenFields": sorted(submitted),
1302
  "metrics": bool(raw.get("metrics", True))}
1303
  _refuse_unshapeable_bu(out)
1304
  return out
 
1588
  # ⭐⭐ W36-T22 / C2 β€” EVERY listed database, not an `enforced` subset of them. See
1589
  # `perms.tenant_governable_modules` for why the flag is deleted rather than defaulted.
1590
  governed = [m["key"] for m in modules]
1591
+ # ⭐ ONE `_module_fields` PASS, SHARED BY THE PICKER AND THE CASCADE. It used to be built
1592
+ # inline at the return; hoisted so the prune below reads the SAME vocabulary the payload
1593
+ # offers rather than paying a second store round-trip per database to ask again.
1594
+ fields_by_module = {k: _module_fields(k, session=session) for k in governed}
1595
+ # ⭐⭐ W40-T05 / OWNER I16 β€” THE CASCADE, AT THE ADMIN DOOR. *"If the field is deleted, its
1596
+ # permission filter goes with it."* A stored leaf naming a column the database no longer has
1597
+ # is not ignored by the wall β€” `apply_row_scope` uses `permits`, which DENIES what it cannot
1598
+ # answer β€” so a deleted column silently converts that account's grid to zero rows. Dropping
1599
+ # the leaf here means the editor renders, and the record stores, only rules that still exist.
1600
+ #
1601
+ # β›”β›” GUARDED ON `is_migrated`, AND THAT GUARD IS A BU LEAK AWAY FROM OPTIONAL.
1602
+ # `users.set_access(perms=…)` STAMPS `perms_v` in the same read-modify-write, so writing here
1603
+ # would MIGRATE an un-migrated record β€” and `_fold_legacy_scope` runs only WHILE a record is
1604
+ # un-migrated. A GET would then consume the one chance to fold the legacy `bus`/`agent` scope
1605
+ # in, and the account would silently acquire the other business unit's customers. The
1606
+ # `stored` test alone would very nearly cover it (an un-migrated record carries no perms
1607
+ # block), so the marker is asked explicitly rather than inferred from an empty dict.
1608
+ if stored and perm_scope.is_migrated(rec):
1609
+ _pruned, _gone = _prune_stale_filters(stored, session=session)
1610
+ if _gone:
1611
+ users.set_access(uname, perms=_pruned)
1612
+ # Re-read rather than trust the write: this route's whole job is to report what is
1613
+ # STORED, and a store that took nothing must not be reported as if it had.
1614
+ rec = _registry().get(uname) or rec
1615
+ stored = rec.get("perms") or _pruned
1616
  perms_out = {}
1617
  for k in governed:
1618
  e = stored.get(k)
 
1677
  # item 11's *"EVERY database should be able to be toggleable by admin"*, and it is
1678
  # the SAME resolver `_clean_perms` validates against, so the picker cannot offer a
1679
  # column the validator will reject.
1680
+ "fields_by_module": fields_by_module}
1681
 
1682
 
1683
  @router.put("/admin/users/{username}/perms")
api/routes_auth.py CHANGED
@@ -180,7 +180,41 @@ def login(request: Request, response: Response, body: dict = Body(default=None))
180
  raise err(401, "invalid_credentials", "that username and password do not match")
181
 
182
  _clear_failures(throttle_key)
183
- value, _claims = aios_session.mint(tenant, user["username"], int(user.get("epoch") or 0))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  aios_session.set_cookie(response, request, value)
185
  # Wave 19 (R4): the login stamp. THE RESOLVED username, never the posted identifier β€” the
186
  # person may have typed an email address and `users.verify` resolved it to the registry key;
@@ -214,8 +248,9 @@ def logout(request: Request):
214
 
215
  ⚠ This clears the BROWSER's copy only β€” the signed value stays cryptographically valid until
216
  it expires, which is the honest cost of a stateless session. "Sign me out everywhere" is
217
- `core.users.bump_epoch` (a password change already does it); D2's Postgres session mirror
218
- makes per-device revocation possible and arrives with C-2.
 
219
  """
220
  out = Response(status_code=204)
221
  aios_session.clear_cookie(out, request)
 
180
  raise err(401, "invalid_credentials", "that username and password do not match")
181
 
182
  _clear_failures(throttle_key)
183
+
184
+ # ⭐ WAVE 40, R2 β€” SINGLE SESSION, NEWEST WINS. Signing in here signs out every session this
185
+ # account already holds, so a forgotten sign-in on someone else's computer cannot lock the
186
+ # owner of the account out of it. NO SESSION REGISTRY IS BUILT and none is needed: the cookie
187
+ # is stateless and carries the epoch it was minted under, `deps._user_for` admits only on an
188
+ # exact match, so one bump kills every outstanding cookie for this user at once.
189
+ #
190
+ # β›” MINT FROM THE BUMPED VALUE, NEVER FROM `user["epoch"]` AGAIN. `user` was projected BEFORE
191
+ # the bump; minting from it would issue every cookie in the product at the OLD epoch, which
192
+ # `_user_for` refuses on the very next request β€” "200, then 401 forever".
193
+ #
194
+ # TWO IDENTITIES ARE EXEMPT, both by design:
195
+ # * `qa_runner` β€” the synthetic staging identity has no registry record to bump (its epoch is
196
+ # derived from APP_PASSWORD), and `verify_api.py` asserts its login path reads and writes
197
+ # NOTHING on tenant #0's `users` key, which a bump's read-modify-write would violate.
198
+ # * `emergency_master` β€” `deps._user_for` admits that identity whatever the record's epoch
199
+ # says, so a bump revokes nothing there and only risks a store write on the break-glass
200
+ # path that exists precisely for a store outage.
201
+ #
202
+ # A FAILED BUMP MUST NOT FAIL THE LOGIN. `store.update` raises `StoreWriteRefused` where the
203
+ # deployment may not write this tenant (mapped to 503 in `main.py`) and `RuntimeError` when the
204
+ # store is unavailable, so an unguarded bump would turn every sign-in on such a deployment into
205
+ # a 503. The sign-in stands at the epoch it can prove; only the sign-out-everywhere half is
206
+ # lost, and the log says so rather than leaving it silent.
207
+ session_epoch = int(user.get("epoch") or 0)
208
+ if not (user.get("qa_runner") or user.get("emergency_master")):
209
+ try:
210
+ bumped = users.bump_epoch(user["username"])
211
+ if bumped is not None:
212
+ session_epoch = int(bumped)
213
+ except Exception as exc:
214
+ print(f"[aios-api] login for {user['username']!r}: could not bump the session epoch "
215
+ f"({type(exc).__name__}). This sign-in stands, but the account's previous "
216
+ f"session was NOT signed out.")
217
+ value, _claims = aios_session.mint(tenant, user["username"], session_epoch)
218
  aios_session.set_cookie(response, request, value)
219
  # Wave 19 (R4): the login stamp. THE RESOLVED username, never the posted identifier β€” the
220
  # person may have typed an email address and `users.verify` resolved it to the registry key;
 
248
 
249
  ⚠ This clears the BROWSER's copy only β€” the signed value stays cryptographically valid until
250
  it expires, which is the honest cost of a stateless session. "Sign me out everywhere" is
251
+ `core.users.bump_epoch` (a password change already does it, and since wave 40's R2 so does
252
+ every LOGIN β€” signing in anywhere signs out every other session the account holds); D2's
253
+ Postgres session mirror makes per-device revocation possible and arrives with C-2.
254
  """
255
  out = Response(status_code=204)
256
  aios_session.clear_cookie(out, request)
api/routes_automation.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_shares.py CHANGED
@@ -1,90 +1,111 @@
1
- """routes_shares.py β€” the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
2
-
3
- GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
4
- PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
5
- GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
6
-
7
- `kind` ∈ view | folder | database | field. Roles are `view` | `edit` β€” the same two words the
8
- view rail already speaks, now extended to folders, databases and COLUMNS so there is ONE
9
- vocabulary in the UI (R10: "the same picker views use").
10
-
11
- ⭐⭐ **W38-T16 β€” `field` IS THE FOURTH KIND, AND ITS `oid` IS TOPIC-QUALIFIED: `"<table_key>:<field_key>"`**
12
- (`shares.field_oid`). A bare column key repeats across databases β€” `notes` exists on a dozen β€”
13
- so a grant stored under one would admit the grantee to every `notes` column in the tenant at
14
- once. β›” THREE functions in this file branch on kind and ALL THREE need the new one, which is not
15
- obvious because only two of them fail loudly: `_owns_object` (without it a column's own creator
16
- is 404'd trying to share the thing they just made) and `_object_ref` (without it `route` is None,
17
- `_notify_new_grantees` returns early, and the grantee is **granted and never told** β€” owner item
18
- 18's silent half, reopened one kind over). `_can_see_object` stays deliberately CLOSED for
19
- anything that is not a view.
20
-
21
- β›” **RE-SHARING IS THE OWNER'S, AND THAT IS ENFORCED HERE, NOT IN THE CLIENT.** `PUT` requires
22
- `shares.may_administer` (owner or admin). A collaborator with `edit` may change an object's
23
- CONTENT and may not change who else can reach it β€” otherwise anyone you shared a view with could
24
- widen it to everyone, or grant themselves ownership and lock you out. The client greys the editor
25
- for non-administrators; that is a courtesy, and this check is the wall.
26
-
27
- ⚠ **THE GRANT NEVER WIDENS PAST THE MODULE WALL β€” ON A GOVERNED MODULE.** `*` ("everyone") means
28
- every account that can already open the surface: `require_session` plus the topic's own gate run
29
- first, and for `customer_data` / `product_data` the receiver's own row scope and hidden-field
30
- closure run BEFORE any foreign view is merged. Sharing there can only narrow-or-equal the set that
31
- could already reach the data ([[aios-permissioning]]).
32
-
33
- β›”β›” **AND THAT SENTENCE IS FALSE FOR `kind='database'`, WHICH IS WHY IT NOW SAYS "ON A GOVERNED
34
- MODULE" (W32-T26, audit S-8).** `routes_admin._PERM_MODULES` is `("customer_data","product_data")`
35
- and `_clean_perms` **400s** on anything else, so **no row filter and no hidden field can even be
36
- DECLARED for a `ut_*` database** β€” `routes_tables.py` makes zero `perm_scope` calls and passes
37
- `hidden_keys=frozenset()`. There is no module wall behind a user table for a grant to be bounded
38
- by: **this registry IS the wall.** So a `database` grant is ALL-OR-NOTHING β€” every row, every
39
- column β€” and an `*` database grant admits every account in the tenant to all of it.
40
- That is a real capability, deliberately kept; what was wrong was a docstring promising a second
41
- wall that does not exist for this kind. Scoping user tables is booked, not done
42
- (`waves/wave32/sharing-audit.md` S-8).
43
-
44
- ⚠ **TWO SYSTEMS ANSWER "IS THIS SHARED", AND THEY ARE NOT THE SAME ONE (audit S-4).** THIS
45
- registry decides who appears in *"Shared with me"* and who may re-share. **`table_store.is_shared`
46
- β€” the view's own `permissions` β€” is what actually decides who may OPEN a view.** A grant here
47
- whose object is invisible under that one is a row in a list that opens a refusal, which is what
48
- made item 18 worth auditing. `_entries_or_400` closes the common cause (a name nobody has), but
49
- the two vocabularies are still two.
50
- """
51
- from fastapi import APIRouter, Body, Depends
52
-
53
- import core.shares as shares
54
- import core.users as users
55
- from deps import Session, err, require_session
56
- # ⭐ W32-T28 (C3) β€” the SHARE notification's topic word, imported from the module that CLASSIFIES
57
- # it (`routes_alerts.notification_view`) rather than typed again here. The producer and the
58
- # reader agreeing about one string is the whole difference between an Inbox row that opens the
59
- # shared database and one that is quietly unclickable.
60
- from routes_alerts import SHARE_TOPIC as _SHARE_TOPIC
61
-
62
- router = APIRouter(prefix="/api/v1")
63
-
64
-
65
- def _kind_or_400(raw):
66
- try:
67
- return shares._check_kind(raw)
68
- except ValueError as e:
69
- raise err(400, "bad_kind", str(e))
70
-
71
-
72
- # ── ⭐⭐ WAVE 32 Β· T26 (owner item 18, ruling R12) β€” THE WALL THIS FILE SAID IT HAD ─────────────
73
- #
74
- # `put_share`'s comment used to justify the first-claim rule with *"reaching this route at all
75
- # means passing the surface's own wall"*. **There was no such wall.** `kind` and `oid` are free
76
- # strings off the URL and the only dependency was `require_session`, so any signed-in account
77
- # could `PUT` a grant on an id it had never seen. Because the 403 sat behind `if rec["owner"]`,
78
- # an object with no grant record skipped the check entirely and the caller was stamped OWNER β€”
79
- # sticky, so **the real creator was then refused on their own view, permanently.** Driven, not
80
- # argued: `waves/wave32/sharing-audit.md` S-1 carries the four-step transcript.
81
- #
82
- # ⚠ AND IT WAS SILENT ON BOTH SIDES. The claimant does not even see the object in their own
83
- # "Shared with me" (`shared_with` excludes what you own), so nothing appears anywhere until the
84
- # victim next opens the dialog.
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
 
@@ -136,38 +157,38 @@ def _field_owner(session, definition, already_shared):
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
142
- tenant #0) to answer a question about KEYS (D-185).
143
- """
144
- try:
145
- import core.user_tables as ut
146
- return (*_BUILTIN_TOPICS, *(ut.all_defs(st=session.runtime) or {}))
147
- except Exception: # noqa: BLE001
148
- return _BUILTIN_TOPICS
149
-
150
-
151
- def _owns_object(session, kind, oid):
152
- """May this caller CLAIM an object that has no grant record yet β€” i.e. do they own it?
153
-
154
- β›” THIS GUARDS THE CLAIM, NOT THE READ, AND THAT IS DELIBERATE. Resolving a view means asking
155
- each topic's workspace in turn, which is N store reads; making every share call pay that
156
- would put a loop on a route the manage-access dialog opens. The dangerous path is the one
157
- where a caller is about to be stamped OWNER of something nobody owns β€” so the resolution runs
158
- exactly there, and the common path (a record exists, `may_administer` decides) is untouched.
159
- """
160
- if session.admin:
161
- return True
162
- if kind == "field":
163
- # ⭐⭐ W38-T16 β€” A COLUMN'S OWNER IS ITS `createdBy`, WHICH THE CREATE DOOR ALREADY STAMPS
164
- # (`routes_tables.patch_shared_cell`) and the DELETE door already reads as its wall (R8 /
165
- # D-172: creator-or-admin). Read from the same place by all three, so a column cannot be
166
- # deletable by one person and shareable by another.
167
- # ⚠ THIS BRANCH IS NOT OPTIONAL AND ITS ABSENCE FAILS SILENTLY IN THE WORST DIRECTION:
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
@@ -175,118 +196,118 @@ def _owns_object(session, kind, oid):
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"
181
- # here would be the second definition this wave keeps finding.
182
- try:
183
- import core.user_tables as ut
184
- return bool(ut.may_open(oid, session.uname, is_admin=session.admin,
185
- st=session.runtime))
186
- except Exception: # noqa: BLE001
187
- return False
188
- try:
189
- import core.table_store as table_store
190
- except Exception: # noqa: BLE001
191
- return False
192
- for topic in _topics(session):
193
- try:
194
- ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
195
- hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
196
- except Exception: # noqa: BLE001
197
- continue
198
- if hit:
199
- # `find_view`/`find_folder` answer `(owner_username, …)`. The claim belongs to the
200
- # person whose personal stratum holds it β€” anybody else reaching this line is
201
- # exactly the case S-1 describes.
202
- return str(hit[0]) == str(session.uname)
203
- return False
204
-
205
-
206
- def _can_see_object(session, kind, oid):
207
- """May this caller READ an object's grant list β€” i.e. can they reach the object at all?
208
-
209
- β›”β›” THIS IS DELIBERATELY WIDER THAN {@link _owns_object}, AND CONFLATING THE TWO IS A
210
- REGRESSION I SHIPPED AND CAUGHT. The first version of T26 guarded BOTH doors with the
211
- ownership test, which reads sensibly and is wrong for the read, because **`find_view` searches
212
- PERSONAL STRATA ONLY** (its own docstring says so). So a view living in alice's stratum with
213
- `permissions.edit = "collaborative"` and no grant record yet β€” a view bob **can open and edit
214
- in the grid** β€” answered `404` when bob opened its manage-access dialog. Measured before
215
- fixing: `table_store._may_see(view, "bob") is True` while `GET /share/view/vc` said
216
- `404 no_object`.
217
- ⚠ THAT IS THE AUDIT'S OWN S-4 BITING THE AUDIT'S OWN FIX: two systems answer "is this shared",
218
- and the wall consulted the grant registry (system A) plus stratum ownership, never the view's
219
- `permissions` (system B) β€” which is the one that actually decides who may OPEN it.
220
- ⚠ And it hides the ANSWER, not just the editor. `ViewSidebar`'s Share row is deliberately not
221
- gated on edit rights because *"hiding the row from everyone else would hide the ANSWER too β€”
222
- 'who has this?' is a fair question for anyone the view was shared with"*. A 404 there tells a
223
- legitimate collaborator their view does not exist.
224
-
225
- β›” THE CLAIM KEEPS THE NARROW TEST. Being able to SEE an object must not let you become its
226
- owner β€” that is S-1, and widening this predicate onto `put_share` would re-open it.
227
- """
228
- if _owns_object(session, kind, oid):
229
- return True
230
- if kind != "view":
231
- # A folder carries no per-object visibility flag of its own, and a database's `may_open`
232
- # (inside `_owns_object`) already admits grantees. Nothing wider to ask.
233
- # ⭐ W38-T16 β€” AND `field` KEEPS THIS CLOSED, DELIBERATELY. A grantee never reaches here:
234
- # `get_share` tests `role is None` first and a grant answers a role, so the only caller
235
- # left is an account with no relationship to the column at all. Widening it would let any
236
- # signed-in session enumerate who holds which column on a database they cannot open.
237
- return False
238
- try:
239
- import core.table_store as table_store
240
- for topic in _topics(session):
241
- hit = table_store.make(f"{topic}_table_workspace", st=session.runtime).find_view(oid)
242
- if hit:
243
- return bool(table_store._may_see(hit[1] if len(hit) > 1 else {},
244
- session.uname, is_admin=session.admin))
245
- except Exception: # noqa: BLE001
246
- return False
247
- return False
248
-
249
-
250
- def _entries_or_400(session, entries):
251
- """Validate a grant list against the tenant's REAL, ACTIVE accounts β€” and refuse BY NAME.
252
-
253
- β›” `core.shares._clean_entries` silently drops junk, and its docstring argues that correctly:
254
- a UI mid-save must not lose the whole list to one malformed row. **But it validates the SHAPE
255
- of a string and the role word β€” never that the user EXISTS, is ACTIVE, or is in this tenant**,
256
- so a typo'd name is stored, reported as a successful save, and never reaches anybody. The
257
- sharer believes the person has access. That is item 18's plain reading.
258
- ⚠ The correct population is computed THREE FUNCTIONS BELOW and served to the picker
259
- (`_people`). One route, two populations, and the write door was the permissive one.
260
- ⚠ `*` (everyone) is not a user and is admitted deliberately β€” it is R10's vocabulary for
261
- "every account that can already open the surface".
262
- """
263
- known = {p["username"].strip().lower() for p in _people(session.tenant)}
264
- unknown = []
265
- for e in entries or ():
266
- if not isinstance(e, dict):
267
- continue
268
- user = str(e.get("user") or "").strip().lower()
269
- if user and user != shares.EVERYONE and user not in known:
270
- unknown.append(user)
271
- if unknown:
272
- raise err(400, "unknown_people",
273
- "no active account in this workspace is named "
274
- + ", ".join(sorted(set(unknown)))
275
- + ". Nothing was shared. Pick people from the list rather than typing a name.")
276
-
277
-
278
- @router.get("/share/mine")
279
- def my_shares(session: Session = Depends(require_session)):
280
- """Everything shared WITH me, by kind β€” the "Shared with me" rail section (R10).
281
-
282
- Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
283
- in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
284
- that is not malformed at all.
285
- """
286
- return shares.shared_with(session.uname, st=session.runtime)
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":
@@ -303,50 +324,57 @@ def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
303
  # a Member who owns an unshared View receives the people picker; a collaborator still does not.
304
  if not rec["owner"] and not may_admin and _owns_object(session, kind, oid):
305
  may_admin = True
306
- # ⭐ W32-T26 (audit S-3) β€” A STRANGER LEARNS NOTHING. This route used to answer for ANY id:
307
- # who owns it, everyone it is granted to, and the tenant's whole username↔name directory β€”
308
- # to any signed-in session, about objects it cannot open. Now a caller with no role on an
309
- # object must prove they can reach it, and gets a 404 otherwise: the same answer a
310
- # non-existent id gives, so the route cannot be used to probe which ids are real.
311
- # ⚠ `role is None` is the cheap pre-test, so the N-topic resolution below runs only for a
312
- # caller who has no relationship with the object at all.
313
- if role is None and not _can_see_object(session, kind, oid):
314
- raise err(404, "no_object", "no such item, or it is not shared with this account")
315
- return {
316
- **rec,
317
- "role": role,
318
- "mayAdminister": may_admin,
319
- # ⚠ WAVE 21 (C1 identity fix): grant entries BIND on USERNAMES, so the picker must carry
320
- # them. `assignable_people` serves bare display names because `user`-kind CELLS store
321
- # display names β€” that list's shape cannot change without migrating cell values β€” so
322
- # this route serves objects of its own. Existing grants that were written as lowercased
323
- # display names are normalised by the wave-21 cleanup script.
324
- # ⭐ W32-T26 (audit S-3) β€” the roster is the EDITOR's data, so it rides only for a caller
325
- # who may open the editor. A read-only grantee gets the grant list (their fair question is
326
- # "who else has this?") and not a directory of every account in the workspace.
327
- "people": _people(session.tenant) if may_admin else [],
328
- }
329
-
330
-
331
- def _people(tenant):
332
- """[{username, name}] for this tenant β€” same population as `assignable_people`, with the
333
- BINDING identity alongside the display one."""
334
- try:
335
- reg = users.registry() or {}
336
- except Exception:
337
- return []
338
- want = str(tenant or '').strip().lower()
339
- out = []
340
- for uname, u in reg.items():
341
- if not isinstance(u, dict) or u.get('active') is False:
342
- continue
343
- if want and str(u.get('tenant') or 'royal-imports').strip().lower() != want:
344
- continue
345
- out.append({"username": str(uname), "name": str(u.get('name') or uname)})
346
- return sorted(out, key=lambda p: p["name"].lower())
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)
@@ -355,31 +383,88 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None),
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
361
- # the alternative (refusing until somebody seeds an owner) would make a brand-new folder
362
- # unshareable by the person who just made it.
363
- if rec["owner"]:
364
- if not shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
365
- st=session.runtime):
366
- raise err(403, "not_owner",
367
- "only the owner of this item (or an administrator) can change who it is "
368
- "shared with")
369
- # β›”β›” W32-T26 (audit S-1) β€” THE CLAIM NOW HAS A PRECONDITION. An object with no grant record
370
- # is still claimed by the first person to share it β€” that rule is right, and refusing until
371
- # somebody seeds an owner would make a brand-new folder unshareable by the person who just
372
- # made it. What was missing is the half the old comment ASSERTED and the code never did: the
373
- # claimant has to be able to reach the object. Without this, any signed-in account could
374
- # stamp itself owner of an id it had never seen and lock the real creator out for good.
375
- elif not _owns_object(session, kind, oid):
376
- raise err(404, "no_object", "no such item, or it is not shared with this account")
377
- entries = body.get("entries")
 
 
 
 
 
 
 
 
 
 
378
  if not isinstance(entries, list):
379
  raise err(400, "bad_entries",
380
  "entries must be a list of {user, role}. Send [] to un-share, which is how "
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
@@ -399,91 +484,101 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None),
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 [])
405
- return out
406
-
407
-
408
- def _notify_new_grantees(session, kind, oid, before, after):
409
- """⭐⭐ W32-T28 (owner item 18's last clause, contract C3) β€” tell the RECEIVER, in their Inbox.
410
-
411
- Owner item 18 ends *"being shared a database notifies the receiver"*. Until now sharing was
412
- silent: the grant landed in a rail section the receiver had to notice on their own, which is
413
- why "I shared it with you" and "I never saw it" were both true.
414
-
415
- β›” WRITTEN ON THE SHARE, NEVER POLLED. `/notifications` re-evaluates view-ALERTS on read
416
- because an alert is a live question about rows; a share is an EVENT that happened once, and
417
- polling for it would mean re-deriving "was this new?" on every inbox open β€” the diff below
418
- only exists here, at the moment the set changes.
419
-
420
- ⚠ ONLY THE NEWLY ADDED. `PUT` REPLACES the whole entry set (revoking is expressed by absence),
421
- so every save re-sends everyone who was already there. Diffing against `before` is what stops
422
- a rename or a role change from ringing the bell for people whose access did not change.
423
- ⚠ `*` IS NOT NOTIFIED: there is no user to name, and minting one notification per account in
424
- the tenant on a single click is a broadcast nobody asked for. The rail still shows it.
425
- ⚠ IT NEVER RAISES. A notification that fails must not fail the share that triggered it β€” the
426
- grant is the user's actual intent, and `core.alerts.notify` writes with `flush='async'`.
427
- """
428
- try:
429
- was = {e.get("user") for e in (before or ()) if isinstance(e, dict)}
430
- fresh = [str(e.get("user")) for e in (after or ())
431
- if isinstance(e, dict) and e.get("user") not in was
432
- and e.get("user") != shares.EVERYONE]
433
- if not fresh:
434
- return
435
- import core.alerts as alerts
436
-
437
- label, route, view_id = _object_ref(session, kind, oid)
438
- if not route:
439
- # β›” NO ROUTE, NO NOTIFICATION β€” the receiver would get a row that opens nothing, and
440
- # `notification_view` would have to invent a target. Silence is the honest answer
441
- # here; the rail still shows the grant under "Shared with me".
442
- return
443
- sharer = str(session.user.get("name") or session.uname)
444
- for user in fresh:
445
- # ⚠ THE SHAPE IS `routes_alerts.notification_view`'s SHARE BRANCH, and the two must
446
- # agree or the Inbox row is unclickable: `topic` selects the branch and `key` becomes
447
- # `alertId`, which that branch reads as the id to open. Both constants are IMPORTED
448
- # from there rather than typed again β€” one vocabulary, one owner.
449
- # ⭐⭐ W33-T28 (`ASK C-14`, answered) β€” `actor` IS THE SENDER, AND IT IS THE ONLY WAY
450
- # THE INBOX CAN NAME ONE. An alert and an automation have no person behind them and
451
- # are honestly named by their machine; a SHARE has a real person, and only this call
452
- # site knows who. β›” It is passed as its OWN field rather than recovered from the
453
- # `detail` prose below: a sender parsed out of "<name> shared this with you" breaks
454
- # the first time the sentence is reworded, silently, in the header
455
- # [[grep-output-is-not-source]]. The prose stays as the body; this is the From.
456
- alerts.notify(user, label, topic=_SHARE_TOPIC, key=route, row_id=view_id,
457
- detail=f"{sharer} shared this with you", actor=sharer,
458
- st=session.runtime)
459
- except Exception: # noqa: BLE001
460
- return
461
-
462
-
463
- def _object_ref(session, kind, oid):
464
- """`(label, route, view_id)` β€” what to CALL the shared thing, and where it OPENS.
465
-
466
- β›” THE ROUTE IS RESOLVED HERE, NOT SHAPED IN THE CONSUMER, AND THE FIRST VERSION GOT IT
467
- WRONG: it put the raw `oid` in the notification's key, so a shared VIEW produced
468
- `target: {module: "database", id: "view_42"}` β€” an instruction to open a database named
469
- `view_42`. It read perfectly in the payload and would have opened nothing. **A view is not
470
- addressable on its own; it is a SELECTION inside a topic's grid**, so the pair is what has to
471
- travel. Caught by looking at the notification the driver actually produced, not by reading
472
- the code back.
473
-
474
- ⚠ `label` never falls back to a raw id. A notification headed `ut_leads_3f2a` tells the
475
- receiver nothing they can act on, and the id is already in the target.
476
- ⚠ An unresolvable object answers `route=None`, and the caller then sends NOTHING rather than
477
- a row that opens nowhere.
478
- """
479
- try:
480
- if kind == "field":
481
- # ⭐⭐ W38-T16 β€” A COLUMN IS NOT ADDRESSABLE ON ITS OWN, exactly as a view is not: it
482
- # is a column INSIDE a database, so the target that travels is the DATABASE. Without
483
- # this branch the function falls through to the view/folder loop, finds nothing,
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:
@@ -494,39 +589,39 @@ def _object_ref(session, kind, oid):
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 {}
511
- # A user table IS its own route key in both vocabularies (`route_for_topic`).
512
- return (str(defn.get("label") or "").strip() or "A database", str(oid), "")
513
- import core.table_store as table_store
514
- from routes_alerts import route_for_topic
515
- for topic in _topics(session):
516
- ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
517
- hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
518
- if not hit:
519
- continue
520
- route = route_for_topic(topic)
521
- if not route:
522
- break
523
- row = hit[1] if len(hit) > 1 else {}
524
- name = str((row or {}).get("name") or "").strip()
525
- # ⚠ Only a VIEW carries a selection. A folder is a rail grouping, so the target opens
526
- # the grid and stops there rather than naming a view the receiver did not get.
527
- return (name or ("A view" if kind == "view" else "A folder"),
528
- route, str(oid) if kind == "view" else "")
529
- except Exception: # noqa: BLE001
530
- pass
531
- return ({"view": "A view", "folder": "A folder",
532
- "field": "A column"}.get(kind, "An item"), None, "")
 
1
+ """routes_shares.py β€” the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
2
+
3
+ GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
4
+ PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
5
+ GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
6
+
7
+ `kind` ∈ view | folder | database | field. Roles are `view` | `edit` β€” the same two words the
8
+ view rail already speaks, now extended to folders, databases and COLUMNS so there is ONE
9
+ vocabulary in the UI (R10: "the same picker views use").
10
+
11
+ ⭐⭐ **W38-T16 β€” `field` IS THE FOURTH KIND, AND ITS `oid` IS TOPIC-QUALIFIED: `"<table_key>:<field_key>"`**
12
+ (`shares.field_oid`). A bare column key repeats across databases β€” `notes` exists on a dozen β€”
13
+ so a grant stored under one would admit the grantee to every `notes` column in the tenant at
14
+ once. β›” THREE functions in this file branch on kind and ALL THREE need the new one, which is not
15
+ obvious because only two of them fail loudly: `_owns_object` (without it a column's own creator
16
+ is 404'd trying to share the thing they just made) and `_object_ref` (without it `route` is None,
17
+ `_notify_new_grantees` returns early, and the grantee is **granted and never told** β€” owner item
18
+ 18's silent half, reopened one kind over). `_can_see_object` stays deliberately CLOSED for
19
+ anything that is not a view.
20
+
21
+ ⭐⭐ **A "Can edit" GRANTEE MAY RE-SHARE A VIEW β€” OWNER RULING R4, BUILT AS W40-T02** (instruction
22
+ 4: *"Edit View so a member can share a View as well, not just an admin"*). This REVERSES the flat
23
+ owner-or-admin sentence that stood here, and the reversal is bounded three ways, all enforced HERE
24
+ and not in the client:
25
+
26
+ 1. **ONLY `kind='view'`.** `shares.RESHARE_KINDS` is the one spelling of that. `folder`,
27
+ `database` and `field` still require the owner or an admin, and the paragraph below is why
28
+ the `database` kind in particular was never a candidate: a `ut_*` grant's blast radius is a
29
+ whole table, where a view is one saved SELECTION over rows the receiver's own wall governs.
30
+ 2. **A RE-SHARE MAY NEVER EXCEED THE RE-SHARER'S OWN ROLE**, and the strict reading ships: an
31
+ `edit` grantee may hand out `view` and NEVER `edit`. `shares.max_grantable_role` answers the
32
+ ceiling, `put_share` enforces it on the DELTA (a name arriving at `edit`, or one raised to
33
+ it) β€” never on every row of the body, because the `PUT` REPLACES and `ShareDialog.save`
34
+ therefore re-sends the whole list with the re-sharer's own `edit` row inside it. Conferring
35
+ `edit` stays the owner's alone.
36
+ 3. **OWNERSHIP NEVER MOVES.** A caller re-sharing rather than owning passes the EXISTING owner
37
+ straight through (`put_share` below, stated rather than incidental).
38
+
39
+ β›” **SO OF THE TWO FEARS THE OLD SENTENCE NAMED, ONE IS ANSWERED AND ONE IS PRICED.** *"Grant
40
+ themselves ownership and lock you out"* is closed by (3) and by `set_grants`' sticky owner: there
41
+ is no path here by which a grantee becomes an owner. *"Widen a users-scoped view to everyone"* is
42
+ NOT closed β€” an `edit` grantee may add `*` β€” it is CAPPED by (2) at `Can view`, on a surface where
43
+ `require_session` plus the topic's own gate plus the receiver's row scope all still run first. That
44
+ is the capability R4 asked for, stated as a cost rather than left as a surprise. A `view` grantee
45
+ still cannot share at all, and still gets `403`. The client greys the editor for non-administrators
46
+ and offers "Can edit" to anyone it greys in; that is a courtesy, and these checks are the wall.
47
+
48
+ ⚠ **THE GRANT NEVER WIDENS PAST THE MODULE WALL β€” ON A GOVERNED MODULE.** `*` ("everyone") means
49
+ every account that can already open the surface: `require_session` plus the topic's own gate run
50
+ first, and for `customer_data` / `product_data` the receiver's own row scope and hidden-field
51
+ closure run BEFORE any foreign view is merged. Sharing there can only narrow-or-equal the set that
52
+ could already reach the data ([[aios-permissioning]]).
53
+
54
+ β›”β›” **AND THAT SENTENCE IS FALSE FOR `kind='database'`, WHICH IS WHY IT NOW SAYS "ON A GOVERNED
55
+ MODULE" (W32-T26, audit S-8).** `routes_admin._PERM_MODULES` is `("customer_data","product_data")`
56
+ and `_clean_perms` **400s** on anything else, so **no row filter and no hidden field can even be
57
+ DECLARED for a `ut_*` database** β€” `routes_tables.py` makes zero `perm_scope` calls and passes
58
+ `hidden_keys=frozenset()`. There is no module wall behind a user table for a grant to be bounded
59
+ by: **this registry IS the wall.** So a `database` grant is ALL-OR-NOTHING β€” every row, every
60
+ column β€” and an `*` database grant admits every account in the tenant to all of it.
61
+ That is a real capability, deliberately kept; what was wrong was a docstring promising a second
62
+ wall that does not exist for this kind. Scoping user tables is booked, not done
63
+ (`waves/wave32/sharing-audit.md` S-8).
64
+
65
+ ⚠ **TWO SYSTEMS ANSWER "IS THIS SHARED", AND THEY ARE NOT THE SAME ONE (audit S-4).** THIS
66
+ registry decides who appears in *"Shared with me"* and who may re-share. **`table_store.is_shared`
67
+ β€” the view's own `permissions` β€” is what actually decides who may OPEN a view.** A grant here
68
+ whose object is invisible under that one is a row in a list that opens a refusal, which is what
69
+ made item 18 worth auditing. `_entries_or_400` closes the common cause (a name nobody has), but
70
+ the two vocabularies are still two.
71
+ """
72
+ from fastapi import APIRouter, Body, Depends
73
+
74
+ import core.shares as shares
75
+ import core.users as users
76
+ from deps import Session, err, require_session
77
+ # ⭐ W32-T28 (C3) β€” the SHARE notification's topic word, imported from the module that CLASSIFIES
78
+ # it (`routes_alerts.notification_view`) rather than typed again here. The producer and the
79
+ # reader agreeing about one string is the whole difference between an Inbox row that opens the
80
+ # shared database and one that is quietly unclickable.
81
+ from routes_alerts import SHARE_TOPIC as _SHARE_TOPIC
82
+
83
+ router = APIRouter(prefix="/api/v1")
84
+
85
+
86
+ def _kind_or_400(raw):
87
+ try:
88
+ return shares._check_kind(raw)
89
+ except ValueError as e:
90
+ raise err(400, "bad_kind", str(e))
91
+
92
+
93
+ # ── ⭐⭐ WAVE 32 Β· T26 (owner item 18, ruling R12) β€” THE WALL THIS FILE SAID IT HAD ─────────────
94
+ #
95
+ # `put_share`'s comment used to justify the first-claim rule with *"reaching this route at all
96
+ # means passing the surface's own wall"*. **There was no such wall.** `kind` and `oid` are free
97
+ # strings off the URL and the only dependency was `require_session`, so any signed-in account
98
+ # could `PUT` a grant on an id it had never seen. Because the 403 sat behind `if rec["owner"]`,
99
+ # an object with no grant record skipped the check entirely and the caller was stamped OWNER β€”
100
+ # sticky, so **the real creator was then refused on their own view, permanently.** Driven, not
101
+ # argued: `waves/wave32/sharing-audit.md` S-1 carries the four-step transcript.
102
+ #
103
+ # ⚠ AND IT WAS SILENT ON BOTH SIDES. The claimant does not even see the object in their own
104
+ # "Shared with me" (`shared_with` excludes what you own), so nothing appears anywhere until the
105
+ # victim next opens the dialog.
106
+
107
+ #: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
108
+ #: route is not told which topic β€” so resolving one means asking each.
109
  _BUILTIN_TOPICS = ("customer", "product")
110
 
111
 
 
157
 
158
 
159
  def _topics(session):
160
+ """Every topic whose workspace could hold a view or folder for this tenant.
161
+
162
+ ⚠ `all_defs`, never `all_tables` β€” the latter is the whole 28.6 MB row payload (~703 ms on
163
+ tenant #0) to answer a question about KEYS (D-185).
164
+ """
165
+ try:
166
+ import core.user_tables as ut
167
+ return (*_BUILTIN_TOPICS, *(ut.all_defs(st=session.runtime) or {}))
168
+ except Exception: # noqa: BLE001
169
+ return _BUILTIN_TOPICS
170
+
171
+
172
+ def _owns_object(session, kind, oid):
173
+ """May this caller CLAIM an object that has no grant record yet β€” i.e. do they own it?
174
+
175
+ β›” THIS GUARDS THE CLAIM, NOT THE READ, AND THAT IS DELIBERATE. Resolving a view means asking
176
+ each topic's workspace in turn, which is N store reads; making every share call pay that
177
+ would put a loop on a route the manage-access dialog opens. The dangerous path is the one
178
+ where a caller is about to be stamped OWNER of something nobody owns β€” so the resolution runs
179
+ exactly there, and the common path (a record exists, `may_administer` decides) is untouched.
180
+ """
181
+ if session.admin:
182
+ return True
183
+ if kind == "field":
184
+ # ⭐⭐ W38-T16 β€” A COLUMN'S OWNER IS ITS `createdBy`, WHICH THE CREATE DOOR ALREADY STAMPS
185
+ # (`routes_tables.patch_shared_cell`) and the DELETE door already reads as its wall (R8 /
186
+ # D-172: creator-or-admin). Read from the same place by all three, so a column cannot be
187
+ # deletable by one person and shareable by another.
188
+ # ⚠ THIS BRANCH IS NOT OPTIONAL AND ITS ABSENCE FAILS SILENTLY IN THE WORST DIRECTION:
189
+ # a brand-new column has no grant record, so `put_share` falls to this predicate β€” and
190
+ # without it the column's own creator is answered `404 no_object` on the first attempt to
191
+ # share the thing they just made.
192
  table_key, field_key = shares.split_field_oid(oid)
193
  if not table_key:
194
  return False
 
196
  session, table_key, field_key)
197
  owner = _field_owner(session, defn, _shared)
198
  return bool(defn) and owner.lower() == str(session.uname).strip().lower()
199
+ if kind == "database":
200
+ # ⚠ `may_open` is THE resolver for a user table (its own docstring says so) and already
201
+ # admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
202
+ # here would be the second definition this wave keeps finding.
203
+ try:
204
+ import core.user_tables as ut
205
+ return bool(ut.may_open(oid, session.uname, is_admin=session.admin,
206
+ st=session.runtime))
207
+ except Exception: # noqa: BLE001
208
+ return False
209
+ try:
210
+ import core.table_store as table_store
211
+ except Exception: # noqa: BLE001
212
+ return False
213
+ for topic in _topics(session):
214
+ try:
215
+ ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
216
+ hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
217
+ except Exception: # noqa: BLE001
218
+ continue
219
+ if hit:
220
+ # `find_view`/`find_folder` answer `(owner_username, …)`. The claim belongs to the
221
+ # person whose personal stratum holds it β€” anybody else reaching this line is
222
+ # exactly the case S-1 describes.
223
+ return str(hit[0]) == str(session.uname)
224
+ return False
225
+
226
+
227
+ def _can_see_object(session, kind, oid):
228
+ """May this caller READ an object's grant list β€” i.e. can they reach the object at all?
229
+
230
+ β›”β›” THIS IS DELIBERATELY WIDER THAN {@link _owns_object}, AND CONFLATING THE TWO IS A
231
+ REGRESSION I SHIPPED AND CAUGHT. The first version of T26 guarded BOTH doors with the
232
+ ownership test, which reads sensibly and is wrong for the read, because **`find_view` searches
233
+ PERSONAL STRATA ONLY** (its own docstring says so). So a view living in alice's stratum with
234
+ `permissions.edit = "collaborative"` and no grant record yet β€” a view bob **can open and edit
235
+ in the grid** β€” answered `404` when bob opened its manage-access dialog. Measured before
236
+ fixing: `table_store._may_see(view, "bob") is True` while `GET /share/view/vc` said
237
+ `404 no_object`.
238
+ ⚠ THAT IS THE AUDIT'S OWN S-4 BITING THE AUDIT'S OWN FIX: two systems answer "is this shared",
239
+ and the wall consulted the grant registry (system A) plus stratum ownership, never the view's
240
+ `permissions` (system B) β€” which is the one that actually decides who may OPEN it.
241
+ ⚠ And it hides the ANSWER, not just the editor. `ViewSidebar`'s Share row is deliberately not
242
+ gated on edit rights because *"hiding the row from everyone else would hide the ANSWER too β€”
243
+ 'who has this?' is a fair question for anyone the view was shared with"*. A 404 there tells a
244
+ legitimate collaborator their view does not exist.
245
+
246
+ β›” THE CLAIM KEEPS THE NARROW TEST. Being able to SEE an object must not let you become its
247
+ owner β€” that is S-1, and widening this predicate onto `put_share` would re-open it.
248
+ """
249
+ if _owns_object(session, kind, oid):
250
+ return True
251
+ if kind != "view":
252
+ # A folder carries no per-object visibility flag of its own, and a database's `may_open`
253
+ # (inside `_owns_object`) already admits grantees. Nothing wider to ask.
254
+ # ⭐ W38-T16 β€” AND `field` KEEPS THIS CLOSED, DELIBERATELY. A grantee never reaches here:
255
+ # `get_share` tests `role is None` first and a grant answers a role, so the only caller
256
+ # left is an account with no relationship to the column at all. Widening it would let any
257
+ # signed-in session enumerate who holds which column on a database they cannot open.
258
+ return False
259
+ try:
260
+ import core.table_store as table_store
261
+ for topic in _topics(session):
262
+ hit = table_store.make(f"{topic}_table_workspace", st=session.runtime).find_view(oid)
263
+ if hit:
264
+ return bool(table_store._may_see(hit[1] if len(hit) > 1 else {},
265
+ session.uname, is_admin=session.admin))
266
+ except Exception: # noqa: BLE001
267
+ return False
268
+ return False
269
+
270
+
271
+ def _entries_or_400(session, entries):
272
+ """Validate a grant list against the tenant's REAL, ACTIVE accounts β€” and refuse BY NAME.
273
+
274
+ β›” `core.shares._clean_entries` silently drops junk, and its docstring argues that correctly:
275
+ a UI mid-save must not lose the whole list to one malformed row. **But it validates the SHAPE
276
+ of a string and the role word β€” never that the user EXISTS, is ACTIVE, or is in this tenant**,
277
+ so a typo'd name is stored, reported as a successful save, and never reaches anybody. The
278
+ sharer believes the person has access. That is item 18's plain reading.
279
+ ⚠ The correct population is computed THREE FUNCTIONS BELOW and served to the picker
280
+ (`_people`). One route, two populations, and the write door was the permissive one.
281
+ ⚠ `*` (everyone) is not a user and is admitted deliberately β€” it is R10's vocabulary for
282
+ "every account that can already open the surface".
283
+ """
284
+ known = {p["username"].strip().lower() for p in _people(session.tenant)}
285
+ unknown = []
286
+ for e in entries or ():
287
+ if not isinstance(e, dict):
288
+ continue
289
+ user = str(e.get("user") or "").strip().lower()
290
+ if user and user != shares.EVERYONE and user not in known:
291
+ unknown.append(user)
292
+ if unknown:
293
+ raise err(400, "unknown_people",
294
+ "no active account in this workspace is named "
295
+ + ", ".join(sorted(set(unknown)))
296
+ + ". Nothing was shared. Pick people from the list rather than typing a name.")
297
+
298
+
299
+ @router.get("/share/mine")
300
+ def my_shares(session: Session = Depends(require_session)):
301
+ """Everything shared WITH me, by kind β€” the "Shared with me" rail section (R10).
302
+
303
+ Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
304
+ in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
305
+ that is not malformed at all.
306
+ """
307
+ return shares.shared_with(session.uname, st=session.runtime)
308
+
309
+
310
+ @router.get("/share/{kind}/{oid}")
311
  def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
312
  kind = _kind_or_400(kind)
313
  if kind == "field":
 
324
  # a Member who owns an unshared View receives the people picker; a collaborator still does not.
325
  if not rec["owner"] and not may_admin and _owns_object(session, kind, oid):
326
  may_admin = True
327
+ # ⭐ W32-T26 (audit S-3) β€” A STRANGER LEARNS NOTHING. This route used to answer for ANY id:
328
+ # who owns it, everyone it is granted to, and the tenant's whole username↔name directory β€”
329
+ # to any signed-in session, about objects it cannot open. Now a caller with no role on an
330
+ # object must prove they can reach it, and gets a 404 otherwise: the same answer a
331
+ # non-existent id gives, so the route cannot be used to probe which ids are real.
332
+ # ⚠ `role is None` is the cheap pre-test, so the N-topic resolution below runs only for a
333
+ # caller who has no relationship with the object at all.
334
+ if role is None and not _can_see_object(session, kind, oid):
335
+ raise err(404, "no_object", "no such item, or it is not shared with this account")
336
+ return {
337
+ **rec,
338
+ "role": role,
339
+ "mayAdminister": may_admin,
340
+ # ⚠ WAVE 21 (C1 identity fix): grant entries BIND on USERNAMES, so the picker must carry
341
+ # them. `assignable_people` serves bare display names because `user`-kind CELLS store
342
+ # display names β€” that list's shape cannot change without migrating cell values β€” so
343
+ # this route serves objects of its own. Existing grants that were written as lowercased
344
+ # display names are normalised by the wave-21 cleanup script.
345
+ # ⭐ W32-T26 (audit S-3) β€” the roster is the EDITOR's data, so it rides only for a caller
346
+ # who may open the editor. A read-only grantee gets the grant list (their fair question is
347
+ # "who else has this?") and not a directory of every account in the workspace.
348
+ # ⭐⭐ W40-T02 / R4 β€” AND THAT RULE IS WHY THIS LINE NEEDED NO EDIT. `may_administer` now
349
+ # answers True for an `edit` grantee on a VIEW, which MOVES that account into "may open
350
+ # the editor" β€” so the picker they need arrives by the roster riding on the same flag it
351
+ # always did. Gating it on anything else (owner, `role == 'owner'`, a fresh predicate)
352
+ # would be a second answer to a question this file already answers once, and would leave
353
+ # the new grantee with an editor and no people to put in it. A `view` grantee is still
354
+ # `may_admin=False` here and still gets `[]`.
355
+ "people": _people(session.tenant) if may_admin else [],
356
+ }
357
+
358
+
359
+ def _people(tenant):
360
+ """[{username, name}] for this tenant β€” same population as `assignable_people`, with the
361
+ BINDING identity alongside the display one."""
362
+ try:
363
+ reg = users.registry() or {}
364
+ except Exception:
365
+ return []
366
+ want = str(tenant or '').strip().lower()
367
+ out = []
368
+ for uname, u in reg.items():
369
+ if not isinstance(u, dict) or u.get('active') is False:
370
+ continue
371
+ if want and str(u.get('tenant') or 'royal-imports').strip().lower() != want:
372
+ continue
373
+ out.append({"username": str(uname), "name": str(u.get('name') or uname)})
374
+ return sorted(out, key=lambda p: p["name"].lower())
375
+
376
+
377
+ @router.put("/share/{kind}/{oid}")
378
  def put_share(kind: str, oid: str, body: dict = Body(default=None),
379
  session: Session = Depends(require_session)):
380
  kind = _kind_or_400(kind)
 
383
  if table_key and field_key:
384
  oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
385
  body = body or {}
386
+ rec = shares.grants(kind, oid, st=session.runtime)
387
+ # `claiming` is the "no owner yet, and this caller may become one" branch, hoisted to a name
388
+ # because TWO decisions below need it: the ceiling (a claimant is about to be the owner, so
389
+ # their ceiling is an owner's) and the owner written back (D3 β€” see `set_grants` at the end).
390
+ claiming = False
391
+ # An object with NO grant record yet has no owner β€” the first person to share it claims it.
392
+ # That is safe because reaching this route at all means passing the surface's own wall, and
393
+ # the alternative (refusing until somebody seeds an owner) would make a brand-new folder
394
+ # unshareable by the person who just made it.
395
+ if rec["owner"]:
396
+ if not shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
397
+ st=session.runtime):
398
+ # ⭐ R4 / W40-T02 β€” `may_administer` now also admits an `edit` grantee on a VIEW, so
399
+ # the population refused here is narrower than the code word `not_owner` suggests: a
400
+ # `view` grantee, or an account with an `edit` role on a kind outside
401
+ # `shares.RESHARE_KINDS`. The code string is kept because clients match on it.
402
+ raise err(403, "not_owner",
403
+ "only the owner of this item (or an administrator) can change who it is "
404
+ "shared with")
405
+ # β›”β›” W32-T26 (audit S-1) β€” THE CLAIM NOW HAS A PRECONDITION. An object with no grant record
406
+ # is still claimed by the first person to share it β€” that rule is right, and refusing until
407
+ # somebody seeds an owner would make a brand-new folder unshareable by the person who just
408
+ # made it. What was missing is the half the old comment ASSERTED and the code never did: the
409
+ # claimant has to be able to reach the object. Without this, any signed-in account could
410
+ # stamp itself owner of an id it had never seen and lock the real creator out for good.
411
+ elif not _owns_object(session, kind, oid):
412
+ raise err(404, "no_object", "no such item, or it is not shared with this account")
413
+ else:
414
+ claiming = True
415
+ entries = body.get("entries")
416
  if not isinstance(entries, list):
417
  raise err(400, "bad_entries",
418
  "entries must be a list of {user, role}. Send [] to un-share, which is how "
419
  "revoking is expressed")
420
  _entries_or_400(session, entries)
421
+ # ⭐⭐ R4 / W40-T02 β€” THE CEILING. `may_administer` above now opens this door to an `edit`
422
+ # grantee on a VIEW, so R4's other half ("a re-share may never exceed the role the re-sharer
423
+ # holds") needs a check of its own: that caller may hand out `view`, and conferring `edit`
424
+ # stays the owner's or an administrator's.
425
+ #
426
+ # β›” AFTER THE ADMISSION, NEVER BEFORE, AND THAT ORDER IS A SECURITY PROPERTY. A caller with
427
+ # no role at all must keep receiving `404 no_object` (audit S-1/S-3: a stranger learns
428
+ # nothing, so this route cannot be used to probe which ids are real). A ceiling raised first
429
+ # would answer that caller `403` and turn the one route hardened against id-probing back into
430
+ # an oracle that confirms an id exists. It also runs before the `field` promotion below, so a
431
+ # refusal cannot leave a column promoted with no grant written.
432
+ #
433
+ # β›” AND IT IS THE DELTA, NOT EVERY ROW OF THE BODY β€” read off the shipped client, not
434
+ # assumed. `ShareDialog.save` PUTs the WHOLE list every time ("a body assembled from a delta
435
+ # would revoke everyone it failed to mention"), so the re-sharer's OWN `{user, role: "edit"}`
436
+ # row rides in every payload they are able to produce. Refusing per-entry would `403` the
437
+ # exact re-share this ticket exists to enable, and the only body that would pass is one that
438
+ # revokes the re-sharer. So what is refused is edit access this caller is CREATING: a name
439
+ # arriving at `edit`, or an existing `view` grantee raised to it. A row that already stood at
440
+ # `edit` was the OWNER's decision, and is not this caller's to be refused for.
441
+ #
442
+ # ⚠ A CLAIMANT IS AN OWNER. The branch above admits a Member who owns an object that has no
443
+ # grant record yet, and `set_grants` is about to stamp them owner β€” asking the registry for
444
+ # their role here would answer `None` (no record exists to hold one) and refuse the first
445
+ # `edit` grant on every newly created view. Same predicate as the door, one line apart.
446
+ ceiling = "edit" if claiming else shares.max_grantable_role(
447
+ kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
448
+ if ceiling != "edit":
449
+ held = {e.get("user"): e.get("role") for e in (rec["entries"] or ())}
450
+ raised = set()
451
+ for e in entries:
452
+ if not isinstance(e, dict):
453
+ continue
454
+ who = str(e.get("user") or "").strip().lower()
455
+ if who and str(e.get("role") or "").strip().lower() == "edit" \
456
+ and held.get(who) != "edit":
457
+ raised.add(who)
458
+ if raised:
459
+ noun = {"view": "view", "folder": "folder",
460
+ "database": "database", "field": "column"}.get(kind, "item")
461
+ named = ", ".join("everyone in this workspace" if w == shares.EVERYONE else w
462
+ for w in sorted(raised))
463
+ raise err(403, "grant_exceeds_role",
464
+ "you can share this " + noun + " at Can view, which is as far as your own "
465
+ "access reaches. Only its owner (or an administrator) can give somebody "
466
+ "Can edit, so nothing was saved. Set " + named
467
+ + " to Can view and save again.")
468
  if kind == "field":
469
  # A field grant is a visibility and edit wall. Promote a private custom
470
  # field exactly once, then keep the requested Share field role as the
 
484
  stamped["granted"] = True
485
  shared_overlay.put_field(shared_key, field_key, stamped, st=session.runtime)
486
  oid = shares.field_oid(grant_topic, field_key)
487
+ # ⭐⭐ R4 / W40-T02 (D3) β€” OWNERSHIP NEVER MOVES ON A RE-SHARE, AND IT IS SAID HERE RATHER
488
+ # THAN LEFT TO FALL OUT. `set_grants`' owner is sticky, so the old `rec["owner"] or
489
+ # session.uname` already happened not to transfer ownership β€” incidentally, as a property of
490
+ # the callee. R4 names ownership transfer as one of the two halves of the old protection that
491
+ # SURVIVES the widening, and a rule that survives by accident is one the next edit deletes
492
+ # without noticing. So the branch is explicit: a claimant becomes the owner, and everybody
493
+ # else β€” an owner re-saving, an admin, and now an `edit` grantee re-sharing β€” passes the
494
+ # EXISTING owner straight back through. `claiming` is the same flag the admission set, so
495
+ # there is no second answer to "is this person taking ownership".
496
+ out = shares.set_grants(kind, oid, entries,
497
+ owner=session.uname if claiming else rec["owner"],
498
  st=session.runtime)
499
+ _notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
500
+ return out
501
+
502
+
503
+ def _notify_new_grantees(session, kind, oid, before, after):
504
+ """⭐⭐ W32-T28 (owner item 18's last clause, contract C3) β€” tell the RECEIVER, in their Inbox.
505
+
506
+ Owner item 18 ends *"being shared a database notifies the receiver"*. Until now sharing was
507
+ silent: the grant landed in a rail section the receiver had to notice on their own, which is
508
+ why "I shared it with you" and "I never saw it" were both true.
509
+
510
+ β›” WRITTEN ON THE SHARE, NEVER POLLED. `/notifications` re-evaluates view-ALERTS on read
511
+ because an alert is a live question about rows; a share is an EVENT that happened once, and
512
+ polling for it would mean re-deriving "was this new?" on every inbox open β€” the diff below
513
+ only exists here, at the moment the set changes.
514
+
515
+ ⚠ ONLY THE NEWLY ADDED. `PUT` REPLACES the whole entry set (revoking is expressed by absence),
516
+ so every save re-sends everyone who was already there. Diffing against `before` is what stops
517
+ a rename or a role change from ringing the bell for people whose access did not change.
518
+ ⚠ `*` IS NOT NOTIFIED: there is no user to name, and minting one notification per account in
519
+ the tenant on a single click is a broadcast nobody asked for. The rail still shows it.
520
+ ⚠ IT NEVER RAISES. A notification that fails must not fail the share that triggered it β€” the
521
+ grant is the user's actual intent, and `core.alerts.notify` writes with `flush='async'`.
522
+ """
523
+ try:
524
+ was = {e.get("user") for e in (before or ()) if isinstance(e, dict)}
525
+ fresh = [str(e.get("user")) for e in (after or ())
526
+ if isinstance(e, dict) and e.get("user") not in was
527
+ and e.get("user") != shares.EVERYONE]
528
+ if not fresh:
529
+ return
530
+ import core.alerts as alerts
531
+
532
+ label, route, view_id = _object_ref(session, kind, oid)
533
+ if not route:
534
+ # β›” NO ROUTE, NO NOTIFICATION β€” the receiver would get a row that opens nothing, and
535
+ # `notification_view` would have to invent a target. Silence is the honest answer
536
+ # here; the rail still shows the grant under "Shared with me".
537
+ return
538
+ sharer = str(session.user.get("name") or session.uname)
539
+ for user in fresh:
540
+ # ⚠ THE SHAPE IS `routes_alerts.notification_view`'s SHARE BRANCH, and the two must
541
+ # agree or the Inbox row is unclickable: `topic` selects the branch and `key` becomes
542
+ # `alertId`, which that branch reads as the id to open. Both constants are IMPORTED
543
+ # from there rather than typed again β€” one vocabulary, one owner.
544
+ # ⭐⭐ W33-T28 (`ASK C-14`, answered) β€” `actor` IS THE SENDER, AND IT IS THE ONLY WAY
545
+ # THE INBOX CAN NAME ONE. An alert and an automation have no person behind them and
546
+ # are honestly named by their machine; a SHARE has a real person, and only this call
547
+ # site knows who. β›” It is passed as its OWN field rather than recovered from the
548
+ # `detail` prose below: a sender parsed out of "<name> shared this with you" breaks
549
+ # the first time the sentence is reworded, silently, in the header
550
+ # [[grep-output-is-not-source]]. The prose stays as the body; this is the From.
551
+ alerts.notify(user, label, topic=_SHARE_TOPIC, key=route, row_id=view_id,
552
+ detail=f"{sharer} shared this with you", actor=sharer,
553
+ st=session.runtime)
554
+ except Exception: # noqa: BLE001
555
+ return
556
+
557
+
558
+ def _object_ref(session, kind, oid):
559
+ """`(label, route, view_id)` β€” what to CALL the shared thing, and where it OPENS.
560
+
561
+ β›” THE ROUTE IS RESOLVED HERE, NOT SHAPED IN THE CONSUMER, AND THE FIRST VERSION GOT IT
562
+ WRONG: it put the raw `oid` in the notification's key, so a shared VIEW produced
563
+ `target: {module: "database", id: "view_42"}` β€” an instruction to open a database named
564
+ `view_42`. It read perfectly in the payload and would have opened nothing. **A view is not
565
+ addressable on its own; it is a SELECTION inside a topic's grid**, so the pair is what has to
566
+ travel. Caught by looking at the notification the driver actually produced, not by reading
567
+ the code back.
568
+
569
+ ⚠ `label` never falls back to a raw id. A notification headed `ut_leads_3f2a` tells the
570
+ receiver nothing they can act on, and the id is already in the target.
571
+ ⚠ An unresolvable object answers `route=None`, and the caller then sends NOTHING rather than
572
+ a row that opens nowhere.
573
+ """
574
+ try:
575
+ if kind == "field":
576
+ # ⭐⭐ W38-T16 β€” A COLUMN IS NOT ADDRESSABLE ON ITS OWN, exactly as a view is not: it
577
+ # is a column INSIDE a database, so the target that travels is the DATABASE. Without
578
+ # this branch the function falls through to the view/folder loop, finds nothing,
579
+ # answers `route=None` β€” and `_notify_new_grantees` returns EARLY. The grant lands and
580
+ # the receiver is never told, which is the silent half of owner item 18 reopened one
581
+ # kind over.
582
  from routes_alerts import route_for_topic
583
  table_key, field_key = shares.split_field_oid(oid)
584
  if not table_key:
 
589
  except Exception: # noqa: BLE001
590
  defn = None
591
  label = str((defn or {}).get("label") or "").strip() or field_key
592
+ # ⚠ TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
593
+ # by whatever the calling door already held: a `ut_*` database uses its bare key,
594
+ # while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
595
+ # `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
596
+ # `customer_data`), so the suffix comes off before it is asked β€” rather than a second
597
+ # route table being written here, which is how the two come apart.
598
  _WS = "_table_workspace"
599
  scope = {"customer_data": "customer", "product_data": "product"}.get(table_key)
600
  if scope is None:
601
  scope = table_key[:-len(_WS)] if table_key.endswith(_WS) else table_key
602
  return (label, route_for_topic(scope) or None, "")
603
+ if kind == "database":
604
+ import core.user_tables as ut
605
+ defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
606
+ # A user table IS its own route key in both vocabularies (`route_for_topic`).
607
+ return (str(defn.get("label") or "").strip() or "A database", str(oid), "")
608
+ import core.table_store as table_store
609
+ from routes_alerts import route_for_topic
610
+ for topic in _topics(session):
611
+ ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
612
+ hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
613
+ if not hit:
614
+ continue
615
+ route = route_for_topic(topic)
616
+ if not route:
617
+ break
618
+ row = hit[1] if len(hit) > 1 else {}
619
+ name = str((row or {}).get("name") or "").strip()
620
+ # ⚠ Only a VIEW carries a selection. A folder is a rail grouping, so the target opens
621
+ # the grid and stops there rather than naming a view the receiver did not get.
622
+ return (name or ("A view" if kind == "view" else "A folder"),
623
+ route, str(oid) if kind == "view" else "")
624
+ except Exception: # noqa: BLE001
625
+ pass
626
+ return ({"view": "A view", "folder": "A folder",
627
+ "field": "A column"}.get(kind, "An item"), None, "")
platform/aios_grid_fields.json CHANGED
@@ -1,396 +1,413 @@
1
- {
2
- "_comment": "CANONICAL field contract for the AIOS Airtable-style grid β€” the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description β€” every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β€” no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` β€” not `is empty` β€” finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED β€” LTM's replacement is a creatable Sales measure column (the demo column IS Sales Β· the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED β€” every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
3
- "fields": [
4
- {
5
- "key": "customer",
6
- "label": "Customer",
7
- "type": "text",
8
- "source": "odoo",
9
- "pinned": true,
10
- "default": true,
11
- "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
12
- },
13
- {
14
- "key": "partner_id",
15
- "label": "Odoo ID",
16
- "type": "int",
17
- "source": "odoo",
18
- "derived": true,
19
- "default": false,
20
- "description": "The Odoo res.partner id β€” the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
21
- },
22
- {
23
- "key": "odoo_status",
24
- "label": "Odoo record",
25
- "type": "status",
26
- "source": "odoo",
27
- "default": false,
28
- "options": [
29
- "Active",
30
- "Archived"
31
- ],
32
- "description": "Whether this customer still exists in Odoo. Archived means deleted there."
33
- },
34
- {
35
- "key": "agent",
36
- "label": "Agent",
37
- "type": "text",
38
- "source": "odoo",
39
- "default": true,
40
- "description": "The sales agent who owns this account."
41
- },
42
- {
43
- "key": "dba",
44
- "label": "DBA",
45
- "type": "select",
46
- "source": "odoo",
47
- "default": false,
48
- "options": [
49
- "Fisch",
50
- "Royal",
51
- "Both"
52
- ],
53
- "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
54
- },
55
- {
56
- "key": "salesperson",
57
- "label": "Salesperson",
58
- "type": "text",
59
- "source": "odoo",
60
- "default": false,
61
- "description": "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
62
- },
63
- {
64
- "key": "street",
65
- "label": "Street",
66
- "type": "text",
67
- "source": "odoo",
68
- "default": false,
69
- "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
70
- },
71
- {
72
- "key": "street2",
73
- "label": "Street 2",
74
- "type": "text",
75
- "source": "odoo",
76
- "default": false,
77
- "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
78
- },
79
- {
80
- "key": "city",
81
- "label": "City",
82
- "type": "text",
83
- "source": "odoo",
84
- "default": true,
85
- "description": "City on the customer's Odoo address."
86
- },
87
- {
88
- "key": "state",
89
- "label": "State",
90
- "type": "text",
91
- "source": "odoo",
92
- "default": true,
93
- "description": "State or province on the customer's Odoo address."
94
- },
95
- {
96
- "key": "country",
97
- "label": "Country",
98
- "type": "text",
99
- "source": "odoo",
100
- "default": false,
101
- "description": "Country on the customer's Odoo address."
102
- },
103
- {
104
- "key": "zip",
105
- "label": "ZIP",
106
- "type": "text",
107
- "source": "odoo",
108
- "default": false,
109
- "description": "Postal code on the customer's Odoo address."
110
- },
111
- {
112
- "key": "customer_since",
113
- "label": "Customer since",
114
- "type": "date",
115
- "source": "odoo",
116
- "default": false,
117
- "description": "When this customer was first set up in Odoo."
118
- },
119
- {
120
- "key": "tags",
121
- "label": "Tags",
122
- "type": "text",
123
- "source": "odoo",
124
- "default": false,
125
- "description": "Odoo labels on this customer, comma-separated."
126
- },
127
- {
128
- "key": "pricelist",
129
- "label": "Price list",
130
- "type": "text",
131
- "source": "odoo",
132
- "default": false,
133
- "description": "The price list this customer buys on."
134
- },
135
- {
136
- "key": "payment_terms",
137
- "label": "Payment terms",
138
- "type": "text",
139
- "source": "odoo",
140
- "default": false,
141
- "description": "Payment terms on this customer's account β€” Net 30, for example."
142
- },
143
- {
144
- "key": "last_order",
145
- "label": "Last order",
146
- "type": "date",
147
- "source": "odoo",
148
- "default": true,
149
- "description": "Date of the most recent confirmed order."
150
- },
151
- {
152
- "key": "overdue_days",
153
- "label": "Overdue days",
154
- "type": "int",
155
- "source": "odoo",
156
- "default": true,
157
- "description": "How many days late this customer is running against their own usual ordering rhythm."
158
- },
159
- {
160
- "_note": "filterable:false β€” DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β€” see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
161
- "key": "est_missed",
162
- "label": "Est. missed $",
163
- "type": "currency",
164
- "source": "odoo",
165
- "default": true,
166
- "agg": "sum",
167
- "filterable": false,
168
- "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
169
- },
170
- {
171
- "_note": "wave 21 R1 β€” KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β€” 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
172
- "key": "ar_open",
173
- "label": "AR current $",
174
- "type": "currency",
175
- "source": "odoo",
176
- "default": false,
177
- "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
178
- },
179
- {
180
- "key": "ar_overdue",
181
- "label": "AR overdue $",
182
- "type": "currency",
183
- "source": "odoo",
184
- "default": false,
185
- "description": "Invoiced money past due β€” same basis as the Collections page."
186
- },
187
- {
188
- "_note": "wave 21 R1 β€” the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β€” no second oracle.",
189
- "key": "ar_outstanding",
190
- "label": "AR outstanding $",
191
- "type": "currency",
192
- "source": "odoo",
193
- "default": false,
194
- "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
195
- },
196
- {
197
- "key": "ar_exposure",
198
- "label": "Credit exposure $",
199
- "type": "currency",
200
- "source": "odoo",
201
- "default": false,
202
- "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
203
- },
204
- {
205
- "key": "ar_aged_1_30",
206
- "label": "1-30 days $",
207
- "type": "currency",
208
- "source": "odoo",
209
- "default": false,
210
- "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
211
- },
212
- {
213
- "key": "ar_aged_31_60",
214
- "label": "31-60 days $",
215
- "type": "currency",
216
- "source": "odoo",
217
- "default": false,
218
- "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
219
- },
220
- {
221
- "key": "ar_aged_61_90",
222
- "label": "61-90 days $",
223
- "type": "currency",
224
- "source": "odoo",
225
- "default": false,
226
- "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
227
- },
228
- {
229
- "key": "ar_aged_90_plus",
230
- "label": "90+ days $",
231
- "type": "currency",
232
- "source": "odoo",
233
- "default": false,
234
- "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
235
- },
236
- {
237
- "key": "days_to_pay",
238
- "label": "Days to pay",
239
- "type": "int",
240
- "source": "odoo",
241
- "default": false,
242
- "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
243
- },
244
- {
245
- "key": "top_category",
246
- "label": "Top category",
247
- "type": "text",
248
- "source": "odoo",
249
- "default": false,
250
- "description": "The category this customer spent the most on in the last 12 months."
251
- },
252
- {
253
- "key": "top_category_pct",
254
- "label": "Top category %",
255
- "type": "pct",
256
- "source": "odoo",
257
- "default": false,
258
- "description": "Share of last-12-months spend that went to the top category."
259
- },
260
- {
261
- "key": "sku_count",
262
- "label": "SKUs bought",
263
- "type": "int",
264
- "source": "odoo",
265
- "default": false,
266
- "description": "Distinct products bought in the last 12 months."
267
- },
268
- {
269
- "key": "top_sku",
270
- "label": "Top SKU",
271
- "type": "text",
272
- "source": "odoo",
273
- "default": false,
274
- "description": "The product this customer spent the most on in the last 12 months."
275
- },
276
- {
277
- "key": "days_since",
278
- "label": "Days since order",
279
- "type": "int",
280
- "source": "odoo",
281
- "default": false,
282
- "description": "Days since the last confirmed order."
283
- },
284
- {
285
- "key": "typical_gap_days",
286
- "label": "Typical gap days",
287
- "type": "int",
288
- "source": "odoo",
289
- "default": false,
290
- "description": "Days this customer usually goes between orders, from their own history."
291
- },
292
- {
293
- "key": "notes",
294
- "label": "Notes",
295
- "type": "text",
296
- "source": "overlay",
297
- "default": false,
298
- "description": "Your notes on this customer. Saved in this app only, visible only to you."
299
- }
300
- ],
301
- "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
302
- "_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
303
- "product_data": {
304
- "identity": "pid",
305
- "business_key": "code",
306
- "fields": [
307
- {
308
- "key": "code",
309
- "label": "SKU",
310
- "type": "text",
311
- "source": "odoo",
312
- "pinned": true,
313
- "default": true,
314
- "description": "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
315
- },
316
- {
317
- "key": "product",
318
- "label": "Product",
319
- "type": "text",
320
- "source": "odoo",
321
- "default": true,
322
- "description": "Product name as it appears in Odoo."
323
- },
324
- {
325
- "key": "category",
326
- "label": "Category",
327
- "type": "select",
328
- "source": "odoo",
329
- "default": true,
330
- "description": "Product category; '(uncategorized)' when Odoo carries none."
331
- },
332
- {
333
- "key": "supplier",
334
- "label": "Supplier",
335
- "type": "text",
336
- "source": "overlay",
337
- "default": true,
338
- "description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
339
- "shared": true
340
- },
341
- {
342
- "key": "origin_country",
343
- "label": "Country",
344
- "type": "text",
345
- "source": "overlay",
346
- "default": false,
347
- "description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
348
- "shared": true
349
- },
350
- {
351
- "key": "lead_days",
352
- "label": "Lead time (days)",
353
- "type": "int",
354
- "source": "overlay",
355
- "default": true,
356
- "description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
357
- "shared": true
358
- },
359
- {
360
- "key": "first_cost",
361
- "label": "First cost",
362
- "type": "currency",
363
- "source": "overlay",
364
- "default": false,
365
- "description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
366
- "shared": true
367
- },
368
- {
369
- "key": "price_fisch",
370
- "label": "Fisch price",
371
- "type": "currency",
372
- "source": "odoo",
373
- "description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
374
- },
375
- {
376
- "key": "price_royal_1",
377
- "label": "Royal 1 price",
378
- "type": "currency",
379
- "source": "odoo",
380
- "description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
381
- },
382
- {
383
- "key": "price_royal_2",
384
- "label": "Royal 2 price",
385
- "type": "currency",
386
- "source": "odoo",
387
- "description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
388
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  {
390
  "key": "price_1",
391
  "label": "Price 1",
392
  "type": "currency",
393
  "source": "odoo",
 
394
  "description": "Cheapest live price for this SKU, across active pricelists."
395
  },
396
  {
@@ -398,6 +415,7 @@
398
  "label": "Unit 1",
399
  "type": "text",
400
  "source": "odoo",
 
401
  "description": "Package label for Price 1. Blank means Odoo has no package reference for that price."
402
  },
403
  {
@@ -405,6 +423,7 @@
405
  "label": "Price 2",
406
  "type": "currency",
407
  "source": "odoo",
 
408
  "description": "Second-cheapest live price for this SKU."
409
  },
410
  {
@@ -412,6 +431,7 @@
412
  "label": "Unit 2",
413
  "type": "text",
414
  "source": "odoo",
 
415
  "description": "Package label for Price 2."
416
  },
417
  {
@@ -419,6 +439,7 @@
419
  "label": "Price 3",
420
  "type": "currency",
421
  "source": "odoo",
 
422
  "description": "Third-cheapest live price for this SKU."
423
  },
424
  {
@@ -426,8 +447,153 @@
426
  "label": "Unit 3",
427
  "type": "text",
428
  "source": "odoo",
 
429
  "description": "Package label for Price 3."
430
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  {
432
  "key": "pre_book_qty",
433
  "label": "Pre-book qty",
@@ -444,180 +610,188 @@
444
  },
445
  {
446
  "key": "rev_ytd",
447
- "label": "Revenue YTD",
448
- "type": "currency",
449
- "source": "odoo",
450
- "default": true,
451
- "description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
452
- },
453
- {
454
- "key": "rev_ly",
455
- "label": "Revenue LY",
456
- "type": "currency",
457
- "source": "odoo",
458
- "description": "Same period last year β€” seasonal wholesale compares like for like."
459
- },
460
- {
461
- "key": "yoy_pct",
462
- "label": "YoY %",
463
- "type": "pct",
464
- "source": "odoo",
465
- "description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
466
- },
467
- {
468
- "key": "qty_ytd",
469
- "label": "Units YTD",
470
- "type": "int",
471
- "source": "odoo",
472
- "description": "Units sold year to date."
473
- },
474
- {
475
- "key": "orders_ytd",
476
- "label": "Orders YTD",
477
- "type": "int",
478
- "source": "odoo",
479
- "description": "Distinct orders containing this SKU, year to date."
480
- },
481
- {
482
- "key": "on_hand",
483
- "label": "On hand",
484
- "type": "int",
485
- "source": "odoo",
486
- "description": "Units in stock. CONSOLIDATED β€” one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
487
- },
488
- {
489
- "key": "unit_cost",
490
- "label": "Unit cost",
491
- "type": "currency",
492
- "source": "odoo",
493
- "description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
494
- },
495
- {
496
- "key": "inv_value",
497
- "label": "Stock value",
498
- "type": "currency",
499
- "source": "odoo",
500
- "description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
501
- },
502
- {
503
- "key": "qty_ltm",
504
- "label": "Units LTM",
505
- "type": "int",
506
- "source": "odoo",
507
- "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
508
- },
509
- {
510
- "key": "incoming",
511
- "label": "Inbound units",
512
- "type": "int",
513
- "source": "odoo",
514
- "description": "Units already on order and not yet received, from Odoo's own incoming quantity on the product. In the product's STOCK unit of measure, the same unit as On hand, so the two can be added. Days of supply and both cover gap columns all count this as stock (owner, 2026-08-19)."
515
- },
516
- {
517
- "key": "dos",
518
- "label": "Days of supply",
519
- "type": "int",
520
- "source": "odoo",
521
- "description": "Days of supply at the trailing twelve month rate, counting ON HAND PLUS INBOUND units as stock; null means it never sells through. Same stock figure as the cover gap columns, so the two cannot disagree about how much you have. They still divide by different rates: this one is the trailing average, the cover gap uses the forward 8 month forecast, so a seasonal SKU reads differently in each. Consolidated; absent for a BU-scoped caller."
522
- },
523
- {
524
- "key": "cover_gap_d",
525
- "label": "Cover gap (days)",
526
- "type": "int",
527
- "source": "odoo",
528
- "default": false,
529
- "description": "Days of cover minus supplier lead time. Negative means it runs out before a reorder lands. It counts INBOUND units as stock, exactly as Days of supply now does. The one thing it does differently: the burn rate is the FORWARD 8 month forecast rather than the trailing twelve month average, so a seasonal SKU reads differently here."
530
- },
531
- {
532
- "key": "cover_gap_units",
533
- "label": "Cover gap (units)",
534
- "type": "int",
535
- "source": "odoo",
536
- "default": false,
537
- "description": "The recommended reorder quantity: units of forecast demand over the lead time that on-hand plus inbound does not cover. POSITIVE means buy this many; negative is surplus units; blank means we cannot say, because the SKU has no lead time on file or no forecast demand. Rounded AWAY from zero, so a real shortfall never rounds down to nothing."
538
- },
539
- {
540
- "key": "demand_fwd",
541
- "label": "Forecast units (8 mo)",
542
- "type": "int",
543
- "source": "odoo",
544
- "description": "Units this SKU is expected to sell over the next 8 months, read as the units it actually sold in the same 8 calendar months one year ago. Seasonal on purpose: a flat annual average spreads Valentine's, Mother's Day and Christmas evenly across the year and understates the months buyers actually order for. A SKU too new to appear in that window falls back to its trailing twelve month rate scaled to 8 months. This is the burn rate both cover gap columns divide by, shown so the reorder quantity can be checked."
545
- },
546
- {
547
- "key": "stock_bucket",
548
- "label": "Stock status",
549
- "type": "select",
550
- "source": "odoo",
551
- "description": "Dead / excess / healthy bucket, from the same days of supply figure beside it, so it counts inbound units too. One exception on purpose: 'Out of stock' still keys on the REAL shelf, because that is a present tense fact somebody can walk into the warehouse and check. A row can honestly read 'Out of stock' with a days of supply beside it when the replenishment is on the water; Inbound units is why. Consolidated; absent for a BU-scoped caller."
552
- },
553
- {
554
- "key": "discontinued",
555
- "label": "Discontinued",
556
- "type": "select",
557
- "source": "odoo",
558
- "options": [
559
- "Yes",
560
- "No"
561
- ],
562
- "description": "Whether Odoo carries the Discontinued product tag on this SKU. Every row gets an explicit Yes or No rather than a blank, because a blank reads as an inactive condition in the filter engine and would silently WIDEN any view that filtered on it."
563
- },
564
- {
565
- "key": "needs_pricing",
566
- "label": "Needs pricing",
567
- "type": "select",
568
- "source": "overlay",
569
- "default": false,
570
- "options": [
571
- "Yes"
572
- ],
573
- "shared": true,
574
- "description": "Team-maintained. A SKU carries β€œYes” when it appears on the NEEDS PRICING tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
575
- },
576
- {
577
- "key": "march_pricelist",
578
- "label": "March pricelist",
579
- "type": "select",
580
- "source": "overlay",
581
- "default": false,
582
- "options": [
583
- "Yes"
584
- ],
585
- "shared": true,
586
- "description": "Team-maintained. A SKU carries β€œYes” when it appears on the March Pricelist tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
587
- },
588
- {
589
- "key": "price_changes",
590
- "label": "Price changes",
591
- "type": "select",
592
- "source": "overlay",
593
- "default": false,
594
- "options": [
595
- "Yes"
596
- ],
597
- "shared": true,
598
- "description": "Team-maintained. A SKU carries β€œYes” when it appears on the Price Changes tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
599
- },
600
- {
601
- "key": "closeouts",
602
- "label": "Closeouts",
603
- "type": "select",
604
- "source": "overlay",
605
- "default": false,
606
- "options": [
607
- "Yes"
608
- ],
609
- "shared": true,
610
- "description": "Team-maintained. A SKU carries β€œYes” when it appears on the Closeouts tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
611
- },
612
- {
613
- "key": "notes",
614
- "label": "Notes",
615
- "type": "text",
616
- "source": "overlay",
617
- "default": false,
618
- "shared": true,
619
- "description": "Team-maintained. The 2027 workbook's own non-Odoo columns (Product Description, Packing) folded into one field. Shared with everyone in the workspace."
620
- }
621
- ]
622
- }
623
- }
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "CANONICAL field contract for the AIOS Airtable-style grid β€” the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,multiselect,currency,int,date,pct,url} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02. multiselect = one cell holding a comma-joined SET, so a choice list offers the MEMBERS, not the joined string; customer tags and product supplier are the first, wave 40 W40-T06. url = a link cell, GridCellKind.Uri, drawn by cells.ts and opened by CustomerGrid's scheme-guarded click path; maps_url is the first, wave 40 W40-T08). W40-T08 also adds created_on, the uniform records-created DATE column every database carries (owner I24). It is a plain date field and NOT the created_time TYPE on purpose: created_time renders the row-level _created datum and its value never lives at the field's own key, so it is invisible to the semantic topic, to a stored-cell read and to any host-side export, while a date field is an ordinary cell that all three can see. The _created datum still rides every row for a created_time column a user creates. `description` (wave 5) is the CANONICAL per-field description β€” every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β€” no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank connector attributes display as '(none)' (they are no longer all `text`: W40-T06 retyped eight of them to `select` and `tags` to `multiselect`, and the sentinel is a real member of every one of those vocabularies), so `is '(none)'` β€” not `is empty` β€” finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED β€” LTM's replacement is a creatable Sales measure column (the demo column IS Sales Β· the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED β€” every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
3
+ "fields": [
4
+ {
5
+ "key": "customer",
6
+ "label": "Customer",
7
+ "type": "text",
8
+ "source": "odoo",
9
+ "pinned": true,
10
+ "default": true,
11
+ "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
12
+ },
13
+ {
14
+ "key": "partner_id",
15
+ "label": "Odoo ID",
16
+ "type": "int",
17
+ "source": "odoo",
18
+ "derived": true,
19
+ "default": false,
20
+ "description": "The Odoo res.partner id β€” the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
21
+ },
22
+ {
23
+ "key": "odoo_status",
24
+ "label": "Odoo record",
25
+ "type": "status",
26
+ "source": "odoo",
27
+ "default": false,
28
+ "options": [
29
+ "Active",
30
+ "Archived"
31
+ ],
32
+ "description": "Whether this customer still exists in Odoo. Archived means deleted there."
33
+ },
34
+ {
35
+ "key": "agent",
36
+ "label": "Agent",
37
+ "type": "select",
38
+ "source": "odoo",
39
+ "default": true,
40
+ "description": "The sales agent who owns this account."
41
+ },
42
+ {
43
+ "key": "dba",
44
+ "label": "DBA",
45
+ "type": "select",
46
+ "source": "odoo",
47
+ "default": false,
48
+ "options": [
49
+ "Fisch",
50
+ "Royal",
51
+ "Both"
52
+ ],
53
+ "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
54
+ },
55
+ {
56
+ "key": "salesperson",
57
+ "label": "Salesperson",
58
+ "type": "select",
59
+ "source": "odoo",
60
+ "default": false,
61
+ "description": "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
62
+ },
63
+ {
64
+ "key": "street",
65
+ "label": "Street",
66
+ "type": "text",
67
+ "source": "odoo",
68
+ "default": false,
69
+ "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
70
+ },
71
+ {
72
+ "key": "street2",
73
+ "label": "Street 2",
74
+ "type": "text",
75
+ "source": "odoo",
76
+ "default": false,
77
+ "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
78
+ },
79
+ {
80
+ "key": "city",
81
+ "label": "City",
82
+ "type": "select",
83
+ "source": "odoo",
84
+ "default": true,
85
+ "description": "City on the customer's Odoo address."
86
+ },
87
+ {
88
+ "key": "state",
89
+ "label": "State",
90
+ "type": "select",
91
+ "source": "odoo",
92
+ "default": true,
93
+ "description": "State or province on the customer's Odoo address."
94
+ },
95
+ {
96
+ "key": "country",
97
+ "label": "Country",
98
+ "type": "select",
99
+ "source": "odoo",
100
+ "default": false,
101
+ "description": "Country on the customer's Odoo address."
102
+ },
103
+ {
104
+ "key": "zip",
105
+ "label": "ZIP",
106
+ "type": "text",
107
+ "source": "odoo",
108
+ "default": false,
109
+ "description": "Postal code on the customer's Odoo address."
110
+ },
111
+ {
112
+ "key": "maps_url",
113
+ "label": "Google Maps",
114
+ "type": "url",
115
+ "source": "odoo",
116
+ "default": true,
117
+ "description": "A Google Maps link to this customer's address."
118
+ },
119
+ {
120
+ "key": "customer_since",
121
+ "label": "Customer since",
122
+ "type": "date",
123
+ "source": "odoo",
124
+ "default": false,
125
+ "description": "When this customer was first set up in Odoo."
126
+ },
127
+ {
128
+ "key": "tags",
129
+ "label": "Tags",
130
+ "type": "multiselect",
131
+ "source": "odoo",
132
+ "default": false,
133
+ "description": "Odoo labels on this customer, comma-separated."
134
+ },
135
+ {
136
+ "key": "pricelist",
137
+ "label": "Customer price list",
138
+ "type": "select",
139
+ "source": "odoo",
140
+ "default": false,
141
+ "description": "The price list this customer buys on."
142
+ },
143
+ {
144
+ "key": "payment_terms",
145
+ "label": "Payment terms",
146
+ "type": "select",
147
+ "source": "odoo",
148
+ "default": false,
149
+ "description": "Payment terms on this customer's account β€” Net 30, for example."
150
+ },
151
+ {
152
+ "key": "last_order",
153
+ "label": "Last order",
154
+ "type": "date",
155
+ "source": "odoo",
156
+ "default": true,
157
+ "description": "Date of the most recent confirmed order."
158
+ },
159
+ {
160
+ "key": "overdue_days",
161
+ "label": "Overdue days",
162
+ "type": "int",
163
+ "source": "odoo",
164
+ "default": true,
165
+ "description": "How many days late this customer is running against their own usual ordering rhythm."
166
+ },
167
+ {
168
+ "_note": "filterable:false β€” DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β€” see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
169
+ "key": "est_missed",
170
+ "label": "Est. missed $",
171
+ "type": "currency",
172
+ "source": "odoo",
173
+ "default": true,
174
+ "agg": "sum",
175
+ "filterable": false,
176
+ "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
177
+ },
178
+ {
179
+ "_note": "wave 21 R1 β€” KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β€” 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
180
+ "key": "ar_open",
181
+ "label": "AR current $",
182
+ "type": "currency",
183
+ "source": "odoo",
184
+ "default": false,
185
+ "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
186
+ },
187
+ {
188
+ "key": "ar_overdue",
189
+ "label": "AR overdue $",
190
+ "type": "currency",
191
+ "source": "odoo",
192
+ "default": false,
193
+ "description": "Invoiced money past due β€” same basis as the Collections page."
194
+ },
195
+ {
196
+ "_note": "wave 21 R1 β€” the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β€” no second oracle.",
197
+ "key": "ar_outstanding",
198
+ "label": "AR outstanding $",
199
+ "type": "currency",
200
+ "source": "odoo",
201
+ "default": false,
202
+ "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
203
+ },
204
+ {
205
+ "key": "ar_exposure",
206
+ "label": "Credit exposure $",
207
+ "type": "currency",
208
+ "source": "odoo",
209
+ "default": false,
210
+ "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
211
+ },
212
+ {
213
+ "key": "ar_aged_1_30",
214
+ "label": "1-30 days $",
215
+ "type": "currency",
216
+ "source": "odoo",
217
+ "default": false,
218
+ "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
219
+ },
220
+ {
221
+ "key": "ar_aged_31_60",
222
+ "label": "31-60 days $",
223
+ "type": "currency",
224
+ "source": "odoo",
225
+ "default": false,
226
+ "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
227
+ },
228
+ {
229
+ "key": "ar_aged_61_90",
230
+ "label": "61-90 days $",
231
+ "type": "currency",
232
+ "source": "odoo",
233
+ "default": false,
234
+ "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
235
+ },
236
+ {
237
+ "key": "ar_aged_90_plus",
238
+ "label": "90+ days $",
239
+ "type": "currency",
240
+ "source": "odoo",
241
+ "default": false,
242
+ "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
243
+ },
244
+ {
245
+ "key": "days_to_pay",
246
+ "label": "Days to pay",
247
+ "type": "int",
248
+ "source": "odoo",
249
+ "default": false,
250
+ "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
251
+ },
252
+ {
253
+ "key": "top_category",
254
+ "label": "Top category",
255
+ "type": "select",
256
+ "source": "odoo",
257
+ "default": false,
258
+ "description": "The category this customer spent the most on in the last 12 months."
259
+ },
260
+ {
261
+ "key": "top_category_pct",
262
+ "label": "Top category %",
263
+ "type": "pct",
264
+ "source": "odoo",
265
+ "default": false,
266
+ "description": "Share of last-12-months spend that went to the top category."
267
+ },
268
+ {
269
+ "key": "sku_count",
270
+ "label": "SKUs bought",
271
+ "type": "int",
272
+ "source": "odoo",
273
+ "default": false,
274
+ "description": "Distinct products bought in the last 12 months."
275
+ },
276
+ {
277
+ "key": "top_sku",
278
+ "label": "Top SKU",
279
+ "type": "text",
280
+ "source": "odoo",
281
+ "default": false,
282
+ "description": "The product this customer spent the most on in the last 12 months."
283
+ },
284
+ {
285
+ "key": "days_since",
286
+ "label": "Days since order",
287
+ "type": "int",
288
+ "source": "odoo",
289
+ "default": false,
290
+ "description": "Days since the last confirmed order."
291
+ },
292
+ {
293
+ "key": "typical_gap_days",
294
+ "label": "Typical gap days",
295
+ "type": "int",
296
+ "source": "odoo",
297
+ "default": false,
298
+ "description": "Days this customer usually goes between orders, from their own history."
299
+ },
300
+ {
301
+ "key": "created_on",
302
+ "label": "Record created",
303
+ "type": "date",
304
+ "source": "odoo",
305
+ "default": false,
306
+ "description": "The date this customer record was created in Odoo."
307
+ },
308
+ {
309
+ "key": "notes",
310
+ "label": "Notes",
311
+ "type": "text",
312
+ "source": "overlay",
313
+ "default": false,
314
+ "description": "Your notes on this customer. Saved in this app only, visible only to you."
315
+ }
316
+ ],
317
+ "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
318
+ "_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
319
+ "product_data": {
320
+ "identity": "pid",
321
+ "business_key": "code",
322
+ "fields": [
323
+ {
324
+ "key": "code",
325
+ "label": "SKU",
326
+ "type": "text",
327
+ "source": "odoo",
328
+ "pinned": true,
329
+ "default": true,
330
+ "description": "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
331
+ },
332
+ {
333
+ "key": "product",
334
+ "label": "Product",
335
+ "type": "text",
336
+ "source": "odoo",
337
+ "default": true,
338
+ "description": "Product name as it appears in Odoo."
339
+ },
340
+ {
341
+ "key": "category",
342
+ "label": "Category",
343
+ "type": "select",
344
+ "source": "odoo",
345
+ "default": true,
346
+ "description": "Product category; '(uncategorized)' when Odoo carries none."
347
+ },
348
+ {
349
+ "key": "supplier",
350
+ "label": "Supplier",
351
+ "type": "multiselect",
352
+ "source": "overlay",
353
+ "default": true,
354
+ "description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
355
+ "shared": true
356
+ },
357
+ {
358
+ "key": "origin_country",
359
+ "label": "Country",
360
+ "type": "text",
361
+ "source": "overlay",
362
+ "default": false,
363
+ "description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
364
+ "shared": true
365
+ },
366
+ {
367
+ "key": "lead_days",
368
+ "label": "Lead time (days)",
369
+ "type": "int",
370
+ "source": "overlay",
371
+ "default": true,
372
+ "description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
373
+ "shared": true
374
+ },
375
+ {
376
+ "key": "first_cost",
377
+ "label": "First cost",
378
+ "type": "currency",
379
+ "source": "overlay",
380
+ "default": false,
381
+ "description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
382
+ "shared": true
383
+ },
384
+ {
385
+ "key": "price_fisch",
386
+ "label": "Fisch price",
387
+ "type": "currency",
388
+ "source": "odoo",
389
+ "description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
390
+ },
391
+ {
392
+ "key": "price_royal_1",
393
+ "label": "Royal 1 price",
394
+ "type": "currency",
395
+ "source": "odoo",
396
+ "description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
397
+ },
398
+ {
399
+ "key": "price_royal_2",
400
+ "label": "Royal 2 price",
401
+ "type": "currency",
402
+ "source": "odoo",
403
+ "description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
404
+ },
405
  {
406
  "key": "price_1",
407
  "label": "Price 1",
408
  "type": "currency",
409
  "source": "odoo",
410
+ "default": false,
411
  "description": "Cheapest live price for this SKU, across active pricelists."
412
  },
413
  {
 
415
  "label": "Unit 1",
416
  "type": "text",
417
  "source": "odoo",
418
+ "default": false,
419
  "description": "Package label for Price 1. Blank means Odoo has no package reference for that price."
420
  },
421
  {
 
423
  "label": "Price 2",
424
  "type": "currency",
425
  "source": "odoo",
426
+ "default": false,
427
  "description": "Second-cheapest live price for this SKU."
428
  },
429
  {
 
431
  "label": "Unit 2",
432
  "type": "text",
433
  "source": "odoo",
434
+ "default": false,
435
  "description": "Package label for Price 2."
436
  },
437
  {
 
439
  "label": "Price 3",
440
  "type": "currency",
441
  "source": "odoo",
442
+ "default": false,
443
  "description": "Third-cheapest live price for this SKU."
444
  },
445
  {
 
447
  "label": "Unit 3",
448
  "type": "text",
449
  "source": "odoo",
450
+ "default": false,
451
  "description": "Package label for Price 3."
452
  },
453
+ {
454
+ "key": "price_fisch_t1",
455
+ "label": "Fisch tier 1 price",
456
+ "type": "currency",
457
+ "source": "odoo",
458
+ "default": false,
459
+ "description": "Fisch price tier 1, cheapest first. Blank when Fisch does not price this SKU."
460
+ },
461
+ {
462
+ "key": "unit_fisch_t1",
463
+ "label": "Fisch tier 1 unit",
464
+ "type": "text",
465
+ "source": "odoo",
466
+ "default": false,
467
+ "description": "Package label for the Fisch tier 1 price. Blank when Odoo names no package."
468
+ },
469
+ {
470
+ "key": "price_fisch_t2",
471
+ "label": "Fisch tier 2 price",
472
+ "type": "currency",
473
+ "source": "odoo",
474
+ "default": false,
475
+ "description": "Fisch price tier 2. Blank when Fisch prices this SKU at only one tier."
476
+ },
477
+ {
478
+ "key": "unit_fisch_t2",
479
+ "label": "Fisch tier 2 unit",
480
+ "type": "text",
481
+ "source": "odoo",
482
+ "default": false,
483
+ "description": "Package label for the Fisch tier 2 price. Blank when Odoo names no package."
484
+ },
485
+ {
486
+ "key": "price_fisch_t3",
487
+ "label": "Fisch tier 3 price",
488
+ "type": "currency",
489
+ "source": "odoo",
490
+ "default": false,
491
+ "description": "Fisch price tier 3. Blank when Fisch prices this SKU at fewer than three tiers."
492
+ },
493
+ {
494
+ "key": "unit_fisch_t3",
495
+ "label": "Fisch tier 3 unit",
496
+ "type": "text",
497
+ "source": "odoo",
498
+ "default": false,
499
+ "description": "Package label for the Fisch tier 3 price. Blank when Odoo names no package."
500
+ },
501
+ {
502
+ "key": "price_royal1_t1",
503
+ "label": "Royal 1 tier 1 price",
504
+ "type": "currency",
505
+ "source": "odoo",
506
+ "default": false,
507
+ "description": "Royal 1 price tier 1, cheapest first. Blank when Royal 1 does not price this SKU."
508
+ },
509
+ {
510
+ "key": "unit_royal1_t1",
511
+ "label": "Royal 1 tier 1 unit",
512
+ "type": "text",
513
+ "source": "odoo",
514
+ "default": false,
515
+ "description": "Package label for the Royal 1 tier 1 price. Blank when Odoo names no package."
516
+ },
517
+ {
518
+ "key": "price_royal1_t2",
519
+ "label": "Royal 1 tier 2 price",
520
+ "type": "currency",
521
+ "source": "odoo",
522
+ "default": false,
523
+ "description": "Royal 1 price tier 2. Blank when Royal 1 prices this SKU at only one tier."
524
+ },
525
+ {
526
+ "key": "unit_royal1_t2",
527
+ "label": "Royal 1 tier 2 unit",
528
+ "type": "text",
529
+ "source": "odoo",
530
+ "default": false,
531
+ "description": "Package label for the Royal 1 tier 2 price. Blank when Odoo names no package."
532
+ },
533
+ {
534
+ "key": "price_royal1_t3",
535
+ "label": "Royal 1 tier 3 price",
536
+ "type": "currency",
537
+ "source": "odoo",
538
+ "default": false,
539
+ "description": "Royal 1 price tier 3. Blank when Royal 1 prices this SKU at fewer than three tiers."
540
+ },
541
+ {
542
+ "key": "unit_royal1_t3",
543
+ "label": "Royal 1 tier 3 unit",
544
+ "type": "text",
545
+ "source": "odoo",
546
+ "default": false,
547
+ "description": "Package label for the Royal 1 tier 3 price. Blank when Odoo names no package."
548
+ },
549
+ {
550
+ "key": "price_royal2_t1",
551
+ "label": "Royal 2 tier 1 price",
552
+ "type": "currency",
553
+ "source": "odoo",
554
+ "default": false,
555
+ "description": "Royal 2 price tier 1, cheapest first. Blank when Royal 2 does not price this SKU."
556
+ },
557
+ {
558
+ "key": "unit_royal2_t1",
559
+ "label": "Royal 2 tier 1 unit",
560
+ "type": "text",
561
+ "source": "odoo",
562
+ "default": false,
563
+ "description": "Package label for the Royal 2 tier 1 price. Blank when Odoo names no package."
564
+ },
565
+ {
566
+ "key": "price_royal2_t2",
567
+ "label": "Royal 2 tier 2 price",
568
+ "type": "currency",
569
+ "source": "odoo",
570
+ "default": false,
571
+ "description": "Royal 2 price tier 2. Blank when Royal 2 prices this SKU at only one tier."
572
+ },
573
+ {
574
+ "key": "unit_royal2_t2",
575
+ "label": "Royal 2 tier 2 unit",
576
+ "type": "text",
577
+ "source": "odoo",
578
+ "default": false,
579
+ "description": "Package label for the Royal 2 tier 2 price. Blank when Odoo names no package."
580
+ },
581
+ {
582
+ "key": "price_royal2_t3",
583
+ "label": "Royal 2 tier 3 price",
584
+ "type": "currency",
585
+ "source": "odoo",
586
+ "default": false,
587
+ "description": "Royal 2 price tier 3. Blank when Royal 2 prices this SKU at fewer than three tiers."
588
+ },
589
+ {
590
+ "key": "unit_royal2_t3",
591
+ "label": "Royal 2 tier 3 unit",
592
+ "type": "text",
593
+ "source": "odoo",
594
+ "default": false,
595
+ "description": "Package label for the Royal 2 tier 3 price. Blank when Odoo names no package."
596
+ },
597
  {
598
  "key": "pre_book_qty",
599
  "label": "Pre-book qty",
 
610
  },
611
  {
612
  "key": "rev_ytd",
613
+ "label": "Revenue YTD",
614
+ "type": "currency",
615
+ "source": "odoo",
616
+ "default": true,
617
+ "description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
618
+ },
619
+ {
620
+ "key": "rev_ly",
621
+ "label": "Revenue LY",
622
+ "type": "currency",
623
+ "source": "odoo",
624
+ "description": "Same period last year β€” seasonal wholesale compares like for like."
625
+ },
626
+ {
627
+ "key": "yoy_pct",
628
+ "label": "YoY %",
629
+ "type": "pct",
630
+ "source": "odoo",
631
+ "description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
632
+ },
633
+ {
634
+ "key": "qty_ytd",
635
+ "label": "Units YTD",
636
+ "type": "int",
637
+ "source": "odoo",
638
+ "description": "Units sold year to date."
639
+ },
640
+ {
641
+ "key": "orders_ytd",
642
+ "label": "Orders YTD",
643
+ "type": "int",
644
+ "source": "odoo",
645
+ "description": "Distinct orders containing this SKU, year to date."
646
+ },
647
+ {
648
+ "key": "on_hand",
649
+ "label": "On hand",
650
+ "type": "int",
651
+ "source": "odoo",
652
+ "description": "Units in stock. CONSOLIDATED β€” one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
653
+ },
654
+ {
655
+ "key": "unit_cost",
656
+ "label": "Unit cost",
657
+ "type": "currency",
658
+ "source": "odoo",
659
+ "description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
660
+ },
661
+ {
662
+ "key": "inv_value",
663
+ "label": "Stock value",
664
+ "type": "currency",
665
+ "source": "odoo",
666
+ "description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
667
+ },
668
+ {
669
+ "key": "qty_ltm",
670
+ "label": "Units LTM",
671
+ "type": "int",
672
+ "source": "odoo",
673
+ "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
674
+ },
675
+ {
676
+ "key": "incoming",
677
+ "label": "Inbound units",
678
+ "type": "int",
679
+ "source": "odoo",
680
+ "description": "Units already on order and not yet received, from Odoo's own incoming quantity on the product. In the product's STOCK unit of measure, the same unit as On hand, so the two can be added. Days of supply and both cover gap columns all count this as stock (owner, 2026-08-19)."
681
+ },
682
+ {
683
+ "key": "dos",
684
+ "label": "Days of supply",
685
+ "type": "int",
686
+ "source": "odoo",
687
+ "description": "Days of supply at the trailing twelve month rate, counting ON HAND PLUS INBOUND units as stock. A blank means there is no honest number to print, and Stock status beside it says which of two reasons applies: 'No recent sales' is stock on the shelf that sold nothing in the window, so there is no rate to divide by; 'No stock record' is a product the inventory read returned no row for, so its shelf is unknown rather than empty. A product that sold nothing and holds nothing reads 0, not blank. Same stock figure as the cover gap columns, so the two cannot disagree about how much you have. They still divide by different rates: this one is the trailing average, the cover gap uses the forward 8 month forecast, so a seasonal SKU reads differently in each. A business unit caller gets this column too, re-scoped: the shelf is the whole warehouse's, the rate is that unit's."
688
+ },
689
+ {
690
+ "key": "cover_gap_d",
691
+ "label": "Cover gap (days)",
692
+ "type": "int",
693
+ "source": "odoo",
694
+ "default": false,
695
+ "description": "Days of cover minus supplier lead time. Negative means it runs out before a reorder lands. It counts INBOUND units as stock, exactly as Days of supply now does. The one thing it does differently: the burn rate is the FORWARD 8 month forecast rather than the trailing twelve month average, so a seasonal SKU reads differently here."
696
+ },
697
+ {
698
+ "key": "cover_gap_units",
699
+ "label": "Cover gap (units)",
700
+ "type": "int",
701
+ "source": "odoo",
702
+ "default": false,
703
+ "description": "The recommended reorder quantity: units of forecast demand over the lead time that on-hand plus inbound does not cover. POSITIVE means buy this many; negative is surplus units; blank means we cannot say, because the SKU has no lead time on file or no forecast demand. Rounded AWAY from zero, so a real shortfall never rounds down to nothing."
704
+ },
705
+ {
706
+ "key": "demand_fwd",
707
+ "label": "Forecast units (8 mo)",
708
+ "type": "int",
709
+ "source": "odoo",
710
+ "description": "Units this SKU is expected to sell over the next 8 months, read as the units it actually sold in the same 8 calendar months one year ago. Seasonal on purpose: a flat annual average spreads Valentine's, Mother's Day and Christmas evenly across the year and understates the months buyers actually order for. A SKU too new to appear in that window falls back to its trailing twelve month rate scaled to 8 months. This is the burn rate both cover gap columns divide by, shown so the reorder quantity can be checked."
711
+ },
712
+ {
713
+ "key": "stock_bucket",
714
+ "label": "Stock status",
715
+ "type": "select",
716
+ "source": "odoo",
717
+ "description": "Dead / excess / healthy bucket, from the same days of supply figure beside it, so it counts inbound units too. Two of its values are not coverage bands at all. 'Out of stock' keys on the REAL shelf rather than the inbound-inclusive figure, because that is a present tense fact somebody can walk into the warehouse and check, so a row can honestly read 'Out of stock' with a days of supply beside it when the replenishment is on the water; Inbound units is why. 'No stock record' means the inventory read returned no row for this product, so its shelf is unknown rather than empty: it reads that way for the handful of products carrying no SKU code, and for every row at once if an inventory read fails, which is the case where a blank Days of supply is an outage rather than a slow seller. A business unit caller gets this column too, re-scoped through that unit's own sales rate."
718
+ },
719
+ {
720
+ "key": "discontinued",
721
+ "label": "Discontinued",
722
+ "type": "select",
723
+ "source": "odoo",
724
+ "options": [
725
+ "Yes",
726
+ "No"
727
+ ],
728
+ "description": "Whether Odoo carries the Discontinued product tag on this SKU. Every row gets an explicit Yes or No rather than a blank, because a blank reads as an inactive condition in the filter engine and would silently WIDEN any view that filtered on it."
729
+ },
730
+ {
731
+ "key": "created_on",
732
+ "label": "Record created",
733
+ "type": "date",
734
+ "source": "odoo",
735
+ "default": false,
736
+ "description": "The date this product record was created in Odoo. Read from the live product.product create_date: the mirror carries write_date and no create_date, so there is no cheaper source. A SKU whose code is on more than one active record takes the EARLIEST of them, because the row is the SKU and that is when it first existed."
737
+ },
738
+ {
739
+ "key": "needs_pricing",
740
+ "label": "Needs pricing",
741
+ "type": "select",
742
+ "source": "overlay",
743
+ "default": false,
744
+ "options": [
745
+ "Yes"
746
+ ],
747
+ "shared": true,
748
+ "description": "Team-maintained. A SKU carries β€œYes” when it appears on the NEEDS PRICING tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
749
+ },
750
+ {
751
+ "key": "march_pricelist",
752
+ "label": "March pricelist",
753
+ "type": "select",
754
+ "source": "overlay",
755
+ "default": false,
756
+ "options": [
757
+ "Yes"
758
+ ],
759
+ "shared": true,
760
+ "description": "Team-maintained. A SKU carries β€œYes” when it appears on the March Pricelist tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
761
+ },
762
+ {
763
+ "key": "price_changes",
764
+ "label": "Price changes",
765
+ "type": "select",
766
+ "source": "overlay",
767
+ "default": false,
768
+ "options": [
769
+ "Yes"
770
+ ],
771
+ "shared": true,
772
+ "description": "Team-maintained. A SKU carries β€œYes” when it appears on the Price Changes tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
773
+ },
774
+ {
775
+ "key": "closeouts",
776
+ "label": "Closeouts",
777
+ "type": "select",
778
+ "source": "overlay",
779
+ "default": false,
780
+ "options": [
781
+ "Yes"
782
+ ],
783
+ "shared": true,
784
+ "description": "Team-maintained. A SKU carries β€œYes” when it appears on the Closeouts tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
785
+ },
786
+ {
787
+ "key": "notes",
788
+ "label": "Notes",
789
+ "type": "text",
790
+ "source": "overlay",
791
+ "default": false,
792
+ "shared": true,
793
+ "description": "Team-maintained. The 2027 workbook's own non-Odoo columns (Product Description, Packing) folded into one field. Shared with everyone in the workspace."
794
+ }
795
+ ]
796
+ }
797
+ }
platform/core/data_binding.py CHANGED
@@ -263,9 +263,34 @@ def describe(repo=None):
263
 
264
  ⚠ Carries no secret: a Space id and a dataset repo id are public names. The DATA is not, and
265
  none of it is here. The field is session-gated anyway (see `routes_admin.settings`).
 
 
 
 
 
 
 
266
  """
267
  rid = str(repo or '').strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  return {
 
269
  'deployment': deployment_id(),
270
  'production': is_production_deployment(),
271
  'repo': rid,
 
263
 
264
  ⚠ Carries no secret: a Space id and a dataset repo id are public names. The DATA is not, and
265
  none of it is here. The field is session-gated anyway (see `routes_admin.settings`).
266
+
267
+ ⭐⭐ W40-T13 (D-333) β€” IT ALSO NAMES THE STORE BACKEND, WHICH IS THE OTHER HALF OF THE SAME
268
+ QUESTION AND THE HALF THAT WENT MISSING. `repo` answers "WHICH STORE am I bound to"; after the
269
+ Postgres cutover the sharper question is "am I READING that store from HF or from Neon", and
270
+ the payload could not answer it at all. W36-T62's `done-when` said *"/settings reports the pg
271
+ backend"* and was therefore unmeasurable, which is how a whole cutover shipped with the
272
+ verification clause pointed at a field that did not exist. It exists now.
273
  """
274
  rid = str(repo or '').strip()
275
+ # β›”β›” THE IMPORT IS FUNCTION-LOCAL AND MUST STAY THAT WAY. `core/store.py` imports THIS module
276
+ # at its own module scope, so pulling `core.store` up to the top of this file is a hard import
277
+ # cycle that kills the app at boot rather than at a test. Do not "tidy" it upward.
278
+ #
279
+ # β›” AND AN UNRECOGNISED BACKEND REPORTS `unknown`, NEVER `hf`. `store.backend()` RAISES on a
280
+ # value it does not know, and this function is called from `routes_admin::_store_binding`
281
+ # inside a bare `except` whose fallback dict has no `backend` key at all β€” so a raise here
282
+ # would delete the field from the payload on exactly the deployment that most needs it. And
283
+ # defaulting to `hf` would be worse than raising: a misconfigured container would report the
284
+ # store it is NOT reading, which is the guess this field exists to replace. ImportError rides
285
+ # the same branch for the same reason: an honest "I could not tell" beats a confident wrong
286
+ # answer on a surface nobody can inspect from outside (D-160).
287
+ try:
288
+ import core.store as store # noqa: PLC0415 β€” cycle, see above
289
+ store_backend = store.backend()
290
+ except (RuntimeError, ImportError):
291
+ store_backend = 'unknown'
292
  return {
293
+ 'backend': store_backend,
294
  'deployment': deployment_id(),
295
  'production': is_production_deployment(),
296
  'repo': rid,
platform/core/field_permissions.py CHANGED
@@ -58,6 +58,161 @@ def grant_entries(permissions):
58
  return []
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def _field_is_migratable(field):
62
  return (isinstance(field, dict) and field.get("custom") is True
63
  and field.get("source") == "overlay")
@@ -74,13 +229,34 @@ def migrate_legacy_fields(table_key, st=None, known_users=None, grant_topic=None
74
  grant_key = str(grant_topic or key).strip()
75
  shared_key = str(shared_key or key).strip()
76
  if not key or st is None:
77
- return {"promoted": 0, "normalized": 0}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  try:
79
  document = st.get(key) or {}
80
  except Exception:
81
- return {"promoted": 0, "normalized": 0}
82
  if not isinstance(document, dict):
83
- return {"promoted": 0, "normalized": 0}
84
 
85
  promoted = 0
86
  normalized = 0
@@ -151,7 +327,10 @@ def migrate_legacy_fields(table_key, st=None, known_users=None, grant_topic=None
151
  values.pop(field_key, None)
152
  return data
153
  st.update(key, _remove, flush="sync")
154
- return {"promoted": promoted, "normalized": normalized}
 
 
 
155
 
156
 
157
  def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None):
@@ -162,7 +341,36 @@ def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None)
162
  return dict(field)
163
  shared = dict(field)
164
  shared.update({"shared": True, "granted": True, "source": "overlay", "custom": True})
165
- permissions = stored_permissions(shared, fallback="collaborative")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  shared["permissions"] = permissions
167
  shared["createdBy"] = str(shared.get("createdBy") or owner).strip().lower()
168
  shared_overlay.put_field(shared_key, key, shared, st=st)
 
58
  return []
59
 
60
 
61
+ def permissions_from_grants(entries):
62
+ """⭐⭐ W40-T03 / CONTRACT C4 β€” THE CLASSIFICATION IS DERIVED FROM THE REGISTRY, NEVER STAMPED.
63
+
64
+ Owner instruction 21 (2026-08-23): *"A Field shared with only Karen Ganley shows under Hide
65
+ Fields as 'Shared with everyone' β€” it should be 'Shared with me'. Fix the logic. By default
66
+ every Field created is private until it is shared."*
67
+
68
+ The registry (`core/shares.py`) already knows exactly who a column is shared with; the
69
+ `permissions` bag on the definition is only the WORD the client sections on
70
+ (`FieldsHidePanel` buckets by `types.fieldEditMode`). Until now those two could disagree,
71
+ because `promote_field` stamped the bag from a hardcoded fallback BEFORE the request's own
72
+ grant entries were written. This function is the single place the bag is derived from the
73
+ grants, so the word on screen and the wall in the registry answer the same question.
74
+
75
+ β›” CLASSIFICATION IS ABOUT **WHO**, NOT ABOUT **ROLE**, and that is C4's wording rather than an
76
+ approximation: *"'Shared with me' means grants naming me"*. A `view`-role grantee is a person
77
+ the column is shared with exactly as an `edit`-role one is, so both count. The role itself is
78
+ NOT recoverable from this bag and is not meant to be β€” `shares.role_for` is what answers "may
79
+ this account change it", on every path that asks (see the four short-circuits named on
80
+ `reconcile_shared_permissions`).
81
+
82
+ β›” THE WILDCARD WINS, AND THE NAMED USERS ARE DISCARDED. `*` plus named users is still "shared
83
+ with everyone" on screen, because everyone can already reach it; carrying the named few
84
+ alongside would produce a bag that says `users` while the registry admits the room. Discarding
85
+ them is safe precisely because this bag is not a wall for a shared field: `role_for` keeps the
86
+ stronger of an everyone-grant and a personal one, so a named user's raised role survives in
87
+ the registry where it is actually read.
88
+
89
+ β›” `known_users` IS DELIBERATELY NOT PASSED, HERE OR BY ANY CALLER ON THIS PATH.
90
+ `clean_permissions` collapses to `{'edit': 'personal'}` when the filtered users list ends up
91
+ empty, so ONE grantee missing from the user registry would silently reflip a shared column to
92
+ Private β€” the owner's defect in the other direction, and just as invisible. `put_share` has
93
+ already validated every grantee through `_entries_or_400` before the grant is stored, so the
94
+ filter would only ever fire on a name that door already accepted.
95
+
96
+ ⚠ THE USERS LIST IS SORTED, AND THAT IS WHAT MAKES THE RECONCILE IDEMPOTENT rather than merely
97
+ convergent. `shares._clean_entries` preserves stored insertion order, so two grant sets holding
98
+ the same people in a different order would otherwise derive two different-but-equal bags and
99
+ rewrite each other forever on alternating reads (the D-358 egress class).
100
+ """
101
+ users = []
102
+ seen = set()
103
+ for entry in entries or ():
104
+ if not isinstance(entry, dict):
105
+ continue
106
+ user = str(entry.get("user") or "").strip().lower()
107
+ if not user or user in seen:
108
+ continue
109
+ seen.add(user)
110
+ if user == shares.EVERYONE:
111
+ return clean_permissions({"edit": "collaborative"})
112
+ users.append(user)
113
+ if not users:
114
+ return clean_permissions({"edit": "personal"})
115
+ return clean_permissions({"edit": "users", "users": sorted(users)})
116
+
117
+
118
+ def _same_classification(left, right):
119
+ """Do two canonical bags say the same thing about WHO?
120
+
121
+ ⚠ THE USERS LIST IS COMPARED AS A SET, not as a sequence, and that is a write-count decision
122
+ rather than a nicety. A stored bag minted before the sort above carries the registry's
123
+ insertion order; comparing sequences would rewrite every such column once for a change no
124
+ reader can see. `shared_overlay.put_field` is a `flush='sync'` store commit per field, so a
125
+ difference nobody can observe must not cost one.
126
+ """
127
+ if left.get("edit") != right.get("edit"):
128
+ return False
129
+ return set(left.get("users") or ()) == set(right.get("users") or ())
130
+
131
+
132
+ def reconcile_shared_permissions(shared_key, grant_topic, st=None):
133
+ """Re-derive every SHARED definition's `permissions` bag from the grant registry. Returns the
134
+ number of definitions actually rewritten.
135
+
136
+ ⭐⭐ THIS IS A **WRITE ON A READ PATH**, ON A TENANT-WIDE STRATUM, AND IT IS DELIBERATE.
137
+ `routes_customers.py`'s `ROUTE_KIND` comment argues the opposite case for route columns
138
+ (*"Backfilling every stored route definition on a read is a write nobody asked for, on a
139
+ tenant-wide stratum, triggered by opening a page"*) and settles it with "re-solving is one
140
+ click and it is the click the owner is already making". A SHARED FIELD HAS NO SUCH CLICK: the
141
+ owner's "Fisch Main Catalog" was shared once, months ago, and nothing will ever rewrite its
142
+ definition again. Leaving it to a future re-share is leaving it wrong forever. C4 also makes
143
+ the registry AUTHORITATIVE, which a stamp that is never re-derived cannot be. This function is
144
+ called from `migrate_legacy_fields`, which is already exactly such a pass β€” it runs
145
+ `flush='sync'` promotions on the same three read paths β€” so this adds a kind of write to a
146
+ door that already makes them, not a new door.
147
+
148
+ β›” ONLY THE SHARED STRATUM, CHECKED ON `shared`/`granted`. On a PER-USER private field the bag
149
+ IS the wall and re-deriving it would move a permission. On a shared one it is not, and that is
150
+ checked rather than reasoned β€” all four consumers short-circuit on the share marks BEFORE they
151
+ ever read the bag: `grid_events._may_edit_field_definition` and `::_may_edit_field_value` both
152
+ take `_field_share_role` on `definition.get('shared') or definition.get('granted')`;
153
+ `types.mayEditField` and `types.mayEditFieldDefinition` take the same branch in the same order
154
+ on the client; and `perm_scope.field_grant_hidden` keys off `FIELD_GRANT_MARK` plus
155
+ `shares.role_for` and never reads the bag at all. Nothing on this path is a permission
156
+ decision. This is the same fact the `ROUTE_KIND` comment states of its own change: *"AND IT
157
+ MOVES NO WALL, CHECKED RATHER THAN REASONED."*
158
+
159
+ β›” NO GRANT RECORD AT ALL MEANS **LEAVE IT ALONE**, and the distinction is why `shares.grants`
160
+ returning an owner with zero entries is a different answer from returning neither. A record
161
+ with an owner and no entries is a column somebody has managed and shared with nobody: that is
162
+ `personal`, positively. A column the registry has never heard of is a column this function
163
+ knows nothing about, and inventing a classification for it would be guessing in the direction
164
+ of a visible change.
165
+
166
+ ⚠ ONE `put_field` PER CHANGED DEFINITION, and the bound is what makes that acceptable. Only a
167
+ definition whose stored bag DISAGREES with its grants is rewritten, which in practice is the
168
+ handful first shared before this derivation existed; steady state is zero writes, proven by
169
+ the idempotence leg of the harness. `shared_overlay.put_rows`' docstring carries this repo's
170
+ measured scar for the opposite shape (*"18 writes against one document under the store's
171
+ coalescing single-flight landed ZERO while answering 200 eighteen times"*), so if a corpus ever
172
+ makes this pass write in bulk it must become ONE batched write rather than a loop.
173
+ """
174
+ bucket = str(shared_key or "").strip()
175
+ grant_key = str(grant_topic or bucket).strip()
176
+ if not bucket or st is None:
177
+ return 0
178
+ rewritten = 0
179
+ for field_key, defn in list(shared_overlay.fields(bucket, st=st).items()):
180
+ # ⚠ EACH DEFINITION IS GUARDED ON ITS OWN. `shares.field_oid` RAISES on an empty key, and
181
+ # the caller wraps this whole function in one swallowing except β€” so a single malformed
182
+ # entry would otherwise skip every REMAINING column, including the one the owner reported,
183
+ # and report as "nothing to reclassify" with no failure anywhere near the cause.
184
+ try:
185
+ if not isinstance(defn, dict):
186
+ continue
187
+ if not (defn.get("shared") or defn.get("granted")):
188
+ continue
189
+ key = str(field_key or "").strip()
190
+ if not key:
191
+ continue
192
+ record = shares.grants("field", shares.field_oid(grant_key, key), st=st)
193
+ if not record.get("owner") and not record.get("entries"):
194
+ continue
195
+ derived = permissions_from_grants(record.get("entries"))
196
+ stored = defn.get("permissions")
197
+ # β›” A MISSING BAG IS ALWAYS A DIFFERENCE, EVEN WHEN THE DERIVATION SAYS `personal`.
198
+ # This is the leg that decides whether the fix is visible at all. The client's own
199
+ # fallback is `"collaborative"` (`types.cleanFieldPermissions(field.permissions,
200
+ # "collaborative")`), so a definition carrying NO bag renders as "Shared with
201
+ # everyone" no matter what the registry says. Comparing canonicalised values would
202
+ # find `personal == personal` and write nothing, and the column would keep reading
203
+ # "Shared with everyone" forever. The server must emit an EXPLICIT bag on every
204
+ # shared definition; absence is not a value the wire can carry.
205
+ if isinstance(stored, dict) and _same_classification(clean_permissions(stored), derived):
206
+ continue
207
+ updated = dict(defn)
208
+ updated["permissions"] = derived
209
+ shared_overlay.put_field(bucket, key, updated, st=st)
210
+ rewritten += 1
211
+ except Exception:
212
+ continue
213
+ return rewritten
214
+
215
+
216
  def _field_is_migratable(field):
217
  return (isinstance(field, dict) and field.get("custom") is True
218
  and field.get("source") == "overlay")
 
229
  grant_key = str(grant_topic or key).strip()
230
  shared_key = str(shared_key or key).strip()
231
  if not key or st is None:
232
+ return {"promoted": 0, "normalized": 0, "reclassified": 0}
233
+ # ⭐⭐ W40-T03 β€” RECONCILE THE SHARED STRATUM'S CLASSIFICATION FIRST, IN ITS OWN SWALLOWING
234
+ # GUARD. This is the ONE seam that reaches every shared-field surface: all three readers
235
+ # (`routes_customers.shared_fields`, `routes_tables._ut_shared_fields`,
236
+ # `product_data`) already call this function with the right keys and read the definitions
237
+ # immediately afterwards, so a reconcile that writes into the shared overlay is picked up by
238
+ # that same read.
239
+ #
240
+ # β›” THE GUARD IS NOT DEFENSIVENESS, IT IS A BLAST RADIUS. `routes_customers.shared_fields`
241
+ # wraps its whole call in a `try/except` that degrades to `{}` β€” i.e. a raise in here would
242
+ # blank EVERY shared column on the customer grid and read as "nothing is shared yet". A
243
+ # classification that fails must leave the previous word on screen, never the page empty.
244
+ #
245
+ # ⚠ THE TWO KEYS ARE DIFFERENT STRINGS AND THE ORDER MATTERS. `shared_key` names the BUCKET
246
+ # (`product_table_workspace__shared`); `grant_key` names the registry TOPIC
247
+ # (`product_data:<field>`). For the product topic they are not the same value. Swapping them
248
+ # finds no grant record, the "no record" arm fires for every column, and the reconcile
249
+ # silently does nothing at all.
250
+ try:
251
+ reclassified = reconcile_shared_permissions(shared_key, grant_key, st=st)
252
+ except Exception:
253
+ reclassified = 0
254
  try:
255
  document = st.get(key) or {}
256
  except Exception:
257
+ return {"promoted": 0, "normalized": 0, "reclassified": reclassified}
258
  if not isinstance(document, dict):
259
+ return {"promoted": 0, "normalized": 0, "reclassified": reclassified}
260
 
261
  promoted = 0
262
  normalized = 0
 
327
  values.pop(field_key, None)
328
  return data
329
  st.update(key, _remove, flush="sync")
330
+ # β›” `promoted` AND `normalized` KEEP THEIR NAMES AND THEIR MEANINGS.
331
+ # `aios-web/api/verify_field_permissions.py` asserts `result["promoted"] == 1`; renaming or
332
+ # folding either into the new count would turn a green gate red for a change it never made.
333
+ return {"promoted": promoted, "normalized": normalized, "reclassified": reclassified}
334
 
335
 
336
  def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None):
 
341
  return dict(field)
342
  shared = dict(field)
343
  shared.update({"shared": True, "granted": True, "source": "overlay", "custom": True})
344
+ # ⭐⭐ W40-T03 / OWNER INSTRUCTION 21 β€” THE FALLBACK IS `personal`, AND IT IS THE ROOT CAUSE.
345
+ #
346
+ # Owner: *"By default every Field created is private until it is shared."*
347
+ #
348
+ # β›” THE BUG WAS AN ORDERING ONE, WHICH IS WHY IT LOOKED LIKE THE OPPOSITE OF ITSELF.
349
+ # `routes_shares.put_share` promotes FIRST and writes the request's own grant entries one line
350
+ # LATER, so on a first share this function cannot see who the field is being shared with. With
351
+ # `fallback="collaborative"` it therefore stamped `{'edit': 'collaborative'}` on EVERY first
352
+ # share β€” Karen-only included β€” and the bag was never re-derived afterwards. `set_grants`
353
+ # immediately replaced the transient `*` grant with Karen's, so the WALL was right the whole
354
+ # time; the permanent lie was the WORD, and the word is what the owner reads under Hide fields.
355
+ #
356
+ # β›” AN EXPLICIT BAG STILL WINS, AND THAT IS WHAT KEEPS THE OTHER CALLER BYTE-IDENTICAL.
357
+ # `grid_events.field_upsert` (the field editor's permission picker) sets
358
+ # `field['permissions']` in BOTH arms of its `may_change_permissions` branch before calling
359
+ # this, and only reaches it when that bag already says `collaborative` or `users`. The
360
+ # fallback is never consulted there, so this line moves nothing on that path.
361
+ #
362
+ # ⚠ THE CALL SITE CHANGES, NOT `stored_permissions`' OWN DEFAULT. That default answers a
363
+ # different question β€” *"what did an old field, written before this vocabulary existed,
364
+ # mean?"* β€” and `migrate_legacy_fields` still needs the old answer for its LEGACY promotion
365
+ # leg, which `verify_field_permissions.py` asserts to the letter.
366
+ #
367
+ # ⚠ `set_grants` BELOW IS LEFT IN PLACE. With `personal` it writes `[]` plus an owner, which is
368
+ # the shape `routes_customers.py`'s route claim states in full: *"the empty entry list is the
369
+ # point. `set_grants` keeps a record with an owner and no entries, so 'shared with nobody' is
370
+ # STORED and is a different fact from 'never shared'."* `reconcile_shared_permissions` reads
371
+ # exactly that difference, so dropping the call would make a freshly promoted column
372
+ # unclassifiable rather than private.
373
+ permissions = stored_permissions(shared, fallback="personal")
374
  shared["permissions"] = permissions
375
  shared["createdBy"] = str(shared.get("createdBy") or owner).strip().lower()
376
  shared_overlay.put_field(shared_key, key, shared, st=st)
platform/core/grid_events.py CHANGED
@@ -420,6 +420,92 @@ def _docs_key(ctx):
420
 
421
 
422
  # ------------------------------------------------------------------ the workspace family (X1 amd.)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
424
  """Durable Airtable-style view/schema/overlay state, with an honest session fallback.
425
 
@@ -427,6 +513,11 @@ def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
427
  with them. Own views win a same-id collision β€” a personal view is the user's own object and
428
  must never be shadowed by somebody else's share.
429
 
 
 
 
 
 
430
  ⚠ `allowed_pids` is a SECURITY parameter, not a convenience. A shared view carries
431
  `memberPids`, which the writer's scope validated on the way IN β€” but the READER may have a
432
  narrower scope. Handing a Fisch-scoped user a view whose member list was built by a
@@ -457,6 +548,69 @@ def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
457
  except Exception as _ge:
458
  _tel.error('sharedviews:granted', _ge)
459
  granted = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  if shared or granted:
461
  merged = dict(granted)
462
  merged.update(shared) # the everyone-bucket outranks a grant
@@ -478,9 +632,16 @@ def _granted_views(ctx, uname):
478
  The R10 registry stores bare ids, tenant-scoped through the SAME store handle this topic's
479
  bucket uses; the record itself is fetched out of the OWNER's personal stratum of THIS
480
  bucket. An id granted on another topic simply finds nothing here and contributes nothing
481
- (fail-closed). Each projected view is stamped `shared` / `sharedRole` / `owner` so the
482
- client can synthesise the "Shared with me" group and disable edit affordances for
483
  role=view β€” the stamps live on the PROJECTION only, never written back to the store.
 
 
 
 
 
 
 
484
  `role_for` is asked with is_admin=False deliberately: the stamp reports the GRANT (the
485
  registry listed only explicit entries), not the caller's rank.
486
  """
@@ -503,6 +664,12 @@ def _granted_views(ctx, uname):
503
  v['shared'] = True
504
  v['sharedRole'] = role
505
  v['owner'] = owner
 
 
 
 
 
 
506
  out[str(vid)] = v
507
 
508
  # ⭐ D-37 (2026-08-05) β€” THE FOLDER LEG. W20's R10 said "a folder share = its views ride
@@ -537,6 +704,8 @@ def _granted_views(ctx, uname):
537
  v['shared'] = True
538
  v['sharedRole'] = role
539
  v['owner'] = owner
 
 
540
  # The folder's NAME rides so the client can group these together later; today it
541
  # renders in the same "Shared with me" group the view leg feeds. Stamps live on the
542
  # PROJECTION only and are never written back, exactly as above.
 
420
 
421
 
422
  # ------------------------------------------------------------------ the workspace family (X1 amd.)
423
+ def _grant_marker(ctx, ws):
424
+ """`has_grants(view_id) -> bool` for this tenant, or None when nothing here is granted at all.
425
+
426
+ ⭐⭐ ONE PREDICATE, THREE MERGE LEGS β€” W40-T01, owner instruction 2, contract C1.
427
+ `table_workspace` assembles its `views` from three sources (granted / `__shared__` bucket /
428
+ the caller's own stratum) and a share mark has to mean the SAME thing on all three, or the
429
+ client is back to branching on who is looking. So the rule lives here once and the registry is
430
+ read ONCE per workspace read; a closure rather than three calls, because `granted_oids` exists
431
+ precisely to stop this becoming N reads of the whole grant bucket per rail.
432
+
433
+ ⭐⭐ THE ASYMMETRY THIS CLOSES. Owner, verbatim: *"A shared View must show the
434
+ shared/collaborative icon in EVERY account, not only the recipient's."* Contract C1 states the
435
+ meaning that makes that true: **`shared` on a view means "this view has grants", not "granted
436
+ to me"**. The receiver's copy has been stamped since wave 21 (`_granted_views`); the SHARER's
437
+ copy never was and structurally could not be, because `_granted_views` and `shared_views` both
438
+ EXCLUDE what the caller owns by design β€” so the person who did the sharing is the one person
439
+ the old projection could never tell.
440
+
441
+ "Carries a grant" is EITHER of two things, and the folder leg is not optional: a receiver
442
+ already gets `shared` stamped through `_granted_views`' folder pass (D-37), so answering only
443
+ the direct-view half here would re-create exactly the asymmetry instruction 2 reports, one
444
+ level down.
445
+
446
+ β›” THE SOURCE IS THE R10 GRANT REGISTRY ONLY (`core/shares.py`), AND THE NARROWING IS
447
+ DELIBERATE β€” not an oversight for the next reader to "fix". `routes_shares.py`'s own module
448
+ docstring warns that TWO SYSTEMS answer "is this shared" and they are not the same one (audit
449
+ S-4): the other is the legacy `__shared__` bucket (`permissions.edit in ('collaborative',
450
+ 'users')`). BUCKET MEMBERSHIP IS NOT A GRANT. A view living in that bucket is asked the same
451
+ question as any other β€” does the REGISTRY hold an entry for it β€” so a `collaborative` view
452
+ nobody ever granted carries no mark, and that stays booked rather than fixed here: marking it
453
+ would additionally need an `owner` stamp on that leg to keep the tooltip honest, which is a
454
+ copy change on a client file outside this ticket.
455
+
456
+ ⚠ WHICH IS WHY THE `__shared__` LEG STILL NEEDS ASKING, and it is the corner the own-views
457
+ leg cannot reach. `save_view` moves a `collaborative`/`users` view OUT of the creator's
458
+ personal stratum, so its OWNER has no copy in `ws['views']` at all; and `merged.update(shared)`
459
+ outranks the stamped `granted` copy, so a receiver's mark would be overwritten on the way
460
+ through. Both are answered by asking this predicate on that leg too. β›” The merge PRECEDENCE
461
+ is untouched β€” "the everyone-bucket outranks a grant" is deliberate and is not this ticket's
462
+ to reverse; the fix stamps the copy that wins, it does not re-arbitrate which copy wins.
463
+
464
+ ⚠ MEMBERSHIP OF THE ID IS THE WHOLE TEST, and there is one known false positive it cannot
465
+ see: a PINNED PER-USER id (`all-customers`, `tpl_overview`) exists once per user, so a grant
466
+ recorded against that id by anybody would mark every user's own copy. That is the same
467
+ first-match hazard `handle_one` documents at the foreign-view wall (W33-T13). It is decoration
468
+ on a view nobody shared, never a widening β€” and `granted_oids` cannot answer "whose" without
469
+ becoming the N-reads-per-rail shape it exists to avoid. Booked, not papered over.
470
+
471
+ ⚠ NO `createdBy` FILTER, on any leg. Ownership is not what this answers β€” "has grants" is a
472
+ property of the VIEW, which is the whole of C1 β€” and re-filtering on `createdBy` would drop
473
+ the mark off any record whose copy of it is absent or stale, the same class of trap that keeps
474
+ `scope_view` off the own-views leg. WHO is sharing is answered separately, by `sharedOut`.
475
+
476
+ ⚠ THE FOLDER MAP IS THE CALLER'S OWN `itemFolders`, which is the only placement map this read
477
+ has in hand. That is exact for the own-views leg. For a `__shared__` view it answers the
478
+ caller's own filing of it, and a folder grant on somebody else's folder cannot reach a view
479
+ in that bucket anyway (`find_folder` reads the owner's personal stratum, which a shared view
480
+ has already left) β€” so the direct-view half is what carries that leg in practice.
481
+ """
482
+ import core.shares as shares
483
+ # The SAME store handle `_granted_views` resolves, and asked for the same reason: the grants
484
+ # bucket is tenant-scoped and must be read out of the tenant whose workspace this is. Written
485
+ # as `_tops(ctx).st` rather than `_store_of(ctx)` so the two legs cannot drift on tenancy.
486
+ st = getattr(_tops(ctx), 'st', None)
487
+ view_oids = shares.granted_oids('view', st=st)
488
+ folder_oids = shares.granted_oids('folder', st=st)
489
+ if not view_oids and not folder_oids:
490
+ return None # nothing in this tenant is granted: no marks, no closure
491
+ # ⚠ `itemFolders` IS KEYED BY SURFACE FIRST (`{'views': {itemId: folderId}, …}`) β€” reading item
492
+ # ids off the top level finds the surface NAMES and matches nothing, which is the same trap
493
+ # `table_store.find_folder` carries a note about. It is already in the payload above, so the
494
+ # folder leg costs no extra store read.
495
+ placed = (ws.get('itemFolders') or {}).get('views') or {}
496
+
497
+ def _has_grants(view_id):
498
+ key = str(view_id)
499
+ if key in view_oids:
500
+ return True
501
+ fid = str(placed.get(key) or '')
502
+ # `fid` truthiness FIRST: '' must never match a grant stored under the empty-string oid,
503
+ # which would mark every unfiled view at once.
504
+ return bool(fid and fid in folder_oids)
505
+
506
+ return _has_grants
507
+
508
+
509
  def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
510
  """Durable Airtable-style view/schema/overlay state, with an honest session fallback.
511
 
 
513
  with them. Own views win a same-id collision β€” a personal view is the user's own object and
514
  must never be shadowed by somebody else's share.
515
 
516
+ ⭐ W40-T01 (owner instruction 2, contract C1): every view that HAS grants carries
517
+ `hasShares: True` whoever is asking, and the copy belonging to the account doing the sharing
518
+ additionally carries `sharedOut: True`. Stamped on ALL THREE merge legs from ONE predicate
519
+ (`_grant_marker`), on the PROJECTION only, and never written back.
520
+
521
  ⚠ `allowed_pids` is a SECURITY parameter, not a convenience. A shared view carries
522
  `memberPids`, which the writer's scope validated on the way IN β€” but the READER may have a
523
  narrower scope. Handing a Fisch-scoped user a view whose member list was built by a
 
548
  except Exception as _ge:
549
  _tel.error('sharedviews:granted', _ge)
550
  granted = {}
551
+ # ⭐⭐ W40-T01 / OWNER INSTRUCTION 2 / CONTRACT C1 β€” THE SHARE MARKS, ON THE TWO LEGS THAT
552
+ # ARRIVE UNSTAMPED. `granted` was already stamped by `_granted_views`; these two were not.
553
+ # β›” AND THIS SITS OUTSIDE THE `if shared or granted:` GUARD BELOW, deliberately. Four
554
+ # traps, all measured, each of which makes the "obvious" placement silently do nothing:
555
+ #
556
+ # β›” 1. THAT GUARD IS UNREACHABLE FOR THIS TICKET'S CASE. For the exact fixture the
557
+ # owner reports β€” I own a view, I shared it OUT, nothing is shared TO me β€” BOTH
558
+ # `shared` and `granted` are empty (`shared_views` and `_granted_views` each exclude
559
+ # what the caller owns, and a new view defaults to `permissions.edit='personal'` so
560
+ # it is not in the `__shared__` bucket either). The whole block is SKIPPED and
561
+ # `ws['views']` returns raw. Stamping "on the legs of the merge" is therefore not
562
+ # enough: the sharer's leg has to run unconditionally or it never runs at all.
563
+ #
564
+ # β›” 2. DO NOT ROUTE THESE THROUGH `scope_view`. Today own views only reach it when the
565
+ # caller has shares; make the comprehension unconditional and an own view whose
566
+ # `createdBy` is absent or mismatched starts being re-scoped, and `scope_view` with
567
+ # `allowed_pids=None` sets `memberPids: []` β€” the list, wiped, on the user's OWN view.
568
+ # Stamp beside that path, never into it. The granted/shared legs keep re-scoping
569
+ # exactly as before, and `scope_view` copies (`out = dict(view)`) so the stamps ride.
570
+ #
571
+ # β›” 3. `table_store.workspace()` RETURNS A SHALLOW COPY: the outer dict is fresh but each
572
+ # view VALUE is a live reference into the cached store document. Mutating one in place
573
+ # would poison the cache every other reader shares and could be written back. So a
574
+ # marked view is REPLACED with a copy; unmarked ones stay the same references they
575
+ # were, and nothing here is ever persisted (the `view_upsert` save path builds its
576
+ # record from a fixed key whitelist, so these stamps cannot survive a round trip).
577
+ # The `shared` leg is copied at the stamp for the same reason, rather than trusting
578
+ # `shared_views`' own `dict(v)` from a distance.
579
+ #
580
+ # β›” 4. THE `__shared__` LEG IS NOT OPTIONAL, and it is the corner leg 1 cannot reach.
581
+ # `merged.update(shared)` below OUTRANKS the stamped `granted` copy, so a receiver's
582
+ # mark would be overwritten on the way through; and the OWNER of such a view has no
583
+ # copy in `ws['views']` at all, because `save_view` moves a collaborative/users view
584
+ # OUT of the personal stratum. Unmarked in EVERY account: instruction 2's exact
585
+ # defect, one corner over. β›” The merge PRECEDENCE is untouched β€” stamping the copy
586
+ # that wins is not re-arbitrating which copy wins.
587
+ #
588
+ # `hasShares` is the marker, NOT `shared`: the client reads `view.shared` as "granted to
589
+ # ME" in two live places (it gates `mayEditView` and it groups the "Shared with me"
590
+ # section), so re-using that key would take the owner's own view away from them.
591
+ # `sharedOut` says WHO is doing the sharing β€” always true on the caller's own stratum, and
592
+ # on the `__shared__` leg only when the record's `createdBy` is this caller. It is what
593
+ # lets the client word the owner's tooltip as sharing OUT rather than re-using the
594
+ # receiver-worded copy, which C1 forbids.
595
+ # Absence is the negative: an unmarked view is left alone rather than stamped False.
596
+ try:
597
+ _has_grants = _grant_marker(ctx, ws) if (ws.get('views') or shared) else None
598
+ except Exception as _me:
599
+ # A share mark is DECORATION. It must never be the thing that takes down a workspace
600
+ # read, so it fails the way its two siblings above do: log and carry on unmarked.
601
+ _tel.error('sharedviews:marks', _me)
602
+ _has_grants = None
603
+ if _has_grants:
604
+ ws['views'] = {
605
+ vid: ({**v, 'hasShares': True, 'sharedOut': True}
606
+ if isinstance(v, dict) and _has_grants(vid) else v)
607
+ for vid, v in (ws.get('views') or {}).items()}
608
+ if shared:
609
+ shared = {
610
+ vid: ({**v, 'hasShares': True,
611
+ **({'sharedOut': True} if v.get('createdBy') == uname else {})}
612
+ if isinstance(v, dict) and _has_grants(vid) else v)
613
+ for vid, v in shared.items()}
614
  if shared or granted:
615
  merged = dict(granted)
616
  merged.update(shared) # the everyone-bucket outranks a grant
 
632
  The R10 registry stores bare ids, tenant-scoped through the SAME store handle this topic's
633
  bucket uses; the record itself is fetched out of the OWNER's personal stratum of THIS
634
  bucket. An id granted on another topic simply finds nothing here and contributes nothing
635
+ (fail-closed). Each projected view is stamped `shared` / `sharedRole` / `owner` / `hasShares`
636
+ so the client can synthesise the "Shared with me" group and disable edit affordances for
637
  role=view β€” the stamps live on the PROJECTION only, never written back to the store.
638
+ `hasShares` (W40-T01, contract C1) is the viewer-INDEPENDENT one: `shared` still means
639
+ "granted to me" and only ever appears here, while `hasShares` means "this view has grants"
640
+ and is stamped on ALL THREE of `table_workspace`'s merge legs β€” this one, the caller's own
641
+ stratum, and the `__shared__` bucket β€” from the single predicate `_grant_marker`.
642
+ ⚠ A view in BOTH this projection and the `__shared__` bucket keeps the bucket's copy, because
643
+ `merged.update(shared)` outranks it there; that copy is stamped on its own leg, which is why
644
+ stamping here is not sufficient on its own.
645
  `role_for` is asked with is_admin=False deliberately: the stamp reports the GRANT (the
646
  registry listed only explicit entries), not the caller's rank.
647
  """
 
664
  v['shared'] = True
665
  v['sharedRole'] = role
666
  v['owner'] = owner
667
+ # ⭐ W40-T01 (C1): the RECEIVER's half of the one marker both sides now carry. `shared`
668
+ # keeps its old meaning here β€” "granted to ME" β€” because the client still reads it that
669
+ # way; `hasShares` is the viewer-independent fact underneath it, and stamping it on this
670
+ # leg too is what lets the client draw ONE mark from ONE key instead of branching on who
671
+ # is looking. No `sharedOut`: this account is not the one sharing it.
672
+ v['hasShares'] = True
673
  out[str(vid)] = v
674
 
675
  # ⭐ D-37 (2026-08-05) β€” THE FOLDER LEG. W20's R10 said "a folder share = its views ride
 
704
  v['shared'] = True
705
  v['sharedRole'] = role
706
  v['owner'] = owner
707
+ v['hasShares'] = True # W40-T01 (C1) β€” the folder pass carries it too, or a
708
+ # folder share would still be marked on one side only
709
  # The folder's NAME rides so the client can group these together later; today it
710
  # renders in the same "Shared with me" group the view leg feeds. Stamps live on the
711
  # PROJECTION only and are never written back, exactly as above.
platform/core/perm_scope.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/core/shares.py CHANGED
@@ -1,296 +1,414 @@
1
- """core/shares.py β€” ONE grant registry for every shareable object (wave 20, owner ruling R10).
2
-
3
- WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
4
- already use** (specific users or everyone; role = view | edit), plus one manage-access editor
5
- that can add or revoke people later, on any of the three.
6
-
7
- WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
8
- (`core/table_store.py`) and that stays β€” moving it would rewrite every stored view for no gain.
9
- But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
10
- definition; giving each its own grant field would put the same three-line permission decision in
11
- three files owned by two sessions, which is how the three drift. One registry, one predicate,
12
- three callers.
13
-
14
- shares.set_grants(kind, oid, entries, owner=…, st=…) # replaces the whole grant set
15
- shares.grants(kind, oid, st=…) # -> {'owner': str, 'entries': [...]}
16
- shares.role_for(kind, oid, user, is_admin=…, st=…) # -> 'owner'|'edit'|'view'|None
17
- shares.shared_with(user, kind=…, st=…) # -> [oid] this user was granted
18
-
19
- THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
20
- `edit` = may also change the object's CONTENT. Neither ever means "may re-share": changing grants
21
- is the OWNER's (or an admin's), which is `table_store._may_administer`'s existing rule promoted to
22
- every kind. A collaborator who could rewrite grants could grant themselves sole ownership of
23
- somebody else's object, or quietly widen a users-scoped share to everyone.
24
-
25
- β›” AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β€” junk in the bucket, a missing
26
- owner, an unknown role string all resolve to None rather than to a default that opens something.
27
- [[aios-permissioning]]: no fail-open defaults, ever.
28
-
29
- ⚠ THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
30
- session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
31
- (`core.store`) is tenant #0 and exists for the same reason it does everywhere else β€” the ~28
32
- callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
33
- NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
34
- """
35
- import core.store as store
36
-
37
- #: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
38
- #: is a question across kinds β€” the "Shared with me" folder (R10) is exactly that query, and
39
- #: three separate buckets would make it three reads that can disagree about what a user can see.
40
- SHARES_KEY = 'object_shares'
41
-
42
- #: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
43
- #: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
44
- #:
45
- #: ⭐⭐ W38-T16 β€” `field` IS THE FOURTH, AND NOTHING IN THIS FILE BRANCHES ON IT. Every function
46
- #: below treats `kind` as an opaque bucket key (`_check_kind / grants / set_grants / role_for /
47
- #: may_see / may_edit / may_administer / shared_with / drop_objects`), so the kind's whole cost
48
- #: here is this tuple member. That is the point of one registry: the new object's WALL is written
49
- #: once in `core.perm_scope`, and its DOORS once in `routes_shares.py` β€” never a fourth
50
- #: permission decision in a fourth file ([[one-evaluator-per-question]]).
51
- KINDS = ('view', 'folder', 'database', 'field')
52
-
53
- #: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
54
- #: Spelled as a single character so it can never collide with a username (usernames are lower-case
55
- #: and non-empty by `core/users.py`, and are checked against this explicitly below).
56
- #:
57
- #: β›”β›” **AND WHAT THAT MEANS DEPENDS ON THE KIND β€” THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
58
- #: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
59
- #: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
60
- #: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
61
- #: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
62
- #: whole point:
63
- #: * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β€”
64
- #: the sentence holds. `require_session` plus the topic's own gate run first, and the
65
- #: receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
66
- #: merged, so a grant can only narrow-or-equal what that account could already reach.
67
- #: * `kind='database'` on a `ut_*` table β€” ⭐⭐ **THE SENTENCE HOLDS HERE TOO NOW, AND THAT IS
68
- #: W36-T21 / OWNER RULING R6 (audit S-8, CLOSED).** It did not until wave 36, and the reason
69
- #: is worth keeping: `routes_admin._clean_perms` 400'd any key outside
70
- #: `("customer_data", "product_data")`, so no row filter and no hidden field could even be
71
- #: DECLARED for a user table, `routes_tables.py` made zero `perm_scope` calls, and it passed
72
- #: `hidden_keys=frozenset()`. A `database` grant was therefore ALL-OR-NOTHING β€” every row,
73
- #: every column β€” and this registry was the only wall behind it.
74
- #:
75
- #: ⭐ WHAT CHANGED: `perm_scope.scoped_table` (contract C1) is the ONE door to any database's
76
- #: rows. `routes_tables` applies the permanent filter before `pids` is taken and the transitive
77
- #: hidden-field closure after `workspace_wire`, on EVERY `ut_*` read β€” the same code that walls
78
- #: `customer_data` β€” and a door that cannot apply them REFUSES rather than serving the lot. So a
79
- #: `database` grant is once again bounded by a second wall: it decides WHETHER an account reaches
80
- #: the database, and C1 decides WHICH rows and columns it then sees. An `*` grant still admits
81
- #: every account in the tenant, and each of them still only sees what their own wall allows.
82
- #:
83
- #: ⚠ `routes_shares.py`'s module docstring carries the OLD sentence at the other door and is in
84
- #: no wave-36 fence β€” the audit's own fix was "say so at both", so one of the two is now stale.
85
- #: Booked in `mailbox/C.md` (C-14) rather than edited across a fence
86
- #: [[two-gates-can-assert-opposite-things]].
87
- EVERYONE = '*'
88
-
89
- ROLES = ('view', 'edit')
90
-
91
- #: β›”β›” A FIELD's OBJECT ID IS TOPIC-QUALIFIED, AND THE SEPARATOR IS DECLARED HERE SO THERE IS ONE
92
- #: SPELLING OF IT. A bare field key is NOT unique: `notes` exists on a dozen databases, and a
93
- #: grant stored under it would admit a grantee to every `notes` column in the tenant at once β€”
94
- #: the widening direction, silently, forever. `table_key` is the qualifier because it is the same
95
- #: identifier `shared_overlay.bucket()` already keys the values by, so the grant and the data it
96
- #: governs are named by the same string ([[one-question-two-normalizers]]).
97
- #:
98
- #: ⚠ A `ut_*` key and a registry topic key both match `[a-z0-9_]+` and neither can contain `:`,
99
- #: so the split below is unambiguous in both directions.
100
- FIELD_OID_SEP = ':'
101
-
102
-
103
- def field_oid(table_key, field_key):
104
- """`"<table_key>:<field_key>"` β€” the share id of ONE column on ONE database."""
105
- table = str(table_key or '').strip()
106
- field = str(field_key or '').strip()
107
- if not table or not field:
108
- raise ValueError('shares.field_oid: a field share names BOTH a database and a column. '
109
- 'A bare field key repeats across tables and would grant all of them')
110
- return f'{table}{FIELD_OID_SEP}{field}'
111
-
112
-
113
- def split_field_oid(oid):
114
- """`(table_key, field_key)` or `(None, None)` for anything that is not a field oid.
115
-
116
- β›” FAIL-CLOSED ON JUNK, like every other read here: a caller that cannot learn WHICH database
117
- an id names must not fall back to "the one I happen to be looking at", which is how a grant
118
- on somebody else's column would be read as a grant on this one.
119
- """
120
- raw = str(oid or '')
121
- table, sep, field = raw.partition(FIELD_OID_SEP)
122
- if not sep or not table.strip() or not field.strip() or FIELD_OID_SEP in field:
123
- return (None, None)
124
- return (table.strip(), field.strip())
125
-
126
-
127
- def _st(st):
128
- return st if st is not None else store
129
-
130
-
131
- def _check_kind(kind):
132
- k = str(kind or '').strip().lower()
133
- if k not in KINDS:
134
- raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)}. '
135
- f'This door refuses to invent a namespace from a typo.')
136
- return k
137
-
138
-
139
- def _clean_entries(entries):
140
- """Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}].
141
-
142
- Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
143
- that just listed the people it is about to grant, so a rejected row must not abort the whole
144
- save β€” but an entry with an unknown ROLE must not be stored as something else's default
145
- either. Dropped, never coerced.
146
- """
147
- out, seen = [], set()
148
- for e in entries or ():
149
- if not isinstance(e, dict):
150
- continue
151
- user = str(e.get('user') or '').strip().lower()
152
- role = str(e.get('role') or '').strip().lower()
153
- if not user or role not in ROLES or user in seen:
154
- continue
155
- seen.add(user)
156
- out.append({'user': user, 'role': role})
157
- return out
158
-
159
-
160
- def grants(kind, oid, st=None):
161
- """`{'owner': str|None, 'entries': [{'user','role'}]}` β€” never raises on a junk bucket."""
162
- kind = _check_kind(kind)
163
- try:
164
- bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
165
- rec = bucket.get(str(oid)) or {}
166
- except Exception:
167
- return {'owner': None, 'entries': []}
168
- if not isinstance(rec, dict):
169
- return {'owner': None, 'entries': []}
170
- return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
171
- 'entries': _clean_entries(rec.get('entries'))}
172
-
173
-
174
- def set_grants(kind, oid, entries, owner=None, st=None):
175
- """REPLACE the grant set for one object. Returns the stored record.
176
-
177
- ⚠ REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
178
- entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
179
- manage-access editor R10 asks for is exactly "here is the list now".
180
- """
181
- kind = _check_kind(kind)
182
- oid = str(oid)
183
- clean = _clean_entries(entries)
184
- owner_l = str(owner).strip().lower() if owner else None
185
-
186
- def _apply(data):
187
- by_kind = dict(data.get(kind) or {})
188
- prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
189
- # The owner is STICKY: set once, and a later save that omits it must not orphan the
190
- # object. An ownerless grant record cannot answer "who may re-share this", so every
191
- # administer check would fail closed and the object would become unmanageable.
192
- keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
193
- if prior.get('owner') else None)
194
- if not clean and not keep_owner:
195
- by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
196
- else:
197
- by_kind[oid] = {'owner': keep_owner, 'entries': clean}
198
- data[kind] = by_kind
199
- return data
200
-
201
- _st(st).update(SHARES_KEY, _apply, flush='async')
202
- return grants(kind, oid, st=st)
203
-
204
-
205
- def role_for(kind, oid, user, is_admin=False, st=None):
206
- """`'owner'` | `'edit'` | `'view'` | `None` β€” the caller's effective role, fail-closed.
207
-
208
- An ADMIN reads as `'owner'`: an admin who could not administer an object could not
209
- administer the tenant either, which is `table_store._may_administer`'s existing rule and is
210
- kept identical here so the two cannot disagree about the same view.
211
- """
212
- user = str(user or '').strip().lower()
213
- if not user:
214
- return None
215
- rec = grants(kind, oid, st=st)
216
- if is_admin or (rec['owner'] and rec['owner'] == user):
217
- return 'owner'
218
- best = None
219
- for e in rec['entries']:
220
- if e['user'] == user or e['user'] == EVERYONE:
221
- # The STRONGER of the two wins when both a personal and an everyone grant exist:
222
- # naming somebody explicitly is how you RAISE them above the room, so an
223
- # everyone-view + alice-edit pair must leave alice editing.
224
- if e['role'] == 'edit':
225
- return 'edit'
226
- best = best or 'view'
227
- return best
228
-
229
-
230
- def may_see(kind, oid, user, is_admin=False, st=None):
231
- return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
232
-
233
-
234
- def may_edit(kind, oid, user, is_admin=False, st=None):
235
- return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
236
-
237
-
238
- def may_administer(kind, oid, user, is_admin=False, st=None):
239
- """Only the owner or an admin may change grants or delete. See the module note on why this
240
- is deliberately narrower than `may_edit`."""
241
- return role_for(kind, oid, user, is_admin=is_admin, st=st) == 'owner'
242
-
243
-
244
- def shared_with(user, kind=None, st=None):
245
- """Every object id this user has been granted (excluding what they own).
246
-
247
- This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
248
- you made is not something shared *with* you, and listing it there would make the system
249
- folder a duplicate of the rail above it.
250
- """
251
- user = str(user or '').strip().lower()
252
- if not user:
253
- return {}
254
- try:
255
- data = _st(st).get(SHARES_KEY) or {}
256
- except Exception:
257
- return {}
258
- out = {}
259
- for k in ([_check_kind(kind)] if kind else KINDS):
260
- hits = []
261
- for oid, rec in (data.get(k) or {}).items():
262
- if not isinstance(rec, dict):
263
- continue
264
- owner = str(rec.get('owner') or '').strip().lower()
265
- if owner == user:
266
- continue
267
- for e in _clean_entries(rec.get('entries')):
268
- if e['user'] in (user, EVERYONE):
269
- hits.append(str(oid))
270
- break
271
- out[k] = sorted(hits)
272
- return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
273
-
274
-
275
- def drop_objects(pairs, st=None):
276
- """Remove whole grant RECORDS, owner husk included β€” wave 21, item 6a (C3).
277
-
278
- A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
279
- into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
280
- transaction for the whole sweep β€” a table delete drops its database grant plus a view
281
- grant per view that lived in its bucket."""
282
- want = {}
283
- for kind, oid in pairs or ():
284
- want.setdefault(_check_kind(kind), set()).add(str(oid))
285
- if not want:
286
- return
287
-
288
- def _apply(data):
289
- for kind, oids in want.items():
290
- by_kind = data.get(kind)
291
- if isinstance(by_kind, dict):
292
- for oid in oids:
293
- by_kind.pop(oid, None)
294
- return data
295
-
296
- _st(st).update(SHARES_KEY, _apply, flush='async')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """core/shares.py β€” ONE grant registry for every shareable object (wave 20, owner ruling R10).
2
+
3
+ WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
4
+ already use** (specific users or everyone; role = view | edit), plus one manage-access editor
5
+ that can add or revoke people later, on any of the three.
6
+
7
+ WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
8
+ (`core/table_store.py`) and that stays β€” moving it would rewrite every stored view for no gain.
9
+ But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
10
+ definition; giving each its own grant field would put the same three-line permission decision in
11
+ three files owned by two sessions, which is how the three drift. One registry, one predicate,
12
+ three callers.
13
+
14
+ shares.set_grants(kind, oid, entries, owner=…, st=…) # replaces the whole grant set
15
+ shares.grants(kind, oid, st=…) # -> {'owner': str, 'entries': [...]}
16
+ shares.role_for(kind, oid, user, is_admin=…, st=…) # -> 'owner'|'edit'|'view'|None
17
+ shares.max_grantable_role(kind, oid, user, …) # -> 'edit'|'view'|None (R4's ceiling)
18
+ shares.shared_with(user, kind=…, st=…) # -> [oid] this user was granted
19
+
20
+ THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
21
+ `edit` = may also change the object's CONTENT.
22
+
23
+ ⭐⭐ AND ON A VIEW, `edit` NOW ALSO MEANS "MAY RE-SHARE" β€” OWNER RULING R4, instruction 4 (*"Edit
24
+ View so a member can share a View as well, not just an admin"*), built as W40-T02. This REVERSES
25
+ the flat owner-or-admin rule this paragraph used to state, and it reverses it for ONE kind:
26
+ `RESHARE_KINDS` below is where the widening is spelled, once, and `folder` / `database` / `field`
27
+ are untouched β€” still the owner's or an admin's, for the reason given at that constant.
28
+
29
+ β›” WHICH HALF OF THE OLD PROTECTION SURVIVES, BECAUSE THE FEAR IT NAMED WAS REAL AND ONLY HALF OF
30
+ IT IS ANSWERED. The sentence here used to be *"a collaborator who could rewrite grants could grant
31
+ themselves sole ownership of somebody else's object, or quietly widen a users-scoped share to
32
+ everyone"*. Taking those one at a time:
33
+ * **SOLE OWNERSHIP IS BLOCKED β€” and now stated rather than incidental.** The owner is STICKY in
34
+ `set_grants` and `routes_shares.put_share` passes the EXISTING owner straight back through, so
35
+ a re-share cannot transfer ownership and no grantee can lock the creator out of their own view.
36
+ Ownership moves for nobody, by any path this registry offers.
37
+ * **WIDENING IS BOUNDED, NOT BLOCKED, AND THAT IS R4's DELIBERATE COST.** A re-share may never
38
+ exceed the role the re-sharer holds, and the STRICTER reading of that ships: an `edit` grantee
39
+ may add somebody β€” `*` included β€” at `view`, and can never mint `edit` access for anyone.
40
+ Handing out `edit` stays the owner's (or an admin's) alone. A `view` grantee still cannot share
41
+ at all. `max_grantable_role` is that ceiling, and `routes_shares.put_share` is where it bites.
42
+
43
+ β›” AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β€” junk in the bucket, a missing
44
+ owner, an unknown role string all resolve to None rather than to a default that opens something.
45
+ [[aios-permissioning]]: no fail-open defaults, ever.
46
+
47
+ ⚠ THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
48
+ session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
49
+ (`core.store`) is tenant #0 and exists for the same reason it does everywhere else β€” the ~28
50
+ callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
51
+ NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
52
+ """
53
+ import core.store as store
54
+
55
+ #: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
56
+ #: is a question across kinds β€” the "Shared with me" folder (R10) is exactly that query, and
57
+ #: three separate buckets would make it three reads that can disagree about what a user can see.
58
+ SHARES_KEY = 'object_shares'
59
+
60
+ #: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
61
+ #: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
62
+ #:
63
+ #: ⭐⭐ W38-T16 β€” `field` IS THE FOURTH, AND NOTHING IN THIS FILE BRANCHES ON IT. Every function
64
+ #: below treats `kind` as an opaque bucket key (`_check_kind / grants / set_grants / role_for /
65
+ #: may_see / may_edit / may_administer / shared_with / drop_objects`), so the kind's whole cost
66
+ #: here is this tuple member. That is the point of one registry: the new object's WALL is written
67
+ #: once in `core.perm_scope`, and its DOORS once in `routes_shares.py` β€” never a fourth
68
+ #: permission decision in a fourth file ([[one-evaluator-per-question]]).
69
+ KINDS = ('view', 'folder', 'database', 'field')
70
+
71
+ #: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
72
+ #: Spelled as a single character so it can never collide with a username (usernames are lower-case
73
+ #: and non-empty by `core/users.py`, and are checked against this explicitly below).
74
+ #:
75
+ #: β›”β›” **AND WHAT THAT MEANS DEPENDS ON THE KIND β€” THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
76
+ #: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
77
+ #: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
78
+ #: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
79
+ #: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
80
+ #: whole point:
81
+ #: * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β€”
82
+ #: the sentence holds. `require_session` plus the topic's own gate run first, and the
83
+ #: receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
84
+ #: merged, so a grant can only narrow-or-equal what that account could already reach.
85
+ #: * `kind='database'` on a `ut_*` table β€” ⭐⭐ **THE SENTENCE HOLDS HERE TOO NOW, AND THAT IS
86
+ #: W36-T21 / OWNER RULING R6 (audit S-8, CLOSED).** It did not until wave 36, and the reason
87
+ #: is worth keeping: `routes_admin._clean_perms` 400'd any key outside
88
+ #: `("customer_data", "product_data")`, so no row filter and no hidden field could even be
89
+ #: DECLARED for a user table, `routes_tables.py` made zero `perm_scope` calls, and it passed
90
+ #: `hidden_keys=frozenset()`. A `database` grant was therefore ALL-OR-NOTHING β€” every row,
91
+ #: every column β€” and this registry was the only wall behind it.
92
+ #:
93
+ #: ⭐ WHAT CHANGED: `perm_scope.scoped_table` (contract C1) is the ONE door to any database's
94
+ #: rows. `routes_tables` applies the permanent filter before `pids` is taken and the transitive
95
+ #: hidden-field closure after `workspace_wire`, on EVERY `ut_*` read β€” the same code that walls
96
+ #: `customer_data` β€” and a door that cannot apply them REFUSES rather than serving the lot. So a
97
+ #: `database` grant is once again bounded by a second wall: it decides WHETHER an account reaches
98
+ #: the database, and C1 decides WHICH rows and columns it then sees. An `*` grant still admits
99
+ #: every account in the tenant, and each of them still only sees what their own wall allows.
100
+ #:
101
+ #: ⚠ `routes_shares.py`'s module docstring carries the OLD sentence at the other door and is in
102
+ #: no wave-36 fence β€” the audit's own fix was "say so at both", so one of the two is now stale.
103
+ #: Booked in `mailbox/C.md` (C-14) rather than edited across a fence
104
+ #: [[two-gates-can-assert-opposite-things]].
105
+ EVERYONE = '*'
106
+
107
+ ROLES = ('view', 'edit')
108
+
109
+ #: ⭐⭐ THE KINDS AN `edit` GRANTEE MAY RE-SHARE β€” R4 / W40-T02, and it is `view` ALONE.
110
+ #:
111
+ #: R4's words are "may re-share **A VIEW**", and the widening goes no further than the ruling
112
+ #: names. That restraint is the whole reason this is a tuple rather than a bare `role == 'edit'`
113
+ #: arm inside `may_administer`: every predicate in this file treats `kind` as an opaque bucket key
114
+ #: (see the note on `KINDS`), so an UNGATED widening would reach `folder`, `database` and `field`
115
+ #: in the same line β€” handing an `edit` grantee on a `ut_*` DATABASE the power to re-share the
116
+ #: whole table. The asymmetry is one of blast radius: a view is one saved SELECTION, opened through
117
+ #: the receiver's own module wall and row scope; a database grant admits an account to a database.
118
+ #: `routes_shares.py`'s module docstring carries that argument at the door where it is enforced.
119
+ #:
120
+ #: ⚠ ONE SPELLING, DELIBERATELY. `may_administer` and `max_grantable_role` both consult this, so
121
+ #: "who may open the editor" and "what may they hand out" cannot come apart about the same kind.
122
+ RESHARE_KINDS = ('view',)
123
+
124
+ #: β›”β›” A FIELD's OBJECT ID IS TOPIC-QUALIFIED, AND THE SEPARATOR IS DECLARED HERE SO THERE IS ONE
125
+ #: SPELLING OF IT. A bare field key is NOT unique: `notes` exists on a dozen databases, and a
126
+ #: grant stored under it would admit a grantee to every `notes` column in the tenant at once β€”
127
+ #: the widening direction, silently, forever. `table_key` is the qualifier because it is the same
128
+ #: identifier `shared_overlay.bucket()` already keys the values by, so the grant and the data it
129
+ #: governs are named by the same string ([[one-question-two-normalizers]]).
130
+ #:
131
+ #: ⚠ A `ut_*` key and a registry topic key both match `[a-z0-9_]+` and neither can contain `:`,
132
+ #: so the split below is unambiguous in both directions.
133
+ FIELD_OID_SEP = ':'
134
+
135
+
136
+ def field_oid(table_key, field_key):
137
+ """`"<table_key>:<field_key>"` β€” the share id of ONE column on ONE database."""
138
+ table = str(table_key or '').strip()
139
+ field = str(field_key or '').strip()
140
+ if not table or not field:
141
+ raise ValueError('shares.field_oid: a field share names BOTH a database and a column. '
142
+ 'A bare field key repeats across tables and would grant all of them')
143
+ return f'{table}{FIELD_OID_SEP}{field}'
144
+
145
+
146
+ def split_field_oid(oid):
147
+ """`(table_key, field_key)` or `(None, None)` for anything that is not a field oid.
148
+
149
+ β›” FAIL-CLOSED ON JUNK, like every other read here: a caller that cannot learn WHICH database
150
+ an id names must not fall back to "the one I happen to be looking at", which is how a grant
151
+ on somebody else's column would be read as a grant on this one.
152
+ """
153
+ raw = str(oid or '')
154
+ table, sep, field = raw.partition(FIELD_OID_SEP)
155
+ if not sep or not table.strip() or not field.strip() or FIELD_OID_SEP in field:
156
+ return (None, None)
157
+ return (table.strip(), field.strip())
158
+
159
+
160
+ def _st(st):
161
+ return st if st is not None else store
162
+
163
+
164
+ def _check_kind(kind):
165
+ k = str(kind or '').strip().lower()
166
+ if k not in KINDS:
167
+ raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)}. '
168
+ f'This door refuses to invent a namespace from a typo.')
169
+ return k
170
+
171
+
172
+ def _clean_entries(entries):
173
+ """Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}].
174
+
175
+ Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
176
+ that just listed the people it is about to grant, so a rejected row must not abort the whole
177
+ save β€” but an entry with an unknown ROLE must not be stored as something else's default
178
+ either. Dropped, never coerced.
179
+ """
180
+ out, seen = [], set()
181
+ for e in entries or ():
182
+ if not isinstance(e, dict):
183
+ continue
184
+ user = str(e.get('user') or '').strip().lower()
185
+ role = str(e.get('role') or '').strip().lower()
186
+ if not user or role not in ROLES or user in seen:
187
+ continue
188
+ seen.add(user)
189
+ out.append({'user': user, 'role': role})
190
+ return out
191
+
192
+
193
+ def grants(kind, oid, st=None):
194
+ """`{'owner': str|None, 'entries': [{'user','role'}]}` β€” never raises on a junk bucket."""
195
+ kind = _check_kind(kind)
196
+ try:
197
+ bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
198
+ rec = bucket.get(str(oid)) or {}
199
+ except Exception:
200
+ return {'owner': None, 'entries': []}
201
+ if not isinstance(rec, dict):
202
+ return {'owner': None, 'entries': []}
203
+ return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
204
+ 'entries': _clean_entries(rec.get('entries'))}
205
+
206
+
207
+ def set_grants(kind, oid, entries, owner=None, st=None):
208
+ """REPLACE the grant set for one object. Returns the stored record.
209
+
210
+ ⚠ REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
211
+ entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
212
+ manage-access editor R10 asks for is exactly "here is the list now".
213
+ """
214
+ kind = _check_kind(kind)
215
+ oid = str(oid)
216
+ clean = _clean_entries(entries)
217
+ owner_l = str(owner).strip().lower() if owner else None
218
+
219
+ def _apply(data):
220
+ by_kind = dict(data.get(kind) or {})
221
+ prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
222
+ # The owner is STICKY: set once, and a later save that omits it must not orphan the
223
+ # object. An ownerless grant record cannot answer "who may re-share this", so every
224
+ # administer check would fail closed and the object would become unmanageable.
225
+ keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
226
+ if prior.get('owner') else None)
227
+ if not clean and not keep_owner:
228
+ by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
229
+ else:
230
+ by_kind[oid] = {'owner': keep_owner, 'entries': clean}
231
+ data[kind] = by_kind
232
+ return data
233
+
234
+ _st(st).update(SHARES_KEY, _apply, flush='async')
235
+ return grants(kind, oid, st=st)
236
+
237
+
238
+ def role_for(kind, oid, user, is_admin=False, st=None):
239
+ """`'owner'` | `'edit'` | `'view'` | `None` β€” the caller's effective role, fail-closed.
240
+
241
+ An ADMIN reads as `'owner'`: an admin who could not administer an object could not
242
+ administer the tenant either, which is `table_store._may_administer`'s existing rule and is
243
+ kept identical here so the two cannot disagree about the same view.
244
+ """
245
+ user = str(user or '').strip().lower()
246
+ if not user:
247
+ return None
248
+ rec = grants(kind, oid, st=st)
249
+ if is_admin or (rec['owner'] and rec['owner'] == user):
250
+ return 'owner'
251
+ best = None
252
+ for e in rec['entries']:
253
+ if e['user'] == user or e['user'] == EVERYONE:
254
+ # The STRONGER of the two wins when both a personal and an everyone grant exist:
255
+ # naming somebody explicitly is how you RAISE them above the room, so an
256
+ # everyone-view + alice-edit pair must leave alice editing.
257
+ if e['role'] == 'edit':
258
+ return 'edit'
259
+ best = best or 'view'
260
+ return best
261
+
262
+
263
+ def may_see(kind, oid, user, is_admin=False, st=None):
264
+ return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
265
+
266
+
267
+ def may_edit(kind, oid, user, is_admin=False, st=None):
268
+ return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
269
+
270
+
271
+ def may_administer(kind, oid, user, is_admin=False, st=None):
272
+ """May this caller change WHO ELSE reaches the object, and revoke them?
273
+
274
+ ⭐⭐ R4 / W40-T02 β€” AN `edit` GRANTEE ANSWERS TRUE, ON A `RESHARE_KINDS` KIND. Owner
275
+ instruction 4: *"Edit View so a member can share a View as well, not just an admin"*. That
276
+ reverses this function's old owner-or-admin rule for exactly one kind; the module note says
277
+ which half of the protection survives and `RESHARE_KINDS` says why it stops at `view`.
278
+
279
+ β›” ADMINISTERING IS NOT GRANTING, AND THE SECOND QUESTION HAS ITS OWN PREDICATE. True here
280
+ means "may open the editor and rewrite the list"; it does NOT mean every role is theirs to
281
+ hand out. `max_grantable_role` is the ceiling, and R4's two halves are only both honoured if
282
+ a caller consults both ([[one-evaluator-per-question]]).
283
+
284
+ β›” THE GATE IS THE KIND, NOT THE ROLE ALONE. `role_for` is deliberately kind-agnostic, so
285
+ testing `== 'edit'` without `RESHARE_KINDS` would widen all four kinds in one line.
286
+ """
287
+ role = role_for(kind, oid, user, is_admin=is_admin, st=st)
288
+ if role == 'owner':
289
+ return True
290
+ return role == 'edit' and _check_kind(kind) in RESHARE_KINDS
291
+
292
+
293
+ def max_grantable_role(kind, oid, user, is_admin=False, st=None):
294
+ """The STRONGEST role this caller may hand SOMEBODY ELSE on this object, fail-closed.
295
+
296
+ `'edit'` for the owner or an admin Β· `'view'` for an `edit` grantee on a `RESHARE_KINDS` kind
297
+ Β· `None` for anybody who may not administer the object at all.
298
+
299
+ ⭐⭐ WHY A SECOND PREDICATE RATHER THAN A FLAG ON `may_administer` (R4 / W40-T02). R4 grants an
300
+ `edit` holder the right to re-share and caps it in the same breath: *"a re-share may never
301
+ exceed the role the re-sharer holds"*. Those are two different questions, and a single boolean
302
+ answering both is exactly how the cap gets dropped by the next caller that only needs the door.
303
+
304
+ β›” THE NARROWER OF R4's TWO READINGS SHIPS, AND ON PURPOSE. "Never exceed the role you hold"
305
+ reads either as *may grant up to and including `edit`* (an edit holder confers edit) or as *may
306
+ confer strictly less than the owner can*. This returns `'view'` β€” the second β€” because it is the
307
+ FAIL-CLOSED direction. A wrong `'view'` costs the owner one click to raise somebody; a wrong
308
+ `'edit'` lets a chain of collaborators propagate edit access the owner never approved, with
309
+ nothing in the store recording who widened it. W40-T02's `done-when` pins the same reading.
310
+
311
+ ⚠ THIS CAPS WHAT A CALLER GRANTS, NOT WHAT THE STORED SET ALREADY HOLDS, and the difference is
312
+ load-bearing. `routes_shares.put_share` enforces it against the DELTA β€” a name arriving at
313
+ `edit`, or an existing `view` grantee raised to it β€” never against every row of a body, because
314
+ the PUT REPLACES and the client therefore re-sends the whole list, the re-sharer's own `edit`
315
+ row included. The evidence for that is at the call site, where the body is.
316
+ """
317
+ role = role_for(kind, oid, user, is_admin=is_admin, st=st)
318
+ if role == 'owner':
319
+ return 'edit'
320
+ if role == 'edit' and _check_kind(kind) in RESHARE_KINDS:
321
+ return 'view'
322
+ return None
323
+
324
+
325
+ def shared_with(user, kind=None, st=None):
326
+ """Every object id this user has been granted (excluding what they own).
327
+
328
+ This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
329
+ you made is not something shared *with* you, and listing it there would make the system
330
+ folder a duplicate of the rail above it.
331
+ """
332
+ user = str(user or '').strip().lower()
333
+ if not user:
334
+ return {}
335
+ try:
336
+ data = _st(st).get(SHARES_KEY) or {}
337
+ except Exception:
338
+ return {}
339
+ out = {}
340
+ for k in ([_check_kind(kind)] if kind else KINDS):
341
+ hits = []
342
+ for oid, rec in (data.get(k) or {}).items():
343
+ if not isinstance(rec, dict):
344
+ continue
345
+ owner = str(rec.get('owner') or '').strip().lower()
346
+ if owner == user:
347
+ continue
348
+ for e in _clean_entries(rec.get('entries')):
349
+ if e['user'] in (user, EVERYONE):
350
+ hits.append(str(oid))
351
+ break
352
+ out[k] = sorted(hits)
353
+ return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
354
+
355
+
356
+ def granted_oids(kind, st=None):
357
+ """Every object id of one `kind` that carries AT LEAST ONE grant entry β€” in ONE bucket read.
358
+
359
+ ⭐ W40-T01 / OWNER INSTRUCTION 2 ("a shared View must show the shared icon in EVERY account,
360
+ not only the recipient's"). `shared_with` answers *what was granted TO me* and deliberately
361
+ EXCLUDES what the caller owns β€” so it structurally cannot answer the other question a share
362
+ mark asks: *does this object have grants at all*. That question has no viewer in it, which is
363
+ why this is a separate function rather than a flag bolted onto `shared_with`.
364
+
365
+ ⚠ ONE READ, NOT N β€” and that is the whole reason this exists rather than a loop at the caller.
366
+ The obvious spelling is `grants(kind, oid)` per view, and `grants` re-reads the WHOLE bucket
367
+ on every call; a busy rail carries dozens of views, so painting one icon would cost dozens of
368
+ full reads of the tenant's entire grant registry. This reads the bucket once and hands back a
369
+ set the caller tests in O(1).
370
+
371
+ β›” AN ENTRY IS WHAT COUNTS, NOT A RECORD. `set_grants` keeps an owner-only HUSK after a full
372
+ revoke β€” the owner is sticky, deliberately, see its own note β€” so testing for the record's
373
+ mere EXISTENCE would leave the mark lit forever after the last person was removed. That is
374
+ exactly the revoke case, so it is the difference between this being right and being decorative
375
+ noise. `_clean_entries` is the same normaliser every other read here uses, so a junk entry
376
+ cannot mark an object either.
377
+
378
+ β›” FAIL-CLOSED like the rest of this module: an unreadable bucket answers the EMPTY set β€” no
379
+ marks β€” never a default that claims something is shared. An unknown KIND still RAISES, exactly
380
+ as `grants` / `shared_with` / `drop_objects` do: that is a typo in a caller, not junk in the
381
+ store, and swallowing it into "nothing is shared" would hide a caller that never works.
382
+ """
383
+ kind = _check_kind(kind)
384
+ try:
385
+ by_kind = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
386
+ rows = list(by_kind.items())
387
+ except Exception:
388
+ return set()
389
+ return {str(oid) for oid, rec in rows
390
+ if isinstance(rec, dict) and _clean_entries(rec.get('entries'))}
391
+
392
+
393
+ def drop_objects(pairs, st=None):
394
+ """Remove whole grant RECORDS, owner husk included β€” wave 21, item 6a (C3).
395
+
396
+ A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
397
+ into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
398
+ transaction for the whole sweep β€” a table delete drops its database grant plus a view
399
+ grant per view that lived in its bucket."""
400
+ want = {}
401
+ for kind, oid in pairs or ():
402
+ want.setdefault(_check_kind(kind), set()).add(str(oid))
403
+ if not want:
404
+ return
405
+
406
+ def _apply(data):
407
+ for kind, oids in want.items():
408
+ by_kind = data.get(kind)
409
+ if isinstance(by_kind, dict):
410
+ for oid in oids:
411
+ by_kind.pop(oid, None)
412
+ return data
413
+
414
+ _st(st).update(SHARES_KEY, _apply, flush='async')
platform/core/users.py CHANGED
@@ -1,24 +1,24 @@
1
- """Per-user accounts for the platform, persisted in the HF Dataset store (users.json).
2
-
3
- Passwords are salted + PBKDF2-HMAC-SHA256 (200k iterations) β€” never stored or logged in plaintext.
4
- A bootstrap 'admin' account is seeded from APP_PASSWORD so the owner can always log in and create
5
- users; APP_PASSWORD also works as an emergency master for 'admin' if the registry is unreachable.
6
-
7
- Each account carries BU access ('all' or a list of team-ids [5=Fisch, 6=Royal]) which drives
8
- allowed_bus() β€” the basis for per-Business-Unit permissioning (a Royal-only user never sees Fisch).
9
- """
10
- import os
11
- import hmac
12
- import hashlib
13
- import secrets
14
-
15
- import core.store as store
16
-
17
- BU_LABELS = {5: 'Fisch', 6: 'Royal'}
18
- _ITER = 200_000
19
-
20
- #: Wave 15 C-PERM β€” the explicit-resolution marker. Mirrors `core.perm_scope.PERMS_VERSION`;
21
- #: kept as a literal here so `users` does not import the permission layer it is read by.
22
  PERMS_VERSION = 1
23
 
24
 
@@ -64,451 +64,488 @@ def qa_identity(username, pw=None):
64
  return {'username': _QA_USERNAME, 'name': 'QA Runner', 'role': 'user',
65
  'bus': 'all', 'modules': ['customers'], 'tenant': _QA_TENANT,
66
  'epoch': qa_epoch, 'qa_runner': True}
67
-
68
-
69
- def _hash(pw, salt):
70
- return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex()
71
-
72
-
73
- def _record(pw, name, role, bus, active=True, modules='all', agent=None, email=None,
74
- perms=None, tenant='royal-imports', platform_admin=False):
75
- salt = secrets.token_hex(16)
76
- rec = {'salt': salt, 'hash': _hash(pw, salt), 'name': name, 'role': role,
77
- 'bus': bus, 'active': active, 'modules': modules,
78
- 'agent': agent or None, 'email': email or None,
79
- # Wave 18 (C1-TENANT, R1): the account's COMPANY. Absent == 'royal-imports' on every
80
- # pre-wave record β€” no migration. Login binds the session to THIS value; the posted
81
- # tenant field can hint but never override it.
82
- 'tenant': str(tenant or 'royal-imports').strip().lower()}
83
- if platform_admin is True:
84
- # Wave 19 (R3): the PLATFORM-operator flag β€” half of `core.platform_admin`'s double lock
85
- # (the other half is `tenant == 'loopable'`). Written ONLY for True, so every record that
86
- # is not deliberately promoted keeps its pre-wave shape and answers False by absence.
87
- # There is no UI writer and there never should be: it is set by provisioning, on purpose.
88
- rec['platform_admin'] = True
89
- if perms is not None:
90
- # Wave 15 C-PERM. A record written WITH perms is migrated by construction β€” the marker
91
- # and the block are set together, here, so no writer can create one without the other.
92
- # (`core.perm_scope` reads an unmarked record as legacy, so a block without its marker
93
- # would be silently ignored; a marker without a block would deny everything.)
94
- rec['perms'] = perms
95
- rec['perms_v'] = PERMS_VERSION
96
- return rec
97
-
98
-
99
- def registry():
100
- return store.get('users')
101
-
102
-
103
- def ensure_bootstrap():
104
- """Seed an 'admin' account from APP_PASSWORD ONLY on a truly fresh store (no users file yet).
105
- Idempotent; no-op if the store is unavailable (the app then falls back to the master-password
106
- path in verify()).
107
-
108
- Critically, this NEVER overwrites an existing registry: it seeds only when store.exists('users')
109
- is definitively False. A transient read failure at startup used to return {} and make this
110
- re-seed just {admin} over the real accounts β€” that is the bug that wiped users on restart."""
111
- if not store.available():
112
- return
113
- if store.exists('users'): # present, or uncertain -> never clobber
114
- return
115
- try:
116
- reg = store.get('users', fresh=True)
117
- except Exception:
118
- return
119
- if reg:
120
- return
121
- master = os.environ.get('APP_PASSWORD', '')
122
- if not master:
123
- return
124
- try:
125
- store.put('users', {'admin': _record(master, 'Administrator', 'admin', 'all')})
126
- except Exception:
127
- pass
128
-
129
-
130
- def _public(username, u):
131
- return {'username': username, 'name': u.get('name', username),
132
- 'role': u.get('role', 'user'), 'bus': u.get('bus', 'all'),
133
- 'modules': u.get('modules', 'all'),
134
- 'agent': u.get('agent'), 'email': u.get('email'),
135
- # Wave 18 (C1-TENANT): the session's tenant binding travels on the projection or it
136
- # does not travel β€” the same rule the perms block states below.
137
- 'tenant': str(u.get('tenant') or 'royal-imports').strip().lower(),
138
- # Wave 14 C-AVATAR: the profile photo is public-safe by definition (it is served
139
- # to every grid session via the workspace map); without it here the API session's
140
- # user record silently drops it and /me can never show your own photo.
141
- 'avatar': u.get('avatar') or None,
142
- # β›” WAVE 15 C-PERM β€” THE WALL TRAVELS ON THIS PROJECTION OR IT DOES NOT TRAVEL.
143
- # `deps._user_for` builds every API session from `_public()`, so a `perms` block
144
- # dropped here is a restricted account served as an unrestricted one β€” silently, on
145
- # every route, with nothing to notice. `perms_v` must ride ALONG WITH it and for the
146
- # same reason inverted: the marker without the block denies everything, the block
147
- # without the marker is ignored. Two keys, one fact, never separated.
148
- # `verify_api` asserts a restricted user's SESSION OBJECT carries both, at the mount
149
- # rather than by grep β€” a projection is exactly the kind of wiring that looks
150
- # present in three files and is absent in the one that runs.
151
- **({'perms': u['perms']} if isinstance(u.get('perms'), dict) else {}),
152
- **({'perms_v': int(u['perms_v'] or 0)} if u.get('perms_v') else {}),
153
- # β›” WAVE 19 R3 β€” THE SAME RULE, ON A NEW FIELD. `deps._user_for` builds every API
154
- # session from this projection, so the platform-admin flag travels here or
155
- # `core.platform_admin.is_platform_admin(session.user)` is blind and the Loopable
156
- # admin plane 403s its own operator. Carried ONLY when the record says True, so a
157
- # session dict for any other account is byte-identical to its pre-wave shape.
158
- # Not a client leak: `routes_auth._public_user` is a whitelist projection and does
159
- # not name this key, so it reaches no browser via /login or /me β€” the client's copy
160
- # is the separate `platformAdmin` bool on GET /settings, which is derived from this.
161
- **({'platform_admin': True} if u.get('platform_admin') is True else {}),
162
- 'epoch': int(u.get('epoch') or 0)}
163
-
164
-
165
- # ------------------------------------------------------------------ session revocation (X3)
166
- # The API's session cookie is SIGNED AND STATELESS: there is no server-side session table to
167
- # delete from, so "log this user out everywhere" needs a number that lives with the account. The
168
- # cookie carries the epoch it was minted under; bumping the account's epoch makes every
169
- # outstanding cookie for that user fail verification on its next use. Absent == 0, so every
170
- # record written before this wave is valid without a migration.
171
- def epoch(username):
172
- """The current session epoch for `username`. None when there is no such account.
173
-
174
- None is NOT 0. 0 is "this account exists and has never been revoked"; None is "no record" β€”
175
- which the session verifier must treat as a reason to refuse, not as a default to compare
176
- against. (The APP_PASSWORD emergency-master admin has no record at all; the verifier handles
177
- that case explicitly rather than inventing an epoch for it here.)
178
- """
179
- username = (username or '').strip().lower()
180
- try:
181
- u = (store.get('users') or {}).get(username)
182
- except Exception:
183
- return None
184
- return int((u or {}).get('epoch') or 0) if u else None
185
-
186
-
187
- def bump_epoch(username):
188
- """Revoke every outstanding API session for this account."""
189
- username = (username or '').strip().lower()
190
-
191
- def _set(reg):
192
- u = reg.get(username)
193
- if u:
194
- u['epoch'] = int(u.get('epoch') or 0) + 1
195
- return reg
196
- store.update('users', _set)
197
-
198
-
199
- def verify(username, pw):
200
- """Return a public user dict on success, else None. APP_PASSWORD is an emergency master for the
201
- 'admin' login even if the store is unreachable, so the owner is never locked out."""
202
- username = (username or '').strip().lower()
203
- if not username or not pw:
204
- return None
205
- master = os.environ.get('APP_PASSWORD', '')
206
- try:
207
- # read fresh so accounts created moments ago (UI or out-of-band) are recognised at once
208
- reg = store.get('users', fresh=True)
209
- except Exception:
210
- reg = {}
211
- u = reg.get(username)
212
- # β›” KEY FIRST, EMAIL SECOND, AND SINCE WAVE 37 THAT ORDER IS A DECISION RATHER THAN AN ACCIDENT.
213
- # R8 lets a username BE an email address, so a typed string can now match a registry KEY and a
214
- # different account's `email` field at the same time. The key wins, here, by construction: this
215
- # branch is only reached when `reg.get(username)` missed. `routes_admin.py::create_user` refuses
216
- # to CREATE either collision (`username_shadows_email` / `email_shadows_username`) so the
217
- # ambiguity cannot be introduced through the product; this line is what decides it for any
218
- # record that arrived some other way.
219
- if u is None and '@' in username:
220
- # Wave 18 (R1): the login box takes a username OR an email β€” admin@nurilab.id signs in
221
- # without knowing the slug an admin chose. First case-insensitive email match wins;
222
- # ambiguity is an admin data problem, not a login feature.
223
- for k, r in reg.items():
224
- if isinstance(r, dict) and str(r.get('email') or '').strip().lower() == username:
225
- username, u = k, r
226
- break
227
- if u and u.get('active', True) and hmac.compare_digest(_hash(pw, u['salt']), u['hash']):
228
- return _public(username, u)
229
- # emergency master: admin + APP_PASSWORD always works (covers first run / store outage)
230
- if username == 'admin' and master and hmac.compare_digest(str(pw), master):
231
- # ⚠ CARRY THE RECORD'S CURRENT EPOCH when there is a record to read, so the session cookie
232
- # the API mints from this dict AGREES with the stored account.
233
- #
234
- # This is not what stops the emergency lockout β€” `deps._user_for`'s master fallback does
235
- # that, and a negative control confirmed the lockout is gone with or without this line.
236
- # What it fixes is subtler and is a SCOPE question: a cookie whose epoch disagrees with the
237
- # record falls through to that master fallback, which hands back a SYNTHETIC identity
238
- # (`bus: 'all'`, `modules: 'all'`). An admin whose record narrows either field would
239
- # therefore be silently WIDENED to consolidated, all-module access for the life of that
240
- # session. Matching the epoch means the record branch wins and the account's real scope
241
- # applies, leaving the master fallback as the true last resort it is meant to be.
242
- # 0 when the store is unreachable, which is the case this branch was written for.
243
- return {'username': 'admin', 'name': 'Administrator', 'role': 'admin', 'bus': 'all',
244
- 'modules': 'all', 'epoch': int((reg.get('admin') or {}).get('epoch') or 0)}
245
- return None
246
-
247
-
248
- def create_user(username, pw, name, role='user', bus='all', modules='all',
249
- agent=None, email=None, tenant=None, platform_admin=None):
250
- """Create β€” or, from app.py's dialog, OVERWRITE β€” an account.
251
-
252
- β›” X3: OVERWRITING AN ACCOUNT MUST NOT RESURRECT ITS OLD SESSIONS. `_record()` builds a fresh
253
- record with no `epoch` key, i.e. absent == 0. So re-saving an existing username used to reset
254
- the epoch to 0, and every cookie minted before that account's last password rotation started
255
- verifying again β€” a silent un-revocation. `app.py`'s "Add / update a user" calls this function
256
- for BOTH add and update, so the hole was reachable from the shipped UI.
257
-
258
- Epoch revocation is only ever as strong as the NARROWEST write path that touches the record, so
259
- the carry-and-bump lives here rather than in each caller: an overwrite is at least as
260
- session-invalidating as a password change, and it usually IS one.
261
- """
262
- username = (username or '').strip().lower()
263
- if not username or not pw:
264
- raise ValueError('username and password are required')
265
-
266
- def _add(reg):
267
- prior = reg.get(username) or {}
268
- rec = _record(pw, name or username, role, bus, modules=modules,
269
- agent=agent, email=email,
270
- # An overwrite that names no tenant KEEPS the account's company β€” a
271
- # rename must never quietly move a user between tenants.
272
- tenant=(tenant or prior.get('tenant') or 'royal-imports'),
273
- # Wave 19 (R3): CARRIED, for the same reason `epoch` is carried below β€”
274
- # `_record` builds a FRESH record, so re-running the provisioner (it is
275
- # documented as idempotent) or saving an account through this function
276
- # would silently DEMOTE a platform admin and lock the operator out of
277
- # their own plane. None = leave as it was; True/False = set it deliberately.
278
- platform_admin=(prior.get('platform_admin') is True
279
- if platform_admin is None else platform_admin is True))
280
- if prior:
281
- rec['epoch'] = int(prior.get('epoch') or 0) + 1
282
- reg[username] = rec
283
- return reg
284
- store.update('users', _add)
285
-
286
-
287
- def set_password(username, pw):
288
- username = (username or '').strip().lower()
289
-
290
- def _set(reg):
291
- u = reg.get(username)
292
- if u:
293
- u['salt'] = secrets.token_hex(16)
294
- u['hash'] = _hash(pw, u['salt'])
295
- # X3: a password change revokes every outstanding API session for the account. Bumped
296
- # INSIDE the same read-modify-write as the hash so the two can never disagree β€” a
297
- # separate update() could rotate the password and leave old cookies live if the second
298
- # write failed.
299
- u['epoch'] = int(u.get('epoch') or 0) + 1
300
- return reg
301
- store.update('users', _set)
302
-
303
-
304
- def set_active(username, active):
305
- username = (username or '').strip().lower()
306
-
307
- def _set(reg):
308
- if username in reg:
309
- reg[username]['active'] = bool(active)
310
- # X3: DEACTIVATION must kill live sessions, not just future logins β€” otherwise a
311
- # disabled account keeps working until its cookie expires. Bumped on reactivation too:
312
- # cheap, and it means a re-enabled account never resurrects a stale cookie.
313
- reg[username]['epoch'] = int(reg[username].get('epoch') or 0) + 1
314
- return reg
315
- store.update('users', _set)
316
-
317
-
318
- def set_platform_admin(username, on):
319
- """Promote/demote a PLATFORM administrator (wave 19, R3) without touching the password.
320
-
321
- The narrow write, deliberately: `create_user` is the destructive path (fresh salt, fresh
322
- hash, bumped epoch) and promoting somebody should not sign them out or rotate a credential.
323
- Cleared by REMOVING the key, so a demoted record goes back to its pre-wave shape rather than
324
- carrying a `False` that reads as "somebody considered this".
325
-
326
- ⚠ This is the flag only. It grants nothing on its own β€” `core.platform_admin` also demands
327
- the `loopable` tenant, and there is no code path anywhere that moves an account between
328
- tenants, which is what makes the second lock hold.
329
- """
330
- username = (username or '').strip().lower()
331
-
332
- def _set(reg):
333
- u = reg.get(username)
334
- if u:
335
- if on is True:
336
- u['platform_admin'] = True
337
- else:
338
- u.pop('platform_admin', None)
339
- return reg
340
- store.update('users', _set)
341
-
342
-
343
- # ------------------------------------------------------------------ activity stamps (wave 19 R4)
344
- # "When did this account last sign in, and is anyone actually using it?" β€” the two questions the
345
- # Loopable admin plane exists to answer and that NOTHING in the product could answer before this
346
- # wave (there is no login history, no audit log, no request log anywhere).
347
- #
348
- # β›” THIS IS THE FIRST HIGH-FREQUENCY WRITER `users.json` HAS EVER HAD, and that bucket also holds
349
- # every password hash, `active`, `epoch` and the permission blocks. Three rules follow, and the
350
- # second one is a correctness rule, not a performance one:
351
- #
352
- # 1. **In-place mutation of ONE key.** Never `_record()`, never a whole-record replace: a stamp
353
- # that rebuilt the record would reset the salt/hash (locking the user out) or the epoch
354
- # (silently un-revoking every cookie ever minted for them). `set_password`'s docstring
355
- # explains why that class of bug is worth naming out loud.
356
- #
357
- # 2. **SYNCHRONOUS FLUSH β€” deliberately NOT the async path, and this reverses my first draft.**
358
- # `store.update(flush='async')` rebases on the PROCESS CACHE once a key is `_owned`
359
- # (`core/store.py:284`) and its worker uploads that whole cached blob. For a
360
- # table-workspace key, written by one process, that is exactly right. For `users` it is a
361
- # silent-revert machine: tenant #0's Streamlit host writes the SAME file, so an API process
362
- # holding a cache from an hour ago would, on its next stamp, upload a blob in which a
363
- # password rotation or a deactivation performed in the other host simply never happened.
364
- # A telemetry stamp must not be able to resurrect a disabled account. `flush='sync'` does a
365
- # FRESH strict read inside the store's lock and then uploads, which is the same discipline
366
- # every other `users` writer already uses.
367
- #
368
- # 3. **OFF THE REQUEST THREAD, so rule 2 costs nothing.** A sync commit is a hub round-trip, and
369
- # neither a sign-in nor a random request an hour later should wait for it. Each stamp runs on
370
- # a short-lived daemon thread; `flush_stamps()` is how a test or a shutdown waits for them.
371
- # Everything is fail-silent: a stamp is telemetry and may never turn a good login into a
372
- # failed one β€” the plane shows an honest "never" instead.
373
- def _now_iso():
374
- import datetime as _dt
375
- return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec='seconds')
376
-
377
-
378
- #: Live stamp threads, so `flush_stamps()` can join them. Bounded by construction β€” one thread per
379
- #: stamp, and a stamp is at most one per login plus one per account per hour per process.
380
- _STAMPS = []
381
- _STAMPS_LOCK = __import__('threading').Lock()
382
-
383
-
384
- def _stamp(username, fields):
385
- """Merge `fields` into ONE account record, on a background thread, with a fresh read."""
386
- username = (username or '').strip().lower()
387
- if not username or not fields:
388
- return None
389
-
390
- def _set(reg):
391
- u = reg.get(username)
392
- if isinstance(u, dict):
393
- u.update(fields)
394
- return reg
395
-
396
- def _work():
397
- try:
398
- store.update('users', _set) # sync: fresh strict read + blocking upload
399
- except Exception:
400
- pass # a lost stamp is a lost stamp, never an error
401
- import threading
402
- t = threading.Thread(target=_work, daemon=True, name=f'user-stamp:{username}')
403
- with _STAMPS_LOCK:
404
- _STAMPS[:] = [x for x in _STAMPS if x.is_alive()]
405
- _STAMPS.append(t)
406
- t.start()
407
- return t
408
-
409
-
410
- def flush_stamps(timeout=10.0):
411
- """Block until outstanding stamp writes have been applied. For gates and shutdown hooks β€”
412
- the app never needs it, exactly like `core.store.flush`."""
413
- with _STAMPS_LOCK:
414
- pending = list(_STAMPS)
415
- for t in pending:
416
- t.join(timeout=timeout)
417
- return all(not t.is_alive() for t in pending)
418
-
419
-
420
- def touch_login(username, when=None):
421
- """Stamp `last_login` (ISO-8601, UTC, OFFSET-BEARING) on a SUCCESSFUL login.
422
-
423
- `username` must be the RESOLVED account key, not what the person typed: `verify()` accepts an
424
- email address and resolves it to the registry key, so stamping the typed identifier would
425
- write a stamp onto a key that does not exist and create a phantom account in the registry.
426
-
427
- `last_active` rides along β€” signing in IS activity, and setting both here means the plane's
428
- two columns agree the moment somebody logs in rather than an hour later.
429
- """
430
- stamp = when or _now_iso()
431
- return _stamp(username, {'last_login': stamp, 'last_active': stamp})
432
-
433
-
434
- def touch_active(username, when=None):
435
- """Stamp `last_active` β€” "this session did something". Throttled BY THE CALLER (`deps.py`
436
- holds a process-local last-seen map), so this is not a store round-trip per request."""
437
- return _stamp(username, {'last_active': when or _now_iso()})
438
-
439
-
440
- def set_access(username, role=None, bus=None, modules=None, agent=None, email=None,
441
- name=None, perms=None):
442
- """Update access fields. agent/email: pass '' to clear, None to leave unchanged β€”
443
- the user↔agent link scopes the Customer List page / digests to that agent's book.
444
-
445
- `name` follows the same None-means-unchanged idiom. It is here because it had no setter at all:
446
- a display name could previously only be changed by re-creating the record through
447
- `create_user`, i.e. by also resetting the password (and, before the fix above, the session
448
- epoch). Y4's `PATCH {name?}` needs the narrow write, not the destructive one."""
449
- username = (username or '').strip().lower()
450
-
451
- def _set(reg):
452
- u = reg.get(username)
453
- if u:
454
- if name is not None:
455
- u['name'] = name
456
- if role is not None:
457
- u['role'] = role
458
- if bus is not None:
459
- u['bus'] = bus
460
- if modules is not None:
461
- u['modules'] = modules
462
- if agent is not None:
463
- u['agent'] = agent or None
464
- if email is not None:
465
- u['email'] = email or None
466
- if perms is not None:
467
- # Wave 15 C-PERM. Writing perms MIGRATES the record: the marker goes on in the
468
- # same read-modify-write, so a record can never end up with one and not the
469
- # other (see `_record`). Whole-block replace, matching the PUT route's shape β€”
470
- # a merge would make "remove this restriction" unexpressible.
471
- u['perms'] = perms
472
- u['perms_v'] = PERMS_VERSION
473
- return reg
474
- store.update('users', _set)
475
-
476
-
477
- def allowed_bus_labels(user):
478
- """BU labels this user may select. 'all' -> All+Fisch+Royal; a single BU -> just that BU (no
479
- 'All', so the other BU is never reachable); multiple -> All + each."""
480
- bus = (user or {}).get('bus', 'all')
481
- if bus == 'all':
482
- return ['All', 'Fisch', 'Royal']
483
- labels = [BU_LABELS[b] for b in bus if b in BU_LABELS]
484
- if not labels:
485
- return ['All', 'Fisch', 'Royal']
486
- return (['All'] + labels) if len(labels) > 1 else labels
487
-
488
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  def assignable_people(tenant=None):
490
- """Display names for `user`-typed overlay columns β€” the tenant's ACTIVE accounts.
491
-
492
- Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
493
- call rather than persisted with the column: a snapshot would keep offering people who
494
- have left and never offer people who joined. Deactivated accounts are excluded; a value
495
- already stored on a row is untouched β€” history should still say who owned something.
496
-
497
- Wave 18 (C1-TENANT): pass `tenant` to scope the choices to ONE company β€” the user registry
498
- is a global control-plane bucket, and a Nurilab picker offering Royal's staff is a
499
- cross-tenant name leak.
500
-
501
- β›”β›” WAVE 33 (W33-T37) β€” A BLANK `tenant` NOW RETURNS NOTHING. It used to skip the filter and
502
- return EVERY tenant's staff: `if want and …` is structurally fail-OPEN, and the exemption was
503
- written for "None = unscoped (the Streamlit host, tenant #0's process)" β€” a host DELETED at
504
- EXIT-6. So the sanctioned caller no longer exists, and what was left is a whole-platform
505
- roster one forgotten kwarg away, with no error to notice
506
- ([[gate-must-go-red-not-crash]]'s sibling: a wall that answers instead of refusing).
507
- ⚠ The direction is the safety: this can only ever NARROW. All six live call sites pass
508
- `session.tenant`, which `aios_session.read` refuses to admit blank, so nothing legitimate
509
- changes β€” and a future caller that forgets gets an empty picker somebody notices instead of a
510
- leak nobody does.
511
- """
512
  return sorted({str(u.get('name') or n) for n, u in _tenant_accounts(tenant)})
513
 
514
 
@@ -517,67 +554,67 @@ def assignable_identities(tenant=None):
517
  return [{"username": str(username).strip().lower(),
518
  "name": str(record.get("name") or username)}
519
  for username, record in sorted(_tenant_accounts(tenant), key=lambda item: item[0])]
520
-
521
-
522
- def set_avatar(username, data_url):
523
- """Set (or clear, with None/'') the user's profile photo β€” a data URL (wave 14 C-AVATAR,
524
- [[loopable-wave14-split]] item 11). Stored VERBATIM; the API route owns validation (mime +
525
- decoded size) because this value is served back to every grid session. Cleared by removing
526
- the key, so records without a photo keep their pre-wave shape."""
527
- username = (username or '').strip().lower()
528
-
529
- def _set(reg):
530
- u = reg.get(username)
531
- if u:
532
- if data_url:
533
- u['avatar'] = str(data_url)
534
- else:
535
- u.pop('avatar', None)
536
- return reg
537
- store.update('users', _set)
538
-
539
-
540
- def avatar_map(tenant=None):
541
- """{display name -> avatar data URL} for ACTIVE accounts with a photo β€” the companion of
542
- `assignable_people()`, keyed by the SAME vocabulary: a `user` cell stores the display
543
- name, so the display name is the only join a renderer has. Two active accounts sharing a
544
- display name share one option; the first WITH a photo wins the key rather than a coin
545
- flip deciding whether the option has a face. `tenant` scopes it exactly as
546
- `assignable_people(tenant)` does, and for the same leak β€” including wave 33's fail-closed
547
- blank, which both take from the ONE resolver below rather than each writing `if want and …`.
548
- """
549
- out = {}
550
- for username, u in sorted(_tenant_accounts(tenant), key=lambda kv: kv[0]):
551
- av = u.get('avatar')
552
- nm = str(u.get('name') or username)
553
- if av and nm not in out:
554
- out[nm] = str(av)
555
- return out
556
-
557
-
558
- def _tenant_accounts(tenant):
559
- """`[(username, record), …]` β€” the ACTIVE accounts of exactly ONE tenant. FAIL-CLOSED.
560
-
561
- β›” THE ONE PLACE THE TENANT FILTER FOR THE USER REGISTRY IS WRITTEN. It was written twice,
562
- identically, in `assignable_people` and `avatar_map`, and both copies were fail-OPEN on a
563
- blank slug in the same way (`if want and …`). Two copies of a wall is two places for one of
564
- them to be fixed [[one-question-two-normalizers]]; `routes_shares._people` is a THIRD copy of
565
- the same predicate and is lane C's to fold in.
566
-
567
- ⚠ `str(u.get('tenant') or 'royal-imports')` is kept on the RECORD side deliberately: a
568
- pre-wave-18 account genuinely has no `tenant` key and IS tenant #0's, and `users._public`
569
- normalises it the same way. What changed is the WANT side β€” a blank want is now a refusal
570
- rather than a wildcard.
571
- """
572
- want = str(tenant or '').strip().lower()
573
- if not want:
574
- # Not an error and not everything: nobody. See `assignable_people`'s note β€” the one
575
- # caller this exemption was written for (the Streamlit host) was deleted at EXIT-6.
576
- return []
577
- try:
578
- reg = registry() or {}
579
- except Exception:
580
- return []
581
- return [(n, u) for n, u in reg.items()
582
- if isinstance(u, dict) and u.get('active') is not False
583
- and str(u.get('tenant') or 'royal-imports').strip().lower() == want]
 
1
+ """Per-user accounts for the platform, persisted in the HF Dataset store (users.json).
2
+
3
+ Passwords are salted + PBKDF2-HMAC-SHA256 (200k iterations) β€” never stored or logged in plaintext.
4
+ A bootstrap 'admin' account is seeded from APP_PASSWORD so the owner can always log in and create
5
+ users; APP_PASSWORD also works as an emergency master for 'admin' if the registry is unreachable.
6
+
7
+ Each account carries BU access ('all' or a list of team-ids [5=Fisch, 6=Royal]) which drives
8
+ allowed_bus() β€” the basis for per-Business-Unit permissioning (a Royal-only user never sees Fisch).
9
+ """
10
+ import os
11
+ import hmac
12
+ import hashlib
13
+ import secrets
14
+
15
+ import core.store as store
16
+
17
+ BU_LABELS = {5: 'Fisch', 6: 'Royal'}
18
+ _ITER = 200_000
19
+
20
+ #: Wave 15 C-PERM β€” the explicit-resolution marker. Mirrors `core.perm_scope.PERMS_VERSION`;
21
+ #: kept as a literal here so `users` does not import the permission layer it is read by.
22
  PERMS_VERSION = 1
23
 
24
 
 
64
  return {'username': _QA_USERNAME, 'name': 'QA Runner', 'role': 'user',
65
  'bus': 'all', 'modules': ['customers'], 'tenant': _QA_TENANT,
66
  'epoch': qa_epoch, 'qa_runner': True}
67
+
68
+
69
+ def _hash(pw, salt):
70
+ return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex()
71
+
72
+
73
+ def _record(pw, name, role, bus, active=True, modules='all', agent=None, email=None,
74
+ perms=None, tenant='royal-imports', platform_admin=False):
75
+ salt = secrets.token_hex(16)
76
+ rec = {'salt': salt, 'hash': _hash(pw, salt), 'name': name, 'role': role,
77
+ 'bus': bus, 'active': active, 'modules': modules,
78
+ 'agent': agent or None, 'email': email or None,
79
+ # Wave 18 (C1-TENANT, R1): the account's COMPANY. Absent == 'royal-imports' on every
80
+ # pre-wave record β€” no migration. Login binds the session to THIS value; the posted
81
+ # tenant field can hint but never override it.
82
+ 'tenant': str(tenant or 'royal-imports').strip().lower()}
83
+ if platform_admin is True:
84
+ # Wave 19 (R3): the PLATFORM-operator flag β€” half of `core.platform_admin`'s double lock
85
+ # (the other half is `tenant == 'loopable'`). Written ONLY for True, so every record that
86
+ # is not deliberately promoted keeps its pre-wave shape and answers False by absence.
87
+ # There is no UI writer and there never should be: it is set by provisioning, on purpose.
88
+ rec['platform_admin'] = True
89
+ if perms is not None:
90
+ # Wave 15 C-PERM. A record written WITH perms is migrated by construction β€” the marker
91
+ # and the block are set together, here, so no writer can create one without the other.
92
+ # (`core.perm_scope` reads an unmarked record as legacy, so a block without its marker
93
+ # would be silently ignored; a marker without a block would deny everything.)
94
+ rec['perms'] = perms
95
+ rec['perms_v'] = PERMS_VERSION
96
+ return rec
97
+
98
+
99
+ def registry():
100
+ return store.get('users')
101
+
102
+
103
+ def ensure_bootstrap():
104
+ """Seed an 'admin' account from APP_PASSWORD ONLY on a truly fresh store (no users file yet).
105
+ Idempotent; no-op if the store is unavailable (the app then falls back to the master-password
106
+ path in verify()).
107
+
108
+ Critically, this NEVER overwrites an existing registry: it seeds only when store.exists('users')
109
+ is definitively False. A transient read failure at startup used to return {} and make this
110
+ re-seed just {admin} over the real accounts β€” that is the bug that wiped users on restart."""
111
+ if not store.available():
112
+ return
113
+ if store.exists('users'): # present, or uncertain -> never clobber
114
+ return
115
+ try:
116
+ reg = store.get('users', fresh=True)
117
+ except Exception:
118
+ return
119
+ if reg:
120
+ return
121
+ master = os.environ.get('APP_PASSWORD', '')
122
+ if not master:
123
+ return
124
+ try:
125
+ store.put('users', {'admin': _record(master, 'Administrator', 'admin', 'all')})
126
+ except Exception:
127
+ pass
128
+
129
+
130
+ def _public(username, u):
131
+ return {'username': username, 'name': u.get('name', username),
132
+ 'role': u.get('role', 'user'), 'bus': u.get('bus', 'all'),
133
+ 'modules': u.get('modules', 'all'),
134
+ 'agent': u.get('agent'), 'email': u.get('email'),
135
+ # Wave 18 (C1-TENANT): the session's tenant binding travels on the projection or it
136
+ # does not travel β€” the same rule the perms block states below.
137
+ 'tenant': str(u.get('tenant') or 'royal-imports').strip().lower(),
138
+ # Wave 14 C-AVATAR: the profile photo is public-safe by definition (it is served
139
+ # to every grid session via the workspace map); without it here the API session's
140
+ # user record silently drops it and /me can never show your own photo.
141
+ 'avatar': u.get('avatar') or None,
142
+ # β›” WAVE 15 C-PERM β€” THE WALL TRAVELS ON THIS PROJECTION OR IT DOES NOT TRAVEL.
143
+ # `deps._user_for` builds every API session from `_public()`, so a `perms` block
144
+ # dropped here is a restricted account served as an unrestricted one β€” silently, on
145
+ # every route, with nothing to notice. `perms_v` must ride ALONG WITH it and for the
146
+ # same reason inverted: the marker without the block denies everything, the block
147
+ # without the marker is ignored. Two keys, one fact, never separated.
148
+ # `verify_api` asserts a restricted user's SESSION OBJECT carries both, at the mount
149
+ # rather than by grep β€” a projection is exactly the kind of wiring that looks
150
+ # present in three files and is absent in the one that runs.
151
+ **({'perms': u['perms']} if isinstance(u.get('perms'), dict) else {}),
152
+ **({'perms_v': int(u['perms_v'] or 0)} if u.get('perms_v') else {}),
153
+ # β›” WAVE 19 R3 β€” THE SAME RULE, ON A NEW FIELD. `deps._user_for` builds every API
154
+ # session from this projection, so the platform-admin flag travels here or
155
+ # `core.platform_admin.is_platform_admin(session.user)` is blind and the Loopable
156
+ # admin plane 403s its own operator. Carried ONLY when the record says True, so a
157
+ # session dict for any other account is byte-identical to its pre-wave shape.
158
+ # Not a client leak: `routes_auth._public_user` is a whitelist projection and does
159
+ # not name this key, so it reaches no browser via /login or /me β€” the client's copy
160
+ # is the separate `platformAdmin` bool on GET /settings, which is derived from this.
161
+ **({'platform_admin': True} if u.get('platform_admin') is True else {}),
162
+ 'epoch': int(u.get('epoch') or 0)}
163
+
164
+
165
+ # ------------------------------------------------------------------ session revocation (X3)
166
+ # The API's session cookie is SIGNED AND STATELESS: there is no server-side session table to
167
+ # delete from, so "log this user out everywhere" needs a number that lives with the account. The
168
+ # cookie carries the epoch it was minted under; bumping the account's epoch makes every
169
+ # outstanding cookie for that user fail verification on its next use. Absent == 0, so every
170
+ # record written before this wave is valid without a migration.
171
+ def epoch(username):
172
+ """The current session epoch for `username`. None when there is no such account.
173
+
174
+ None is NOT 0. 0 is "this account exists and has never been revoked"; None is "no record" β€”
175
+ which the session verifier must treat as a reason to refuse, not as a default to compare
176
+ against. (The APP_PASSWORD emergency-master admin has no record at all; the verifier handles
177
+ that case explicitly rather than inventing an epoch for it here.)
178
+ """
179
+ username = (username or '').strip().lower()
180
+ try:
181
+ u = (store.get('users') or {}).get(username)
182
+ except Exception:
183
+ return None
184
+ return int((u or {}).get('epoch') or 0) if u else None
185
+
186
+
187
+ def bump_epoch(username):
188
+ """Revoke every outstanding API session for this account, and RETURN the new epoch.
189
+
190
+ None when there was no such record: nothing was bumped, and inventing 0 would hand a caller an
191
+ epoch that no cookie should ever be minted under.
192
+
193
+ ⭐ WHY IT RETURNS β€” WAVE 40, R2 ("signing in on computer B signs computer A out immediately").
194
+ A login now bumps the epoch and then has to MINT under it, and the minting caller cannot find
195
+ that number by reading the registry back: between the write and the read another writer (this
196
+ module's own `touch_login` thread, a second login, an admin edit) can land, and a cookie minted
197
+ at the wrong epoch is refused by `deps._user_for` on the very next request β€” a 200 login
198
+ followed by 401 forever. The write is the only place that knows what it wrote.
199
+
200
+ The value comes from the document `store.update` RETURNED rather than from what we tried to
201
+ write: on the HF backend a cross-process rebase can rebuild that document from a newer copy and
202
+ replay this closure against it, so the LANDED epoch is the authority. If another process bumped
203
+ the same account concurrently the landed value is higher than ours, which is still exactly the
204
+ newest-wins answer R2 asks for. The closure-captured value is the fallback for a store whose
205
+ `update` returns nothing.
206
+ """
207
+ username = (username or '').strip().lower()
208
+ bumped = {}
209
+
210
+ def _set(reg):
211
+ u = reg.get(username)
212
+ if u:
213
+ u['epoch'] = int(u.get('epoch') or 0) + 1
214
+ bumped['epoch'] = u['epoch']
215
+ return reg
216
+ landed = store.update('users', _set)
217
+ if 'epoch' not in bumped:
218
+ return None # no such record: nothing was revoked, so promise nothing
219
+ try:
220
+ rec = (landed or {}).get(username) or {}
221
+ if rec.get('epoch') is not None:
222
+ return int(rec['epoch'])
223
+ except Exception:
224
+ pass
225
+ return int(bumped['epoch'])
226
+
227
+
228
+ def verify(username, pw):
229
+ """Return a public user dict on success, else None. APP_PASSWORD is an emergency master for the
230
+ 'admin' login even if the store is unreachable, so the owner is never locked out."""
231
+ username = (username or '').strip().lower()
232
+ if not username or not pw:
233
+ return None
234
+ master = os.environ.get('APP_PASSWORD', '')
235
+ try:
236
+ # read fresh so accounts created moments ago (UI or out-of-band) are recognised at once
237
+ reg = store.get('users', fresh=True)
238
+ except Exception:
239
+ reg = {}
240
+ u = reg.get(username)
241
+ # β›” KEY FIRST, EMAIL SECOND, AND SINCE WAVE 37 THAT ORDER IS A DECISION RATHER THAN AN ACCIDENT.
242
+ # R8 lets a username BE an email address, so a typed string can now match a registry KEY and a
243
+ # different account's `email` field at the same time. The key wins, here, by construction: this
244
+ # branch is only reached when `reg.get(username)` missed. `routes_admin.py::create_user` refuses
245
+ # to CREATE either collision (`username_shadows_email` / `email_shadows_username`) so the
246
+ # ambiguity cannot be introduced through the product; this line is what decides it for any
247
+ # record that arrived some other way.
248
+ if u is None and '@' in username:
249
+ # Wave 18 (R1): the login box takes a username OR an email β€” admin@nurilab.id signs in
250
+ # without knowing the slug an admin chose. First case-insensitive email match wins;
251
+ # ambiguity is an admin data problem, not a login feature.
252
+ for k, r in reg.items():
253
+ if isinstance(r, dict) and str(r.get('email') or '').strip().lower() == username:
254
+ username, u = k, r
255
+ break
256
+ if u and u.get('active', True) and hmac.compare_digest(_hash(pw, u['salt']), u['hash']):
257
+ return _public(username, u)
258
+ # emergency master: admin + APP_PASSWORD always works (covers first run / store outage)
259
+ if username == 'admin' and master and hmac.compare_digest(str(pw), master):
260
+ # ⚠ CARRY THE RECORD'S CURRENT EPOCH when there is a record to read, so the session cookie
261
+ # the API mints from this dict AGREES with the stored account.
262
+ #
263
+ # This is not what stops the emergency lockout β€” `deps._user_for`'s master fallback does
264
+ # that, and a negative control confirmed the lockout is gone with or without this line.
265
+ # What it fixes is subtler and is a SCOPE question: a cookie whose epoch disagrees with the
266
+ # record falls through to that master fallback, which hands back a SYNTHETIC identity
267
+ # (`bus: 'all'`, `modules: 'all'`). An admin whose record narrows either field would
268
+ # therefore be silently WIDENED to consolidated, all-module access for the life of that
269
+ # session. Matching the epoch means the record branch wins and the account's real scope
270
+ # applies, leaving the master fallback as the true last resort it is meant to be.
271
+ # 0 when the store is unreachable, which is the case this branch was written for.
272
+ #
273
+ # ⚠ WAVE 40 (R2) β€” `emergency_master` MARKS THIS DICT SO THE LOGIN CAN SKIP THE EPOCH BUMP.
274
+ # Every ordinary sign-in now bumps the account's epoch to sign its other sessions out, but
275
+ # this identity is deliberately NOT epoch-revocable: `deps._user_for`'s master fallback
276
+ # admits it whatever the record says. A bump here would therefore revoke nothing, while
277
+ # forcing a store WRITE on the one code path that exists for a store outage. Internal to
278
+ # the server: `routes_auth._public_user` is a whitelist projection and does not name it.
279
+ return {'username': 'admin', 'name': 'Administrator', 'role': 'admin', 'bus': 'all',
280
+ 'modules': 'all', 'epoch': int((reg.get('admin') or {}).get('epoch') or 0),
281
+ 'emergency_master': True}
282
+ return None
283
+
284
+
285
+ def create_user(username, pw, name, role='user', bus='all', modules='all',
286
+ agent=None, email=None, tenant=None, platform_admin=None):
287
+ """Create β€” or, from app.py's dialog, OVERWRITE β€” an account.
288
+
289
+ β›” X3: OVERWRITING AN ACCOUNT MUST NOT RESURRECT ITS OLD SESSIONS. `_record()` builds a fresh
290
+ record with no `epoch` key, i.e. absent == 0. So re-saving an existing username used to reset
291
+ the epoch to 0, and every cookie minted before that account's last password rotation started
292
+ verifying again β€” a silent un-revocation. `app.py`'s "Add / update a user" calls this function
293
+ for BOTH add and update, so the hole was reachable from the shipped UI.
294
+
295
+ Epoch revocation is only ever as strong as the NARROWEST write path that touches the record, so
296
+ the carry-and-bump lives here rather than in each caller: an overwrite is at least as
297
+ session-invalidating as a password change, and it usually IS one.
298
+ """
299
+ username = (username or '').strip().lower()
300
+ if not username or not pw:
301
+ raise ValueError('username and password are required')
302
+
303
+ def _add(reg):
304
+ prior = reg.get(username) or {}
305
+ rec = _record(pw, name or username, role, bus, modules=modules,
306
+ agent=agent, email=email,
307
+ # An overwrite that names no tenant KEEPS the account's company β€” a
308
+ # rename must never quietly move a user between tenants.
309
+ tenant=(tenant or prior.get('tenant') or 'royal-imports'),
310
+ # Wave 19 (R3): CARRIED, for the same reason `epoch` is carried below β€”
311
+ # `_record` builds a FRESH record, so re-running the provisioner (it is
312
+ # documented as idempotent) or saving an account through this function
313
+ # would silently DEMOTE a platform admin and lock the operator out of
314
+ # their own plane. None = leave as it was; True/False = set it deliberately.
315
+ platform_admin=(prior.get('platform_admin') is True
316
+ if platform_admin is None else platform_admin is True))
317
+ if prior:
318
+ rec['epoch'] = int(prior.get('epoch') or 0) + 1
319
+ reg[username] = rec
320
+ return reg
321
+ store.update('users', _add)
322
+
323
+
324
+ def set_password(username, pw):
325
+ username = (username or '').strip().lower()
326
+
327
+ def _set(reg):
328
+ u = reg.get(username)
329
+ if u:
330
+ u['salt'] = secrets.token_hex(16)
331
+ u['hash'] = _hash(pw, u['salt'])
332
+ # X3: a password change revokes every outstanding API session for the account. Bumped
333
+ # INSIDE the same read-modify-write as the hash so the two can never disagree β€” a
334
+ # separate update() could rotate the password and leave old cookies live if the second
335
+ # write failed.
336
+ u['epoch'] = int(u.get('epoch') or 0) + 1
337
+ return reg
338
+ store.update('users', _set)
339
+
340
+
341
+ def set_active(username, active):
342
+ username = (username or '').strip().lower()
343
+
344
+ def _set(reg):
345
+ if username in reg:
346
+ reg[username]['active'] = bool(active)
347
+ # X3: DEACTIVATION must kill live sessions, not just future logins β€” otherwise a
348
+ # disabled account keeps working until its cookie expires. Bumped on reactivation too:
349
+ # cheap, and it means a re-enabled account never resurrects a stale cookie.
350
+ reg[username]['epoch'] = int(reg[username].get('epoch') or 0) + 1
351
+ return reg
352
+ store.update('users', _set)
353
+
354
+
355
+ def set_platform_admin(username, on):
356
+ """Promote/demote a PLATFORM administrator (wave 19, R3) without touching the password.
357
+
358
+ The narrow write, deliberately: `create_user` is the destructive path (fresh salt, fresh
359
+ hash, bumped epoch) and promoting somebody should not sign them out or rotate a credential.
360
+ Cleared by REMOVING the key, so a demoted record goes back to its pre-wave shape rather than
361
+ carrying a `False` that reads as "somebody considered this".
362
+
363
+ ⚠ This is the flag only. It grants nothing on its own β€” `core.platform_admin` also demands
364
+ the `loopable` tenant, and there is no code path anywhere that moves an account between
365
+ tenants, which is what makes the second lock hold.
366
+ """
367
+ username = (username or '').strip().lower()
368
+
369
+ def _set(reg):
370
+ u = reg.get(username)
371
+ if u:
372
+ if on is True:
373
+ u['platform_admin'] = True
374
+ else:
375
+ u.pop('platform_admin', None)
376
+ return reg
377
+ store.update('users', _set)
378
+
379
+
380
+ # ------------------------------------------------------------------ activity stamps (wave 19 R4)
381
+ # "When did this account last sign in, and is anyone actually using it?" β€” the two questions the
382
+ # Loopable admin plane exists to answer and that NOTHING in the product could answer before this
383
+ # wave (there is no login history, no audit log, no request log anywhere).
384
+ #
385
+ # β›” THIS IS THE FIRST HIGH-FREQUENCY WRITER `users.json` HAS EVER HAD, and that bucket also holds
386
+ # every password hash, `active`, `epoch` and the permission blocks. Three rules follow, and the
387
+ # second one is a correctness rule, not a performance one:
388
+ #
389
+ # 1. **In-place mutation of ONE key.** Never `_record()`, never a whole-record replace: a stamp
390
+ # that rebuilt the record would reset the salt/hash (locking the user out) or the epoch
391
+ # (silently un-revoking every cookie ever minted for them). `set_password`'s docstring
392
+ # explains why that class of bug is worth naming out loud.
393
+ #
394
+ # 2. **SYNCHRONOUS FLUSH β€” deliberately NOT the async path, and this reverses my first draft.**
395
+ # `store.update(flush='async')` rebases on the PROCESS CACHE once a key is `_owned`
396
+ # (`core/store.py:284`) and its worker uploads that whole cached blob. For a
397
+ # table-workspace key, written by one process, that is exactly right. For `users` it is a
398
+ # silent-revert machine: tenant #0's Streamlit host writes the SAME file, so an API process
399
+ # holding a cache from an hour ago would, on its next stamp, upload a blob in which a
400
+ # password rotation or a deactivation performed in the other host simply never happened.
401
+ # A telemetry stamp must not be able to resurrect a disabled account. `flush='sync'` does a
402
+ # FRESH strict read inside the store's lock and then uploads, which is the same discipline
403
+ # every other `users` writer already uses.
404
+ #
405
+ # 3. **OFF THE REQUEST THREAD, so rule 2 costs nothing.** A sync commit is a hub round-trip, and
406
+ # neither a sign-in nor a random request an hour later should wait for it. Each stamp runs on
407
+ # a short-lived daemon thread; `flush_stamps()` is how a test or a shutdown waits for them.
408
+ # Everything is fail-silent: a stamp is telemetry and may never turn a good login into a
409
+ # failed one β€” the plane shows an honest "never" instead.
410
+ def _now_iso():
411
+ import datetime as _dt
412
+ return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec='seconds')
413
+
414
+
415
+ #: Live stamp threads, so `flush_stamps()` can join them. Bounded by construction β€” one thread per
416
+ #: stamp, and a stamp is at most one per login plus one per account per hour per process.
417
+ _STAMPS = []
418
+ _STAMPS_LOCK = __import__('threading').Lock()
419
+
420
+
421
+ def _stamp(username, fields):
422
+ """Merge `fields` into ONE account record, on a background thread, with a fresh read."""
423
+ username = (username or '').strip().lower()
424
+ if not username or not fields:
425
+ return None
426
+
427
+ def _set(reg):
428
+ u = reg.get(username)
429
+ if isinstance(u, dict):
430
+ u.update(fields)
431
+ return reg
432
+
433
+ def _work():
434
+ try:
435
+ store.update('users', _set) # sync: fresh strict read + blocking upload
436
+ except Exception:
437
+ pass # a lost stamp is a lost stamp, never an error
438
+ import threading
439
+ t = threading.Thread(target=_work, daemon=True, name=f'user-stamp:{username}')
440
+ with _STAMPS_LOCK:
441
+ _STAMPS[:] = [x for x in _STAMPS if x.is_alive()]
442
+ _STAMPS.append(t)
443
+ t.start()
444
+ return t
445
+
446
+
447
+ def flush_stamps(timeout=10.0):
448
+ """Block until outstanding stamp writes have been applied. For gates and shutdown hooks β€”
449
+ the app never needs it, exactly like `core.store.flush`."""
450
+ with _STAMPS_LOCK:
451
+ pending = list(_STAMPS)
452
+ for t in pending:
453
+ t.join(timeout=timeout)
454
+ return all(not t.is_alive() for t in pending)
455
+
456
+
457
+ def touch_login(username, when=None):
458
+ """Stamp `last_login` (ISO-8601, UTC, OFFSET-BEARING) on a SUCCESSFUL login.
459
+
460
+ `username` must be the RESOLVED account key, not what the person typed: `verify()` accepts an
461
+ email address and resolves it to the registry key, so stamping the typed identifier would
462
+ write a stamp onto a key that does not exist and create a phantom account in the registry.
463
+
464
+ `last_active` rides along β€” signing in IS activity, and setting both here means the plane's
465
+ two columns agree the moment somebody logs in rather than an hour later.
466
+ """
467
+ stamp = when or _now_iso()
468
+ return _stamp(username, {'last_login': stamp, 'last_active': stamp})
469
+
470
+
471
+ def touch_active(username, when=None):
472
+ """Stamp `last_active` β€” "this session did something". Throttled BY THE CALLER (`deps.py`
473
+ holds a process-local last-seen map), so this is not a store round-trip per request."""
474
+ return _stamp(username, {'last_active': when or _now_iso()})
475
+
476
+
477
+ def set_access(username, role=None, bus=None, modules=None, agent=None, email=None,
478
+ name=None, perms=None):
479
+ """Update access fields. agent/email: pass '' to clear, None to leave unchanged β€”
480
+ the user↔agent link scopes the Customer List page / digests to that agent's book.
481
+
482
+ `name` follows the same None-means-unchanged idiom. It is here because it had no setter at all:
483
+ a display name could previously only be changed by re-creating the record through
484
+ `create_user`, i.e. by also resetting the password (and, before the fix above, the session
485
+ epoch). Y4's `PATCH {name?}` needs the narrow write, not the destructive one."""
486
+ username = (username or '').strip().lower()
487
+
488
+ def _set(reg):
489
+ u = reg.get(username)
490
+ if u:
491
+ if name is not None:
492
+ u['name'] = name
493
+ if role is not None:
494
+ u['role'] = role
495
+ if bus is not None:
496
+ u['bus'] = bus
497
+ if modules is not None:
498
+ u['modules'] = modules
499
+ if agent is not None:
500
+ u['agent'] = agent or None
501
+ if email is not None:
502
+ u['email'] = email or None
503
+ if perms is not None:
504
+ # Wave 15 C-PERM. Writing perms MIGRATES the record: the marker goes on in the
505
+ # same read-modify-write, so a record can never end up with one and not the
506
+ # other (see `_record`). Whole-block replace, matching the PUT route's shape β€”
507
+ # a merge would make "remove this restriction" unexpressible.
508
+ u['perms'] = perms
509
+ u['perms_v'] = PERMS_VERSION
510
+ return reg
511
+ store.update('users', _set)
512
+
513
+
514
+ def allowed_bus_labels(user):
515
+ """BU labels this user may select. 'all' -> All+Fisch+Royal; a single BU -> just that BU (no
516
+ 'All', so the other BU is never reachable); multiple -> All + each."""
517
+ bus = (user or {}).get('bus', 'all')
518
+ if bus == 'all':
519
+ return ['All', 'Fisch', 'Royal']
520
+ labels = [BU_LABELS[b] for b in bus if b in BU_LABELS]
521
+ if not labels:
522
+ return ['All', 'Fisch', 'Royal']
523
+ return (['All'] + labels) if len(labels) > 1 else labels
524
+
525
+
526
  def assignable_people(tenant=None):
527
+ """Display names for `user`-typed overlay columns β€” the tenant's ACTIVE accounts.
528
+
529
+ Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
530
+ call rather than persisted with the column: a snapshot would keep offering people who
531
+ have left and never offer people who joined. Deactivated accounts are excluded; a value
532
+ already stored on a row is untouched β€” history should still say who owned something.
533
+
534
+ Wave 18 (C1-TENANT): pass `tenant` to scope the choices to ONE company β€” the user registry
535
+ is a global control-plane bucket, and a Nurilab picker offering Royal's staff is a
536
+ cross-tenant name leak.
537
+
538
+ β›”β›” WAVE 33 (W33-T37) β€” A BLANK `tenant` NOW RETURNS NOTHING. It used to skip the filter and
539
+ return EVERY tenant's staff: `if want and …` is structurally fail-OPEN, and the exemption was
540
+ written for "None = unscoped (the Streamlit host, tenant #0's process)" β€” a host DELETED at
541
+ EXIT-6. So the sanctioned caller no longer exists, and what was left is a whole-platform
542
+ roster one forgotten kwarg away, with no error to notice
543
+ ([[gate-must-go-red-not-crash]]'s sibling: a wall that answers instead of refusing).
544
+ ⚠ The direction is the safety: this can only ever NARROW. All six live call sites pass
545
+ `session.tenant`, which `aios_session.read` refuses to admit blank, so nothing legitimate
546
+ changes β€” and a future caller that forgets gets an empty picker somebody notices instead of a
547
+ leak nobody does.
548
+ """
549
  return sorted({str(u.get('name') or n) for n, u in _tenant_accounts(tenant)})
550
 
551
 
 
554
  return [{"username": str(username).strip().lower(),
555
  "name": str(record.get("name") or username)}
556
  for username, record in sorted(_tenant_accounts(tenant), key=lambda item: item[0])]
557
+
558
+
559
+ def set_avatar(username, data_url):
560
+ """Set (or clear, with None/'') the user's profile photo β€” a data URL (wave 14 C-AVATAR,
561
+ [[loopable-wave14-split]] item 11). Stored VERBATIM; the API route owns validation (mime +
562
+ decoded size) because this value is served back to every grid session. Cleared by removing
563
+ the key, so records without a photo keep their pre-wave shape."""
564
+ username = (username or '').strip().lower()
565
+
566
+ def _set(reg):
567
+ u = reg.get(username)
568
+ if u:
569
+ if data_url:
570
+ u['avatar'] = str(data_url)
571
+ else:
572
+ u.pop('avatar', None)
573
+ return reg
574
+ store.update('users', _set)
575
+
576
+
577
+ def avatar_map(tenant=None):
578
+ """{display name -> avatar data URL} for ACTIVE accounts with a photo β€” the companion of
579
+ `assignable_people()`, keyed by the SAME vocabulary: a `user` cell stores the display
580
+ name, so the display name is the only join a renderer has. Two active accounts sharing a
581
+ display name share one option; the first WITH a photo wins the key rather than a coin
582
+ flip deciding whether the option has a face. `tenant` scopes it exactly as
583
+ `assignable_people(tenant)` does, and for the same leak β€” including wave 33's fail-closed
584
+ blank, which both take from the ONE resolver below rather than each writing `if want and …`.
585
+ """
586
+ out = {}
587
+ for username, u in sorted(_tenant_accounts(tenant), key=lambda kv: kv[0]):
588
+ av = u.get('avatar')
589
+ nm = str(u.get('name') or username)
590
+ if av and nm not in out:
591
+ out[nm] = str(av)
592
+ return out
593
+
594
+
595
+ def _tenant_accounts(tenant):
596
+ """`[(username, record), …]` β€” the ACTIVE accounts of exactly ONE tenant. FAIL-CLOSED.
597
+
598
+ β›” THE ONE PLACE THE TENANT FILTER FOR THE USER REGISTRY IS WRITTEN. It was written twice,
599
+ identically, in `assignable_people` and `avatar_map`, and both copies were fail-OPEN on a
600
+ blank slug in the same way (`if want and …`). Two copies of a wall is two places for one of
601
+ them to be fixed [[one-question-two-normalizers]]; `routes_shares._people` is a THIRD copy of
602
+ the same predicate and is lane C's to fold in.
603
+
604
+ ⚠ `str(u.get('tenant') or 'royal-imports')` is kept on the RECORD side deliberately: a
605
+ pre-wave-18 account genuinely has no `tenant` key and IS tenant #0's, and `users._public`
606
+ normalises it the same way. What changed is the WANT side β€” a blank want is now a refusal
607
+ rather than a wildcard.
608
+ """
609
+ want = str(tenant or '').strip().lower()
610
+ if not want:
611
+ # Not an error and not everything: nobody. See `assignable_people`'s note β€” the one
612
+ # caller this exemption was written for (the Streamlit host) was deleted at EXIT-6.
613
+ return []
614
+ try:
615
+ reg = registry() or {}
616
+ except Exception:
617
+ return []
618
+ return [(n, u) for n, u in reg.items()
619
+ if isinstance(u, dict) and u.get('active') is not False
620
+ and str(u.get('tenant') or 'royal-imports').strip().lower() == want]
platform/harness/semantic.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/model/topics/odoo_customers.yml CHANGED
@@ -1,213 +1,223 @@
1
- # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
- # the databases a person actually opens, not only the line and document grains.
3
- #
4
- # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
- # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
- # field row: add the column to the grid contract and re-emit, or the agent is being
7
- # trained on a schema the product does not have.
8
- key: odoo_customers
9
- label: "Odoo customers"
10
- entity: res.partner
11
- # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
- grid: customer_data
13
- subject: "odoo:res.partner"
14
- grain: "one row per customer in tenant #0's scoped book β€” a REGISTRY, not a dated event stream. Identity is the Odoo `res.partner` id (the row's `pid` IS that id)."
15
- scope:
16
- population: "the customer book `modules/customer_data.pool()` builds; the grid's own contract states it as one row per customer who ordered in the last 24 months"
17
- identity: "the Odoo res.partner id β€” exposed as the `partner_id` column (W33-T43) and used as the row pid, so there is ONE id per row and no second one"
18
- merged: "W33-T44 retired `ut_odoo_customers`, which presented this same subject. This topic describes the SURVIVING database, `customer_data`, which keeps its store bucket so no saved view, grant, cohort or formula moved"
19
- no_date: "a registry has no date dimension; ask sales_lines or customer_invoices for anything time-windowed about a customer"
20
- store:
21
- table: res_partner
22
- alias: p
23
- # NO date_col β€” a registry is not a dated event stream. Stated rather than
24
- # omitted, so its absence reads as a fact and not as an unfinished file.
25
- dims:
26
- agent: {label: "Sales agent"}
27
- state: {label: "State"}
28
- country: {label: "Country"}
29
-
30
- # key / label / type / kind, derived from the grid contract. `kind` says where the
31
- # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
32
- # from another topic; a `link` points at another database.
33
- fields:
34
- - key: customer
35
- label: "Customer"
36
- type: text
37
- kind: data
38
- means: "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
39
- - key: partner_id
40
- label: "Odoo ID"
41
- type: int
42
- kind: data
43
- means: "The Odoo res.partner id β€” this row's identity, and the key every Odoo document joins on. W33-T43 / owner item 12: 'One unique ID per database always.' It is DERIVED rather than read off the pool row because a customer row's pid IS the partner id, so the value is already on every row and a second copy in the pool would be a second source for one fact."
44
- - key: odoo_status
45
- label: "Odoo record"
46
- type: status
47
- kind: data
48
- means: "Whether this customer still exists in Odoo. Archived means deleted there."
49
- - key: agent
50
- label: "Agent"
51
- type: text
52
- kind: data
53
- means: "The sales agent who owns this account."
54
- - key: dba
55
- label: "DBA"
56
- type: select
57
- kind: data
58
- means: "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
59
- - key: salesperson
60
- label: "Salesperson"
61
- type: text
62
- kind: data
63
- means: "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
64
- - key: street
65
- label: "Street"
66
- type: text
67
- kind: data
68
- means: "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
69
- - key: street2
70
- label: "Street 2"
71
- type: text
72
- kind: data
73
- means: "Second address line (suite, unit, floor) on the customer's Odoo address."
74
- - key: city
75
- label: "City"
76
- type: text
77
- kind: data
78
- means: "City on the customer's Odoo address."
79
- - key: state
80
- label: "State"
81
- type: text
82
- kind: data
83
- means: "State or province on the customer's Odoo address."
84
- - key: country
85
- label: "Country"
86
- type: text
87
- kind: data
88
- means: "Country on the customer's Odoo address."
89
- - key: zip
90
- label: "ZIP"
91
- type: text
92
- kind: data
93
- means: "Postal code on the customer's Odoo address."
94
- - key: customer_since
95
- label: "Customer since"
96
- type: date
97
- kind: data
98
- means: "When this customer was first set up in Odoo."
99
- - key: tags
100
- label: "Tags"
101
- type: text
102
- kind: data
103
- means: "Odoo labels on this customer, comma-separated."
104
- - key: pricelist
105
- label: "Price list"
106
- type: text
107
- kind: data
108
- means: "The price list this customer buys on."
109
- - key: payment_terms
110
- label: "Payment terms"
111
- type: text
112
- kind: data
113
- means: "Payment terms on this customer's account β€” Net 30, for example."
114
- - key: last_order
115
- label: "Last order"
116
- type: date
117
- kind: data
118
- means: "Date of the most recent confirmed order."
119
- - key: overdue_days
120
- label: "Overdue days"
121
- type: int
122
- kind: data
123
- means: "How many days late this customer is running against their own usual ordering rhythm."
124
- - key: est_missed
125
- label: "Est. missed $"
126
- type: currency
127
- kind: data
128
- means: "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
129
- - key: ar_open
130
- label: "AR current $"
131
- type: currency
132
- kind: data
133
- means: "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
134
- - key: ar_overdue
135
- label: "AR overdue $"
136
- type: currency
137
- kind: data
138
- means: "Invoiced money past due β€” same basis as the Collections page."
139
- - key: ar_outstanding
140
- label: "AR outstanding $"
141
- type: currency
142
- kind: data
143
- means: "Total invoiced money owed right now: AR current $ plus AR overdue $."
144
- - key: ar_exposure
145
- label: "Credit exposure $"
146
- type: currency
147
- kind: data
148
- means: "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
149
- - key: ar_aged_1_30
150
- label: "1-30 days $"
151
- type: currency
152
- kind: data
153
- means: "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
154
- - key: ar_aged_31_60
155
- label: "31-60 days $"
156
- type: currency
157
- kind: data
158
- means: "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
159
- - key: ar_aged_61_90
160
- label: "61-90 days $"
161
- type: currency
162
- kind: data
163
- means: "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
164
- - key: ar_aged_90_plus
165
- label: "90+ days $"
166
- type: currency
167
- kind: data
168
- means: "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
169
- - key: days_to_pay
170
- label: "Days to pay"
171
- type: int
172
- kind: data
173
- means: "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
174
- - key: top_category
175
- label: "Top category"
176
- type: text
177
- kind: data
178
- means: "The category this customer spent the most on in the last 12 months."
179
- - key: top_category_pct
180
- label: "Top category %"
181
- type: pct
182
- kind: data
183
- means: "Share of last-12-months spend that went to the top category."
184
- - key: sku_count
185
- label: "SKUs bought"
186
- type: int
187
- kind: data
188
- means: "Distinct products bought in the last 12 months."
189
- - key: top_sku
190
- label: "Top SKU"
191
- type: text
192
- kind: data
193
- means: "The product this customer spent the most on in the last 12 months."
194
- - key: days_since
195
- label: "Days since order"
196
- type: int
197
- kind: data
198
- means: "Days since the last confirmed order."
199
- - key: typical_gap_days
200
- label: "Typical gap days"
201
- type: int
202
- kind: data
203
- means: "Days this customer usually goes between orders, from their own history."
204
- - key: notes
205
- label: "Notes"
206
- type: text
207
- kind: data
208
- means: "Your notes on this customer. Saved in this app only, visible only to you."
209
-
210
- ai_context: >
211
- The customer REGISTRY as a person sees it in the app β€” the database labelled 'Odoo customers'.
212
- Use it to answer 'who is this customer', 'which customers exist', 'which agent owns them', 'where are they', and for the AR columns it carries (ar_open, ar_overdue, days_to_pay), which are reconciled by modules/ar.py.
213
- β›” For anything WINDOWED or at line grain go to sales_lines / customer_invoices / receivables β€” those topics own the time dimension and this one has none.
 
 
 
 
 
 
 
 
 
 
 
1
+ # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
+ # the databases a person actually opens, not only the line and document grains.
3
+ #
4
+ # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
+ # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
+ # field row: add the column to the grid contract and re-emit, or the agent is being
7
+ # trained on a schema the product does not have.
8
+ key: odoo_customers
9
+ label: "Odoo customers"
10
+ entity: res.partner
11
+ # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
+ grid: customer_data
13
+ subject: "odoo:res.partner"
14
+ grain: "one row per customer in tenant #0's scoped book β€” a REGISTRY, not a dated event stream. Identity is the Odoo `res.partner` id (the row's `pid` IS that id)."
15
+ scope:
16
+ population: "the customer book `modules/customer_data.pool()` builds; the grid's own contract states it as one row per customer who ordered in the last 24 months"
17
+ identity: "the Odoo res.partner id β€” exposed as the `partner_id` column (W33-T43) and used as the row pid, so there is ONE id per row and no second one"
18
+ merged: "W33-T44 retired `ut_odoo_customers`, which presented this same subject. This topic describes the SURVIVING database, `customer_data`, which keeps its store bucket so no saved view, grant, cohort or formula moved"
19
+ no_date: "a registry has no date dimension; ask sales_lines or customer_invoices for anything time-windowed about a customer"
20
+ store:
21
+ table: res_partner
22
+ alias: p
23
+ # NO date_col β€” a registry is not a dated event stream. Stated rather than
24
+ # omitted, so its absence reads as a fact and not as an unfinished file.
25
+ dims:
26
+ agent: {label: "Sales agent"}
27
+ state: {label: "State"}
28
+ country: {label: "Country"}
29
+
30
+ # key / label / type / kind, derived from the grid contract. `kind` says where the
31
+ # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
32
+ # from another topic; a `link` points at another database.
33
+ fields:
34
+ - key: customer
35
+ label: "Customer"
36
+ type: text
37
+ kind: data
38
+ means: "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
39
+ - key: partner_id
40
+ label: "Odoo ID"
41
+ type: int
42
+ kind: data
43
+ means: "The Odoo res.partner id β€” this row's identity, and the key every Odoo document joins on. W33-T43 / owner item 12: 'One unique ID per database always.' It is DERIVED rather than read off the pool row because a customer row's pid IS the partner id, so the value is already on every row and a second copy in the pool would be a second source for one fact."
44
+ - key: odoo_status
45
+ label: "Odoo record"
46
+ type: status
47
+ kind: data
48
+ means: "Whether this customer still exists in Odoo. Archived means deleted there."
49
+ - key: agent
50
+ label: "Agent"
51
+ type: select
52
+ kind: data
53
+ means: "The sales agent who owns this account."
54
+ - key: dba
55
+ label: "DBA"
56
+ type: select
57
+ kind: data
58
+ means: "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
59
+ - key: salesperson
60
+ label: "Salesperson"
61
+ type: select
62
+ kind: data
63
+ means: "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
64
+ - key: street
65
+ label: "Street"
66
+ type: text
67
+ kind: data
68
+ means: "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
69
+ - key: street2
70
+ label: "Street 2"
71
+ type: text
72
+ kind: data
73
+ means: "Second address line (suite, unit, floor) on the customer's Odoo address."
74
+ - key: city
75
+ label: "City"
76
+ type: select
77
+ kind: data
78
+ means: "City on the customer's Odoo address."
79
+ - key: state
80
+ label: "State"
81
+ type: select
82
+ kind: data
83
+ means: "State or province on the customer's Odoo address."
84
+ - key: country
85
+ label: "Country"
86
+ type: select
87
+ kind: data
88
+ means: "Country on the customer's Odoo address."
89
+ - key: zip
90
+ label: "ZIP"
91
+ type: text
92
+ kind: data
93
+ means: "Postal code on the customer's Odoo address."
94
+ - key: maps_url
95
+ label: "Google Maps"
96
+ type: url
97
+ kind: data
98
+ means: "A Google Maps link to this customer's address."
99
+ - key: customer_since
100
+ label: "Customer since"
101
+ type: date
102
+ kind: data
103
+ means: "When this customer was first set up in Odoo."
104
+ - key: tags
105
+ label: "Tags"
106
+ type: multiselect
107
+ kind: data
108
+ means: "Odoo labels on this customer, comma-separated."
109
+ - key: pricelist
110
+ label: "Customer price list"
111
+ type: select
112
+ kind: data
113
+ means: "The price list this customer buys on."
114
+ - key: payment_terms
115
+ label: "Payment terms"
116
+ type: select
117
+ kind: data
118
+ means: "Payment terms on this customer's account β€” Net 30, for example."
119
+ - key: last_order
120
+ label: "Last order"
121
+ type: date
122
+ kind: data
123
+ means: "Date of the most recent confirmed order."
124
+ - key: overdue_days
125
+ label: "Overdue days"
126
+ type: int
127
+ kind: data
128
+ means: "How many days late this customer is running against their own usual ordering rhythm."
129
+ - key: est_missed
130
+ label: "Est. missed $"
131
+ type: currency
132
+ kind: data
133
+ means: "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
134
+ - key: ar_open
135
+ label: "AR current $"
136
+ type: currency
137
+ kind: data
138
+ means: "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
139
+ - key: ar_overdue
140
+ label: "AR overdue $"
141
+ type: currency
142
+ kind: data
143
+ means: "Invoiced money past due β€” same basis as the Collections page."
144
+ - key: ar_outstanding
145
+ label: "AR outstanding $"
146
+ type: currency
147
+ kind: data
148
+ means: "Total invoiced money owed right now: AR current $ plus AR overdue $."
149
+ - key: ar_exposure
150
+ label: "Credit exposure $"
151
+ type: currency
152
+ kind: data
153
+ means: "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
154
+ - key: ar_aged_1_30
155
+ label: "1-30 days $"
156
+ type: currency
157
+ kind: data
158
+ means: "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
159
+ - key: ar_aged_31_60
160
+ label: "31-60 days $"
161
+ type: currency
162
+ kind: data
163
+ means: "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
164
+ - key: ar_aged_61_90
165
+ label: "61-90 days $"
166
+ type: currency
167
+ kind: data
168
+ means: "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
169
+ - key: ar_aged_90_plus
170
+ label: "90+ days $"
171
+ type: currency
172
+ kind: data
173
+ means: "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
174
+ - key: days_to_pay
175
+ label: "Days to pay"
176
+ type: int
177
+ kind: data
178
+ means: "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
179
+ - key: top_category
180
+ label: "Top category"
181
+ type: select
182
+ kind: data
183
+ means: "The category this customer spent the most on in the last 12 months."
184
+ - key: top_category_pct
185
+ label: "Top category %"
186
+ type: pct
187
+ kind: data
188
+ means: "Share of last-12-months spend that went to the top category."
189
+ - key: sku_count
190
+ label: "SKUs bought"
191
+ type: int
192
+ kind: data
193
+ means: "Distinct products bought in the last 12 months."
194
+ - key: top_sku
195
+ label: "Top SKU"
196
+ type: text
197
+ kind: data
198
+ means: "The product this customer spent the most on in the last 12 months."
199
+ - key: days_since
200
+ label: "Days since order"
201
+ type: int
202
+ kind: data
203
+ means: "Days since the last confirmed order."
204
+ - key: typical_gap_days
205
+ label: "Typical gap days"
206
+ type: int
207
+ kind: data
208
+ means: "Days this customer usually goes between orders, from their own history."
209
+ - key: created_on
210
+ label: "Record created"
211
+ type: date
212
+ kind: data
213
+ means: "The date this customer record was created in Odoo."
214
+ - key: notes
215
+ label: "Notes"
216
+ type: text
217
+ kind: data
218
+ means: "Your notes on this customer. Saved in this app only, visible only to you."
219
+
220
+ ai_context: >
221
+ The customer REGISTRY as a person sees it in the app β€” the database labelled 'Odoo customers'.
222
+ Use it to answer 'who is this customer', 'which customers exist', 'which agent owns them', 'where are they', and for the AR columns it carries (ar_open, ar_overdue, days_to_pay), which are reconciled by modules/ar.py.
223
+ β›” For anything WINDOWED or at line grain go to sales_lines / customer_invoices / receivables β€” those topics own the time dimension and this one has none.
platform/model/topics/odoo_products.yml CHANGED
@@ -1,52 +1,63 @@
1
- # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
- # the databases a person actually opens, not only the line and document grains.
3
- #
4
- # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
- # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
- # field row: add the column to the grid contract and re-emit, or the agent is being
7
- # trained on a schema the product does not have.
8
- key: odoo_products
9
- label: "Odoo products"
10
- entity: product.product
11
- # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
- grid: product_data
13
- subject: "odoo:product.product"
14
- grain: "one row per SKU in the active catalogue β€” a CATALOGUE, not a dated event stream"
15
- scope:
16
- population: "every ACTIVE product, sold or not (measured 5,862). Deliberately not the sales-window universe: that lands at 2,717 and hides ~2,550 SKUs that have never sold in wholesale scope"
17
- identity: "the SKU code (`default_code`). ⚠ 33 active products carry NO code and are keyed `pid:<odoo product id>` instead, so every active product has exactly one row and none is dropped"
18
- merged: "W33-T44 retired `ut_odoo_products`, which presented this same subject on Odoo's product_id. This topic describes the SURVIVING database, `product_data`"
19
- no_date: "a catalogue has no date dimension; ask sales_lines for movement"
20
- store:
21
- table: product_product
22
- alias: pp
23
- # NO date_col β€” a registry is not a dated event stream. Stated rather than
24
- # omitted, so its absence reads as a fact and not as an unfinished file.
25
- dims:
26
- category: {label: "Category"}
27
- supplier: {label: "Supplier"}
28
-
29
- # ⭐⭐ W37-T10 / owner item 4 (ruling R1) β€” THE LOOKBACK-MEASURE BINDING. Owner: *"Odoo products
30
- # should have a lookbsck metrics like sales etc."* This block is what turns `measures: []` on the
31
- # product workspace into a real catalogue, and it is DECLARED HERE rather than hand-written in a
32
- # route for the reason `_rollup_source_offer` gives about itself: a second list is a second
33
- # definition of the same fact, and the two drift the day somebody adds a metric.
34
- #
35
- # β›” THE JOIN KEY IS THE TRAP CONTRACT C1 NAMES, and this is where it is answered. `source` is a
36
- # FACT topic; `dim` is the dim of THAT topic which carries THIS entity's identity. `sales_lines`
37
- # has two candidates and only one is right: `product` groups by Odoo's `product_id`, while this
38
- # database's identity (see `scope.identity` above) is `default_code`. Binding to `product` would
39
- # key every cell on a number no grid row carries β€” a column of nulls that reads as "never sold".
40
- #
41
- # ⚠ A SINGLE-TOPIC GROUPED QUERY, which is why `semantic.store_query`'s cross-topic refusal does
42
- # not bite: `revenue` lives ON `sales_lines` and we group `sales_lines` by its OWN dim. The same
43
- # refusal is real and DOES bite `aov`/`orders` (denominator `orders` lives on `sales_orders`), so
44
- # those two are absent from `keys` below and `entity_measures()` re-proves that rather than
45
- # trusting this list.
46
- # ⭐ A LIST SINCE W37-T13: an entity's columns legitimately come from more than ONE fact topic.
47
- # Sales movement is `sales_lines`; physical movement is `stock_moves`. Same grid, same join key
48
- # (`product_code`), different tables β€” forcing stock into the sales topic would have defined a
49
- # metric against rows it does not have.
 
 
 
 
 
 
 
 
 
 
 
50
  measures:
51
  - source: sales_lines
52
  dim: product_code
@@ -57,81 +68,123 @@ measures:
57
  # crm.team 7 is not part of either wholesale BU.
58
  scope_variant: all_channels
59
  # ⭐ The SHIP-FIRST six of `proto/P3-metric-catalog.md`, and they cost ONE grouped query
60
- # together (measured 0.22 s over 1,670 groups against the mirror; 2.55 s live).
61
- keys: [units, revenue, margin, cogs, margin_pct, asp]
62
- # β›” REPORTED, NOT SILENTLY DROPPED (standing rule 1's second sentence, applied to a catalogue).
63
- # Each of these is a real metric `proto/P3-metric-catalog.md` measured and this wave does not
64
- # ship, with the CAUSE and the fix β€” so the next session extends the list instead of
65
- # re-measuring, and nobody reads the six as "all Odoo can answer".
66
- not_yet:
67
- - key: days_since_last_sale
68
- cause: "needs `agg: max` over a DATE, which `semantic._measure_sql` does not implement (sum | count | count_distinct only), and a date-typed measure could not render anyway: `aios_grid.MEASURE_FIELD_TYPES` is {currency, int, pct}"
69
- fix: "add `agg: max` to `_measure_sql` and express the metric as an INT β€” `date_diff('day', max(date), today)` β€” so it renders as a number of days rather than a date. Measured live at 4.61 s over 3,348 SKUs"
70
- - key: distinct_orders
71
- cause: "`__count` on the live path is LINE count, not ORDER count (12,707 lines vs 12,645 distinct SKU-order pairs); the honest figure needs a 2-level groupby measured at 10.87 s live"
72
- fix: "on the STORE path this is `count(DISTINCT l.order_id)` in one pass β€” add it as a `count_distinct` metric on `sales_lines` with field `order_id`"
73
-
74
- # ⭐⭐ W37-T13 β€” PHYSICAL MOVEMENT. `stock.move` and `stock_location` are mirrored now (the
75
- # `not_yet` entry that used to sit here said "not mirrored, so there is no store binding to
76
- # group"; that is what changed). The direction rule is TWO-SIDED and lives in the metrics'
77
- # `store_filter_sql` β€” see `model/metrics/stock.yml`, which carries the 58% trap in full.
78
- - source: stock_moves
79
- dim: product_code
80
- keys: [stock_in, stock_out, stock_net]
81
- not_yet:
82
- - key: stock_in_excl_adjustments
83
- cause: "adjustments and scrap are ~30% of IN / ~20% of OUT and the two obvious exclusions are NOT equivalent - the 5,320-unit gap between them is entirely `Virtual Locations/Scrap`, so the choice is a business ruling rather than a filter"
84
- fix: "ask the owner which exclusion they mean, then add it as a SEPARATE metric with `store_filter_sql` narrowed on `sl.usage`/`sd.usage` - never by changing what `stock_in` means"
85
-
86
- # key / label / type / kind, derived from the grid contract. `kind` says where the
87
- # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
88
- # from another topic; a `link` points at another database.
89
- fields:
90
- - key: code
91
- label: "SKU"
92
- type: text
93
- kind: data
94
- means: "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
95
- - key: product
96
- label: "Product"
97
- type: text
98
- kind: data
99
- means: "Product name as it appears in Odoo."
100
- - key: category
101
- label: "Category"
102
- type: select
103
- kind: data
104
- means: "Product category; '(uncategorized)' when Odoo carries none."
105
- - key: supplier
106
- label: "Supplier"
107
- type: text
108
- kind: data
109
- means: "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet."
110
- - key: origin_country
111
- label: "Country"
112
- type: text
113
- kind: data
114
- means: "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet."
115
- - key: lead_days
116
- label: "Lead time (days)"
117
- type: int
118
- kind: data
119
- means: "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone."
120
- - key: first_cost
121
- label: "First cost"
122
- type: currency
123
- kind: data
124
- means: "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone."
125
- - key: price_fisch
126
- label: "Fisch price"
127
- type: currency
128
- kind: data
129
- means: "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
130
- - key: price_royal_1
131
- label: "Royal 1 price"
132
- type: currency
133
- kind: data
134
- means: "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  - key: price_royal_2
136
  label: "Royal 2 price"
137
  type: currency
@@ -141,7 +194,7 @@ fields:
141
  label: "Price 1"
142
  type: currency
143
  kind: data
144
- means: "Cheapest live package price for this SKU across active pricelists."
145
  - key: unit_1
146
  label: "Unit 1"
147
  type: text
@@ -151,7 +204,7 @@ fields:
151
  label: "Price 2"
152
  type: currency
153
  kind: data
154
- means: "Second-cheapest live package price for this SKU."
155
  - key: unit_2
156
  label: "Unit 2"
157
  type: text
@@ -161,12 +214,102 @@ fields:
161
  label: "Price 3"
162
  type: currency
163
  kind: data
164
- means: "Third-cheapest live package price for this SKU."
165
  - key: unit_3
166
  label: "Unit 3"
167
  type: text
168
  kind: data
169
  means: "Package label for Price 3."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  - key: pre_book_qty
171
  label: "Pre-book qty"
172
  type: int
@@ -178,67 +321,72 @@ fields:
178
  kind: data
179
  means: "Units still owed on confirmed, not-fully-delivered wholesale goods orders, including already-due promises. Delivery-charge service lines are excluded."
180
  - key: rev_ytd
181
- label: "Revenue YTD"
182
- type: currency
183
- kind: data
184
- means: "Year-to-date revenue for this SKU, BU-scoped when the caller is."
185
- - key: rev_ly
186
- label: "Revenue LY"
187
- type: currency
188
- kind: data
189
- means: "Same period last year β€” seasonal wholesale compares like for like."
190
- - key: yoy_pct
191
- label: "YoY %"
192
- type: pct
193
- kind: data
194
- means: "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
195
- - key: qty_ytd
196
- label: "Units YTD"
197
- type: int
198
- kind: data
199
- means: "Units sold year to date."
200
- - key: orders_ytd
201
- label: "Orders YTD"
202
- type: int
203
- kind: data
204
- means: "Distinct orders containing this SKU, year to date."
205
- - key: on_hand
206
- label: "On hand"
207
- type: int
208
- kind: data
209
- means: "Units in stock. CONSOLIDATED β€” one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
210
- - key: unit_cost
211
- label: "Unit cost"
212
- type: currency
213
- kind: data
214
- means: "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
215
- - key: inv_value
216
- label: "Stock value"
217
- type: currency
218
- kind: data
219
- means: "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
220
- - key: qty_ltm
221
- label: "Units LTM"
222
- type: int
223
- kind: data
224
- means: "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
225
- - key: dos
226
- label: "Days of supply"
227
- type: int
228
- kind: data
229
- means: "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
230
- - key: cover_gap_d
231
- label: "Cover gap (days)"
232
- type: int
233
- kind: data
234
- means: "Days of supply minus lead time. Negative means it runs out before a reorder lands."
235
- - key: stock_bucket
236
- label: "Stock status"
237
- type: select
238
- kind: data
239
- means: "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
240
-
241
- ai_context: >
242
- The product CATALOGUE β€” the database labelled 'Odoo products'.
243
- Use it for 'what do we sell', 'what does it cost' (first_cost/unit_cost), 'who supplies it', 'what is on hand' and the stock-coverage columns.
244
- β›” Revenue and unit columns here are WINDOWED SNAPSHOTS (rev_ytd, qty_ltm); for any other window, or for line grain, go to sales_lines.
 
 
 
 
 
 
1
+ # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
+ # the databases a person actually opens, not only the line and document grains.
3
+ #
4
+ # β›” THE `fields:` BLOCK IS A HAND-MAINTAINED, CURATED SUBSET OF THE GRID'S FIELD
5
+ # CONTRACT, AND NOTHING ENFORCES IT. This comment used to say the block was GENERATED
6
+ # and "held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a field
7
+ # row." MEASURED 2026-08-24: `section_entity_topics` does not exist anywhere in the tree
8
+ # and no gate compares this file to `aios_grid_fields.json`, so that sentence was an
9
+ # unchecked claim forbidding the only edit anyone can actually make
10
+ # ([[a-declared-gate-is-an-unchecked-claim]]).
11
+ #
12
+ # What is TRUE: 9 of the product contract's keys are deliberately absent (D-400 β€”
13
+ # incoming, cover_gap_units, demand_fwd, discontinued, needs_pricing, march_pricelist,
14
+ # price_changes, closeouts, notes), and no key here is absent from the contract. So a
15
+ # field row IS hand-edited, and the rule is the honest one: the key, type and label must
16
+ # match `aios_grid_fields.json`, because the agent is otherwise trained on a schema the
17
+ # product does not have. Building the missing gate is booked; until it exists, this
18
+ # paragraph is the only thing keeping the two in step.
19
+ key: odoo_products
20
+ label: "Odoo products"
21
+ entity: product.product
22
+ # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
23
+ grid: product_data
24
+ subject: "odoo:product.product"
25
+ grain: "one row per SKU in the active catalogue β€” a CATALOGUE, not a dated event stream"
26
+ scope:
27
+ population: "every ACTIVE product, sold or not (measured 5,862). Deliberately not the sales-window universe: that lands at 2,717 and hides ~2,550 SKUs that have never sold in wholesale scope"
28
+ identity: "the SKU code (`default_code`). ⚠ 33 active products carry NO code and are keyed `pid:<odoo product id>` instead, so every active product has exactly one row and none is dropped"
29
+ merged: "W33-T44 retired `ut_odoo_products`, which presented this same subject on Odoo's product_id. This topic describes the SURVIVING database, `product_data`"
30
+ no_date: "a catalogue has no date dimension; ask sales_lines for movement"
31
+ store:
32
+ table: product_product
33
+ alias: pp
34
+ # NO date_col β€” a registry is not a dated event stream. Stated rather than
35
+ # omitted, so its absence reads as a fact and not as an unfinished file.
36
+ dims:
37
+ category: {label: "Category"}
38
+ supplier: {label: "Supplier"}
39
+
40
+ # ⭐⭐ W37-T10 / owner item 4 (ruling R1) β€” THE LOOKBACK-MEASURE BINDING. Owner: *"Odoo products
41
+ # should have a lookbsck metrics like sales etc."* This block is what turns `measures: []` on the
42
+ # product workspace into a real catalogue, and it is DECLARED HERE rather than hand-written in a
43
+ # route for the reason `_rollup_source_offer` gives about itself: a second list is a second
44
+ # definition of the same fact, and the two drift the day somebody adds a metric.
45
+ #
46
+ # β›” THE JOIN KEY IS THE TRAP CONTRACT C1 NAMES, and this is where it is answered. `source` is a
47
+ # FACT topic; `dim` is the dim of THAT topic which carries THIS entity's identity. `sales_lines`
48
+ # has two candidates and only one is right: `product` groups by Odoo's `product_id`, while this
49
+ # database's identity (see `scope.identity` above) is `default_code`. Binding to `product` would
50
+ # key every cell on a number no grid row carries β€” a column of nulls that reads as "never sold".
51
+ #
52
+ # ⚠ A SINGLE-TOPIC GROUPED QUERY, which is why `semantic.store_query`'s cross-topic refusal does
53
+ # not bite: `revenue` lives ON `sales_lines` and we group `sales_lines` by its OWN dim. The same
54
+ # refusal is real and DOES bite `aov`/`orders` (denominator `orders` lives on `sales_orders`), so
55
+ # those two are absent from `keys` below and `entity_measures()` re-proves that rather than
56
+ # trusting this list.
57
+ # ⭐ A LIST SINCE W37-T13: an entity's columns legitimately come from more than ONE fact topic.
58
+ # Sales movement is `sales_lines`; physical movement is `stock_moves`. Same grid, same join key
59
+ # (`product_code`), different tables β€” forcing stock into the sales topic would have defined a
60
+ # metric against rows it does not have.
61
  measures:
62
  - source: sales_lines
63
  dim: product_code
 
68
  # crm.team 7 is not part of either wholesale BU.
69
  scope_variant: all_channels
70
  # ⭐ The SHIP-FIRST six of `proto/P3-metric-catalog.md`, and they cost ONE grouped query
71
+ # together (measured 0.22 s over 1,670 groups against the mirror; 2.55 s live).
72
+ keys: [units, revenue, margin, cogs, margin_pct, asp]
73
+ # β›” REPORTED, NOT SILENTLY DROPPED (standing rule 1's second sentence, applied to a catalogue).
74
+ # Each of these is a real metric `proto/P3-metric-catalog.md` measured and this wave does not
75
+ # ship, with the CAUSE and the fix β€” so the next session extends the list instead of
76
+ # re-measuring, and nobody reads the six as "all Odoo can answer".
77
+ not_yet:
78
+ - key: days_since_last_sale
79
+ cause: "needs `agg: max` over a DATE, which `semantic._measure_sql` does not implement (sum | count | count_distinct only), and a date-typed measure could not render anyway: `aios_grid.MEASURE_FIELD_TYPES` is {currency, int, pct}"
80
+ fix: "add `agg: max` to `_measure_sql` and express the metric as an INT β€” `date_diff('day', max(date), today)` β€” so it renders as a number of days rather than a date. Measured live at 4.61 s over 3,348 SKUs"
81
+ - key: distinct_orders
82
+ cause: "`__count` on the live path is LINE count, not ORDER count (12,707 lines vs 12,645 distinct SKU-order pairs); the honest figure needs a 2-level groupby measured at 10.87 s live"
83
+ fix: "on the STORE path this is `count(DISTINCT l.order_id)` in one pass β€” add it as a `count_distinct` metric on `sales_lines` with field `order_id`"
84
+
85
+ # ⭐⭐ W37-T13 β€” PHYSICAL MOVEMENT. `stock.move` and `stock_location` are mirrored now (the
86
+ # `not_yet` entry that used to sit here said "not mirrored, so there is no store binding to
87
+ # group"; that is what changed). The direction rule is TWO-SIDED and lives in the metrics'
88
+ # `store_filter_sql` β€” see `model/metrics/stock.yml`, which carries the 58% trap in full.
89
+ - source: stock_moves
90
+ dim: product_code
91
+ keys: [stock_in, stock_out, stock_net]
92
+ not_yet:
93
+ - key: stock_in_excl_adjustments
94
+ cause: "adjustments and scrap are ~30% of IN / ~20% of OUT and the two obvious exclusions are NOT equivalent - the 5,320-unit gap between them is entirely `Virtual Locations/Scrap`, so the choice is a business ruling rather than a filter"
95
+ fix: "ask the owner which exclusion they mean, then add it as a SEPARATE metric with `store_filter_sql` narrowed on `sl.usage`/`sd.usage` - never by changing what `stock_in` means"
96
+
97
+ # ⭐⭐ W40-T10 / owner item 26 (ruling R7, contract C6) β€” PER-METRIC CHANNEL SCOPE. Owner:
98
+ # *"Separate revenue and the other Odoo products metrics by Analytics, as a filter under the
99
+ # Metrics field, specific to the Odoo databases."* R7 makes it PER METRIC rather than per grid, so
100
+ # "Revenue (Fisch)" and "Revenue (Amazon)" sit side by side on one grid and answer differently.
101
+ #
102
+ # β›” THE CHANNEL IS A BINDING, NOT A MEMBER OF THE SAVED FIELD. C6 words it as part of the stored
103
+ # Metric spec and it CANNOT be one: `aios_grid.clean_measure_field` rebuilds that dict from known
104
+ # members and returns `{key, window}`, so a `channel` member is dropped on save with nothing to
105
+ # notice, and `aios_grid.py` is in no lane's fence. The key survives, so the channel rides the
106
+ # KEY: `semantic._one_binding_offer` derives the suffix and the "(Fisch)" from the variant name,
107
+ # and the catalogue offers `revenue` / `revenue_fisch` / `revenue_royal` / `revenue_amazon` as
108
+ # four distinct keys. No client change: the picker already offers whatever the catalogue offers.
109
+ #
110
+ # β›” THESE MUST STAY *AFTER* THE TWO ABOVE. `entity_measure_binding()` and two gates
111
+ # (`verify_product_pool::W39-T11`, `verify_odoo_relational::_prove_entity_measures`) read
112
+ # `bindings[0]` as "the" binding; putting a channel binding first would silently retarget them.
113
+ #
114
+ # ⚠ `not_yet:` is deliberately NOT repeated here. It documents the METRIC and why the wave does
115
+ # not ship it, which is a fact about the metric and not about the channel β€” copying it four times
116
+ # would be four statements of one thing, and the day one is answered three would go stale.
117
+ #
118
+ # ⭐ COST: one grouped scan PER CHANNEL, because `entity_measure_values` groups by
119
+ # `(topic, dim, scope_variant)`. Four channel columns of the same measure over the same window
120
+ # are four scans, ~0.22 s each against the mirror. A grid carrying one channel costs exactly what
121
+ # it costs today.
122
+ - source: sales_lines
123
+ dim: product_code
124
+ scope_variant: channel_fisch
125
+ keys: [units, revenue, margin, cogs, margin_pct, asp]
126
+ - source: sales_lines
127
+ dim: product_code
128
+ scope_variant: channel_royal
129
+ keys: [units, revenue, margin, cogs, margin_pct, asp]
130
+ # β›” `amazon` IS THE EXCLUDED PARTNER, NOT crm.team 7 β€” measured, and the difference is real:
131
+ # GIFTWARE DEALS carries 24 orders on team 7 AND 2 on team 5, so the partner and the team name
132
+ # different sets. `core/odoo.sale_line_domain` already fences wholesale on the PARTNER, so taking
133
+ # the team here would be a second, disagreeing idea of what Amazon is.
134
+ - source: sales_lines
135
+ dim: product_code
136
+ scope_variant: channel_amazon
137
+ keys: [units, revenue, margin, cogs, margin_pct, asp]
138
+
139
+ # key / label / type / kind, derived from the grid contract. `kind` says where the
140
+ # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
141
+ # from another topic; a `link` points at another database.
142
+ fields:
143
+ - key: code
144
+ label: "SKU"
145
+ type: text
146
+ kind: data
147
+ means: "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
148
+ - key: product
149
+ label: "Product"
150
+ type: text
151
+ kind: data
152
+ means: "Product name as it appears in Odoo."
153
+ - key: category
154
+ label: "Category"
155
+ type: select
156
+ kind: data
157
+ means: "Product category; '(uncategorized)' when Odoo carries none."
158
+ - key: supplier
159
+ label: "Supplier"
160
+ type: multiselect
161
+ kind: data
162
+ means: "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet."
163
+ - key: origin_country
164
+ label: "Country"
165
+ type: text
166
+ kind: data
167
+ means: "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet."
168
+ - key: lead_days
169
+ label: "Lead time (days)"
170
+ type: int
171
+ kind: data
172
+ means: "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone."
173
+ - key: first_cost
174
+ label: "First cost"
175
+ type: currency
176
+ kind: data
177
+ means: "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone."
178
+ - key: price_fisch
179
+ label: "Fisch price"
180
+ type: currency
181
+ kind: data
182
+ means: "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
183
+ - key: price_royal_1
184
+ label: "Royal 1 price"
185
+ type: currency
186
+ kind: data
187
+ means: "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
188
  - key: price_royal_2
189
  label: "Royal 2 price"
190
  type: currency
 
194
  label: "Price 1"
195
  type: currency
196
  kind: data
197
+ means: "Cheapest live package price for this SKU pooled across ALL active pricelists and unattributed. Ask the per-list tier fields for what a named list charges."
198
  - key: unit_1
199
  label: "Unit 1"
200
  type: text
 
204
  label: "Price 2"
205
  type: currency
206
  kind: data
207
+ means: "Second-cheapest live package price pooled across ALL pricelists, unattributed."
208
  - key: unit_2
209
  label: "Unit 2"
210
  type: text
 
214
  label: "Price 3"
215
  type: currency
216
  kind: data
217
+ means: "Third-cheapest live package price pooled across ALL pricelists, unattributed."
218
  - key: unit_3
219
  label: "Unit 3"
220
  type: text
221
  kind: data
222
  means: "Package label for Price 3."
223
+ - key: price_fisch_t1
224
+ label: "Fisch tier 1 price"
225
+ type: currency
226
+ kind: data
227
+ means: "Fisch price tier 1, cheapest first. Blank when Fisch does not price this SKU."
228
+ - key: unit_fisch_t1
229
+ label: "Fisch tier 1 unit"
230
+ type: text
231
+ kind: data
232
+ means: "Package label for the Fisch tier 1 price. Blank when Odoo names no package."
233
+ - key: price_fisch_t2
234
+ label: "Fisch tier 2 price"
235
+ type: currency
236
+ kind: data
237
+ means: "Fisch price tier 2. Blank when Fisch prices this SKU at only one tier."
238
+ - key: unit_fisch_t2
239
+ label: "Fisch tier 2 unit"
240
+ type: text
241
+ kind: data
242
+ means: "Package label for the Fisch tier 2 price. Blank when Odoo names no package."
243
+ - key: price_fisch_t3
244
+ label: "Fisch tier 3 price"
245
+ type: currency
246
+ kind: data
247
+ means: "Fisch price tier 3. Blank when Fisch prices this SKU at fewer than three tiers."
248
+ - key: unit_fisch_t3
249
+ label: "Fisch tier 3 unit"
250
+ type: text
251
+ kind: data
252
+ means: "Package label for the Fisch tier 3 price. Blank when Odoo names no package."
253
+ - key: price_royal1_t1
254
+ label: "Royal 1 tier 1 price"
255
+ type: currency
256
+ kind: data
257
+ means: "Royal 1 price tier 1, cheapest first. Blank when Royal 1 does not price this SKU."
258
+ - key: unit_royal1_t1
259
+ label: "Royal 1 tier 1 unit"
260
+ type: text
261
+ kind: data
262
+ means: "Package label for the Royal 1 tier 1 price. Blank when Odoo names no package."
263
+ - key: price_royal1_t2
264
+ label: "Royal 1 tier 2 price"
265
+ type: currency
266
+ kind: data
267
+ means: "Royal 1 price tier 2. Blank when Royal 1 prices this SKU at only one tier."
268
+ - key: unit_royal1_t2
269
+ label: "Royal 1 tier 2 unit"
270
+ type: text
271
+ kind: data
272
+ means: "Package label for the Royal 1 tier 2 price. Blank when Odoo names no package."
273
+ - key: price_royal1_t3
274
+ label: "Royal 1 tier 3 price"
275
+ type: currency
276
+ kind: data
277
+ means: "Royal 1 price tier 3. Blank when Royal 1 prices this SKU at fewer than three tiers."
278
+ - key: unit_royal1_t3
279
+ label: "Royal 1 tier 3 unit"
280
+ type: text
281
+ kind: data
282
+ means: "Package label for the Royal 1 tier 3 price. Blank when Odoo names no package."
283
+ - key: price_royal2_t1
284
+ label: "Royal 2 tier 1 price"
285
+ type: currency
286
+ kind: data
287
+ means: "Royal 2 price tier 1, cheapest first. Blank when Royal 2 does not price this SKU."
288
+ - key: unit_royal2_t1
289
+ label: "Royal 2 tier 1 unit"
290
+ type: text
291
+ kind: data
292
+ means: "Package label for the Royal 2 tier 1 price. Blank when Odoo names no package."
293
+ - key: price_royal2_t2
294
+ label: "Royal 2 tier 2 price"
295
+ type: currency
296
+ kind: data
297
+ means: "Royal 2 price tier 2. Blank when Royal 2 prices this SKU at only one tier."
298
+ - key: unit_royal2_t2
299
+ label: "Royal 2 tier 2 unit"
300
+ type: text
301
+ kind: data
302
+ means: "Package label for the Royal 2 tier 2 price. Blank when Odoo names no package."
303
+ - key: price_royal2_t3
304
+ label: "Royal 2 tier 3 price"
305
+ type: currency
306
+ kind: data
307
+ means: "Royal 2 price tier 3. Blank when Royal 2 prices this SKU at fewer than three tiers."
308
+ - key: unit_royal2_t3
309
+ label: "Royal 2 tier 3 unit"
310
+ type: text
311
+ kind: data
312
+ means: "Package label for the Royal 2 tier 3 price. Blank when Odoo names no package."
313
  - key: pre_book_qty
314
  label: "Pre-book qty"
315
  type: int
 
321
  kind: data
322
  means: "Units still owed on confirmed, not-fully-delivered wholesale goods orders, including already-due promises. Delivery-charge service lines are excluded."
323
  - key: rev_ytd
324
+ label: "Revenue YTD"
325
+ type: currency
326
+ kind: data
327
+ means: "Year-to-date revenue for this SKU, BU-scoped when the caller is."
328
+ - key: rev_ly
329
+ label: "Revenue LY"
330
+ type: currency
331
+ kind: data
332
+ means: "Same period last year β€” seasonal wholesale compares like for like."
333
+ - key: yoy_pct
334
+ label: "YoY %"
335
+ type: pct
336
+ kind: data
337
+ means: "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
338
+ - key: qty_ytd
339
+ label: "Units YTD"
340
+ type: int
341
+ kind: data
342
+ means: "Units sold year to date."
343
+ - key: orders_ytd
344
+ label: "Orders YTD"
345
+ type: int
346
+ kind: data
347
+ means: "Distinct orders containing this SKU, year to date."
348
+ - key: on_hand
349
+ label: "On hand"
350
+ type: int
351
+ kind: data
352
+ means: "Units in stock. CONSOLIDATED β€” one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
353
+ - key: unit_cost
354
+ label: "Unit cost"
355
+ type: currency
356
+ kind: data
357
+ means: "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
358
+ - key: inv_value
359
+ label: "Stock value"
360
+ type: currency
361
+ kind: data
362
+ means: "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
363
+ - key: qty_ltm
364
+ label: "Units LTM"
365
+ type: int
366
+ kind: data
367
+ means: "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
368
+ - key: dos
369
+ label: "Days of supply"
370
+ type: int
371
+ kind: data
372
+ means: "Days of supply at the trailing twelve month rate, counting on hand plus inbound units as stock. Blank means there is no honest number: either the product sold nothing in the window and Stock status reads 'No recent sales', or the inventory read returned no row for it and Stock status reads 'No stock record'. A product that sold nothing and holds nothing reads 0, not blank. A business unit caller gets this column too, re-scoped: the shelf is the whole warehouse's, the rate is that unit's."
373
+ - key: cover_gap_d
374
+ label: "Cover gap (days)"
375
+ type: int
376
+ kind: data
377
+ means: "Days of supply minus lead time. Negative means it runs out before a reorder lands."
378
+ - key: stock_bucket
379
+ label: "Stock status"
380
+ type: select
381
+ kind: data
382
+ means: "Dead / excess / healthy bucket from the inventory module, off the same days of supply figure beside it. Two values are not coverage bands: 'Out of stock' keys on the real shelf rather than the inbound-inclusive figure, and 'No stock record' means the inventory read returned no row for this product, so its shelf is unknown rather than empty. A business unit caller gets this column too, re-scoped through that unit's own sales rate."
383
+ - key: created_on
384
+ label: "Record created"
385
+ type: date
386
+ kind: data
387
+ means: "The date this product record was created in Odoo. The same uniform records-created column the customer topic carries."
388
+
389
+ ai_context: >
390
+ The product CATALOGUE β€” the database labelled 'Odoo products'.
391
+ Use it for 'what do we sell', 'what does it cost' (first_cost/unit_cost), 'who supplies it', 'what is on hand' and the stock-coverage columns.
392
+ β›” Revenue and unit columns here are WINDOWED SNAPSHOTS (rev_ytd, qty_ltm); for any other window, or for line grain, go to sales_lines.
platform/modules/customer_data.py CHANGED
@@ -18,6 +18,9 @@ tie. Persistence: HF store key 'customer_lists'
18
  The user↔agent link (users.py 'agent' field) scopes the pool to that agent's book; users with
19
  no link (owner/CFO/admin) see the whole book.
20
  """
 
 
 
21
  import core.odoo as O
22
  import core.periods as P
23
  import core.store as store
@@ -31,6 +34,57 @@ LIMIT = 500 # honest builder display cap β€” always shown with the full
31
  KEY = 'customer_lists'
32
  TABLE_KEY = 'customer_table_workspace'
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  # ------------------------------------------------------------------ the metric pool
35
  # Field metadata for the rule builder: key -> (label, kind). 'num' fields take >=/<=/>/</=,
36
  # 'text' fields take contains/=. Every field is a column of pool() rows.
@@ -577,6 +631,19 @@ def _pool_build(agent_name, team_id, limit, fast=False):
577
  # Row-level datum for the `created_time` field type (wave-5 item 11) β€” rides every
578
  # row like pid, no column of its own until a user creates one.
579
  '_created': a.get('created_at', ''),
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  'tags': a.get('tags', '(none)'), 'pricelist': a.get('pricelist', '(none)'),
581
  # AR / credit exposure β€” composed from the already-reconciled ar.py blocks, so the
582
  # Customer table and the Collections page cannot disagree.
@@ -679,7 +746,12 @@ def _reconcile_ledger(rows, total, agent_name, team_id):
679
  'agent': '(none)', 'street': '(none)', 'street2': '(none)',
680
  'city': '(none)', 'state': '(none)', 'country': '(none)',
681
  'zip': '(none)', 'payment_terms': '(none)', 'customer_since': '',
682
- '_created': '', 'tags': '(none)', 'pricelist': '(none)',
 
 
 
 
 
683
  'ar_open': 0.0, 'ar_overdue': 0.0, 'ar_outstanding': 0.0, 'ar_exposure': 0.0,
684
  'days_to_pay': None,
685
  **{k: 0.0 for k in AR_BUCKET_FIELDS.values()},
 
18
  The user↔agent link (users.py 'agent' field) scopes the pool to that agent's book; users with
19
  no link (owner/CFO/admin) see the whole book.
20
  """
21
+ import re
22
+ from urllib.parse import quote_plus
23
+
24
  import core.odoo as O
25
  import core.periods as P
26
  import core.store as store
 
34
  KEY = 'customer_lists'
35
  TABLE_KEY = 'customer_table_workspace'
36
 
37
+ # ------------------------------------------------------------------ the Google Maps link
38
+ #: The sentinel every blank connector attribute collapses to (`customers._partner_attrs` writes
39
+ #: it; the client's `CONNECTOR_BLANK` renders it). It is a real, visible value on the grid, NOT a
40
+ #: null β€” so an address builder that treats it as text pastes the literal string "(none)" into a
41
+ #: query and sends the reader to a place called none.
42
+ BLANK_ATTR = '(none)'
43
+
44
+ #: A trailing ISO country code in parentheses, as `O.m2o_name(state_id)` yields it: Odoo's state
45
+ #: names read "Texas (US)". ⚠ ANCHORED and UPPERCASE-ONLY on purpose. A greedy "strip the last
46
+ #: parenthetical" would eat a legitimate name that happens to end in brackets, and there is no
47
+ #: reason to risk that for a tidiness fix.
48
+ _STATE_COUNTRY_SUFFIX = re.compile(r'\s*\([A-Z]{2,3}\)$')
49
+
50
+ #: Google's documented Maps URLs endpoint for a place SEARCH. The same shape the client already
51
+ #: builds for a pin (`customer-grid/mapProjection.ts`), so the two doors agree.
52
+ MAPS_SEARCH_URL = 'https://www.google.com/maps/search/?api=1&query='
53
+
54
+
55
+ def _addr_part(v):
56
+ """One address line, or '' β€” the sentinel and whitespace both read as absent."""
57
+ s = str(v or '').strip()
58
+ return '' if not s or s == BLANK_ATTR else s
59
+
60
+
61
+ def maps_url(a):
62
+ """A Google Maps link to a partner's address, or '' when there is no address to point at.
63
+
64
+ ⭐⭐ W40-T08 (owner I27) β€” SERVER-SIDE, and that is the whole design, per the wave's AM-1. The
65
+ owner's instruction offered a client Formula as one option; D-115 says a formula column's
66
+ values exist only in the browser, so the link would be blank in every host-side export and
67
+ invisible to anything reading the stored cell. Computed here it is an ordinary cell value on
68
+ the pool row, so it exports, filters and reads back like any other column.
69
+
70
+ β›” BLANK UNLESS THE ROW CAN ACTUALLY BE LOCATED. The gate is street OR city OR zip: a country
71
+ alone ("United States") resolves to a link that opens the middle of a continent, which is
72
+ worse than an empty cell because it looks like an answer. MEASURED 2026-08-23: 140 of 3,641
73
+ customers have no street, city or zip, and those are the rows this returns '' for.
74
+
75
+ ⚠ `state` is stripped of its country suffix. It arrives as `O.m2o_name(state_id)`, i.e.
76
+ "Texas (US)", and the country is already its own part of the address. Google resolves the
77
+ string either way, so this is a tidiness call, not a correctness one.
78
+ """
79
+ street, street2 = _addr_part(a.get('street')), _addr_part(a.get('street2'))
80
+ city, zipcode = _addr_part(a.get('city')), _addr_part(a.get('zip'))
81
+ if not (street or city or zipcode):
82
+ return ''
83
+ state = _STATE_COUNTRY_SUFFIX.sub('', _addr_part(a.get('state')))
84
+ parts = [p for p in (street, street2, city, state, zipcode,
85
+ _addr_part(a.get('country'))) if p]
86
+ return MAPS_SEARCH_URL + quote_plus(', '.join(parts))
87
+
88
  # ------------------------------------------------------------------ the metric pool
89
  # Field metadata for the rule builder: key -> (label, kind). 'num' fields take >=/<=/>/</=,
90
  # 'text' fields take contains/=. Every field is a column of pool() rows.
 
631
  # Row-level datum for the `created_time` field type (wave-5 item 11) β€” rides every
632
  # row like pid, no column of its own until a user creates one.
633
  '_created': a.get('created_at', ''),
634
+ # ⭐⭐ W40-T08 (owner I24) β€” the DECLARED pre-set date column, beside the row datum
635
+ # above rather than instead of it. `_created` is only ever readable through a
636
+ # `created_time` column the user has to create; this is a real cell under its own key,
637
+ # so it exports, filters and appears in the semantic topic. Truncated the way
638
+ # `customer_since` is, and for the same reason: a date-typed column compared
639
+ # ISO-LEXICALLY drifts by the time component if the time is left on.
640
+ # ⚠ The value is IDENTICAL to `customer_since` on this database, because both come
641
+ # from `res.partner.create_date`. That is not a duplicate by accident: "Customer
642
+ # since" is a business fact about the relationship and cannot be the uniform
643
+ # records-created column I24 asks every database for (Products has no such notion).
644
+ 'created_on': str(a.get('created_at') or '')[:10],
645
+ # ⭐⭐ W40-T08 (owner I27) β€” the Google Maps link, replacing the hardcoded "Go" column.
646
+ 'maps_url': maps_url(a),
647
  'tags': a.get('tags', '(none)'), 'pricelist': a.get('pricelist', '(none)'),
648
  # AR / credit exposure β€” composed from the already-reconciled ar.py blocks, so the
649
  # Customer table and the Collections page cannot disagree.
 
746
  'agent': '(none)', 'street': '(none)', 'street2': '(none)',
747
  'city': '(none)', 'state': '(none)', 'country': '(none)',
748
  'zip': '(none)', 'payment_terms': '(none)', 'customer_since': '',
749
+ # W40-T08 lands on BOTH row templates, which is the warning above being obeyed
750
+ # rather than restated. A revived row has no address and no create_date to read,
751
+ # so both are blank β€” and blank is exactly what `maps_url` returns for an
752
+ # addressless customer anyway, so the archived rows agree with the live ones.
753
+ '_created': '', 'created_on': '', 'maps_url': '',
754
+ 'tags': '(none)', 'pricelist': '(none)',
755
  'ar_open': 0.0, 'ar_overdue': 0.0, 'ar_outstanding': 0.0, 'ar_exposure': 0.0,
756
  'days_to_pay': None,
757
  **{k: 0.0 for k in AR_BUCKET_FIELDS.values()},
platform/modules/inventory.py CHANGED
@@ -14,11 +14,29 @@ import core.periods as P
14
 
15
 
16
  def _cat_main_map():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  cats = O.search_read('product.category', [], ['id', 'complete_name'])
18
  out = {}
19
  for c in cats:
20
  parts = [x.strip() for x in (c['complete_name'] or '').split('/')]
21
- out[c['id']] = parts[1] if len(parts) >= 2 else (parts[0] if parts else None)
22
  return out
23
 
24
 
@@ -220,8 +238,33 @@ def _bucket(dos_raw, on_hand, qty_ltm):
220
  return _COVERAGE_BUCKETS[-1][0]
221
 
222
 
223
- # the coverage facet, worst→best, for the SKU directory filter
224
- COVERAGE_LABELS = ['Out of stock'] + [b[0] for b in _COVERAGE_BUCKETS] + ['No recent sales']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
 
227
  def sku_inventory(target_days=180, carry_pct=_CARRY_PCT, t=None):
 
14
 
15
 
16
  def _cat_main_map():
17
+ """{category id: MAIN category name}, the SECOND segment of Odoo's `complete_name` path.
18
+
19
+ THE ROOT MAPS TO `None`, NOT TO ITS OWN NAME (owner instruction 23, wave 40). Odoo roots the
20
+ whole category tree at one node whose `complete_name` is the bare word "All", and the old
21
+ `parts[0]` fallback handed that word out as though it were a category. It is not one: it is
22
+ the tree's root, and a product filed directly on it is UNCATEGORISED. Exactly one of the 74
23
+ categories is single segment, so the root is the only value that could ever reach this branch.
24
+
25
+ MEASURED live 2026-08-23: 3,334 of 5,841 coded products (57.1%) sit directly on the root, so
26
+ this is the MAJORITY of the catalogue and not an edge case.
27
+
28
+ `None` IS THE CONTRACT, and all three callers already honour it. `inventory._build`,
29
+ `financial._product_cat` and `sales._product_cat` each read this map through
30
+ `... or '(uncategorized)'`, which is the sentinel the product grid's own field description
31
+ already promises: "Product category; '(uncategorized)' when Odoo carries none". So the root's
32
+ products land on the DECLARED blank value instead of on an invented label, and no caller has
33
+ to learn a new convention.
34
+ """
35
  cats = O.search_read('product.category', [], ['id', 'complete_name'])
36
  out = {}
37
  for c in cats:
38
  parts = [x.strip() for x in (c['complete_name'] or '').split('/')]
39
+ out[c['id']] = parts[1] if len(parts) >= 2 else None
40
  return out
41
 
42
 
 
238
  return _COVERAGE_BUCKETS[-1][0]
239
 
240
 
241
+ #: ⭐ AM-4 (2026-08-23), BRANCH (c) β€” WHAT A SKU WITH NO INVENTORY ROW SAYS FOR ITSELF.
242
+ #: `_bucket` never returns this and never can: it is `product_data._dos_with_inbound`'s answer for
243
+ #: `on_hand is None`, a case that returns before `_bucket` is reached. It lives here, named, rather
244
+ #: than as a literal there, because two places must spell it identically β€” the product row that
245
+ #: carries it, and the permission editor's dropdown, which `routes_admin::
246
+ #: _server_side_vocabularies` fills from the list below.
247
+ #:
248
+ #: β›” THE WORDING IS LOAD-BEARING, NOT DECORATION, and AM-4 is explicit about why.
249
+ #: `product_data._inventory_by_code` degrades to `{}` on ANY failure, so during an inventory-read
250
+ #: outage EVERY product in the catalogue renders this label at once. "Stock not tracked" would
251
+ #: then be a catalogue-wide false claim about the warehouse, and "never sold" is false for most of
252
+ #: these rows even on a good day. What is genuinely absent in BOTH cases β€” the ~33 products with
253
+ #: no `default_code`, and an outage β€” is the RECORD, so that is what the label names.
254
+ NO_STOCK_RECORD = 'No stock record'
255
+
256
+ # Every value a `stock_bucket` cell can hold, and the vocabulary the permission editor offers for
257
+ # that column (`routes_admin::_server_side_vocabularies` imports this rather than restating it).
258
+ # The coverage BANDS run worst→best between the two ends; the last two are not on that axis at all
259
+ # β€” 'No recent sales' is a full shelf nobody is buying from, and `NO_STOCK_RECORD` means there is
260
+ # no reading of the shelf to band in the first place.
261
+ # ⚠ THE ORDER IS FOR A READER OF THIS FILE, NOT A CONTRACT ANY SURFACE HONOURS. Measured
262
+ # 2026-08-24: `routes_admin.py:868` re-sorts every supplied vocabulary alphabetically, and the
263
+ # grid's own dropdown (`types.ts::choiceVocabulary`) discovers values from loaded ROWS and sorts
264
+ # them too. Same SET on both, never this sequence. Do not add a label here expecting it to land in
265
+ # a particular slot on screen.
266
+ COVERAGE_LABELS = (['Out of stock'] + [b[0] for b in _COVERAGE_BUCKETS]
267
+ + ['No recent sales', NO_STOCK_RECORD])
268
 
269
 
270
  def sku_inventory(target_days=180, carry_pct=_CARRY_PCT, t=None):
platform/modules/product_data.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/modules/products.py CHANGED
@@ -289,8 +289,13 @@ def catalogue():
289
  # to aggregate it (`Fault 2: Cannot aggregate field 'incoming_qty'`), so nothing downstream
290
  # may push a filter or a read_group on it back to Odoo. It is materialised into the pool here
291
  # and summed on our side; `validate()` reconciles that sum against purchase-order lines.
 
 
 
 
 
292
  prods = O.search_read('product.product', dom,
293
- ['id', 'default_code', 'display_name', 'name',
294
  'product_tag_ids', 'incoming_qty'],
295
  limit=50000)
296
  n = O.get_odoo().search_count('product.product', dom)
@@ -323,13 +328,23 @@ def catalogue():
323
  # downstream in `aios_grid.py` and has to be carried from the source read.
324
  row = out.setdefault(code, {'product': p.get('display_name') or p.get('name') or code,
325
  'category': code_cat.get(code, '(uncategorized)'),
326
- 'id': p['id'],
327
  'discontinued': 'No', 'incoming': 0.0})
328
  # β›” ACROSS EVERY RECORD SHARING THE CODE, not just the first one that won above. A SKU
329
  # whose variants are separate records carries its inbound on whichever record the PO named,
330
  # and 'first record wins' would drop the rest β€” a buy list that under-counts what is
331
  # already on the water tells you to re-order stock you have already bought.
332
  row['incoming'] += float(p.get('incoming_qty') or 0.0)
 
 
 
 
 
 
 
 
 
 
333
  if disc_tag is not None and disc_tag in (p.get('product_tag_ids') or ()):
334
  row['discontinued'] = 'Yes'
335
  return out
@@ -432,6 +447,98 @@ PRICELIST_COLUMNS = (
432
  ("price_royal_2", "Royal 2"),
433
  )
434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
 
436
  def pricelist_by_code():
437
  """`({code: {column_key: price}}, report)` β€” the date-valid FIXED base-tier price for each
 
289
  # to aggregate it (`Fault 2: Cannot aggregate field 'incoming_qty'`), so nothing downstream
290
  # may push a filter or a read_group on it back to Odoo. It is materialised into the pool here
291
  # and summed on our side; `validate()` reconciles that sum against purchase-order lines.
292
+ # ⭐⭐ W40-T08 (owner I24) β€” `create_date` RIDES THIS READ TOO, for the same reason the two
293
+ # above do. It has to come from the LIVE record: the DuckDB mirror's `product_product` carries
294
+ # `write_date` and NO `create_date`, so there is no cheaper source, and this read already pulls
295
+ # every active product once. MEASURED 2026-08-23 against live Odoo: 5,874 active products, of
296
+ # which 0 have a null `create_date`, so the column is populated for the whole catalogue.
297
  prods = O.search_read('product.product', dom,
298
+ ['id', 'default_code', 'display_name', 'name', 'create_date',
299
  'product_tag_ids', 'incoming_qty'],
300
  limit=50000)
301
  n = O.get_odoo().search_count('product.product', dom)
 
328
  # downstream in `aios_grid.py` and has to be carried from the source read.
329
  row = out.setdefault(code, {'product': p.get('display_name') or p.get('name') or code,
330
  'category': code_cat.get(code, '(uncategorized)'),
331
+ 'id': p['id'], 'created_at': '',
332
  'discontinued': 'No', 'incoming': 0.0})
333
  # β›” ACROSS EVERY RECORD SHARING THE CODE, not just the first one that won above. A SKU
334
  # whose variants are separate records carries its inbound on whichever record the PO named,
335
  # and 'first record wins' would drop the rest β€” a buy list that under-counts what is
336
  # already on the water tells you to re-order stock you have already bought.
337
  row['incoming'] += float(p.get('incoming_qty') or 0.0)
338
+ # ⭐ W40-T08 β€” the EARLIEST create_date across the records sharing this code, not the
339
+ # first record's. A row here is a SKU, and the honest answer to "when was this record
340
+ # created" for a merged SKU is when the earliest of its records appeared. Only `2112-12`
341
+ # is affected today (5,873 active products, 5,872 distinct codes), so this is a one-row
342
+ # policy call rather than a measurable difference; it is written down because the
343
+ # first-wins convention above would otherwise read as covering this key too.
344
+ # ⚠ Lexical `<` is a real date comparison here: Odoo datetimes are fixed-width ISO.
345
+ _cd = str(p.get('create_date') or '')
346
+ if _cd and (not row['created_at'] or _cd < row['created_at']):
347
+ row['created_at'] = _cd
348
  if disc_tag is not None and disc_tag in (p.get('product_tag_ids') or ()):
349
  row['discontinued'] = 'Yes'
350
  return out
 
447
  ("price_royal_2", "Royal 2"),
448
  )
449
 
450
+ #: ⭐⭐ W40-T09 / RULING R6 (owner I25: *"Fisch price and Royal price each have 3 units and 3
451
+ #: prices. Separate them and put all of them in the Odoo fields."*) β€” HOW MANY (price, unit)
452
+ #: PAIRS EACH DECLARED LIST SHOWS.
453
+ #:
454
+ #: β›” THIS CONSTANT IS THE CAP, AND NAMING IT IS THE POINT. `tier_prices_by_code` below returns
455
+ #: EVERY live tier a SKU has; only the WRITE into the grid is limited, and it is limited here in
456
+ #: one place rather than by a `range(1, 4)` inlined at the call site the way the pooled
457
+ #: `price_1..3` block was. MEASURED on the live book 2026-08-24 over 3,996 priced codes: exactly
458
+ #: ONE SKU/list pair exceeds this depth β€” `6AB` on Fisch, which carries FOUR tiers β€” so raising
459
+ #: this number to 4 is the whole of the fix if the owner ever wants that fourth cell. Max tiers
460
+ #: observed per list: Fisch 4, Royal 1 3, Royal 2 3, Public Pricelist 1.
461
+ PRICELIST_TIER_DEPTH = 3
462
+
463
+
464
+ def _pricelist_slug(col):
465
+ """`price_royal_1` -> `royal1`, the token the per-list tier keys are built on.
466
+
467
+ β›” THE UNDERSCORE IS REMOVED DELIBERATELY, and it is not cosmetic. `price_royal_1` ALREADY
468
+ EXISTS and means "the Royal 1 list's base price", so a tier key of `price_royal_1_t1` shares
469
+ a prefix with a different column and is ambiguous to anyone diffing the contract by eye.
470
+ `price_royal1_t1` cannot be mistaken for a suffix of it.
471
+ """
472
+ return col[len("price_"):].replace("_", "")
473
+
474
+
475
+ #: `[(price_key, unit_key, pricelist_name, tier_no)]` β€” the 18 columns R6 declares, DERIVED from
476
+ #: `PRICELIST_COLUMNS` so this tenant's list names live in exactly one place. A fourth pricelist
477
+ #: added above becomes six more tier columns here with no second edit.
478
+ PRICELIST_TIER_COLUMNS = tuple(
479
+ (f"price_{_pricelist_slug(col)}_t{n}", f"unit_{_pricelist_slug(col)}_t{n}", name, n)
480
+ for col, name in PRICELIST_COLUMNS
481
+ for n in range(1, PRICELIST_TIER_DEPTH + 1)
482
+ )
483
+
484
+
485
+ def per_list_tier_cells(tiers):
486
+ """One SKU's `tier_prices_by_code` entry -> `{column_key: value}` for all 18 R6 cells.
487
+
488
+ ⭐⭐ THE WHOLE OF W40-T09, and it is a GROUP-BY, not a new read. `tier_prices_by_code`
489
+ already carries the pricelist NAME on every entry, so attributing prices to the list that
490
+ charges them costs nothing beyond this loop.
491
+
492
+ β›” **IT NEVER BORROWS.** A list this SKU is not priced on gets `None` in all six of its
493
+ cells β€” never the cheapest price from another list, never a fallback. That is R6's negative
494
+ control and it is the reason the pooled `price_1..3` block could not simply be relabelled:
495
+ those three are the cheapest three POOLED ACROSS ALL LISTS and unattributed, so on a SKU
496
+ priced only on Fisch they would put a Fisch price under a Royal heading.
497
+ MEASURED: `BOUT-WH` has three tiers, all Fisch, on NEITHER Royal list β€” one SKU that is
498
+ simultaneously the cheapest-first case and this negative control.
499
+
500
+ ⚠ CHEAPEST-FIRST IS GUARANTEED HERE, not inherited. `tier_prices_by_code` does sort its
501
+ lists globally by price, and a stable group-by would preserve that within each list β€” but a
502
+ caller handing in a fixture, or a future change to that sort key, would silently reorder the
503
+ cells. Sorting the group is one line and makes the guarantee local to the function that
504
+ makes it. The tiebreak matches the reader's: unit name, then list name.
505
+
506
+ ⚠ A LIST NOT IN `PRICELIST_COLUMNS` IS INVISIBLE HERE BY DESIGN β€” the columns are named
507
+ after the three lists the contract declares. MEASURED: 3 active SKUs are priced ONLY on
508
+ Public Pricelist, so they carry all eighteen cells empty. `tier_prices_by_code` still sees
509
+ those prices; nothing is lost from the honest set, only from these named columns.
510
+ """
511
+ grouped = {}
512
+ for tier in tiers or ():
513
+ grouped.setdefault(tier.get("pricelist"), []).append(tier)
514
+ for got in grouped.values():
515
+ got.sort(key=lambda t: (t.get("unit_price") or 0.0,
516
+ str(t.get("unit") or "").lower(),
517
+ str(t.get("pricelist") or "").lower()))
518
+ cells = {}
519
+ for price_key, unit_key, name, n in PRICELIST_TIER_COLUMNS:
520
+ got = grouped.get(name) or []
521
+ tier = got[n - 1] if len(got) >= n else None
522
+ cells[price_key] = tier.get("unit_price") if tier else None
523
+ cells[unit_key] = tier.get("unit") if tier else None
524
+ return cells
525
+
526
+
527
+ def tier_cap_overflow(tiers):
528
+ """`{pricelist_name: how many tiers PRICELIST_TIER_DEPTH hides}` for one SKU, `{}` when none.
529
+
530
+ ⭐ STANDING RULE 1's SECOND SENTENCE, in code: *"if there is lag or it can't be done, you
531
+ need to explicitly tell me why and recommend a fix."* R6 declares three pairs per list, so a
532
+ fourth tier is dropped β€” and a dropped tier that nobody counts is the silent cap the rule
533
+ forbids. `validate_price_and_unit_cells` turns this into a named, reported figure.
534
+ """
535
+ counts = {}
536
+ for tier in tiers or ():
537
+ counts[tier.get("pricelist")] = counts.get(tier.get("pricelist"), 0) + 1
538
+ declared = {name for _p, _u, name, _n in PRICELIST_TIER_COLUMNS}
539
+ return {name: n - PRICELIST_TIER_DEPTH for name, n in counts.items()
540
+ if name in declared and n > PRICELIST_TIER_DEPTH}
541
+
542
 
543
  def pricelist_by_code():
544
  """`({code: {column_key: price}}, report)` β€” the date-valid FIXED base-tier price for each
web/public/sample_customers.json CHANGED
@@ -1,558 +1,574 @@
1
- {
2
- "fields": [
3
- {
4
- "key": "customer",
5
- "label": "Customer",
6
- "type": "text",
7
- "source": "odoo",
8
- "pinned": true,
9
- "default": true,
10
- "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
11
- },
12
- {
13
- "key": "partner_id",
14
- "label": "Odoo ID",
15
- "type": "int",
16
- "source": "odoo",
17
- "derived": true,
18
- "default": false,
19
- "description": "The Odoo res.partner id β€” the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
20
- },
21
- {
22
- "key": "odoo_status",
23
- "label": "Odoo record",
24
- "type": "status",
25
- "source": "odoo",
26
- "default": false,
27
- "options": [
28
- "Active",
29
- "Archived"
30
- ],
31
- "description": "Whether this customer still exists in Odoo. Archived means deleted there."
32
- },
33
- {
34
- "key": "agent",
35
- "label": "Agent",
36
- "type": "text",
37
- "source": "odoo",
38
- "default": true,
39
- "description": "The sales agent who owns this account."
40
- },
41
- {
42
- "key": "dba",
43
- "label": "DBA",
44
- "type": "select",
45
- "source": "odoo",
46
- "default": false,
47
- "options": [
48
- "Fisch",
49
- "Royal",
50
- "Both"
51
- ],
52
- "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
53
- },
54
- {
55
- "key": "salesperson",
56
- "label": "Salesperson",
57
- "type": "text",
58
- "source": "odoo",
59
- "default": false,
60
- "description": "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
61
- },
62
- {
63
- "key": "street",
64
- "label": "Street",
65
- "type": "text",
66
- "source": "odoo",
67
- "default": false,
68
- "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
69
- },
70
- {
71
- "key": "street2",
72
- "label": "Street 2",
73
- "type": "text",
74
- "source": "odoo",
75
- "default": false,
76
- "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
77
- },
78
- {
79
- "key": "city",
80
- "label": "City",
81
- "type": "text",
82
- "source": "odoo",
83
- "default": true,
84
- "description": "City on the customer's Odoo address."
85
- },
86
- {
87
- "key": "state",
88
- "label": "State",
89
- "type": "text",
90
- "source": "odoo",
91
- "default": true,
92
- "description": "State or province on the customer's Odoo address."
93
- },
94
- {
95
- "key": "country",
96
- "label": "Country",
97
- "type": "text",
98
- "source": "odoo",
99
- "default": false,
100
- "description": "Country on the customer's Odoo address."
101
- },
102
- {
103
- "key": "zip",
104
- "label": "ZIP",
105
- "type": "text",
106
- "source": "odoo",
107
- "default": false,
108
- "description": "Postal code on the customer's Odoo address."
109
- },
110
- {
111
- "key": "customer_since",
112
- "label": "Customer since",
113
- "type": "date",
114
- "source": "odoo",
115
- "default": false,
116
- "description": "When this customer was first set up in Odoo."
117
- },
118
- {
119
- "key": "tags",
120
- "label": "Tags",
121
- "type": "text",
122
- "source": "odoo",
123
- "default": false,
124
- "description": "Odoo labels on this customer, comma-separated."
125
- },
126
- {
127
- "key": "pricelist",
128
- "label": "Price list",
129
- "type": "text",
130
- "source": "odoo",
131
- "default": false,
132
- "description": "The price list this customer buys on."
133
- },
134
- {
135
- "key": "payment_terms",
136
- "label": "Payment terms",
137
- "type": "text",
138
- "source": "odoo",
139
- "default": false,
140
- "description": "Payment terms on this customer's account β€” Net 30, for example."
141
- },
142
- {
143
- "key": "last_order",
144
- "label": "Last order",
145
- "type": "date",
146
- "source": "odoo",
147
- "default": true,
148
- "description": "Date of the most recent confirmed order."
149
- },
150
- {
151
- "key": "overdue_days",
152
- "label": "Overdue days",
153
- "type": "int",
154
- "source": "odoo",
155
- "default": true,
156
- "description": "How many days late this customer is running against their own usual ordering rhythm."
157
- },
158
- {
159
- "_note": "filterable:false β€” DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β€” see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
160
- "key": "est_missed",
161
- "label": "Est. missed $",
162
- "type": "currency",
163
- "source": "odoo",
164
- "default": true,
165
- "agg": "sum",
166
- "filterable": false,
167
- "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
168
- },
169
- {
170
- "_note": "wave 21 R1 β€” KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β€” 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
171
- "key": "ar_open",
172
- "label": "AR current $",
173
- "type": "currency",
174
- "source": "odoo",
175
- "default": false,
176
- "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
177
- },
178
- {
179
- "key": "ar_overdue",
180
- "label": "AR overdue $",
181
- "type": "currency",
182
- "source": "odoo",
183
- "default": false,
184
- "description": "Invoiced money past due β€” same basis as the Collections page."
185
- },
186
- {
187
- "_note": "wave 21 R1 β€” the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β€” no second oracle.",
188
- "key": "ar_outstanding",
189
- "label": "AR outstanding $",
190
- "type": "currency",
191
- "source": "odoo",
192
- "default": false,
193
- "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
194
- },
195
- {
196
- "key": "ar_exposure",
197
- "label": "Credit exposure $",
198
- "type": "currency",
199
- "source": "odoo",
200
- "default": false,
201
- "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
202
- },
203
- {
204
- "key": "ar_aged_1_30",
205
- "label": "1-30 days $",
206
- "type": "currency",
207
- "source": "odoo",
208
- "default": false,
209
- "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
210
- },
211
- {
212
- "key": "ar_aged_31_60",
213
- "label": "31-60 days $",
214
- "type": "currency",
215
- "source": "odoo",
216
- "default": false,
217
- "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
218
- },
219
- {
220
- "key": "ar_aged_61_90",
221
- "label": "61-90 days $",
222
- "type": "currency",
223
- "source": "odoo",
224
- "default": false,
225
- "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
226
- },
227
- {
228
- "key": "ar_aged_90_plus",
229
- "label": "90+ days $",
230
- "type": "currency",
231
- "source": "odoo",
232
- "default": false,
233
- "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
234
- },
235
- {
236
- "key": "days_to_pay",
237
- "label": "Days to pay",
238
- "type": "int",
239
- "source": "odoo",
240
- "default": false,
241
- "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
242
- },
243
- {
244
- "key": "top_category",
245
- "label": "Top category",
246
- "type": "text",
247
- "source": "odoo",
248
- "default": false,
249
- "description": "The category this customer spent the most on in the last 12 months."
250
- },
251
- {
252
- "key": "top_category_pct",
253
- "label": "Top category %",
254
- "type": "pct",
255
- "source": "odoo",
256
- "default": false,
257
- "description": "Share of last-12-months spend that went to the top category."
258
- },
259
- {
260
- "key": "sku_count",
261
- "label": "SKUs bought",
262
- "type": "int",
263
- "source": "odoo",
264
- "default": false,
265
- "description": "Distinct products bought in the last 12 months."
266
- },
267
- {
268
- "key": "top_sku",
269
- "label": "Top SKU",
270
- "type": "text",
271
- "source": "odoo",
272
- "default": false,
273
- "description": "The product this customer spent the most on in the last 12 months."
274
- },
275
- {
276
- "key": "days_since",
277
- "label": "Days since order",
278
- "type": "int",
279
- "source": "odoo",
280
- "default": false,
281
- "description": "Days since the last confirmed order."
282
- },
283
- {
284
- "key": "typical_gap_days",
285
- "label": "Typical gap days",
286
- "type": "int",
287
- "source": "odoo",
288
- "default": false,
289
- "description": "Days this customer usually goes between orders, from their own history."
290
- },
291
- {
292
- "key": "notes",
293
- "label": "Notes",
294
- "type": "text",
295
- "source": "overlay",
296
- "default": false,
297
- "description": "Your notes on this customer. Saved in this app only, visible only to you."
298
- }
299
- ],
300
- "rows": [
301
- {
302
- "pid": 101,
303
- "customer": "Poppy Flowers",
304
- "status": "New",
305
- "agent": "Naomi Linnell Rivera",
306
- "city": "Charlottesville",
307
- "state": "Virginia (US)",
308
- "last_order": "2026-07-21",
309
- "est_missed": 0,
310
- "notes": "",
311
- "country": "United States",
312
- "zip": "02720",
313
- "payment_terms": "30 Days",
314
- "pricelist": "Fisch 1 (USD)",
315
- "tags": "Royal",
316
- "customer_since": "2023-01-10",
317
- "salesperson": "Jessica",
318
- "ar_open": 0,
319
- "ar_overdue": 0,
320
- "ar_exposure": 0,
321
- "top_category": "Styrofoam",
322
- "top_category_pct": 0.47,
323
- "sku_count": 93,
324
- "top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
325
- "days_to_pay": 34,
326
- "_created": "2023-01-15 09:10:00",
327
- "lat": 25.7617,
328
- "lon": -80.1918,
329
- "odoo_status": "Archived",
330
- "dba": "Royal",
331
- "ar_outstanding": 0
332
- },
333
- {
334
- "pid": 102,
335
- "customer": "Meadow & Vine Wholesale",
336
- "status": "Growing",
337
- "agent": "Carla Jimenez",
338
- "city": "Portland",
339
- "state": "Oregon (US)",
340
- "last_order": "2026-07-19",
341
- "est_missed": 0,
342
- "notes": "Expanding to a second storefront.",
343
- "country": "United States",
344
- "zip": "77041",
345
- "payment_terms": "Immediate Payment",
346
- "pricelist": "Royal 1 (USD)",
347
- "tags": "Royal, Key account",
348
- "customer_since": "2024-02-11",
349
- "salesperson": "Naomi",
350
- "ar_open": 1240.5,
351
- "ar_overdue": 0,
352
- "ar_exposure": 1740.5,
353
- "top_category": "Ribbon",
354
- "top_category_pct": 0.95,
355
- "sku_count": 4,
356
- "top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
357
- "days_to_pay": null,
358
- "_created": "2023-02-15 09:11:00",
359
- "lat": 27.9506,
360
- "lon": -82.4572,
361
- "odoo_status": "Active",
362
- "dba": "Fisch",
363
- "ar_outstanding": 1240.5
364
- },
365
- {
366
- "pid": 103,
367
- "customer": "Bluestem Floral Supply",
368
- "status": "Growing",
369
- "agent": "Naomi Linnell Rivera",
370
- "city": "Kansas City",
371
- "state": "Missouri (US)",
372
- "last_order": "2026-07-17",
373
- "est_missed": 0,
374
- "notes": "",
375
- "country": "United States",
376
- "zip": "11219",
377
- "payment_terms": "60 Days",
378
- "pricelist": "Royal 1 (USD)",
379
- "tags": "Fisch",
380
- "customer_since": "2025-03-12",
381
- "salesperson": "Karen",
382
- "ar_open": 0,
383
- "ar_overdue": 5120.25,
384
- "ar_exposure": 5120.25,
385
- "top_category": "Foams & Finishes",
386
- "top_category_pct": 0.31,
387
- "sku_count": 27,
388
- "top_sku": "SATIN RIBBON 2IN",
389
- "days_to_pay": 61,
390
- "_created": "2023-03-15 09:12:00",
391
- "lat": 28.5384,
392
- "lon": -81.3789,
393
- "odoo_status": "Active",
394
- "dba": "Both",
395
- "ar_outstanding": 5120.25
396
- },
397
- {
398
- "pid": 104,
399
- "customer": "Camellia Row Florist",
400
- "status": "Declining",
401
- "agent": "Devon Marsh",
402
- "city": "Savannah",
403
- "state": "Georgia (US)",
404
- "last_order": "2026-05-30",
405
- "est_missed": 41200,
406
- "notes": "Switched some volume to a local grower.",
407
- "country": "United States",
408
- "zip": "07649",
409
- "payment_terms": "30 Days",
410
- "pricelist": "Fisch 1 (USD)",
411
- "tags": "(none)",
412
- "customer_since": "2026-04-13",
413
- "salesperson": "(none)",
414
- "ar_open": 8300,
415
- "ar_overdue": 940,
416
- "ar_exposure": 11440,
417
- "top_category": "All",
418
- "top_category_pct": 1.0,
419
- "sku_count": 1,
420
- "top_sku": "(none)",
421
- "days_to_pay": 12,
422
- "_created": "2023-04-15 09:13:00",
423
- "lat": 30.3322,
424
- "lon": -81.6557,
425
- "odoo_status": "Active",
426
- "dba": "Royal",
427
- "ar_outstanding": 9240
428
- },
429
- {
430
- "pid": 105,
431
- "customer": "Harborlight Wholesale Blooms",
432
- "status": "Growing",
433
- "agent": "Carla Jimenez",
434
- "city": "Seattle",
435
- "state": "Washington (US)",
436
- "last_order": "2026-07-22",
437
- "est_missed": 0,
438
- "notes": "Top-10 account.",
439
- "country": "United States",
440
- "zip": "33125",
441
- "payment_terms": "Immediate Payment",
442
- "pricelist": "Royal 1 (USD)",
443
- "tags": "Royal",
444
- "customer_since": "2023-05-14",
445
- "salesperson": "Jessica",
446
- "ar_open": 0,
447
- "ar_overdue": 0,
448
- "ar_exposure": 0,
449
- "top_category": "Styrofoam",
450
- "top_category_pct": 0.47,
451
- "sku_count": 93,
452
- "top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
453
- "days_to_pay": 34,
454
- "_created": "2023-05-15 09:14:00",
455
- "lat": 26.1224,
456
- "lon": -80.1373,
457
- "odoo_status": "Active",
458
- "dba": "",
459
- "ar_outstanding": 0
460
- },
461
- {
462
- "pid": 106,
463
- "customer": "Dogwood & Fern Co.",
464
- "status": "Dormant",
465
- "agent": "Devon Marsh",
466
- "city": "Asheville",
467
- "state": "North Carolina (US)",
468
- "last_order": "2026-02-11",
469
- "est_missed": 52400,
470
- "notes": "No spring order this year.",
471
- "country": "United States",
472
- "zip": "90210",
473
- "payment_terms": "60 Days",
474
- "pricelist": "Royal 1 (USD)",
475
- "tags": "Royal, Key account",
476
- "customer_since": "2024-06-15",
477
- "salesperson": "Naomi",
478
- "ar_open": 1240.5,
479
- "ar_overdue": 0,
480
- "ar_exposure": 1740.5,
481
- "top_category": "Ribbon",
482
- "top_category_pct": 0.95,
483
- "sku_count": 4,
484
- "top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
485
- "days_to_pay": null,
486
- "_created": "2023-06-15 09:15:00",
487
- "lat": 27.3364,
488
- "lon": -82.5307,
489
- "odoo_status": "Active",
490
- "dba": "Fisch",
491
- "ar_outstanding": 1240.5
492
- },
493
- {
494
- "pid": 107,
495
- "customer": "Verbena Market Florals",
496
- "status": "Lost",
497
- "agent": "Naomi Linnell Rivera",
498
- "city": "Austin",
499
- "state": "Texas (US)",
500
- "last_order": "2025-11-04",
501
- "est_missed": 78300,
502
- "notes": "Went with a competitor on freight terms.",
503
- "country": "United States",
504
- "zip": "08701",
505
- "payment_terms": "30 Days",
506
- "pricelist": "Fisch 1 (USD)",
507
- "tags": "Fisch",
508
- "customer_since": "2025-07-16",
509
- "salesperson": "Karen",
510
- "ar_open": 0,
511
- "ar_overdue": 5120.25,
512
- "ar_exposure": 5120.25,
513
- "top_category": "Foams & Finishes",
514
- "top_category_pct": 0.31,
515
- "sku_count": 27,
516
- "top_sku": "SATIN RIBBON 2IN",
517
- "days_to_pay": 61,
518
- "_created": "2023-07-15 09:16:00",
519
- "lat": null,
520
- "lon": null,
521
- "odoo_status": "Active",
522
- "dba": "Both",
523
- "ar_outstanding": 5120.25
524
- },
525
- {
526
- "pid": 108,
527
- "customer": "Larkspur Lane Supply",
528
- "status": "New",
529
- "agent": "Carla Jimenez",
530
- "city": "Denver",
531
- "state": "Colorado (US)",
532
- "last_order": "2026-07-14",
533
- "est_missed": 0,
534
- "notes": "First order in April.",
535
- "country": "United States",
536
- "zip": "60614",
537
- "payment_terms": "Immediate Payment",
538
- "pricelist": "Royal 1 (USD)",
539
- "tags": "(none)",
540
- "customer_since": "2026-08-17",
541
- "salesperson": "(none)",
542
- "ar_open": 8300,
543
- "ar_overdue": 940,
544
- "ar_exposure": 11440,
545
- "top_category": "All",
546
- "top_category_pct": 1.0,
547
- "sku_count": 1,
548
- "top_sku": "(none)",
549
- "days_to_pay": 12,
550
- "_created": "2023-08-15 09:17:00",
551
- "lat": 33.749,
552
- "lon": -84.388,
553
- "odoo_status": "Active",
554
- "dba": "Royal",
555
- "ar_outstanding": 9240
556
- }
557
- ]
558
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fields": [
3
+ {
4
+ "key": "customer",
5
+ "label": "Customer",
6
+ "type": "text",
7
+ "source": "odoo",
8
+ "pinned": true,
9
+ "default": true,
10
+ "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
11
+ },
12
+ {
13
+ "key": "partner_id",
14
+ "label": "Odoo ID",
15
+ "type": "int",
16
+ "source": "odoo",
17
+ "derived": true,
18
+ "default": false,
19
+ "description": "The Odoo res.partner id β€” the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
20
+ },
21
+ {
22
+ "key": "odoo_status",
23
+ "label": "Odoo record",
24
+ "type": "status",
25
+ "source": "odoo",
26
+ "default": false,
27
+ "options": [
28
+ "Active",
29
+ "Archived"
30
+ ],
31
+ "description": "Whether this customer still exists in Odoo. Archived means deleted there."
32
+ },
33
+ {
34
+ "key": "agent",
35
+ "label": "Agent",
36
+ "type": "select",
37
+ "source": "odoo",
38
+ "default": true,
39
+ "description": "The sales agent who owns this account."
40
+ },
41
+ {
42
+ "key": "dba",
43
+ "label": "DBA",
44
+ "type": "select",
45
+ "source": "odoo",
46
+ "default": false,
47
+ "options": [
48
+ "Fisch",
49
+ "Royal",
50
+ "Both"
51
+ ],
52
+ "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
53
+ },
54
+ {
55
+ "key": "salesperson",
56
+ "label": "Salesperson",
57
+ "type": "select",
58
+ "source": "odoo",
59
+ "default": false,
60
+ "description": "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
61
+ },
62
+ {
63
+ "key": "street",
64
+ "label": "Street",
65
+ "type": "text",
66
+ "source": "odoo",
67
+ "default": false,
68
+ "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
69
+ },
70
+ {
71
+ "key": "street2",
72
+ "label": "Street 2",
73
+ "type": "text",
74
+ "source": "odoo",
75
+ "default": false,
76
+ "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
77
+ },
78
+ {
79
+ "key": "city",
80
+ "label": "City",
81
+ "type": "select",
82
+ "source": "odoo",
83
+ "default": true,
84
+ "description": "City on the customer's Odoo address."
85
+ },
86
+ {
87
+ "key": "state",
88
+ "label": "State",
89
+ "type": "select",
90
+ "source": "odoo",
91
+ "default": true,
92
+ "description": "State or province on the customer's Odoo address."
93
+ },
94
+ {
95
+ "key": "country",
96
+ "label": "Country",
97
+ "type": "select",
98
+ "source": "odoo",
99
+ "default": false,
100
+ "description": "Country on the customer's Odoo address."
101
+ },
102
+ {
103
+ "key": "zip",
104
+ "label": "ZIP",
105
+ "type": "text",
106
+ "source": "odoo",
107
+ "default": false,
108
+ "description": "Postal code on the customer's Odoo address."
109
+ },
110
+ {
111
+ "key": "maps_url",
112
+ "label": "Google Maps",
113
+ "type": "url",
114
+ "source": "odoo",
115
+ "default": true,
116
+ "description": "A Google Maps link to this customer's address."
117
+ },
118
+ {
119
+ "key": "customer_since",
120
+ "label": "Customer since",
121
+ "type": "date",
122
+ "source": "odoo",
123
+ "default": false,
124
+ "description": "When this customer was first set up in Odoo."
125
+ },
126
+ {
127
+ "key": "tags",
128
+ "label": "Tags",
129
+ "type": "multiselect",
130
+ "source": "odoo",
131
+ "default": false,
132
+ "description": "Odoo labels on this customer, comma-separated."
133
+ },
134
+ {
135
+ "key": "pricelist",
136
+ "label": "Customer price list",
137
+ "type": "select",
138
+ "source": "odoo",
139
+ "default": false,
140
+ "description": "The price list this customer buys on."
141
+ },
142
+ {
143
+ "key": "payment_terms",
144
+ "label": "Payment terms",
145
+ "type": "select",
146
+ "source": "odoo",
147
+ "default": false,
148
+ "description": "Payment terms on this customer's account β€” Net 30, for example."
149
+ },
150
+ {
151
+ "key": "last_order",
152
+ "label": "Last order",
153
+ "type": "date",
154
+ "source": "odoo",
155
+ "default": true,
156
+ "description": "Date of the most recent confirmed order."
157
+ },
158
+ {
159
+ "key": "overdue_days",
160
+ "label": "Overdue days",
161
+ "type": "int",
162
+ "source": "odoo",
163
+ "default": true,
164
+ "description": "How many days late this customer is running against their own usual ordering rhythm."
165
+ },
166
+ {
167
+ "_note": "filterable:false β€” DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β€” see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
168
+ "key": "est_missed",
169
+ "label": "Est. missed $",
170
+ "type": "currency",
171
+ "source": "odoo",
172
+ "default": true,
173
+ "agg": "sum",
174
+ "filterable": false,
175
+ "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
176
+ },
177
+ {
178
+ "_note": "wave 21 R1 β€” KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β€” 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
179
+ "key": "ar_open",
180
+ "label": "AR current $",
181
+ "type": "currency",
182
+ "source": "odoo",
183
+ "default": false,
184
+ "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
185
+ },
186
+ {
187
+ "key": "ar_overdue",
188
+ "label": "AR overdue $",
189
+ "type": "currency",
190
+ "source": "odoo",
191
+ "default": false,
192
+ "description": "Invoiced money past due β€” same basis as the Collections page."
193
+ },
194
+ {
195
+ "_note": "wave 21 R1 β€” the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β€” no second oracle.",
196
+ "key": "ar_outstanding",
197
+ "label": "AR outstanding $",
198
+ "type": "currency",
199
+ "source": "odoo",
200
+ "default": false,
201
+ "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
202
+ },
203
+ {
204
+ "key": "ar_exposure",
205
+ "label": "Credit exposure $",
206
+ "type": "currency",
207
+ "source": "odoo",
208
+ "default": false,
209
+ "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
210
+ },
211
+ {
212
+ "key": "ar_aged_1_30",
213
+ "label": "1-30 days $",
214
+ "type": "currency",
215
+ "source": "odoo",
216
+ "default": false,
217
+ "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
218
+ },
219
+ {
220
+ "key": "ar_aged_31_60",
221
+ "label": "31-60 days $",
222
+ "type": "currency",
223
+ "source": "odoo",
224
+ "default": false,
225
+ "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
226
+ },
227
+ {
228
+ "key": "ar_aged_61_90",
229
+ "label": "61-90 days $",
230
+ "type": "currency",
231
+ "source": "odoo",
232
+ "default": false,
233
+ "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
234
+ },
235
+ {
236
+ "key": "ar_aged_90_plus",
237
+ "label": "90+ days $",
238
+ "type": "currency",
239
+ "source": "odoo",
240
+ "default": false,
241
+ "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
242
+ },
243
+ {
244
+ "key": "days_to_pay",
245
+ "label": "Days to pay",
246
+ "type": "int",
247
+ "source": "odoo",
248
+ "default": false,
249
+ "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
250
+ },
251
+ {
252
+ "key": "top_category",
253
+ "label": "Top category",
254
+ "type": "select",
255
+ "source": "odoo",
256
+ "default": false,
257
+ "description": "The category this customer spent the most on in the last 12 months."
258
+ },
259
+ {
260
+ "key": "top_category_pct",
261
+ "label": "Top category %",
262
+ "type": "pct",
263
+ "source": "odoo",
264
+ "default": false,
265
+ "description": "Share of last-12-months spend that went to the top category."
266
+ },
267
+ {
268
+ "key": "sku_count",
269
+ "label": "SKUs bought",
270
+ "type": "int",
271
+ "source": "odoo",
272
+ "default": false,
273
+ "description": "Distinct products bought in the last 12 months."
274
+ },
275
+ {
276
+ "key": "top_sku",
277
+ "label": "Top SKU",
278
+ "type": "text",
279
+ "source": "odoo",
280
+ "default": false,
281
+ "description": "The product this customer spent the most on in the last 12 months."
282
+ },
283
+ {
284
+ "key": "days_since",
285
+ "label": "Days since order",
286
+ "type": "int",
287
+ "source": "odoo",
288
+ "default": false,
289
+ "description": "Days since the last confirmed order."
290
+ },
291
+ {
292
+ "key": "typical_gap_days",
293
+ "label": "Typical gap days",
294
+ "type": "int",
295
+ "source": "odoo",
296
+ "default": false,
297
+ "description": "Days this customer usually goes between orders, from their own history."
298
+ },
299
+ {
300
+ "key": "created_on",
301
+ "label": "Record created",
302
+ "type": "date",
303
+ "source": "odoo",
304
+ "default": false,
305
+ "description": "The date this customer record was created in Odoo."
306
+ },
307
+ {
308
+ "key": "notes",
309
+ "label": "Notes",
310
+ "type": "text",
311
+ "source": "overlay",
312
+ "default": false,
313
+ "description": "Your notes on this customer. Saved in this app only, visible only to you."
314
+ }
315
+ ],
316
+ "rows": [
317
+ {
318
+ "pid": 101,
319
+ "customer": "Poppy Flowers",
320
+ "status": "New",
321
+ "agent": "Naomi Linnell Rivera",
322
+ "city": "Charlottesville",
323
+ "state": "Virginia (US)",
324
+ "last_order": "2026-07-21",
325
+ "est_missed": 0,
326
+ "notes": "",
327
+ "country": "United States",
328
+ "zip": "02720",
329
+ "payment_terms": "30 Days",
330
+ "pricelist": "Fisch 1 (USD)",
331
+ "tags": "Royal",
332
+ "customer_since": "2023-01-10",
333
+ "salesperson": "Jessica",
334
+ "ar_open": 0,
335
+ "ar_overdue": 0,
336
+ "ar_exposure": 0,
337
+ "top_category": "Styrofoam",
338
+ "top_category_pct": 0.47,
339
+ "sku_count": 93,
340
+ "top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
341
+ "days_to_pay": 34,
342
+ "_created": "2023-01-15 09:10:00",
343
+ "lat": 25.7617,
344
+ "lon": -80.1918,
345
+ "odoo_status": "Archived",
346
+ "dba": "Royal",
347
+ "ar_outstanding": 0
348
+ },
349
+ {
350
+ "pid": 102,
351
+ "customer": "Meadow & Vine Wholesale",
352
+ "status": "Growing",
353
+ "agent": "Carla Jimenez",
354
+ "city": "Portland",
355
+ "state": "Oregon (US)",
356
+ "last_order": "2026-07-19",
357
+ "est_missed": 0,
358
+ "notes": "Expanding to a second storefront.",
359
+ "country": "United States",
360
+ "zip": "77041",
361
+ "payment_terms": "Immediate Payment",
362
+ "pricelist": "Royal 1 (USD)",
363
+ "tags": "Royal, Key account",
364
+ "customer_since": "2024-02-11",
365
+ "salesperson": "Naomi",
366
+ "ar_open": 1240.5,
367
+ "ar_overdue": 0,
368
+ "ar_exposure": 1740.5,
369
+ "top_category": "Ribbon",
370
+ "top_category_pct": 0.95,
371
+ "sku_count": 4,
372
+ "top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
373
+ "days_to_pay": null,
374
+ "_created": "2023-02-15 09:11:00",
375
+ "lat": 27.9506,
376
+ "lon": -82.4572,
377
+ "odoo_status": "Active",
378
+ "dba": "Fisch",
379
+ "ar_outstanding": 1240.5
380
+ },
381
+ {
382
+ "pid": 103,
383
+ "customer": "Bluestem Floral Supply",
384
+ "status": "Growing",
385
+ "agent": "Naomi Linnell Rivera",
386
+ "city": "Kansas City",
387
+ "state": "Missouri (US)",
388
+ "last_order": "2026-07-17",
389
+ "est_missed": 0,
390
+ "notes": "",
391
+ "country": "United States",
392
+ "zip": "11219",
393
+ "payment_terms": "60 Days",
394
+ "pricelist": "Royal 1 (USD)",
395
+ "tags": "Fisch",
396
+ "customer_since": "2025-03-12",
397
+ "salesperson": "Karen",
398
+ "ar_open": 0,
399
+ "ar_overdue": 5120.25,
400
+ "ar_exposure": 5120.25,
401
+ "top_category": "Foams & Finishes",
402
+ "top_category_pct": 0.31,
403
+ "sku_count": 27,
404
+ "top_sku": "SATIN RIBBON 2IN",
405
+ "days_to_pay": 61,
406
+ "_created": "2023-03-15 09:12:00",
407
+ "lat": 28.5384,
408
+ "lon": -81.3789,
409
+ "odoo_status": "Active",
410
+ "dba": "Both",
411
+ "ar_outstanding": 5120.25
412
+ },
413
+ {
414
+ "pid": 104,
415
+ "customer": "Camellia Row Florist",
416
+ "status": "Declining",
417
+ "agent": "Devon Marsh",
418
+ "city": "Savannah",
419
+ "state": "Georgia (US)",
420
+ "last_order": "2026-05-30",
421
+ "est_missed": 41200,
422
+ "notes": "Switched some volume to a local grower.",
423
+ "country": "United States",
424
+ "zip": "07649",
425
+ "payment_terms": "30 Days",
426
+ "pricelist": "Fisch 1 (USD)",
427
+ "tags": "(none)",
428
+ "customer_since": "2026-04-13",
429
+ "salesperson": "(none)",
430
+ "ar_open": 8300,
431
+ "ar_overdue": 940,
432
+ "ar_exposure": 11440,
433
+ "top_category": "All",
434
+ "top_category_pct": 1.0,
435
+ "sku_count": 1,
436
+ "top_sku": "(none)",
437
+ "days_to_pay": 12,
438
+ "_created": "2023-04-15 09:13:00",
439
+ "lat": 30.3322,
440
+ "lon": -81.6557,
441
+ "odoo_status": "Active",
442
+ "dba": "Royal",
443
+ "ar_outstanding": 9240
444
+ },
445
+ {
446
+ "pid": 105,
447
+ "customer": "Harborlight Wholesale Blooms",
448
+ "status": "Growing",
449
+ "agent": "Carla Jimenez",
450
+ "city": "Seattle",
451
+ "state": "Washington (US)",
452
+ "last_order": "2026-07-22",
453
+ "est_missed": 0,
454
+ "notes": "Top-10 account.",
455
+ "country": "United States",
456
+ "zip": "33125",
457
+ "payment_terms": "Immediate Payment",
458
+ "pricelist": "Royal 1 (USD)",
459
+ "tags": "Royal",
460
+ "customer_since": "2023-05-14",
461
+ "salesperson": "Jessica",
462
+ "ar_open": 0,
463
+ "ar_overdue": 0,
464
+ "ar_exposure": 0,
465
+ "top_category": "Styrofoam",
466
+ "top_category_pct": 0.47,
467
+ "sku_count": 93,
468
+ "top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
469
+ "days_to_pay": 34,
470
+ "_created": "2023-05-15 09:14:00",
471
+ "lat": 26.1224,
472
+ "lon": -80.1373,
473
+ "odoo_status": "Active",
474
+ "dba": "",
475
+ "ar_outstanding": 0
476
+ },
477
+ {
478
+ "pid": 106,
479
+ "customer": "Dogwood & Fern Co.",
480
+ "status": "Dormant",
481
+ "agent": "Devon Marsh",
482
+ "city": "Asheville",
483
+ "state": "North Carolina (US)",
484
+ "last_order": "2026-02-11",
485
+ "est_missed": 52400,
486
+ "notes": "No spring order this year.",
487
+ "country": "United States",
488
+ "zip": "90210",
489
+ "payment_terms": "60 Days",
490
+ "pricelist": "Royal 1 (USD)",
491
+ "tags": "Royal, Key account",
492
+ "customer_since": "2024-06-15",
493
+ "salesperson": "Naomi",
494
+ "ar_open": 1240.5,
495
+ "ar_overdue": 0,
496
+ "ar_exposure": 1740.5,
497
+ "top_category": "Ribbon",
498
+ "top_category_pct": 0.95,
499
+ "sku_count": 4,
500
+ "top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
501
+ "days_to_pay": null,
502
+ "_created": "2023-06-15 09:15:00",
503
+ "lat": 27.3364,
504
+ "lon": -82.5307,
505
+ "odoo_status": "Active",
506
+ "dba": "Fisch",
507
+ "ar_outstanding": 1240.5
508
+ },
509
+ {
510
+ "pid": 107,
511
+ "customer": "Verbena Market Florals",
512
+ "status": "Lost",
513
+ "agent": "Naomi Linnell Rivera",
514
+ "city": "Austin",
515
+ "state": "Texas (US)",
516
+ "last_order": "2025-11-04",
517
+ "est_missed": 78300,
518
+ "notes": "Went with a competitor on freight terms.",
519
+ "country": "United States",
520
+ "zip": "08701",
521
+ "payment_terms": "30 Days",
522
+ "pricelist": "Fisch 1 (USD)",
523
+ "tags": "Fisch",
524
+ "customer_since": "2025-07-16",
525
+ "salesperson": "Karen",
526
+ "ar_open": 0,
527
+ "ar_overdue": 5120.25,
528
+ "ar_exposure": 5120.25,
529
+ "top_category": "Foams & Finishes",
530
+ "top_category_pct": 0.31,
531
+ "sku_count": 27,
532
+ "top_sku": "SATIN RIBBON 2IN",
533
+ "days_to_pay": 61,
534
+ "_created": "2023-07-15 09:16:00",
535
+ "lat": null,
536
+ "lon": null,
537
+ "odoo_status": "Active",
538
+ "dba": "Both",
539
+ "ar_outstanding": 5120.25
540
+ },
541
+ {
542
+ "pid": 108,
543
+ "customer": "Larkspur Lane Supply",
544
+ "status": "New",
545
+ "agent": "Carla Jimenez",
546
+ "city": "Denver",
547
+ "state": "Colorado (US)",
548
+ "last_order": "2026-07-14",
549
+ "est_missed": 0,
550
+ "notes": "First order in April.",
551
+ "country": "United States",
552
+ "zip": "60614",
553
+ "payment_terms": "Immediate Payment",
554
+ "pricelist": "Royal 1 (USD)",
555
+ "tags": "(none)",
556
+ "customer_since": "2026-08-17",
557
+ "salesperson": "(none)",
558
+ "ar_open": 8300,
559
+ "ar_overdue": 940,
560
+ "ar_exposure": 11440,
561
+ "top_category": "All",
562
+ "top_category_pct": 1.0,
563
+ "sku_count": 1,
564
+ "top_sku": "(none)",
565
+ "days_to_pay": 12,
566
+ "_created": "2023-08-15 09:17:00",
567
+ "lat": 33.749,
568
+ "lon": -84.388,
569
+ "odoo_status": "Active",
570
+ "dba": "Royal",
571
+ "ar_outstanding": 9240
572
+ }
573
+ ]
574
+ }
web/src/customer-grid/ColumnMenu.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useMemo, useRef, useState } from "react";
2
  // ⭐ W36-T07 (owner item 10) β€” the field agent. Its own module for `ColumnMenu`'s own reason:
3
  // this file pulls the whole grid and cannot load under a node gate, so the chat had to sit
4
  // where a test can render it.
@@ -8,8 +8,16 @@ import { AnchoredOverlay } from "./OverlaySurface";
8
  import type { AnchorRect } from "./OverlaySurface";
9
  import { FieldTypeIcon, MenuLabel } from "./icons";
10
  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, mayEditFieldDefinition } from "./types";
15
  import type { AggName, Field, FieldFormat, FieldScope, FieldType, Measure, RollupCondition, RollupFn,
@@ -18,7 +26,7 @@ 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,
@@ -135,6 +143,16 @@ interface ColumnMenuProps {
135
  locked: boolean;
136
  /** Product-owned preset fields may still drive view actions, but their schema is immutable. */
137
  schemaLocked?: boolean;
 
 
 
 
 
 
 
 
 
 
138
  /** Wave-5 item 1 β€” who is looking. Gates the permissions entry (creator-or-admin). */
139
  viewer?: Viewer;
140
  /** Wave-5 item 3 β€” what the CURRENT VIEW does with this field, so the conditional
@@ -879,8 +897,18 @@ type CreateKind = FieldType | "measure" | "geocode";
879
  * of them end up disagreeing about what is offered.
880
  * ⚠ NO DASH of any kind in the label β€” it reaches the screen, and `web_prose` cannot see a
881
  * wrapped JSX sentence, so this one is on the author.
 
 
 
 
 
 
 
 
 
 
882
  */
883
- const GEOCODE_LABEL = "Coordinates from an address";
884
  const GEOCODE_ADDRESS_TYPES: readonly FieldType[] = ["text", "url", "email", "phone"];
885
 
886
  /**
@@ -941,6 +969,11 @@ function TypePicker({
941
  ? [{ value: "geocode" as CreateKind, label: GEOCODE_LABEL }]
942
  : []),
943
  ].filter((r) => !needle || r.label.toLowerCase().includes(needle));
 
 
 
 
 
944
  return (
945
  <div className="cg-type-picker">
946
  <div className="cg-type-search">
@@ -962,44 +995,64 @@ function TypePicker({
962
  />
963
  </div>
964
  <div className="cg-type-list" role="listbox" aria-label="Field type">
965
- {rows.map((r) => (
966
- <button
967
- key={r.value}
968
- type="button"
969
- role="option"
970
- aria-selected={value === r.value}
971
- className={"cg-type-row" + (value === r.value ? " is-on" : "")}
972
- onClick={() => onPick(r.value)}
973
- >
974
- {/* Each row wears the mark its COLUMN will wear. A measure column's real type is
975
- currency/int/pct and a geocode column's is `text`, so both pseudo-kinds borrow
976
- rather than inventing a glyph no column ever shows (the iconShapes ruling). */}
977
- <FieldTypeIcon
978
- type={
979
- r.value === "measure" ? "currency" : r.value === "geocode" ? "text" : r.value
980
- }
981
- size={16}
982
- />
983
- <span className="cg-type-label">{r.label}</span>
984
- {value === r.value && (
985
- <svg
986
- className="cg-type-check"
987
- width="14"
988
- height="14"
989
- viewBox="0 0 16 16"
990
- fill="none"
991
- aria-hidden="true"
992
  >
993
- <path
994
- d="m3.5 8.5 3 3 6-6.5"
995
- stroke="currentColor"
996
- strokeWidth="1.6"
997
- strokeLinecap="round"
998
- strokeLinejoin="round"
 
 
999
  />
1000
- </svg>
1001
- )}
1002
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
  ))}
1004
  {rows.length === 0 && <div className="cg-pop-note">No matching type.</div>}
1005
  </div>
@@ -2000,6 +2053,7 @@ export default function ColumnMenu({
2000
  geocodable,
2001
  locked,
2002
  schemaLocked = false,
 
2003
  viewer,
2004
  sortedDir,
2005
  isFiltered,
@@ -2038,6 +2092,18 @@ export default function ColumnMenu({
2038
  measures = [],
2039
  tableKey,
2040
  }: ColumnMenuProps) {
 
 
 
 
 
 
 
 
 
 
 
 
2041
  const canEditDefinition = !schemaLocked && mayEditFieldDefinition(field, viewer);
2042
  const [pane, setPane] = useState<MenuPane>(
2043
  schemaLocked || (initialPane === "edit" && !canEditDefinition)
@@ -2600,6 +2666,23 @@ export default function ColumnMenu({
2600
  */
2601
  const editingRelational =
2602
  !!onFieldConfig && (field.type === "rollup" || field.type === "link");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2603
  const editRollupValid =
2604
  field.type !== "rollup" ||
2605
  (editRollup.source
@@ -2729,10 +2812,15 @@ export default function ColumnMenu({
2729
  * the overlay stratum and user-created columns. Hidden fields included in both: "change this
2730
  * to Notes" is the whole point, and swapping BACK to a hidden custom field is the documented
2731
  * restore path. The locked primary column cannot be swapped.
 
 
 
 
2732
  */
2733
  const swapOptions = fields.filter((f) => f.key !== field.key);
2734
  const presetSwap = swapOptions.filter((f) => f.source === "odoo" && !f.custom);
2735
  const yourSwap = swapOptions.filter((f) => !(f.source === "odoo" && !f.custom));
 
2736
 
2737
  // Item 8c β€” the period draft is valid when it normalizes and differs from what the field
2738
  // already has. Compared through normalizeWindow so `{kind:'ltm'}` and `{kind:'ltm', n:undefined}`
@@ -3099,7 +3187,7 @@ export default function ColumnMenu({
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>
3105
  <input
@@ -3132,9 +3220,19 @@ export default function ColumnMenu({
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
@@ -3441,8 +3539,18 @@ export default function ColumnMenu({
3441
  Back
3442
  </button>
3443
  </div>
3444
- {/* -------- Change field (owner item 9, folded in here by owner item 8) -------- */}
3445
- {!locked && swapOptions.length > 0 && (
 
 
 
 
 
 
 
 
 
 
3446
  <div className="cg-edit-swap">
3447
  <span className="cg-type-title">Change field</span>
3448
  <div className="cg-field-hint">
@@ -3459,8 +3567,10 @@ export default function ColumnMenu({
3459
  ariaLabel="Change this column to another field"
3460
  placeholder="Choose a field…"
3461
  // Focus falls here when the pane has no editable Name (a read-only source
3462
- // field): the swap picker is then the pane's first live control.
3463
- overlayAutofocus={!onRename}
 
 
3464
  value={swapTo || undefined}
3465
  onChange={setSwapTo}
3466
  fields={[
@@ -3880,21 +3990,37 @@ export default function ColumnMenu({
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>
3889
- <circle cx="12" cy="3.6" r="1.9" stroke="currentColor" strokeWidth={1.3} />
3890
- <circle cx="4" cy="8" r="1.9" stroke="currentColor" strokeWidth={1.3} />
3891
- <circle cx="12" cy="12.4" r="1.9" stroke="currentColor" strokeWidth={1.3} />
3892
- <path
3893
- d="m5.75 7.1 4.5-2.6M5.75 8.9l4.5 2.6"
3894
- stroke="currentColor"
3895
- strokeWidth={1.3}
3896
- strokeLinecap="round"
3897
- />
 
 
 
 
 
 
3898
  </svg>
3899
  }
3900
  text="Share field"
@@ -4051,7 +4177,18 @@ export default function ColumnMenu({
4051
  </button>
4052
  </>
4053
  )}
4054
- {!schemaLocked && onDelete && (
 
 
 
 
 
 
 
 
 
 
 
4055
  <button
4056
  type="button"
4057
  className="is-danger"
 
1
+ import { Fragment, useMemo, useRef, useState } from "react";
2
  // ⭐ W36-T07 (owner item 10) β€” the field agent. Its own module for `ColumnMenu`'s own reason:
3
  // this file pulls the whole grid and cannot load under a node gate, so the chat had to sit
4
  // where a test can render it.
 
8
  import type { AnchorRect } from "./OverlaySurface";
9
  import { FieldTypeIcon, MenuLabel } from "./icons";
10
  import { FieldSelectButton } from "./FieldSelect";
11
+ // ⭐⭐ W40-T24 β€” the pre-set definition lock. It lives in `display.ts` rather than here because a
12
+ // rule stated inside JSX cannot be LOADED by a node gate; `display.ts`'s header carries the full
13
+ // argument, and `_test/gridUx.test.ts` is what runs it.
14
+ import { columnDefinitionOffer } from "./display";
15
+ // ⭐⭐ W40-T29 β€” the ADVANCED grouping is a pure function in `types.ts` for the reason
16
+ // `columnDefinitionOffer` sits in `display.ts`: this file imports React, so nothing stated inside
17
+ // it can be LOADED by a node gate, and "which types are advanced" is exactly the kind of decision
18
+ // that must be runnable rather than read back to itself as source text.
19
  import { CODE_LANGUAGE_LABELS, CODE_LANGUAGES, CREATABLE_TYPES, choiceOptions, choiceRenames,
20
+ codeLanguageOf, directionLabel, groupTypePickerRows, isDerivedLink, isMachineOwned,
21
  isProfileField, parseOptions, ratingMax, ROLLUP_FN_LABELS, ROLLUP_FNS,
22
  ROLLUP_REF_OPS, mayEditFieldDefinition } from "./types";
23
  import type { AggName, Field, FieldFormat, FieldScope, FieldType, Measure, RollupCondition, RollupFn,
 
26
  import type { WindowSpec } from "./windows";
27
  import { normalizeWindow, windowLabel } from "./windows";
28
  import { WindowPicker } from "../filter-kit";
29
+ import { SHARE_SHAPE, TYPE_LABELS } from "./iconShapes";
30
  import { AGG_LABELS, aggOptions } from "./aggregations";
31
  import { validateFormula } from "./formulaEngine";
32
  import { BRAND_SWATCHES, defaultOptionColor, nearestSwatch, normalizeOptionColor,
 
143
  locked: boolean;
144
  /** Product-owned preset fields may still drive view actions, but their schema is immutable. */
145
  schemaLocked?: boolean;
146
+ /**
147
+ * ⭐⭐ W40-T24 β€” the grid is a `ut_*` runtime database rather than an Odoo-backed topic. It
148
+ * feeds `columnDefinitionOffer` (display.ts), which is this menu's ONE statement of the pre-set
149
+ * definition lock, and EVERY verdict reads it: a shared-definition column on a user database is
150
+ * the user's to reshape, and the identical shape on an Odoo-backed topic is not.
151
+ * ⚠ OPTIONAL and false by default only for the "+" create instance, which has no real subject
152
+ * column β€” it passes `locked`, so its verdicts are the closed ones either way. A real column
153
+ * menu must pass it; defaulting is a convenience for the create door, not a shrug.
154
+ */
155
+ isUserTable?: boolean;
156
  /** Wave-5 item 1 β€” who is looking. Gates the permissions entry (creator-or-admin). */
157
  viewer?: Viewer;
158
  /** Wave-5 item 3 β€” what the CURRENT VIEW does with this field, so the conditional
 
897
  * of them end up disagreeing about what is offered.
898
  * ⚠ NO DASH of any kind in the label β€” it reaches the screen, and `web_prose` cannot see a
899
  * wrapped JSX sentence, so this one is on the author.
900
+ *
901
+ * ⭐⭐ W40-T29 (owner instruction 31) β€” SHORTENED TO ONE NOUN. The label used to carry a trailing
902
+ * clause naming where the numbers come from, which is the IMPLEMENTATION read out at someone
903
+ * scanning a list of nouns; every other row in that list is the thing you get, not how it is got,
904
+ * and the source column is picked one control later anyway. The old wording is not repeated here:
905
+ * a rename that leaves its own before-picture in a doc block is how the next reader reinstates it,
906
+ * and `_test/gridUx.test.ts` asserts the whole file is free of it.
907
+ * ⚠ THE IDENTIFIER STAYS. One producer, one consumer, and `verify_grid_ux.py`'s geocode-door scan
908
+ * matches `label: GEOCODE_LABEL` BY NAME, so inlining the string here would blind the gate rather
909
+ * than satisfy it.
910
  */
911
+ const GEOCODE_LABEL = "Coordinates";
912
  const GEOCODE_ADDRESS_TYPES: readonly FieldType[] = ["text", "url", "email", "phone"];
913
 
914
  /**
 
969
  ? [{ value: "geocode" as CreateKind, label: GEOCODE_LABEL }]
970
  : []),
971
  ].filter((r) => !needle || r.label.toLowerCase().includes(needle));
972
+ // ⭐⭐ W40-T29 (owner instruction 31) β€” the list stopped being a wall of 22 nouns here, and the
973
+ // grouping is applied to the ALREADY-FILTERED rows on purpose: one find box still reaches both
974
+ // groups, and a group the needle empties is dropped by `groupTypePickerRows` heading and all
975
+ // rather than leaving a label with nothing behind it (R8).
976
+ const groups = groupTypePickerRows(rows);
977
  return (
978
  <div className="cg-type-picker">
979
  <div className="cg-type-search">
 
995
  />
996
  </div>
997
  <div className="cg-type-list" role="listbox" aria-label="Field type">
998
+ {groups.map((group) => (
999
+ // β›” A FRAGMENT, NOT A WRAPPER, and the reason is an accessibility one rather than a
1000
+ // layout one. `.cg-type-list` is the flex column that lays the rows out, so a wrapping
1001
+ // div would need `display: contents` to stay out of the way β€” which DROPS the element
1002
+ // from the accessibility tree, inside the one container that is claiming to be a
1003
+ // listbox. `FieldSelect.tsx` reached this conclusion first for its own grouped list
1004
+ // ("Pre-set fields" / "Your fields" / "New field"); this follows it rather than
1005
+ // inventing a second answer to a question the codebase already settled.
1006
+ // ⚠ The heading is `role="presentation"` for the same reason: a listbox's children are
1007
+ // options, and a bare div claiming nothing is what keeps the heading out of the option
1008
+ // set while leaving it visible. Arrow-key navigation elsewhere collects
1009
+ // `button.cg-type-row`, so a non-button child is invisible to it by construction.
1010
+ <Fragment key={group.heading ?? "ordinary"}>
1011
+ {group.heading && (
1012
+ <div className="cg-type-group" role="presentation">
1013
+ {group.heading}
1014
+ </div>
1015
+ )}
1016
+ {group.rows.map((r) => (
1017
+ <button
1018
+ key={r.value}
1019
+ type="button"
1020
+ role="option"
1021
+ aria-selected={value === r.value}
1022
+ className={"cg-type-row" + (value === r.value ? " is-on" : "")}
1023
+ onClick={() => onPick(r.value)}
 
1024
  >
1025
+ {/* Each row wears the mark its COLUMN will wear. A measure column's real type is
1026
+ currency/int/pct and a geocode column's is `text`, so both pseudo-kinds borrow
1027
+ rather than inventing a glyph no column ever shows (the iconShapes ruling). */}
1028
+ <FieldTypeIcon
1029
+ type={
1030
+ r.value === "measure" ? "currency" : r.value === "geocode" ? "text" : r.value
1031
+ }
1032
+ size={16}
1033
  />
1034
+ <span className="cg-type-label">{r.label}</span>
1035
+ {value === r.value && (
1036
+ <svg
1037
+ className="cg-type-check"
1038
+ width="14"
1039
+ height="14"
1040
+ viewBox="0 0 16 16"
1041
+ fill="none"
1042
+ aria-hidden="true"
1043
+ >
1044
+ <path
1045
+ d="m3.5 8.5 3 3 6-6.5"
1046
+ stroke="currentColor"
1047
+ strokeWidth="1.6"
1048
+ strokeLinecap="round"
1049
+ strokeLinejoin="round"
1050
+ />
1051
+ </svg>
1052
+ )}
1053
+ </button>
1054
+ ))}
1055
+ </Fragment>
1056
  ))}
1057
  {rows.length === 0 && <div className="cg-pop-note">No matching type.</div>}
1058
  </div>
 
2053
  geocodable,
2054
  locked,
2055
  schemaLocked = false,
2056
+ isUserTable = false,
2057
  viewer,
2058
  sortedDir,
2059
  isFiltered,
 
2092
  measures = [],
2093
  tableKey,
2094
  }: ColumnMenuProps) {
2095
+ /**
2096
+ * ⭐⭐ W40-T24 (owner instruction 18, contract C2) β€” the pre-set DEFINITION lock, read from the
2097
+ * one place that states it. `display.ts` carries the whole argument, including the measured
2098
+ * reason the lock is spelt `isUserSchemaField` and NOT `isPresetField` (nine product_data
2099
+ * contract columns, Supplier among them, are declared `source: "overlay"` so that their cells
2100
+ * stay typeable, and `isPresetField` answers false for every one).
2101
+ *
2102
+ * ⚠ ONLY `swap` AND `remove` ARE READ HERE, and that is the design, not an omission: `rename`
2103
+ * and `retype` are already enforced at the host by withholding their handlers, and a second
2104
+ * refusal in this file could only subtract from a door the host deliberately opened.
2105
+ */
2106
+ const offer = columnDefinitionOffer(field, { isUserTable, isPrimary: locked });
2107
  const canEditDefinition = !schemaLocked && mayEditFieldDefinition(field, viewer);
2108
  const [pane, setPane] = useState<MenuPane>(
2109
  schemaLocked || (initialPane === "edit" && !canEditDefinition)
 
2666
  */
2667
  const editingRelational =
2668
  !!onFieldConfig && (field.type === "rollup" || field.type === "link");
2669
+ /**
2670
+ * ⭐⭐ W40-T24 β€” WHERE THE EDIT PANE'S OPENING FOCUS LANDS, decided ONCE.
2671
+ *
2672
+ * `AnchoredOverlay`'s `initialFocus` is the selector `[data-overlay-autofocus]` and it does
2673
+ * `querySelector(...)?.focus()` β€” a MISS is silent, and focus then stays on the grid header
2674
+ * button behind the overlay. That is what removing the swap picker from a pre-set column would
2675
+ * have caused: the picker carried `overlayAutofocus={!onRename}`, so on Supplier it WAS the
2676
+ * pane's focus target, and taking it away leaves a dialog nobody's keyboard is inside. The
2677
+ * defect would not show in a screenshot of the fix.
2678
+ *
2679
+ * Precedence, first match wins, exactly as `querySelector` reads the DOM: the editable Name box
2680
+ * Β· the swap picker Β· the Description box, which is the one control a pre-set column still owns.
2681
+ * ⚠ It also settles a latent double-marking: an `editingRelational` column with no `onRename`
2682
+ * put the attribute on the Name box AND the picker, and which one won was document order rather
2683
+ * than a decision.
2684
+ */
2685
+ const nameIsFocusTarget = !!onRename || editingRelational;
2686
  const editRollupValid =
2687
  field.type !== "rollup" ||
2688
  (editRollup.source
 
2812
  * the overlay stratum and user-created columns. Hidden fields included in both: "change this
2813
  * to Notes" is the whole point, and swapping BACK to a hidden custom field is the documented
2814
  * restore path. The locked primary column cannot be swapped.
2815
+ *
2816
+ * ⭐⭐ W40-T24 β€” NOR CAN A PRE-SET ONE, which is `offer.swap`. This list is what the picker
2817
+ * would contain; `swapOffered` is whether the picker exists at all, and it is read twice (the
2818
+ * control itself, and the focus fallback that has to know whether the control is there).
2819
  */
2820
  const swapOptions = fields.filter((f) => f.key !== field.key);
2821
  const presetSwap = swapOptions.filter((f) => f.source === "odoo" && !f.custom);
2822
  const yourSwap = swapOptions.filter((f) => !(f.source === "odoo" && !f.custom));
2823
+ const swapOffered = offer.swap && swapOptions.length > 0;
2824
 
2825
  // Item 8c β€” the period draft is valid when it normalizes and differs from what the field
2826
  // already has. Compared through normalizeWindow so `{kind:'ltm'}` and `{kind:'ltm', n:undefined}`
 
3187
  >
3188
  <PaneHead title="Edit field" sub={typeLine} onClose={onClose} />
3189
  <div className="cg-column-create cg-field-edit">
3190
+ {nameIsFocusTarget ? (
3191
  <label>
3192
  <span>Name</span>
3193
  <input
 
3220
  Default description: {field.description}
3221
  </span>
3222
  )}
3223
+ {/* ⭐⭐ W40-T24 β€” LAST IN THE FOCUS PRECEDENCE, and the only reason it is marked at
3224
+ all: on a pre-set column the Name box is disabled and the swap picker is now
3225
+ gone, so without this the pane would open with `[data-overlay-autofocus]` matching
3226
+ nothing and the keyboard left outside the dialog. The Description is the one
3227
+ control a pre-set column still owns, which makes it the honest landing spot as
3228
+ well as the available one. `undefined` REMOVES the attribute (React drops it), so
3229
+ exactly one element in the pane ever carries it. */}
3230
  <textarea
3231
  className="cg-input"
3232
  rows={3}
3233
+ data-overlay-autofocus={
3234
+ !nameIsFocusTarget && !swapOffered ? true : undefined
3235
+ }
3236
  value={note}
3237
  placeholder={
3238
  field.description
 
3539
  Back
3540
  </button>
3541
  </div>
3542
+ {/* -------- Change field (owner item 9, folded in here by owner item 8) --------
3543
+ ⭐⭐ W40-T24 (owner instruction 18, contract C2) β€” GATED ON THE PRE-SET LOCK, which
3544
+ it never was. The condition here used to be `!locked` alone, and `locked` means
3545
+ "is the pinned primary column" β€” so every contract column on every grid offered to
3546
+ be redefined into something else, which is the "choose a field dropdown that makes
3547
+ no sense" the owner reported about Supplier and was never only about Supplier.
3548
+ It is the SAME predicate that already withholds the Field-type picker and the
3549
+ editable Name box, which is what makes this pane say one thing about the column
3550
+ instead of three. `offer.swap` carries the `!locked` half (the primary column is the
3551
+ row identity and can never be swapped away); `swapOptions.length > 0` stays because
3552
+ an empty picker is its own fake affordance. */}
3553
+ {swapOffered && (
3554
  <div className="cg-edit-swap">
3555
  <span className="cg-type-title">Change field</span>
3556
  <div className="cg-field-hint">
 
3567
  ariaLabel="Change this column to another field"
3568
  placeholder="Choose a field…"
3569
  // Focus falls here when the pane has no editable Name (a read-only source
3570
+ // field): the swap picker is then the pane's first live control. Second in the
3571
+ // precedence `nameIsFocusTarget` documents, and reading THAT rather than
3572
+ // `!onRename` is what keeps the attribute on exactly one element.
3573
+ overlayAutofocus={!nameIsFocusTarget}
3574
  value={swapTo || undefined}
3575
  onChange={setSwapTo}
3576
  fields={[
 
3990
  {/* β›” AN INLINE NODE, NOT A `MenuIcon` NAME, and that is forced rather than chosen.
3991
  `MenuLabel`'s prop is `MenuIconName | ReactNode` and `ReactNode` admits any
3992
  string, so `icon="share"` would COMPILE and paint an empty 16px box forever
3993
+ (`MENU_ICONS` has no such key, and `icons.tsx` is not this ticket's file).
3994
+ ⭐⭐ W40-T24 β€” THE GEOMETRY IS `SHARE_SHAPE`, NOT A THREE-NODE GRAPH ANY MORE.
3995
+ The argument that stood here β€” that the graph is "the mark every product uses for
3996
+ this verb" β€” was true of a product where it was the only share drawing. It is not
3997
+ any more: W40-T20 put `SHARE_SHAPE` (two people) on the grid header's shared-field
3998
+ sprite and W40-T21 put it on Share view and Share folder, so this row was the last
3999
+ place one verb spoke in a second drawing β€” a field whose HEADER wears two people,
4000
+ shared from a row wearing a graph. DESIGN.md Β§4's "one mark, one meaning" is the
4001
+ rule and `iconShapes.ts` is the vocabulary; paths as DATA is what stops the two
4002
+ painters drifting, which is exactly what a hand-written copy here would restart.
4003
+ ⚠ Rendered the way `ViewSidebar.ShareMark` renders it β€” same viewBox, same 1.35
4004
+ stroke, same round caps β€” so the rail's share and this one are one mark and not
4005
+ two readings of one array. */}
4006
  <MenuLabel
4007
  icon={
4008
+ <svg width={16} height={16} viewBox="0 0 16 16" aria-hidden>
4009
+ {SHARE_SHAPE.map((s) =>
4010
+ s.fill ? (
4011
+ <path key={s.d} d={s.d} fill="currentColor" />
4012
+ ) : (
4013
+ <path
4014
+ key={s.d}
4015
+ d={s.d}
4016
+ fill="none"
4017
+ stroke="currentColor"
4018
+ strokeWidth={1.35}
4019
+ strokeLinecap="round"
4020
+ strokeLinejoin="round"
4021
+ />
4022
+ )
4023
+ )}
4024
  </svg>
4025
  }
4026
  text="Share field"
 
4177
  </button>
4178
  </>
4179
  )}
4180
+ {/* ⭐⭐ W40-T24 (contract C2) β€” `offer.remove` IS THE PRE-SET LOCK, ADDED BESIDE the two
4181
+ conditions that were already here rather than replacing either. `onDelete` asks
4182
+ whether the host wired a delete DOOR at all (it has three, one per stratum) and
4183
+ `schemaLocked` is `CustomerGrid.isSchemaLocked`; AND-ing a third refusal can only
4184
+ subtract, so nothing is duplicated and no working delete is widened.
4185
+ ⚠ On the Odoo grids this is behaviour-neutral: a pre-set column already had no door,
4186
+ so Delete never rendered. What changes is that the absence is now the LOCK's answer
4187
+ rather than a coincidence of wiring, which is the half of D-413 the client can settle.
4188
+ ⚠ The pre-set ROLLUP keeps its Delete, by the 2026-08-09 owner ruling `isSchemaLocked`
4189
+ already carries. `columnDefinitionOffer` restates that exception rather than dropping
4190
+ it β€” see its note. */}
4191
+ {!schemaLocked && offer.remove && onDelete && (
4192
  <button
4193
  type="button"
4194
  className="is-danger"
web/src/customer-grid/CustomerGrid.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/RecordDetail.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/ViewSidebar.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/cells.ts CHANGED
@@ -1,807 +1,815 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / cells.ts
3
- // The ONLY place that turns a (field, value) into a glide GridCell. glide has
4
- // no per-column formatter β€” all formatting is per-cell here, in getCellContent.
5
- //
6
- // currency/int -> NumberCell (raw number kept for copy/paste, right-aligned)
7
- // pct -> NumberCell showing v.toFixed(1)+"%" (yoy_pct is already in percent-points)
8
- // date -> TextCell (locale date string; read-only, no picker in core)
9
- // status -> BubbleCell (Airtable-style colored pill via themeOverride)
10
- // checkbox -> BooleanCell (wave-5 item 11; overlay stores '1' or '')
11
- // url -> UriCell (renders as a link; opening is CustomerGrid's click path)
12
- // phone/email -> TextCell (actionable links in the record drawer)
13
- // rating -> Custom (canvas-drawn stars β€” SVG/vector paths, NEVER emoji)
14
- // created_time -> TextCell (read-only; the row's `_created`, injected at its key)
15
- // formula -> NumberCell (client-computed, read-only; blank = could not compute)
16
- // text/other -> TextCell
17
- //
18
- // Wave-5 item 10: `field.format` shapes the DISPLAY string (thousands /
19
- // decimals 0..4 / 34.0M abbreviation for numbers; include-time + local|utc
20
- // for dates). A field with NO format renders byte-identically to before the
21
- // format existed β€” parity is asserted by keeping the default paths in terms
22
- // of the same toLocaleString calls.
23
- //
24
- // Editability is the CALLER's decision (permissions + stratum + type); this
25
- // module just applies the flag it is handed.
26
- // ---------------------------------------------------------------------------
27
-
28
- import { GridCellKind } from "@glideapps/glide-data-grid";
29
- import type {
30
- CustomCell,
31
- CustomRenderer,
32
- GridCell,
33
- Theme,
34
- } from "@glideapps/glide-data-grid";
35
- import { assetUrl } from "./catalogData";
36
- import type { Field } from "./types";
37
- import { ratingMax } from "./types";
38
- import {
39
- LP_BLUE_TEXT,
40
- LP_BLUE_TINT,
41
- LP_GREEN_DEEP,
42
- LP_GREEN_TINT,
43
- LP_RED_DEEP,
44
- LP_RED_TINT,
45
- LP_YELLOW_DEEP,
46
- LP_YELLOW_TINT,
47
- STATUS_BUBBLE,
48
- } from "./theme";
49
- // ⚠ ALIASED: this module has its own `pickTint` (the bubble table above) and choiceColors has
50
- // another. C-AVATAR names choiceColors' as the avatar fallback's source, so the two stay
51
- // distinguishable at the call site rather than one silently shadowing the other.
52
- import { optionTint, pickTint as choicePickTint } from "./choiceColors";
53
- import { automationState, avatarInitials, avatarSize, checkboxOn, dateTimeText, formulaIsBlank,
54
- codePreview, formulaIsText, jsonPreview, num, numberText, numericIsBlank,
55
- userCellPayload } from "./display";
56
- import type { AutomationState, CellValue, UserCellData } from "./display";
57
-
58
- // Wave-7 (item W2): the pure display-string half moved to display.ts so the
59
- // export builders can run under node without dragging glide along. Re-exported
60
- // here so every existing import keeps working unchanged.
61
- export { checkboxOn, dateTimeText, formatDisplay } from "./display";
62
- // C-AVATAR β€” the pure halves live in display.ts (node-reachable, so a gate can hold them);
63
- // re-exported here so callers keep importing "the cell module" for everything avatar-shaped.
64
- export { avatarInitials, avatarSize, userCellPayload } from "./display";
65
- export type { UserCellData } from "./display";
66
- // C5-AUTOFIELD β€” same arrangement: the string parsing is pure and lives in display.ts (a node
67
- // gate can hold it); only the canvas tint table below needs this module.
68
- export { automationDetail, automationState, automationStateLabel } from "./display";
69
- export type { AutomationState } from "./display";
70
- // Wave-23 C7 β€” same arrangement again: the parse, the preview and the pretty-printer are pure
71
- // (`verify_grid_ux` drives them under node); only the canvas cell below needs this module.
72
- export { codePreview, jsonParse, jsonPretty, jsonPreview, MAX_JSON_BYTES } from "./display";
73
-
74
- /**
75
- * Wave-18 C5-AUTOFIELD β€” the cell tint per automation state.
76
- *
77
- * Standing brand rule: the PASTEL is the fill and the measured `-deep` variant carries the ink.
78
- * `bgCell` + `textDark` is that pairing at cell scale, and every combination below is one of the
79
- * pairs already measured for `STATUS_BUBBLE` (β‰₯ 4.9:1), so nothing new needed measuring.
80
- *
81
- * `none` deliberately has NO override: a cell that has never run reads as an ordinary empty
82
- * cell, because a tint would claim the automation had produced something.
83
- */
84
- const AUTOMATION_TINT: Record<AutomationState, Partial<Theme> | undefined> = {
85
- ok: { bgCell: LP_GREEN_TINT, textDark: LP_GREEN_DEEP },
86
- partial: { bgCell: LP_YELLOW_TINT, textDark: LP_YELLOW_DEEP },
87
- blocked: { bgCell: LP_YELLOW_TINT, textDark: LP_YELLOW_DEEP },
88
- error: { bgCell: LP_RED_TINT, textDark: LP_RED_DEEP },
89
- queued: { bgCell: LP_BLUE_TINT, textDark: LP_BLUE_TEXT },
90
- none: undefined,
91
- };
92
-
93
- /** Stable pill tint for a user-defined choice. Hashed from the VALUE, so the same choice keeps
94
- * its colour across rows, sessions and users without anyone picking one β€” and a renamed option
95
- * simply gets a new colour rather than inheriting a stale mapping. */
96
- const PICK_TINTS = [
97
- // 2026-07-31 (owner item 7): ONE construction β€” every pill is a `-tint` wash carrying its
98
- // family's `-deep` ink, the standing brand rule ("pastels are fills; text takes the deep
99
- // weight"). The old second pass (full pastel + near-black ink) is GONE: it is what read as
100
- // "black font on a saturated chip". Six families now β€” the four C1 hues, the brand purple,
101
- // and a neutral grey β€” all mirrored from index.css tokens; every pairing measures β‰₯ 5.0:1.
102
- { bg: "#EDF3FD", fg: "#4F6079" }, // blue-tint / LP_BLUE_TEXT
103
- { bg: "#EBF6EF", fg: "#35754E" }, // green-tint / green-deep
104
- { bg: "#FBF4E0", fg: "#7E6428" }, // yellow-tint/ yellow-deep
105
- { bg: "#FCEEEC", fg: "#A3453C" }, // red-tint / red-deep
106
- { bg: "#F1EEFB", fg: "#6B57A8" }, // purple-tint/ purple-deep (--lp-purple family)
107
- { bg: "#EEF1F4", fg: "#4B5563" }, // neutral wash / slate β€” the sixth distinct family
108
- ];
109
-
110
- export function pickTint(v: string): { bg: string; fg: string } {
111
- let h = 0;
112
- for (let i = 0; i < v.length; i += 1) h = (h * 31 + v.charCodeAt(i)) >>> 0;
113
- return PICK_TINTS[h % PICK_TINTS.length];
114
- }
115
-
116
- // --- rating: a canvas-drawn star row (owner constant: vector, never emoji) --
117
-
118
- export interface RatingCellData {
119
- kind: "aios-rating";
120
- /** 0 = unset (draws all-empty outlines). */
121
- value: number;
122
- max: number;
123
- }
124
- export type RatingCell = CustomCell<RatingCellData>;
125
-
126
- function drawStar(
127
- ctx: CanvasRenderingContext2D,
128
- cx: number,
129
- cy: number,
130
- r: number,
131
- filled: boolean
132
- ): void {
133
- ctx.beginPath();
134
- for (let i = 0; i < 10; i += 1) {
135
- const rad = (Math.PI / 5) * i - Math.PI / 2;
136
- const rr = i % 2 === 0 ? r : r * 0.45;
137
- const px = cx + Math.cos(rad) * rr;
138
- const py = cy + Math.sin(rad) * rr;
139
- if (i === 0) ctx.moveTo(px, py);
140
- else ctx.lineTo(px, py);
141
- }
142
- ctx.closePath();
143
- if (filled) {
144
- // I7c: C1's yellow is a FILL token (1.38:1) and a star has to be seen, so the
145
- // filled star rides the deep amber variant: 3.29:1 on white, clearing the 3:1
146
- // non-text bar the old gold #C8A24B never did (it measured 2.41:1).
147
- ctx.fillStyle = "#A98A3E";
148
- ctx.fill();
149
- } else {
150
- ctx.strokeStyle = "rgba(118, 143, 182, 0.55)"; // blue-deep outline, empty slots
151
- ctx.lineWidth = 1;
152
- ctx.stroke();
153
- }
154
- }
155
-
156
- // --- user: the assignee AVATAR (wave-14 item 11 / R6 / contract C-AVATAR) -----
157
- //
158
- // R6: a grid cell shows the PHOTO OR ICON ONLY β€” no name text. (Pickers and the record modal
159
- // are where a name belongs; the column is a glanceable "who owns this", and thirty repetitions
160
- // of "Farhan Sanyoto" down a column is the thing the owner asked to stop reading.)
161
- //
162
- // The name is still the cell's COPY value. An avatar-only cell with no `copyData` makes an
163
- // assignee column copy as nothing β€” a real regression that no screenshot shows.
164
-
165
- export type UserCell = CustomCell<UserCellData>;
166
-
167
- /**
168
- * Decoded avatar images, keyed by their data URL. `null` = this URL failed to decode, so we
169
- * stop retrying it and paint the fallback forever (a corrupt stored photo must not spin).
170
- *
171
- * ⚠ Module scope, like the tint tables above: an Image per cell per frame would re-decode a
172
- * base64 payload on every scroll tick.
173
- */
174
- const _avatarImgs = new Map<string, HTMLImageElement | null>();
175
- let _avatarRepaint: (() => void) | undefined;
176
-
177
- /**
178
- * ⚠ THE HALF THAT IS EASY TO FORGET. glide repaints when `getCellContent`'s identity changes β€”
179
- * it knows nothing about an `Image.onload` that fires three frames later. Without this hook the
180
- * fallback initials paint once and STAY, and because the fallback is a deliberate, correct-looking
181
- * design, nothing on screen says the photo never arrived. (Same shape as wave-13's "honest empty
182
- * state that made the failure look deliberate".)
183
- *
184
- * CustomerGrid calls this once and bumps a counter that is in `useGetCellContent`'s deps.
185
- */
186
- export function setAvatarRepaint(fn: (() => void) | undefined): void {
187
- _avatarRepaint = fn;
188
- }
189
-
190
- function avatarImage(dataUrl: string): HTMLImageElement | null {
191
- const hit = _avatarImgs.get(dataUrl);
192
- if (hit !== undefined) return hit;
193
- // A DOM-less environment (a node gate, a locked-down embed) has no Image constructor. Cache
194
- // the refusal so the fallback is what paints, rather than throwing inside a draw call.
195
- if (typeof Image === "undefined") {
196
- _avatarImgs.set(dataUrl, null);
197
- return null;
198
- }
199
- const img = new Image();
200
- _avatarImgs.set(dataUrl, img);
201
- img.onload = () => _avatarRepaint?.();
202
- img.onerror = () => {
203
- _avatarImgs.set(dataUrl, null);
204
- _avatarRepaint?.();
205
- };
206
- img.src = dataUrl;
207
- return img;
208
- }
209
-
210
- /** C-AVATAR β€” the circle, photo or not. Exported so any other canvas surface paints the same
211
- * avatar rather than growing a second almost-matching one. */
212
- export function drawAvatar(
213
- ctx: CanvasRenderingContext2D,
214
- cx: number,
215
- cy: number,
216
- r: number,
217
- name: string,
218
- photo: string | undefined,
219
- fontFamily: string
220
- ): void {
221
- const img = photo ? avatarImage(photo) : null;
222
- // `complete` alone is not enough: a FAILED decode is also "complete", with a zero natural size.
223
- if (img && img.complete && img.naturalWidth > 0) {
224
- ctx.save();
225
- ctx.beginPath();
226
- ctx.arc(cx, cy, r, 0, Math.PI * 2);
227
- ctx.clip();
228
- // COVER, not contain: a portrait must fill the circle, not sit letterboxed inside it.
229
- const scale = Math.max((r * 2) / img.naturalWidth, (r * 2) / img.naturalHeight);
230
- const w = img.naturalWidth * scale;
231
- const h = img.naturalHeight * scale;
232
- ctx.drawImage(img, cx - w / 2, cy - h / 2, w, h);
233
- ctx.restore();
234
- return;
235
- }
236
- // The fallback, and it is the DEFAULT state until HOST's C-AVATAR lands β€” never a broken-image
237
- // glyph. Hashed from the full name so one person keeps one colour across rows and sessions.
238
- const tint = choicePickTint(name);
239
- ctx.save();
240
- ctx.beginPath();
241
- ctx.arc(cx, cy, r, 0, Math.PI * 2);
242
- ctx.fillStyle = tint.bg;
243
- ctx.fill();
244
- ctx.fillStyle = tint.fg;
245
- ctx.font = `600 ${Math.round(r * 0.9)}px ${fontFamily}`;
246
- ctx.textAlign = "center";
247
- ctx.textBaseline = "middle";
248
- ctx.fillText(avatarInitials(name), cx, cy + 0.5);
249
- ctx.restore();
250
- }
251
-
252
- /** Registered once on the DataEditor (customRenderers), beside the rating renderer. */
253
- export const userCellRenderer: CustomRenderer<UserCell> = {
254
- kind: GridCellKind.Custom,
255
- isMatch: (cell): cell is UserCell =>
256
- (cell.data as UserCellData | undefined)?.kind === "aios-user",
257
- draw: (args, cell) => {
258
- const { ctx, rect, theme } = args;
259
- const { name, photo } = cell.data;
260
- if (!name) return true; // unassigned paints nothing β€” an empty circle would look assigned
261
- const d = avatarSize(rect.height);
262
- const cx = rect.x + theme.cellHorizontalPadding + d / 2;
263
- const cy = rect.y + rect.height / 2;
264
- // Never paint outside the cell: a column dragged narrow drops the avatar rather than
265
- // bleeding it over its neighbour.
266
- if (cx + d / 2 > rect.x + rect.width - theme.cellHorizontalPadding) return true;
267
- drawAvatar(ctx, cx, cy, d / 2, name, photo, theme.fontFamily);
268
- return true;
269
- },
270
- };
271
-
272
- // --- image: the record THUMBNAIL (wave-19 R7 / contract C5) -------------------
273
- //
274
- // The cell holds a REFERENCE, never bytes (`types.ts: imageRefKind`), so what paints here is an
275
- // <img> the browser fetches from the asset routes and caches like any other image. Same
276
- // module-scoped decode cache and same repaint hook as the avatar above, and for the same reason:
277
- // glide repaints on `getCellContent` identity, and knows nothing about an `onload` three frames
278
- // later. Without the hook the placeholder paints once and stays β€” a deliberate-looking empty
279
- // frame over a picture that did arrive.
280
-
281
- export interface ImageCellData {
282
- kind: "aios-image";
283
- /** The resolved URL, or "" when the cell is empty (paints the placeholder frame). */
284
- url: string;
285
- /** The raw cell value β€” the copy/export payload, so a picture column is not invisible in a CSV. */
286
- ref: string;
287
- }
288
- export type ImageCell = CustomCell<ImageCellData>;
289
-
290
- const _cellImgs = new Map<string, HTMLImageElement | null>();
291
-
292
- function cellImage(url: string): HTMLImageElement | null {
293
- const hit = _cellImgs.get(url);
294
- if (hit !== undefined) return hit;
295
- if (typeof Image === "undefined") {
296
- _cellImgs.set(url, null);
297
- return null;
298
- }
299
- const img = new Image();
300
- _cellImgs.set(url, img);
301
- img.onload = () => _avatarRepaint?.();
302
- img.onerror = () => {
303
- // A reference that does not resolve is NOT retried: an unknown SKU code is the ordinary
304
- // state of a product with no master on file, and retrying it every repaint would be a
305
- // 404 per scroll tick per row.
306
- _cellImgs.set(url, null);
307
- _avatarRepaint?.();
308
- };
309
- img.src = url;
310
- return img;
311
- }
312
-
313
- /** Registered once on the DataEditor (customRenderers), beside rating and user. */
314
- export const imageCellRenderer: CustomRenderer<ImageCell> = {
315
- kind: GridCellKind.Custom,
316
- isMatch: (cell): cell is ImageCell =>
317
- (cell.data as ImageCellData | undefined)?.kind === "aios-image",
318
- draw: (args, cell) => {
319
- const { ctx, rect, theme } = args;
320
- const { url } = cell.data;
321
- const pad = theme.cellHorizontalPadding;
322
- // Square, inset by 3px top and bottom so a tall row shows a bigger picture and a short one
323
- // still leaves the grid line visible.
324
- const size = Math.max(0, Math.min(rect.height - 6, rect.width - pad * 2));
325
- if (size <= 2) return true; // column dragged too narrow: paint nothing, never bleed
326
- const x = rect.x + pad;
327
- const y = rect.y + (rect.height - size) / 2;
328
- const img = url ? cellImage(url) : null;
329
- ctx.save();
330
- ctx.beginPath();
331
- // A 3px radius, matching the choice pills β€” one rounding vocabulary across the canvas.
332
- const r = Math.min(3, size / 2);
333
- ctx.moveTo(x + r, y);
334
- ctx.arcTo(x + size, y, x + size, y + size, r);
335
- ctx.arcTo(x + size, y + size, x, y + size, r);
336
- ctx.arcTo(x, y + size, x, y, r);
337
- ctx.arcTo(x, y, x + size, y, r);
338
- ctx.closePath();
339
- // `complete` alone is not enough β€” a FAILED decode is also complete, with a zero natural size.
340
- if (img && img.complete && img.naturalWidth > 0) {
341
- ctx.clip();
342
- // CONTAIN, not cover: a product photo cropped to a square loses the thing being sold.
343
- // Letterboxing inside the frame is the honest fit for a catalogue picture.
344
- const scale = Math.min(size / img.naturalWidth, size / img.naturalHeight);
345
- const w = img.naturalWidth * scale;
346
- const h = img.naturalHeight * scale;
347
- ctx.drawImage(img, x + (size - w) / 2, y + (size - h) / 2, w, h);
348
- } else {
349
- // The empty frame: a quiet outline, never a broken-image glyph and never a coloured block
350
- // that would read as content. Identical whether the cell is empty or the fetch failed β€”
351
- // the record modal is where a user finds out which, in words.
352
- ctx.fillStyle = LP_BLUE_TINT;
353
- ctx.fill();
354
- ctx.strokeStyle = "rgba(118, 143, 182, 0.45)";
355
- ctx.lineWidth = 1;
356
- ctx.stroke();
357
- }
358
- ctx.restore();
359
- return true;
360
- },
361
- };
362
-
363
- /** Registered once on the DataEditor (customRenderers). Pure canvas paths. */
364
- export const ratingCellRenderer: CustomRenderer<RatingCell> = {
365
- kind: GridCellKind.Custom,
366
- isMatch: (cell): cell is RatingCell =>
367
- (cell.data as RatingCellData | undefined)?.kind === "aios-rating",
368
- draw: (args, cell) => {
369
- const { ctx, rect, theme } = args;
370
- const { value, max } = cell.data;
371
- const size = 13;
372
- const gap = 3;
373
- const cy = rect.y + rect.height / 2;
374
- let cx = rect.x + theme.cellHorizontalPadding + size / 2;
375
- const maxRight = rect.x + rect.width - theme.cellHorizontalPadding;
376
- for (let i = 0; i < max; i += 1) {
377
- if (cx + size / 2 > maxRight) break; // never paint outside the cell
378
- drawStar(ctx, cx, cy, size / 2, i < value);
379
- cx += size + gap;
380
- }
381
- return true;
382
- },
383
- };
384
-
385
- /** Build the cell for one (field, value). `editable` gates overlay + readonly.
386
- *
387
- * ⚠ A BLANK numeric cell renders EMPTY, never "$0" (2026-07-27). `''`/null is how a value that
388
- * could not be computed degrades β€” a measure column whose store query failed, an overlay number
389
- * nobody typed, a FORMULA that hit an error β€” and painting it as $0 would state a number nobody
390
- * computed. A real 0 arrives as the NUMBER 0 (the pool and the zero-group both emit it) and
391
- * still renders "$0". Mirrors the blank-vs-zero rule the engine already has (`isBlank`: 0 is
392
- * NOT blank). */
393
- export function makeCell(
394
- field: Field,
395
- v: CellValue,
396
- editable: boolean,
397
- /** C-AVATAR β€” username β†’ data URL, straight off `GridWorkspace.userAvatars`. Absent, or a
398
- * username absent from it, paints the initials fallback. */
399
- userAvatars?: Record<string, string>
400
- ): GridCell {
401
- // β›” NO MACHINE WASH (owner item 2, 2026-08-06): *"Remove the light grey highlight for the
402
- // column that is supposedly pre-set."* Wave-23 R9 introduced a grey background on every
403
- // machine-owned cell; on a `ut_*` database that is EVERY column but one, so the grid read as a
404
- // sea of grey with a white stripe rather than as a table. The fact it was trying to state β€”
405
- // *something else fills this column* β€” is now stated in WORDS where a reader will meet it
406
- // (the `Pre-set` chip in Hide fields, `isPresetField`) and enforced where it matters (the
407
- // cells refuse the edit, `isMachineWritten`). A label beats a tint the reader has to be taught.
408
- return baseCell(field, v, editable, userAvatars);
409
- }
410
-
411
- /* β›” THE MACHINE WASH IS RETIRED (owner item 2, 2026-08-06).
412
-
413
- Wave-23 C8/R9 washed every machine-owned cell grey. On the Customer grid that was a handful
414
- of columns; on a `ut_*` database it is EVERY column but the identity one, so the owner's
415
- Instagram table rendered as a grey sheet with one white stripe. `withMachineWash` and
416
- `theme.machineCellTheme` are deleted with it β€” a composer nothing composes is a subject a gate
417
- can still go green on ([[gate-answers-the-wrong-question]]), so the precedence assertions that
418
- guarded it were RETARGETED onto the new law rather than dropped: `makeCell` adds no wash, and
419
- a machine cell keeps exactly the override it already carried.
420
-
421
- What replaced the signal, because removing it without replacing it would be a loss:
422
- Β· the `Pre-set` chip in Hide fields (`isPresetField`) β€” the same word Odoo columns use;
423
- Β· the custom-field DOT no longer painted on them (they are not yours to edit);
424
- Β· and the cells genuinely refuse the edit now (`isMachineWritten`), which the wash never did. */
425
-
426
- function baseCell(
427
- field: Field,
428
- v: CellValue,
429
- editable: boolean,
430
- userAvatars?: Record<string, string>
431
- ): GridCell {
432
- const ro = { allowOverlay: editable, readonly: !editable };
433
- const blank = v == null || v === "";
434
- // β›” W29-T81 β€” A NUMERIC COLUMN HOLDING A NON-NUMBER PAINTS NOTHING, not `0`. `blank` above is
435
- // emptiness; this is emptiness OR a value no reader would call a number. The import door can
436
- // now put a spreadsheet's "seventeen-ish" in an `int` column, and `num()` answers 0 for it β€”
437
- // a fabricated figure on the canvas beside an honest em-dash in the record panel.
438
- const noNumber = numericIsBlank(v);
439
-
440
- switch (field.type) {
441
- case "currency":
442
- return {
443
- kind: GridCellKind.Number,
444
- data: noNumber ? undefined : num(v),
445
- displayData: noNumber ? "" : "$" + numberText(num(v), field.format),
446
- contentAlign: "right",
447
- ...ro,
448
- };
449
- case "int":
450
- return {
451
- kind: GridCellKind.Number,
452
- data: noNumber ? undefined : num(v),
453
- displayData: noNumber ? "" : numberText(num(v), field.format),
454
- contentAlign: "right",
455
- ...ro,
456
- };
457
- case "formula": {
458
- // Computed client-side (formulaEngine.ts) over values ALREADY injected at this key by
459
- // CustomerGrid's computedRows. Read-only by nature β€” the caller passes editable=false.
460
- // 2026-07-31 (owner item 2): a formula may now return TEXT (CONCATENATE, &, TEXT(),
461
- // TRUE/FALSE) β€” a non-numeric result renders as a text cell, never as NaN.
462
- //
463
- // β›” THE TEST WAS `!Number.isFinite(num(v))` AND IT NEVER FIRED. `num()` returns 0 for
464
- // anything non-finite, so that read `Number.isFinite(0)` β€” always true β€” and every text
465
- // formula printed as `0` here and in `formatDisplay`, which held its own copy of the same
466
- // broken test. `formulaIsText` is now the ONE test, asked of the RAW value, shared by both
467
- // renderers precisely because two copies is how they drifted. See its note in display.ts.
468
- // ⚠ `formulaIsBlank`, NOT this function's shared `blank` (which is `v === ""`). A formula
469
- // returning a SPACE is neither empty by that test nor text by the one below it, so it fell
470
- // through to the numeric path and painted `0` β€” `Number(" ")` is 0. The two predicates are
471
- // written to be total over a formula's three states; using only one of them re-opens the
472
- // gap in miniature.
473
- const formulaBlank = formulaIsBlank(v);
474
- if (!formulaBlank && formulaIsText(v)) {
475
- return {
476
- kind: GridCellKind.Text,
477
- data: v,
478
- displayData: v,
479
- allowOverlay: false,
480
- readonly: true,
481
- };
482
- }
483
- const asNum = num(v);
484
- return {
485
- kind: GridCellKind.Number,
486
- data: formulaBlank ? undefined : asNum,
487
- displayData: formulaBlank ? "" : numberText(asNum, field.format),
488
- contentAlign: "right",
489
- allowOverlay: false,
490
- readonly: true,
491
- };
492
- }
493
- case "pct":
494
- return {
495
- kind: GridCellKind.Number,
496
- data: noNumber ? undefined : num(v),
497
- displayData: noNumber ? "" : num(v).toFixed(1) + "%",
498
- contentAlign: "right",
499
- ...ro,
500
- };
501
- /* ⭐ WAVE-26 ITEM 3 β€” `copyData` IS THE STORED STAMP, and it is not decoration.
502
- MEASURED in the installed glide (`data-editor/copy-paste.js::convertCellToBuffer`): a
503
- `Text` cell copies as `copyData ?? displayData`, and the text/plain buffer takes that
504
- FORMATTED string β€” so without this line, copying a date puts the DISPLAY string on the
505
- clipboard. That was survivable while the display read `8/5/2026` (no space, so
506
- `new Date()` re-parsed it); item 3 changed it to `Aug 5, 2026`, whose first space
507
- `parseStamp` turns into `AugT5, 2026` β€” an Invalid Date that renders verbatim forever.
508
- β›” So a display change silently became a WRITE change, one paste away. The same argument
509
- `userCellPayload` records for the assignee cell: a value's clipboard identity is its
510
- STORED form, and leaving it to a fallback makes it depend on which buffer the browser
511
- hands back (the text/html one carries `gdg-raw-value`, the plain one does not).
512
- ⚠ `coerceClipboardValue` normalises a pasted display string as the second half of this;
513
- neither is sufficient alone β€” this one fixes OUR copy, that one fixes Excel's. */
514
- case "date":
515
- return {
516
- kind: GridCellKind.Text,
517
- data: String(v ?? ""),
518
- displayData: dateTimeText(field, v),
519
- copyData: String(v ?? ""),
520
- ...ro,
521
- };
522
- case "created_time":
523
- return {
524
- kind: GridCellKind.Text,
525
- data: String(v ?? ""),
526
- displayData: dateTimeText(field, v),
527
- copyData: String(v ?? ""),
528
- allowOverlay: false,
529
- readonly: true,
530
- };
531
- case "checkbox":
532
- // glide toggles a non-readonly BooleanCell on click and reports it through
533
- // onCellEdited β€” no overlay editor involved.
534
- return {
535
- kind: GridCellKind.Boolean,
536
- data: checkboxOn(v),
537
- allowOverlay: false,
538
- readonly: !editable,
539
- };
540
- case "url":
541
- // Renders as a link. OPENING is CustomerGrid's click path (scheme-guarded);
542
- // hoverEffect gives the pointer affordance.
543
- return {
544
- kind: GridCellKind.Uri,
545
- data: String(v ?? ""),
546
- hoverEffect: true,
547
- ...ro,
548
- };
549
- case "image": {
550
- // Wave-19 R7 / C5. `allowOverlay:false` for the same reason `select` has it: the value is
551
- // a REFERENCE that is picked or uploaded, never typed into a cell β€” the record modal owns
552
- // the picker and the upload. `copyData` carries the raw ref so the column is not invisible
553
- // in a copy or an export (the lesson the avatar cell booked one wave earlier).
554
- const ref = String(v ?? "").trim();
555
- return {
556
- kind: GridCellKind.Custom,
557
- data: {
558
- kind: "aios-image",
559
- url: ref ? assetUrl(ref, "web") : "",
560
- ref,
561
- } satisfies ImageCellData,
562
- copyData: ref,
563
- allowOverlay: false,
564
- };
565
- }
566
- case "json": {
567
- // ⭐ Wave-23 C7 (owner item 5) β€” the compact preview; the DOCUMENT lives in the viewer.
568
- //
569
- // `allowOverlay: false` for the same reason `select` and `image` carry it: glide's text
570
- // overlay is a one-line box, and a one-line box over a 32 KB document is an editor that
571
- // can only damage the value β€” one keystroke in the wrong place and a well-formed payload
572
- // becomes unparseable, saved. CustomerGrid opens the viewer on click instead (the same
573
- // `onCellClicked` path the pickers use), and THAT is where the raw text is editable, with
574
- // parse-on-save.
575
- //
576
- // ⚠ `readonly` is NOT set, and the difference matters: `allowOverlay:false` means "no
577
- // inline editor", while `readonly` would tell glide the CELL cannot change β€” which would
578
- // also block the paste path that legitimately writes a whole document into it.
579
- //
580
- // ⚠ `copyData` carries the RAW document, never the preview (the lesson the image cell
581
- // booked in wave 19). Copy a json column and you get the payload; copy the preview and
582
- // you get the sentence "{…} 5 keys", which is not data and cannot be pasted back.
583
- const raw = String(v ?? "");
584
- const text = jsonPreview(raw);
585
- return {
586
- kind: GridCellKind.Text,
587
- data: text,
588
- displayData: text,
589
- copyData: raw,
590
- allowOverlay: false,
591
- };
592
- }
593
- case "code": {
594
- // ⭐ Wave-27 item 13 (R13) β€” the compact preview; the SNIPPET lives in the editor.
595
- //
596
- // Every rule the json cell above states applies here for the same reasons, so this is
597
- // written the same way rather than differently: `allowOverlay:false` because glide's
598
- // one-line text overlay over a multi-line snippet is an editor that can only damage the
599
- // value; `readonly` deliberately NOT set, so the paste path that legitimately writes a
600
- // whole snippet still works; `copyData` the RAW text, never the preview, or copying a code
601
- // column would yield "SELECT * FROM … +12 more", which is not data and cannot be pasted
602
- // back (the lesson the image cell booked in wave 19, and the W26 date-cell repeat of it).
603
- const raw = String(v ?? "");
604
- const text = codePreview(raw);
605
- return {
606
- kind: GridCellKind.Text,
607
- data: text,
608
- displayData: text,
609
- copyData: raw,
610
- allowOverlay: false,
611
- };
612
- }
613
- case "ai_enrich": {
614
- // ⭐⭐ Wave-34 (owner ruling R13) β€” the AI ENRICHMENT cell. The value is an ordinary stored
615
- // string, so this is a Text cell; what it is NOT is a `formula` (recomputed in the browser)
616
- // or a `rollup` (read through from another table). Nobody has to be told a number came from
617
- // somewhere else here, because the answer is words.
618
- //
619
- // Written the same way `code` and `json` are, and for their reason rather than a new one:
620
- // Β· `allowOverlay: false` β€” glide's ONE-LINE overlay editor over multi-line prose is an
621
- // editor that can only damage the value. The place a person edits an answer is the
622
- // record drawer, which shows the whole thing.
623
- // Β· `readonly` deliberately NOT set, so the paste path that legitimately writes a whole
624
- // answer still works.
625
- // Β· `copyData` the RAW text, never the preview, or copying this column would yield
626
- // "Ann is a florist +2 more", which is not data and cannot be pasted back. That is the
627
- // lesson the image cell booked in wave 19 and the date cell repeated in wave 26.
628
- //
629
- // β›” EDITING IS THE POINT, NOT AN AFTERTHOUGHT, which is why the column is NOT in
630
- // `READONLY_CELL_TYPES` and is NOT in `is_computed_cell` server-side: typing over a cell is
631
- // what marks it the human's, and `core.user_tables.ai_enrich_human_authored` then protects
632
- // it from every automatic run forever.
633
- //
634
- // ⚠ OWED, AND NAMED RATHER THAN FAKED: this branch cannot paint the agent-written /
635
- // human-edited / stale / errored STATE, because `makeCell(field, v, editable, avatars)` is
636
- // handed the value and never the row, and the per-cell mark lives in a stratum the row
637
- // payload does not carry. The drawer shows the state; the canvas will once the call site in
638
- // `CustomerGrid.tsx` (lane C's file) passes it. Rendering a guessed state here would be
639
- // worse than none: "done" on a cell nothing has run for is a claim.
640
- const raw = String(v ?? "");
641
- const text = codePreview(raw);
642
- return {
643
- kind: GridCellKind.Text,
644
- data: text,
645
- displayData: text,
646
- copyData: raw,
647
- allowOverlay: false,
648
- };
649
- }
650
- case "rating": {
651
- const max = ratingMax(field);
652
- const n = Math.max(0, Math.min(max, Math.round(num(v))));
653
- return {
654
- kind: GridCellKind.Custom,
655
- data: { kind: "aios-rating", value: blank ? 0 : n, max } satisfies RatingCellData,
656
- copyData: blank ? "" : String(n),
657
- allowOverlay: false,
658
- };
659
- }
660
- case "automation": {
661
- // Wave-18 C5-AUTOFIELD. The cell is what the last RUN wrote β€” `ok Β· 2026-08-03 14:10 Β·
662
- // 12 posts` β€” so it is read-only by NATURE, not by policy: a value typed here would be
663
- // overwritten by the next run with nothing anywhere saying so. The column's behaviour is
664
- // configured through its gear, the way a formula's expression is.
665
- //
666
- // A tinted CELL rather than a bubble, and that is the whole design: the state and the
667
- // detail are one sentence, and splitting them into two pills would put "2026-08-03 14:10
668
- // Β· 12 posts" in a bubble, which is not what a bubble is for. `bgCell` is the one theme
669
- // key glide alpha-blends (theme.ts:172), so the tints below are the flat pastels rather
670
- // than anything semi-transparent.
671
- const text = String(v ?? "");
672
- return {
673
- kind: GridCellKind.Text,
674
- data: text,
675
- displayData: text,
676
- allowOverlay: false,
677
- readonly: true,
678
- themeOverride: AUTOMATION_TINT[automationState(text)],
679
- };
680
- }
681
- case "link": {
682
- // ⭐ 2026-08-07 β€” a relation, painted as ONE bubble saying how many rows it reaches.
683
- //
684
- // β›” NOT one bubble per linked row, and that is a measured decision rather than a
685
- // simplification: the cell holds row IDS, and a column of `1,2,3` pills tells a reader
686
- // nothing β€” the ids are not names. Airtable can paint the linked record's PRIMARY value
687
- // because it has that row loaded; this client has not loaded the other table at all. So
688
- // the honest cell is the COUNT, and the record drawer is where the rows themselves belong.
689
- //
690
- // ⚠ `copyData` carries the RAW id list, never the "12 posts" sentence β€” the W26 date-cell
691
- // lesson, where a glide Text cell copies `copyData ?? displayData` and the FORMATTED
692
- // string silently reached the clipboard and then the paste path. A count is not data and
693
- // cannot be pasted back into a relation.
694
- const ids = String(v ?? "").split(",").map((s) => s.trim()).filter((s) => s !== "");
695
- return {
696
- kind: GridCellKind.Bubble,
697
- data: ids.length ? [ids.length === 1 ? "1 record" : `${ids.length} records`] : [],
698
- copyData: String(v ?? ""),
699
- allowOverlay: false,
700
- };
701
- }
702
- case "rollup": {
703
- // ⭐ 2026-08-07 β€” an aggregate the HOST computed. Read-only by nature, exactly like
704
- // `formula`: the value is arithmetic, and a value box over arithmetic is an editor that
705
- // can only produce a number the next refresh throws away.
706
- //
707
- // ⚠ BLANK STAYS BLANK. `_rollup_fold` returns "" for "no rows to aggregate" and only the
708
- // count family ever returns a real 0 β€” so painting `0` here for an empty cell would invent
709
- // the measurement the server just refused to invent. `num(v)` would do exactly that
710
- // (`num("")` is 0), which is why the raw string is rendered rather than a parsed number.
711
- // ⭐ 2026-08-10 (owner: *"I want to be able to use commas for numbers so instead of 1000
712
- // its 1,000"*) β€” A ROLLUP IS FORMATTED LIKE ANY OTHER NUMBER, and it was the only numeric
713
- // kind that was not. `int` and `currency` have gone through `numberText` since wave 5 and
714
- // `formula` since it became a real column; a rollup rendered its raw fold string, so an
715
- // "Avg views" column read `1491552.43` while the `Followers` column beside it read
716
- // `56,147,007`. Two numeric columns, two dialects, one grid.
717
- //
718
- // ⚠ THE BLANK RULE IS PRESERVED EXACTLY, and it is why this is a guarded branch rather
719
- // than a call. `_rollup_fold` returns "" for "no rows to aggregate" and only the count
720
- // family ever returns a real 0, so `num(v)` β€” which maps "" to 0 β€” would paint the
721
- // measurement the server just refused to invent. A blank stays the empty string, and a
722
- // fold that is not a number at all (`concatenate`, `arrayunique`, `latest` over text)
723
- // keeps its own text.
724
- const raw = String(v ?? "");
725
- const asNum = raw.trim() === "" ? NaN : Number(raw);
726
- const text = Number.isFinite(asNum) ? numberText(asNum, field.format) : raw;
727
- return {
728
- kind: GridCellKind.Text,
729
- data: text,
730
- displayData: text,
731
- // β›” THE CLIPBOARD TAKES THE RAW NUMBER, NOT THE FORMATTED ONE β€” wave 26's measured
732
- // defect on `date`, where a glide Text cell copies `copyData ?? displayData` and the
733
- // formatted string reached the clipboard, so pasting `Aug 5, 2026` into a number column
734
- // stored the words. `1,491,552.43` pastes as text everywhere; `1491552.43` pastes as
735
- // a number.
736
- copyData: raw,
737
- allowOverlay: false,
738
- readonly: true,
739
- };
740
- }
741
- case "status":
742
- return {
743
- kind: GridCellKind.Bubble,
744
- data: [String(v ?? "")],
745
- allowOverlay: false,
746
- themeOverride: STATUS_BUBBLE[String(v ?? "").toLowerCase()],
747
- };
748
- case "user": {
749
- // Wave-14 item 11 / R6 β€” the AVATAR ONLY, no name text. Split out of the `select` branch
750
- // below (it was a name pill until this wave) because "who owns this" is a face, not a
751
- // sentence, once a column has thirty rows of it.
752
- //
753
- // Still `allowOverlay:false`, for the same reason `select` is: the value is PICKED, never
754
- // typed, and CustomerGrid opens its anchored picker on click (onCellClicked β†’ isPickType,
755
- // which already includes `user`, so the click path needs no change).
756
- //
757
- // ⚠ `copyData` carries the NAME. The cell shows no text, so without this the column would
758
- // copy and export as empty β€” invisible in every screenshot. Built by `userCellPayload` in
759
- // display.ts so a node gate can actually assert that (this module imports glide).
760
- return {
761
- kind: GridCellKind.Custom,
762
- ...userCellPayload(String(v ?? ""), userAvatars),
763
- allowOverlay: false,
764
- };
765
- }
766
- case "select": {
767
- // Picked, never typed. `allowOverlay:false` keeps glide's text editor OUT of the way β€”
768
- // a free-text editor on a constrained field is how a column ends up holding "Done",
769
- // "done" and "DONE" as three different values. CustomerGrid opens an anchored picker on
770
- // click instead (onCellClicked), which is also why this stays dependency-free: glide's
771
- // dropdown cell lives in a separate package we deliberately have not added.
772
- const s = String(v ?? "");
773
- const tint = optionTint(field, s);
774
- return {
775
- kind: GridCellKind.Bubble,
776
- data: s ? [s] : [],
777
- allowOverlay: false,
778
- themeOverride: tint ? { bgBubble: tint.bg, textBubble: tint.fg } : undefined,
779
- };
780
- }
781
- case "multiselect": {
782
- // The cell holds a comma-joined SET (the `multi` contract the Cohorts column uses), so
783
- // every member paints as its own pill. One themeOverride per CELL is all glide offers, so
784
- // the pills share the first member's tint rather than each carrying its own.
785
- const parts = String(v ?? "")
786
- .split(",")
787
- .map((s) => s.trim())
788
- .filter((s) => s !== "");
789
- const tint = parts.length ? optionTint(field, parts[0]) : undefined;
790
- return {
791
- kind: GridCellKind.Bubble,
792
- data: parts,
793
- allowOverlay: false,
794
- themeOverride: tint ? { bgBubble: tint.bg, textBubble: tint.fg } : undefined,
795
- };
796
- }
797
- default:
798
- // text / status-family fallthrough β€” phone and email ride this branch too: typed as
799
- // text in the grid, rendered as actionable tel:/mailto: links in the record drawer.
800
- return {
801
- kind: GridCellKind.Text,
802
- data: String(v ?? ""),
803
- displayData: String(v ?? ""),
804
- ...ro,
805
- };
806
- }
807
- }
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / cells.ts
3
+ // The ONLY place that turns a (field, value) into a glide GridCell. glide has
4
+ // no per-column formatter β€” all formatting is per-cell here, in getCellContent.
5
+ //
6
+ // currency/int -> NumberCell (raw number kept for copy/paste, right-aligned)
7
+ // pct -> NumberCell showing v.toFixed(1)+"%" (yoy_pct is already in percent-points)
8
+ // date -> TextCell (locale date string; read-only, no picker in core)
9
+ // status -> BubbleCell (Airtable-style colored pill via themeOverride)
10
+ // checkbox -> BooleanCell (wave-5 item 11; overlay stores '1' or '')
11
+ // url -> UriCell (renders as a link; opening is CustomerGrid's click path)
12
+ // phone/email -> TextCell (actionable links in the record drawer)
13
+ // rating -> Custom (canvas-drawn stars β€” SVG/vector paths, NEVER emoji)
14
+ // created_time -> TextCell (read-only; the row's `_created`, injected at its key)
15
+ // formula -> NumberCell (client-computed, read-only; blank = could not compute)
16
+ // text/other -> TextCell
17
+ //
18
+ // Wave-5 item 10: `field.format` shapes the DISPLAY string (thousands /
19
+ // decimals 0..4 / 34.0M abbreviation for numbers; include-time + local|utc
20
+ // for dates). A field with NO format renders byte-identically to before the
21
+ // format existed β€” parity is asserted by keeping the default paths in terms
22
+ // of the same toLocaleString calls.
23
+ //
24
+ // Editability is the CALLER's decision (permissions + stratum + type); this
25
+ // module just applies the flag it is handed.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ import { GridCellKind } from "@glideapps/glide-data-grid";
29
+ import type {
30
+ CustomCell,
31
+ CustomRenderer,
32
+ GridCell,
33
+ Theme,
34
+ } from "@glideapps/glide-data-grid";
35
+ import { assetUrl } from "./catalogData";
36
+ import type { Field } from "./types";
37
+ import { ratingMax } from "./types";
38
+ import {
39
+ LP_BLUE_TEXT,
40
+ LP_BLUE_TINT,
41
+ LP_GREEN_DEEP,
42
+ LP_GREEN_TINT,
43
+ LP_RED_DEEP,
44
+ LP_RED_TINT,
45
+ LP_YELLOW_DEEP,
46
+ LP_YELLOW_TINT,
47
+ STATUS_BUBBLE,
48
+ } from "./theme";
49
+ // ⚠ ALIASED: this module has its own `pickTint` (the bubble table above) and choiceColors has
50
+ // another. C-AVATAR names choiceColors' as the avatar fallback's source, so the two stay
51
+ // distinguishable at the call site rather than one silently shadowing the other.
52
+ import { optionTint, pickTint as choicePickTint } from "./choiceColors";
53
+ import { automationState, avatarInitials, avatarSize, checkboxOn, dateTimeText, formulaIsBlank,
54
+ codePreview, formulaIsText, jsonPreview, num, numberText, numericIsBlank,
55
+ userCellPayload } from "./display";
56
+ import type { AutomationState, CellValue, UserCellData } from "./display";
57
+
58
+ // Wave-7 (item W2): the pure display-string half moved to display.ts so the
59
+ // export builders can run under node without dragging glide along. Re-exported
60
+ // here so every existing import keeps working unchanged.
61
+ export { checkboxOn, dateTimeText, formatDisplay } from "./display";
62
+ // C-AVATAR β€” the pure halves live in display.ts (node-reachable, so a gate can hold them);
63
+ // re-exported here so callers keep importing "the cell module" for everything avatar-shaped.
64
+ export { avatarInitials, avatarSize, userCellPayload } from "./display";
65
+ export type { UserCellData } from "./display";
66
+ // C5-AUTOFIELD β€” same arrangement: the string parsing is pure and lives in display.ts (a node
67
+ // gate can hold it); only the canvas tint table below needs this module.
68
+ export { automationDetail, automationState, automationStateLabel } from "./display";
69
+ export type { AutomationState } from "./display";
70
+ // Wave-23 C7 β€” same arrangement again: the parse, the preview and the pretty-printer are pure
71
+ // (`verify_grid_ux` drives them under node); only the canvas cell below needs this module.
72
+ export { codePreview, jsonParse, jsonPretty, jsonPreview, MAX_JSON_BYTES } from "./display";
73
+
74
+ // ⭐ W40-T23 (owner instruction 14) β€” "typing a letter opens the picker" rests on two pure
75
+ // decisions: which keystroke counts as typing, and which choices a query keeps. Same arrangement
76
+ // as every block above β€” they live in display.ts so a node gate can EXECUTE them (this file
77
+ // imports `GridCellKind` as a value, so nothing in it is node-reachable), and are re-exported
78
+ // here so callers keep importing "the cell module". Their second consumer is the record
79
+ // drawer's chip row, which is a different control answering the same instruction.
80
+ export { matchChoices, pickerTypeSeed } from "./display";
81
+
82
+ /**
83
+ * Wave-18 C5-AUTOFIELD β€” the cell tint per automation state.
84
+ *
85
+ * Standing brand rule: the PASTEL is the fill and the measured `-deep` variant carries the ink.
86
+ * `bgCell` + `textDark` is that pairing at cell scale, and every combination below is one of the
87
+ * pairs already measured for `STATUS_BUBBLE` (β‰₯ 4.9:1), so nothing new needed measuring.
88
+ *
89
+ * `none` deliberately has NO override: a cell that has never run reads as an ordinary empty
90
+ * cell, because a tint would claim the automation had produced something.
91
+ */
92
+ const AUTOMATION_TINT: Record<AutomationState, Partial<Theme> | undefined> = {
93
+ ok: { bgCell: LP_GREEN_TINT, textDark: LP_GREEN_DEEP },
94
+ partial: { bgCell: LP_YELLOW_TINT, textDark: LP_YELLOW_DEEP },
95
+ blocked: { bgCell: LP_YELLOW_TINT, textDark: LP_YELLOW_DEEP },
96
+ error: { bgCell: LP_RED_TINT, textDark: LP_RED_DEEP },
97
+ queued: { bgCell: LP_BLUE_TINT, textDark: LP_BLUE_TEXT },
98
+ none: undefined,
99
+ };
100
+
101
+ /** Stable pill tint for a user-defined choice. Hashed from the VALUE, so the same choice keeps
102
+ * its colour across rows, sessions and users without anyone picking one β€” and a renamed option
103
+ * simply gets a new colour rather than inheriting a stale mapping. */
104
+ const PICK_TINTS = [
105
+ // 2026-07-31 (owner item 7): ONE construction β€” every pill is a `-tint` wash carrying its
106
+ // family's `-deep` ink, the standing brand rule ("pastels are fills; text takes the deep
107
+ // weight"). The old second pass (full pastel + near-black ink) is GONE: it is what read as
108
+ // "black font on a saturated chip". Six families now β€” the four C1 hues, the brand purple,
109
+ // and a neutral grey β€” all mirrored from index.css tokens; every pairing measures β‰₯ 5.0:1.
110
+ { bg: "#EDF3FD", fg: "#4F6079" }, // blue-tint / LP_BLUE_TEXT
111
+ { bg: "#EBF6EF", fg: "#35754E" }, // green-tint / green-deep
112
+ { bg: "#FBF4E0", fg: "#7E6428" }, // yellow-tint/ yellow-deep
113
+ { bg: "#FCEEEC", fg: "#A3453C" }, // red-tint / red-deep
114
+ { bg: "#F1EEFB", fg: "#6B57A8" }, // purple-tint/ purple-deep (--lp-purple family)
115
+ { bg: "#EEF1F4", fg: "#4B5563" }, // neutral wash / slate β€” the sixth distinct family
116
+ ];
117
+
118
+ export function pickTint(v: string): { bg: string; fg: string } {
119
+ let h = 0;
120
+ for (let i = 0; i < v.length; i += 1) h = (h * 31 + v.charCodeAt(i)) >>> 0;
121
+ return PICK_TINTS[h % PICK_TINTS.length];
122
+ }
123
+
124
+ // --- rating: a canvas-drawn star row (owner constant: vector, never emoji) --
125
+
126
+ export interface RatingCellData {
127
+ kind: "aios-rating";
128
+ /** 0 = unset (draws all-empty outlines). */
129
+ value: number;
130
+ max: number;
131
+ }
132
+ export type RatingCell = CustomCell<RatingCellData>;
133
+
134
+ function drawStar(
135
+ ctx: CanvasRenderingContext2D,
136
+ cx: number,
137
+ cy: number,
138
+ r: number,
139
+ filled: boolean
140
+ ): void {
141
+ ctx.beginPath();
142
+ for (let i = 0; i < 10; i += 1) {
143
+ const rad = (Math.PI / 5) * i - Math.PI / 2;
144
+ const rr = i % 2 === 0 ? r : r * 0.45;
145
+ const px = cx + Math.cos(rad) * rr;
146
+ const py = cy + Math.sin(rad) * rr;
147
+ if (i === 0) ctx.moveTo(px, py);
148
+ else ctx.lineTo(px, py);
149
+ }
150
+ ctx.closePath();
151
+ if (filled) {
152
+ // I7c: C1's yellow is a FILL token (1.38:1) and a star has to be seen, so the
153
+ // filled star rides the deep amber variant: 3.29:1 on white, clearing the 3:1
154
+ // non-text bar the old gold #C8A24B never did (it measured 2.41:1).
155
+ ctx.fillStyle = "#A98A3E";
156
+ ctx.fill();
157
+ } else {
158
+ ctx.strokeStyle = "rgba(118, 143, 182, 0.55)"; // blue-deep outline, empty slots
159
+ ctx.lineWidth = 1;
160
+ ctx.stroke();
161
+ }
162
+ }
163
+
164
+ // --- user: the assignee AVATAR (wave-14 item 11 / R6 / contract C-AVATAR) -----
165
+ //
166
+ // R6: a grid cell shows the PHOTO OR ICON ONLY β€” no name text. (Pickers and the record modal
167
+ // are where a name belongs; the column is a glanceable "who owns this", and thirty repetitions
168
+ // of "Farhan Sanyoto" down a column is the thing the owner asked to stop reading.)
169
+ //
170
+ // The name is still the cell's COPY value. An avatar-only cell with no `copyData` makes an
171
+ // assignee column copy as nothing β€” a real regression that no screenshot shows.
172
+
173
+ export type UserCell = CustomCell<UserCellData>;
174
+
175
+ /**
176
+ * Decoded avatar images, keyed by their data URL. `null` = this URL failed to decode, so we
177
+ * stop retrying it and paint the fallback forever (a corrupt stored photo must not spin).
178
+ *
179
+ * ⚠ Module scope, like the tint tables above: an Image per cell per frame would re-decode a
180
+ * base64 payload on every scroll tick.
181
+ */
182
+ const _avatarImgs = new Map<string, HTMLImageElement | null>();
183
+ let _avatarRepaint: (() => void) | undefined;
184
+
185
+ /**
186
+ * ⚠ THE HALF THAT IS EASY TO FORGET. glide repaints when `getCellContent`'s identity changes β€”
187
+ * it knows nothing about an `Image.onload` that fires three frames later. Without this hook the
188
+ * fallback initials paint once and STAY, and because the fallback is a deliberate, correct-looking
189
+ * design, nothing on screen says the photo never arrived. (Same shape as wave-13's "honest empty
190
+ * state that made the failure look deliberate".)
191
+ *
192
+ * CustomerGrid calls this once and bumps a counter that is in `useGetCellContent`'s deps.
193
+ */
194
+ export function setAvatarRepaint(fn: (() => void) | undefined): void {
195
+ _avatarRepaint = fn;
196
+ }
197
+
198
+ function avatarImage(dataUrl: string): HTMLImageElement | null {
199
+ const hit = _avatarImgs.get(dataUrl);
200
+ if (hit !== undefined) return hit;
201
+ // A DOM-less environment (a node gate, a locked-down embed) has no Image constructor. Cache
202
+ // the refusal so the fallback is what paints, rather than throwing inside a draw call.
203
+ if (typeof Image === "undefined") {
204
+ _avatarImgs.set(dataUrl, null);
205
+ return null;
206
+ }
207
+ const img = new Image();
208
+ _avatarImgs.set(dataUrl, img);
209
+ img.onload = () => _avatarRepaint?.();
210
+ img.onerror = () => {
211
+ _avatarImgs.set(dataUrl, null);
212
+ _avatarRepaint?.();
213
+ };
214
+ img.src = dataUrl;
215
+ return img;
216
+ }
217
+
218
+ /** C-AVATAR β€” the circle, photo or not. Exported so any other canvas surface paints the same
219
+ * avatar rather than growing a second almost-matching one. */
220
+ export function drawAvatar(
221
+ ctx: CanvasRenderingContext2D,
222
+ cx: number,
223
+ cy: number,
224
+ r: number,
225
+ name: string,
226
+ photo: string | undefined,
227
+ fontFamily: string
228
+ ): void {
229
+ const img = photo ? avatarImage(photo) : null;
230
+ // `complete` alone is not enough: a FAILED decode is also "complete", with a zero natural size.
231
+ if (img && img.complete && img.naturalWidth > 0) {
232
+ ctx.save();
233
+ ctx.beginPath();
234
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
235
+ ctx.clip();
236
+ // COVER, not contain: a portrait must fill the circle, not sit letterboxed inside it.
237
+ const scale = Math.max((r * 2) / img.naturalWidth, (r * 2) / img.naturalHeight);
238
+ const w = img.naturalWidth * scale;
239
+ const h = img.naturalHeight * scale;
240
+ ctx.drawImage(img, cx - w / 2, cy - h / 2, w, h);
241
+ ctx.restore();
242
+ return;
243
+ }
244
+ // The fallback, and it is the DEFAULT state until HOST's C-AVATAR lands β€” never a broken-image
245
+ // glyph. Hashed from the full name so one person keeps one colour across rows and sessions.
246
+ const tint = choicePickTint(name);
247
+ ctx.save();
248
+ ctx.beginPath();
249
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
250
+ ctx.fillStyle = tint.bg;
251
+ ctx.fill();
252
+ ctx.fillStyle = tint.fg;
253
+ ctx.font = `600 ${Math.round(r * 0.9)}px ${fontFamily}`;
254
+ ctx.textAlign = "center";
255
+ ctx.textBaseline = "middle";
256
+ ctx.fillText(avatarInitials(name), cx, cy + 0.5);
257
+ ctx.restore();
258
+ }
259
+
260
+ /** Registered once on the DataEditor (customRenderers), beside the rating renderer. */
261
+ export const userCellRenderer: CustomRenderer<UserCell> = {
262
+ kind: GridCellKind.Custom,
263
+ isMatch: (cell): cell is UserCell =>
264
+ (cell.data as UserCellData | undefined)?.kind === "aios-user",
265
+ draw: (args, cell) => {
266
+ const { ctx, rect, theme } = args;
267
+ const { name, photo } = cell.data;
268
+ if (!name) return true; // unassigned paints nothing β€” an empty circle would look assigned
269
+ const d = avatarSize(rect.height);
270
+ const cx = rect.x + theme.cellHorizontalPadding + d / 2;
271
+ const cy = rect.y + rect.height / 2;
272
+ // Never paint outside the cell: a column dragged narrow drops the avatar rather than
273
+ // bleeding it over its neighbour.
274
+ if (cx + d / 2 > rect.x + rect.width - theme.cellHorizontalPadding) return true;
275
+ drawAvatar(ctx, cx, cy, d / 2, name, photo, theme.fontFamily);
276
+ return true;
277
+ },
278
+ };
279
+
280
+ // --- image: the record THUMBNAIL (wave-19 R7 / contract C5) -------------------
281
+ //
282
+ // The cell holds a REFERENCE, never bytes (`types.ts: imageRefKind`), so what paints here is an
283
+ // <img> the browser fetches from the asset routes and caches like any other image. Same
284
+ // module-scoped decode cache and same repaint hook as the avatar above, and for the same reason:
285
+ // glide repaints on `getCellContent` identity, and knows nothing about an `onload` three frames
286
+ // later. Without the hook the placeholder paints once and stays β€” a deliberate-looking empty
287
+ // frame over a picture that did arrive.
288
+
289
+ export interface ImageCellData {
290
+ kind: "aios-image";
291
+ /** The resolved URL, or "" when the cell is empty (paints the placeholder frame). */
292
+ url: string;
293
+ /** The raw cell value β€” the copy/export payload, so a picture column is not invisible in a CSV. */
294
+ ref: string;
295
+ }
296
+ export type ImageCell = CustomCell<ImageCellData>;
297
+
298
+ const _cellImgs = new Map<string, HTMLImageElement | null>();
299
+
300
+ function cellImage(url: string): HTMLImageElement | null {
301
+ const hit = _cellImgs.get(url);
302
+ if (hit !== undefined) return hit;
303
+ if (typeof Image === "undefined") {
304
+ _cellImgs.set(url, null);
305
+ return null;
306
+ }
307
+ const img = new Image();
308
+ _cellImgs.set(url, img);
309
+ img.onload = () => _avatarRepaint?.();
310
+ img.onerror = () => {
311
+ // A reference that does not resolve is NOT retried: an unknown SKU code is the ordinary
312
+ // state of a product with no master on file, and retrying it every repaint would be a
313
+ // 404 per scroll tick per row.
314
+ _cellImgs.set(url, null);
315
+ _avatarRepaint?.();
316
+ };
317
+ img.src = url;
318
+ return img;
319
+ }
320
+
321
+ /** Registered once on the DataEditor (customRenderers), beside rating and user. */
322
+ export const imageCellRenderer: CustomRenderer<ImageCell> = {
323
+ kind: GridCellKind.Custom,
324
+ isMatch: (cell): cell is ImageCell =>
325
+ (cell.data as ImageCellData | undefined)?.kind === "aios-image",
326
+ draw: (args, cell) => {
327
+ const { ctx, rect, theme } = args;
328
+ const { url } = cell.data;
329
+ const pad = theme.cellHorizontalPadding;
330
+ // Square, inset by 3px top and bottom so a tall row shows a bigger picture and a short one
331
+ // still leaves the grid line visible.
332
+ const size = Math.max(0, Math.min(rect.height - 6, rect.width - pad * 2));
333
+ if (size <= 2) return true; // column dragged too narrow: paint nothing, never bleed
334
+ const x = rect.x + pad;
335
+ const y = rect.y + (rect.height - size) / 2;
336
+ const img = url ? cellImage(url) : null;
337
+ ctx.save();
338
+ ctx.beginPath();
339
+ // A 3px radius, matching the choice pills β€” one rounding vocabulary across the canvas.
340
+ const r = Math.min(3, size / 2);
341
+ ctx.moveTo(x + r, y);
342
+ ctx.arcTo(x + size, y, x + size, y + size, r);
343
+ ctx.arcTo(x + size, y + size, x, y + size, r);
344
+ ctx.arcTo(x, y + size, x, y, r);
345
+ ctx.arcTo(x, y, x + size, y, r);
346
+ ctx.closePath();
347
+ // `complete` alone is not enough β€” a FAILED decode is also complete, with a zero natural size.
348
+ if (img && img.complete && img.naturalWidth > 0) {
349
+ ctx.clip();
350
+ // CONTAIN, not cover: a product photo cropped to a square loses the thing being sold.
351
+ // Letterboxing inside the frame is the honest fit for a catalogue picture.
352
+ const scale = Math.min(size / img.naturalWidth, size / img.naturalHeight);
353
+ const w = img.naturalWidth * scale;
354
+ const h = img.naturalHeight * scale;
355
+ ctx.drawImage(img, x + (size - w) / 2, y + (size - h) / 2, w, h);
356
+ } else {
357
+ // The empty frame: a quiet outline, never a broken-image glyph and never a coloured block
358
+ // that would read as content. Identical whether the cell is empty or the fetch failed β€”
359
+ // the record modal is where a user finds out which, in words.
360
+ ctx.fillStyle = LP_BLUE_TINT;
361
+ ctx.fill();
362
+ ctx.strokeStyle = "rgba(118, 143, 182, 0.45)";
363
+ ctx.lineWidth = 1;
364
+ ctx.stroke();
365
+ }
366
+ ctx.restore();
367
+ return true;
368
+ },
369
+ };
370
+
371
+ /** Registered once on the DataEditor (customRenderers). Pure canvas paths. */
372
+ export const ratingCellRenderer: CustomRenderer<RatingCell> = {
373
+ kind: GridCellKind.Custom,
374
+ isMatch: (cell): cell is RatingCell =>
375
+ (cell.data as RatingCellData | undefined)?.kind === "aios-rating",
376
+ draw: (args, cell) => {
377
+ const { ctx, rect, theme } = args;
378
+ const { value, max } = cell.data;
379
+ const size = 13;
380
+ const gap = 3;
381
+ const cy = rect.y + rect.height / 2;
382
+ let cx = rect.x + theme.cellHorizontalPadding + size / 2;
383
+ const maxRight = rect.x + rect.width - theme.cellHorizontalPadding;
384
+ for (let i = 0; i < max; i += 1) {
385
+ if (cx + size / 2 > maxRight) break; // never paint outside the cell
386
+ drawStar(ctx, cx, cy, size / 2, i < value);
387
+ cx += size + gap;
388
+ }
389
+ return true;
390
+ },
391
+ };
392
+
393
+ /** Build the cell for one (field, value). `editable` gates overlay + readonly.
394
+ *
395
+ * ⚠ A BLANK numeric cell renders EMPTY, never "$0" (2026-07-27). `''`/null is how a value that
396
+ * could not be computed degrades β€” a measure column whose store query failed, an overlay number
397
+ * nobody typed, a FORMULA that hit an error β€” and painting it as $0 would state a number nobody
398
+ * computed. A real 0 arrives as the NUMBER 0 (the pool and the zero-group both emit it) and
399
+ * still renders "$0". Mirrors the blank-vs-zero rule the engine already has (`isBlank`: 0 is
400
+ * NOT blank). */
401
+ export function makeCell(
402
+ field: Field,
403
+ v: CellValue,
404
+ editable: boolean,
405
+ /** C-AVATAR β€” username β†’ data URL, straight off `GridWorkspace.userAvatars`. Absent, or a
406
+ * username absent from it, paints the initials fallback. */
407
+ userAvatars?: Record<string, string>
408
+ ): GridCell {
409
+ // β›” NO MACHINE WASH (owner item 2, 2026-08-06): *"Remove the light grey highlight for the
410
+ // column that is supposedly pre-set."* Wave-23 R9 introduced a grey background on every
411
+ // machine-owned cell; on a `ut_*` database that is EVERY column but one, so the grid read as a
412
+ // sea of grey with a white stripe rather than as a table. The fact it was trying to state β€”
413
+ // *something else fills this column* β€” is now stated in WORDS where a reader will meet it
414
+ // (the `Pre-set` chip in Hide fields, `isPresetField`) and enforced where it matters (the
415
+ // cells refuse the edit, `isMachineWritten`). A label beats a tint the reader has to be taught.
416
+ return baseCell(field, v, editable, userAvatars);
417
+ }
418
+
419
+ /* β›” THE MACHINE WASH IS RETIRED (owner item 2, 2026-08-06).
420
+
421
+ Wave-23 C8/R9 washed every machine-owned cell grey. On the Customer grid that was a handful
422
+ of columns; on a `ut_*` database it is EVERY column but the identity one, so the owner's
423
+ Instagram table rendered as a grey sheet with one white stripe. `withMachineWash` and
424
+ `theme.machineCellTheme` are deleted with it β€” a composer nothing composes is a subject a gate
425
+ can still go green on ([[gate-answers-the-wrong-question]]), so the precedence assertions that
426
+ guarded it were RETARGETED onto the new law rather than dropped: `makeCell` adds no wash, and
427
+ a machine cell keeps exactly the override it already carried.
428
+
429
+ What replaced the signal, because removing it without replacing it would be a loss:
430
+ Β· the `Pre-set` chip in Hide fields (`isPresetField`) β€” the same word Odoo columns use;
431
+ Β· the custom-field DOT no longer painted on them (they are not yours to edit);
432
+ Β· and the cells genuinely refuse the edit now (`isMachineWritten`), which the wash never did. */
433
+
434
+ function baseCell(
435
+ field: Field,
436
+ v: CellValue,
437
+ editable: boolean,
438
+ userAvatars?: Record<string, string>
439
+ ): GridCell {
440
+ const ro = { allowOverlay: editable, readonly: !editable };
441
+ const blank = v == null || v === "";
442
+ // β›” W29-T81 β€” A NUMERIC COLUMN HOLDING A NON-NUMBER PAINTS NOTHING, not `0`. `blank` above is
443
+ // emptiness; this is emptiness OR a value no reader would call a number. The import door can
444
+ // now put a spreadsheet's "seventeen-ish" in an `int` column, and `num()` answers 0 for it β€”
445
+ // a fabricated figure on the canvas beside an honest em-dash in the record panel.
446
+ const noNumber = numericIsBlank(v);
447
+
448
+ switch (field.type) {
449
+ case "currency":
450
+ return {
451
+ kind: GridCellKind.Number,
452
+ data: noNumber ? undefined : num(v),
453
+ displayData: noNumber ? "" : "$" + numberText(num(v), field.format),
454
+ contentAlign: "right",
455
+ ...ro,
456
+ };
457
+ case "int":
458
+ return {
459
+ kind: GridCellKind.Number,
460
+ data: noNumber ? undefined : num(v),
461
+ displayData: noNumber ? "" : numberText(num(v), field.format),
462
+ contentAlign: "right",
463
+ ...ro,
464
+ };
465
+ case "formula": {
466
+ // Computed client-side (formulaEngine.ts) over values ALREADY injected at this key by
467
+ // CustomerGrid's computedRows. Read-only by nature β€” the caller passes editable=false.
468
+ // 2026-07-31 (owner item 2): a formula may now return TEXT (CONCATENATE, &, TEXT(),
469
+ // TRUE/FALSE) β€” a non-numeric result renders as a text cell, never as NaN.
470
+ //
471
+ // β›” THE TEST WAS `!Number.isFinite(num(v))` AND IT NEVER FIRED. `num()` returns 0 for
472
+ // anything non-finite, so that read `Number.isFinite(0)` β€” always true β€” and every text
473
+ // formula printed as `0` here and in `formatDisplay`, which held its own copy of the same
474
+ // broken test. `formulaIsText` is now the ONE test, asked of the RAW value, shared by both
475
+ // renderers precisely because two copies is how they drifted. See its note in display.ts.
476
+ // ⚠ `formulaIsBlank`, NOT this function's shared `blank` (which is `v === ""`). A formula
477
+ // returning a SPACE is neither empty by that test nor text by the one below it, so it fell
478
+ // through to the numeric path and painted `0` β€” `Number(" ")` is 0. The two predicates are
479
+ // written to be total over a formula's three states; using only one of them re-opens the
480
+ // gap in miniature.
481
+ const formulaBlank = formulaIsBlank(v);
482
+ if (!formulaBlank && formulaIsText(v)) {
483
+ return {
484
+ kind: GridCellKind.Text,
485
+ data: v,
486
+ displayData: v,
487
+ allowOverlay: false,
488
+ readonly: true,
489
+ };
490
+ }
491
+ const asNum = num(v);
492
+ return {
493
+ kind: GridCellKind.Number,
494
+ data: formulaBlank ? undefined : asNum,
495
+ displayData: formulaBlank ? "" : numberText(asNum, field.format),
496
+ contentAlign: "right",
497
+ allowOverlay: false,
498
+ readonly: true,
499
+ };
500
+ }
501
+ case "pct":
502
+ return {
503
+ kind: GridCellKind.Number,
504
+ data: noNumber ? undefined : num(v),
505
+ displayData: noNumber ? "" : num(v).toFixed(1) + "%",
506
+ contentAlign: "right",
507
+ ...ro,
508
+ };
509
+ /* ⭐ WAVE-26 ITEM 3 β€” `copyData` IS THE STORED STAMP, and it is not decoration.
510
+ MEASURED in the installed glide (`data-editor/copy-paste.js::convertCellToBuffer`): a
511
+ `Text` cell copies as `copyData ?? displayData`, and the text/plain buffer takes that
512
+ FORMATTED string β€” so without this line, copying a date puts the DISPLAY string on the
513
+ clipboard. That was survivable while the display read `8/5/2026` (no space, so
514
+ `new Date()` re-parsed it); item 3 changed it to `Aug 5, 2026`, whose first space
515
+ `parseStamp` turns into `AugT5, 2026` β€” an Invalid Date that renders verbatim forever.
516
+ β›” So a display change silently became a WRITE change, one paste away. The same argument
517
+ `userCellPayload` records for the assignee cell: a value's clipboard identity is its
518
+ STORED form, and leaving it to a fallback makes it depend on which buffer the browser
519
+ hands back (the text/html one carries `gdg-raw-value`, the plain one does not).
520
+ ⚠ `coerceClipboardValue` normalises a pasted display string as the second half of this;
521
+ neither is sufficient alone β€” this one fixes OUR copy, that one fixes Excel's. */
522
+ case "date":
523
+ return {
524
+ kind: GridCellKind.Text,
525
+ data: String(v ?? ""),
526
+ displayData: dateTimeText(field, v),
527
+ copyData: String(v ?? ""),
528
+ ...ro,
529
+ };
530
+ case "created_time":
531
+ return {
532
+ kind: GridCellKind.Text,
533
+ data: String(v ?? ""),
534
+ displayData: dateTimeText(field, v),
535
+ copyData: String(v ?? ""),
536
+ allowOverlay: false,
537
+ readonly: true,
538
+ };
539
+ case "checkbox":
540
+ // glide toggles a non-readonly BooleanCell on click and reports it through
541
+ // onCellEdited β€” no overlay editor involved.
542
+ return {
543
+ kind: GridCellKind.Boolean,
544
+ data: checkboxOn(v),
545
+ allowOverlay: false,
546
+ readonly: !editable,
547
+ };
548
+ case "url":
549
+ // Renders as a link. OPENING is CustomerGrid's click path (scheme-guarded);
550
+ // hoverEffect gives the pointer affordance.
551
+ return {
552
+ kind: GridCellKind.Uri,
553
+ data: String(v ?? ""),
554
+ hoverEffect: true,
555
+ ...ro,
556
+ };
557
+ case "image": {
558
+ // Wave-19 R7 / C5. `allowOverlay:false` for the same reason `select` has it: the value is
559
+ // a REFERENCE that is picked or uploaded, never typed into a cell β€” the record modal owns
560
+ // the picker and the upload. `copyData` carries the raw ref so the column is not invisible
561
+ // in a copy or an export (the lesson the avatar cell booked one wave earlier).
562
+ const ref = String(v ?? "").trim();
563
+ return {
564
+ kind: GridCellKind.Custom,
565
+ data: {
566
+ kind: "aios-image",
567
+ url: ref ? assetUrl(ref, "web") : "",
568
+ ref,
569
+ } satisfies ImageCellData,
570
+ copyData: ref,
571
+ allowOverlay: false,
572
+ };
573
+ }
574
+ case "json": {
575
+ // ⭐ Wave-23 C7 (owner item 5) β€” the compact preview; the DOCUMENT lives in the viewer.
576
+ //
577
+ // `allowOverlay: false` for the same reason `select` and `image` carry it: glide's text
578
+ // overlay is a one-line box, and a one-line box over a 32 KB document is an editor that
579
+ // can only damage the value β€” one keystroke in the wrong place and a well-formed payload
580
+ // becomes unparseable, saved. CustomerGrid opens the viewer on click instead (the same
581
+ // `onCellClicked` path the pickers use), and THAT is where the raw text is editable, with
582
+ // parse-on-save.
583
+ //
584
+ // ⚠ `readonly` is NOT set, and the difference matters: `allowOverlay:false` means "no
585
+ // inline editor", while `readonly` would tell glide the CELL cannot change β€” which would
586
+ // also block the paste path that legitimately writes a whole document into it.
587
+ //
588
+ // ⚠ `copyData` carries the RAW document, never the preview (the lesson the image cell
589
+ // booked in wave 19). Copy a json column and you get the payload; copy the preview and
590
+ // you get the sentence "{…} 5 keys", which is not data and cannot be pasted back.
591
+ const raw = String(v ?? "");
592
+ const text = jsonPreview(raw);
593
+ return {
594
+ kind: GridCellKind.Text,
595
+ data: text,
596
+ displayData: text,
597
+ copyData: raw,
598
+ allowOverlay: false,
599
+ };
600
+ }
601
+ case "code": {
602
+ // ⭐ Wave-27 item 13 (R13) β€” the compact preview; the SNIPPET lives in the editor.
603
+ //
604
+ // Every rule the json cell above states applies here for the same reasons, so this is
605
+ // written the same way rather than differently: `allowOverlay:false` because glide's
606
+ // one-line text overlay over a multi-line snippet is an editor that can only damage the
607
+ // value; `readonly` deliberately NOT set, so the paste path that legitimately writes a
608
+ // whole snippet still works; `copyData` the RAW text, never the preview, or copying a code
609
+ // column would yield "SELECT * FROM … +12 more", which is not data and cannot be pasted
610
+ // back (the lesson the image cell booked in wave 19, and the W26 date-cell repeat of it).
611
+ const raw = String(v ?? "");
612
+ const text = codePreview(raw);
613
+ return {
614
+ kind: GridCellKind.Text,
615
+ data: text,
616
+ displayData: text,
617
+ copyData: raw,
618
+ allowOverlay: false,
619
+ };
620
+ }
621
+ case "ai_enrich": {
622
+ // ⭐⭐ Wave-34 (owner ruling R13) β€” the AI ENRICHMENT cell. The value is an ordinary stored
623
+ // string, so this is a Text cell; what it is NOT is a `formula` (recomputed in the browser)
624
+ // or a `rollup` (read through from another table). Nobody has to be told a number came from
625
+ // somewhere else here, because the answer is words.
626
+ //
627
+ // Written the same way `code` and `json` are, and for their reason rather than a new one:
628
+ // Β· `allowOverlay: false` β€” glide's ONE-LINE overlay editor over multi-line prose is an
629
+ // editor that can only damage the value. The place a person edits an answer is the
630
+ // record drawer, which shows the whole thing.
631
+ // Β· `readonly` deliberately NOT set, so the paste path that legitimately writes a whole
632
+ // answer still works.
633
+ // Β· `copyData` the RAW text, never the preview, or copying this column would yield
634
+ // "Ann is a florist +2 more", which is not data and cannot be pasted back. That is the
635
+ // lesson the image cell booked in wave 19 and the date cell repeated in wave 26.
636
+ //
637
+ // β›” EDITING IS THE POINT, NOT AN AFTERTHOUGHT, which is why the column is NOT in
638
+ // `READONLY_CELL_TYPES` and is NOT in `is_computed_cell` server-side: typing over a cell is
639
+ // what marks it the human's, and `core.user_tables.ai_enrich_human_authored` then protects
640
+ // it from every automatic run forever.
641
+ //
642
+ // ⚠ OWED, AND NAMED RATHER THAN FAKED: this branch cannot paint the agent-written /
643
+ // human-edited / stale / errored STATE, because `makeCell(field, v, editable, avatars)` is
644
+ // handed the value and never the row, and the per-cell mark lives in a stratum the row
645
+ // payload does not carry. The drawer shows the state; the canvas will once the call site in
646
+ // `CustomerGrid.tsx` (lane C's file) passes it. Rendering a guessed state here would be
647
+ // worse than none: "done" on a cell nothing has run for is a claim.
648
+ const raw = String(v ?? "");
649
+ const text = codePreview(raw);
650
+ return {
651
+ kind: GridCellKind.Text,
652
+ data: text,
653
+ displayData: text,
654
+ copyData: raw,
655
+ allowOverlay: false,
656
+ };
657
+ }
658
+ case "rating": {
659
+ const max = ratingMax(field);
660
+ const n = Math.max(0, Math.min(max, Math.round(num(v))));
661
+ return {
662
+ kind: GridCellKind.Custom,
663
+ data: { kind: "aios-rating", value: blank ? 0 : n, max } satisfies RatingCellData,
664
+ copyData: blank ? "" : String(n),
665
+ allowOverlay: false,
666
+ };
667
+ }
668
+ case "automation": {
669
+ // Wave-18 C5-AUTOFIELD. The cell is what the last RUN wrote β€” `ok Β· 2026-08-03 14:10 Β·
670
+ // 12 posts` β€” so it is read-only by NATURE, not by policy: a value typed here would be
671
+ // overwritten by the next run with nothing anywhere saying so. The column's behaviour is
672
+ // configured through its gear, the way a formula's expression is.
673
+ //
674
+ // A tinted CELL rather than a bubble, and that is the whole design: the state and the
675
+ // detail are one sentence, and splitting them into two pills would put "2026-08-03 14:10
676
+ // Β· 12 posts" in a bubble, which is not what a bubble is for. `bgCell` is the one theme
677
+ // key glide alpha-blends (theme.ts:172), so the tints below are the flat pastels rather
678
+ // than anything semi-transparent.
679
+ const text = String(v ?? "");
680
+ return {
681
+ kind: GridCellKind.Text,
682
+ data: text,
683
+ displayData: text,
684
+ allowOverlay: false,
685
+ readonly: true,
686
+ themeOverride: AUTOMATION_TINT[automationState(text)],
687
+ };
688
+ }
689
+ case "link": {
690
+ // ⭐ 2026-08-07 β€” a relation, painted as ONE bubble saying how many rows it reaches.
691
+ //
692
+ // β›” NOT one bubble per linked row, and that is a measured decision rather than a
693
+ // simplification: the cell holds row IDS, and a column of `1,2,3` pills tells a reader
694
+ // nothing β€” the ids are not names. Airtable can paint the linked record's PRIMARY value
695
+ // because it has that row loaded; this client has not loaded the other table at all. So
696
+ // the honest cell is the COUNT, and the record drawer is where the rows themselves belong.
697
+ //
698
+ // ⚠ `copyData` carries the RAW id list, never the "12 posts" sentence β€” the W26 date-cell
699
+ // lesson, where a glide Text cell copies `copyData ?? displayData` and the FORMATTED
700
+ // string silently reached the clipboard and then the paste path. A count is not data and
701
+ // cannot be pasted back into a relation.
702
+ const ids = String(v ?? "").split(",").map((s) => s.trim()).filter((s) => s !== "");
703
+ return {
704
+ kind: GridCellKind.Bubble,
705
+ data: ids.length ? [ids.length === 1 ? "1 record" : `${ids.length} records`] : [],
706
+ copyData: String(v ?? ""),
707
+ allowOverlay: false,
708
+ };
709
+ }
710
+ case "rollup": {
711
+ // ⭐ 2026-08-07 β€” an aggregate the HOST computed. Read-only by nature, exactly like
712
+ // `formula`: the value is arithmetic, and a value box over arithmetic is an editor that
713
+ // can only produce a number the next refresh throws away.
714
+ //
715
+ // ⚠ BLANK STAYS BLANK. `_rollup_fold` returns "" for "no rows to aggregate" and only the
716
+ // count family ever returns a real 0 β€” so painting `0` here for an empty cell would invent
717
+ // the measurement the server just refused to invent. `num(v)` would do exactly that
718
+ // (`num("")` is 0), which is why the raw string is rendered rather than a parsed number.
719
+ // ⭐ 2026-08-10 (owner: *"I want to be able to use commas for numbers so instead of 1000
720
+ // its 1,000"*) β€” A ROLLUP IS FORMATTED LIKE ANY OTHER NUMBER, and it was the only numeric
721
+ // kind that was not. `int` and `currency` have gone through `numberText` since wave 5 and
722
+ // `formula` since it became a real column; a rollup rendered its raw fold string, so an
723
+ // "Avg views" column read `1491552.43` while the `Followers` column beside it read
724
+ // `56,147,007`. Two numeric columns, two dialects, one grid.
725
+ //
726
+ // ⚠ THE BLANK RULE IS PRESERVED EXACTLY, and it is why this is a guarded branch rather
727
+ // than a call. `_rollup_fold` returns "" for "no rows to aggregate" and only the count
728
+ // family ever returns a real 0, so `num(v)` β€” which maps "" to 0 β€” would paint the
729
+ // measurement the server just refused to invent. A blank stays the empty string, and a
730
+ // fold that is not a number at all (`concatenate`, `arrayunique`, `latest` over text)
731
+ // keeps its own text.
732
+ const raw = String(v ?? "");
733
+ const asNum = raw.trim() === "" ? NaN : Number(raw);
734
+ const text = Number.isFinite(asNum) ? numberText(asNum, field.format) : raw;
735
+ return {
736
+ kind: GridCellKind.Text,
737
+ data: text,
738
+ displayData: text,
739
+ // β›” THE CLIPBOARD TAKES THE RAW NUMBER, NOT THE FORMATTED ONE β€” wave 26's measured
740
+ // defect on `date`, where a glide Text cell copies `copyData ?? displayData` and the
741
+ // formatted string reached the clipboard, so pasting `Aug 5, 2026` into a number column
742
+ // stored the words. `1,491,552.43` pastes as text everywhere; `1491552.43` pastes as
743
+ // a number.
744
+ copyData: raw,
745
+ allowOverlay: false,
746
+ readonly: true,
747
+ };
748
+ }
749
+ case "status":
750
+ return {
751
+ kind: GridCellKind.Bubble,
752
+ data: [String(v ?? "")],
753
+ allowOverlay: false,
754
+ themeOverride: STATUS_BUBBLE[String(v ?? "").toLowerCase()],
755
+ };
756
+ case "user": {
757
+ // Wave-14 item 11 / R6 β€” the AVATAR ONLY, no name text. Split out of the `select` branch
758
+ // below (it was a name pill until this wave) because "who owns this" is a face, not a
759
+ // sentence, once a column has thirty rows of it.
760
+ //
761
+ // Still `allowOverlay:false`, for the same reason `select` is: the value is PICKED, never
762
+ // typed, and CustomerGrid opens its anchored picker on click (onCellClicked β†’ isPickType,
763
+ // which already includes `user`, so the click path needs no change).
764
+ //
765
+ // ⚠ `copyData` carries the NAME. The cell shows no text, so without this the column would
766
+ // copy and export as empty β€” invisible in every screenshot. Built by `userCellPayload` in
767
+ // display.ts so a node gate can actually assert that (this module imports glide).
768
+ return {
769
+ kind: GridCellKind.Custom,
770
+ ...userCellPayload(String(v ?? ""), userAvatars),
771
+ allowOverlay: false,
772
+ };
773
+ }
774
+ case "select": {
775
+ // Picked, never typed. `allowOverlay:false` keeps glide's text editor OUT of the way β€”
776
+ // a free-text editor on a constrained field is how a column ends up holding "Done",
777
+ // "done" and "DONE" as three different values. CustomerGrid opens an anchored picker on
778
+ // click instead (onCellClicked), which is also why this stays dependency-free: glide's
779
+ // dropdown cell lives in a separate package we deliberately have not added.
780
+ const s = String(v ?? "");
781
+ const tint = optionTint(field, s);
782
+ return {
783
+ kind: GridCellKind.Bubble,
784
+ data: s ? [s] : [],
785
+ allowOverlay: false,
786
+ themeOverride: tint ? { bgBubble: tint.bg, textBubble: tint.fg } : undefined,
787
+ };
788
+ }
789
+ case "multiselect": {
790
+ // The cell holds a comma-joined SET (the `multi` contract the Cohorts column uses), so
791
+ // every member paints as its own pill. One themeOverride per CELL is all glide offers, so
792
+ // the pills share the first member's tint rather than each carrying its own.
793
+ const parts = String(v ?? "")
794
+ .split(",")
795
+ .map((s) => s.trim())
796
+ .filter((s) => s !== "");
797
+ const tint = parts.length ? optionTint(field, parts[0]) : undefined;
798
+ return {
799
+ kind: GridCellKind.Bubble,
800
+ data: parts,
801
+ allowOverlay: false,
802
+ themeOverride: tint ? { bgBubble: tint.bg, textBubble: tint.fg } : undefined,
803
+ };
804
+ }
805
+ default:
806
+ // text / status-family fallthrough β€” phone and email ride this branch too: typed as
807
+ // text in the grid, rendered as actionable tel:/mailto: links in the record drawer.
808
+ return {
809
+ kind: GridCellKind.Text,
810
+ data: String(v ?? ""),
811
+ displayData: String(v ?? ""),
812
+ ...ro,
813
+ };
814
+ }
815
+ }
web/src/customer-grid/display.ts CHANGED
@@ -1,777 +1,950 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / display.ts
3
- // The pure DISPLAY-STRING half of cells.ts, split out in wave 7 (item W2): the
4
- // export builders run under node in the verify gates, and importing them
5
- // through cells.ts dragged the whole glide-data-grid package into a plain
6
- // script. Everything here depends only on types.ts. cells.ts re-exports these
7
- // names, so every existing import keeps working; the formatting is the same
8
- // bytes it was β€” this is a move, not a change.
9
- // ---------------------------------------------------------------------------
10
-
11
- import type { Field, FieldFormat } from "./types";
12
- import { ratingMax } from "./types";
13
-
14
- export type CellValue = string | number | null | undefined;
15
-
16
- // --- C-AVATAR (wave-14 item 11): the two PURE halves of the assignee avatar ---
17
- // They live here rather than in cells.ts for this module's founding reason: cells.ts imports
18
- // glide, so nothing in it can be reached by a node gate. The canvas drawing stays there; the
19
- // arithmetic and the string handling β€” the parts that can be wrong in ways a screenshot of one
20
- // avatar will not show β€” are here, and `cells.ts` re-exports both.
21
-
22
- /**
23
- * Up to two letters for the fallback circle.
24
- *
25
- * ⚠ The LOCAL PART only. Usernames in this tenant are email-shaped, and splitting
26
- * "fsanyoto@gmail.com" on its separators gives ["fsanyoto@gmail", "com"] β†’ "FC" β€” a person's
27
- * avatar reading as their mail provider. Cutting at "@" first gives "FS".
28
- *
29
- * ⚠ A HYPHEN IS NOT A SEPARATOR. Space, dot and underscore divide a given name from a family
30
- * name in a username; a hyphen almost always JOINS a compound one β€” Jo-Anne, Marie-Claire,
31
- * Al-Rashid. Treating it as a separator turns "jo-anne_smith" into "JA", which is not that
32
- * person's initials and is wrong in the one way nobody can spot: it looks like initials.
33
- */
34
- export function avatarInitials(name: string): string {
35
- const local = name.split("@")[0];
36
- const parts = local.trim().split(/[\s._]+/).filter(Boolean);
37
- if (parts.length === 0) return "?";
38
- if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
39
- return (parts[0][0] + parts[1][0]).toUpperCase();
40
- }
41
-
42
- /**
43
- * The avatar's diameter for a row of `height` px: 22 on a comfortable row, never larger, and
44
- * never so small it stops being a face. Bounded on BOTH sides on purpose β€” a `tall` row (48px)
45
- * must not grow a 42px portrait, and a `short` one (28px) must not shrink to a dot.
46
- */
47
- export function avatarSize(height: number): number {
48
- return Math.max(14, Math.min(22, height - 6));
49
- }
50
-
51
- export interface UserCellData {
52
- kind: "aios-user";
53
- /** The assignee's username. "" = unassigned; the cell then paints nothing at all. */
54
- name: string;
55
- /** C-AVATAR data URL, when the workspace served one for this user. */
56
- photo?: string;
57
- }
58
-
59
- /**
60
- * The `user` cell's payload β€” HERE rather than inline in `cells.ts` for one reason: `copyData`.
61
- *
62
- * ⚠ R6 removed the NAME from the cell, so the cell renders no text at all. glide's copy and
63
- * export take `copyData`, which means an assignee column silently copies and exports as EMPTY
64
- * unless it is set β€” a real regression that appears in no screenshot, because the screenshot
65
- * shows the avatar working perfectly. `cells.ts` imports glide and is therefore unreachable from
66
- * a node gate; this is, so the claim can actually be asserted.
67
- */
68
- export function userCellPayload(
69
- name: string,
70
- userAvatars?: Record<string, string>
71
- ): { data: UserCellData; copyData: string } {
72
- return {
73
- data: { kind: "aios-user", name, photo: userAvatars?.[name] },
74
- copyData: name,
75
- };
76
- }
77
-
78
- /**
79
- * The number to PAINT, with `0` as the fallback every existing caller already relies on.
80
- *
81
- * β›”β›” IT MUST PARSE THE SAME WAY THE FOLD DOES, AND FOR ONE COMMIT IT DID NOT. This was a bare
82
- * `Number(v)`, so `"1,234.50"` β†’ `NaN` β†’ **0**: the canvas painted `$0` while the totals row
83
- * underneath counted 1,234.5. The T81 fix that introduced `numericIsBlank` made the BLANK
84
- * decision consistent and left the VALUE on this function, so the cell stopped being blank and
85
- * started being a fabricated zero β€” the exact defect T81 exists to kill, one step to the right.
86
- * ⚠ Caught by an adversarial read, NOT by the leg named "the fold counts exactly what the cell
87
- * shows" β€” which only ever called `computeAggs` and never evaluated what the cell shows. A test
88
- * whose NAME makes a claim its assertion does not is worse than no test.
89
- * ⚠ REACHABLE, and by more than one path: the import door refuses-without-rewriting (so `$45`
90
- * is storable by design, and `api_api` asserts it imports), and `automation_engine._rollup_fold`
91
- * returns raw source cells as STRINGS for `latest`/`min`/`max`.
92
- */
93
- export function num(v: CellValue): number {
94
- return numericOrUndefined(v) ?? 0;
95
- }
96
-
97
- /**
98
- * β›” W29-T81 β€” HAS THIS NUMERIC CELL NOTHING TO SHOW? Blank, or a stored value that is not a
99
- * number at all.
100
- *
101
- * `num()` above answers **0** for anything unparseable, which is a measurement where there was
102
- * none. That is survivable for a column only this app writes; it stopped being survivable when
103
- * the import door began accepting rows from a spreadsheet β€” a `qty` column holding
104
- * "seventeen-ish" painted `0` on the canvas while the record panel honestly showed an em-dash.
105
- * Two surfaces disagreeing about the same cell, and the one that looks authoritative is the one
106
- * making the number up.
107
- *
108
- * ⚠ The FOLD was already right (`aggregations.numOf` skips what will not parse), so the totals
109
- * row never counted these β€” it is the painted cell alone. Same family as `formulaIsText` below,
110
- * asked of the RAW value for the same reason.
111
- * ⚠ `" "` is blank, not a zero: `Number(" ")` is 0, which is how a whitespace cell becomes a
112
- * number nobody typed.
113
- */
114
- export function numericIsBlank(v: CellValue): boolean {
115
- return numericOrUndefined(v) === undefined;
116
- }
117
-
118
- /**
119
- * β›”β›” THE ONE NUMERIC READING OF A STORED CELL, and it has to be one.
120
- *
121
- * `numericIsBlank` decides whether the CANVAS paints anything; `aggregations.numOf` decides
122
- * whether the TOTALS ROW counts it. They were written separately and normalised differently β€”
123
- * this one trimmed, that one stripped `$`, `,` and spaces β€” so `"1,234.50"` painted BLANK in the
124
- * cell and was COUNTED in the total underneath it. That is T81's own defect ("two surfaces
125
- * disagreeing about one cell") reintroduced in the opposite direction, by the fix for it, and no
126
- * test could see it because every value in the legs was comma-free.
127
- *
128
- * ⚠ Human spellings are ACCEPTED here on purpose. `coerceClipboardValue` canonicalises what the
129
- * UI writes, but the import door refuses-without-rewriting by design, so a `"$45"` posted by curl
130
- * is storable β€” and the honest reading of that cell is 45 on BOTH surfaces, never 45 in the total
131
- * and nothing in the cell ([[one-evaluator-per-question]]).
132
- */
133
- export function numericOrUndefined(v: CellValue): number | undefined {
134
- if (v === null || v === undefined || v === "") return undefined;
135
- if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
136
- const cleaned = String(v).replace(/[$,\s]/g, "");
137
- if (cleaned === "") return undefined; // " " is blank, not the zero `Number("")` gives
138
- const n = Number(cleaned);
139
- return Number.isFinite(n) ? n : undefined;
140
- }
141
-
142
- /**
143
- * β›” IS THIS FORMULA RESULT TEXT? β€” and it must be asked of the RAW value.
144
- *
145
- * A formula may return text since 2026-07-31 (owner item 2: CONCATENATE, `&`, TEXT(),
146
- * TRUE/FALSE, and any `IF(cond, "yes", "no")`). Both renderers tried to detect that with
147
- * `!Number.isFinite(num(v))` β€” and `num()` above returns **0** for anything non-finite, so the
148
- * test was `Number.isFinite(0)`, which is always true. The text branch therefore never ran in
149
- * EITHER renderer, and every text-returning formula printed as `0`: on the canvas, in the list,
150
- * on kanban cards, in the record panel and in all four export formats.
151
- *
152
- * The feature had never worked. It surfaced by rendering the owner's own Buy signal formula
153
- * through the shipped bundle rather than by asking whether the code looked right.
154
- *
155
- * ONE function, exported, called by both `formatDisplay` and `makeCell` β€” they are meant to be
156
- * one rendering, and the way they drifted was each holding its own copy of this test.
157
- * `Number("")` is 0, so the empty case is excluded explicitly rather than relied upon.
158
- *
159
- * A TYPE PREDICATE, not a bare boolean: `makeCell` builds a `TextCell` from the value straight
160
- * after this test, and without the narrowing the caller has to re-assert the string it just
161
- * proved β€” which is the kind of cast that outlives the reason for it.
162
- */
163
- export function formulaIsText(v: CellValue): v is string {
164
- return typeof v === "string" && v.trim() !== "" && !Number.isFinite(Number(v));
165
- }
166
-
167
- /**
168
- * β›” AND THE BLANK TEST HAS TO AGREE WITH IT, or a value falls between the two.
169
- *
170
- * `formulaIsText` excludes whitespace-only strings (a `" "` result is not TEXT worth painting).
171
- * The callers' own blank guard was `v === ""`, which does not catch `" "` β€” so a formula
172
- * returning a space matched NEITHER, fell through to the numeric path, and printed `0`, because
173
- * `Number(" ")` is 0. The exact bug this pair was written to fix, surviving in a narrower case.
174
- *
175
- * A formula's result is therefore one of exactly three things, and these two predicates make the
176
- * three TOTAL: blank (null, empty, or whitespace), text, or a number. Reachable in practice β€”
177
- * `CONCATENATE(" ", "")` is a space, and so is `TRIM()` of one.
178
- */
179
- export function formulaIsBlank(v: CellValue): boolean {
180
- return v == null || (typeof v === "string" && v.trim() === "");
181
- }
182
-
183
- /** A checkbox cell's boolean, out of the overlay's '1'-or-empty contract. */
184
- export function checkboxOn(v: CellValue | boolean): boolean {
185
- return v === "1" || v === 1 || v === true;
186
- }
187
-
188
- /**
189
- * Item 10 β€” the number DISPLAY string. With no format: exactly the pre-wave-5
190
- * rendering (toLocaleString). `abbrev` wins over decimals when the magnitude
191
- * calls for it (34.0M β€” one decimal, k/M/B); `thousands: false` drops the
192
- * separators; `decimals` fixes 0..4 places.
193
- */
194
- export function numberText(v: number, fmt: FieldFormat | undefined): string {
195
- if (fmt?.abbrev && Math.abs(v) >= 1000) {
196
- const abs = Math.abs(v);
197
- const [div, suffix] =
198
- abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : [1e3, "k"];
199
- return (v / div).toFixed(1) + suffix;
200
- }
201
- const d =
202
- fmt && Number.isInteger(fmt.decimals) && (fmt.decimals as number) >= 0 &&
203
- (fmt.decimals as number) <= 4
204
- ? (fmt.decimals as number)
205
- : null;
206
- const thousands = fmt?.thousands !== false;
207
- if (d != null)
208
- return thousands
209
- ? v.toLocaleString(undefined, {
210
- minimumFractionDigits: d,
211
- maximumFractionDigits: d,
212
- })
213
- : v.toFixed(d);
214
- return thousands ? v.toLocaleString() : String(v);
215
- }
216
-
217
- /**
218
- * Parse a stored date/datetime string. Odoo datetimes arrive as
219
- * "YYYY-MM-DD HH:MM:SS" and are UTC by Odoo convention, so a time-carrying
220
- * value with no zone gets a Z; a bare date keeps today's parse (UTC midnight)
221
- * so format-less rendering stays byte-identical to the pre-wave-5 path.
222
- *
223
- * ⭐ EXPORTED in wave 29 (W29-T30) because the xlsx writer needs a Date to turn
224
- * into an Excel serial, and the alternative was a SECOND date parser in
225
- * `export.ts` β€” two evaluators for one question, which is how the two drift and
226
- * a stamp exports as a different day than it renders
227
- * ([[one-evaluator-per-question]]). It stays the only parser in this module.
228
- */
229
- export function parseStamp(raw: string): Date | null {
230
- const s = raw.includes(" ") ? raw.replace(" ", "T") : raw;
231
- const iso =
232
- /T\d{2}:\d{2}/.test(s) && !/(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(s) ? s + "Z" : s;
233
- const d = new Date(iso);
234
- return Number.isNaN(d.getTime()) ? null : d;
235
- }
236
-
237
- /** A BARE calendar day: the shape Odoo sends for a `date`, and the shape wave 26's migration
238
- * converted the Instagram preset stamps into. `parseStamp` gives it UTC midnight. */
239
- const BARE_DAY = /^\d{4}-\d{2}-\d{2}$/;
240
-
241
- /**
242
- * Is this stored value a calendar DAY rather than an INSTANT? β€” the question that decides which
243
- * clock renders it (see `dateTimeText`).
244
- *
245
- * Exported, and it is the whole reason the test for this is worth anything: the alternative is
246
- * asserting a rendered string, which depends on the machine's timezone β€” so the check would pass
247
- * on the owner's box (+07:00, where the two clocks agree on the day) and could only ever go red
248
- * somewhere else. A predicate is the same decision with no ambient state in it.
249
- *
250
- * ⚠ The SHAPE OF THE RAW STRING, never `field.type`: wave 26 re-typed columns that hold full ISO
251
- * stamps to `date`, so one `date` field can carry both shapes at once during a migration.
252
- */
253
- export function isBareDay(v: CellValue): boolean {
254
- return BARE_DAY.test(String(v ?? "").trim());
255
- }
256
-
257
- /**
258
- * ⭐ Wave-26 item 3 (owner, R3) β€” the day, spelled. `Aug 5, 2026`, never `8/5/2026` and never
259
- * the raw stamp. One vocabulary for the date half whether or not a time follows it, so a
260
- * `date` column and a `created_time` column in the same table read alike.
261
- */
262
- const DAY_PARTS = { year: "numeric", month: "short", day: "numeric" } as const;
263
-
264
- /**
265
- * Item 10 β€” the date DISPLAY string. `time` includes the time of day
266
- * (created_time defaults to true β€” a creation stamp without its time reads as
267
- * a duplicate of nothing); `tz: 'utc'` renders the tenant-neutral clock.
268
- * Unparseable input renders VERBATIM rather than "Invalid Date" β€” the raw
269
- * string is at least true.
270
- *
271
- * ⭐ WAVE 26 ITEM 3 (owner, via R3). The complaint was literal: cells read
272
- * `2026-08-05T14:03:11+07:00`. SESSION A's half re-typed the Instagram preset
273
- * columns from `text` to `date` (which is what routed them here at all) and
274
- * migrated the stored cells to bare `YYYY-MM-DD`; this half is that a date must
275
- * READ as one. Two changes, and the second is the one that is not cosmetic:
276
- *
277
- * β›” **A BARE `YYYY-MM-DD` IS A CALENDAR DAY, NOT AN INSTANT, so it renders in
278
- * UTC.** `parseStamp` gives a bare date UTC midnight, so rendering it in the
279
- * viewer's local zone shows the PREVIOUS DAY to everyone west of Greenwich β€” a
280
- * value stored as the 5th reading as the 4th, silently, for a whole hemisphere.
281
- * `ui/fmt.ts:date()` already refused to inherit this and its comment names this
282
- * function as the holdout; it is no longer one. The tenant runs at +07:00, where
283
- * the two agree, which is exactly why it could sit here unnoticed β€” and why the
284
- * US market in COMMERCIALIZATION_PLAN C1b would have met it first.
285
- *
286
- * A value that CARRIES a time is a real instant and keeps the existing zone
287
- * behaviour (local, or UTC when the field says so): shifting those to UTC would
288
- * introduce the same off-by-a-day from the other direction.
289
- */
290
- export function dateTimeText(
291
- // ⚠ STRUCTURAL, not `Field`: this reads exactly two members, and item 8's post rows hold a
292
- // raw `posted_at` with no column behind it. Widening the parameter (rather than fabricating a
293
- // fake Field at the call site) is what lets the posts list render its days through the SAME
294
- // formatter as a date cell instead of growing a second one. Every existing caller passes a
295
- // `Field`, which is assignable.
296
- field: { type: Field["type"]; format?: FieldFormat },
297
- v: CellValue
298
- ): string {
299
- if (v == null || v === "") return "";
300
- const raw = String(v);
301
- const d = parseStamp(raw);
302
- if (!d) return raw;
303
- const fmt = field.format;
304
- const withTime = fmt?.time ?? field.type === "created_time";
305
- const utc = fmt?.tz === "utc" || isBareDay(raw);
306
- const zone = utc ? { timeZone: "UTC" as const } : undefined;
307
- return withTime
308
- ? d.toLocaleString(undefined, {
309
- ...DAY_PARTS,
310
- hour: "numeric",
311
- minute: "2-digit",
312
- second: "2-digit",
313
- ...zone,
314
- })
315
- : d.toLocaleDateString(undefined, { ...DAY_PARTS, ...zone });
316
- }
317
-
318
- /**
319
- * The plain-string rendering of one (field, value), mirroring makeCell's
320
- * displayData. Reused by the record-detail panel and the W2 export builders so
321
- * a value reads identically on the canvas, in the panel and in a file. Returns
322
- * "" for empty so callers can substitute their own placeholder.
323
- */
324
- /**
325
- * Wave-18 C5-AUTOFIELD β€” the STATE word of an automation cell.
326
- *
327
- * An automation cell holds one machine-written line: `state Β· when Β· detail`, e.g.
328
- * `ok Β· 2026-08-03 14:10 Β· 12 posts`. The state is the first token, and every surface that
329
- * paints the cell β€” canvas tint, record modal, the rail β€” reads it through HERE rather than
330
- * re-splitting the string, because two parsers of one format is how the canvas and the panel
331
- * end up disagreeing about whether a run succeeded (the exact way `formula` broke above).
332
- *
333
- * `"none"` is the honest answer for a cell nothing has written yet β€” NOT "ok". A column that
334
- * has never run must not look like a column that ran and found nothing.
335
- */
336
- export type AutomationState = "ok" | "partial" | "error" | "blocked" | "queued" | "none";
337
-
338
- const AUTOMATION_STATES: AutomationState[] = ["ok", "partial", "error", "blocked", "queued"];
339
-
340
- export function automationState(v: CellValue): AutomationState {
341
- const head = String(v ?? "").split("Β·")[0].trim().toLowerCase();
342
- return (AUTOMATION_STATES as string[]).includes(head)
343
- ? (head as AutomationState)
344
- : "none";
345
- }
346
-
347
- /** Everything after the state word β€” the timestamp and the run's own detail. */
348
- export function automationDetail(v: CellValue): string {
349
- const parts = String(v ?? "").split("Β·");
350
- return parts.length > 1 ? parts.slice(1).join("Β·").trim() : "";
351
- }
352
-
353
- /** Sentence-cased state, for anywhere a word reads better than a token. */
354
- export function automationStateLabel(v: CellValue): string {
355
- const s = automationState(v);
356
- return s === "none" ? "Not run yet" : s[0].toUpperCase() + s.slice(1);
357
- }
358
-
359
- /**
360
- * ⭐ Wave-23 C7 (owner item 5) β€” THE JSON PREVIEW: what a 200px cell says about a document.
361
- *
362
- * `MAX_JSON_BYTES` is the contract's ceiling, mirrored from the host's write validation so the
363
- * viewer can refuse a paste with the same number the server would (a client that lets you type
364
- * 40 KB and then shows you a server refusal has wasted the edit).
365
- *
366
- * The four cases, and each one is a decision rather than a formatting preference:
367
- * Β· **a single-pair object shows THE PAIR.** `{handle: "royalimports"}` is more useful than
368
- * "1 key" and it is the shape most machine writes actually have. Past one pair the pairs
369
- * stop fitting and the honest answer is the count.
370
- * Β· **many keys / many items β†’ `{…} N keys` / `[…] N items`.** Showing the FIRST pair of a
371
- * twelve-key object would let a reader take one arbitrary value β€” whichever key the writer's
372
- * serializer happened to emit first β€” for the cell's content.
373
- * Β· **a bare scalar renders as itself.** `12`, `"ok"`, `true` and `null` are all valid JSON
374
- * documents, and wrapping them in braces would describe a shape they do not have.
375
- * Β· β›” **text that does not parse renders AS ITSELF, never as a shape.** The host validates on
376
- * write, so this only happens to a value that predates the validation or arrived another
377
- * way β€” and the one thing the preview must never do is claim a document is well-formed. The
378
- * viewer's raw tab is where such a value gets read and repaired.
379
- *
380
- * Blank stays blank: an empty json cell is a document nobody has written, and `{}` is a document
381
- * somebody wrote that is empty. Two different facts, two different cells.
382
- */
383
- export const MAX_JSON_BYTES = 32 * 1024;
384
-
385
- /**
386
- * ⭐ WAVE 31 Β· T22 (D-173) β€” is this parsed value the SERVER'S STAND-IN for a document it did not
387
- * send, and if so how big was the real one? Returns a short size string, or null.
388
- *
389
- * β›” THE SHAPE IS `routes_tables._thin_json`'s OWN, and it is matched on `_truncated === true`
390
- * plus a numeric `bytes` β€” never on the key alone, because a person's own document could contain
391
- * a `_truncated` key and must not be reported as absent. `_url` is deliberately NOT required: the
392
- * viewer keys its fetch on it, but a preview that refused to warn when it was missing would go
393
- * quiet in exactly the degraded case that most needs a warning.
394
- */
395
- export function truncatedDoc(value: unknown): string | null {
396
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
397
- const v = value as Record<string, unknown>;
398
- if (v._truncated !== true) return null;
399
- const bytes = typeof v.bytes === "number" && isFinite(v.bytes) ? Math.max(0, v.bytes) : 0;
400
- if (!bytes) return "not shown";
401
- return bytes >= 1024 ? `${Math.round(bytes / 1024).toLocaleString()} KB not shown`
402
- : `${bytes.toLocaleString()} bytes not shown`;
403
- }
404
-
405
- /** Does this text parse as JSON? The ONE test, shared by the preview, the cell and the viewer's
406
- * save β€” three copies of a try/catch is how they end up disagreeing about `""` or `NaN`. */
407
- export function jsonParse(v: CellValue): { ok: boolean; value?: unknown } {
408
- const s = String(v ?? "").trim();
409
- if (s === "") return { ok: false };
410
- try {
411
- return { ok: true, value: JSON.parse(s) as unknown };
412
- } catch {
413
- return { ok: false };
414
- }
415
- }
416
-
417
- /**
418
- * ⭐ WAVE-27 item 13 (R13) β€” one compact line for a CODE cell.
419
- *
420
- * Deliberately NOT `jsonPreview`, and the difference is the whole reason `code` is its own kind:
421
- * a json preview SUMMARISES a parsed document ("{…} 5 keys"), which it can only do because the
422
- * value is guaranteed to parse. A snippet has no structure to summarise and often does not parse
423
- * at all β€” half-written SQL is the normal state of one β€” so the honest preview is the first
424
- * non-blank LINE, clipped, plus the line count when there is more underneath. That way the cell
425
- * says what the snippet starts with AND that there is more, instead of inventing a shape for it.
426
- */
427
- export function codePreview(v: CellValue): string {
428
- const raw = String(v ?? "");
429
- if (raw.trim() === "") return "";
430
- const lines = raw.split("\n");
431
- const firstIdx = lines.findIndex((l) => l.trim() !== "");
432
- const first = clip((lines[firstIdx < 0 ? 0 : firstIdx] ?? "").trim(), 60);
433
- // The count is of REAL lines, so a snippet padded with blank lines does not claim depth it
434
- // does not have β€” and it is checkable against what opening the editor shows
435
- // ([[no-unverifiable-aggregates]]).
436
- const n = lines.filter((l) => l.trim() !== "").length;
437
- return n > 1 ? `${first} +${n - 1} more` : first;
438
- }
439
-
440
- /**
441
- * ⭐⭐ W37-T14 / T15 β€” THE TWO SHAPES WAVE 37 ADDED, READ AS WHAT THEY ARE.
442
- *
443
- * β›” FOUND BY QA ON THE DEPLOYED BUILD, not by a gate: `1.5BOPP`'s Units cell rendered
444
- * **`[…] 2 items`** and its Tier prices cell the same way, because both are `type: "json"` and
445
- * both fell through to the generic array branch below. The server was serving the right data the
446
- * whole time β€” `[{name:"SLEEVE",qty:12},{name:"Master",qty:144}]` β€” and the grid said nothing
447
- * about it. A column whose entire job is to show which units a SKU sells in, showing a count of
448
- * items, is the [[permitted-is-not-answerable]] shape in the RENDER layer.
449
- *
450
- * ⚠ KEYED ON THE SHAPE, NEVER ON THE COLUMN KEY. A key test (`field.key === "units"`) would
451
- * miss the same document on a user database, on a rollup, or under any rename β€” and this file
452
- * has no access to the field anyway. Both shapes are exact: EVERY element must be an object
453
- * carrying exactly the pair, so an unrelated array of objects still falls through to the generic
454
- * branch and is not silently relabelled.
455
- *
456
- * ⚠ `Γ—` is U+00D7 MULTIPLICATION SIGN and `Β·` is U+00B7 MIDDLE DOT β€” neither is an em or en
457
- * dash, so this copy is inside standing rule 2 rather than an exception to it.
458
- */
459
- function namedPairList(value: unknown): string | null {
460
- if (!Array.isArray(value) || value.length === 0) return null;
461
- const rows = value as Record<string, unknown>[];
462
- const every = (a: string, b: string) =>
463
- rows.every(
464
- (r) =>
465
- r !== null &&
466
- typeof r === "object" &&
467
- !Array.isArray(r) &&
468
- typeof r[a] === "string" &&
469
- typeof r[b] === "number",
470
- );
471
- // W37-T15 β€” units of measure: the NAME and its conversion factor, which is the whole point.
472
- if (every("name", "qty"))
473
- return rows.map((r) => `${String(r.name)} Γ—${trimNum(r.qty as number)}`).join(" Β· ");
474
- // W37-T14 β€” the pricelists that really price this SKU today, each with its price.
475
- if (every("pricelist", "unit_price"))
476
- return rows
477
- .map((r) => `${String(r.pricelist)} $${numberText(r.unit_price as number, { decimals: 2 })}`)
478
- .join(" Β· ");
479
- return null;
480
- }
481
-
482
- /** `12` not `12.0`, and `1.5` kept β€” a conversion factor is a count far more often than not. */
483
- function trimNum(n: number): string {
484
- return Number.isFinite(n) ? String(Number(n.toFixed(4))) : String(n);
485
- }
486
-
487
- /** One compact line for a json cell. See the note above for why each case reads as it does. */
488
- export function jsonPreview(v: CellValue): string {
489
- const raw = String(v ?? "").trim();
490
- if (raw === "") return "";
491
- const parsed = jsonParse(raw);
492
- // ⚠ First line only, and clipped: an unparseable value is often a whole pasted response, and
493
- // a cell is not where a 4 KB blob gets read. It is shown rather than hidden because the
494
- // reader has to be able to see that the column holds something the app could not open.
495
- if (!parsed.ok) return clip(raw.split("\n")[0], 60);
496
- const value = parsed.value;
497
- // ⭐ ITEM 8 β€” a posts window says what it holds. Before wave 26 this document fell through to
498
- // the generic object branch and every creator's `posts` cell read `{…} 4 keys`, which is true
499
- // about JSON and says nothing about the record.
500
- // ⚠ The ARRAY LENGTH, never the document's own `n`: the count in a cell has to be checkable
501
- // against what opening it shows ([[no-unverifiable-aggregates]]).
502
- // ⭐⭐ WAVE 31 Β· T22 (D-173) β€” A DOCUMENT THE LIST DID NOT SEND SAYS SO, WITH ITS SIZE.
503
- //
504
- // β›” THE SWALLOW, and it is one branch below this one. `routes_tables._thin_json` replaces an
505
- // oversized `json` cell with a stand-in β€” `{_truncated, bytes, _url}` β€” so the big document
506
- // does not ride a list response (measured: `source_payload` was 95.6–98.5% of every IG grid's
507
- // bytes). That stand-in is itself valid JSON with three keys, so it fell through to the generic
508
- // object branch and the cell read **`{…} 3 keys`** β€” a confident, checkable-looking claim about
509
- // a document that is not there, and indistinguishable from a real three-key document. R6's
510
- // second sentence applies to our own wire: a value we declined to send is reported, never
511
- // disguised.
512
- // ⚠ THE SIZE IS THE SERVER'S OWN `bytes`, not a guess, and it is what makes the cell honest β€”
513
- // "this column holds 41 KB you have not been shown" is a different fact from "3 keys".
514
- const thinned = truncatedDoc(value);
515
- if (thinned) return `{…} ${thinned}`;
516
- const shown = postsWindowOf(value);
517
- if (shown)
518
- return shown.posts.length === 0
519
- ? "No posts"
520
- : `${shown.posts.length} post${shown.posts.length === 1 ? "" : "s"}`;
521
- // ⭐ W37-T14/T15 β€” the two known shapes read as themselves; anything else keeps the old count.
522
- const pairs = namedPairList(value);
523
- if (pairs) return clip(pairs, 60);
524
- if (Array.isArray(value))
525
- return value.length === 0 ? "[]" : `[…] ${value.length} item${value.length === 1 ? "" : "s"}`;
526
- if (value !== null && typeof value === "object") {
527
- const keys = Object.keys(value as Record<string, unknown>);
528
- if (keys.length === 0) return "{}";
529
- if (keys.length === 1)
530
- return clip(`{${keys[0]}: ${scalarText((value as Record<string, unknown>)[keys[0]])}}`, 60);
531
- return `{…} ${keys.length} keys`;
532
- }
533
- return clip(scalarText(value), 60);
534
- }
535
-
536
- /** The day, spelled, for a caller holding a raw value and no column β€” the posts list's
537
- * `posted_at`. Routed through `dateTimeText` so it can never drift from a date CELL. */
538
- export function dayText(v: CellValue): string {
539
- return dateTimeText({ type: "date" }, v);
540
- }
541
-
542
- /* ═══ ⭐ WAVE 26 ITEM 8 (contract C2) β€” THE `posts` WINDOW ═══
543
- The preset `posts` cell holds ONE object written by the engine's `posts_window`:
544
-
545
- { n, metrics, as_of, posts: [{ shortcode, url, posted_at, type, caption,
546
- views?, likes?, comments? }] }
547
-
548
- Two laws come with it and both are enforced below rather than in the components:
549
-
550
- β›” `metrics: false` β‡’ views/likes/comments are ABSENT KEYS, NEVER 0. A reader must render an
551
- absent metric as NOTHING. Coercing it to a number here β€” `Number(p.views) || 0` is the
552
- natural thing to type β€” would fabricate a measurement, which is the engine's own
553
- blank-never-zero law broken at the display seam, silently and plausibly (a creator with no
554
- metrics bought would read as a creator with no engagement).
555
-
556
- β›” IT IS A WINDOW, NOT THE HISTORY. `ut_ig_posts` / `ut_ig_post_snapshots` accumulate; this
557
- cell is the last N (R1/R3 β€” "one store for one series"). So nothing here may present the
558
- cell as a total.
559
-
560
- ⚠ DETECTED BY SHAPE, never by the field's key. A user may name a column `posts`, and the
561
- preset key is not a contract the renderer can see from the value alone. An object carrying a
562
- `posts` ARRAY OF OBJECTS is what makes "3 posts" a true sentence about it, whoever wrote it.
563
- ═══ */
564
- export interface PostSummary {
565
- shortcode?: string;
566
- url?: string;
567
- posted_at?: string;
568
- type?: string;
569
- caption?: string;
570
- /** β›” `undefined` when the metric was not bought. Never 0 β€” see the note above. */
571
- views?: number;
572
- likes?: number;
573
- comments?: number;
574
- }
575
-
576
- export interface PostsWindow {
577
- /** The engine's own count. May differ from `posts.length`; readers show the LENGTH, because
578
- * that is the number a reader can check against what is in front of them. */
579
- n: number;
580
- metrics: boolean;
581
- as_of?: string;
582
- posts: PostSummary[];
583
- }
584
-
585
- function str(v: unknown): string | undefined {
586
- return typeof v === "string" && v !== "" ? v : undefined;
587
- }
588
-
589
- /** β›” A number ONLY when the key really holds one. `undefined` for absent, for null, and for a
590
- * non-numeric β€” anything else invents a measurement. */
591
- function metric(v: unknown): number | undefined {
592
- return typeof v === "number" && Number.isFinite(v) ? v : undefined;
593
- }
594
-
595
- /** The window behind an ALREADY-PARSED value, or null when this is not one. */
596
- export function postsWindowOf(value: unknown): PostsWindow | null {
597
- if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
598
- const doc = value as Record<string, unknown>;
599
- const rows = doc.posts;
600
- if (!Array.isArray(rows)) return null;
601
- if (!rows.every((r) => r !== null && typeof r === "object" && !Array.isArray(r))) return null;
602
- const posts: PostSummary[] = rows.map((r) => {
603
- const p = r as Record<string, unknown>;
604
- return {
605
- shortcode: str(p.shortcode),
606
- url: str(p.url),
607
- posted_at: str(p.posted_at),
608
- type: str(p.type),
609
- caption: str(p.caption),
610
- views: metric(p.views),
611
- likes: metric(p.likes),
612
- comments: metric(p.comments),
613
- };
614
- });
615
- return {
616
- n: typeof doc.n === "number" ? doc.n : posts.length,
617
- metrics: doc.metrics === true,
618
- as_of: str(doc.as_of),
619
- posts,
620
- };
621
- }
622
-
623
- /** The window behind a stored CELL, or null. */
624
- export function postsWindow(v: CellValue): PostsWindow | null {
625
- const parsed = jsonParse(v);
626
- return parsed.ok ? postsWindowOf(parsed.value) : null;
627
- }
628
-
629
- /** A nested value, small enough to sit inside a one-pair preview. Objects and arrays collapse
630
- * to their own marks rather than recursing β€” a preview that unfolds is not a preview. */
631
- function scalarText(v: unknown): string {
632
- if (v === null) return "null";
633
- if (Array.isArray(v)) return `[…] ${v.length}`;
634
- if (typeof v === "object") return `{…} ${Object.keys(v as object).length}`;
635
- return typeof v === "string" ? v : String(v);
636
- }
637
-
638
- /** ⚠ An ellipsis CHARACTER, not three dots: the grid's canvas measures text and three periods
639
- * are three glyphs wide. Same mark the group-bar fitter uses. */
640
- function clip(s: string, n: number): string {
641
- return s.length <= n ? s : s.slice(0, n - 1) + "…";
642
- }
643
-
644
- /**
645
- * The document, indented for the viewer's pretty tab. Returns the RAW TEXT UNCHANGED when it
646
- * does not parse β€” re-indenting is not repair, and handing a reader a "prettified" version of
647
- * something the app could not read would hide the only thing they need to see.
648
- */
649
- export function jsonPretty(v: CellValue): string {
650
- const raw = String(v ?? "");
651
- const parsed = jsonParse(raw);
652
- return parsed.ok ? JSON.stringify(parsed.value, null, 2) : raw;
653
- }
654
-
655
- /**
656
- * Wave-5 item 11 β€” the actionable link behind a url/email/phone value, or null when the value
657
- * does not parse as one (the raw text still shows; a link that goes nowhere is worse than no
658
- * link). url without a scheme gets https://; `javascript:` can never come out of here.
659
- *
660
- * ⭐ MOVED here from `RecordDetail.tsx` for wave-26 item 11, when the KANBAN CARD became a
661
- * second surface that needs it. Two copies of a scheme guard is how one of them ends up
662
- * accepting `javascript:` β€” the same argument that moved `formatDisplay`'s formula test into one
663
- * function after the canvas and the panel had drifted. It is also the only reason this claim is
664
- * testable at all: both call sites are React components, and this module is glide-free and
665
- * React-free, so a node gate can reach it.
666
- */
667
- export function actionHref(field: Field, v: CellValue): string | null {
668
- const s = String(v ?? "").trim();
669
- if (!s) return null;
670
- if (field.type === "url") {
671
- if (/^https?:\/\//i.test(s)) return s;
672
- if (/^[\w-]+(\.[\w-]+)+/.test(s)) return `https://${s}`;
673
- return null;
674
- }
675
- if (field.type === "email")
676
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) ? `mailto:${s}` : null;
677
- if (field.type === "phone") {
678
- const digits = s.replace(/[\s().-]/g, "");
679
- return /^\+?\d{5,}$/.test(digits) ? `tel:${digits}` : null;
680
- }
681
- return null;
682
- }
683
-
684
- export function formatDisplay(field: Field, v: CellValue): string {
685
- switch (field.type) {
686
- case "currency":
687
- return v == null || v === "" ? "" : "$" + numberText(num(v), field.format);
688
- case "formula": {
689
- // β›” A FORMULA MAY RETURN TEXT (owner item 2, 2026-07-31 β€” CONCATENATE, `&`, TEXT(),
690
- // and any `IF(cond, "yes", "no")`). `makeCell` learned that; this function did not, and
691
- // the two are supposed to be one rendering. So every surface that reads THIS one β€”
692
- // ListView, KanbanView, the calendar's summary cells, the record-detail panel and ALL
693
- // FOUR export formats β€” printed a text result as `0`, because `num("Buy now")` is NaN.
694
- // The canvas showed the words and the file showed a zero, for the same cell.
695
- //
696
- // Found by rendering the owner's own Buy signal formula through the shipped bundle
697
- // (_qa_owner_20260803). The branch below is `makeCell`'s test, verbatim: a non-blank,
698
- // non-numeric STRING is its own display.
699
- // The three states, in the one order that makes them total β€” see `formulaIsBlank`.
700
- if (formulaIsBlank(v)) return "";
701
- if (formulaIsText(v)) return v;
702
- return numberText(num(v), field.format);
703
- }
704
- case "int":
705
- return v == null || v === "" ? "" : numberText(num(v), field.format);
706
- case "pct":
707
- return v == null || v === "" ? "" : num(v).toFixed(1) + "%";
708
- case "date":
709
- case "created_time":
710
- return dateTimeText(field, v);
711
- case "checkbox":
712
- return checkboxOn(v) ? "Checked" : "";
713
- case "rating": {
714
- const n = num(v);
715
- return n >= 1 ? `${Math.round(n)} of ${ratingMax(field)}` : "";
716
- }
717
- case "json":
718
- // Wave-23 C7 β€” the SAME compact line the canvas cell paints. Explicit here rather than
719
- // left to `default` for the reason the `formula` note above records: this function feeds
720
- // ListView, KanbanView, the calendar, the record panel and all four EXPORT formats, and
721
- // falling through would dump a whole 32 KB document into a CSV cell.
722
- return jsonPreview(v);
723
- case "code":
724
- // ⭐ Wave-27 item 13 (R13) β€” the same compact line the canvas cell paints, and explicit
725
- // for `json`'s exact reason: a snippet is multi-LINE, and falling through to `default`
726
- // would put raw newlines into a CSV cell, a kanban card and a calendar chip.
727
- return codePreview(v);
728
- case "ai_enrich":
729
- // ⭐⭐ Wave-34 (owner ruling R13) β€” an enrichment answer is model-authored PROSE and can run
730
- // to several lines, so it is explicit here for exactly the reason `json` and `code` are:
731
- // this function feeds ListView, KanbanView, the calendar, the record panel and all four
732
- // EXPORT formats, and `default` would put raw newlines into a CSV cell.
733
- // ⚠ `codePreview` is REUSED rather than given a prose-flavoured twin. Its "first line
734
- // +N more" is already this product's one way of saying "a multi-line value in a one-line
735
- // slot", and the count is checkable against what the drawer shows; a second helper would
736
- // be a second answer to one question ([[one-question-two-normalizers]]).
737
- return codePreview(v);
738
- case "automation":
739
- // The machine-written line, verbatim. Explicit rather than left to the `default` branch
740
- // below: `formula` fell through a default once and printed every text result as `0` for
741
- // months, and the lesson recorded there is that a type whose rendering is deliberate
742
- // should SAY so where a reader looks for it.
743
- return String(v ?? "");
744
- case "metric":
745
- // Wave-22 C7 β€” a server-computed number over the master snapshot series. BLANK IS A
746
- // STATE, never zero: the engine sends "" when the window holds no snapshots, and
747
- // rendering that as 0 would fabricate a measurement (the engine's own blank-never-zero
748
- // law, kept at the display seam too).
749
- return v == null || v === "" ? "" : numberText(num(v), field.format);
750
- case "rollup": {
751
- // ⭐ WAVE 29 (W29-T30) β€” THE MISSING CASE. `rollup` fell through to `default` and printed
752
- // its raw fold string, so ONE cell rendered two ways: the canvas showed `1,491,552.43`
753
- // (`cells.ts`'s rollup branch, comma-fixed 2026-08-10 on the owner's instruction) while
754
- // every CSV, PDF, JSON-display and Excel export showed `1491552.43`. Same family as D-121
755
- // (the record panel ignoring a field's format) β€” three surfaces, one concept.
756
- //
757
- // β›” THE BLANK RULE IS THE POINT, and it is why this is a guarded branch and not a call to
758
- // `numberText(num(v), ...)`. `_rollup_fold` returns "" for "no rows to aggregate" and only
759
- // the count family ever returns a real 0, so `num("")` β€” which is 0 β€” would paint the
760
- // measurement the server just refused to invent. A fold that is not a number at all
761
- // (`concatenate`, `arrayunique`, `latest` over text) keeps its own text.
762
- // β‡’ Mirrors `cells.ts`'s branch line for line, deliberately: one concept, one rendering.
763
- const raw = String(v ?? "");
764
- const asNum = raw.trim() === "" ? NaN : Number(raw);
765
- return Number.isFinite(asNum) ? numberText(asNum, field.format) : raw;
766
- }
767
- case "multiselect":
768
- // The comma-joined SET, read back with breathing room ("A, B" not "A,B").
769
- return String(v ?? "")
770
- .split(",")
771
- .map((s) => s.trim())
772
- .filter((s) => s !== "")
773
- .join(", ");
774
- default:
775
- return String(v ?? "");
776
- }
777
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / display.ts
3
+ // The pure DISPLAY-STRING half of cells.ts, split out in wave 7 (item W2): the
4
+ // export builders run under node in the verify gates, and importing them
5
+ // through cells.ts dragged the whole glide-data-grid package into a plain
6
+ // script. Everything here depends only on types.ts. cells.ts re-exports these
7
+ // names, so every existing import keeps working; the formatting is the same
8
+ // bytes it was β€” this is a move, not a change.
9
+ //
10
+ // ⚠ W40-T24 β€” `columnDefinitionOffer` at the foot of this file is NOT a display
11
+ // string, and it is here on purpose rather than by drift. It is a decision the
12
+ // Edit-field pane takes, and the pane lives in `ColumnMenu.tsx`, which imports
13
+ // React and therefore cannot be LOADED by the node gates β€” a rule stated only
14
+ // inside JSX is a rule no gate can run. Its natural home is `types.ts` beside
15
+ // the two predicates it composes; that file belongs to another lane this wave,
16
+ // so it lives in the nearest module that is both node-reachable and already
17
+ // imported by `_test/gridUx.test.ts`. If types.ts is ever free, move it there
18
+ // and leave a re-export: what must not happen is a second copy.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import type { Field, FieldFormat } from "./types";
22
+ import { isUserSchemaField, ratingMax } from "./types";
23
+
24
+ export type CellValue = string | number | null | undefined;
25
+
26
+ // --- C-AVATAR (wave-14 item 11): the two PURE halves of the assignee avatar ---
27
+ // They live here rather than in cells.ts for this module's founding reason: cells.ts imports
28
+ // glide, so nothing in it can be reached by a node gate. The canvas drawing stays there; the
29
+ // arithmetic and the string handling β€” the parts that can be wrong in ways a screenshot of one
30
+ // avatar will not show β€” are here, and `cells.ts` re-exports both.
31
+
32
+ /**
33
+ * Up to two letters for the fallback circle.
34
+ *
35
+ * ⚠ The LOCAL PART only. Usernames in this tenant are email-shaped, and splitting
36
+ * "fsanyoto@gmail.com" on its separators gives ["fsanyoto@gmail", "com"] β†’ "FC" β€” a person's
37
+ * avatar reading as their mail provider. Cutting at "@" first gives "FS".
38
+ *
39
+ * ⚠ A HYPHEN IS NOT A SEPARATOR. Space, dot and underscore divide a given name from a family
40
+ * name in a username; a hyphen almost always JOINS a compound one β€” Jo-Anne, Marie-Claire,
41
+ * Al-Rashid. Treating it as a separator turns "jo-anne_smith" into "JA", which is not that
42
+ * person's initials and is wrong in the one way nobody can spot: it looks like initials.
43
+ */
44
+ export function avatarInitials(name: string): string {
45
+ const local = name.split("@")[0];
46
+ const parts = local.trim().split(/[\s._]+/).filter(Boolean);
47
+ if (parts.length === 0) return "?";
48
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
49
+ return (parts[0][0] + parts[1][0]).toUpperCase();
50
+ }
51
+
52
+ /**
53
+ * The avatar's diameter for a row of `height` px: 22 on a comfortable row, never larger, and
54
+ * never so small it stops being a face. Bounded on BOTH sides on purpose β€” a `tall` row (48px)
55
+ * must not grow a 42px portrait, and a `short` one (28px) must not shrink to a dot.
56
+ */
57
+ export function avatarSize(height: number): number {
58
+ return Math.max(14, Math.min(22, height - 6));
59
+ }
60
+
61
+ export interface UserCellData {
62
+ kind: "aios-user";
63
+ /** The assignee's username. "" = unassigned; the cell then paints nothing at all. */
64
+ name: string;
65
+ /** C-AVATAR data URL, when the workspace served one for this user. */
66
+ photo?: string;
67
+ }
68
+
69
+ /**
70
+ * The `user` cell's payload β€” HERE rather than inline in `cells.ts` for one reason: `copyData`.
71
+ *
72
+ * ⚠ R6 removed the NAME from the cell, so the cell renders no text at all. glide's copy and
73
+ * export take `copyData`, which means an assignee column silently copies and exports as EMPTY
74
+ * unless it is set β€” a real regression that appears in no screenshot, because the screenshot
75
+ * shows the avatar working perfectly. `cells.ts` imports glide and is therefore unreachable from
76
+ * a node gate; this is, so the claim can actually be asserted.
77
+ */
78
+ export function userCellPayload(
79
+ name: string,
80
+ userAvatars?: Record<string, string>
81
+ ): { data: UserCellData; copyData: string } {
82
+ return {
83
+ data: { kind: "aios-user", name, photo: userAvatars?.[name] },
84
+ copyData: name,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The number to PAINT, with `0` as the fallback every existing caller already relies on.
90
+ *
91
+ * β›”β›” IT MUST PARSE THE SAME WAY THE FOLD DOES, AND FOR ONE COMMIT IT DID NOT. This was a bare
92
+ * `Number(v)`, so `"1,234.50"` β†’ `NaN` β†’ **0**: the canvas painted `$0` while the totals row
93
+ * underneath counted 1,234.5. The T81 fix that introduced `numericIsBlank` made the BLANK
94
+ * decision consistent and left the VALUE on this function, so the cell stopped being blank and
95
+ * started being a fabricated zero β€” the exact defect T81 exists to kill, one step to the right.
96
+ * ⚠ Caught by an adversarial read, NOT by the leg named "the fold counts exactly what the cell
97
+ * shows" β€” which only ever called `computeAggs` and never evaluated what the cell shows. A test
98
+ * whose NAME makes a claim its assertion does not is worse than no test.
99
+ * ⚠ REACHABLE, and by more than one path: the import door refuses-without-rewriting (so `$45`
100
+ * is storable by design, and `api_api` asserts it imports), and `automation_engine._rollup_fold`
101
+ * returns raw source cells as STRINGS for `latest`/`min`/`max`.
102
+ */
103
+ export function num(v: CellValue): number {
104
+ return numericOrUndefined(v) ?? 0;
105
+ }
106
+
107
+ /**
108
+ * β›” W29-T81 β€” HAS THIS NUMERIC CELL NOTHING TO SHOW? Blank, or a stored value that is not a
109
+ * number at all.
110
+ *
111
+ * `num()` above answers **0** for anything unparseable, which is a measurement where there was
112
+ * none. That is survivable for a column only this app writes; it stopped being survivable when
113
+ * the import door began accepting rows from a spreadsheet β€” a `qty` column holding
114
+ * "seventeen-ish" painted `0` on the canvas while the record panel honestly showed an em-dash.
115
+ * Two surfaces disagreeing about the same cell, and the one that looks authoritative is the one
116
+ * making the number up.
117
+ *
118
+ * ⚠ The FOLD was already right (`aggregations.numOf` skips what will not parse), so the totals
119
+ * row never counted these β€” it is the painted cell alone. Same family as `formulaIsText` below,
120
+ * asked of the RAW value for the same reason.
121
+ * ⚠ `" "` is blank, not a zero: `Number(" ")` is 0, which is how a whitespace cell becomes a
122
+ * number nobody typed.
123
+ */
124
+ export function numericIsBlank(v: CellValue): boolean {
125
+ return numericOrUndefined(v) === undefined;
126
+ }
127
+
128
+ /**
129
+ * β›”β›” THE ONE NUMERIC READING OF A STORED CELL, and it has to be one.
130
+ *
131
+ * `numericIsBlank` decides whether the CANVAS paints anything; `aggregations.numOf` decides
132
+ * whether the TOTALS ROW counts it. They were written separately and normalised differently β€”
133
+ * this one trimmed, that one stripped `$`, `,` and spaces β€” so `"1,234.50"` painted BLANK in the
134
+ * cell and was COUNTED in the total underneath it. That is T81's own defect ("two surfaces
135
+ * disagreeing about one cell") reintroduced in the opposite direction, by the fix for it, and no
136
+ * test could see it because every value in the legs was comma-free.
137
+ *
138
+ * ⚠ Human spellings are ACCEPTED here on purpose. `coerceClipboardValue` canonicalises what the
139
+ * UI writes, but the import door refuses-without-rewriting by design, so a `"$45"` posted by curl
140
+ * is storable β€” and the honest reading of that cell is 45 on BOTH surfaces, never 45 in the total
141
+ * and nothing in the cell ([[one-evaluator-per-question]]).
142
+ */
143
+ export function numericOrUndefined(v: CellValue): number | undefined {
144
+ if (v === null || v === undefined || v === "") return undefined;
145
+ if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
146
+ const cleaned = String(v).replace(/[$,\s]/g, "");
147
+ if (cleaned === "") return undefined; // " " is blank, not the zero `Number("")` gives
148
+ const n = Number(cleaned);
149
+ return Number.isFinite(n) ? n : undefined;
150
+ }
151
+
152
+ /**
153
+ * β›” IS THIS FORMULA RESULT TEXT? β€” and it must be asked of the RAW value.
154
+ *
155
+ * A formula may return text since 2026-07-31 (owner item 2: CONCATENATE, `&`, TEXT(),
156
+ * TRUE/FALSE, and any `IF(cond, "yes", "no")`). Both renderers tried to detect that with
157
+ * `!Number.isFinite(num(v))` β€” and `num()` above returns **0** for anything non-finite, so the
158
+ * test was `Number.isFinite(0)`, which is always true. The text branch therefore never ran in
159
+ * EITHER renderer, and every text-returning formula printed as `0`: on the canvas, in the list,
160
+ * on kanban cards, in the record panel and in all four export formats.
161
+ *
162
+ * The feature had never worked. It surfaced by rendering the owner's own Buy signal formula
163
+ * through the shipped bundle rather than by asking whether the code looked right.
164
+ *
165
+ * ONE function, exported, called by both `formatDisplay` and `makeCell` β€” they are meant to be
166
+ * one rendering, and the way they drifted was each holding its own copy of this test.
167
+ * `Number("")` is 0, so the empty case is excluded explicitly rather than relied upon.
168
+ *
169
+ * A TYPE PREDICATE, not a bare boolean: `makeCell` builds a `TextCell` from the value straight
170
+ * after this test, and without the narrowing the caller has to re-assert the string it just
171
+ * proved β€” which is the kind of cast that outlives the reason for it.
172
+ */
173
+ export function formulaIsText(v: CellValue): v is string {
174
+ return typeof v === "string" && v.trim() !== "" && !Number.isFinite(Number(v));
175
+ }
176
+
177
+ /**
178
+ * β›” AND THE BLANK TEST HAS TO AGREE WITH IT, or a value falls between the two.
179
+ *
180
+ * `formulaIsText` excludes whitespace-only strings (a `" "` result is not TEXT worth painting).
181
+ * The callers' own blank guard was `v === ""`, which does not catch `" "` β€” so a formula
182
+ * returning a space matched NEITHER, fell through to the numeric path, and printed `0`, because
183
+ * `Number(" ")` is 0. The exact bug this pair was written to fix, surviving in a narrower case.
184
+ *
185
+ * A formula's result is therefore one of exactly three things, and these two predicates make the
186
+ * three TOTAL: blank (null, empty, or whitespace), text, or a number. Reachable in practice β€”
187
+ * `CONCATENATE(" ", "")` is a space, and so is `TRIM()` of one.
188
+ */
189
+ export function formulaIsBlank(v: CellValue): boolean {
190
+ return v == null || (typeof v === "string" && v.trim() === "");
191
+ }
192
+
193
+ /** A checkbox cell's boolean, out of the overlay's '1'-or-empty contract. */
194
+ export function checkboxOn(v: CellValue | boolean): boolean {
195
+ return v === "1" || v === 1 || v === true;
196
+ }
197
+
198
+ /**
199
+ * Item 10 β€” the number DISPLAY string. With no format: exactly the pre-wave-5
200
+ * rendering (toLocaleString). `abbrev` wins over decimals when the magnitude
201
+ * calls for it (34.0M β€” one decimal, k/M/B); `thousands: false` drops the
202
+ * separators; `decimals` fixes 0..4 places.
203
+ */
204
+ export function numberText(v: number, fmt: FieldFormat | undefined): string {
205
+ if (fmt?.abbrev && Math.abs(v) >= 1000) {
206
+ const abs = Math.abs(v);
207
+ const [div, suffix] =
208
+ abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : [1e3, "k"];
209
+ return (v / div).toFixed(1) + suffix;
210
+ }
211
+ const d =
212
+ fmt && Number.isInteger(fmt.decimals) && (fmt.decimals as number) >= 0 &&
213
+ (fmt.decimals as number) <= 4
214
+ ? (fmt.decimals as number)
215
+ : null;
216
+ const thousands = fmt?.thousands !== false;
217
+ if (d != null)
218
+ return thousands
219
+ ? v.toLocaleString(undefined, {
220
+ minimumFractionDigits: d,
221
+ maximumFractionDigits: d,
222
+ })
223
+ : v.toFixed(d);
224
+ return thousands ? v.toLocaleString() : String(v);
225
+ }
226
+
227
+ /**
228
+ * Parse a stored date/datetime string. Odoo datetimes arrive as
229
+ * "YYYY-MM-DD HH:MM:SS" and are UTC by Odoo convention, so a time-carrying
230
+ * value with no zone gets a Z; a bare date keeps today's parse (UTC midnight)
231
+ * so format-less rendering stays byte-identical to the pre-wave-5 path.
232
+ *
233
+ * ⭐ EXPORTED in wave 29 (W29-T30) because the xlsx writer needs a Date to turn
234
+ * into an Excel serial, and the alternative was a SECOND date parser in
235
+ * `export.ts` β€” two evaluators for one question, which is how the two drift and
236
+ * a stamp exports as a different day than it renders
237
+ * ([[one-evaluator-per-question]]). It stays the only parser in this module.
238
+ */
239
+ export function parseStamp(raw: string): Date | null {
240
+ const s = raw.includes(" ") ? raw.replace(" ", "T") : raw;
241
+ const iso =
242
+ /T\d{2}:\d{2}/.test(s) && !/(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(s) ? s + "Z" : s;
243
+ const d = new Date(iso);
244
+ return Number.isNaN(d.getTime()) ? null : d;
245
+ }
246
+
247
+ /** A BARE calendar day: the shape Odoo sends for a `date`, and the shape wave 26's migration
248
+ * converted the Instagram preset stamps into. `parseStamp` gives it UTC midnight. */
249
+ const BARE_DAY = /^\d{4}-\d{2}-\d{2}$/;
250
+
251
+ /**
252
+ * Is this stored value a calendar DAY rather than an INSTANT? β€” the question that decides which
253
+ * clock renders it (see `dateTimeText`).
254
+ *
255
+ * Exported, and it is the whole reason the test for this is worth anything: the alternative is
256
+ * asserting a rendered string, which depends on the machine's timezone β€” so the check would pass
257
+ * on the owner's box (+07:00, where the two clocks agree on the day) and could only ever go red
258
+ * somewhere else. A predicate is the same decision with no ambient state in it.
259
+ *
260
+ * ⚠ The SHAPE OF THE RAW STRING, never `field.type`: wave 26 re-typed columns that hold full ISO
261
+ * stamps to `date`, so one `date` field can carry both shapes at once during a migration.
262
+ */
263
+ export function isBareDay(v: CellValue): boolean {
264
+ return BARE_DAY.test(String(v ?? "").trim());
265
+ }
266
+
267
+ /**
268
+ * ⭐ Wave-26 item 3 (owner, R3) β€” the day, spelled. `Aug 5, 2026`, never `8/5/2026` and never
269
+ * the raw stamp. One vocabulary for the date half whether or not a time follows it, so a
270
+ * `date` column and a `created_time` column in the same table read alike.
271
+ */
272
+ const DAY_PARTS = { year: "numeric", month: "short", day: "numeric" } as const;
273
+
274
+ /**
275
+ * Item 10 β€” the date DISPLAY string. `time` includes the time of day
276
+ * (created_time defaults to true β€” a creation stamp without its time reads as
277
+ * a duplicate of nothing); `tz: 'utc'` renders the tenant-neutral clock.
278
+ * Unparseable input renders VERBATIM rather than "Invalid Date" β€” the raw
279
+ * string is at least true.
280
+ *
281
+ * ⭐ WAVE 26 ITEM 3 (owner, via R3). The complaint was literal: cells read
282
+ * `2026-08-05T14:03:11+07:00`. SESSION A's half re-typed the Instagram preset
283
+ * columns from `text` to `date` (which is what routed them here at all) and
284
+ * migrated the stored cells to bare `YYYY-MM-DD`; this half is that a date must
285
+ * READ as one. Two changes, and the second is the one that is not cosmetic:
286
+ *
287
+ * β›” **A BARE `YYYY-MM-DD` IS A CALENDAR DAY, NOT AN INSTANT, so it renders in
288
+ * UTC.** `parseStamp` gives a bare date UTC midnight, so rendering it in the
289
+ * viewer's local zone shows the PREVIOUS DAY to everyone west of Greenwich β€” a
290
+ * value stored as the 5th reading as the 4th, silently, for a whole hemisphere.
291
+ * `ui/fmt.ts:date()` already refused to inherit this and its comment names this
292
+ * function as the holdout; it is no longer one. The tenant runs at +07:00, where
293
+ * the two agree, which is exactly why it could sit here unnoticed β€” and why the
294
+ * US market in COMMERCIALIZATION_PLAN C1b would have met it first.
295
+ *
296
+ * A value that CARRIES a time is a real instant and keeps the existing zone
297
+ * behaviour (local, or UTC when the field says so): shifting those to UTC would
298
+ * introduce the same off-by-a-day from the other direction.
299
+ */
300
+ export function dateTimeText(
301
+ // ⚠ STRUCTURAL, not `Field`: this reads exactly two members, and item 8's post rows hold a
302
+ // raw `posted_at` with no column behind it. Widening the parameter (rather than fabricating a
303
+ // fake Field at the call site) is what lets the posts list render its days through the SAME
304
+ // formatter as a date cell instead of growing a second one. Every existing caller passes a
305
+ // `Field`, which is assignable.
306
+ field: { type: Field["type"]; format?: FieldFormat },
307
+ v: CellValue
308
+ ): string {
309
+ if (v == null || v === "") return "";
310
+ const raw = String(v);
311
+ const d = parseStamp(raw);
312
+ if (!d) return raw;
313
+ const fmt = field.format;
314
+ const withTime = fmt?.time ?? field.type === "created_time";
315
+ const utc = fmt?.tz === "utc" || isBareDay(raw);
316
+ const zone = utc ? { timeZone: "UTC" as const } : undefined;
317
+ return withTime
318
+ ? d.toLocaleString(undefined, {
319
+ ...DAY_PARTS,
320
+ hour: "numeric",
321
+ minute: "2-digit",
322
+ second: "2-digit",
323
+ ...zone,
324
+ })
325
+ : d.toLocaleDateString(undefined, { ...DAY_PARTS, ...zone });
326
+ }
327
+
328
+ /**
329
+ * The plain-string rendering of one (field, value), mirroring makeCell's
330
+ * displayData. Reused by the record-detail panel and the W2 export builders so
331
+ * a value reads identically on the canvas, in the panel and in a file. Returns
332
+ * "" for empty so callers can substitute their own placeholder.
333
+ */
334
+ /**
335
+ * Wave-18 C5-AUTOFIELD β€” the STATE word of an automation cell.
336
+ *
337
+ * An automation cell holds one machine-written line: `state Β· when Β· detail`, e.g.
338
+ * `ok Β· 2026-08-03 14:10 Β· 12 posts`. The state is the first token, and every surface that
339
+ * paints the cell β€” canvas tint, record modal, the rail β€” reads it through HERE rather than
340
+ * re-splitting the string, because two parsers of one format is how the canvas and the panel
341
+ * end up disagreeing about whether a run succeeded (the exact way `formula` broke above).
342
+ *
343
+ * `"none"` is the honest answer for a cell nothing has written yet β€” NOT "ok". A column that
344
+ * has never run must not look like a column that ran and found nothing.
345
+ */
346
+ export type AutomationState = "ok" | "partial" | "error" | "blocked" | "queued" | "none";
347
+
348
+ const AUTOMATION_STATES: AutomationState[] = ["ok", "partial", "error", "blocked", "queued"];
349
+
350
+ export function automationState(v: CellValue): AutomationState {
351
+ const head = String(v ?? "").split("Β·")[0].trim().toLowerCase();
352
+ return (AUTOMATION_STATES as string[]).includes(head)
353
+ ? (head as AutomationState)
354
+ : "none";
355
+ }
356
+
357
+ /** Everything after the state word β€” the timestamp and the run's own detail. */
358
+ export function automationDetail(v: CellValue): string {
359
+ const parts = String(v ?? "").split("Β·");
360
+ return parts.length > 1 ? parts.slice(1).join("Β·").trim() : "";
361
+ }
362
+
363
+ /** Sentence-cased state, for anywhere a word reads better than a token. */
364
+ export function automationStateLabel(v: CellValue): string {
365
+ const s = automationState(v);
366
+ return s === "none" ? "Not run yet" : s[0].toUpperCase() + s.slice(1);
367
+ }
368
+
369
+ /**
370
+ * ⭐ Wave-23 C7 (owner item 5) β€” THE JSON PREVIEW: what a 200px cell says about a document.
371
+ *
372
+ * `MAX_JSON_BYTES` is the contract's ceiling, mirrored from the host's write validation so the
373
+ * viewer can refuse a paste with the same number the server would (a client that lets you type
374
+ * 40 KB and then shows you a server refusal has wasted the edit).
375
+ *
376
+ * The four cases, and each one is a decision rather than a formatting preference:
377
+ * Β· **a single-pair object shows THE PAIR.** `{handle: "royalimports"}` is more useful than
378
+ * "1 key" and it is the shape most machine writes actually have. Past one pair the pairs
379
+ * stop fitting and the honest answer is the count.
380
+ * Β· **many keys / many items β†’ `{…} N keys` / `[…] N items`.** Showing the FIRST pair of a
381
+ * twelve-key object would let a reader take one arbitrary value β€” whichever key the writer's
382
+ * serializer happened to emit first β€” for the cell's content.
383
+ * Β· **a bare scalar renders as itself.** `12`, `"ok"`, `true` and `null` are all valid JSON
384
+ * documents, and wrapping them in braces would describe a shape they do not have.
385
+ * Β· β›” **text that does not parse renders AS ITSELF, never as a shape.** The host validates on
386
+ * write, so this only happens to a value that predates the validation or arrived another
387
+ * way β€” and the one thing the preview must never do is claim a document is well-formed. The
388
+ * viewer's raw tab is where such a value gets read and repaired.
389
+ *
390
+ * Blank stays blank: an empty json cell is a document nobody has written, and `{}` is a document
391
+ * somebody wrote that is empty. Two different facts, two different cells.
392
+ */
393
+ export const MAX_JSON_BYTES = 32 * 1024;
394
+
395
+ /**
396
+ * ⭐ WAVE 31 Β· T22 (D-173) β€” is this parsed value the SERVER'S STAND-IN for a document it did not
397
+ * send, and if so how big was the real one? Returns a short size string, or null.
398
+ *
399
+ * β›” THE SHAPE IS `routes_tables._thin_json`'s OWN, and it is matched on `_truncated === true`
400
+ * plus a numeric `bytes` β€” never on the key alone, because a person's own document could contain
401
+ * a `_truncated` key and must not be reported as absent. `_url` is deliberately NOT required: the
402
+ * viewer keys its fetch on it, but a preview that refused to warn when it was missing would go
403
+ * quiet in exactly the degraded case that most needs a warning.
404
+ */
405
+ export function truncatedDoc(value: unknown): string | null {
406
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
407
+ const v = value as Record<string, unknown>;
408
+ if (v._truncated !== true) return null;
409
+ const bytes = typeof v.bytes === "number" && isFinite(v.bytes) ? Math.max(0, v.bytes) : 0;
410
+ if (!bytes) return "not shown";
411
+ return bytes >= 1024 ? `${Math.round(bytes / 1024).toLocaleString()} KB not shown`
412
+ : `${bytes.toLocaleString()} bytes not shown`;
413
+ }
414
+
415
+ /** Does this text parse as JSON? The ONE test, shared by the preview, the cell and the viewer's
416
+ * save β€” three copies of a try/catch is how they end up disagreeing about `""` or `NaN`. */
417
+ export function jsonParse(v: CellValue): { ok: boolean; value?: unknown } {
418
+ const s = String(v ?? "").trim();
419
+ if (s === "") return { ok: false };
420
+ try {
421
+ return { ok: true, value: JSON.parse(s) as unknown };
422
+ } catch {
423
+ return { ok: false };
424
+ }
425
+ }
426
+
427
+ /**
428
+ * ⭐ WAVE-27 item 13 (R13) β€” one compact line for a CODE cell.
429
+ *
430
+ * Deliberately NOT `jsonPreview`, and the difference is the whole reason `code` is its own kind:
431
+ * a json preview SUMMARISES a parsed document ("{…} 5 keys"), which it can only do because the
432
+ * value is guaranteed to parse. A snippet has no structure to summarise and often does not parse
433
+ * at all β€” half-written SQL is the normal state of one β€” so the honest preview is the first
434
+ * non-blank LINE, clipped, plus the line count when there is more underneath. That way the cell
435
+ * says what the snippet starts with AND that there is more, instead of inventing a shape for it.
436
+ */
437
+ export function codePreview(v: CellValue): string {
438
+ const raw = String(v ?? "");
439
+ if (raw.trim() === "") return "";
440
+ const lines = raw.split("\n");
441
+ const firstIdx = lines.findIndex((l) => l.trim() !== "");
442
+ const first = clip((lines[firstIdx < 0 ? 0 : firstIdx] ?? "").trim(), 60);
443
+ // The count is of REAL lines, so a snippet padded with blank lines does not claim depth it
444
+ // does not have β€” and it is checkable against what opening the editor shows
445
+ // ([[no-unverifiable-aggregates]]).
446
+ const n = lines.filter((l) => l.trim() !== "").length;
447
+ return n > 1 ? `${first} +${n - 1} more` : first;
448
+ }
449
+
450
+ /**
451
+ * ⭐⭐ W37-T14 / T15 β€” THE TWO SHAPES WAVE 37 ADDED, READ AS WHAT THEY ARE.
452
+ *
453
+ * β›” FOUND BY QA ON THE DEPLOYED BUILD, not by a gate: `1.5BOPP`'s Units cell rendered
454
+ * **`[…] 2 items`** and its Tier prices cell the same way, because both are `type: "json"` and
455
+ * both fell through to the generic array branch below. The server was serving the right data the
456
+ * whole time β€” `[{name:"SLEEVE",qty:12},{name:"Master",qty:144}]` β€” and the grid said nothing
457
+ * about it. A column whose entire job is to show which units a SKU sells in, showing a count of
458
+ * items, is the [[permitted-is-not-answerable]] shape in the RENDER layer.
459
+ *
460
+ * ⚠ KEYED ON THE SHAPE, NEVER ON THE COLUMN KEY. A key test (`field.key === "units"`) would
461
+ * miss the same document on a user database, on a rollup, or under any rename β€” and this file
462
+ * has no access to the field anyway. Both shapes are exact: EVERY element must be an object
463
+ * carrying exactly the pair, so an unrelated array of objects still falls through to the generic
464
+ * branch and is not silently relabelled.
465
+ *
466
+ * ⚠ `Γ—` is U+00D7 MULTIPLICATION SIGN and `Β·` is U+00B7 MIDDLE DOT β€” neither is an em or en
467
+ * dash, so this copy is inside standing rule 2 rather than an exception to it.
468
+ */
469
+ function namedPairList(value: unknown): string | null {
470
+ if (!Array.isArray(value) || value.length === 0) return null;
471
+ const rows = value as Record<string, unknown>[];
472
+ const every = (a: string, b: string) =>
473
+ rows.every(
474
+ (r) =>
475
+ r !== null &&
476
+ typeof r === "object" &&
477
+ !Array.isArray(r) &&
478
+ typeof r[a] === "string" &&
479
+ typeof r[b] === "number",
480
+ );
481
+ // W37-T15 β€” units of measure: the NAME and its conversion factor, which is the whole point.
482
+ if (every("name", "qty"))
483
+ return rows.map((r) => `${String(r.name)} Γ—${trimNum(r.qty as number)}`).join(" Β· ");
484
+ // W37-T14 β€” the pricelists that really price this SKU today, each with its price.
485
+ if (every("pricelist", "unit_price"))
486
+ return rows
487
+ .map((r) => `${String(r.pricelist)} $${numberText(r.unit_price as number, { decimals: 2 })}`)
488
+ .join(" Β· ");
489
+ return null;
490
+ }
491
+
492
+ /** `12` not `12.0`, and `1.5` kept β€” a conversion factor is a count far more often than not. */
493
+ function trimNum(n: number): string {
494
+ return Number.isFinite(n) ? String(Number(n.toFixed(4))) : String(n);
495
+ }
496
+
497
+ /** One compact line for a json cell. See the note above for why each case reads as it does. */
498
+ export function jsonPreview(v: CellValue): string {
499
+ const raw = String(v ?? "").trim();
500
+ if (raw === "") return "";
501
+ const parsed = jsonParse(raw);
502
+ // ⚠ First line only, and clipped: an unparseable value is often a whole pasted response, and
503
+ // a cell is not where a 4 KB blob gets read. It is shown rather than hidden because the
504
+ // reader has to be able to see that the column holds something the app could not open.
505
+ if (!parsed.ok) return clip(raw.split("\n")[0], 60);
506
+ const value = parsed.value;
507
+ // ⭐ ITEM 8 β€” a posts window says what it holds. Before wave 26 this document fell through to
508
+ // the generic object branch and every creator's `posts` cell read `{…} 4 keys`, which is true
509
+ // about JSON and says nothing about the record.
510
+ // ⚠ The ARRAY LENGTH, never the document's own `n`: the count in a cell has to be checkable
511
+ // against what opening it shows ([[no-unverifiable-aggregates]]).
512
+ // ⭐⭐ WAVE 31 Β· T22 (D-173) β€” A DOCUMENT THE LIST DID NOT SEND SAYS SO, WITH ITS SIZE.
513
+ //
514
+ // β›” THE SWALLOW, and it is one branch below this one. `routes_tables._thin_json` replaces an
515
+ // oversized `json` cell with a stand-in β€” `{_truncated, bytes, _url}` β€” so the big document
516
+ // does not ride a list response (measured: `source_payload` was 95.6–98.5% of every IG grid's
517
+ // bytes). That stand-in is itself valid JSON with three keys, so it fell through to the generic
518
+ // object branch and the cell read **`{…} 3 keys`** β€” a confident, checkable-looking claim about
519
+ // a document that is not there, and indistinguishable from a real three-key document. R6's
520
+ // second sentence applies to our own wire: a value we declined to send is reported, never
521
+ // disguised.
522
+ // ⚠ THE SIZE IS THE SERVER'S OWN `bytes`, not a guess, and it is what makes the cell honest β€”
523
+ // "this column holds 41 KB you have not been shown" is a different fact from "3 keys".
524
+ const thinned = truncatedDoc(value);
525
+ if (thinned) return `{…} ${thinned}`;
526
+ const shown = postsWindowOf(value);
527
+ if (shown)
528
+ return shown.posts.length === 0
529
+ ? "No posts"
530
+ : `${shown.posts.length} post${shown.posts.length === 1 ? "" : "s"}`;
531
+ // ⭐ W37-T14/T15 β€” the two known shapes read as themselves; anything else keeps the old count.
532
+ const pairs = namedPairList(value);
533
+ if (pairs) return clip(pairs, 60);
534
+ if (Array.isArray(value))
535
+ return value.length === 0 ? "[]" : `[…] ${value.length} item${value.length === 1 ? "" : "s"}`;
536
+ if (value !== null && typeof value === "object") {
537
+ const keys = Object.keys(value as Record<string, unknown>);
538
+ if (keys.length === 0) return "{}";
539
+ if (keys.length === 1)
540
+ return clip(`{${keys[0]}: ${scalarText((value as Record<string, unknown>)[keys[0]])}}`, 60);
541
+ return `{…} ${keys.length} keys`;
542
+ }
543
+ return clip(scalarText(value), 60);
544
+ }
545
+
546
+ /** The day, spelled, for a caller holding a raw value and no column β€” the posts list's
547
+ * `posted_at`. Routed through `dateTimeText` so it can never drift from a date CELL. */
548
+ export function dayText(v: CellValue): string {
549
+ return dateTimeText({ type: "date" }, v);
550
+ }
551
+
552
+ /* ═══ ⭐ WAVE 26 ITEM 8 (contract C2) β€” THE `posts` WINDOW ═══
553
+ The preset `posts` cell holds ONE object written by the engine's `posts_window`:
554
+
555
+ { n, metrics, as_of, posts: [{ shortcode, url, posted_at, type, caption,
556
+ views?, likes?, comments? }] }
557
+
558
+ Two laws come with it and both are enforced below rather than in the components:
559
+
560
+ β›” `metrics: false` β‡’ views/likes/comments are ABSENT KEYS, NEVER 0. A reader must render an
561
+ absent metric as NOTHING. Coercing it to a number here β€” `Number(p.views) || 0` is the
562
+ natural thing to type β€” would fabricate a measurement, which is the engine's own
563
+ blank-never-zero law broken at the display seam, silently and plausibly (a creator with no
564
+ metrics bought would read as a creator with no engagement).
565
+
566
+ β›” IT IS A WINDOW, NOT THE HISTORY. `ut_ig_posts` / `ut_ig_post_snapshots` accumulate; this
567
+ cell is the last N (R1/R3 β€” "one store for one series"). So nothing here may present the
568
+ cell as a total.
569
+
570
+ ⚠ DETECTED BY SHAPE, never by the field's key. A user may name a column `posts`, and the
571
+ preset key is not a contract the renderer can see from the value alone. An object carrying a
572
+ `posts` ARRAY OF OBJECTS is what makes "3 posts" a true sentence about it, whoever wrote it.
573
+ ═══ */
574
+ export interface PostSummary {
575
+ shortcode?: string;
576
+ url?: string;
577
+ posted_at?: string;
578
+ type?: string;
579
+ caption?: string;
580
+ /** β›” `undefined` when the metric was not bought. Never 0 β€” see the note above. */
581
+ views?: number;
582
+ likes?: number;
583
+ comments?: number;
584
+ }
585
+
586
+ export interface PostsWindow {
587
+ /** The engine's own count. May differ from `posts.length`; readers show the LENGTH, because
588
+ * that is the number a reader can check against what is in front of them. */
589
+ n: number;
590
+ metrics: boolean;
591
+ as_of?: string;
592
+ posts: PostSummary[];
593
+ }
594
+
595
+ function str(v: unknown): string | undefined {
596
+ return typeof v === "string" && v !== "" ? v : undefined;
597
+ }
598
+
599
+ /** β›” A number ONLY when the key really holds one. `undefined` for absent, for null, and for a
600
+ * non-numeric β€” anything else invents a measurement. */
601
+ function metric(v: unknown): number | undefined {
602
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
603
+ }
604
+
605
+ /** The window behind an ALREADY-PARSED value, or null when this is not one. */
606
+ export function postsWindowOf(value: unknown): PostsWindow | null {
607
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
608
+ const doc = value as Record<string, unknown>;
609
+ const rows = doc.posts;
610
+ if (!Array.isArray(rows)) return null;
611
+ if (!rows.every((r) => r !== null && typeof r === "object" && !Array.isArray(r))) return null;
612
+ const posts: PostSummary[] = rows.map((r) => {
613
+ const p = r as Record<string, unknown>;
614
+ return {
615
+ shortcode: str(p.shortcode),
616
+ url: str(p.url),
617
+ posted_at: str(p.posted_at),
618
+ type: str(p.type),
619
+ caption: str(p.caption),
620
+ views: metric(p.views),
621
+ likes: metric(p.likes),
622
+ comments: metric(p.comments),
623
+ };
624
+ });
625
+ return {
626
+ n: typeof doc.n === "number" ? doc.n : posts.length,
627
+ metrics: doc.metrics === true,
628
+ as_of: str(doc.as_of),
629
+ posts,
630
+ };
631
+ }
632
+
633
+ /** The window behind a stored CELL, or null. */
634
+ export function postsWindow(v: CellValue): PostsWindow | null {
635
+ const parsed = jsonParse(v);
636
+ return parsed.ok ? postsWindowOf(parsed.value) : null;
637
+ }
638
+
639
+ /** A nested value, small enough to sit inside a one-pair preview. Objects and arrays collapse
640
+ * to their own marks rather than recursing β€” a preview that unfolds is not a preview. */
641
+ function scalarText(v: unknown): string {
642
+ if (v === null) return "null";
643
+ if (Array.isArray(v)) return `[…] ${v.length}`;
644
+ if (typeof v === "object") return `{…} ${Object.keys(v as object).length}`;
645
+ return typeof v === "string" ? v : String(v);
646
+ }
647
+
648
+ /** ⚠ An ellipsis CHARACTER, not three dots: the grid's canvas measures text and three periods
649
+ * are three glyphs wide. Same mark the group-bar fitter uses. */
650
+ function clip(s: string, n: number): string {
651
+ return s.length <= n ? s : s.slice(0, n - 1) + "…";
652
+ }
653
+
654
+ /**
655
+ * The document, indented for the viewer's pretty tab. Returns the RAW TEXT UNCHANGED when it
656
+ * does not parse β€” re-indenting is not repair, and handing a reader a "prettified" version of
657
+ * something the app could not read would hide the only thing they need to see.
658
+ */
659
+ export function jsonPretty(v: CellValue): string {
660
+ const raw = String(v ?? "");
661
+ const parsed = jsonParse(raw);
662
+ return parsed.ok ? JSON.stringify(parsed.value, null, 2) : raw;
663
+ }
664
+
665
+ /**
666
+ * Wave-5 item 11 β€” the actionable link behind a url/email/phone value, or null when the value
667
+ * does not parse as one (the raw text still shows; a link that goes nowhere is worse than no
668
+ * link). url without a scheme gets https://; `javascript:` can never come out of here.
669
+ *
670
+ * ⭐ MOVED here from `RecordDetail.tsx` for wave-26 item 11, when the KANBAN CARD became a
671
+ * second surface that needs it. Two copies of a scheme guard is how one of them ends up
672
+ * accepting `javascript:` β€” the same argument that moved `formatDisplay`'s formula test into one
673
+ * function after the canvas and the panel had drifted. It is also the only reason this claim is
674
+ * testable at all: both call sites are React components, and this module is glide-free and
675
+ * React-free, so a node gate can reach it.
676
+ */
677
+ export function actionHref(field: Field, v: CellValue): string | null {
678
+ const s = String(v ?? "").trim();
679
+ if (!s) return null;
680
+ if (field.type === "url") {
681
+ if (/^https?:\/\//i.test(s)) return s;
682
+ if (/^[\w-]+(\.[\w-]+)+/.test(s)) return `https://${s}`;
683
+ return null;
684
+ }
685
+ if (field.type === "email")
686
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) ? `mailto:${s}` : null;
687
+ if (field.type === "phone") {
688
+ const digits = s.replace(/[\s().-]/g, "");
689
+ return /^\+?\d{5,}$/.test(digits) ? `tel:${digits}` : null;
690
+ }
691
+ return null;
692
+ }
693
+
694
+ export function formatDisplay(field: Field, v: CellValue): string {
695
+ switch (field.type) {
696
+ case "currency":
697
+ return v == null || v === "" ? "" : "$" + numberText(num(v), field.format);
698
+ case "formula": {
699
+ // β›” A FORMULA MAY RETURN TEXT (owner item 2, 2026-07-31 β€” CONCATENATE, `&`, TEXT(),
700
+ // and any `IF(cond, "yes", "no")`). `makeCell` learned that; this function did not, and
701
+ // the two are supposed to be one rendering. So every surface that reads THIS one β€”
702
+ // ListView, KanbanView, the calendar's summary cells, the record-detail panel and ALL
703
+ // FOUR export formats β€” printed a text result as `0`, because `num("Buy now")` is NaN.
704
+ // The canvas showed the words and the file showed a zero, for the same cell.
705
+ //
706
+ // Found by rendering the owner's own Buy signal formula through the shipped bundle
707
+ // (_qa_owner_20260803). The branch below is `makeCell`'s test, verbatim: a non-blank,
708
+ // non-numeric STRING is its own display.
709
+ // The three states, in the one order that makes them total β€” see `formulaIsBlank`.
710
+ if (formulaIsBlank(v)) return "";
711
+ if (formulaIsText(v)) return v;
712
+ return numberText(num(v), field.format);
713
+ }
714
+ case "int":
715
+ return v == null || v === "" ? "" : numberText(num(v), field.format);
716
+ case "pct":
717
+ return v == null || v === "" ? "" : num(v).toFixed(1) + "%";
718
+ case "date":
719
+ case "created_time":
720
+ return dateTimeText(field, v);
721
+ case "checkbox":
722
+ return checkboxOn(v) ? "Checked" : "";
723
+ case "rating": {
724
+ const n = num(v);
725
+ return n >= 1 ? `${Math.round(n)} of ${ratingMax(field)}` : "";
726
+ }
727
+ case "json":
728
+ // Wave-23 C7 β€” the SAME compact line the canvas cell paints. Explicit here rather than
729
+ // left to `default` for the reason the `formula` note above records: this function feeds
730
+ // ListView, KanbanView, the calendar, the record panel and all four EXPORT formats, and
731
+ // falling through would dump a whole 32 KB document into a CSV cell.
732
+ return jsonPreview(v);
733
+ case "code":
734
+ // ⭐ Wave-27 item 13 (R13) β€” the same compact line the canvas cell paints, and explicit
735
+ // for `json`'s exact reason: a snippet is multi-LINE, and falling through to `default`
736
+ // would put raw newlines into a CSV cell, a kanban card and a calendar chip.
737
+ return codePreview(v);
738
+ case "ai_enrich":
739
+ // ⭐⭐ Wave-34 (owner ruling R13) β€” an enrichment answer is model-authored PROSE and can run
740
+ // to several lines, so it is explicit here for exactly the reason `json` and `code` are:
741
+ // this function feeds ListView, KanbanView, the calendar, the record panel and all four
742
+ // EXPORT formats, and `default` would put raw newlines into a CSV cell.
743
+ // ⚠ `codePreview` is REUSED rather than given a prose-flavoured twin. Its "first line
744
+ // +N more" is already this product's one way of saying "a multi-line value in a one-line
745
+ // slot", and the count is checkable against what the drawer shows; a second helper would
746
+ // be a second answer to one question ([[one-question-two-normalizers]]).
747
+ return codePreview(v);
748
+ case "automation":
749
+ // The machine-written line, verbatim. Explicit rather than left to the `default` branch
750
+ // below: `formula` fell through a default once and printed every text result as `0` for
751
+ // months, and the lesson recorded there is that a type whose rendering is deliberate
752
+ // should SAY so where a reader looks for it.
753
+ return String(v ?? "");
754
+ case "metric":
755
+ // Wave-22 C7 β€” a server-computed number over the master snapshot series. BLANK IS A
756
+ // STATE, never zero: the engine sends "" when the window holds no snapshots, and
757
+ // rendering that as 0 would fabricate a measurement (the engine's own blank-never-zero
758
+ // law, kept at the display seam too).
759
+ return v == null || v === "" ? "" : numberText(num(v), field.format);
760
+ case "rollup": {
761
+ // ⭐ WAVE 29 (W29-T30) β€” THE MISSING CASE. `rollup` fell through to `default` and printed
762
+ // its raw fold string, so ONE cell rendered two ways: the canvas showed `1,491,552.43`
763
+ // (`cells.ts`'s rollup branch, comma-fixed 2026-08-10 on the owner's instruction) while
764
+ // every CSV, PDF, JSON-display and Excel export showed `1491552.43`. Same family as D-121
765
+ // (the record panel ignoring a field's format) β€” three surfaces, one concept.
766
+ //
767
+ // β›” THE BLANK RULE IS THE POINT, and it is why this is a guarded branch and not a call to
768
+ // `numberText(num(v), ...)`. `_rollup_fold` returns "" for "no rows to aggregate" and only
769
+ // the count family ever returns a real 0, so `num("")` β€” which is 0 β€” would paint the
770
+ // measurement the server just refused to invent. A fold that is not a number at all
771
+ // (`concatenate`, `arrayunique`, `latest` over text) keeps its own text.
772
+ // β‡’ Mirrors `cells.ts`'s branch line for line, deliberately: one concept, one rendering.
773
+ const raw = String(v ?? "");
774
+ const asNum = raw.trim() === "" ? NaN : Number(raw);
775
+ return Number.isFinite(asNum) ? numberText(asNum, field.format) : raw;
776
+ }
777
+ case "multiselect":
778
+ // The comma-joined SET, read back with breathing room ("A, B" not "A,B").
779
+ return String(v ?? "")
780
+ .split(",")
781
+ .map((s) => s.trim())
782
+ .filter((s) => s !== "")
783
+ .join(", ");
784
+ default:
785
+ return String(v ?? "");
786
+ }
787
+ }
788
+
789
+ // --- W40-T23: the two rules behind "typing a letter opens the picker" ---------
790
+ // MOVED here from cells.ts, for this module's founding reason and no other: cells.ts imports
791
+ // `GridCellKind` as a VALUE, so nothing in it can be reached by a node gate, and these two are
792
+ // exactly the kind of decision that can be wrong in ways a screenshot will never show β€” which
793
+ // keystroke counts as typing, and which choices a query keeps. cells.ts re-exports both, so
794
+ // every existing import keeps working; the bodies are the same bytes they were β€” this is a
795
+ // move, not a change.
796
+ //
797
+ // Their consumers are two DIFFERENT controls β€” the grid's anchored picker and the record
798
+ // drawer's chip row β€” which is the whole reason they are functions rather than expressions
799
+ // inside one handler. One rule, two surfaces, no second copy to drift.
800
+
801
+ /**
802
+ * The character a keystroke should SEED a choice picker with, or `null` when the keystroke is
803
+ * not somebody typing.
804
+ *
805
+ * β›” THE `null` CASES ARE THE CONTRACT, not the leftovers. `Ctrl+C` must still copy, `Enter`
806
+ * must still walk the column, `ContextMenu` must still open the header menu β€” each of those is
807
+ * a live branch of `onGridKeyDown` that this predicate has to decline before it runs.
808
+ *
809
+ * Β· one code point only, so every named key (`Enter`, `ArrowDown`, `Tab`, `F2`, `ContextMenu`)
810
+ * is rejected by LENGTH rather than by a list somebody has to keep complete;
811
+ * Β· no Ctrl / Meta / Alt, which is what keeps `Ctrl+C` a copy and `Alt+F` a menu;
812
+ * Β· SHIFT IS DELIBERATELY NOT TESTED β€” `Shift+V` is a capital V, and a guard copied from the
813
+ * Enter branch (where Shift means "go up") would ship a feature that works on `v` and not on
814
+ * `V`;
815
+ * Β· a SPACE is refused. It reads as "no filter" anyway once trimmed, and in the record drawer
816
+ * Space is the key that ACTIVATES the focused chip β€” a bare keystroke must never write a
817
+ * value.
818
+ */
819
+ export function pickerTypeSeed(
820
+ key: string,
821
+ mods: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }
822
+ ): string | null {
823
+ if (mods.ctrlKey || mods.metaKey || mods.altKey) return null;
824
+ // `[...key]` counts CODE POINTS, not UTF-16 units: an astral character arrives as a
825
+ // length-2 string and is one keystroke, so `key.length === 1` would silently refuse it.
826
+ const chars = [...key];
827
+ if (chars.length !== 1) return null;
828
+ if (key === " ") return null;
829
+ return key;
830
+ }
831
+
832
+ /**
833
+ * Which choices survive a query. The house rule, verbatim from `FieldSelect.tsx`: trimmed,
834
+ * case-insensitive, SUBSTRING (not prefix) β€” so "v" finds "Delivered" as well as "Void", which
835
+ * is what a person means by "filter to options containing v".
836
+ *
837
+ * An empty (or all-whitespace) query returns the list unchanged, so the seeded and unseeded
838
+ * pickers are the same component in the same state rather than two code paths.
839
+ *
840
+ * ⚠ Returns a COPY, never the input array: the grid maps over the result every render, and a
841
+ * caller that sorted or spliced an alias of the field's own choice list would be editing the
842
+ * column definition through a filter.
843
+ */
844
+ export function matchChoices(
845
+ choices: readonly string[],
846
+ query: string
847
+ ): string[] {
848
+ const needle = query.trim().toLowerCase();
849
+ if (!needle) return [...choices];
850
+ return choices.filter((choice) => choice.toLowerCase().includes(needle));
851
+ }
852
+
853
+ // ---------------------------------------------------------------------------
854
+ // ⭐⭐ W40-T24 β€” THE PRE-SET COLUMN'S DEFINITION LOCK, STATED ONCE
855
+ // ---------------------------------------------------------------------------
856
+
857
+ /** The four definition verbs the Edit-field pane and the column menu can offer. */
858
+ export interface ColumnDefinitionOffer {
859
+ /** "Change field" β€” point this column slot at a different field, or at a new one. */
860
+ swap: boolean;
861
+ /** The editable Name box in the Edit-field pane. */
862
+ rename: boolean;
863
+ /** The Field-type picker in the Edit-field pane. */
864
+ retype: boolean;
865
+ /** The "Delete field" row. Spelt `remove` only because `delete` reads as the operator. */
866
+ remove: boolean;
867
+ }
868
+
869
+ /**
870
+ * ⭐⭐ W40-T24 (owner instruction 18, contract C2) β€” **WHICH DEFINITION CONTROLS DOES THIS
871
+ * COLUMN OFFER?** One function, one answer, so the four verbs cannot drift apart.
872
+ *
873
+ * Owner, verbatim: *"the Supplier field is wrong. Editable at record level, yes, but 'Edit
874
+ * field' offers a 'Choose field' dropdown that makes no sense. Audit every pre-set field so the
875
+ * structure is standard: a pre-set field's definition is not editable and shows no 'choose a
876
+ * field' dropdown."* The complaint arrived about Supplier and was never about Supplier: the swap
877
+ * picker was gated on `!locked` alone, and `locked` means "is the pinned primary column". Zero
878
+ * pre-set awareness, so EVERY contract column on every grid carried the same exposure, and a fix
879
+ * written for Supplier would have been the wrong shape of fix.
880
+ *
881
+ * β›”β›” **THE LOCK IS `isUserSchemaField`, AND `isPresetField` CANNOT DO THIS JOB β€” MEASURED, NOT
882
+ * ASSUMED.** The obvious reach is for the predicate that carries the word, and it is wrong here.
883
+ * `platform/aios_grid_fields.json` declares NINE product_data contract columns β€”
884
+ * `supplier`, `origin_country`, `lead_days`, `first_cost`, `needs_pricing`, `march_pricelist`,
885
+ * `price_changes`, `closeouts`, `notes` β€” as `{"source": "overlay", "shared": true}` with no
886
+ * `custom` and no `preset` key. That is deliberate and it is what makes their CELLS typeable
887
+ * (`mayEditField` requires `source === "overlay"`), which is the owner's *"editable at record
888
+ * level, yes"*. Run `isPresetField` over any of them and it answers **false**: it recognises the
889
+ * Odoo-sourced stratum and the machine-owned one, and the overlay-stratum contract columns are
890
+ * neither. Gate the swap picker on it and Supplier β€” the column the instruction names β€” keeps
891
+ * the dropdown. ⚠ So the two predicates are NOT complements and the choice is not a matter of
892
+ * taste: one of them is false for the subject of the ticket.
893
+ *
894
+ * Β· `isUserSchemaField` β€” *is this column's shape THIS user's to change?* False for all nine
895
+ * of those, false for the Odoo-sourced columns (Category, Agent), false for a machine-owned
896
+ * automation column, and TRUE for a `custom_` overlay column the user made and for every
897
+ * `ut_*` shared-definition column. That is the pre-set line drawn exactly where C2 draws it.
898
+ * Β· `isPresetField` β€” *is this column filled FOR you?* Still the right predicate for the
899
+ * PRE-SET CHIP and the custom-field dot, which is what it was written for. It answers a
900
+ * question about where the VALUES come from; this lock is about who owns the DEFINITION, and
901
+ * the nine columns above are the proof that those are different sets.
902
+ *
903
+ * β›” **THE SWAP TAKES THE SAME PREDICATE AS RETYPE, WHICH IS THE POINT.** `CustomerGrid.
904
+ * changeField` only rewrites `config.order`/`config.visible`, so the swap is mechanically a view
905
+ * edit β€” but it sits INSIDE the Edit-field pane, and a pane whose Name box is disabled and whose
906
+ * type picker is absent while a third control offers to make this column something else is
907
+ * incoherent whatever the plumbing does. One predicate for all of the pane's definition verbs is
908
+ * what makes the pane say one thing.
909
+ *
910
+ * β›” **`rename` AND `retype` ARE REPORTED HERE, ENFORCED AT THE HOST β€” DELIBERATELY.** The host
911
+ * withholds the `onRename`/`onRetype` PROPS (`CustomerGrid.tsx`, the `onRetype=` and `onRename=`
912
+ * prop chains at the `<ColumnMenu>` render), and a control whose handler is undefined is not
913
+ * rendered at all. Enforcing the same refusal a second time inside the menu could only SUBTRACT
914
+ * from columns that work today β€” the Destination column's rename (owner item 3/6, 2026-08-23)
915
+ * reaches the pane through the route branch, not through the stratum β€” and two enforcement
916
+ * points that agree today are how one silently becomes the other's contradiction. So these two
917
+ * fields mirror the host's real rule, route carve-out included, and exist so a node gate can
918
+ * assert what the JSX does. If the host chain changes, this changes with it.
919
+ *
920
+ * β›” **`remove` IS THE LOCK'S VERDICT ONLY.** Whether a delete DOOR exists at all is a separate
921
+ * question the host answers three different ways (per-user overlay, `ut_*` shared definition,
922
+ * route order), and `ColumnMenu` keeps `onDelete &&` plus its `schemaLocked` prop beside this.
923
+ * AND-ing can only subtract, so nothing here duplicates `CustomerGrid.isSchemaLocked` β€” which is
924
+ * not exported, and copying it out would be D-413's disease in a fresh location.
925
+ * ⚠ THE ROLLUP CLAUSE IS AN OWNER RULING, NOT A HACK (2026-08-09): *"No rollup field should be
926
+ * uneditable, everything is custom and changeable always."* A rollup holds no data of its own β€”
927
+ * it is a question asked of other rows, and re-asking it costs nothing β€” so deleting a pre-set
928
+ * one destroys no measurement. `isSchemaLocked` carries the SAME exception in the same words
929
+ * (`&& field.type !== "rollup"`); dropping it here would revoke a live ruling by accident.
930
+ *
931
+ * @param opts.isUserTable the grid is a `ut_*` runtime database, not an Odoo-backed topic.
932
+ * @param opts.isPrimary this is the pinned primary column (the menu's `locked` prop) β€” the row
933
+ * identity, which can never be swapped away whatever else is true of it.
934
+ */
935
+ export function columnDefinitionOffer(
936
+ field: Field,
937
+ opts: { isUserTable: boolean; isPrimary: boolean }
938
+ ): ColumnDefinitionOffer {
939
+ const ownShape = isUserSchemaField(field, opts.isUserTable);
940
+ // The route order column's own rename and delete doors. Matched on the DECLARED `kind`, never
941
+ // on the `route_` key prefix β€” the server declares the kind precisely so nobody has to read a
942
+ // key as a type, and the host's own chain matches it the same way.
943
+ const routeOrder = field.shared === true && field.kind === "route_order" && !opts.isPrimary;
944
+ return {
945
+ swap: !opts.isPrimary && ownShape,
946
+ rename: ownShape || routeOrder,
947
+ retype: ownShape,
948
+ remove: ownShape || routeOrder || field.type === "rollup",
949
+ };
950
+ }
web/src/customer-grid/export.ts CHANGED
@@ -27,7 +27,7 @@
27
  // the installed build) β€” so a Blob URL + a[download] click works in the embed.
28
  // ---------------------------------------------------------------------------
29
 
30
- import type { Field, Row } from "./types";
31
  import {
32
  formatDisplay,
33
  formulaIsBlank,
@@ -47,6 +47,108 @@ export const EXPORT_LABELS: Record<ExportFormat, string> = {
47
  json: "JSON",
48
  };
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  // ------------------------------------------------------------------ filename
51
 
52
  /** `<view or cohort name> - <today>.<ext>` β€” `today` is the PAYLOAD's string
@@ -319,12 +421,18 @@ function buildStyles(codes: string[]): string {
319
  /** A minimal SpreadsheetML workbook: one sheet, NUMERIC kinds written as real
320
  * numbers with a number format that reproduces the grid's rendering, everything
321
  * else an inline string carrying the DISPLAY value (contract C2 β€” what the grid
322
- * shows is what the file says). */
 
 
 
 
 
323
  export function buildXlsx(
324
  fields: Field[],
325
  rows: Row[],
326
  name: string,
327
- today: string | undefined
 
328
  ): Uint8Array {
329
  const enc = new TextEncoder();
330
  const styles = styleTable();
@@ -378,11 +486,45 @@ export function buildXlsx(
378
  }
379
 
380
  const rowXml = (cells: string[]) => `<row>${cells.join("")}</row>`;
381
- const body = [
382
- // The header row is LABELS β€” text, and it stays text.
383
- rowXml(fields.map((f) => cell(f.label))),
384
- ...rows.map((r) => rowXml(fields.map((f) => dataCell(f, r[f.key])))),
385
- ].join("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  const sheet =
387
  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
388
  `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
@@ -590,13 +732,20 @@ export function triggerDownload(filename: string, blob: Blob): void {
590
  window.setTimeout(() => URL.revokeObjectURL(url), 4000);
591
  }
592
 
593
- /** One door for all four formats: build, wrap, download. */
 
 
 
 
 
 
594
  export function runExport(
595
  format: ExportFormat,
596
  name: string,
597
  today: string | undefined,
598
  fields: Field[],
599
- rows: Row[]
 
600
  ): void {
601
  const filename = exportFilename(name, today, format);
602
  if (format === "csv") {
@@ -617,7 +766,7 @@ export function runExport(
617
  if (format === "xlsx") {
618
  triggerDownload(
619
  filename,
620
- new Blob([buildXlsx(fields, rows, name, today).buffer as ArrayBuffer], {
621
  type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
622
  })
623
  );
 
27
  // the installed build) β€” so a Blob URL + a[download] click works in the embed.
28
  // ---------------------------------------------------------------------------
29
 
30
+ import type { Field, Row, VisibleRow } from "./types";
31
  import {
32
  formatDisplay,
33
  formulaIsBlank,
 
47
  json: "JSON",
48
  };
49
 
50
+ // --------------------------------------------------------------- grouping
51
+ //
52
+ // ⭐ WAVE 40, OWNER INSTRUCTION 22 (W40-T26) β€” *"A grouped View exported to Excel
53
+ // must show the grouping in the Excel file."* Until now the export ran its
54
+ // pipeline with `groupBy` hardcoded to null, so a view grouped by City exported
55
+ // as one undifferentiated block and the file said nothing the screen said.
56
+ //
57
+ // ⭐ AMENDMENT AM-3 (2026-08-23) IS THE CONTRACT: this ships VISIBLE grouping,
58
+ // not COLLAPSIBLE grouping. A labelled band row carrying the group value and its
59
+ // count precedes each block. Excel's +/- outline gutter needs `sheetFormatPr`,
60
+ // `outlineLevel` and friends in a strict `CT_Worksheet` element order; that is
61
+ // booked as a follow-up rather than built here, and NOTHING below invents a new
62
+ // OOXML element β€” the bands are ordinary text rows through the existing
63
+ // `rowXml()`/`cell()` helpers, which is why the container's invariants hold.
64
+
65
+ /** One VISIBLE group block: the bucket the pipeline formed, its band text and
66
+ * the rows that belong to it, in the order the grid paints them. */
67
+ export interface ExportGroup {
68
+ /** The bucket key exactly as `groupRows` formed it (the trimmed cell text; ""
69
+ * for the blank bucket, "1"/"" for a checkbox, ONE member of a multi cell). */
70
+ key: string;
71
+ /** The grid's own group-bar label for that key β€” "(empty)", "Checked",
72
+ * "Unchecked" or the cell text. Used verbatim when the key has no display
73
+ * rendering of its own, so the file never shows a nameless band. */
74
+ label: string;
75
+ /** The bucket's size, from the pipeline. Equal to `rows.length` here: the
76
+ * export always runs with an EMPTY collapsed set, so no bucket is folded. */
77
+ count: number;
78
+ rows: Row[];
79
+ }
80
+
81
+ /** The grouped shape handed to a builder. `field` is the column the view is
82
+ * grouped BY, so a band renders its value the same way that value renders in
83
+ * its own column (see `bandText`); absent if the field has gone missing. */
84
+ export interface ExportGrouping {
85
+ field?: Field;
86
+ blocks: ExportGroup[];
87
+ }
88
+
89
+ /**
90
+ * ⭐ WHICH FORMATS CARRY GROUPING, and it is a DECISION rather than an accident.
91
+ *
92
+ * EXCEL ONLY. The owner's words are "exported to Excel" and the ticket's
93
+ * `done-when` names the xlsx; the other three are left exactly as they are, in
94
+ * both the grouped and ungrouped case:
95
+ *
96
+ * Β· JSON is a RAW-VALUE contract β€” `{ fields, rows }` where every row is a
97
+ * record carrying its stable pid. A band has no pid, and grouping a MULTI
98
+ * field lists one record under EVERY member of its cell, so feeding the
99
+ * bucketed walk into this builder would put duplicate pids into a
100
+ * machine-readable file. That is a data defect, not a feature.
101
+ * Β· PDF is a paginated table: a band row is a layout change (band fill, page
102
+ * breaks, the repeated header) of exactly the kind AM-3 defers.
103
+ * Β· CSV has no band vocabulary and its rows would silently REORDER.
104
+ *
105
+ * β›” It selects which BUILDER receives the grouped structure. It must never
106
+ * decide which ROWS were matched: `exportRows` runs its record pipeline
107
+ * identically for all four formats, so the same view exported twice always
108
+ * carries the same records.
109
+ */
110
+ export function groupingApplies(format: ExportFormat): boolean {
111
+ return format === "xlsx";
112
+ }
113
+
114
+ /**
115
+ * β›”β›” THE SENTINEL WALK β€” the half of instruction 22 that is invisible from the
116
+ * outside, and the reason unhardcoding a `groupBy` on its own changes NOTHING.
117
+ *
118
+ * A grouped pipeline result is not a tree. It is a FLAT `VisibleRow[]`
119
+ * interleaving `data` rows with `group-header` / `group-footer` sentinels, and
120
+ * every caller in the grid narrows it with a `kind === "data"` filter that
121
+ * throws all of them away. The export did the same. So: a header OPENS a block,
122
+ * a footer CLOSES it, and a data row joins whichever block is open.
123
+ *
124
+ * ⚠ A data row arriving with NO open block is skipped rather than inventing an
125
+ * unnamed bucket for it. `groupRows` always emits a header first, so this is a
126
+ * defensive branch and not a case in the product β€” but a nameless band is worse
127
+ * than a missing one, because it looks deliberate.
128
+ *
129
+ * ⚠ A footer only ever ENDS a block, so the table-wide total footer the grid
130
+ * appends under `TOTAL_GROUP_KEY` cannot open a phantom group here.
131
+ *
132
+ * Lives in `export.ts` rather than inline at the call site so a node gate can
133
+ * reach it: this walk is exactly the kind of thing that is wrong in a way no
134
+ * screenshot shows.
135
+ */
136
+ export function toExportGroups(bucketed: VisibleRow[]): ExportGroup[] {
137
+ const blocks: ExportGroup[] = [];
138
+ let block: ExportGroup | null = null;
139
+ for (const vr of bucketed) {
140
+ if (vr.kind === "group-header") {
141
+ block = { key: vr.groupKey, label: vr.label, count: vr.count, rows: [] };
142
+ blocks.push(block);
143
+ } else if (vr.kind === "group-footer") {
144
+ block = null;
145
+ } else if (block) {
146
+ block.rows.push(vr.record);
147
+ }
148
+ }
149
+ return blocks;
150
+ }
151
+
152
  // ------------------------------------------------------------------ filename
153
 
154
  /** `<view or cohort name> - <today>.<ext>` β€” `today` is the PAYLOAD's string
 
421
  /** A minimal SpreadsheetML workbook: one sheet, NUMERIC kinds written as real
422
  * numbers with a number format that reproduces the grid's rendering, everything
423
  * else an inline string carrying the DISPLAY value (contract C2 β€” what the grid
424
+ * shows is what the file says).
425
+ *
426
+ * ⭐ W40-T26. `grouping` is OPTIONAL and absent means UNGROUPED: the body is
427
+ * then built from `rows` by the same expression as before, so an ungrouped
428
+ * export is byte-identical to the pre-grouping writer. Present, it replaces the
429
+ * flat body with band-then-block, and `rows` is not read. */
430
  export function buildXlsx(
431
  fields: Field[],
432
  rows: Row[],
433
  name: string,
434
+ today: string | undefined,
435
+ grouping?: ExportGrouping
436
  ): Uint8Array {
437
  const enc = new TextEncoder();
438
  const styles = styleTable();
 
486
  }
487
 
488
  const rowXml = (cells: string[]) => `<row>${cells.join("")}</row>`;
489
+ const dataRow = (r: Row) => rowXml(fields.map((f) => dataCell(f, r[f.key])));
490
+
491
+ /**
492
+ * ⭐ W40-T26 / AM-3 β€” THE BAND'S TEXT: the group's VALUE and its COUNT, and
493
+ * nothing else. Not a sentence about grouping (DESIGN.md Β§4), not the canvas's
494
+ * β–Ό/β–Ά marker (there is nothing to collapse in a file), no dash of any kind.
495
+ *
496
+ * ⭐ THE VALUE GOES THROUGH `formatDisplay`, THE SAME FUNCTION EVERY OTHER
497
+ * EXPORTED VALUE GOES THROUGH. A date bucket's key is the raw "2026-07-28" and
498
+ * that column's own cells arrive in Excel as serials rendered "Jul 28, 2026";
499
+ * printing the key verbatim would make one file say the same value two ways.
500
+ *
501
+ * β›” AND IT FALLS BACK, because `formatDisplay` returns "" for exactly the
502
+ * buckets that most need a name: the blank bucket ("" -> "(empty)") and an
503
+ * unchecked checkbox ("" -> "Unchecked"). The pipeline's own label is the
504
+ * grid's group-bar wording, so the fallback is the screen's answer rather than
505
+ * a second one invented here.
506
+ */
507
+ const bandText = (g: ExportGroup): string => {
508
+ const shown = grouping?.field ? formatDisplay(grouping.field, g.key) : "";
509
+ return `${shown || g.label} (${g.count.toLocaleString()})`;
510
+ };
511
+
512
+ // The header row is LABELS β€” text, and it stays text.
513
+ const bodyRows = [rowXml(fields.map((f) => cell(f.label)))];
514
+ if (grouping) {
515
+ // A band is ONE text cell. Cells carry no `r` attribute in this writer, so
516
+ // position is order and a one-cell row lands in column A β€” the same
517
+ // mechanism every other row here already relies on. No merge, no span:
518
+ // `mergeCells` is a new CT_Worksheet element and AM-3 defers that class of
519
+ // change.
520
+ for (const g of grouping.blocks) {
521
+ bodyRows.push(rowXml([cell(bandText(g))]));
522
+ for (const r of g.rows) bodyRows.push(dataRow(r));
523
+ }
524
+ } else {
525
+ for (const r of rows) bodyRows.push(dataRow(r));
526
+ }
527
+ const body = bodyRows.join("");
528
  const sheet =
529
  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
530
  `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
 
732
  window.setTimeout(() => URL.revokeObjectURL(url), 4000);
733
  }
734
 
735
+ /** One door for all four formats: build, wrap, download.
736
+ *
737
+ * ⭐ W40-T26. `grouping` reaches the xlsx builder and nothing else β€” see
738
+ * `groupingApplies` for why, and note that the CALLER has already decided: it
739
+ * passes `undefined` for every format that does not carry bands, so the three
740
+ * other builders are called with exactly the arguments they were called with
741
+ * before this ticket existed. */
742
  export function runExport(
743
  format: ExportFormat,
744
  name: string,
745
  today: string | undefined,
746
  fields: Field[],
747
+ rows: Row[],
748
+ grouping?: ExportGrouping
749
  ): void {
750
  const filename = exportFilename(name, today, format);
751
  if (format === "csv") {
 
766
  if (format === "xlsx") {
767
  triggerDownload(
768
  filename,
769
+ new Blob([buildXlsx(fields, rows, name, today, grouping).buffer as ArrayBuffer], {
770
  type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
771
  })
772
  );
web/src/customer-grid/folders.ts CHANGED
@@ -1,492 +1,572 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / folders.ts
3
- // Wave-8 I11c (contract C4) β€” the folder MODEL for the Views and Cohorts rails,
4
- // pure and React-free so verify_folders.py can run it under node.
5
- //
6
- // One level, deliberately. Nesting brings cycle-checking, move-into-your-own-
7
- // descendant, and recursive delete semantics with it; the owner asked for
8
- // folders you can drag things into, and a flat model is the whole of that.
9
- //
10
- // THE PART THAT IS EASY TO GET WRONG β€” the echo. Folder operations ride the
11
- // same once-by-id event log as everything else, so between the emit and the
12
- // host's echo there is a window where the payload still describes the world as
13
- // it was BEFORE the click. Render that naively and a just-deleted folder
14
- // reappears for one round trip (the "delete blip"), a rename flickers back to
15
- // the old name, and a dragged view jumps home. So this module reconciles the
16
- // host's copy against this browser's own recent stamps, exactly as
17
- // optimism.ts::reconcileFields does for fields β€” same ECHO_RECENT_MS window,
18
- // same rule that a STALE stamp yields to the host (divergence is not an echo).
19
- // ---------------------------------------------------------------------------
20
-
21
- import { ECHO_RECENT_MS } from "./viewEcho";
22
- import type { GridFolder } from "./types";
23
-
24
- /** What this browser did recently, by folder id / item id. Values are epoch ms
25
- * from THIS machine's clock β€” both sides of every comparison are local, so
26
- * this is not the tenant-day rule (that one is about two ENGINES agreeing). */
27
- export interface FolderStamps {
28
- created?: Record<string, number>;
29
- renamed?: Record<string, { at: number; name: string }>;
30
- deleted?: Record<string, number>;
31
- /** itemId -> {at, folderId} for a drag this browser just performed. */
32
- moved?: Record<string, { at: number; folderId: string | null }>;
33
- /**
34
- * WAVE 20 item 19 (C-FOLDER-REORDER) β€” the FULL folder order this browser just set.
35
- *
36
- * ONE stamp, not one per folder, because a reorder is one decision about a list: the
37
- * order the user dropped into is the order they want, and reconstructing it from N
38
- * per-folder stamps would let two of them age out at different moments and leave a
39
- * sequence nobody ever chose. The host answers with `order` NUMBERS on each folder
40
- * (that is the durable form); this is what to render until it does.
41
- */
42
- ordered?: { at: number; order: string[] };
43
- /**
44
- * ⭐ WAVE 27 Β· OWNER ITEM 5 (contract C7) β€” the FULL VIEW order this browser just set.
45
- *
46
- * A separate stamp from `ordered` above, deliberately, even though both are "a list this
47
- * browser dragged into shape": they age independently and they are different decisions. One
48
- * stamp holding both would make reordering a folder revive a view order the user had already
49
- * let go of, and vice versa β€” the exact "a sequence nobody ever chose" failure `ordered`'s own
50
- * note refuses one level down.
51
- */
52
- orderedViews?: { at: number; order: string[] };
53
- }
54
-
55
- export const FOLDER_STAMP_MAX = 64;
56
-
57
- const isRecent = (t: number | undefined, now: number): boolean =>
58
- typeof t === "number" && now - t <= ECHO_RECENT_MS;
59
-
60
- /** Drop stamps past the echo window so the map cannot grow without bound and a
61
- * long-lived tab cannot keep asserting an edit nobody remembers. */
62
- export function pruneFolderStamps(stamps: FolderStamps | undefined, now: number): FolderStamps {
63
- const out: FolderStamps = {};
64
- const keepNum = (rec: Record<string, number> | undefined) => {
65
- if (!rec) return undefined;
66
- const kept = Object.entries(rec).filter(([, t]) => isRecent(t, now));
67
- return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
68
- };
69
- const keepObj = <T extends { at: number }>(rec: Record<string, T> | undefined) => {
70
- if (!rec) return undefined;
71
- const kept = Object.entries(rec).filter(([, v]) => isRecent(v.at, now));
72
- return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
73
- };
74
- const created = keepNum(stamps?.created);
75
- const renamed = keepObj(stamps?.renamed);
76
- const deleted = keepNum(stamps?.deleted);
77
- const moved = keepObj(stamps?.moved);
78
- if (created) out.created = created;
79
- if (renamed) out.renamed = renamed;
80
- if (deleted) out.deleted = deleted;
81
- if (moved) out.moved = moved;
82
- // Item 19: a single stamp, so it is kept or dropped whole β€” pruning it by halves is
83
- // exactly the partial sequence the field's own note refuses.
84
- if (isRecent(stamps?.ordered?.at, now) && stamps?.ordered) out.ordered = stamps.ordered;
85
- // Item 5 (C7): the same rule for the VIEW order, kept or dropped whole for the same reason.
86
- if (isRecent(stamps?.orderedViews?.at, now) && stamps?.orderedViews)
87
- out.orderedViews = stamps.orderedViews;
88
- return out;
89
- }
90
-
91
- /**
92
- * ⭐ WAVE 27 Β· OWNER ITEM 5 (contract C7) β€” the rail's view order, as this browser last set it.
93
- *
94
- * β›” WHY IT IS NEEDED AT ALL: the server assembles view order (`aios_grid.py:1602-1647`) and the
95
- * echo is one round trip behind the drop. Without this the row springs back to its old place the
96
- * instant the workspace refreshes, which reads as "the drag did not work" β€” the NO-BLIP law's
97
- * subject, applied to a sequence instead of to a value.
98
- *
99
- * ⚠ PAST THE ECHO WINDOW THE SERVER WINS, unconditionally. That asymmetry is the whole design of
100
- * this module: inside the window a local drag is newer truth; outside it, a difference between
101
- * the copies is divergence between SESSIONS, and the durable store decides.
102
- *
103
- * β›” IDS THE STAMP DOES NOT NAME KEEP THEIR SERVER ORDER, appended after the named ones β€” the
104
- * same rule the host applies to a `folder_reorder` payload. A view created in another tab since
105
- * the drop is not evidence that the drop was wrong; dropping it would be this function deleting
106
- * a view from the rail to defend a sequence.
107
- */
108
- export function applyViewOrder<T extends { id: string }>(
109
- views: T[],
110
- stamps: FolderStamps | undefined,
111
- now: number
112
- ): T[] {
113
- const stamp = stamps?.orderedViews;
114
- if (!stamp || !isRecent(stamp.at, now) || !Array.isArray(stamp.order)) return views;
115
- const byId = new Map(views.map((v) => [v.id, v]));
116
- const out: T[] = [];
117
- const placed = new Set<string>();
118
- for (const id of stamp.order) {
119
- const v = byId.get(id);
120
- if (!v || placed.has(id)) continue;
121
- placed.add(id);
122
- out.push(v);
123
- }
124
- for (const v of views) if (!placed.has(v.id)) out.push(v);
125
- return out;
126
- }
127
-
128
- /**
129
- * The host's folder list, corrected by what this browser just did.
130
- *
131
- * deleted recently -> DROP it, even though the echo still lists it
132
- * (the tombstone rule; without this a deleted folder
133
- * blinks back for one round trip)
134
- * renamed recently -> keep OUR name until the echo carries it
135
- * created recently -> keep OURS if the echo has not caught up yet
136
- *
137
- * Everything stale yields to the host: past the window, a difference between
138
- * the copies is divergence between sessions, and host state is the durable
139
- * truth. That asymmetry is the whole design.
140
- */
141
- export function reconcileFolders(
142
- hostFolders: GridFolder[] | undefined,
143
- localFolders: GridFolder[] | undefined,
144
- stamps: FolderStamps | undefined,
145
- now: number
146
- ): GridFolder[] {
147
- const host = hostFolders ?? [];
148
- const out: GridFolder[] = [];
149
- const seen = new Set<string>();
150
-
151
- for (const f of host) {
152
- if (isRecent(stamps?.deleted?.[f.id], now)) continue; // tombstone
153
- seen.add(f.id);
154
- const rename = stamps?.renamed?.[f.id];
155
- out.push(isRecent(rename?.at, now) && rename ? { ...f, name: rename.name } : f);
156
- }
157
-
158
- // A folder this browser created that the echo has not yet returned. Skipped
159
- // when it was also deleted since β€” creating and deleting inside one window
160
- // must net to nothing, not to a ghost.
161
- for (const f of localFolders ?? []) {
162
- if (seen.has(f.id)) continue;
163
- if (!isRecent(stamps?.created?.[f.id], now)) continue;
164
- if (isRecent(stamps?.deleted?.[f.id], now)) continue;
165
- out.push(f);
166
- }
167
-
168
- out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
169
-
170
- // ── WAVE 20 item 19 (C-FOLDER-REORDER): this browser's drag, until the echo carries it.
171
- //
172
- // Applied AFTER the host sort and as a SEPARATE pass, both deliberately:
173
- // Β· the host's `order` numbers are the durable truth and stay the base sequence, so a
174
- // folder the stamp never names keeps exactly the place the server gave it;
175
- // Β· `Array.prototype.sort` is stable (ES2019), so every unnamed folder β€” one created in
176
- // another tab between the drag and the echo, say β€” holds its relative position at the
177
- // end instead of being flung to the front by a missing rank.
178
- // A stamped id that has since been DELETED needs no handling: the tombstone pass above
179
- // already dropped it, and `rank` is only ever consulted for folders that survived.
180
- const ordered = stamps?.ordered;
181
- if (isRecent(ordered?.at, now) && ordered) {
182
- const rank = new Map(ordered.order.map((id, i) => [id, i]));
183
- out.sort(
184
- (a, b) =>
185
- (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
186
- (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)
187
- );
188
- }
189
- return out;
190
- }
191
-
192
- /**
193
- * Where an item actually belongs right now: this browser's recent drag wins
194
- * over the host's echo, and a folder that no longer exists resolves to ROOT.
195
- *
196
- * The second half matters as much as the first. `folder_delete` moves contents
197
- * to root host-side, but the client sees the folder vanish one render before
198
- * the items' `folderId` is rewritten β€” and an item pointing at a folder nobody
199
- * renders would simply not appear in any group. Resolving a dangling ref to
200
- * root is what stops a folder delete from making views look deleted too.
201
- */
202
- export function resolveFolderId(
203
- itemId: string,
204
- hostFolderId: string | null | undefined,
205
- folders: GridFolder[],
206
- stamps: FolderStamps | undefined,
207
- now: number
208
- ): string | null {
209
- const moved = stamps?.moved?.[itemId];
210
- const id = isRecent(moved?.at, now) && moved ? moved.folderId : (hostFolderId ?? null);
211
- if (id == null) return null;
212
- // ⚠ W32-T27 β€” the RESERVED root placement is not a dangling reference. It names no folder by
213
- // design, so the `folders.some(...)` test below would null it and hand the item straight back
214
- // to the Shared bucket, re-creating item 20 one layer down from where it was fixed.
215
- if (id === ROOT_FOLDER_ID) return ROOT_FOLDER_ID;
216
- return folders.some((f) => f.id === id) ? id : null;
217
- }
218
-
219
- export interface FolderGroup<T> {
220
- folder: GridFolder | null; // null = the root group
221
- items: T[];
222
- }
223
-
224
- /**
225
- * WAVE 20 item 18 / WAVE 21 item 9 (ruling R12, contract C1) β€” the SYNTHETIC folder
226
- * that every view shared WITH you appears under.
227
- *
228
- * β›” IT IS NOT A STORED FOLDER, and nothing may ever write one with this id. It has no
229
- * record in `folders`, no `order`, no icon, and the rail refuses every action on it
230
- * (`ViewSidebar` suppresses the row menu for exactly this id): it cannot be renamed into
231
- * something else, duplicated into a second copy of other people's work, or deleted. It is a
232
- * READING of the view list β€” "these arrived by grant" β€” rendered as a group because that is
233
- * the only shape this rail has for "a set of views with something in common".
234
- *
235
- * Declared HERE rather than in the component (where it lived through wave 20) so the
236
- * synthesis below is pure, and `verify_folders.py` can run it under node like every other
237
- * rule in this file. A constant a gate cannot reach is a contract nobody checks.
238
- */
239
- export const SHARED_FOLDER_ID = "__shared__";
240
- export const SHARED_FOLDER_NAME = "Shared with me";
241
-
242
- /**
243
- * ⭐⭐ WAVE 34 Β· T25 (ruling R11) β€” A SHARED **FOLDER** GETS ITS OWN GROUP, under this prefix.
244
- *
245
- * R11: *"A folder (with the Views inside it) can be shared with a team member."* Everything below
246
- * the client already worked and was VERIFIED before this was written: `core/shares.py::KINDS` has
247
- * carried `'folder'` since wave 20, the rail already offers "Share folder", and
248
- * `grid_events.py::_granted_views` has projected each granted folder's views since `D-37`. What
249
- * did not work is the last inch: every shared view landed in ONE flat "Shared with me" group, so
250
- * the receiver got loose views and **the folder itself was invisible**.
251
- *
252
- * ⭐ The name has been on the wire the whole time. `_granted_views` stamps
253
- * `v['sharedFolder'] = <folder name>` with the comment *"so the client can group these together
254
- * later"*, and a census found ZERO readers of it anywhere in `web/src` β€” a flag shipped without
255
- * its reader. This is the reader.
256
- *
257
- * ⚠ A PREFIX RATHER THAN ONE RESERVED ID, because there can be several: two colleagues can each
258
- * share a folder with you. {@link isSyntheticFolderId} is what every affordance must ask instead
259
- * of comparing against `SHARED_FOLDER_ID` β€” a rail that suppressed actions on one synthetic group
260
- * and offered them on another would emit `item_move`/`folder_rename` against a folder id that
261
- * exists in nobody's store.
262
- *
263
- * β›”β›” THE KEY IS **OWNER + NAME**, NOT NAME, AND THE FIRST DRAFT OF THIS FUNCTION GOT IT WRONG.
264
- * It keyed on the folder name alone while the comment right here claimed to handle two colleagues
265
- * sharing folders with the SAME NAME β€” so those two folders merged into ONE group, and the head's
266
- * `title` (which reads the first item's `owner`) misattributed every view from the second sharer.
267
- * Per-view `sharedRole` was never affected, so nothing leaked; what broke was the rail telling you
268
- * whose work you were reading, which is the entire point of the group. **A comment that describes
269
- * the case the code does not handle is worse than no comment**, because it is what a reviewer
270
- * checks against. Found by this ticket's own verifier reading the two side by side.
271
- *
272
- * β›” NOTHING MAY EVER WRITE ONE. Like `SHARED_FOLDER_ID` these ids are a READING of the view list,
273
- * not a record: no `order`, no icon, no row in `folders`.
274
- */
275
- export const SHARED_FOLDER_PREFIX = "__sharedfld__:";
276
-
277
- /**
278
- * The synthetic group id for a folder somebody shared with you.
279
- *
280
- * ⚠ `owner` FIRST and separated by a character usernames cannot contain. `core/users.py` keeps
281
- * usernames lower-case and non-empty; a folder NAME is free text and can contain anything a user
282
- * types, `:` included. Putting the free-text half last means no pair of (owner, name) can spell
283
- * another pair's id, whatever somebody calls a folder.
284
- */
285
- export function sharedFolderGroupId(owner: string, name: string): string {
286
- return `${SHARED_FOLDER_PREFIX}${owner}:${name}`;
287
- }
288
-
289
- /**
290
- * Is this group id SYNTHESISED rather than stored? True for "Shared with me" and for every
291
- * shared-folder group. Every drag, drop, rename and delete affordance in the rail asks this.
292
- */
293
- export function isSyntheticFolderId(id: string | null | undefined): boolean {
294
- return id === SHARED_FOLDER_ID
295
- || (typeof id === "string" && id.startsWith(SHARED_FOLDER_PREFIX));
296
- }
297
-
298
- /**
299
- * ⭐⭐ WAVE 32 Β· T27 (owner item 20) β€” **"FILED AT ROOT", AS A VALUE.**
300
- *
301
- * Owner: *a shared View cannot be moved out of the Shared folder.* The cause is that **root was
302
- * represented by ABSENCE at every layer**, and absence cannot distinguish two different facts:
303
- *
304
- * Β· `folderId == null` because the receiver never filed this view β€” it should show under
305
- * "Shared with me", which is where a grant LANDS;
306
- * Β· `folderId == null` because the receiver deliberately dragged it OUT of that group.
307
- *
308
- * `groupByFolder` had to guess, and it guessed "shared" β€” so filing a shared view at root put it
309
- * straight back where it came from. **The root bucket was unreachable for a shared view by
310
- * construction**, which is exactly why only folder→folder moves ever appeared to work.
311
- *
312
- * β›” THE SENTINEL IS STORED, NOT DERIVED, AND THAT IS THE WHOLE FIX. Both other layers wrote the
313
- * same absence and must both learn this value: `core/grid_events.py`'s `item_move` branch
314
- * (`if target is None: cur.pop(item_id, None) # back to the root`) and
315
- * `aios_grid.clean_item_folders`, whose own docstring states the defect one level deeper β€”
316
- * *"nothing stores 'this item is in no folder'"*. A client-only fix is impossible; there is
317
- * nothing to read back.
318
- *
319
- * ⚠ It is a RESERVED id in the same namespace as real folder ids, so `resolveFolderId` must pass
320
- * it through rather than treating it as dangling, and `clean_item_folders` must admit it beside
321
- * `fid in fids`. It is deliberately NOT rendered as a group: {@link groupByFolder} maps it onto
322
- * the ordinary root bucket, so nothing in the rail ever shows the word.
323
- */
324
- export const ROOT_FOLDER_ID = "__root__";
325
-
326
- /**
327
- * Group items into folders + a root bucket, in folder order, root LAST.
328
- *
329
- * Root last because the rails are read top-down and folders are the structure
330
- * the user made; ungrouped items are the leftovers. Every item appears exactly
331
- * once β€” a grouping that can drop an item would make a view look deleted.
332
- *
333
- * ⭐ WAVE 21 item 9 (R12/C1) β€” `isShared` adds the synthetic "Shared with me" group,
334
- * AFTER root, and three things about it are deliberate:
335
- *
336
- * Β· **After root, not before it.** C1 says LAST in as many words. It reads correctly
337
- * too: the rail is "my folders, my loose views, and then other people's".
338
- * Β· **A shared view the receiver has FILED still goes to their folder.** The `__shared__`
339
- * group is where a grant LANDS, not a cage it stays in β€” the rail's own note calls
340
- * moving out "per-receiver placement" and that must keep working. So the synthetic
341
- * group collects only the shared views that resolved to ROOT.
342
- * Β· **An empty group does not render.** A folder head with nothing under it says "here is
343
- * something you cannot reach" β€” the same reason `foldNav` drops empty nav folders.
344
- *
345
- * Omitting `isShared` leaves the function byte-identical to the pre-wave-21 one, which is
346
- * what every existing caller (the cohort rail, the tests) still gets.
347
- */
348
- export function groupByFolder<T>(
349
- items: T[],
350
- folders: GridFolder[],
351
- folderIdOf: (item: T) => string | null,
352
- isShared?: (item: T) => boolean,
353
- /**
354
- * ⭐ WAVE 34 Β· T25 (R11) β€” the folder a shared item arrived through, if it did.
355
- *
356
- * Returns the folder's NAME and its OWNER, because the pair is the identity: the wire carries
357
- * no folder id for a grant (`_granted_views` stamps a name and an owner, never an id), and the
358
- * name alone is not unique across sharers.
359
- *
360
- * Optional, so every existing caller (the cohort rail, the tests) keeps the pre-T25 shape
361
- * byte-for-byte: with it omitted, every shared item falls into the flat "Shared with me"
362
- * group exactly as before. Supplied, the shared views SPLIT by the folder that carried them,
363
- * and the folder finally appears on the receiver's screen.
364
- */
365
- sharedFolderOf?: (item: T) => { name: string; owner: string } | null
366
- ): FolderGroup<T>[] {
367
- const buckets = new Map<string, T[]>(folders.map((f) => [f.id, []]));
368
- const root: T[] = [];
369
- const shared: T[] = [];
370
- /** synthetic group id -> {name, views}. Keyed by OWNER+NAME; see `SHARED_FOLDER_PREFIX`. */
371
- const sharedFolders = new Map<string, { name: string; items: T[] }>();
372
- for (const item of items) {
373
- const id = folderIdOf(item);
374
- const bucket = id == null ? undefined : buckets.get(id);
375
- if (bucket) bucket.push(item);
376
- // ⭐⭐ W32-T27 (owner item 20) β€” THE ROOT BUCKET IS REACHABLE FOR A SHARED VIEW NOW.
377
- // `ROOT_FOLDER_ID` is the receiver saying "I filed this at the top level"; absence still
378
- // means "this arrived by grant and I have not filed it". Before this line the two were one
379
- // value, `isShared` won, and a shared view dragged to root returned to "Shared with me" on
380
- // the next render β€” the owner's item 20, in one branch.
381
- else if (id === ROOT_FOLDER_ID) root.push(item);
382
- else if (isShared?.(item)) {
383
- // ⭐ W34-T25 (R11): a shared view that arrived through a FOLDER goes to that folder's own
384
- // group; one that was granted directly still goes to the flat "Shared with me". The two
385
- // are different facts and the receiver can see which is which.
386
- const via = sharedFolderOf?.(item);
387
- const name = via ? String(via.name || "").trim() : "";
388
- const owner = via ? String(via.owner || "").trim() : "";
389
- // ⚠ BOTH halves required. A folder name with no owner cannot be told apart from another
390
- // sharer's folder of the same name, so it falls back to the flat group rather than
391
- // inventing a group that might merge two people's work under one person's label.
392
- if (name && owner) {
393
- const key = sharedFolderGroupId(owner, name);
394
- const bucket2 = sharedFolders.get(key);
395
- if (bucket2) bucket2.items.push(item);
396
- else sharedFolders.set(key, { name, items: [item] });
397
- } else shared.push(item);
398
- }
399
- else root.push(item);
400
- }
401
- const out: FolderGroup<T>[] = folders.map((f) => ({ folder: f, items: buckets.get(f.id) ?? [] }));
402
- out.push({ folder: null, items: root });
403
- if (shared.length)
404
- out.push({ folder: { id: SHARED_FOLDER_ID, name: SHARED_FOLDER_NAME }, items: shared });
405
- // ⚠ SORTED, not first-seen. A Map preserves insertion order, which here is the order the VIEWS
406
- // happen to arrive in β€” so one view moving could reorder whole folders in the rail for no reason
407
- // a reader could name. Sorting on the composite ID (owner first) is stable across every payload
408
- // AND groups one colleague's folders together, which reads better than interleaving two people's
409
- // folders alphabetically by name.
410
- for (const key of [...sharedFolders.keys()].sort((a, b) => a.localeCompare(b))) {
411
- const grp = sharedFolders.get(key)!;
412
- out.push({ folder: { id: key, name: grp.name }, items: grp.items });
413
- }
414
- return out;
415
- }
416
-
417
- /**
418
- * WAVE 20 item 19 (C-FOLDER-REORDER) β€” where a dragged folder lands: the full order with
419
- * `draggedId` moved to sit immediately BEFORE `beforeId`, or last when that is null (the
420
- * drop on the ungrouped section below every folder).
421
- *
422
- * Here rather than inside the rail because it is the only part of the drag a test can hold:
423
- * the drop handler is DOM, the emit is the caller's, and this is the arithmetic that decides
424
- * what the user sees. `null` means "emit nothing" β€” an unknown id, or a drop that changes
425
- * nothing. Returning the unchanged array instead would be worse than useless: the caller
426
- * cannot tell it apart from a real reorder, so every no-op drag would write the store, bump
427
- * every reader's payload, and reconcile to the identical list.
428
- *
429
- * ⚠ The dragged id is REMOVED BEFORE the target index is read. Taking the index first and
430
- * splicing after is the classic off-by-one here: dragging a folder DOWNWARD would land it one
431
- * place short of where it was dropped, and only in that direction β€” the shape of bug that
432
- * survives a demo and gets reported as "it sometimes doesn't move".
433
- */
434
- export function reorderFolderIds(
435
- ids: string[],
436
- draggedId: string,
437
- beforeId: string | null
438
- ): string[] | null {
439
- if (!ids.includes(draggedId)) return null;
440
- // β›” DROPPED ON ITSELF. Without this the id is filtered out, `indexOf` cannot find its own
441
- // target, and the "not found" branch sends the folder to the END β€” so releasing a drag over
442
- // the folder you picked up would quietly move it to the bottom of the rail. Found by this
443
- // function's own gate the minute the arithmetic left the component; the drop handler's
444
- // indicator suppresses the same case visually, which is exactly why it would never have
445
- // been noticed there.
446
- if (beforeId === draggedId) return null;
447
- const rest = ids.filter((id) => id !== draggedId);
448
- const found = beforeId ? rest.indexOf(beforeId) : -1;
449
- const at = found < 0 ? rest.length : found;
450
- const next = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
451
- if (next.length === ids.length && next.every((id, i) => id === ids[i])) return null;
452
- return next;
453
- }
454
-
455
- /**
456
- * ⭐⭐ WAVE 34 Β· T22 (ruling R4, `D-249`) β€” MAY THIS VIEWER FILE THIS VIEW INTO A FOLDER?
457
- *
458
- * β›” NOT THE SAME QUESTION AS `mayEditView`, AND CONFLATING THEM IS THE DEFECT. The rail gated its
459
- * `draggable` on `mayEditView`, which fail-closes on a shared view whose role is not `edit` β€” so a
460
- * read-only shared view could not BEGIN a drag, and the owner's report is that a view cannot be
461
- * taken out of a folder. Filing is not a content edit: it writes a PLACEMENT into the receiver's
462
- * own workspace stratum and never touches the owner's object.
463
- *
464
- * ⭐ THE SERVER ALREADY AGREED, IN TWO PLACES, BEFORE THIS FUNCTION EXISTED. `grid_events.py::
465
- * table_workspace` merges granted views into `ws['views']`, which is the very set `item_move`'s
466
- * own-ids test reads β€” so the write this drag produces is ACCEPTED today. And the `view_reorder`
467
- * branch beside it carries the rule in prose: *"NO OWNERSHIP FILTER … refusing ids they do not own
468
- * would make exactly those un-draggable, which is the half of sharing people notice."* The client
469
- * was the only refuser, and it was refusing a write the server was ready to take.
470
- *
471
- * ⚠ `mayEdit` IS A PARAMETER, NOT AN IMPORT, and that is deliberate: this module is
472
- * "pure and React-free so verify_folders.py can run it under node" (see the header), and its only
473
- * imports today are one constant and one type. Taking the verdict rather than the resolver keeps
474
- * the truth table testable in isolation and keeps this file's dependency surface where its own
475
- * header promised it would stay.
476
- *
477
- * ⚠ USE IT ONLY AT THE DRAG SITE. Every other affordance the rail gates on `mayEditView` β€” rename,
478
- * description, delete, permissions β€” really is a content edit on somebody else's object, and the
479
- * server really does refuse those.
480
- */
481
- export function mayFileView(view: { shared?: boolean }, mayEdit: boolean): boolean {
482
- return view.shared === true || mayEdit;
483
- }
484
-
485
- /** A fresh folder id. Client-generated, like every other id in this component. */
486
- export function newFolderId(): string {
487
- const rand =
488
- typeof crypto !== "undefined" && "randomUUID" in crypto
489
- ? crypto.randomUUID().slice(0, 8)
490
- : Math.random().toString(36).slice(2, 10);
491
- return `fld_${rand}`;
492
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / folders.ts
3
+ // Wave-8 I11c (contract C4) β€” the folder MODEL for the Views and Cohorts rails,
4
+ // pure and React-free so verify_folders.py can run it under node.
5
+ //
6
+ // One level, deliberately. Nesting brings cycle-checking, move-into-your-own-
7
+ // descendant, and recursive delete semantics with it; the owner asked for
8
+ // folders you can drag things into, and a flat model is the whole of that.
9
+ //
10
+ // THE PART THAT IS EASY TO GET WRONG β€” the echo. Folder operations ride the
11
+ // same once-by-id event log as everything else, so between the emit and the
12
+ // host's echo there is a window where the payload still describes the world as
13
+ // it was BEFORE the click. Render that naively and a just-deleted folder
14
+ // reappears for one round trip (the "delete blip"), a rename flickers back to
15
+ // the old name, and a dragged view jumps home. So this module reconciles the
16
+ // host's copy against this browser's own recent stamps, exactly as
17
+ // optimism.ts::reconcileFields does for fields β€” same ECHO_RECENT_MS window,
18
+ // same rule that a STALE stamp yields to the host (divergence is not an echo).
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { ECHO_RECENT_MS } from "./viewEcho";
22
+ import type { GridFolder } from "./types";
23
+
24
+ /** What this browser did recently, by folder id / item id. Values are epoch ms
25
+ * from THIS machine's clock β€” both sides of every comparison are local, so
26
+ * this is not the tenant-day rule (that one is about two ENGINES agreeing). */
27
+ export interface FolderStamps {
28
+ created?: Record<string, number>;
29
+ renamed?: Record<string, { at: number; name: string }>;
30
+ deleted?: Record<string, number>;
31
+ /** itemId -> {at, folderId} for a drag this browser just performed. */
32
+ moved?: Record<string, { at: number; folderId: string | null }>;
33
+ /**
34
+ * WAVE 20 item 19 (C-FOLDER-REORDER) β€” the FULL folder order this browser just set.
35
+ *
36
+ * ONE stamp, not one per folder, because a reorder is one decision about a list: the
37
+ * order the user dropped into is the order they want, and reconstructing it from N
38
+ * per-folder stamps would let two of them age out at different moments and leave a
39
+ * sequence nobody ever chose. The host answers with `order` NUMBERS on each folder
40
+ * (that is the durable form); this is what to render until it does.
41
+ */
42
+ ordered?: { at: number; order: string[] };
43
+ /**
44
+ * ⭐ WAVE 27 Β· OWNER ITEM 5 (contract C7) β€” the FULL VIEW order this browser just set.
45
+ *
46
+ * A separate stamp from `ordered` above, deliberately, even though both are "a list this
47
+ * browser dragged into shape": they age independently and they are different decisions. One
48
+ * stamp holding both would make reordering a folder revive a view order the user had already
49
+ * let go of, and vice versa β€” the exact "a sequence nobody ever chose" failure `ordered`'s own
50
+ * note refuses one level down.
51
+ */
52
+ orderedViews?: { at: number; order: string[] };
53
+ }
54
+
55
+ export const FOLDER_STAMP_MAX = 64;
56
+
57
+ const isRecent = (t: number | undefined, now: number): boolean =>
58
+ typeof t === "number" && now - t <= ECHO_RECENT_MS;
59
+
60
+ /** Drop stamps past the echo window so the map cannot grow without bound and a
61
+ * long-lived tab cannot keep asserting an edit nobody remembers. */
62
+ export function pruneFolderStamps(stamps: FolderStamps | undefined, now: number): FolderStamps {
63
+ const out: FolderStamps = {};
64
+ const keepNum = (rec: Record<string, number> | undefined) => {
65
+ if (!rec) return undefined;
66
+ const kept = Object.entries(rec).filter(([, t]) => isRecent(t, now));
67
+ return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
68
+ };
69
+ const keepObj = <T extends { at: number }>(rec: Record<string, T> | undefined) => {
70
+ if (!rec) return undefined;
71
+ const kept = Object.entries(rec).filter(([, v]) => isRecent(v.at, now));
72
+ return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
73
+ };
74
+ const created = keepNum(stamps?.created);
75
+ const renamed = keepObj(stamps?.renamed);
76
+ const deleted = keepNum(stamps?.deleted);
77
+ const moved = keepObj(stamps?.moved);
78
+ if (created) out.created = created;
79
+ if (renamed) out.renamed = renamed;
80
+ if (deleted) out.deleted = deleted;
81
+ if (moved) out.moved = moved;
82
+ // Item 19: a single stamp, so it is kept or dropped whole β€” pruning it by halves is
83
+ // exactly the partial sequence the field's own note refuses.
84
+ if (isRecent(stamps?.ordered?.at, now) && stamps?.ordered) out.ordered = stamps.ordered;
85
+ // Item 5 (C7): the same rule for the VIEW order, kept or dropped whole for the same reason.
86
+ if (isRecent(stamps?.orderedViews?.at, now) && stamps?.orderedViews)
87
+ out.orderedViews = stamps.orderedViews;
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * ⭐ WAVE 27 Β· OWNER ITEM 5 (contract C7) β€” the rail's view order, as this browser last set it.
93
+ *
94
+ * β›” WHY IT IS NEEDED AT ALL: the server assembles view order (`aios_grid.py:1602-1647`) and the
95
+ * echo is one round trip behind the drop. Without this the row springs back to its old place the
96
+ * instant the workspace refreshes, which reads as "the drag did not work" β€” the NO-BLIP law's
97
+ * subject, applied to a sequence instead of to a value.
98
+ *
99
+ * ⚠ PAST THE ECHO WINDOW THE SERVER WINS, unconditionally. That asymmetry is the whole design of
100
+ * this module: inside the window a local drag is newer truth; outside it, a difference between
101
+ * the copies is divergence between SESSIONS, and the durable store decides.
102
+ *
103
+ * β›” IDS THE STAMP DOES NOT NAME KEEP THEIR SERVER ORDER, appended after the named ones β€” the
104
+ * same rule the host applies to a `folder_reorder` payload. A view created in another tab since
105
+ * the drop is not evidence that the drop was wrong; dropping it would be this function deleting
106
+ * a view from the rail to defend a sequence.
107
+ */
108
+ export function applyViewOrder<T extends { id: string }>(
109
+ views: T[],
110
+ stamps: FolderStamps | undefined,
111
+ now: number
112
+ ): T[] {
113
+ const stamp = stamps?.orderedViews;
114
+ if (!stamp || !isRecent(stamp.at, now) || !Array.isArray(stamp.order)) return views;
115
+ const byId = new Map(views.map((v) => [v.id, v]));
116
+ const out: T[] = [];
117
+ const placed = new Set<string>();
118
+ for (const id of stamp.order) {
119
+ const v = byId.get(id);
120
+ if (!v || placed.has(id)) continue;
121
+ placed.add(id);
122
+ out.push(v);
123
+ }
124
+ for (const v of views) if (!placed.has(v.id)) out.push(v);
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * The host's folder list, corrected by what this browser just did.
130
+ *
131
+ * deleted recently -> DROP it, even though the echo still lists it
132
+ * (the tombstone rule; without this a deleted folder
133
+ * blinks back for one round trip)
134
+ * renamed recently -> keep OUR name until the echo carries it
135
+ * created recently -> keep OURS if the echo has not caught up yet
136
+ *
137
+ * Everything stale yields to the host: past the window, a difference between
138
+ * the copies is divergence between sessions, and host state is the durable
139
+ * truth. That asymmetry is the whole design.
140
+ */
141
+ export function reconcileFolders(
142
+ hostFolders: GridFolder[] | undefined,
143
+ localFolders: GridFolder[] | undefined,
144
+ stamps: FolderStamps | undefined,
145
+ now: number
146
+ ): GridFolder[] {
147
+ const host = hostFolders ?? [];
148
+ const out: GridFolder[] = [];
149
+ const seen = new Set<string>();
150
+
151
+ for (const f of host) {
152
+ if (isRecent(stamps?.deleted?.[f.id], now)) continue; // tombstone
153
+ seen.add(f.id);
154
+ const rename = stamps?.renamed?.[f.id];
155
+ out.push(isRecent(rename?.at, now) && rename ? { ...f, name: rename.name } : f);
156
+ }
157
+
158
+ // A folder this browser created that the echo has not yet returned. Skipped
159
+ // when it was also deleted since β€” creating and deleting inside one window
160
+ // must net to nothing, not to a ghost.
161
+ for (const f of localFolders ?? []) {
162
+ if (seen.has(f.id)) continue;
163
+ if (!isRecent(stamps?.created?.[f.id], now)) continue;
164
+ if (isRecent(stamps?.deleted?.[f.id], now)) continue;
165
+ out.push(f);
166
+ }
167
+
168
+ out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
169
+
170
+ // ── WAVE 20 item 19 (C-FOLDER-REORDER): this browser's drag, until the echo carries it.
171
+ //
172
+ // Applied AFTER the host sort and as a SEPARATE pass, both deliberately:
173
+ // Β· the host's `order` numbers are the durable truth and stay the base sequence, so a
174
+ // folder the stamp never names keeps exactly the place the server gave it;
175
+ // Β· `Array.prototype.sort` is stable (ES2019), so every unnamed folder β€” one created in
176
+ // another tab between the drag and the echo, say β€” holds its relative position at the
177
+ // end instead of being flung to the front by a missing rank.
178
+ // A stamped id that has since been DELETED needs no handling: the tombstone pass above
179
+ // already dropped it, and `rank` is only ever consulted for folders that survived.
180
+ const ordered = stamps?.ordered;
181
+ if (isRecent(ordered?.at, now) && ordered) {
182
+ const rank = new Map(ordered.order.map((id, i) => [id, i]));
183
+ out.sort(
184
+ (a, b) =>
185
+ (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
186
+ (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)
187
+ );
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /**
193
+ * Where an item actually belongs right now: this browser's recent drag wins
194
+ * over the host's echo, and a folder that no longer exists resolves to ROOT.
195
+ *
196
+ * The second half matters as much as the first. `folder_delete` moves contents
197
+ * to root host-side, but the client sees the folder vanish one render before
198
+ * the items' `folderId` is rewritten β€” and an item pointing at a folder nobody
199
+ * renders would simply not appear in any group. Resolving a dangling ref to
200
+ * root is what stops a folder delete from making views look deleted too.
201
+ */
202
+ export function resolveFolderId(
203
+ itemId: string,
204
+ hostFolderId: string | null | undefined,
205
+ folders: GridFolder[],
206
+ stamps: FolderStamps | undefined,
207
+ now: number
208
+ ): string | null {
209
+ const moved = stamps?.moved?.[itemId];
210
+ const id = isRecent(moved?.at, now) && moved ? moved.folderId : (hostFolderId ?? null);
211
+ if (id == null) return null;
212
+ // ⚠ W32-T27 β€” the RESERVED root placement is not a dangling reference. It names no folder by
213
+ // design, so the `folders.some(...)` test below would null it and hand the item straight back
214
+ // to the Shared bucket, re-creating item 20 one layer down from where it was fixed.
215
+ if (id === ROOT_FOLDER_ID) return ROOT_FOLDER_ID;
216
+ return folders.some((f) => f.id === id) ? id : null;
217
+ }
218
+
219
+ export interface FolderGroup<T> {
220
+ folder: GridFolder | null; // null = the root group
221
+ items: T[];
222
+ /**
223
+ * ⭐⭐ WAVE 40 Β· T28 (owner instruction 30) β€” THE PIN GROUP: unfiled default views, ABOVE
224
+ * every folder.
225
+ *
226
+ * β›” IT IS A SECOND `folder: null` GROUP, and that is why this flag exists rather than a
227
+ * reserved folder id. The pin group and the ungrouped section are both "views in no folder",
228
+ * so neither can carry a heading and neither may be given a synthetic id β€” an id would be
229
+ * emitted by the rail's own drag surfaces (`item_move`, `folder_reorder`) against a folder
230
+ * that exists in nobody's store, which is the failure {@link isSyntheticFolderId} was
231
+ * introduced to stop. A flag says "this bucket renders first" and says nothing else, so the
232
+ * rail can key, class and drop-guard the two apart without either becoming a place.
233
+ *
234
+ * ⚠ ABSENT on every other group, never `false`, so a caller that does not pass `isPinned`
235
+ * gets output that is byte-identical (JSON included) to the pre-T28 shape.
236
+ */
237
+ pinned?: true;
238
+ }
239
+
240
+ /**
241
+ * WAVE 20 item 18 / WAVE 21 item 9 (ruling R12, contract C1) β€” the SYNTHETIC folder
242
+ * that every view shared WITH you appears under.
243
+ *
244
+ * β›” IT IS NOT A STORED FOLDER, and nothing may ever write one with this id. It has no
245
+ * record in `folders`, no `order`, no icon, and the rail refuses every action on it
246
+ * (`ViewSidebar` suppresses the row menu for exactly this id): it cannot be renamed into
247
+ * something else, duplicated into a second copy of other people's work, or deleted. It is a
248
+ * READING of the view list β€” "these arrived by grant" β€” rendered as a group because that is
249
+ * the only shape this rail has for "a set of views with something in common".
250
+ *
251
+ * Declared HERE rather than in the component (where it lived through wave 20) so the
252
+ * synthesis below is pure, and `verify_folders.py` can run it under node like every other
253
+ * rule in this file. A constant a gate cannot reach is a contract nobody checks.
254
+ */
255
+ export const SHARED_FOLDER_ID = "__shared__";
256
+ export const SHARED_FOLDER_NAME = "Shared with me";
257
+
258
+ /**
259
+ * ⭐⭐ WAVE 34 Β· T25 (ruling R11) β€” A SHARED **FOLDER** GETS ITS OWN GROUP, under this prefix.
260
+ *
261
+ * R11: *"A folder (with the Views inside it) can be shared with a team member."* Everything below
262
+ * the client already worked and was VERIFIED before this was written: `core/shares.py::KINDS` has
263
+ * carried `'folder'` since wave 20, the rail already offers "Share folder", and
264
+ * `grid_events.py::_granted_views` has projected each granted folder's views since `D-37`. What
265
+ * did not work is the last inch: every shared view landed in ONE flat "Shared with me" group, so
266
+ * the receiver got loose views and **the folder itself was invisible**.
267
+ *
268
+ * ⭐ The name has been on the wire the whole time. `_granted_views` stamps
269
+ * `v['sharedFolder'] = <folder name>` with the comment *"so the client can group these together
270
+ * later"*, and a census found ZERO readers of it anywhere in `web/src` β€” a flag shipped without
271
+ * its reader. This is the reader.
272
+ *
273
+ * ⚠ A PREFIX RATHER THAN ONE RESERVED ID, because there can be several: two colleagues can each
274
+ * share a folder with you. {@link isSyntheticFolderId} is what every affordance must ask instead
275
+ * of comparing against `SHARED_FOLDER_ID` β€” a rail that suppressed actions on one synthetic group
276
+ * and offered them on another would emit `item_move`/`folder_rename` against a folder id that
277
+ * exists in nobody's store.
278
+ *
279
+ * β›”β›” THE KEY IS **OWNER + NAME**, NOT NAME, AND THE FIRST DRAFT OF THIS FUNCTION GOT IT WRONG.
280
+ * It keyed on the folder name alone while the comment right here claimed to handle two colleagues
281
+ * sharing folders with the SAME NAME β€” so those two folders merged into ONE group, and the head's
282
+ * `title` (which reads the first item's `owner`) misattributed every view from the second sharer.
283
+ * Per-view `sharedRole` was never affected, so nothing leaked; what broke was the rail telling you
284
+ * whose work you were reading, which is the entire point of the group. **A comment that describes
285
+ * the case the code does not handle is worse than no comment**, because it is what a reviewer
286
+ * checks against. Found by this ticket's own verifier reading the two side by side.
287
+ *
288
+ * β›” NOTHING MAY EVER WRITE ONE. Like `SHARED_FOLDER_ID` these ids are a READING of the view list,
289
+ * not a record: no `order`, no icon, no row in `folders`.
290
+ */
291
+ export const SHARED_FOLDER_PREFIX = "__sharedfld__:";
292
+
293
+ /**
294
+ * The synthetic group id for a folder somebody shared with you.
295
+ *
296
+ * ⚠ `owner` FIRST and separated by a character usernames cannot contain. `core/users.py` keeps
297
+ * usernames lower-case and non-empty; a folder NAME is free text and can contain anything a user
298
+ * types, `:` included. Putting the free-text half last means no pair of (owner, name) can spell
299
+ * another pair's id, whatever somebody calls a folder.
300
+ */
301
+ export function sharedFolderGroupId(owner: string, name: string): string {
302
+ return `${SHARED_FOLDER_PREFIX}${owner}:${name}`;
303
+ }
304
+
305
+ /**
306
+ * Is this group id SYNTHESISED rather than stored? True for "Shared with me" and for every
307
+ * shared-folder group. Every drag, drop, rename and delete affordance in the rail asks this.
308
+ */
309
+ export function isSyntheticFolderId(id: string | null | undefined): boolean {
310
+ return id === SHARED_FOLDER_ID
311
+ || (typeof id === "string" && id.startsWith(SHARED_FOLDER_PREFIX));
312
+ }
313
+
314
+ /**
315
+ * ⭐⭐ WAVE 32 Β· T27 (owner item 20) β€” **"FILED AT ROOT", AS A VALUE.**
316
+ *
317
+ * Owner: *a shared View cannot be moved out of the Shared folder.* The cause is that **root was
318
+ * represented by ABSENCE at every layer**, and absence cannot distinguish two different facts:
319
+ *
320
+ * Β· `folderId == null` because the receiver never filed this view β€” it should show under
321
+ * "Shared with me", which is where a grant LANDS;
322
+ * Β· `folderId == null` because the receiver deliberately dragged it OUT of that group.
323
+ *
324
+ * `groupByFolder` had to guess, and it guessed "shared" β€” so filing a shared view at root put it
325
+ * straight back where it came from. **The root bucket was unreachable for a shared view by
326
+ * construction**, which is exactly why only folder→folder moves ever appeared to work.
327
+ *
328
+ * β›” THE SENTINEL IS STORED, NOT DERIVED, AND THAT IS THE WHOLE FIX. Both other layers wrote the
329
+ * same absence and must both learn this value: `core/grid_events.py`'s `item_move` branch
330
+ * (`if target is None: cur.pop(item_id, None) # back to the root`) and
331
+ * `aios_grid.clean_item_folders`, whose own docstring states the defect one level deeper β€”
332
+ * *"nothing stores 'this item is in no folder'"*. A client-only fix is impossible; there is
333
+ * nothing to read back.
334
+ *
335
+ * ⚠ It is a RESERVED id in the same namespace as real folder ids, so `resolveFolderId` must pass
336
+ * it through rather than treating it as dangling, and `clean_item_folders` must admit it beside
337
+ * `fid in fids`. It is deliberately NOT rendered as a group: {@link groupByFolder} maps it onto
338
+ * the ordinary root bucket, so nothing in the rail ever shows the word.
339
+ */
340
+ export const ROOT_FOLDER_ID = "__root__";
341
+
342
+ /**
343
+ * Group items into folders + a root bucket, in folder order, root LAST.
344
+ *
345
+ * Root last because the rails are read top-down and folders are the structure
346
+ * the user made; ungrouped items are the leftovers. Every item appears exactly
347
+ * once β€” a grouping that can drop an item would make a view look deleted.
348
+ *
349
+ * ⭐⭐ WAVE 40 Β· T28 (owner instruction 30) β€” WITH ONE BUCKET IN FRONT OF THE FOLDERS. The
350
+ * unfiled DEFAULT views lift out of the leftovers and render first, because they are not
351
+ * leftovers: they are the row every user lands on. See `isPinned` below for the whole rule,
352
+ * including the stored value that makes "unless the user moves it down" answerable. Omitting
353
+ * that predicate leaves this function byte-identical to the pre-T28 one, root LAST included,
354
+ * which is what the cohort rail and every existing test still get.
355
+ *
356
+ * ⭐ WAVE 21 item 9 (R12/C1) β€” `isShared` adds the synthetic "Shared with me" group,
357
+ * AFTER root, and three things about it are deliberate:
358
+ *
359
+ * Β· **After root, not before it.** C1 says LAST in as many words. It reads correctly
360
+ * too: the rail is "my folders, my loose views, and then other people's".
361
+ * Β· **A shared view the receiver has FILED still goes to their folder.** The `__shared__`
362
+ * group is where a grant LANDS, not a cage it stays in β€” the rail's own note calls
363
+ * moving out "per-receiver placement" and that must keep working. So the synthetic
364
+ * group collects only the shared views that resolved to ROOT.
365
+ * Β· **An empty group does not render.** A folder head with nothing under it says "here is
366
+ * something you cannot reach" β€” the same reason `foldNav` drops empty nav folders.
367
+ *
368
+ * Omitting `isShared` leaves the function byte-identical to the pre-wave-21 one, which is
369
+ * what every existing caller (the cohort rail, the tests) still gets.
370
+ */
371
+ export function groupByFolder<T>(
372
+ items: T[],
373
+ folders: GridFolder[],
374
+ folderIdOf: (item: T) => string | null,
375
+ isShared?: (item: T) => boolean,
376
+ /**
377
+ * ⭐ WAVE 34 Β· T25 (R11) β€” the folder a shared item arrived through, if it did.
378
+ *
379
+ * Returns the folder's NAME and its OWNER, because the pair is the identity: the wire carries
380
+ * no folder id for a grant (`_granted_views` stamps a name and an owner, never an id), and the
381
+ * name alone is not unique across sharers.
382
+ *
383
+ * Optional, so every existing caller (the cohort rail, the tests) keeps the pre-T25 shape
384
+ * byte-for-byte: with it omitted, every shared item falls into the flat "Shared with me"
385
+ * group exactly as before. Supplied, the shared views SPLIT by the folder that carried them,
386
+ * and the folder finally appears on the receiver's screen.
387
+ */
388
+ sharedFolderOf?: (item: T) => { name: string; owner: string } | null,
389
+ /**
390
+ * ⭐⭐ WAVE 40 Β· T28 (owner instruction 30) β€” IS THIS VIEW A DEFAULT THAT PINS TO THE TOP?
391
+ *
392
+ * Owner: *"when it is not in a folder it must sit at the very top above all folders unless
393
+ * the user moves it down."* The rail passes `isUndeletableView`, so the three ids nobody can
394
+ * delete β€” the per-database default, Starred, and the IG Overview β€” are the pinned set. A
395
+ * PREDICATE rather than an id list for the same reason `mayFileView` takes a verdict: this
396
+ * module is "pure and React-free so verify_folders.py can run it under node" (see the header)
397
+ * and its only imports are one constant and one type. Importing the id set would point this
398
+ * file at `types.ts` for a decision the caller already knows the answer to.
399
+ *
400
+ * β›”β›” THE PIN ASKS FOR **ABSENCE**, NOT FOR THE ROOT SENTINEL, AND THAT IS WHERE "UNLESS THE
401
+ * USER MOVES IT DOWN" LIVES. W32-T27 split the one meaning of "root" into two stored values
402
+ * precisely so this question could be asked: `folderId == null` is *"nobody has ever placed
403
+ * this"*, and {@link ROOT_FOLDER_ID} is *"the user dragged it here on purpose"*. So the pin
404
+ * engages on the first and YIELDS on the second β€” drag the default view down onto the
405
+ * ungrouped section and `item_move` stores the sentinel, the lift stops applying, and it
406
+ * renders among the ordinary loose views where it was dropped. Filing it into a folder is the
407
+ * same yield by the instruction's own words. Both are DURABLE: the sentinel survives
408
+ * `aios_grid.clean_item_folders`, so the placement outlives the session that made it.
409
+ *
410
+ * ⚠ Deleting the folder a pinned view was filed into DROPS the placement (contents fall to
411
+ * root by absence, not by sentinel β€” `grid_events.py`'s `folder_delete`), so the pin
412
+ * re-engages. That is the only path back to the top, and it is a consequence rather than a
413
+ * feature: nothing in the rail today clears a placement to absence.
414
+ */
415
+ isPinned?: (item: T) => boolean
416
+ ): FolderGroup<T>[] {
417
+ const buckets = new Map<string, T[]>(folders.map((f) => [f.id, []]));
418
+ const root: T[] = [];
419
+ const shared: T[] = [];
420
+ /** W40-T28 β€” unfiled pinned views, in the order they arrived, rendered above every folder. */
421
+ const pinned: T[] = [];
422
+ /** synthetic group id -> {name, views}. Keyed by OWNER+NAME; see `SHARED_FOLDER_PREFIX`. */
423
+ const sharedFolders = new Map<string, { name: string; items: T[] }>();
424
+ for (const item of items) {
425
+ const id = folderIdOf(item);
426
+ const bucket = id == null ? undefined : buckets.get(id);
427
+ // ⭐⭐ W40-T28 (owner instruction 30) β€” AN UNFILED DEFAULT LIFTS OUT OF THE ROOT BUCKET.
428
+ //
429
+ // β›”β›” `id == null` IS THE WHOLE OF "UNLESS THE USER MOVES IT DOWN", AND IT IS TESTED HERE
430
+ // β€” FIRST β€” RATHER THAN INHERITED FROM THE CHAIN BELOW. Placed after the other branches this
431
+ // clause reads identically and DOES NOTHING: a folder id is taken by `buckets`, the root
432
+ // sentinel by its own branch, and a dangling ref was nulled by `resolveFolderId` long before
433
+ // this line, so `id` is already absent by the time control arrives and the test can be
434
+ // deleted with no observable change. A rule no mutation can break is a rule no gate is
435
+ // checking ([[a-declared-gate-is-an-unchecked-claim]]) β€” so the placement question is asked
436
+ // where its answer still decides something, and `verify_folders.py` can red it on its own leg.
437
+ //
438
+ // ⚠ WHAT ABSENCE MEANS IS W32-T27's SPLIT, reused rather than reinvented: `folderId == null`
439
+ // is "nobody has ever placed this", {@link ROOT_FOLDER_ID} is "the user dragged it here on
440
+ // purpose". So the pin engages on the first and yields to the second β€” drop the default view
441
+ // onto the ungrouped section and `item_move` stores the sentinel, the lift stops applying,
442
+ // and it renders among the loose views where it was dropped. Filing it into a folder is the
443
+ // same yield by the instruction's own words.
444
+ //
445
+ // ⚠ `!isShared` KEEPS THE GRANT BRANCH UNTOUCHED. A pinned view is per-user system state and
446
+ // can never BE a grant, so the two cannot collide in practice β€” the guard is what makes that
447
+ // a structural fact rather than an assumption, and it is why every shared-grouping leg stays
448
+ // byte-identical to the pre-T28 one.
449
+ if (id == null && !isShared?.(item) && isPinned?.(item)) pinned.push(item);
450
+ else if (bucket) bucket.push(item);
451
+ // ⭐⭐ W32-T27 (owner item 20) β€” THE ROOT BUCKET IS REACHABLE FOR A SHARED VIEW NOW.
452
+ // `ROOT_FOLDER_ID` is the receiver saying "I filed this at the top level"; absence still
453
+ // means "this arrived by grant and I have not filed it". Before this line the two were one
454
+ // value, `isShared` won, and a shared view dragged to root returned to "Shared with me" on
455
+ // the next render β€” the owner's item 20, in one branch.
456
+ else if (id === ROOT_FOLDER_ID) root.push(item);
457
+ else if (isShared?.(item)) {
458
+ // ⭐ W34-T25 (R11): a shared view that arrived through a FOLDER goes to that folder's own
459
+ // group; one that was granted directly still goes to the flat "Shared with me". The two
460
+ // are different facts and the receiver can see which is which.
461
+ const via = sharedFolderOf?.(item);
462
+ const name = via ? String(via.name || "").trim() : "";
463
+ const owner = via ? String(via.owner || "").trim() : "";
464
+ // ⚠ BOTH halves required. A folder name with no owner cannot be told apart from another
465
+ // sharer's folder of the same name, so it falls back to the flat group rather than
466
+ // inventing a group that might merge two people's work under one person's label.
467
+ if (name && owner) {
468
+ const key = sharedFolderGroupId(owner, name);
469
+ const bucket2 = sharedFolders.get(key);
470
+ if (bucket2) bucket2.items.push(item);
471
+ else sharedFolders.set(key, { name, items: [item] });
472
+ } else shared.push(item);
473
+ }
474
+ else root.push(item);
475
+ }
476
+ const out: FolderGroup<T>[] = folders.map((f) => ({ folder: f, items: buckets.get(f.id) ?? [] }));
477
+ // ⭐⭐ W40-T28 β€” IN FRONT OF EVERY FOLDER, which is the half of instruction 30 this module owns.
478
+ // ⚠ An EMPTY pin group is never emitted, the same rule the shared group carries: a bucket with
479
+ // nothing in it is a section the user cannot reach, and here it would also be a phantom second
480
+ // root the rail would have to render around.
481
+ if (pinned.length) out.unshift({ folder: null, items: pinned, pinned: true });
482
+ out.push({ folder: null, items: root });
483
+ if (shared.length)
484
+ out.push({ folder: { id: SHARED_FOLDER_ID, name: SHARED_FOLDER_NAME }, items: shared });
485
+ // ⚠ SORTED, not first-seen. A Map preserves insertion order, which here is the order the VIEWS
486
+ // happen to arrive in β€” so one view moving could reorder whole folders in the rail for no reason
487
+ // a reader could name. Sorting on the composite ID (owner first) is stable across every payload
488
+ // AND groups one colleague's folders together, which reads better than interleaving two people's
489
+ // folders alphabetically by name.
490
+ for (const key of [...sharedFolders.keys()].sort((a, b) => a.localeCompare(b))) {
491
+ const grp = sharedFolders.get(key)!;
492
+ out.push({ folder: { id: key, name: grp.name }, items: grp.items });
493
+ }
494
+ return out;
495
+ }
496
+
497
+ /**
498
+ * WAVE 20 item 19 (C-FOLDER-REORDER) β€” where a dragged folder lands: the full order with
499
+ * `draggedId` moved to sit immediately BEFORE `beforeId`, or last when that is null (the
500
+ * drop on the ungrouped section below every folder).
501
+ *
502
+ * Here rather than inside the rail because it is the only part of the drag a test can hold:
503
+ * the drop handler is DOM, the emit is the caller's, and this is the arithmetic that decides
504
+ * what the user sees. `null` means "emit nothing" β€” an unknown id, or a drop that changes
505
+ * nothing. Returning the unchanged array instead would be worse than useless: the caller
506
+ * cannot tell it apart from a real reorder, so every no-op drag would write the store, bump
507
+ * every reader's payload, and reconcile to the identical list.
508
+ *
509
+ * ⚠ The dragged id is REMOVED BEFORE the target index is read. Taking the index first and
510
+ * splicing after is the classic off-by-one here: dragging a folder DOWNWARD would land it one
511
+ * place short of where it was dropped, and only in that direction β€” the shape of bug that
512
+ * survives a demo and gets reported as "it sometimes doesn't move".
513
+ */
514
+ export function reorderFolderIds(
515
+ ids: string[],
516
+ draggedId: string,
517
+ beforeId: string | null
518
+ ): string[] | null {
519
+ if (!ids.includes(draggedId)) return null;
520
+ // β›” DROPPED ON ITSELF. Without this the id is filtered out, `indexOf` cannot find its own
521
+ // target, and the "not found" branch sends the folder to the END β€” so releasing a drag over
522
+ // the folder you picked up would quietly move it to the bottom of the rail. Found by this
523
+ // function's own gate the minute the arithmetic left the component; the drop handler's
524
+ // indicator suppresses the same case visually, which is exactly why it would never have
525
+ // been noticed there.
526
+ if (beforeId === draggedId) return null;
527
+ const rest = ids.filter((id) => id !== draggedId);
528
+ const found = beforeId ? rest.indexOf(beforeId) : -1;
529
+ const at = found < 0 ? rest.length : found;
530
+ const next = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
531
+ if (next.length === ids.length && next.every((id, i) => id === ids[i])) return null;
532
+ return next;
533
+ }
534
+
535
+ /**
536
+ * ⭐⭐ WAVE 34 Β· T22 (ruling R4, `D-249`) β€” MAY THIS VIEWER FILE THIS VIEW INTO A FOLDER?
537
+ *
538
+ * β›” NOT THE SAME QUESTION AS `mayEditView`, AND CONFLATING THEM IS THE DEFECT. The rail gated its
539
+ * `draggable` on `mayEditView`, which fail-closes on a shared view whose role is not `edit` β€” so a
540
+ * read-only shared view could not BEGIN a drag, and the owner's report is that a view cannot be
541
+ * taken out of a folder. Filing is not a content edit: it writes a PLACEMENT into the receiver's
542
+ * own workspace stratum and never touches the owner's object.
543
+ *
544
+ * ⭐ THE SERVER ALREADY AGREED, IN TWO PLACES, BEFORE THIS FUNCTION EXISTED. `grid_events.py::
545
+ * table_workspace` merges granted views into `ws['views']`, which is the very set `item_move`'s
546
+ * own-ids test reads β€” so the write this drag produces is ACCEPTED today. And the `view_reorder`
547
+ * branch beside it carries the rule in prose: *"NO OWNERSHIP FILTER … refusing ids they do not own
548
+ * would make exactly those un-draggable, which is the half of sharing people notice."* The client
549
+ * was the only refuser, and it was refusing a write the server was ready to take.
550
+ *
551
+ * ⚠ `mayEdit` IS A PARAMETER, NOT AN IMPORT, and that is deliberate: this module is
552
+ * "pure and React-free so verify_folders.py can run it under node" (see the header), and its only
553
+ * imports today are one constant and one type. Taking the verdict rather than the resolver keeps
554
+ * the truth table testable in isolation and keeps this file's dependency surface where its own
555
+ * header promised it would stay.
556
+ *
557
+ * ⚠ USE IT ONLY AT THE DRAG SITE. Every other affordance the rail gates on `mayEditView` β€” rename,
558
+ * description, delete, permissions β€” really is a content edit on somebody else's object, and the
559
+ * server really does refuse those.
560
+ */
561
+ export function mayFileView(view: { shared?: boolean }, mayEdit: boolean): boolean {
562
+ return view.shared === true || mayEdit;
563
+ }
564
+
565
+ /** A fresh folder id. Client-generated, like every other id in this component. */
566
+ export function newFolderId(): string {
567
+ const rand =
568
+ typeof crypto !== "undefined" && "randomUUID" in crypto
569
+ ? crypto.randomUUID().slice(0, 8)
570
+ : Math.random().toString(36).slice(2, 10);
571
+ return `fld_${rand}`;
572
+ }
web/src/customer-grid/iconShapes.ts CHANGED
@@ -1,355 +1,355 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / iconShapes.ts
3
- // Wave-8 items I18 + I20 β€” ONE geometry source for the grid's icon vocabulary,
4
- // rendered by TWO very different painters:
5
- //
6
- // - React <FieldTypeIcon> / <ModeIcon> β€” DOM svg in panels, popovers, menus
7
- // - glide headerIcons sprites β€” canvas, drawn from an SVG *string*
8
- //
9
- // Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
10
- // never be a React component. Keeping the paths as DATA (IconShape[]) and giving
11
- // each painter its own thin renderer is what stops the two from drifting β€” the
12
- // alternative (hand-copying every path into a template literal) guarantees the
13
- // header and the panel eventually disagree about what a "date" looks like.
14
- //
15
- // Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
16
- // currentColor. Vector paths only β€” NEVER emoji (owner constant).
17
- // ---------------------------------------------------------------------------
18
-
19
- import type { AggName, DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
20
- import {
21
- LP_BLUE,
22
- LP_BLUE_DEEP,
23
- LP_GREEN,
24
- LP_GREEN_DEEP,
25
- LP_LINE,
26
- LP_MUTED,
27
- LP_RED,
28
- LP_RED_DEEP,
29
- LP_YELLOW,
30
- LP_YELLOW_DEEP,
31
- } from "./theme";
32
-
33
- /** One drawing primitive. `fill: true` fills the path instead of stroking it
34
- * (the rating star is the only shape that reads better solid). */
35
- export type IconShape = { d: string; fill?: boolean };
36
-
37
- /** A circle as a path β€” two half-arcs. Sprites are SVG *source*, so every shape
38
- * has to survive being serialized into a string; paths do, <circle> elements
39
- * would need a second serializer branch for no benefit. */
40
- const circle = (cx: number, cy: number, r: number): string =>
41
- `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
42
-
43
- const CALENDAR: IconShape[] = [
44
- { d: "M3.2 4.6h9.6v8.2H3.2z" },
45
- { d: "M3.2 7.2h9.6" },
46
- { d: "M5.8 3v3.2" },
47
- { d: "M10.2 3v3.2" },
48
- ];
49
-
50
- /**
51
- * Field type β†’ icon geometry. A TOTAL record on purpose: adding a FieldType
52
- * without an icon is a compile error, not a silently blank header.
53
- */
54
- export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
55
- text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
56
- status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
57
- currency: [
58
- { d: "M8 2.8v10.4" },
59
- { d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
60
- ],
61
- int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
62
- date: CALENDAR,
63
- pct: [
64
- { d: circle(4.6, 4.6, 1.6) },
65
- { d: circle(11.4, 11.4, 1.6) },
66
- { d: "M12.2 3.9L3.8 12.3" },
67
- ],
68
- select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
69
- user: [
70
- { d: circle(8, 6, 2.4) },
71
- { d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
72
- ],
73
- multiselect: [
74
- { d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
75
- { d: "M7.2 5.7h6M7.2 10.3h6" },
76
- ],
77
- checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
78
- phone: [
79
- { d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
80
- ],
81
- email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
82
- url: [
83
- { d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
84
- { d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
85
- { d: "M6.2 9.8l3.6-3.6" },
86
- ],
87
- rating: [
88
- {
89
- d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
90
- fill: true,
91
- },
92
- ],
93
- created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
94
- formula: [
95
- { d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
96
- { d: "M4.2 7.6h4.6" },
97
- { d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
98
- ],
99
- // Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
100
- // with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
101
- // Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
102
- // (`created_time` owns the closed rim + hands).
103
- automation: [
104
- { d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
105
- { d: "M4.2 6L5.3 4.1 3.1 4.5" },
106
- { d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
107
- ],
108
- // Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
109
- // series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
110
- // the formula fx (that computes over the ROW) and not a bare number (int owns ##).
111
- metric: [
112
- { d: "M3.2 3.2v9.6h9.6" },
113
- { d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
114
- ],
115
- // Wave-23 C7 β€” TWO BRACES facing each other with a dot between them: the universal mark for
116
- // "a structured document", and the one glyph in this table that draws its own SYNTAX rather
117
- // than a picture of what the value means. Deliberately not a document page (nothing here owns
118
- // that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree
119
- // of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from
120
- // reading as parentheses at small sizes.
121
- json: [
122
- { d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" },
123
- { d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" },
124
- { d: circle(8, 8, 0.85), fill: true },
125
- ],
126
- // ⭐ Wave-27 item 13 (R13) β€” THE ANGLE BRACKETS, the mark every editor on earth uses for
127
- // "this is source". Drawn as two chevrons with a slash leaning between them, which is what
128
- // separates it from `json` two entries up: json draws BRACES (a document's own syntax), code
129
- // draws BRACKETS (a snippet's). Deliberately not a terminal prompt (that reads as "run this",
130
- // and R13 is explicit there is no execution engine) and not a page of lines (`list` mode and
131
- // `text` already trade on that reading).
132
- code: [
133
- { d: "M5.6 4.9 2.6 8l3 3.1" },
134
- { d: "M10.4 4.9 13.4 8l-3 3.1" },
135
- { d: "M9.1 3.6 6.9 12.4" },
136
- ],
137
- // Wave-19 R7 β€” a framed picture: the mount, a sun, and the hill line every photo glyph
138
- // resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
139
- image: [
140
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
141
- { d: circle(6, 6.3, 1.1) },
142
- { d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
143
- ],
144
- // ⭐ 2026-08-07 β€” TWO INTERLOCKING CHAIN LINKS, the one glyph everybody already reads as
145
- // "this points at something else". Drawn as two rounded rectangles overlapping at the centre
146
- // rather than as an arrow into a box: an arrow would mean navigation, and a link column is a
147
- // relation that exists in both directions whether or not you follow it.
148
- link: [
149
- { d: "M6.6 5.2H4.9a2.8 2.8 0 000 5.6h1.7" },
150
- { d: "M9.4 5.2h1.7a2.8 2.8 0 010 5.6H9.4" },
151
- { d: "M5.6 8h4.8" },
152
- ],
153
- // ⭐ 2026-08-07 β€” THREE BARS FOLDING INTO ONE, read top-to-bottom: many linked values
154
- // collapsing to a single aggregate. Deliberately not a sigma (too fine at 16px, and it would
155
- // claim SUM when the function is chosen per column) and not a funnel (that is filtering,
156
- // which is what `limit` does β€” a different half of the same field).
157
- rollup: [
158
- { d: "M3.2 4.4h9.6" },
159
- { d: "M4.8 8h6.4" },
160
- { d: "M6.6 11.6h2.8" },
161
- ],
162
- // ⭐ WAVE 34 Β· T53 (R13), drawn by C on F's ask (`F-2`) β€” AI ENRICHMENT.
163
- // The SPARKLE, because it is the product's own AI mark already: `Shell.tsx::SparkIcon` wears it
164
- // on the Assistant rail row, so a column the AI fills reads as the same family rather than as a
165
- // second vocabulary for one idea. Deliberately NOT the `automation` cycle-ring two entries up
166
- // (that means "a job that runs, repeatedly", and an enrichment can be manual) and not a robot
167
- // head (`Shell.tsx::RobotIcon` claims that for the Agents MODULE; a field is not a module).
168
- // ⚠ TWO stars, not one: a lone four-point star at 16px with a 1.35 stroke reads as a plus sign.
169
- // The small companion is what makes the mark say "sparkle".
170
- ai_enrich: [
171
- { d: "M6.6 2.6l1.3 3.1 3.1 1.3-3.1 1.3-1.3 3.1-1.3-3.1L2.2 7l3.1-1.3z" },
172
- { d: "M11.9 9.8l.7 1.6 1.6.7-1.6.7-.7 1.6-.7-1.6-1.6-.7 1.6-.7z" },
173
- ],
174
- };
175
-
176
- /**
177
- * Display mode β†’ icon geometry (I18). Also total: the wave-8 Dashboard mode
178
- * cannot land in DISPLAY_MODES without the compiler demanding its icon here.
179
- */
180
- export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
181
- grid: [
182
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
183
- { d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
184
- ],
185
- list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
186
- // I19c β€” a framed set of bars: "several charts", not "one chart".
187
- chart: [
188
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
189
- { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
190
- ],
191
- // I10 (C2) β€” the LEGACY key. Kept so this record stays total over DisplayMode, which is
192
- // what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
193
- // promise. Same drawing: a stored 'dashboard' IS a chart.
194
- dashboard: [
195
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
196
- { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
197
- ],
198
- calendar: CALENDAR,
199
- kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
200
- map: [
201
- { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
202
- { d: circle(8, 6.1, 1.3) },
203
- ],
204
- // 2026-08-02 item 7 β€” the time-series view. Drawn as a framed grid with a trend running
205
- // through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
206
- // a per-row line toggle. Deliberately not the plain `line` chart mark β€” a chart view and a
207
- // time-series table must not be the same picture in the same rail.
208
- timeseries: [
209
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
210
- { d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
211
- { d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
212
- ],
213
- // Wave-18 C6-CATALOG β€” an OPEN BOOK: two facing leaves with a spine between them, and a
214
- // product block sitting on the left one. Every other mode in this table draws a way of
215
- // arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
216
- // with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
217
- // not a framed grid (`grid`/`chart`/`timeseries` share the frame).
218
- catalog: [
219
- { d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
220
- { d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
221
- { d: "M3.9 6.4h2.3v2.8H3.9z" },
222
- ],
223
- // Wave-23 C9 β€” a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath
224
- // them. Every other mode here draws a way of ARRANGING records that already exist; this one
225
- // has to read as a record being MADE, so it is the only mark whose bottom line is open.
226
- // Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil
227
- // (an edit affordance means something else app-wide).
228
- // ⭐ Wave-27 C3 (owner item 8) β€” a CARD WITH TWO ARROWS LEAVING IT, left and right. Every
229
- // other mark here draws an arrangement of many records; this one has to read as ONE record
230
- // with two exits, because that is exactly what the deck is. Deliberately not a stack of cards
231
- // (that is a lane, and `kanban` owns it) and not a hand or a gesture glyph (nothing else in
232
- // this vocabulary draws a body part, and it would read as "drag" rather than "decide").
233
- swipe: [
234
- { d: "M5.4 3.4h5.2v9.2H5.4z" },
235
- { d: "M3.6 8H1.4M2.8 6.8 1.4 8l1.4 1.2" },
236
- { d: "M12.4 8h2.2M13.2 6.8 14.6 8l-1.4 1.2" },
237
- ],
238
- form: [
239
- { d: "M3.4 2.8h9.2v10.4H3.4z" },
240
- { d: "M5.6 5.6h4.8" },
241
- { d: "M5.6 8h4.8" },
242
- { d: "M5.6 10.6h2.6" },
243
- ],
244
- // W36-T04 β€” angle brackets over a baseline: the universal mark for "this is code", and the one
245
- // shape in this table that is about the AUTHORING rather than about the arrangement of rows.
246
- script: [
247
- { d: "M6 5.4 3.2 8l2.8 2.6M10 5.4 12.8 8 10 10.6" },
248
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
249
- ],
250
- };
251
-
252
- /**
253
- * Wave-9 I16 β€” one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
254
- * vocabulary cannot ship without a drawing.
255
- *
256
- * ⚠ The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
257
- * an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
258
- * picker is NOT a native `<select>` β€” it is a radio-row list like the mode switcher, which
259
- * is the only shape that can carry a real mark.
260
- *
261
- * The key type is a string union declared here rather than imported from chartData.ts: this
262
- * module is a leaf (types + theme only) and chartData imports IT, not the reverse.
263
- */
264
- export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
265
- export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
266
- bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
267
- line: [
268
- { d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
269
- { d: circle(5.8, 7.8, 0.9) },
270
- { d: circle(8.4, 9.8, 0.9) },
271
- ],
272
- area: [
273
- { d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
274
- { d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
275
- ],
276
- donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
277
- // A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
278
- // reads as "one number" beside four marks that all read as "a distribution".
279
- kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
280
- // Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
281
- // values, not a distribution), with a header band and a column rule.
282
- table: [
283
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
284
- { d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
285
- ],
286
- };
287
-
288
- export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
289
- bar: "Bar",
290
- line: "Line",
291
- area: "Area",
292
- donut: "Donut",
293
- kpi: "Single value",
294
- table: "Table",
295
- };
296
-
297
- /** I16 β€” the pastel each chart kind wears, same family rule as MODE_TONE. */
298
- export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
299
- bar: "blue",
300
- line: "green",
301
- area: "green",
302
- donut: "yellow",
303
- kpi: "neutral",
304
- table: "neutral",
305
- };
306
-
307
- /**
308
- * Wave-9 contract C5 (I15) β€” folder icon geometry. TOTAL over `FolderShape`, so a shape key
309
- * added to the wire contract in types.ts cannot ship without a drawing.
310
- *
311
- * Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
312
- * the same rail and read as one family. `folder` is first because it is the default every
313
- * pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
314
- */
315
- export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
316
- folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
317
- star: [
318
- { d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
319
- ],
320
- flag: [
321
- { d: "M4.2 13.4V2.9" },
322
- { d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
323
- ],
324
- tag: [
325
- { d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
326
- { d: circle(5.6, 5.6, 1) },
327
- ],
328
- bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
329
- // Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
330
- // Reusing the geometry rather than drawing a second "chart" is the whole point of the
331
- // one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
332
- grid: MODE_SHAPES.grid,
333
- chart: MODE_SHAPES.dashboard,
334
- map: MODE_SHAPES.map,
335
- users: TYPE_SHAPES.user,
336
- clock: TYPE_SHAPES.created_time,
337
- heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
338
- bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
339
- };
340
-
341
- /**
342
- * Tone key β†’ the pastel it FILLS with, and the -d weight it STROKES with.
343
- *
344
- * Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
345
- * that a base pastel at that size smudges β€” LP_BLUE measures 1.88:1 on white. So the pastel
346
- * is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
347
- * outline (an outline that reads at all).
348
- *
349
- * The default tone is `neutral` β€” HOST's C5 key, not "grey". The whitelist is SHARED between
350
- * the two ends, so the name matters more than the word: a tone the host does not recognise
351
- * degrades to the default and the user's choice silently disappears on reload.
352
- */
353
  export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
354
  neutral: { fill: LP_LINE, stroke: LP_MUTED },
355
  blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
@@ -368,456 +368,491 @@ export function folderTonePaint(tone: unknown): { fill: string; stroke: string }
368
  return (typeof tone === "string" ? FOLDER_TONE_PAINT[tone as FolderTone] : undefined)
369
  ?? FOLDER_TONE_PAINT.neutral;
370
  }
371
-
372
- export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
373
- neutral: "Neutral",
374
- blue: "Blue",
375
- green: "Green",
376
- yellow: "Yellow",
377
- red: "Red",
378
- };
379
-
380
- export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
381
- folder: "Folder",
382
- star: "Star",
383
- flag: "Flag",
384
- tag: "Tag",
385
- bookmark: "Bookmark",
386
- grid: "Table",
387
- chart: "Chart",
388
- map: "Map",
389
- users: "People",
390
- clock: "Clock",
391
- heart: "Heart",
392
- bolt: "Priority",
393
- };
394
-
395
- /**
396
- * Wave-9 I14 β€” the tone each CREATABLE view type wears in the "+ Create new…" flyout.
397
- *
398
- * The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
399
- * is a list you read rather than scan. Assigned by family, not by rotation: the two
400
- * record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
401
- * share yellow, chart is green because it is the analytical one, map is red because it is
402
- * the geographic one. Folder is grey β€” it is not a view, and the flyout's last row should
403
- * not compete with the six above it.
404
- */
405
- /**
406
- * Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
407
- * sits beside the geometry, the way TYPE_LABELS does β€” the mode switcher, the create flyout
408
- * and the create prompt now read ONE table instead of three. C2's "Dashboard" β†’ "Chart"
409
- * rename is a single line here as a direct result.
410
- */
411
- export const MODE_LABELS: Record<DisplayMode, string> = {
412
- grid: "Grid",
413
- chart: "Chart",
414
- // Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
415
- // read before normalisation must never render a blank switcher chip.
416
- dashboard: "Chart",
417
- list: "List",
418
- calendar: "Calendar",
419
- kanban: "Kanban",
420
- map: "Map",
421
- timeseries: "Time series",
422
- catalog: "Catalog",
423
- // Wave-23 C9 β€” the mode that COLLECTS records. "Form", the word the whole product uses for
424
- // it (the public page, the share panel, the `form_submitted` trigger); a synonym here would
425
- // be the one surface calling it something else.
426
- form: "Form",
427
- // ⭐ Wave-27 C3 (item 8) β€” the owner's own word for it. Not "Triage" or "Review": the gesture
428
- // IS the name here, and the two candidates both collide with vocabulary this product already
429
- // spends elsewhere (a `review` automation decision, the retired review lanes).
430
- swipe: "Swipe",
431
- // W36-T04 β€” the owner's own words are "code script"; "Script" is the half that is not implied
432
- // by the icon, and the product has no other surface competing for the noun.
433
- //
434
- // ⭐⭐ W37-T41 (owner ruling, 2026-08-19) β€” RENAMED TO "Custom", and the one-word form is the
435
- // ruling rather than a shortening of it. Every wave-37 governing text calls this surface the
436
- // "Custom View": PRD items 10 / R2, T41's own `done-when`, D-391, and `ScriptViewPanel`'s
437
- // shipped copy ("Picking Custom View mints a view with an EMPTY source"). "Script" was the
438
- // product calling one thing two names, which is the defect `swipe`'s label note warns about
439
- // one table up.
440
- //
441
- // β›” IT IS NOT SPELLED "Custom View" HERE, and the reason is this table's job rather than a
442
- // preference. Every value is a KIND NOUN that four call sites COMPOSE into a phrase:
443
- // `ViewSidebar` renders `New ${MODE_LABELS[creating].toLowerCase()} view` (:1060), the same
444
- // string as the flyout's aria-label (:1045), `${MODE_LABELS[mode]} view, locked` (:1619) and
445
- // `It also stays a ${MODE_LABELS[mode].toLowerCase()}.` (:1626). A label already carrying the
446
- // noun reads "New custom view view" at two of them. One word keeps the create prompt reading
447
- // "New custom view", the locked aria-label reading "Custom view, locked", and the picker row
448
- // reading "Custom" beside Grid, Chart and Map, which are kind nouns too.
449
- script: "Custom",
450
- };
451
-
452
- /**
453
- * I14 β€” the view types the "+ Create new…" flyout OFFERS, in the order it lists them.
454
- *
455
- * Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
456
- * C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
457
- * rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β€” one list cannot
458
- * express both. Living in this pure data module means the gate can assert the offered set
459
- * without importing a React component, and I10 becomes a one-line edit in one file.
460
- */
461
- // 2026-08-02 item 7 β€” `timeseries` was deliberately held OUT of this list until the host
462
- // accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
463
- // wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
464
- // not know, so offering it early would let a user create a view that silently reverts to a
465
- // grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
466
- // `DISPLAY_MODES += timeseries`, so it is offerable now.
467
- //
468
- // wave17 GRID, owner item 10 β€” THE ORDER BELOW IS THE OWNER'S, stated verbatim:
469
- // Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
470
- //
471
- // ⚠ It supersedes the two orderings this list has carried before it, and the reasoning that
472
- // produced them is now WRONG rather than merely outranked, so it is not left here to be
473
- // re-applied: `timeseries` was "listed last… it belongs beside Chart", and `list` sat second
474
- // as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
475
- // a product decision, so it is asserted in `verify_icons` rather than left to a comment β€”
476
- // nothing else on screen would go red if a future edit re-sorted it "sensibly".
477
- // Wave-18 C6-CATALOG β€” `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
478
- // accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
479
- // "C6 HOST MIRROR APPLIED β€” you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
480
- // It lands LAST by contract: the owner's seven-mode order above is a product decision and the
481
- // new mode joins the end of it rather than being sorted into it.
482
- // ⭐ Wave-27 C3 (owner item 8) β€” `swipe` is HELD OUT of this list, and the reason corrects a
483
- // mistake this file made an hour earlier.
484
- //
485
- // β›” THE HOLD WAS NEVER ONLY ABOUT THE TWO REGISTRIES. It was first written as "do not offer a
486
- // mode the HOST has not accepted", and one session owning both registries this wave genuinely
487
- // does close that half β€” which is what made it tempting to skip. But the rule the hold really
488
- // encodes is broader and this wave proved it: **do not offer a mode whose CONSUMER does not
489
- // exist.** `swipe` was briefly listed here while `CustomerGrid`'s mode dispatch had no branch
490
- // for it, so picking "Swipe" wrote a mode the host now happily PERSISTS, and the body rendered
491
- // a grid under a chip reading "View Β· Swipe" β€” surviving reload, with the agreement leg green
492
- // because it only ever compared two lists.
493
- //
494
- // So the hold stood until `SwipeView` was mounted (contract C3), and it was not a mailbox
495
- // handshake: `verify_icons` DERIVES the condition β€” either `swipe` is absent here, or
496
- // `CustomerGrid.tsx` mounts `SwipeView`. `form` (wave-23) is the same defect from the other
497
- // direction, sitting unoffered because its host mirror never landed (DEBT D-90).
498
- //
499
- // β›” THE HOLD OUTLIVED THE WAVE, AND THAT IS THE LESSON WORTH MORE THAN THE FEATURE.
500
- // Wave 27 closed with the mount NEVER LANDING. `SwipeView.tsx` shipped, the server accepted
501
- // `swipe`, all four maps above carried it, 50 gates were green, the wave-27 mailbox recorded
502
- // "RESOLVED BY C (swipe mounted)", the close-out booked D-102 β€” a negative control FOR the
503
- // swipe carry β€” and the owner could not find the view because **nothing imported the file.**
504
- // The derived gate could not catch it: its condition is a DISJUNCTION and **absence satisfies
505
- // it**, so the unshipped state was permanently green ([[gate-can-report-green-on-nothing]]).
506
- // A hold that is safe to leave in place is a hold nothing forces you to lift.
507
- // The audit query that found it, after the battery did not: for each artifact a wave adds, grep
508
- // for its CONSUMER β€” who imports/mounts/registers it β€” excluding the file itself, `_test/`, css
509
- // and comments. `SwipeView` had four hits and three were prose.
510
- // Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
511
- // It lands LAST, by `catalog`'s rule: the owner's mode order is a product decision and a new
512
- // mode joins the end of it rather than being sorted into it.
513
- export const CREATABLE_MODES: DisplayMode[] = [
514
- "grid",
515
- "chart",
516
- "calendar",
517
- "kanban",
518
- "timeseries",
519
- "map",
520
- "list",
521
- "catalog",
522
- "swipe",
523
- // Mounted 2026-08-11 (`CustomerGrid.tsx`, `displayMode === "form"` renders `FormInterface`), so
524
- // `form` is offerable and its `HELD_MODES` entry came out in the same edit β€” the hold's own text
525
- // named this mount as its release condition. ⚠ Found by `verify_icons.py::mode_parity` law E
526
- // during /validate-wave, NOT by the lane that mounted it: T29 mounted the component and T41 built
527
- // the Interface group, and between them the DOOR was never opened β€” the wave's biggest new
528
- // surface was unreachable behind two green tickets.
529
- "form",
530
- // ⭐⭐ W37-T41 (owner item 10 / R2) β€” `script` JOINS THE OFFER, and this is the release of the
531
- // longest-held entry in this file. The hold's own release condition, written into
532
- // `icons.test.ts::HELD_MODES`, named THREE things that had to land in one change: the name
533
- // joins this list, `CustomerGrid.tsx` gains a `displayMode === "script"` branch, and the
534
- // HELD_MODES entry comes out. All three are in this change.
535
- //
536
- // β›” AND A FOURTH THAT THE HOLD DID NOT NAME, which is the one that decides whether the
537
- // feature works: `CustomerGrid.tsx::createView` had to learn that picking this mode mints a
538
- // SCRIPT VIEW through `createScriptView` rather than persisting a workspace view. A script
539
- // view is not a workspace view (it is its source and its version history, in E's own
540
- // per-database store), so the three hops above alone would have offered the mode, written
541
- // `config.display.mode = "script"` into a stored view, left `activeScriptId` null and drawn
542
- // nothing, with `mode_parity` GREEN. That is wave 27's `swipe` defect exactly, reached
543
- // through a different door.
544
- // ⚠ OFFERED ONLY WHERE IT CAN EXIST. `ViewSidebar` takes `scriptable` and drops this row
545
- // when it is false: script views are listed and resolved only on `ut_*` databases
546
- // (`CustomerGrid`'s `scriptRows` effect gates on `hostWorkspace && isUserTable`), so offering
547
- // it on a pool scope would mint a view the rail can never show
548
- // ([[permitted-is-not-answerable]]). It lands LAST by `catalog`'s rule.
549
- "script",
550
- ];
551
-
552
- /**
553
- * ⭐ WAVE-29 R6 (owner item 10) β€” the kind dropdown splits under TWO headers, and the strings are
554
- * the owner's own: exactly `View` and `Interface`. Not "View as" (what the popover said before),
555
- * not "Custom interface".
556
- *
557
- * The line the two groups draw: a **View** ARRANGES the records β€” the same rows, re-shaped (a
558
- * grid, a board, a chart). An **Interface** is a SURFACE BUILT OVER them: a map is a picture of the
559
- * world with records placed on it, a catalog is a published artifact, a form is a door records come
560
- * IN through and shows no records at all.
561
- *
562
- * ⭐ WAVE-33 item 9 AMENDED THAT LINE, and the owner drew it one notch differently than R6 did:
563
- * `swipe` and `timeseries` moved from View to Interface. The reading that makes both rulings one
564
- * rule β€” and the one the next mode should be grouped by β€” is **what the surface is FOR**, not how
565
- * many rows it shows. A deck triages records one at a time and WRITES to them; a trend answers a
566
- * question about a measure over time; neither hands you the row set re-shaped, which is what every
567
- * remaining View does. ⚠ Do NOT re-derive the split from `MODE_TONE` below: the tones still say
568
- * `catalog`/`form` are neutral because they arrange nothing, and `swipe`/`timeseries` are toned,
569
- * so tone and group are no longer the same cut. Group membership lives HERE and only here.
570
- *
571
- * ⚠ TOTAL over `DisplayMode`, so tsc refuses a new mode with no group rather than letting it
572
- * vanish from the dropdown: membership is derived by FILTERING `CREATABLE_MODES` through this
573
- * map, and a mode whose group label matched nothing would silently stop being offered while
574
- * every existing check (paintable, labelled, toned, unique, ordered) stayed green.
575
- * `dashboard` is grouped like the `chart` it is the legacy spelling of β€” it is never offered, and
576
- * a partial map is a worse answer than an unused entry.
577
- */
578
- export const MODE_GROUP_LABELS = ["View", "Interface"] as const;
579
- export type ModeGroup = (typeof MODE_GROUP_LABELS)[number];
580
-
581
- export const MODE_GROUP: Record<DisplayMode, ModeGroup> = {
582
- grid: "View",
583
- list: "View",
584
- kanban: "View",
585
- calendar: "View",
586
- chart: "View",
587
- dashboard: "View",
588
- // ⭐ WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
589
- // of under View, when a user toggle it." See the amended taxonomy note above β€” a deck and a
590
- // trend are both surfaces a person WORKS IN, not re-shapings of the row set.
591
- timeseries: "Interface",
592
- swipe: "Interface",
593
- map: "Interface",
594
- catalog: "Interface",
595
- form: "Interface",
596
- // W36-T04 β€” "Interface", by the W33 item-9 taxonomy: a script view is a surface somebody WORKS
597
- // IN (writes, runs, reads an answer), not a re-shaping of the row set.
598
- script: "Interface",
599
- };
600
-
601
- /**
602
- * The offered modes, split into R6's two groups β€” DERIVED, never a third hand-written list.
603
- *
604
- * ⚠ ORDER: R6 names each group's MEMBERS; the order inside a group stays `CREATABLE_MODES`', which
605
- * is the wave-17 owner ruling ("Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List") and
606
- * is separately asserted. The two rulings are compatible read this way and only this way: R6 moved
607
- * `map` out of the run of views, so wave-17's single sequence can no longer exist as one list, but
608
- * every pair it ordered is still in that relative order here.
609
- */
610
- export const CREATABLE_GROUPS: readonly { label: ModeGroup; modes: DisplayMode[] }[] =
611
- MODE_GROUP_LABELS.map((label) => ({
612
- label,
613
- modes: CREATABLE_MODES.filter((m) => MODE_GROUP[m] === label),
614
- }));
615
-
616
- export const MODE_TONE: Record<DisplayMode, FolderTone> = {
617
- grid: "blue",
618
- list: "blue",
619
- chart: "green",
620
- dashboard: "green",
621
- calendar: "yellow",
622
- kanban: "yellow",
623
- map: "red",
624
- // Green with `chart`: it is the other analytical mode, and the two belong to one family.
625
- timeseries: "green",
626
- // Wave-18 C6-CATALOG β€” NEUTRAL, and it is the honest pick rather than the leftover one. The
627
- // four colour tones each name a family of ways to arrange records (blue = tabular, green =
628
- // analytical, yellow = board/date, red = spatial); a catalog arranges nothing β€” it is a
629
- // published artifact. Giving it a colour would file it under a family it is not in.
630
- catalog: "neutral",
631
- // Wave-23 C9 β€” NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones
632
- // name families of ways to ARRANGE records (blue tabular, green analytical, yellow
633
- // board/date, red spatial). A form arranges nothing β€” it is a door records come in through β€”
634
- // so giving it a colour would file it under a family it is not in.
635
- form: "neutral",
636
- // ⭐ Wave-27 C3 β€” YELLOW, with `kanban`, and this is a family claim rather than a leftover:
637
- // a swipe deck writes the SAME single-select a kanban stacks by (R2 binds it to one), so the
638
- // two are one family seen at two zooms β€” all the lanes at once, or one card at a time. Filing
639
- // it neutral (the `catalog`/`form` reasoning) would be wrong for the opposite reason those
640
- // two are neutral: this mode does arrange records, and it arranges them by the board's field.
641
- swipe: "yellow",
642
- // W36-T04 β€” NEUTRAL, by `catalog`'s and `form`'s rule rather than by elimination: the four
643
- // tones name families of ways to ARRANGE records (blue tabular, green analytical, yellow
644
- // board/date, red spatial). A script arranges nothing; it emits whatever it computes.
645
- script: "neutral",
646
- };
647
-
648
- /**
649
- * Human labels for every field type. Lives here beside the icons so the two
650
- * halves of "how a field type presents itself" stay in one file (ColumnMenu
651
- * imports it rather than keeping a second copy).
652
- */
653
- export const TYPE_LABELS: Record<FieldType, string> = {
654
- // ⭐ WAVE-29 item 3 β€” the owner's own words: "Change 'Single line text' to 'Text', keep it
655
- // simple for the Field type". Airtable's phrase described the column's SHAPE (one line, versus
656
- // its long-text sibling); this product has no multi-line text kind, so the qualifier
657
- // distinguished the type from nothing and only made the commonest row in the menu the longest.
658
- // βœ… The STORED key is `"text"` and always was β€” the old string was never persisted anywhere,
659
- // client or server, so this is a label change with no migration behind it.
660
- text: "Text",
661
- select: "Single select",
662
- multiselect: "Multi select",
663
- user: "Assignee",
664
- int: "Number",
665
- currency: "Currency",
666
- pct: "Percent",
667
- date: "Date",
668
- checkbox: "Checkbox",
669
- phone: "Phone number",
670
- email: "Email",
671
- url: "URL",
672
- rating: "Rating",
673
- created_time: "Created time",
674
- formula: "Formula",
675
- // Wave-18 C5-AUTOFIELD (D's spec, applied by C).
676
- automation: "Automation",
677
- // Wave-22 C7 β€” spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
678
- // on headers and the field gear, not the create menu.
679
- metric: "Metric",
680
- // Wave-19 R7 β€” the picture column.
681
- image: "Image",
682
- // Wave-23 C7 β€” the structured-document column. "JSON" rather than "Structured data": it is
683
- // the word on the wire, in the viewer's raw tab and in every error the server can return, and
684
- // a friendlier synonym would be the only place in the product using a different one.
685
- json: "JSON",
686
- // ⭐ 2026-08-07 β€” Airtable's own wording, deliberately. "Link to another record" is what a
687
- // person migrating from Airtable searches this menu for, and inventing a synonym ("Relation",
688
- // "Reference") would make the feature they came for look absent.
689
- link: "Link to another record",
690
- rollup: "Rollup",
691
- // ⭐ Wave-27 item 13 (R13) β€” "Code", not "Snippet" or "Source": it is the word the field kind
692
- // is called everywhere else in this wave (the ruling, the language picker, the viewer header),
693
- // and it says what the column holds without implying the product will run it.
694
- code: "Code",
695
- // ⭐ WAVE 34 Β· T53 (R13), on F's ask (`F-2`) β€” the owner's own noun for the kind: "a field kind
696
- // called AI enrichment". Not "AI field" (every field in an AI-built view would qualify) and not
697
- // "Generate" (that names the verb, and the column's value is the point, not the act).
698
- ai_enrich: "AI enrichment",
699
- status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
700
- };
701
-
702
- /**
703
- * ⭐ WAVE-29 C7 (item 17) β€” THE COLUMN-SUMMARY vocabulary: what a field's `agg` may be, which is
704
- * what the totals row and the per-group subtotals compute. Server twin:
705
- * `platform/aios_grid.py::FIELD_AGGS`, and `verify_icons.py::agg_parity` reads BOTH FILES and
706
- * compares them name-for-name in order β€” the cross-language boundary is the one a type cannot
707
- * police, so it gets a gate.
708
- *
709
- * β›” ONE CLIENT LIST, IMPORTED β€” never re-declared. `aggregations.ts` and the field editor import
710
- * from here rather than keeping their own copy, which is why this lives in the pure data module
711
- * beside `TYPE_LABELS` and `CREATABLE_MODES`: a second client list would need a second gate, and
712
- * the two would drift in the direction nobody is watching. C7 says "C publishes, E mirrors"; a
713
- * mirror that is an import cannot fall out of step at all.
714
- *
715
- * β›” NOT the chart vocabulary. `CHART_AGGS` (`aios_grid.py`, `viz/chartData.ts`) spells it `avg`
716
- * and gatekeeps a STORED value β€” renaming it would silently turn saved charts into sums. This
717
- * list spells it `average`, matching `ROLLUP_FNS` (16 names, live in production), so a column
718
- * summary and a rollup fold say the same word for the same operation.
719
- *
720
- * ⚠ `median` is net-new β€” in neither `CHART_AGGS` nor `ROLLUP_FNS`.
721
- * ⚠ `count` counts ROWS in the scope, not non-blank cells.
722
- * ⚠ Which types may carry which: `sum/average/median/min/max` are numeric-only and the evaluator
723
- * for that is ALREADY `isNumericFieldType` (types.ts) β€” do not write a second one. `count` is
724
- * legal on any type.
725
- */
726
- // ⭐ W29-T74 β€” an ALIAS of `types.AggName`, not a fifth copy of the union. `Field.agg` is typed
727
- // `AggName`, so a second literal here would be a type that has to be kept in step by eye with a
728
- // type the compiler already owns β€” the same defect as the array below, one level up.
729
- export type FieldAgg = AggName;
730
-
731
- /** ORDERED β€” the order is the picker's order, on both engines. */
732
- export const FIELD_AGGS: readonly FieldAgg[] = [
733
- "sum",
734
- "average",
735
- "median",
736
- "min",
737
- "max",
738
- "count",
739
- ];
740
-
741
- /**
742
- * Human labels, in the summary bar's own compact register (Airtable's wording).
743
- *
744
- * ⚠ `FIELD_AGG_LABELS`, not `AGG_LABELS`, and the prefix is load-bearing: `viewModes.tsx` already
745
- * has a module-local `AGG_LABELS` for the CALENDAR summary picker over `CHART_AGGS`, where the
746
- * same five names wear different words ("Total", "Lowest", "Highest") for a day cell. Two tables
747
- * called `AGG_LABELS` describing two vocabularies is how a future import lands on the wrong one.
748
- */
749
- export const FIELD_AGG_LABELS: Record<FieldAgg, string> = {
750
- sum: "Sum",
751
- average: "Average",
752
- median: "Median",
753
- min: "Min",
754
- max: "Max",
755
- count: "Count",
756
- };
757
-
758
-
759
- // ------------------------------------------------------------ glide sprites
760
-
761
- /** Serialize one shape to SVG source in an explicit colour (canvas sprites get
762
- * no `currentColor` β€” glide hands the painter the theme colours directly). */
763
- function shapeSource(s: IconShape, color: string): string {
764
- return s.fill
765
- ? `<path d="${s.d}" fill="${color}"/>`
766
- : `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
767
- `stroke-linecap="round" stroke-linejoin="round"/>`;
768
- }
769
-
770
- function sprite(shapes: IconShape[]) {
771
- return ({ fgColor }: { fgColor: string }) =>
772
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
773
- shapes.map((s) => shapeSource(s, fgColor)).join("") +
774
- `</svg>`;
775
- }
776
-
777
- /** Glide header-icon NAME for a field type β€” the `icon` a GridColumn asks for. */
778
- export function typeIconName(type: FieldType): string {
779
- return `t_${type}`;
780
- }
781
-
782
- /**
783
- * The sprite map handed to <DataEditor headerIcons>. One entry per field type
784
- * (I20 draws the type mark in every column header), built from the same shapes
785
- * the React icons use.
786
- *
787
- * Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
788
- * why theme.ts must set it β€” the library default is #FFFFFF, i.e. invisible on
789
- * our header (that was I21's actual bug, not a too-pale hex of ours).
790
- */
791
- export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
792
- Object.fromEntries(
793
- (Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
794
- );
795
-
796
- /**
797
- * The header sprite map handed to <DataEditor headerIcons>. Two families:
798
- *
799
- * t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
800
- * from the same shapes the React icons use. Painted by glide in
801
- * `theme.fgIconHeader`.
802
- * aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i).
803
- * OUTLINE ONLY: a dark-grey ring with a transparent interior, per
804
- * the owner. It still deliberately IGNORES the colours glide hands
805
- * it, for the reason wave-8 recorded - glide's "special" variant is
806
- * accentColor behind bgHeader, which under the C1 pastels is a pale
807
- * glyph on a pale disc, i.e. I21 in a new costume.
808
- * ⚠ It is NO LONGER a column `overlayIcon`. Glide draws an overlay
809
- * at a hard-coded offset from the TYPE mark on the far LEFT of the
810
- * header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
811
- * aligned. It is now painted by CustomerGrid's `drawHeader`
812
- * callback at `infoMarkRect()` - see overlayPlacement.ts.
813
- */
814
- export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = {
815
- ...TYPE_SPRITES,
816
- aiosInfo: () =>
817
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
818
- `<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` +
819
- `<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` +
820
- `stroke-linecap="round"/>` +
821
- `<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` +
822
- `</svg>`,
823
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / iconShapes.ts
3
+ // Wave-8 items I18 + I20 β€” ONE geometry source for the grid's icon vocabulary,
4
+ // rendered by TWO very different painters:
5
+ //
6
+ // - React <FieldTypeIcon> / <ModeIcon> β€” DOM svg in panels, popovers, menus
7
+ // - glide headerIcons sprites β€” canvas, drawn from an SVG *string*
8
+ //
9
+ // Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
10
+ // never be a React component. Keeping the paths as DATA (IconShape[]) and giving
11
+ // each painter its own thin renderer is what stops the two from drifting β€” the
12
+ // alternative (hand-copying every path into a template literal) guarantees the
13
+ // header and the panel eventually disagree about what a "date" looks like.
14
+ //
15
+ // Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
16
+ // currentColor. Vector paths only β€” NEVER emoji (owner constant).
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import type { AggName, DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
20
+ import {
21
+ LP_BLUE,
22
+ LP_BLUE_DEEP,
23
+ LP_GREEN,
24
+ LP_GREEN_DEEP,
25
+ LP_LINE,
26
+ LP_MUTED,
27
+ LP_RED,
28
+ LP_RED_DEEP,
29
+ LP_YELLOW,
30
+ LP_YELLOW_DEEP,
31
+ } from "./theme";
32
+
33
+ /** One drawing primitive. `fill: true` fills the path instead of stroking it
34
+ * (the rating star is the only shape that reads better solid). */
35
+ export type IconShape = { d: string; fill?: boolean };
36
+
37
+ /** A circle as a path β€” two half-arcs. Sprites are SVG *source*, so every shape
38
+ * has to survive being serialized into a string; paths do, <circle> elements
39
+ * would need a second serializer branch for no benefit. */
40
+ const circle = (cx: number, cy: number, r: number): string =>
41
+ `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
42
+
43
+ const CALENDAR: IconShape[] = [
44
+ { d: "M3.2 4.6h9.6v8.2H3.2z" },
45
+ { d: "M3.2 7.2h9.6" },
46
+ { d: "M5.8 3v3.2" },
47
+ { d: "M10.2 3v3.2" },
48
+ ];
49
+
50
+ /**
51
+ * Field type β†’ icon geometry. A TOTAL record on purpose: adding a FieldType
52
+ * without an icon is a compile error, not a silently blank header.
53
+ */
54
+ export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
55
+ text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
56
+ status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
57
+ currency: [
58
+ { d: "M8 2.8v10.4" },
59
+ { d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
60
+ ],
61
+ int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
62
+ date: CALENDAR,
63
+ pct: [
64
+ { d: circle(4.6, 4.6, 1.6) },
65
+ { d: circle(11.4, 11.4, 1.6) },
66
+ { d: "M12.2 3.9L3.8 12.3" },
67
+ ],
68
+ select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
69
+ user: [
70
+ { d: circle(8, 6, 2.4) },
71
+ { d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
72
+ ],
73
+ multiselect: [
74
+ { d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
75
+ { d: "M7.2 5.7h6M7.2 10.3h6" },
76
+ ],
77
+ checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
78
+ phone: [
79
+ { d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
80
+ ],
81
+ email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
82
+ url: [
83
+ { d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
84
+ { d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
85
+ { d: "M6.2 9.8l3.6-3.6" },
86
+ ],
87
+ rating: [
88
+ {
89
+ d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
90
+ fill: true,
91
+ },
92
+ ],
93
+ created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
94
+ formula: [
95
+ { d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
96
+ { d: "M4.2 7.6h4.6" },
97
+ { d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
98
+ ],
99
+ // Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
100
+ // with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
101
+ // Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
102
+ // (`created_time` owns the closed rim + hands).
103
+ automation: [
104
+ { d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
105
+ { d: "M4.2 6L5.3 4.1 3.1 4.5" },
106
+ { d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
107
+ ],
108
+ // Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
109
+ // series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
110
+ // the formula fx (that computes over the ROW) and not a bare number (int owns ##).
111
+ metric: [
112
+ { d: "M3.2 3.2v9.6h9.6" },
113
+ { d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
114
+ ],
115
+ // Wave-23 C7 β€” TWO BRACES facing each other with a dot between them: the universal mark for
116
+ // "a structured document", and the one glyph in this table that draws its own SYNTAX rather
117
+ // than a picture of what the value means. Deliberately not a document page (nothing here owns
118
+ // that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree
119
+ // of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from
120
+ // reading as parentheses at small sizes.
121
+ json: [
122
+ { d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" },
123
+ { d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" },
124
+ { d: circle(8, 8, 0.85), fill: true },
125
+ ],
126
+ // ⭐ Wave-27 item 13 (R13) β€” THE ANGLE BRACKETS, the mark every editor on earth uses for
127
+ // "this is source". Drawn as two chevrons with a slash leaning between them, which is what
128
+ // separates it from `json` two entries up: json draws BRACES (a document's own syntax), code
129
+ // draws BRACKETS (a snippet's). Deliberately not a terminal prompt (that reads as "run this",
130
+ // and R13 is explicit there is no execution engine) and not a page of lines (`list` mode and
131
+ // `text` already trade on that reading).
132
+ code: [
133
+ { d: "M5.6 4.9 2.6 8l3 3.1" },
134
+ { d: "M10.4 4.9 13.4 8l-3 3.1" },
135
+ { d: "M9.1 3.6 6.9 12.4" },
136
+ ],
137
+ // Wave-19 R7 β€” a framed picture: the mount, a sun, and the hill line every photo glyph
138
+ // resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
139
+ image: [
140
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
141
+ { d: circle(6, 6.3, 1.1) },
142
+ { d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
143
+ ],
144
+ // ⭐ 2026-08-07 β€” TWO INTERLOCKING CHAIN LINKS, the one glyph everybody already reads as
145
+ // "this points at something else". Drawn as two rounded rectangles overlapping at the centre
146
+ // rather than as an arrow into a box: an arrow would mean navigation, and a link column is a
147
+ // relation that exists in both directions whether or not you follow it.
148
+ link: [
149
+ { d: "M6.6 5.2H4.9a2.8 2.8 0 000 5.6h1.7" },
150
+ { d: "M9.4 5.2h1.7a2.8 2.8 0 010 5.6H9.4" },
151
+ { d: "M5.6 8h4.8" },
152
+ ],
153
+ // ⭐ 2026-08-07 β€” THREE BARS FOLDING INTO ONE, read top-to-bottom: many linked values
154
+ // collapsing to a single aggregate. Deliberately not a sigma (too fine at 16px, and it would
155
+ // claim SUM when the function is chosen per column) and not a funnel (that is filtering,
156
+ // which is what `limit` does β€” a different half of the same field).
157
+ rollup: [
158
+ { d: "M3.2 4.4h9.6" },
159
+ { d: "M4.8 8h6.4" },
160
+ { d: "M6.6 11.6h2.8" },
161
+ ],
162
+ // ⭐ WAVE 34 Β· T53 (R13), drawn by C on F's ask (`F-2`) β€” AI ENRICHMENT.
163
+ // The SPARKLE, because it is the product's own AI mark already: `Shell.tsx::SparkIcon` wears it
164
+ // on the Assistant rail row, so a column the AI fills reads as the same family rather than as a
165
+ // second vocabulary for one idea. Deliberately NOT the `automation` cycle-ring two entries up
166
+ // (that means "a job that runs, repeatedly", and an enrichment can be manual) and not a robot
167
+ // head (`Shell.tsx::RobotIcon` claims that for the Agents MODULE; a field is not a module).
168
+ // ⚠ TWO stars, not one: a lone four-point star at 16px with a 1.35 stroke reads as a plus sign.
169
+ // The small companion is what makes the mark say "sparkle".
170
+ ai_enrich: [
171
+ { d: "M6.6 2.6l1.3 3.1 3.1 1.3-3.1 1.3-1.3 3.1-1.3-3.1L2.2 7l3.1-1.3z" },
172
+ { d: "M11.9 9.8l.7 1.6 1.6.7-1.6.7-.7 1.6-.7-1.6-1.6-.7 1.6-.7z" },
173
+ ],
174
+ };
175
+
176
+ /**
177
+ * Display mode β†’ icon geometry (I18). Also total: the wave-8 Dashboard mode
178
+ * cannot land in DISPLAY_MODES without the compiler demanding its icon here.
179
+ */
180
+ export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
181
+ grid: [
182
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
183
+ { d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
184
+ ],
185
+ list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
186
+ // I19c β€” a framed set of bars: "several charts", not "one chart".
187
+ chart: [
188
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
189
+ { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
190
+ ],
191
+ // I10 (C2) β€” the LEGACY key. Kept so this record stays total over DisplayMode, which is
192
+ // what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
193
+ // promise. Same drawing: a stored 'dashboard' IS a chart.
194
+ dashboard: [
195
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
196
+ { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
197
+ ],
198
+ calendar: CALENDAR,
199
+ kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
200
+ map: [
201
+ { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
202
+ { d: circle(8, 6.1, 1.3) },
203
+ ],
204
+ // 2026-08-02 item 7 β€” the time-series view. Drawn as a framed grid with a trend running
205
+ // through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
206
+ // a per-row line toggle. Deliberately not the plain `line` chart mark β€” a chart view and a
207
+ // time-series table must not be the same picture in the same rail.
208
+ timeseries: [
209
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
210
+ { d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
211
+ { d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
212
+ ],
213
+ // Wave-18 C6-CATALOG β€” an OPEN BOOK: two facing leaves with a spine between them, and a
214
+ // product block sitting on the left one. Every other mode in this table draws a way of
215
+ // arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
216
+ // with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
217
+ // not a framed grid (`grid`/`chart`/`timeseries` share the frame).
218
+ catalog: [
219
+ { d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
220
+ { d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
221
+ { d: "M3.9 6.4h2.3v2.8H3.9z" },
222
+ ],
223
+ // Wave-23 C9 β€” a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath
224
+ // them. Every other mode here draws a way of ARRANGING records that already exist; this one
225
+ // has to read as a record being MADE, so it is the only mark whose bottom line is open.
226
+ // Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil
227
+ // (an edit affordance means something else app-wide).
228
+ // ⭐ Wave-27 C3 (owner item 8) β€” a CARD WITH TWO ARROWS LEAVING IT, left and right. Every
229
+ // other mark here draws an arrangement of many records; this one has to read as ONE record
230
+ // with two exits, because that is exactly what the deck is. Deliberately not a stack of cards
231
+ // (that is a lane, and `kanban` owns it) and not a hand or a gesture glyph (nothing else in
232
+ // this vocabulary draws a body part, and it would read as "drag" rather than "decide").
233
+ swipe: [
234
+ { d: "M5.4 3.4h5.2v9.2H5.4z" },
235
+ { d: "M3.6 8H1.4M2.8 6.8 1.4 8l1.4 1.2" },
236
+ { d: "M12.4 8h2.2M13.2 6.8 14.6 8l-1.4 1.2" },
237
+ ],
238
+ form: [
239
+ { d: "M3.4 2.8h9.2v10.4H3.4z" },
240
+ { d: "M5.6 5.6h4.8" },
241
+ { d: "M5.6 8h4.8" },
242
+ { d: "M5.6 10.6h2.6" },
243
+ ],
244
+ // W36-T04 β€” angle brackets over a baseline: the universal mark for "this is code", and the one
245
+ // shape in this table that is about the AUTHORING rather than about the arrangement of rows.
246
+ script: [
247
+ { d: "M6 5.4 3.2 8l2.8 2.6M10 5.4 12.8 8 10 10.6" },
248
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
249
+ ],
250
+ };
251
+
252
+ /**
253
+ * Wave-9 I16 β€” one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
254
+ * vocabulary cannot ship without a drawing.
255
+ *
256
+ * ⚠ The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
257
+ * an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
258
+ * picker is NOT a native `<select>` β€” it is a radio-row list like the mode switcher, which
259
+ * is the only shape that can carry a real mark.
260
+ *
261
+ * The key type is a string union declared here rather than imported from chartData.ts: this
262
+ * module is a leaf (types + theme only) and chartData imports IT, not the reverse.
263
+ */
264
+ export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
265
+ export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
266
+ bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
267
+ line: [
268
+ { d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
269
+ { d: circle(5.8, 7.8, 0.9) },
270
+ { d: circle(8.4, 9.8, 0.9) },
271
+ ],
272
+ area: [
273
+ { d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
274
+ { d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
275
+ ],
276
+ donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
277
+ // A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
278
+ // reads as "one number" beside four marks that all read as "a distribution".
279
+ kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
280
+ // Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
281
+ // values, not a distribution), with a header band and a column rule.
282
+ table: [
283
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
284
+ { d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
285
+ ],
286
+ };
287
+
288
+ export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
289
+ bar: "Bar",
290
+ line: "Line",
291
+ area: "Area",
292
+ donut: "Donut",
293
+ kpi: "Single value",
294
+ table: "Table",
295
+ };
296
+
297
+ /** I16 β€” the pastel each chart kind wears, same family rule as MODE_TONE. */
298
+ export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
299
+ bar: "blue",
300
+ line: "green",
301
+ area: "green",
302
+ donut: "yellow",
303
+ kpi: "neutral",
304
+ table: "neutral",
305
+ };
306
+
307
+ /**
308
+ * Wave-9 contract C5 (I15) β€” folder icon geometry. TOTAL over `FolderShape`, so a shape key
309
+ * added to the wire contract in types.ts cannot ship without a drawing.
310
+ *
311
+ * Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
312
+ * the same rail and read as one family. `folder` is first because it is the default every
313
+ * pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
314
+ */
315
+ export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
316
+ folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
317
+ star: [
318
+ { d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
319
+ ],
320
+ flag: [
321
+ { d: "M4.2 13.4V2.9" },
322
+ { d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
323
+ ],
324
+ tag: [
325
+ { d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
326
+ { d: circle(5.6, 5.6, 1) },
327
+ ],
328
+ bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
329
+ // Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
330
+ // Reusing the geometry rather than drawing a second "chart" is the whole point of the
331
+ // one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
332
+ grid: MODE_SHAPES.grid,
333
+ chart: MODE_SHAPES.dashboard,
334
+ map: MODE_SHAPES.map,
335
+ users: TYPE_SHAPES.user,
336
+ clock: TYPE_SHAPES.created_time,
337
+ heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
338
+ bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
339
+ };
340
+
341
+ /**
342
+ * Tone key β†’ the pastel it FILLS with, and the -d weight it STROKES with.
343
+ *
344
+ * Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
345
+ * that a base pastel at that size smudges β€” LP_BLUE measures 1.88:1 on white. So the pastel
346
+ * is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
347
+ * outline (an outline that reads at all).
348
+ *
349
+ * The default tone is `neutral` β€” HOST's C5 key, not "grey". The whitelist is SHARED between
350
+ * the two ends, so the name matters more than the word: a tone the host does not recognise
351
+ * degrades to the default and the user's choice silently disappears on reload.
352
+ */
353
  export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
354
  neutral: { fill: LP_LINE, stroke: LP_MUTED },
355
  blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
 
368
  return (typeof tone === "string" ? FOLDER_TONE_PAINT[tone as FolderTone] : undefined)
369
  ?? FOLDER_TONE_PAINT.neutral;
370
  }
371
+
372
+ export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
373
+ neutral: "Neutral",
374
+ blue: "Blue",
375
+ green: "Green",
376
+ yellow: "Yellow",
377
+ red: "Red",
378
+ };
379
+
380
+ export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
381
+ folder: "Folder",
382
+ star: "Star",
383
+ flag: "Flag",
384
+ tag: "Tag",
385
+ bookmark: "Bookmark",
386
+ grid: "Table",
387
+ chart: "Chart",
388
+ map: "Map",
389
+ users: "People",
390
+ clock: "Clock",
391
+ heart: "Heart",
392
+ bolt: "Priority",
393
+ };
394
+
395
+ /**
396
+ * Wave-9 I14 β€” the tone each CREATABLE view type wears in the "+ Create new…" flyout.
397
+ *
398
+ * The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
399
+ * is a list you read rather than scan. Assigned by family, not by rotation: the two
400
+ * record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
401
+ * share yellow, chart is green because it is the analytical one, map is red because it is
402
+ * the geographic one. Folder is grey β€” it is not a view, and the flyout's last row should
403
+ * not compete with the six above it.
404
+ */
405
+ /**
406
+ * Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
407
+ * sits beside the geometry, the way TYPE_LABELS does β€” the mode switcher, the create flyout
408
+ * and the create prompt now read ONE table instead of three. C2's "Dashboard" β†’ "Chart"
409
+ * rename is a single line here as a direct result.
410
+ */
411
+ export const MODE_LABELS: Record<DisplayMode, string> = {
412
+ grid: "Grid",
413
+ chart: "Chart",
414
+ // Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
415
+ // read before normalisation must never render a blank switcher chip.
416
+ dashboard: "Chart",
417
+ list: "List",
418
+ calendar: "Calendar",
419
+ kanban: "Kanban",
420
+ map: "Map",
421
+ timeseries: "Time series",
422
+ catalog: "Catalog",
423
+ // Wave-23 C9 β€” the mode that COLLECTS records. "Form", the word the whole product uses for
424
+ // it (the public page, the share panel, the `form_submitted` trigger); a synonym here would
425
+ // be the one surface calling it something else.
426
+ form: "Form",
427
+ // ⭐ Wave-27 C3 (item 8) β€” the owner's own word for it. Not "Triage" or "Review": the gesture
428
+ // IS the name here, and the two candidates both collide with vocabulary this product already
429
+ // spends elsewhere (a `review` automation decision, the retired review lanes).
430
+ swipe: "Swipe",
431
+ // W36-T04 β€” the owner's own words are "code script"; "Script" is the half that is not implied
432
+ // by the icon, and the product has no other surface competing for the noun.
433
+ //
434
+ // ⭐⭐ W37-T41 (owner ruling, 2026-08-19) β€” RENAMED TO "Custom", and the one-word form is the
435
+ // ruling rather than a shortening of it. Every wave-37 governing text calls this surface the
436
+ // "Custom View": PRD items 10 / R2, T41's own `done-when`, D-391, and `ScriptViewPanel`'s
437
+ // shipped copy ("Picking Custom View mints a view with an EMPTY source"). "Script" was the
438
+ // product calling one thing two names, which is the defect `swipe`'s label note warns about
439
+ // one table up.
440
+ //
441
+ // β›” IT IS NOT SPELLED "Custom View" HERE, and the reason is this table's job rather than a
442
+ // preference. Every value is a KIND NOUN that four call sites COMPOSE into a phrase:
443
+ // `ViewSidebar` renders `New ${MODE_LABELS[creating].toLowerCase()} view` (:1060), the same
444
+ // string as the flyout's aria-label (:1045), `${MODE_LABELS[mode]} view, locked` (:1619) and
445
+ // `It also stays a ${MODE_LABELS[mode].toLowerCase()}.` (:1626). A label already carrying the
446
+ // noun reads "New custom view view" at two of them. One word keeps the create prompt reading
447
+ // "New custom view", the locked aria-label reading "Custom view, locked", and the picker row
448
+ // reading "Custom" beside Grid, Chart and Map, which are kind nouns too.
449
+ script: "Custom",
450
+ };
451
+
452
+ /**
453
+ * I14 β€” the view types the "+ Create new…" flyout OFFERS, in the order it lists them.
454
+ *
455
+ * Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
456
+ * C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
457
+ * rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β€” one list cannot
458
+ * express both. Living in this pure data module means the gate can assert the offered set
459
+ * without importing a React component, and I10 becomes a one-line edit in one file.
460
+ */
461
+ // 2026-08-02 item 7 β€” `timeseries` was deliberately held OUT of this list until the host
462
+ // accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
463
+ // wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
464
+ // not know, so offering it early would let a user create a view that silently reverts to a
465
+ // grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
466
+ // `DISPLAY_MODES += timeseries`, so it is offerable now.
467
+ //
468
+ // wave17 GRID, owner item 10 β€” THE ORDER BELOW IS THE OWNER'S, stated verbatim:
469
+ // Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
470
+ //
471
+ // ⚠ It supersedes the two orderings this list has carried before it, and the reasoning that
472
+ // produced them is now WRONG rather than merely outranked, so it is not left here to be
473
+ // re-applied: `timeseries` was "listed last… it belongs beside Chart", and `list` sat second
474
+ // as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
475
+ // a product decision, so it is asserted in `verify_icons` rather than left to a comment β€”
476
+ // nothing else on screen would go red if a future edit re-sorted it "sensibly".
477
+ // Wave-18 C6-CATALOG β€” `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
478
+ // accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
479
+ // "C6 HOST MIRROR APPLIED β€” you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
480
+ // It lands LAST by contract: the owner's seven-mode order above is a product decision and the
481
+ // new mode joins the end of it rather than being sorted into it.
482
+ // ⭐ Wave-27 C3 (owner item 8) β€” `swipe` is HELD OUT of this list, and the reason corrects a
483
+ // mistake this file made an hour earlier.
484
+ //
485
+ // β›” THE HOLD WAS NEVER ONLY ABOUT THE TWO REGISTRIES. It was first written as "do not offer a
486
+ // mode the HOST has not accepted", and one session owning both registries this wave genuinely
487
+ // does close that half β€” which is what made it tempting to skip. But the rule the hold really
488
+ // encodes is broader and this wave proved it: **do not offer a mode whose CONSUMER does not
489
+ // exist.** `swipe` was briefly listed here while `CustomerGrid`'s mode dispatch had no branch
490
+ // for it, so picking "Swipe" wrote a mode the host now happily PERSISTS, and the body rendered
491
+ // a grid under a chip reading "View Β· Swipe" β€” surviving reload, with the agreement leg green
492
+ // because it only ever compared two lists.
493
+ //
494
+ // So the hold stood until `SwipeView` was mounted (contract C3), and it was not a mailbox
495
+ // handshake: `verify_icons` DERIVES the condition β€” either `swipe` is absent here, or
496
+ // `CustomerGrid.tsx` mounts `SwipeView`. `form` (wave-23) is the same defect from the other
497
+ // direction, sitting unoffered because its host mirror never landed (DEBT D-90).
498
+ //
499
+ // β›” THE HOLD OUTLIVED THE WAVE, AND THAT IS THE LESSON WORTH MORE THAN THE FEATURE.
500
+ // Wave 27 closed with the mount NEVER LANDING. `SwipeView.tsx` shipped, the server accepted
501
+ // `swipe`, all four maps above carried it, 50 gates were green, the wave-27 mailbox recorded
502
+ // "RESOLVED BY C (swipe mounted)", the close-out booked D-102 β€” a negative control FOR the
503
+ // swipe carry β€” and the owner could not find the view because **nothing imported the file.**
504
+ // The derived gate could not catch it: its condition is a DISJUNCTION and **absence satisfies
505
+ // it**, so the unshipped state was permanently green ([[gate-can-report-green-on-nothing]]).
506
+ // A hold that is safe to leave in place is a hold nothing forces you to lift.
507
+ // The audit query that found it, after the battery did not: for each artifact a wave adds, grep
508
+ // for its CONSUMER β€” who imports/mounts/registers it β€” excluding the file itself, `_test/`, css
509
+ // and comments. `SwipeView` had four hits and three were prose.
510
+ // Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
511
+ // It lands LAST, by `catalog`'s rule: the owner's mode order is a product decision and a new
512
+ // mode joins the end of it rather than being sorted into it.
513
+ export const CREATABLE_MODES: DisplayMode[] = [
514
+ "grid",
515
+ "chart",
516
+ "calendar",
517
+ "kanban",
518
+ "timeseries",
519
+ "map",
520
+ "list",
521
+ "catalog",
522
+ "swipe",
523
+ // Mounted 2026-08-11 (`CustomerGrid.tsx`, `displayMode === "form"` renders `FormInterface`), so
524
+ // `form` is offerable and its `HELD_MODES` entry came out in the same edit β€” the hold's own text
525
+ // named this mount as its release condition. ⚠ Found by `verify_icons.py::mode_parity` law E
526
+ // during /validate-wave, NOT by the lane that mounted it: T29 mounted the component and T41 built
527
+ // the Interface group, and between them the DOOR was never opened β€” the wave's biggest new
528
+ // surface was unreachable behind two green tickets.
529
+ "form",
530
+ // ⭐⭐ W37-T41 (owner item 10 / R2) β€” `script` JOINS THE OFFER, and this is the release of the
531
+ // longest-held entry in this file. The hold's own release condition, written into
532
+ // `icons.test.ts::HELD_MODES`, named THREE things that had to land in one change: the name
533
+ // joins this list, `CustomerGrid.tsx` gains a `displayMode === "script"` branch, and the
534
+ // HELD_MODES entry comes out. All three are in this change.
535
+ //
536
+ // β›” AND A FOURTH THAT THE HOLD DID NOT NAME, which is the one that decides whether the
537
+ // feature works: `CustomerGrid.tsx::createView` had to learn that picking this mode mints a
538
+ // SCRIPT VIEW through `createScriptView` rather than persisting a workspace view. A script
539
+ // view is not a workspace view (it is its source and its version history, in E's own
540
+ // per-database store), so the three hops above alone would have offered the mode, written
541
+ // `config.display.mode = "script"` into a stored view, left `activeScriptId` null and drawn
542
+ // nothing, with `mode_parity` GREEN. That is wave 27's `swipe` defect exactly, reached
543
+ // through a different door.
544
+ // ⚠ OFFERED ONLY WHERE IT CAN EXIST. `ViewSidebar` takes `scriptable` and drops this row
545
+ // when it is false: script views are listed and resolved only on `ut_*` databases
546
+ // (`CustomerGrid`'s `scriptRows` effect gates on `hostWorkspace && isUserTable`), so offering
547
+ // it on a pool scope would mint a view the rail can never show
548
+ // ([[permitted-is-not-answerable]]). It lands LAST by `catalog`'s rule.
549
+ "script",
550
+ ];
551
+
552
+ /**
553
+ * ⭐ WAVE-29 R6 (owner item 10) β€” the kind dropdown splits under TWO headers, and the strings are
554
+ * the owner's own: exactly `View` and `Interface`. Not "View as" (what the popover said before),
555
+ * not "Custom interface".
556
+ *
557
+ * The line the two groups draw: a **View** ARRANGES the records β€” the same rows, re-shaped (a
558
+ * grid, a board, a chart). An **Interface** is a SURFACE BUILT OVER them: a map is a picture of the
559
+ * world with records placed on it, a catalog is a published artifact, a form is a door records come
560
+ * IN through and shows no records at all.
561
+ *
562
+ * ⭐ WAVE-33 item 9 AMENDED THAT LINE, and the owner drew it one notch differently than R6 did:
563
+ * `swipe` and `timeseries` moved from View to Interface. The reading that makes both rulings one
564
+ * rule β€” and the one the next mode should be grouped by β€” is **what the surface is FOR**, not how
565
+ * many rows it shows. A deck triages records one at a time and WRITES to them; a trend answers a
566
+ * question about a measure over time; neither hands you the row set re-shaped, which is what every
567
+ * remaining View does. ⚠ Do NOT re-derive the split from `MODE_TONE` below: the tones still say
568
+ * `catalog`/`form` are neutral because they arrange nothing, and `swipe`/`timeseries` are toned,
569
+ * so tone and group are no longer the same cut. Group membership lives HERE and only here.
570
+ *
571
+ * ⚠ TOTAL over `DisplayMode`, so tsc refuses a new mode with no group rather than letting it
572
+ * vanish from the dropdown: membership is derived by FILTERING `CREATABLE_MODES` through this
573
+ * map, and a mode whose group label matched nothing would silently stop being offered while
574
+ * every existing check (paintable, labelled, toned, unique, ordered) stayed green.
575
+ * `dashboard` is grouped like the `chart` it is the legacy spelling of β€” it is never offered, and
576
+ * a partial map is a worse answer than an unused entry.
577
+ */
578
+ export const MODE_GROUP_LABELS = ["View", "Interface"] as const;
579
+ export type ModeGroup = (typeof MODE_GROUP_LABELS)[number];
580
+
581
+ export const MODE_GROUP: Record<DisplayMode, ModeGroup> = {
582
+ grid: "View",
583
+ list: "View",
584
+ kanban: "View",
585
+ calendar: "View",
586
+ chart: "View",
587
+ dashboard: "View",
588
+ // ⭐ WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
589
+ // of under View, when a user toggle it." See the amended taxonomy note above β€” a deck and a
590
+ // trend are both surfaces a person WORKS IN, not re-shapings of the row set.
591
+ timeseries: "Interface",
592
+ swipe: "Interface",
593
+ map: "Interface",
594
+ catalog: "Interface",
595
+ form: "Interface",
596
+ // W36-T04 β€” "Interface", by the W33 item-9 taxonomy: a script view is a surface somebody WORKS
597
+ // IN (writes, runs, reads an answer), not a re-shaping of the row set.
598
+ script: "Interface",
599
+ };
600
+
601
+ /**
602
+ * The offered modes, split into R6's two groups β€” DERIVED, never a third hand-written list.
603
+ *
604
+ * ⚠ ORDER: R6 names each group's MEMBERS; the order inside a group stays `CREATABLE_MODES`', which
605
+ * is the wave-17 owner ruling ("Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List") and
606
+ * is separately asserted. The two rulings are compatible read this way and only this way: R6 moved
607
+ * `map` out of the run of views, so wave-17's single sequence can no longer exist as one list, but
608
+ * every pair it ordered is still in that relative order here.
609
+ */
610
+ export const CREATABLE_GROUPS: readonly { label: ModeGroup; modes: DisplayMode[] }[] =
611
+ MODE_GROUP_LABELS.map((label) => ({
612
+ label,
613
+ modes: CREATABLE_MODES.filter((m) => MODE_GROUP[m] === label),
614
+ }));
615
+
616
+ export const MODE_TONE: Record<DisplayMode, FolderTone> = {
617
+ grid: "blue",
618
+ list: "blue",
619
+ chart: "green",
620
+ dashboard: "green",
621
+ calendar: "yellow",
622
+ kanban: "yellow",
623
+ map: "red",
624
+ // Green with `chart`: it is the other analytical mode, and the two belong to one family.
625
+ timeseries: "green",
626
+ // Wave-18 C6-CATALOG β€” NEUTRAL, and it is the honest pick rather than the leftover one. The
627
+ // four colour tones each name a family of ways to arrange records (blue = tabular, green =
628
+ // analytical, yellow = board/date, red = spatial); a catalog arranges nothing β€” it is a
629
+ // published artifact. Giving it a colour would file it under a family it is not in.
630
+ catalog: "neutral",
631
+ // Wave-23 C9 β€” NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones
632
+ // name families of ways to ARRANGE records (blue tabular, green analytical, yellow
633
+ // board/date, red spatial). A form arranges nothing β€” it is a door records come in through β€”
634
+ // so giving it a colour would file it under a family it is not in.
635
+ form: "neutral",
636
+ // ⭐ Wave-27 C3 β€” YELLOW, with `kanban`, and this is a family claim rather than a leftover:
637
+ // a swipe deck writes the SAME single-select a kanban stacks by (R2 binds it to one), so the
638
+ // two are one family seen at two zooms β€” all the lanes at once, or one card at a time. Filing
639
+ // it neutral (the `catalog`/`form` reasoning) would be wrong for the opposite reason those
640
+ // two are neutral: this mode does arrange records, and it arranges them by the board's field.
641
+ swipe: "yellow",
642
+ // W36-T04 β€” NEUTRAL, by `catalog`'s and `form`'s rule rather than by elimination: the four
643
+ // tones name families of ways to ARRANGE records (blue tabular, green analytical, yellow
644
+ // board/date, red spatial). A script arranges nothing; it emits whatever it computes.
645
+ script: "neutral",
646
+ };
647
+
648
+ /**
649
+ * Human labels for every field type. Lives here beside the icons so the two
650
+ * halves of "how a field type presents itself" stay in one file (ColumnMenu
651
+ * imports it rather than keeping a second copy).
652
+ */
653
+ export const TYPE_LABELS: Record<FieldType, string> = {
654
+ // ⭐ WAVE-29 item 3 β€” the owner's own words: "Change 'Single line text' to 'Text', keep it
655
+ // simple for the Field type". Airtable's phrase described the column's SHAPE (one line, versus
656
+ // its long-text sibling); this product has no multi-line text kind, so the qualifier
657
+ // distinguished the type from nothing and only made the commonest row in the menu the longest.
658
+ // βœ… The STORED key is `"text"` and always was β€” the old string was never persisted anywhere,
659
+ // client or server, so this is a label change with no migration behind it.
660
+ text: "Text",
661
+ select: "Single select",
662
+ multiselect: "Multi select",
663
+ user: "Assignee",
664
+ int: "Number",
665
+ currency: "Currency",
666
+ pct: "Percent",
667
+ date: "Date",
668
+ checkbox: "Checkbox",
669
+ phone: "Phone number",
670
+ email: "Email",
671
+ url: "URL",
672
+ rating: "Rating",
673
+ created_time: "Created time",
674
+ formula: "Formula",
675
+ // Wave-18 C5-AUTOFIELD (D's spec, applied by C).
676
+ automation: "Automation",
677
+ // Wave-22 C7 β€” spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
678
+ // on headers and the field gear, not the create menu.
679
+ metric: "Metric",
680
+ // Wave-19 R7 β€” the picture column.
681
+ image: "Image",
682
+ // Wave-23 C7 β€” the structured-document column. "JSON" rather than "Structured data": it is
683
+ // the word on the wire, in the viewer's raw tab and in every error the server can return, and
684
+ // a friendlier synonym would be the only place in the product using a different one.
685
+ json: "JSON",
686
+ // ⭐ 2026-08-07 β€” Airtable's own wording, deliberately. "Link to another record" is what a
687
+ // person migrating from Airtable searches this menu for, and inventing a synonym ("Relation",
688
+ // "Reference") would make the feature they came for look absent.
689
+ link: "Link to another record",
690
+ rollup: "Rollup",
691
+ // ⭐ Wave-27 item 13 (R13) β€” "Code", not "Snippet" or "Source": it is the word the field kind
692
+ // is called everywhere else in this wave (the ruling, the language picker, the viewer header),
693
+ // and it says what the column holds without implying the product will run it.
694
+ code: "Code",
695
+ // ⭐ WAVE 34 Β· T53 (R13), on F's ask (`F-2`) β€” the owner's own noun for the kind: "a field kind
696
+ // called AI enrichment". Not "AI field" (every field in an AI-built view would qualify) and not
697
+ // "Generate" (that names the verb, and the column's value is the point, not the act).
698
+ ai_enrich: "AI enrichment",
699
+ status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
700
+ };
701
+
702
+ /**
703
+ * ⭐ WAVE-29 C7 (item 17) β€” THE COLUMN-SUMMARY vocabulary: what a field's `agg` may be, which is
704
+ * what the totals row and the per-group subtotals compute. Server twin:
705
+ * `platform/aios_grid.py::FIELD_AGGS`, and `verify_icons.py::agg_parity` reads BOTH FILES and
706
+ * compares them name-for-name in order β€” the cross-language boundary is the one a type cannot
707
+ * police, so it gets a gate.
708
+ *
709
+ * β›” ONE CLIENT LIST, IMPORTED β€” never re-declared. `aggregations.ts` and the field editor import
710
+ * from here rather than keeping their own copy, which is why this lives in the pure data module
711
+ * beside `TYPE_LABELS` and `CREATABLE_MODES`: a second client list would need a second gate, and
712
+ * the two would drift in the direction nobody is watching. C7 says "C publishes, E mirrors"; a
713
+ * mirror that is an import cannot fall out of step at all.
714
+ *
715
+ * β›” NOT the chart vocabulary. `CHART_AGGS` (`aios_grid.py`, `viz/chartData.ts`) spells it `avg`
716
+ * and gatekeeps a STORED value β€” renaming it would silently turn saved charts into sums. This
717
+ * list spells it `average`, matching `ROLLUP_FNS` (16 names, live in production), so a column
718
+ * summary and a rollup fold say the same word for the same operation.
719
+ *
720
+ * ⚠ `median` is net-new β€” in neither `CHART_AGGS` nor `ROLLUP_FNS`.
721
+ * ⚠ `count` counts ROWS in the scope, not non-blank cells.
722
+ * ⚠ Which types may carry which: `sum/average/median/min/max` are numeric-only and the evaluator
723
+ * for that is ALREADY `isNumericFieldType` (types.ts) β€” do not write a second one. `count` is
724
+ * legal on any type.
725
+ */
726
+ // ⭐ W29-T74 β€” an ALIAS of `types.AggName`, not a fifth copy of the union. `Field.agg` is typed
727
+ // `AggName`, so a second literal here would be a type that has to be kept in step by eye with a
728
+ // type the compiler already owns β€” the same defect as the array below, one level up.
729
+ export type FieldAgg = AggName;
730
+
731
+ /** ORDERED β€” the order is the picker's order, on both engines. */
732
+ export const FIELD_AGGS: readonly FieldAgg[] = [
733
+ "sum",
734
+ "average",
735
+ "median",
736
+ "min",
737
+ "max",
738
+ "count",
739
+ ];
740
+
741
+ /**
742
+ * Human labels, in the summary bar's own compact register (Airtable's wording).
743
+ *
744
+ * ⚠ `FIELD_AGG_LABELS`, not `AGG_LABELS`, and the prefix is load-bearing: `viewModes.tsx` already
745
+ * has a module-local `AGG_LABELS` for the CALENDAR summary picker over `CHART_AGGS`, where the
746
+ * same five names wear different words ("Total", "Lowest", "Highest") for a day cell. Two tables
747
+ * called `AGG_LABELS` describing two vocabularies is how a future import lands on the wrong one.
748
+ */
749
+ export const FIELD_AGG_LABELS: Record<FieldAgg, string> = {
750
+ sum: "Sum",
751
+ average: "Average",
752
+ median: "Median",
753
+ min: "Min",
754
+ max: "Max",
755
+ count: "Count",
756
+ };
757
+
758
+
759
+ // ------------------------------------------------------------ glide sprites
760
+
761
+ /** Serialize one shape to SVG source in an explicit colour (canvas sprites get
762
+ * no `currentColor` β€” glide hands the painter the theme colours directly). */
763
+ function shapeSource(s: IconShape, color: string): string {
764
+ return s.fill
765
+ ? `<path d="${s.d}" fill="${color}"/>`
766
+ : `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
767
+ `stroke-linecap="round" stroke-linejoin="round"/>`;
768
+ }
769
+
770
+ function sprite(shapes: IconShape[]) {
771
+ return ({ fgColor }: { fgColor: string }) =>
772
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
773
+ shapes.map((s) => shapeSource(s, fgColor)).join("") +
774
+ `</svg>`;
775
+ }
776
+
777
+ /** Glide header-icon NAME for a field type β€” the `icon` a GridColumn asks for. */
778
+ export function typeIconName(type: FieldType): string {
779
+ return `t_${type}`;
780
+ }
781
+
782
+ /**
783
+ * The sprite map handed to <DataEditor headerIcons>. One entry per field type
784
+ * (I20 draws the type mark in every column header), built from the same shapes
785
+ * the React icons use.
786
+ *
787
+ * Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
788
+ * why theme.ts must set it β€” the library default is #FFFFFF, i.e. invisible on
789
+ * our header (that was I21's actual bug, not a too-pale hex of ours).
790
+ */
791
+ export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
792
+ Object.fromEntries(
793
+ (Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
794
+ );
795
+
796
+ /**
797
+ * The header sprite map handed to <DataEditor headerIcons>. Two families:
798
+ *
799
+ * t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
800
+ * from the same shapes the React icons use. Painted by glide in
801
+ * `theme.fgIconHeader`.
802
+ * aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i).
803
+ * OUTLINE ONLY: a dark-grey ring with a transparent interior, per
804
+ * the owner. It still deliberately IGNORES the colours glide hands
805
+ * it, for the reason wave-8 recorded - glide's "special" variant is
806
+ * accentColor behind bgHeader, which under the C1 pastels is a pale
807
+ * glyph on a pale disc, i.e. I21 in a new costume.
808
+ * ⚠ It is NO LONGER a column `overlayIcon`. Glide draws an overlay
809
+ * at a hard-coded offset from the TYPE mark on the far LEFT of the
810
+ * header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
811
+ * aligned. It is now painted by CustomerGrid's `drawHeader`
812
+ * callback at `infoMarkRect()` - see overlayPlacement.ts.
813
+ */
814
+ /**
815
+ * ⭐ W40-T20 β€” THE share glyph: two people, one behind the other. The ONE geometry for "this is
816
+ * shared", as data, so the canvas sprite below and any React consumer paint the same shape
817
+ * instead of two hand-copied SVGs that drift.
818
+ *
819
+ * β›” NOT A PADLOCK. `LockMark` (icons.tsx) is a padlock body plus a shackle and it means a LOCK
820
+ * (DESIGN.md Β§4, THE THREE LOCKS). A share is a different fact about a column and wears a
821
+ * different mark, which is the whole of "one mark, one meaning" β€” D-254 is what happens when it
822
+ * does not.
823
+ *
824
+ * Traced off the rail's `.cg-view-shared` mark (ViewSidebar.tsx) at the same viewBox, so a shared
825
+ * VIEW in the rail and a shared FIELD in a header say "shared" with the same drawing. The head is
826
+ * an arc path rather than a `<circle>` because `IconShape` is path data and one serializer
827
+ * (`shapeSource`) paints the entire vocabulary.
828
+ */
829
+ export const SHARE_SHAPE: IconShape[] = [
830
+ // The near figure: head, then shoulders.
831
+ { d: "M5.6 3.4a2.2 2.2 0 1 1 0 4.4 2.2 2.2 0 1 1 0-4.4" },
832
+ { d: "M1.9 12.6c0-2 1.7-3.2 3.7-3.2s3.7 1.2 3.7 3.2" },
833
+ // The far figure, cropped by the near one: half a head and a shoulder.
834
+ { d: "M10.6 4.1a2.2 2.2 0 0 1 0 4.2M11.4 9.7c1.6.3 2.7 1.4 2.7 2.9" },
835
+ ];
836
+
837
+ export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = {
838
+ ...TYPE_SPRITES,
839
+ /**
840
+ * ⭐ W40-T20 β€” the shared-field mark, drawn by `CustomerGrid.drawGridHeader` on the innermost
841
+ * slot of `headerMarkFit`'s strip.
842
+ *
843
+ * It IGNORES the colours glide hands it, for exactly the reason `aiosInfo` does and recorded in
844
+ * that entry's note: glide's variants resolve to the accent behind `bgHeader`, which under the
845
+ * C1 pastels is a pale glyph on a pale disc. LP_MUTED keeps it legible on every header state.
846
+ */
847
+ aiosShared: () =>
848
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
849
+ SHARE_SHAPE.map((s) => shapeSource(s, LP_MUTED)).join("") +
850
+ `</svg>`,
851
+ aiosInfo: () =>
852
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
853
+ `<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` +
854
+ `<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` +
855
+ `stroke-linecap="round"/>` +
856
+ `<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` +
857
+ `</svg>`,
858
+ };
web/src/customer-grid/overlayPlacement.ts CHANGED
@@ -213,6 +213,18 @@ export function fitGroupLabel(
213
  export const CUSTOM_MARK_SIZE = 6;
214
  /** Clearance BETWEEN two marks in the strip. */
215
  export const HEADER_MARK_GAP = 6;
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
  /**
218
  * R11 β€” where the header's right-hand marks go, laid out from the OUTSIDE IN.
@@ -257,16 +269,83 @@ export function headerMarkLayout(
257
  return out;
258
  }
259
 
260
- /** The strip's ORDER, in one place: (i) outermost, the user-created dot inboard of it. Both the
261
- * truncation reserve and the drawing path read this, so they cannot disagree about how many
262
- * marks a column carries or which is which. */
263
- export function headerMarkSizes(hasInfoMark: boolean, isCustomField: boolean): number[] {
 
 
 
 
 
 
 
 
 
 
264
  const out: number[] = [];
265
  if (hasInfoMark) out.push(INFO_MARK_SIZE);
266
  if (isCustomField) out.push(CUSTOM_MARK_SIZE);
 
267
  return out;
268
  }
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  // ------------------------------------------------- owner item 16: the header title vs the (i)
271
 
272
  /**
@@ -310,13 +389,22 @@ export function headerLabelSpace(
310
  * the label gets reserved room for only one of them.
311
  */
312
  marks: boolean | readonly number[],
313
- menuSlotWidth = HEADER_MENU_SLOT
 
 
 
 
 
 
 
314
  ): number | null {
315
  const sizes = marks === true ? [INFO_MARK_SIZE] : marks === false ? [] : marks;
316
- // The INNERMOST mark is the one the label must clear, and `headerMarkLayout` is all-or-nothing,
317
- // so this is still ONE geometry source queried twice rather than two copies of the arithmetic.
318
- const layout = headerMarkLayout({ x: 0, y: 0, width, height: 32 }, menuSlotWidth, sizes);
319
- if (!layout) return null;
 
 
320
  return Math.max(0, layout[layout.length - 1].x - HEADER_LABEL_X - HEADER_LABEL_GAP);
321
  }
322
 
@@ -423,45 +511,76 @@ export function expandButtonRect(
423
  return { x, y, size };
424
  }
425
 
426
- // ------------------------------------------------ W35-T29 (R5): the record star, on the row
427
 
428
  /** Smaller than the Expand button: a mark, not an action with a destination. */
429
  export const STAR_BTN_SIZE = 18;
430
- /** From the LEFT edge of the primary cell, mirroring `EXPAND_BTN_INSET` at the other end. */
431
- export const STAR_BTN_INSET = 4;
 
 
 
 
 
432
 
433
  /**
434
- * ⭐⭐ W35-T29 (R5) β€” where the row's star sits: **at the LEFT end of the primary cell**, which is
435
- * the gutter position in this product. Airtable puts the row's own marks there; our Expand button
436
- * already occupies the RIGHT end of the same cell (`expandButtonRect`), so the two book-end the
437
- * primary cell and neither can land on the other.
438
- *
439
- * β›” THE SAME BOUNDS, FROM THE SAME SOURCE. `primary` is glide's own `getBounds(0, row)`, so
440
- * freeze, horizontal scroll and row-height mode are handled by the component that owns them
441
- * rather than re-derived here β€” and because the button is a real `<button>` over the canvas, this
442
- * rect IS the hit test. A canvas-DRAWN star would need a second copy of this arithmetic for its
443
- * click target, which is the shape [[ui-invisible-to-assertions]] names.
444
- *
445
- * ⚠ ITS MINIMA ARE ITS OWN AND ARE LOWER THAN THE EXPAND'S. This mark is smaller and sits where
446
- * the text starts rather than where it ends, so a row that cannot carry the Expand can still
447
- * carry this. Sharing `EXPAND_BTN_MIN_ROW` would have hidden the star on exactly the compact row
448
- * heights a long list is read at.
449
- *
450
- * `null` when the row is too short, or when the rect would fall outside the grid's own box β€”
451
- * absent rather than clamped, for the same reason the Expand is: a clamped `position: fixed`
452
- * button sits at the edge pointing at a row whose cell is somewhere else.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  */
454
  export function starButtonRect(
455
  primary: { x: number; y: number; width: number; height: number },
456
  box?: { x: number; y: number; width: number; height: number },
457
  size = STAR_BTN_SIZE,
458
- inset = STAR_BTN_INSET
459
  ): { x: number; y: number; size: number } | null {
460
- if (primary.height < size + 2) return null;
461
- // It must leave room for a readable label beside it, or the mark covers the name it marks.
462
- if (primary.width < size + inset * 2 + EXPAND_BTN_MIN_LABEL) return null;
463
- const x = Math.round(primary.x + inset);
464
- const y = Math.round(primary.y + (primary.height - size) / 2);
 
 
 
 
 
 
465
  if (box) {
466
  if (x < box.x || x + size > box.x + box.width) return null;
467
  if (y < box.y || y + size > box.y + box.height) return null;
 
213
  export const CUSTOM_MARK_SIZE = 6;
214
  /** Clearance BETWEEN two marks in the strip. */
215
  export const HEADER_MARK_GAP = 6;
216
+ /**
217
+ * ⭐ W40-T20 β€” the SHARE mark, the strip's third slot: "this field is shared, so what you type
218
+ * here is what everyone with access to this table reads".
219
+ *
220
+ * 13px is the size `ViewSidebar` renders the same two-people glyph at, so one concept reads at
221
+ * one weight wherever the product says it.
222
+ *
223
+ * β›” IT IS NOT A PADLOCK AND IT IS NOT `LockMark`. A share and a lock are different facts about a
224
+ * column (DESIGN.md Β§4, THE THREE LOCKS: a share says who else can see the column, a lock says
225
+ * what may be changed), and one glyph carrying both meanings is D-254 in a second surface.
226
+ */
227
+ export const SHARE_MARK_SIZE = 13;
228
 
229
  /**
230
  * R11 β€” where the header's right-hand marks go, laid out from the OUTSIDE IN.
 
269
  return out;
270
  }
271
 
272
+ /** The strip's ORDER, in one place: (i) outermost, the user-created dot inboard of it, the share
273
+ * mark innermost. Both the truncation reserve and the drawing path read this, so they cannot
274
+ * disagree about how many marks a column carries or which is which.
275
+ *
276
+ * ⭐ W40-T20 β€” `isSharedField` is OPTIONAL and defaults to false, deliberately: every pre-T20
277
+ * call site asks the two-boolean question and must keep getting the two-boolean answer. The
278
+ * share mark goes LAST because last is INNERMOST, and innermost is the slot `headerMarkFit`
279
+ * gives up first β€” so a column too tight for three marks loses the new one rather than the two
280
+ * that shipped before it. */
281
+ export function headerMarkSizes(
282
+ hasInfoMark: boolean,
283
+ isCustomField: boolean,
284
+ isSharedField = false
285
+ ): number[] {
286
  const out: number[] = [];
287
  if (hasInfoMark) out.push(INFO_MARK_SIZE);
288
  if (isCustomField) out.push(CUSTOM_MARK_SIZE);
289
+ if (isSharedField) out.push(SHARE_MARK_SIZE);
290
  return out;
291
  }
292
 
293
+ /**
294
+ * ⭐ W40-T20 β€” does this field's header carry a share mark? THE rule, in one exported place.
295
+ *
296
+ * It lives here rather than inside `CustomerGrid.drawGridHeader` for the reason the rest of this
297
+ * module exists: a decision buried in a React canvas callback is unobservable to anything but a
298
+ * screenshot, and this one has two consumers (the drawer, and the label reserve) that must never
299
+ * answer it differently.
300
+ *
301
+ * ⚠ STRUCTURALLY TYPED ON PURPOSE. This module imports nothing β€” that is what lets a gate compile
302
+ * it with tsc and run it under node β€” so it takes the one property it reads rather than `Field`.
303
+ *
304
+ * β›” `shared` means "this definition lives in the tenant-wide field stratum", i.e. THIS FIELD HAS
305
+ * BEEN SHARED β€” not "somebody shared it WITH ME" (contract C1). The server stamps it on the
306
+ * owner's own copy too (`routes_customers._merge_shared_fields`, `shares.role_for` returning
307
+ * `"owner"`), so the mark reads as "this column is shared" for everyone who can see it, and the
308
+ * copy that explains it must not be worded as though the reader were only ever the receiver.
309
+ */
310
+ export function hasShareMark(field: { shared?: boolean }): boolean {
311
+ return field.shared === true;
312
+ }
313
+
314
+ /**
315
+ * ⭐ W40-T20 β€” the strip that DEGRADES, and the ONE function both the drawer and the label
316
+ * reserve go through.
317
+ *
318
+ * Why it had to exist. `headerMarkLayout` is ALL-OR-NOTHING by contract, and that contract is
319
+ * what stops the reserve and the drawer disagreeing (see its note). It is also why a third mark
320
+ * could not simply be appended: on a column with room for exactly two, asking for three returns
321
+ * `null`, and the (i) and the user-created dot β€” both shipped, both gated β€” would have vanished
322
+ * to make room for a mark that was then not drawn either. Adding a feature by deleting two is
323
+ * not adding a feature.
324
+ *
325
+ * So the strip gives way from the INSIDE, one droppable slot at a time. `droppable` is how many
326
+ * TRAILING slots may be surrendered; everything ahead of them keeps the all-or-nothing behaviour
327
+ * it shipped with. **`droppable = 0` is exactly `headerMarkLayout`** β€” which is why every
328
+ * pre-T20 caller, and every negative control aimed at that function, is untouched by this.
329
+ *
330
+ * Returns the surviving layout AND how many slots survived, because a drawer needs to know which
331
+ * marks it may paint, not merely where they would have gone.
332
+ */
333
+ export function headerMarkFit(
334
+ header: { x: number; y: number; width: number; height: number },
335
+ menuSlotWidth: number,
336
+ sizes: readonly number[],
337
+ droppable = 0
338
+ ): { layout: { x: number; y: number; size: number }[]; kept: number } | null {
339
+ // Never below one mark: a strip that "degrades" to nothing is the all-or-nothing answer, and
340
+ // `headerMarkLayout` already gives that by returning null.
341
+ const floor = Math.max(1, sizes.length - Math.max(0, droppable));
342
+ for (let kept = sizes.length; kept >= floor; kept--) {
343
+ const layout = headerMarkLayout(header, menuSlotWidth, sizes.slice(0, kept));
344
+ if (layout) return { layout, kept };
345
+ }
346
+ return null;
347
+ }
348
+
349
  // ------------------------------------------------- owner item 16: the header title vs the (i)
350
 
351
  /**
 
389
  * the label gets reserved room for only one of them.
390
  */
391
  marks: boolean | readonly number[],
392
+ menuSlotWidth = HEADER_MENU_SLOT,
393
+ /**
394
+ * ⭐ W40-T20 β€” how many TRAILING marks this column is willing to give up, passed straight to
395
+ * `headerMarkFit`. It defaults to 0, so every pre-T20 caller keeps the all-or-nothing reserve
396
+ * it had; a caller that asks for a droppable share mark must say so here too, or it reserves
397
+ * room for a strip the drawer will not paint.
398
+ */
399
+ droppable = 0
400
  ): number | null {
401
  const sizes = marks === true ? [INFO_MARK_SIZE] : marks === false ? [] : marks;
402
+ // The INNERMOST mark is the one the label must clear, and the strip is fitted through
403
+ // `headerMarkFit` β€” the SAME function the drawer uses β€” so the reserve is taken against the
404
+ // marks that actually SURVIVE the column's width, never against the ones merely asked for.
405
+ const fit = headerMarkFit({ x: 0, y: 0, width, height: 32 }, menuSlotWidth, sizes, droppable);
406
+ if (!fit) return null;
407
+ const layout = fit.layout;
408
  return Math.max(0, layout[layout.length - 1].x - HEADER_LABEL_X - HEADER_LABEL_GAP);
409
  }
410
 
 
511
  return { x, y, size };
512
  }
513
 
514
+ // -------------------------------- the record star, BESIDE the Expand (owner instruction 29)
515
 
516
  /** Smaller than the Expand button: a mark, not an action with a destination. */
517
  export const STAR_BTN_SIZE = 18;
518
+ /**
519
+ * The clear space between the star and the Expand it now sits against.
520
+ *
521
+ * 4px is not a taste call: it is `.cg-detail-nav`'s own `gap`, which is what a group of adjacent
522
+ * icon buttons already measures elsewhere in this grid, and it is on `DESIGN.md` Β§4's 4/8 grid.
523
+ */
524
+ export const STAR_BTN_GAP = 4;
525
 
526
  /**
527
+ * ⭐⭐ OWNER INSTRUCTION 29 (wave 40, 2026-08-24) β€” where the row's star sits: **immediately to
528
+ * the LEFT of the Expand button**, at the right end of the primary cell, level with it.
529
+ *
530
+ * β›” THIS REVERSES W35-T29 (R5, R6), AND THE OLD ARGUMENT IS RECORDED HERE RATHER THAN DELETED,
531
+ * because a reversed ruling whose reasoning is left standing is how the next session restores it.
532
+ * R5 put the star at the LEFT end of the primary cell and the Expand at the right, deliberately:
533
+ * Airtable keeps a row's own marks in the gutter, and book-ending the cell meant neither control
534
+ * could ever land on the other. That was a real design, it shipped, and the owner has now ruled
535
+ * against it: *"The star label on a record must sit exactly beside and to the LEFT of the record
536
+ * expand icon, on every database."* Beside beats book-ended. The two are now ONE CLUSTER at the
537
+ * right end, and everything below follows from that single fact.
538
+ *
539
+ * β›” DERIVED FROM THE EXPAND, NOT COMPUTED ALONGSIDE IT. This function asks `expandButtonRect`
540
+ * where its neighbour is and steps left from the answer. That is what makes "beside" a property
541
+ * rather than a coincidence: two independent copies of the same arithmetic drift, and this file
542
+ * exists because that drift is invisible to tsc, to the build and to a screenshot taken at one
543
+ * size ([[ui-invisible-to-assertions]]).
544
+ *
545
+ * β›” ITS MINIMA ARE THE EXPAND'S NOW, AND THAT REPLACES THE OPPOSITE CLAIM R5 MADE HERE. The old
546
+ * comment argued the star should keep LOWER minima than the Expand, so "a row that cannot carry
547
+ * the Expand can still carry this", and warned that sharing `EXPAND_BTN_MIN_ROW` would hide the
548
+ * star "on exactly the compact row heights a long list is read at". That protection was sound for
549
+ * a mark living at the far end of the cell and is incoherent for one anchored to the Expand: a
550
+ * star placed beside a button that is not there is a mark floating where its neighbour should be.
551
+ * It also protected a case this product cannot produce. `CustomerGrid.ROW_PX` is the whole set of
552
+ * data-row heights and it is `{ short: 28, medium: 34, tall: 48 }` β€” every one of them clears
553
+ * `EXPAND_BTN_MIN_ROW` (26), so the band the old minima defended (20..25px) is empty. Deleting
554
+ * the second height gate rather than re-tuning it is what makes "both or neither" structural.
555
+ *
556
+ * ⚠ THE WIDTH GATE IS THIS FUNCTION'S OWN, AND IT IS DELIBERATELY STRICTER THAN THE EXPAND'S.
557
+ * Two controls plus a readable name need more of the cell than one did, so the star refuses at a
558
+ * width the Expand still accepts. The asymmetry is on purpose and runs only in this direction:
559
+ * item 17 removed the click that used to open a record, so the Expand is the ONLY pointer route
560
+ * to one and must not disappear from a narrow primary column to keep a mark company.
561
+ *
562
+ * `null` when the Expand is absent for any reason, when the cell is too narrow for the pair, or
563
+ * when the star's own rect would fall outside the grid's box β€” absent rather than clamped, for
564
+ * the reason the Expand is: a clamped `position: fixed` button sits at the edge pointing at a row
565
+ * whose cell is somewhere else.
566
  */
567
  export function starButtonRect(
568
  primary: { x: number; y: number; width: number; height: number },
569
  box?: { x: number; y: number; width: number; height: number },
570
  size = STAR_BTN_SIZE,
571
+ gap = STAR_BTN_GAP
572
  ): { x: number; y: number; size: number } | null {
573
+ // No Expand, no star. The row height gate, the label gate and the box clamp are all inherited
574
+ // from this one call, so the two controls cannot disagree about whether the row carries them.
575
+ const expand = expandButtonRect(primary, box);
576
+ if (!expand) return null;
577
+ // Room for BOTH controls, their gap, the gutter at either side, and a name still worth reading.
578
+ if (primary.width < EXPAND_BTN_SIZE + EXPAND_BTN_INSET * 2 + gap + size + EXPAND_BTN_MIN_LABEL)
579
+ return null;
580
+ const x = expand.x - gap - size;
581
+ // Level with its neighbour BY CONSTRUCTION: the same centre whatever the two sizes are, so
582
+ // "beside" means level and not merely adjacent, and no second centring rule can drift.
583
+ const y = expand.y + Math.round((expand.size - size) / 2);
584
  if (box) {
585
  if (x < box.x || x + size > box.x + box.width) return null;
586
  if (y < box.y || y + size > box.y + box.height) return null;
web/src/customer-grid/rowStar.css CHANGED
@@ -1,80 +1,87 @@
1
- /* ---------------------------------------------------------------------------
2
- customer-grid / rowStar.css β€” W35-T29 (rulings R5, R6), the record star.
3
-
4
- β›” ITS OWN SHEET, AND THE REASON IS OWNERSHIP RATHER THAN TIDINESS. `.cg-row-expand`, the hover
5
- control this one sits beside, lives in `index.css`, which belongs to another lane this wave. A
6
- new surface brings its own stylesheet (the wave's own rule) and a change to an existing
7
- `.cg-*` rule is that lane's ticket.
8
-
9
- ⚠ IMPORTED BY `CustomerGrid.tsx`, the module that RENDERS these classes β€” never by a lazy
10
- parent. W35-T22 in this same wave was exactly that mistake one directory over: `query.css` was
11
- imported only by a `lazy()` page while the component drawing its classes shipped in another
12
- chunk, so the rail painted unstyled for a network round trip.
13
- --------------------------------------------------------------------------- */
14
-
15
- /* The hover control at the LEFT end of the primary cell, mirroring the Expand button at its
16
- right end. Both are real DOM buttons laid over the canvas at glide's own bounds, so the rect
17
- IS the hit test and there is no second copy of the geometry to drift from the drawing. */
18
- .cg-row-star {
19
- position: fixed;
20
- z-index: 55;
21
- display: inline-flex;
22
- align-items: center;
23
- justify-content: center;
24
- padding: 0;
25
- border: 1px solid transparent;
26
- border-radius: var(--lp-r-sm);
27
- background: transparent;
28
- color: var(--lp-muted);
29
- cursor: pointer;
30
- }
31
-
32
- /* β›” A STARRED ROW'S MARK IS NOT HOVER-ONLY. The hover control is how you SET one; a row that is
33
- already starred has to say so while the pointer is elsewhere, or the grid cannot answer the
34
- question the feature exists for. D-253 is a booked defect whose whole content is "satisfied
35
- only on hover" β€” this is that lesson applied to a mark rather than to an explanation.
36
- The always-on mark is drawn by the same button with `is-on`; the caller keeps it mounted for a
37
- starred row whether or not the pointer is over it. */
38
- .cg-row-star.is-on {
39
- color: var(--lp-yellow-deep);
40
- }
41
-
42
- .cg-row-star:hover {
43
- border-color: var(--lp-line);
44
- background: var(--cg-white);
45
- color: var(--lp-yellow-deep);
46
- }
47
-
48
- .cg-row-star:focus-visible {
49
- outline: 2px solid var(--lp-blue-deep);
50
- outline-offset: 1px;
51
- }
52
-
53
- .cg-row-star:disabled {
54
- cursor: default;
55
- opacity: 0.55;
56
- }
57
-
58
- /* The drawer's own copy of the control (`RecordDetail`), which is in the flow rather than over a
59
- canvas β€” so it takes the colour rules above and none of the positioning.
60
- ⭐ OWNER ITEM 12 (2026-08-23) β€” it now sits INSIDE `.cg-detail-nav`, beside the prev/next keys,
61
- so it is sized and bordered like them: a group of three buttons reads as a group only if the
62
- third one is the same shape. The border is transparent until hover, which is what still tells
63
- an ACTION apart from the two NAVIGATION keys beside it. */
64
- .cg-detail-star {
65
- display: inline-flex;
66
- align-items: center;
67
- justify-content: center;
68
- width: 28px;
69
- height: 28px;
70
- padding: 0;
71
- border: 1px solid transparent;
72
- border-radius: var(--lp-r-md);
73
- background: transparent;
74
- color: var(--lp-muted);
75
- cursor: pointer;
76
- }
77
-
78
- .cg-detail-star.is-on { color: var(--lp-yellow-deep); }
79
- .cg-detail-star:hover { border-color: var(--lp-line); color: var(--lp-yellow-deep); }
80
- .cg-detail-star:disabled { cursor: default; opacity: 0.55; }
 
 
 
 
 
 
 
 
1
+ /* ---------------------------------------------------------------------------
2
+ customer-grid / rowStar.css β€” W35-T29 (rulings R5, R6), the record star.
3
+
4
+ β›” ITS OWN SHEET, AND THE REASON IS OWNERSHIP RATHER THAN TIDINESS. `.cg-row-expand`, the hover
5
+ control this one sits beside, lives in `index.css`, which belongs to another lane this wave. A
6
+ new surface brings its own stylesheet (the wave's own rule) and a change to an existing
7
+ `.cg-*` rule is that lane's ticket.
8
+
9
+ ⚠ IMPORTED BY `CustomerGrid.tsx`, the module that RENDERS these classes β€” never by a lazy
10
+ parent. W35-T22 in this same wave was exactly that mistake one directory over: `query.css` was
11
+ imported only by a `lazy()` page while the component drawing its classes shipped in another
12
+ chunk, so the rail painted unstyled for a network round trip.
13
+ --------------------------------------------------------------------------- */
14
+
15
+ /* ⭐ The hover control IMMEDIATELY TO THE LEFT OF THE EXPAND BUTTON, at the right end of the
16
+ primary cell and level with it β€” owner instruction 29 (wave 40, 2026-08-24). It REVERSES
17
+ W35-T29 (R5, R6), which deliberately put this mark at the LEFT end so the two book-ended
18
+ the cell; that line stood right here and is gone because the code no longer does it.
19
+ Both are real DOM buttons laid over the canvas at glide's own bounds, so the rect IS the
20
+ hit test and there is no second copy of the geometry to drift from the drawing.
21
+
22
+ ⚠ NO GEOMETRY LIVES IN THIS SHEET. `left`/`top`/`width`/`height` are written inline from
23
+ `starButtonRect`, which now derives them from `expandButtonRect` β€” so the reversal is a
24
+ change of arithmetic, not of style, and nothing here had to move with it. */
25
+ .cg-row-star {
26
+ position: fixed;
27
+ z-index: 55;
28
+ display: inline-flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ padding: 0;
32
+ border: 1px solid transparent;
33
+ border-radius: var(--lp-r-sm);
34
+ background: transparent;
35
+ color: var(--lp-muted);
36
+ cursor: pointer;
37
+ }
38
+
39
+ /* β›” A STARRED ROW'S MARK IS NOT HOVER-ONLY. The hover control is how you SET one; a row that is
40
+ already starred has to say so while the pointer is elsewhere, or the grid cannot answer the
41
+ question the feature exists for. D-253 is a booked defect whose whole content is "satisfied
42
+ only on hover" β€” this is that lesson applied to a mark rather than to an explanation.
43
+ The always-on mark is drawn by the same button with `is-on`; the caller keeps it mounted for a
44
+ starred row whether or not the pointer is over it. */
45
+ .cg-row-star.is-on {
46
+ color: var(--lp-yellow-deep);
47
+ }
48
+
49
+ .cg-row-star:hover {
50
+ border-color: var(--lp-line);
51
+ background: var(--cg-white);
52
+ color: var(--lp-yellow-deep);
53
+ }
54
+
55
+ .cg-row-star:focus-visible {
56
+ outline: 2px solid var(--lp-blue-deep);
57
+ outline-offset: 1px;
58
+ }
59
+
60
+ .cg-row-star:disabled {
61
+ cursor: default;
62
+ opacity: 0.55;
63
+ }
64
+
65
+ /* The drawer's own copy of the control (`RecordDetail`), which is in the flow rather than over a
66
+ canvas β€” so it takes the colour rules above and none of the positioning.
67
+ ⭐ OWNER ITEM 12 (2026-08-23) β€” it now sits INSIDE `.cg-detail-nav`, beside the prev/next keys,
68
+ so it is sized and bordered like them: a group of three buttons reads as a group only if the
69
+ third one is the same shape. The border is transparent until hover, which is what still tells
70
+ an ACTION apart from the two NAVIGATION keys beside it. */
71
+ .cg-detail-star {
72
+ display: inline-flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ width: 28px;
76
+ height: 28px;
77
+ padding: 0;
78
+ border: 1px solid transparent;
79
+ border-radius: var(--lp-r-md);
80
+ background: transparent;
81
+ color: var(--lp-muted);
82
+ cursor: pointer;
83
+ }
84
+
85
+ .cg-detail-star.is-on { color: var(--lp-yellow-deep); }
86
+ .cg-detail-star:hover { border-color: var(--lp-line); color: var(--lp-yellow-deep); }
87
+ .cg-detail-star:disabled { cursor: default; opacity: 0.55; }
web/src/customer-grid/types.ts CHANGED
@@ -201,6 +201,81 @@ export const CREATABLE_TYPES: readonly FieldType[] = [
201
  "ai_enrich",
202
  ];
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  /**
205
  * ⭐ Wave-19 R7 / C5 β€” THE THREE THINGS AN `image` CELL MAY HOLD, and how each resolves.
206
  *
@@ -3397,6 +3472,35 @@ export interface SavedView {
3397
  /** C1 β€” whose view this is, when it is not mine. The username, so it matches what every
3398
  * grant is stored against (see `shareModel.parsePeople`'s note on the two shapes). */
3399
  owner?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3400
  /** C4 β€” the folder this view sits in; null/absent = root. */
3401
  folderId?: string | null;
3402
  id: string;
@@ -3477,6 +3581,62 @@ export function allViewName(scope: string): string {
3477
  return scope === "product" ? "All products" : "All customers";
3478
  }
3479
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3480
  /**
3481
  * The ONE rule for "this view cannot be deleted", mirroring what the host actually enforces
3482
  * (`app.py:5410` refuses `view_delete` for `all-customers`). The client hides exactly what
@@ -3746,7 +3906,10 @@ export function viewEditMode(view: Pick<SavedView, "permissions">): ViewEditMode
3746
  * at the host, not merely hidden here.
3747
  */
3748
  export function mayEditView(
3749
- view: Pick<SavedView, "permissions" | "createdBy" | "shared" | "sharedRole">,
 
 
 
3750
  viewer: Viewer | undefined
3751
  ): boolean {
3752
  /**
@@ -3772,6 +3935,31 @@ export function mayEditView(
3772
  * ONE place, three consumers: the rail's row menu, the rail's drag, and the grid's own
3773
  * delete/config-write guards all ask this function. Putting the rule in any one of them
3774
  * would leave the other two offering an edit the server refuses.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3775
  */
3776
  if (view.shared && (view.sharedRole ?? "view") !== "edit") return false;
3777
  const mode = viewEditMode(view);
 
201
  "ai_enrich",
202
  ];
203
 
204
+ /**
205
+ * ⭐⭐ W40-T29 (owner instruction 31) β€” WHICH CREATE KINDS READ AS ADVANCED. Three of them, and
206
+ * the whole discipline of this block is that the answer is a PRESENTATION grouping and nothing
207
+ * else.
208
+ *
209
+ * β›” THE LIST ABOVE IS NOT PARTITIONED, AND MUST NEVER BE. `CREATABLE_TYPES` is one half of a
210
+ * cross-language contract `verify_fields_contract.py` diffs against `aios_grid.CUSTOM_FIELD_TYPES`,
211
+ * and the `json` scar recorded in its own comment is exactly what happens when a type reaches every
212
+ * table except that one: real, documented, unreachable, with every individual file internally
213
+ * consistent. Lifting `automation` and `json` out of it into an "advanced list" would reproduce
214
+ * that failure deliberately. So the grouping is applied to the ROWS the picker has already built,
215
+ * at render time, and the contract array is READ, never rewritten.
216
+ *
217
+ * ⚠ `automation` and `json` are typed `FieldType`, so a typo fails `tsc`. `geocode` cannot be:
218
+ * it is a PSEUDO-KIND (see `Field.geocode` for why five vocabularies stay untouched by it) and no
219
+ * compiler can hold it. The asymmetry is honest rather than an oversight, and it is the reason the
220
+ * exported set below is `string[]` while its first half is not.
221
+ */
222
+ const ADVANCED_FIELD_KINDS: readonly FieldType[] = ["automation", "json"];
223
+
224
+ /** The advanced set the picker groups on: the two real types above plus the geocode pseudo-kind,
225
+ * which joins by VALUE because it cannot join by type. */
226
+ export const ADVANCED_CREATE_KINDS: readonly string[] = [...ADVANCED_FIELD_KINDS, "geocode"];
227
+
228
+ /**
229
+ * The group's heading. ONE WORD: `DESIGN.md` Β§4 forbids over-explaining, so the list does not get
230
+ * a sentence about what makes a type advanced.
231
+ * ⚠ Not "ADVANCED" β€” `CLAUDE.md` bans ALL-CAPS app chrome. The owner spelled it in capitals in
232
+ * instruction 31; that is emphasis in prose, not a label.
233
+ */
234
+ export const ADVANCED_TYPES_HEADING = "Advanced";
235
+
236
+ /** A run of field-type picker rows under an optional heading. `heading: null` is the ordinary
237
+ * group, deliberately unlabelled: naming the majority case is chrome nobody reads, and the
238
+ * divider under it already says where the ordinary list ends. */
239
+ export interface TypePickerGroup<T> {
240
+ heading: string | null;
241
+ rows: T[];
242
+ }
243
+
244
+ /**
245
+ * ⭐⭐ W40-T29 β€” split a flat field-type picker list into the ordinary rows and the advanced ones.
246
+ *
247
+ * It lives in this file, and not in `ColumnMenu.tsx` where it is rendered, for the reason
248
+ * `display.ts`'s header states at length: `ColumnMenu.tsx` imports React and therefore cannot be
249
+ * LOADED by the node gates, so a rule stated inside JSX is a rule no gate can run. `columnDefinitionOffer`
250
+ * had to settle for `display.ts` because this file was held by another lane that wave; the two
251
+ * belong together and this one made it home.
252
+ *
253
+ * β›” A STABLE PARTITION, NEVER A SORT. Each group keeps the order it was handed, so the ordinary
254
+ * group reads in `CREATABLE_TYPES`' own order and the advanced group in the order those three
255
+ * happen to sit in the caller's list. Re-ordering here would be a second opinion about a
256
+ * vocabulary that already has an owner, and the day someone reorders the contract array the
257
+ * picker would silently disagree with it.
258
+ *
259
+ * β›” AN EMPTY GROUP IS OMITTED ENTIRELY, and that is R8's fake-affordance rule rather than
260
+ * tidiness: the caller filters this list through a find box, so typing "text" empties the advanced
261
+ * group, and a heading left standing over nothing is a label with nothing behind it. Deciding it
262
+ * here rather than in JSX is also what makes the behaviour runnable by a node gate instead of
263
+ * being an accident of a CSS `:empty` selector.
264
+ */
265
+ export function groupTypePickerRows<T extends { value: string }>(
266
+ rows: readonly T[]
267
+ ): TypePickerGroup<T>[] {
268
+ const ordinary: T[] = [];
269
+ const advanced: T[] = [];
270
+ for (const row of rows) {
271
+ (ADVANCED_CREATE_KINDS.includes(row.value) ? advanced : ordinary).push(row);
272
+ }
273
+ const out: TypePickerGroup<T>[] = [];
274
+ if (ordinary.length) out.push({ heading: null, rows: ordinary });
275
+ if (advanced.length) out.push({ heading: ADVANCED_TYPES_HEADING, rows: advanced });
276
+ return out;
277
+ }
278
+
279
  /**
280
  * ⭐ Wave-19 R7 / C5 β€” THE THREE THINGS AN `image` CELL MAY HOLD, and how each resolves.
281
  *
 
3472
  /** C1 β€” whose view this is, when it is not mine. The username, so it matches what every
3473
  * grant is stored against (see `shareModel.parsePeople`'s note on the two shapes). */
3474
  owner?: string;
3475
+ /**
3476
+ * ⭐⭐ WAVE 40 Β· AM-5 (owner instruction 2) β€” THIS VIEW HAS GRANTS, WHOEVER IS ASKING.
3477
+ *
3478
+ * Viewer-INDEPENDENT, and it is what draws the share mark. C1 was written as "redefine
3479
+ * `shared` to mean this view has grants"; AM-5 settles it as a NEW key instead, because
3480
+ * redefining `shared` would have locked an owner out of their own view through
3481
+ * `mayEditView` and moved it into the recipient's "Shared with me" group. So `shared`
3482
+ * keeps meaning "granted TO me" and this means "somebody can see this".
3483
+ *
3484
+ * β›” ABSENCE IS THE NEGATIVE. Nothing is ever stamped `false`; a view with no grants
3485
+ * simply carries neither key. Produced by `platform/core/grid_events.py::_grant_marker`
3486
+ * on all three merge legs from one predicate, and derived on every read from the grant
3487
+ * registry rather than stored.
3488
+ *
3489
+ * ⚠ IT DOES NOT IMPLY `sharedRole`. A view living BOTH in the legacy `__shared__` bucket
3490
+ * and in the grant registry reaches the recipient with `hasShares` and no role stamp,
3491
+ * because the bucket leg outranks the granted copy. `viewShareMark` has a third branch
3492
+ * for exactly that, and it must not be collapsed into the other two.
3493
+ */
3494
+ hasShares?: boolean;
3495
+ /**
3496
+ * ⭐ WAVE 40 Β· AM-5 β€” THIS ACCOUNT IS THE ONE SHARING IT OUT.
3497
+ *
3498
+ * Only ever on the sharer's copy, which is how the owner's tooltip can read as sharing OUT
3499
+ * without reusing the receiver wording C1 forbids. Unconditional on the caller's own
3500
+ * stratum, `createdBy == uname` on the `__shared__` leg, and NEVER on a view arriving
3501
+ * through `_granted_views`.
3502
+ */
3503
+ sharedOut?: boolean;
3504
  /** C4 β€” the folder this view sits in; null/absent = root. */
3505
  folderId?: string | null;
3506
  id: string;
 
3581
  return scope === "product" ? "All products" : "All customers";
3582
  }
3583
 
3584
+ /**
3585
+ * ⭐⭐ WAVE 40 Β· T28 (owner instruction 30) β€” WHAT THE PINNED SYSTEM VIEW IS CALLED ON SCREEN.
3586
+ *
3587
+ * Owner: *"Rename 'All customers' and every other undeletable default view to 'Default view'."*
3588
+ * Per assumption A2 that is the PER-DATABASE default ONLY. `UNDELETABLE_VIEW_IDS` holds three
3589
+ * ids, and renaming all three would put three rows reading "Default view" in one rail, which is
3590
+ * a worse rail than the one the instruction is fixing. Starred and IG Overview keep the names
3591
+ * their own servers mint; they pin to the top beside this one and are told apart by their names.
3592
+ */
3593
+ export const DEFAULT_VIEW_NAME = "Default view";
3594
+
3595
+ /**
3596
+ * β›” THE HOST'S OWN MINTS, DERIVED RATHER THAN RE-SPELLED, so the three nouns keep exactly one
3597
+ * home on this side of the wire. `aios_grid.views_from_defs(system_name=…)` produces the same
3598
+ * three strings; this set is what {@link viewDisplayName} recognises as "still the mint, nobody
3599
+ * has renamed it".
3600
+ */
3601
+ const SYSTEM_VIEW_MINTS = new Set([
3602
+ allViewName("customer"),
3603
+ allViewName("product"),
3604
+ allViewName("ut_"),
3605
+ ]);
3606
+
3607
+ /**
3608
+ * ⭐⭐ WAVE 40 Β· T28 β€” the name to PAINT for a view, which is not always the name stored on it.
3609
+ *
3610
+ * β›” A DISPLAY RULE, NOT A RENAME, and that distinction is the whole reason this function exists
3611
+ * instead of a changed `allViewName`. `views_from_defs` still mints the system view as "All
3612
+ * customers" / "All products" / "All records", and that host half is in NO lane's fence this
3613
+ * wave. A client that minted a different string would disagree with the echo and repaint the
3614
+ * rail on every workspace arrival β€” the flicker `allViewName`'s own note refuses one paragraph
3615
+ * up. Resolving at the point of PAINT collapses both spellings onto ONE output, so the rail
3616
+ * shows the same characters whether the name came from this browser's mint or from the host's
3617
+ * echo, and there is no frame in which the two differ. When the host half lands, "Default view"
3618
+ * is not in the mint set and passes through verbatim, so the two stay in step through the change.
3619
+ *
3620
+ * β›” IT MUST NOT CLOBBER A USER'S RENAME, and `all-customers` really IS renamable β€” this was
3621
+ * checked rather than assumed. `grid_events.py`'s `view_upsert` exempts it from the reserved-name
3622
+ * list by id (`if view_id != 'all-customers'`), and a saved record under the pinned id REPLACES
3623
+ * the projection through the ordinary saved-config overlay, so a rename of the default view
3624
+ * persists and means what it says. The override therefore fires only while the stored name is
3625
+ * STILL one of the mints β€” or empty, which is what a half-assembled workspace carries. Anything
3626
+ * a person typed passes through unchanged.
3627
+ *
3628
+ * ⚠ SCOPE-FREE, deliberately. The rail has no `scope` prop, and threading one in would give this
3629
+ * decision two homes; more importantly a stale echo carrying another topic's noun must still
3630
+ * resolve, rather than leaking "All records" onto a customer rail for one round trip.
3631
+ *
3632
+ * ⚠ ONLY `ALL_VIEW_ID`. A2 in one line: every other id, undeletable or not, keeps its own name.
3633
+ */
3634
+ export function viewDisplayName(view: Pick<SavedView, "id" | "name">): string {
3635
+ if (view.id !== ALL_VIEW_ID) return view.name;
3636
+ const stored = view.name ?? "";
3637
+ return stored === "" || SYSTEM_VIEW_MINTS.has(stored) ? DEFAULT_VIEW_NAME : stored;
3638
+ }
3639
+
3640
  /**
3641
  * The ONE rule for "this view cannot be deleted", mirroring what the host actually enforces
3642
  * (`app.py:5410` refuses `view_delete` for `all-customers`). The client hides exactly what
 
3906
  * at the host, not merely hidden here.
3907
  */
3908
  export function mayEditView(
3909
+ // `owner` joined this Pick with wave 40's C1 and STAYS, although the guard below no longer
3910
+ // reads it: the field is part of what a caller must be able to hand this function, and
3911
+ // narrowing the type back would be a second change with its own blast radius.
3912
+ view: Pick<SavedView, "permissions" | "createdBy" | "shared" | "sharedRole" | "owner">,
3913
  viewer: Viewer | undefined
3914
  ): boolean {
3915
  /**
 
3935
  * ONE place, three consumers: the rail's row menu, the rail's drag, and the grid's own
3936
  * delete/config-write guards all ask this function. Putting the rule in any one of them
3937
  * would leave the other two offering an edit the server refuses.
3938
+ *
3939
+ * ⭐⭐ WAVE 40 Β· CONTRACT C1 (owner instruction 2) β€” THIS GUARD BRIEFLY READ
3940
+ * `view.shared && view.owner && …`, AND THE `&& view.owner` IS GONE AGAIN. Recorded rather
3941
+ * than quietly reverted, because the reasoning that added it was sound and only its PREMISE
3942
+ * was wrong, and the next person reading C1 will reach for it again.
3943
+ *
3944
+ * The premise was that C1 would REDEFINE `shared` to mean "this view has grants" and stamp it
3945
+ * on the sharer's own leg too. On such a leg there is no `sharedRole` (ownership is not a
3946
+ * grant), `?? "view"` would read read-only, and this line would have locked an owner out of
3947
+ * every view they had ever shared. `owner` was added as the discriminator, since it already
3948
+ * means "whose view this is, WHEN IT IS NOT MINE".
3949
+ *
3950
+ * β›” BUT `shared` WAS NEVER REDEFINED. AM-5 settles C1 the other way: the marker is a NEW
3951
+ * key, `hasShares`, and `shared` / `sharedRole` / `owner` keep meaning "granted TO me, and on
3952
+ * what terms". Verified in the server, not inferred: `core/grid_events.py` writes `shared` on
3953
+ * exactly two lines, both inside `_granted_views`, both after `if owner == uname: continue`,
3954
+ * and both stamping `sharedRole` and `owner` in the same three statements. The sharer's own
3955
+ * leg gets `hasShares` and `sharedOut` and NEVER `shared`.
3956
+ *
3957
+ * β›” SO THE EXTRA CONDITION PROTECTED AGAINST NOTHING AND COST A FAIL-CLOSED CHECK. With it,
3958
+ * any `shared` view reaching the client without an `owner` fell through and read as EDITABLE.
3959
+ * `folders.test` had asserted the opposite since wave 21 and went red on all four of its C1
3960
+ * legs β€” "a view-role share is NOT editable", "…not by an ADMIN either", "…and not via the
3961
+ * unstamped-createdBy escape hatch", "a shared view with NO role reads as read-only". The
3962
+ * gate was right. Read those four names as this line's negative control.
3963
  */
3964
  if (view.shared && (view.sharedRole ?? "view") !== "edit") return false;
3965
  const mode = viewEditMode(view);
web/src/customer-grid/useGridColumns.ts CHANGED
@@ -1,422 +1,483 @@
1
- import { useCallback, useMemo } from "react";
2
- import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
3
- import type { Field, FilterNode, ViewConfig } from "./types";
4
- import { isFilterGroup, isRuleActive, measureColumnIndex, ruleColumnKeys } from "./types";
5
- import { typeIconName } from "./iconShapes";
6
- import { fitHeaderTitle, headerLabelSpace, headerMarkSizes } from "./overlayPlacement";
7
- import { COLUMN_TONE_THEME, INVOLVED_HEADER_FONT, lightTheme } from "./theme";
8
- import type { ControlTone } from "./theme";
9
-
10
- const DEFAULT_WIDTH = 150;
11
-
12
- /**
13
- * Owner item 16 β€” measure header text in GLIDE'S OWN HEADER FONT.
14
- *
15
- * ⚠ NOT the cell font. Glide's `headerFontStyle` is "600 13px" and its `baseFontStyle` is
16
- * "13px" (`common/styles.js:73-75`); semibold is materially wider, so measuring with the cell
17
- * font under-measures, under-truncates, and leaves the label running under the (i) β€” the exact
18
- * defect this item is about, surviving a fix that looked correct in the diff.
19
- *
20
- * ⚠⚠ Wave-14 item 2 adds the SAME trap one weight up. An involved column paints its header at
21
- * **700** (`INVOLVED_HEADER_FONT`), so measuring every column at 600 would under-truncate
22
- * exactly the filtered/sorted/grouped columns β€” the ones the owner is looking at. Hence TWO
23
- * cached contexts, and both font strings are read off the theme layer rather than retyped here:
24
- * a literal in this file is a copy that can drift from the one glide actually paints with, which
25
- * is the whole failure mode above.
26
- *
27
- * ⚠ Wave-15 R5 gave each control its OWN hue, so there are now three involved-column themes
28
- * instead of one. They deliberately share `INVOLVED_HEADER_FONT`, so ONE bold context still
29
- * measures all three correctly β€” but the constant, not any one of the three theme objects, is
30
- * what this reads. Picking `COLUMN_TONE_THEME.filter.headerFontStyle` would have measured every
31
- * involved column against whichever tone happened to be listed first.
32
- *
33
- * One lazily-built offscreen context per weight, the same shape as CustomerGrid's
34
- * `measureCellText`. A context can legitimately be null (a headless canvas, a locked-down
35
- * embed); 0 then measures as "everything fits" and no title is truncated, which is the safe
36
- * direction to fail β€” a title that slightly overlaps beats one silently cut to "C…".
37
- */
38
- const _hdrCtx: Record<"base" | "bold", CanvasRenderingContext2D | null | undefined> = {
39
- base: undefined,
40
- bold: undefined,
41
- };
42
- function measureHeaderText(text: string, bold = false): number {
43
- const slot = bold ? "bold" : "base";
44
- if (_hdrCtx[slot] === undefined) {
45
- const ctx = document.createElement("canvas").getContext("2d");
46
- if (ctx) {
47
- const style =
48
- (bold ? INVOLVED_HEADER_FONT : lightTheme.headerFontStyle) ?? "600 13px";
49
- ctx.font = `${style} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`;
50
- }
51
- _hdrCtx[slot] = ctx;
52
- }
53
- const ctx = _hdrCtx[slot];
54
- return ctx ? ctx.measureText(text).width : 0;
55
- }
56
-
57
- /** The (i) is drawn for exactly this condition β€” `CustomerGrid.drawGridHeader` resolves the
58
- * same one from the same field, so the reserve and the mark can never disagree. */
59
- function hasInfoMark(field: Field): boolean {
60
- return !!(field.note || field.description);
61
- }
62
-
63
- /** Every column key an ACTIVE filter rule names, anywhere in the tree. Inactive (half-typed)
64
- * rules are skipped: a rule that is not narrowing anything must not tint a column as though
65
- * it were β€” that is the same "it looks like it is working" lie the engine refuses to tell.
66
- *
67
- * ⚠ Wave-20 item 2: the key a rule NAMES is not always the column it is ABOUT β€” a measure
68
- * condition carries the MEASURE key. `ruleColumnKeys` is the one resolution (types.ts); it
69
- * is what stopped a filtered-and-sorted measure column wearing the sort hue. */
70
- function filteredKeys(
71
- nodes: FilterNode[],
72
- out: Set<string>,
73
- measureCols: Map<string, string[]>
74
- ): void {
75
- for (const node of nodes ?? []) {
76
- if (isFilterGroup(node)) {
77
- filteredKeys(node.children, out, measureCols);
78
- continue;
79
- }
80
- if (isRuleActive(node)) for (const key of ruleColumnKeys(node, measureCols)) out.add(key);
81
- }
82
- }
83
-
84
- /**
85
- * The column's theme override, or `undefined` β€” glide's fast path for "no override" is an absent
86
- * object, so an empty `{}` would cost a ~35-key theme merge per column per frame.
87
- *
88
- * ⚠ Wave-14 item 1 (R11): a user-created field contributes no theme. Its yellow header wash is
89
- * dead and its replacement is a painted DOT, not a background β€” so the only thing that can put a
90
- * themeOverride on a column is being INVOLVED in a control.
91
- *
92
- * ⚠ Wave-15 R5: the returned object is one of the three SHARED `COLUMN_TONE_THEME` references
93
- * (never a fresh object), which is what keeps glide's theme merge cache-friendly across frames.
94
- *
95
- * β›” Wave-29 R8 (2026-08-11): those three references now carry **HEADER keys only**. glide builds a
96
- * header theme from the column override alone and a cell theme from column β†’ row β†’ cell
97
- * (`data-grid-render.header.js:59`, `common/styles.js mergeAndRealizeTheme`), so the tone reaching
98
- * a column theme is exactly how a sort used to repaint record bodies. With no `bgCell` in the
99
- * table there is no longer a path from "this column is sorted" to a cell's background at all β€”
100
- * which is a property of what `COLUMN_TONE_THEME` CONTAINS, not of anything this function does.
101
- * A body wash belongs to `getRowThemeOverride` (status) or the cell itself (automation).
102
- */
103
- function columnTheme(tone: ControlTone | undefined): Partial<Theme> | undefined {
104
- return tone ? COLUMN_TONE_THEME[tone] : undefined;
105
- }
106
-
107
- /**
108
- * Owner item 22 β€” key β†’ which control involves it, or absent.
109
- *
110
- * ⚠ ONE tone per column, and the precedence is a decision, not an accident: **filter > sort >
111
- * group** (owner R6, wave 15). A filter changes WHICH ROWS you are looking at; when a column is
112
- * doing two jobs the header names the most consequential one. Blending two tints would produce a
113
- * third colour that matches no chip in the toolbar, which is worse than picking.
114
- *
115
- * ⚠ THE PRECEDENCE IS THE WRITE ORDER BELOW, and it reads backwards: later `set` calls WIN, so
116
- * the least important control is written FIRST. It was `sort, group, filter` β€” i.e. filter >
117
- * group > sort β€” until R6 named the order explicitly; getting this wrong is a one-line edit that
118
- * changes a colour on screen and nothing else, so it is stated here rather than left to be
119
- * re-derived from the sequence.
120
- */
121
- /* wave17 GRID β€” item 2 / owner R5. EXPORTED, and only since the row band died.
122
- `activeControlTone` used to sit below this function and restate the same precedence for the
123
- whole-table band; the band was its only consumer, so R5 took both. That left the surviving
124
- precedence β€” the one that still paints β€” asserted by nothing, because `verify_icons` was
125
- testing the twin. Exported so the gate can reach the function that is actually on screen. */
126
- export function columnTones(
127
- config: ViewConfig,
128
- /* wave20 item 2 β€” the FIELDS, because a measure condition names its measure and only the
129
- field list can say which column displays it. Defaulted so the signature stays callable
130
- with a config alone (the pre-wave-20 gate legs), and because a caller with no fields
131
- legitimately has no measure columns to resolve. */
132
- fields: Field[] = []
133
- ): Map<string, ControlTone> {
134
- const out = new Map<string, ControlTone>();
135
- if (config.groupBy) out.set(config.groupBy, "group");
136
- for (const s of config.sorts ?? []) out.set(s.colId, "sort");
137
- const filtered = new Set<string>();
138
- filteredKeys(config.filters ?? [], filtered, measureColumnIndex(fields));
139
- for (const key of filtered) out.set(key, "filter");
140
- return out;
141
- }
142
-
143
- /**
144
- * ⭐ WAVE 30 (owner item 2 scouting) β€” THE OVERLAY ARM IS GONE, and the arithmetic is the reason.
145
- *
146
- * This read `field.default !== false || field.source === "overlay"`. The second arm was written
147
- * when `overlay` meant "a column a user added here", and those columns have no `default` key at
148
- * all β€” so the arm was a belt-and-braces no-op for them. Then `user_tables._clean_field` started
149
- * stamping `source: "overlay"` on EVERY `ut_*` field, and every connected database became a `ut_*`
150
- * one: the exception quietly became the rule and swallowed the first arm entirely.
151
- *
152
- * β›” WHAT THAT COST, measured on the live orders grid: `odoo_relational.order_fields` declares
153
- * `default: False` on `odoo_id`, `state`, `customer_link` and `partner_id`, and every one of them
154
- * opened SHOWN. The grid arrives wide, and "Hide fields" reads inactive because
155
- * `shownCount === fields.length` β€” so the control that says "some columns are hidden" says the
156
- * opposite of what its own table declares. A second instance of [[fallback-that-became-the-rule]].
157
- *
158
- * ⚠ DROPPING THE ARM DOES NOT HIDE USER COLUMNS, and this is the check that matters before
159
- * believing the fix. A column somebody created carries no `default` key, `undefined !== false` is
160
- * true, and it stays visible. `_clean_field` also keeps `default` ONLY when it is `True`, so a
161
- * `ut_*` column can never arrive carrying `default: false` by accident β€” only a shipped field
162
- * CONTRACT (which is written straight into the store, bypassing that validator) can declare it.
163
- *
164
- * ⚠ THIS PREDICATE HAS A SERVER TWIN and the twin is the one that decides on first open:
165
- * `platform/aios_grid.py:_default_view_config` carries the same expression and builds the "All
166
- * records" system view the client pins as its landing default. Fixing one side alone changes
167
- * nothing a user sees β€” see `mailbox/F.md` F-1 ([[one-question-two-normalizers]] across two
168
- * languages).
169
- */
170
- function isDefaultVisible(field: Field): boolean {
171
- if (field.default === false) return false;
172
- /**
173
- * ⭐⭐ OWNER, 2026-08-23 β€” A NEW VIEW SHOWS THE DATABASE'S OWN COLUMNS AND NOTHING ELSE.
174
- *
175
- * Owner: *"when any new View is created only shareable pre-set data should show. Shared to me
176
- * or Shared to everyone should be auto hidden."*
177
- *
178
- * β›” `custom || shared` IS THE WHOLE PREDICATE, and it is chosen because it is exactly the
179
- * membership test `FieldsHidePanel` already uses to draw its two SHARED sections:
180
- * "Shared with me" (field.custom || field.shared) && fieldEditMode === "users"
181
- * "Shared with everyone" (field.custom || field.shared) && fieldEditMode === "collaborative"
182
- * Both sections are subsets of `custom || shared`, so hiding on that flag hides both by
183
- * construction rather than by keeping a second list of section names in step with the panel.
184
- *
185
- * ⚠ IT ALSO HIDES THE UNTITLED SECTION'S CREATED COLUMNS, and that is deliberate rather than
186
- * over-reach. A route order carries `shared: true` with `edit: personal`, so it is filed under
187
- * no heading at all β€” and 52 of them landed on the customer grid the same day this was asked
188
- * for. The owner's first sentence is the rule ("ONLY pre-set data shows"); the second names the
189
- * two headings, it does not bound the set.
190
- *
191
- * ⚠ MEASURED ON THE LIVE CUSTOMER GRID before it was written: of 105 fields, 77 opened SHOWN.
192
- * 52 route columns, 14 collaborative `custom_*`, 3 `measure_*`, one legacy route and 7 plain
193
- * Odoo columns. After this, the 7 remain β€” which is precisely what
194
- * `aios_grid_fields.json` declares as `default: true`, i.e. the contract's own answer before
195
- * anybody's columns piled on top of it.
196
- *
197
- * β›”β›” A STORED VIEW IS UNTOUCHED. `normalizeConfig` spreads the saved `config` OVER this
198
- * base, so a view that already recorded its own `visible` keeps it. What moves is a view born
199
- * without one: the "All records" system view, a new view, a cohort view.
200
- */
201
- if (field.custom === true || field.shared === true) return false;
202
- return true;
203
- }
204
-
205
- export function defaultViewConfig(fields: Field[]): ViewConfig {
206
- const shown = fields.filter(isDefaultVisible).map((field) => field.key);
207
- const hidden = fields.filter((field) => !isDefaultVisible(field)).map((field) => field.key);
208
- return {
209
- filters: [],
210
- filterConj: "and",
211
- sorts: [],
212
- groupBy: null,
213
- colorBy: null,
214
- rowHeightMode: "short",
215
- order: [...shown, ...hidden],
216
- visible: shown,
217
- widths: {},
218
- memberPids: [],
219
- };
220
- }
221
-
222
- function reconcileOrder(order: string[], fields: Field[]): string[] {
223
- const valid = new Set(fields.map((field) => field.key));
224
- const kept = order.filter((key, index) => valid.has(key) && order.indexOf(key) === index);
225
- for (const field of fields) if (!kept.includes(field.key)) kept.push(field.key);
226
- const locked = fields.find((field) => field.pinned)?.key ?? fields[0]?.key;
227
- if (locked && kept[0] !== locked) {
228
- const without = kept.filter((key) => key !== locked);
229
- return [locked, ...without];
230
- }
231
- return kept;
232
- }
233
-
234
- function reconcileVisible(visible: string[], fields: Field[], lockedKey: string): Set<string> {
235
- const valid = new Set(fields.map((field) => field.key));
236
- const out = new Set(visible.filter((key) => valid.has(key)));
237
- if (out.size === 0) {
238
- for (const field of fields) if (isDefaultVisible(field)) out.add(field.key);
239
- }
240
- if (lockedKey) out.add(lockedKey);
241
- return out;
242
- }
243
-
244
- export interface GridColumnsApi {
245
- visibleCols: GridColumn[];
246
- fieldByKey: Map<string, Field>;
247
- order: string[];
248
- visible: Set<string>;
249
- widths: Record<string, number>;
250
- lockedKey: string;
251
- onColumnResize: (col: GridColumn, newSize: number) => void;
252
- onColumnMoved: (from: number, to: number) => void;
253
- onColumnProposeMove: (from: number, to: number) => boolean;
254
- setColumnVisible: (key: string, show: boolean) => void;
255
- insertColumn: (key: string, anchorKey: string | null, side: "left" | "right" | "end") => void;
256
- }
257
-
258
- /**
259
- * Controlled column adapter. ViewConfig is the durable source of truth, so
260
- * choosing a saved view is one atomic state change instead of a chain of hook
261
- * setters that can flash or overwrite one another.
262
- */
263
- export function useGridColumns(
264
- fields: Field[],
265
- config: ViewConfig,
266
- onConfig: (next: ViewConfig) => void
267
- ): GridColumnsApi {
268
- const fieldByKey = useMemo(
269
- () => new Map(fields.map((field) => [field.key, field])),
270
- [fields]
271
- );
272
- const lockedKey = useMemo(
273
- () => fields.find((field) => field.pinned)?.key ?? fields[0]?.key ?? "",
274
- [fields]
275
- );
276
- const order = useMemo(() => reconcileOrder(config.order, fields), [config.order, fields]);
277
- const visible = useMemo(
278
- () => reconcileVisible(config.visible, fields, lockedKey),
279
- [config.visible, fields, lockedKey]
280
- );
281
-
282
- // Item 22 β€” recomputed only when the three controls move, not on every width drag.
283
- // Wave-20 item 2 adds `fields`: a measure condition can only be resolved to the column that
284
- // displays it, and a new measure column must re-tint without waiting for a control to move.
285
- const tones = useMemo(
286
- () => columnTones(config, fields),
287
- [config.filters, config.sorts, config.groupBy, fields] // eslint-disable-line react-hooks/exhaustive-deps
288
- );
289
-
290
- const visibleCols = useMemo<GridColumn[]>(
291
- () =>
292
- order
293
- .filter((key) => visible.has(key))
294
- .map((key) => {
295
- const field = fieldByKey.get(key)!;
296
- const width = config.widths[key] ?? DEFAULT_WIDTH;
297
- // Wave-14 item 2 β€” an INVOLVED column's header paints at 700, so it must be MEASURED
298
- // at 700. `tones` is already a dep of this memo, so this costs nothing.
299
- const involved = tones.has(key);
300
- // R11 β€” the marks this header carries, in the strip's canonical order. Both this
301
- // reserve and `CustomerGrid.drawGridHeader`'s painting read `headerMarkSizes`, so a
302
- // column can never reserve room for one mark and then be given two.
303
- const marks = headerMarkSizes(hasInfoMark(field), field.source === "overlay");
304
- return {
305
- id: key,
306
- // Owner item 16 β€” ELLIPSISED so the title can never run under the (i). Glide
307
- // truncates nothing: `drawHeaderInner` calls `fillText(c.title, …)` with no clip
308
- // and no max width, so on a narrow column the name simply painted through the
309
- // mark. Shortening the title glide is given is the whole fix β€” the alternative,
310
- // clipping inside `drawHeader`, cuts mid-glyph with no ellipsis to say it did.
311
- // The FULL name stays reachable: CustomerGrid's header tip leads with it whenever
312
- // this returns something shorter than `field.label`.
313
- title: fitHeaderTitle(
314
- field.label,
315
- headerLabelSpace(width, marks),
316
- (text) => measureHeaderText(text, involved)
317
- ),
318
- width,
319
- hasMenu: true,
320
- menuIcon: "dots",
321
- // Wave-8 I20 β€” the field-TYPE mark leads every header, so the column says what
322
- // KIND of thing it holds before you read a single cell (same geometry as the
323
- // Fields panel's rows β€” icons.tsx).
324
- icon: typeIconName(field.type),
325
- // Wave-9 I3 β€” the (i) is NO LONGER `overlayIcon`. Glide draws an overlay at a
326
- // hard-coded offset from the TYPE mark (drawHeaderInner: `drawX + 9`), i.e.
327
- // pinned to the far LEFT of the header, and no prop moves it; the owner asked
328
- // for it right-aligned and centred with the field name. It is drawn instead by
329
- // CustomerGrid's `drawHeader` callback, which resolves the same
330
- // `field.note || field.description` condition from the field itself.
331
- // The hover text is still CustomerGrid's floating tip (pointer-events: none β€”
332
- // a tooltip must never swallow the next click, [[ui-invisible-to-assertions]]).
333
- // Wave-14 R4 β€” being INVOLVED in a filter/sort/group is the only thing that tints a
334
- // column now, and all three tint it the same faint warm grey; the hue that says
335
- // WHICH control lives on the toolbar chip. User-created ownership moved out of the
336
- // theme entirely and is the dot drawn in the mark strip (R11).
337
- themeOverride: columnTheme(tones.get(key)),
338
- };
339
- }),
340
- [order, visible, config.widths, fieldByKey, tones]
341
- );
342
-
343
- const onColumnResize = useCallback(
344
- (column: GridColumn, newSize: number) => {
345
- if (!column.id) return;
346
- onConfig({
347
- ...config,
348
- widths: { ...config.widths, [column.id]: Math.round(newSize) },
349
- });
350
- },
351
- [config, onConfig]
352
- );
353
-
354
- const onColumnMoved = useCallback(
355
- (from: number, to: number) => {
356
- if (from === 0 || to === 0) return;
357
- const visibleKeys = order.filter((key) => visible.has(key));
358
- if (
359
- from < 0 ||
360
- from >= visibleKeys.length ||
361
- to < 0 ||
362
- to >= visibleKeys.length
363
- )
364
- return;
365
- const movedKeys = visibleKeys.slice();
366
- const [moved] = movedKeys.splice(from, 1);
367
- movedKeys.splice(to, 0, moved);
368
- let index = 0;
369
- const nextOrder = order.map((key) =>
370
- visible.has(key) ? movedKeys[index++] : key
371
- );
372
- onConfig({ ...config, order: nextOrder });
373
- },
374
- [config, onConfig, order, visible]
375
- );
376
-
377
- const onColumnProposeMove = useCallback(
378
- (from: number, to: number) => from !== 0 && to !== 0,
379
- []
380
- );
381
-
382
- const setColumnVisible = useCallback(
383
- (key: string, show: boolean) => {
384
- if (!show && key === lockedKey) return;
385
- const next = new Set(visible);
386
- if (show) next.add(key);
387
- else next.delete(key);
388
- onConfig({ ...config, visible: [...next] });
389
- },
390
- [config, lockedKey, onConfig, visible]
391
- );
392
-
393
- const insertColumn = useCallback(
394
- (key: string, anchorKey: string | null, side: "left" | "right" | "end") => {
395
- const nextOrder = order.filter((item) => item !== key);
396
- if (side === "end" || !anchorKey) {
397
- nextOrder.push(key);
398
- } else {
399
- const anchor = Math.max(0, nextOrder.indexOf(anchorKey));
400
- nextOrder.splice(anchor + (side === "right" ? 1 : 0), 0, key);
401
- }
402
- const nextVisible = new Set(visible);
403
- nextVisible.add(key);
404
- onConfig({ ...config, order: nextOrder, visible: [...nextVisible] });
405
- },
406
- [config, onConfig, order, visible]
407
- );
408
-
409
- return {
410
- visibleCols,
411
- fieldByKey,
412
- order,
413
- visible,
414
- widths: config.widths,
415
- lockedKey,
416
- onColumnResize,
417
- onColumnMoved,
418
- onColumnProposeMove,
419
- setColumnVisible,
420
- insertColumn,
421
- };
422
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useMemo } from "react";
2
+ import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
3
+ import type { Field, FilterNode, ViewConfig } from "./types";
4
+ import { isFilterGroup, isMachineOwned, isRuleActive, measureColumnIndex,
5
+ ruleColumnKeys } from "./types";
6
+ import { typeIconName } from "./iconShapes";
7
+ import { fitHeaderTitle, hasShareMark, HEADER_MENU_SLOT, headerLabelSpace,
8
+ headerMarkSizes } from "./overlayPlacement";
9
+ import { COLUMN_TONE_THEME, INVOLVED_HEADER_FONT, lightTheme } from "./theme";
10
+ import type { ControlTone } from "./theme";
11
+
12
+ const DEFAULT_WIDTH = 150;
13
+
14
+ /**
15
+ * Owner item 16 β€” measure header text in GLIDE'S OWN HEADER FONT.
16
+ *
17
+ * ⚠ NOT the cell font. Glide's `headerFontStyle` is "600 13px" and its `baseFontStyle` is
18
+ * "13px" (`common/styles.js:73-75`); semibold is materially wider, so measuring with the cell
19
+ * font under-measures, under-truncates, and leaves the label running under the (i) β€” the exact
20
+ * defect this item is about, surviving a fix that looked correct in the diff.
21
+ *
22
+ * ⚠⚠ Wave-14 item 2 adds the SAME trap one weight up. An involved column paints its header at
23
+ * **700** (`INVOLVED_HEADER_FONT`), so measuring every column at 600 would under-truncate
24
+ * exactly the filtered/sorted/grouped columns β€” the ones the owner is looking at. Hence TWO
25
+ * cached contexts, and both font strings are read off the theme layer rather than retyped here:
26
+ * a literal in this file is a copy that can drift from the one glide actually paints with, which
27
+ * is the whole failure mode above.
28
+ *
29
+ * ⚠ Wave-15 R5 gave each control its OWN hue, so there are now three involved-column themes
30
+ * instead of one. They deliberately share `INVOLVED_HEADER_FONT`, so ONE bold context still
31
+ * measures all three correctly β€” but the constant, not any one of the three theme objects, is
32
+ * what this reads. Picking `COLUMN_TONE_THEME.filter.headerFontStyle` would have measured every
33
+ * involved column against whichever tone happened to be listed first.
34
+ *
35
+ * One lazily-built offscreen context per weight, the same shape as CustomerGrid's
36
+ * `measureCellText`. A context can legitimately be null (a headless canvas, a locked-down
37
+ * embed); 0 then measures as "everything fits" and no title is truncated, which is the safe
38
+ * direction to fail β€” a title that slightly overlaps beats one silently cut to "C…".
39
+ */
40
+ const _hdrCtx: Record<"base" | "bold", CanvasRenderingContext2D | null | undefined> = {
41
+ base: undefined,
42
+ bold: undefined,
43
+ };
44
+ function measureHeaderText(text: string, bold = false): number {
45
+ const slot = bold ? "bold" : "base";
46
+ if (_hdrCtx[slot] === undefined) {
47
+ const ctx = document.createElement("canvas").getContext("2d");
48
+ if (ctx) {
49
+ const style =
50
+ (bold ? INVOLVED_HEADER_FONT : lightTheme.headerFontStyle) ?? "600 13px";
51
+ ctx.font = `${style} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`;
52
+ }
53
+ _hdrCtx[slot] = ctx;
54
+ }
55
+ const ctx = _hdrCtx[slot];
56
+ return ctx ? ctx.measureText(text).width : 0;
57
+ }
58
+
59
+ /** The (i) is drawn for exactly this condition. */
60
+ function hasInfoMark(field: Field): boolean {
61
+ return !!(field.note || field.description);
62
+ }
63
+
64
+ /**
65
+ * The user-created-field DOT's condition β€” owner item 4 (2026-08-06): *"why is the Field still
66
+ * have the Dot at the header to mark its a custom editable field?"*
67
+ *
68
+ * β›” `!isMachineOwned` is half of the rule, and it is the half that USED TO BE MISSING FROM THIS
69
+ * FILE. `source === "overlay"` alone is true of every column in a `ut_*` database, including the
70
+ * ones an automation spawns and fills, so the reserve kept room for a dot the drawer refused to
71
+ * paint. Nothing went red: over-reserving only shortens a title slightly, which is why it sat
72
+ * here through two waves. It is fixed by DELETION of the second copy, not by a second correction
73
+ * that could drift again β€” see `headerMarksFor`.
74
+ */
75
+ function hasCustomMark(field: Field): boolean {
76
+ return field.source === "overlay" && !isMachineOwned(field);
77
+ }
78
+
79
+ /**
80
+ * ⭐ W40-T20 β€” THE header mark strip a field carries, resolved ONCE for both of its readers: this
81
+ * module's truncation RESERVE and `CustomerGrid.drawGridHeader`'s PAINTING.
82
+ *
83
+ * β›” WHY IT IS A FUNCTION AND NOT A COMMENT. What stood here before was the claim *"both this
84
+ * reserve and drawGridHeader's painting read `headerMarkSizes`, so a column can never reserve
85
+ * room for one mark and then be given two"* β€” and it was FALSE IN TWO WAYS at once. The two call
86
+ * sites each resolved the booleans themselves, and they had already diverged on the dot
87
+ * (`hasCustomMark` above); adding the share mark would have diverged them a second time, on the
88
+ * mark whose whole purpose is to sit INNERMOST, i.e. exactly where a wrong reserve lets a title
89
+ * paint through a glyph. A shared resolver is the only version of that sentence that stays true
90
+ * when the next mark is added.
91
+ *
92
+ * ⚠ `droppable` TRAVELS WITH `sizes` for the same reason. They are two halves of one answer:
93
+ * reserving for a strip of three while the drawer paints two puts the label's right edge in the
94
+ * wrong place, and reserving for three while the drawer ALSO refuses all three (the `droppable`
95
+ * mismatch) returns `null`, which means "do not truncate at all" and paints the full label
96
+ * straight through both surviving marks. Passing one without the other is worse than passing
97
+ * neither, so neither is passed alone.
98
+ */
99
+ export function headerMarksFor(field: Field): {
100
+ info: boolean;
101
+ custom: boolean;
102
+ shared: boolean;
103
+ sizes: number[];
104
+ droppable: number;
105
+ } {
106
+ const info = hasInfoMark(field);
107
+ const custom = hasCustomMark(field);
108
+ const shared = hasShareMark(field);
109
+ return {
110
+ info,
111
+ custom,
112
+ shared,
113
+ sizes: headerMarkSizes(info, custom, shared),
114
+ // Only the share mark may be given up, and only because it is innermost. See `headerMarkFit`.
115
+ droppable: shared ? 1 : 0,
116
+ };
117
+ }
118
+
119
+ /** Every column key an ACTIVE filter rule names, anywhere in the tree. Inactive (half-typed)
120
+ * rules are skipped: a rule that is not narrowing anything must not tint a column as though
121
+ * it were β€” that is the same "it looks like it is working" lie the engine refuses to tell.
122
+ *
123
+ * ⚠ Wave-20 item 2: the key a rule NAMES is not always the column it is ABOUT β€” a measure
124
+ * condition carries the MEASURE key. `ruleColumnKeys` is the one resolution (types.ts); it
125
+ * is what stopped a filtered-and-sorted measure column wearing the sort hue. */
126
+ function filteredKeys(
127
+ nodes: FilterNode[],
128
+ out: Set<string>,
129
+ measureCols: Map<string, string[]>
130
+ ): void {
131
+ for (const node of nodes ?? []) {
132
+ if (isFilterGroup(node)) {
133
+ filteredKeys(node.children, out, measureCols);
134
+ continue;
135
+ }
136
+ if (isRuleActive(node)) for (const key of ruleColumnKeys(node, measureCols)) out.add(key);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The column's theme override, or `undefined` β€” glide's fast path for "no override" is an absent
142
+ * object, so an empty `{}` would cost a ~35-key theme merge per column per frame.
143
+ *
144
+ * ⚠ Wave-14 item 1 (R11): a user-created field contributes no theme. Its yellow header wash is
145
+ * dead and its replacement is a painted DOT, not a background β€” so the only thing that can put a
146
+ * themeOverride on a column is being INVOLVED in a control.
147
+ *
148
+ * ⚠ Wave-15 R5: the returned object is one of the three SHARED `COLUMN_TONE_THEME` references
149
+ * (never a fresh object), which is what keeps glide's theme merge cache-friendly across frames.
150
+ *
151
+ * β›” Wave-29 R8 (2026-08-11): those three references now carry **HEADER keys only**. glide builds a
152
+ * header theme from the column override alone and a cell theme from column β†’ row β†’ cell
153
+ * (`data-grid-render.header.js:59`, `common/styles.js mergeAndRealizeTheme`), so the tone reaching
154
+ * a column theme is exactly how a sort used to repaint record bodies. With no `bgCell` in the
155
+ * table there is no longer a path from "this column is sorted" to a cell's background at all β€”
156
+ * which is a property of what `COLUMN_TONE_THEME` CONTAINS, not of anything this function does.
157
+ * A body wash belongs to `getRowThemeOverride` (status) or the cell itself (automation).
158
+ */
159
+ function columnTheme(tone: ControlTone | undefined): Partial<Theme> | undefined {
160
+ return tone ? COLUMN_TONE_THEME[tone] : undefined;
161
+ }
162
+
163
+ /**
164
+ * Owner item 22 β€” key β†’ which control involves it, or absent.
165
+ *
166
+ * ⚠ ONE tone per column, and the precedence is a decision, not an accident: **filter > sort >
167
+ * group** (owner R6, wave 15). A filter changes WHICH ROWS you are looking at; when a column is
168
+ * doing two jobs the header names the most consequential one. Blending two tints would produce a
169
+ * third colour that matches no chip in the toolbar, which is worse than picking.
170
+ *
171
+ * ⚠ THE PRECEDENCE IS THE WRITE ORDER BELOW, and it reads backwards: later `set` calls WIN, so
172
+ * the least important control is written FIRST. It was `sort, group, filter` β€” i.e. filter >
173
+ * group > sort β€” until R6 named the order explicitly; getting this wrong is a one-line edit that
174
+ * changes a colour on screen and nothing else, so it is stated here rather than left to be
175
+ * re-derived from the sequence.
176
+ */
177
+ /* wave17 GRID β€” item 2 / owner R5. EXPORTED, and only since the row band died.
178
+ `activeControlTone` used to sit below this function and restate the same precedence for the
179
+ whole-table band; the band was its only consumer, so R5 took both. That left the surviving
180
+ precedence β€” the one that still paints β€” asserted by nothing, because `verify_icons` was
181
+ testing the twin. Exported so the gate can reach the function that is actually on screen. */
182
+ export function columnTones(
183
+ config: ViewConfig,
184
+ /* wave20 item 2 β€” the FIELDS, because a measure condition names its measure and only the
185
+ field list can say which column displays it. Defaulted so the signature stays callable
186
+ with a config alone (the pre-wave-20 gate legs), and because a caller with no fields
187
+ legitimately has no measure columns to resolve. */
188
+ fields: Field[] = []
189
+ ): Map<string, ControlTone> {
190
+ const out = new Map<string, ControlTone>();
191
+ if (config.groupBy) out.set(config.groupBy, "group");
192
+ for (const s of config.sorts ?? []) out.set(s.colId, "sort");
193
+ const filtered = new Set<string>();
194
+ filteredKeys(config.filters ?? [], filtered, measureColumnIndex(fields));
195
+ for (const key of filtered) out.set(key, "filter");
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * ⭐ WAVE 30 (owner item 2 scouting) β€” THE OVERLAY ARM IS GONE, and the arithmetic is the reason.
201
+ *
202
+ * This read `field.default !== false || field.source === "overlay"`. The second arm was written
203
+ * when `overlay` meant "a column a user added here", and those columns have no `default` key at
204
+ * all β€” so the arm was a belt-and-braces no-op for them. Then `user_tables._clean_field` started
205
+ * stamping `source: "overlay"` on EVERY `ut_*` field, and every connected database became a `ut_*`
206
+ * one: the exception quietly became the rule and swallowed the first arm entirely.
207
+ *
208
+ * β›” WHAT THAT COST, measured on the live orders grid: `odoo_relational.order_fields` declares
209
+ * `default: False` on `odoo_id`, `state`, `customer_link` and `partner_id`, and every one of them
210
+ * opened SHOWN. The grid arrives wide, and "Hide fields" reads inactive because
211
+ * `shownCount === fields.length` β€” so the control that says "some columns are hidden" says the
212
+ * opposite of what its own table declares. A second instance of [[fallback-that-became-the-rule]].
213
+ *
214
+ * ⚠ DROPPING THE ARM DOES NOT HIDE USER COLUMNS, and this is the check that matters before
215
+ * believing the fix. A column somebody created carries no `default` key, `undefined !== false` is
216
+ * true, and it stays visible. `_clean_field` also keeps `default` ONLY when it is `True`, so a
217
+ * `ut_*` column can never arrive carrying `default: false` by accident β€” only a shipped field
218
+ * CONTRACT (which is written straight into the store, bypassing that validator) can declare it.
219
+ *
220
+ * ⚠ THIS PREDICATE HAS A SERVER TWIN and the twin is the one that decides on first open:
221
+ * `platform/aios_grid.py:_default_view_config` carries the same expression and builds the "All
222
+ * records" system view the client pins as its landing default. Fixing one side alone changes
223
+ * nothing a user sees β€” see `mailbox/F.md` F-1 ([[one-question-two-normalizers]] across two
224
+ * languages).
225
+ */
226
+ function isDefaultVisible(field: Field): boolean {
227
+ if (field.default === false) return false;
228
+ /**
229
+ * ⭐⭐ OWNER, 2026-08-23 β€” A NEW VIEW SHOWS THE DATABASE'S OWN COLUMNS AND NOTHING ELSE.
230
+ *
231
+ * Owner: *"when any new View is created only shareable pre-set data should show. Shared to me
232
+ * or Shared to everyone should be auto hidden."*
233
+ *
234
+ * β›” `custom || shared` IS THE WHOLE PREDICATE, and it is chosen because it is exactly the
235
+ * membership test `FieldsHidePanel` already uses to draw its two SHARED sections:
236
+ * "Shared with me" (field.custom || field.shared) && fieldEditMode === "users"
237
+ * "Shared with everyone" (field.custom || field.shared) && fieldEditMode === "collaborative"
238
+ * Both sections are subsets of `custom || shared`, so hiding on that flag hides both by
239
+ * construction rather than by keeping a second list of section names in step with the panel.
240
+ *
241
+ * ⚠ IT ALSO HIDES THE UNTITLED SECTION'S CREATED COLUMNS, and that is deliberate rather than
242
+ * over-reach. A route order carries `shared: true` with `edit: personal`, so it is filed under
243
+ * no heading at all β€” and 52 of them landed on the customer grid the same day this was asked
244
+ * for. The owner's first sentence is the rule ("ONLY pre-set data shows"); the second names the
245
+ * two headings, it does not bound the set.
246
+ *
247
+ * ⚠ MEASURED ON THE LIVE CUSTOMER GRID before it was written: of 105 fields, 77 opened SHOWN.
248
+ * 52 route columns, 14 collaborative `custom_*`, 3 `measure_*`, one legacy route and 7 plain
249
+ * Odoo columns. After this, the 7 remain β€” which is precisely what
250
+ * `aios_grid_fields.json` declares as `default: true`, i.e. the contract's own answer before
251
+ * anybody's columns piled on top of it.
252
+ *
253
+ * β›”β›” A STORED VIEW IS UNTOUCHED. `normalizeConfig` spreads the saved `config` OVER this
254
+ * base, so a view that already recorded its own `visible` keeps it. What moves is a view born
255
+ * without one: the "All records" system view, a new view, a cohort view.
256
+ */
257
+ if (field.custom === true || field.shared === true) return false;
258
+ return true;
259
+ }
260
+
261
+ export function defaultViewConfig(fields: Field[]): ViewConfig {
262
+ const shown = fields.filter(isDefaultVisible).map((field) => field.key);
263
+ const hidden = fields.filter((field) => !isDefaultVisible(field)).map((field) => field.key);
264
+ return {
265
+ filters: [],
266
+ filterConj: "and",
267
+ sorts: [],
268
+ groupBy: null,
269
+ colorBy: null,
270
+ rowHeightMode: "short",
271
+ order: [...shown, ...hidden],
272
+ visible: shown,
273
+ widths: {},
274
+ memberPids: [],
275
+ };
276
+ }
277
+
278
+ function reconcileOrder(order: string[], fields: Field[]): string[] {
279
+ const valid = new Set(fields.map((field) => field.key));
280
+ const kept = order.filter((key, index) => valid.has(key) && order.indexOf(key) === index);
281
+ for (const field of fields) if (!kept.includes(field.key)) kept.push(field.key);
282
+ const locked = fields.find((field) => field.pinned)?.key ?? fields[0]?.key;
283
+ if (locked && kept[0] !== locked) {
284
+ const without = kept.filter((key) => key !== locked);
285
+ return [locked, ...without];
286
+ }
287
+ return kept;
288
+ }
289
+
290
+ function reconcileVisible(visible: string[], fields: Field[], lockedKey: string): Set<string> {
291
+ const valid = new Set(fields.map((field) => field.key));
292
+ const out = new Set(visible.filter((key) => valid.has(key)));
293
+ if (out.size === 0) {
294
+ for (const field of fields) if (isDefaultVisible(field)) out.add(field.key);
295
+ }
296
+ if (lockedKey) out.add(lockedKey);
297
+ return out;
298
+ }
299
+
300
+ export interface GridColumnsApi {
301
+ visibleCols: GridColumn[];
302
+ fieldByKey: Map<string, Field>;
303
+ order: string[];
304
+ visible: Set<string>;
305
+ widths: Record<string, number>;
306
+ lockedKey: string;
307
+ onColumnResize: (col: GridColumn, newSize: number) => void;
308
+ onColumnMoved: (from: number, to: number) => void;
309
+ onColumnProposeMove: (from: number, to: number) => boolean;
310
+ setColumnVisible: (key: string, show: boolean) => void;
311
+ insertColumn: (key: string, anchorKey: string | null, side: "left" | "right" | "end") => void;
312
+ }
313
+
314
+ /**
315
+ * Controlled column adapter. ViewConfig is the durable source of truth, so
316
+ * choosing a saved view is one atomic state change instead of a chain of hook
317
+ * setters that can flash or overwrite one another.
318
+ */
319
+ export function useGridColumns(
320
+ fields: Field[],
321
+ config: ViewConfig,
322
+ onConfig: (next: ViewConfig) => void
323
+ ): GridColumnsApi {
324
+ const fieldByKey = useMemo(
325
+ () => new Map(fields.map((field) => [field.key, field])),
326
+ [fields]
327
+ );
328
+ const lockedKey = useMemo(
329
+ () => fields.find((field) => field.pinned)?.key ?? fields[0]?.key ?? "",
330
+ [fields]
331
+ );
332
+ const order = useMemo(() => reconcileOrder(config.order, fields), [config.order, fields]);
333
+ const visible = useMemo(
334
+ () => reconcileVisible(config.visible, fields, lockedKey),
335
+ [config.visible, fields, lockedKey]
336
+ );
337
+
338
+ // Item 22 β€” recomputed only when the three controls move, not on every width drag.
339
+ // Wave-20 item 2 adds `fields`: a measure condition can only be resolved to the column that
340
+ // displays it, and a new measure column must re-tint without waiting for a control to move.
341
+ const tones = useMemo(
342
+ () => columnTones(config, fields),
343
+ [config.filters, config.sorts, config.groupBy, fields] // eslint-disable-line react-hooks/exhaustive-deps
344
+ );
345
+
346
+ const visibleCols = useMemo<GridColumn[]>(
347
+ () =>
348
+ order
349
+ .filter((key) => visible.has(key))
350
+ .map((key) => {
351
+ const field = fieldByKey.get(key)!;
352
+ const width = config.widths[key] ?? DEFAULT_WIDTH;
353
+ // Wave-14 item 2 β€” an INVOLVED column's header paints at 700, so it must be MEASURED
354
+ // at 700. `tones` is already a dep of this memo, so this costs nothing.
355
+ const involved = tones.has(key);
356
+ // R11 / W40-T20 β€” the marks this header carries, in the strip's canonical order. This
357
+ // reserve and `CustomerGrid.drawGridHeader`'s painting call the SAME resolver, so a
358
+ // column cannot reserve room for one strip and then be given a different one. That is
359
+ // a property of `headerMarksFor` being one function, not of two call sites agreeing.
360
+ const marks = headerMarksFor(field);
361
+ return {
362
+ id: key,
363
+ // Owner item 16 β€” ELLIPSISED so the title can never run under the (i). Glide
364
+ // truncates nothing: `drawHeaderInner` calls `fillText(c.title, …)` with no clip
365
+ // and no max width, so on a narrow column the name simply painted through the
366
+ // mark. Shortening the title glide is given is the whole fix β€” the alternative,
367
+ // clipping inside `drawHeader`, cuts mid-glyph with no ellipsis to say it did.
368
+ // The FULL name stays reachable: CustomerGrid's header tip leads with it whenever
369
+ // this returns something shorter than `field.label`.
370
+ // ⚠ W40-T20 β€” `menuSlotWidth` is passed EXPLICITLY so `droppable` can be. It is the
371
+ // constant that was already defaulting here, so the number is unchanged; what changes
372
+ // is that the reserve now clears the marks the drawer actually paints rather than the
373
+ // ones it was asked for.
374
+ title: fitHeaderTitle(
375
+ field.label,
376
+ headerLabelSpace(width, marks.sizes, HEADER_MENU_SLOT, marks.droppable),
377
+ (text) => measureHeaderText(text, involved)
378
+ ),
379
+ width,
380
+ hasMenu: true,
381
+ menuIcon: "dots",
382
+ // Wave-8 I20 β€” the field-TYPE mark leads every header, so the column says what
383
+ // KIND of thing it holds before you read a single cell (same geometry as the
384
+ // Fields panel's rows β€” icons.tsx).
385
+ icon: typeIconName(field.type),
386
+ // Wave-9 I3 β€” the (i) is NO LONGER `overlayIcon`. Glide draws an overlay at a
387
+ // hard-coded offset from the TYPE mark (drawHeaderInner: `drawX + 9`), i.e.
388
+ // pinned to the far LEFT of the header, and no prop moves it; the owner asked
389
+ // for it right-aligned and centred with the field name. It is drawn instead by
390
+ // CustomerGrid's `drawHeader` callback, which resolves the same
391
+ // `field.note || field.description` condition from the field itself.
392
+ // The hover text is still CustomerGrid's floating tip (pointer-events: none β€”
393
+ // a tooltip must never swallow the next click, [[ui-invisible-to-assertions]]).
394
+ // Wave-14 R4 β€” being INVOLVED in a filter/sort/group is the only thing that tints a
395
+ // column now, and all three tint it the same faint warm grey; the hue that says
396
+ // WHICH control lives on the toolbar chip. User-created ownership moved out of the
397
+ // theme entirely and is the dot drawn in the mark strip (R11).
398
+ themeOverride: columnTheme(tones.get(key)),
399
+ };
400
+ }),
401
+ [order, visible, config.widths, fieldByKey, tones]
402
+ );
403
+
404
+ const onColumnResize = useCallback(
405
+ (column: GridColumn, newSize: number) => {
406
+ if (!column.id) return;
407
+ onConfig({
408
+ ...config,
409
+ widths: { ...config.widths, [column.id]: Math.round(newSize) },
410
+ });
411
+ },
412
+ [config, onConfig]
413
+ );
414
+
415
+ const onColumnMoved = useCallback(
416
+ (from: number, to: number) => {
417
+ if (from === 0 || to === 0) return;
418
+ const visibleKeys = order.filter((key) => visible.has(key));
419
+ if (
420
+ from < 0 ||
421
+ from >= visibleKeys.length ||
422
+ to < 0 ||
423
+ to >= visibleKeys.length
424
+ )
425
+ return;
426
+ const movedKeys = visibleKeys.slice();
427
+ const [moved] = movedKeys.splice(from, 1);
428
+ movedKeys.splice(to, 0, moved);
429
+ let index = 0;
430
+ const nextOrder = order.map((key) =>
431
+ visible.has(key) ? movedKeys[index++] : key
432
+ );
433
+ onConfig({ ...config, order: nextOrder });
434
+ },
435
+ [config, onConfig, order, visible]
436
+ );
437
+
438
+ const onColumnProposeMove = useCallback(
439
+ (from: number, to: number) => from !== 0 && to !== 0,
440
+ []
441
+ );
442
+
443
+ const setColumnVisible = useCallback(
444
+ (key: string, show: boolean) => {
445
+ if (!show && key === lockedKey) return;
446
+ const next = new Set(visible);
447
+ if (show) next.add(key);
448
+ else next.delete(key);
449
+ onConfig({ ...config, visible: [...next] });
450
+ },
451
+ [config, lockedKey, onConfig, visible]
452
+ );
453
+
454
+ const insertColumn = useCallback(
455
+ (key: string, anchorKey: string | null, side: "left" | "right" | "end") => {
456
+ const nextOrder = order.filter((item) => item !== key);
457
+ if (side === "end" || !anchorKey) {
458
+ nextOrder.push(key);
459
+ } else {
460
+ const anchor = Math.max(0, nextOrder.indexOf(anchorKey));
461
+ nextOrder.splice(anchor + (side === "right" ? 1 : 0), 0, key);
462
+ }
463
+ const nextVisible = new Set(visible);
464
+ nextVisible.add(key);
465
+ onConfig({ ...config, order: nextOrder, visible: [...nextVisible] });
466
+ },
467
+ [config, onConfig, order, visible]
468
+ );
469
+
470
+ return {
471
+ visibleCols,
472
+ fieldByKey,
473
+ order,
474
+ visible,
475
+ widths: config.widths,
476
+ lockedKey,
477
+ onColumnResize,
478
+ onColumnMoved,
479
+ onColumnProposeMove,
480
+ setColumnVisible,
481
+ insertColumn,
482
+ };
483
+ }
web/src/filter-kit/FieldsHidePanel.tsx CHANGED
@@ -14,7 +14,9 @@
14
  // ---------------------------------------------------------------------------
15
 
16
  import { useState } from "react";
17
- import type { Field } from "../customer-grid/types";
 
 
18
  // ⭐ WAVE 30 item 2 (R5) β€” `fieldLabel` is the ONE resolver for a field's NAME, and every read
19
  // of `f.label` in this file goes through it. `Field.label` is declared `string` and arrives
20
  // over the WIRE, where nothing enforces that; the search filter below did
@@ -28,12 +30,25 @@ import type { Field } from "../customer-grid/types";
28
  import { fieldEditMode, fieldLabel, isPresetField } from "../customer-grid/types";
29
  import { FieldTypeIcon } from "../customer-grid/icons";
30
  import { TYPE_LABELS } from "../customer-grid/iconShapes";
 
 
 
 
 
 
 
 
 
 
31
 
32
  export interface FieldsHidePanelProps {
33
  /** Owner item 23 β€” in the view's COLUMN ORDER, not definition order. Dragging a row here
34
  * rewrites `config.order`, so a list showing a different sequence from the one it edits
35
- * would move a field to a position the user never pointed at. */
36
- fields: Field[];
 
 
 
37
  /** The keys that are HIDDEN. Every field not named here renders as shown. */
38
  hidden: ReadonlySet<string>;
39
  onToggle: (key: string) => void;
@@ -52,6 +67,27 @@ export interface FieldsHidePanelProps {
52
  * affordance renders for them at all. */
53
  deletableKeys?: ReadonlySet<string>;
54
  onDeleteField?: (key: string) => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
 
57
  /**
@@ -72,6 +108,10 @@ export function FieldsHidePanel({
72
  onFieldOrder,
73
  deletableKeys,
74
  onDeleteField,
 
 
 
 
75
  }: FieldsHidePanelProps) {
76
  const [query, setQuery] = useState("");
77
  /** W9 β€” destructive, so the trash arms first (the grid's confirm pattern): first
@@ -83,16 +123,52 @@ export function FieldsHidePanel({
83
  * that slot. */
84
  const [dragKey, setDragKey] = useState<string | null>(null);
85
  const [overKey, setOverKey] = useState<string | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  const q = query.trim().toLowerCase();
87
- const shown = q ? fields.filter((f) => fieldLabel(f).toLowerCase().includes(q)) : fields;
88
- const sections = [
89
- { key: "personal", title: "", fields: shown.filter((field) =>
90
- (!field.custom && !field.shared) || fieldEditMode(field) === "personal") },
91
- { key: "me", title: "Shared with me", fields: shown.filter((field) =>
92
- (field.custom || field.shared) && fieldEditMode(field) === "users") },
93
- { key: "everyone", title: "Shared with everyone", fields: shown.filter((field) =>
94
- (field.custom || field.shared) && fieldEditMode(field) === "collaborative") },
95
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  /**
97
  * Item 23 β€” reorder is OFF while a search is narrowing the list. Dropping "City" above
98
  * "State" in a list that is hiding the eight fields between them means nothing the user can
@@ -209,10 +285,10 @@ export function FieldsHidePanel({
209
  draggable={canReorder && !locked}
210
  aria-label={
211
  locked
212
- ? `${fieldLabel(f)} is always first`
213
  : canReorder
214
- ? `Reorder ${fieldLabel(f)}: drag, or use the arrow keys`
215
- : `Reorder ${fieldLabel(f)} (clear the search first)`
216
  }
217
  title={
218
  canReorder && !locked
@@ -262,7 +338,7 @@ export function FieldsHidePanel({
262
  draws (icons.tsx). Titled, because this list is where the icon
263
  vocabulary is learned. */}
264
  <FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
265
- <span className="cg-check-label">{fieldLabel(f)}</span>
266
  {/* ⭐ OWNER ITEM 3 (2026-08-06): *"it doesn't show pre-set when I hide fields just
267
  like how Odoo fields are. This is always the standard label."* Right β€” the chip
268
  asked `source === "odoo"`, so a column an AUTOMATION fills (source `overlay`)
@@ -283,7 +359,7 @@ export function FieldsHidePanel({
283
  type="button"
284
  className={"cg-check-del" + (armed ? " is-armed" : "")}
285
  aria-label={
286
- armed ? `Permanently delete ${fieldLabel(f)}?` : `Delete ${fieldLabel(f)}`
287
  }
288
  title={armed ? undefined : "Removes this field and its values on every row."}
289
  onClick={(e) => {
@@ -317,7 +393,24 @@ export function FieldsHidePanel({
317
  })}
318
  </div>
319
  ))}
320
- {shown.length === 0 && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  <div className="cg-builder-empty">No field matches your search.</div>
322
  )}
323
  </div>
 
14
  // ---------------------------------------------------------------------------
15
 
16
  import { useState } from "react";
17
+ // ⚠ W40-T16 β€” the bare `Field` type import is GONE, not misplaced: the prop below is
18
+ // `PermsField[]` now, which extends it, so importing both would leave one unread and
19
+ // `tsconfig.app.json`'s `noUnusedLocals` reds the build on exactly that.
20
  // ⭐ WAVE 30 item 2 (R5) β€” `fieldLabel` is the ONE resolver for a field's NAME, and every read
21
  // of `f.label` in this file goes through it. `Field.label` is declared `string` and arrives
22
  // over the WIRE, where nothing enforces that; the search filter below did
 
30
  import { fieldEditMode, fieldLabel, isPresetField } from "../customer-grid/types";
31
  import { FieldTypeIcon } from "../customer-grid/icons";
32
  import { TYPE_LABELS } from "../customer-grid/iconShapes";
33
+ // ⭐⭐ W40-T16 (owner instruction 13) β€” A METRIC IS A ROW LIKE ANY OTHER, and the ONE thing that
34
+ // makes it read differently is its NAME. `metricLabel` is that rule, declared once beside the
35
+ // flag it depends on, and it is a pure string function so this panel does not acquire a runtime
36
+ // dependency on anything the permission editor fetches or holds.
37
+ // ⚠ THE FIRST IMPORT FROM `settings/**` INTO THE KIT, and it is deliberately the leaf:
38
+ // `permsModel.ts` imports nothing at runtime (its only import is `import type`), so the module
39
+ // graph stays acyclic and `verify_ui.py`'s render smoke β€” which mounts `PermsEditor` and reaches
40
+ // this file through it β€” still resolves under bare node.
41
+ import type { PermsField } from "../settings/permsModel";
42
+ import { metricLabel, presetHideFields } from "../settings/permsModel";
43
 
44
  export interface FieldsHidePanelProps {
45
  /** Owner item 23 β€” in the view's COLUMN ORDER, not definition order. Dragging a row here
46
  * rewrites `config.order`, so a list showing a different sequence from the one it edits
47
+ * would move a field to a position the user never pointed at.
48
+ * ⭐ W40-T16 β€” `PermsField[]`, which is `Field` plus an OPTIONAL metric flag. The grid
49
+ * toolbar keeps passing `Field[]` and keeps compiling, because an optional extra key makes
50
+ * the two assignable; its fields never carry the flag, so no metric row can appear there. */
51
+ fields: PermsField[];
52
  /** The keys that are HIDDEN. Every field not named here renders as shown. */
53
  hidden: ReadonlySet<string>;
54
  onToggle: (key: string) => void;
 
67
  * affordance renders for them at all. */
68
  deletableKeys?: ReadonlySet<string>;
69
  onDeleteField?: (key: string) => void;
70
+ /**
71
+ * ⭐⭐ W40-T17 (owner instruction 15) β€” DOES THIS MOUNT FILE ROWS UNDER "Shared with me" AND
72
+ * "Shared with everyone"? Absent means YES, which is the grid's behaviour and every behaviour
73
+ * this panel has ever had. The manage-user permission editor passes `false`, because a
74
+ * permission is set on the database's own pre-set columns and on nothing else: owner, verbatim,
75
+ * *"stop displaying 'Shared with me' / 'Shared with everyone' fields under Hide Fields -
76
+ * permission on pre-set Fields only."*
77
+ *
78
+ * β›” A PROP WITH A SAFE DEFAULT, NEVER AN UNCONDITIONAL REMOVAL, AND THE REASON IS THAT THIS
79
+ * PANEL SERVES TWO MOUNTS. `customer-grid/Toolbar.tsx` mounts it for the grid, where the two
80
+ * sections were ADDED on purpose by the 2026-08-21 hotfix and are what a user reads to tell
81
+ * their own columns from the tenant's. Deleting them here would silently take a shipped
82
+ * affordance off a surface this ticket never looked at, and no diff of the permission editor
83
+ * would show it. The grid passes nothing and is therefore untouched, byte for byte.
84
+ *
85
+ * ⚠ `false` DOES NOT MERELY DROP TWO SECTIONS. It swaps the whole membership question for
86
+ * AM-2's: `permsModel::presetHideFields`, i.e. `metric || !custom`. Filing the same rows under
87
+ * one untitled heading would still list somebody's personal column as a thing an admin can
88
+ * permission, which is the half of instruction 15 the section titles only made visible.
89
+ */
90
+ sharedSections?: boolean;
91
  }
92
 
93
  /**
 
108
  onFieldOrder,
109
  deletableKeys,
110
  onDeleteField,
111
+ // ⭐⭐ W40-T17 β€” THE DEFAULT IS THE WHOLE SAFETY PROPERTY. `true` here is what makes the grid's
112
+ // mount need no edit at all: `customer-grid/Toolbar.tsx` passes no `sharedSections`, so it
113
+ // renders the three sections below exactly as it did before this ticket.
114
+ sharedSections = true,
115
  }: FieldsHidePanelProps) {
116
  const [query, setQuery] = useState("");
117
  /** W9 β€” destructive, so the trash arms first (the grid's confirm pattern): first
 
123
  * that slot. */
124
  const [dragKey, setDragKey] = useState<string | null>(null);
125
  const [overKey, setOverKey] = useState<string | null>(null);
126
+ /**
127
+ * ⭐⭐ W40-T16 (owner instruction 13) β€” HOW A ROW IS NAMED, in one place, for the same reason
128
+ * wave 30's R5 put `fieldLabel` in one place: this panel reads a field's name at SIX sites (the
129
+ * search filter, three grip aria-labels, the visible label and two delete aria-labels), and a
130
+ * name resolved differently at any one of them means what a user SEARCHES and what they READ
131
+ * answer the same question differently.
132
+ *
133
+ * ⚠ `fieldLabel` STAYS THE RESOLVER. This adds the metric prefix on top of it and nothing else,
134
+ * so a label-less definition arriving over the wire is still guarded exactly as before.
135
+ * A field with no metric flag comes back byte-identical to `fieldLabel(f)` β€” which is what
136
+ * keeps the GRID's mount of this panel unchanged, since grid fields never carry the flag.
137
+ */
138
+ const rowLabel = (f: PermsField): string =>
139
+ f.isMetric ? metricLabel(fieldLabel(f)) : fieldLabel(f);
140
  const q = query.trim().toLowerCase();
141
+ const shown = q ? fields.filter((f) => rowLabel(f).toLowerCase().includes(q)) : fields;
142
+ /**
143
+ * ⭐⭐ W40-T17 (owner instruction 15) β€” TWO MOUNTS, TWO QUESTIONS, ONE PANEL.
144
+ *
145
+ * The GRID asks "whose column is this?" and answers it with three sections, because a person
146
+ * looking at their own table needs to tell their columns from the tenant's. The PERMISSION
147
+ * EDITOR asks "which columns may an admin set a rule on?", and AM-2 answers that with
148
+ * `metric || !custom` β€” the database's own pre-set columns, plus every bound measure. They are
149
+ * not the same list narrowed; they are different questions with different answers.
150
+ *
151
+ * β›” THE THREE-SECTION ARM IS UNCHANGED AND MUST STAY SPELLED EXACTLY THIS WAY.
152
+ * `verify_map.py`'s `private-by-default-is-anchored` leg reads this file for the literal
153
+ * `fieldEditMode(field) === "collaborative"`: it is what pins "Shared with everyone" to the
154
+ * `collaborative` answer, so a route column stamped `edit: personal` is filed as private by
155
+ * rule rather than by luck. Rename the parameter or move the predicate and that gate reports a
156
+ * fact about route columns going stale, one surface away from here.
157
+ *
158
+ * ⚠ THE NARROW ARM RENDERS ONE UNTITLED SECTION, which is what the first section has always
159
+ * been. No heading appears and no heading disappears: `section.title` is falsy, so the rows sit
160
+ * directly under the search box exactly as the personal rows do on the grid today.
161
+ */
162
+ const sections = sharedSections
163
+ ? [
164
+ { key: "personal", title: "", fields: shown.filter((field) =>
165
+ (!field.custom && !field.shared) || fieldEditMode(field) === "personal") },
166
+ { key: "me", title: "Shared with me", fields: shown.filter((field) =>
167
+ (field.custom || field.shared) && fieldEditMode(field) === "users") },
168
+ { key: "everyone", title: "Shared with everyone", fields: shown.filter((field) =>
169
+ (field.custom || field.shared) && fieldEditMode(field) === "collaborative") },
170
+ ]
171
+ : [{ key: "personal", title: "", fields: presetHideFields(shown) }];
172
  /**
173
  * Item 23 β€” reorder is OFF while a search is narrowing the list. Dropping "City" above
174
  * "State" in a list that is hiding the eight fields between them means nothing the user can
 
285
  draggable={canReorder && !locked}
286
  aria-label={
287
  locked
288
+ ? `${rowLabel(f)} is always first`
289
  : canReorder
290
+ ? `Reorder ${rowLabel(f)}: drag, or use the arrow keys`
291
+ : `Reorder ${rowLabel(f)} (clear the search first)`
292
  }
293
  title={
294
  canReorder && !locked
 
338
  draws (icons.tsx). Titled, because this list is where the icon
339
  vocabulary is learned. */}
340
  <FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
341
+ <span className="cg-check-label">{rowLabel(f)}</span>
342
  {/* ⭐ OWNER ITEM 3 (2026-08-06): *"it doesn't show pre-set when I hide fields just
343
  like how Odoo fields are. This is always the standard label."* Right β€” the chip
344
  asked `source === "odoo"`, so a column an AUTOMATION fills (source `overlay`)
 
359
  type="button"
360
  className={"cg-check-del" + (armed ? " is-armed" : "")}
361
  aria-label={
362
+ armed ? `Permanently delete ${rowLabel(f)}?` : `Delete ${rowLabel(f)}`
363
  }
364
  title={armed ? undefined : "Removes this field and its values on every row."}
365
  onClick={(e) => {
 
393
  })}
394
  </div>
395
  ))}
396
+ {/* ⭐⭐ W40-T17 β€” GATED ON WHAT THE SECTIONS ACTUALLY HOLD, not on `shown`, and the two
397
+ stopped being the same question the moment a mount could narrow the vocabulary. On the
398
+ narrow arm a query matching only somebody's own columns leaves `shown` non-empty and
399
+ every section EMPTY: a search box with nothing under it and no sentence saying why,
400
+ which reads as a broken panel rather than as a search that found nothing.
401
+ β›” THE GRID IS UNAFFECTED, BY PARTITION RATHER THAN BY LUCK. `fieldEditMode` answers
402
+ exactly one of `personal` / `users` / `collaborative` (`types.ts::FIELD_EDIT_MODES`),
403
+ and the three predicates above split on that answer, so every field lands in exactly
404
+ one section and `sections.every(empty)` is TRUE precisely when `shown` is empty. Same
405
+ render, same condition, one fewer way to paint a blank.
406
+ ⚠ THE SENTENCE IS UNCHANGED, AND THE ONE CASE IT WOULD NOT FIT IS NAMED RATHER THAN
407
+ ASSUMED AWAY. With no query at all, this branch needs a module whose EVERY field is
408
+ `custom` and none a metric. No such module exists: an Odoo topic's columns are the
409
+ contract in `aios_grid.FIELDS`, and a `ut_*` database's are stamped `source: overlay`
410
+ by `core.user_tables._clean_field`, which never writes `custom`. A new copy for a
411
+ state nothing can reach would be a paragraph the empty-state rule (DESIGN.md Β§4)
412
+ exists to refuse; if a wire ever makes it reachable, that is when it earns a line. */}
413
+ {sections.every((section) => section.fields.length === 0) && (
414
  <div className="cg-builder-empty">No field matches your search.</div>
415
  )}
416
  </div>
web/src/filter-kit/FilterBuilderPanel.tsx CHANGED
@@ -36,6 +36,14 @@ import type { AnchorMode } from "../customer-grid/windows";
36
  import { ANCHOR_LABELS, ANCHOR_MODES, MAX_N } from "../customer-grid/windows";
37
  import { FieldSelectButton } from "../customer-grid/FieldSelect";
38
  import type { FieldSelectItem } from "../customer-grid/FieldSelect";
 
 
 
 
 
 
 
 
39
  import { WindowPicker } from "./WindowPicker";
40
  import {
41
  COHORT_OP_LIST, DEFAULT_DATE_WINDOW, DEFAULT_WINDOW, MEASURE_MARK, MEASURE_OP_LIST,
@@ -543,6 +551,18 @@ function LeafRow({
543
  // C4 β€” the view-membership leaf, on exactly the terms the cohort leaf above it runs.
544
  const isView = rule.colId === VIEW_FIELD;
545
  const hasViews = viewChoices.length > 0;
 
 
 
 
 
 
 
 
 
 
 
 
546
  const t = isMeasure
547
  ? measureByKey.get(rule.colId)?.type ?? "currency"
548
  : fieldByKey.get(rule.colId)?.type ?? "text";
@@ -639,6 +659,27 @@ function LeafRow({
639
  const offeredOps = withCurrent(canRank ? [...opList, ...RANK_OPS] : opList, opValue);
640
  const plainOps = offeredOps.filter((op) => !isRankOp(op));
641
  const rankedOps = offeredOps.filter((op) => isRankOp(op));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
 
643
  return (
644
  <span className={"cg-cond-row" + (isMeasure ? " is-measure" : "")
@@ -650,12 +691,13 @@ function LeafRow({
650
  adds the type MARK to each row and takes NO grouping with it: the marks are what the
651
  optgroup was reaching for, and they say it per row instead of splitting the list. */}
652
  <FieldSelectButton
653
- className="cg-cond-field"
654
- ariaLabel="Field"
 
655
  value={rule.colId}
656
  onChange={onField}
657
  fields={[
658
- ...withCurrentField(fields, rule.colId, fieldByKey, measureByKey, hasCohorts),
659
  ...measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK })),
660
  // A cohort leaf asks about membership of a SET, which is the multiselect mark's
661
  // own meaning β€” the vocabulary already had the right glyph for it.
 
36
  import { ANCHOR_LABELS, ANCHOR_MODES, MAX_N } from "../customer-grid/windows";
37
  import { FieldSelectButton } from "../customer-grid/FieldSelect";
38
  import type { FieldSelectItem } from "../customer-grid/FieldSelect";
39
+ // ⭐ W40-T18 β€” the deleted-column vocabulary. Same direction `filter-kit/FieldsHidePanel.tsx`
40
+ // already imports `metricLabel` / `presetHideFields` from: `settings/permsModel` is a PURE model
41
+ // module with no runtime import of its own, it is already in the grid bundle because that panel
42
+ // mounts in the grid toolbar, and putting these three beside it is what lets `perms.test.ts`
43
+ // assert them under bare node β€” nothing in this file can be imported by that harness.
44
+ import {
45
+ DELETED_FIELD_NOTE, isDeletedFieldRef, withDeletedFieldLabel,
46
+ } from "../settings/permsModel";
47
  import { WindowPicker } from "./WindowPicker";
48
  import {
49
  COHORT_OP_LIST, DEFAULT_DATE_WINDOW, DEFAULT_WINDOW, MEASURE_MARK, MEASURE_OP_LIST,
 
551
  // C4 β€” the view-membership leaf, on exactly the terms the cohort leaf above it runs.
552
  const isView = rule.colId === VIEW_FIELD;
553
  const hasViews = viewChoices.length > 0;
554
+ // ⭐⭐ W40-T18 (owner instruction 16) β€” THE CONDITION WHOSE COLUMN IS GONE.
555
+ //
556
+ // `withCurrentField` resolves the current key against `fieldByKey`, the COMPLETE list, and
557
+ // appends whatever it finds so a narrowed picker never orphans history. When it finds nothing
558
+ // it appends the RAW KEY as the label. That row is the defect: `custom_1723489` reads as a
559
+ // field name, and an admin cannot tell a deleted column from a badly named one.
560
+ //
561
+ // β›” MEASURE-SHAPED RULES ARE EXCLUDED. `isMeasureRule` is a test of the rule's SHAPE (it
562
+ // carries a window), so a measure whose key has gone would otherwise be named a deleted
563
+ // COLUMN, which it is not. It keeps today's rendering; see the handoff.
564
+ const goneColumn = !isMeasure
565
+ && isDeletedFieldRef(rule.colId, fieldByKey, measureByKey, [COHORT_FIELD, VIEW_FIELD]);
566
  const t = isMeasure
567
  ? measureByKey.get(rule.colId)?.type ?? "currency"
568
  : fieldByKey.get(rule.colId)?.type ?? "text";
 
659
  const offeredOps = withCurrent(canRank ? [...opList, ...RANK_OPS] : opList, opValue);
660
  const plainOps = offeredOps.filter((op) => !isRankOp(op));
661
  const rankedOps = offeredOps.filter((op) => isRankOp(op));
662
+ // ⭐⭐ W40-T18 β€” HOW A GONE COLUMN READS, in three parts and no new CSS.
663
+ //
664
+ // (1) THE ROW. `withCurrentField` has already synthesised a picker row labelled with the raw
665
+ // key; `withDeletedFieldLabel` rewrites that one row to the word and moves the key into
666
+ // the muted `hint` line `FieldSelect` paints under a label. Nothing is removed from the
667
+ // list and no condition is dropped: this panel edits live permission records, and a rule
668
+ // that vanishes on render is a rule nobody decided to delete.
669
+ const offeredFields = goneColumn
670
+ ? withDeletedFieldLabel(
671
+ withCurrentField(fields, rule.colId, fieldByKey, measureByKey, hasCohorts), rule.colId)
672
+ : withCurrentField(fields, rule.colId, fieldByKey, measureByKey, hasCohorts);
673
+ // (2) THE MARK. `is-missing` is index.css's OWN state for "set to something this table cannot
674
+ // offer" β€” amber border, amber label β€” and it is APPENDED rather than written fresh
675
+ // because this panel also mounts in the grid toolbar, while every lane-G stylesheet loads
676
+ // with the settings modal alone. The button paints that state unaided when a value
677
+ // resolves to no row; it cannot here, precisely because a row was synthesised for it, so
678
+ // the caller says it instead.
679
+ const fieldClass = "cg-cond-field" + (goneColumn ? " is-missing" : "");
680
+ // (3) THE NAME A SCREEN READER HEARS, which `aria-label` supplies INSTEAD of the visible
681
+ // label β€” so the state and the key both belong in it or neither is announced at all.
682
+ const fieldAria = goneColumn ? `Field, deleted column ${rule.colId}` : "Field";
683
 
684
  return (
685
  <span className={"cg-cond-row" + (isMeasure ? " is-measure" : "")
 
691
  adds the type MARK to each row and takes NO grouping with it: the marks are what the
692
  optgroup was reaching for, and they say it per row instead of splitting the list. */}
693
  <FieldSelectButton
694
+ className={fieldClass}
695
+ ariaLabel={fieldAria}
696
+ title={goneColumn ? DELETED_FIELD_NOTE : undefined}
697
  value={rule.colId}
698
  onChange={onField}
699
  fields={[
700
+ ...offeredFields,
701
  ...measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK })),
702
  // A cohort leaf asks about membership of a SET, which is the multiselect mark's
703
  // own meaning β€” the vocabulary already had the right glyph for it.
web/src/filter-kit/ops.ts CHANGED
@@ -1,400 +1,426 @@
1
- // ---------------------------------------------------------------------------
2
- // filter-kit / ops.ts
3
- // The filter/sort operator VOCABULARY β€” which operators each field type offers,
4
- // and how each one reads to a human.
5
- //
6
- // Extracted verbatim from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so
7
- // the permission editor and the grid toolbar offer ONE vocabulary rather than two
8
- // that drift. The matching engine (matchFilter/makeComparator in useVisibleRows)
9
- // and its Python mirrors (filter_sql.py, filter_eval.py) are the other halves of
10
- // the same contract; these are only the labels the popovers render.
11
- // ---------------------------------------------------------------------------
12
-
13
- import type {
14
- Conjunction,
15
- Field,
16
- FieldType,
17
- FilterNode,
18
- FilterOp,
19
- FilterRule,
20
- Measure,
21
- } from "../customer-grid/types";
22
- import {
23
- COHORT_FIELD, COHORT_OPS, MEASURE_OPS, MEASURE_PAIR_OPS, isFilterGroup,
24
- isDateFamilyType, isNumericFieldType, isRankOp, normalizeCohortOp,
25
- } from "../customer-grid/types";
26
- import type { CohortOp } from "../customer-grid/types";
27
- import type { FieldSelectItem, FieldSelectType } from "../customer-grid/FieldSelect";
28
- import type { WindowKind, WindowSpec } from "../customer-grid/windows";
29
-
30
- // The type families live in types.ts (ONE definition for every UI surface); the engine keeps
31
- // its own deliberately self-contained copies β€” see isNumericFieldType's doc.
32
- export const isNumericType = isNumericFieldType;
33
- export const isDateFamily = isDateFamilyType;
34
-
35
- /**
36
- * Operators offered for a field type (first = the default for new conditions).
37
- * Mirrors Airtable's own per-type sets, verified against the live product
38
- * (2026-07-25): a text field offers exactly contains / does not contain / is /
39
- * is not / is empty / is not empty. Every type ends with the two value-free ops.
40
- *
41
- * NOTE on isEmpty over the Customer List contract: several Odoo-derived money
42
- * columns (revenue_ytd, revenue_ly, at_risk, ltm_rev, orders_24m, est_missed) are
43
- * built with 0.0/0 defaults in customer_list.pool(), so "is empty" correctly
44
- * matches NOTHING there β€” 0 is a real value, not a blank. The columns where it
45
- * genuinely bites are last_order (''), days_since, typical_gap_days, yoy_pct, and
46
- * every overlay field (notes / custom) β€” "customers with no notes yet" is the
47
- * common case. Offered on all types anyway, exactly as Airtable does.
48
- *
49
- * ⚠ AND the SENTINEL columns, which are a third case and the one that surprises:
50
- * agent Β· city Β· state Β· country Β· zip Β· payment_terms Β· pricelist Β· tags all
51
- * collapse a blank to the STRING '(none)' in `_partner_attrs`, so grouping has no
52
- * null bucket. `isEmpty` tests `value === ""`, so on these it matches nothing even
53
- * though 76 customers have no country and 79 no zip β€” the blanks are found with
54
- * `is` `(none)`, which is also what the cell visibly says. Each of those fields
55
- * carries that sentence as its column `note`, because a source comment is not
56
- * where the person filtering is looking. DATE attributes keep '' instead
57
- * (customer_since, last_order): '(none)' in a date column would sort and compare
58
- * as text against ISO dates.
59
- */
60
- export function opsForType(t: FieldType): FilterOp[] {
61
- // Wave-5: a checkbox stores '1' or blank, so "is checked" IS `isNotEmpty` and "is
62
- // unchecked" IS `isEmpty` β€” the honest mapping onto the existing vocabulary (no new
63
- // FilterOp, the filter_sql lock-step untouched). Both are value-free, so no value control
64
- // renders. First = the default: "is checked".
65
- if (t === "checkbox") return ["isNotEmpty", "isEmpty"];
66
- if (isNumericType(t))
67
- return ["gte", "gt", "lte", "lt", "eq", "neq", "between", "isEmpty", "isNotEmpty"];
68
- if (isDateFamily(t))
69
- // Owner item 3, in the order they asked for it. `between` is GONE from the offer β€” "is
70
- // within" plus a custom range says the same thing and reads better β€” but it stays in the
71
- // ENGINE, because saved views hold it. `legacyOps` below is what keeps such a view
72
- // rendering its own operator instead of silently showing the first one in this list.
73
- return ["eq", "within", "lt", "gt", "lte", "gte", "neq", "isEmpty", "isNotEmpty"];
74
- // `select` and `user` are picked from a fixed list, so they behave like `status`: is / is
75
- // not / empty. `contains` on a chosen value is a substring test against a closed vocabulary β€”
76
- // it would find "Done" inside "Not done" and read as a bug.
77
- if (t === "status" || t === "select" || t === "user")
78
- return ["eq", "neq", "isEmpty", "isNotEmpty"];
79
- // A `multiselect` cell is a comma-joined SET, so the useful question is membership β€” "has" β€”
80
- // which is the EXISTING `contains` op over the joined string (no new engine vocabulary; the
81
- // ops are mirrored in filter_sql.py and inventing one here would break the lock-step). The
82
- // value is still picked from the declared options, so the substring caveat above is bounded
83
- // by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
84
- if (t === "multiselect")
85
- return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
86
- // ⭐ Wave-23 C7 β€” `json` gets EXACTLY the two value-free ops, and the omissions are the
87
- // decision. `contains` over a serialized document is a substring test against punctuation and
88
- // key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
89
- // fragment of 5120, and every one of those reads as a working filter. `eq` is worse β€” two
90
- // documents that mean the same thing differ by key order and whitespace, so "is" would answer
91
- // false for a record that plainly matches. "Has anything been captured here yet" is the
92
- // question a json column can answer soundly, and it is the one people actually ask.
93
- //
94
- // ⚠ NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
95
- // `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
96
- // are TYPE-INDEPENDENT β€” they test blankness before any type dispatch β€” so the lock-step is
97
- // untouched and nothing on the server had to move for this line. (The contract's prose spells
98
- // them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
99
- if (t === "json") return ["isNotEmpty", "isEmpty"];
100
- // Wave-18 C5-AUTOFIELD β€” `automation` lands HERE, on the text ops, and that is the whole
101
- // v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
102
- // `contains ok` and `contains error` are the two questions anyone actually asks of the
103
- // column, and both are already sound over a string. Nothing new enters the operator
104
- // vocabulary, so the `filter_sql.py` lock-step is untouched β€” a new op for "state is" would
105
- // have to be mirrored in the Python engine, and the substring answer is the same answer.
106
- return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"]; // text
107
- }
108
-
109
- /**
110
- * C-OPS β€” the RANK operators, worded so the row still reads as a sentence.
111
- *
112
- * The unit lives in the OPERATOR, not beside the box: "top" followed by a bare 10 is ambiguous
113
- * between ten records and ten percent, so `top N` and `top N%` are two operators and the box
114
- * supplies N. The alternative β€” a "%" suffix element after the input β€” would put a second
115
- * thing in the value slot that every gate locating `.cg-cond-val` would have to know about.
116
- *
117
- * ⚠ AND THEY ARE SHORT ON PURPOSE. A 136px `.cg-select` has ~97.9px of readable width (see
118
- * `cohortOpLabel` below, and `withCurrentField` below) β€” about 15 characters of 12.5px Inter.
119
- * "is in the bottom N%" is 19 and would have shipped CLIPPED, which no assertion here can see
120
- * and no screenshot in this session could catch. Dropping the "is " costs nothing: the numeric
121
- * operators beside these are bare glyphs (`β‰₯ > ≀ <`), so the row already reads without one,
122
- * and the `Ranked` divider carries the "these are a different kind of question" signal. Fifth
123
- * time this control has outgrown a label; the label gives way, never the control.
124
- */
125
- export const RANK_OP_LABELS: Record<string, string> = {
126
- topN: "top N",
127
- bottomN: "bottom N",
128
- inTopPct: "top N%",
129
- inBottomPct: "bottom N%",
130
- aboveAvg: "above average",
131
- belowAvg: "below average",
132
- inQuartile: "in quartile",
133
- inDecile: "in decile",
134
- };
135
-
136
- /**
137
- * The value a rank op starts at when it is chosen.
138
- *
139
- * Seeded rather than left blank, and this is a deliberate break from how the other operators
140
- * behave: a blank value makes a rule INACTIVE, so `is in the top N` with nothing typed would
141
- * sit in the builder narrowing nothing, and "the filter I just added does nothing" reads as a
142
- * broken control rather than as an unfinished sentence. Every rank op has an obvious default β€”
143
- * ten, or the top slice β€” so there is a right answer to seed. The two averages take none.
144
- */
145
- export function rankDefault(op: FilterOp): string {
146
- switch (op) {
147
- case "topN":
148
- case "bottomN":
149
- case "inTopPct":
150
- case "inBottomPct":
151
- case "inDecile":
152
- return "10";
153
- case "inQuartile":
154
- return "4";
155
- default:
156
- return ""; // aboveAvg / belowAvg β€” value-free
157
- }
158
- }
159
-
160
- /* β›” `hasRankCondition` LIVED HERE AND IS DELETED WITH THE PARAGRAPH IT GATED (wave 34, R5).
161
- Its whole job was to decide whether to draw the builder's rank explainer; the owner has
162
- removed that paragraph, and the predicate had exactly one caller besides its own recursion.
163
- ⚠ `isRankOp` stays: `opLabel` below still calls it. `isMeasureRule` was `hasRankCondition`'s
164
- ONLY use in this module, so THIS FILE'S IMPORT of it went too β€” the symbol itself lives on in
165
- `types.ts` with plenty of callers, including `FilterBuilderPanel` two files over. The
166
- distinction matters: an unused IMPORT is dead weight here, an unused EXPORT would be dead code
167
- there, and only one of those was true.
168
- ⚠⚠ THIS COMMENT'S FIRST DRAFT SAID BOTH SYMBOLS "STAY, they have many callers" β€” and `tsc`
169
- refuted it in the next run. That is the THIRD time in one session a confident claim about who
170
- calls a symbol was wrong within a minute of writing it. The habit worth keeping is not "be more
171
- careful"; it is that the compiler answers this question for free, so ask it before asserting. */
172
-
173
- /** Human label for an operator, read naturally per field type. */
174
- export function opLabel(op: FilterOp, t: FieldType): string {
175
- // Rank first and type-independently: a rank op means the same thing on every numeric
176
- // family, and a view whose column has since changed type must still SAY what it holds
177
- // rather than falling through to a raw key.
178
- if (isRankOp(op)) return RANK_OP_LABELS[op] ?? op;
179
- // Checkbox first: its two value-free ops READ as states, not as blankness β€” the storage
180
- // contract ('1' or blank) is an implementation detail the sentence must not leak.
181
- if (t === "checkbox") {
182
- if (op === "isNotEmpty") return "is checked";
183
- if (op === "isEmpty") return "is unchecked";
184
- }
185
- // Type-independent: Airtable words these identically for every field type.
186
- if (op === "isEmpty") return "is empty";
187
- if (op === "isNotEmpty") return "is not empty";
188
- if (isDateFamily(t)) {
189
- switch (op) {
190
- case "eq":
191
- return "is";
192
- case "neq":
193
- return "is not";
194
- case "within":
195
- return "is within";
196
- case "lt":
197
- return "is before";
198
- case "lte":
199
- return "is on or before";
200
- case "gt":
201
- return "is after";
202
- case "gte":
203
- return "is on or after";
204
- case "between":
205
- return "is between"; // no longer offered; still rendered for a saved view
206
- }
207
- }
208
- if (isNumericType(t)) {
209
- switch (op) {
210
- case "gte":
211
- return "β‰₯"; // β‰₯
212
- case "gt":
213
- return ">";
214
- case "lte":
215
- return "≀"; // ≀
216
- case "lt":
217
- return "<";
218
- case "eq":
219
- return "=";
220
- case "neq":
221
- return "β‰ "; // β‰ 
222
- case "between":
223
- return "is between";
224
- default:
225
- return op;
226
- }
227
- }
228
- if (t === "multiselect") {
229
- // Membership wording over the joined-set cell. "has" IS `contains` (see opsForType) β€” only
230
- // the label changes, so the engine and its Python mirror stay untouched.
231
- switch (op) {
232
- case "contains":
233
- return "has";
234
- case "doesNotContain":
235
- return "does not have";
236
- case "eq":
237
- return "is exactly";
238
- case "neq":
239
- return "is not exactly";
240
- default:
241
- return op;
242
- }
243
- }
244
- // text / status. (Airtable suffixes value-taking ops with "…" in the OPEN
245
- // dropdown only; a native <select> shows one string in both states, so we use
246
- // the closed-state wording β€” which is what's on screen almost all the time.)
247
- switch (op) {
248
- case "contains":
249
- return "contains";
250
- case "doesNotContain":
251
- return "does not contain";
252
- case "eq":
253
- return "is";
254
- case "neq":
255
- return "is not";
256
- default:
257
- return op;
258
- }
259
- }
260
-
261
- /**
262
- * The offered list, PLUS whatever the rule is actually set to.
263
- *
264
- * A `<select>` whose `value` is not among its `<option>`s renders the FIRST option instead β€”
265
- * so a saved view holding a dropped operator (a date `between`) or a column that has since left
266
- * the filter list (`at_risk`, once its measure replacement shipped) would display something the
267
- * rule does not say, and the first edit would silently rewrite it to that. Painted, plausible,
268
- * and wrong: the class of failure a screenshot catches and an assertion does not.
269
- */
270
- export function withCurrent<T extends string>(offered: readonly T[], current: T | undefined): T[] {
271
- return current && !offered.includes(current) ? [...offered, current] : [...offered];
272
- }
273
-
274
- /** A group's summary line, mirroring Airtable's wording. */
275
- export function groupSummary(conj: Conjunction): string {
276
- return conj === "or"
277
- ? "Any of the following are true…"
278
- : "All of the following are true…";
279
- }
280
-
281
- /**
282
- * CG-8 β€” the operators a MEASURE condition may use, and why they are not `opsForType`.
283
- *
284
- * A measure is answered by a SQL `HAVING`, and `harness/measure_filter.OPS` has exactly six
285
- * comparisons. `between` would need two, and `isEmpty`/`isNotEmpty` have no meaning for a sum
286
- * that is defined to be 0 when there is nothing to add up β€” the resolver refuses all three by
287
- * name. Offering them here would produce a condition the server can only reject.
288
- *
289
- * The owner's "dates between X and Y" is the WINDOW's between, not the value's, and that is the
290
- * `custom` window kind β€” so nothing is lost.
291
- */
292
- export const MEASURE_OP_LIST: FilterOp[] = [...MEASURE_OPS];
293
-
294
- /** CG-9 β€” narrower still when the other side is a measure. `=` and `β‰ ` between two sums of
295
- * floats match nobody and everybody respectively; see types.ts MEASURE_PAIR_OPS. */
296
- export const MEASURE_PAIR_OP_LIST: FilterOp[] = [...MEASURE_PAIR_OPS];
297
-
298
- /**
299
- * A cohort condition names a SET of cohorts (owner, 2026-07-27), so its operators are set
300
- * operators. `eq`/`neq` are not offered β€” the validator rewrites them to `anyOf`/`noneOf`, and
301
- * `withCurrent` would otherwise show a legacy view an operator it can never choose again.
302
- */
303
- export const COHORT_OP_LIST: CohortOp[] = [...COHORT_OPS];
304
-
305
- /**
306
- * ⚠ "is part of any of" measures ~96px at 12.5px Inter against the 97.9px of readable width a
307
- * 136px select actually has (136 - 20.1 arrow - 16 padding - 2 border) β€” inside the box only
308
- * until a fallback font renders it. Airtable's own wording for a multi-select is shorter and
309
- * says the same thing, so the LABEL gives way, not the control. Third time this rule has been
310
- * applied; see the "(not filterable)" and "the 75th percentile" clippings before it.
311
- */
312
- export function cohortOpLabel(op: FilterOp | CohortOp): string {
313
- switch (normalizeCohortOp(op)) {
314
- case "allOf":
315
- return "is all of";
316
- case "noneOf":
317
- return "is none of";
318
- default:
319
- return "is any of";
320
- }
321
- }
322
-
323
- /** Item 20 β€” the mark a MEASURE row wears in every picker on this surface. Named once so the
324
- * three lists that offer measures cannot drift into two different glyphs for one concept. */
325
- export const MEASURE_MARK: FieldSelectType = "measure";
326
-
327
- /** A measure condition is identified by its window, so a new one needs a default window. */
328
- export const DEFAULT_WINDOW: WindowSpec = { kind: "ltm" };
329
-
330
- /** The default RANGE for `is within` β€” the owner's first example ("the past month"). */
331
- export const DEFAULT_DATE_WINDOW: WindowSpec = { kind: "past_month" };
332
-
333
- /**
334
- * The ranges `is within` offers, in the owner's own order (item 3, 2026-07-26):
335
- * the past number of days Β· the past week Β· the past month Β· the past year Β· this calendar
336
- * week Β· this calendar month Β· this calendar year.
337
- *
338
- * Plus `custom`, which is not a nicety: dropping `between` from the date operators would
339
- * otherwise remove the ability to ask for two exact dates at all. The remaining kinds
340
- * (yesterday, last_quarter, ytd_last_year, the forward-looking ones…) stay available to a
341
- * MEASURE's window, where they make sense; "last order is within year to date, last year" is a
342
- * question nobody asks.
343
- */
344
- export const WITHIN_KINDS: WindowKind[] = [
345
- "last_n_days", "past_week", "past_month", "past_year",
346
- "this_week", "this_month", "this_year", "custom",
347
- ];
348
-
349
- /**
350
- * The field dropdown's COLUMN half, plus the column the rule is actually set to if the picker
351
- * no longer offers it. See `withCurrent` β€” a select whose value is missing from its options
352
- * silently displays a different field than the rule holds. This is not hypothetical: the
353
- * shipped "Win-back" list filters `at_risk`, which left the filter list the day its measure
354
- * replacement arrived.
355
- */
356
- export function withCurrentField(
357
- fields: Field[],
358
- current: string,
359
- fieldByKey: Map<string, Field>,
360
- measureByKey: Map<string, Measure>,
361
- hasCohorts: boolean
362
- ): FieldSelectItem[] {
363
- // Item 20: the rows carry their TYPE now, because the picker paints the type mark. Same
364
- // list, same order, same `withCurrent` rule β€” only the shape of a row grew a field.
365
- const out: FieldSelectItem[] = fields.map((f) => ({
366
- key: f.key, label: f.label, type: f.type,
367
- }));
368
- const known = new Set(out.map((o) => o.key));
369
- if (!current || known.has(current) || measureByKey.has(current)) return out;
370
- if (current === COHORT_FIELD && hasCohorts) return out;
371
- // The field's OWN label, with no "(not filterable)" annotation: the select is ~170px and the
372
- // suffix CLIPPED to "At risk $ (not filter" β€” a truncated parenthetical reads as a rendering
373
- // bug, and the property that matters is that the condition says what it says. Found by
374
- // reading the screenshot; every assertion in the run was green.
375
- const f = fieldByKey.get(current);
376
- out.push({ key: current, label: f ? f.label : current, type: f?.type });
377
- return out;
378
- }
379
-
380
- /** Ids only have to be unique within one view's tree; they never leave this browser's view. */
381
- let ruleSeq = 0;
382
- export function newRuleId(): string {
383
- ruleSeq += 1;
384
- return `r${Date.now().toString(36)}${ruleSeq.toString(36)}`;
385
- }
386
-
387
- /** A fresh leaf condition on the first available field. */
388
- export function newCondition(fields: Field[]): FilterRule | null {
389
- const f0 = fields[0];
390
- if (!f0) return null;
391
- return { colId: f0.key, op: opsForType(f0.type)[0], value: "" };
392
- }
393
-
394
- /** Total LEAF conditions in a filter tree (groups contribute their contents). */
395
- export function countConditions(nodes: FilterNode[]): number {
396
- let n = 0;
397
- for (const node of nodes)
398
- n += isFilterGroup(node) ? countConditions(node.children) : 1;
399
- return n;
400
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // filter-kit / ops.ts
3
+ // The filter/sort operator VOCABULARY β€” which operators each field type offers,
4
+ // and how each one reads to a human.
5
+ //
6
+ // Extracted verbatim from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so
7
+ // the permission editor and the grid toolbar offer ONE vocabulary rather than two
8
+ // that drift. The matching engine (matchFilter/makeComparator in useVisibleRows)
9
+ // and its Python mirrors (filter_sql.py, filter_eval.py) are the other halves of
10
+ // the same contract; these are only the labels the popovers render.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import type {
14
+ Conjunction,
15
+ Field,
16
+ FieldType,
17
+ FilterNode,
18
+ FilterOp,
19
+ FilterRule,
20
+ Measure,
21
+ } from "../customer-grid/types";
22
+ import {
23
+ COHORT_FIELD, COHORT_OPS, MEASURE_OPS, MEASURE_PAIR_OPS, isFilterGroup,
24
+ isDateFamilyType, isNumericFieldType, isRankOp, normalizeCohortOp,
25
+ } from "../customer-grid/types";
26
+ import type { CohortOp } from "../customer-grid/types";
27
+ import type { FieldSelectItem, FieldSelectType } from "../customer-grid/FieldSelect";
28
+ import type { WindowKind, WindowSpec } from "../customer-grid/windows";
29
+
30
+ // The type families live in types.ts (ONE definition for every UI surface); the engine keeps
31
+ // its own deliberately self-contained copies β€” see isNumericFieldType's doc.
32
+ export const isNumericType = isNumericFieldType;
33
+ export const isDateFamily = isDateFamilyType;
34
+
35
+ /**
36
+ * Operators offered for a field type (first = the default for new conditions).
37
+ * Mirrors Airtable's own per-type sets, verified against the live product
38
+ * (2026-07-25): a text field offers exactly contains / does not contain / is /
39
+ * is not / is empty / is not empty. Every type ends with the two value-free ops.
40
+ *
41
+ * NOTE on isEmpty over the Customer List contract: several Odoo-derived money
42
+ * columns (revenue_ytd, revenue_ly, at_risk, ltm_rev, orders_24m, est_missed) are
43
+ * built with 0.0/0 defaults in customer_list.pool(), so "is empty" correctly
44
+ * matches NOTHING there β€” 0 is a real value, not a blank. The columns where it
45
+ * genuinely bites are last_order (''), days_since, typical_gap_days, yoy_pct, and
46
+ * every overlay field (notes / custom) β€” "customers with no notes yet" is the
47
+ * common case. Offered on all types anyway, exactly as Airtable does.
48
+ *
49
+ * ⚠ AND the SENTINEL columns, which are a third case and the one that surprises:
50
+ * agent Β· city Β· state Β· country Β· zip Β· payment_terms Β· pricelist Β· tags all
51
+ * collapse a blank to the STRING '(none)' in `_partner_attrs`, so grouping has no
52
+ * null bucket. `isEmpty` tests `value === ""`, so on these it matches nothing even
53
+ * though 76 customers have no country and 79 no zip β€” the blanks are found with
54
+ * `is` `(none)`, which is also what the cell visibly says. Each of those fields
55
+ * carries that sentence as its column `note`, because a source comment is not
56
+ * where the person filtering is looking. DATE attributes keep '' instead
57
+ * (customer_since, last_order): '(none)' in a date column would sort and compare
58
+ * as text against ISO dates.
59
+ */
60
+ export function opsForType(t: FieldType): FilterOp[] {
61
+ // Wave-5: a checkbox stores '1' or blank, so "is checked" IS `isNotEmpty` and "is
62
+ // unchecked" IS `isEmpty` β€” the honest mapping onto the existing vocabulary (no new
63
+ // FilterOp, the filter_sql lock-step untouched). Both are value-free, so no value control
64
+ // renders. First = the default: "is checked".
65
+ if (t === "checkbox") return ["isNotEmpty", "isEmpty"];
66
+ if (isNumericType(t))
67
+ return ["gte", "gt", "lte", "lt", "eq", "neq", "between", "isEmpty", "isNotEmpty"];
68
+ if (isDateFamily(t))
69
+ // Owner item 3, in the order they asked for it. `between` is GONE from the offer β€” "is
70
+ // within" plus a custom range says the same thing and reads better β€” but it stays in the
71
+ // ENGINE, because saved views hold it. `legacyOps` below is what keeps such a view
72
+ // rendering its own operator instead of silently showing the first one in this list.
73
+ return ["eq", "within", "lt", "gt", "lte", "gte", "neq", "isEmpty", "isNotEmpty"];
74
+ // `select` and `user` are picked from a fixed list, so they behave like `status`: is / is
75
+ // not / empty. `contains` on a chosen value is a substring test against a closed vocabulary β€”
76
+ // it would find "Done" inside "Not done" and read as a bug.
77
+ if (t === "status" || t === "select" || t === "user")
78
+ return ["eq", "neq", "isEmpty", "isNotEmpty"];
79
+ // A `multiselect` cell is a comma-joined SET, so the useful question is membership β€” "has" β€”
80
+ // which is the EXISTING `contains` op over the joined string (no new engine vocabulary; the
81
+ // ops are mirrored in filter_sql.py and inventing one here would break the lock-step). The
82
+ // value is still picked from the declared options, so the substring caveat above is bounded
83
+ // by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
84
+ if (t === "multiselect")
85
+ return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
86
+ // ⭐ Wave-23 C7 β€” `json` gets EXACTLY the two value-free ops, and the omissions are the
87
+ // decision. `contains` over a serialized document is a substring test against punctuation and
88
+ // key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
89
+ // fragment of 5120, and every one of those reads as a working filter. `eq` is worse β€” two
90
+ // documents that mean the same thing differ by key order and whitespace, so "is" would answer
91
+ // false for a record that plainly matches. "Has anything been captured here yet" is the
92
+ // question a json column can answer soundly, and it is the one people actually ask.
93
+ //
94
+ // ⚠ NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
95
+ // `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
96
+ // are TYPE-INDEPENDENT β€” they test blankness before any type dispatch β€” so the lock-step is
97
+ // untouched and nothing on the server had to move for this line. (The contract's prose spells
98
+ // them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
99
+ if (t === "json") return ["isNotEmpty", "isEmpty"];
100
+ // Wave-18 C5-AUTOFIELD β€” `automation` lands HERE, on the text ops, and that is the whole
101
+ // v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
102
+ // `contains ok` and `contains error` are the two questions anyone actually asks of the
103
+ // column, and both are already sound over a string. Nothing new enters the operator
104
+ // vocabulary, so the `filter_sql.py` lock-step is untouched β€” a new op for "state is" would
105
+ // have to be mirrored in the Python engine, and the substring answer is the same answer.
106
+ return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"]; // text
107
+ }
108
+
109
+ /**
110
+ * C-OPS β€” the RANK operators, worded so the row still reads as a sentence.
111
+ *
112
+ * The unit lives in the OPERATOR, not beside the box: "top" followed by a bare 10 is ambiguous
113
+ * between ten records and ten percent, so `top N` and `top N%` are two operators and the box
114
+ * supplies N. The alternative β€” a "%" suffix element after the input β€” would put a second
115
+ * thing in the value slot that every gate locating `.cg-cond-val` would have to know about.
116
+ *
117
+ * ⚠ AND THEY ARE SHORT ON PURPOSE. A 136px `.cg-select` has ~97.9px of readable width (see
118
+ * `cohortOpLabel` below, and `withCurrentField` below) β€” about 15 characters of 12.5px Inter.
119
+ * "is in the bottom N%" is 19 and would have shipped CLIPPED, which no assertion here can see
120
+ * and no screenshot in this session could catch. Dropping the "is " still costs nothing, and
121
+ * instruction 19 SHARPENED that argument rather than refuting it: the four numeric comparisons
122
+ * beside these were bare glyphs (`β‰₯ > ≀ <`) and are now prefix-free WORDS ("more than",
123
+ * "less than or equal to"), so the row still reads without an "is " β€” and those words are
124
+ * longer than the glyphs they replaced, so this control has LESS spare room than when the
125
+ * width above was measured, not more. (Only those four are prefix-free; `eq` / `neq` /
126
+ * `between` still read "is" / "is not" / "is between", and the rank ops sit beside all of
127
+ * them.) The `Ranked` divider carries the "these are a different kind of question" signal.
128
+ * Fifth time this control has outgrown a label; the label gives way, never the control.
129
+ */
130
+ export const RANK_OP_LABELS: Record<string, string> = {
131
+ topN: "top N",
132
+ bottomN: "bottom N",
133
+ inTopPct: "top N%",
134
+ inBottomPct: "bottom N%",
135
+ aboveAvg: "above average",
136
+ belowAvg: "below average",
137
+ inQuartile: "in quartile",
138
+ inDecile: "in decile",
139
+ };
140
+
141
+ /**
142
+ * The value a rank op starts at when it is chosen.
143
+ *
144
+ * Seeded rather than left blank, and this is a deliberate break from how the other operators
145
+ * behave: a blank value makes a rule INACTIVE, so `is in the top N` with nothing typed would
146
+ * sit in the builder narrowing nothing, and "the filter I just added does nothing" reads as a
147
+ * broken control rather than as an unfinished sentence. Every rank op has an obvious default β€”
148
+ * ten, or the top slice β€” so there is a right answer to seed. The two averages take none.
149
+ */
150
+ export function rankDefault(op: FilterOp): string {
151
+ switch (op) {
152
+ case "topN":
153
+ case "bottomN":
154
+ case "inTopPct":
155
+ case "inBottomPct":
156
+ case "inDecile":
157
+ return "10";
158
+ case "inQuartile":
159
+ return "4";
160
+ default:
161
+ return ""; // aboveAvg / belowAvg β€” value-free
162
+ }
163
+ }
164
+
165
+ /* β›” `hasRankCondition` LIVED HERE AND IS DELETED WITH THE PARAGRAPH IT GATED (wave 34, R5).
166
+ Its whole job was to decide whether to draw the builder's rank explainer; the owner has
167
+ removed that paragraph, and the predicate had exactly one caller besides its own recursion.
168
+ ⚠ `isRankOp` stays: `opLabel` below still calls it. `isMeasureRule` was `hasRankCondition`'s
169
+ ONLY use in this module, so THIS FILE'S IMPORT of it went too β€” the symbol itself lives on in
170
+ `types.ts` with plenty of callers, including `FilterBuilderPanel` two files over. The
171
+ distinction matters: an unused IMPORT is dead weight here, an unused EXPORT would be dead code
172
+ there, and only one of those was true.
173
+ ⚠⚠ THIS COMMENT'S FIRST DRAFT SAID BOTH SYMBOLS "STAY, they have many callers" β€” and `tsc`
174
+ refuted it in the next run. That is the THIRD time in one session a confident claim about who
175
+ calls a symbol was wrong within a minute of writing it. The habit worth keeping is not "be more
176
+ careful"; it is that the compiler answers this question for free, so ask it before asserting. */
177
+
178
+ /**
179
+ * Human label for an operator, read naturally per field type.
180
+ *
181
+ * ⭐ Instruction 19 (wave 40) β€” a numeric comparison reads as WORDS, never as a symbol, because
182
+ * "non-mathematical people get them confused". C5 makes this the ONE operator-label map, so the
183
+ * grid filter builder, the permission filter builder, view filters and measure conditions all
184
+ * changed together and no surface hand-writes an operator string of its own. (A measure
185
+ * condition needs no separate map: `FilterBuilderPanel` resolves its type from the measure
186
+ * itself and falls back to `currency`, and `MEASURE_OPS` offers exactly the six comparisons
187
+ * below, so a measure row reads its label off this same numeric branch.)
188
+ *
189
+ * ⚠ NO leading "is " on the four comparisons, unlike `automation/CondBuilder.tsx::OP_WORDS`
190
+ * whose voice otherwise sets the house standard here. These labels already outrun the control
191
+ * that renders them; W40-T30 widens `.cg-select` in `index.css` for "less than or equal to"
192
+ * exactly, and an "is " prefix would outrun the wider control too. This file does not own that
193
+ * width, and it must not buy itself slack by abbreviating: "at least" / "at most" are shorter
194
+ * and are precisely the shorthand instruction 19 exists to remove.
195
+ *
196
+ * `gte` is "more than or equal to", not "greater than or equal to", so one axis carries one
197
+ * vocabulary. "more than" and "less than" are the owner's own two examples; a set learned once
198
+ * beats two synonyms for the same direction, which is the whole point of the instruction.
199
+ */
200
+ export function opLabel(op: FilterOp, t: FieldType): string {
201
+ // Rank first and type-independently: a rank op means the same thing on every numeric
202
+ // family, and a view whose column has since changed type must still SAY what it holds
203
+ // rather than falling through to a raw key.
204
+ if (isRankOp(op)) return RANK_OP_LABELS[op] ?? op;
205
+ // Checkbox first: its two value-free ops READ as states, not as blankness β€” the storage
206
+ // contract ('1' or blank) is an implementation detail the sentence must not leak.
207
+ if (t === "checkbox") {
208
+ if (op === "isNotEmpty") return "is checked";
209
+ if (op === "isEmpty") return "is unchecked";
210
+ }
211
+ // Type-independent: Airtable words these identically for every field type.
212
+ if (op === "isEmpty") return "is empty";
213
+ if (op === "isNotEmpty") return "is not empty";
214
+ if (isDateFamily(t)) {
215
+ switch (op) {
216
+ case "eq":
217
+ return "is";
218
+ case "neq":
219
+ return "is not";
220
+ case "within":
221
+ return "is within";
222
+ case "lt":
223
+ return "is before";
224
+ case "lte":
225
+ return "is on or before";
226
+ case "gt":
227
+ return "is after";
228
+ case "gte":
229
+ return "is on or after";
230
+ case "between":
231
+ return "is between"; // no longer offered; still rendered for a saved view
232
+ }
233
+ }
234
+ if (isNumericType(t)) {
235
+ switch (op) {
236
+ case "gte":
237
+ return "more than or equal to";
238
+ case "gt":
239
+ return "more than";
240
+ case "lte":
241
+ return "less than or equal to";
242
+ case "lt":
243
+ return "less than";
244
+ case "eq":
245
+ return "is";
246
+ case "neq":
247
+ return "is not";
248
+ case "between":
249
+ return "is between";
250
+ default:
251
+ return op;
252
+ }
253
+ }
254
+ if (t === "multiselect") {
255
+ // Membership wording over the joined-set cell. "has" IS `contains` (see opsForType) β€” only
256
+ // the label changes, so the engine and its Python mirror stay untouched.
257
+ switch (op) {
258
+ case "contains":
259
+ return "has";
260
+ case "doesNotContain":
261
+ return "does not have";
262
+ case "eq":
263
+ return "is exactly";
264
+ case "neq":
265
+ return "is not exactly";
266
+ default:
267
+ return op;
268
+ }
269
+ }
270
+ // text / status. (Airtable suffixes value-taking ops with "…" in the OPEN
271
+ // dropdown only; a native <select> shows one string in both states, so we use
272
+ // the closed-state wording β€” which is what's on screen almost all the time.)
273
+ switch (op) {
274
+ case "contains":
275
+ return "contains";
276
+ case "doesNotContain":
277
+ return "does not contain";
278
+ case "eq":
279
+ return "is";
280
+ case "neq":
281
+ return "is not";
282
+ default:
283
+ return op;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * The offered list, PLUS whatever the rule is actually set to.
289
+ *
290
+ * A `<select>` whose `value` is not among its `<option>`s renders the FIRST option instead β€”
291
+ * so a saved view holding a dropped operator (a date `between`) or a column that has since left
292
+ * the filter list (`at_risk`, once its measure replacement shipped) would display something the
293
+ * rule does not say, and the first edit would silently rewrite it to that. Painted, plausible,
294
+ * and wrong: the class of failure a screenshot catches and an assertion does not.
295
+ */
296
+ export function withCurrent<T extends string>(offered: readonly T[], current: T | undefined): T[] {
297
+ return current && !offered.includes(current) ? [...offered, current] : [...offered];
298
+ }
299
+
300
+ /** A group's summary line, mirroring Airtable's wording. */
301
+ export function groupSummary(conj: Conjunction): string {
302
+ return conj === "or"
303
+ ? "Any of the following are true…"
304
+ : "All of the following are true…";
305
+ }
306
+
307
+ /**
308
+ * CG-8 β€” the operators a MEASURE condition may use, and why they are not `opsForType`.
309
+ *
310
+ * A measure is answered by a SQL `HAVING`, and `harness/measure_filter.OPS` has exactly six
311
+ * comparisons. `between` would need two, and `isEmpty`/`isNotEmpty` have no meaning for a sum
312
+ * that is defined to be 0 when there is nothing to add up β€” the resolver refuses all three by
313
+ * name. Offering them here would produce a condition the server can only reject.
314
+ *
315
+ * The owner's "dates between X and Y" is the WINDOW's between, not the value's, and that is the
316
+ * `custom` window kind β€” so nothing is lost.
317
+ */
318
+ export const MEASURE_OP_LIST: FilterOp[] = [...MEASURE_OPS];
319
+
320
+ /** CG-9 β€” narrower still when the other side is a measure. `=` and `β‰ ` between two sums of
321
+ * floats match nobody and everybody respectively; see types.ts MEASURE_PAIR_OPS. */
322
+ export const MEASURE_PAIR_OP_LIST: FilterOp[] = [...MEASURE_PAIR_OPS];
323
+
324
+ /**
325
+ * A cohort condition names a SET of cohorts (owner, 2026-07-27), so its operators are set
326
+ * operators. `eq`/`neq` are not offered β€” the validator rewrites them to `anyOf`/`noneOf`, and
327
+ * `withCurrent` would otherwise show a legacy view an operator it can never choose again.
328
+ */
329
+ export const COHORT_OP_LIST: CohortOp[] = [...COHORT_OPS];
330
+
331
+ /**
332
+ * ⚠ "is part of any of" measures ~96px at 12.5px Inter against the 97.9px of readable width a
333
+ * 136px select actually has (136 - 20.1 arrow - 16 padding - 2 border) β€” inside the box only
334
+ * until a fallback font renders it. Airtable's own wording for a multi-select is shorter and
335
+ * says the same thing, so the LABEL gives way, not the control. Third time this rule has been
336
+ * applied; see the "(not filterable)" and "the 75th percentile" clippings before it.
337
+ */
338
+ export function cohortOpLabel(op: FilterOp | CohortOp): string {
339
+ switch (normalizeCohortOp(op)) {
340
+ case "allOf":
341
+ return "is all of";
342
+ case "noneOf":
343
+ return "is none of";
344
+ default:
345
+ return "is any of";
346
+ }
347
+ }
348
+
349
+ /** Item 20 β€” the mark a MEASURE row wears in every picker on this surface. Named once so the
350
+ * three lists that offer measures cannot drift into two different glyphs for one concept. */
351
+ export const MEASURE_MARK: FieldSelectType = "measure";
352
+
353
+ /** A measure condition is identified by its window, so a new one needs a default window. */
354
+ export const DEFAULT_WINDOW: WindowSpec = { kind: "ltm" };
355
+
356
+ /** The default RANGE for `is within` β€” the owner's first example ("the past month"). */
357
+ export const DEFAULT_DATE_WINDOW: WindowSpec = { kind: "past_month" };
358
+
359
+ /**
360
+ * The ranges `is within` offers, in the owner's own order (item 3, 2026-07-26):
361
+ * the past number of days Β· the past week Β· the past month Β· the past year Β· this calendar
362
+ * week Β· this calendar month Β· this calendar year.
363
+ *
364
+ * Plus `custom`, which is not a nicety: dropping `between` from the date operators would
365
+ * otherwise remove the ability to ask for two exact dates at all. The remaining kinds
366
+ * (yesterday, last_quarter, ytd_last_year, the forward-looking ones…) stay available to a
367
+ * MEASURE's window, where they make sense; "last order is within year to date, last year" is a
368
+ * question nobody asks.
369
+ */
370
+ export const WITHIN_KINDS: WindowKind[] = [
371
+ "last_n_days", "past_week", "past_month", "past_year",
372
+ "this_week", "this_month", "this_year", "custom",
373
+ ];
374
+
375
+ /**
376
+ * The field dropdown's COLUMN half, plus the column the rule is actually set to if the picker
377
+ * no longer offers it. See `withCurrent` β€” a select whose value is missing from its options
378
+ * silently displays a different field than the rule holds. This is not hypothetical: the
379
+ * shipped "Win-back" list filters `at_risk`, which left the filter list the day its measure
380
+ * replacement arrived.
381
+ */
382
+ export function withCurrentField(
383
+ fields: Field[],
384
+ current: string,
385
+ fieldByKey: Map<string, Field>,
386
+ measureByKey: Map<string, Measure>,
387
+ hasCohorts: boolean
388
+ ): FieldSelectItem[] {
389
+ // Item 20: the rows carry their TYPE now, because the picker paints the type mark. Same
390
+ // list, same order, same `withCurrent` rule β€” only the shape of a row grew a field.
391
+ const out: FieldSelectItem[] = fields.map((f) => ({
392
+ key: f.key, label: f.label, type: f.type,
393
+ }));
394
+ const known = new Set(out.map((o) => o.key));
395
+ if (!current || known.has(current) || measureByKey.has(current)) return out;
396
+ if (current === COHORT_FIELD && hasCohorts) return out;
397
+ // The field's OWN label, with no "(not filterable)" annotation: the select is ~170px and the
398
+ // suffix CLIPPED to "At risk $ (not filter" β€” a truncated parenthetical reads as a rendering
399
+ // bug, and the property that matters is that the condition says what it says. Found by
400
+ // reading the screenshot; every assertion in the run was green.
401
+ const f = fieldByKey.get(current);
402
+ out.push({ key: current, label: f ? f.label : current, type: f?.type });
403
+ return out;
404
+ }
405
+
406
+ /** Ids only have to be unique within one view's tree; they never leave this browser's view. */
407
+ let ruleSeq = 0;
408
+ export function newRuleId(): string {
409
+ ruleSeq += 1;
410
+ return `r${Date.now().toString(36)}${ruleSeq.toString(36)}`;
411
+ }
412
+
413
+ /** A fresh leaf condition on the first available field. */
414
+ export function newCondition(fields: Field[]): FilterRule | null {
415
+ const f0 = fields[0];
416
+ if (!f0) return null;
417
+ return { colId: f0.key, op: opsForType(f0.type)[0], value: "" };
418
+ }
419
+
420
+ /** Total LEAF conditions in a filter tree (groups contribute their contents). */
421
+ export function countConditions(nodes: FilterNode[]): number {
422
+ let n = 0;
423
+ for (const node of nodes)
424
+ n += isFilterGroup(node) ? countConditions(node.children) : 1;
425
+ return n;
426
+ }
web/src/index.css CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/settings/DatabasePermsPane.tsx ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // settings / DatabasePermsPane.tsx β€” ONE DATABASE'S RULE, given the whole pane.
3
+ // (wave 40, owner instruction 7 Β· W40-T14.)
4
+ //
5
+ // ⭐ WHY IT EXISTS, IN THE OWNER'S OWN WORDS: *"Manage user, Access section: show
6
+ // only the Database, with just a checkbox and an Edit button (put the Metrics
7
+ // field toggle under there). Edit opens the whole database's Fields and Filters
8
+ // to toggle, with a Back button to return."*
9
+ //
10
+ // ⚠ THE PARENTHESIS IN THAT QUOTE IS SUPERSEDED, AND ONLY THE PARENTHESIS. Owner instruction 13
11
+ // (W40-T16) folded the Metrics toggle one level further in: it is no longer a box "under there"
12
+ // but one CHECKBOX PER METRIC inside this pane's Hide-fields list. The room, the Back button and
13
+ // the Fields/Filters pairing are instruction 7's and are unchanged. Read the quote as the reason
14
+ // this file exists, never as a description of the controls it draws today.
15
+ //
16
+ // What it replaces is a single-row INLINE ACCORDION. That shape was right when
17
+ // the list was two databases and the panels were the only thing under them; it
18
+ // is wrong now, because a filter builder and a field list opened inside a row of
19
+ // a list of every database the tenant has are two menus that failed to close.
20
+ // A rule about one database gets a room, and the room says which database.
21
+ //
22
+ // β›” IT OWNS NO STATE AND WRITES NOTHING. Every value comes from the caller's
23
+ // DRAFT and every change goes back out as a callback, exactly as
24
+ // `ModulePermsList` does β€” `PermsEditor` holds the draft, the confirm and the
25
+ // PUT. This component is a layout and a set of handlers, so the account page
26
+ // keeps one source of truth for what is about to be saved.
27
+ //
28
+ // β›”β›” THE `lockedKey` / `hideableKeys` PAIR BELOW IS LOAD-BEARING AND ITS
29
+ // FAILURE IS SILENT. Drop either and one click on "Hide all" hides the database's
30
+ // own identity column: a record whose faithful enforcement is a table of blank
31
+ // rows, which the server's PUT validation ACCEPTS because the identity column is
32
+ // a perfectly known field key. Both halves ship together or the pane is a defect.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit";
36
+ import type { FilterTree } from "../customer-grid/types";
37
+ import type { PermsModule, PermsRecord } from "./permsModel";
38
+ import { filterOf, hiddenSetFor, hideAllKeys, identityKey, showAllKeys } from "./permsModel";
39
+ import "./perms.css";
40
+
41
+ export interface DatabasePermsPaneProps {
42
+ /** The database being edited: its label titles the pane, its fields feed both panels. */
43
+ module: PermsModule;
44
+ /**
45
+ * ⚠ THE WHOLE DRAFT, keyed by module, NOT this module's entry. `filterOf` and `hiddenSetFor`
46
+ * are where "an absent filter is an empty tree" and "absent hidden fields is an empty set" are
47
+ * spelled, once; taking a bare entry would make this file spell them a second time, and two
48
+ * spellings of one default are what those helpers exist to prevent.
49
+ */
50
+ entries: PermsRecord;
51
+ /** Return to the account page. The SAME function the account page's Escape handler calls. */
52
+ onBack: () => void;
53
+ onFilter: (next: FilterTree | null) => void;
54
+ onToggleHidden: (fieldKey: string) => void;
55
+ onSetHidden: (keys: string[]) => void;
56
+ /*
57
+ * ⭐⭐ W40-T16 (OWNER INSTRUCTION 13) β€” `onMetrics` IS GONE FROM THIS PANE, AND SO IS THE BOX
58
+ * IT DREW. Owner, verbatim: *"Fold 'Metrics fields (lookback measures over this database)' into
59
+ * the 'Hide fields' checkboxes, one row per metric, named 'Metric - Revenue', 'Metric - Order'
60
+ * and so on, so a user can check the ones the permissioning is limited to."*
61
+ *
62
+ * β›” A DELETION RATHER THAN A HIDE, because the control it replaces answered a COARSER
63
+ * question. One box said "this account may build measures over this database, or may not";
64
+ * the Hide-fields list below now carries one checkbox per bound measure, so the same admin
65
+ * decides WHICH ones. Leaving the blanket box beside them would put two controls on one screen
66
+ * that can contradict each other, and the record has no way to express the contradiction.
67
+ *
68
+ * ⚠ THE CAPABILITY ITSELF IS UNTOUCHED. `PermsEntry.metrics` still rides the wire, still parses
69
+ * (`parseEntry`) and is still emitted (`toPutBody`), because a record written before this
70
+ * ticket may carry an explicit revocation. What honours it here is `hiddenSetFor` below, on the
71
+ * READ side: a blanket `metrics: false` paints every metric row as hidden without rewriting
72
+ * anybody's stored rule. See the note at that call.
73
+ */
74
+ /** People this tenant can name in a `user` condition. Absent β‡’ the panel says so. */
75
+ userOptions?: string[];
76
+ }
77
+
78
+ export function DatabasePermsPane({
79
+ module,
80
+ entries,
81
+ onBack,
82
+ onFilter,
83
+ onToggleHidden,
84
+ onSetHidden,
85
+ userOptions,
86
+ }: DatabasePermsPaneProps) {
87
+ return (
88
+ <div className="set-dbperm">
89
+ <div className="set-dbperm-head">
90
+ {/* β›” ONE WORD, AND THE SHORTNESS IS FORCED RATHER THAN CHOSEN. The account's own exit,
91
+ "Back to accounts", is rendered by `PermsEditor` DIRECTLY ABOVE this pane and stays
92
+ there while the room is open. Spelling this one "Back to account" would put two
93
+ controls one letter apart on one screen, going to different places, which is a worse
94
+ room than either label is a good one. Position already says where this one goes: it
95
+ sits on the database title's line, so it leaves the database. `DESIGN.md` Β§4 asks for
96
+ the shorter label wherever the longer one is narration, and here it also happens to
97
+ be the only unambiguous one. */}
98
+ <button type="button" className="set-secondary set-dbperm-back" onClick={onBack}>
99
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden focusable="false">
100
+ <path
101
+ d="M9.5 4 5.5 8l4 4"
102
+ stroke="currentColor"
103
+ strokeWidth={1.3}
104
+ strokeLinecap="round"
105
+ strokeLinejoin="round"
106
+ />
107
+ </svg>
108
+ Back
109
+ </button>
110
+ {/* The pane says which database it is about. Without it every one of these rooms looks
111
+ identical, and the reader's only clue is what they clicked a scroll ago. */}
112
+ <h4 className="set-h4 set-dbperm-title">{module.label}</h4>
113
+ </div>
114
+
115
+ {/* THE KIT'S OWN PANELS (contract C-KIT), not lookalikes: the same two the grid toolbar
116
+ mounts, so an admin writes a condition with the control they already know and there is
117
+ one condition grammar in the product rather than two that drift within a wave.
118
+ They STACK here rather than sitting side by side: the pane is theirs alone now, and a
119
+ filter and a field list read as two decisions taken in order, not one wide row. */}
120
+ <div className="set-dbperm-panels">
121
+ <div className="cg-pop set-perm-pop">
122
+ <FilterBuilderPanel
123
+ fields={module.fields}
124
+ filters={filterOf(entries, module.key)}
125
+ onChange={onFilter}
126
+ userOptions={userOptions}
127
+ />
128
+ </div>
129
+ <div className="cg-pop set-perm-pop">
130
+ {/* ⭐⭐ W40-T16 β€” THIS LIST IS WHERE THE METRICS RULE NOW LIVES. `module.fields` carries
131
+ C3's `measure_`-namespaced pseudo-fields, `permsModel::parseField` marks each one,
132
+ and the panel draws it as an ordinary checkbox named "Metric - Revenue". Checking it
133
+ puts that field's key into `hiddenFields` through the same `onToggle` every other
134
+ row uses, so there is no second write path for a metric and no second record shape.
135
+ β›” `hiddenSetFor`, NOT `hiddenSet`, AND THE DIFFERENCE IS THE MIGRATION. A record
136
+ written before this ticket can carry a blanket `metrics: false` (W38-T19's box), and
137
+ the control that wrote it is gone from this pane. The union paints every metric row
138
+ as hidden for such a record, so the old revocation is HONOURED on screen rather than
139
+ silently reading as a grant. It is a read-side union on purpose: folding it into
140
+ `hiddenFields` would edit somebody's stored rule as a side effect of opening a page. */}
141
+ <FieldsHidePanel
142
+ fields={module.fields}
143
+ hidden={hiddenSetFor(entries, module.key, module.fields)}
144
+ onToggle={onToggleHidden}
145
+ /* ⭐⭐ W40-T17 (OWNER INSTRUCTION 15) β€” THE ONE PROP THAT MAKES THIS MOUNT DIFFER
146
+ FROM THE GRID'S. Owner, verbatim: *"stop displaying 'Shared with me' / 'Shared
147
+ with everyone' fields under Hide Fields - permission on pre-set Fields only."*
148
+ `false` drops both sections AND swaps the membership rule for AM-2's
149
+ `metric || !custom`, so this list is the database's own columns plus its bound
150
+ measures and nothing else.
151
+ β›” IT IS PASSED HERE AND NOWHERE ELSE. `customer-grid/Toolbar.tsx` mounts the same
152
+ panel and passes no `sharedSections`, so the grid keeps all three sections exactly
153
+ as the 2026-08-21 hotfix left them. That is the whole reason this is a prop rather
154
+ than a deletion: the two surfaces are supposed to differ now. */
155
+ sharedSections={false}
156
+ // β›”β›” BOTH HALVES OF THE TRAP, and neither is decoration. `lockedKey` disables the
157
+ // identity column's own checkbox; `hideableKeys` is what keeps "Hide all" from
158
+ // including it anyway. One without the other still ships blank rows.
159
+ lockedKey={identityKey(module.fields)}
160
+ /* ⭐⭐ W40-T17 β€” THE BULK ACTIONS REACH EXACTLY WHAT THIS ROOM LISTS, and the
161
+ narrowing above is what owes them the change: a list showing four rows whose
162
+ "Show all" clears hidden state for seven fields would WIDEN a permission through
163
+ a control that never displayed the columns it just revealed. `showAllKeys`
164
+ subtracts the governed keys instead of emptying the record; `hideAllKeys` adds
165
+ them without disturbing what the record hides elsewhere. Both strip the identity
166
+ key, so the trap above stays closed from this side too. */
167
+ onHideAll={() => onSetHidden(hideAllKeys(entries, module.key, module.fields))}
168
+ onShowAll={() => onSetHidden(showAllKeys(entries, module.key, module.fields))}
169
+ />
170
+ </div>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
web/src/settings/ModulePermsList.tsx CHANGED
@@ -30,7 +30,8 @@ import type { ReactNode } from "react";
30
  import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit";
31
  import type { FilterTree } from "../customer-grid/types";
32
  import type { PermsModule, PermsRecord } from "./permsModel";
33
- import { filterOf, hiddenSet, hideableKeys, identityKey, moduleSummary } from "./permsModel";
 
34
  import "./perms.css";
35
 
36
  export interface ModulePermsListProps {
@@ -54,10 +55,32 @@ export interface ModulePermsListProps {
54
  * reds `web_ui` on a build break in a file that ticket may not repair.
55
  */
56
  onMetrics?: (key: string, on: boolean) => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  /** People this tenant can name in a `user` condition. Absent β‡’ the panel says so. */
58
  userOptions?: string[];
59
- /** Anything the CALLER wants in a row's head β€” the account editor's "Copy to…"
60
- * door. Returning null is the normal case and costs the row nothing. */
 
 
 
 
 
 
 
 
 
61
  headExtra?: (m: PermsModule) => ReactNode;
62
  /** Shown when the tenant governs nothing at all. */
63
  emptyNote?: string;
@@ -71,10 +94,15 @@ export function ModulePermsList({
71
  onToggleHidden,
72
  onSetHidden,
73
  onMetrics,
 
74
  userOptions,
75
  headExtra,
76
  emptyNote,
77
  }: ModulePermsListProps) {
 
 
 
 
78
  // ⭐ Owner item 11: *"the database should not show all immediately the detail
79
  // (for filter or hide fields)"*. Single-valued rather than a Set, deliberately
80
  // β€” the complaint was a wall of panels, and an accordion cannot become one by
@@ -127,8 +155,25 @@ export function ModulePermsList({
127
  controls keeps every new one off that rule β€” one flex child in,
128
  one flex child out. */}
129
  <span className="set-perm-headend">
130
- <span className="set-perm-sum">{moduleSummary(entry, schemaless)}</span>
131
- {canDetail ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  <button
133
  type="button"
134
  className="set-secondary set-perm-disclose"
@@ -138,7 +183,7 @@ export function ModulePermsList({
138
  {shown ? "Hide detail" : "Conditions and fields"}
139
  </button>
140
  ) : null}
141
- {headExtra?.(m) ?? null}
142
  </span>
143
  </div>
144
 
@@ -154,7 +199,28 @@ export function ModulePermsList({
154
  `space-between` and lives in `index.css`, which this ticket's fence
155
  does not contain; a new flex child there would need CSS that cannot
156
  be written, so the box takes its own line under the head. */}
157
- {on && !schemaless && onMetrics ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  <label className="set-check">
159
  <input
160
  type="checkbox"
@@ -179,7 +245,10 @@ export function ModulePermsList({
179
  </p>
180
  ) : null}
181
 
182
- {canDetail && shown ? (
 
 
 
183
  <div className="set-perm-panels">
184
  <div className="cg-pop set-perm-pop">
185
  <FilterBuilderPanel
@@ -190,9 +259,15 @@ export function ModulePermsList({
190
  />
191
  </div>
192
  <div className="cg-pop set-perm-pop">
 
 
 
 
 
 
193
  <FieldsHidePanel
194
  fields={m.fields}
195
- hidden={hiddenSet(entries, m.key)}
196
  onToggle={(key) => onToggleHidden(m.key, key)}
197
  // ⚠ `lockedKey` IS NOT OPTIONAL HERE, whatever the prop says.
198
  // Absent, the panel locks nothing and one click on "Hide all"
@@ -201,8 +276,23 @@ export function ModulePermsList({
201
  // validation would accept because the identity column is a
202
  // perfectly KNOWN field key.
203
  lockedKey={identityKey(m.fields)}
204
- onHideAll={() => onSetHidden(m.key, hideableKeys(m.fields))}
205
- onShowAll={() => onSetHidden(m.key, [])}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  />
207
  </div>
208
  </div>
 
30
  import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit";
31
  import type { FilterTree } from "../customer-grid/types";
32
  import type { PermsModule, PermsRecord } from "./permsModel";
33
+ import { filterOf, hiddenSetFor, hideAllKeys, identityKey, moduleSummary,
34
+ showAllKeys } from "./permsModel";
35
  import "./perms.css";
36
 
37
  export interface ModulePermsListProps {
 
55
  * reds `web_ui` on a build break in a file that ticket may not repair.
56
  */
57
  onMetrics?: (key: string, on: boolean) => void;
58
+ /**
59
+ * ⭐⭐ W40-T14 (owner instruction 7) β€” THE DRILL-IN. When supplied, the row is a checkbox and
60
+ * ONE way in: this opens a full-pane editor of that database's Fields and Filters, and the row
61
+ * renders no summary, no accordion, no inline panels, no Metrics box and no `headExtra`. Owner,
62
+ * verbatim: *"show only the Database, with just a checkbox and an Edit button (put the Metrics
63
+ * field toggle under there)"*.
64
+ *
65
+ * β›” OPTIONAL FOR THE SAME FORCED REASON `onMetrics` IS. `manage-agent/ManageAgentPane.tsx`
66
+ * mounts this component and is outside W40-T14's fence; a required prop stops it compiling in a
67
+ * file this ticket may not repair. Absent β‡’ every branch below is the accordion this file has
68
+ * always rendered, byte for byte.
69
+ */
70
+ onEdit?: (key: string) => void;
71
  /** People this tenant can name in a `user` condition. Absent β‡’ the panel says so. */
72
  userOptions?: string[];
73
+ /**
74
+ * Anything the CALLER wants in a row's head. Returning null is the normal case and costs the
75
+ * row nothing.
76
+ *
77
+ * ⚠ NO CALLER SUPPLIES IT TODAY, AND THE PROP IS KEPT ANYWAY. It carried the account editor's
78
+ * per-row "Copy to" door, which owner instruction 7 DELETED (W40-T14) β€” the account-level
79
+ * "Apply this access to…" survives and is the whole copy affordance now. What is left here is
80
+ * a seam, declared because `manage-agent/ManageAgentPane.tsx` mounts this same component from
81
+ * outside that ticket's fence and removing a prop is a breaking change to a file it may not
82
+ * repair. A drilled row ignores it regardless: `onEdit` means "a checkbox and one way in".
83
+ */
84
  headExtra?: (m: PermsModule) => ReactNode;
85
  /** Shown when the tenant governs nothing at all. */
86
  emptyNote?: string;
 
94
  onToggleHidden,
95
  onSetHidden,
96
  onMetrics,
97
+ onEdit,
98
  userOptions,
99
  headExtra,
100
  emptyNote,
101
  }: ModulePermsListProps) {
102
+ // ⭐ W40-T14 β€” WHICH MODE, read ONCE. Every branch below asks this same question, and a second
103
+ // spelling of it (`onEdit !== undefined` here, `!!onEdit` there) is how two of them come to
104
+ // disagree about which room they are in.
105
+ const drilled = onEdit !== undefined;
106
  // ⭐ Owner item 11: *"the database should not show all immediately the detail
107
  // (for filter or hide fields)"*. Single-valued rather than a Set, deliberately
108
  // β€” the complaint was a wall of panels, and an accordion cannot become one by
 
155
  controls keeps every new one off that rule β€” one flex child in,
156
  one flex child out. */}
157
  <span className="set-perm-headend">
158
+ {/* ⭐ W40-T14 β€” the summary, the accordion and `headExtra` are the ACCORDION
159
+ room's furniture. The drill-in row is "a checkbox and one way in", so it
160
+ renders the Edit button in their place and nothing else. */}
161
+ {drilled ? null : (
162
+ <span className="set-perm-sum">{moduleSummary(entry, schemaless)}</span>
163
+ )}
164
+ {canDetail && drilled ? (
165
+ // β›” `canDetail` (= `on && !schemaless`), NOT unconditional: a database nobody
166
+ // may open has no rule to edit, and a schemaless surface has no fields and no
167
+ // filter β€” the help paragraph below already says so for that second case.
168
+ <button
169
+ type="button"
170
+ className="set-secondary set-perm-edit"
171
+ onClick={() => onEdit(m.key)}
172
+ >
173
+ Edit
174
+ </button>
175
+ ) : null}
176
+ {canDetail && !drilled ? (
177
  <button
178
  type="button"
179
  className="set-secondary set-perm-disclose"
 
183
  {shown ? "Hide detail" : "Conditions and fields"}
184
  </button>
185
  ) : null}
186
+ {drilled ? null : headExtra?.(m) ?? null}
187
  </span>
188
  </div>
189
 
 
199
  `space-between` and lives in `index.css`, which this ticket's fence
200
  does not contain; a new flex child there would need CSS that cannot
201
  be written, so the box takes its own line under the head. */}
202
+ {/* ⭐ W40-T14 β€” `!drilled &&` is the whole change here. Owner instruction 7 moved this
203
+ box INTO the database's own pane ("put the Metrics field toggle under there"), so
204
+ the drill-in row must not draw a second one.
205
+ ⭐⭐ W40-T16 β€” AND AFTER THIS TICKET THE BOX BELOW RENDERS NOWHERE AT ALL. Owner
206
+ instruction 13 folded the blanket toggle into the HIDE-FIELDS list, one checkbox
207
+ per metric, and `DatabasePermsPane`'s copy is deleted; `PermsEditor` passes
208
+ `onEdit` for every account row, so `!drilled` suppresses this one. β›” AND THE
209
+ OTHER CALLER DOES NOT MOUNT IT EITHER β€” MEASURED, not assumed:
210
+ `manage-agent/ManageAgentPane.tsx` passes exactly `modules, entries, onAccess,
211
+ onFilter, onToggleHidden, onSetHidden, userOptions, emptyNote`, and no
212
+ `onMetrics`. So nothing in the UI writes `PermsEntry.metrics` any more.
213
+ ⚠ THAT IS INSTRUCTION 13, NOT A DEFECT: a blanket control REPLACED by finer ones
214
+ is the whole ask. The boolean survives as a legacy value β€” read by `parseEntry`,
215
+ emitted by `toPutBody`, honoured on screen by `hiddenSetFor` β€” and every record
216
+ written from here on carries it GRANTED.
217
+ β›” THE JSX IS KEPT AS A SEAM, DELIBERATELY AND WITH ITS COST NAMED. It is what
218
+ lets a future room govern the capability without re-deriving the prop, and it
219
+ carries `verify_ui.py`'s `t19-unmounted` / `t19-control-gone` controls, the only
220
+ two in that file built from code that actually shipped broken. Deleting it is
221
+ legal β€” nothing passes the prop, so `tsc` would not notice β€” but it is a decision
222
+ to take out loud, in the same change as those two gate blocks, never a tidy-up. */}
223
+ {!drilled && on && !schemaless && onMetrics ? (
224
  <label className="set-check">
225
  <input
226
  type="checkbox"
 
245
  </p>
246
  ) : null}
247
 
248
+ {/* ⚠ `!drilled` is written even though `openDetail` can never leave `null` in that
249
+ mode (nothing renders the disclosure that sets it). An invariant held by an absent
250
+ button is one refactor away from being false; the gate on the thing itself is not. */}
251
+ {!drilled && canDetail && shown ? (
252
  <div className="set-perm-panels">
253
  <div className="cg-pop set-perm-pop">
254
  <FilterBuilderPanel
 
259
  />
260
  </div>
261
  <div className="cg-pop set-perm-pop">
262
+ {/* ⭐ W40-T16 β€” `hiddenSetFor`, the same resolver the manage-user pane uses.
263
+ ONE spelling of "which keys read as hidden": this room's fields carry no
264
+ metric flag today (the agent editor's payload has no `measure_` pseudo-
265
+ fields), so the union adds nothing here and the accordion is unchanged.
266
+ It is written anyway because the alternative is two answers to one question
267
+ in two files, which is the drift this component was extracted to prevent. */}
268
  <FieldsHidePanel
269
  fields={m.fields}
270
+ hidden={hiddenSetFor(entries, m.key, m.fields)}
271
  onToggle={(key) => onToggleHidden(m.key, key)}
272
  // ⚠ `lockedKey` IS NOT OPTIONAL HERE, whatever the prop says.
273
  // Absent, the panel locks nothing and one click on "Hide all"
 
276
  // validation would accept because the identity column is a
277
  // perfectly KNOWN field key.
278
  lockedKey={identityKey(m.fields)}
279
+ /* ⭐⭐ W40-T17 (owner instruction 15) β€” UNCONDITIONAL HERE, NOT A SECOND
280
+ PROP, AND THAT IS WHAT THIS COMPONENT IS. `ModulePermsList` IS the
281
+ permissioning list: every mount of it β€” the account editor's Access
282
+ section and `manage-agent/ManageAgentPane` alike β€” is editing the same
283
+ `perms` record through the same wall, so its panel is always a
284
+ permissioning panel and there is no caller for whom the shared sections
285
+ would be right. Owner: *"stop displaying 'Shared with me' / 'Shared with
286
+ everyone' fields under Hide Fields - permission on pre-set Fields only."*
287
+ That is a sentence about permissioning, not about one screen.
288
+ ⚠ IT IS ALSO WHAT KEEPS THE TWO ROOMS IDENTICAL. This file exists because
289
+ the owner ruled manage-agent is *"the same exact layout"* as manage user
290
+ (wave 33); gating one room and not the other would put the divergence
291
+ inside the very component extracted to prevent it β€” and LATENTLY, since
292
+ neither section fills up until the server ships `custom`/`shared`. */
293
+ sharedSections={false}
294
+ onHideAll={() => onSetHidden(m.key, hideAllKeys(entries, m.key, m.fields))}
295
+ onShowAll={() => onSetHidden(m.key, showAllKeys(entries, m.key, m.fields))}
296
  />
297
  </div>
298
  </div>
web/src/settings/PermsEditor.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/settings/perms.css CHANGED
@@ -23,10 +23,13 @@
23
  flex: none;
24
  }
25
 
26
- /* Smaller than a primary action and quieter than `Copy to…`: this button reveals
27
- detail, it does not commit anything. It borrows `.set-secondary`'s border,
28
- surface and focus ring rather than restating them β€” a second definition of the
29
- button chrome is a second place for the focus ring to go missing. */
 
 
 
30
  .set-perm-disclose {
31
  font-size: var(--lp-fs-2xs);
32
  padding: 3px 9px;
@@ -40,8 +43,174 @@
40
  color: var(--lp-blue-deep);
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  @media (max-width: 640px) {
44
  /* The head wraps before the label truncates: a database name is the one thing
45
  in this row that cannot be guessed from context. */
46
  .set-perm-headend { flex-wrap: wrap; justify-content: flex-end; }
 
 
 
 
 
47
  }
 
23
  flex: none;
24
  }
25
 
26
+ /* Smaller and quieter than a primary action: this button reveals detail, it does
27
+ not commit anything. It borrows `.set-secondary`'s border, surface and focus
28
+ ring rather than restating them β€” a second definition of the button chrome is a
29
+ second place for the focus ring to go missing.
30
+ ⚠ STILL LIVE, and only in the agent editor. Wave 40 replaced the account
31
+ editor's accordion with a full-pane room (`.set-perm-edit` below); the agent
32
+ editor mounts the same list without `onEdit` and keeps this disclosure. */
33
  .set-perm-disclose {
34
  font-size: var(--lp-fs-2xs);
35
  padding: 3px 9px;
 
43
  color: var(--lp-blue-deep);
44
  }
45
 
46
+ /* ── wave 40, owner instruction 7 (W40-T14): the row's ONE way in, and the room
47
+ it opens ──────────────────────────────────────────────────────────────────
48
+ *
49
+ * WHY THE ACCORDION BECAME A ROOM. Wave 33's disclosure answered "show every
50
+ * database, not every panel", and it did β€” while the list was two rows. It is now
51
+ * every database the tenant has, and a filter builder unfolded inside one row of
52
+ * that list reads as a menu that failed to close. Owner: *"Edit opens the whole
53
+ * database's Fields and Filters to toggle, with a Back button to return."*
54
+ */
55
+
56
+ /* Sized and quietened like the disclosure it replaces, and borrowing
57
+ `.set-secondary`'s border, surface and focus ring for the same reason that one
58
+ does: a second definition of the button chrome is a second place for the focus
59
+ ring to go missing. */
60
+ .set-perm-edit {
61
+ font-size: var(--lp-fs-2xs);
62
+ padding: 3px 9px;
63
+ white-space: nowrap;
64
+ }
65
+
66
+ /* The way out and the name of the room share one line, so the reader learns
67
+ where they are and how to leave in a single glance. `baseline` rather than
68
+ `center`: the title's cap height should sit on the button's label, which is
69
+ the alignment every other head on this page uses. */
70
+ .set-dbperm-head {
71
+ display: flex;
72
+ align-items: baseline;
73
+ gap: 12px;
74
+ margin-bottom: 16px;
75
+ }
76
+ /* The chevron rides with the words rather than beside them. */
77
+ .set-dbperm-back { display: inline-flex; align-items: center; gap: 6px; flex: none; }
78
+ /* `.set-h4` carries block margins meant for a heading that starts a section; here
79
+ it is a flex item on the head's own line. */
80
+ .set-dbperm-title { margin: 0; min-width: 0; }
81
+ /* ⭐ W40-T16 β€” `.set-dbperm-metrics` IS DELETED WITH THE BOX IT SPACED. Owner instruction 13
82
+ folded the blanket Metrics checkbox into the Hide-fields list as one row per metric, so the
83
+ rule had no element left to apply to. A rule that matches nothing is how a stylesheet ends up
84
+ describing a screen that no longer exists. */
85
+
86
+ /* THE PANELS STACK, one decision per eye level. A grid rather than a column flex
87
+ box on purpose: `.set-perm-pop` carries `flex: 1 1 320px` for the side-by-side
88
+ accordion, and flex properties are inert on a GRID item, so this container gets
89
+ the stacking it wants without redefining a rule that belongs to `index.css`. */
90
+ .set-dbperm-panels {
91
+ display: grid;
92
+ grid-template-columns: minmax(0, 1fr);
93
+ gap: 14px;
94
+ }
95
+
96
+ /* ── wave 40, owner instructions 8 + 9 + 10 (W40-T15): the account page stops
97
+ shouting, and Save sits where the hand is ──────────────────────────────────
98
+ *
99
+ * Three complaints, one page. The header put an exit, a person and two chips on
100
+ * one flex row; the reset-password control was split across a two-column grid
101
+ * with its consequence read BEFORE the button that causes it; and a long access
102
+ * list had its only Save at the bottom.
103
+ *
104
+ * β›” EVERY SELECTOR BELOW THAT OVERRIDES AN `index.css` DEFAULT CARRIES TWO
105
+ * CLASSES ON PURPOSE. A single-class rule here would tie at 0-1-0 with the rule
106
+ * it means to beat, and the winner would then be vite's import order across two
107
+ * stylesheets β€” a thing no reader of either file can see. Nothing in `index.css`
108
+ * is edited or restated; these only bound the new arrangement.
109
+ */
110
+
111
+ /* --- instruction 9c: the way out is page chrome, on its own line ----------- */
112
+ /* The quiet-button register this system already uses (`.shell-newfolder`: muted
113
+ ink, wash on hover, nothing else), with the native chrome reset in the BASE
114
+ rule as `DESIGN.md` §4 requires. ⚠ `font: inherit` FIRST and the size after
115
+ it: a shorthand declared below its own longhands re-expands and undoes them,
116
+ which is the Alerts-button scar `verify_ui.py`'s R5b cascade check exists to
117
+ catch. The negative left margin is optical, not spacing β€” it pulls the
118
+ chevron out to the page's text edge so the label starts where the title does. */
119
+ .set-perm-back {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: 6px;
123
+ border: 0;
124
+ background: transparent;
125
+ font: inherit;
126
+ font-size: var(--lp-fs-2xs);
127
+ color: var(--lp-muted);
128
+ padding: 4px 8px;
129
+ margin: 0 0 10px -8px;
130
+ border-radius: var(--lp-r-sm);
131
+ cursor: pointer;
132
+ }
133
+ .set-perm-back:hover { background: var(--lp-wash); color: var(--lp-ink); }
134
+ .set-perm-back:focus-visible { outline: 2px solid var(--lp-blue-deep); outline-offset: 1px; }
135
+
136
+ /* The identity is the page TITLE now, so it owns the space under the head
137
+ rather than sharing a row with the exit. */
138
+ .set-perm-ident { margin-bottom: 16px; }
139
+ /* The chips sit beside the name, which is where `index.css` already argued they
140
+ belong: pushed to the far end they read as a status of the dialog rather than
141
+ of the person.
142
+ ⚠ `baseline`, NOT the `center` the old head used, and the difference is which
143
+ sibling the chip is beside. On that row the chip sat next to a TWO-LINE block
144
+ (name over username) and index.css records the reason for centring it: a chip
145
+ top-aligned against two lines looks dropped. Here its sibling is the one-line
146
+ title, so the chip's text sits on the title's baseline, which is the alignment
147
+ `.set-perm-modhead` and `.set-dbperm-head` already use for a heading and a
148
+ smaller control on one line. The username moved out of this row entirely. */
149
+ .set-perm-nameline {
150
+ display: flex;
151
+ align-items: baseline;
152
+ flex-wrap: wrap;
153
+ gap: 10px;
154
+ }
155
+
156
+ /* --- instruction 9a + 9b: the password group reads top to bottom ----------- */
157
+ /* Label, box and button on one line and one baseline; the consequence under
158
+ them. `flex-end` rather than `baseline` because the label sits ABOVE the box:
159
+ what has to line up is the bottom of the input and the bottom of the button,
160
+ which are both 32px tall. */
161
+ .set-pw-row {
162
+ display: flex;
163
+ align-items: flex-end;
164
+ flex-wrap: wrap;
165
+ gap: 12px;
166
+ }
167
+ /* `.set-field` carries 13px of bottom margin meant for stacked form rows; here
168
+ it would lift the box off the button's baseline. The width keeps the box the
169
+ size a password is, instead of letting it stretch the whole card. */
170
+ .set-pw-group .set-pw-field { flex: 1 1 220px; max-width: 320px; margin-bottom: 0; }
171
+ /* The gap goes ABOVE the sentence now that the sentence is last. */
172
+ .set-perms .set-pw-note { margin: 8px 0 0; }
173
+
174
+ /* --- instruction 10: Save access at the head of the section too ------------ */
175
+ /* The trailing controls of the Access head, grouped as ONE flex child so
176
+ `index.css`'s `space-between` on `.set-perm-sect` still sees exactly two. */
177
+ .set-perm-sectend {
178
+ display: flex;
179
+ align-items: center;
180
+ gap: 8px;
181
+ flex: none;
182
+ }
183
+ /* Sized to the copy door beside it rather than to the 32px action row at the
184
+ bottom of the page: controls sharing a row share a height (`DESIGN.md` Β§4),
185
+ and this one is riding a heading's line. It stays `.set-primary` in colour,
186
+ because it commits the same write the bottom button commits. */
187
+ .set-perms .set-perm-savetop {
188
+ height: 28px;
189
+ padding: 0 12px;
190
+ font-size: var(--lp-fs-2xs);
191
+ }
192
+
193
+ /* --- instruction 8: "Database" and "Module" ------------------------------- */
194
+ /* A label for a group of cards, one step quieter than the `Access` heading it
195
+ sits under, so the reader gets the hierarchy from weight rather than from
196
+ indentation. Sentence case: the product has no ALL-CAPS chrome.
197
+ ⚠ The bottom margin is deliberately small because `.set-perm-mod` brings 14px
198
+ of its own top margin and adjacent margins collapse to the larger β€” anything
199
+ under 14px here has no effect at all, so 4px says "as tight as the card
200
+ allows" rather than pretending to a number the layout cannot honour. */
201
+ .set-perms .set-perm-group {
202
+ margin: 22px 0 4px;
203
+ font-size: var(--lp-fs-2xs);
204
+ color: var(--lp-muted);
205
+ }
206
+
207
  @media (max-width: 640px) {
208
  /* The head wraps before the label truncates: a database name is the one thing
209
  in this row that cannot be guessed from context. */
210
  .set-perm-headend { flex-wrap: wrap; justify-content: flex-end; }
211
+ .set-dbperm-head { flex-wrap: wrap; }
212
+ /* The Access heading keeps its own line and the two controls take the next
213
+ one, rather than the copy door and Save being crushed into a strip beside
214
+ the word. */
215
+ .set-perm-sectend { flex-wrap: wrap; justify-content: flex-end; }
216
  }
web/src/settings/permsModel.ts CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/shell/Shell.tsx CHANGED
The diff for this file is too large to render. See raw diff