fsanyoto commited on
Commit
ea2c336
·
verified ·
1 Parent(s): 8f2f0ce

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
Files changed (49) hide show
  1. RELEASES.json +23 -0
  2. VERSION +1 -1
  3. api/automation_engine.py +0 -0
  4. api/main.py +7 -0
  5. api/routes_alerts.py +233 -0
  6. api/routes_automation.py +40 -5
  7. api/routes_platform_admin.py +618 -593
  8. api/routes_shares.py +87 -0
  9. api/routes_tables.py +161 -4
  10. platform/core/alerts.py +241 -0
  11. platform/core/grid_events.py +151 -1
  12. platform/core/releases.py +94 -0
  13. platform/core/shares.py +200 -0
  14. platform/core/store.py +91 -12
  15. platform/core/store_backend.py +24 -15
  16. platform/core/store_pg.py +74 -0
  17. platform/core/table_store.py +15 -0
  18. platform/core/user_tables.py +259 -34
  19. platform/harness/runtime.py +20 -4
  20. platform/modules/customer_data.py +89 -1
  21. platform/modules/customers.py +36 -3
  22. requirements.txt +11 -0
  23. web/src/alerts/AlertsPane.tsx +261 -0
  24. web/src/alerts/alertsApi.ts +93 -0
  25. web/src/alerts/alertsModel.ts +218 -0
  26. web/src/automation/AutomationCreate.tsx +36 -5
  27. web/src/automation/AutomationDetail.tsx +262 -9
  28. web/src/automation/AutomationSurface.tsx +13 -4
  29. web/src/automation/automationApi.ts +80 -7
  30. web/src/customer-grid/ColumnMenu.tsx +0 -0
  31. web/src/customer-grid/CustomerGrid.tsx +517 -36
  32. web/src/customer-grid/ViewSidebar.tsx +449 -141
  33. web/src/customer-grid/apiBridge.ts +72 -0
  34. web/src/customer-grid/folders.ts +73 -1
  35. web/src/customer-grid/liveWorkspace.ts +46 -0
  36. web/src/customer-grid/overlayPlacement.ts +14 -1
  37. web/src/customer-grid/types.ts +189 -3
  38. web/src/customer-grid/undoStack.ts +189 -0
  39. web/src/customer-grid/useGridColumns.ts +26 -9
  40. web/src/customer-grid/useGridSelection.ts +4 -0
  41. web/src/customer-grid/useVisibleRows.ts +6 -1
  42. web/src/index.css +634 -103
  43. web/src/settings/AdminPane.tsx +646 -568
  44. web/src/settings/platformAdminApi.ts +263 -224
  45. web/src/shell/NavExtras.tsx +21 -1
  46. web/src/shell/ShareDialog.tsx +328 -0
  47. web/src/shell/Shell.tsx +267 -44
  48. web/src/shell/nav.ts +29 -0
  49. web/src/shell/shareModel.ts +206 -0
RELEASES.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "current": "v3 (6b9fa62)",
3
+ "releases": [
4
+ {
5
+ "version": "v3",
6
+ "sha": "6b9fa62",
7
+ "date": "2026-08-05",
8
+ "subject": "release v3"
9
+ },
10
+ {
11
+ "version": "v2",
12
+ "sha": "fd05861",
13
+ "date": "2026-08-04",
14
+ "subject": "release v2"
15
+ },
16
+ {
17
+ "version": "v1",
18
+ "sha": "5b4e2c4",
19
+ "date": "2026-08-04",
20
+ "subject": "release v1"
21
+ }
22
+ ]
23
+ }
VERSION CHANGED
@@ -1 +1 @@
1
- v2 (fd05861)
 
1
+ v3 (6b9fa62)
api/automation_engine.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/main.py CHANGED
@@ -55,6 +55,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException # noqa
55
 
56
  import aios_session # noqa: E402
57
  import routes_admin # noqa: E402
 
58
  import routes_assets # noqa: E402 (wave 18 C2-ASSET — catalog product imagery)
59
  import routes_auth # noqa: E402
60
  import routes_automation # noqa: E402 (wave 18 C4-AUTO — SESSION D's router, mounted by A)
@@ -67,6 +68,7 @@ import routes_pages # noqa: E402
67
  import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane)
68
  import routes_products # noqa: E402 (wave 15 C-TOPIC — gated, not yet in the nav)
69
  import routes_records # noqa: E402
 
70
  import routes_tables # noqa: E402 (wave 18 C3-UT — user-created databases over the wire)
71
  from core import grid_events # noqa: E402
72
  from deps import Session, module_gate # noqa: E402
@@ -193,6 +195,11 @@ app.include_router(routes_statements.router)
193
  # platform-operator route ever shadowing each other. Every path it declares is gated by
194
  # `core.platform_admin.is_platform_admin`, and `verify_api` enumerates this router to prove it.
195
  app.include_router(routes_platform_admin.router)
 
 
 
 
 
196
 
197
 
198
  # --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
 
55
 
56
  import aios_session # noqa: E402
57
  import routes_admin # noqa: E402
58
+ import routes_alerts # noqa: E402 (wave 20 item 25 — the Alerts inbox)
59
  import routes_assets # noqa: E402 (wave 18 C2-ASSET — catalog product imagery)
60
  import routes_auth # noqa: E402
61
  import routes_automation # noqa: E402 (wave 18 C4-AUTO — SESSION D's router, mounted by A)
 
68
  import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane)
69
  import routes_products # noqa: E402 (wave 15 C-TOPIC — gated, not yet in the nav)
70
  import routes_records # noqa: E402
71
+ import routes_shares # noqa: E402 (wave 20 R10 — grants for views, folders and databases)
72
  import routes_tables # noqa: E402 (wave 18 C3-UT — user-created databases over the wire)
73
  from core import grid_events # noqa: E402
74
  from deps import Session, module_gate # noqa: E402
 
195
  # platform-operator route ever shadowing each other. Every path it declares is gated by
196
  # `core.platform_admin.is_platform_admin`, and `verify_api` enumerates this router to prove it.
197
  app.include_router(routes_platform_admin.router)
198
+ # Wave 20 (owner items 25 + 18/23/26): the Alerts inbox and the manage-access surface. Both are
199
+ # session-gated rather than admin-gated — an alert is a person's own subscription, and sharing is
200
+ # something every user does with their own views/folders/databases.
201
+ app.include_router(routes_alerts.router)
202
+ app.include_router(routes_shares.router)
203
 
204
 
205
  # --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
api/routes_alerts.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_alerts.py — the Alerts module (wave 20, owner item 25, contract C-ALERT).
2
+
3
+ GET /api/v1/alerts -> {alerts:[...]}
4
+ POST /api/v1/alerts <- {viewId, topic, label?}
5
+ DELETE /api/v1/alerts/{alert_id}
6
+ POST /api/v1/alerts/{alert_id}/run -> evaluate now (the pane's manual refresh)
7
+ GET /api/v1/notifications -> {unread, items:[...]}
8
+ POST /api/v1/notifications/read <- {ids:[...]|null, read?:bool}
9
+
10
+ The semantics — an alert is a view plus a remembered matched set, a notification is a NEW
11
+ ENTRANT, and the first evaluation seeds silently — live in `core.alerts` with the reasoning.
12
+ This file owns the two things a route must: WHO may do it, and HOW the view gets evaluated.
13
+
14
+ ⭐ **THE EVALUATION RUNS AS THE ALERT'S OWNER, NOT AS THE CALLER.** `_run_alert` builds the pool
15
+ for `rec['owner']`, never for whoever tripped the write hook. Any other choice leaks: a
16
+ full-access admin editing a cell would otherwise evaluate a BU-scoped user's alert over the whole
17
+ book, and the notification would name customers that user may not see — a permission leak wearing
18
+ a notification's clothes. The owner's own scope is the only correct basis for their alert.
19
+
20
+ ⚠ **AN ALERT IS NOT A SECOND READ PATH.** It resolves rows through the same
21
+ `routes_customers.grid_assembly` / `routes_tables.ut_assembly` the grid uses, so a row that an
22
+ alert can see is by construction a row its owner could open. Re-implementing the filter here
23
+ would be a second definition of "matches", and those two would drift.
24
+ """
25
+ from fastapi import APIRouter, Body, Depends
26
+
27
+ import core.alerts as alerts
28
+ from deps import Session, err, require_session
29
+
30
+ router = APIRouter(prefix="/api/v1")
31
+
32
+ #: The alert-bearing surfaces. `ut_` tables are admitted by prefix, like everywhere else.
33
+ _TOPICS = ("customer", "product")
34
+
35
+
36
+ def _topic_or_400(raw):
37
+ topic = str(raw or "").strip().lower()
38
+ if topic.startswith("ut_") or topic in _TOPICS:
39
+ return topic
40
+ raise err(400, "bad_topic", f"topic must be one of {', '.join(_TOPICS)} or a ut_ table")
41
+
42
+
43
+ def _owner_session(session: Session, owner: str):
44
+ """A `Session` for the alert's OWNER (see the module note on why the owner, not the caller).
45
+
46
+ ⚠ `Session` exposes `uname`/`admin` as PROPERTIES derived from `user`, not as fields — so an
47
+ owner session is built by swapping the `user` RECORD and letting both derive themselves. An
48
+ earlier version passed `uname=`/`admin=` to the constructor, which would have raised on the
49
+ first write hook of the wave; the properties are the single definition of who a session is,
50
+ and going around them is how a session with an admin flag and a non-admin record exists.
51
+
52
+ Returns None when the owner is gone or deactivated — their alerts then stop evaluating rather
53
+ than evaluating as somebody else, which is the fail-closed direction.
54
+ """
55
+ import core.users as users
56
+
57
+ if str(owner) == str(session.uname):
58
+ return session
59
+ rec = (users.registry() or {}).get(str(owner))
60
+ if not isinstance(rec, dict) or not rec.get("active", True):
61
+ return None
62
+ # `_public` is THE definition of what a session may know about its own account (never a hash
63
+ # or a salt) — the same one `routes_auth` uses. Building the dict by hand here would be a
64
+ # second definition, and the one that leaks is always the copy.
65
+ return Session(tenant=session.tenant, user=users._public(str(owner), rec),
66
+ claims=session.claims, runtime=session.runtime)
67
+
68
+
69
+ def _evaluate(session: Session, rec: dict):
70
+ """Resolve `rec`'s view over its topic AS THE ALERT'S OWNER, then fold the result in."""
71
+ import aios_grid
72
+ from harness import filter_eval
73
+
74
+ owner_sess = _owner_session(session, rec.get("owner"))
75
+ if owner_sess is None:
76
+ return {"skipped": "owner_unavailable"}
77
+ topic = str(rec.get("topic") or "")
78
+ try:
79
+ if topic.startswith("ut_"):
80
+ from routes_tables import ut_assembly
81
+ g = ut_assembly(owner_sess, topic,
82
+ storage_key=f"{owner_sess.tenant}:{topic}:{owner_sess.uname}")
83
+ else:
84
+ from routes_customers import grid_assembly
85
+ g = grid_assembly(owner_sess, scope=topic, consume_corrections=False)
86
+ except Exception as e: # noqa: BLE001
87
+ return {"skipped": "unavailable", "detail": type(e).__name__}
88
+
89
+ view = (g.get("views") or {}).get(str(rec.get("viewId")))
90
+ if not isinstance(view, dict):
91
+ # Deleted, or un-shared out from under the alert. Say so on the RECORD rather than
92
+ # deleting the alert: an alert that silently vanishes is indistinguishable from one that
93
+ # never fires, and the user cannot debug what is not there.
94
+ return {"skipped": "view_missing"}
95
+
96
+ # The SAME row build the grid and `/customers` use — `rows_from_pool` is what puts derived
97
+ # and overlay values on a row. Evaluating a filter against raw pool dicts would silently
98
+ # never match any condition on a user-created or measure column.
99
+ rows = aios_grid.rows_from_pool(g["rows_src"], g["fields"], g["ws"].get("overlays"),
100
+ derived=g.get("derived"))
101
+ config = view.get("config") or view
102
+ ctx = filter_eval.EvalCtx(
103
+ cohort_sets={str(k): {str(p) for p in (v.get("memberPids") or ())}
104
+ for k, v in (g.get("lists") or {}).items() if isinstance(v, dict)},
105
+ measure_sets=g.get("measure_sets") or {},
106
+ today=g.get("today"))
107
+ pids = filter_eval.visible_pids(config.get("filters") or [], rows, g["fields"], ctx,
108
+ member_pids=config.get("memberPids"))
109
+ labels = {str(r.get("pid")): str(r.get("name") or r.get("pid")) for r in rows}
110
+ return alerts.evaluate(rec.get("id"), [str(p) for p in pids],
111
+ labels=labels, partial=False, st=session.runtime)
112
+
113
+
114
+ @router.get("/alerts")
115
+ def list_alerts(session: Session = Depends(require_session)):
116
+ return {"alerts": alerts.list_alerts(user=session.uname, is_admin=session.admin,
117
+ st=session.runtime)}
118
+
119
+
120
+ @router.post("/alerts")
121
+ def create_alert(body: dict = Body(default=None), session: Session = Depends(require_session)):
122
+ body = body or {}
123
+ view_id = str(body.get("viewId") or "").strip()
124
+ if not view_id:
125
+ raise err(400, "bad_view", "an alert needs the id of the view it watches")
126
+ topic = _topic_or_400(body.get("topic"))
127
+ _require_filtered_view(session, topic, view_id)
128
+ import uuid
129
+ aid = f"al_{uuid.uuid4().hex[:12]}"
130
+ rec = alerts.create(aid, view_id=view_id, topic=topic, owner=session.uname,
131
+ label=body.get("label") or "", st=session.runtime)
132
+ # SEED IMMEDIATELY, so the alert starts from "everything currently matching is old news".
133
+ # Deferring this to the first write hook would mean the next edit announces the whole view.
134
+ outcome = _evaluate(session, rec)
135
+ return {"alert": {**rec, "seeded": True}, "first": outcome}
136
+
137
+
138
+ def _require_filtered_view(session: Session, topic: str, view_id: str):
139
+ """400 unless `view_id` exists on `topic` AND actually narrows something.
140
+
141
+ ⛔ AN ALERT ON AN UNFILTERED VIEW IS SILENTLY INCAPABLE OF ALERTING, which is worse than one
142
+ that is refused. `filter_eval` treats an inactive tree as "no narrowing, every row shows"
143
+ (`visible_pids`'s own rule), so such an alert seeds with the entire table and can never see an
144
+ entrant again — there is nothing left to enter. The owner's words are *"when a Record gets
145
+ into that Filter's criteria"*: no criteria, no alert, and said at creation rather than
146
+ discovered by never being notified.
147
+
148
+ `is_rule_active` is the SAME activeness predicate the engine and the column tints use — a
149
+ half-typed rule is not a filter, and this must agree with what actually narrows or it would
150
+ accept a view whose one rule the engine then ignores.
151
+ """
152
+ from harness import filter_eval
153
+
154
+ try:
155
+ if topic.startswith("ut_"):
156
+ from routes_tables import ut_assembly
157
+ g = ut_assembly(session, topic,
158
+ storage_key=f"{session.tenant}:{topic}:{session.uname}")
159
+ else:
160
+ from routes_customers import grid_assembly
161
+ g = grid_assembly(session, scope=topic, consume_corrections=False)
162
+ except Exception: # noqa: BLE001
163
+ raise err(503, "unavailable", "the table is unavailable — try again in a moment")
164
+ view = (g.get("views") or {}).get(str(view_id))
165
+ if not isinstance(view, dict):
166
+ raise err(404, "no_view", "that view does not exist on this table")
167
+ nodes, _conj = filter_eval.tree_parts((view.get("config") or view).get("filters") or [])
168
+
169
+ def _any_active(ns):
170
+ for n in ns or ():
171
+ if isinstance(n, dict) and isinstance(n.get("children"), list):
172
+ if _any_active(n["children"]):
173
+ return True
174
+ elif filter_eval.is_rule_active(n):
175
+ return True
176
+ return False
177
+
178
+ if not _any_active(nodes):
179
+ raise err(400, "no_filter",
180
+ "this view has no active filter, so no record can ever ENTER it — add a "
181
+ "condition to the view first, then create the alert")
182
+
183
+
184
+ @router.delete("/alerts/{alert_id}")
185
+ def delete_alert(alert_id: str, session: Session = Depends(require_session)):
186
+ rec = next((r for r in alerts.list_alerts(st=session.runtime)
187
+ if str(r.get("id")) == str(alert_id)), None)
188
+ if rec is None:
189
+ raise err(404, "no_alert", "that alert does not exist")
190
+ if str(rec.get("owner")) != str(session.uname) and not session.admin:
191
+ raise err(403, "not_yours", "only the alert's owner (or an administrator) can delete it")
192
+ alerts.delete(alert_id, st=session.runtime)
193
+ return {"ok": True}
194
+
195
+
196
+ @router.post("/alerts/{alert_id}/run")
197
+ def run_alert(alert_id: str, session: Session = Depends(require_session)):
198
+ rec = next((r for r in alerts.list_alerts(user=session.uname, is_admin=session.admin,
199
+ st=session.runtime)
200
+ if str(r.get("id")) == str(alert_id)), None)
201
+ if rec is None:
202
+ raise err(404, "no_alert", "that alert does not exist")
203
+ return _evaluate(session, rec)
204
+
205
+
206
+ @router.get("/notifications")
207
+ def notifications(session: Session = Depends(require_session)):
208
+ return alerts.inbox(session.uname, st=session.runtime)
209
+
210
+
211
+ @router.post("/notifications/read")
212
+ def read_notifications(body: dict = Body(default=None),
213
+ session: Session = Depends(require_session)):
214
+ body = body or {}
215
+ ids = body.get("ids")
216
+ if ids is not None and not isinstance(ids, list):
217
+ raise err(400, "bad_ids", "ids must be a list, or null to mark every notification")
218
+ return alerts.mark_read(session.uname, ids, read=bool(body.get("read", True)),
219
+ st=session.runtime)
220
+
221
+
222
+ def after_write(session: Session, topic_key: str):
223
+ """THE WRITE HOOK — call after a write that could change what a view matches.
224
+
225
+ Exported as a plain function (not a route) so `core.grid_events`' callers and S2's automation
226
+ upserts reach it the same way. It never raises: an alert evaluation failing must not fail the
227
+ edit that triggered it.
228
+ """
229
+ try:
230
+ return alerts.after_write(topic_key, st=session.runtime,
231
+ runner=lambda rec: _evaluate(session, rec))
232
+ except Exception: # noqa: BLE001
233
+ return {"evaluated": 0}
api/routes_automation.py CHANGED
@@ -64,16 +64,43 @@ def list_automations(session: Session = Depends(_GATE)):
64
  sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())]
65
  return {"automations": items,
66
  "kinds": [{"key": "scrape_db", "label": "Web page to database"},
67
- {"key": "field_instagram", "label": "Instagram profile column"}],
 
68
  "cronPresets": engine.CRON_PRESETS,
69
  # ⚠ A BOOLEAN, NEVER THE KEY. The surface needs to say "the paid rung is not
70
  # configured" honestly instead of offering a tier that will silently refuse — and
71
  # that needs exactly one bit. Shipping the key itself to a browser would put a
72
  # billable secret in every user's devtools.
73
- "hikerReady": engine.hiker_ready(),
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  "storeAvailable": bool(session.runtime.available())}
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  @router.get("/automations/tables")
78
  def automation_tables(session: Session = Depends(_GATE)):
79
  """The blank databases an automation can target, with their fields.
@@ -82,13 +109,21 @@ def automation_tables(session: Session = Depends(_GATE)):
82
  two can never collide. It exists because the automation editor needs the table+field list to
83
  build a config at all, and D must not block on A's route landing. When C3-UT is live this
84
  keeps working (same bucket) — it is a duplicate reader, never a second writer.
 
 
 
 
 
 
 
85
  """
 
 
86
  out = []
87
  for key, t in sorted(engine.ut_all(session.runtime).items(),
88
  key=lambda kv: (kv[1].get("label") or "").lower()):
89
- if not (session.admin or t.get("createdBy") in (session.uname, "automation",
90
- "scheduler")):
91
- continue # user_tables.may_open's rule, mirrored fail-closed
92
  out.append({"key": key, "label": t.get("label") or key,
93
  "source": t.get("source") or "Blank",
94
  "rowCount": len(t.get("rows") or {}),
 
64
  sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())]
65
  return {"automations": items,
66
  "kinds": [{"key": "scrape_db", "label": "Web page to database"},
67
+ {"key": "field_instagram", "label": "Instagram profile column"},
68
+ {"key": "discover_instagram", "label": "Find Instagram profiles"}],
69
  "cronPresets": engine.CRON_PRESETS,
70
  # ⚠ A BOOLEAN, NEVER THE KEY. The surface needs to say "the paid rung is not
71
  # configured" honestly instead of offering a tier that will silently refuse — and
72
  # that needs exactly one bit. Shipping the key itself to a browser would put a
73
  # billable secret in every user's devtools.
74
+ "paidReady": engine.bd_ready(),
75
+ # THE SOURCE REGISTRY (D-9's seam), on the wire for the same reason `cronPresets` is:
76
+ # the module that RUNS a source is the only thing entitled to say what it can do, and
77
+ # a client copy of "Instagram can discover, TikTok cannot" goes stale in silence.
78
+ "sources": engine.source_status(),
79
+ # The discovery vocabulary, likewise server-owned: every name here is MEASURED-
80
+ # accepted by the vendor's own validator, so a client that invented one would build a
81
+ # query the API rejects. `lead` is the subset seen carrying VALUES on real rows.
82
+ "discover": {"fields": list(engine.BD_FILTER_FIELDS),
83
+ "lead": list(engine.BD_FILTER_LEAD),
84
+ "operators": list(engine.BD_FILTER_OPS),
85
+ "nullaryOperators": list(engine.BD_NULLARY_OPS),
86
+ "maxRecords": engine.BD_MAX_RECORDS,
87
+ "table": engine.DISCOVER_TABLE},
88
  "storeAvailable": bool(session.runtime.available())}
89
 
90
 
91
+ @router.post("/automations/discover/estimate")
92
+ def discover_estimate(body: dict = Body(default=None), session: Session = Depends(_GATE)):
93
+ """What would this search cost? Shown BEFORE the run, never after.
94
+
95
+ ⚠ THE ANSWER IS AN ESTIMATE AND SAYS SO IN ITS OWN PAYLOAD (`basis: "SPEC"`). Bright Data
96
+ never returns a price before a run — the funds gate fires first and `price: 0` means "not
97
+ priced", not "free" — and this account's token cannot read a balance (`/customer/balance`
98
+ answers 403). A number presented as billed would be the invented measurement this whole
99
+ module refuses to make.
100
+ """
101
+ return engine.discover_estimate((body or {}).get("recordsLimit"))
102
+
103
+
104
  @router.get("/automations/tables")
105
  def automation_tables(session: Session = Depends(_GATE)):
106
  """The blank databases an automation can target, with their fields.
 
109
  two can never collide. It exists because the automation editor needs the table+field list to
110
  build a config at all, and D must not block on A's route landing. When C3-UT is live this
111
  keeps working (same bucket) — it is a duplicate reader, never a second writer.
112
+
113
+ ⛔ WAVE 20, item 3 — IT NO LONGER MIRRORS THE WALL, IT CALLS IT. This route re-implemented
114
+ `may_open` and got it WIDER: it also admitted `createdBy in ('automation', 'scheduler')`, so
115
+ a non-admin saw automation-created databases here and was refused the moment they opened,
116
+ edited or deleted one. A duplicate READER is fine; a duplicate WALL is not, because the two
117
+ only disagree in front of a user. One resolver now, and the engine stamps a human owner so
118
+ the merge takes nothing legitimate away (`ut_ensure`, `MACHINE_OWNERS`).
119
  """
120
+ import core.user_tables as user_tables
121
+
122
  out = []
123
  for key, t in sorted(engine.ut_all(session.runtime).items(),
124
  key=lambda kv: (kv[1].get("label") or "").lower()):
125
+ if not user_tables.may_open(key, session.uname, session.admin, st=session.runtime):
126
+ continue
 
127
  out.append({"key": key, "label": t.get("label") or key,
128
  "source": t.get("source") or "Blank",
129
  "rowCount": len(t.get("rows") or {}),
api/routes_platform_admin.py CHANGED
@@ -1,593 +1,618 @@
1
- """routes_platform_admin.py — THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 / R3+R4).
2
-
3
- Every other admin surface in this product answers questions about ONE tenant. This one answers
4
- questions about the PLATFORM: who our customers are, what they are running, whether their data
5
- sources are alive, and what the automation fleet costs. It is the first cross-tenant reader that
6
- has ever existed here, which is why it is also the most carefully walled.
7
-
8
- ⛔ THE WALL. `padmin_gate` is `core.platform_admin.is_platform_admin` — a DOUBLE lock (the record
9
- flag AND the `loopable` tenant), fail-closed, applied to every single route in this file through
10
- one dependency. A tenant admin is NOT admitted: `role: 'admin'` is Royal's or Nurilab's authority
11
- over their own workspace and it must never widen into a view of each other. `verify_api.py`'s
12
- W19-ADMIN section proves that by ENUMERATING this router and having a tenant admin try every path
13
- it declares, so a route added later cannot quietly ship without the wall.
14
-
15
- ⛔ CROSS-TENANT READS GO THROUGH EACH TENANT'S OWN RUNTIME. `runtime.get_runtime(slug)` per tenant,
16
- then `rt.get(...)` — never `core.store.get("<raw key>")`. The runtime is what applies the store
17
- NAMESPACE (`t/<slug>/…`) or binds the tenant's OWN dataset repo (R2), so reading raw keys would
18
- silently return tenant #0's data labelled as somebody else's — a wrong answer that looks right,
19
- which is the worst failure this plane could have.
20
-
21
- HONEST DEGRADATION IS THE WHOLE DESIGN, NOT AN ERROR PATH. This plane reads six subsystems across
22
- N tenants; on any given day one of them can be unreachable (a suspended tenant record, a locked
23
- keychain, an HF repo hiccup, no AWS credentials in this container). A 500 would take the entire
24
- dashboard down because one cell could not be filled. So every collector catches, and every row can
25
- carry an `error` string that the pane RENDERS — "unknown" is a real answer and it is never
26
- rendered as a zero. ([[gate-can-report-green-on-nothing]]: a fabricated 0 and a true 0 must not
27
- look alike.)
28
-
29
- EVERY COUNT DRILLS TO ROWS. `/overview`'s per-tenant counts are computed by the SAME collector
30
- functions the `/users`, `/databases`, `/connectors` and `/automations` routes serve rows from, so
31
- a number and its drill-down cannot disagree — they are one computation, projected twice
32
- ([[no-unverifiable-aggregates]]).
33
- """
34
- import os
35
- import subprocess
36
- import sys
37
- import time
38
- from pathlib import Path
39
-
40
- from fastapi import APIRouter, Depends
41
-
42
- import core.platform_admin as platform_admin
43
- import core.store as store
44
- from deps import Session, err, require_session, users
45
-
46
- router = APIRouter(prefix="/api/v1/platform-admin")
47
-
48
-
49
- def _now_iso():
50
- """UTC, offset-bearing — the one stamp format anything client-side may subtract from."""
51
- import datetime as _dt
52
- return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
53
-
54
-
55
- def padmin_gate(session: Session = Depends(require_session)) -> Session:
56
- """401 without a session, 403 unless this account is a Loopable platform operator.
57
-
58
- ONE dependency for the whole router. A per-route check is a per-route chance to forget, and
59
- the thing being forgotten here would be every customer's data at once.
60
-
61
- The message deliberately does not confirm that a platform plane exists for somebody else —
62
- a tenant admin who pokes at this URL learns only that their account cannot open it.
63
- """
64
- if not platform_admin.is_platform_admin(session.user):
65
- raise err(403, "forbidden", "your account does not have access to this surface")
66
- return session
67
-
68
-
69
- # ══════════════════════════════════════════════════════════════════════════ tenants
70
-
71
-
72
- def _tenant_bucket():
73
- """The control-plane `tenants` records, or {} — a platform fact, in the DEFAULT store."""
74
- from harness import runtime
75
- try:
76
- recs = store.get(runtime.TENANTS_KEY) or {}
77
- return {str(k).strip().lower(): v for k, v in recs.items() if isinstance(v, dict)}
78
- except Exception:
79
- return {}
80
-
81
-
82
- def _slugs():
83
- """Every tenant this deployment knows: compiled builders + control-plane records.
84
-
85
- `runtime.known_tenants()` is the union and it is the same list login resolves against, so
86
- this plane cannot show a customer the door does not recognise (or miss one it does).
87
- """
88
- from harness import runtime
89
- try:
90
- return list(runtime.known_tenants())
91
- except Exception:
92
- return sorted(_tenant_bucket())
93
-
94
-
95
- def _runtime_for(slug):
96
- """(runtime, error). A tenant whose runtime will not build is a ROW WITH A NOTE, never a 500.
97
-
98
- Two real cases produce one: a SUSPENDED record (`get_runtime` raises KeyError by design —
99
- "which tenants exist" is not a question the login path answers), and tenant #0's builder,
100
- which makes a LIVE Odoo call (`harness.tenants.royal_imports` `excluded_customer_ids`) and
101
- therefore fails on a box that cannot reach the ERP. Neither may take the dashboard down.
102
- """
103
- from harness import runtime
104
- try:
105
- return runtime.get_runtime(slug), ""
106
- except KeyError:
107
- return None, "not resolvable (suspended, or no builder and no active record)"
108
- except Exception as e: # noqa: BLE001
109
- return None, f"runtime unavailable ({type(e).__name__})"
110
-
111
-
112
- def _scan(want=""):
113
- """[(row, runtime)] — every tenant (or one), with its runtime RESOLVED EXACTLY ONCE.
114
-
115
- The one-resolution rule is not tidiness. `get_runtime` is LRU-cached on success, but a tenant
116
- that FAILS to build is not cached, and tenant #0's builder makes a live Odoo call — so a route
117
- that asked twice would pay the timeout twice, and `/overview` (which asks about six
118
- subsystems) would pay it six times. Resolve once, pass the handle down.
119
- """
120
- bucket = _tenant_bucket()
121
- want = str(want or "").strip().lower()
122
- out = []
123
- for slug in _slugs():
124
- if want and slug != want:
125
- continue
126
- rec = bucket.get(slug) or {}
127
- rt, error = _runtime_for(slug)
128
- out.append(({
129
- "slug": slug,
130
- # The record's name, else the built runtime's, else the slug. Tenant #0 is compiled
131
- # and has no record, so without the runtime fallback it would render as "royal-imports".
132
- "name": str(rec.get("name") or (getattr(rt, "name", "") if rt else "") or slug),
133
- "source": "record" if rec else "compiled",
134
- "status": str(rec.get("status") or ("active" if rt else "unknown")),
135
- "domains": list(rec.get("domains") or []),
136
- "modules": rec.get("modules", "all" if not rec else []),
137
- # R2: the isolation shape. "own repo" and "shared repo + prefix" are genuinely
138
- # different blast radii and an operator should be able to see which is which.
139
- "storeRepo": rec.get("store_repo") or ("shared (tenant #0 repo)" if not rec else ""),
140
- "storePrefix": getattr(rt, "store_namespace", "") if rt else "",
141
- "error": error,
142
- }, rt))
143
- return out
144
-
145
-
146
- # ══════════════════════════════════════════════════════════════════════════ users
147
-
148
-
149
- def _user_rows(tenant=None):
150
- """Accounts across every tenant, from the GLOBAL registry (`users.json` is control-plane).
151
-
152
- ⛔ NEVER `salt` OR `hash`. This projection is the only one these routes use, mirroring
153
- `routes_admin._view`'s discipline: one function that can leak, and it does not.
154
-
155
- R4's two new fields ride here — `lastLogin` / `lastActive`, absent on every pre-wave record,
156
- rendered as "never" rather than as a fabricated date.
157
- """
158
- try:
159
- reg = users.registry() or {}
160
- except Exception:
161
- return []
162
- want = str(tenant or "").strip().lower()
163
- rows = []
164
- for uname, rec in sorted(reg.items()):
165
- if not isinstance(rec, dict):
166
- continue
167
- slug = str(rec.get("tenant") or "royal-imports").strip().lower()
168
- if want and slug != want:
169
- continue
170
- rows.append({
171
- "username": uname,
172
- "name": rec.get("name") or uname,
173
- "email": rec.get("email") or "",
174
- "tenant": slug,
175
- "role": rec.get("role", "user"),
176
- "active": bool(rec.get("active", True)),
177
- "lastLogin": rec.get("last_login") or "",
178
- "lastActive": rec.get("last_active") or "",
179
- "platformAdmin": rec.get("platform_admin") is True,
180
- })
181
- return rows
182
-
183
-
184
- # ══════════════════════════════════════════════════════════════════════════ databases
185
-
186
-
187
- def _database_rows(slug, rt):
188
- """A tenant's user-created databases (`ut_*`) with their row counts.
189
-
190
- Via `core.user_tables.all_tables(st=rt)` — the TenantRuntime, never the module-global, which
191
- the engine's own header (`automation_engine.py:49-52`) flags as a cross-tenant defect for
192
- every R2 tenant. That booked defect is exactly the mistake a cross-tenant reader would make
193
- most easily, so it is stated at the call site too.
194
- """
195
- try:
196
- import core.user_tables as ut
197
- tables = ut.all_tables(st=rt) or {}
198
- except Exception as e: # noqa: BLE001
199
- return [], f"databases unreadable ({type(e).__name__})"
200
- rows = []
201
- for key, t in sorted(tables.items(), key=lambda kv: (kv[1].get("label") or "").lower()):
202
- if not isinstance(t, dict):
203
- continue
204
- rows.append({
205
- "tenant": slug,
206
- "key": key,
207
- "label": t.get("label") or key,
208
- "source": t.get("source") or "Blank",
209
- "createdBy": t.get("createdBy") or "",
210
- "created": t.get("created") or "",
211
- "fields": len(t.get("fields") or []),
212
- "rowCount": len(t.get("rows") or {}),
213
- })
214
- return rows, ""
215
-
216
-
217
- # ══════════════════════════════════════════════════════════════════════════ connectors
218
-
219
-
220
- def _connector_rows(slug, rt):
221
- """A tenant's data sources and which one is actually RESOLVED (i.e. would serve a query).
222
-
223
- `_resolved_odoo_key` is imported from `routes_keychain` rather than re-derived. It is the
224
- route-level mirror of `TenantRuntime.odoo_source()`, it takes a runtime (not a session), and
225
- a second copy of that resolution here would be a copy that drifts at which point this plane
226
- would confidently name the wrong live connector. Reused, not restated.
227
- """
228
- try:
229
- import core.keychain as keychain
230
- from routes_keychain import _CONNECTOR_FLAGS_KEY, _resolved_odoo_key
231
- entries = keychain.list_entries(rt)
232
- locked = not keychain.unlocked()
233
- resolved, _flag = _resolved_odoo_key(rt)
234
- flags = rt.get(_CONNECTOR_FLAGS_KEY) or {}
235
- except Exception as e: # noqa: BLE001
236
- return [], f"connectors unreadable ({type(e).__name__})", False
237
-
238
- rows = []
239
- if slug == "royal-imports" and os.environ.get("ODOO_URL"):
240
- rows.append({"tenant": slug, "key": "odoo-env", "label": "Odoo (environment)",
241
- "type": "odoo", "source": "env", "active": resolved == "env",
242
- "paused": bool((flags.get("odoo-env") or {}).get("paused"))})
243
- for e in entries:
244
- rows.append({"tenant": slug, "key": e["id"], "label": e["label"], "type": e["type"],
245
- "source": "keychain",
246
- "active": e["type"] == "odoo" and resolved == f"keychain:{e['id']}",
247
- "paused": bool((flags.get(e["id"]) or {}).get("paused"))})
248
- return rows, "", locked
249
-
250
-
251
- # ══════════════════════════════════════════════════════════════════════════ automations + cost
252
-
253
- #: THE UNIT MODEL, from `ops/provision_automation_cron.py` (its cost note at :25-28 and the
254
- #: function it actually provisions at :211 / :269). Restated as constants rather than as prose so
255
- #: the arithmetic below is re-checkable against the thing that was really deployed:
256
- #: EventBridge Scheduler `rate(15 minutes)` a 128 MB, 30 s-timeout Lambda that POSTs the tick.
257
- TICK_CADENCE = "rate(15 minutes)"
258
- TICK_PER_DAY = 96 # 1440 / 15
259
- LAMBDA_MB = 128
260
- FREE_LAMBDA_REQUESTS = 1_000_000 # AWS always-free, per month
261
- FREE_SCHEDULER_INVOCATIONS = 14_000_000
262
- DAYS_PER_MONTH = 30.4
263
-
264
-
265
- def _runs_per_day(cron):
266
- """Scheduled runs/day for the five-field crons this product offers, or None.
267
-
268
- ⚠ DELIBERATELY NARROW. It answers exactly the shapes `automation_engine.CRON_PRESETS` can
269
- produce (every-N-minutes, hourly, daily, weekly, monthly) and returns None for anything else
270
- an unparsed cadence is reported as "custom", never as a guessed number that would then be
271
- multiplied into a cost. A wrong denominator is worse than an absent one.
272
-
273
- ⚠ AND IT IS NOT MEASURED FROM HISTORY, on purpose: `automation_engine.MAX_RUNS` trims run
274
- history to 20 entries, so a 15-minute automation retains ~5 hours of it. Deriving runs/day
275
- from that window would understate by ~5x. History is reported as what it is — the last N runs.
276
- """
277
- parts = str(cron or "").split()
278
- if len(parts) != 5:
279
- return None
280
- minute, hour, dom, _mon, dow = parts
281
- if minute.startswith("*/") and hour == "*":
282
- try:
283
- step = int(minute[2:])
284
- except ValueError:
285
- return None
286
- return (1440.0 / step) if step > 0 else None
287
- if minute.isdigit() and hour == "*":
288
- return 24.0
289
- if minute.isdigit() and hour.isdigit():
290
- if dom.isdigit():
291
- return 1.0 / DAYS_PER_MONTH # monthly
292
- if dow.isdigit():
293
- return 1.0 / 7.0 # weekly
294
- return 1.0 # daily
295
- return None
296
-
297
-
298
- def _automation_rows(slug, rt):
299
- """A tenant's automations, their schedules, and their retained run history."""
300
- try:
301
- import automation_engine as engine
302
- defs = engine.all_definitions(rt) or {}
303
- max_runs = int(getattr(engine, "MAX_RUNS", 20))
304
- except Exception as e: # noqa: BLE001
305
- return [], f"automations unreadable ({type(e).__name__})", 20
306
- rows = []
307
- for auto_id, d in sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower()):
308
- if not isinstance(d, dict):
309
- continue
310
- sched = d.get("schedule") or {}
311
- status = d.get("status") or {}
312
- runs = list(d.get("runs") or [])
313
- cron = sched.get("cron") or ""
314
- per_day = _runs_per_day(cron) if sched.get("enabled") else 0.0
315
- rows.append({
316
- "tenant": slug,
317
- "id": auto_id,
318
- "name": d.get("name") or auto_id,
319
- "kind": d.get("kind") or "",
320
- "enabled": bool(sched.get("enabled")),
321
- "cron": cron,
322
- "runsPerDay": round(per_day, 2) if per_day is not None else None,
323
- "state": status.get("state") or "idle",
324
- "lastRunAt": status.get("lastRunAt") or "",
325
- "lastSummary": status.get("lastSummary") or "",
326
- # The retained window, named as such — see `_runs_per_day`'s second warning.
327
- "runsRetained": len(runs),
328
- "failedRetained": sum(1 for r in runs if isinstance(r, dict) and not r.get("ok")),
329
- "createdBy": d.get("createdBy") or "",
330
- "created": d.get("created") or "",
331
- })
332
- return rows, "", max_runs
333
-
334
-
335
- def _automation_cost(auto_rows):
336
- """The estimated monthly cost of running the automation fleet — and the honest shape of it.
337
-
338
- THE POINT THIS BLOCK EXISTS TO MAKE, which is not intuitive: **the external cron does not
339
- scale with automations or tenants.** One EventBridge schedule fires one Lambda, which POSTs
340
- one tick, and that tick runs every DUE automation for every tenant (`provision_automation_cron`
341
- states this as the reason it stays $0 "at ten tenants"). So the AWS bill is a function of the
342
- CADENCE alone the fleet below adds work inside the API container, which is already paid for.
343
-
344
- WHAT IS NOT COMPUTED HERE, deliberately: Lambda GB-seconds. That needs the real average
345
- duration of the function, which only CloudWatch knows; assuming one would be inventing the
346
- larger half of the free-tier calculation. `/aws` reports the measured figure when credentials
347
- are available, and this block says so rather than filling the gap with a plausible number.
348
- """
349
- invocations = TICK_PER_DAY * DAYS_PER_MONTH
350
- fleet_runs = sum(r["runsPerDay"] or 0 for r in auto_rows if r.get("enabled"))
351
- unknown_cadence = sum(1 for r in auto_rows if r.get("enabled") and r.get("runsPerDay") is None)
352
- return {
353
- "cadence": TICK_CADENCE,
354
- "invocationsPerMonth": int(round(invocations)),
355
- "freeRequestsPct": round(100.0 * invocations / FREE_LAMBDA_REQUESTS, 3),
356
- "freeSchedulerPct": round(100.0 * invocations / FREE_SCHEDULER_INVOCATIONS, 4),
357
- "lambdaMb": LAMBDA_MB,
358
- "usd": 0.0,
359
- "fleetRunsPerDay": round(fleet_runs, 2),
360
- "unknownCadence": unknown_cadence,
361
- "basis": (
362
- f"One EventBridge schedule ({TICK_CADENCE}) fires one {LAMBDA_MB} MB Lambda that "
363
- f"POSTs the tick; that ONE tick runs every due automation for every tenant, so the "
364
- f"AWS cost is set by the cadence and does not grow with the fleet. "
365
- f"{int(round(invocations)):,} invocations/month is "
366
- f"{round(100.0 * invocations / FREE_LAMBDA_REQUESTS, 3)}% of the 1,000,000-request "
367
- f"always-free tier, so the marginal cost of the automations below is $0.00. "
368
- f"Compute (GB-seconds) depends on measured durations and is NOT estimated here "
369
- f"the AWS report reads the real figure when credentials are available."
370
- ),
371
- }
372
-
373
-
374
- # ══════════════════════════════════════════════════════════════════════════ the AWS report
375
-
376
- #: `ops/aws_usage_report.py`, relative to this file: aios-web/api/ -> repo root -> ops/.
377
- _AWS_REPORT = Path(__file__).resolve().parents[2] / "ops" / "aws_usage_report.py"
378
- _AWS_TIMEOUT = 60
379
-
380
-
381
- def _aws_report(days=7):
382
- """The AWS usage report as text, or an honest block saying why there is none.
383
-
384
- ⛔ SUBPROCESS, NEVER `import`. Two concrete reasons, both found by reading that file rather
385
- than by running it:
386
- * it reassigns `sys.stdout` to a UTF-8 wrapper AT MODULE IMPORT (a cp1252 fix for this
387
- box) importing it would mutate the API process's stdout as a side effect;
388
- * its `main()` calls `argparse.parse_args()` with no argv, so inside a server it would
389
- parse UVICORN's arguments and can `sys.exit(2)` and `SystemExit` is a `BaseException`,
390
- which `except Exception` does not catch. A route that "cannot crash" would crash.
391
-
392
- ⚠ ON THE SPACE THIS IS THE NORMAL PATH, NOT THE ERROR PATH. `deploy_web.py` ships `api/*.py`
393
- and `web/`; `ops/` is not in the image, and AWS credentials live in a local `.env` that is
394
- never deployed. So the honest block below is what the deployed plane shows, and it is written
395
- to be read by an operator as information ("run it here") rather than as a fault.
396
- """
397
- if not _AWS_REPORT.is_file():
398
- return {"available": False, "text": "",
399
- "note": ("The AWS usage report is not part of this deployment — `ops/` ships "
400
- "with the repository, not with the container image. Run "
401
- "`python ops/aws_usage_report.py` locally for the live figures.")}
402
- try:
403
- proc = subprocess.run(
404
- [sys.executable, str(_AWS_REPORT), "--days", str(int(days))],
405
- capture_output=True, text=True, timeout=_AWS_TIMEOUT,
406
- cwd=str(_AWS_REPORT.parent.parent))
407
- except subprocess.TimeoutExpired:
408
- return {"available": False, "text": "",
409
- "note": (f"The AWS report did not answer within {_AWS_TIMEOUT}s — CloudWatch may "
410
- f"be unreachable from here. Nothing was assumed about usage.")}
411
- except Exception as e: # noqa: BLE001
412
- return {"available": False, "text": "",
413
- "note": f"The AWS report could not be run here ({type(e).__name__})."}
414
- out = (proc.stdout or "").strip()
415
- if proc.returncode != 0 or not out:
416
- detail = (proc.stderr or "").strip().splitlines()
417
- return {"available": False, "text": out,
418
- "note": ("AWS credentials are not available here, or boto3 is not installed — "
419
- "no usage could be read, and none is guessed. "
420
- + (detail[-1][:200] if detail else ""))}
421
- return {"available": True, "text": out, "note": ""}
422
-
423
-
424
- # ══════════════════════════════════════════════════════════════════════════ routes
425
-
426
-
427
- @router.get("/overview")
428
- def overview(session: Session = Depends(padmin_gate)):
429
- """THE PLANE'S ONE TABLE: every tenant, with the counts that drill to the rows below.
430
-
431
- Each count is produced by the same collector its `/…` route serves, so the number and its
432
- drill-down are one computation projected twice. A subsystem that cannot be read contributes
433
- an `errors` entry on that tenant's row and a NULL count — never a zero, which would read as
434
- "this customer has no databases" when the truth is "we could not look".
435
- """
436
- t0 = time.time()
437
- all_users = _user_rows()
438
- users_by_tenant = {}
439
- for u in all_users:
440
- users_by_tenant.setdefault(u["tenant"], []).append(u)
441
-
442
- rows = []
443
- for t, rt in _scan():
444
- slug = t["slug"]
445
- row = dict(t)
446
- row["users"] = len(users_by_tenant.get(slug, []))
447
- row["admins"] = sum(1 for u in users_by_tenant.get(slug, []) if u["role"] == "admin")
448
- errors = [t["error"]] if t["error"] else []
449
- if rt is None:
450
- # An unresolvable tenant still shows its ACCOUNTS (they live in the global registry,
451
- # which is readable regardless) everything tenant-store-shaped is honestly unknown.
452
- row.update({"databases": None, "rows": None, "connectors": None,
453
- "automations": None, "keychainLocked": None, "errors": errors})
454
- rows.append(row)
455
- continue
456
- dbs, db_err = _database_rows(slug, rt)
457
- conns, conn_err, locked = _connector_rows(slug, rt)
458
- autos, auto_err, _mr = _automation_rows(slug, rt)
459
- errors += [e for e in (db_err, conn_err, auto_err) if e]
460
- row.update({
461
- "databases": None if db_err else len(dbs),
462
- "rows": None if db_err else sum(d["rowCount"] for d in dbs),
463
- "connectors": None if conn_err else len(conns),
464
- "connectorsPaused": None if conn_err else sum(1 for c in conns if c["paused"]),
465
- "automations": None if auto_err else len(autos),
466
- "automationsEnabled": None if auto_err else sum(1 for a in autos if a["enabled"]),
467
- "keychainLocked": None if conn_err else locked,
468
- "errors": errors,
469
- })
470
- rows.append(row)
471
-
472
- return {
473
- "tenants": rows,
474
- "totals": {
475
- "tenants": len(rows),
476
- # EVERY account, not the sum of the rows: an account whose `tenant` names a slug this
477
- # deployment no longer knows belongs in the platform total and would vanish from a
478
- # per-row sum. `orphanUsers` names that gap instead of hiding it.
479
- "users": len(all_users),
480
- "orphanUsers": len(all_users) - sum(r["users"] for r in rows),
481
- # Sums SKIP unknowns rather than treating them as 0, and say how many were skipped —
482
- # a total that silently absorbs an unreadable tenant is a fabricated total.
483
- "databases": sum(r["databases"] or 0 for r in rows),
484
- "rows": sum(r["rows"] or 0 for r in rows),
485
- "automations": sum(r["automations"] or 0 for r in rows),
486
- "unknownTenants": sum(1 for r in rows if r["databases"] is None),
487
- },
488
- "storeAvailable": bool(_store_ok()),
489
- # ⚠ OFFSET-BEARING, and that is not pedantry. The client renders this as "read 2 minutes
490
- # ago" via `Date.parse`, which reads a NAIVE stamp as browser-local so a UTC container
491
- # and a US viewer would turn "just now" into "5 hours ago", or into a future date that
492
- # renders as "just now" forever. The stamps written by `core.users` carry an offset for
493
- # the same reason; anything a browser subtracts from `Date.now()` must say what zone it
494
- # is in. `verify_api` asserts the offset so this cannot regress to `strftime`.
495
- "generatedAt": _now_iso(),
496
- "tookMs": int((time.time() - t0) * 1000),
497
- }
498
-
499
-
500
- def _store_ok():
501
- try:
502
- return store.available()
503
- except Exception:
504
- return False
505
-
506
-
507
- @router.get("/users")
508
- def platform_users(tenant: str = "", session: Session = Depends(padmin_gate)):
509
- """Every account on the platform, or one tenant's — the drill behind the Users count.
510
-
511
- R4's stamps are the columns that did not exist before this wave: `lastLogin` is written by
512
- `routes_auth.login`, `lastActive` by `deps.require_session` (throttled to once an hour per
513
- account per process). Absent means never seen, and the pane renders it as "never".
514
- """
515
- rows = _user_rows(tenant)
516
- return {"users": rows, "count": len(rows),
517
- "stampsNote": ("Login and activity stamps started with this release — accounts that "
518
- "have not signed in since show no date rather than an invented one. "
519
- "Activity is recorded at most once an hour per account.")}
520
-
521
-
522
- @router.get("/databases")
523
- def platform_databases(tenant: str = "", session: Session = Depends(padmin_gate)):
524
- """Every tenant's user-created databases and row counts the drill behind Databases/Rows."""
525
- out, errors = [], {}
526
- for t, rt in _scan(tenant):
527
- slug = t["slug"]
528
- if rt is None:
529
- errors[slug] = t["error"]
530
- continue
531
- rows, err = _database_rows(slug, rt)
532
- if err:
533
- errors[slug] = err
534
- continue
535
- out += rows
536
- return {"databases": out, "count": len(out),
537
- "rows": sum(d["rowCount"] for d in out), "errors": errors}
538
-
539
-
540
- @router.get("/connectors")
541
- def platform_connectors(tenant: str = "", session: Session = Depends(padmin_gate)):
542
- """Every tenant's data sources, which one is live, and which are paused.
543
-
544
- METADATA ONLY — `keychain.list_entries` never decrypts, so no credential, and no masked
545
- preview either: a platform operator needs to know a source EXISTS and whether it serves, not
546
- what the secret looks like. The tenant's own admin surface is where previews belong.
547
- """
548
- out, errors, locked_any = [], {}, {}
549
- for t, rt in _scan(tenant):
550
- slug = t["slug"]
551
- if rt is None:
552
- errors[slug] = t["error"]
553
- continue
554
- rows, err, locked = _connector_rows(slug, rt)
555
- if err:
556
- errors[slug] = err
557
- continue
558
- locked_any[slug] = locked
559
- out += rows
560
- return {"connectors": out, "count": len(out), "keychainLocked": locked_any,
561
- "errors": errors,
562
- "note": ("A locked keychain means this container has no `AIOS_KEYCHAIN_KEY` — stored "
563
- "credentials cannot be read, so a tenant's sources fail closed rather than "
564
- "falling back to anyone else's.")}
565
-
566
-
567
- @router.get("/automations")
568
- def platform_automations(tenant: str = "", session: Session = Depends(padmin_gate)):
569
- """Every tenant's automations, their schedules and run history, plus the fleet cost model."""
570
- out, errors, retained = [], {}, 20
571
- for t, rt in _scan(tenant):
572
- slug = t["slug"]
573
- if rt is None:
574
- errors[slug] = t["error"]
575
- continue
576
- rows, err, max_runs = _automation_rows(slug, rt)
577
- if err:
578
- errors[slug] = err
579
- continue
580
- retained = max_runs
581
- out += rows
582
- return {"automations": out, "count": len(out),
583
- "enabled": sum(1 for a in out if a["enabled"]),
584
- "historyRetained": retained,
585
- "cost": _automation_cost(out), "errors": errors,
586
- "tickEnabled": os.environ.get("AIOS_AUTOMATIONS") == "1"}
587
-
588
-
589
- @router.get("/aws")
590
- def platform_aws(days: int = 7, session: Session = Depends(padmin_gate)):
591
- """The AWS cron's usage report, verbatim, or an honest note explaining its absence."""
592
- days = max(1, min(int(days or 7), 90))
593
- return {"days": days, "report": _aws_report(days)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_platform_admin.py — THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 / R3+R4).
2
+
3
+ Every other admin surface in this product answers questions about ONE tenant. This one answers
4
+ questions about the PLATFORM: who our customers are, what they are running, whether their data
5
+ sources are alive, and what the automation fleet costs. It is the first cross-tenant reader that
6
+ has ever existed here, which is why it is also the most carefully walled.
7
+
8
+ ⛔ THE WALL. `padmin_gate` is `core.platform_admin.is_platform_admin` — a DOUBLE lock (the record
9
+ flag AND the `loopable` tenant), fail-closed, applied to every single route in this file through
10
+ one dependency. A tenant admin is NOT admitted: `role: 'admin'` is Royal's or Nurilab's authority
11
+ over their own workspace and it must never widen into a view of each other. `verify_api.py`'s
12
+ W19-ADMIN section proves that by ENUMERATING this router and having a tenant admin try every path
13
+ it declares, so a route added later cannot quietly ship without the wall.
14
+
15
+ ⛔ CROSS-TENANT READS GO THROUGH EACH TENANT'S OWN RUNTIME. `runtime.get_runtime(slug)` per tenant,
16
+ then `rt.get(...)` — never `core.store.get("<raw key>")`. The runtime is what applies the store
17
+ NAMESPACE (`t/<slug>/…`) or binds the tenant's OWN dataset repo (R2), so reading raw keys would
18
+ silently return tenant #0's data labelled as somebody else's — a wrong answer that looks right,
19
+ which is the worst failure this plane could have.
20
+
21
+ HONEST DEGRADATION IS THE WHOLE DESIGN, NOT AN ERROR PATH. This plane reads six subsystems across
22
+ N tenants; on any given day one of them can be unreachable (a suspended tenant record, a locked
23
+ keychain, an HF repo hiccup, no AWS credentials in this container). A 500 would take the entire
24
+ dashboard down because one cell could not be filled. So every collector catches, and every row can
25
+ carry an `error` string that the pane RENDERS — "unknown" is a real answer and it is never
26
+ rendered as a zero. ([[gate-can-report-green-on-nothing]]: a fabricated 0 and a true 0 must not
27
+ look alike.)
28
+
29
+ EVERY COUNT DRILLS TO ROWS. `/overview`'s per-tenant counts are computed by the SAME collector
30
+ functions the `/users`, `/databases`, `/connectors` and `/automations` routes serve rows from, so
31
+ a number and its drill-down cannot disagree — they are one computation, projected twice
32
+ ([[no-unverifiable-aggregates]]).
33
+ """
34
+ import json
35
+ import os
36
+ import subprocess
37
+ import sys
38
+ import time
39
+ from pathlib import Path
40
+
41
+ from fastapi import APIRouter, Depends
42
+
43
+ import core.platform_admin as platform_admin
44
+ import core.store as store
45
+ from deps import Session, err, require_session, users
46
+
47
+ router = APIRouter(prefix="/api/v1/platform-admin")
48
+
49
+
50
+ def _now_iso():
51
+ """UTC, offset-bearing the one stamp format anything client-side may subtract from."""
52
+ import datetime as _dt
53
+ return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
54
+
55
+
56
+ def padmin_gate(session: Session = Depends(require_session)) -> Session:
57
+ """401 without a session, 403 unless this account is a Loopable platform operator.
58
+
59
+ ONE dependency for the whole router. A per-route check is a per-route chance to forget, and
60
+ the thing being forgotten here would be every customer's data at once.
61
+
62
+ The message deliberately does not confirm that a platform plane exists for somebody else
63
+ a tenant admin who pokes at this URL learns only that their account cannot open it.
64
+ """
65
+ if not platform_admin.is_platform_admin(session.user):
66
+ raise err(403, "forbidden", "your account does not have access to this surface")
67
+ return session
68
+
69
+
70
+ # ══════════════════════════════════════════════════════════════════════════ tenants
71
+
72
+
73
+ def _tenant_bucket():
74
+ """The control-plane `tenants` records, or {} — a platform fact, in the DEFAULT store."""
75
+ from harness import runtime
76
+ try:
77
+ recs = store.get(runtime.TENANTS_KEY) or {}
78
+ return {str(k).strip().lower(): v for k, v in recs.items() if isinstance(v, dict)}
79
+ except Exception:
80
+ return {}
81
+
82
+
83
+ def _slugs():
84
+ """Every tenant this deployment knows: compiled builders + control-plane records.
85
+
86
+ `runtime.known_tenants()` is the union and it is the same list login resolves against, so
87
+ this plane cannot show a customer the door does not recognise (or miss one it does).
88
+ """
89
+ from harness import runtime
90
+ try:
91
+ return list(runtime.known_tenants())
92
+ except Exception:
93
+ return sorted(_tenant_bucket())
94
+
95
+
96
+ def _runtime_for(slug):
97
+ """(runtime, error). A tenant whose runtime will not build is a ROW WITH A NOTE, never a 500.
98
+
99
+ Two real cases produce one: a SUSPENDED record (`get_runtime` raises KeyError by design
100
+ "which tenants exist" is not a question the login path answers), and tenant #0's builder,
101
+ which makes a LIVE Odoo call (`harness.tenants.royal_imports` `excluded_customer_ids`) and
102
+ therefore fails on a box that cannot reach the ERP. Neither may take the dashboard down.
103
+ """
104
+ from harness import runtime
105
+ try:
106
+ return runtime.get_runtime(slug), ""
107
+ except KeyError:
108
+ return None, "not resolvable (suspended, or no builder and no active record)"
109
+ except Exception as e: # noqa: BLE001
110
+ return None, f"runtime unavailable ({type(e).__name__})"
111
+
112
+
113
+ def _scan(want=""):
114
+ """[(row, runtime)] — every tenant (or one), with its runtime RESOLVED EXACTLY ONCE.
115
+
116
+ The one-resolution rule is not tidiness. `get_runtime` is LRU-cached on success, but a tenant
117
+ that FAILS to build is not cached, and tenant #0's builder makes a live Odoo call — so a route
118
+ that asked twice would pay the timeout twice, and `/overview` (which asks about six
119
+ subsystems) would pay it six times. Resolve once, pass the handle down.
120
+ """
121
+ bucket = _tenant_bucket()
122
+ want = str(want or "").strip().lower()
123
+ out = []
124
+ for slug in _slugs():
125
+ if want and slug != want:
126
+ continue
127
+ rec = bucket.get(slug) or {}
128
+ rt, error = _runtime_for(slug)
129
+ out.append(({
130
+ "slug": slug,
131
+ # The record's name, else the built runtime's, else the slug. Tenant #0 is compiled
132
+ # and has no record, so without the runtime fallback it would render as "royal-imports".
133
+ "name": str(rec.get("name") or (getattr(rt, "name", "") if rt else "") or slug),
134
+ "source": "record" if rec else "compiled",
135
+ "status": str(rec.get("status") or ("active" if rt else "unknown")),
136
+ "domains": list(rec.get("domains") or []),
137
+ "modules": rec.get("modules", "all" if not rec else []),
138
+ # R2: the isolation shape. "own repo" and "shared repo + prefix" are genuinely
139
+ # different blast radii and an operator should be able to see which is which.
140
+ "storeRepo": rec.get("store_repo") or ("shared (tenant #0 repo)" if not rec else ""),
141
+ "storePrefix": getattr(rt, "store_namespace", "") if rt else "",
142
+ "error": error,
143
+ }, rt))
144
+ return out
145
+
146
+
147
+ # ══════════════════════════════════════════════════════════════════════════ users
148
+
149
+
150
+ def _user_rows(tenant=None):
151
+ """Accounts across every tenant, from the GLOBAL registry (`users.json` is control-plane).
152
+
153
+ ⛔ NEVER `salt` OR `hash`. This projection is the only one these routes use, mirroring
154
+ `routes_admin._view`'s discipline: one function that can leak, and it does not.
155
+
156
+ R4's two new fields ride here `lastLogin` / `lastActive`, absent on every pre-wave record,
157
+ rendered as "never" rather than as a fabricated date.
158
+ """
159
+ try:
160
+ reg = users.registry() or {}
161
+ except Exception:
162
+ return []
163
+ want = str(tenant or "").strip().lower()
164
+ rows = []
165
+ for uname, rec in sorted(reg.items()):
166
+ if not isinstance(rec, dict):
167
+ continue
168
+ slug = str(rec.get("tenant") or "royal-imports").strip().lower()
169
+ if want and slug != want:
170
+ continue
171
+ rows.append({
172
+ "username": uname,
173
+ "name": rec.get("name") or uname,
174
+ "email": rec.get("email") or "",
175
+ "tenant": slug,
176
+ "role": rec.get("role", "user"),
177
+ "active": bool(rec.get("active", True)),
178
+ "lastLogin": rec.get("last_login") or "",
179
+ "lastActive": rec.get("last_active") or "",
180
+ "platformAdmin": rec.get("platform_admin") is True,
181
+ })
182
+ return rows
183
+
184
+
185
+ # ══════════════════════════════════════════════════════════════════════════ databases
186
+
187
+
188
+ def _database_rows(slug, rt):
189
+ """A tenant's user-created databases (`ut_*`) with their row counts.
190
+
191
+ Via `core.user_tables.all_tables(st=rt)` the TenantRuntime, never the module-global, which
192
+ the engine's own header (`automation_engine.py:49-52`) flags as a cross-tenant defect for
193
+ every R2 tenant. That booked defect is exactly the mistake a cross-tenant reader would make
194
+ most easily, so it is stated at the call site too.
195
+ """
196
+ try:
197
+ import core.user_tables as ut
198
+ tables = ut.all_tables(st=rt) or {}
199
+ except Exception as e: # noqa: BLE001
200
+ return [], f"databases unreadable ({type(e).__name__})"
201
+ rows = []
202
+ for key, t in sorted(tables.items(), key=lambda kv: (kv[1].get("label") or "").lower()):
203
+ if not isinstance(t, dict):
204
+ continue
205
+ rows.append({
206
+ "tenant": slug,
207
+ "key": key,
208
+ "label": t.get("label") or key,
209
+ "source": t.get("source") or "Blank",
210
+ "createdBy": t.get("createdBy") or "",
211
+ "created": t.get("created") or "",
212
+ "fields": len(t.get("fields") or []),
213
+ "rowCount": len(t.get("rows") or {}),
214
+ })
215
+ return rows, ""
216
+
217
+
218
+ # ════════════════════���═════════════════════════════════════════════════════ connectors
219
+
220
+
221
+ def _connector_rows(slug, rt):
222
+ """A tenant's data sources and which one is actually RESOLVED (i.e. would serve a query).
223
+
224
+ `_resolved_odoo_key` is imported from `routes_keychain` rather than re-derived. It is the
225
+ route-level mirror of `TenantRuntime.odoo_source()`, it takes a runtime (not a session), and
226
+ a second copy of that resolution here would be a copy that drifts at which point this plane
227
+ would confidently name the wrong live connector. Reused, not restated.
228
+ """
229
+ try:
230
+ import core.keychain as keychain
231
+ from routes_keychain import _CONNECTOR_FLAGS_KEY, _resolved_odoo_key
232
+ entries = keychain.list_entries(rt)
233
+ locked = not keychain.unlocked()
234
+ resolved, _flag = _resolved_odoo_key(rt)
235
+ flags = rt.get(_CONNECTOR_FLAGS_KEY) or {}
236
+ except Exception as e: # noqa: BLE001
237
+ return [], f"connectors unreadable ({type(e).__name__})", False
238
+
239
+ rows = []
240
+ if slug == "royal-imports" and os.environ.get("ODOO_URL"):
241
+ rows.append({"tenant": slug, "key": "odoo-env", "label": "Odoo (environment)",
242
+ "type": "odoo", "source": "env", "active": resolved == "env",
243
+ "paused": bool((flags.get("odoo-env") or {}).get("paused"))})
244
+ for e in entries:
245
+ rows.append({"tenant": slug, "key": e["id"], "label": e["label"], "type": e["type"],
246
+ "source": "keychain",
247
+ "active": e["type"] == "odoo" and resolved == f"keychain:{e['id']}",
248
+ "paused": bool((flags.get(e["id"]) or {}).get("paused"))})
249
+ return rows, "", locked
250
+
251
+
252
+ # ══════════════════════════════════════════════════════════════════════════ automations + cost
253
+
254
+ #: THE UNIT MODEL, from `ops/provision_automation_cron.py` (its cost note at :25-28 and the
255
+ #: function it actually provisions at :211 / :269). Restated as constants rather than as prose so
256
+ #: the arithmetic below is re-checkable against the thing that was really deployed:
257
+ #: EventBridge Scheduler `rate(15 minutes)` → a 128 MB, 30 s-timeout Lambda that POSTs the tick.
258
+ TICK_CADENCE = "rate(15 minutes)"
259
+ TICK_PER_DAY = 96 # 1440 / 15
260
+ LAMBDA_MB = 128
261
+ FREE_LAMBDA_REQUESTS = 1_000_000 # AWS always-free, per month
262
+ FREE_SCHEDULER_INVOCATIONS = 14_000_000
263
+ DAYS_PER_MONTH = 30.4
264
+
265
+
266
+ def _runs_per_day(cron):
267
+ """Scheduled runs/day for the five-field crons this product offers, or None.
268
+
269
+ DELIBERATELY NARROW. It answers exactly the shapes `automation_engine.CRON_PRESETS` can
270
+ produce (every-N-minutes, hourly, daily, weekly, monthly) and returns None for anything else
271
+ an unparsed cadence is reported as "custom", never as a guessed number that would then be
272
+ multiplied into a cost. A wrong denominator is worse than an absent one.
273
+
274
+ AND IT IS NOT MEASURED FROM HISTORY, on purpose: `automation_engine.MAX_RUNS` trims run
275
+ history to 20 entries, so a 15-minute automation retains ~5 hours of it. Deriving runs/day
276
+ from that window would understate by ~5x. History is reported as what it is — the last N runs.
277
+ """
278
+ parts = str(cron or "").split()
279
+ if len(parts) != 5:
280
+ return None
281
+ minute, hour, dom, _mon, dow = parts
282
+ if minute.startswith("*/") and hour == "*":
283
+ try:
284
+ step = int(minute[2:])
285
+ except ValueError:
286
+ return None
287
+ return (1440.0 / step) if step > 0 else None
288
+ if minute.isdigit() and hour == "*":
289
+ return 24.0
290
+ if minute.isdigit() and hour.isdigit():
291
+ if dom.isdigit():
292
+ return 1.0 / DAYS_PER_MONTH # monthly
293
+ if dow.isdigit():
294
+ return 1.0 / 7.0 # weekly
295
+ return 1.0 # daily
296
+ return None
297
+
298
+
299
+ def _automation_rows(slug, rt):
300
+ """A tenant's automations, their schedules, and their retained run history."""
301
+ try:
302
+ import automation_engine as engine
303
+ defs = engine.all_definitions(rt) or {}
304
+ max_runs = int(getattr(engine, "MAX_RUNS", 20))
305
+ except Exception as e: # noqa: BLE001
306
+ return [], f"automations unreadable ({type(e).__name__})", 20
307
+ rows = []
308
+ for auto_id, d in sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower()):
309
+ if not isinstance(d, dict):
310
+ continue
311
+ sched = d.get("schedule") or {}
312
+ status = d.get("status") or {}
313
+ runs = list(d.get("runs") or [])
314
+ cron = sched.get("cron") or ""
315
+ per_day = _runs_per_day(cron) if sched.get("enabled") else 0.0
316
+ rows.append({
317
+ "tenant": slug,
318
+ "id": auto_id,
319
+ "name": d.get("name") or auto_id,
320
+ "kind": d.get("kind") or "",
321
+ "enabled": bool(sched.get("enabled")),
322
+ "cron": cron,
323
+ "runsPerDay": round(per_day, 2) if per_day is not None else None,
324
+ "state": status.get("state") or "idle",
325
+ "lastRunAt": status.get("lastRunAt") or "",
326
+ "lastSummary": status.get("lastSummary") or "",
327
+ # The retained window, named as such — see `_runs_per_day`'s second warning.
328
+ "runsRetained": len(runs),
329
+ "failedRetained": sum(1 for r in runs if isinstance(r, dict) and not r.get("ok")),
330
+ "createdBy": d.get("createdBy") or "",
331
+ "created": d.get("created") or "",
332
+ })
333
+ return rows, "", max_runs
334
+
335
+
336
+ def _automation_cost(auto_rows):
337
+ """The estimated monthly cost of running the automation fleet — and the honest shape of it.
338
+
339
+ THE POINT THIS BLOCK EXISTS TO MAKE, which is not intuitive: **the external cron does not
340
+ scale with automations or tenants.** One EventBridge schedule fires one Lambda, which POSTs
341
+ one tick, and that tick runs every DUE automation for every tenant (`provision_automation_cron`
342
+ states this as the reason it stays $0 "at ten tenants"). So the AWS bill is a function of the
343
+ CADENCE alone — the fleet below adds work inside the API container, which is already paid for.
344
+
345
+ WHAT IS NOT COMPUTED HERE, deliberately: Lambda GB-seconds. That needs the real average
346
+ duration of the function, which only CloudWatch knows; assuming one would be inventing the
347
+ larger half of the free-tier calculation. `/aws` reports the measured figure when credentials
348
+ are available, and this block says so rather than filling the gap with a plausible number.
349
+ """
350
+ invocations = TICK_PER_DAY * DAYS_PER_MONTH
351
+ fleet_runs = sum(r["runsPerDay"] or 0 for r in auto_rows if r.get("enabled"))
352
+ unknown_cadence = sum(1 for r in auto_rows if r.get("enabled") and r.get("runsPerDay") is None)
353
+ return {
354
+ "cadence": TICK_CADENCE,
355
+ "invocationsPerMonth": int(round(invocations)),
356
+ "freeRequestsPct": round(100.0 * invocations / FREE_LAMBDA_REQUESTS, 3),
357
+ "freeSchedulerPct": round(100.0 * invocations / FREE_SCHEDULER_INVOCATIONS, 4),
358
+ "lambdaMb": LAMBDA_MB,
359
+ "usd": 0.0,
360
+ "fleetRunsPerDay": round(fleet_runs, 2),
361
+ "unknownCadence": unknown_cadence,
362
+ "basis": (
363
+ f"One EventBridge schedule ({TICK_CADENCE}) fires one {LAMBDA_MB} MB Lambda that "
364
+ f"POSTs the tick; that ONE tick runs every due automation for every tenant, so the "
365
+ f"AWS cost is set by the cadence and does not grow with the fleet. "
366
+ f"{int(round(invocations)):,} invocations/month is "
367
+ f"{round(100.0 * invocations / FREE_LAMBDA_REQUESTS, 3)}% of the 1,000,000-request "
368
+ f"always-free tier, so the marginal cost of the automations below is $0.00. "
369
+ f"Compute (GB-seconds) depends on measured durations and is NOT estimated here — "
370
+ f"the AWS report reads the real figure when credentials are available."
371
+ ),
372
+ }
373
+
374
+
375
+ # ══════════════════════════════════════════════════════════════════════════ the AWS report
376
+
377
+ #: `ops/aws_usage_report.py`, relative to this file: aios-web/api/ -> repo root -> ops/.
378
+ _AWS_REPORT = Path(__file__).resolve().parents[2] / "ops" / "aws_usage_report.py"
379
+ _AWS_TIMEOUT = 60
380
+
381
+
382
+ def _aws_report(days=7):
383
+ """The AWS usage report as text, or an honest block saying why there is none.
384
+
385
+ SUBPROCESS, NEVER `import`. Two concrete reasons, both found by reading that file rather
386
+ than by running it:
387
+ * it reassigns `sys.stdout` to a UTF-8 wrapper AT MODULE IMPORT (a cp1252 fix for this
388
+ box) importing it would mutate the API process's stdout as a side effect;
389
+ * its `main()` calls `argparse.parse_args()` with no argv, so inside a server it would
390
+ parse UVICORN's arguments and can `sys.exit(2)` and `SystemExit` is a `BaseException`,
391
+ which `except Exception` does not catch. A route that "cannot crash" would crash.
392
+
393
+ ON THE SPACE THIS IS THE NORMAL PATH, NOT THE ERROR PATH. `deploy_web.py` ships `api/*.py`
394
+ and `web/`; `ops/` is not in the image, and AWS credentials live in a local `.env` that is
395
+ never deployed. So the honest block below is what the deployed plane shows, and it is written
396
+ to be read by an operator as information ("run it here") rather than as a fault.
397
+ """
398
+ if not _AWS_REPORT.is_file():
399
+ return {"available": False, "text": "",
400
+ "note": ("The AWS usage report is not part of this deployment `ops/` ships "
401
+ "with the repository, not with the container image. Run "
402
+ "`python ops/aws_usage_report.py` locally for the live figures.")}
403
+ try:
404
+ proc = subprocess.run(
405
+ [sys.executable, str(_AWS_REPORT), "--days", str(int(days))],
406
+ capture_output=True, text=True, timeout=_AWS_TIMEOUT,
407
+ cwd=str(_AWS_REPORT.parent.parent))
408
+ except subprocess.TimeoutExpired:
409
+ return {"available": False, "text": "",
410
+ "note": (f"The AWS report did not answer within {_AWS_TIMEOUT}s — CloudWatch may "
411
+ f"be unreachable from here. Nothing was assumed about usage.")}
412
+ except Exception as e: # noqa: BLE001
413
+ return {"available": False, "text": "",
414
+ "note": f"The AWS report could not be run here ({type(e).__name__})."}
415
+ out = (proc.stdout or "").strip()
416
+ if proc.returncode != 0 or not out:
417
+ detail = (proc.stderr or "").strip().splitlines()
418
+ return {"available": False, "text": out,
419
+ "note": ("AWS credentials are not available here, or boto3 is not installed — "
420
+ "no usage could be read, and none is guessed. "
421
+ + (detail[-1][:200] if detail else ""))}
422
+ return {"available": True, "text": out, "note": ""}
423
+
424
+
425
+ # ══════════════════════════════════════════════════════════════════════════ routes
426
+
427
+
428
+ @router.get("/overview")
429
+ def overview(session: Session = Depends(padmin_gate)):
430
+ """THE PLANE'S ONE TABLE: every tenant, with the counts that drill to the rows below.
431
+
432
+ Each count is produced by the same collector its `/…` route serves, so the number and its
433
+ drill-down are one computation projected twice. A subsystem that cannot be read contributes
434
+ an `errors` entry on that tenant's row and a NULL count never a zero, which would read as
435
+ "this customer has no databases" when the truth is "we could not look".
436
+ """
437
+ t0 = time.time()
438
+ all_users = _user_rows()
439
+ users_by_tenant = {}
440
+ for u in all_users:
441
+ users_by_tenant.setdefault(u["tenant"], []).append(u)
442
+
443
+ rows = []
444
+ for t, rt in _scan():
445
+ slug = t["slug"]
446
+ row = dict(t)
447
+ row["users"] = len(users_by_tenant.get(slug, []))
448
+ row["admins"] = sum(1 for u in users_by_tenant.get(slug, []) if u["role"] == "admin")
449
+ errors = [t["error"]] if t["error"] else []
450
+ if rt is None:
451
+ # An unresolvable tenant still shows its ACCOUNTS (they live in the global registry,
452
+ # which is readable regardless) — everything tenant-store-shaped is honestly unknown.
453
+ row.update({"databases": None, "rows": None, "connectors": None,
454
+ "automations": None, "keychainLocked": None, "errors": errors})
455
+ rows.append(row)
456
+ continue
457
+ dbs, db_err = _database_rows(slug, rt)
458
+ conns, conn_err, locked = _connector_rows(slug, rt)
459
+ autos, auto_err, _mr = _automation_rows(slug, rt)
460
+ errors += [e for e in (db_err, conn_err, auto_err) if e]
461
+ row.update({
462
+ "databases": None if db_err else len(dbs),
463
+ "rows": None if db_err else sum(d["rowCount"] for d in dbs),
464
+ "connectors": None if conn_err else len(conns),
465
+ "connectorsPaused": None if conn_err else sum(1 for c in conns if c["paused"]),
466
+ "automations": None if auto_err else len(autos),
467
+ "automationsEnabled": None if auto_err else sum(1 for a in autos if a["enabled"]),
468
+ "keychainLocked": None if conn_err else locked,
469
+ "errors": errors,
470
+ })
471
+ rows.append(row)
472
+
473
+ return {
474
+ "tenants": rows,
475
+ "totals": {
476
+ "tenants": len(rows),
477
+ # EVERY account, not the sum of the rows: an account whose `tenant` names a slug this
478
+ # deployment no longer knows belongs in the platform total and would vanish from a
479
+ # per-row sum. `orphanUsers` names that gap instead of hiding it.
480
+ "users": len(all_users),
481
+ "orphanUsers": len(all_users) - sum(r["users"] for r in rows),
482
+ # Sums SKIP unknowns rather than treating them as 0, and say how many were skipped —
483
+ # a total that silently absorbs an unreadable tenant is a fabricated total.
484
+ "databases": sum(r["databases"] or 0 for r in rows),
485
+ "rows": sum(r["rows"] or 0 for r in rows),
486
+ "automations": sum(r["automations"] or 0 for r in rows),
487
+ "unknownTenants": sum(1 for r in rows if r["databases"] is None),
488
+ },
489
+ "storeAvailable": bool(_store_ok()),
490
+ # OFFSET-BEARING, and that is not pedantry. The client renders this as "read 2 minutes
491
+ # ago" via `Date.parse`, which reads a NAIVE stamp as browser-local so a UTC container
492
+ # and a US viewer would turn "just now" into "5 hours ago", or into a future date that
493
+ # renders as "just now" forever. The stamps written by `core.users` carry an offset for
494
+ # the same reason; anything a browser subtracts from `Date.now()` must say what zone it
495
+ # is in. `verify_api` asserts the offset so this cannot regress to `strftime`.
496
+ "generatedAt": _now_iso(),
497
+ "tookMs": int((time.time() - t0) * 1000),
498
+ }
499
+
500
+
501
+ def _store_ok():
502
+ try:
503
+ return store.available()
504
+ except Exception:
505
+ return False
506
+
507
+
508
+ @router.get("/users")
509
+ def platform_users(tenant: str = "", session: Session = Depends(padmin_gate)):
510
+ """Every account on the platform, or one tenant's — the drill behind the Users count.
511
+
512
+ R4's stamps are the columns that did not exist before this wave: `lastLogin` is written by
513
+ `routes_auth.login`, `lastActive` by `deps.require_session` (throttled to once an hour per
514
+ account per process). Absent means never seen, and the pane renders it as "never".
515
+ """
516
+ rows = _user_rows(tenant)
517
+ return {"users": rows, "count": len(rows),
518
+ "stampsNote": ("Login and activity stamps started with this release accounts that "
519
+ "have not signed in since show no date rather than an invented one. "
520
+ "Activity is recorded at most once an hour per account.")}
521
+
522
+
523
+ @router.get("/databases")
524
+ def platform_databases(tenant: str = "", session: Session = Depends(padmin_gate)):
525
+ """Every tenant's user-created databases and row counts — the drill behind Databases/Rows."""
526
+ out, errors = [], {}
527
+ for t, rt in _scan(tenant):
528
+ slug = t["slug"]
529
+ if rt is None:
530
+ errors[slug] = t["error"]
531
+ continue
532
+ rows, err = _database_rows(slug, rt)
533
+ if err:
534
+ errors[slug] = err
535
+ continue
536
+ out += rows
537
+ return {"databases": out, "count": len(out),
538
+ "rows": sum(d["rowCount"] for d in out), "errors": errors}
539
+
540
+
541
+ @router.get("/connectors")
542
+ def platform_connectors(tenant: str = "", session: Session = Depends(padmin_gate)):
543
+ """Every tenant's data sources, which one is live, and which are paused.
544
+
545
+ METADATA ONLY `keychain.list_entries` never decrypts, so no credential, and no masked
546
+ preview either: a platform operator needs to know a source EXISTS and whether it serves, not
547
+ what the secret looks like. The tenant's own admin surface is where previews belong.
548
+ """
549
+ out, errors, locked_any = [], {}, {}
550
+ for t, rt in _scan(tenant):
551
+ slug = t["slug"]
552
+ if rt is None:
553
+ errors[slug] = t["error"]
554
+ continue
555
+ rows, err, locked = _connector_rows(slug, rt)
556
+ if err:
557
+ errors[slug] = err
558
+ continue
559
+ locked_any[slug] = locked
560
+ out += rows
561
+ return {"connectors": out, "count": len(out), "keychainLocked": locked_any,
562
+ "errors": errors,
563
+ "note": ("A locked keychain means this container has no `AIOS_KEYCHAIN_KEY` stored "
564
+ "credentials cannot be read, so a tenant's sources fail closed rather than "
565
+ "falling back to anyone else's.")}
566
+
567
+
568
+ @router.get("/automations")
569
+ def platform_automations(tenant: str = "", session: Session = Depends(padmin_gate)):
570
+ """Every tenant's automations, their schedules and run history, plus the fleet cost model."""
571
+ out, errors, retained = [], {}, 20
572
+ for t, rt in _scan(tenant):
573
+ slug = t["slug"]
574
+ if rt is None:
575
+ errors[slug] = t["error"]
576
+ continue
577
+ rows, err, max_runs = _automation_rows(slug, rt)
578
+ if err:
579
+ errors[slug] = err
580
+ continue
581
+ retained = max_runs
582
+ out += rows
583
+ return {"automations": out, "count": len(out),
584
+ "enabled": sum(1 for a in out if a["enabled"]),
585
+ "historyRetained": retained,
586
+ "cost": _automation_cost(out), "errors": errors,
587
+ "tickEnabled": os.environ.get("AIOS_AUTOMATIONS") == "1"}
588
+
589
+
590
+ @router.get("/aws")
591
+ def platform_aws(days: int = 7, session: Session = Depends(padmin_gate)):
592
+ """The AWS cron's usage report, verbatim, or an honest note explaining its absence."""
593
+ days = max(1, min(int(days or 7), 90))
594
+ return {"days": days, "report": _aws_report(days)}
595
+
596
+
597
+ @router.get("/releases")
598
+ def platform_releases(session: Session = Depends(padmin_gate)):
599
+ """⭐ WAVE 20 (owner item 12, ruling R6) — WHAT IS RUNNING WHERE, and what else could be.
600
+
601
+ Owner: *"Make this part of the deploy skill. Also ability to revert to any version we want.
602
+ Make sure App versioning is something that our company admin (loopable, non-tenant) can
603
+ easily see."* This is the SEEING half; promoting stays a CLI command by R6, so nothing here
604
+ writes and no web session can move production.
605
+
606
+ ⛔ THE HUB READS LIVE IN `core/releases.py`, NOT HERE. `ops/verify_portability.py` B2 forbids
607
+ the HuggingFace SDK anywhere under `aios-web/api/` — the API process is host-agnostic by
608
+ design — and it caught this endpoint's first draft doing exactly that. Same delegation shape
609
+ as `routes_assets` -> `core/assets.py`.
610
+ """
611
+ import core.releases as releases
612
+
613
+ return {"here": os.environ.get("AIOS_VERSION") or "unknown",
614
+ "environments": releases.environments(),
615
+ "releases": releases.history(),
616
+ # The runbook, in the payload, because the panel is read-only BY DESIGN and a user
617
+ # looking at it is exactly the person who needs to know how to move a version.
618
+ "promote": "python aios-web/deploy_web.py --promote=vN (or --promote=staging)"}
api/routes_shares.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. Roles are `view` | `edit` — the same two words the view rail
8
+ already speaks, now extended to folders and databases so there is ONE vocabulary in the UI
9
+ (R10: "the same picker views use").
10
+
11
+ ⛔ **RE-SHARING IS THE OWNER'S, AND THAT IS ENFORCED HERE, NOT IN THE CLIENT.** `PUT` requires
12
+ `shares.may_administer` (owner or admin). A collaborator with `edit` may change an object's
13
+ CONTENT and may not change who else can reach it — otherwise anyone you shared a view with could
14
+ widen it to everyone, or grant themselves ownership and lock you out. The client greys the editor
15
+ for non-administrators; that is a courtesy, and this check is the wall.
16
+
17
+ ⚠ **THE GRANT NEVER WIDENS PAST THE MODULE WALL.** `*` ("everyone") means every account that can
18
+ already open the surface — `require_session` plus the topic's own gate run first and are
19
+ unaffected by anything here. Sharing can only ever narrow-or-equal the set that could already
20
+ reach the data ([[aios-permissioning]]).
21
+ """
22
+ from fastapi import APIRouter, Body, Depends
23
+
24
+ import core.shares as shares
25
+ import core.users as users
26
+ from deps import Session, err, require_session
27
+
28
+ router = APIRouter(prefix="/api/v1")
29
+
30
+
31
+ def _kind_or_400(raw):
32
+ try:
33
+ return shares._check_kind(raw)
34
+ except ValueError as e:
35
+ raise err(400, "bad_kind", str(e))
36
+
37
+
38
+ @router.get("/share/mine")
39
+ def my_shares(session: Session = Depends(require_session)):
40
+ """Everything shared WITH me, by kind — the "Shared with me" rail section (R10).
41
+
42
+ Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
43
+ in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
44
+ that is not malformed at all.
45
+ """
46
+ return shares.shared_with(session.uname, st=session.runtime)
47
+
48
+
49
+ @router.get("/share/{kind}/{oid}")
50
+ def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
51
+ kind = _kind_or_400(kind)
52
+ rec = shares.grants(kind, oid, st=session.runtime)
53
+ return {
54
+ **rec,
55
+ "role": shares.role_for(kind, oid, session.uname, is_admin=session.admin,
56
+ st=session.runtime),
57
+ "mayAdminister": shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
58
+ st=session.runtime),
59
+ # The people this account may share WITH — reusing the ONE definition of an assignable
60
+ # person (`core.users.assignable_people`, the same list the `user` field kind offers), so
61
+ # the share picker and the assignee picker can never disagree about who exists.
62
+ "people": users.assignable_people(tenant=session.tenant),
63
+ }
64
+
65
+
66
+ @router.put("/share/{kind}/{oid}")
67
+ def put_share(kind: str, oid: str, body: dict = Body(default=None),
68
+ session: Session = Depends(require_session)):
69
+ kind = _kind_or_400(kind)
70
+ body = body or {}
71
+ rec = shares.grants(kind, oid, st=session.runtime)
72
+ # An object with NO grant record yet has no owner — the first person to share it claims it.
73
+ # That is safe because reaching this route at all means passing the surface's own wall, and
74
+ # the alternative (refusing until somebody seeds an owner) would make a brand-new folder
75
+ # unshareable by the person who just made it.
76
+ if rec["owner"] and not shares.may_administer(kind, oid, session.uname,
77
+ is_admin=session.admin, st=session.runtime):
78
+ raise err(403, "not_owner",
79
+ "only the owner of this item (or an administrator) can change who it is "
80
+ "shared with")
81
+ entries = body.get("entries")
82
+ if not isinstance(entries, list):
83
+ raise err(400, "bad_entries",
84
+ "entries must be a list of {user, role} — send [] to un-share, which is how "
85
+ "revoking is expressed")
86
+ return shares.set_grants(kind, oid, entries, owner=rec["owner"] or session.uname,
87
+ st=session.runtime)
api/routes_tables.py CHANGED
@@ -107,16 +107,42 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
107
  "defn": defn}
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  @router.get("/tables")
111
  def list_tables(session: Session = Depends(require_session)):
112
  """This session's user tables — the list the '+ New database' surface renders."""
113
  ut = _ut()
 
114
  out = []
115
  for key, t in sorted(ut.all_tables(st=session.runtime).items(),
116
- key=lambda kv: (kv[1].get("label") or "").lower()):
117
  if not ut.may_open(key, session.uname, session.admin, st=session.runtime):
118
  continue
119
- out.append({"key": key, "label": t.get("label") or key,
120
  "source": t.get("source") or "Blank",
121
  "createdBy": t.get("createdBy") or "",
122
  "created": t.get("created") or "",
@@ -150,10 +176,40 @@ def create_table(body: dict = Body(default=None),
150
  return {"key": key}
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  @router.delete("/tables/{table_key}")
154
  def delete_table(table_key: str, session: Session = Depends(require_session)):
155
  """Creator or admin only — the same actors `may_open` admits, and deletion is the one
156
- operation the client must confirm explicitly (the server cannot tell a click from a plan)."""
 
 
 
 
 
 
 
 
 
157
  _defn_or_refuse(session, table_key)
158
  try:
159
  _ut().delete(table_key, st=session.runtime)
@@ -194,13 +250,20 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
194
  @router.post("/tables/{table_key}/rows", status_code=201)
195
  def add_row(table_key: str, body: dict = Body(default=None),
196
  session: Session = Depends(require_session)):
 
 
 
 
 
 
197
  _defn_or_refuse(session, table_key)
198
  ut = _ut()
199
  values = (body or {}).get("values") or {}
200
  if not isinstance(values, dict):
201
  raise err(400, "bad_values", "values must be an object of {fieldKey: value}")
202
  try:
203
- rid = ut.add_row(table_key, values, session.uname, st=session.runtime)
 
204
  except Exception:
205
  raise err(503, "store_unavailable", "the row was not saved — the store refused")
206
  if rid is None:
@@ -209,6 +272,100 @@ def add_row(table_key: str, body: dict = Body(default=None),
209
  return {"rid": rid, "pid": int(rid)}
210
 
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  @router.delete("/tables/{table_key}/rows/{rid}")
213
  def delete_row(table_key: str, rid: str, session: Session = Depends(require_session)):
214
  _defn_or_refuse(session, table_key)
 
107
  "defn": defn}
108
 
109
 
110
+ def ut_label(defn, key, meta=None):
111
+ """THE name of a user table, resolved ONCE (wave 20, item 6a).
112
+
113
+ `nav_meta`'s rename wins, then the definition's own label, then the key. Every surface that
114
+ shows a database name reads through here, because the alternative is what wave 20 found: the
115
+ rail showed the renamed name (it reads `nav_meta`) while the automation editor's picker
116
+ showed the original (it reads the definition), and neither looked broken.
117
+
118
+ ⚠ `set_label` now writes the DEFINITION too, so the two agree at the source. This resolver
119
+ stays because it makes every row already stored — renamed before that fix landed — read
120
+ correctly today, without a migration.
121
+ """
122
+ return ((meta or {}).get(key, {}).get("name")
123
+ or (defn or {}).get("label") or key)
124
+
125
+
126
+ def nav_meta(session):
127
+ """The tenant's nav_meta bucket, read defensively. A store blip must not take a list down."""
128
+ try:
129
+ got = session.runtime.get("nav_meta")
130
+ return got if isinstance(got, dict) else {}
131
+ except Exception: # noqa: BLE001
132
+ return {}
133
+
134
+
135
  @router.get("/tables")
136
  def list_tables(session: Session = Depends(require_session)):
137
  """This session's user tables — the list the '+ New database' surface renders."""
138
  ut = _ut()
139
+ meta = nav_meta(session)
140
  out = []
141
  for key, t in sorted(ut.all_tables(st=session.runtime).items(),
142
+ key=lambda kv: (ut_label(kv[1], kv[0], meta) or "").lower()):
143
  if not ut.may_open(key, session.uname, session.admin, st=session.runtime):
144
  continue
145
+ out.append({"key": key, "label": ut_label(t, key, meta),
146
  "source": t.get("source") or "Blank",
147
  "createdBy": t.get("createdBy") or "",
148
  "created": t.get("created") or "",
 
176
  return {"key": key}
177
 
178
 
179
+ @router.patch("/tables/{table_key}")
180
+ def patch_table(table_key: str, body: dict = Body(default=None),
181
+ session: Session = Depends(require_session)):
182
+ """Rename a database — IN ITS DEFINITION (wave 20, item 6a).
183
+
184
+ ⚠ THE RENAME DOOR IN THE NAV WRITES `nav_meta` AND MUST ALSO CALL THIS. `nav_meta` is the
185
+ nav's display layer; the definition is what the automation editor's database picker, the
186
+ schema drawer and every future reader see. A rename that lands in only one of them leaves a
187
+ picker that is confidently wrong rather than obviously stale. Posted to the wave doc as an
188
+ amendment for whoever owns that door.
189
+ """
190
+ defn = _defn_or_refuse(session, table_key)
191
+ ut = _ut()
192
+ if not (session.admin or defn.get("createdBy") == session.uname):
193
+ raise err(403, "forbidden", "only the database's creator or an admin can rename it")
194
+ label = ut.set_label(table_key, (body or {}).get("label"), st=session.runtime)
195
+ if not label:
196
+ raise err(400, "bad_label", "give the database a name")
197
+ return {"key": table_key, "label": label}
198
+
199
+
200
  @router.delete("/tables/{table_key}")
201
  def delete_table(table_key: str, session: Session = Depends(require_session)):
202
  """Creator or admin only — the same actors `may_open` admits, and deletion is the one
203
+ operation the client must confirm explicitly (the server cannot tell a click from a plan).
204
+
205
+ ⚠ WAVE 20, item 3 — THE OWNER'S "an admin cannot delete a database" IS NOT A WALL BUG HERE.
206
+ Measured: **no client calls this route at all.** The React shell POSTs `/tables` to create
207
+ one and has no delete affordance anywhere, so the endpoint is live, correct and unreachable.
208
+ The wall's real defect was next door and is fixed: `routes_automation` carried a SECOND,
209
+ wider ownership rule, and the engine stamped `createdBy: "scheduler"` on tables a cron
210
+ created first — which made a table's owner depend on a race, and left it admin-only forever
211
+ after. See `user_tables.MACHINE_OWNERS` and `automation_engine.ut_ensure`.
212
+ """
213
  _defn_or_refuse(session, table_key)
214
  try:
215
  _ut().delete(table_key, st=session.runtime)
 
250
  @router.post("/tables/{table_key}/rows", status_code=201)
251
  def add_row(table_key: str, body: dict = Body(default=None),
252
  session: Session = Depends(require_session)):
253
+ """Append a row — or RESTORE one under its old id (contract C-ADDROW / C-UNDO).
254
+
255
+ ⚠ THE ANSWER IS ALWAYS THE ID THAT WAS STORED, never the one that was asked for. An undo
256
+ that requested `rid: 7` and got 12 because 7 had been re-used must find that out from the
257
+ response rather than assume; the client re-anchors on what came back.
258
+ """
259
  _defn_or_refuse(session, table_key)
260
  ut = _ut()
261
  values = (body or {}).get("values") or {}
262
  if not isinstance(values, dict):
263
  raise err(400, "bad_values", "values must be an object of {fieldKey: value}")
264
  try:
265
+ rid = ut.add_row(table_key, values, session.uname, st=session.runtime,
266
+ rid=(body or {}).get("rid"))
267
  except Exception:
268
  raise err(503, "store_unavailable", "the row was not saved — the store refused")
269
  if rid is None:
 
272
  return {"rid": rid, "pid": int(rid)}
273
 
274
 
275
+ # ---------------------------------------------------------------------------------------------
276
+ # THE SHARED FIELD SCHEMA (contract C-FIELD, owner ruling R2)
277
+ # ---------------------------------------------------------------------------------------------
278
+ # R2: a `ut_*` table's fields are the TABLE'S schema — everyone with access sees the same
279
+ # columns, the creator or an admin edits them, and a per-field `editRole` can open ONE column's
280
+ # definition to everyone without handing over the table. This supersedes wave 17's "fields are
281
+ # per-user" law for this path only; the connector scopes keep their own model.
282
+ #
283
+ # ⚠ WHY IT MATTERS BEYOND TIDINESS: a grid add-field on a `ut_` scope used to land in the
284
+ # per-user workspace overlay, which is why the automation editor's "Automation column" picker
285
+ # could not see a column the user had just created — it reads the DEFINITION. Same defect shape
286
+ # as the rename (item 6a): two places to look, and the surfaces disagreed silently.
287
+
288
+ def _field_or_refuse(session, table_key, fkey=""):
289
+ """The schema wall. `_defn_or_refuse` first (404/403 on the table), then the per-field rule."""
290
+ defn = _defn_or_refuse(session, table_key)
291
+ ut = _ut()
292
+ if not ut.is_user_table(table_key, st=session.runtime):
293
+ raise err(400, "not_a_user_table",
294
+ "only a user-created database has an editable schema — a connected source "
295
+ "owns its own columns")
296
+ if fkey and not ut.may_edit_field(table_key, fkey, session.uname, session.admin,
297
+ st=session.runtime):
298
+ raise err(403, "forbidden", "that column can only be changed by the database's creator "
299
+ "or an admin")
300
+ if not fkey and not (session.admin or defn.get("createdBy") == session.uname):
301
+ raise err(403, "forbidden", "only the database's creator or an admin can add a column")
302
+ return defn
303
+
304
+
305
+ @router.post("/tables/{table_key}/fields", status_code=201)
306
+ def add_field(table_key: str, body: dict = Body(default=None),
307
+ session: Session = Depends(require_session)):
308
+ _field_or_refuse(session, table_key)
309
+ ut = _ut()
310
+ field = ut.add_field(table_key, body or {}, st=session.runtime)
311
+ if not field:
312
+ raise err(400, "refused",
313
+ f"the column was refused — check the name and type, or the table may be at "
314
+ f"its {ut.MAX_FIELDS}-column cap (types: "
315
+ f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
316
+ return {"field": field}
317
+
318
+
319
+ @router.patch("/tables/{table_key}/fields/{fkey}")
320
+ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
321
+ session: Session = Depends(require_session)):
322
+ """Edit one column's definition, and MIGRATE its values when options are renamed.
323
+
324
+ ⛔ A CHOICE RENAME IS AN EXPLICIT MAPPING, NEVER A DIFF (contract C-RENAME). `{renames:
325
+ [{from, to}]}` arrives alongside the new options list, because a diff cannot tell "renamed
326
+ Blue to Navy" from "deleted Blue, added Navy" — and guessing wrong empties the column and
327
+ every saved view that filtered on it.
328
+ """
329
+ _field_or_refuse(session, table_key, fkey)
330
+ ut = _ut()
331
+ body = body or {}
332
+ migrated = None
333
+ renames = body.get("renames")
334
+ if renames:
335
+ try:
336
+ migrated = ut.rename_choice_values(table_key, fkey, renames, st=session.runtime)
337
+ except Exception:
338
+ raise err(503, "store_unavailable", "the rename did not land — try again")
339
+ # The per-user workspace strata and any view filter naming the old value are the OTHER
340
+ # half of C-RENAME and belong to `core.table_store`. Called only if it is there: an
341
+ # enumerator's mirror waits for its counterpart rather than guessing at its shape, and a
342
+ # missing counterpart must not lose the half that DID land.
343
+ try:
344
+ import core.table_store as table_store
345
+ fn = getattr(table_store, "rename_choice_values", None)
346
+ if callable(fn):
347
+ fn(table_key, fkey, renames, st=session.runtime)
348
+ migrated = dict(migrated or {}, workspace=True)
349
+ except Exception: # noqa: BLE001
350
+ migrated = dict(migrated or {}, workspace=False)
351
+ field = ut.patch_field(table_key, fkey, body, st=session.runtime)
352
+ if not field:
353
+ raise err(400, "refused", "that column could not be changed — check the name and type")
354
+ out = {"field": field}
355
+ if migrated is not None:
356
+ out["migrated"] = migrated
357
+ return out
358
+
359
+
360
+ @router.delete("/tables/{table_key}/fields/{fkey}")
361
+ def delete_field(table_key: str, fkey: str, session: Session = Depends(require_session)):
362
+ _field_or_refuse(session, table_key, fkey)
363
+ if not _ut().delete_field(table_key, fkey, st=session.runtime):
364
+ raise err(400, "refused",
365
+ "that column could not be removed — a database must keep at least one")
366
+ return {"deleted": fkey}
367
+
368
+
369
  @router.delete("/tables/{table_key}/rows/{rid}")
370
  def delete_row(table_key: str, rid: str, session: Session = Depends(require_session)):
371
  _defn_or_refuse(session, table_key)
platform/core/alerts.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """core/alerts.py — "tell me when a record ENTERS this view" (wave 20, owner item 25).
2
+
3
+ THE OWNER'S SHAPE, verbatim: *"a user click a view and click 'Create alert'. This way when a
4
+ Record gets into that Filter's criteria, a user gets notified in that module … Read/Unread like an
5
+ inbox type of thing so they can confirm whether they have seen the Record that got filtered in."*
6
+
7
+ So an alert is **a view plus a remembered MATCHED SET**, and a notification is a **new entrant** —
8
+ a pid that matches now and did not last time. That definition is the whole design, and it is what
9
+ makes the module trustworthy rather than noisy:
10
+
11
+ * a record already in the view when the alert is created is NOT news. The first evaluation
12
+ SEEDS the set silently (`seed=True`); without that, creating an alert on a 400-row view
13
+ would announce 400 "new" records, and the user would turn the feature off in one click;
14
+ * a record that leaves and comes back IS news again — it re-entered the criteria, which is the
15
+ event the owner described;
16
+ * a record that merely CHANGES while staying in the view is not an entrant. "Still matching" is
17
+ not an event. (An on-change alert is a different feature with a different noise budget; it is
18
+ NOT smuggled in here under the same name.)
19
+
20
+ ⚠ **WHY THE MATCHED SET IS STORED AND NOT RECOMPUTED FROM HISTORY.** There is no row-level audit
21
+ log to diff against — `store` holds current state, and Odoo is read-only. The remembered set IS
22
+ the history, so it is written on every evaluation, in the same transaction that queues the
23
+ notifications. Losing that write while keeping the notifications would re-announce the same
24
+ records forever.
25
+
26
+ ⚠ **WHAT AN UNANSWERABLE LEAF ACTUALLY DOES HERE — CHECKED, NOT ASSUMED.** `harness.filter_eval`'s
27
+ `EvalCtx` is explicit that each of its four members "absent, the condition matches NOTHING rather
28
+ than everything" — the engine NARROWS on an unresolved measure/cohort/rank set rather than
29
+ widening. So the failure mode is not a false alert; it is a **missed** one, and a missed entrant
30
+ is re-detected on the next evaluation because `matched` only ever records what genuinely matched.
31
+
32
+ That is the safe direction, and it is why `partial` is a caller-supplied flag rather than
33
+ something inferred in here: the caller (`routes_alerts`) is the only layer that knows whether it
34
+ could BUILD a complete context. When it says so, `evaluate()` records the attempt and changes
35
+ nothing — updating `matched` from a narrowed evaluation would drop rows out of the remembered set
36
+ and then re-announce every one of them as an "entrant" on the next complete pass. That is the
37
+ real hazard: not a phantom alert, but a storm of stale ones.
38
+
39
+ ⚠ **EVERY STAMP CARRIES A UTC OFFSET** (DEBT D-18). A browser can only render a timestamp
40
+ relatively if it can subtract it from `now`, and a naive container-local stamp is unsubtractable —
41
+ it renders VERBATIM or, worse, silently as the reader's own zone. `_now_iso()` is the one place
42
+ that is decided.
43
+ """
44
+ import datetime as _dt
45
+
46
+ import core.store as store
47
+
48
+ #: Alert DEFINITIONS, per tenant: {alert_id: {...}}. Separate from the notifications bucket so a
49
+ #: busy inbox never rewrites the definitions (and a definition edit never rewrites the inbox).
50
+ ALERTS_KEY = 'alerts'
51
+ #: The inbox: {username: [notification, ...]}, newest last.
52
+ NOTIFICATIONS_KEY = 'alert_notifications'
53
+
54
+ #: Per user. An inbox is a WORKING queue, not an archive — the store is a JSON blob read whole on
55
+ #: every request, so an unbounded inbox is a payload that grows without limit for a user who
56
+ #: never clicks. Oldest READ entries are dropped first; unread ones survive the cap because the
57
+ #: whole point is that the user has not seen them yet.
58
+ MAX_NOTIFICATIONS = 200
59
+
60
+
61
+ def _now_iso():
62
+ """UTC, WITH the offset — `2026-08-05T09:41:07.123456+00:00`. See the module note (D-18)."""
63
+ return _dt.datetime.now(_dt.timezone.utc).isoformat()
64
+
65
+
66
+ def _st(st):
67
+ return st if st is not None else store
68
+
69
+
70
+ def list_alerts(user=None, st=None, is_admin=False):
71
+ """Alert definitions this user may see: their own. Admins see all (they support them)."""
72
+ try:
73
+ recs = _st(st).get(ALERTS_KEY) or {}
74
+ except Exception:
75
+ return []
76
+ out = []
77
+ for aid, rec in recs.items():
78
+ if not isinstance(rec, dict):
79
+ continue
80
+ if user and not is_admin and str(rec.get('owner') or '') != str(user):
81
+ continue
82
+ out.append({**rec, 'id': str(aid)})
83
+ return sorted(out, key=lambda r: str(r.get('createdAt') or ''))
84
+
85
+
86
+ def create(alert_id, *, view_id, topic, owner, label='', st=None):
87
+ """Register an alert on a view. Returns the record.
88
+
89
+ The view is stored BY ID, never by a copy of its filter tree. An alert whose criteria were
90
+ snapshotted at creation would silently stop matching the view the moment its owner edited it —
91
+ and the user's mental model is "alert me on THIS VIEW", not "on this view as it was in
92
+ August".
93
+ """
94
+ rec = {'id': str(alert_id), 'viewId': str(view_id), 'topic': str(topic),
95
+ 'owner': str(owner), 'label': str(label or '')[:160],
96
+ 'createdAt': _now_iso(), 'matched': [], 'seeded': False,
97
+ 'lastRunAt': None, 'lastError': None}
98
+
99
+ def _apply(data):
100
+ data[str(alert_id)] = rec
101
+ return data
102
+
103
+ _st(st).update(ALERTS_KEY, _apply, flush='sync')
104
+ return rec
105
+
106
+
107
+ def delete(alert_id, st=None):
108
+ def _apply(data):
109
+ data.pop(str(alert_id), None)
110
+ return data
111
+
112
+ _st(st).update(ALERTS_KEY, _apply, flush='sync')
113
+ return True
114
+
115
+
116
+ def evaluate(alert_id, matching_pids, *, labels=None, partial=False, st=None):
117
+ """Fold a fresh evaluation of one alert's view into its state; queue any NEW entrants.
118
+
119
+ `matching_pids` is what the view matches NOW — the caller owns running the filter, because
120
+ only it knows the topic's rows and the reader's scope. `partial` says the evaluation could not
121
+ answer every leaf; see the module note on why that suppresses everything.
122
+
123
+ Returns `{'new': [...], 'seeded': bool}` or `{'skipped': 'partial'|'missing'}`.
124
+ """
125
+ now_set = {str(p) for p in (matching_pids or ())}
126
+ outcome = {}
127
+
128
+ def _apply(data):
129
+ rec = data.get(str(alert_id))
130
+ if not isinstance(rec, dict):
131
+ outcome['skipped'] = 'missing'
132
+ return data
133
+ if partial:
134
+ # Record the ATTEMPT (so "last checked" is honest) but change nothing else. Writing
135
+ # `matched` here would bake a widened set in as truth, and the next COMPLETE
136
+ # evaluation would then report every genuinely-absent row as an entrant.
137
+ rec['lastRunAt'] = _now_iso()
138
+ rec['lastError'] = ('the view could not be fully evaluated (a filter leaf was '
139
+ 'unanswerable); no alert was raised')
140
+ outcome['skipped'] = 'partial'
141
+ data[str(alert_id)] = rec
142
+ return data
143
+ prior = {str(p) for p in (rec.get('matched') or ())}
144
+ seeding = not rec.get('seeded')
145
+ entrants = [] if seeding else sorted(now_set - prior, key=lambda s: (len(s), s))
146
+ rec['matched'] = sorted(now_set, key=lambda s: (len(s), s))
147
+ rec['seeded'] = True
148
+ rec['lastRunAt'] = _now_iso()
149
+ rec['lastError'] = None
150
+ data[str(alert_id)] = rec
151
+ outcome['new'] = entrants
152
+ outcome['seeded'] = seeding
153
+ outcome['_rec'] = rec
154
+ return data
155
+
156
+ _st(st).update(ALERTS_KEY, _apply, flush='sync')
157
+ if outcome.get('skipped'):
158
+ return {'skipped': outcome['skipped']}
159
+ rec = outcome.pop('_rec', {}) or {}
160
+ if outcome.get('new'):
161
+ _queue(rec, outcome['new'], labels or {}, st=st)
162
+ return outcome
163
+
164
+
165
+ def _queue(rec, pids, labels, st=None):
166
+ """Append one notification per entrant to the alert owner's inbox."""
167
+ at = _now_iso()
168
+ items = [{'id': f"{rec.get('id')}:{pid}:{at}",
169
+ 'alertId': str(rec.get('id')), 'viewId': str(rec.get('viewId')),
170
+ 'topic': str(rec.get('topic')), 'rowId': str(pid),
171
+ 'label': str(labels.get(str(pid)) or labels.get(pid) or pid),
172
+ 'alertLabel': str(rec.get('label') or ''),
173
+ 'at': at, 'read': False}
174
+ for pid in pids]
175
+ owner = str(rec.get('owner') or '')
176
+
177
+ def _apply(data):
178
+ inbox = list(data.get(owner) or [])
179
+ inbox.extend(items)
180
+ if len(inbox) > MAX_NOTIFICATIONS:
181
+ # Drop READ entries oldest-first; keep every unread one. A cap that dropped unread
182
+ # notifications would silently lose exactly the records the user has not confirmed —
183
+ # the one thing this module promises not to do.
184
+ unread = [n for n in inbox if not n.get('read')]
185
+ read = [n for n in inbox if n.get('read')]
186
+ keep_read = read[max(0, len(unread) + len(read) - MAX_NOTIFICATIONS):]
187
+ inbox = sorted(unread + keep_read, key=lambda n: str(n.get('at') or ''))
188
+ data[owner] = inbox
189
+ return data
190
+
191
+ _st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
192
+
193
+
194
+ def inbox(user, st=None, limit=100):
195
+ """`{'unread': int, 'items': [...]}` — newest first, for the badge and the pane."""
196
+ try:
197
+ items = list((_st(st).get(NOTIFICATIONS_KEY) or {}).get(str(user)) or [])
198
+ except Exception:
199
+ return {'unread': 0, 'items': []}
200
+ items = [n for n in items if isinstance(n, dict)]
201
+ items.sort(key=lambda n: str(n.get('at') or ''), reverse=True)
202
+ return {'unread': sum(1 for n in items if not n.get('read')), 'items': items[:max(0, limit)]}
203
+
204
+
205
+ def mark_read(user, ids, read=True, st=None):
206
+ """Mark specific notifications read/unread. `ids=None` marks every one (mark-all-read)."""
207
+ want = None if ids is None else {str(i) for i in ids}
208
+
209
+ def _apply(data):
210
+ inbox_ = [dict(n) for n in (data.get(str(user)) or []) if isinstance(n, dict)]
211
+ for n in inbox_:
212
+ if want is None or str(n.get('id')) in want:
213
+ n['read'] = bool(read)
214
+ data[str(user)] = inbox_
215
+ return data
216
+
217
+ _st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
218
+ return inbox(user, st=st)
219
+
220
+
221
+ def after_write(topic_key, st=None, runner=None):
222
+ """The write-path HOOK: re-evaluate every alert whose view belongs to `topic_key`.
223
+
224
+ ⚠ CALLED FROM A WRITE PATH, SO IT MUST NOT RAISE AND MUST NOT BLOCK ON A FULL POOL BUILD.
225
+ A failed alert evaluation must never fail the edit that triggered it — the user typed into a
226
+ cell; whether an alert fires is not their problem. `runner` is injected by the caller
227
+ (`routes_alerts` passes one that can resolve the topic's rows for the alert's owner), so this
228
+ module never reaches for a pool itself and stays unit-testable with a fake.
229
+ """
230
+ if runner is None:
231
+ return {'evaluated': 0}
232
+ n = 0
233
+ for rec in list_alerts(st=st):
234
+ if str(rec.get('topic')) != str(topic_key):
235
+ continue
236
+ try:
237
+ runner(rec)
238
+ n += 1
239
+ except Exception: # noqa: BLE001 — see the docstring
240
+ continue
241
+ return {'evaluated': n}
platform/core/grid_events.py CHANGED
@@ -1063,7 +1063,7 @@ def handle_one(event, ctx):
1063
  return True
1064
 
1065
  if kind in ('folder_create', 'folder_rename', 'folder_delete', 'folder_duplicate',
1066
- 'item_move'):
1067
  # FOLDERS over the Views / Cohorts sidebars (owner item 11, contract C4). All five
1068
  # events touch ONE home — the table workspace's `folders` + `itemFolders` — so a cohort
1069
  # can be filed without the cohort STORE learning about folders at all.
@@ -1123,6 +1123,37 @@ def handle_one(event, ctx):
1123
  # an item with no placement renders at the top level.
1124
  placed[surface] = {k: v for k, v in (placed.get(surface) or {}).items()
1125
  if v != fid}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1126
  elif kind == 'folder_duplicate':
1127
  new_id = str(event.get('newId') or '').strip()[:80]
1128
  src = next((f for f in lst if f['id'] == fid), None)
@@ -1193,6 +1224,125 @@ def handle_one(event, ctx):
1193
  # invisible to the next render (the values leave WITH the column).
1194
  return False
1195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1196
  if kind == 'field_duplicate':
1197
  # Wave-5 item 1. The DESTINATION key is client-generated (the established pattern —
1198
  # the client must know it synchronously to place the clone right of the source), and
 
1063
  return True
1064
 
1065
  if kind in ('folder_create', 'folder_rename', 'folder_delete', 'folder_duplicate',
1066
+ 'item_move', 'folder_reorder'):
1067
  # FOLDERS over the Views / Cohorts sidebars (owner item 11, contract C4). All five
1068
  # events touch ONE home — the table workspace's `folders` + `itemFolders` — so a cohort
1069
  # can be filed without the cohort STORE learning about folders at all.
 
1123
  # an item with no placement renders at the top level.
1124
  placed[surface] = {k: v for k, v in (placed.get(surface) or {}).items()
1125
  if v != fid}
1126
+ elif kind == 'folder_reorder':
1127
+ # ⭐ WAVE 20 (owner item 19, contract C-FOLDER-REORDER) — the user's own folder
1128
+ # ORDER in the rail. Views could already be dragged between folders; the folders
1129
+ # themselves could not be dragged past each other.
1130
+ #
1131
+ # THE WIRE CARRIES THE FULL ORDER, NEVER A DELTA, and that is the contract's
1132
+ # doing: a partial list cannot say where an UNNAMED folder went, so a "moved B
1133
+ # after C" message would leave every other folder's position to be re-derived by
1134
+ # two sides that can disagree. The client sends the list it is looking at.
1135
+ #
1136
+ # ⚠ IDS THIS USER DOES NOT OWN ARE IGNORED, NOT REJECTED. A stale tab can emit an
1137
+ # order containing a folder that has since been deleted; dropping the whole event
1138
+ # would make the rail un-reorderable until reload, while dropping the unknown id
1139
+ # reorders exactly what still exists. Anything the payload omits keeps its
1140
+ # relative position AFTER the named ones — an order that silently deleted a
1141
+ # folder it merely failed to mention would be a data loss dressed as a sort.
1142
+ order = [str(i).strip()[:80] for i in (event.get('order') or [])]
1143
+ if not order:
1144
+ return None
1145
+ known = {f['id']: f for f in lst}
1146
+ seen, ranked = set(), []
1147
+ for fid_ in order:
1148
+ if fid_ in known and fid_ not in seen:
1149
+ seen.add(fid_)
1150
+ ranked.append(known[fid_])
1151
+ ranked.extend(f for f in lst if f['id'] not in seen)
1152
+ if len(ranked) != len(lst): # cannot happen; refuse rather than truncate
1153
+ return None
1154
+ for i, f in enumerate(ranked):
1155
+ f['order'] = i
1156
+ lst = ranked
1157
  elif kind == 'folder_duplicate':
1158
  new_id = str(event.get('newId') or '').strip()[:80]
1159
  src = next((f for f in lst if f['id'] == fid), None)
 
1224
  # invisible to the next render (the values leave WITH the column).
1225
  return False
1226
 
1227
+ if kind == 'choice_rename':
1228
+ # ⭐ WAVE 20 (owner item 15, contract C-RENAME) — RENAMING AN OPTION CARRIES ITS VALUES.
1229
+ #
1230
+ # Owner: *"if I change the option from 'Aaron' to 'Aron' the existing field that has the
1231
+ # Selected 'Aaron' doesn't change to 'Aron'."* Correct, and it is a data-model fact rather
1232
+ # than an oversight: a select cell stores the option's LABEL, so renaming the option in
1233
+ # the field definition orphans every cell holding the old string. They do not render as
1234
+ # the new name; they render as a value that is no longer an option.
1235
+ #
1236
+ # ⛔ THE WIRE CARRIES AN EXPLICIT {from, to} MAPPING, NEVER A DIFF OF THE CHOICE LISTS.
1237
+ # Diffing is what makes this unsafe: rename A→B while also DELETING C and adding D, and a
1238
+ # differ sees two removals and two additions with no way to know which pairs up. It would
1239
+ # cheerfully rewrite every C cell to D. The client knows which row of its editor the user
1240
+ # typed in; that intent travels, and nothing here has to guess it.
1241
+ #
1242
+ # THREE PLACES HOLD THE OLD STRING, and missing any one is its own visible bug:
1243
+ # 1. the CELLS (`overlays`) — the owner's complaint;
1244
+ # 2. the FIELD's own `choices` list — else the picker still offers the old name;
1245
+ # 3. every VIEW that FILTERS or COLOURS BY that value — a saved view filtering
1246
+ # `Agent is "Aaron"` silently matches NOTHING the moment the value moves, which is
1247
+ # the failure that looks like data loss ([[aios-shared-views]]).
1248
+ key = str(event.get('key') or '')[:80]
1249
+ pairs = []
1250
+ for r in (event.get('renames') or ()):
1251
+ if not isinstance(r, dict):
1252
+ continue
1253
+ a, b = str(r.get('from') or '')[:200], str(r.get('to') or '')[:200]
1254
+ if a and b and a != b:
1255
+ pairs.append((a, b))
1256
+ field = field_by_key.get(key)
1257
+ if not key or not pairs or not isinstance(field, dict):
1258
+ return False
1259
+ if str(field.get('type') or '') not in ('select', 'multiselect', 'status'):
1260
+ return False
1261
+ # Only a field this caller may edit. A rename is a write to the field CONTRACT, so it
1262
+ # rides the same wall `field_upsert` does rather than a looser one of its own.
1263
+ if key in (ctx.hidden_keys or frozenset()):
1264
+ return False
1265
+ mapping = dict(pairs)
1266
+
1267
+ def _rename_in(ws_):
1268
+ fields_ = ws_.setdefault('fields', {})
1269
+ fld = fields_.get(key)
1270
+ if isinstance(fld, dict) and isinstance(fld.get('choices'), list):
1271
+ seen, out = set(), []
1272
+ for c in fld['choices']:
1273
+ if isinstance(c, dict):
1274
+ nm = mapping.get(str(c.get('name') or ''), None)
1275
+ if nm is not None:
1276
+ c = {**c, 'name': nm}
1277
+ ident = str(c.get('name') or '')
1278
+ else:
1279
+ c = mapping.get(str(c), str(c))
1280
+ ident = str(c)
1281
+ # A rename ONTO an existing option MERGES rather than duplicating it: the
1282
+ # user typed a name that already exists, and two identical options is a
1283
+ # picker bug. Their cells merge too, which is what the value rewrite below
1284
+ # does anyway.
1285
+ if ident in seen:
1286
+ continue
1287
+ seen.add(ident)
1288
+ out.append(c)
1289
+ fld['choices'] = out
1290
+ fields_[key] = fld
1291
+ for row in (ws_.get('overlays') or {}).values():
1292
+ if not isinstance(row, dict) or key not in row:
1293
+ continue
1294
+ val = row[key]
1295
+ if isinstance(val, list): # multiselect
1296
+ merged, out2 = set(), []
1297
+ for v in val:
1298
+ nv = mapping.get(str(v), str(v))
1299
+ if nv not in merged:
1300
+ merged.add(nv)
1301
+ out2.append(nv)
1302
+ row[key] = out2
1303
+ elif isinstance(val, str) and val in mapping:
1304
+ row[key] = mapping[val]
1305
+ # Views: filter rule values + colour-by keys naming the old option.
1306
+ for view in (ws_.get('views') or {}).values():
1307
+ if not isinstance(view, dict):
1308
+ continue
1309
+ cfg = view.get('config') if isinstance(view.get('config'), dict) else view
1310
+
1311
+ def _walk(nodes):
1312
+ for n in nodes or ():
1313
+ if not isinstance(n, dict):
1314
+ continue
1315
+ if isinstance(n.get('children'), list):
1316
+ _walk(n['children'])
1317
+ continue
1318
+ if str(n.get('colId') or '') != key:
1319
+ continue
1320
+ v = n.get('value')
1321
+ if isinstance(v, str) and v in mapping:
1322
+ n['value'] = mapping[v]
1323
+ elif isinstance(v, list):
1324
+ n['value'] = [mapping.get(str(x), x) for x in v]
1325
+
1326
+ _walk(cfg.get('filters') or [])
1327
+ # ⚠ `colorBy` NEEDS NO REWRITE, and the reason is worth stating because the
1328
+ # opposite looks obvious: it stores a COLUMN KEY (a string, validated against
1329
+ # `valid_keys` at `view_upsert`), not a {value: colour} map. Per-value colours
1330
+ # ride the CHOICE OBJECTS themselves, which this function renames in place —
1331
+ # `{**c, 'name': nm}` keeps every other key on the choice, colour included — so
1332
+ # a renamed option keeps its swatch for free. An earlier version of this handler
1333
+ # tried to rewrite `colorBy['rules']` and crashed the whole event pipeline with
1334
+ # `TypeError: unhashable type: 'dict'` on the FIRST view it touched.
1335
+ return ws_
1336
+
1337
+ if store.available():
1338
+ _tops(ctx).rename_choice_values(uname, _rename_in)
1339
+ else:
1340
+ _session_ready()
1341
+ _rename_in(ws)
1342
+ # True = the host must re-read: the client dropped its optimistic copy of the CHOICES,
1343
+ # but the rewritten cells and view filters only exist server-side until the next payload.
1344
+ return True
1345
+
1346
  if kind == 'field_duplicate':
1347
  # Wave-5 item 1. The DESTINATION key is client-generated (the established pattern —
1348
  # the client must know it synchronously to place the clone right of the source), and
platform/core/releases.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """core/releases.py — what each deployed environment is RUNNING (wave 20, owner item 12 / R6).
2
+
3
+ ⛔ **THIS MODULE EXISTS BECAUSE OF `ops/verify_portability.py` B2**, and the reason is worth
4
+ stating rather than inferring: the API process must stay HOST-AGNOSTIC. The releases panel was
5
+ first written with `HfApi`/`hf_hub_download` inside `routes_platform_admin.py`, and the gate
6
+ turned red on the first run — correctly. A hub client in a request handler couples the runtime to
7
+ HuggingFace, in the one process whose design promise is that moving hosts is a config change.
8
+
9
+ So the route DELEGATES here, exactly as `routes_assets` delegates to `core/assets.py` (the same
10
+ rule, the same shape, and that module's own note says so). The SDK import is LAZY and every path
11
+ is gated on a token being configured, so a deployment with no hub credential simply reports
12
+ nothing rather than failing to import.
13
+
14
+ ⚠ **WHAT THIS READS IS EACH SPACE'S OWN CLAIM ABOUT ITSELF.** `VERSION` is written INTO the Space
15
+ by `deploy_web.py` at deploy time, so "what is LIVE running" is a lookup, not an inference from
16
+ timestamps — `last_modified` moves when a SECRET is pushed, which would report a deploy that never
17
+ happened. An unreachable environment yields a NOTE, never a blank: about a production environment,
18
+ "no version" reads as "nothing is deployed", which is the worst available way to be wrong.
19
+ """
20
+ import json
21
+ import os
22
+
23
+
24
+ def _token():
25
+ """The credential that can read the Spaces. `AIOS_HF_TOKEN` is the personal (fsanyoto) token
26
+ that owns the staging Space; `HF_TOKEN` is the org-scoped one. Either can read."""
27
+ return os.environ.get('AIOS_HF_TOKEN') or os.environ.get('HF_TOKEN') or None
28
+
29
+
30
+ #: The two environments, by Space id. ⚠ These are REPO IDENTIFIERS, not host literals — no URL is
31
+ #: built anywhere in this module (portability C1). Mirrors `deploy_web.STAGING`/`LIVE`, which
32
+ #: cannot be imported here: it lives outside the container's tree.
33
+ ENVIRONMENTS = (('staging', 'fsanyoto/loopable'), ('live', 'royal-imports/cfo-os'))
34
+
35
+
36
+ def environments():
37
+ """[{env, space, version, note, stage}] — one row per deployed environment.
38
+
39
+ Never raises: every failure becomes a `note` on its own row. A panel that 500s because one
40
+ Space was briefly unreachable tells the operator less than a row saying so.
41
+ """
42
+ tok = _token()
43
+ out = []
44
+ for name, repo in ENVIRONMENTS:
45
+ row = {'env': name, 'space': repo, 'version': None, 'note': None, 'stage': None}
46
+ if not tok:
47
+ row['note'] = ('no hub credential is configured on this deployment, so other '
48
+ 'environments cannot be read from here')
49
+ out.append(row)
50
+ continue
51
+ try:
52
+ from huggingface_hub import hf_hub_download # noqa: PLC0415 — lazy, see header
53
+ p = hf_hub_download(repo, 'VERSION', repo_type='space', token=tok)
54
+ with open(p, encoding='utf-8') as f:
55
+ row['version'] = f.read().strip().splitlines()[0]
56
+ except Exception as e: # noqa: BLE001
57
+ row['note'] = f'could not read this Space\'s VERSION ({type(e).__name__})'
58
+ try:
59
+ from huggingface_hub import HfApi # noqa: PLC0415
60
+ row['stage'] = str(HfApi(token=tok).space_info(repo).runtime.stage)
61
+ except Exception: # noqa: BLE001
62
+ pass
63
+ out.append(row)
64
+ return out
65
+
66
+
67
+ def history():
68
+ """Every `vN` ever cut: [{version, sha, date, subject}], newest first.
69
+
70
+ Read from `RELEASES.json`, which `deploy_web.py` ships INTO the Space beside `VERSION` — the
71
+ history has to travel with the build because the container has no git checkout to derive it
72
+ from. Tries the local filesystem FIRST (this deployment's own copy, no network at all) and
73
+ only then asks the hub, so the common case costs nothing.
74
+ """
75
+ from pathlib import Path
76
+
77
+ for p in (Path('RELEASES.json'), Path(__file__).resolve().parents[2] / 'RELEASES.json'):
78
+ try:
79
+ if p.is_file():
80
+ return (json.loads(p.read_text(encoding='utf-8')) or {}).get('releases') or []
81
+ except Exception: # noqa: BLE001
82
+ pass
83
+ tok = _token()
84
+ if not tok:
85
+ return []
86
+ for _name, repo in ENVIRONMENTS:
87
+ try:
88
+ from huggingface_hub import hf_hub_download # noqa: PLC0415
89
+ q = hf_hub_download(repo, 'RELEASES.json', repo_type='space', token=tok)
90
+ with open(q, encoding='utf-8') as f:
91
+ return (json.load(f) or {}).get('releases') or []
92
+ except Exception: # noqa: BLE001
93
+ continue
94
+ return []
platform/core/shares.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ KINDS = ('view', 'folder', 'database')
45
+
46
+ #: `*` is "everyone who can already open the surface". It is NOT "every account on the platform" —
47
+ #: the module/table wall runs FIRST and this never widens past it. Spelled as a single character
48
+ #: so it can never collide with a username (usernames are lower-case and non-empty by
49
+ #: `core/users.py`, and are checked against this explicitly below).
50
+ EVERYONE = '*'
51
+
52
+ ROLES = ('view', 'edit')
53
+
54
+
55
+ def _st(st):
56
+ return st if st is not None else store
57
+
58
+
59
+ def _check_kind(kind):
60
+ k = str(kind or '').strip().lower()
61
+ if k not in KINDS:
62
+ raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)} — '
63
+ f'refusing to invent a namespace from a typo.')
64
+ return k
65
+
66
+
67
+ def _clean_entries(entries):
68
+ """Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}].
69
+
70
+ Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
71
+ that just listed the people it is about to grant, so a rejected row must not abort the whole
72
+ save — but an entry with an unknown ROLE must not be stored as something else's default
73
+ either. Dropped, never coerced.
74
+ """
75
+ out, seen = [], set()
76
+ for e in entries or ():
77
+ if not isinstance(e, dict):
78
+ continue
79
+ user = str(e.get('user') or '').strip().lower()
80
+ role = str(e.get('role') or '').strip().lower()
81
+ if not user or role not in ROLES or user in seen:
82
+ continue
83
+ seen.add(user)
84
+ out.append({'user': user, 'role': role})
85
+ return out
86
+
87
+
88
+ def grants(kind, oid, st=None):
89
+ """`{'owner': str|None, 'entries': [{'user','role'}]}` — never raises on a junk bucket."""
90
+ kind = _check_kind(kind)
91
+ try:
92
+ bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
93
+ rec = bucket.get(str(oid)) or {}
94
+ except Exception:
95
+ return {'owner': None, 'entries': []}
96
+ if not isinstance(rec, dict):
97
+ return {'owner': None, 'entries': []}
98
+ return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
99
+ 'entries': _clean_entries(rec.get('entries'))}
100
+
101
+
102
+ def set_grants(kind, oid, entries, owner=None, st=None):
103
+ """REPLACE the grant set for one object. Returns the stored record.
104
+
105
+ ⚠ REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
106
+ entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
107
+ manage-access editor R10 asks for is exactly "here is the list now".
108
+ """
109
+ kind = _check_kind(kind)
110
+ oid = str(oid)
111
+ clean = _clean_entries(entries)
112
+ owner_l = str(owner).strip().lower() if owner else None
113
+
114
+ def _apply(data):
115
+ by_kind = dict(data.get(kind) or {})
116
+ prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
117
+ # The owner is STICKY: set once, and a later save that omits it must not orphan the
118
+ # object. An ownerless grant record cannot answer "who may re-share this", so every
119
+ # administer check would fail closed and the object would become unmanageable.
120
+ keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
121
+ if prior.get('owner') else None)
122
+ if not clean and not keep_owner:
123
+ by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
124
+ else:
125
+ by_kind[oid] = {'owner': keep_owner, 'entries': clean}
126
+ data[kind] = by_kind
127
+ return data
128
+
129
+ _st(st).update(SHARES_KEY, _apply, flush='async')
130
+ return grants(kind, oid, st=st)
131
+
132
+
133
+ def role_for(kind, oid, user, is_admin=False, st=None):
134
+ """`'owner'` | `'edit'` | `'view'` | `None` — the caller's effective role, fail-closed.
135
+
136
+ An ADMIN reads as `'owner'`: an admin who could not administer an object could not
137
+ administer the tenant either, which is `table_store._may_administer`'s existing rule and is
138
+ kept identical here so the two cannot disagree about the same view.
139
+ """
140
+ user = str(user or '').strip().lower()
141
+ if not user:
142
+ return None
143
+ rec = grants(kind, oid, st=st)
144
+ if is_admin or (rec['owner'] and rec['owner'] == user):
145
+ return 'owner'
146
+ best = None
147
+ for e in rec['entries']:
148
+ if e['user'] == user or e['user'] == EVERYONE:
149
+ # The STRONGER of the two wins when both a personal and an everyone grant exist:
150
+ # naming somebody explicitly is how you RAISE them above the room, so an
151
+ # everyone-view + alice-edit pair must leave alice editing.
152
+ if e['role'] == 'edit':
153
+ return 'edit'
154
+ best = best or 'view'
155
+ return best
156
+
157
+
158
+ def may_see(kind, oid, user, is_admin=False, st=None):
159
+ return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
160
+
161
+
162
+ def may_edit(kind, oid, user, is_admin=False, st=None):
163
+ return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
164
+
165
+
166
+ def may_administer(kind, oid, user, is_admin=False, st=None):
167
+ """Only the owner or an admin may change grants or delete. See the module note on why this
168
+ is deliberately narrower than `may_edit`."""
169
+ return role_for(kind, oid, user, is_admin=is_admin, st=st) == 'owner'
170
+
171
+
172
+ def shared_with(user, kind=None, st=None):
173
+ """Every object id this user has been granted (excluding what they own).
174
+
175
+ This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
176
+ you made is not something shared *with* you, and listing it there would make the system
177
+ folder a duplicate of the rail above it.
178
+ """
179
+ user = str(user or '').strip().lower()
180
+ if not user:
181
+ return {}
182
+ try:
183
+ data = _st(st).get(SHARES_KEY) or {}
184
+ except Exception:
185
+ return {}
186
+ out = {}
187
+ for k in ([_check_kind(kind)] if kind else KINDS):
188
+ hits = []
189
+ for oid, rec in (data.get(k) or {}).items():
190
+ if not isinstance(rec, dict):
191
+ continue
192
+ owner = str(rec.get('owner') or '').strip().lower()
193
+ if owner == user:
194
+ continue
195
+ for e in _clean_entries(rec.get('entries')):
196
+ if e['user'] in (user, EVERYONE):
197
+ hits.append(str(oid))
198
+ break
199
+ out[k] = sorted(hits)
200
+ return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
platform/core/store.py CHANGED
@@ -304,8 +304,15 @@ _INSTANCES_LOCK = threading.RLock()
304
 
305
 
306
  def for_repo(repo_id):
307
- """The Store bound to `repo_id` — one instance per repo per process (each with its own
308
- cache and flush state). The default instance IS `for_repo(REPO)`."""
 
 
 
 
 
 
 
309
  rid = str(repo_id or '').strip() or REPO
310
  with _INSTANCES_LOCK:
311
  inst = _INSTANCES.get(rid)
@@ -315,47 +322,119 @@ def for_repo(repo_id):
315
  return inst
316
 
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  _DEFAULT = for_repo(REPO)
319
 
320
 
 
 
 
 
 
 
 
 
 
 
321
  def available():
322
- return _DEFAULT.available()
323
 
324
 
325
  def get(name, fresh=False):
326
- return _DEFAULT.get(name, fresh=fresh)
327
 
328
 
329
  def _read_strict(name):
330
- return _DEFAULT._read_strict(name)
331
 
332
 
333
  def exists(name):
334
- return _DEFAULT.exists(name)
335
 
336
 
337
  def upload_bytes(path_in_repo, data, message=None):
338
- return _DEFAULT.upload_bytes(path_in_repo, data, message=message)
339
 
340
 
341
  def download_bytes(path_in_repo):
342
- return _DEFAULT.download_bytes(path_in_repo)
343
 
344
 
345
  def delete_path(path_in_repo):
346
- return _DEFAULT.delete_path(path_in_repo)
347
 
348
 
349
  def put(name, data):
350
- return _DEFAULT.put(name, data)
351
 
352
 
353
  def flush(name=None, timeout=30.0):
354
- return _DEFAULT.flush(name=name, timeout=timeout)
355
 
356
 
357
  def update(name, fn, flush='sync'):
358
- return _DEFAULT.update(name, fn, flush=flush)
359
 
360
 
361
  def _flush_all_at_exit():
 
304
 
305
 
306
  def for_repo(repo_id):
307
+ """The HF Store bound to `repo_id` — one instance per repo per process (each with its own
308
+ cache and flush state).
309
+
310
+ ⚠ WAVE 20: this is now the HF-SPECIFIC factory. Callers that want "the right store for this
311
+ tenant, whatever the backend is" call `handle()` below. `for_repo` keeps its exact old
312
+ behaviour because the seed/migration tooling has to be able to name the FILE store
313
+ explicitly while the app runs on Postgres — a migration that could only reach the active
314
+ backend could not copy between them.
315
+ """
316
  rid = str(repo_id or '').strip() or REPO
317
  with _INSTANCES_LOCK:
318
  inst = _INSTANCES.get(rid)
 
322
  return inst
323
 
324
 
325
+ # =============================================================================================
326
+ # WAVE 20 (owner ruling R1, closes DEBT D-4) — THE BACKEND SEAM.
327
+ #
328
+ # `core/store_backend.py` has carried this warning since EXIT-2b, and it was correct at the time:
329
+ #
330
+ # "Flipping STORE_BACKEND=pg does NOT redirect the ~40 existing callers that say
331
+ # `import core.store as store` — they are bound to the HF module directly. Rewiring them is
332
+ # task C-4 … THE DEFAULT IS `hf` AND STAYS `hf` until C-4 says otherwise."
333
+ #
334
+ # **R1 IS C-4.** The owner ruled the cutover on 2026-08-05 (the D-4 trigger that fired: a SECOND
335
+ # server process — `royal-imports/cfo-os` came back as a pinned LIVE environment beside staging,
336
+ # and two containers on one last-write-wins file store is the race B-3 was always about).
337
+ #
338
+ # THE REWIRE IS HERE RATHER THAN IN 28 FILES, and that is a deliberate choice over the obvious
339
+ # alternative of `sed`-ing every `import core.store as store` to `import core.store_backend`:
340
+ # * every one of those callers means "the store for the tenant I am serving", which is exactly
341
+ # what this module has always meant. The BACKEND is not their concern and making it their
342
+ # concern is how one of them gets missed;
343
+ # * a missed caller under a search-and-replace does not fail — it silently keeps writing to the
344
+ # file store while everything else writes to Postgres. That is the split-brain this whole
345
+ # ruling exists to end, reintroduced by the fix for it;
346
+ # * `store_backend.py`'s stated reason for refusing to do it this way — "no dual-read window and
347
+ # no way to compare the two stores' contents first" — is satisfied: `ops/seed_pg_from_hf.py`
348
+ # copies, then `--verify` diffs the two stores key-by-key before anything flips.
349
+ #
350
+ # ⛔ THE IMPORT IS LAZY AND MUST STAY LAZY. `core/store_pg.py` imports psycopg only inside
351
+ # `_pool()`, so an `hf` deployment installs no driver; importing it at module scope here would
352
+ # undo that and make the default backend depend on the optional dependency.
353
+ # =============================================================================================
354
+
355
+ def backend():
356
+ """`'hf'` | `'pg'` — validated, resolved per call so a test can flip the env var.
357
+
358
+ Mirrors `core.store_backend.name()` deliberately rather than importing it: that module
359
+ imports THIS one, so reaching back would be a cycle. `verify_store_pg` asserts the two agree
360
+ on every value, which is the guard against them drifting apart.
361
+ """
362
+ raw = (os.environ.get('STORE_BACKEND') or 'hf').strip().lower()
363
+ if raw not in ('hf', 'pg'):
364
+ raise RuntimeError(
365
+ f"STORE_BACKEND={raw!r} is not a backend. Use 'hf' (the HF Dataset store) or 'pg' "
366
+ f"(Postgres, needs DATABASE_URL). Refusing to guess: a typo that silently served the "
367
+ f"other store is how data ends up in two places.")
368
+ return raw
369
+
370
+
371
+ def handle(repo_id=None, slug=None):
372
+ """THE tenant-bound store handle for the ACTIVE backend — the one seam every caller crosses.
373
+
374
+ `repo_id` addresses the HF backend (a dataset repo); `slug` addresses Postgres (a schema).
375
+ Both are passed by `harness.runtime.get_runtime`, which knows both facts, so flipping the
376
+ backend never needs a lookup table between them — and neither identifier has to be invented
377
+ for the backend that does not use it.
378
+ """
379
+ if backend() == 'pg':
380
+ import core.store_pg as _pg # noqa: PLC0415 — lazy: see the banner above
381
+ return _pg.PgStore(slug or os.environ.get('AIOS_TENANT') or 'royal-imports')
382
+ return for_repo(repo_id)
383
+
384
+
385
+ #: The HF default instance. Kept as a module global (not a property) because the seed/migration
386
+ #: tooling and `_flush_all_at_exit` both need the FILE store by name even when pg is active.
387
  _DEFAULT = for_repo(REPO)
388
 
389
 
390
+ def _d():
391
+ """The default tenant's store on the active backend — what every module function below uses.
392
+
393
+ ⚠ Resolved PER CALL, never cached. A cached default would freeze the backend at import time,
394
+ and import order is exactly what nobody controls: `api/main.py` imports half the platform
395
+ before it has read a single environment variable it did not inherit.
396
+ """
397
+ return handle() if backend() == 'pg' else _DEFAULT
398
+
399
+
400
  def available():
401
+ return _d().available()
402
 
403
 
404
  def get(name, fresh=False):
405
+ return _d().get(name, fresh=fresh)
406
 
407
 
408
  def _read_strict(name):
409
+ return _d()._read_strict(name)
410
 
411
 
412
  def exists(name):
413
+ return _d().exists(name)
414
 
415
 
416
  def upload_bytes(path_in_repo, data, message=None):
417
+ return _d().upload_bytes(path_in_repo, data, message=message)
418
 
419
 
420
  def download_bytes(path_in_repo):
421
+ return _d().download_bytes(path_in_repo)
422
 
423
 
424
  def delete_path(path_in_repo):
425
+ return _d().delete_path(path_in_repo)
426
 
427
 
428
  def put(name, data):
429
+ return _d().put(name, data)
430
 
431
 
432
  def flush(name=None, timeout=30.0):
433
+ return _d().flush(name=name, timeout=timeout)
434
 
435
 
436
  def update(name, fn, flush='sync'):
437
+ return _d().update(name, fn, flush=flush)
438
 
439
 
440
  def _flush_all_at_exit():
platform/core/store_backend.py CHANGED
@@ -3,21 +3,30 @@
3
  import core.store_backend as store # new code
4
  store.get('users') # goes to whichever backend is selected
5
 
6
- WHAT THIS DOES **NOT** DO, stated first because assuming otherwise would be a silent data
7
- split. Flipping `STORE_BACKEND=pg` does NOT redirect the ~40 existing callers that say
8
- `import core.store as store` they are bound to the HF module directly. Rewiring them is task
9
- **C-4** (the HF→Postgres cutover with its dual-read window), and C-4 cannot start until the
10
- database exists (owner blocker **B-3**). Until then this selector is the SEAM, exercised by new
11
- code and by the gate, and the default is `hf`, which is exactly today's behaviour.
12
-
13
- Doing it any other way would be worse. The tempting shortcut have `core/store.py` itself
14
- delegate would mean every existing caller silently changes backend the moment an env var is set
15
- in some shell, with no dual-read window and no way to compare the two stores' contents first.
16
- "One store" is the goal; getting there through a flag nobody audited would produce two.
17
-
18
- THE DEFAULT IS `hf` AND STAYS `hf` until C-4 says otherwise. An unrecognised value is a
19
- configuration error and RAISES rather than falling back a typo'd backend name that quietly
20
- served the old store would be discovered by data going missing.
 
 
 
 
 
 
 
 
 
21
  """
22
  import os
23
 
 
3
  import core.store_backend as store # new code
4
  store.get('users') # goes to whichever backend is selected
5
 
6
+ **C-4 HAPPENED WAVE 20, 2026-08-05 (owner ruling R1, DEBT D-4).** This header used to say,
7
+ in bold, that flipping `STORE_BACKEND=pg` did NOT redirect the ~28 callers that say
8
+ `import core.store as store`, and that "the tempting shortcut have `core/store.py` itself
9
+ delegate" would be worse because there was "no dual-read window and no way to compare the two
10
+ stores' contents first".
11
+
12
+ Both halves of that objection were ANSWERED rather than ignored, which is the only reason the
13
+ shortcut became the design:
14
+ * the comparison exists `ops/seed_pg_from_hf.py --verify` diffs the two stores key-by-key
15
+ (and value-by-value) and is run BEFORE any environment flips;
16
+ * the "silently changes backend the moment an env var is set in some shell" risk is why the
17
+ flip is fail-closed and loud: no `DATABASE_URL` under `pg` RAISES, it never falls back to the
18
+ file store. A misconfigured process refuses to serve instead of quietly writing to the wrong
19
+ place which is the failure mode the original warning actually cared about.
20
+
21
+ So **`core/store.py::handle()` is now the seam**, and it is the one every caller crosses:
22
+ module-level functions resolve it per call via `_d()`, and `harness.runtime.get_runtime` binds a
23
+ per-TENANT handle (a dataset repo on `hf`, a `t_<slug>` schema on `pg`). THIS module remains the
24
+ by-name selector for code that wants a specific backend's module rather than the active handle —
25
+ the migration tooling, and `verify_store_pg`, which asserts `store.backend()` and `name()` agree
26
+ on every value so the two entry points cannot drift.
27
+
28
+ An unrecognised value is a configuration error and RAISES rather than falling back — a typo'd
29
+ backend name that quietly served the old store would be discovered by data going missing.
30
  """
31
  import os
32
 
platform/core/store_pg.py CHANGED
@@ -249,3 +249,77 @@ def close():
249
  _POOL['pool'] = None
250
  _POOL['url'] = None
251
  _POOL.pop('ok', None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  _POOL['pool'] = None
250
  _POOL['url'] = None
251
  _POOL.pop('ok', None)
252
+
253
+
254
+ # ---------------------------------------------------------------------------------------------
255
+ # WAVE 20 (R1 / D-4) — THE TENANT-BOUND HANDLE. This is what made the cutover a small change.
256
+ #
257
+ # `core/store.py` grew a CLASS in wave 18 because a tenant can own its own dataset REPO, and
258
+ # `harness.runtime.TenantRuntime` carries one bound instance per tenant. Postgres isolates by
259
+ # SCHEMA instead (`t_<slug>`, see harness/pg/schema.sql's own argument for schema-over-RLS), so
260
+ # the two models meet here: one object, bound to one slug, exposing `core.store.Store`'s methods.
261
+ #
262
+ # ⚠ THE BINDING IS THE POINT. The module functions above default their schema from `AIOS_TENANT`,
263
+ # which is a PROCESS-wide answer to a PER-REQUEST question — correct for a single-tenant worker,
264
+ # wrong for the shared API that serves four tenants from one process. A handle carries its slug
265
+ # explicitly, so a write cannot land in another tenant's schema because some env var was right
266
+ # for the last request. That is the same property `TenantRuntime.store_handle` already gives the
267
+ # HF backend (EXIT-4b proof #3), reached a different way.
268
+ # ---------------------------------------------------------------------------------------------
269
+ class PgStore:
270
+ """`core.store.Store`'s interface for ONE tenant's schema.
271
+
272
+ Deliberately a thin binder over the module functions rather than a reimplementation: every
273
+ behaviour argument (FOR UPDATE in `update`, absent-is-success in `delete_path`, `fresh`
274
+ ignored because a SELECT has no cache to bypass) is stated once, up there, and cannot drift
275
+ between the two entry points.
276
+ """
277
+
278
+ def __init__(self, tenant_slug):
279
+ self.tenant_slug = str(tenant_slug or '').strip().lower() or 'royal-imports'
280
+ #: Kept so `Store`-shaped debugging (`repr`, telemetry, the admin panes) reads the same
281
+ #: on both backends. There is no repo here; the schema is the address.
282
+ self.repo = f'pg:{_schema_for(self.tenant_slug)}'
283
+
284
+ def __repr__(self):
285
+ return f'<PgStore {self.repo}>'
286
+
287
+ def available(self):
288
+ return available()
289
+
290
+ def get(self, name, fresh=False):
291
+ return get(name, fresh=fresh, tenant_slug=self.tenant_slug)
292
+
293
+ def _read_strict(self, name):
294
+ """Interface parity with the HF Store. There, `get` is lenient (swallows a transient
295
+ failure) and `_read_strict` opts out so a failed read ABORTS a read-modify-write instead
296
+ of merging into `{}`. Here `get` ALREADY raises — the leniency it opts out of does not
297
+ exist — so the strict read is the ordinary one, and saying so beats a second code path."""
298
+ return get(name, tenant_slug=self.tenant_slug)
299
+
300
+ def exists(self, name):
301
+ return exists(name, tenant_slug=self.tenant_slug)
302
+
303
+ def put(self, name, data):
304
+ return put(name, data, tenant_slug=self.tenant_slug)
305
+
306
+ def update(self, name, fn, flush='sync'):
307
+ return update(name, fn, flush=flush, tenant_slug=self.tenant_slug)
308
+
309
+ def upload_bytes(self, path_in_repo, data, message=None):
310
+ return upload_bytes(path_in_repo, data, message=message, tenant_slug=self.tenant_slug)
311
+
312
+ def download_bytes(self, path_in_repo):
313
+ return download_bytes(path_in_repo, tenant_slug=self.tenant_slug)
314
+
315
+ def delete_path(self, path_in_repo):
316
+ return delete_path(path_in_repo, tenant_slug=self.tenant_slug)
317
+
318
+ def flush(self, name=None, timeout=30.0):
319
+ return True
320
+
321
+ def _flush_now_at_exit(self):
322
+ """The atexit hook `core.store` registers for its instances. Nothing is buffered here —
323
+ every write above committed before it returned — so this is honestly a no-op rather than
324
+ an unimplemented method that would raise during interpreter shutdown."""
325
+ return True
platform/core/table_store.py CHANGED
@@ -199,6 +199,21 @@ class TableStore:
199
  # coalesces in the background. Registry/auth writes elsewhere stay flush='sync'.
200
  return self._st.update(self.table_key, _up, flush='async')
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  def save_active_view(self, username, view_id):
203
  """Remember which view this user last opened (owner item 3, 2026-07-31).
204
 
 
199
  # coalesces in the background. Registry/auth writes elsewhere stay flush='sync'.
200
  return self._st.update(self.table_key, _up, flush='async')
201
 
202
+ def rename_choice_values(self, username, change):
203
+ """Apply an arbitrary workspace rewrite (wave 20, item 15 / C-RENAME).
204
+
205
+ ⚠ NAMED FOR ITS ONE CALLER RATHER THAN EXPOSED AS A GENERIC `mutate`, deliberately. A
206
+ public "do anything to the workspace" method is an invitation to put write logic in
207
+ callers instead of here, and every OTHER method on this class exists precisely because
208
+ that logic belongs in one place. Renaming a choice is the one operation that must touch
209
+ three strata AT ONCE — the field's `choices`, the cells in `overlays`, and the views that
210
+ filter or colour by the old value — inside a SINGLE transaction, because a rename that
211
+ updated the cells and not the filters would leave a saved view matching nothing.
212
+
213
+ `change(ws)` receives the whole workspace with every stratum pre-created (see `_update`).
214
+ """
215
+ return self._update(username, change)
216
+
217
  def save_active_view(self, username, view_id):
218
  """Remember which view this user last opened (owner item 3, 2026-07-31).
219
 
platform/core/user_tables.py CHANGED
@@ -178,15 +178,26 @@ def set_fields(table_key, fields, st=None):
178
  return True
179
 
180
 
181
- def add_row(table_key, values=None, username=None, st=None):
182
- """Append one row. Returns the new row id, or None if refused."""
 
 
 
 
 
 
 
183
  if not is_user_table(table_key, st):
184
  return None # ⚠ never on a connector-backed table
185
  defn = get(table_key, st) or {}
186
  rows = defn.get('rows') or {}
187
  if len(rows) >= MAX_ROWS:
188
  return None
189
- rid = str(max((int(r) for r in rows if str(r).isdigit()), default=0) + 1)
 
 
 
 
190
  valid = {f['key'] for f in (defn.get('fields') or [])}
191
  clean = {k: str(v) for k, v in (values or {}).items() if k in valid}
192
 
@@ -200,45 +211,244 @@ def add_row(table_key, values=None, username=None, st=None):
200
  return rid
201
 
202
 
203
- def upsert_rows(table_key, rows_by_key, key_field, username=None, st=None):
204
- """Bulk UPSERT for the automation engine (wave 18 C4-AUTO): match on `key_field`'s value,
205
- update matched rows, insert the rest. Returns `{'updated': n, 'inserted': n, 'orphans': n}`
206
- where orphans = existing rows whose key value did not appear this run — COUNTED, never
207
- deleted (the contract's no-silent-delete rule). Refuses on a non-user table."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  if not is_user_table(table_key, st):
209
  return None
210
  defn = get(table_key, st) or {}
211
- valid = {f['key'] for f in (defn.get('fields') or [])}
212
- if key_field not in valid:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  return None
214
- incoming = {}
215
- for kv, vals in (rows_by_key or {}).items():
216
- kv = str(kv).strip()
217
- if kv:
218
- incoming[kv] = {k: str(v) for k, v in (vals or {}).items() if k in valid}
219
- counts = {'updated': 0, 'inserted': 0, 'orphans': 0}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
  def _apply(cur):
222
  t = cur.get(str(table_key))
223
  if t is None:
224
  return cur
225
- rows = t.setdefault('rows', {})
226
- by_val = {}
227
- for rid, row in rows.items():
228
- v = str((row or {}).get(key_field) or '').strip()
229
- if v and v not in by_val:
230
- by_val[v] = rid
231
- next_id = max((int(r) for r in rows if str(r).isdigit()), default=0)
232
- for kv, vals in incoming.items():
233
- rid = by_val.get(kv)
234
- if rid is not None:
235
- rows[rid].update(vals)
236
- counts['updated'] += 1
237
- elif len(rows) < MAX_ROWS:
238
- next_id += 1
239
- rows[str(next_id)] = vals
240
- counts['inserted'] += 1
241
- counts['orphans'] = sum(1 for v in by_val if v not in incoming)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  return cur
243
 
244
  _st(st).update(STORE_KEY, _apply, flush='sync')
@@ -259,8 +469,15 @@ def delete_row(table_key, row_id, st=None):
259
  return True
260
 
261
 
 
 
 
 
 
 
 
262
  def may_open(table_key, viewer, is_admin=False, st=None):
263
- """FAIL-CLOSED visibility: the creator, or an admin. Nothing else.
264
 
265
  A user table cannot be gated by `allowed_modules` — a module grant written last month cannot
266
  describe a table created this morning — so it needs its own rule, and the safe rule is the
@@ -268,6 +485,14 @@ def may_open(table_key, viewer, is_admin=False, st=None):
268
  a BU-scoped sales agent) could open a table somebody else created just by knowing its key.
269
  Sharing a user table with named colleagues is a follow-on, and it should reuse the shared-
270
  VIEW vocabulary rather than inventing a second one.
 
 
 
 
 
 
 
 
271
  """
272
  t = get(table_key, st)
273
  if not t:
 
178
  return True
179
 
180
 
181
+ def add_row(table_key, values=None, username=None, st=None, rid=None):
182
+ """Append one row. Returns the new row id, or None if refused.
183
+
184
+ `rid` RESTORES A ROW UNDER ITS OLD ID (contract C-ADDROW / C-UNDO). Undo has to put a deleted
185
+ row back where it was: a restore under a fresh id would break every cohort, comment and view
186
+ filter that named the original, so the id is part of what is being undone. It is honoured
187
+ only when that id is FREE — an undo can never overwrite a row somebody has since created in
188
+ the gap, and it never invents a non-numeric id, because `scoped_pool` reads row ids as ints.
189
+ """
190
  if not is_user_table(table_key, st):
191
  return None # ⚠ never on a connector-backed table
192
  defn = get(table_key, st) or {}
193
  rows = defn.get('rows') or {}
194
  if len(rows) >= MAX_ROWS:
195
  return None
196
+ want = str(rid or '').strip()
197
+ if want and want.isdigit() and want not in rows:
198
+ rid = want
199
+ else:
200
+ rid = str(max((int(r) for r in rows if str(r).isdigit()), default=0) + 1)
201
  valid = {f['key'] for f in (defn.get('fields') or [])}
202
  clean = {k: str(v) for k, v in (values or {}).items() if k in valid}
203
 
 
211
  return rid
212
 
213
 
214
+ # ⛔ `upsert_rows` USED TO LIVE HERE, AND DELETING IT IS THE FIX (DEBT D-6, wave 20).
215
+ #
216
+ # There were TWO bulk-upsert implementations this one and `automation_engine.upsert_rows` —
217
+ # with different counts (`{updated, inserted, orphans}` vs the engine's seven, including the
218
+ # `capped` count that makes a full table LOUD), a different signature (a dict keyed by value vs
219
+ # a list of rows) and a different cap story. D-6 called it a "silent drift risk"; the honest
220
+ # measurement is worse and simpler: **this one had zero callers, anywhere in the repo.** It was
221
+ # not drifting from the engine, it was a second answer nobody had ever asked.
222
+ #
223
+ # So there is now one implementation because there is one implementation — no parity gate to
224
+ # maintain, no second definition to keep in step. If a host-side bulk upsert is ever wanted, the
225
+ # engine's is the one that has been exercised (verify_automation section B) and it is PURE:
226
+ # `(existing, incoming, key_field, cap) -> (rows, counts)`, so it can be lifted here without its
227
+ # store half coming along. Do not re-derive a new one.
228
+
229
+
230
+ def set_label(table_key, label, st=None):
231
+ """Rename a table IN ITS DEFINITION. Returns the stored label, or None if refused.
232
+
233
+ ⚠ WAVE 20, item 6a — THE RENAME USED TO WRITE ONLY `nav_meta`. That bucket is the nav's
234
+ display layer, so the rail showed the new name while everything reading the DEFINITION —
235
+ the automation editor's database picker above all — went on showing the old one. A rename
236
+ that only some surfaces can see is worse than no rename: the picker was not stale-looking,
237
+ it was confidently wrong, and a user choosing "Influencers" there could be choosing the
238
+ table they had renamed to something else months earlier.
239
+ """
240
+ label = ' '.join(str(label or '').split())[:MAX_LABEL]
241
+ if not label or not is_user_table(table_key, st):
242
+ return None
243
+
244
+ def _set(cur):
245
+ t = cur.get(str(table_key))
246
+ if t is not None:
247
+ t['label'] = label
248
+ return cur
249
+
250
+ _st(st).update(STORE_KEY, _set, flush='sync')
251
+ return label
252
+
253
+
254
+ # ---------------------------------------------------------------------------------------------
255
+ # THE SHARED FIELD SCHEMA (contract C-FIELD, owner ruling R2)
256
+ # ---------------------------------------------------------------------------------------------
257
+ # R2 reverses wave 17's "fields are per-user" law FOR THIS PATH: a `ut_*` table's fields are the
258
+ # TABLE'S SCHEMA, the way they are in Airtable — everyone with access to the database sees the
259
+ # same columns, and the creator or an admin edits them. Per-field `editRole` narrows or widens
260
+ # who may edit ONE column's definition without handing over the whole table.
261
+ #
262
+ # ⚠ `editRole` GOVERNS THE SCHEMA, NEVER THE VALUES. 'everyone' means anybody with access may
263
+ # rename this column or change its options; it does not decide who may type in its cells. Those
264
+ # are different questions and conflating them would let a column's own settings quietly become a
265
+ # data-permission system nobody wrote.
266
+
267
+ #: Who may edit ONE field's definition. Fail-closed default: admins (= creator or admin).
268
+ FIELD_EDIT_ROLES = ('admins', 'everyone')
269
+
270
+
271
+ def _clean_field(raw, previous=None):
272
+ """One field dict → the stored shape, or None. The single validator for create AND patch, so
273
+ a column cannot be typed one way on the way in and another on the way back."""
274
+ prev = previous or {}
275
+ raw = raw if isinstance(raw, dict) else {}
276
+ label = ' '.join(str(raw.get('label') or prev.get('label') or '').split())[:80]
277
+ key = re.sub(r'[^a-z0-9_]+', '_',
278
+ str(raw.get('key') or prev.get('key') or _slug(label)).strip().lower())
279
+ key = key.strip('_')[:60]
280
+ ftype = str(raw.get('type') or prev.get('type') or 'text').strip().lower()
281
+ if not key or not label or ftype not in UT_FIELD_TYPES:
282
+ return None
283
+ out = {'key': key, 'label': label, 'type': ftype, 'source': 'overlay'}
284
+ opts_raw = raw.get('options') if 'options' in raw else prev.get('options')
285
+ if ftype in ('select', 'multiselect'):
286
+ opts = [' '.join(str(o).split())[:60] for o in (opts_raw or []) if str(o).strip()][:50]
287
+ if opts:
288
+ out['options'] = opts
289
+ role = str(raw.get('editRole') or prev.get('editRole') or 'admins').strip().lower()
290
+ out['editRole'] = role if role in FIELD_EDIT_ROLES else 'admins'
291
+ if prev.get('default') is True or raw.get('default') is True:
292
+ out['default'] = True
293
+ # An automation column carries the binding the engine reads; it is preserved across a patch
294
+ # rather than re-declared, because the automation editor owns it and this route does not.
295
+ auto = raw.get('automation') if 'automation' in raw else prev.get('automation')
296
+ if isinstance(auto, dict):
297
+ out['automation'] = auto
298
+ return out
299
+
300
+
301
+ def may_edit_field(table_key, fkey, viewer, is_admin=False, st=None):
302
+ """May `viewer` change THIS column's definition? Creator/admin always; others only when the
303
+ field itself says `editRole: 'everyone'`. Fail-closed on an unknown field."""
304
+ if may_open(table_key, viewer, is_admin, st) and (
305
+ bool(is_admin) or (get(table_key, st) or {}).get('createdBy') == viewer):
306
+ return True
307
+ for f in ((get(table_key, st) or {}).get('fields') or []):
308
+ if f.get('key') == str(fkey):
309
+ return f.get('editRole') == 'everyone'
310
+ return False
311
+
312
+
313
+ def add_field(table_key, raw, st=None):
314
+ """Append one column to the shared schema. Returns the stored field, or None if refused."""
315
  if not is_user_table(table_key, st):
316
  return None
317
  defn = get(table_key, st) or {}
318
+ have = [f for f in (defn.get('fields') or [])]
319
+ if len(have) >= MAX_FIELDS:
320
+ return None
321
+ field = _clean_field(raw)
322
+ if not field or any(f.get('key') == field['key'] for f in have):
323
+ return None
324
+
325
+ def _add(cur):
326
+ t = cur.get(str(table_key))
327
+ if t is not None:
328
+ t.setdefault('fields', []).append(field)
329
+ return cur
330
+
331
+ _st(st).update(STORE_KEY, _add, flush='sync')
332
+ return field
333
+
334
+
335
+ def patch_field(table_key, fkey, raw, st=None):
336
+ """Edit one column's definition IN PLACE. Returns the stored field, or None if refused.
337
+
338
+ ⚠ THE KEY NEVER MOVES. A field's key is what every stored cell is filed under, so accepting
339
+ a new one here would orphan every value in the column while looking like a rename. The
340
+ LABEL is the renameable thing; the key is identity.
341
+ """
342
+ if not is_user_table(table_key, st):
343
+ return None
344
+ fields = [dict(f) for f in ((get(table_key, st) or {}).get('fields') or [])]
345
+ idx = next((i for i, f in enumerate(fields) if f.get('key') == str(fkey)), -1)
346
+ if idx < 0:
347
+ return None
348
+ merged = dict(raw or {})
349
+ merged['key'] = str(fkey)
350
+ field = _clean_field(merged, fields[idx])
351
+ if not field:
352
  return None
353
+
354
+ def _set(cur):
355
+ t = cur.get(str(table_key))
356
+ if t is not None:
357
+ for i, f in enumerate(t.get('fields') or []):
358
+ if f.get('key') == str(fkey):
359
+ t['fields'][i] = field
360
+ break
361
+ return cur
362
+
363
+ _st(st).update(STORE_KEY, _set, flush='sync')
364
+ return field
365
+
366
+
367
+ def delete_field(table_key, fkey, st=None):
368
+ """Drop one column from the shared schema. Refuses to leave a table fieldless.
369
+
370
+ ⚠ THE CELLS ARE LEFT IN THE ROWS ON PURPOSE. A deleted column whose values were also
371
+ scrubbed makes an accidental delete unrecoverable; the values are invisible without a field
372
+ declaring them, and re-adding the column with the same key brings them back. Same reasoning
373
+ as `routes_tables.delete_table` leaving the workspace bucket in place. Booked, not hidden.
374
+ """
375
+ if not is_user_table(table_key, st):
376
+ return False
377
+ fields = (get(table_key, st) or {}).get('fields') or []
378
+ if len(fields) <= 1 or not any(f.get('key') == str(fkey) for f in fields):
379
+ return False
380
+
381
+ def _drop(cur):
382
+ t = cur.get(str(table_key))
383
+ if t is not None:
384
+ t['fields'] = [f for f in (t.get('fields') or []) if f.get('key') != str(fkey)]
385
+ return cur
386
+
387
+ _st(st).update(STORE_KEY, _drop, flush='sync')
388
+ return True
389
+
390
+
391
+ def rename_choice_values(table_key, fkey, renames, st=None):
392
+ """Rename select/multiselect OPTIONS **and migrate every stored cell** (contract C-RENAME).
393
+
394
+ `renames` = `[{'from': old, 'to': new}, …]` — an EXPLICIT MAPPING, never a diff. A diff
395
+ cannot tell "renamed Blue to Navy" from "deleted Blue and added Navy", and guessing wrong
396
+ empties a column silently.
397
+
398
+ Returns `{'options': n, 'cells': n}`. This is the DEFINITION half — the base options list and
399
+ the base row values. A user's per-user overlay stratum and any view filter naming the old
400
+ value are `core.table_store`'s half of the same contract; the caller runs both.
401
+ """
402
+ pairs = []
403
+ for r in (renames or [])[:50]:
404
+ if not isinstance(r, dict):
405
+ continue
406
+ a = ' '.join(str(r.get('from') or '').split())[:60]
407
+ b = ' '.join(str(r.get('to') or '').split())[:60]
408
+ if a and b and a != b:
409
+ pairs.append((a, b))
410
+ if not pairs or not is_user_table(table_key, st):
411
+ return {'options': 0, 'cells': 0}
412
+ mapping = dict(pairs)
413
+ counts = {'options': 0, 'cells': 0}
414
 
415
  def _apply(cur):
416
  t = cur.get(str(table_key))
417
  if t is None:
418
  return cur
419
+ multi = False
420
+ for f in (t.get('fields') or []):
421
+ if f.get('key') != str(fkey):
422
+ continue
423
+ multi = f.get('type') == 'multiselect'
424
+ opts = f.get('options') or []
425
+ new_opts, seen = [], set()
426
+ for o in opts:
427
+ v = mapping.get(o, o)
428
+ if v not in seen: # a rename ONTO an existing option merges them
429
+ seen.add(v)
430
+ new_opts.append(v)
431
+ if o in mapping:
432
+ counts['options'] += 1
433
+ if opts:
434
+ f['options'] = new_opts
435
+ for row in (t.get('rows') or {}).values():
436
+ if not isinstance(row, dict) or str(fkey) not in row:
437
+ continue
438
+ cell = str(row.get(str(fkey)) or '')
439
+ if not cell:
440
+ continue
441
+ if multi:
442
+ # ⚠ MULTISELECT CELLS ARE COMMA-JOINED LABEL STRINGS, so a rename has to walk the
443
+ # parts. Rewriting the whole string would only ever hit a single-value cell.
444
+ parts = [p.strip() for p in cell.split(',')]
445
+ nxt = [mapping.get(p, p) for p in parts]
446
+ if nxt != parts:
447
+ row[str(fkey)] = ', '.join(dict.fromkeys(nxt))
448
+ counts['cells'] += 1
449
+ elif cell in mapping:
450
+ row[str(fkey)] = mapping[cell]
451
+ counts['cells'] += 1
452
  return cur
453
 
454
  _st(st).update(STORE_KEY, _apply, flush='sync')
 
469
  return True
470
 
471
 
472
+ #: Names the ENGINE uses for itself when no human is on the other end of a run. ⛔ A table
473
+ #: stamped with one of these has NO HUMAN OWNER, so `may_open` can only admit an admin — which
474
+ #: is why the automation engine must never mint one (see `ut_ensure`'s owner rule, wave 20).
475
+ #: Named here rather than inline so the two modules that care about it read the SAME list.
476
+ MACHINE_OWNERS = ('automation', 'scheduler')
477
+
478
+
479
  def may_open(table_key, viewer, is_admin=False, st=None):
480
+ """FAIL-CLOSED visibility: the creator, or an admin. Nothing else. **THE one resolver.**
481
 
482
  A user table cannot be gated by `allowed_modules` — a module grant written last month cannot
483
  describe a table created this morning — so it needs its own rule, and the safe rule is the
 
485
  a BU-scoped sales agent) could open a table somebody else created just by knowing its key.
486
  Sharing a user table with named colleagues is a follow-on, and it should reuse the shared-
487
  VIEW vocabulary rather than inventing a second one.
488
+
489
+ ⛔ WAVE 20 — THERE WAS A SECOND, WIDER RULE, AND THE TWO DISAGREED. `routes_automation`'s
490
+ table picker admitted `createdBy in (uname, 'automation', 'scheduler')`, so a non-admin could
491
+ SEE an automation-created database in the picker and then be refused when they opened it,
492
+ edited it, or tried to delete it. Two ideas of who owns a table is the same defect class as
493
+ the two ideas of an agent's book (D-30): both surfaces look right in isolation and only
494
+ disagree in front of a user. The picker now calls THIS function, and the engine stamps a
495
+ real owner (see `MACHINE_OWNERS`) so nothing legitimate is narrowed by the merge.
496
  """
497
  t = get(table_key, st)
498
  if not t:
platform/harness/runtime.py CHANGED
@@ -250,11 +250,27 @@ def get_runtime(tenant_key):
250
  # R2: a tenant with its OWN repo gets a bound Store instance and an EMPTY prefix (the
251
  # repo boundary is the namespace); everyone else keeps the t/<slug>/ prefix in the
252
  # shared repo. Tenant #0 keeps both defaults — empty prefix, shared repo.
 
 
 
 
 
 
 
 
 
 
 
 
253
  repo = (getattr(tenant, "config", {}) or {}).get("store_repo")
254
- rt = TenantRuntime(
255
- key=key, tenant=tenant,
256
- store_namespace=("" if (key == "royal-imports" or repo) else f"t/{key}/"),
257
- store_handle=(store.for_repo(repo) if repo else None))
 
 
 
 
258
  _CACHE[key] = rt
259
  while len(_CACHE) > _MAX_RUNTIMES:
260
  _CACHE.popitem(last=False) # evict least recently used
 
250
  # R2: a tenant with its OWN repo gets a bound Store instance and an EMPTY prefix (the
251
  # repo boundary is the namespace); everyone else keeps the t/<slug>/ prefix in the
252
  # shared repo. Tenant #0 keeps both defaults — empty prefix, shared repo.
253
+ #
254
+ # ⭐ WAVE 20 (R1 / D-4): under Postgres the address is a SCHEMA, so `store.handle()` is
255
+ # asked for one by SLUG and the namespace prefix goes to EMPTY for EVERY tenant —
256
+ # `t_<slug>.store_kv` already isolates, and prefixing keys inside an isolated schema
257
+ # would namespace them twice. That is not a cosmetic difference: a `t/nurilab/`-prefixed
258
+ # key written into `t_nurilab` is a key the migration did not copy and no reader looks
259
+ # for, i.e. a silent empty workspace.
260
+ #
261
+ # ⚠ NOTHING ELSE IN THIS FILE CHANGES, and that is the design working. Every product-data
262
+ # read/write already goes through `rt.get/put/update/exists` (the `st=` handle threaded
263
+ # through the modules), so binding the handle correctly here IS the cutover for the
264
+ # tenant-aware paths — the module-level `core.store` functions cover the rest via `_d()`.
265
  repo = (getattr(tenant, "config", {}) or {}).get("store_repo")
266
+ if store.backend() == "pg":
267
+ rt = TenantRuntime(key=key, tenant=tenant, store_namespace="",
268
+ store_handle=store.handle(slug=key))
269
+ else:
270
+ rt = TenantRuntime(
271
+ key=key, tenant=tenant,
272
+ store_namespace=("" if (key == "royal-imports" or repo) else f"t/{key}/"),
273
+ store_handle=(store.for_repo(repo) if repo else None))
274
  _CACHE[key] = rt
275
  while len(_CACHE) > _MAX_RUNTIMES:
276
  _CACHE.popitem(last=False) # evict least recently used
platform/modules/customer_data.py CHANGED
@@ -272,6 +272,94 @@ def _dba_attrs(pids, t):
272
  return {}
273
 
274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  def _pool_build(agent_name, team_id, limit, fast=False):
276
  t = P.today()
277
  yf, yt = P.ytd(t)
@@ -282,7 +370,7 @@ def _pool_build(agent_name, team_id, limit, fast=False):
282
  last = cust._cust_rev(lf, lt, team_id, pids)
283
  ltm = cust._cust_rev(mf, mt, team_id, pids)
284
  cad = cust._cadence_bulk(t, team_id, agent_pids=pids)
285
- all_pids = set(this) | set(last) | set(cad)
286
  total = len(all_pids)
287
  if limit is not None and total > limit:
288
  # the SAME ordering the assembled pool ships (see the sort below) — the slice must be
 
272
  return {}
273
 
274
 
275
+ def _book_pids(team_id=None, agent_pids=None):
276
+ """⭐ WAVE 20 (owner items 8 + 27, ruling R3) — THE WHOLE BOOK, not just who bought recently.
277
+
278
+ Owner: *"Martin Pasternak showing only 313 accounts when I filter agent, whereas in Odoo it is
279
+ 494 … I want our App to show complete source of truth, even if no sale.order at all or not.
280
+ Its important to see what customer is getting assigned to Martin or whether we can retarget
281
+ them."* The pool was a 24-MONTH SALES universe (YTD ∪ LY-YTD ∪ cadence-window buyers), so a
282
+ customer assigned to an agent who has never ordered — precisely a retargeting target — did not
283
+ exist in the app at all.
284
+
285
+ R3's definition, MEASURED against live Odoo 2026-08-05:
286
+ customer_rank>0 active .............. 3,614
287
+ ever ordered (confirmed, team 5/6) ... 1,748
288
+ UNION ............................... 3,723 (vs 1,555 in the old 24-month pool)
289
+ Martin's book under this rule is **494** — the owner's Odoo number, to the account.
290
+
291
+ ⛔ **THE "agent-assigned" LEG OF R3 IS DELIBERATELY NOT A THIRD TERM, and that is a
292
+ measurement, not a shortcut.** Taken literally it added 174 partners and pushed Martin to 503;
293
+ every one of the 9 extras on his book was an ODOO ADDRESS RECORD rather than an account —
294
+ `type` in (`delivery`, `other`), most carrying a `parent_id`, and TWO with `name: False`.
295
+ Shipping them would have put nameless rows in the customer table and made "how many customers
296
+ does Martin have" answer 503 against an Odoo screen that says 494.
297
+ Excluding address types wholesale is also wrong (`type not in (delivery, invoice, other,
298
+ private)` measured **488** — it drops 6 genuine accounts that happen to carry a delivery
299
+ type). `customer_rank > 0` is the predicate that means "this partner is a customer record",
300
+ it reproduces the owner's number exactly, and every agent-assigned ACCOUNT already satisfies
301
+ it — so the leg is subsumed rather than dropped. Item 27's actual ask (the 126 assigned
302
+ partners with no `sale.order` at all) is fully served: they are rank>0 and they are in.
303
+
304
+ ⚠ ACTIVE ONLY. Archived partners stay out (R3): they are ex-customers, and putting them in
305
+ every count would make "how many customers do we have" unanswerable. The agent-LOGIN scope
306
+ (`customers.agent_partner_ids`) deliberately still includes archived — an agent's own book is
307
+ their whole history — and the two remain compatible because that set is INTERSECTED with this
308
+ pool, so archived rows drop out of the table without narrowing the agent's own permissions.
309
+
310
+ ⚠ BU SCOPE IS APPLIED THROUGH ORDERS, NOT THROUGH THE PARTNER. `res.partner` carries no team,
311
+ so a BU-scoped caller gets the partners who have ORDERED in that BU (plus their own agent
312
+ book). Widening it to every partner for a scoped user would cross the BU isolation rule that
313
+ the whole permissioning model rests on.
314
+ """
315
+ try:
316
+ if team_id:
317
+ # Scoped: partners with confirmed orders in THIS BU, ever. `read_group` on partner_id
318
+ # rather than a search_read of orders — the group is the distinct set, and it is one
319
+ # round trip instead of paging tens of thousands of order rows.
320
+ rows = O.read_group('sale.order',
321
+ [('state', 'in', ('sale', 'done')), ('team_id', '=', team_id)],
322
+ ['partner_id'], ['partner_id'], lazy=False)
323
+ book = {O.m2o_id(r.get('partner_id')) for r in rows}
324
+ book.discard(None)
325
+ else:
326
+ rank = O.search_read('res.partner',
327
+ [('customer_rank', '>', 0), ('active', '=', True)],
328
+ ['id'], limit=200000)
329
+ # `ever` is NOT redundant with rank>0: a partner can be archived-then-reactivated, or
330
+ # have had its rank reset, and an account that demonstrably bought from us belongs in
331
+ # the book whatever its flags say now. An ORDER is a fact; a rank is a setting.
332
+ rows = O.read_group('sale.order',
333
+ [('state', 'in', ('sale', 'done')), ('team_id', 'in', (5, 6))],
334
+ ['partner_id'], ['partner_id'], lazy=False)
335
+ ever = {O.m2o_id(r.get('partner_id')) for r in rows}
336
+ ever.discard(None)
337
+ book = {r['id'] for r in rank} | ever
338
+ # An agent filter NARROWS the book to that agent's partners — never widens it. The
339
+ # intersection is what keeps an agent-login user inside their own book while still
340
+ # gaining every no-order account assigned to them, which is the whole point of item 27.
341
+ return (book & set(agent_pids)) if agent_pids is not None else book
342
+ except Exception as e:
343
+ # Degrade to the sales-derived universe rather than failing the page — but LOUDLY.
344
+ #
345
+ # ⛔ THIS DEGRADATION IS NOT LIKE THE OTHERS IN THIS MODULE, and the difference is what
346
+ # makes silence wrong here. Every other family (`_ar_attrs`, `_mix_attrs`, …) degrades to
347
+ # a blank COLUMN, which is visible: the user sees an empty column and asks why. This one
348
+ # degrades to absent ROWS. The pool still builds from the sales legs, so the page renders
349
+ # perfectly, the counts look plausible, and Martin is quietly back at 313 with nothing
350
+ # anywhere saying the book leg failed. An invisible degradation of a COUNT is the failure
351
+ # mode this codebase keeps writing rules against ([[no-unverifiable-aggregates]]).
352
+ try:
353
+ import harness.telemetry as _tel
354
+ _tel.error('customer_data:_book_pids', e, fallback='sales-derived pool only')
355
+ except Exception:
356
+ pass
357
+ print(f"[aios] WARNING customer_data._book_pids failed ({type(e).__name__}: {e}) - the "
358
+ f"customer pool is falling back to the 24-month SALES universe, so accounts with "
359
+ f"no recent orders (owner item 27) are MISSING from this build.")
360
+ return set()
361
+
362
+
363
  def _pool_build(agent_name, team_id, limit, fast=False):
364
  t = P.today()
365
  yf, yt = P.ytd(t)
 
370
  last = cust._cust_rev(lf, lt, team_id, pids)
371
  ltm = cust._cust_rev(mf, mt, team_id, pids)
372
  cad = cust._cadence_bulk(t, team_id, agent_pids=pids)
373
+ all_pids = set(this) | set(last) | set(cad) | _book_pids(team_id, pids)
374
  total = len(all_pids)
375
  if limit is not None and total > limit:
376
  # the SAME ordering the assembled pool ships (see the sort below) — the slice must be
platform/modules/customers.py CHANGED
@@ -89,11 +89,30 @@ def agent_options(t=None, team_id=None):
89
  return [r['group'] for r in by_dimension('agent', t, team_id=team_id)]
90
 
91
 
92
- def agent_partner_ids(agent_name):
93
  """frozenset of partner ids assigned to `agent_name` via res.partner.agent_ids. None when no
94
  agent is selected ('All agents'); an EMPTY frozenset (matches nobody) when the agent has no
95
  customers. '(none)' resolves to customers with no agent assigned.
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  INCLUDES INACTIVE/archived partners (active in [True,False]) — the agent's book is the whole
98
  book (dormant + archived accounts too), which is what the Agents page promises AND what an
99
  AGENT-LOGIN user must see as their complete, isolated book. Active-only used to drop archived
@@ -103,6 +122,9 @@ def agent_partner_ids(agent_name):
103
  if not agent_name or agent_name in ('All agents', 'All'):
104
  return None
105
  _all = [('active', 'in', [True, False])]
 
 
 
106
  if agent_name == '(none)':
107
  rows = O.search_read('res.partner', [('customer_rank', '>', 0),
108
  ('agent_ids', '=', False)] + _all, ['id'], limit=100000)
@@ -110,7 +132,7 @@ def agent_partner_ids(agent_name):
110
  ag = O.search_read('res.partner', [('name', '=', agent_name)] + _all, ['id'], limit=10)
111
  if not ag:
112
  return frozenset()
113
- rows = O.search_read('res.partner', [('agent_ids', 'in', [a['id'] for a in ag])] + _all,
114
  ['id'], limit=100000)
115
  return frozenset(r['id'] for r in rows)
116
 
@@ -591,7 +613,18 @@ def _partner_attrs(pids):
591
  'city': city.title() if city else '(none)',
592
  'state': O.m2o_name(r.get('state_id')) or '(none)',
593
  'country': O.m2o_name(r.get('country_id')) or '(none)',
594
- 'agent': (anames.get(ag[0]) or '(none)') if ag else '(none)',
 
 
 
 
 
 
 
 
 
 
 
595
  'zip': (r.get('zip') or '').strip() or '(none)',
596
  'payment_terms': O.m2o_name(r.get('property_payment_term_id')) or '(none)',
597
  'customer_since': str(since)[:10] if since else '',
 
89
  return [r['group'] for r in by_dimension('agent', t, team_id=team_id)]
90
 
91
 
92
+ def agent_partner_ids(agent_name, customers_only=False):
93
  """frozenset of partner ids assigned to `agent_name` via res.partner.agent_ids. None when no
94
  agent is selected ('All agents'); an EMPTY frozenset (matches nobody) when the agent has no
95
  customers. '(none)' resolves to customers with no agent assigned.
96
 
97
+ ⭐ WAVE 20 (R3, DEBT D-30) — `customers_only` IS THE EXPLICIT POLICY THIS FUNCTION WAS
98
+ MISSING, and the two callers genuinely want different answers:
99
+
100
+ * **Permission scope (default, `False`)** — an agent-LOGIN user's whole app is bounded by
101
+ this set, so it must be GENEROUS: archived accounts and Odoo address records included.
102
+ Narrowing it would hide an agent's own data from them, and the fails-closed trap in the
103
+ note below is what that costs.
104
+ * **Display/reporting (`True`)** — "how many accounts does Martin have" must answer what
105
+ Odoo answers. MEASURED 2026-08-05: the generous set is 503 for Martin and the honest one
106
+ is **494**, the owner's number; the 9-row gap is entirely `type in (delivery, other)`
107
+ ADDRESS records that ride the pool because they appear on orders, two of them with no
108
+ name at all.
109
+
110
+ Both are the SAME m2m-contains resolver — which is D-30's actual requirement. The bug was
111
+ never the generosity; it was that the Agent COLUMN used a THIRD rule (`agent_ids[0]`, first
112
+ agent only) that agreed with neither, so an admin filtering `Agent = X` and X's own login saw
113
+ different books. The column now lists every agent on the partner and this states its policy
114
+ out loud, so the two can be reconciled by reading them instead of by measuring them.
115
+
116
  INCLUDES INACTIVE/archived partners (active in [True,False]) — the agent's book is the whole
117
  book (dormant + archived accounts too), which is what the Agents page promises AND what an
118
  AGENT-LOGIN user must see as their complete, isolated book. Active-only used to drop archived
 
122
  if not agent_name or agent_name in ('All agents', 'All'):
123
  return None
124
  _all = [('active', 'in', [True, False])]
125
+ # The display policy: a real customer record, still active. See the docstring for the
126
+ # measured 503-vs-494 this closes.
127
+ _qual = [('customer_rank', '>', 0), ('active', '=', True)] if customers_only else _all
128
  if agent_name == '(none)':
129
  rows = O.search_read('res.partner', [('customer_rank', '>', 0),
130
  ('agent_ids', '=', False)] + _all, ['id'], limit=100000)
 
132
  ag = O.search_read('res.partner', [('name', '=', agent_name)] + _all, ['id'], limit=10)
133
  if not ag:
134
  return frozenset()
135
+ rows = O.search_read('res.partner', [('agent_ids', 'in', [a['id'] for a in ag])] + _qual,
136
  ['id'], limit=100000)
137
  return frozenset(r['id'] for r in rows)
138
 
 
613
  'city': city.title() if city else '(none)',
614
  'state': O.m2o_name(r.get('state_id')) or '(none)',
615
  'country': O.m2o_name(r.get('country_id')) or '(none)',
616
+ # WAVE 20 (R3, closes DEBT D-30) EVERY agent on the partner, not just the first.
617
+ #
618
+ # This was `anames.get(ag[0])`, and it made TWO definitions of "an agent's book" that
619
+ # disagreed: `agent_partner_ids()` (which scopes an agent-LOGIN user's whole app) is
620
+ # m2m-CONTAINS, while this column named only `agent_ids[0]`. So an admin filtering
621
+ # `Agent = X` and X's own login saw different sets — MEASURED book-wide 2026-08-05:
622
+ # Tara Devon Gallager 9 rows, Sang Ching 6, Moishe Rubenstein 1, Martin Pasternak 1.
623
+ #
624
+ # Joined with ', ' rather than kept as a list because the column is a TEXT field the
625
+ # grid groups and filters on; `contains` then matches any agent on a shared account,
626
+ # which is the m2m question asked in the vocabulary the column already speaks.
627
+ 'agent': ', '.join(n for n in (anames.get(a) for a in ag) if n) or '(none)',
628
  'zip': (r.get('zip') or '').strip() or '(none)',
629
  'payment_terms': O.m2o_name(r.get('property_payment_term_id')) or '(none)',
630
  'customer_since': str(since)[:10] if since else '',
requirements.txt CHANGED
@@ -24,3 +24,14 @@ pillow>=10.0
24
  beautifulsoup4>=4.12
25
  lxml>=5.0
26
  cryptography>=42.0
 
 
 
 
 
 
 
 
 
 
 
 
24
  beautifulsoup4>=4.12
25
  lxml>=5.0
26
  cryptography>=42.0
27
+ # ⭐ WAVE 20 (R1 / D-4) — THE POSTGRES DRIVER, AND IT IS LOAD-BEARING IN THIS FILE SPECIFICALLY.
28
+ # `core/store_pg.py` is the store backend from this wave on, and it is deliberately FAIL-CLOSED:
29
+ # `_pool()` raises rather than falling back to the HF file store, so a container that gets
30
+ # `STORE_BACKEND=pg` without this line does not degrade — it refuses every request that touches
31
+ # the store, which is every authenticated request.
32
+ #
33
+ # ⛔ AND THIS IS THE FILE THAT MATTERS: the Dockerfile does `COPY requirements.txt` from the
34
+ # Space root, i.e. THIS manifest, not `api/requirements.txt`. That header's own streamlit story
35
+ # is the same defect in the other direction — the pinned intent and the shipped manifest are two
36
+ # documents. psycopg is now in BOTH, and `ops/verify_portability.py` gates the pair.
37
+ psycopg[binary,pool]>=3.2
web/src/alerts/AlertsPane.tsx ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // alerts/AlertsPane.tsx — WAVE 20 item 25 (C-ALERT): the inbox.
3
+ //
4
+ // Two lists, one panel, and the order is the point: what HAPPENED first, what is
5
+ // WATCHING second. An inbox that opens on its own configuration is a settings
6
+ // screen wearing a bell.
7
+ //
8
+ // A drawer rather than a route, for the same reason the AI note is a modal: the
9
+ // nav is server-filtered and an undeclared surface is denied by design (this
10
+ // shell has no client-invented pages). Alerts are not a granted module — they
11
+ // are this account's own inbox over its own views.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import { useCallback, useEffect, useState } from "react";
15
+ import {
16
+ deleteAlert,
17
+ fetchAlerts,
18
+ fetchInbox,
19
+ markRead,
20
+ runAlert,
21
+ } from "./alertsApi";
22
+ import { EMPTY_INBOX, applyRead, inboxOrder, routeForTopic, stampText } from "./alertsModel";
23
+ import type { Alert, Inbox, Notification } from "./alertsModel";
24
+
25
+ /** The clock face this pane uses for its one glyph — drawn, never an emoji. */
26
+ function BellIcon() {
27
+ return (
28
+ <svg className="alerts-bell" viewBox="0 0 16 16" aria-hidden="true">
29
+ <path d="M8 2.2a3.6 3.6 0 0 1 3.6 3.6v2.4l1.2 2H3.2l1.2-2V5.8A3.6 3.6 0 0 1 8 2.2Z" />
30
+ <path d="M6.6 12.6a1.5 1.5 0 0 0 2.8 0" />
31
+ </svg>
32
+ );
33
+ }
34
+
35
+ export default function AlertsPane({
36
+ onClose,
37
+ onOpenView,
38
+ onInbox,
39
+ onToast,
40
+ }: {
41
+ onClose: () => void;
42
+ /** Navigate to the alert's view. The FRAME owns routing; this pane owns the row. */
43
+ onOpenView: (topic: string, viewId: string) => void;
44
+ /** Hand the freshly-read inbox back so the nav badge and the pane agree. */
45
+ onInbox: (inbox: Inbox) => void;
46
+ onToast: (message: string) => void;
47
+ }) {
48
+ const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX);
49
+ const [alerts, setAlerts] = useState<Alert[]>([]);
50
+ const [error, setError] = useState("");
51
+ const [busy, setBusy] = useState(false);
52
+
53
+ const publish = useCallback(
54
+ (next: Inbox) => {
55
+ setInbox(next);
56
+ onInbox(next);
57
+ },
58
+ [onInbox]
59
+ );
60
+
61
+ const load = useCallback(async () => {
62
+ const [inboxRes, alertRes] = await Promise.all([fetchInbox(), fetchAlerts()]);
63
+ if (inboxRes.ok) publish(inboxRes.value);
64
+ else setError(inboxRes.message);
65
+ if (alertRes.ok) setAlerts(alertRes.value);
66
+ }, [publish]);
67
+
68
+ useEffect(() => {
69
+ void load();
70
+ }, [load]);
71
+
72
+ // ⛔ Escape closes it, like every other panel in this shell — a scrim with no
73
+ // keyboard way out is a trap (the wave-18 lesson, kept).
74
+ useEffect(() => {
75
+ const onKey = (e: KeyboardEvent) => {
76
+ if (e.key === "Escape") onClose();
77
+ };
78
+ window.addEventListener("keydown", onKey);
79
+ return () => window.removeEventListener("keydown", onKey);
80
+ }, [onClose]);
81
+
82
+ const toggleRead = useCallback(
83
+ (n: Notification) => {
84
+ // Optimistic, with the server's own convention (`ids`, `read`) sent
85
+ // explicitly — and reverted out loud if the write is refused, because a
86
+ // badge that silently disagrees with the list is how a reader learns to
87
+ // stop trusting it.
88
+ const before = inbox;
89
+ publish(applyRead(inbox, [n.id], !n.read));
90
+ void markRead([n.id], !n.read).then((r) => {
91
+ if (!r.ok) {
92
+ publish(before);
93
+ onToast(r.message);
94
+ }
95
+ });
96
+ },
97
+ [inbox, publish, onToast]
98
+ );
99
+
100
+ const markAll = useCallback(() => {
101
+ const before = inbox;
102
+ publish(applyRead(inbox, null, true));
103
+ void markRead(null, true).then((r) => {
104
+ if (!r.ok) {
105
+ publish(before);
106
+ onToast(r.message);
107
+ }
108
+ });
109
+ }, [inbox, publish, onToast]);
110
+
111
+ const open = useCallback(
112
+ (n: Notification) => {
113
+ if (!n.read) {
114
+ publish(applyRead(inbox, [n.id], true));
115
+ void markRead([n.id], true);
116
+ }
117
+ // A route this client cannot resolve opens NOTHING and says so — alerts
118
+ // outlive the surfaces they were made from.
119
+ if (!routeForTopic(n.topic)) {
120
+ onToast("That alert's table is no longer available to this account.");
121
+ return;
122
+ }
123
+ onOpenView(n.topic, n.viewId);
124
+ onClose();
125
+ },
126
+ [inbox, publish, onOpenView, onClose, onToast]
127
+ );
128
+
129
+ const rows = inboxOrder(inbox.items);
130
+
131
+ return (
132
+ <div className="shell-newdb-scrim" onClick={onClose}>
133
+ <aside
134
+ className="alerts-pane"
135
+ role="dialog"
136
+ aria-label="Alerts"
137
+ onClick={(e) => e.stopPropagation()}
138
+ >
139
+ <header className="alerts-head">
140
+ <h2>
141
+ <BellIcon /> Alerts
142
+ </h2>
143
+ <button
144
+ type="button"
145
+ className="alerts-markall"
146
+ disabled={inbox.unread === 0}
147
+ onClick={markAll}
148
+ >
149
+ Mark all read
150
+ </button>
151
+ </header>
152
+
153
+ {error ? <p className="shell-newdb-err">{error}</p> : null}
154
+
155
+ <div className="alerts-scroll">
156
+ {rows.length === 0 ? (
157
+ <p className="alerts-empty">
158
+ Nothing new. An alert watches ONE view and tells you when a record it had
159
+ never matched arrives in it — make one from a view's ··· menu.
160
+ </p>
161
+ ) : (
162
+ <ul className="alerts-list">
163
+ {rows.map((n) => (
164
+ <li key={n.id} className={"alerts-row" + (n.read ? "" : " is-unread")}>
165
+ <button
166
+ type="button"
167
+ className="alerts-row-main"
168
+ onClick={() => open(n)}
169
+ title={`Open ${n.alertLabel || "the alert's view"}`}
170
+ >
171
+ <span className="alerts-row-label">{n.label}</span>
172
+ <span className="alerts-row-meta">
173
+ {n.alertLabel ? `${n.alertLabel} · ` : ""}
174
+ {/* Readable, but never re-derived: `stampText` is string surgery
175
+ over the server's own UTC-with-offset stamp (D-18). Parsing it
176
+ into a browser Date is how a tenant a day ahead gets told an
177
+ event happened tomorrow. */}
178
+ {stampText(n.at)}
179
+ </span>
180
+ </button>
181
+ <button
182
+ type="button"
183
+ className="alerts-row-toggle"
184
+ aria-label={n.read ? `Mark ${n.label} unread` : `Mark ${n.label} read`}
185
+ title={n.read ? "Mark unread" : "Mark read"}
186
+ onClick={() => toggleRead(n)}
187
+ >
188
+ {/* The ACTION, not the state: "Read" beside an unread row reads as
189
+ a label for the row itself. */}
190
+ {n.read ? "Mark unread" : "Mark read"}
191
+ </button>
192
+ </li>
193
+ ))}
194
+ </ul>
195
+ )}
196
+
197
+ {alerts.length > 0 ? (
198
+ <section className="alerts-watching">
199
+ <h3>Watching</h3>
200
+ {alerts.map((a) => (
201
+ <div key={a.id} className="alerts-watch-row">
202
+ <span className="alerts-watch-label">{a.label}</span>
203
+ <span className="alerts-watch-meta">
204
+ {/* The remembered set's SIZE, which is what "no news" means here:
205
+ an alert with 40 matches and nothing new is working. */}
206
+ {a.matched.toLocaleString()} matched
207
+ {a.lastError ? ` · ${a.lastError}` : ""}
208
+ </span>
209
+ <button
210
+ type="button"
211
+ className="alerts-watch-run"
212
+ disabled={busy}
213
+ onClick={() => {
214
+ setBusy(true);
215
+ void runAlert(a.id).then((r) => {
216
+ setBusy(false);
217
+ if (!r.ok) return onToast(r.message);
218
+ const v = r.value as { new?: unknown[]; skipped?: string };
219
+ onToast(
220
+ v?.skipped
221
+ ? `Skipped: ${v.skipped}`
222
+ : `${(v?.new ?? []).length} new since the last check.`
223
+ );
224
+ void load();
225
+ });
226
+ }}
227
+ >
228
+ Check now
229
+ </button>
230
+ <button
231
+ type="button"
232
+ className="alerts-watch-del"
233
+ disabled={busy}
234
+ aria-label={`Delete the alert ${a.label}`}
235
+ onClick={() => {
236
+ setBusy(true);
237
+ void deleteAlert(a.id).then((r) => {
238
+ setBusy(false);
239
+ if (!r.ok) return onToast(r.message);
240
+ setAlerts((cur) => cur.filter((x) => x.id !== a.id));
241
+ void load();
242
+ });
243
+ }}
244
+ >
245
+ Delete
246
+ </button>
247
+ </div>
248
+ ))}
249
+ </section>
250
+ ) : null}
251
+ </div>
252
+
253
+ <div className="shell-newdb-actions alerts-foot">
254
+ <button type="button" className="login-submit" onClick={onClose}>
255
+ Close
256
+ </button>
257
+ </div>
258
+ </aside>
259
+ </div>
260
+ );
261
+ }
web/src/alerts/alertsApi.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // alerts/alertsApi.ts — WAVE 20 item 25 (C-ALERT): the seven calls, and nothing
3
+ // else. Every one carries the session cookie and fails CLOSED: an unreadable
4
+ // answer is an empty inbox, never a half-parsed one.
5
+ //
6
+ // Shaped like `shell/session.ts` on purpose — the same `{ok}` result type, the
7
+ // same 4xx/5xx message policy — because the failure that matters is identical:
8
+ // a wrong `credentials` word produces no client-side symptom at all, just a 401
9
+ // from a server that never saw a session.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import { API_V1, CREDENTIALS } from "../apiContract";
13
+ import { EMPTY_INBOX, parseAlerts, parseInbox } from "./alertsModel";
14
+ import type { Alert, Inbox } from "./alertsModel";
15
+
16
+ export type Result<T> =
17
+ | { ok: true; value: T }
18
+ | { ok: false; status: number; message: string };
19
+
20
+ /** 4xx text is POLICY the reader needs ("this view has no filter"); a 5xx's text
21
+ * is the server's internals and is never shown. Same split as session.ts. */
22
+ export function errorMessage(status: number, message?: string): string {
23
+ if (status >= 500 || !message) {
24
+ return status >= 500
25
+ ? "Something went wrong on our side. Try again in a moment."
26
+ : `The server answered ${status}.`;
27
+ }
28
+ return message;
29
+ }
30
+
31
+ async function call<T>(
32
+ path: string,
33
+ init: RequestInit,
34
+ read: (body: unknown) => T
35
+ ): Promise<Result<T>> {
36
+ let res: Response;
37
+ try {
38
+ res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
39
+ } catch {
40
+ return { ok: false, status: 0, message: "Cannot reach the server." };
41
+ }
42
+ const body = (await res.json().catch(() => null)) as unknown;
43
+ if (!res.ok) {
44
+ const detail = (body as { error?: { message?: string } } | null)?.error?.message;
45
+ return { ok: false, status: res.status, message: errorMessage(res.status, detail) };
46
+ }
47
+ return { ok: true, value: read(body) };
48
+ }
49
+
50
+ const json = (data: unknown): RequestInit => ({
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify(data),
54
+ });
55
+
56
+ export function fetchInbox(): Promise<Result<Inbox>> {
57
+ return call("/notifications", {}, parseInbox);
58
+ }
59
+
60
+ /** `ids: null` marks EVERYTHING — the API's own convention, not a shortcut. */
61
+ export function markRead(ids: string[] | null, read = true): Promise<Result<true>> {
62
+ return call("/notifications/read", json({ ids, read }), () => true as const);
63
+ }
64
+
65
+ export function fetchAlerts(): Promise<Result<Alert[]>> {
66
+ return call("/alerts", {}, parseAlerts);
67
+ }
68
+
69
+ /**
70
+ * `POST /alerts`. ⚠ 400 `no_filter` is a REAL ANSWER, not a failure to handle:
71
+ * a view with no active filter matches every row, so an alert on it would seed
72
+ * with the whole table and could never see an entrant again. The message rides
73
+ * back to the caller verbatim — that is the difference between "refused" and
74
+ * "silently incapable".
75
+ */
76
+ export function createAlert(
77
+ viewId: string,
78
+ topic: string,
79
+ label?: string
80
+ ): Promise<Result<unknown>> {
81
+ return call("/alerts", json({ viewId, topic, ...(label ? { label } : {}) }), (b) => b);
82
+ }
83
+
84
+ export function deleteAlert(id: string): Promise<Result<true>> {
85
+ return call(`/alerts/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
86
+ }
87
+
88
+ /** Run one alert now. Answers `{new:[rowId], seeded}` or `{skipped:"<reason>"}`. */
89
+ export function runAlert(id: string): Promise<Result<unknown>> {
90
+ return call(`/alerts/${encodeURIComponent(id)}/run`, { method: "POST" }, (b) => b);
91
+ }
92
+
93
+ export { EMPTY_INBOX };
web/src/alerts/alertsModel.ts ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // alerts/alertsModel.ts — WAVE 20 item 25 (contract C-ALERT): the inbox's PURE
3
+ // half. React-free and fetch-free, so `verify_alerts.py` runs it under node.
4
+ //
5
+ // An alert says "tell me when a record ENTERS this view". The client half is
6
+ // small, and every part of it fails silently when it is wrong:
7
+ //
8
+ // · an unread count taken from `items.length` rather than from the server's
9
+ // own `unread` disagrees with the badge the moment a page is capped or a
10
+ // read lands in another tab — and a badge that says 3 when the list shows 9
11
+ // teaches the reader to ignore the badge;
12
+ // · a notification whose `viewId` no longer resolves must open NOTHING rather
13
+ // than a wrong view — alerts outlive the views they were made from;
14
+ // · `topic` is the surface's scope key ("customer", "product", "ut_…"), and
15
+ // the ROUTE is a registry key ("customer_data") — mapping one to the other
16
+ // by guesswork sends every click to a page that does not exist.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** One notification, as `GET /api/v1/notifications` sends it. */
20
+ export interface Notification {
21
+ id: string;
22
+ alertId: string;
23
+ viewId: string;
24
+ topic: string;
25
+ rowId: string;
26
+ /** What entered — the record's own label. */
27
+ label: string;
28
+ /** What the alert is called, so a row reads without opening anything. */
29
+ alertLabel: string;
30
+ /** UTC WITH OFFSET (D-18). Kept as the server's STRING: re-formatting it here
31
+ * would re-introduce the browser-clock drift the offset exists to remove. */
32
+ at: string;
33
+ read: boolean;
34
+ }
35
+
36
+ /** One alert, as `GET /api/v1/alerts` sends it. */
37
+ export interface Alert {
38
+ id: string;
39
+ viewId: string;
40
+ topic: string;
41
+ owner: string;
42
+ label: string;
43
+ createdAt: string;
44
+ /** How many records are in its remembered set right now. */
45
+ matched: number;
46
+ seeded: boolean;
47
+ lastRunAt: string;
48
+ lastError: string;
49
+ }
50
+
51
+ export interface Inbox {
52
+ unread: number;
53
+ items: Notification[];
54
+ }
55
+
56
+ export const EMPTY_INBOX: Inbox = { unread: 0, items: [] };
57
+
58
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
59
+ const num = (v: unknown): number => (typeof v === "number" && isFinite(v) ? v : 0);
60
+
61
+ /**
62
+ * `GET /notifications` → the inbox, fail-closed.
63
+ *
64
+ * ⚠ `unread` COMES FROM THE SERVER, and is not recounted from `items`. The two
65
+ * can legitimately differ — the list is what this page holds, the count is what
66
+ * the account has — and recomputing it here would make the badge a function of
67
+ * whatever the last fetch happened to include.
68
+ */
69
+ export function parseInbox(body: unknown): Inbox {
70
+ const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
71
+ const raw = Array.isArray(b.items) ? b.items : [];
72
+ const items: Notification[] = [];
73
+ for (const item of raw) {
74
+ if (!item || typeof item !== "object") continue;
75
+ const n = item as Record<string, unknown>;
76
+ const id = str(n.id);
77
+ // An id-less notification cannot be marked read, so it would sit unread for
78
+ // ever and hold the badge up. Dropped, not rendered.
79
+ if (!id) continue;
80
+ items.push({
81
+ id,
82
+ alertId: str(n.alertId),
83
+ viewId: str(n.viewId),
84
+ topic: str(n.topic),
85
+ rowId: String(n.rowId ?? ""),
86
+ label: str(n.label) || String(n.rowId ?? ""),
87
+ alertLabel: str(n.alertLabel),
88
+ at: str(n.at),
89
+ read: n.read === true,
90
+ });
91
+ }
92
+ return { unread: Math.max(0, num(b.unread)), items };
93
+ }
94
+
95
+ /** `GET /alerts` → the alert list, fail-closed. */
96
+ export function parseAlerts(body: unknown): Alert[] {
97
+ const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
98
+ const raw = Array.isArray(b.alerts) ? b.alerts : [];
99
+ const out: Alert[] = [];
100
+ for (const item of raw) {
101
+ if (!item || typeof item !== "object") continue;
102
+ const a = item as Record<string, unknown>;
103
+ const id = str(a.id);
104
+ if (!id) continue;
105
+ out.push({
106
+ id,
107
+ viewId: str(a.viewId),
108
+ topic: str(a.topic),
109
+ owner: str(a.owner),
110
+ label: str(a.label) || "Untitled alert",
111
+ createdAt: str(a.createdAt),
112
+ matched: num(a.matched),
113
+ seeded: a.seeded === true,
114
+ lastRunAt: str(a.lastRunAt),
115
+ lastError: str(a.lastError),
116
+ });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * A topic (the grid's scope key) → the hash route that renders it.
123
+ *
124
+ * The two built-ins are the only pair that differ, and they differ because the
125
+ * REGISTRY names the surface while the GRID names the scope; a user table is its
126
+ * own key in both. `null` for anything else: a notification for a topic this
127
+ * client cannot route to must do nothing, not navigate somewhere plausible.
128
+ */
129
+ export function routeForTopic(topic: string): string | null {
130
+ const t = str(topic).trim();
131
+ if (t === "customer") return "customer_data";
132
+ if (t === "product") return "product_data";
133
+ if (/^ut_[A-Za-z0-9_]+$/.test(t)) return t;
134
+ return null;
135
+ }
136
+
137
+ /** Newest first, and unread before read at the same instant — the inbox order.
138
+ * Stable: two notifications from one write keep the server's order. */
139
+ export function inboxOrder(items: Notification[]): Notification[] {
140
+ return [...items].sort((a, b) => {
141
+ if (a.read !== b.read) return a.read ? 1 : -1;
142
+ return a.at < b.at ? 1 : a.at > b.at ? -1 : 0;
143
+ });
144
+ }
145
+
146
+ /**
147
+ * The stamp, made readable WITHOUT touching a clock.
148
+ *
149
+ * ⛔ NO `new Date()`, NO `toLocaleString()`, and that is the whole design. The
150
+ * server sends UTC WITH ITS OFFSET (D-18) precisely so every reader sees the
151
+ * same instant; parsing it into a browser Date and formatting it back would
152
+ * re-introduce the drift the offset exists to remove — a tenant a day ahead
153
+ * being told an event happened tomorrow ([[date-window-vocabulary]]). This is
154
+ * STRING SURGERY: keep the date and the minutes, drop the seconds and the `T`.
155
+ * Anything that does not look like an ISO stamp passes through untouched, so a
156
+ * format this function has never seen is shown as sent rather than mangled.
157
+ */
158
+ export function stampText(at: string): string {
159
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(str(at));
160
+ return m ? `${m[1]} ${m[2]}` : str(at);
161
+ }
162
+
163
+ /**
164
+ * The badge's text. Never the raw number past 99: a nav row is 236px wide and a
165
+ * four-digit badge pushes the label out of it.
166
+ */
167
+ export function badgeText(unread: number): string {
168
+ const n = Math.max(0, Math.floor(unread));
169
+ if (n <= 0) return "";
170
+ return n > 99 ? "99+" : String(n);
171
+ }
172
+
173
+ /**
174
+ * Apply a read/unread change LOCALLY, mirroring what the server just stored, and
175
+ * return the new inbox with the count corrected.
176
+ *
177
+ * `ids === null` is "all of them" (the API's own convention for mark-all). The
178
+ * count is derived from the ITEMS here — deliberately, and it is the one place
179
+ * that is right to do so: the server's answer is in flight, and the alternative
180
+ * is a badge that keeps its old number until the refetch lands.
181
+ */
182
+ export function applyRead(inbox: Inbox, ids: string[] | null, read: boolean): Inbox {
183
+ const wanted = ids === null ? null : new Set(ids);
184
+ const items = inbox.items.map((n) =>
185
+ wanted === null || wanted.has(n.id) ? { ...n, read } : n
186
+ );
187
+ const seenUnread = items.filter((n) => !n.read).length;
188
+ // A page can hold fewer notifications than the account has, so a partial read
189
+ // must SUBTRACT from the server's count rather than replace it with this
190
+ // page's tally — except when marking everything, where zero is the answer.
191
+ if (wanted === null) return { unread: read ? 0 : items.length, items };
192
+ const changed = inbox.items.filter(
193
+ (n) => wanted.has(n.id) && n.read !== read
194
+ ).length;
195
+ const delta = read ? -changed : changed;
196
+ return { unread: Math.max(seenUnread, inbox.unread + delta), items };
197
+ }
198
+
199
+ /**
200
+ * The shell↔rail channel for "make an alert out of this view" — the view rail
201
+ * raises it, the frame (which knows the current route, and therefore the topic)
202
+ * answers. Same reason as C-SHARE's event: the rail is host-neutral and cannot
203
+ * import the shell, and it does not know its own scope key.
204
+ */
205
+ export const ALERT_CREATE_EVENT = "aios:alert-create";
206
+
207
+ export interface AlertCreateRequest {
208
+ viewId: string;
209
+ label: string;
210
+ }
211
+
212
+ export function parseAlertCreate(detail: unknown): AlertCreateRequest | null {
213
+ if (!detail || typeof detail !== "object") return null;
214
+ const d = detail as Record<string, unknown>;
215
+ const viewId = str(d.viewId).trim();
216
+ if (!viewId) return null;
217
+ return { viewId, label: str(d.label).trim() || viewId };
218
+ }
web/src/automation/AutomationCreate.tsx CHANGED
@@ -104,7 +104,19 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
104
  config:
105
  kind === "scrape_db"
106
  ? { url, extract, tableIndex, fieldMap, keyField, targetLabel }
107
- : { targetTable, fieldKey, urlField, maxPosts: 24, tier: "anonymous" },
 
 
 
 
 
 
 
 
 
 
 
 
108
  });
109
  await onCreated(res.automation.id);
110
  } catch (e) {
@@ -118,7 +130,9 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
118
  !!name.trim() &&
119
  (kind === "scrape_db"
120
  ? !!url.trim() && plan.some((c) => c.include && c.key === keyField)
121
- : !!targetTable && !!fieldKey);
 
 
122
 
123
  return (
124
  <div className="auto-create-pane">
@@ -164,7 +178,21 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
164
  ))}
165
  </div>
166
 
167
- {kind === "scrape_db" ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  <>
169
  <div className="auto-field">
170
  <label htmlFor="auto-new-url">Page URL</label>
@@ -363,8 +391,11 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
363
  </div>
364
  </div>
365
  <p className="auto-hint">
366
- It starts on the free anonymous rung. The canvas&rsquo;s HikerAPI step turns on the
367
- paid one, which returns exact counts and full post data and works from a datacenter.
 
 
 
368
  </p>
369
  {table && !(table.fields || []).some((f) => f.type === "automation") ? (
370
  <p className="auto-note">
 
104
  config:
105
  kind === "scrape_db"
106
  ? { url, extract, tableIndex, fieldMap, keyField, targetLabel }
107
+ : kind === "discover_instagram"
108
+ ? {
109
+ // A search is created with a SMALL, NARROW default and no schedule, then
110
+ // widened on the canvas where the cost estimate is. The one shape that must
111
+ // never be the default is the expensive one.
112
+ recordsLimit: 25,
113
+ operator: "and",
114
+ predicates: [
115
+ { name: "followers", operator: ">=", value: 10000 },
116
+ { name: "biography", operator: "includes", value: "" },
117
+ ],
118
+ }
119
+ : { targetTable, fieldKey, urlField, maxPosts: 24, tier: "anonymous" },
120
  });
121
  await onCreated(res.automation.id);
122
  } catch (e) {
 
130
  !!name.trim() &&
131
  (kind === "scrape_db"
132
  ? !!url.trim() && plan.some((c) => c.include && c.key === keyField)
133
+ : kind === "discover_instagram"
134
+ ? true
135
+ : !!targetTable && !!fieldKey);
136
 
137
  return (
138
  <div className="auto-create-pane">
 
178
  ))}
179
  </div>
180
 
181
+ {kind === "discover_instagram" ? (
182
+ <>
183
+ <p className="auto-hint">
184
+ It searches a corpus of pre-collected public Instagram profiles for accounts you
185
+ have never heard of, and collects the matches into a candidates database. You set
186
+ the conditions and how many profiles to fetch on the canvas, where the cost
187
+ estimate is — searching is the one thing this workspace does that spends money per
188
+ run.
189
+ </p>
190
+ <p className="auto-hint">
191
+ It starts with a narrow default and no schedule. Nothing it finds is ever tracked
192
+ automatically; you tick the accounts you want.
193
+ </p>
194
+ </>
195
+ ) : kind === "scrape_db" ? (
196
  <>
197
  <div className="auto-field">
198
  <label htmlFor="auto-new-url">Page URL</label>
 
391
  </div>
392
  </div>
393
  <p className="auto-hint">
394
+ It starts on the free anonymous rung. The canvas&rsquo;s Bright Data step turns on
395
+ the paid one, which returns exact counts and the profile&rsquo;s top posts, and
396
+ works from a datacenter. Likes and comments per post are a separate step again —
397
+ they cost one record per post rather than one per profile, so they are off until
398
+ you turn them on.
399
  </p>
400
  {table && !(table.fields || []).some((f) => f.type === "automation") ? (
401
  <p className="auto-note">
web/src/automation/AutomationDetail.tsx CHANGED
@@ -27,6 +27,8 @@ import type {
27
  GraphNode,
28
  RunEntry,
29
  RunRows,
 
 
30
  SourcePreview,
31
  UserTable,
32
  } from "./automationApi";
@@ -34,6 +36,7 @@ import {
34
  AutomationError,
35
  COUNT_LABELS,
36
  deleteAutomation,
 
37
  fieldKeyFor,
38
  listTables,
39
  patchAutomation,
@@ -47,7 +50,9 @@ interface Props {
47
  automation: Automation;
48
  kinds: { key: AutomationKind; label: string }[];
49
  cronPresets: { cron: string; label: string }[];
50
- hikerReady: boolean;
 
 
51
  onSaved: (id?: string) => void | Promise<void>;
52
  onDeleted: () => void | Promise<void>;
53
  }
@@ -58,11 +63,19 @@ interface ColumnPlan {
58
  key: string;
59
  }
60
 
 
 
 
 
 
 
 
61
  export default function AutomationDetail({
62
  automation,
63
  kinds,
64
  cronPresets,
65
- hikerReady,
 
66
  onSaved,
67
  onDeleted,
68
  }: Props) {
@@ -98,6 +111,17 @@ export default function AutomationDetail({
98
  const [urlField, setUrlField] = useState(String(cfg.urlField || ""));
99
  const [maxPosts, setMaxPosts] = useState(Number(cfg.maxPosts || 24));
100
 
 
 
 
 
 
 
 
 
 
 
 
101
  // --- run history drill
102
  const [drill, setDrill] = useState<RunRows | null>(null);
103
  const [drillFor, setDrillFor] = useState("");
@@ -179,6 +203,14 @@ export default function AutomationDetail({
179
  targetLabel,
180
  };
181
  }
 
 
 
 
 
 
 
 
182
  return { targetTable, fieldKey, urlField, maxPosts };
183
  };
184
 
@@ -545,6 +577,9 @@ export default function AutomationDetail({
545
  }}
546
  >
547
  <option value="">Choose a database…</option>
 
 
 
548
  {tables.map((t) => (
549
  <option key={t.key} value={t.key}>
550
  {t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"})
@@ -577,6 +612,18 @@ export default function AutomationDetail({
577
  onChange={(e) => edit(setFieldKey)(e.target.value)}
578
  >
579
  <option value="">Choose a column…</option>
 
 
 
 
 
 
 
 
 
 
 
 
580
  {(table?.fields || [])
581
  .filter((f) => f.type === "automation")
582
  .map((f) => (
@@ -609,14 +656,22 @@ export default function AutomationDetail({
609
  onChange={(e) => edit(setMaxPosts)(Number(e.target.value) || 24)}
610
  />
611
  </div>
612
- <h3>HikerAPI — the paid rung</h3>
613
  <p className="auto-hint">
614
- Exact follower counts and post-level data, and it works from a datacenter.
615
- Roughly $0.0006 a request. Its node&rsquo;s switch turns it on.
616
- {hikerReady
617
  ? " A key is configured on this deployment."
618
  : " No key is configured on this deployment yet, so it will report itself blocked and the anonymous rung will answer instead."}
619
  </p>
 
 
 
 
 
 
 
 
620
  <h3>Anonymous — the free rung</h3>
621
  <p className="auto-hint">
622
  No login and no credentials. Instagram blocks datacenter addresses and has
@@ -634,6 +689,181 @@ export default function AutomationDetail({
634
  </>
635
  ) : null}
636
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
  {node.panel === "write" ? (
638
  <>
639
  {kind === "scrape_db" ? (
@@ -647,11 +877,20 @@ export default function AutomationDetail({
647
  onChange={(e) => edit(setTargetLabel)(e.target.value)}
648
  />
649
  </div>
 
 
 
 
 
 
 
 
650
  ) : (
651
  <p className="auto-hint">
652
- Each pull appends a timestamped row to <code>ut_ig_snapshots</code> and, for
653
- every post, to <code>ut_ig_post_snapshots</code> so the history is a time
654
- series you can filter by date rather than a single current value.{" "}
 
655
  <code>ut_ig_posts</code> holds one row per post, ever.
656
  </p>
657
  )}
@@ -660,6 +899,20 @@ export default function AutomationDetail({
660
  reads everything and reports exactly what it would have written, without
661
  creating or changing a single row.
662
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  <p className="auto-hint">
664
  A row that stops appearing at the source is counted and kept, never deleted.
665
  </p>
 
27
  GraphNode,
28
  RunEntry,
29
  RunRows,
30
+ DiscoverEstimate,
31
+ DiscoverVocab,
32
  SourcePreview,
33
  UserTable,
34
  } from "./automationApi";
 
36
  AutomationError,
37
  COUNT_LABELS,
38
  deleteAutomation,
39
+ discoverEstimate,
40
  fieldKeyFor,
41
  listTables,
42
  patchAutomation,
 
50
  automation: Automation;
51
  kinds: { key: AutomationKind; label: string }[];
52
  cronPresets: { cron: string; label: string }[];
53
+ paidReady: boolean;
54
+ /** The server-declared discovery vocabulary (absent until the list loads). */
55
+ discover?: DiscoverVocab;
56
  onSaved: (id?: string) => void | Promise<void>;
57
  onDeleted: () => void | Promise<void>;
58
  }
 
63
  key: string;
64
  }
65
 
66
+ /** One condition of a corpus search. Mirrors the server's `clean_predicates`. */
67
+ interface Predicate {
68
+ name: string;
69
+ operator: string;
70
+ value?: string | number;
71
+ }
72
+
73
  export default function AutomationDetail({
74
  automation,
75
  kinds,
76
  cronPresets,
77
+ paidReady,
78
+ discover,
79
  onSaved,
80
  onDeleted,
81
  }: Props) {
 
111
  const [urlField, setUrlField] = useState(String(cfg.urlField || ""));
112
  const [maxPosts, setMaxPosts] = useState(Number(cfg.maxPosts || 24));
113
 
114
+ // --- discover_instagram half (R7)
115
+ const [recordsLimit, setRecordsLimit] = useState(Number(cfg.recordsLimit || 25));
116
+ const [joinOp, setJoinOp] = useState(String(cfg.operator || "and"));
117
+ const [preds, setPreds] = useState<Predicate[]>(() => {
118
+ const stored = (automation.config as { predicates?: Predicate[] })?.predicates;
119
+ return stored && stored.length
120
+ ? stored.map((p) => ({ ...p }))
121
+ : [{ name: "followers", operator: ">=", value: 10000 }];
122
+ });
123
+ const [estimate, setEstimate] = useState<DiscoverEstimate | null>(null);
124
+
125
  // --- run history drill
126
  const [drill, setDrill] = useState<RunRows | null>(null);
127
  const [drillFor, setDrillFor] = useState("");
 
203
  targetLabel,
204
  };
205
  }
206
+ if (kind === "discover_instagram") {
207
+ return {
208
+ recordsLimit,
209
+ operator: joinOp,
210
+ predicates: preds.filter((p) => p.name && p.operator),
211
+ targetTable: String(cfg.targetTable || ""),
212
+ };
213
+ }
214
  return { targetTable, fieldKey, urlField, maxPosts };
215
  };
216
 
 
577
  }}
578
  >
579
  <option value="">Choose a database…</option>
580
+ {targetTable && !tables.some((t) => t.key === targetTable) ? (
581
+ <option value={targetTable}>{targetTable} (not visible to you)</option>
582
+ ) : null}
583
  {tables.map((t) => (
584
  <option key={t.key} value={t.key}>
585
  {t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"})
 
612
  onChange={(e) => edit(setFieldKey)(e.target.value)}
613
  >
614
  <option value="">Choose a column…</option>
615
+ {/*
616
+ ⚠ THE STORED VALUE IS ALWAYS AN OPTION, even when the list
617
+ below does not contain it. A <select> whose `value` matches
618
+ no <option> renders the FIRST one — so a column the server
619
+ knows about but this list has not caught up with would look
620
+ like a DIFFERENT column, and the next Save would write that
621
+ different column without anybody choosing it. Showing it as
622
+ "(not in this database)" is the honest version.
623
+ */}
624
+ {fieldKey && !(table?.fields || []).some((f) => f.key === fieldKey) ? (
625
+ <option value={fieldKey}>{fieldKey} (not in this database)</option>
626
+ ) : null}
627
  {(table?.fields || [])
628
  .filter((f) => f.type === "automation")
629
  .map((f) => (
 
656
  onChange={(e) => edit(setMaxPosts)(Number(e.target.value) || 24)}
657
  />
658
  </div>
659
+ <h3>Bright Data — the paid rung</h3>
660
  <p className="auto-hint">
661
+ Exact follower counts and the profile&rsquo;s top posts, and it works from a
662
+ datacenter. Its node&rsquo;s switch turns it on.
663
+ {paidReady
664
  ? " A key is configured on this deployment."
665
  : " No key is configured on this deployment yet, so it will report itself blocked and the anonymous rung will answer instead."}
666
  </p>
667
+ <h3>Post engagement — the per-post rung</h3>
668
+ <p className="auto-hint">
669
+ Likes and comments for each post. It is a separate step because it is a
670
+ separate bill: the profile read costs one record, and engagement costs{" "}
671
+ <strong>one record per post</strong>. With it off, the posts database still
672
+ grows but the engagement series does not — and no empty rows are appended to
673
+ stand in for the numbers nobody bought.
674
+ </p>
675
  <h3>Anonymous — the free rung</h3>
676
  <p className="auto-hint">
677
  No login and no credentials. Instagram blocks datacenter addresses and has
 
689
  </>
690
  ) : null}
691
 
692
+ {node.panel === "find" ? (
693
+ <>
694
+ <div className="auto-field">
695
+ <label htmlFor="auto-records">How many profiles to fetch</label>
696
+ <input
697
+ id="auto-records"
698
+ className="auto-input"
699
+ type="number"
700
+ min={1}
701
+ max={discover?.maxRecords || 1000}
702
+ value={recordsLimit}
703
+ onChange={(e) => {
704
+ edit(setRecordsLimit)(Number(e.target.value) || 1);
705
+ setEstimate(null);
706
+ }}
707
+ />
708
+ </div>
709
+ <p className="auto-hint">
710
+ A search must be bounded. An unbounded one matches a share of a corpus of
711
+ hundreds of millions of profiles, and the vendor refuses it outright rather
712
+ than delivering it.
713
+ </p>
714
+
715
+ <h3>Conditions</h3>
716
+ {preds.map((pr, i) => (
717
+ <div className="auto-field-row" key={i}>
718
+ <div className="auto-field">
719
+ <label htmlFor={"auto-pred-f-" + i}>Field</label>
720
+ <select
721
+ id={"auto-pred-f-" + i}
722
+ className="auto-input"
723
+ value={pr.name}
724
+ onChange={(e) =>
725
+ edit(setPreds)(
726
+ preds.map((q, j) => (j === i ? { ...q, name: e.target.value } : q))
727
+ )
728
+ }
729
+ >
730
+ {/*
731
+ The stored value is ALWAYS an option — see the note on the
732
+ automation-column picker. A select whose value matches no option
733
+ renders the first one, which silently changes what a Save writes.
734
+ */}
735
+ {pr.name && !(discover?.fields || []).includes(pr.name) ? (
736
+ <option value={pr.name}>{pr.name}</option>
737
+ ) : null}
738
+ {(discover?.fields || []).map((f) => (
739
+ <option key={f} value={f}>
740
+ {f}
741
+ {(discover?.lead || []).includes(f) ? " (has values)" : ""}
742
+ </option>
743
+ ))}
744
+ </select>
745
+ </div>
746
+ <div className="auto-field">
747
+ <label htmlFor={"auto-pred-o-" + i}>Comparison</label>
748
+ <select
749
+ id={"auto-pred-o-" + i}
750
+ className="auto-input"
751
+ value={pr.operator}
752
+ onChange={(e) =>
753
+ edit(setPreds)(
754
+ preds.map((q, j) =>
755
+ j === i ? { ...q, operator: e.target.value } : q
756
+ )
757
+ )
758
+ }
759
+ >
760
+ {pr.operator && !(discover?.operators || []).includes(pr.operator) ? (
761
+ <option value={pr.operator}>{pr.operator}</option>
762
+ ) : null}
763
+ {(discover?.operators || []).map((o) => (
764
+ <option key={o} value={o}>
765
+ {o}
766
+ </option>
767
+ ))}
768
+ </select>
769
+ </div>
770
+ {(discover?.nullaryOperators || []).includes(pr.operator) ? null : (
771
+ <div className="auto-field">
772
+ <label htmlFor={"auto-pred-v-" + i}>Value</label>
773
+ <input
774
+ id={"auto-pred-v-" + i}
775
+ className="auto-input"
776
+ value={pr.value === undefined ? "" : String(pr.value)}
777
+ onChange={(e) =>
778
+ edit(setPreds)(
779
+ preds.map((q, j) => (j === i ? { ...q, value: e.target.value } : q))
780
+ )
781
+ }
782
+ />
783
+ </div>
784
+ )}
785
+ <div className="auto-field">
786
+ <label htmlFor={"auto-pred-x-" + i}>&nbsp;</label>
787
+ <button
788
+ id={"auto-pred-x-" + i}
789
+ type="button"
790
+ className="auto-btn"
791
+ disabled={preds.length < 2}
792
+ onClick={() => edit(setPreds)(preds.filter((_q, j) => j !== i))}
793
+ >
794
+ Remove
795
+ </button>
796
+ </div>
797
+ </div>
798
+ ))}
799
+ <div className="auto-head-actions">
800
+ <button
801
+ type="button"
802
+ className="auto-btn"
803
+ onClick={() =>
804
+ edit(setPreds)([
805
+ ...preds,
806
+ { name: "biography", operator: "includes", value: "" },
807
+ ])
808
+ }
809
+ >
810
+ Add a condition
811
+ </button>
812
+ <div className="auto-field">
813
+ <label htmlFor="auto-joinop">Match</label>
814
+ <select
815
+ id="auto-joinop"
816
+ className="auto-input"
817
+ value={joinOp}
818
+ onChange={(e) => edit(setJoinOp)(e.target.value)}
819
+ >
820
+ <option value="and">All of them</option>
821
+ <option value="or">Any of them</option>
822
+ </select>
823
+ </div>
824
+ </div>
825
+ <p className="auto-hint">
826
+ Fields marked <em>has values</em> are the ones we have actually seen carrying
827
+ data. The others can be searched, but were empty on every profile we have
828
+ looked at — and a condition on an empty field returns nothing while looking
829
+ exactly like &ldquo;no such accounts exist&rdquo;.
830
+ </p>
831
+ <p className="auto-hint">
832
+ Narrow conditions matter more than a small limit. The search reads the whole
833
+ corpus either way, so a broad condition is slow and expensive however few rows
834
+ you ask to keep. A search normally takes about twenty minutes; a run that
835
+ finds one still going says so and the next run collects it.
836
+ </p>
837
+
838
+ <h3>What it costs</h3>
839
+ <div className="auto-head-actions">
840
+ <button
841
+ type="button"
842
+ className="auto-btn"
843
+ onClick={() => {
844
+ void discoverEstimate(recordsLimit)
845
+ .then(setEstimate)
846
+ .catch(() => setEstimate(null));
847
+ }}
848
+ >
849
+ Estimate this search
850
+ </button>
851
+ </div>
852
+ {estimate ? (
853
+ <p className="auto-hint">
854
+ About <strong>${estimate.usd}</strong> for {estimate.records} profiles, at $
855
+ {estimate.unitUsd} each.{" "}
856
+ {/*
857
+ THE CAVEAT IS NOT OPTIONAL. The vendor never quotes a price before a run
858
+ and this deployment cannot read its own balance, so presenting this as a
859
+ billed figure would be inventing a measurement.
860
+ */}
861
+ <strong>This is an estimate</strong> — {estimate.note}.
862
+ </p>
863
+ ) : null}
864
+ </>
865
+ ) : null}
866
+
867
  {node.panel === "write" ? (
868
  <>
869
  {kind === "scrape_db" ? (
 
877
  onChange={(e) => edit(setTargetLabel)(e.target.value)}
878
  />
879
  </div>
880
+ ) : kind === "discover_instagram" ? (
881
+ <p className="auto-hint">
882
+ Matches are collected into{" "}
883
+ <code>{discover?.table || "ut_ig_candidates"}</code>, upserted by handle — so
884
+ re-finding an account costs nothing and its <em>Times found</em> count grows.{" "}
885
+ <strong>Nothing found is ever tracked automatically.</strong> Tick the ones
886
+ you want; a later search can never un-tick them.
887
+ </p>
888
  ) : (
889
  <p className="auto-hint">
890
+ Each pull appends a timestamped row to <code>ut_ig_snapshots</code> and, when
891
+ the post-engagement step is on, one per post to{" "}
892
+ <code>ut_ig_post_snapshots</code> so the history is a time series you can
893
+ filter by date rather than a single current value.{" "}
894
  <code>ut_ig_posts</code> holds one row per post, ever.
895
  </p>
896
  )}
 
899
  reads everything and reports exactly what it would have written, without
900
  creating or changing a single row.
901
  </p>
902
+ {kind === "field_instagram" ? (
903
+ <p className="auto-hint">
904
+ {/*
905
+ ⚠ "Writes nothing" and "costs nothing" are DIFFERENT CLAIMS, and only the
906
+ first one is true. A dry run still READS — so with the paid steps on it
907
+ bills exactly what a real run bills. Saying so here is the only place a
908
+ user finds out before the invoice.
909
+ */}
910
+ A dry run still <strong>reads</strong>. With the Bright Data step on it
911
+ costs what a real run costs, and with post engagement on that is one
912
+ charged record per post. Turn those steps off too if you want a free
913
+ rehearsal.
914
+ </p>
915
+ ) : null}
916
  <p className="auto-hint">
917
  A row that stops appearing at the source is counted and kept, never deleted.
918
  </p>
web/src/automation/AutomationSurface.tsx CHANGED
@@ -257,7 +257,8 @@ export default function AutomationSurface() {
257
  automation={active}
258
  kinds={data?.kinds || []}
259
  cronPresets={data?.cronPresets || []}
260
- hikerReady={!!data?.hikerReady}
 
261
  onSaved={async (id) => {
262
  const next = await load();
263
  if (id && next) setActiveId(id);
@@ -284,10 +285,18 @@ export default function AutomationSurface() {
284
  </li>
285
  <li>
286
  <strong>Instagram profile column.</strong> For every record carrying a
287
- public profile URL, read the profile — exactly, through HikerAPI, or
288
  approximately on the free anonymous rung — and append a timestamped row per
289
- pull, so followers and post engagement become a series you can filter by
290
- date rather than a single current number.
 
 
 
 
 
 
 
 
291
  </li>
292
  </ul>
293
  </div>
 
257
  automation={active}
258
  kinds={data?.kinds || []}
259
  cronPresets={data?.cronPresets || []}
260
+ paidReady={!!data?.paidReady}
261
+ discover={data?.discover}
262
  onSaved={async (id) => {
263
  const next = await load();
264
  if (id && next) setActiveId(id);
 
285
  </li>
286
  <li>
287
  <strong>Instagram profile column.</strong> For every record carrying a
288
+ public profile URL, read the profile — exactly, through Bright Data, or
289
  approximately on the free anonymous rung — and append a timestamped row per
290
+ pull, so followers become a series you can filter by date rather than a
291
+ single current number.
292
+ </li>
293
+ <li>
294
+ <strong>Find Instagram profiles.</strong> Search a corpus of pre-collected
295
+ public profiles for accounts you have never heard of — a follower band, a
296
+ phrase in the bio, an engagement floor — and collect the matches into a
297
+ candidates database. Searching costs money, so every run is bounded by a
298
+ profile count you set and shows an estimate first. Nothing found is ever
299
+ tracked automatically; you tick the ones you want.
300
  </li>
301
  </ul>
302
  </div>
web/src/automation/automationApi.ts CHANGED
@@ -16,9 +16,14 @@
16
  // ---------------------------------------------------------------------------
17
  import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, signal } from "../apiContract";
18
 
19
- export type AutomationKind = "scrape_db" | "field_instagram";
20
  export type RunState = "idle" | "running" | "ok" | "error" | "partial";
21
- export type CaptureTier = "anonymous" | "hiker";
 
 
 
 
 
22
 
23
  /**
24
  * A node's dot. Deliberately WIDER than `RunState` — a single step can be
@@ -92,14 +97,60 @@ export interface AutomationList {
92
  kinds: { key: AutomationKind; label: string }[];
93
  cronPresets: { cron: string; label: string }[];
94
  /**
95
- * Is `AIOS_HIKERAPI_KEY` configured on the server? A BOOLEAN, never the key —
96
  * the surface needs to say "the paid rung is not configured" honestly rather
97
  * than offering a tier that will silently refuse.
98
  */
99
- hikerReady: boolean;
 
 
 
 
 
 
 
 
 
 
100
  storeAvailable: boolean;
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  export interface UserTable {
104
  key: string;
105
  label: string;
@@ -198,9 +249,10 @@ export function runAutomation(id: string): Promise<{ started: string }> {
198
  * Flip one canvas node's switch.
199
  *
200
  * The client says WHICH NODE was clicked and nothing else — the server owns what
201
- * each switch means (`engine.NODE_TOGGLES`). A client that knew "the HikerAPI
202
  * node writes config.tier" would be a second copy of that knowledge, free to
203
- * drift from the engine that reads it.
 
204
  */
205
  export function toggleNode(
206
  id: string,
@@ -220,6 +272,14 @@ export function runRows(id: string): Promise<RunRows> {
220
  return send(`/automations/${encodeURIComponent(id)}/rows`);
221
  }
222
 
 
 
 
 
 
 
 
 
223
  export function previewSource(
224
  url: string,
225
  extract: string,
@@ -278,7 +338,20 @@ export const COUNT_LABELS: [string, string][] = [
278
  ["partial", "Profile only"],
279
  ["blocked", "Blocked"],
280
  ["error", "Errors"],
281
- ["paid", "Exact via HikerAPI"],
282
  ["posts", "Posts"],
283
  ["new_posts", "New posts"],
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  ];
 
16
  // ---------------------------------------------------------------------------
17
  import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, signal } from "../apiContract";
18
 
19
+ export type AutomationKind = "scrape_db" | "field_instagram" | "discover_instagram";
20
  export type RunState = "idle" | "running" | "ok" | "error" | "partial";
21
+ /**
22
+ * ⚠ `hiker` is NOT in this union any more (wave 20, D-21) — the vendor moved.
23
+ * Stored configs still SAY `hiker` and the server maps them forward
24
+ * (`TIER_ALIASES`), so nothing in this client should ever write that word.
25
+ */
26
+ export type CaptureTier = "anonymous" | "brightdata";
27
 
28
  /**
29
  * A node's dot. Deliberately WIDER than `RunState` — a single step can be
 
97
  kinds: { key: AutomationKind; label: string }[];
98
  cronPresets: { cron: string; label: string }[];
99
  /**
100
+ * Is `AIOS_BRIGHTDATA_KEY` configured on the server? A BOOLEAN, never the key —
101
  * the surface needs to say "the paid rung is not configured" honestly rather
102
  * than offering a tier that will silently refuse.
103
  */
104
+ paidReady: boolean;
105
+ /**
106
+ * THE SOURCE REGISTRY (DEBT D-9's seam). Server-declared, for the reason
107
+ * `cronPresets` is: the module that RUNS a source is the only thing entitled
108
+ * to say what it can do, and a client copy of "Instagram can discover,
109
+ * TikTok cannot" goes stale in silence. `canDiscover: false` is an honest
110
+ * statement, not a gap — offering the button anyway offers a refusal.
111
+ */
112
+ sources: SourceStatus[];
113
+ /** The discovery vocabulary — every name MEASURED-accepted by the vendor. */
114
+ discover: DiscoverVocab;
115
  storeAvailable: boolean;
116
  }
117
 
118
+ export interface SourceStatus {
119
+ key: string;
120
+ label: string;
121
+ vendor: string;
122
+ ready: boolean;
123
+ canDiscover: boolean;
124
+ }
125
+
126
+ export interface DiscoverVocab {
127
+ fields: string[];
128
+ /**
129
+ * The subset seen carrying VALUES on real corpus rows. The others are
130
+ * filterable and were null on every row measured — and a filter on an
131
+ * unpopulated field returns nothing while looking exactly like "no such
132
+ * influencers exist".
133
+ */
134
+ lead: string[];
135
+ operators: string[];
136
+ nullaryOperators: string[];
137
+ maxRecords: number;
138
+ table: string;
139
+ }
140
+
141
+ /**
142
+ * ⚠ AN ESTIMATE, AND IT CARRIES ITS OWN CAVEAT (`basis: "SPEC"`). Bright Data
143
+ * never quotes a price before a run, and this account's token cannot read a
144
+ * balance — so no surface may present this as a billed figure.
145
+ */
146
+ export interface DiscoverEstimate {
147
+ records: number;
148
+ usd: number;
149
+ unitUsd: number;
150
+ basis: string;
151
+ note: string;
152
+ }
153
+
154
  export interface UserTable {
155
  key: string;
156
  label: string;
 
249
  * Flip one canvas node's switch.
250
  *
251
  * The client says WHICH NODE was clicked and nothing else — the server owns what
252
+ * each switch means (`engine.NODE_TOGGLES`). A client that knew "the Bright Data
253
  * node writes config.tier" would be a second copy of that knowledge, free to
254
+ * drift from the engine that reads it — and it would have had to be rewritten
255
+ * when the vendor changed. It did not.
256
  */
257
  export function toggleNode(
258
  id: string,
 
272
  return send(`/automations/${encodeURIComponent(id)}/rows`);
273
  }
274
 
275
+ /** What would a search of this size cost? Asked BEFORE the run, never after. */
276
+ export function discoverEstimate(recordsLimit: number): Promise<DiscoverEstimate> {
277
+ return send("/automations/discover/estimate", {
278
+ method: "POST",
279
+ body: JSON.stringify({ recordsLimit }),
280
+ });
281
+ }
282
+
283
  export function previewSource(
284
  url: string,
285
  extract: string,
 
338
  ["partial", "Profile only"],
339
  ["blocked", "Blocked"],
340
  ["error", "Errors"],
341
+ ["paid", "Exact via Bright Data"],
342
  ["posts", "Posts"],
343
  ["new_posts", "New posts"],
344
+ // The engagement series only grows while the per-post rung is on, so its
345
+ // count is its own chip — "12 posts" and "0 engagement snapshots" together
346
+ // are the honest picture of a profile-only run.
347
+ ["metrics", "Engagement snapshots"],
348
+ // Discovery.
349
+ ["asked", "Asked for"],
350
+ ["found", "Profiles found"],
351
+ ["new", "New to us"],
352
+ ["seen_again", "Seen before"],
353
+ ["dropped", "Returned but unusable (no handle)"],
354
+ // ⚠ Its own chip for the same reason `capped` has one: a database that could
355
+ // not be CREATED is a system fact, and the run that hit it wrote nothing.
356
+ ["missing_tables", "Databases that could not be created"],
357
  ];
web/src/customer-grid/ColumnMenu.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -14,6 +14,7 @@ import type {
14
  EditableGridCell,
15
  GridKeyEventArgs,
16
  GridMouseEventArgs,
 
17
  DrawHeaderCallback,
18
  HeaderClickedEventArgs,
19
  Item,
@@ -38,7 +39,11 @@ import ColumnMenu from "./ColumnMenu";
38
  import type { ColumnMenuState } from "./ColumnMenu";
39
  import { HEADER_ICONS } from "./iconShapes";
40
  import { emitHostEvent, eventId } from "./hostBridge";
41
- import { NAV_MINIMIZE_EVENT, TOAST_EVENT, signal } from "../apiContract";
 
 
 
 
42
  import { exportFilename, runExport, triggerDownload } from "./export";
43
  import type { ExportFormat } from "./export";
44
  // owner item 3 (2026-08-03) — a time-series view exports its SHEET, not the rows under it.
@@ -48,7 +53,8 @@ import { echoReemit, reconcileEchoView } from "./viewEcho";
48
  import { pruneStamps, reconcileFields } from "./optimism";
49
  import { newFolderId, pruneFolderStamps, reconcileFolders, resolveFolderId } from "./folders";
50
  import type { FolderStamps } from "./folders";
51
- import { adoptNewFields, adoptNewViews, pruneTombstones, stampTombstone } from "./liveWorkspace";
 
52
  import type { Tombstones } from "./liveWorkspace";
53
  import type { GridFolder, ViewPermissions } from "./types";
54
  import type { FieldStamps } from "./optimism";
@@ -87,7 +93,7 @@ import { MAX_CALENDAR_METRICS,
87
  MAX_FROZEN, choiceOptions, clampFrozenCount, cleanDisplay, formulaOf, topicForScope,
88
  isDateFamilyType, isFilterGroup, isGroupableField, isNumericFieldType, isPickType, mayEditField,
89
  isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock,
90
- ratingMax,
91
  tableMode, uniqueDisplayName } from "./types";
92
  import type {
93
  DisplayMode,
@@ -259,6 +265,11 @@ interface LocalWorkspace {
259
  * after mount (liveWorkspace.ts). Persisted, not a ref, because a remount inside the
260
  * echo window would otherwise re-adopt a view the user deleted a second ago. */
261
  viewTombstones?: Tombstones;
 
 
 
 
 
262
  }
263
 
264
  function sameConfig(a: ViewConfig, b: ViewConfig): boolean {
@@ -329,26 +340,41 @@ function splitMulti(v: string): string[] {
329
  .filter((s) => s !== "");
330
  }
331
 
332
- /** Wave-5 item 3 — does any leaf of the filter tree name this column? Measure leaves carry
333
- * measure keys, which are never field keys, so they cannot false-positive here. */
334
- function treeNamesField(nodes: FilterNode[], key: string): boolean {
 
 
 
 
 
 
 
 
 
 
 
335
  for (const n of nodes) {
336
  if (isFilterGroup(n)) {
337
- if (treeNamesField(n.children, key)) return true;
338
- } else if ((n as FilterRule).colId === key) return true;
339
  }
340
  return false;
341
  }
342
 
343
  /** Wave-5 item 3 — "Don't filter by this field": drop every leaf naming the column, prune
344
  * groups that end up empty. Returns new arrays throughout (the config is state). */
345
- function dropFieldFromTree(nodes: FilterNode[], key: string): FilterNode[] {
 
 
 
 
346
  const out: FilterNode[] = [];
347
  for (const n of nodes) {
348
  if (isFilterGroup(n)) {
349
- const children = dropFieldFromTree(n.children, key);
350
  if (children.length) out.push({ ...n, children });
351
- } else if ((n as FilterRule).colId !== key) {
352
  out.push(n);
353
  }
354
  }
@@ -376,6 +402,10 @@ interface FieldBuildExtra {
376
  label?: string;
377
  colorCodeOptions?: boolean;
378
  optionColors?: Record<string, string>;
 
 
 
 
379
  }
380
 
381
  /**
@@ -498,6 +528,12 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
498
  const [selAddOpen, setSelAddOpen] = useState(false);
499
  const [selListName, setSelListName] = useState("");
500
  const selAddRef = useRef<HTMLButtonElement>(null);
 
 
 
 
 
 
501
  /** Wave-2 item 2c — cohort mode: the active cohort and the "+ Add customers" picker. Picks
502
  * ACCUMULATE across searches (search, tick, search again, confirm once). */
503
  const [activeCohortId, setActiveCohortId] = useState<string | null>(null);
@@ -540,6 +576,8 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
540
  * init, so the mount path and the after-mount path answer "was this deleted here?" the
541
  * same way. Persisted with the local workspace for the same reason `reemitted` is. */
542
  const viewTombstonesRef = useRef<Tombstones>({});
 
 
543
  const initializedKey = useRef<string | null>(null);
544
  const saveTimer = useRef<number | null>(null);
545
  const gridRef = useRef<DataEditorRef>(null);
@@ -591,7 +629,13 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
591
  const hostViews = payload.workspace?.views ?? [];
592
  const byId = new Map<string, SavedView>();
593
  byId.set(ALL_VIEW_ID, allCustomersView(initialFields));
594
- for (const view of local?.views ?? []) byId.set(view.id, view);
 
 
 
 
 
 
595
  // Host state wins — EXCEPT when it is a lagged echo of this browser's own
596
  // in-flight edit, where taking it would turn a just-completed measure rule
597
  // valueless: inactive, no pending marker, whole-book count. A rerun replaces
@@ -664,7 +708,10 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
664
  for (const view of initialViews) {
665
  const key = reemit.get(view.id);
666
  if (key === undefined) continue;
667
- emitHostEvent({ id: eventId("view"), type: "view_upsert", view });
 
 
 
668
  stamped[view.id] = key;
669
  }
670
  reemittedRef.current = stamped;
@@ -687,6 +734,9 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
687
  // Pruned on the way out, like every other stamp map here: the persisted blob is a
688
  // recent window, never an archive of everything this tab ever deleted.
689
  viewTombstones: pruneTombstones(viewTombstonesRef.current, Date.now()),
 
 
 
690
  });
691
  }, [workspaceReady, storageKey, fields, views, activeViewId]);
692
 
@@ -765,6 +815,50 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
765
  insertColumn,
766
  } = useGridColumns(fields, config, updateConfig);
767
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
768
  // Airtable behavior: configuration changes to the active view autosave.
769
  useEffect(() => {
770
  if (!workspaceReady) return;
@@ -780,6 +874,8 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
780
  setViews((current) =>
781
  current.map((view) => (view.id === updated.id ? updated : view))
782
  );
 
 
783
  emitHostEvent({ id: eventId("view"), type: "view_upsert", view: updated });
784
  setSaveState("saved");
785
  saveTimer.current = null;
@@ -1122,6 +1218,223 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
1122
  displayPidToIndex,
1123
  visibleCols.length
1124
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1125
  /** Owner item 23 — the fields in the view's column order, for the Hide-fields panel. Built
1126
  * from `order` (already reconciled by useGridColumns) rather than from `config.order` so the
1127
  * panel and the grid can never disagree about which fields exist or where they sit. */
@@ -1334,6 +1647,15 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
1334
  let rowsPx = HEADER_PX;
1335
  if (typeof rowHeight === "number") rowsPx += displayRows.length * rowHeight;
1336
  else for (let i = 0; i < displayRows.length; i++) rowsPx += rowHeight(i);
 
 
 
 
 
 
 
 
 
1337
 
1338
  /* Fit is tested against the client box the OTHER axis's scrollbar leaves behind — the same
1339
  `clientWidth`/`clientHeight` glide's own scroll handler reads (infinite-scroller.js:
@@ -1355,7 +1677,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
1355
  below: rowsPx < clientH ? rowsPx : null,
1356
  right: colsPx < clientW ? colsPx : null,
1357
  };
1358
- }, [visibleCols, displayRows, rowHeight, gridSize]);
1359
  /* ═══ end W18-B VOID (geometry) ═══ */
1360
 
1361
  // The record drawer resolves positions against what the MODE paints: the display slice for
@@ -1619,18 +1941,18 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
1619
  // Wave-5 item 11 — a checkbox toggles straight through glide's BooleanCell (no overlay
1620
  // editor); the overlay store keeps its '1'-or-empty contract.
1621
  if (value.kind === GridCellKind.Boolean && field.type === "checkbox") {
1622
- patchOverlay(row.record.pid, { [field.key]: value.data ? "1" : "" });
1623
  return;
1624
  }
1625
  if (value.kind === GridCellKind.Uri) {
1626
- patchOverlay(row.record.pid, { [field.key]: value.data ?? "" });
1627
  return;
1628
  }
1629
  if (value.kind === GridCellKind.Text || value.kind === GridCellKind.Number) {
1630
- patchOverlay(row.record.pid, { [field.key]: value.data });
1631
  }
1632
  },
1633
- [displayRows, visibleCols, fieldByKey, patchOverlay, canEditField]
1634
  );
1635
  const validateCell = useCallback(
1636
  (cell: Item): boolean => {
@@ -1676,13 +1998,17 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
1676
  allowedChoices: statusValues[field.key] ?? choiceOptions(field),
1677
  });
1678
  if (!patches) return false;
1679
- for (const patch of patches)
1680
- patchOverlay(patch.pid, { [field.key]: patch.value });
 
 
 
 
1681
  // We handled the write ourselves. Returning false tells glide not to run its cell
1682
  // renderers' generic paste path (Bubble/rating renderers cannot enforce this contract).
1683
  return false;
1684
  },
1685
- [visibleCols, fieldByKey, canEditField, statusValues, displayRows, patchOverlay,
1686
  gridSelection.current]
1687
  );
1688
 
@@ -2623,8 +2949,23 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
2623
  ...(type === "rating" ? { max: extra?.max ?? 5 } : {}),
2624
  };
2625
  saveField(next);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2626
  },
2627
- [fieldByKey, saveField]
2628
  );
2629
 
2630
  /**
@@ -2713,6 +3054,61 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
2713
  }, [activeCohortId, selectedPids, cohortMemberSet, clearSelection]);
2714
 
2715
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2716
  /** Cohort mode — confirm the "+ Add customers" picker (contract event `cohort_add`). */
2717
  const addCustomersToCohort = useCallback(() => {
2718
  if (!activeCohortId || addCustPicked.size === 0) return;
@@ -2981,9 +3377,9 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
2981
  const onKanbanMove = useCallback(
2982
  (pid: number, value: string) => {
2983
  if (!kanbanField) return;
2984
- patchOverlay(pid, { [kanbanField.key]: value });
2985
  },
2986
- [kanbanField, patchOverlay]
2987
  );
2988
  const onListToggleGroup = useCallback((groupKey: string) => {
2989
  setCollapsed((current) => {
@@ -3101,7 +3497,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3101
  ? config.sorts.find((s) => s.colId === menuField.key)?.dir ?? null
3102
  : null;
3103
  const menuIsFiltered = menuField
3104
- ? treeNamesField(config.filters, menuField.key)
3105
  : false;
3106
  // Item 11c — is the menu's field the current frozen boundary (its Pin would be a no-op)?
3107
  const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
@@ -3414,6 +3810,9 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3414
  onCellActivated={onCellActivated}
3415
  onCellEdited={onCellEdited}
3416
  onPaste={onGridPaste}
 
 
 
3417
  validateCell={validateCell}
3418
  onColumnResize={onColumnResize}
3419
  onColumnMoved={onColumnMoved}
@@ -3433,9 +3832,23 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3433
  customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
3434
  headerIcons={HEADER_ICONS}
3435
  rightElement={
3436
- // Wave-6 item 4 — the far-right "+" of the header row: the create-field
3437
- // form, insert-at-end. Sticky so it stays visible however far the columns
3438
- // scroll.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3439
  <button
3440
  type="button"
3441
  className="cg-add-field-btn"
@@ -3456,7 +3869,22 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3456
  +
3457
  </button>
3458
  }
3459
- rightElementProps={{ sticky: true, fill: false }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3460
  />
3461
  {/* ═══ W18-B VOID ═══ (wave 18, owner item 1b / ruling R12) — the two rectangles.
3462
 
@@ -3740,6 +4168,24 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3740
  Add to cohort
3741
  </button>
3742
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3743
  <button type="button" className="cg-btn" style={ONE_LINE}
3744
  onClick={clearSelection}>
3745
  Clear
@@ -3880,7 +4326,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3880
  onClearFilter={() =>
3881
  updateConfig({
3882
  ...config,
3883
- filters: dropFieldFromTree(config.filters, menuField.key),
3884
  })
3885
  }
3886
  onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
@@ -3982,6 +4428,41 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3982
  </AnchoredOverlay>
3983
  )}
3984
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3985
  {/* Cohort mode's "+ Add customers" picker (item 2c): search the POOL, tick many, confirm
3986
  once. Candidates exclude current members; picks ACCUMULATE across searches, so the
3987
  confirm button carries the running total. The 50-row render cap is stated beside the
@@ -4122,7 +4603,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
4122
  }
4123
  aria-label={`${n} star${n === 1 ? "" : "s"}`}
4124
  onClick={() => {
4125
- patchOverlay(picker.pid, { [pickerField.key]: String(n) });
4126
  closePicker();
4127
  }}
4128
  >
@@ -4160,12 +4641,12 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
4160
  const next = on
4161
  ? pickerParts.filter((p) => p !== choice)
4162
  : [...pickerParts, choice];
4163
- patchOverlay(picker.pid, {
4164
  [pickerField.key]: next.join(","),
4165
- });
4166
  return;
4167
  }
4168
- patchOverlay(picker.pid, { [pickerField.key]: choice });
4169
  closePicker();
4170
  }}
4171
  >
@@ -4255,7 +4736,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
4255
  // (the querySelector takes the FIRST match, so a real choice row wins).
4256
  data-overlay-autofocus={pickerChoices.length === 0 ? true : undefined}
4257
  onClick={() => {
4258
- patchOverlay(picker.pid, { [pickerField.key]: "" });
4259
  closePicker();
4260
  }}
4261
  >
@@ -4329,7 +4810,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
4329
  [detailPid]: { ...current[detailPid], [key]: value },
4330
  }))
4331
  }
4332
- onNotesCommit={(key, value) => patchOverlay(detailPid, { [key]: value })}
4333
  viewer={viewer}
4334
  // Wave-14 close-out stitch (HOST): TSREC's two light-switch props, unwired when
4335
  // the GRID session ended — item 9's picker vocabulary + item 11's photos. Same
 
14
  EditableGridCell,
15
  GridKeyEventArgs,
16
  GridMouseEventArgs,
17
+ GridSelection,
18
  DrawHeaderCallback,
19
  HeaderClickedEventArgs,
20
  Item,
 
39
  import type { ColumnMenuState } from "./ColumnMenu";
40
  import { HEADER_ICONS } from "./iconShapes";
41
  import { emitHostEvent, eventId } from "./hostBridge";
42
+ import { NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT, signal } from "../apiContract";
43
+ import { addTableRow, deleteTableRow } from "./apiBridge";
44
+ // Owner item 16 / R4 / C-UNDO — the stack and every inverse. Pure, so a node gate can run it.
45
+ import { describe, directed, popRedo, popUndo, pushUndo, stackFor } from "./undoStack";
46
+ import type { CellChange, UndoBook, UndoEntry, UndoValue } from "./undoStack";
47
  import { exportFilename, runExport, triggerDownload } from "./export";
48
  import type { ExportFormat } from "./export";
49
  // owner item 3 (2026-08-03) — a time-series view exports its SHEET, not the rows under it.
 
53
  import { pruneStamps, reconcileFields } from "./optimism";
54
  import { newFolderId, pruneFolderStamps, reconcileFolders, resolveFolderId } from "./folders";
55
  import type { FolderStamps } from "./folders";
56
+ import { adoptNewFields, adoptNewViews, pruneTombstones, seedLocalViews, stampTombstone }
57
+ from "./liveWorkspace";
58
  import type { Tombstones } from "./liveWorkspace";
59
  import type { GridFolder, ViewPermissions } from "./types";
60
  import type { FieldStamps } from "./optimism";
 
93
  MAX_FROZEN, choiceOptions, clampFrozenCount, cleanDisplay, formulaOf, topicForScope,
94
  isDateFamilyType, isFilterGroup, isGroupableField, isNumericFieldType, isPickType, mayEditField,
95
  isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock,
96
+ measureColumnIndex, ratingMax, ruleColumnKeys,
97
  tableMode, uniqueDisplayName } from "./types";
98
  import type {
99
  DisplayMode,
 
265
  * after mount (liveWorkspace.ts). Persisted, not a ref, because a remount inside the
266
  * echo window would otherwise re-adopt a view the user deleted a second ago. */
267
  viewTombstones?: Tombstones;
268
+ /** D-19 (wave 20) — viewId → when THIS browser last WROTE it. The counterpart of the
269
+ * tombstones above: they stop a deleted view coming back, this stops a just-created one
270
+ * being dropped by the host-list rule at init, before its upsert has round-tripped.
271
+ * Same window, same prune, same browser-clock caveat. */
272
+ viewWrites?: Tombstones;
273
  }
274
 
275
  function sameConfig(a: ViewConfig, b: ViewConfig): boolean {
 
340
  .filter((s) => s !== "");
341
  }
342
 
343
+ /** Wave-5 item 3 — does any leaf of the filter tree name this column?
344
+ *
345
+ * ⚠ Wave-20 item 2 RETARGETED the note that stood here ("measure leaves carry measure keys,
346
+ * which are never field keys, so they cannot false-positive"). True about false POSITIVES,
347
+ * and precisely why it was a false NEGATIVE: a measure column's own condition names the
348
+ * MEASURE, so this answered "not filtered" for a column the user had visibly filtered, and
349
+ * the menu never offered "Don't filter by this field" on it. Both doors now go through
350
+ * `ruleColumnKeys` — the same resolution the tint uses, so the menu and the colour can never
351
+ * disagree about which columns a filter is about. */
352
+ function treeNamesField(
353
+ nodes: FilterNode[],
354
+ key: string,
355
+ measureCols: Map<string, string[]>
356
+ ): boolean {
357
  for (const n of nodes) {
358
  if (isFilterGroup(n)) {
359
+ if (treeNamesField(n.children, key, measureCols)) return true;
360
+ } else if (ruleColumnKeys(n as FilterRule, measureCols).includes(key)) return true;
361
  }
362
  return false;
363
  }
364
 
365
  /** Wave-5 item 3 — "Don't filter by this field": drop every leaf naming the column, prune
366
  * groups that end up empty. Returns new arrays throughout (the config is state). */
367
+ function dropFieldFromTree(
368
+ nodes: FilterNode[],
369
+ key: string,
370
+ measureCols: Map<string, string[]>
371
+ ): FilterNode[] {
372
  const out: FilterNode[] = [];
373
  for (const n of nodes) {
374
  if (isFilterGroup(n)) {
375
+ const children = dropFieldFromTree(n.children, key, measureCols);
376
  if (children.length) out.push({ ...n, children });
377
+ } else if (!ruleColumnKeys(n as FilterRule, measureCols).includes(key)) {
378
  out.push(n);
379
  }
380
  }
 
402
  label?: string;
403
  colorCodeOptions?: boolean;
404
  optionColors?: Record<string, string>;
405
+ /** Item 15 (C-RENAME) — the option renames this save carries, by row identity. Consumed by
406
+ * `retypeField` (which emits `choice_rename`) and ignored by every create path: a field
407
+ * being CREATED has no values to migrate. */
408
+ renames?: { from: string; to: string }[];
409
  }
410
 
411
  /**
 
528
  const [selAddOpen, setSelAddOpen] = useState(false);
529
  const [selListName, setSelListName] = useState("");
530
  const selAddRef = useRef<HTMLButtonElement>(null);
531
+ /** Owner item 9 (wave 20) — its counterpart: "Remove from cohort…", in ORDINARY views. */
532
+ const [selRemoveOpen, setSelRemoveOpen] = useState(false);
533
+ const selRemoveRef = useRef<HTMLButtonElement>(null);
534
+ /** Owner item 4 / C-ADDROW — the pid the trailing "+" just created, so the cursor can land on
535
+ * it once the re-read actually brings it back (the row does not exist locally before that). */
536
+ const [newRowPid, setNewRowPid] = useState<number | null>(null);
537
  /** Wave-2 item 2c — cohort mode: the active cohort and the "+ Add customers" picker. Picks
538
  * ACCUMULATE across searches (search, tick, search again, confirm once). */
539
  const [activeCohortId, setActiveCohortId] = useState<string | null>(null);
 
576
  * init, so the mount path and the after-mount path answer "was this deleted here?" the
577
  * same way. Persisted with the local workspace for the same reason `reemitted` is. */
578
  const viewTombstonesRef = useRef<Tombstones>({});
579
+ /** D-19 — viewId → when this browser last wrote it (see LocalWorkspace.viewWrites). */
580
+ const viewWritesRef = useRef<Tombstones>({});
581
  const initializedKey = useRef<string | null>(null);
582
  const saveTimer = useRef<number | null>(null);
583
  const gridRef = useRef<DataEditorRef>(null);
 
629
  const hostViews = payload.workspace?.views ?? [];
630
  const byId = new Map<string, SavedView>();
631
  byId.set(ALL_VIEW_ID, allCustomersView(initialFields));
632
+ // D-19 the HOST'S LIST DECIDES WHICH VIEWS EXIST. A local copy the host no longer
633
+ // names is a ghost (deleted elsewhere, share revoked, store moved) and is dropped here,
634
+ // guarded by this browser's own recent writes so an optimistic create survives.
635
+ viewWritesRef.current = pruneTombstones(local?.viewWrites, Date.now());
636
+ for (const view of seedLocalViews(local?.views ?? [], hostViews, viewWritesRef.current,
637
+ Date.now(), payload.workspace != null))
638
+ byId.set(view.id, view);
639
  // Host state wins — EXCEPT when it is a lagged echo of this browser's own
640
  // in-flight edit, where taking it would turn a just-completed measure rule
641
  // valueless: inactive, no pending marker, whole-book count. A rerun replaces
 
708
  for (const view of initialViews) {
709
  const key = reemit.get(view.id);
710
  if (key === undefined) continue;
711
+ viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id,
712
+ Date.now());
713
+ viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id, Date.now());
714
+ emitHostEvent({ id: eventId("view"), type: "view_upsert", view });
715
  stamped[view.id] = key;
716
  }
717
  reemittedRef.current = stamped;
 
734
  // Pruned on the way out, like every other stamp map here: the persisted blob is a
735
  // recent window, never an archive of everything this tab ever deleted.
736
  viewTombstones: pruneTombstones(viewTombstonesRef.current, Date.now()),
737
+ // D-19's other half — without persisting these, a reload inside the echo window would
738
+ // drop a view this browser created seconds ago.
739
+ viewWrites: pruneTombstones(viewWritesRef.current, Date.now()),
740
  });
741
  }, [workspaceReady, storageKey, fields, views, activeViewId]);
742
 
 
815
  insertColumn,
816
  } = useGridColumns(fields, config, updateConfig);
817
 
818
+ /**
819
+ * ⭐ Wave-20 owner item 4 (ruling R8, contract C-ADDROW) — **THE GHOST ROW.**
820
+ *
821
+ * A trailing "+" row at the bottom of the grid, the way Airtable grows a table, replacing the
822
+ * "Add record" button that used to sit in the shell's header bar (S4 deleted it this wave).
823
+ *
824
+ * ⛔ USER DATABASES ONLY. A connector's rows are read-synced from Odoo; a "+" there could only
825
+ * refuse, and R8 names that a fake affordance. The test is the SCOPE (`ut_*`), which is also
826
+ * the only scope with a rows endpoint to POST to — so the affordance and the capability come
827
+ * from the same fact rather than from two lists that can drift.
828
+ *
829
+ * ⚠ THE ROW IS NOT ADDED LOCALLY. `rawRows` is the server's answer, and a client-invented row
830
+ * would have no rid, no defaults and no place in anyone else's copy. The POST clears the rows
831
+ * cache and fires `ROWS_STALE_EVENT`, `useCustomerData` re-reads, and the row arrives with the
832
+ * id the store gave it. The cursor then follows it (`newRowPid` + the effect below) — which is
833
+ * why the pid is remembered instead of glide's `"bottom"` being returned here: at the moment
834
+ * this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD
835
+ * row.
836
+ */
837
+ const isUserTable = scope.startsWith("ut_");
838
+ const appendRow = useCallback(async (): Promise<undefined> => {
839
+ if (!isUserTable) return undefined;
840
+ const made = await addTableRow(scope);
841
+ // `undefined` either way, never glide's "bottom": glide would move the selection to the
842
+ // last row it currently knows about, which is the row BEFORE the one just created. The
843
+ // effect below lands the cursor when the re-read brings the real one in.
844
+ if (!made) return undefined; // addTableRow already said why, in the server's words
845
+ setNewRowPid(made.pid);
846
+ // R4 — the append is undoable: Ctrl+Z deletes the row it just created, and a redo restores
847
+ // it under the SAME rid (the server's `{rid}` passthrough, C-ADDROW). An empty new record
848
+ // carries no values, so the entry is the id and nothing else.
849
+ undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
850
+ kind: "rowAdd", table: scope, rid: made.rid, values: {},
851
+ });
852
+ signal(ROWS_STALE_EVENT);
853
+ return undefined;
854
+ }, [isUserTable, scope]);
855
+
856
+ /* wave20 item 2 — measure key + window -> the columns that display it. Built ONCE per field
857
+ list and handed to every consumer of "which column is this rule about", so the tint
858
+ (`columnTones`, inside the hook above) and the column menu's filter doors resolve a
859
+ measure condition the same way. See `types.ruleColumnKeys`. */
860
+ const measureCols = useMemo(() => measureColumnIndex(fields), [fields]);
861
+
862
  // Airtable behavior: configuration changes to the active view autosave.
863
  useEffect(() => {
864
  if (!workspaceReady) return;
 
874
  setViews((current) =>
875
  current.map((view) => (view.id === updated.id ? updated : view))
876
  );
877
+ viewWritesRef.current = stampTombstone(viewWritesRef.current, updated.id,
878
+ Date.now());
879
  emitHostEvent({ id: eventId("view"), type: "view_upsert", view: updated });
880
  setSaveState("saved");
881
  saveTimer.current = null;
 
1218
  displayPidToIndex,
1219
  visibleCols.length
1220
  );
1221
+ /* ════════════════════════ owner item 16 / R4 / C-UNDO ════════════════════════
1222
+ THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
1223
+ thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
1224
+ call site. Everything below funnels through `patchAndRecord` / `patchManyAndRecord`, so a
1225
+ write path that forgets to record is a write path that does not reach the store either.
1226
+
1227
+ ⚠ A REF, NOT STATE. Nothing on screen depends on the stack in v1 (no undo button), so
1228
+ keeping it in state would repaint the grid on every keystroke of a paste for no pixels.
1229
+ Per SCOPE (a Ctrl+Z on the Customer grid must never rewrite a user table's cell) and per
1230
+ tab (nothing persists it — a stack restored into a session that did not make those edits
1231
+ would undo somebody else's work). */
1232
+ const undoBook = useRef<UndoBook>({});
1233
+ const applyingUndo = useRef(false);
1234
+
1235
+ /** The value a cell holds RIGHT NOW: the overlay stratum wins over the payload row, exactly
1236
+ * as `useGetCellContent` renders it — so what undo restores is what was on screen. */
1237
+ const rowByPid = useMemo(() => {
1238
+ const map = new Map<number, Row>();
1239
+ for (const r of rawRows) map.set(r.pid, r);
1240
+ return map;
1241
+ }, [rawRows]);
1242
+ const currentValue = useCallback(
1243
+ (pid: number, key: string): UndoValue => {
1244
+ const edited = overlayEdits[pid]?.[key];
1245
+ const raw = edited !== undefined ? edited : rowByPid.get(pid)?.[key];
1246
+ return raw == null ? null : (raw as UndoValue);
1247
+ },
1248
+ [overlayEdits, rowByPid]
1249
+ );
1250
+
1251
+ const recordCells = useCallback(
1252
+ (changes: CellChange[], label: string) => {
1253
+ if (applyingUndo.current || changes.length === 0) return;
1254
+ undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
1255
+ kind: "cells", label, changes,
1256
+ });
1257
+ },
1258
+ [scope]
1259
+ );
1260
+
1261
+ /** ONE cell write, recorded. Every editor, picker and drag goes through this. */
1262
+ const patchAndRecord = useCallback(
1263
+ (pid: number, updates: Partial<Row>, label = "an edit") => {
1264
+ const changes: CellChange[] = Object.entries(updates).map(([key, value]) => ({
1265
+ pid, key,
1266
+ before: currentValue(pid, key),
1267
+ after: (value ?? null) as UndoValue,
1268
+ }));
1269
+ patchOverlay(pid, updates);
1270
+ recordCells(changes, label);
1271
+ },
1272
+ [currentValue, patchOverlay, recordCells]
1273
+ );
1274
+
1275
+ /** MANY cells, ONE stack entry — a paste and a bulk clear are each one user action (R4). */
1276
+ const patchManyAndRecord = useCallback(
1277
+ (writes: { pid: number; updates: Partial<Row> }[], label: string) => {
1278
+ const changes: CellChange[] = [];
1279
+ for (const w of writes)
1280
+ for (const [key, value] of Object.entries(w.updates))
1281
+ changes.push({
1282
+ pid: w.pid, key,
1283
+ before: currentValue(w.pid, key),
1284
+ after: (value ?? null) as UndoValue,
1285
+ });
1286
+ for (const w of writes) patchOverlay(w.pid, w.updates);
1287
+ recordCells(changes, label);
1288
+ },
1289
+ [currentValue, patchOverlay, recordCells]
1290
+ );
1291
+
1292
+ /**
1293
+ * Apply one stack entry in one direction. The INVERSE lives in `undoStack.directed` — this
1294
+ * only knows how to WRITE each op, and it writes through the same doors the user does
1295
+ * (`patchOverlay`, the rows endpoint), so an undone edit is persisted exactly like the edit
1296
+ * was. Nothing here is optimistic-only: a Ctrl+Z that reverted the screen and not the store
1297
+ * would come back on the next reload.
1298
+ */
1299
+ const applyEntry = useCallback(
1300
+ async (entry: UndoEntry, dir: "back" | "forward") => {
1301
+ const op = directed(entry, dir);
1302
+ if (op.kind === "cells") {
1303
+ const byPid = new Map<number, Partial<Row>>();
1304
+ for (const c of op.changes) {
1305
+ const at = byPid.get(c.pid) ?? {};
1306
+ at[c.key] = (c.after ?? "") as Row[string];
1307
+ byPid.set(c.pid, at);
1308
+ }
1309
+ applyingUndo.current = true;
1310
+ try {
1311
+ for (const [pid, updates] of byPid) patchOverlay(pid, updates);
1312
+ } finally {
1313
+ applyingUndo.current = false;
1314
+ }
1315
+ } else if (op.kind === "rowAdd") {
1316
+ // Restore under the OLD id where the store still has it free; the server answers with
1317
+ // what it actually wrote and the re-read is what puts the row back on screen.
1318
+ const made = await addTableRow(op.table, op.values as Record<string, unknown>, op.rid);
1319
+ if (!made) return; // the server already said why
1320
+ signal(ROWS_STALE_EVENT);
1321
+ } else if (op.kind === "rowDelete") {
1322
+ const ok = await deleteTableRow(op.table, op.rid);
1323
+ if (!ok) return;
1324
+ signal(ROWS_STALE_EVENT);
1325
+ } else if (op.kind === "choiceRename") {
1326
+ // Item 15 — the inverse mapping. The host rewrites the values and the saved views that
1327
+ // name them, exactly as it did on the way out; `directed` already turned the pairs
1328
+ // around, so this emits what it is given.
1329
+ emitHostEvent({
1330
+ id: eventId("choicerename"),
1331
+ type: "choice_rename",
1332
+ key: op.fieldKey,
1333
+ renames: op.renames,
1334
+ });
1335
+ }
1336
+ signal(TOAST_EVENT, describe(entry, dir));
1337
+ },
1338
+ [patchOverlay]
1339
+ );
1340
+
1341
+ /**
1342
+ * Ctrl+Z / Ctrl+Shift+Z (and Ctrl+Y, which is the same request on Windows).
1343
+ *
1344
+ * ⚠ ON `window`, IN CAPTURE, and it steps aside for real text fields. The grid is a canvas —
1345
+ * glide's key handling only fires while the canvas has focus, so a Ctrl+Z after clicking the
1346
+ * toolbar would do nothing, which is exactly the "sometimes it works" the owner would report
1347
+ * next. But an <input> with a cursor in it has its OWN undo that belongs to the browser, and
1348
+ * stealing that would be worse than not having ours: the search box, the rename field and
1349
+ * glide's own cell editor are all inputs, so `activeElement` decides.
1350
+ *
1351
+ * Nothing to undo says so out loud rather than silently ignoring the key — an undo that
1352
+ * appears to do nothing is indistinguishable from one that is broken.
1353
+ */
1354
+ useEffect(() => {
1355
+ const onKey = (event: KeyboardEvent) => {
1356
+ if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
1357
+ const key = event.key.toLowerCase();
1358
+ if (key !== "z" && key !== "y") return;
1359
+ const el = document.activeElement as HTMLElement | null;
1360
+ if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable))
1361
+ return;
1362
+ const forward = key === "y" || event.shiftKey;
1363
+ event.preventDefault();
1364
+ const book = undoBook.current;
1365
+ const state = stackFor(book, scope);
1366
+ const { state: next, entry } = forward ? popRedo(state) : popUndo(state);
1367
+ if (!entry) {
1368
+ signal(TOAST_EVENT, forward ? "Nothing to redo." : "Nothing to undo.");
1369
+ return;
1370
+ }
1371
+ book[scope] = next;
1372
+ void applyEntry(entry, forward ? "forward" : "back");
1373
+ };
1374
+ window.addEventListener("keydown", onKey, true);
1375
+ return () => window.removeEventListener("keydown", onKey, true);
1376
+ }, [scope, applyEntry]);
1377
+
1378
+ /**
1379
+ * R4 — **BULK BACKSPACE/DELETE IS ONE ACTION.** glide's own delete walks the selection and
1380
+ * calls `onCellEdited` per cell, which would put forty entries on the stack for one keypress;
1381
+ * returning `false` takes the whole operation over so it lands as one.
1382
+ *
1383
+ * ⚠ PRESETS ARE NEVER ATTEMPTED (the contract says so, and it is also the only honest
1384
+ * behaviour): `canEditField` is the same verdict the editor and the paste path use, so a
1385
+ * selection spanning read-only columns clears the editable ones and leaves the rest exactly
1386
+ * as they were — rather than firing writes the server will refuse one by one.
1387
+ */
1388
+ const onGridDelete = useCallback(
1389
+ (sel: GridSelection): boolean => {
1390
+ const byPid = new Map<number, Partial<Row>>();
1391
+ const clear = (rowIndex: number, colIndex: number) => {
1392
+ const vr = displayRows[rowIndex];
1393
+ if (!vr || vr.kind !== "data") return;
1394
+ const column = visibleCols[colIndex];
1395
+ const field = column ? fieldByKey.get(column.id!) : undefined;
1396
+ if (!field || !canEditField(field)) return;
1397
+ const at = byPid.get(vr.record.pid) ?? {};
1398
+ at[field.key] = "";
1399
+ byPid.set(vr.record.pid, at);
1400
+ };
1401
+ for (const rowIndex of sel.rows)
1402
+ for (let c = 0; c < visibleCols.length; c++) clear(rowIndex, c);
1403
+ const range = sel.current?.range;
1404
+ if (range)
1405
+ for (let y = range.y; y < range.y + range.height; y++)
1406
+ for (let x = range.x; x < range.x + range.width; x++) clear(y, x);
1407
+ const writes = [...byPid.entries()].map(([pid, updates]) => ({ pid, updates }));
1408
+ if (!writes.length) return true;
1409
+ patchManyAndRecord(writes, "clearing cells");
1410
+ // ⚠ `false` ONLY WHEN THIS ACTUALLY DID THE WORK. Returning it unconditionally cancels
1411
+ // glide's own delete for cases this handler does not cover — a COLUMN selection, which
1412
+ // glide deletes from `toDelete.columns` and the loops above never look at — so Delete
1413
+ // would silently clear nothing. Handing the keypress back when there is nothing to
1414
+ // group is strictly safer than swallowing it: the per-cell path still refuses read-only
1415
+ // fields (`onCellEdited`'s own `canEditField`), it just does not arrive as one entry.
1416
+ return false;
1417
+ },
1418
+ [displayRows, visibleCols, fieldByKey, canEditField, patchManyAndRecord]
1419
+ );
1420
+
1421
+ /**
1422
+ * Owner item 4 / C-ADDROW — the cursor FOLLOWS the appended row, once it exists.
1423
+ *
1424
+ * The append is a server round trip, so at click time there is nothing to focus; this waits
1425
+ * for the re-read to bring the pid back and then lands the active cell on its first column,
1426
+ * scrolled into view. `newRowPid` is cleared either way — a row the re-read never produced
1427
+ * (a refused write, a filter that excludes it) must not leave a cursor waiting forever.
1428
+ */
1429
+ useEffect(() => {
1430
+ if (newRowPid === null) return;
1431
+ const index = displayPidToIndex.get(newRowPid);
1432
+ if (index === undefined) return;
1433
+ setActiveCell(0, index);
1434
+ gridRef.current?.scrollTo(0, index, "vertical", 0, 0, { vAlign: "center" });
1435
+ setNewRowPid(null);
1436
+ }, [newRowPid, displayPidToIndex, setActiveCell]);
1437
+
1438
  /** Owner item 23 — the fields in the view's column order, for the Hide-fields panel. Built
1439
  * from `order` (already reconciled by useGridColumns) rather than from `config.order` so the
1440
  * panel and the grid can never disagree about which fields exist or where they sit. */
 
1647
  let rowsPx = HEADER_PX;
1648
  if (typeof rowHeight === "number") rowsPx += displayRows.length * rowHeight;
1649
  else for (let i = 0; i < displayRows.length; i++) rowsPx += rowHeight(i);
1650
+ // ⚠ Owner item 4 — THE GHOST ROW IS PART OF THE TABLE, and this line is what makes it
1651
+ // visible. glide draws its trailing row AFTER the last data row, but `displayRows` (our
1652
+ // rows) does not contain it, so the void started exactly where the ghost row does and
1653
+ // painted flat #F6F8FC straight over it. The "+" was still clickable the whole time
1654
+ // (`.cg-grid-void` is `pointer-events: none`), which is the worst version of this bug:
1655
+ // the gate went green on an affordance nobody could see. Found by READING THE SCREENSHOT
1656
+ // ([[ui-invisible-to-assertions]], [[finalize-visual-review-sop]]).
1657
+ if (isUserTable)
1658
+ rowsPx += typeof rowHeight === "number" ? rowHeight : rowHeight(displayRows.length);
1659
 
1660
  /* Fit is tested against the client box the OTHER axis's scrollbar leaves behind — the same
1661
  `clientWidth`/`clientHeight` glide's own scroll handler reads (infinite-scroller.js:
 
1677
  below: rowsPx < clientH ? rowsPx : null,
1678
  right: colsPx < clientW ? colsPx : null,
1679
  };
1680
+ }, [visibleCols, displayRows, rowHeight, gridSize, isUserTable]);
1681
  /* ═══ end W18-B VOID (geometry) ═══ */
1682
 
1683
  // The record drawer resolves positions against what the MODE paints: the display slice for
 
1941
  // Wave-5 item 11 — a checkbox toggles straight through glide's BooleanCell (no overlay
1942
  // editor); the overlay store keeps its '1'-or-empty contract.
1943
  if (value.kind === GridCellKind.Boolean && field.type === "checkbox") {
1944
+ patchAndRecord(row.record.pid, { [field.key]: value.data ? "1" : "" }, "a tick");
1945
  return;
1946
  }
1947
  if (value.kind === GridCellKind.Uri) {
1948
+ patchAndRecord(row.record.pid, { [field.key]: value.data ?? "" }, "an edit");
1949
  return;
1950
  }
1951
  if (value.kind === GridCellKind.Text || value.kind === GridCellKind.Number) {
1952
+ patchAndRecord(row.record.pid, { [field.key]: value.data }, "an edit");
1953
  }
1954
  },
1955
+ [displayRows, visibleCols, fieldByKey, patchAndRecord, canEditField]
1956
  );
1957
  const validateCell = useCallback(
1958
  (cell: Item): boolean => {
 
1998
  allowedChoices: statusValues[field.key] ?? choiceOptions(field),
1999
  });
2000
  if (!patches) return false;
2001
+ // R4 — ONE stack entry for the whole paste. Cell by cell would put forty entries on the
2002
+ // stack for one Ctrl+V, and undoing a paste one cell at a time is not undoing a paste.
2003
+ patchManyAndRecord(
2004
+ patches.map((patch) => ({ pid: patch.pid, updates: { [field.key]: patch.value } })),
2005
+ "a paste"
2006
+ );
2007
  // We handled the write ourselves. Returning false tells glide not to run its cell
2008
  // renderers' generic paste path (Bubble/rating renderers cannot enforce this contract).
2009
  return false;
2010
  },
2011
+ [visibleCols, fieldByKey, canEditField, statusValues, displayRows, patchManyAndRecord,
2012
  gridSelection.current]
2013
  );
2014
 
 
2949
  ...(type === "rating" ? { max: extra?.max ?? 5 } : {}),
2950
  };
2951
  saveField(next);
2952
+ /* ⭐ Owner item 15 / C-RENAME — the VALUES follow the definition.
2953
+ BESIDE the upsert, never instead of it, and AFTER it: the def is written optimistically
2954
+ here, while the rename is a host-side migration over overlay values and saved views. The
2955
+ order matters if the host processes the batch in order — the list must already offer
2956
+ "Navy" before any cell is moved onto it. */
2957
+ const renames = extra?.renames?.filter((r) => r.from && r.to && r.from !== r.to) ?? [];
2958
+ if (renames.length) {
2959
+ emitHostEvent({ id: eventId("choicerename"), type: "choice_rename", key, renames });
2960
+ // R4 — and it is undoable: Ctrl+Z emits the mapping turned around. The declared LIST is
2961
+ // restored by the same inverse (the host rewrites values and views back), so undo does
2962
+ // not need to re-send the definition.
2963
+ undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
2964
+ kind: "choiceRename", fieldKey: key, renames,
2965
+ });
2966
+ }
2967
  },
2968
+ [fieldByKey, saveField, scope]
2969
  );
2970
 
2971
  /**
 
3054
  }, [activeCohortId, selectedPids, cohortMemberSet, clearSelection]);
3055
 
3056
 
3057
+ /**
3058
+ * ⭐ Wave-20 owner item 9 — **REMOVE FROM A COHORT WITHOUT BEING INSIDE IT.**
3059
+ *
3060
+ * "Remove from cohort" existed only under `cohortMode` (you had to open the locked view
3061
+ * first), while "Add to cohort" worked from any view. So the two halves of the same idea
3062
+ * lived on different screens: you could put a customer in a cohort from wherever you found
3063
+ * them, and then had to go looking for the cohort to take them out again.
3064
+ *
3065
+ * SAME EVENT, same host validation as the cohort-mode button — `cohort_remove` with the pids
3066
+ * intersected client-side as a courtesy. What is new is only WHICH cohort: the picker names
3067
+ * it, instead of it being implied by the page you are standing on.
3068
+ */
3069
+ const removeSelectionFromList = useCallback(
3070
+ (cohortId: string) => {
3071
+ const members = cohortSets[cohortId];
3072
+ if (!members) return;
3073
+ const pids = [...selectedPids].filter((p) => members.has(p));
3074
+ if (!pids.length) return;
3075
+ emitHostEvent({ id: eventId("cohortrm"), type: "cohort_remove", cohortId, pids });
3076
+ setSelRemoveOpen(false);
3077
+ clearSelection();
3078
+ },
3079
+ [cohortSets, selectedPids, clearSelection]
3080
+ );
3081
+
3082
+ /**
3083
+ * The cohorts the checked rows can actually be removed FROM: those holding at least one of
3084
+ * them, and only where this viewer may edit the projected view.
3085
+ *
3086
+ * ⚠ A cohort holding NONE of the checked rows is not offered. Airtable's rule and the one R8
3087
+ * states for the ghost row: an affordance that can only refuse is a fake affordance — and
3088
+ * here it would be worse than that, because "Remove from Q3 plan" that removes nothing looks
3089
+ * exactly like a write that failed.
3090
+ *
3091
+ * The edit test is the COURTESY half (`mayEditView`, like every other client-side permission
3092
+ * check on this surface); the host re-validates ownership and pool on the event regardless.
3093
+ * A cohort with no projected view is still offered — the host owns that verdict, and hiding
3094
+ * it here would silently drop sets the reader can see in their own rail.
3095
+ */
3096
+ const removableLists = useMemo(() => {
3097
+ if (cohortMode || selectedPids.size === 0) return [];
3098
+ const out: { id: string; name: string; hits: number }[] = [];
3099
+ for (const l of lists) {
3100
+ const members = cohortSets[l.id];
3101
+ if (!members) continue;
3102
+ let hits = 0;
3103
+ for (const pid of selectedPids) if (members.has(pid)) hits += 1;
3104
+ if (!hits) continue;
3105
+ const projected = views.find((v) => v.id === l.id);
3106
+ if (projected && !mayEditView(projected, viewer)) continue;
3107
+ out.push({ id: l.id, name: l.name, hits });
3108
+ }
3109
+ return out;
3110
+ }, [cohortMode, selectedPids, lists, cohortSets, views, viewer]);
3111
+
3112
  /** Cohort mode — confirm the "+ Add customers" picker (contract event `cohort_add`). */
3113
  const addCustomersToCohort = useCallback(() => {
3114
  if (!activeCohortId || addCustPicked.size === 0) return;
 
3377
  const onKanbanMove = useCallback(
3378
  (pid: number, value: string) => {
3379
  if (!kanbanField) return;
3380
+ patchAndRecord(pid, { [kanbanField.key]: value }, "a card move");
3381
  },
3382
+ [kanbanField, patchAndRecord]
3383
  );
3384
  const onListToggleGroup = useCallback((groupKey: string) => {
3385
  setCollapsed((current) => {
 
3497
  ? config.sorts.find((s) => s.colId === menuField.key)?.dir ?? null
3498
  : null;
3499
  const menuIsFiltered = menuField
3500
+ ? treeNamesField(config.filters, menuField.key, measureCols)
3501
  : false;
3502
  // Item 11c — is the menu's field the current frozen boundary (its Pin would be a no-op)?
3503
  const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
 
3810
  onCellActivated={onCellActivated}
3811
  onCellEdited={onCellEdited}
3812
  onPaste={onGridPaste}
3813
+ // R4 — Backspace/Delete over a selection is ONE undoable action. Returning
3814
+ // `false` from here takes the write over from glide's per-cell path.
3815
+ onDelete={onGridDelete}
3816
  validateCell={validateCell}
3817
  onColumnResize={onColumnResize}
3818
  onColumnMoved={onColumnMoved}
 
3832
  customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
3833
  headerIcons={HEADER_ICONS}
3834
  rightElement={
3835
+ // Wave-6 item 4 — the "+" of the header row: the create-field form,
3836
+ // insert-at-end.
3837
+ //
3838
+ // ⚠ Wave-20 owner item 5 UNPINNED it. It used to be `sticky: true` ("so it
3839
+ // stays visible however far the columns scroll"), which parked it against the
3840
+ // right edge of the WINDOW — on a four-column table that is half a screen of
3841
+ // white between the last field and the control that adds the next one, and it
3842
+ // reads as page chrome rather than as part of the table. It now sits
3843
+ // immediately after the last column, where Airtable puts it.
3844
+ //
3845
+ // TWO changes are needed and neither works alone: `sticky:false` stops glide
3846
+ // pinning the wrapper (`infinite-scroller.js:204` sets `right` only when
3847
+ // sticky), and the CSS region kills `.dvn-spacer`'s `flex-grow: 1`, which
3848
+ // would otherwise expand to fill the scroller and push the button right back
3849
+ // to the edge. Consequence, accepted: on a table wider than the viewport the
3850
+ // "+" is off-screen until you scroll to the end of the columns — which is
3851
+ // where the new column is going to land anyway.
3852
  <button
3853
  type="button"
3854
  className="cg-add-field-btn"
 
3869
  +
3870
  </button>
3871
  }
3872
+ rightElementProps={{ sticky: false, fill: false }}
3873
+ // Owner item 4 (R8) — the trailing "+" row, USER DATABASES ONLY. Passing
3874
+ // `onRowAppended` is what makes glide paint the ghost row at all, so the
3875
+ // affordance exists exactly where a POST can succeed. See `appendRow`.
3876
+ onRowAppended={isUserTable ? appendRow : undefined}
3877
+ trailingRowOptions={
3878
+ isUserTable
3879
+ ? {
3880
+ // The hint sits in the identity column (targetColumn 0), which is the
3881
+ // one a new record is named in — the same cell the cursor lands on.
3882
+ hint: "New record",
3883
+ sticky: false,
3884
+ targetColumn: 0,
3885
+ }
3886
+ : undefined
3887
+ }
3888
  />
3889
  {/* ═══ W18-B VOID ═══ (wave 18, owner item 1b / ruling R12) — the two rectangles.
3890
 
 
4168
  Add to cohort
4169
  </button>
4170
  )}
4171
+ {/* ⭐ Owner item 9 — the counterpart, and it renders ONLY when it can do
4172
+ something: the checked rows have to be in a cohort this viewer may edit for
4173
+ "Remove from cohort…" to mean anything. Same bar, same selection, opposite
4174
+ direction — the ellipsis says a picker follows, because unlike cohort mode
4175
+ the target is not implied by where you are standing. */}
4176
+ {!cohortMode && removableLists.length > 0 && (
4177
+ <button
4178
+ type="button"
4179
+ className="cg-btn cg-selbar-remove-pick"
4180
+ style={ONE_LINE}
4181
+ ref={selRemoveRef}
4182
+ onClick={() => setSelRemoveOpen((open) => !open)}
4183
+ aria-expanded={selRemoveOpen}
4184
+ aria-haspopup="dialog"
4185
+ >
4186
+ Remove from cohort…
4187
+ </button>
4188
+ )}
4189
  <button type="button" className="cg-btn" style={ONE_LINE}
4190
  onClick={clearSelection}>
4191
  Clear
 
4326
  onClearFilter={() =>
4327
  updateConfig({
4328
  ...config,
4329
+ filters: dropFieldFromTree(config.filters, menuField.key, measureCols),
4330
  })
4331
  }
4332
  onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
 
4428
  </AnchoredOverlay>
4429
  )}
4430
 
4431
+ {/* ⭐ Owner item 9 — the remove picker. Every row states how many of the CHECKED rows it
4432
+ would take out ("3 of 4"), because the selection and the cohort are two different sets
4433
+ and the difference is the whole reason this needs a picker rather than a button. */}
4434
+ {selRemoveOpen && selRemoveRef.current && removableLists.length > 0 && (
4435
+ <AnchoredOverlay
4436
+ anchor={selRemoveRef.current}
4437
+ className="cg-pop cg-add-list-pop"
4438
+ placement="bottom-start"
4439
+ role="dialog"
4440
+ ariaLabel="Remove selected customers from a locked view"
4441
+ onDismiss={() => setSelRemoveOpen(false)}
4442
+ dataKind="selection-remove-from-list"
4443
+ >
4444
+ <div className="cg-pop-title">Remove from locked view</div>
4445
+ <div className="cg-pop-note">
4446
+ Only the locked views holding one of the {selectedPids.size.toLocaleString()} checked
4447
+ customer{selectedPids.size === 1 ? "" : "s"} are listed. The records themselves are
4448
+ not touched.
4449
+ </div>
4450
+ {removableLists.map((l) => (
4451
+ <button
4452
+ type="button"
4453
+ key={l.id}
4454
+ className="cg-pick-row"
4455
+ onClick={() => removeSelectionFromList(l.id)}
4456
+ >
4457
+ {l.name}
4458
+ <span className="cg-pick-hint">
4459
+ {l.hits.toLocaleString()} of {selectedPids.size.toLocaleString()}
4460
+ </span>
4461
+ </button>
4462
+ ))}
4463
+ </AnchoredOverlay>
4464
+ )}
4465
+
4466
  {/* Cohort mode's "+ Add customers" picker (item 2c): search the POOL, tick many, confirm
4467
  once. Candidates exclude current members; picks ACCUMULATE across searches, so the
4468
  confirm button carries the running total. The 50-row render cap is stated beside the
 
4603
  }
4604
  aria-label={`${n} star${n === 1 ? "" : "s"}`}
4605
  onClick={() => {
4606
+ patchAndRecord(picker.pid, { [pickerField.key]: String(n) }, "a rating");
4607
  closePicker();
4608
  }}
4609
  >
 
4641
  const next = on
4642
  ? pickerParts.filter((p) => p !== choice)
4643
  : [...pickerParts, choice];
4644
+ patchAndRecord(picker.pid, {
4645
  [pickerField.key]: next.join(","),
4646
+ }, "a choice");
4647
  return;
4648
  }
4649
+ patchAndRecord(picker.pid, { [pickerField.key]: choice }, "a choice");
4650
  closePicker();
4651
  }}
4652
  >
 
4736
  // (the querySelector takes the FIRST match, so a real choice row wins).
4737
  data-overlay-autofocus={pickerChoices.length === 0 ? true : undefined}
4738
  onClick={() => {
4739
+ patchAndRecord(picker.pid, { [pickerField.key]: "" }, "a choice");
4740
  closePicker();
4741
  }}
4742
  >
 
4810
  [detailPid]: { ...current[detailPid], [key]: value },
4811
  }))
4812
  }
4813
+ onNotesCommit={(key, value) => patchAndRecord(detailPid, { [key]: value })}
4814
  viewer={viewer}
4815
  // Wave-14 close-out stitch (HOST): TSREC's two light-switch props, unwired when
4816
  // the GRID session ended — item 9's picker vocabulary + item 11's photos. Same
web/src/customer-grid/ViewSidebar.tsx CHANGED
@@ -37,7 +37,14 @@ import {
37
  MODE_TONE,
38
  } from "./iconShapes";
39
  import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
40
- import { groupByFolder } from "./folders";
 
 
 
 
 
 
 
41
 
42
  interface ViewSidebarProps {
43
  views: SavedView[];
@@ -99,6 +106,16 @@ interface ViewSidebarProps {
99
  onFolderDelete?: (folderId: string) => void;
100
  onFolderDuplicate?: (folderId: string) => void;
101
  onItemMove?: (viewId: string, folderId: string | null) => void;
 
 
 
 
 
 
 
 
 
 
102
  /**
103
  * C4 as AMENDED 2026-07-28 — the folder-level bulk "Add to cohort". The caller
104
  * owns the arithmetic (it holds the engine); the rail owns the confirm.
@@ -151,6 +168,7 @@ export default function ViewSidebar({
151
  onFolderDelete,
152
  onFolderDuplicate,
153
  onItemMove,
 
154
  folderAddPreview,
155
  onFolderAddToList,
156
  viewer,
@@ -257,6 +275,32 @@ export default function ViewSidebar({
257
  const shownViews = viewNeedle
258
  ? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle))
259
  : views;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
261
  const groups = groupByFolder(
262
  shownViews,
@@ -265,15 +309,47 @@ export default function ViewSidebar({
265
  );
266
  const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
267
  const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
 
 
 
 
 
 
 
 
 
 
268
  const dropHandlers = (folderId: string | null) =>
269
- foldersOn
270
  ? {
271
  onDragOver: (e: React.DragEvent) => {
 
 
 
 
 
 
 
 
272
  e.preventDefault();
273
  setDropTarget(folderId ?? "__root__");
274
  },
275
- onDragLeave: () => setDropTarget(null),
 
 
 
276
  onDrop: (e: React.DragEvent) => {
 
 
 
 
 
 
 
 
 
 
 
277
  e.preventDefault();
278
  setDropTarget(null);
279
  const id = e.dataTransfer.getData("text/plain");
@@ -299,6 +375,23 @@ export default function ViewSidebar({
299
  setPermEdit("personal");
300
  setPermUsers([]);
301
  setCreating(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  };
303
 
304
  const createFolder = () => {
@@ -312,10 +405,18 @@ export default function ViewSidebar({
312
  setCreatingFolder(false);
313
  };
314
 
315
- /** Both create doors close the flyout first — leaving it open over the form it just
316
- * spawned would put a menu on top of the input it asked you to fill in. */
 
 
 
 
 
 
 
 
 
317
  const startView = (mode: DisplayMode) => {
318
- setCreateMenu(null);
319
  setCreatingFolder(false);
320
  setCreating(mode);
321
  };
@@ -325,6 +426,28 @@ export default function ViewSidebar({
325
  setCreatingFolder(true);
326
  };
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
329
  if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
330
  const items = [
@@ -403,9 +526,24 @@ export default function ViewSidebar({
403
  className="cg-link-btn cg-create-btn"
404
  aria-haspopup="menu"
405
  aria-expanded={!!createMenu}
406
- onClick={(event) =>
407
- setCreateMenu((current) => (current ? null : event.currentTarget))
408
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  >
410
  <span className="cg-create-icon" aria-hidden="true">+</span>
411
  <span className="cg-create-label">Create new…</span>
@@ -444,97 +582,10 @@ export default function ViewSidebar({
444
  />
445
  </div>
446
 
447
- {creating && (
448
- <div className="cg-view-create cg-create-form">
449
- <label htmlFor="cg-new-view">
450
- {/* The prompt NAMES the kind picked in the flyout otherwise the two steps read
451
- as unrelated and "Calendar" silently becomes a fact you cannot see. */}
452
- New {MODE_LABELS[creating].toLowerCase()} view
453
- </label>
454
- <input
455
- id="cg-new-view"
456
- className="cg-input"
457
- autoFocus
458
- value={name}
459
- placeholder="e.g. Florida at risk"
460
- onChange={(event) => setName(event.target.value)}
461
- onKeyDown={(event) => {
462
- if (event.key === "Enter") create();
463
- if (event.key === "Escape") setCreating(null);
464
- }}
465
- />
466
- {/* I17 (C4) — the owner's prompt ORDER: type (picked in the flyout, named above)
467
- → who can edit → the user picker, and the picker only when it applies. */}
468
- <div className="cg-perm" role="radiogroup" aria-label="Who can edit this view">
469
- <span className="cg-perm-title">Who can edit</span>
470
- {VIEW_EDIT_MODES.map((m) => (
471
- <label key={m} className="cg-radio-row cg-perm-row">
472
- <input
473
- type="radio"
474
- name="cg-view-perm"
475
- checked={permEdit === m}
476
- onChange={() => setPermEdit(m)}
477
- />
478
- <span className="cg-perm-label">
479
- {VIEW_EDIT_LABELS[m]}
480
- <span className="cg-perm-blurb">{VIEW_EDIT_BLURBS[m]}</span>
481
- </span>
482
- </label>
483
- ))}
484
- {permEdit === "users" && (
485
- <div className="cg-perm-users">
486
- {userOptions.length === 0 ? (
487
- // Never a silent empty box: an empty grant collapses to Personal host-side,
488
- // so say that rather than letting the user think they shared it.
489
- <span className="cg-perm-empty">
490
- No other accounts to pick — this will save as Personal.
491
- </span>
492
- ) : (
493
- userOptions.map((u) => (
494
- <label key={u} className="cg-perm-user">
495
- <input
496
- type="checkbox"
497
- checked={permUsers.includes(u)}
498
- onChange={(e) =>
499
- setPermUsers((cur) =>
500
- e.target.checked
501
- ? [...cur, u].slice(0, MAX_VIEW_USERS)
502
- : cur.filter((x) => x !== u)
503
- )
504
- }
505
- />
506
- <span>{u}</span>
507
- </label>
508
- ))
509
- )}
510
- {userOptions.length > 0 && permUsers.length === 0 && (
511
- <span className="cg-perm-empty">
512
- Pick at least one person, or this saves as Personal.
513
- </span>
514
- )}
515
- </div>
516
- )}
517
- </div>
518
- <div className="cg-form-actions">
519
- <button
520
- type="button"
521
- className="cg-btn cg-btn--primary"
522
- onClick={create}
523
- disabled={!name.trim()}
524
- >
525
- Create
526
- </button>
527
- <button
528
- type="button"
529
- className="cg-btn"
530
- onClick={() => setCreating(null)}
531
- >
532
- Cancel
533
- </button>
534
- </div>
535
- </div>
536
- )}
537
-
538
  {/* I15 — the folder form: name + the icon the folder will wear. */}
539
  {foldersOn && onFolderCreate && creatingFolder && (
540
  <div className="cg-view-create cg-create-form cg-fold-form">
@@ -613,38 +664,144 @@ export default function ViewSidebar({
613
  anchor={createMenu}
614
  className="cg-view-menu cg-create-flyout"
615
  placement="right-start"
616
- role="menu"
617
- ariaLabel="Create new"
618
- onDismiss={() => setCreateMenu(null)}
619
- onKeyDown={onMenuKeyDown}
620
- dataKind="create-new"
 
 
 
 
 
 
 
 
 
621
  >
622
- {CREATABLE_MODES.map((mode) => (
623
- <button
624
- key={mode}
625
- type="button"
626
- role="menuitem"
627
- className="cg-create-row"
628
- onClick={() => startView(mode)}
629
- >
630
- {/* size 16 — item 13's "bigger icons", and the SAME 16 the view rows use, so a
631
- Calendar is one mark at one size on this whole surface. */}
632
- <ToneModeIcon mode={mode} tone={MODE_TONE[mode]} size={16} />
633
- <span>{MODE_LABELS[mode]}</span>
634
- </button>
635
- ))}
636
- {foldersOn && onFolderCreate && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
  <>
638
- <div className="cg-menu-sep" role="separator" aria-hidden />
639
- <button
640
- type="button"
641
- role="menuitem"
642
- className="cg-create-row"
643
- onClick={() => startFolder()}
644
- >
645
- <FolderMark size={16} />
646
- <span>Folder</span>
647
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  </>
649
  )}
650
  </AnchoredOverlay>
@@ -662,12 +819,42 @@ export default function ViewSidebar({
662
  key={gid ?? "__root__"}
663
  className={
664
  (isRoot ? "cg-fold-root" : "cg-fold") +
665
- (dropTarget === (gid ?? "__root__") ? " is-drop" : "")
 
 
 
 
 
 
666
  }
667
  {...dropHandlers(gid)}
668
  >
669
  {group.folder && (
670
- <div className="cg-fold-head">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  <button
672
  type="button"
673
  className="cg-fold-toggle"
@@ -710,8 +897,28 @@ export default function ViewSidebar({
710
  ) : (
711
  <span className="cg-fold-name">{group.folder.name}</span>
712
  )}
713
- <span className="cg-fold-count">{group.items.length}</span>
 
 
 
 
 
 
 
 
 
 
 
 
714
  </button>
 
 
 
 
 
 
 
 
715
  <button
716
  type="button"
717
  className="cg-view-more"
@@ -729,6 +936,7 @@ export default function ViewSidebar({
729
  >
730
  ···
731
  </button>
 
732
  </div>
733
  )}
734
  {/* A collapsed folder hides its rows but stays a DROP TARGET, so you can
@@ -818,6 +1026,35 @@ export default function ViewSidebar({
818
  so explicitly), so most rows have exactly one reason. When a row has both,
819
  the row-set meaning leads and the title carries the other — never two
820
  padlocks side by side, which would read as a bug. */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
821
  {(view.kind === "locked" || isModeFrozen(view)) && (
822
  <span
823
  className="cg-view-lock"
@@ -902,7 +1139,15 @@ export default function ViewSidebar({
902
  The lock MARK moved onto the ordinary view row, where `kind === "locked"` drives it. */}
903
  </div>
904
 
905
- {/* I11c — the folder "…" menu. Rename · Add to cohort · Duplicate · Delete. */}
 
 
 
 
 
 
 
 
906
  {folderMenu && folderMenuF && (
907
  <AnchoredOverlay
908
  anchor={folderMenu.anchor}
@@ -925,7 +1170,7 @@ export default function ViewSidebar({
925
  setFolderMenu(null);
926
  }}
927
  >
928
- Rename
929
  </button>
930
  {folderAddPreview && onFolderAddToList && (
931
  <button
@@ -939,7 +1184,7 @@ export default function ViewSidebar({
939
  setFolderMenu(null);
940
  }}
941
  >
942
- Add to cohort…
943
  </button>
944
  )}
945
  {onFolderDuplicate && (
@@ -952,14 +1197,30 @@ export default function ViewSidebar({
952
  setFolderMenu(null);
953
  }}
954
  >
955
- Duplicate folder and its views
956
  </button>
957
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
958
  {onFolderDelete && (
959
  <button
960
  type="button"
961
  role="menuitem"
962
- className={"cg-menu-item cg-menu-item--danger" + (confirmDelete === folderMenuF.id ? " is-armed" : "")}
 
 
 
963
  onClick={() => {
964
  if (confirmDelete !== folderMenuF.id) {
965
  setConfirmDelete(folderMenuF.id);
@@ -970,9 +1231,18 @@ export default function ViewSidebar({
970
  setFolderMenu(null);
971
  }}
972
  >
973
- {confirmDelete === folderMenuF.id
974
- ? "Delete folder? Its views move to the top level."
975
- : "Delete folder"}
 
 
 
 
 
 
 
 
 
976
  </button>
977
  )}
978
  </AnchoredOverlay>
@@ -1170,6 +1440,44 @@ export default function ViewSidebar({
1170
  >
1171
  <MenuLabel icon="duplicate" text="Duplicate" />
1172
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1173
  {onAddToList && (
1174
  <button
1175
  type="button"
 
37
  MODE_TONE,
38
  } from "./iconShapes";
39
  import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
40
+ import { groupByFolder, reorderFolderIds } from "./folders";
41
+
42
+ /**
43
+ * WAVE 20 item 18 (C-SHARE) — the system folder every view shared WITH you lands in
44
+ * until you file it somewhere of your own. The id is the CONTRACT's, not this file's
45
+ * invention, and it is the only folder id this component ever recognises by name.
46
+ */
47
+ const SHARED_FOLDER_ID = "__shared__";
48
 
49
  interface ViewSidebarProps {
50
  views: SavedView[];
 
106
  onFolderDelete?: (folderId: string) => void;
107
  onFolderDuplicate?: (folderId: string) => void;
108
  onItemMove?: (viewId: string, folderId: string | null) => void;
109
+ /**
110
+ * WAVE 20 item 19 (C-FOLDER-REORDER) — the folders, in the order the user just dragged
111
+ * them into. The FULL list every time, never a delta: a partial order cannot say where an
112
+ * unnamed folder went, and the host has to be able to stamp `order` on all of them.
113
+ *
114
+ * Optional like every other folder handler here: a caller that does not pass it gets a
115
+ * rail with no folder drag at all, rather than a grip that lifts a folder and drops it
116
+ * back — the posture this whole component takes toward an action nobody can honour.
117
+ */
118
+ onFolderReorder?: (order: string[]) => void;
119
  /**
120
  * C4 as AMENDED 2026-07-28 — the folder-level bulk "Add to cohort". The caller
121
  * owns the arithmetic (it holds the engine); the rail owns the confirm.
 
168
  onFolderDelete,
169
  onFolderDuplicate,
170
  onItemMove,
171
+ onFolderReorder,
172
  folderAddPreview,
173
  onFolderAddToList,
174
  viewer,
 
275
  const shownViews = viewNeedle
276
  ? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle))
277
  : views;
278
+ // ── WAVE 20 item 19 (C-FOLDER-REORDER) — dragging a FOLDER to reorder the rail ──────
279
+ //
280
+ // ⚠ A PRIVATE dataTransfer MIME, and it is not decoration. The VIEW drag two blocks down
281
+ // carries `text/plain`, and the folder groups already accept that drop ("file this view
282
+ // into me"). Sharing one channel would make every folder drag look like a view drop to
283
+ // the handler that fires first — so the two drags are told apart by TYPE, the same
284
+ // discipline the shell nav's own drag uses, and a drop of this type anywhere
285
+ // text-editable types nothing.
286
+ const FOLD_DRAG_TYPE = "application/x-loopable-fold";
287
+ /** The folder being dragged, and the group its insertion line is currently drawn above. */
288
+ const [foldDrag, setFoldDrag] = useState<string | null>(null);
289
+ const [foldOver, setFoldOver] = useState<string | null>(null);
290
+ const foldsReorderable = !!folders && !!onFolderReorder;
291
+ /**
292
+ * Land `draggedId` before `beforeId` (null = last) and emit the new order. The arithmetic
293
+ * lives in `folders.ts` so a gate can run it under node — the handler around it is DOM.
294
+ *
295
+ * ⚠ The order is computed from the `folders` PROP, never from the rendered groups: a live
296
+ * "Find a view" query hides folders with no matches, and an order derived from what is on
297
+ * screen would silently drop the hidden ones out of the sequence.
298
+ */
299
+ const reorderFolders = (draggedId: string, beforeId: string | null) => {
300
+ const next = reorderFolderIds((folders ?? []).map((f) => f.id), draggedId, beforeId);
301
+ // `null` is "nothing to say" — an unknown id, or a drop that changed nothing.
302
+ if (next) onFolderReorder?.(next);
303
+ };
304
  const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
305
  const groups = groupByFolder(
306
  shownViews,
 
309
  );
310
  const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
311
  const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
312
+ /**
313
+ * ONE pair of drop handlers per group, serving TWO drags — a view being filed into this
314
+ * folder (`text/plain`) and, since wave 20 item 19, a folder being dropped in front of
315
+ * this one (`FOLD_DRAG_TYPE`). They must be one pair rather than two spreads: a second
316
+ * `onDragOver`/`onDrop` on the same element replaces the first, silently, and the drag
317
+ * that lost would simply stop working.
318
+ *
319
+ * The type is read BEFORE anything else, so a folder drag never paints the "file a view
320
+ * in here" fill and a view drag never draws the insertion rule.
321
+ */
322
  const dropHandlers = (folderId: string | null) =>
323
+ foldersOn || foldsReorderable
324
  ? {
325
  onDragOver: (e: React.DragEvent) => {
326
+ if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) {
327
+ if (!foldsReorderable) return;
328
+ e.preventDefault();
329
+ e.dataTransfer.dropEffect = "move";
330
+ setFoldOver(folderId ?? "__root__");
331
+ return;
332
+ }
333
+ if (!foldersOn) return;
334
  e.preventDefault();
335
  setDropTarget(folderId ?? "__root__");
336
  },
337
+ onDragLeave: () => {
338
+ setDropTarget(null);
339
+ setFoldOver(null);
340
+ },
341
  onDrop: (e: React.DragEvent) => {
342
+ if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) {
343
+ const dragged = e.dataTransfer.getData(FOLD_DRAG_TYPE);
344
+ setFoldOver(null);
345
+ setFoldDrag(null);
346
+ if (!foldsReorderable || !dragged) return;
347
+ e.preventDefault();
348
+ e.stopPropagation();
349
+ reorderFolders(dragged, folderId);
350
+ return;
351
+ }
352
+ if (!foldersOn) return;
353
  e.preventDefault();
354
  setDropTarget(null);
355
  const id = e.dataTransfer.getData("text/plain");
 
375
  setPermEdit("personal");
376
  setPermUsers([]);
377
  setCreating(null);
378
+ // WAVE 20 item 20 — the two steps share ONE panel, so the anchor outlives step 1 and
379
+ // has to be released here. Left set, the flyout would spring back to the type chooser
380
+ // the instant the view was created.
381
+ setCreateMenu(null);
382
+ };
383
+
384
+ /** Item 20 — abandon the whole flyout, from either step. Escape, an outside click and
385
+ * Cancel all mean the same thing ("I am not creating anything"), so they call one
386
+ * function rather than three combinations of setters — and abandoning drops the DRAFT
387
+ * too, or the next "+ Create new…" would hand back a half-filled form from a decision
388
+ * the user already walked away from. */
389
+ const closeCreate = () => {
390
+ setCreating(null);
391
+ setCreateMenu(null);
392
+ setName("");
393
+ setPermEdit("personal");
394
+ setPermUsers([]);
395
  };
396
 
397
  const createFolder = () => {
 
405
  setCreatingFolder(false);
406
  };
407
 
408
+ /**
409
+ * WAVE 20 item 20 the view door KEEPS the anchor, because the name/permissions step
410
+ * now renders in the SAME anchored box the type chooser used. The old comment ("close
411
+ * the flyout first, or the menu sits on top of the input") described the bug the owner
412
+ * reported from the other side: the two steps of one action were drawn in two different
413
+ * places, one beside the rail and one inside it, so step 2 read as an unrelated form.
414
+ * One panel, two contents, one geometry.
415
+ *
416
+ * The FOLDER door still closes it: that path is a single step and has no second panel
417
+ * to disagree with. Moving it too would be a change the ruling does not ask for.
418
+ */
419
  const startView = (mode: DisplayMode) => {
 
420
  setCreatingFolder(false);
421
  setCreating(mode);
422
  };
 
426
  setCreatingFolder(true);
427
  };
428
 
429
+ /**
430
+ * WAVE 20 items 18/23/26 (R10, C-SHARE) — open the access editor for a view or a
431
+ * folder.
432
+ *
433
+ * ⚠ A WINDOW EVENT, NOT A PROP, and not for convenience. The dialog is the SHELL's
434
+ * (one editor for views, folders and databases — R10 asks for one vocabulary), and
435
+ * this tree is host-neutral: `customer-grid/**` must not import a shell. The frame
436
+ * already listens on this channel for toasts and staleness; this is one more note on
437
+ * it. The literal is spelled once here and once in `shell/shareModel.ts`, which is
438
+ * where it becomes a shared constant when S3 publishes it in `apiContract.ts`
439
+ * (amendment A-S4-4).
440
+ *
441
+ * ⛔ It degrades to NOTHING when no shell is listening (the Streamlit-era bare grid):
442
+ * a dispatched event nobody hears opens no dialog and throws no error — which is the
443
+ * right failure for a frame-level surface reached from a rail.
444
+ */
445
+ const openShare = (kind: "view" | "folder", id: string, label: string) => {
446
+ window.dispatchEvent(
447
+ new CustomEvent("aios:share-open", { detail: { kind, id, label } })
448
+ );
449
+ };
450
+
451
  const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
452
  if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
453
  const items = [
 
526
  className="cg-link-btn cg-create-btn"
527
  aria-haspopup="menu"
528
  aria-expanded={!!createMenu}
529
+ onClick={(event) => {
530
+ // THE ANCHOR IS HOISTED OUT OF THE UPDATER — the same line that took the whole
531
+ // app white from the view row's "…" (see the long note at that button). React
532
+ // nulls `event.currentTarget` when the handler returns and may re-invoke a
533
+ // functional updater afterwards, so `setCreateMenu(cur => … event.currentTarget)`
534
+ // can store null. Item 20 made this panel outlive a single click — it now carries
535
+ // the name step too — so a randomly-nulled anchor would close a form mid-typing
536
+ // rather than merely mis-place a menu.
537
+ const anchor = event.currentTarget;
538
+ if (createMenu) {
539
+ closeCreate();
540
+ return;
541
+ }
542
+ // Every open starts at the type chooser: `creating` left set by an abandoned
543
+ // flyout would re-enter at step 2 for a kind nobody just picked.
544
+ setCreating(null);
545
+ setCreateMenu(anchor);
546
+ }}
547
  >
548
  <span className="cg-create-icon" aria-hidden="true">+</span>
549
  <span className="cg-create-label">Create new…</span>
 
582
  />
583
  </div>
584
 
585
+ {/* WAVE 20 item 20 — THE CREATE FORM NO LONGER RENDERS HERE. It is the flyout's
586
+ second step, inside the same `AnchoredOverlay` (below, next to the type list it
587
+ follows). This comment stands in for the moved markup because the move IS the
588
+ item: two steps of one action that used to appear in two different places. */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  {/* I15 — the folder form: name + the icon the folder will wear. */}
590
  {foldersOn && onFolderCreate && creatingFolder && (
591
  <div className="cg-view-create cg-create-form cg-fold-form">
 
664
  anchor={createMenu}
665
  className="cg-view-menu cg-create-flyout"
666
  placement="right-start"
667
+ // WAVE 20 item 20 — ONE panel, two contents. A menu of choices and a form are
668
+ // different KINDS of thing to a screen reader even when they are one box to the
669
+ // eye, so the role and the label follow the step rather than being frozen at the
670
+ // panel's first purpose.
671
+ role={creating ? "dialog" : "menu"}
672
+ ariaLabel={
673
+ creating ? `New ${MODE_LABELS[creating].toLowerCase()} view` : "Create new"
674
+ }
675
+ onDismiss={closeCreate}
676
+ // ⛔ THE ARROW-KEY HANDLER IS STEP 1's ONLY. `onMenuKeyDown` swallows
677
+ // ArrowUp/ArrowDown to walk `[role=menuitem]`; over a text input that is the
678
+ // cursor keys refusing to move through what you just typed.
679
+ {...(creating ? {} : { onKeyDown: onMenuKeyDown })}
680
+ dataKind={creating ? "create-new-name" : "create-new"}
681
  >
682
+ {creating ? (
683
+ <div className="cg-view-create cg-create-form">
684
+ <label htmlFor="cg-new-view">
685
+ {/* The prompt NAMES the kind picked one step ago — the panel replaced its
686
+ own contents, so without this the box gives no sign that "Calendar" was
687
+ ever chosen. */}
688
+ New {MODE_LABELS[creating].toLowerCase()} view
689
+ </label>
690
+ <input
691
+ id="cg-new-view"
692
+ className="cg-input"
693
+ autoFocus
694
+ // The overlay's own initial-focus effect fires on MOUNT, and this panel does
695
+ // not re-mount between the two steps — so the input carries `autoFocus` (React
696
+ // focuses it when it mounts) and the marker the other in-panel forms use.
697
+ data-overlay-autofocus
698
+ value={name}
699
+ placeholder="e.g. Florida at risk"
700
+ onChange={(event) => setName(event.target.value)}
701
+ onKeyDown={(event) => {
702
+ if (event.key === "Enter") create();
703
+ // Escape is handled by the overlay layer too; both mean "abandon", and
704
+ // calling the same closer twice is idempotent.
705
+ if (event.key === "Escape") closeCreate();
706
+ }}
707
+ />
708
+ {/* I17 (C4) — the owner's prompt ORDER: type (picked in step 1, named above)
709
+ → who can edit → the user picker, and the picker only when it applies. */}
710
+ <div className="cg-perm" role="radiogroup" aria-label="Who can edit this view">
711
+ <span className="cg-perm-title">Who can edit</span>
712
+ {VIEW_EDIT_MODES.map((m) => (
713
+ <label key={m} className="cg-radio-row cg-perm-row">
714
+ <input
715
+ type="radio"
716
+ name="cg-view-perm"
717
+ checked={permEdit === m}
718
+ onChange={() => setPermEdit(m)}
719
+ />
720
+ <span className="cg-perm-label">
721
+ {VIEW_EDIT_LABELS[m]}
722
+ <span className="cg-perm-blurb">{VIEW_EDIT_BLURBS[m]}</span>
723
+ </span>
724
+ </label>
725
+ ))}
726
+ {permEdit === "users" && (
727
+ <div className="cg-perm-users">
728
+ {userOptions.length === 0 ? (
729
+ // Never a silent empty box: an empty grant collapses to Personal
730
+ // host-side, so say that rather than letting the user think they
731
+ // shared it.
732
+ <span className="cg-perm-empty">
733
+ No other accounts to pick — this will save as Personal.
734
+ </span>
735
+ ) : (
736
+ userOptions.map((u) => (
737
+ <label key={u} className="cg-perm-user">
738
+ <input
739
+ type="checkbox"
740
+ checked={permUsers.includes(u)}
741
+ onChange={(e) =>
742
+ setPermUsers((cur) =>
743
+ e.target.checked
744
+ ? [...cur, u].slice(0, MAX_VIEW_USERS)
745
+ : cur.filter((x) => x !== u)
746
+ )
747
+ }
748
+ />
749
+ <span>{u}</span>
750
+ </label>
751
+ ))
752
+ )}
753
+ {userOptions.length > 0 && permUsers.length === 0 && (
754
+ <span className="cg-perm-empty">
755
+ Pick at least one person, or this saves as Personal.
756
+ </span>
757
+ )}
758
+ </div>
759
+ )}
760
+ </div>
761
+ <div className="cg-form-actions">
762
+ <button
763
+ type="button"
764
+ className="cg-btn cg-btn--primary"
765
+ onClick={create}
766
+ disabled={!name.trim()}
767
+ >
768
+ Create
769
+ </button>
770
+ <button type="button" className="cg-btn" onClick={closeCreate}>
771
+ Cancel
772
+ </button>
773
+ </div>
774
+ </div>
775
+ ) : (
776
  <>
777
+ {CREATABLE_MODES.map((mode) => (
778
+ <button
779
+ key={mode}
780
+ type="button"
781
+ role="menuitem"
782
+ className="cg-create-row"
783
+ onClick={() => startView(mode)}
784
+ >
785
+ {/* size 16 — item 13's "bigger icons", and the SAME 16 the view rows use,
786
+ so a Calendar is one mark at one size on this whole surface. */}
787
+ <ToneModeIcon mode={mode} tone={MODE_TONE[mode]} size={16} />
788
+ <span>{MODE_LABELS[mode]}</span>
789
+ </button>
790
+ ))}
791
+ {foldersOn && onFolderCreate && (
792
+ <>
793
+ <div className="cg-menu-sep" role="separator" aria-hidden />
794
+ <button
795
+ type="button"
796
+ role="menuitem"
797
+ className="cg-create-row"
798
+ onClick={() => startFolder()}
799
+ >
800
+ <FolderMark size={16} />
801
+ <span>Folder</span>
802
+ </button>
803
+ </>
804
+ )}
805
  </>
806
  )}
807
  </AnchoredOverlay>
 
819
  key={gid ?? "__root__"}
820
  className={
821
  (isRoot ? "cg-fold-root" : "cg-fold") +
822
+ (dropTarget === (gid ?? "__root__") ? " is-drop" : "") +
823
+ // Item 19 — the insertion rule reads "the folder you are dragging lands HERE":
824
+ // above this folder, or above the ungrouped section, which is after them all.
825
+ (foldOver === (gid ?? "__root__") && foldDrag && foldDrag !== gid
826
+ ? " is-drop-above"
827
+ : "") +
828
+ (foldDrag === gid ? " is-folddrag" : "")
829
  }
830
  {...dropHandlers(gid)}
831
  >
832
  {group.folder && (
833
+ <div
834
+ className="cg-fold-head"
835
+ // ── Item 19 — the folder itself is the drag handle ─────────────────────
836
+ // Draggable only while it is renameable-idle: a `draggable` ancestor eats
837
+ // the text selection inside an input, so dragging would win over editing
838
+ // the name you just opened.
839
+ draggable={foldsReorderable && renamingFolder !== group.folder.id}
840
+ onDragStart={(e) => {
841
+ e.dataTransfer.setData(FOLD_DRAG_TYPE, group.folder!.id);
842
+ e.dataTransfer.effectAllowed = "move";
843
+ setFoldDrag(group.folder!.id);
844
+ }}
845
+ onDragEnd={() => {
846
+ setFoldDrag(null);
847
+ setFoldOver(null);
848
+ }}
849
+ >
850
+ {/* ⛔ NO DRAG GRIP, and that is a decision rather than an omission. A grip in
851
+ flow shifts the folder's name to the right, and this rail's indent is
852
+ MEASURED to the pixel (index.css: "root view name 41.00px / folder header
853
+ 48.36px" — the whole point of the wave-10 item-2 fix); the gutter it would
854
+ otherwise hide in is already occupied by the disclosure chevron, whose
855
+ open/shut state is the only one this row has. The view rows above have been
856
+ draggable with no handle since wave 8, so a folder that drags the same way
857
+ is the vocabulary this list already teaches, not a hidden feature. */}
858
  <button
859
  type="button"
860
  className="cg-fold-toggle"
 
897
  ) : (
898
  <span className="cg-fold-name">{group.folder.name}</span>
899
  )}
900
+ {/* ⛔ WAVE 20 item 21 — THE VIEW COUNT IS DELETED, and the item is not
901
+ really about the number. `.cg-view-more` (the "…" beside this button)
902
+ ships `opacity: 0` and is revealed by `.cg-view-row:hover`; a folder
903
+ head is not a view row, so NO rule ever revealed it — the folder's
904
+ actions button was painted at zero opacity in every state but keyboard
905
+ focus. The badge is what the owner saw sitting where the control should
906
+ have been. Deleting it and making the "…" unconditionally visible (one
907
+ rule in this wave's S4 region) are the two halves of one fix.
908
+ ⚠ `.cg-fold-count` in index.css now has no consumer in this tree. Left
909
+ in place rather than swept blind — same posture as `.cg-fold-new` above
910
+ — and booked in the S4 mailbox so the sweep is a decision, not a
911
+ discovery. The NAV rail's own count stays: that rail reveals its "…" on
912
+ hover already, so it never had this defect and item 21 never named it. */}
913
  </button>
914
+ {/* ⛔ THE SYSTEM FOLDER HAS NO ACTIONS (item 18 / C-SHARE). "Shared with
915
+ me" is not the reader's folder: it cannot be renamed into something
916
+ else, duplicated into a second copy of other people's work, or
917
+ deleted — the contract calls it undeletable, and a menu whose every
918
+ row the server would refuse is three fake affordances rather than
919
+ one. Views MOVE OUT of it normally (that is per-receiver placement),
920
+ which is the only thing anyone needs to do to it. */}
921
+ {group.folder.id === SHARED_FOLDER_ID ? null : (
922
  <button
923
  type="button"
924
  className="cg-view-more"
 
936
  >
937
  ···
938
  </button>
939
+ )}
940
  </div>
941
  )}
942
  {/* A collapsed folder hides its rows but stays a DROP TARGET, so you can
 
1026
  so explicitly), so most rows have exactly one reason. When a row has both,
1027
  the row-set meaning leads and the title carries the other — never two
1028
  padlocks side by side, which would read as a bug. */}
1029
+ {/* ⭐ WAVE 20 item 23 (C-SHARE) — THE SECOND MARK, beside the padlock
1030
+ and never instead of it. They answer different questions: the lock
1031
+ says what this view may become, this says how it got to you. A
1032
+ view someone shared with you is one you can be looking at without
1033
+ having made it — the single most useful thing the rail can tell you
1034
+ before the click.
1035
+ ⚠ `shared` is not on `SavedView` yet (S3, amendment A-S4-4). Read
1036
+ through ONE narrowed cast, here, rather than loosening the type
1037
+ everywhere: an absent flag reads as false, which is exactly right
1038
+ until the server projects it. */}
1039
+ {(view as { shared?: boolean }).shared ? (
1040
+ <span
1041
+ className="cg-view-shared"
1042
+ role="img"
1043
+ aria-label={`${view.name}, shared with you`}
1044
+ title="Shared with you — someone gave you access to this view."
1045
+ >
1046
+ <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
1047
+ <circle cx="5.6" cy="5.6" r="2.2" fill="none" stroke="currentColor"
1048
+ strokeWidth="1.3" />
1049
+ <path d="M1.9 12.6c0-2 1.7-3.2 3.7-3.2s3.7 1.2 3.7 3.2"
1050
+ fill="none" stroke="currentColor" strokeWidth="1.3"
1051
+ strokeLinecap="round" />
1052
+ <path 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"
1053
+ fill="none" stroke="currentColor" strokeWidth="1.3"
1054
+ strokeLinecap="round" />
1055
+ </svg>
1056
+ </span>
1057
+ ) : null}
1058
  {(view.kind === "locked" || isModeFrozen(view)) && (
1059
  <span
1060
  className="cg-view-lock"
 
1139
  The lock MARK moved onto the ordinary view row, where `kind === "locked"` drives it. */}
1140
  </div>
1141
 
1142
+ {/* I11c — the folder "…" menu. Rename · Add to cohort · Duplicate · Delete.
1143
+ WAVE 20 item 22 — every row now carries the SAME icon vocabulary the view row's
1144
+ menu uses (`MenuLabel` over `MENU_ICONS`), and Delete is red AT REST rather than
1145
+ only once armed. Two menus that do the same kind of thing to two kinds of object
1146
+ were drawn in two registers: one with icons and a red delete, one with neither.
1147
+ ⚠ NO NEW CSS FOR THE COLOUR: this overlay already wears `cg-view-menu`, so adding
1148
+ `is-danger` to the button reuses `.cg-view-menu button.is-danger` — the exact rule
1149
+ that paints the view row's Delete, which is what "matching" has to mean. The
1150
+ `cg-menu-item--danger` class stays for its armed background. */}
1151
  {folderMenu && folderMenuF && (
1152
  <AnchoredOverlay
1153
  anchor={folderMenu.anchor}
 
1170
  setFolderMenu(null);
1171
  }}
1172
  >
1173
+ <MenuLabel icon="rename" text="Rename" />
1174
  </button>
1175
  {folderAddPreview && onFolderAddToList && (
1176
  <button
 
1184
  setFolderMenu(null);
1185
  }}
1186
  >
1187
+ <MenuLabel icon="cohortAdd" text="Add to cohort…" />
1188
  </button>
1189
  )}
1190
  {onFolderDuplicate && (
 
1197
  setFolderMenu(null);
1198
  }}
1199
  >
1200
+ <MenuLabel icon="duplicate" text="Duplicate folder and its views" />
1201
  </button>
1202
  )}
1203
+ {/* WAVE 20 item 18 (R10) — a folder shares, and its views ride along
1204
+ (the server's rule, stated in C-SHARE; this row only opens the editor). */}
1205
+ <button
1206
+ type="button"
1207
+ role="menuitem"
1208
+ className="cg-menu-item"
1209
+ onClick={() => {
1210
+ openShare("folder", folderMenuF.id, folderMenuF.name);
1211
+ setFolderMenu(null);
1212
+ }}
1213
+ >
1214
+ <MenuLabel icon="permissions" text="Share folder…" />
1215
+ </button>
1216
  {onFolderDelete && (
1217
  <button
1218
  type="button"
1219
  role="menuitem"
1220
+ className={
1221
+ "cg-menu-item cg-menu-item--danger is-danger" +
1222
+ (confirmDelete === folderMenuF.id ? " is-armed" : "")
1223
+ }
1224
  onClick={() => {
1225
  if (confirmDelete !== folderMenuF.id) {
1226
  setConfirmDelete(folderMenuF.id);
 
1231
  setFolderMenu(null);
1232
  }}
1233
  >
1234
+ {/* The ARMED label is a whole sentence and the menu row is `nowrap` with a
1235
+ 260px ceiling, so it used to run out of the panel. It wraps while armed
1236
+ (one rule in the S4 region) rather than being ellipsised: a confirmation
1237
+ the reader cannot finish reading is not a confirmation. */}
1238
+ <MenuLabel
1239
+ icon="trash"
1240
+ text={
1241
+ confirmDelete === folderMenuF.id
1242
+ ? "Delete folder? Its views move to the top level."
1243
+ : "Delete folder"
1244
+ }
1245
+ />
1246
  </button>
1247
  )}
1248
  </AnchoredOverlay>
 
1440
  >
1441
  <MenuLabel icon="duplicate" text="Duplicate" />
1442
  </button>
1443
+ {/* WAVE 20 items 23/26 (R10) — "who else can reach this view", the same
1444
+ editor a folder and a database open. Deliberately NOT gated on
1445
+ `canEditMenuView`: the server decides who may administer (owner or
1446
+ admin) and says so in the dialog, and hiding the row from everyone else
1447
+ would hide the ANSWER too — "who has this?" is a fair question for
1448
+ anyone the view was shared with. */}
1449
+ <button
1450
+ type="button"
1451
+ role="menuitem"
1452
+ onClick={() => {
1453
+ openShare("view", menuView.id, menuView.name);
1454
+ setMenu(null);
1455
+ }}
1456
+ >
1457
+ <MenuLabel icon="permissions" text="Share view…" />
1458
+ </button>
1459
+ {/* WAVE 20 item 25 (C-ALERT) — the door that MAKES an alert, on the view it
1460
+ watches. It belongs here and nowhere else: an alert IS "tell me when a
1461
+ record enters THIS view", so the only place the question has an obvious
1462
+ subject is the view's own menu.
1463
+ ⚠ The frame answers, because this rail does not know its own topic (it
1464
+ holds views; the scope key belongs to the route) — and because the server
1465
+ refuses a view with no active filter, which is a message the frame is
1466
+ already in the business of showing. */}
1467
+ <button
1468
+ type="button"
1469
+ role="menuitem"
1470
+ onClick={() => {
1471
+ window.dispatchEvent(
1472
+ new CustomEvent("aios:alert-create", {
1473
+ detail: { viewId: menuView.id, label: menuView.name },
1474
+ })
1475
+ );
1476
+ setMenu(null);
1477
+ }}
1478
+ >
1479
+ <MenuLabel icon="cohortAdd" text="Alert me about new records…" />
1480
+ </button>
1481
  {onAddToList && (
1482
  <button
1483
  type="button"
web/src/customer-grid/apiBridge.ts CHANGED
@@ -219,6 +219,78 @@ export function patchCustomer(pid: number, updates: Partial<Row>): Promise<boole
219
  return patchTopicRow(CUSTOMER_TOPIC, pid, updates);
220
  }
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  /**
223
  * Item 7 (contract C-TS) — `POST /api/v1/grid/timeseries`.
224
  *
 
219
  return patchTopicRow(CUSTOMER_TOPIC, pid, updates);
220
  }
221
 
222
+ /**
223
+ * ⭐ Wave-20 owner item 4 / contract C-ADDROW — **APPEND A ROW TO A USER DATABASE**, and
224
+ * (C-UNDO) restore a deleted one under its old id.
225
+ *
226
+ * USER TABLES ONLY, and the refusal is structural rather than checked here: no other scope has
227
+ * a `/tables/{key}/rows` endpoint at all. A connector's rows are read-synced from its source —
228
+ * R8 is explicit that a "+" which must refuse is a fake affordance, so the caller never renders
229
+ * one there.
230
+ *
231
+ * ⚠ THE ANSWER IS THE ID THAT WAS STORED, never the one that was asked for. An undo that
232
+ * requests `rid` may find that id re-used, and the server's own note says it answers with what
233
+ * it actually wrote; the caller re-anchors on the returned value rather than assuming.
234
+ *
235
+ * ⚠ The rows cache is CLEARED on success. `fetchTopicRows` holds a 5-minute window per topic,
236
+ * so a re-read straight after an append would serve the payload from before it — the new row
237
+ * would appear minutes later, which reads as "the button did nothing". Same reason the shell's
238
+ * retired Add-record bar cleared it (wave 20 item 4 moved that door here).
239
+ */
240
+ export async function addTableRow(
241
+ tableKey: string,
242
+ values: Record<string, unknown> = {},
243
+ rid?: string | number
244
+ ): Promise<{ rid: string | number; pid: number } | null> {
245
+ try {
246
+ const res = await fetch(`${API_V1}/tables/${encodeURIComponent(tableKey)}/rows`, {
247
+ method: "POST",
248
+ credentials: CREDENTIALS,
249
+ headers: JSON_HEADERS,
250
+ body: JSON.stringify(rid === undefined ? { values } : { rid, values }),
251
+ });
252
+ if (handledUnauthorized(res.status)) return null;
253
+ const body = (await readJson(res)) as { rid?: string | number; pid?: number;
254
+ detail?: { message?: string } } | null;
255
+ if (!res.ok) {
256
+ // The server states WHY (a row cap, a store refusal). Surfacing its sentence beats a
257
+ // generic failure toast, and staying silent would be the worst of the three.
258
+ const why = typeof body?.detail?.message === "string"
259
+ ? body.detail.message
260
+ : `The server answered ${res.status}.`;
261
+ signal(TOAST_EVENT, why);
262
+ return null;
263
+ }
264
+ if (body?.rid === undefined || typeof body?.pid !== "number") {
265
+ signal(DATA_ERROR_EVENT, "The server did not say which row it created.");
266
+ return null;
267
+ }
268
+ rowsCache.delete(`tables/${tableKey}/rows`);
269
+ return { rid: body.rid, pid: body.pid };
270
+ } catch {
271
+ signal(DATA_ERROR_EVENT, "Cannot reach the server.");
272
+ return null;
273
+ }
274
+ }
275
+
276
+ /** C-UNDO's other half: drop a row this session just added. Same cache rule as the append. */
277
+ export async function deleteTableRow(
278
+ tableKey: string,
279
+ rid: string | number
280
+ ): Promise<boolean> {
281
+ try {
282
+ const res = await fetch(
283
+ `${API_V1}/tables/${encodeURIComponent(tableKey)}/rows/${encodeURIComponent(String(rid))}`,
284
+ { method: "DELETE", credentials: CREDENTIALS }
285
+ );
286
+ if (handledUnauthorized(res.status)) return false;
287
+ if (res.ok) rowsCache.delete(`tables/${tableKey}/rows`);
288
+ return res.ok;
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+
294
  /**
295
  * Item 7 (contract C-TS) — `POST /api/v1/grid/timeseries`.
296
  *
web/src/customer-grid/folders.ts CHANGED
@@ -30,6 +30,16 @@ export interface FolderStamps {
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
 
35
  export const FOLDER_STAMP_MAX = 64;
@@ -59,6 +69,9 @@ export function pruneFolderStamps(stamps: FolderStamps | undefined, now: number)
59
  if (renamed) out.renamed = renamed;
60
  if (deleted) out.deleted = deleted;
61
  if (moved) out.moved = moved;
 
 
 
62
  return out;
63
  }
64
 
@@ -102,7 +115,28 @@ export function reconcileFolders(
102
  out.push(f);
103
  }
104
 
105
- return out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  }
107
 
108
  /**
@@ -158,6 +192,44 @@ export function groupByFolder<T>(
158
  return out;
159
  }
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  /** A fresh folder id. Client-generated, like every other id in this component. */
162
  export function newFolderId(): string {
163
  const rand =
 
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
 
45
  export const FOLDER_STAMP_MAX = 64;
 
69
  if (renamed) out.renamed = renamed;
70
  if (deleted) out.deleted = deleted;
71
  if (moved) out.moved = moved;
72
+ // Item 19: a single stamp, so it is kept or dropped whole — pruning it by halves is
73
+ // exactly the partial sequence the field's own note refuses.
74
+ if (isRecent(stamps?.ordered?.at, now) && stamps?.ordered) out.ordered = stamps.ordered;
75
  return out;
76
  }
77
 
 
115
  out.push(f);
116
  }
117
 
118
+ out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
119
+
120
+ // ── WAVE 20 item 19 (C-FOLDER-REORDER): this browser's drag, until the echo carries it.
121
+ //
122
+ // Applied AFTER the host sort and as a SEPARATE pass, both deliberately:
123
+ // · the host's `order` numbers are the durable truth and stay the base sequence, so a
124
+ // folder the stamp never names keeps exactly the place the server gave it;
125
+ // · `Array.prototype.sort` is stable (ES2019), so every unnamed folder — one created in
126
+ // another tab between the drag and the echo, say — holds its relative position at the
127
+ // end instead of being flung to the front by a missing rank.
128
+ // A stamped id that has since been DELETED needs no handling: the tombstone pass above
129
+ // already dropped it, and `rank` is only ever consulted for folders that survived.
130
+ const ordered = stamps?.ordered;
131
+ if (isRecent(ordered?.at, now) && ordered) {
132
+ const rank = new Map(ordered.order.map((id, i) => [id, i]));
133
+ out.sort(
134
+ (a, b) =>
135
+ (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
136
+ (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)
137
+ );
138
+ }
139
+ return out;
140
  }
141
 
142
  /**
 
192
  return out;
193
  }
194
 
195
+ /**
196
+ * WAVE 20 item 19 (C-FOLDER-REORDER) — where a dragged folder lands: the full order with
197
+ * `draggedId` moved to sit immediately BEFORE `beforeId`, or last when that is null (the
198
+ * drop on the ungrouped section below every folder).
199
+ *
200
+ * Here rather than inside the rail because it is the only part of the drag a test can hold:
201
+ * the drop handler is DOM, the emit is the caller's, and this is the arithmetic that decides
202
+ * what the user sees. `null` means "emit nothing" — an unknown id, or a drop that changes
203
+ * nothing. Returning the unchanged array instead would be worse than useless: the caller
204
+ * cannot tell it apart from a real reorder, so every no-op drag would write the store, bump
205
+ * every reader's payload, and reconcile to the identical list.
206
+ *
207
+ * ⚠ The dragged id is REMOVED BEFORE the target index is read. Taking the index first and
208
+ * splicing after is the classic off-by-one here: dragging a folder DOWNWARD would land it one
209
+ * place short of where it was dropped, and only in that direction — the shape of bug that
210
+ * survives a demo and gets reported as "it sometimes doesn't move".
211
+ */
212
+ export function reorderFolderIds(
213
+ ids: string[],
214
+ draggedId: string,
215
+ beforeId: string | null
216
+ ): string[] | null {
217
+ if (!ids.includes(draggedId)) return null;
218
+ // ⛔ DROPPED ON ITSELF. Without this the id is filtered out, `indexOf` cannot find its own
219
+ // target, and the "not found" branch sends the folder to the END — so releasing a drag over
220
+ // the folder you picked up would quietly move it to the bottom of the rail. Found by this
221
+ // function's own gate the minute the arithmetic left the component; the drop handler's
222
+ // indicator suppresses the same case visually, which is exactly why it would never have
223
+ // been noticed there.
224
+ if (beforeId === draggedId) return null;
225
+ const rest = ids.filter((id) => id !== draggedId);
226
+ const found = beforeId ? rest.indexOf(beforeId) : -1;
227
+ const at = found < 0 ? rest.length : found;
228
+ const next = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
229
+ if (next.length === ids.length && next.every((id, i) => id === ids[i])) return null;
230
+ return next;
231
+ }
232
+
233
  /** A fresh folder id. Client-generated, like every other id in this component. */
234
  export function newFolderId(): string {
235
  const rand =
web/src/customer-grid/liveWorkspace.ts CHANGED
@@ -81,6 +81,52 @@ export function stampTombstone(stamps: Tombstones | undefined, id: string, now:
81
  return pruneTombstones({ ...(stamps ?? {}), [id]: now }, now);
82
  }
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  /**
85
  * Views that have APPEARED on the host since this browser last looked.
86
  *
 
81
  return pruneTombstones({ ...(stamps ?? {}), [id]: now }, now);
82
  }
83
 
84
+ /**
85
+ * ⭐ D-19 (wave 20) — **THE LOCALSTORAGE GHOST.**
86
+ *
87
+ * At init the grid seeded EVERY view from localStorage and then merged the host's list over the
88
+ * top, so a view the host no longer names simply survived — for ever, in that browser. Three
89
+ * ordinary paths produce one: the view was deleted from another tab or another machine, its
90
+ * share was revoked, or the store moved under it. The row keeps working until you click it, and
91
+ * then it is a saved view nobody else can see and no write can reach; the owner reported it as
92
+ * "live and staging disagree". Item 13's pg cutover makes the host list authoritative for real,
93
+ * which turns a rare confusion into a visible one.
94
+ *
95
+ * So: **the host's list decides which views exist.** A local copy the host does not name is
96
+ * dropped — with two guards, and neither is optional:
97
+ *
98
+ * 1. `hostAuthoritative === false` keeps everything. Standalone with no `/workspace` (and the
99
+ * legacy embed) has no host list at all, and "not named" there means "not asked", not
100
+ * "deleted". Dropping on a payload that never carried views would empty the rail.
101
+ * ⛔ **An EMPTY host list counts as not-authoritative for the same reason, and this is the
102
+ * one branch that could destroy data.** A `/workspace` answering `200 {views: []}` is
103
+ * indistinguishable from a store that has not answered yet — a scope whose bucket is
104
+ * briefly empty during item 13's `hf → pg` migration, a fresh backend, a bucket that was
105
+ * never seeded. Dropping there wipes every saved view in that browser, and the persist
106
+ * effect rewrites localStorage immediately after, so there is no second chance. The ghost
107
+ * this exists for is a view missing from a NON-EMPTY list; nothing is lost by refusing to
108
+ * act on no list at all.
109
+ * 2. A view THIS BROWSER wrote inside the echo window survives. A create is optimistic: the
110
+ * row exists locally the instant it is made, and the host cannot name it until its
111
+ * `view_upsert` has been sent AND the next `/workspace` read has come back. Without this
112
+ * guard, creating a view and reloading fast enough would delete it — the exact inverse of
113
+ * the bug, and a worse one.
114
+ *
115
+ * Same window, same reasoning and the same stamp shape as the tombstones above: past
116
+ * ECHO_RECENT_MS, divergence is not an echo.
117
+ */
118
+ export function seedLocalViews(
119
+ localViews: readonly SavedView[],
120
+ hostViews: readonly SavedView[],
121
+ writes: Tombstones | undefined,
122
+ now: number,
123
+ hostAuthoritative: boolean
124
+ ): SavedView[] {
125
+ if (!hostAuthoritative || hostViews.length === 0) return [...localViews];
126
+ const named = new Set(hostViews.map((v) => v.id));
127
+ return localViews.filter((v) => named.has(v.id) || isRecent(writes?.[v.id], now));
128
+ }
129
+
130
  /**
131
  * Views that have APPEARED on the host since this browser last looked.
132
  *
web/src/customer-grid/overlayPlacement.ts CHANGED
@@ -480,9 +480,22 @@ export function computeOverlayPosition(args: {
480
  side = placeBelow ? "below" : "above";
481
  maxHeight = Math.max(1, placeBelow ? below : above);
482
  const unclampedLeft = placement === "bottom-end" ? target.right - width : target.left;
 
 
 
 
 
483
  left = Math.min(Math.max(unclampedLeft, minLeft), maxRight - width);
 
 
 
 
 
 
 
 
484
  top = placeBelow
485
- ? Math.min(target.bottom + gap, maxBottom - maxHeight)
486
  : Math.max(minTop, target.top - gap - Math.min(desiredHeight, maxHeight));
487
  }
488
 
 
480
  side = placeBelow ? "below" : "above";
481
  maxHeight = Math.max(1, placeBelow ? below : above);
482
  const unclampedLeft = placement === "bottom-end" ? target.right - width : target.left;
483
+ // The horizontal clamp needs no `Math.max(minLeft, …)` on its outer bound, unlike the
484
+ // `right-start` branch above: `width` is ALREADY clamped to `maxWidth = maxRight - minLeft`,
485
+ // so `maxRight - width >= minLeft` always holds and the min cannot undo the max. Proven the
486
+ // only way worth proving it — a mutation that removed the "fix" left every leg green, which
487
+ // is what a guard against an impossible state looks like ([[gate-negative-control]]).
488
  left = Math.min(Math.max(unclampedLeft, minLeft), maxRight - width);
489
+ // ⚠ D-20 (wave 20) — `Math.max(minTop, …)` IS load-bearing, and it was missing.
490
+ //
491
+ // The below-branch had no lower bound, so an anchor ABOVE the viewport put the panel above
492
+ // it too — painted, and unreachable. Not hypothetical: it is the null-anchor case D-20 is
493
+ // about (a null anchor degrades to a ZERO rect at the DOCUMENT origin, which is above the
494
+ // viewport whenever `visualViewport` carries an offset — a pinch-zoomed phone), and it is
495
+ // equally a trigger that scrolls out of view with its menu still open. `verify_overlay`'s
496
+ // `below-branch-loses-its-top-clamp` control restages exactly this.
497
  top = placeBelow
498
+ ? Math.max(minTop, Math.min(target.bottom + gap, maxBottom - maxHeight))
499
  : Math.max(minTop, target.top - gap - Math.min(desiredHeight, maxHeight));
500
  }
501
 
web/src/customer-grid/types.ts CHANGED
@@ -963,10 +963,21 @@ export function directionLabel(t: FieldType, dir: "asc" | "desc"): string {
963
  }
964
 
965
  /**
966
- * Groupable = a CLOSED vocabulary (status/select/multiselect/checkbox) or a non-overlay text
967
- * attribute other than the locked identity column (unique per row -> useless as a group key).
968
  * ONE definition, consumed by the toolbar's Group panel and the column menu's "Group by this
969
  * field" alike — two copies of this predicate would drift exactly like operator vocabularies.
 
 
 
 
 
 
 
 
 
 
 
970
  */
971
  export function isGroupableField(field: Field, lockedKey: string): boolean {
972
  return (
@@ -974,7 +985,7 @@ export function isGroupableField(field: Field, lockedKey: string): boolean {
974
  field.type === "select" ||
975
  field.type === "multiselect" ||
976
  field.type === "checkbox" ||
977
- (field.type === "text" && field.source !== "overlay" && field.key !== lockedKey)
978
  );
979
  }
980
 
@@ -1134,6 +1145,19 @@ export interface Field {
1134
  * which is one popover away and is the governed path (CG-8/CG-12's replacement rule).
1135
  */
1136
  measure?: { key: string; window: WindowSpec };
 
 
 
 
 
 
 
 
 
 
 
 
 
1137
  }
1138
 
1139
  /** One data record. Always carries a stable `pid` — the semantic identity that
@@ -1516,6 +1540,144 @@ export function isRuleActive(rule: FilterRule): boolean {
1516
  return rule.value !== "";
1517
  }
1518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1519
  /**
1520
  * CG-8 — a measure the condition builder may offer: FILTERABLE BUT NEVER DISPLAYABLE.
1521
  *
@@ -2382,6 +2544,30 @@ export type HostEvent =
2382
  * global, so an old bundle keeps today's behavior byte-for-byte.
2383
  */
2384
  | { id: string; type: "field_upsert"; field: Field; scope?: FieldScope }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2385
  /**
2386
  * Wave-5 item 1 — Duplicate field (contract AMENDMENT ~20:20: the CLIENT generates the
2387
  * destination `key`, same prefix as `sourceKey` — the established key-generation pattern —
 
963
  }
964
 
965
  /**
966
+ * Groupable = a CLOSED vocabulary (status/select/multiselect/checkbox) or ANY text attribute
967
+ * other than the locked identity column (unique per row -> useless as a group key).
968
  * ONE definition, consumed by the toolbar's Group panel and the column menu's "Group by this
969
  * field" alike — two copies of this predicate would drift exactly like operator vocabularies.
970
+ *
971
+ * ⭐ Wave-20 owner item 11 — `field.source !== "overlay"` is GONE. A user-created text column
972
+ * was the one text field you could not group by, which is backwards: those are the columns
973
+ * holding the reader's OWN vocabulary (a segment, an owner, a call outcome), and they are
974
+ * exactly what a person wants to bucket a table by. The exclusion was never a rule about
975
+ * groupability — the only text field that is useless as a group key is the identity column,
976
+ * and that is tested for on its own.
977
+ *
978
+ * ⚠ Free text does NOT become a closed vocabulary by being grouped: `groupRows` keys on the
979
+ * TRIMMED cell, so "Miami" and "Miami " are one bucket, and blanks fall into the same
980
+ * "(empty)" group every other type uses.
981
  */
982
  export function isGroupableField(field: Field, lockedKey: string): boolean {
983
  return (
 
985
  field.type === "select" ||
986
  field.type === "multiselect" ||
987
  field.type === "checkbox" ||
988
+ (field.type === "text" && field.key !== lockedKey)
989
  );
990
  }
991
 
 
1145
  * which is one popover away and is the governed path (CG-8/CG-12's replacement rule).
1146
  */
1147
  measure?: { key: string; window: WindowSpec };
1148
+ /**
1149
+ * ⭐ Wave-20 R2 / contract C-FIELD — **who may edit THIS COLUMN'S DEFINITION** on a `ut_*`
1150
+ * table, whose fields are the TABLE's schema rather than one user's overlay. `admins` (the
1151
+ * fail-closed default) means the database's creator or an admin; `everyone` opens this one
1152
+ * column without handing over the table.
1153
+ *
1154
+ * ⚠ IT GOVERNS THE SCHEMA, NEVER THE VALUES. `everyone` means anybody with access may rename
1155
+ * the column or change its options — not that anybody may type in its cells. Those are
1156
+ * different questions, and conflating them would turn a column's settings into a
1157
+ * data-permission system nobody wrote (`user_tables._clean_field` says the same thing from
1158
+ * the other side; `verify_grid_ux`'s C-FIELD parity holds the two shapes together).
1159
+ */
1160
+ editRole?: "admins" | "everyone";
1161
  }
1162
 
1163
  /** One data record. Always carries a stable `pid` — the semantic identity that
 
1540
  return rule.value !== "";
1541
  }
1542
 
1543
+ /**
1544
+ * ⭐ Wave-20 owner item 15 (contract C-RENAME) — **THE RENAME MAPPING, BY ROW IDENTITY.**
1545
+ *
1546
+ * Given the option rows as they stand and what each row SAID when the editor opened, which
1547
+ * renames did the user make? A row that kept its id and changed its label was renamed; an id
1548
+ * nobody has seen is a new option; an id that is gone was deleted. That is why the answer is
1549
+ * exact rather than a guess: two lists cannot distinguish "renamed Blue to Navy" from "deleted
1550
+ * Blue, added Navy", and picking wrong empties every cell that said Blue.
1551
+ *
1552
+ * Two cases are deliberately NOT renames:
1553
+ * · a row emptied to "" — a label being retyped is not a rename to nothing;
1554
+ * · a rename ONTO an option that already exists — that is a MERGE, and merging two choices is
1555
+ * a different operation with a different answer for the cells that held either. Refused
1556
+ * here rather than sent as a rename the host would apply as one.
1557
+ *
1558
+ * Lives in types.ts, not beside the editor, for the usual reason: ColumnMenu imports React and
1559
+ * cannot be loaded by a node gate, and this is the half that has to be right.
1560
+ */
1561
+ export function choiceRenames(
1562
+ rows: readonly { id: string; label: string }[],
1563
+ origin: ReadonlyMap<string, string>,
1564
+ savedChoices: readonly string[]
1565
+ ): { from: string; to: string }[] {
1566
+ const existing = new Set(savedChoices.map((o) => o.toLowerCase()));
1567
+ const out: { from: string; to: string }[] = [];
1568
+ for (const row of rows) {
1569
+ const from = origin.get(row.id);
1570
+ const to = row.label.trim();
1571
+ if (!from || !to || from === to) continue;
1572
+ if (existing.has(to.toLowerCase())) continue;
1573
+ out.push({ from, to });
1574
+ }
1575
+ return out;
1576
+ }
1577
+
1578
+ /**
1579
+ * ⭐ Wave-20 owner item 14 — **IS THIS SELECTION EVENT'S ROW SET AUTHORITATIVE, or is it glide
1580
+ * clearing the checkboxes because you clicked a cell?**
1581
+ *
1582
+ * glide's selection is EXCLUSIVE by default: `useSelectionBehavior.setCurrent` (its own
1583
+ * `internal/data-grid/use-selection-behavior.js`) builds every cell-click event with
1584
+ * `rows: CompactSelection.empty()`, and `rowSelectionBlending="mixed"` does NOT change that —
1585
+ * the flag is gated behind `rangeMixable`, which needs `append || trigger === "drag"`, so a
1586
+ * plain click empties the rows whatever props we pass. Translating that emptiness into
1587
+ * `selectedPids` is what un-ticked the checked rows the moment the user clicked another
1588
+ * customer's name: tick four customers for a cohort, click the fifth to read its row, keep one.
1589
+ *
1590
+ * The discriminator is `current`. A ROW-marker interaction comes through `setSelectedRows`,
1591
+ * which sets `current: undefined` in BOTH of its branches; a CELL interaction always carries a
1592
+ * `current`. So:
1593
+ * current === undefined -> a row/marker event (or a genuine clear: Escape, void click) -> ADOPT
1594
+ * rows.length > 0 -> the rows were positively set -> ADOPT (dead under "exclusive",
1595
+ * kept because it is the property meant, not the proxy)
1596
+ * otherwise -> a cell click that emptied the rows -> KEEP the pids
1597
+ *
1598
+ * ⚠ IT LIVES HERE, NOT IN `useGridSelection`, FOR ONE REASON: that module imports glide, and
1599
+ * glide cannot be loaded under node — its CJS build trips over the package's `"type":"module"`
1600
+ * (measured: `ReferenceError: exports is not defined in ES module scope`). A predicate inside it
1601
+ * is therefore ungateable, and there is no vitest here. types.ts imports nothing but `windows`,
1602
+ * so the gate exercises the SHIPPED function rather than a copy ([[gate-negative-control]]).
1603
+ *
1604
+ * ⚠ Typed structurally rather than as glide's `GridSelection` for the same reason — a real
1605
+ * `GridSelection` satisfies it (`CompactSelection` exposes `length`).
1606
+ */
1607
+ export function rowsAuthoritative(sel: {
1608
+ current?: unknown;
1609
+ rows: { length: number };
1610
+ }): boolean {
1611
+ return sel.current === undefined || sel.rows.length > 0;
1612
+ }
1613
+
1614
+ /**
1615
+ * ⭐ Wave-20 owner item 2 — **WHICH COLUMN A FILTER RULE IS ABOUT, and it is not always
1616
+ * `rule.colId`.**
1617
+ *
1618
+ * A FORMULA-MEASURE column (`{measure:{key, window}}`, owner item 7) is `filterable:false`;
1619
+ * its filter REPLACEMENT is a measure CONDITION carrying the same measure and window. That
1620
+ * condition's `colId` is the MEASURE key ("revenue"), while a SORT on the very same column is
1621
+ * written with the COLUMN key (`measure_sales_90d_…`, from `ColumnMenu`'s `onSort`). Two keys,
1622
+ * one column — so every consumer that asked "does the filter tree name this column?" by string
1623
+ * equality answered NO for a column the user had visibly filtered:
1624
+ *
1625
+ * · the tint (`useGridColumns.columnTones`) fell through to the SORT hue, which is owner
1626
+ * item 2 exactly: filter > sort, violated, on the one column class where the two keys
1627
+ * differ. MEASURED — `_qa_wave20_tone.py` reproduced it as the only failing combination
1628
+ * in a 26-check sweep, with the condition provably narrowing 40 rows to 38.
1629
+ * · the column menu's "Don't filter by this field" (`treeNamesField`) never offered itself
1630
+ * on a measure column, and would have dropped nothing if it had.
1631
+ *
1632
+ * ⚠ The window is part of the identity, not decoration. Two columns can display the SAME
1633
+ * measure over different windows; a condition on `last 90 days` is about the 90-day column and
1634
+ * not about the LTM one beside it. `normalizeWindow` first, so a saved view whose window says
1635
+ * `{kind:'last_n_days', n:'90'}` (a string, from JSON) still matches the column that declares
1636
+ * `n: 90`.
1637
+ *
1638
+ * ⚠ NO GUESSING when nothing matches: a measure condition whose measure is not displayed as a
1639
+ * column returns NO keys, so the toolbar chip carries it alone — which is correct and is what
1640
+ * the pre-measure code did by accident. Cohort leaves (`__cohort__`) are deliberately in the
1641
+ * same boat: the derived cohort column's key is chosen HOST-side and the client has no constant
1642
+ * for it, and a tint on the wrong column is worse than no tint.
1643
+ */
1644
+ export function windowKey(spec: unknown): string {
1645
+ const w = normalizeWindow(spec);
1646
+ return w ? `${w.kind}|${w.n ?? ""}|${w.from ?? ""}|${w.to ?? ""}` : "";
1647
+ }
1648
+
1649
+ /** `measureKey|windowKey` -> every column key DISPLAYING that measure over that window
1650
+ * (a duplicated column means two, and both are about the same condition). */
1651
+ export function measureColumnIndex(fields: Field[]): Map<string, string[]> {
1652
+ const out = new Map<string, string[]>();
1653
+ for (const field of fields ?? []) {
1654
+ if (!field.measure) continue;
1655
+ const k = `${field.measure.key}|${windowKey(field.measure.window)}`;
1656
+ const at = out.get(k);
1657
+ if (at) at.push(field.key);
1658
+ else out.set(k, [field.key]);
1659
+ }
1660
+ return out;
1661
+ }
1662
+
1663
+ /**
1664
+ * THE resolution, and the only one any consumer may use: the column keys this rule is about.
1665
+ * A measure condition (the presence of `window` is the discriminator — see FilterRule.window)
1666
+ * resolves through the index; every other rule is about its own `colId`.
1667
+ */
1668
+ export function ruleColumnKeys(
1669
+ rule: FilterRule,
1670
+ measureCols?: Map<string, string[]>
1671
+ ): string[] {
1672
+ // `__cohort__` is a SENTINEL, not a column key. Returned as-is it would put a key in the
1673
+ // tone map that no column can ever match — harmless on screen, but it makes "every key in
1674
+ // this map is a column" false, and that is the invariant the gate checks.
1675
+ if (rule.colId === COHORT_FIELD) return [];
1676
+ if (rule.window)
1677
+ return measureCols?.get(`${rule.colId}|${windowKey(rule.window)}`) ?? [];
1678
+ return [rule.colId];
1679
+ }
1680
+
1681
  /**
1682
  * CG-8 — a measure the condition builder may offer: FILTERABLE BUT NEVER DISPLAYABLE.
1683
  *
 
2544
  * global, so an old bundle keeps today's behavior byte-for-byte.
2545
  */
2546
  | { id: string; type: "field_upsert"; field: Field; scope?: FieldScope }
2547
+ /**
2548
+ * ⭐ Wave-20 owner item 15 / contract C-RENAME — **a choice was RENAMED, and here is the
2549
+ * mapping.** Emitted BESIDE the `field_upsert` that changes the declared list, never instead
2550
+ * of it: the definition and the values are two stores, and only the definition can be written
2551
+ * optimistically.
2552
+ *
2553
+ * The host rewrites (S1's half): every overlay VALUE holding `from`, and every saved view
2554
+ * that names it in a filter or a colour-by. That second half is not tidiness — a view
2555
+ * filtering `Stage is Blue` after Blue became Navy silently matches nothing, which looks
2556
+ * exactly like a view whose records all went away.
2557
+ *
2558
+ * ⚠ EXPLICIT, never a diff. "Renamed Blue to Navy" and "deleted Blue, added Navy" produce
2559
+ * the same pair of lists; the client knows which happened because its option rows carry
2560
+ * stable ids, and this event is how that knowledge crosses the wire (`ColumnMenu`'s
2561
+ * `optionRenames`). One event per SAVE, carrying every rename in it, so the migration is one
2562
+ * pass over the values rather than one per option.
2563
+ */
2564
+ | {
2565
+ id: string;
2566
+ type: "choice_rename";
2567
+ key: string;
2568
+ renames: { from: string; to: string }[];
2569
+ scope?: FieldScope;
2570
+ }
2571
  /**
2572
  * Wave-5 item 1 — Duplicate field (contract AMENDMENT ~20:20: the CLIENT generates the
2573
  * destination `key`, same prefix as `sourceKey` — the established key-generation pattern —
web/src/customer-grid/undoStack.ts ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / undoStack.ts
3
+ // ⭐ Wave-20 owner item 16 (ruling R4, contract C-UNDO) — Ctrl+Z for grid DATA.
4
+ //
5
+ // THE SHAPE, and why it is a module of its own rather than a hook.
6
+ //
7
+ // The contract says "inverse ops recorded at the write layer (optimism.ts /
8
+ // apiBridge.ts)". Neither of those can actually hold it: `optimism.ts` is pure
9
+ // reconciliation with no write functions, and `apiBridge.patchTopicRow` never
10
+ // sees the value a cell held BEFORE the edit — by the time the PATCH is built,
11
+ // the old value is gone. **An inverse can only be captured at the call site**,
12
+ // where both halves are still in hand. So the recording happens in
13
+ // `CustomerGrid.patchAndRecord` (one wrapper over the single `patchOverlay`
14
+ // door every cell write already goes through), and THIS module owns the part
15
+ // that can be reasoned about on its own: the stack, the bounds, the
16
+ // fork-on-new-write rule, and what the inverse of each op IS.
17
+ //
18
+ // It imports nothing. That is deliberate and it is the only way this is
19
+ // gateable: there is no vitest here, gates are `tsc → node` over a `_test/*.ts`,
20
+ // and anything that pulls in React or glide cannot be loaded under node at all
21
+ // (measured: glide's CJS build trips over its own `"type":"module"`).
22
+ //
23
+ // R4 SCOPE — grid DATA only: cell edits, paste, bulk clear, select/multiselect
24
+ // value changes, ut row add/delete. NOT view config (filters, sorts, column
25
+ // widths), NOT field definitions, NOT cohort membership. Those are governed by
26
+ // their own echo/persistence rules and an undo that silently reverted a saved
27
+ // view would be a second writer of the same state.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** What a cell can hold on the wire. `null` is a real value (cleared), not "no entry". */
31
+ export type UndoValue = string | number | null;
32
+
33
+ export interface CellChange {
34
+ pid: number;
35
+ key: string;
36
+ before: UndoValue;
37
+ after: UndoValue;
38
+ }
39
+
40
+ /**
41
+ * One user action. A single entry can carry MANY cells — that is what makes a paste or a
42
+ * bulk Backspace one Ctrl+Z instead of forty (R4: "bulk Backspace = ONE grouped stack entry").
43
+ */
44
+ export type UndoEntry =
45
+ | { kind: "cells"; label: string; changes: CellChange[] }
46
+ /** A ut row that was APPENDED. `values` is what it held, so redo can restore it verbatim. */
47
+ | { kind: "rowAdd"; table: string; rid: string | number; values: Record<string, UndoValue> }
48
+ /** A ut row that was DELETED, with the full row so the undo can put it back under its id. */
49
+ | { kind: "rowDelete"; table: string; rid: string | number; values: Record<string, UndoValue> }
50
+ /** Item 15 — an explicit choice rename; the inverse is the mapping turned around. */
51
+ | { kind: "choiceRename"; fieldKey: string; renames: { from: string; to: string }[] };
52
+
53
+ export interface UndoState {
54
+ past: UndoEntry[];
55
+ future: UndoEntry[];
56
+ }
57
+
58
+ /**
59
+ * The stack's depth. Bounded because every entry holds the values it replaced: a session that
60
+ * pasted a thousand rows would otherwise keep every one of them alive for the life of the tab.
61
+ * Fifty user actions is far past the point where anyone reaches for Ctrl+Z rather than for the
62
+ * value they wanted.
63
+ */
64
+ export const UNDO_MAX = 50;
65
+
66
+ export const EMPTY_UNDO: UndoState = { past: [], future: [] };
67
+
68
+ /**
69
+ * Is this entry worth a stack slot?
70
+ *
71
+ * ⚠ A cell "edit" that changed nothing is the common case, not an edge case: glide commits an
72
+ * edit on every editor close, including one the user opened and left alone, and a bulk clear
73
+ * over rows that were already blank produces a whole selection of them. Recording those makes
74
+ * Ctrl+Z do nothing visible — which reads as "undo is broken" rather than "there was nothing to
75
+ * undo". Compared as STRINGS because the overlay stratum stores strings and a number that has
76
+ * been through the store comes back as one (the `reconcileCellJournal` rule).
77
+ */
78
+ export function meaningful(entry: UndoEntry): boolean {
79
+ if (entry.kind !== "cells") return true;
80
+ return entry.changes.some((c) => String(c.before ?? "") !== String(c.after ?? ""));
81
+ }
82
+
83
+ /** Drop the no-op cells, so an entry that survives is entirely made of real changes. */
84
+ export function pruned(entry: UndoEntry): UndoEntry {
85
+ if (entry.kind !== "cells") return entry;
86
+ return {
87
+ ...entry,
88
+ changes: entry.changes.filter((c) => String(c.before ?? "") !== String(c.after ?? "")),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Record a new action.
94
+ *
95
+ * ⚠ IT CLEARS `future`, and that is the standard undo law rather than a simplification: once
96
+ * you undo three edits and then type something new, the three you undid are no longer reachable
97
+ * — a redo after a fork would apply a change on top of a state it was never computed against.
98
+ */
99
+ export function pushUndo(state: UndoState, entry: UndoEntry): UndoState {
100
+ const clean = pruned(entry);
101
+ if (!meaningful(clean)) return state;
102
+ return { past: [...state.past, clean].slice(-UNDO_MAX), future: [] };
103
+ }
104
+
105
+ /** The entry to apply BACKWARD, and the state that remembers it for redo. */
106
+ export function popUndo(state: UndoState): { state: UndoState; entry: UndoEntry | null } {
107
+ // `[length - 1]`, not `.at(-1)`: the gates compile this module at es2020, where
108
+ // `Array.prototype.at` is not in the lib — and a module a gate cannot compile is a
109
+ // module with no gate.
110
+ const entry = state.past[state.past.length - 1];
111
+ if (!entry) return { state, entry: null };
112
+ return {
113
+ state: { past: state.past.slice(0, -1), future: [...state.future, entry].slice(-UNDO_MAX) },
114
+ entry,
115
+ };
116
+ }
117
+
118
+ /** The entry to apply FORWARD again. */
119
+ export function popRedo(state: UndoState): { state: UndoState; entry: UndoEntry | null } {
120
+ const entry = state.future[state.future.length - 1];
121
+ if (!entry) return { state, entry: null };
122
+ return {
123
+ state: { past: [...state.past, entry].slice(-UNDO_MAX), future: state.future.slice(0, -1) },
124
+ entry,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * The entry as it must be applied in the given direction — the ONE place the inverse of each op
130
+ * is written down.
131
+ *
132
+ * `"back"` is what Ctrl+Z applies; `"forward"` is the redo. For cells the two differ only in
133
+ * which side of the change is written, which is why `CellChange` keeps both rather than storing
134
+ * a "delta" that would have to be re-derived (and could be re-derived wrongly).
135
+ */
136
+ export function directed(
137
+ entry: UndoEntry,
138
+ dir: "back" | "forward"
139
+ ): UndoEntry {
140
+ if (dir === "forward") return entry;
141
+ switch (entry.kind) {
142
+ case "cells":
143
+ return {
144
+ ...entry,
145
+ changes: entry.changes.map((c) => ({ ...c, before: c.after, after: c.before })),
146
+ };
147
+ // Undoing an APPEND deletes the row; undoing a DELETE puts it back. The pair is symmetric,
148
+ // so inverting the kind is the whole of it — and the `values` ride along either way, because
149
+ // a restore needs them and a delete has to keep them for its own undo.
150
+ case "rowAdd":
151
+ return { ...entry, kind: "rowDelete" };
152
+ case "rowDelete":
153
+ return { ...entry, kind: "rowAdd" };
154
+ case "choiceRename":
155
+ return {
156
+ ...entry,
157
+ renames: entry.renames.map((r) => ({ from: r.to, to: r.from })),
158
+ };
159
+ }
160
+ }
161
+
162
+ /** What to tell the user. Short, and always a COUNT — "Undid an edit" on a 40-cell paste is a
163
+ * sentence that makes them check what happened. */
164
+ export function describe(entry: UndoEntry, dir: "back" | "forward"): string {
165
+ const verb = dir === "back" ? "Undid" : "Redid";
166
+ switch (entry.kind) {
167
+ case "cells": {
168
+ const n = entry.changes.length;
169
+ return `${verb} ${n === 1 ? entry.label : `${entry.label} (${n} cells)`}`;
170
+ }
171
+ case "rowAdd":
172
+ return `${verb} adding a record`;
173
+ case "rowDelete":
174
+ return `${verb} deleting a record`;
175
+ case "choiceRename": {
176
+ const n = entry.renames.length;
177
+ return `${verb} renaming ${n === 1 ? "an option" : `${n} options`}`;
178
+ }
179
+ }
180
+ }
181
+
182
+ /** Per-SCOPE stacks in one bag: the customer grid and a user table are different tables, and a
183
+ * Ctrl+Z on one must never rewrite a cell on the other. Per-TAB comes for free — this lives in
184
+ * component state and nothing persists it. */
185
+ export type UndoBook = Record<string, UndoState>;
186
+
187
+ export function stackFor(book: UndoBook, scope: string): UndoState {
188
+ return book[scope] ?? EMPTY_UNDO;
189
+ }
web/src/customer-grid/useGridColumns.ts CHANGED
@@ -1,7 +1,7 @@
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 } 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";
@@ -62,14 +62,22 @@ function hasInfoMark(field: Field): boolean {
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
- function filteredKeys(nodes: FilterNode[], out: Set<string>): void {
 
 
 
 
 
 
 
 
67
  for (const node of nodes ?? []) {
68
  if (isFilterGroup(node)) {
69
- filteredKeys(node.children, out);
70
  continue;
71
  }
72
- if (isRuleActive(node)) out.add(node.colId);
73
  }
74
  }
75
 
@@ -107,12 +115,19 @@ function columnTheme(tone: ControlTone | undefined): Partial<Theme> | undefined
107
  whole-table band; the band was its only consumer, so R5 took both. That left the surviving
108
  precedence — the one that still paints — asserted by nothing, because `verify_icons` was
109
  testing the twin. Exported so the gate can reach the function that is actually on screen. */
110
- export function columnTones(config: ViewConfig): Map<string, ControlTone> {
 
 
 
 
 
 
 
111
  const out = new Map<string, ControlTone>();
112
  if (config.groupBy) out.set(config.groupBy, "group");
113
  for (const s of config.sorts ?? []) out.set(s.colId, "sort");
114
  const filtered = new Set<string>();
115
- filteredKeys(config.filters ?? [], filtered);
116
  for (const key of filtered) out.set(key, "filter");
117
  return out;
118
  }
@@ -199,9 +214,11 @@ export function useGridColumns(
199
  );
200
 
201
  // Item 22 — recomputed only when the three controls move, not on every width drag.
 
 
202
  const tones = useMemo(
203
- () => columnTones(config),
204
- [config.filters, config.sorts, config.groupBy] // eslint-disable-line react-hooks/exhaustive-deps
205
  );
206
 
207
  const visibleCols = useMemo<GridColumn[]>(
 
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";
 
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
 
 
115
  whole-table band; the band was its only consumer, so R5 took both. That left the surviving
116
  precedence — the one that still paints — asserted by nothing, because `verify_icons` was
117
  testing the twin. Exported so the gate can reach the function that is actually on screen. */
118
+ export function columnTones(
119
+ config: ViewConfig,
120
+ /* wave20 item 2 — the FIELDS, because a measure condition names its measure and only the
121
+ field list can say which column displays it. Defaulted so the signature stays callable
122
+ with a config alone (the pre-wave-20 gate legs), and because a caller with no fields
123
+ legitimately has no measure columns to resolve. */
124
+ fields: Field[] = []
125
+ ): Map<string, ControlTone> {
126
  const out = new Map<string, ControlTone>();
127
  if (config.groupBy) out.set(config.groupBy, "group");
128
  for (const s of config.sorts ?? []) out.set(s.colId, "sort");
129
  const filtered = new Set<string>();
130
+ filteredKeys(config.filters ?? [], filtered, measureColumnIndex(fields));
131
  for (const key of filtered) out.set(key, "filter");
132
  return out;
133
  }
 
214
  );
215
 
216
  // Item 22 — recomputed only when the three controls move, not on every width drag.
217
+ // Wave-20 item 2 adds `fields`: a measure condition can only be resolved to the column that
218
+ // displays it, and a new measure column must re-tint without waiting for a control to move.
219
  const tones = useMemo(
220
+ () => columnTones(config, fields),
221
+ [config.filters, config.sorts, config.groupBy, fields] // eslint-disable-line react-hooks/exhaustive-deps
222
  );
223
 
224
  const visibleCols = useMemo<GridColumn[]>(
web/src/customer-grid/useGridSelection.ts CHANGED
@@ -19,6 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
  import { CompactSelection } from "@glideapps/glide-data-grid";
20
  import type { GridSelection, Item } from "@glideapps/glide-data-grid";
21
  import type { VisibleRow } from "./types";
 
22
 
23
  export interface GridSelectionApi {
24
  gridSelection: GridSelection;
@@ -99,6 +100,9 @@ export function useGridSelection(
99
  // follows across row-pipeline rebuilds.
100
  const at = sel.current ? visibleRows[sel.current.cell[1]] : undefined;
101
  currentPidRef.current = at && at.kind === "data" ? at.record.pid : null;
 
 
 
102
  // Translate the new row index-set back to pids (guard synthetic rows).
103
  const pids = new Set<number>();
104
  for (const i of sel.rows) {
 
19
  import { CompactSelection } from "@glideapps/glide-data-grid";
20
  import type { GridSelection, Item } from "@glideapps/glide-data-grid";
21
  import type { VisibleRow } from "./types";
22
+ import { rowsAuthoritative } from "./types";
23
 
24
  export interface GridSelectionApi {
25
  gridSelection: GridSelection;
 
100
  // follows across row-pipeline rebuilds.
101
  const at = sel.current ? visibleRows[sel.current.cell[1]] : undefined;
102
  currentPidRef.current = at && at.kind === "data" ? at.record.pid : null;
103
+ // Owner item 14 — the active cell moves; the CHECKED ROWS do not, unless this event is
104
+ // actually about them. See `rowsAuthoritative`.
105
+ if (!rowsAuthoritative(sel)) return;
106
  // Translate the new row index-set back to pids (guard synthetic rows).
107
  const pids = new Set<number>();
108
  for (const i of sel.rows) {
web/src/customer-grid/useVisibleRows.ts CHANGED
@@ -580,7 +580,12 @@ function groupRows(
580
  };
581
  for (const r of rows) {
582
  const raw = r[groupBy];
583
- const cell = raw == null ? "" : String(raw);
 
 
 
 
 
584
  const keys = isMulti
585
  ? cell.split(",").map((s) => s.trim()).filter((s) => s !== "")
586
  : [cell];
 
580
  };
581
  for (const r of rows) {
582
  const raw = r[groupBy];
583
+ // Wave-20 item 11 TRIMMED. User-created text columns are groupable now, and typed text
584
+ // carries stray spaces: "Miami" and "Miami " are the same bucket to a reader and were two
585
+ // groups here, one of which looked empty-labelled. The multi path below already trimmed its
586
+ // members; this is the single-value half of the same rule. A cell of only spaces trims to
587
+ // "" and joins "(empty)", which is what it looks like on screen.
588
+ const cell = raw == null ? "" : String(raw).trim();
589
  const keys = isMulti
590
  ? cell.split(",").map((s) => s.trim()).filter((s) => s !== "")
591
  : [cell];
web/src/index.css CHANGED
@@ -8199,106 +8199,637 @@ a.cg-map-ctl-b { text-decoration: none; }
8199
  }
8200
  }
8201
  /* == /W19-D == */
8202
-
8203
- /* == EXIT-6 STATEMENTS == */
8204
- /* `settings/StatementsPane.tsx` — the statement-of-account sender, ported off
8205
- `app.py::_collections_statements` when Streamlit was deleted.
8206
-
8207
- ⛔ WHY THE TABLE RULES ARE `stmt-` AND NOT `set-table`. The accounts table was
8208
- removed in wave 17 (R7) and its rules deliberately went with it, with a note
8209
- in this file saying a dead rule with a plausible name is worse than no rule
8210
- because the next thing needing a table adopts decisions nobody made for it.
8211
- This IS that next thing, so it declares its own, named for its one purpose.
8212
-
8213
- Quiet by intent: this pane ends in a control that emails real customers, so
8214
- nothing here competes with the confirm block for attention. */
8215
- .stmt-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 0; }
8216
- .stmt-row .set-input { width: auto; min-width: 180px; flex: 1 1 180px; }
8217
- .stmt-small { margin-top: 6px; }
8218
- .stmt-stack { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
8219
- .stmt-wide { width: 100%; }
8220
- .stmt-link {
8221
- margin-top: 14px;
8222
- padding: 0;
8223
- border: 0;
8224
- background: none;
8225
- color: var(--lp-blue-deep);
8226
- font: inherit;
8227
- font-size: var(--lp-fs-2xs);
8228
- font-weight: 600;
8229
- cursor: pointer;
8230
- text-align: left;
8231
- }
8232
- .stmt-textarea {
8233
- width: 100%;
8234
- padding: 8px 10px;
8235
- border: 1px solid var(--lp-line);
8236
- border-radius: var(--lp-r-sm);
8237
- background: var(--lp-surface);
8238
- color: var(--lp-ink);
8239
- font: inherit;
8240
- font-size: var(--lp-fs-xs);
8241
- line-height: var(--lp-lh);
8242
- resize: vertical;
8243
- }
8244
- /* The worklist scrolls INSIDE its own box. A dunning list is hundreds of rows and
8245
- the settings modal must not grow a second scrollbar for it. */
8246
- .stmt-tablewrap {
8247
- margin-top: 12px;
8248
- max-height: 340px;
8249
- overflow: auto;
8250
- border: 1px solid var(--lp-line);
8251
- border-radius: var(--lp-r-sm);
8252
- }
8253
- .stmt-tablewrap--short { max-height: 200px; }
8254
- .stmt-table { width: 100%; border-collapse: collapse; font-size: var(--lp-fs-2xs); }
8255
- .stmt-table th,
8256
- .stmt-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--lp-line); }
8257
- /* Sticky head: scrolling 400 dunning rows without column labels is unreadable. */
8258
- .stmt-table thead th {
8259
- position: sticky;
8260
- top: 0;
8261
- z-index: 1;
8262
- background: var(--lp-surface-2);
8263
- font-weight: 620;
8264
- }
8265
- .stmt-table tbody tr:last-child td { border-bottom: 0; }
8266
- .stmt-table tbody tr.is-picked { background: var(--lp-blue-tint); }
8267
- /* Money aligns right, everywhere in this product. */
8268
- .stmt-num { text-align: right; font-variant-numeric: tabular-nums; }
8269
- .stmt-table th.stmt-num { text-align: right; }
8270
- /* ⚠ AMBER, NOT `.set-notice` (green) AND NOT `.set-error` (red). The SAFE_MODE
8271
- banner is neither success nor failure — it is a live restriction, and dressing
8272
- a guardrail in green is how somebody reads "ON" as "you are clear to send". */
8273
- .stmt-warn {
8274
- margin: 0 0 12px;
8275
- padding: 9px 12px;
8276
- border-radius: var(--lp-r-sm);
8277
- background: var(--lp-yellow-tint);
8278
- color: var(--lp-yellow-deep);
8279
- font-size: var(--lp-fs-2xs);
8280
- line-height: var(--lp-lh);
8281
- }
8282
- .stmt-details { margin-top: 10px; font-size: var(--lp-fs-2xs); }
8283
- .stmt-details summary { cursor: pointer; font-weight: 600; }
8284
- /* The rendered statement is OUR html from OUR template, but it is still foreign
8285
- markup inside a pane: box it so its own font sizes cannot reflow the modal. */
8286
- .stmt-preview {
8287
- margin-top: 10px;
8288
- padding: 12px;
8289
- max-height: 320px;
8290
- overflow: auto;
8291
- border: 1px solid var(--lp-line);
8292
- border-radius: var(--lp-r-sm);
8293
- background: var(--lp-surface);
8294
- }
8295
- .stmt-preview img { max-width: 100%; }
8296
- /* The confirm block is the loudest thing on the pane, on purpose. */
8297
- .stmt-confirm {
8298
- margin-top: 16px;
8299
- padding: 12px;
8300
- border: 1px solid var(--lp-line);
8301
- border-radius: var(--lp-r-sm);
8302
- background: var(--lp-surface-2);
8303
- }
8304
- .stmt-confirm .stmt-row { margin-top: 12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8199
  }
8200
  }
8201
  /* == /W19-D == */
8202
+
8203
+ /* == EXIT-6 STATEMENTS == */
8204
+ /* `settings/StatementsPane.tsx` — the statement-of-account sender, ported off
8205
+ `app.py::_collections_statements` when Streamlit was deleted.
8206
+
8207
+ ⛔ WHY THE TABLE RULES ARE `stmt-` AND NOT `set-table`. The accounts table was
8208
+ removed in wave 17 (R7) and its rules deliberately went with it, with a note
8209
+ in this file saying a dead rule with a plausible name is worse than no rule
8210
+ because the next thing needing a table adopts decisions nobody made for it.
8211
+ This IS that next thing, so it declares its own, named for its one purpose.
8212
+
8213
+ Quiet by intent: this pane ends in a control that emails real customers, so
8214
+ nothing here competes with the confirm block for attention. */
8215
+ .stmt-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 0; }
8216
+ .stmt-row .set-input { width: auto; min-width: 180px; flex: 1 1 180px; }
8217
+ .stmt-small { margin-top: 6px; }
8218
+ .stmt-stack { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
8219
+ .stmt-wide { width: 100%; }
8220
+ .stmt-link {
8221
+ margin-top: 14px;
8222
+ padding: 0;
8223
+ border: 0;
8224
+ background: none;
8225
+ color: var(--lp-blue-deep);
8226
+ font: inherit;
8227
+ font-size: var(--lp-fs-2xs);
8228
+ font-weight: 600;
8229
+ cursor: pointer;
8230
+ text-align: left;
8231
+ }
8232
+ .stmt-textarea {
8233
+ width: 100%;
8234
+ padding: 8px 10px;
8235
+ border: 1px solid var(--lp-line);
8236
+ border-radius: var(--lp-r-sm);
8237
+ background: var(--lp-surface);
8238
+ color: var(--lp-ink);
8239
+ font: inherit;
8240
+ font-size: var(--lp-fs-xs);
8241
+ line-height: var(--lp-lh);
8242
+ resize: vertical;
8243
+ }
8244
+ /* The worklist scrolls INSIDE its own box. A dunning list is hundreds of rows and
8245
+ the settings modal must not grow a second scrollbar for it. */
8246
+ .stmt-tablewrap {
8247
+ margin-top: 12px;
8248
+ max-height: 340px;
8249
+ overflow: auto;
8250
+ border: 1px solid var(--lp-line);
8251
+ border-radius: var(--lp-r-sm);
8252
+ }
8253
+ .stmt-tablewrap--short { max-height: 200px; }
8254
+ .stmt-table { width: 100%; border-collapse: collapse; font-size: var(--lp-fs-2xs); }
8255
+ .stmt-table th,
8256
+ .stmt-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--lp-line); }
8257
+ /* Sticky head: scrolling 400 dunning rows without column labels is unreadable. */
8258
+ .stmt-table thead th {
8259
+ position: sticky;
8260
+ top: 0;
8261
+ z-index: 1;
8262
+ background: var(--lp-surface-2);
8263
+ font-weight: 620;
8264
+ }
8265
+ .stmt-table tbody tr:last-child td { border-bottom: 0; }
8266
+ .stmt-table tbody tr.is-picked { background: var(--lp-blue-tint); }
8267
+ /* Money aligns right, everywhere in this product. */
8268
+ .stmt-num { text-align: right; font-variant-numeric: tabular-nums; }
8269
+ .stmt-table th.stmt-num { text-align: right; }
8270
+ /* ⚠ AMBER, NOT `.set-notice` (green) AND NOT `.set-error` (red). The SAFE_MODE
8271
+ banner is neither success nor failure — it is a live restriction, and dressing
8272
+ a guardrail in green is how somebody reads "ON" as "you are clear to send". */
8273
+ .stmt-warn {
8274
+ margin: 0 0 12px;
8275
+ padding: 9px 12px;
8276
+ border-radius: var(--lp-r-sm);
8277
+ background: var(--lp-yellow-tint);
8278
+ color: var(--lp-yellow-deep);
8279
+ font-size: var(--lp-fs-2xs);
8280
+ line-height: var(--lp-lh);
8281
+ }
8282
+ .stmt-details { margin-top: 10px; font-size: var(--lp-fs-2xs); }
8283
+ .stmt-details summary { cursor: pointer; font-weight: 600; }
8284
+ /* The rendered statement is OUR html from OUR template, but it is still foreign
8285
+ markup inside a pane: box it so its own font sizes cannot reflow the modal. */
8286
+ .stmt-preview {
8287
+ margin-top: 10px;
8288
+ padding: 12px;
8289
+ max-height: 320px;
8290
+ overflow: auto;
8291
+ border: 1px solid var(--lp-line);
8292
+ border-radius: var(--lp-r-sm);
8293
+ background: var(--lp-surface);
8294
+ }
8295
+ .stmt-preview img { max-width: 100%; }
8296
+ /* The confirm block is the loudest thing on the pane, on purpose. */
8297
+ .stmt-confirm {
8298
+ margin-top: 16px;
8299
+ padding: 12px;
8300
+ border: 1px solid var(--lp-line);
8301
+ border-radius: var(--lp-r-sm);
8302
+ background: var(--lp-surface-2);
8303
+ }
8304
+ .stmt-confirm .stmt-row { margin-top: 12px; }
8305
+
8306
+ /* ==== wave20:S4 shell+rail ====================================================
8307
+ WAVE 20, SESSION S4 — the shell frame and the views rail. Owner items 4, 7,
8308
+ 19, 20, 21, 22, 24 (+ 18/23/25/26 once S1 posts C-SHARE / C-ALERT).
8309
+
8310
+ ⚠ Everything in this block is scoped under a class this session owns, or is a
8311
+ deliberate specificity correction NAMED as one. Nothing here is edited by any
8312
+ other session, and this session edits nothing outside it (the wave-17 scar:
8313
+ four sessions, one stylesheet, line numbers that moved under each other).
8314
+ ============================================================================ */
8315
+
8316
+ /* ── Item 24 — a primary CTA that is only primary ON HOVER ──────────────────
8317
+ `.shell-newdb-actions button` (0,1,1) OUT-RANKS `.login-submit` (0,1,0), so
8318
+ every primary button inside a shell dialog rendered white-on-ink at REST and
8319
+ only turned purple under the pointer — the AI-assistant note's Close (owner
8320
+ item 24) and, from the same cause, the New-database dialog's "Create
8321
+ database". The fix is the specificity, not a new colour: this rule is (0,2,0),
8322
+ so it wins at rest, while `.login-submit:hover:not(:disabled)` (0,3,0) and
8323
+ `:focus-visible` still win over it — the button keeps ONE hover shade and one
8324
+ focus ring, published once at `.login-submit`.
8325
+ Tokens, never literals: `--shell-on-solid` is the MEASURED white for text on
8326
+ `--lp-primary` (7.71:1), declared by `.login-submit` itself and inherited here
8327
+ rather than re-typed as `#fff`. The row's unclassed buttons (Cancel) are
8328
+ untouched — a dialog has one primary. */
8329
+ .shell-newdb-actions .login-submit {
8330
+ color: var(--shell-on-solid);
8331
+ background: var(--lp-primary);
8332
+ border-color: var(--lp-primary);
8333
+ }
8334
+
8335
+ /* ── Item 21 — the folder row's "…" is ALWAYS visible ───────────────────────
8336
+ `.cg-view-more` ships `opacity: 0` and is revealed by `.cg-view-row:hover` /
8337
+ `.is-active`. A FOLDER head is not a `.cg-view-row`, so no rule ever revealed
8338
+ it: the folder's actions button was painted at zero opacity in every state but
8339
+ keyboard focus — present, clickable, invisible ([[ui-invisible-to-assertions]]
8340
+ in reverse). The count badge that sat beside it is deleted in the markup; this
8341
+ makes the control it was standing in front of actually appear. (0,2,0) over
8342
+ the base rule's (0,1,0). */
8343
+ .cg-fold-head .cg-view-more { opacity: 1; }
8344
+
8345
+ /* ── Item 22 — the folder menu's ARMED delete may use two lines ─────────────
8346
+ The one stated exception to R7's "a menu row is ONE LINE". Every other row in
8347
+ both menus is closed vocabulary that fits; this one is a sentence
8348
+ ("Delete folder? Its views move to the top level."), and under `nowrap` with a
8349
+ 260px panel it ran outside the box. Wrapping is the only option that keeps a
8350
+ confirmation readable — an ellipsised confirm asks the reader to agree to a
8351
+ sentence they cannot finish. Armed only, so the resting menu is unchanged. */
8352
+ .cg-view-menu button.is-armed { white-space: normal; }
8353
+ .cg-view-menu button.is-armed .cg-mi-text { overflow: visible; text-overflow: clip; }
8354
+
8355
+ /* ── Item 20 — the create flyout's SECOND step, in the SAME box ─────────────
8356
+ Step 2 (name + who-can-edit) now renders inside the same `AnchoredOverlay` as
8357
+ the type chooser. `.cg-view-create` carries its own border, background and
8358
+ margins for the rail-inline placement it used to have; inside the panel that
8359
+ would draw a second box inside the first. Geometry only — the form's own
8360
+ controls are unchanged, which is the point of the item. */
8361
+ .cg-create-flyout .cg-create-form {
8362
+ margin: 0;
8363
+ padding: 2px;
8364
+ border: 0;
8365
+ background: transparent;
8366
+ }
8367
+ .cg-create-flyout .cg-create-form > label:first-child {
8368
+ display: block;
8369
+ margin-bottom: 4px;
8370
+ font-size: var(--lp-fs-3xs);
8371
+ font-weight: 600;
8372
+ color: var(--lp-muted);
8373
+ }
8374
+ /* ⚠ THE PANEL IS A MENU, AND `.cg-view-menu button` (0,1,1) OUT-RANKS `.cg-btn` (0,1,0).
8375
+ Moving the form inside the flyout therefore repainted its Create/Cancel as full-width,
8376
+ left-aligned, borderless MENU ROWS — the panel's own row style applied to a form's
8377
+ buttons. Caught by READING the screenshot: every assertion about the step (geometry,
8378
+ focus, labels) was green while the two buttons looked like list items
8379
+ ([[ui-invisible-to-assertions]]). These rules restate `.cg-btn`'s own declarations at a
8380
+ specificity that wins inside the panel; they are a copy of that rule, never a second
8381
+ opinion about what a button looks like. */
8382
+ .cg-create-flyout .cg-create-form .cg-btn {
8383
+ width: auto;
8384
+ min-height: 28px;
8385
+ padding: 0 9px;
8386
+ border: 1px solid var(--lp-line);
8387
+ border-radius: var(--lp-r-md);
8388
+ background: var(--cg-white);
8389
+ color: var(--cg-text);
8390
+ font-size: var(--lp-fs-2xs);
8391
+ text-align: center;
8392
+ }
8393
+ .cg-create-flyout .cg-create-form .cg-btn:hover:not(:disabled) { background: var(--cg-soft); }
8394
+ .cg-create-flyout .cg-create-form .cg-btn--primary {
8395
+ border-color: var(--lp-primary);
8396
+ background: var(--lp-primary);
8397
+ color: var(--cg-white);
8398
+ }
8399
+ .cg-create-flyout .cg-create-form .cg-btn--primary:hover:not(:disabled) {
8400
+ background: var(--lp-primary-hover);
8401
+ }
8402
+ /* The who-can-edit rows are `<label>`s, and `.cg-view-create label` (0,1,1) stacks every
8403
+ label in a create form into a COLUMN — so the radio sat above its own words rather than
8404
+ beside them. Pre-dates this wave (the same two rules met in the rail-inline form), but
8405
+ item 20 makes these three rows the whole content of a small panel, where a radio floating
8406
+ over its label is the first thing you see. Scoped to the flyout: nothing else that reads
8407
+ `.cg-view-create label` moves. */
8408
+ .cg-create-flyout .cg-perm-row {
8409
+ flex-direction: row;
8410
+ align-items: flex-start;
8411
+ gap: 9px;
8412
+ }
8413
+
8414
+ /* ── Item 19 (C-FOLDER-REORDER) — dragging a FOLDER to reorder ──────────────
8415
+ A private drag MIME (`application/x-loopable-fold`) keeps this apart from the
8416
+ view drag, which carries `text/plain`; the indicator is an insertion RULE at
8417
+ the top edge of the folder you would land above, never a fill, because a
8418
+ filled folder already means "drop this view INTO me" (`.is-drop`). */
8419
+ .cg-fold.is-folddrag,
8420
+ .cg-fold-root.is-folddrag { opacity: 0.45; }
8421
+ .cg-fold.is-drop-above,
8422
+ .cg-fold-root.is-drop-above { box-shadow: inset 0 2px 0 0 var(--lp-primary); }
8423
+ /* ⛔ NO `.cg-fold-grip` RULE, because there is no grip — see the note at the folder head in
8424
+ ViewSidebar.tsx. A handle in flow moves the folder name off the measured indent this rail
8425
+ spent wave-10 item 2 getting right, and the gutter it could hide in belongs to the
8426
+ disclosure chevron. The head drags whole, exactly as the view rows under it already do. */
8427
+
8428
+ /* ── Item 4 (R8 / C-ADDROW) — the UNIVERSAL database header ─────────────────
8429
+ Every database wears the same header: a chip in a bold colour with a white
8430
+ mark, then the database's name (`reference/Airtable 6.png`). It replaces the
8431
+ `shell-ut-bar`, which existed on user tables ONLY and carried the "Add record"
8432
+ button that S3's trailing "+" row supersedes.
8433
+ ⚠ THE HEIGHT CHAIN IS LOAD-BEARING: glide measures its parent, so a frame that
8434
+ forgets `min-height: 0` or `flex: 1 1 auto` gives the canvas 0px and paints a
8435
+ blank grid that looks like a working page. These three rules are the wave-18
8436
+ `.shell-ut-frame` chain, kept intact rather than re-derived. */
8437
+ .shell-db-frame {
8438
+ display: flex;
8439
+ flex-direction: column;
8440
+ width: 100%;
8441
+ height: 100%;
8442
+ min-height: 0;
8443
+ overflow: hidden;
8444
+ }
8445
+ .shell-db-frame > .shell-grid-host { flex: 1 1 auto; min-height: 0; height: auto; }
8446
+ .shell-db-head {
8447
+ display: flex;
8448
+ align-items: center;
8449
+ gap: 10px;
8450
+ flex: 0 0 auto;
8451
+ padding: 9px 16px;
8452
+ border-bottom: 1px solid var(--lp-line);
8453
+ background: var(--lp-surface);
8454
+ }
8455
+ /* The chip is the one place in the product that paints a mark ON a solid tone
8456
+ rather than beside one. Every pairing below is white on a `-deep` token, whose
8457
+ measured ratio is published beside its declaration: primary 7.71:1, ink
8458
+ 15.42:1, yellow 5.61:1, green 5.52:1, red 5.35:1, blue 3.29:1 — the last is
8459
+ under the 4.5:1 TEXT bar and over the 3:1 bar for a graphical object, which is
8460
+ what a 16px glyph is. */
8461
+ .shell-db-chip {
8462
+ display: inline-flex;
8463
+ align-items: center;
8464
+ justify-content: center;
8465
+ flex: 0 0 auto;
8466
+ width: 26px;
8467
+ height: 26px;
8468
+ border-radius: var(--lp-r-md);
8469
+ background: var(--lp-primary);
8470
+ color: var(--shell-on-solid, #ffffff);
8471
+ }
8472
+ .shell-db-chip--neutral { background: var(--lp-ink); }
8473
+ .shell-db-chip--blue { background: var(--lp-blue-deep); }
8474
+ .shell-db-chip--green { background: var(--lp-green-deep); }
8475
+ .shell-db-chip--yellow { background: var(--lp-yellow-deep); }
8476
+ .shell-db-chip--red { background: var(--lp-red-deep); }
8477
+ /* `.shell-nav-icon` dims to 0.55 for the rail; on a solid chip that reads as a
8478
+ half-erased glyph. And `FolderMark` paints its own `-deep` stroke — legible on
8479
+ the white rail, invisible on its own tone — so inside the chip the mark takes
8480
+ the chip's ink, which is the measured white above. */
8481
+ .shell-db-chip .shell-nav-icon { opacity: 1; }
8482
+ .shell-db-chip .cg-folder-mark path { stroke: currentColor; }
8483
+ /* An `h1` — so the UA's own heading margins and 2em size have to be reset here, or the
8484
+ header row grows to 60px and the name lands off the chip's centre line. */
8485
+ .shell-db-name {
8486
+ margin: 0;
8487
+ min-width: 0;
8488
+ overflow: hidden;
8489
+ font-size: var(--lp-fs-md);
8490
+ font-weight: 650;
8491
+ color: var(--lp-ink);
8492
+ text-overflow: ellipsis;
8493
+ white-space: nowrap;
8494
+ }
8495
+
8496
+ /* ── Items 18 / 23 / 26 (R10, C-SHARE) — the access editor ──────────────────
8497
+ One dialog for a view, a folder and a database, in the shell's existing dialog
8498
+ family (`.shell-newdb`'s box, so a third modal does not introduce a third look).
8499
+ The rail's second mark is `.cg-view-shared`, mirroring `.cg-view-lock` token for
8500
+ token — they sit side by side on the same row and must read as one family. */
8501
+ .cg-view-shared {
8502
+ flex: 0 0 auto;
8503
+ display: inline-flex;
8504
+ align-items: center;
8505
+ margin-left: auto;
8506
+ padding-left: 4px;
8507
+ color: var(--lp-muted);
8508
+ }
8509
+ /* ⚠ When BOTH marks are present the second one must not claim the auto margin as
8510
+ well, or the pair splits across the row with the name stranded between them. */
8511
+ .cg-view-shared + .cg-view-lock { margin-left: 0; }
8512
+ .shell-share { width: 460px; }
8513
+ /* The one-line answer, above the rows that spell it out. Muted and small: it
8514
+ REPEATS what the list says, so it must not compete with it — its whole value is
8515
+ being readable before the eye reaches the rows. */
8516
+ .shell-share-summary {
8517
+ margin: 14px 0 0;
8518
+ color: var(--lp-muted);
8519
+ font-size: var(--lp-fs-2xs);
8520
+ font-weight: 600;
8521
+ }
8522
+ .shell-share-summary + .shell-share-list { margin-top: 6px; }
8523
+ .shell-share-wait { display: flex; justify-content: center; padding: 18px 0; }
8524
+ .shell-share-list {
8525
+ margin-top: 14px;
8526
+ border: 1px solid var(--lp-line);
8527
+ border-radius: var(--lp-r-md);
8528
+ overflow: hidden;
8529
+ }
8530
+ .shell-share-row {
8531
+ display: flex;
8532
+ align-items: center;
8533
+ gap: 8px;
8534
+ padding: 7px 10px;
8535
+ border-bottom: 1px solid var(--lp-line);
8536
+ font-size: var(--lp-fs-xs);
8537
+ }
8538
+ .shell-share-row:last-child { border-bottom: 0; }
8539
+ .shell-share-row.is-owner { background: var(--lp-wash); }
8540
+ .shell-share-who {
8541
+ flex: 1 1 auto;
8542
+ min-width: 0;
8543
+ overflow: hidden;
8544
+ color: var(--lp-ink);
8545
+ text-overflow: ellipsis;
8546
+ white-space: nowrap;
8547
+ }
8548
+ .shell-share-role { flex: 0 0 auto; color: var(--lp-muted); font-size: var(--lp-fs-2xs); }
8549
+ .shell-share-empty {
8550
+ padding: 10px;
8551
+ color: var(--lp-muted);
8552
+ font-size: var(--lp-fs-2xs);
8553
+ }
8554
+ .shell-share-select {
8555
+ flex: 0 0 auto;
8556
+ max-width: 190px;
8557
+ padding: 4px 6px;
8558
+ border: 1px solid var(--lp-line);
8559
+ border-radius: var(--lp-r-sm);
8560
+ background: var(--lp-surface);
8561
+ color: var(--lp-ink);
8562
+ font: inherit;
8563
+ font-size: var(--lp-fs-2xs);
8564
+ }
8565
+ .shell-share-select:disabled { opacity: 0.55; }
8566
+ .shell-share-revoke {
8567
+ flex: 0 0 auto;
8568
+ border: 0;
8569
+ border-radius: var(--lp-r-sm);
8570
+ padding: 4px 6px;
8571
+ background: transparent;
8572
+ color: var(--lp-red-deep);
8573
+ font: inherit;
8574
+ font-size: var(--lp-fs-2xs);
8575
+ cursor: pointer;
8576
+ }
8577
+ .shell-share-revoke:hover:not(:disabled) { background: var(--lp-red-tint); }
8578
+ .shell-share-revoke:disabled { opacity: 0.45; cursor: not-allowed; }
8579
+ .shell-share-add {
8580
+ display: flex;
8581
+ flex-wrap: wrap;
8582
+ align-items: center;
8583
+ gap: 8px;
8584
+ margin-top: 12px;
8585
+ }
8586
+ /* `.login-submit` is `display:block; width:100%` — right for a form's one button,
8587
+ wrong for the third control in a row. Same reset `.shell-hero-create` takes. */
8588
+ .shell-share-grant {
8589
+ display: inline-block;
8590
+ width: auto;
8591
+ height: auto;
8592
+ margin-top: 0;
8593
+ padding: 6px 12px;
8594
+ }
8595
+ .shell-share-blurb,
8596
+ .shell-share-note {
8597
+ flex: 1 1 100%;
8598
+ margin: 2px 0 0;
8599
+ color: var(--lp-muted);
8600
+ font-size: var(--lp-fs-2xs);
8601
+ line-height: 1.4;
8602
+ }
8603
+ .shell-share-note { margin-top: 12px; }
8604
+
8605
+ /* ── Item 25 (C-ALERT) — the Alerts row, its badge, and the inbox ───────────
8606
+ The row is a `<button>` wearing `.shell-nav-item`, so the UA's own button
8607
+ chrome has to go or it renders as a grey box in a rail of links (the wave-19
8608
+ R11 complaint, in the one place a nav row is not an anchor). */
8609
+ .shell-nav-alerts {
8610
+ width: 100%;
8611
+ border: 0;
8612
+ background: transparent;
8613
+ font: inherit;
8614
+ text-align: left;
8615
+ cursor: pointer;
8616
+ }
8617
+ .shell-nav-badge {
8618
+ flex: 0 0 auto;
8619
+ margin-left: auto;
8620
+ min-width: 18px;
8621
+ padding: 1px 5px;
8622
+ border-radius: var(--lp-r-pill);
8623
+ background: var(--lp-primary);
8624
+ color: var(--shell-on-solid, #ffffff);
8625
+ font-size: var(--lp-fs-4xs);
8626
+ font-weight: 600;
8627
+ font-variant-numeric: tabular-nums;
8628
+ text-align: center;
8629
+ }
8630
+ /* Collapsed, the label is gone and the badge would sit alone in a 56px strip
8631
+ pretending to be the icon. It rides the mark's top-right corner instead. */
8632
+ .shell-side.is-collapsed .shell-nav-badge {
8633
+ position: absolute;
8634
+ top: 2px;
8635
+ right: 6px;
8636
+ margin-left: 0;
8637
+ }
8638
+ .shell-side.is-collapsed .shell-nav-alerts { position: relative; }
8639
+ .alerts-pane {
8640
+ width: 520px;
8641
+ max-width: calc(100vw - 48px);
8642
+ max-height: 76vh;
8643
+ display: flex;
8644
+ flex-direction: column;
8645
+ background: var(--lp-surface);
8646
+ border: 1px solid var(--lp-line);
8647
+ border-radius: var(--lp-r-lg);
8648
+ box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
8649
+ overflow: hidden;
8650
+ }
8651
+ .alerts-head {
8652
+ display: flex;
8653
+ align-items: center;
8654
+ gap: 10px;
8655
+ padding: 16px 20px 10px;
8656
+ }
8657
+ .alerts-head h2 {
8658
+ flex: 1 1 auto;
8659
+ display: flex;
8660
+ align-items: center;
8661
+ gap: 8px;
8662
+ margin: 0;
8663
+ font-size: var(--lp-fs-md);
8664
+ font-weight: 650;
8665
+ color: var(--lp-ink);
8666
+ }
8667
+ .alerts-bell {
8668
+ width: 18px;
8669
+ height: 18px;
8670
+ fill: none;
8671
+ stroke: currentColor;
8672
+ stroke-width: 1.3;
8673
+ stroke-linecap: round;
8674
+ stroke-linejoin: round;
8675
+ }
8676
+ .alerts-markall {
8677
+ flex: 0 0 auto;
8678
+ border: 1px solid var(--lp-line);
8679
+ border-radius: var(--lp-r-sm);
8680
+ padding: 4px 9px;
8681
+ background: var(--lp-surface);
8682
+ color: var(--lp-ink);
8683
+ font: inherit;
8684
+ font-size: var(--lp-fs-2xs);
8685
+ cursor: pointer;
8686
+ }
8687
+ .alerts-markall:hover:not(:disabled) { background: var(--lp-wash); }
8688
+ .alerts-markall:disabled { opacity: 0.45; cursor: not-allowed; }
8689
+ .alerts-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 0 20px; }
8690
+ .alerts-empty {
8691
+ margin: 8px 0 16px;
8692
+ color: var(--lp-muted);
8693
+ font-size: var(--lp-fs-2xs);
8694
+ line-height: 1.5;
8695
+ }
8696
+ .alerts-list { list-style: none; margin: 0; padding: 0; }
8697
+ .alerts-row {
8698
+ display: flex;
8699
+ align-items: center;
8700
+ gap: 8px;
8701
+ border-bottom: 1px solid var(--lp-line);
8702
+ }
8703
+ .alerts-row-main {
8704
+ flex: 1 1 auto;
8705
+ min-width: 0;
8706
+ display: flex;
8707
+ flex-direction: column;
8708
+ gap: 2px;
8709
+ border: 0;
8710
+ background: transparent;
8711
+ padding: 9px 4px;
8712
+ font: inherit;
8713
+ text-align: left;
8714
+ cursor: pointer;
8715
+ }
8716
+ .alerts-row-label {
8717
+ overflow: hidden;
8718
+ color: var(--lp-ink);
8719
+ font-size: var(--lp-fs-xs);
8720
+ text-overflow: ellipsis;
8721
+ white-space: nowrap;
8722
+ }
8723
+ /* The unread mark is WEIGHT plus a dot, never colour alone — the row already
8724
+ carries two colours' worth of meaning and a third would need explaining. */
8725
+ .alerts-row.is-unread .alerts-row-label { font-weight: 650; }
8726
+ .alerts-row.is-unread .alerts-row-label::before {
8727
+ content: "";
8728
+ display: inline-block;
8729
+ width: 6px;
8730
+ height: 6px;
8731
+ margin-right: 7px;
8732
+ border-radius: 50%;
8733
+ background: var(--lp-primary);
8734
+ vertical-align: middle;
8735
+ }
8736
+ .alerts-row-meta { color: var(--lp-muted); font-size: var(--lp-fs-3xs); }
8737
+ .alerts-row-toggle {
8738
+ flex: 0 0 auto;
8739
+ border: 0;
8740
+ border-radius: var(--lp-r-sm);
8741
+ padding: 4px 7px;
8742
+ background: transparent;
8743
+ color: var(--lp-muted);
8744
+ font: inherit;
8745
+ font-size: var(--lp-fs-3xs);
8746
+ cursor: pointer;
8747
+ }
8748
+ .alerts-row-toggle:hover { background: var(--lp-wash); color: var(--lp-ink); }
8749
+ .alerts-watching { margin: 18px 0 10px; }
8750
+ .alerts-watching h3 {
8751
+ margin: 0 0 6px;
8752
+ color: var(--lp-muted);
8753
+ font-size: var(--lp-fs-3xs);
8754
+ font-weight: 600;
8755
+ }
8756
+ .alerts-watch-row {
8757
+ display: flex;
8758
+ align-items: center;
8759
+ gap: 8px;
8760
+ padding: 6px 0;
8761
+ border-top: 1px solid var(--lp-line);
8762
+ font-size: var(--lp-fs-2xs);
8763
+ }
8764
+ .alerts-watch-label {
8765
+ flex: 1 1 auto;
8766
+ min-width: 0;
8767
+ overflow: hidden;
8768
+ color: var(--lp-ink);
8769
+ text-overflow: ellipsis;
8770
+ white-space: nowrap;
8771
+ }
8772
+ .alerts-watch-meta { flex: 0 0 auto; color: var(--lp-muted); font-size: var(--lp-fs-3xs); }
8773
+ .alerts-watch-run,
8774
+ .alerts-watch-del {
8775
+ flex: 0 0 auto;
8776
+ border: 0;
8777
+ border-radius: var(--lp-r-sm);
8778
+ padding: 3px 7px;
8779
+ background: transparent;
8780
+ color: var(--lp-muted);
8781
+ font: inherit;
8782
+ font-size: var(--lp-fs-3xs);
8783
+ cursor: pointer;
8784
+ }
8785
+ .alerts-watch-run:hover:not(:disabled) { background: var(--lp-wash); color: var(--lp-ink); }
8786
+ .alerts-watch-del { color: var(--lp-red-deep); }
8787
+ .alerts-watch-del:hover:not(:disabled) { background: var(--lp-red-tint); }
8788
+ .alerts-foot { padding: 10px 20px 16px; margin-top: 0; }
8789
+
8790
+ /* ── Item 7 (R9) — the collapsed rail expands from its own background ───────
8791
+ The strip's blank area is now a click target, so it says so. Interactive
8792
+ children keep their own cursor (the UA default for a link/button is already
8793
+ `pointer`), and `.shell-brand-btn` keeps the explicit one it had. */
8794
+ .shell-side.is-collapsed { cursor: pointer; }
8795
+
8796
+ /* ==== /wave20:S4 ==== */
8797
+
8798
+ /* ==== wave20:S3 grid =========================================================
8799
+ S3's region. Everything below belongs to the grid surface (customer-grid/,
8800
+ ui/, filter-kit/). Comment-anchored, never line-numbered: index.css is shared
8801
+ with S1/S4 this wave and is staged LAST by the integrator.
8802
+ ============================================================================= */
8803
+
8804
+ /* Owner item 5 — THE ADD-FIELD "+" HUGS THE LAST COLUMN.
8805
+ glide lays its `rightElement` out as [ .dvn-stack (the columns) | .dvn-spacer |
8806
+ the element ], and its own rule `… .dvn-scroll-inner .dvn-spacer { flex-grow: 1 }`
8807
+ makes that spacer eat every pixel between the last column and the right edge of the
8808
+ scroller. Unpinning the wrapper (`rightElementProps.sticky = false`) is therefore only
8809
+ half the fix: without this the button lands back on the window edge, one flex rule later.
8810
+
8811
+ ⚠ THE DOUBLED CLASS IS DELIBERATE. glide's selector is `.gdg-… .dvn-scroll-inner
8812
+ .dvn-spacer` — three classes, exactly the specificity of the obvious `.cg-grid-box
8813
+ .dvn-scroll-inner .dvn-spacer`, and at a tie the LATER rule wins. Which of the two
8814
+ stylesheets lands later is a bundler ordering detail (glide's CSS is imported from
8815
+ CustomerGrid.tsx, ours from main.tsx), and this project has already lost one rule to
8816
+ exactly that coin flip ([[loopable-nav-logo-toggle]]). `.cg-grid-box.cg-grid-box`
8817
+ repeats a class that is genuinely on the element, costs nothing, and wins on
8818
+ specificity rather than on order. */
8819
+ .cg-grid-box.cg-grid-box .dvn-scroll-inner .dvn-spacer { flex-grow: 0; }
8820
+
8821
+ /* Owner item 9 — the count each row of the remove picker would take out ("3 of 4"). Pushed to
8822
+ the trailing edge with `margin-left: auto` rather than a spacer element, and allowed to
8823
+ shrink last: the cohort NAME is what the reader is choosing between, so it keeps the
8824
+ ellipsis budget (`.cg-pick-row` above) and this never wraps. */
8825
+ .cg-pick-hint {
8826
+ margin-left: auto;
8827
+ padding-left: 10px;
8828
+ color: var(--lp-muted);
8829
+ font-size: var(--lp-fs-2xs);
8830
+ font-variant-numeric: tabular-nums;
8831
+ white-space: nowrap;
8832
+ flex: 0 0 auto;
8833
+ }
8834
+
8835
+ /* ==== /wave20:S3 ==== */
web/src/settings/AdminPane.tsx CHANGED
@@ -1,568 +1,646 @@
1
- // ---------------------------------------------------------------------------
2
- // settings / AdminPane.tsx — THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 /
3
- // R3+R4, contract C2).
4
- //
5
- // The one surface in this product that looks ACROSS tenants: who our customers
6
- // are, how many people use each workspace, what they have built in it, whether
7
- // their data sources are alive, and what the automation fleet costs.
8
- //
9
- // SELF-CONTAINED BY CONTRACT (C2). It fetches its own data via
10
- // `platformAdminApi` and takes ONE prop, so mounting it is a rail entry plus a
11
- // line in the pane switch — `SettingsModal.tsx` is another session's file this
12
- // wave and must not have to learn anything about this pane's data.
13
- //
14
- // ⚠ THE PROP IS TYPED STRUCTURALLY, not as `SessionUser` from `../shell/session`.
15
- // The mount site passes the shell's session user (a wider object), which
16
- // satisfies this shape by structural typing — and this pane therefore imports
17
- // nothing from the shell, which is what the wave's cross-fence rule asks for
18
- // while `session.ts` is open on another desk.
19
- //
20
- // ⛔ EVERY NUMBER HERE IS THE SERVER'S, AND EVERY NUMBER DRILLS. Clicking a count
21
- // opens the rows it was computed from — the same collector, projected twice
22
- // ([[no-unverifiable-aggregates]]). A count that could not be READ renders as an
23
- // em dash with the reason, never as a zero: "this customer has no databases" and
24
- // "we could not look" are different facts and the pane refuses to conflate them.
25
- //
26
- // DESIGN: sentence-case micro-labels (R6 — no caps in app chrome), short noun
27
- // headers, tabular figures right-aligned, hairlines not shadows, tokens only,
28
- // no emojis (DESIGN.md §2–§4).
29
- // ---------------------------------------------------------------------------
30
-
31
- import { useCallback, useEffect, useState } from "react";
32
- import type {
33
- AutomationRow,
34
- ConnectorRow,
35
- DatabaseRow,
36
- FleetCost,
37
- Overview,
38
- PlatformUser,
39
- } from "./platformAdminApi";
40
- import {
41
- getAutomations,
42
- getAws,
43
- getConnectors,
44
- getDatabases,
45
- getOverview,
46
- getUsers,
47
- } from "./platformAdminApi";
48
-
49
- // --- formatting -------------------------------------------------------------
50
-
51
- const int = (n: number) => n.toLocaleString();
52
-
53
- /** A count that may be unknown. `null` is NOT zero — see the api module's header. */
54
- function Count({ n, why }: { n: number | null | undefined; why?: string }) {
55
- if (n === null || n === undefined)
56
- return (
57
- <span className="padmin-unknown" title={why || "This could not be read just now"}>
58
-
59
- </span>
60
- );
61
- return <>{int(n)}</>;
62
- }
63
-
64
- /** "3 days ago" for a stamp, "never" for an absent one. Never a fabricated date.
65
- *
66
- * ONLY FOR OFFSET-BEARING STAMPS. `Date.parse` reads a stamp with no zone as
67
- * BROWSER-LOCAL, so running a container-local timestamp through this reports the
68
- * viewer's UTC offset as elapsed time hours of error in a column people scan,
69
- * and a future-dated stamp that renders as "just now" indefinitely. Everything
70
- * this pane passes here (`last_login`, `last_active`, `generatedAt`) is written
71
- * with an explicit offset; the automation engine's `lastRunAt` is NOT, so it is
72
- * rendered verbatim instead. */
73
- function ago(stamp: string): string {
74
- if (!stamp) return "never";
75
- const t = Date.parse(stamp.includes("T") ? stamp : stamp.replace(" ", "T"));
76
- if (Number.isNaN(t)) return stamp;
77
- const mins = Math.floor((Date.now() - t) / 60000);
78
- if (mins < 2) return "just now";
79
- if (mins < 60) return `${mins} minutes ago`;
80
- const hours = Math.floor(mins / 60);
81
- if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
82
- const days = Math.floor(hours / 24);
83
- if (days < 60) return `${days} day${days === 1 ? "" : "s"} ago`;
84
- return `${Math.floor(days / 30)} months ago`;
85
- }
86
-
87
- type DrillKind = "users" | "databases" | "connectors" | "automations";
88
-
89
- const DRILL_NOUN: Record<DrillKind, string> = {
90
- users: "People",
91
- databases: "Databases",
92
- connectors: "Data sources",
93
- automations: "Automations",
94
- };
95
-
96
- /** The loading shape — a skeleton, not a spinner (DESIGN.md §4). */
97
- function Skeleton({ rows = 3 }: { rows?: number }) {
98
- return (
99
- <div className="padmin-skel" aria-hidden="true">
100
- {Array.from({ length: rows }, (_, i) => (
101
- <div key={i} className="padmin-skel-row" />
102
- ))}
103
- </div>
104
- );
105
- }
106
-
107
- // --- the pane ---------------------------------------------------------------
108
-
109
- export function AdminPane({ user }: { user: { username: string; name: string; role: string } }) {
110
- const [ov, setOv] = useState<Overview | null>(null);
111
- const [err, setErr] = useState("");
112
- const [fleet, setFleet] = useState<{ rows: AutomationRow[]; cost: FleetCost } | null>(null);
113
-
114
- const [drill, setDrill] = useState<{ kind: DrillKind; tenant: string } | null>(null);
115
- const [drillRows, setDrillRows] = useState<unknown[] | null>(null);
116
- const [drillNote, setDrillNote] = useState("");
117
-
118
- const [aws, setAws] = useState<{ text: string; note: string; available: boolean } | null>(null);
119
- const [awsBusy, setAwsBusy] = useState(false);
120
-
121
- const load = useCallback(() => {
122
- setOv(null);
123
- void getOverview().then((r) => {
124
- if (r.ok) {
125
- setOv(r.data);
126
- setErr("");
127
- } else setErr(r.message);
128
- });
129
- void getAutomations().then((r) => {
130
- if (r.ok) setFleet({ rows: r.data.automations, cost: r.data.cost });
131
- });
132
- }, []);
133
- useEffect(load, [load]);
134
-
135
- const openDrill = useCallback((kind: DrillKind, tenant: string) => {
136
- setDrill({ kind, tenant });
137
- setDrillRows(null);
138
- setDrillNote("");
139
- const fetcher =
140
- kind === "users"
141
- ? getUsers
142
- : kind === "databases"
143
- ? getDatabases
144
- : kind === "connectors"
145
- ? getConnectors
146
- : getAutomations;
147
- void fetcher(tenant || undefined).then((r) => {
148
- if (!r.ok) {
149
- setDrillRows([]);
150
- setDrillNote(r.message);
151
- return;
152
- }
153
- const d = r.data as Record<string, unknown>;
154
- setDrillRows((d[kind] as unknown[]) ?? []);
155
- // The server's own honest note travels with the rows rather than being
156
- // re-invented here: it is the one that knows WHY a tenant is missing.
157
- const errors = (d.errors as Record<string, string>) ?? {};
158
- const words = Object.entries(errors).map(([t, e]) => `${t}: ${e}`);
159
- setDrillNote(
160
- [typeof d.stampsNote === "string" ? d.stampsNote : "", ...words]
161
- .filter(Boolean)
162
- .join(" · ")
163
- );
164
- });
165
- }, []);
166
-
167
- const loadAws = useCallback(() => {
168
- setAwsBusy(true);
169
- void getAws(7).then((r) => {
170
- setAwsBusy(false);
171
- if (r.ok) setAws(r.data.report);
172
- else setAws({ text: "", note: r.message, available: false });
173
- });
174
- }, []);
175
-
176
- return (
177
- <div className="set-pane">
178
- <h3 className="set-h">Loopable admin</h3>
179
- <p className="set-help set-pane-intro">
180
- Every workspace on the platform, signed in as {user.name}. Counts open the rows they were
181
- computed from; anything that could not be read shows a dash and says why, rather than a
182
- zero.
183
- </p>
184
- {err ? <p className="set-error">{err}</p> : null}
185
-
186
- {!ov ? (
187
- <Skeleton rows={4} />
188
- ) : (
189
- <>
190
- <div className="padmin-totals">
191
- <Total label="Workspaces" value={int(ov.totals.tenants)} />
192
- <Total label="People" value={int(ov.totals.users)} />
193
- <Total label="Databases" value={int(ov.totals.databases)} />
194
- <Total label="Records" value={int(ov.totals.rows)} />
195
- <Total
196
- label="Automation cost"
197
- value={fleet ? `$${fleet.cost.usd.toFixed(2)}` : "—"}
198
- hint={fleet?.cost.basis}
199
- />
200
- </div>
201
- {ov.totals.unknownTenants > 0 ? (
202
- <p className="set-help padmin-caveat">
203
- {ov.totals.unknownTenants} workspace
204
- {ov.totals.unknownTenants === 1 ? "" : "s"} could not be read, so the totals above
205
- exclude {ov.totals.unknownTenants === 1 ? "it" : "them"}.
206
- </p>
207
- ) : null}
208
-
209
- <div className="padmin-tablewrap">
210
- <table className="padmin-table">
211
- <thead>
212
- <tr>
213
- <th>Workspace</th>
214
- <th>Storage</th>
215
- <th className="padmin-num">People</th>
216
- <th className="padmin-num">Databases</th>
217
- <th className="padmin-num">Records</th>
218
- <th className="padmin-num">Sources</th>
219
- <th className="padmin-num">Automations</th>
220
- </tr>
221
- </thead>
222
- <tbody>
223
- {ov.tenants.map((t) => (
224
- <tr key={t.slug}>
225
- <td>
226
- <span className="padmin-name">{t.name}</span>
227
- <span className="padmin-meta">
228
- {t.slug}
229
- {t.status !== "active" ? ` · ${t.status}` : ""}
230
- {t.errors && t.errors.length ? ` · ${t.errors[0]}` : ""}
231
- </span>
232
- </td>
233
- <td>
234
- <span className="padmin-meta">
235
- {t.storeRepo && t.storeRepo.includes("/")
236
- ? "own repository"
237
- : t.storePrefix
238
- ? "shared repository"
239
- : "tenant #0 repository"}
240
- {t.keychainLocked ? " · keychain locked" : ""}
241
- </span>
242
- </td>
243
- <DrillCell
244
- n={t.users}
245
- onOpen={() => openDrill("users", t.slug)}
246
- title={`${t.admins} administrator${t.admins === 1 ? "" : "s"}`}
247
- />
248
- <DrillCell
249
- n={t.databases}
250
- onOpen={() => openDrill("databases", t.slug)}
251
- why={t.errors?.[0]}
252
- />
253
- <DrillCell
254
- n={t.rows}
255
- onOpen={() => openDrill("databases", t.slug)}
256
- why={t.errors?.[0]}
257
- />
258
- <DrillCell
259
- n={t.connectors}
260
- onOpen={() => openDrill("connectors", t.slug)}
261
- why={t.errors?.[0]}
262
- title={
263
- t.connectorsPaused ? `${t.connectorsPaused} paused` : "none paused"
264
- }
265
- />
266
- <DrillCell
267
- n={t.automations}
268
- onOpen={() => openDrill("automations", t.slug)}
269
- why={t.errors?.[0]}
270
- title={
271
- t.automationsEnabled != null
272
- ? `${t.automationsEnabled} scheduled`
273
- : undefined
274
- }
275
- />
276
- </tr>
277
- ))}
278
- </tbody>
279
- </table>
280
- </div>
281
-
282
- <p className="set-help padmin-caveat">
283
- Read {ago(ov.generatedAt)} in {ov.tookMs} ms.{" "}
284
- {ov.totals.orphanUsers > 0
285
- ? `${ov.totals.orphanUsers} account${
286
- ov.totals.orphanUsers === 1 ? "" : "s"
287
- } belong to a workspace this deployment no longer knows. `
288
- : ""}
289
- <button type="button" className="padmin-link" onClick={load}>
290
- Refresh
291
- </button>
292
- </p>
293
-
294
- {drill ? (
295
- <section className="padmin-drill">
296
- <div className="padmin-drill-head">
297
- <h4 className="padmin-h4">
298
- {DRILL_NOUN[drill.kind]}
299
- {drill.tenant ? ` — ${drill.tenant}` : ""}
300
- </h4>
301
- <button type="button" className="padmin-link" onClick={() => setDrill(null)}>
302
- Close
303
- </button>
304
- </div>
305
- {drillRows === null ? (
306
- <Skeleton rows={2} />
307
- ) : drillRows.length === 0 ? (
308
- <p className="set-help">Nothing here yet.</p>
309
- ) : (
310
- <DrillTable kind={drill.kind} rows={drillRows} />
311
- )}
312
- {drillNote ? <p className="set-help padmin-caveat">{drillNote}</p> : null}
313
- </section>
314
- ) : null}
315
-
316
- {fleet ? (
317
- <section className="padmin-block">
318
- {/* Named for what it SHOWS. The fleet's rows live one level down,
319
- behind the Automations count — this section is the cost and the
320
- reasoning behind it, so calling it "fleet" would promise a
321
- table that is deliberately not here. */}
322
- <h4 className="padmin-h4">Automation cost</h4>
323
- <p className="set-help">
324
- {fleet.rows.filter((a) => a.enabled).length} scheduled of {fleet.rows.length}
325
- {fleet.cost.fleetRunsPerDay
326
- ? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
327
- : ""}
328
- {fleet.cost.unknownCadence
329
- ? ` (${fleet.cost.unknownCadence} on a custom cadence, not counted)`
330
- : ""}
331
- . {fleet.cost.basis}
332
- </p>
333
- </section>
334
- ) : null}
335
-
336
- <section className="padmin-block">
337
- <h4 className="padmin-h4">AWS cron</h4>
338
- {!aws ? (
339
- <p className="set-help">
340
- The external tick that wakes this app on schedule. Reading its usage runs a live
341
- CloudWatch query and takes a few seconds.{" "}
342
- <button
343
- type="button"
344
- className="padmin-link"
345
- onClick={loadAws}
346
- disabled={awsBusy}
347
- >
348
- {awsBusy ? "Reading…" : "Check usage"}
349
- </button>
350
- </p>
351
- ) : aws.available ? (
352
- <pre className="padmin-pre">{aws.text}</pre>
353
- ) : (
354
- <p className="set-help">{aws.note}</p>
355
- )}
356
- </section>
357
- </>
358
- )}
359
- </div>
360
- );
361
- }
362
-
363
- function Total({ label, value, hint }: { label: string; value: string; hint?: string }) {
364
- // A tooltip nobody can see is a tooltip nobody reads: the label carries a
365
- // dotted underline exactly when there is something behind it.
366
- return (
367
- <div className={"padmin-total" + (hint ? " has-hint" : "")} title={hint || undefined}>
368
- <span className="padmin-total-label">{label}</span>
369
- <span className="padmin-total-value">{value}</span>
370
- </div>
371
- );
372
- }
373
-
374
- /** A numeric cell that opens its own rows. Unknown counts are not clickable —
375
- * there is nothing to drill INTO when the read failed, and a button that opens
376
- * an empty table would read as "none". */
377
- function DrillCell({
378
- n,
379
- onOpen,
380
- why,
381
- title,
382
- }: {
383
- n: number | null | undefined;
384
- onOpen: () => void;
385
- why?: string;
386
- title?: string;
387
- }) {
388
- if (n === null || n === undefined)
389
- return (
390
- <td className="padmin-num">
391
- <Count n={n} why={why} />
392
- </td>
393
- );
394
- return (
395
- <td className="padmin-num">
396
- <button type="button" className="padmin-drill-btn" onClick={onOpen} title={title}>
397
- {int(n)}
398
- </button>
399
- </td>
400
- );
401
- }
402
-
403
- function DrillTable({ kind, rows }: { kind: DrillKind; rows: unknown[] }) {
404
- if (kind === "users") {
405
- const rs = rows as PlatformUser[];
406
- return (
407
- <div className="padmin-tablewrap">
408
- <table className="padmin-table">
409
- <thead>
410
- <tr>
411
- <th>Person</th>
412
- <th>Workspace</th>
413
- <th>Role</th>
414
- <th>Last sign-in</th>
415
- <th>Last active</th>
416
- </tr>
417
- </thead>
418
- <tbody>
419
- {rs.map((u) => (
420
- <tr key={`${u.tenant}/${u.username}`}>
421
- <td>
422
- <span className="padmin-name">{u.name}</span>
423
- <span className="padmin-meta">
424
- {u.username}
425
- {u.email ? ` · ${u.email}` : ""}
426
- {u.active ? "" : " · deactivated"}
427
- </span>
428
- </td>
429
- <td>{u.tenant}</td>
430
- <td>
431
- {u.role}
432
- {u.platformAdmin ? " · platform" : ""}
433
- </td>
434
- <td>{ago(u.lastLogin)}</td>
435
- <td>{ago(u.lastActive)}</td>
436
- </tr>
437
- ))}
438
- </tbody>
439
- </table>
440
- </div>
441
- );
442
- }
443
- if (kind === "databases") {
444
- const rs = rows as DatabaseRow[];
445
- return (
446
- <div className="padmin-tablewrap">
447
- <table className="padmin-table">
448
- <thead>
449
- <tr>
450
- <th>Database</th>
451
- <th>Workspace</th>
452
- <th>Built from</th>
453
- <th className="padmin-num">Fields</th>
454
- <th className="padmin-num">Records</th>
455
- </tr>
456
- </thead>
457
- <tbody>
458
- {rs.map((d) => (
459
- <tr key={`${d.tenant}/${d.key}`}>
460
- <td>
461
- <span className="padmin-name">{d.label}</span>
462
- <span className="padmin-meta">
463
- {d.key}
464
- {d.createdBy ? ` · ${d.createdBy}` : ""}
465
- </span>
466
- </td>
467
- <td>{d.tenant}</td>
468
- <td>{d.source}</td>
469
- <td className="padmin-num">{int(d.fields)}</td>
470
- <td className="padmin-num">{int(d.rowCount)}</td>
471
- </tr>
472
- ))}
473
- </tbody>
474
- </table>
475
- </div>
476
- );
477
- }
478
- if (kind === "connectors") {
479
- const rs = rows as ConnectorRow[];
480
- return (
481
- <div className="padmin-tablewrap">
482
- <table className="padmin-table">
483
- <thead>
484
- <tr>
485
- <th>Source</th>
486
- <th>Workspace</th>
487
- <th>Kind</th>
488
- <th>Status</th>
489
- </tr>
490
- </thead>
491
- <tbody>
492
- {rs.map((c) => (
493
- <tr key={`${c.tenant}/${c.key}`}>
494
- <td>
495
- <span className="padmin-name">{c.label}</span>
496
- <span className="padmin-meta">
497
- {c.source === "env" ? "environment credentials" : "keychain"}
498
- </span>
499
- </td>
500
- <td>{c.tenant}</td>
501
- <td>{c.type}</td>
502
- <td>
503
- <span
504
- className={
505
- "padmin-dot " +
506
- (c.paused ? "is-paused" : c.active ? "is-live" : "is-idle")
507
- }
508
- />
509
- {c.paused ? "paused" : c.active ? "serving" : "stored"}
510
- </td>
511
- </tr>
512
- ))}
513
- </tbody>
514
- </table>
515
- </div>
516
- );
517
- }
518
- const rs = rows as AutomationRow[];
519
- return (
520
- <div className="padmin-tablewrap">
521
- <table className="padmin-table">
522
- <thead>
523
- <tr>
524
- <th>Automation</th>
525
- <th>Workspace</th>
526
- <th>Schedule</th>
527
- <th className="padmin-num">Runs a day</th>
528
- <th>Last run</th>
529
- </tr>
530
- </thead>
531
- <tbody>
532
- {rs.map((a) => (
533
- <tr key={`${a.tenant}/${a.id}`}>
534
- <td>
535
- <span className="padmin-name">{a.name}</span>
536
- <span className="padmin-meta">
537
- {a.kind}
538
- {a.failedRetained
539
- ? ` · ${a.failedRetained} of the last ${a.runsRetained} runs failed`
540
- : ""}
541
- </span>
542
- </td>
543
- <td>{a.tenant}</td>
544
- <td>{a.enabled ? a.cron || "scheduled" : "paused"}</td>
545
- <td className="padmin-num">
546
- {a.enabled ? (a.runsPerDay === null ? "custom" : a.runsPerDay) : "—"}
547
- </td>
548
- <td>
549
- <span
550
- className={
551
- "padmin-dot " +
552
- (a.state === "error" ? "is-error" : a.state === "ok" ? "is-live" : "is-idle")
553
- }
554
- />
555
- {/* Verbatim, not relative: the engine writes this stamp with no
556
- zone (`automation_engine._iso()` is naive local-to-container),
557
- so "x hours ago" would be wrong by the viewer's offset. */}
558
- {a.lastRunAt ? a.lastRunAt.replace("T", " ") : "never"}
559
- </td>
560
- </tr>
561
- ))}
562
- </tbody>
563
- </table>
564
- </div>
565
- );
566
- }
567
-
568
- export default AdminPane;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // settings / AdminPane.tsx — THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 /
3
+ // R3+R4, contract C2).
4
+ //
5
+ // The one surface in this product that looks ACROSS tenants: who our customers
6
+ // are, how many people use each workspace, what they have built in it, whether
7
+ // their data sources are alive, and what the automation fleet costs.
8
+ //
9
+ // SELF-CONTAINED BY CONTRACT (C2). It fetches its own data via
10
+ // `platformAdminApi` and takes ONE prop, so mounting it is a rail entry plus a
11
+ // line in the pane switch — `SettingsModal.tsx` is another session's file this
12
+ // wave and must not have to learn anything about this pane's data.
13
+ //
14
+ // ⚠ THE PROP IS TYPED STRUCTURALLY, not as `SessionUser` from `../shell/session`.
15
+ // The mount site passes the shell's session user (a wider object), which
16
+ // satisfies this shape by structural typing — and this pane therefore imports
17
+ // nothing from the shell, which is what the wave's cross-fence rule asks for
18
+ // while `session.ts` is open on another desk.
19
+ //
20
+ // ⛔ EVERY NUMBER HERE IS THE SERVER'S, AND EVERY NUMBER DRILLS. Clicking a count
21
+ // opens the rows it was computed from — the same collector, projected twice
22
+ // ([[no-unverifiable-aggregates]]). A count that could not be READ renders as an
23
+ // em dash with the reason, never as a zero: "this customer has no databases" and
24
+ // "we could not look" are different facts and the pane refuses to conflate them.
25
+ //
26
+ // DESIGN: sentence-case micro-labels (R6 — no caps in app chrome), short noun
27
+ // headers, tabular figures right-aligned, hairlines not shadows, tokens only,
28
+ // no emojis (DESIGN.md §2–§4).
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import { useCallback, useEffect, useState } from "react";
32
+ import type {
33
+ AutomationRow,
34
+ ConnectorRow,
35
+ DatabaseRow,
36
+ FleetCost,
37
+ Overview,
38
+ PlatformUser,
39
+ ReleasesPayload,
40
+ } from "./platformAdminApi";
41
+ import {
42
+ getAutomations,
43
+ getAws,
44
+ getReleases,
45
+ getConnectors,
46
+ getDatabases,
47
+ getOverview,
48
+ getUsers,
49
+ } from "./platformAdminApi";
50
+
51
+ // --- formatting -------------------------------------------------------------
52
+
53
+ const int = (n: number) => n.toLocaleString();
54
+
55
+ /** A count that may be unknown. `null` is NOT zero — see the api module's header. */
56
+ function Count({ n, why }: { n: number | null | undefined; why?: string }) {
57
+ if (n === null || n === undefined)
58
+ return (
59
+ <span className="padmin-unknown" title={why || "This could not be read just now"}>
60
+
61
+ </span>
62
+ );
63
+ return <>{int(n)}</>;
64
+ }
65
+
66
+ /** "3 days ago" for a stamp, "never" for an absent one. Never a fabricated date.
67
+ *
68
+ * ONLY FOR OFFSET-BEARING STAMPS. `Date.parse` reads a stamp with no zone as
69
+ * BROWSER-LOCAL, so running a container-local timestamp through this reports the
70
+ * viewer's UTC offset as elapsed time hours of error in a column people scan,
71
+ * and a future-dated stamp that renders as "just now" indefinitely. Everything
72
+ * this pane passes here (`last_login`, `last_active`, `generatedAt`) is written
73
+ * with an explicit offset; the automation engine's `lastRunAt` is NOT, so it is
74
+ * rendered verbatim instead. */
75
+ function ago(stamp: string): string {
76
+ if (!stamp) return "never";
77
+ const t = Date.parse(stamp.includes("T") ? stamp : stamp.replace(" ", "T"));
78
+ if (Number.isNaN(t)) return stamp;
79
+ const mins = Math.floor((Date.now() - t) / 60000);
80
+ if (mins < 2) return "just now";
81
+ if (mins < 60) return `${mins} minutes ago`;
82
+ const hours = Math.floor(mins / 60);
83
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
84
+ const days = Math.floor(hours / 24);
85
+ if (days < 60) return `${days} day${days === 1 ? "" : "s"} ago`;
86
+ return `${Math.floor(days / 30)} months ago`;
87
+ }
88
+
89
+ type DrillKind = "users" | "databases" | "connectors" | "automations";
90
+
91
+ const DRILL_NOUN: Record<DrillKind, string> = {
92
+ users: "People",
93
+ databases: "Databases",
94
+ connectors: "Data sources",
95
+ automations: "Automations",
96
+ };
97
+
98
+ /** The loading shape — a skeleton, not a spinner (DESIGN.md §4). */
99
+ function Skeleton({ rows = 3 }: { rows?: number }) {
100
+ return (
101
+ <div className="padmin-skel" aria-hidden="true">
102
+ {Array.from({ length: rows }, (_, i) => (
103
+ <div key={i} className="padmin-skel-row" />
104
+ ))}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ // --- the pane ---------------------------------------------------------------
110
+
111
+ export function AdminPane({ user }: { user: { username: string; name: string; role: string } }) {
112
+ const [ov, setOv] = useState<Overview | null>(null);
113
+ const [err, setErr] = useState("");
114
+ const [fleet, setFleet] = useState<{ rows: AutomationRow[]; cost: FleetCost } | null>(null);
115
+
116
+ const [drill, setDrill] = useState<{ kind: DrillKind; tenant: string } | null>(null);
117
+ const [drillRows, setDrillRows] = useState<unknown[] | null>(null);
118
+ const [drillNote, setDrillNote] = useState("");
119
+
120
+ const [aws, setAws] = useState<{ text: string; note: string; available: boolean } | null>(null);
121
+ // Wave 20 (R6): the releases panel. Loaded with the overview rather than on demand — it is
122
+ // two small Hub reads and the first question an operator opens this pane to answer.
123
+ const [rel, setRel] = useState<ReleasesPayload | null>(null);
124
+ const [awsBusy, setAwsBusy] = useState(false);
125
+
126
+ const load = useCallback(() => {
127
+ setOv(null);
128
+ void getOverview().then((r) => {
129
+ if (r.ok) {
130
+ setOv(r.data);
131
+ setErr("");
132
+ } else setErr(r.message);
133
+ });
134
+ void getAutomations().then((r) => {
135
+ if (r.ok) setFleet({ rows: r.data.automations, cost: r.data.cost });
136
+ });
137
+ void getReleases().then((r) => {
138
+ if (r.ok) setRel(r.data);
139
+ });
140
+ }, []);
141
+ useEffect(load, [load]);
142
+
143
+ const openDrill = useCallback((kind: DrillKind, tenant: string) => {
144
+ setDrill({ kind, tenant });
145
+ setDrillRows(null);
146
+ setDrillNote("");
147
+ const fetcher =
148
+ kind === "users"
149
+ ? getUsers
150
+ : kind === "databases"
151
+ ? getDatabases
152
+ : kind === "connectors"
153
+ ? getConnectors
154
+ : getAutomations;
155
+ void fetcher(tenant || undefined).then((r) => {
156
+ if (!r.ok) {
157
+ setDrillRows([]);
158
+ setDrillNote(r.message);
159
+ return;
160
+ }
161
+ const d = r.data as Record<string, unknown>;
162
+ setDrillRows((d[kind] as unknown[]) ?? []);
163
+ // The server's own honest note travels with the rows rather than being
164
+ // re-invented here: it is the one that knows WHY a tenant is missing.
165
+ const errors = (d.errors as Record<string, string>) ?? {};
166
+ const words = Object.entries(errors).map(([t, e]) => `${t}: ${e}`);
167
+ setDrillNote(
168
+ [typeof d.stampsNote === "string" ? d.stampsNote : "", ...words]
169
+ .filter(Boolean)
170
+ .join(" · ")
171
+ );
172
+ });
173
+ }, []);
174
+
175
+ const loadAws = useCallback(() => {
176
+ setAwsBusy(true);
177
+ void getAws(7).then((r) => {
178
+ setAwsBusy(false);
179
+ if (r.ok) setAws(r.data.report);
180
+ else setAws({ text: "", note: r.message, available: false });
181
+ });
182
+ }, []);
183
+
184
+ return (
185
+ <div className="set-pane">
186
+ <h3 className="set-h">Loopable admin</h3>
187
+ <p className="set-help set-pane-intro">
188
+ Every workspace on the platform, signed in as {user.name}. Counts open the rows they were
189
+ computed from; anything that could not be read shows a dash and says why, rather than a
190
+ zero.
191
+ </p>
192
+ {err ? <p className="set-error">{err}</p> : null}
193
+
194
+ {!ov ? (
195
+ <Skeleton rows={4} />
196
+ ) : (
197
+ <>
198
+ <div className="padmin-totals">
199
+ <Total label="Workspaces" value={int(ov.totals.tenants)} />
200
+ <Total label="People" value={int(ov.totals.users)} />
201
+ <Total label="Databases" value={int(ov.totals.databases)} />
202
+ <Total label="Records" value={int(ov.totals.rows)} />
203
+ <Total
204
+ label="Automation cost"
205
+ value={fleet ? `$${fleet.cost.usd.toFixed(2)}` : ""}
206
+ hint={fleet?.cost.basis}
207
+ />
208
+ </div>
209
+ {ov.totals.unknownTenants > 0 ? (
210
+ <p className="set-help padmin-caveat">
211
+ {ov.totals.unknownTenants} workspace
212
+ {ov.totals.unknownTenants === 1 ? "" : "s"} could not be read, so the totals above
213
+ exclude {ov.totals.unknownTenants === 1 ? "it" : "them"}.
214
+ </p>
215
+ ) : null}
216
+
217
+ <div className="padmin-tablewrap">
218
+ <table className="padmin-table">
219
+ <thead>
220
+ <tr>
221
+ <th>Workspace</th>
222
+ <th>Storage</th>
223
+ <th className="padmin-num">People</th>
224
+ <th className="padmin-num">Databases</th>
225
+ <th className="padmin-num">Records</th>
226
+ <th className="padmin-num">Sources</th>
227
+ <th className="padmin-num">Automations</th>
228
+ </tr>
229
+ </thead>
230
+ <tbody>
231
+ {ov.tenants.map((t) => (
232
+ <tr key={t.slug}>
233
+ <td>
234
+ <span className="padmin-name">{t.name}</span>
235
+ <span className="padmin-meta">
236
+ {t.slug}
237
+ {t.status !== "active" ? ` · ${t.status}` : ""}
238
+ {t.errors && t.errors.length ? ` · ${t.errors[0]}` : ""}
239
+ </span>
240
+ </td>
241
+ <td>
242
+ <span className="padmin-meta">
243
+ {t.storeRepo && t.storeRepo.includes("/")
244
+ ? "own repository"
245
+ : t.storePrefix
246
+ ? "shared repository"
247
+ : "tenant #0 repository"}
248
+ {t.keychainLocked ? " · keychain locked" : ""}
249
+ </span>
250
+ </td>
251
+ <DrillCell
252
+ n={t.users}
253
+ onOpen={() => openDrill("users", t.slug)}
254
+ title={`${t.admins} administrator${t.admins === 1 ? "" : "s"}`}
255
+ />
256
+ <DrillCell
257
+ n={t.databases}
258
+ onOpen={() => openDrill("databases", t.slug)}
259
+ why={t.errors?.[0]}
260
+ />
261
+ <DrillCell
262
+ n={t.rows}
263
+ onOpen={() => openDrill("databases", t.slug)}
264
+ why={t.errors?.[0]}
265
+ />
266
+ <DrillCell
267
+ n={t.connectors}
268
+ onOpen={() => openDrill("connectors", t.slug)}
269
+ why={t.errors?.[0]}
270
+ title={
271
+ t.connectorsPaused ? `${t.connectorsPaused} paused` : "none paused"
272
+ }
273
+ />
274
+ <DrillCell
275
+ n={t.automations}
276
+ onOpen={() => openDrill("automations", t.slug)}
277
+ why={t.errors?.[0]}
278
+ title={
279
+ t.automationsEnabled != null
280
+ ? `${t.automationsEnabled} scheduled`
281
+ : undefined
282
+ }
283
+ />
284
+ </tr>
285
+ ))}
286
+ </tbody>
287
+ </table>
288
+ </div>
289
+
290
+ <p className="set-help padmin-caveat">
291
+ Read {ago(ov.generatedAt)} in {ov.tookMs} ms.{" "}
292
+ {ov.totals.orphanUsers > 0
293
+ ? `${ov.totals.orphanUsers} account${
294
+ ov.totals.orphanUsers === 1 ? "" : "s"
295
+ } belong to a workspace this deployment no longer knows. `
296
+ : ""}
297
+ <button type="button" className="padmin-link" onClick={load}>
298
+ Refresh
299
+ </button>
300
+ </p>
301
+
302
+ {drill ? (
303
+ <section className="padmin-drill">
304
+ <div className="padmin-drill-head">
305
+ <h4 className="padmin-h4">
306
+ {DRILL_NOUN[drill.kind]}
307
+ {drill.tenant ? ` ${drill.tenant}` : ""}
308
+ </h4>
309
+ <button type="button" className="padmin-link" onClick={() => setDrill(null)}>
310
+ Close
311
+ </button>
312
+ </div>
313
+ {drillRows === null ? (
314
+ <Skeleton rows={2} />
315
+ ) : drillRows.length === 0 ? (
316
+ <p className="set-help">Nothing here yet.</p>
317
+ ) : (
318
+ <DrillTable kind={drill.kind} rows={drillRows} />
319
+ )}
320
+ {drillNote ? <p className="set-help padmin-caveat">{drillNote}</p> : null}
321
+ </section>
322
+ ) : null}
323
+
324
+ {fleet ? (
325
+ <section className="padmin-block">
326
+ {/* Named for what it SHOWS. The fleet's rows live one level down,
327
+ behind the Automations count — this section is the cost and the
328
+ reasoning behind it, so calling it "fleet" would promise a
329
+ table that is deliberately not here. */}
330
+ <h4 className="padmin-h4">Automation cost</h4>
331
+ <p className="set-help">
332
+ {fleet.rows.filter((a) => a.enabled).length} scheduled of {fleet.rows.length}
333
+ {fleet.cost.fleetRunsPerDay
334
+ ? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
335
+ : ""}
336
+ {fleet.cost.unknownCadence
337
+ ? ` (${fleet.cost.unknownCadence} on a custom cadence, not counted)`
338
+ : ""}
339
+ . {fleet.cost.basis}
340
+ </p>
341
+ </section>
342
+ ) : null}
343
+
344
+ {/* ── Wave 20 (owner item 12 / R6): what is running where ─���────────────────
345
+ READ-ONLY BY RULING. The operator sees both environments and every cut
346
+ release; moving one is a CLI command, printed below rather than wired to
347
+ a button, so no browser session can rewrite production. */}
348
+ <section className="padmin-block">
349
+ <h4 className="padmin-h4">Releases</h4>
350
+ {!rel ? (
351
+ <Skeleton rows={2} />
352
+ ) : (
353
+ <>
354
+ <table className="padmin-table">
355
+ <thead>
356
+ <tr>
357
+ <th>Environment</th>
358
+ <th>Version</th>
359
+ <th>Stage</th>
360
+ <th>Space</th>
361
+ </tr>
362
+ </thead>
363
+ <tbody>
364
+ {rel.environments.map((e) => (
365
+ <tr key={e.env}>
366
+ <td>{e.env === "live" ? "Live (pinned)" : "Staging (follows the tree)"}</td>
367
+ {/* An unreadable version is a NOTE, never a blank. A blank cell in a
368
+ column headed "Version" reads as "nothing is deployed", which about a
369
+ production environment is the worst available way to be wrong. */}
370
+ <td>{e.version || <span className="set-help">{e.note || "unknown"}</span>}</td>
371
+ <td>{e.stage || "—"}</td>
372
+ {/* The Space ID as TEXT, not a link: the server deliberately builds no
373
+ URL (portability C1 — a host literal in runtime code hard-codes the
374
+ current host into a process designed to move). */}
375
+ <td>{e.space}</td>
376
+ </tr>
377
+ ))}
378
+ </tbody>
379
+ </table>
380
+ {rel.releases.length ? (
381
+ <table className="padmin-table">
382
+ <thead>
383
+ <tr>
384
+ <th>Release</th>
385
+ <th>Commit</th>
386
+ <th>Cut</th>
387
+ <th>What shipped</th>
388
+ </tr>
389
+ </thead>
390
+ <tbody>
391
+ {rel.releases.map((r) => (
392
+ <tr key={r.version}>
393
+ <td>{r.version}</td>
394
+ <td>{r.sha}</td>
395
+ <td>{r.date}</td>
396
+ <td>{r.subject}</td>
397
+ </tr>
398
+ ))}
399
+ </tbody>
400
+ </table>
401
+ ) : (
402
+ <p className="set-help">
403
+ No tagged releases were readable from either Space.
404
+ </p>
405
+ )}
406
+ <p className="set-help">
407
+ To move Live to another version, or roll it back, run:{" "}
408
+ <code>{rel.promote}</code>
409
+ </p>
410
+ </>
411
+ )}
412
+ </section>
413
+
414
+ <section className="padmin-block">
415
+ <h4 className="padmin-h4">AWS cron</h4>
416
+ {!aws ? (
417
+ <p className="set-help">
418
+ The external tick that wakes this app on schedule. Reading its usage runs a live
419
+ CloudWatch query and takes a few seconds.{" "}
420
+ <button
421
+ type="button"
422
+ className="padmin-link"
423
+ onClick={loadAws}
424
+ disabled={awsBusy}
425
+ >
426
+ {awsBusy ? "Reading…" : "Check usage"}
427
+ </button>
428
+ </p>
429
+ ) : aws.available ? (
430
+ <pre className="padmin-pre">{aws.text}</pre>
431
+ ) : (
432
+ <p className="set-help">{aws.note}</p>
433
+ )}
434
+ </section>
435
+ </>
436
+ )}
437
+ </div>
438
+ );
439
+ }
440
+
441
+ function Total({ label, value, hint }: { label: string; value: string; hint?: string }) {
442
+ // A tooltip nobody can see is a tooltip nobody reads: the label carries a
443
+ // dotted underline exactly when there is something behind it.
444
+ return (
445
+ <div className={"padmin-total" + (hint ? " has-hint" : "")} title={hint || undefined}>
446
+ <span className="padmin-total-label">{label}</span>
447
+ <span className="padmin-total-value">{value}</span>
448
+ </div>
449
+ );
450
+ }
451
+
452
+ /** A numeric cell that opens its own rows. Unknown counts are not clickable —
453
+ * there is nothing to drill INTO when the read failed, and a button that opens
454
+ * an empty table would read as "none". */
455
+ function DrillCell({
456
+ n,
457
+ onOpen,
458
+ why,
459
+ title,
460
+ }: {
461
+ n: number | null | undefined;
462
+ onOpen: () => void;
463
+ why?: string;
464
+ title?: string;
465
+ }) {
466
+ if (n === null || n === undefined)
467
+ return (
468
+ <td className="padmin-num">
469
+ <Count n={n} why={why} />
470
+ </td>
471
+ );
472
+ return (
473
+ <td className="padmin-num">
474
+ <button type="button" className="padmin-drill-btn" onClick={onOpen} title={title}>
475
+ {int(n)}
476
+ </button>
477
+ </td>
478
+ );
479
+ }
480
+
481
+ function DrillTable({ kind, rows }: { kind: DrillKind; rows: unknown[] }) {
482
+ if (kind === "users") {
483
+ const rs = rows as PlatformUser[];
484
+ return (
485
+ <div className="padmin-tablewrap">
486
+ <table className="padmin-table">
487
+ <thead>
488
+ <tr>
489
+ <th>Person</th>
490
+ <th>Workspace</th>
491
+ <th>Role</th>
492
+ <th>Last sign-in</th>
493
+ <th>Last active</th>
494
+ </tr>
495
+ </thead>
496
+ <tbody>
497
+ {rs.map((u) => (
498
+ <tr key={`${u.tenant}/${u.username}`}>
499
+ <td>
500
+ <span className="padmin-name">{u.name}</span>
501
+ <span className="padmin-meta">
502
+ {u.username}
503
+ {u.email ? ` · ${u.email}` : ""}
504
+ {u.active ? "" : " · deactivated"}
505
+ </span>
506
+ </td>
507
+ <td>{u.tenant}</td>
508
+ <td>
509
+ {u.role}
510
+ {u.platformAdmin ? " · platform" : ""}
511
+ </td>
512
+ <td>{ago(u.lastLogin)}</td>
513
+ <td>{ago(u.lastActive)}</td>
514
+ </tr>
515
+ ))}
516
+ </tbody>
517
+ </table>
518
+ </div>
519
+ );
520
+ }
521
+ if (kind === "databases") {
522
+ const rs = rows as DatabaseRow[];
523
+ return (
524
+ <div className="padmin-tablewrap">
525
+ <table className="padmin-table">
526
+ <thead>
527
+ <tr>
528
+ <th>Database</th>
529
+ <th>Workspace</th>
530
+ <th>Built from</th>
531
+ <th className="padmin-num">Fields</th>
532
+ <th className="padmin-num">Records</th>
533
+ </tr>
534
+ </thead>
535
+ <tbody>
536
+ {rs.map((d) => (
537
+ <tr key={`${d.tenant}/${d.key}`}>
538
+ <td>
539
+ <span className="padmin-name">{d.label}</span>
540
+ <span className="padmin-meta">
541
+ {d.key}
542
+ {d.createdBy ? ` · ${d.createdBy}` : ""}
543
+ </span>
544
+ </td>
545
+ <td>{d.tenant}</td>
546
+ <td>{d.source}</td>
547
+ <td className="padmin-num">{int(d.fields)}</td>
548
+ <td className="padmin-num">{int(d.rowCount)}</td>
549
+ </tr>
550
+ ))}
551
+ </tbody>
552
+ </table>
553
+ </div>
554
+ );
555
+ }
556
+ if (kind === "connectors") {
557
+ const rs = rows as ConnectorRow[];
558
+ return (
559
+ <div className="padmin-tablewrap">
560
+ <table className="padmin-table">
561
+ <thead>
562
+ <tr>
563
+ <th>Source</th>
564
+ <th>Workspace</th>
565
+ <th>Kind</th>
566
+ <th>Status</th>
567
+ </tr>
568
+ </thead>
569
+ <tbody>
570
+ {rs.map((c) => (
571
+ <tr key={`${c.tenant}/${c.key}`}>
572
+ <td>
573
+ <span className="padmin-name">{c.label}</span>
574
+ <span className="padmin-meta">
575
+ {c.source === "env" ? "environment credentials" : "keychain"}
576
+ </span>
577
+ </td>
578
+ <td>{c.tenant}</td>
579
+ <td>{c.type}</td>
580
+ <td>
581
+ <span
582
+ className={
583
+ "padmin-dot " +
584
+ (c.paused ? "is-paused" : c.active ? "is-live" : "is-idle")
585
+ }
586
+ />
587
+ {c.paused ? "paused" : c.active ? "serving" : "stored"}
588
+ </td>
589
+ </tr>
590
+ ))}
591
+ </tbody>
592
+ </table>
593
+ </div>
594
+ );
595
+ }
596
+ const rs = rows as AutomationRow[];
597
+ return (
598
+ <div className="padmin-tablewrap">
599
+ <table className="padmin-table">
600
+ <thead>
601
+ <tr>
602
+ <th>Automation</th>
603
+ <th>Workspace</th>
604
+ <th>Schedule</th>
605
+ <th className="padmin-num">Runs a day</th>
606
+ <th>Last run</th>
607
+ </tr>
608
+ </thead>
609
+ <tbody>
610
+ {rs.map((a) => (
611
+ <tr key={`${a.tenant}/${a.id}`}>
612
+ <td>
613
+ <span className="padmin-name">{a.name}</span>
614
+ <span className="padmin-meta">
615
+ {a.kind}
616
+ {a.failedRetained
617
+ ? ` · ${a.failedRetained} of the last ${a.runsRetained} runs failed`
618
+ : ""}
619
+ </span>
620
+ </td>
621
+ <td>{a.tenant}</td>
622
+ <td>{a.enabled ? a.cron || "scheduled" : "paused"}</td>
623
+ <td className="padmin-num">
624
+ {a.enabled ? (a.runsPerDay === null ? "custom" : a.runsPerDay) : "—"}
625
+ </td>
626
+ <td>
627
+ <span
628
+ className={
629
+ "padmin-dot " +
630
+ (a.state === "error" ? "is-error" : a.state === "ok" ? "is-live" : "is-idle")
631
+ }
632
+ />
633
+ {/* Verbatim, not relative: the engine writes this stamp with no
634
+ zone (`automation_engine._iso()` is naive local-to-container),
635
+ so "x hours ago" would be wrong by the viewer's offset. */}
636
+ {a.lastRunAt ? a.lastRunAt.replace("T", " ") : "never"}
637
+ </td>
638
+ </tr>
639
+ ))}
640
+ </tbody>
641
+ </table>
642
+ </div>
643
+ );
644
+ }
645
+
646
+ export default AdminPane;
web/src/settings/platformAdminApi.ts CHANGED
@@ -1,224 +1,263 @@
1
- // ---------------------------------------------------------------------------
2
- // settings / platformAdminApi.ts — the Loopable admin plane's client half
3
- // (wave 19, owner item 13 / R3+R4, contract C2).
4
- //
5
- // ⛔ THE CLIENT HIDES; THE SERVER FORBIDS. Every route here is walled by
6
- // `core.platform_admin.is_platform_admin` — a double lock (the record flag AND
7
- // the `loopable` tenant), fail-closed, proven by `verify_api.py` enumerating the
8
- // router and having a tenant admin try every path. The `platformAdmin` flag on
9
- // `GET /settings` exists so the rail does not paint a door that would 403; it is
10
- // never the permission ([[aios-permissioning]]).
11
- //
12
- // SELF-CONTAINED BY CONTRACT (C2). This module and `AdminPane.tsx` are the only
13
- // two files the admin plane owns on the client, and they import nothing from the
14
- // rest of `settings/` — the pane fetches its own data, so mounting it is one line
15
- // in the rail and one line in the pane switch.
16
- //
17
- // ⚠ NULL IS A REAL VALUE HERE, AND IT IS NOT ZERO. Any per-tenant count can come
18
- // back `null`, meaning "that subsystem could not be read for this customer" — a
19
- // suspended tenant record, a locked keychain, an HF repo that did not answer.
20
- // Rendering it as 0 would tell an operator a customer has no databases when the
21
- // truth is that we did not look. The types say `number | null` for exactly that
22
- // reason and the pane renders the difference.
23
- // ---------------------------------------------------------------------------
24
-
25
- import { API_V1, CREDENTIALS } from "../apiContract";
26
-
27
- export type ApiResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
28
-
29
- async function call<T>(path: string): Promise<ApiResult<T>> {
30
- try {
31
- const res = await fetch(`${API_V1}/platform-admin${path}`, { credentials: CREDENTIALS });
32
- let body: unknown = null;
33
- try {
34
- body = await res.json();
35
- } catch {
36
- /* an unreadable body is handled below, never thrown at the pane */
37
- }
38
- if (!res.ok) {
39
- const err = (body as { error?: { message?: string } } | null)?.error;
40
- const message =
41
- res.status >= 500
42
- ? "Something went wrong on our side. Try again in a moment."
43
- : err?.message ||
44
- (res.status === 403
45
- ? "This surface is not available to your account."
46
- : "That could not be loaded.");
47
- return { ok: false, status: res.status, message };
48
- }
49
- return { ok: true, data: body as T };
50
- } catch {
51
- return { ok: false, status: 0, message: "The server could not be reached." };
52
- }
53
- }
54
-
55
- /** One customer. `error` / `errors` carry the honest reason a count is null. */
56
- export interface TenantRow {
57
- slug: string;
58
- name: string;
59
- /** `record` = provisioned into the control-plane bucket; `compiled` = tenant #0. */
60
- source: string;
61
- status: string;
62
- domains: string[];
63
- modules: string[] | "all";
64
- /** R2: a dedicated dataset repo, or the shared repo. The isolation shape. */
65
- storeRepo: string;
66
- storePrefix: string;
67
- error: string;
68
- users: number;
69
- admins: number;
70
- databases: number | null;
71
- rows: number | null;
72
- connectors: number | null;
73
- connectorsPaused?: number | null;
74
- automations: number | null;
75
- automationsEnabled?: number | null;
76
- keychainLocked?: boolean | null;
77
- errors?: string[];
78
- }
79
-
80
- export interface Overview {
81
- tenants: TenantRow[];
82
- totals: {
83
- tenants: number;
84
- users: number;
85
- orphanUsers: number;
86
- databases: number;
87
- rows: number;
88
- automations: number;
89
- unknownTenants: number;
90
- };
91
- storeAvailable: boolean;
92
- generatedAt: string;
93
- tookMs: number;
94
- }
95
-
96
- export interface PlatformUser {
97
- username: string;
98
- name: string;
99
- email: string;
100
- tenant: string;
101
- role: string;
102
- active: boolean;
103
- /** R4 — absent means never, and the pane says "never" rather than inventing a date. */
104
- lastLogin: string;
105
- lastActive: string;
106
- platformAdmin: boolean;
107
- }
108
-
109
- export interface DatabaseRow {
110
- tenant: string;
111
- key: string;
112
- label: string;
113
- source: string;
114
- createdBy: string;
115
- created: string;
116
- fields: number;
117
- rowCount: number;
118
- }
119
-
120
- export interface ConnectorRow {
121
- tenant: string;
122
- key: string;
123
- label: string;
124
- type: string;
125
- source: string;
126
- active: boolean;
127
- paused: boolean;
128
- }
129
-
130
- export interface AutomationRow {
131
- tenant: string;
132
- id: string;
133
- name: string;
134
- kind: string;
135
- enabled: boolean;
136
- cron: string;
137
- /** null = a cadence the server would not parse; shown as "custom", never as a number. */
138
- runsPerDay: number | null;
139
- state: string;
140
- lastRunAt: string;
141
- lastSummary: string;
142
- runsRetained: number;
143
- failedRetained: number;
144
- createdBy: string;
145
- created: string;
146
- }
147
-
148
- export interface FleetCost {
149
- cadence: string;
150
- invocationsPerMonth: number;
151
- freeRequestsPct: number;
152
- freeSchedulerPct: number;
153
- lambdaMb: number;
154
- usd: number;
155
- fleetRunsPerDay: number;
156
- unknownCadence: number;
157
- basis: string;
158
- }
159
-
160
- export interface AwsReport {
161
- available: boolean;
162
- text: string;
163
- note: string;
164
- }
165
-
166
- export function getOverview(): Promise<ApiResult<Overview>> {
167
- return call<Overview>("/overview");
168
- }
169
-
170
- /** `tenant` narrows every drill; omitted, they answer for the whole platform. */
171
- const q = (tenant?: string) => (tenant ? `?tenant=${encodeURIComponent(tenant)}` : "");
172
-
173
- export function getUsers(
174
- tenant?: string
175
- ): Promise<ApiResult<{ users: PlatformUser[]; count: number; stampsNote: string }>> {
176
- return call(`/users${q(tenant)}`);
177
- }
178
-
179
- export function getDatabases(
180
- tenant?: string
181
- ): Promise<
182
- ApiResult<{
183
- databases: DatabaseRow[];
184
- count: number;
185
- rows: number;
186
- errors: Record<string, string>;
187
- }>
188
- > {
189
- return call(`/databases${q(tenant)}`);
190
- }
191
-
192
- export function getConnectors(
193
- tenant?: string
194
- ): Promise<
195
- ApiResult<{
196
- connectors: ConnectorRow[];
197
- count: number;
198
- keychainLocked: Record<string, boolean>;
199
- errors: Record<string, string>;
200
- note: string;
201
- }>
202
- > {
203
- return call(`/connectors${q(tenant)}`);
204
- }
205
-
206
- export function getAutomations(
207
- tenant?: string
208
- ): Promise<
209
- ApiResult<{
210
- automations: AutomationRow[];
211
- count: number;
212
- enabled: number;
213
- historyRetained: number;
214
- cost: FleetCost;
215
- errors: Record<string, string>;
216
- tickEnabled: boolean;
217
- }>
218
- > {
219
- return call(`/automations${q(tenant)}`);
220
- }
221
-
222
- export function getAws(days = 7): Promise<ApiResult<{ days: number; report: AwsReport }>> {
223
- return call(`/aws?days=${encodeURIComponent(String(days))}`);
224
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // settings / platformAdminApi.ts — the Loopable admin plane's client half
3
+ // (wave 19, owner item 13 / R3+R4, contract C2).
4
+ //
5
+ // ⛔ THE CLIENT HIDES; THE SERVER FORBIDS. Every route here is walled by
6
+ // `core.platform_admin.is_platform_admin` — a double lock (the record flag AND
7
+ // the `loopable` tenant), fail-closed, proven by `verify_api.py` enumerating the
8
+ // router and having a tenant admin try every path. The `platformAdmin` flag on
9
+ // `GET /settings` exists so the rail does not paint a door that would 403; it is
10
+ // never the permission ([[aios-permissioning]]).
11
+ //
12
+ // SELF-CONTAINED BY CONTRACT (C2). This module and `AdminPane.tsx` are the only
13
+ // two files the admin plane owns on the client, and they import nothing from the
14
+ // rest of `settings/` — the pane fetches its own data, so mounting it is one line
15
+ // in the rail and one line in the pane switch.
16
+ //
17
+ // ⚠ NULL IS A REAL VALUE HERE, AND IT IS NOT ZERO. Any per-tenant count can come
18
+ // back `null`, meaning "that subsystem could not be read for this customer" — a
19
+ // suspended tenant record, a locked keychain, an HF repo that did not answer.
20
+ // Rendering it as 0 would tell an operator a customer has no databases when the
21
+ // truth is that we did not look. The types say `number | null` for exactly that
22
+ // reason and the pane renders the difference.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ import { API_V1, CREDENTIALS } from "../apiContract";
26
+
27
+ export type ApiResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
28
+
29
+ async function call<T>(path: string): Promise<ApiResult<T>> {
30
+ try {
31
+ const res = await fetch(`${API_V1}/platform-admin${path}`, { credentials: CREDENTIALS });
32
+ let body: unknown = null;
33
+ try {
34
+ body = await res.json();
35
+ } catch {
36
+ /* an unreadable body is handled below, never thrown at the pane */
37
+ }
38
+ if (!res.ok) {
39
+ const err = (body as { error?: { message?: string } } | null)?.error;
40
+ const message =
41
+ res.status >= 500
42
+ ? "Something went wrong on our side. Try again in a moment."
43
+ : err?.message ||
44
+ (res.status === 403
45
+ ? "This surface is not available to your account."
46
+ : "That could not be loaded.");
47
+ return { ok: false, status: res.status, message };
48
+ }
49
+ return { ok: true, data: body as T };
50
+ } catch {
51
+ return { ok: false, status: 0, message: "The server could not be reached." };
52
+ }
53
+ }
54
+
55
+ /** One customer. `error` / `errors` carry the honest reason a count is null. */
56
+ export interface TenantRow {
57
+ slug: string;
58
+ name: string;
59
+ /** `record` = provisioned into the control-plane bucket; `compiled` = tenant #0. */
60
+ source: string;
61
+ status: string;
62
+ domains: string[];
63
+ modules: string[] | "all";
64
+ /** R2: a dedicated dataset repo, or the shared repo. The isolation shape. */
65
+ storeRepo: string;
66
+ storePrefix: string;
67
+ error: string;
68
+ users: number;
69
+ admins: number;
70
+ databases: number | null;
71
+ rows: number | null;
72
+ connectors: number | null;
73
+ connectorsPaused?: number | null;
74
+ automations: number | null;
75
+ automationsEnabled?: number | null;
76
+ keychainLocked?: boolean | null;
77
+ errors?: string[];
78
+ }
79
+
80
+ export interface Overview {
81
+ tenants: TenantRow[];
82
+ totals: {
83
+ tenants: number;
84
+ users: number;
85
+ orphanUsers: number;
86
+ databases: number;
87
+ rows: number;
88
+ automations: number;
89
+ unknownTenants: number;
90
+ };
91
+ storeAvailable: boolean;
92
+ generatedAt: string;
93
+ tookMs: number;
94
+ }
95
+
96
+ export interface PlatformUser {
97
+ username: string;
98
+ name: string;
99
+ email: string;
100
+ tenant: string;
101
+ role: string;
102
+ active: boolean;
103
+ /** R4 — absent means never, and the pane says "never" rather than inventing a date. */
104
+ lastLogin: string;
105
+ lastActive: string;
106
+ platformAdmin: boolean;
107
+ }
108
+
109
+ export interface DatabaseRow {
110
+ tenant: string;
111
+ key: string;
112
+ label: string;
113
+ source: string;
114
+ createdBy: string;
115
+ created: string;
116
+ fields: number;
117
+ rowCount: number;
118
+ }
119
+
120
+ export interface ConnectorRow {
121
+ tenant: string;
122
+ key: string;
123
+ label: string;
124
+ type: string;
125
+ source: string;
126
+ active: boolean;
127
+ paused: boolean;
128
+ }
129
+
130
+ export interface AutomationRow {
131
+ tenant: string;
132
+ id: string;
133
+ name: string;
134
+ kind: string;
135
+ enabled: boolean;
136
+ cron: string;
137
+ /** null = a cadence the server would not parse; shown as "custom", never as a number. */
138
+ runsPerDay: number | null;
139
+ state: string;
140
+ lastRunAt: string;
141
+ lastSummary: string;
142
+ runsRetained: number;
143
+ failedRetained: number;
144
+ createdBy: string;
145
+ created: string;
146
+ }
147
+
148
+ export interface FleetCost {
149
+ cadence: string;
150
+ invocationsPerMonth: number;
151
+ freeRequestsPct: number;
152
+ freeSchedulerPct: number;
153
+ lambdaMb: number;
154
+ usd: number;
155
+ fleetRunsPerDay: number;
156
+ unknownCadence: number;
157
+ basis: string;
158
+ }
159
+
160
+ export interface AwsReport {
161
+ available: boolean;
162
+ text: string;
163
+ note: string;
164
+ }
165
+
166
+ export function getOverview(): Promise<ApiResult<Overview>> {
167
+ return call<Overview>("/overview");
168
+ }
169
+
170
+ /** `tenant` narrows every drill; omitted, they answer for the whole platform. */
171
+ const q = (tenant?: string) => (tenant ? `?tenant=${encodeURIComponent(tenant)}` : "");
172
+
173
+ export function getUsers(
174
+ tenant?: string
175
+ ): Promise<ApiResult<{ users: PlatformUser[]; count: number; stampsNote: string }>> {
176
+ return call(`/users${q(tenant)}`);
177
+ }
178
+
179
+ export function getDatabases(
180
+ tenant?: string
181
+ ): Promise<
182
+ ApiResult<{
183
+ databases: DatabaseRow[];
184
+ count: number;
185
+ rows: number;
186
+ errors: Record<string, string>;
187
+ }>
188
+ > {
189
+ return call(`/databases${q(tenant)}`);
190
+ }
191
+
192
+ export function getConnectors(
193
+ tenant?: string
194
+ ): Promise<
195
+ ApiResult<{
196
+ connectors: ConnectorRow[];
197
+ count: number;
198
+ keychainLocked: Record<string, boolean>;
199
+ errors: Record<string, string>;
200
+ note: string;
201
+ }>
202
+ > {
203
+ return call(`/connectors${q(tenant)}`);
204
+ }
205
+
206
+ export function getAutomations(
207
+ tenant?: string
208
+ ): Promise<
209
+ ApiResult<{
210
+ automations: AutomationRow[];
211
+ count: number;
212
+ enabled: number;
213
+ historyRetained: number;
214
+ cost: FleetCost;
215
+ errors: Record<string, string>;
216
+ tickEnabled: boolean;
217
+ }>
218
+ > {
219
+ return call(`/automations${q(tenant)}`);
220
+ }
221
+
222
+ export function getAws(days = 7): Promise<ApiResult<{ days: number; report: AwsReport }>> {
223
+ return call(`/aws?days=${encodeURIComponent(String(days))}`);
224
+ }
225
+
226
+ // ── Wave 20 (owner item 12 / R6): RELEASES ──────────────────────────────────
227
+ // Read-only by ruling. The operator SEES what is running where and what could be
228
+ // gone back to; promoting stays `deploy_web.py --promote=vN`, so no web session
229
+ // can move production. `promote` carries that command in the payload because the
230
+ // person reading this panel is exactly the person who needs it.
231
+
232
+ /** One environment. `version` is what the SPACE says about itself (its own
233
+ * `VERSION` file) — never inferred from a timestamp, because `last_modified`
234
+ * moves when a SECRET is pushed and would report a deploy that never happened. */
235
+ export interface ReleaseEnv {
236
+ env: string;
237
+ space: string;
238
+ /** null + a `note` = we could not read it. NOT "nothing is deployed". */
239
+ version: string | null;
240
+ note: string | null;
241
+ /** HF runtime stage (`RUNNING`, `BUILDING`, …), or null when unreadable. */
242
+ stage: string | null;
243
+ }
244
+
245
+ export interface ReleaseTag {
246
+ version: string;
247
+ sha: string;
248
+ date: string;
249
+ subject: string;
250
+ }
251
+
252
+ export interface ReleasesPayload {
253
+ /** The version THIS container is running (`AIOS_VERSION`), for the "you are here" case. */
254
+ here: string;
255
+ environments: ReleaseEnv[];
256
+ releases: ReleaseTag[];
257
+ /** The CLI that moves a version. In the payload because the panel is read-only by ruling. */
258
+ promote: string;
259
+ }
260
+
261
+ export function getReleases(): Promise<ApiResult<ReleasesPayload>> {
262
+ return call("/releases");
263
+ }
web/src/shell/NavExtras.tsx CHANGED
@@ -180,6 +180,7 @@ export function RowMenu({
180
  icon,
181
  onIcon,
182
  onIconClear,
 
183
  }: {
184
  entryLabel: string;
185
  canSchema: boolean;
@@ -199,6 +200,9 @@ export function RowMenu({
199
  icon?: FolderIcon;
200
  onIcon?: (icon: FolderIcon) => void;
201
  onIconClear?: () => void;
 
 
 
202
  }) {
203
  // Wave 14 C-NAVFOLD (ruling R10): the three-dots kept "View schema" ONLY.
204
  // Wave 19 (R8/C1) adds Rename and Change icon — the first two things a
@@ -210,7 +214,7 @@ export function RowMenu({
210
  const [name, setName] = useState(entryLabel);
211
  const renameOn = !!canRename && !!onRename;
212
  const iconOn = !!canIcon && !!onIcon;
213
- if (!canSchema && !renameOn && !iconOn) return null;
214
  const submitRename = () => {
215
  const clean = name.trim().replace(/\s+/g, " ");
216
  if (clean && clean !== entryLabel) onRename?.(clean);
@@ -312,6 +316,22 @@ export function RowMenu({
312
  View schema
313
  </button>
314
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  </>
316
  )}
317
  </MenuShell>
 
180
  icon,
181
  onIcon,
182
  onIconClear,
183
+ onShare,
184
  }: {
185
  entryLabel: string;
186
  canSchema: boolean;
 
200
  icon?: FolderIcon;
201
  onIcon?: (icon: FolderIcon) => void;
202
  onIconClear?: () => void;
203
+ /** WAVE 20 item 18 (C-SHARE) — open the access editor for THIS database.
204
+ * Absent = the row does not offer sharing (a group head has nothing to share). */
205
+ onShare?: () => void;
206
  }) {
207
  // Wave 14 C-NAVFOLD (ruling R10): the three-dots kept "View schema" ONLY.
208
  // Wave 19 (R8/C1) adds Rename and Change icon — the first two things a
 
214
  const [name, setName] = useState(entryLabel);
215
  const renameOn = !!canRename && !!onRename;
216
  const iconOn = !!canIcon && !!onIcon;
217
+ if (!canSchema && !renameOn && !iconOn && !onShare) return null;
218
  const submitRename = () => {
219
  const clean = name.trim().replace(/\s+/g, " ");
220
  if (clean && clean !== entryLabel) onRename?.(clean);
 
316
  View schema
317
  </button>
318
  ) : null}
319
+ {/* WAVE 20 item 18 (R10, C-SHARE) — a DATABASE shares with the same two
320
+ roles a view does. Gated on `onShare`, which the Shell passes only
321
+ for a real database row, so a family head or a hand-off link never
322
+ offers to share something that has no id on the server. */}
323
+ {onShare ? (
324
+ <button
325
+ type="button"
326
+ className="shell-navmenu-item"
327
+ onClick={() => {
328
+ close();
329
+ onShare();
330
+ }}
331
+ >
332
+ Share…
333
+ </button>
334
+ ) : null}
335
  </>
336
  )}
337
  </MenuShell>
web/src/shell/ShareDialog.tsx ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // shell/ShareDialog.tsx — WAVE 20 items 18 / 23 / 26 (R10, contract C-SHARE):
3
+ // ONE dialog that shares a view, a folder or a database, and edits who already
4
+ // has it.
5
+ //
6
+ // ONE dialog for three kinds is the ruling, not a convenience: R10 says folders
7
+ // and databases share "with the SAME two-role vocabulary views use". Three
8
+ // dialogs would drift into three vocabularies within a wave.
9
+ //
10
+ // It renders over the ALREADY-LOADED state and decides nothing itself — every
11
+ // rule (what a junk entry means, who may administer, what the PUT carries) is in
12
+ // `shareModel.ts`, where the access gate can run it.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { useCallback, useEffect, useState } from "react";
16
+ import { API_V1, CREDENTIALS } from "../apiContract";
17
+ import {
18
+ SHARE_ROLES,
19
+ addablePeople,
20
+ parseShare,
21
+ sharePutBody,
22
+ shareSummary,
23
+ withEntry,
24
+ withoutEntry,
25
+ } from "./shareModel";
26
+ import type { ShareEntry, ShareKind, ShareRole, ShareState } from "./shareModel";
27
+
28
+ const KIND_WORD: Record<ShareKind, string> = {
29
+ view: "view",
30
+ folder: "folder",
31
+ database: "database",
32
+ };
33
+
34
+ /** What each role MEANS on each kind, in the reader's own terms. A role picker
35
+ * whose options are two nouns makes the reader guess; these are the sentences
36
+ * the view rail already uses ("Anyone who can see this table can change it"),
37
+ * extended to the two new kinds rather than re-invented for them. */
38
+ const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
39
+ view: {
40
+ view: "Can open this view. Cannot rename, refilter or delete it.",
41
+ edit: "Can change this view's filters, sorts and columns.",
42
+ },
43
+ folder: {
44
+ view: "Can open the folder and the views inside it.",
45
+ edit: "Can rename the folder and move views in and out of it.",
46
+ },
47
+ database: {
48
+ view: "Can open this database and read its records.",
49
+ edit: "Can add, edit and delete its records.",
50
+ },
51
+ };
52
+
53
+ export default function ShareDialog({
54
+ kind,
55
+ id,
56
+ label,
57
+ me,
58
+ onClose,
59
+ onToast,
60
+ }: {
61
+ kind: ShareKind;
62
+ id: string;
63
+ label: string;
64
+ /** The signed-in account's username, so the reader recognises themselves in the
65
+ * list. `people` is deliberately the OTHER accounts (it is the add-picker's
66
+ * source), so without this the owner row prints a raw login where every other
67
+ * row prints a name. */
68
+ me: string;
69
+ onClose: () => void;
70
+ onToast: (message: string) => void;
71
+ }) {
72
+ const [state, setState] = useState<ShareState | null>(null);
73
+ const [entries, setEntries] = useState<ShareEntry[]>([]);
74
+ const [error, setError] = useState("");
75
+ const [busy, setBusy] = useState(false);
76
+ const [pick, setPick] = useState("");
77
+ const [pickRole, setPickRole] = useState<ShareRole>("view");
78
+
79
+ const path = `${API_V1}/share/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`;
80
+
81
+ useEffect(() => {
82
+ let dead = false;
83
+ setError("");
84
+ void (async () => {
85
+ try {
86
+ const res = await fetch(path, { credentials: CREDENTIALS });
87
+ if (!res.ok) {
88
+ // 4xx text is policy the reader needs; a 5xx's internals are not theirs.
89
+ if (!dead) setError(res.status >= 500
90
+ ? "Something went wrong on our side. Try again in a moment."
91
+ : `The server answered ${res.status}.`);
92
+ return;
93
+ }
94
+ const body = (await res.json().catch(() => null)) as unknown;
95
+ if (dead) return;
96
+ const parsed = parseShare(body);
97
+ setState(parsed);
98
+ setEntries(parsed.entries);
99
+ } catch {
100
+ if (!dead) setError("Cannot reach the server.");
101
+ }
102
+ })();
103
+ return () => {
104
+ dead = true;
105
+ };
106
+ }, [path]);
107
+
108
+ // ⛔ ESCAPE CLOSES A MODAL, or its scrim becomes a trap — the wave-18 lesson this
109
+ // shell already carries at its other two dialogs.
110
+ useEffect(() => {
111
+ const onKey = (e: KeyboardEvent) => {
112
+ if (e.key === "Escape" && !busy) onClose();
113
+ };
114
+ window.addEventListener("keydown", onKey);
115
+ return () => window.removeEventListener("keydown", onKey);
116
+ }, [busy, onClose]);
117
+
118
+ const save = useCallback(
119
+ async (next: ShareEntry[]) => {
120
+ setBusy(true);
121
+ setError("");
122
+ try {
123
+ const res = await fetch(path, {
124
+ method: "PUT",
125
+ credentials: CREDENTIALS,
126
+ headers: { "Content-Type": "application/json" },
127
+ // The WHOLE list, every time: the PUT replaces, so a body assembled from
128
+ // a delta would revoke everyone it failed to mention.
129
+ body: JSON.stringify(sharePutBody(next)),
130
+ });
131
+ if (!res.ok) {
132
+ const body = (await res.json().catch(() => null)) as
133
+ | { error?: { message?: string } }
134
+ | null;
135
+ setError(
136
+ res.status >= 500
137
+ ? "Something went wrong on our side. Try again in a moment."
138
+ : body?.error?.message || `The server answered ${res.status}.`
139
+ );
140
+ return false;
141
+ }
142
+ const body = (await res.json().catch(() => null)) as unknown;
143
+ // Re-read the SERVER's copy rather than trusting the draft: `_clean_entries`
144
+ // drops what it will not store, and an editor that kept showing a row the
145
+ // store rejected would be the "shared, silently inert" failure this feature
146
+ // exists to avoid.
147
+ // ⚠ Only `.entries` is consumed here — the PUT answers with the stored record
148
+ // (`{owner, entries}`) and says nothing about this session's standing, so the
149
+ // literal below feeds the parser's required field and is never read back. The
150
+ // editor's `mayAdminister` stays the one the GET established; a save cannot
151
+ // promote anybody, and this line must never be the reason it looks like it can.
152
+ const saved = parseShare({ ...(body as object), mayAdminister: true });
153
+ setEntries(saved.entries);
154
+ return true;
155
+ } catch {
156
+ setError("Cannot reach the server.");
157
+ return false;
158
+ } finally {
159
+ setBusy(false);
160
+ }
161
+ },
162
+ [path]
163
+ );
164
+
165
+ const mayAdminister = !!state?.mayAdminister;
166
+ const options = state ? addablePeople(state.people, entries) : [];
167
+ const nameOf = (user: string) =>
168
+ user === "*"
169
+ ? "Everyone"
170
+ : user && user === me.trim().toLowerCase()
171
+ ? "You"
172
+ : state?.people.find((p) => p.user === user)?.name ?? user;
173
+
174
+ return (
175
+ <div className="shell-newdb-scrim" onClick={() => (busy ? null : onClose())}>
176
+ <div
177
+ className="shell-newdb shell-share"
178
+ role="dialog"
179
+ aria-label={`Share ${label}`}
180
+ onClick={(e) => e.stopPropagation()}
181
+ >
182
+ <h2>Share {KIND_WORD[kind]}</h2>
183
+ <p className="shell-newdb-sub">
184
+ <strong>{label}</strong> — who can reach it, and what they can do with it. Sharing
185
+ never widens past this workspace: everyone here can already open the surface it
186
+ lives on.
187
+ </p>
188
+
189
+ {!state && !error ? (
190
+ <div className="shell-share-wait">
191
+ <span className="lp-spin" role="status" aria-label="Loading" />
192
+ </div>
193
+ ) : null}
194
+
195
+ {state ? (
196
+ <>
197
+ {/* THE MANAGE-ACCESS EDITOR (item 23): the list first, because the
198
+ question people open this for is "who has this already" — with the
199
+ one-line answer above it, since the fact that changes everything
200
+ ("Everyone can edit") is the one a list of rows buries. */}
201
+ <p className="shell-share-summary">{shareSummary(entries)}</p>
202
+ <div className="shell-share-list">
203
+ {state.owner ? (
204
+ <div className="shell-share-row is-owner">
205
+ <span className="shell-share-who">{nameOf(state.owner)}</span>
206
+ <span className="shell-share-role">Owner</span>
207
+ </div>
208
+ ) : null}
209
+ {entries.length === 0 ? (
210
+ <div className="shell-share-empty">
211
+ Not shared with anyone yet.
212
+ </div>
213
+ ) : null}
214
+ {entries.map((e) => (
215
+ <div className="shell-share-row" key={e.user}>
216
+ <span className="shell-share-who">{nameOf(e.user)}</span>
217
+ <select
218
+ className="shell-share-select"
219
+ value={e.role}
220
+ disabled={!mayAdminister || busy}
221
+ aria-label={`Role for ${nameOf(e.user)}`}
222
+ onChange={(ev) => {
223
+ const next = withEntry(entries, e.user, ev.target.value as ShareRole);
224
+ setEntries(next);
225
+ void save(next);
226
+ }}
227
+ >
228
+ {SHARE_ROLES.map((r) => (
229
+ <option key={r} value={r}>
230
+ {r === "edit" ? "Can edit" : "Can view"}
231
+ </option>
232
+ ))}
233
+ </select>
234
+ <button
235
+ type="button"
236
+ className="shell-share-revoke"
237
+ disabled={!mayAdminister || busy}
238
+ onClick={() => {
239
+ const next = withoutEntry(entries, e.user);
240
+ setEntries(next);
241
+ void save(next).then((ok) => {
242
+ if (ok) onToast(`${nameOf(e.user)} no longer has this ${KIND_WORD[kind]}.`);
243
+ });
244
+ }}
245
+ >
246
+ Remove
247
+ </button>
248
+ </div>
249
+ ))}
250
+ </div>
251
+
252
+ {mayAdminister ? (
253
+ <div className="shell-share-add">
254
+ <select
255
+ className="shell-share-select"
256
+ value={pick}
257
+ disabled={busy}
258
+ aria-label="Who to share with"
259
+ onChange={(e) => setPick(e.target.value)}
260
+ >
261
+ {/* ⚠ An explicit placeholder OPTION, not a blank first row: a
262
+ <select> whose value matches nothing renders its first option
263
+ while holding "", so the box would read as a chosen person and
264
+ the button beside it would grant somebody nobody picked
265
+ ([[cg-condition-builder-items]]). */}
266
+ <option value="">Choose a person…</option>
267
+ <option value="*">Everyone in this workspace</option>
268
+ {options.map((p) => (
269
+ <option key={p.user} value={p.user}>
270
+ {p.name}
271
+ </option>
272
+ ))}
273
+ </select>
274
+ <select
275
+ className="shell-share-select"
276
+ value={pickRole}
277
+ disabled={busy}
278
+ aria-label="Role for the person being added"
279
+ onChange={(e) => setPickRole(e.target.value as ShareRole)}
280
+ >
281
+ {SHARE_ROLES.map((r) => (
282
+ <option key={r} value={r}>
283
+ {r === "edit" ? "Can edit" : "Can view"}
284
+ </option>
285
+ ))}
286
+ </select>
287
+ <button
288
+ type="button"
289
+ className="login-submit shell-share-grant"
290
+ disabled={busy || !pick}
291
+ onClick={() => {
292
+ const next = withEntry(entries, pick, pickRole);
293
+ setEntries(next);
294
+ void save(next).then((ok) => {
295
+ if (ok) {
296
+ onToast(`${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`);
297
+ setPick("");
298
+ }
299
+ });
300
+ }}
301
+ >
302
+ Share
303
+ </button>
304
+ <p className="shell-share-blurb">{ROLE_BLURB[kind][pickRole]}</p>
305
+ </div>
306
+ ) : (
307
+ // ⛔ NOT A HIDDEN EDITOR — a stated refusal. A collaborator who can
308
+ // change this object's CONTENT still cannot change who else reaches
309
+ // it (the server's rule; this is the courtesy half). Saying why beats
310
+ // greying three controls and letting the reader guess.
311
+ <p className="shell-share-note">
312
+ Only the owner of this {KIND_WORD[kind]} — or an administrator — can change who
313
+ it is shared with. You can still use it as your role allows.
314
+ </p>
315
+ )}
316
+ </>
317
+ ) : null}
318
+
319
+ {error ? <p className="shell-newdb-err">{error}</p> : null}
320
+ <div className="shell-newdb-actions">
321
+ <button type="button" className="login-submit" disabled={busy} onClick={onClose}>
322
+ Done
323
+ </button>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ );
328
+ }
web/src/shell/Shell.tsx CHANGED
@@ -36,15 +36,30 @@ import AutomationSurface from "../automation/AutomationSurface";
36
  import CustomerGrid from "../customer-grid/CustomerGrid";
37
  import { clearCustomersCache } from "../customer-grid/apiBridge";
38
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
39
- import { API_V1, CREDENTIALS, DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT } from "../apiContract";
 
 
40
  import { PageSurface } from "../pages/PageSurface";
41
  import { SettingsModal } from "../settings/SettingsModal";
42
  import type { SettingsSection } from "../settings/SettingsModal";
43
  import { Brand } from "./Brand";
44
  import LoginPage from "./LoginPage";
45
- import { EMPTY_NAV_PREFS, ENVELOPE_KEYS, MAX_NAV_FOLDERS, appLink, defaultRoute, fetchNav, fetchNavPrefs, foldNav, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
46
  import type { NavEntry, NavMetaPatch, NavPage, NavPrefs } from "./nav";
47
  import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras";
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  // WAVE 19 R8 — the chosen database mark. Read-only across the session fence.
49
  import { FolderMark } from "../customer-grid/icons";
50
  import type { FolderIcon } from "../customer-grid/types";
@@ -140,6 +155,44 @@ function ExtIcon() {
140
  );
141
  }
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  /** The ⋯ affordance on the account row. */
144
  function DotsIcon() {
145
  return (
@@ -177,6 +230,18 @@ function AutoIcon() {
177
  );
178
  }
179
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  /** Owner item 11 — the rail toggle: three horizontal bars. One glyph for both rails (the
181
  * views rail draws the same geometry), so "minimize a panel" reads as one idea. */
182
  function RailToggleIcon() {
@@ -354,6 +419,21 @@ export default function Shell() {
354
  // `null` = closed. The SECTION is the open state, so "Settings" and
355
  // "Users" are one component reached two ways rather than two dialogs.
356
  const [settings, setSettings] = useState<SettingsSection | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  const route = useHashRoute();
358
  // Owner items 10/11 — the navigation folds to a slim strip: by the toggle in the rail head,
359
  // or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
@@ -389,6 +469,26 @@ export default function Shell() {
389
  []
390
  );
391
  const tipLeave = useCallback(() => setNavTip(null), []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
  // The window signals the data layer raises (apiContract.ts). It talks to the
394
  // frame this way because `customer-grid/**` is host-neutral — the same tree
@@ -399,15 +499,22 @@ export default function Shell() {
399
  setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
400
  const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
401
  const onNavMinimize = () => setNavCollapsed(true);
 
 
 
 
 
402
  window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
403
  window.addEventListener(DATA_ERROR_EVENT, onDataError);
404
  window.addEventListener(TOAST_EVENT, onToast);
405
  window.addEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
 
406
  return () => {
407
  window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
408
  window.removeEventListener(DATA_ERROR_EVENT, onDataError);
409
  window.removeEventListener(TOAST_EVENT, onToast);
410
  window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
 
411
  };
412
  }, []);
413
 
@@ -593,6 +700,61 @@ export default function Shell() {
593
  // holds no copy to update. Painting a new name locally would mean rendering a
594
  // label from one source while every other reader of the nav still had the old
595
  // one; re-asking is one cheap round trip and it cannot disagree with itself.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  const commitNavMeta = useCallback(async (key: string, patch: NavMetaPatch) => {
597
  const ok = await saveNavMeta(key, patch);
598
  if (ok) setNavEpoch((e) => e + 1);
@@ -644,7 +806,6 @@ export default function Shell() {
644
  const [newDb, setNewDb] = useState<null | { name: string; busy: boolean; err: string }>(null);
645
  const newDbOpenRef = useRef(false);
646
  newDbOpenRef.current = newDb !== null;
647
- const [utBusy, setUtBusy] = useState(false);
648
  const createDb = useCallback(async () => {
649
  setNewDb((cur) => {
650
  if (!cur || cur.busy || !cur.name.trim()) return cur;
@@ -675,32 +836,12 @@ export default function Shell() {
675
  return { ...cur, busy: true, err: "" };
676
  });
677
  }, []);
678
- const addRecord = useCallback(async (key: string) => {
679
- setUtBusy(true);
680
- try {
681
- const res = await fetch(`${API_V1}/tables/${encodeURIComponent(key)}/rows`, {
682
- method: "POST",
683
- credentials: CREDENTIALS,
684
- headers: { "Content-Type": "application/json" },
685
- body: JSON.stringify({ values: {} }),
686
- });
687
- if (res.ok) {
688
- // The grid's rows cache would happily serve the pre-add pull for 5 minutes —
689
- // clear it, then tell the current topic to refetch (the C3-UT amendment's event).
690
- clearCustomersCache();
691
- window.dispatchEvent(new Event(ROWS_STALE_EVENT));
692
- } else {
693
- const body = (await res.json().catch(() => null)) as
694
- | { error?: { message?: string } }
695
- | null;
696
- setToast(body?.error?.message || "The row was not added.");
697
- }
698
- } catch {
699
- setToast("Cannot reach the server.");
700
- } finally {
701
- setUtBusy(false);
702
- }
703
- }, []);
704
 
705
  if (session.phase === "checking") {
706
  // Deliberately wordless. A "Loading…" line here would flash for one round
@@ -751,7 +892,13 @@ export default function Shell() {
751
 
752
  return (
753
  <div className="shell-root">
754
- <aside className={"shell-side" + (navCollapsed ? " is-collapsed" : "")}>
 
 
 
 
 
 
755
  {/* The PRODUCT brand — the same mark the Streamlit host paints, from the
756
  same generated file, so the two shells cannot drift. "Royal Imports"
757
  stays on business documents only. */}
@@ -841,6 +988,37 @@ export default function Shell() {
841
  </a>
842
  ) : null}
843
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
844
  <div
845
  className={
846
  "shell-nav-list" + (dropTarget === "__root__" ? " is-drop-root" : "")
@@ -1026,6 +1204,22 @@ export default function Shell() {
1026
  void commitNavMeta(item.key, { icon })
1027
  }
1028
  onIconClear={() => void commitNavMeta(item.key, { icon: null })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1029
  />
1030
  )}
1031
  </div>
@@ -1152,20 +1346,14 @@ export default function Shell() {
1152
  // view state never bleeds into the other's. Wave 18 (C3-UT): a `ut_` route is a
1153
  // USER TABLE through the same tree — scope IS the key — with the shell-owned
1154
  // Add-record bar above it (the doc's amendment: zero CustomerGrid edits).
1155
- <div className={active.key.startsWith("ut_") ? "shell-ut-frame" : "shell-grid-host"}>
1156
- {active.key.startsWith("ut_") ? (
1157
- <div className="shell-ut-bar">
1158
- <span className="shell-ut-name">{active.label}</span>
1159
- <button
1160
- type="button"
1161
- className="shell-ut-add"
1162
- onClick={() => void addRecord(active.key)}
1163
- disabled={utBusy}
1164
- >
1165
- {utBusy ? "Adding…" : "Add record"}
1166
- </button>
1167
- </div>
1168
- ) : null}
1169
  <div className="shell-grid-host">
1170
  <OverlayProvider>
1171
  <CustomerGrid
@@ -1325,6 +1513,41 @@ export default function Shell() {
1325
  />
1326
  ) : null}
1327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1328
  {/* C-SCHEMA: the database schema drawer, over everything but the toast. */}
1329
  {schemaFor ? (
1330
  <SchemaDrawer schemaKey={schemaFor} onClose={() => setSchemaFor(null)} />
 
36
  import CustomerGrid from "../customer-grid/CustomerGrid";
37
  import { clearCustomersCache } from "../customer-grid/apiBridge";
38
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
39
+ // ROWS_STALE_EVENT left with `addRecord` (wave 20 item 4): the shell no longer writes rows,
40
+ // so it no longer has to tell the grid that it did.
41
+ import { API_V1, CREDENTIALS, DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT } from "../apiContract";
42
  import { PageSurface } from "../pages/PageSurface";
43
  import { SettingsModal } from "../settings/SettingsModal";
44
  import type { SettingsSection } from "../settings/SettingsModal";
45
  import { Brand } from "./Brand";
46
  import LoginPage from "./LoginPage";
47
+ import { EMPTY_NAV_PREFS, ENVELOPE_KEYS, MAX_NAV_FOLDERS, appLink, dbChipClass, defaultRoute, fetchNav, fetchNavPrefs, foldNav, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
48
  import type { NavEntry, NavMetaPatch, NavPage, NavPrefs } from "./nav";
49
  import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras";
50
+ import AlertsPane from "../alerts/AlertsPane";
51
+ import { createAlert, fetchInbox } from "../alerts/alertsApi";
52
+ import {
53
+ ALERT_CREATE_EVENT,
54
+ EMPTY_INBOX,
55
+ badgeText,
56
+ parseAlertCreate,
57
+ routeForTopic,
58
+ } from "../alerts/alertsModel";
59
+ import type { Inbox } from "../alerts/alertsModel";
60
+ import ShareDialog from "./ShareDialog";
61
+ import { SHARE_OPEN_EVENT, parseShareRequest } from "./shareModel";
62
+ import type { ShareRequest } from "./shareModel";
63
  // WAVE 19 R8 — the chosen database mark. Read-only across the session fence.
64
  import { FolderMark } from "../customer-grid/icons";
65
  import type { FolderIcon } from "../customer-grid/types";
 
155
  );
156
  }
157
 
158
+ /**
159
+ * WAVE 20 item 4 (R8 / C-ADDROW) — THE UNIVERSAL DATABASE HEADER.
160
+ *
161
+ * One header, identical on every database (`reference/Airtable 6.png`): a chip in
162
+ * a bold colour carrying the database's mark, then its name. It replaces
163
+ * `shell-ut-bar`, which existed on USER TABLES only — so the two built-in
164
+ * databases had no header at all, and the one place the product named the thing
165
+ * you were looking at appeared or vanished depending on where the table came
166
+ * from. R8 makes it universal.
167
+ *
168
+ * The "Add record" button the old bar carried is GONE with it, deliberately: R8
169
+ * replaces it with the grid's own trailing "+" row (S3's half of C-ADDROW), on
170
+ * user databases only — a connector-backed table's rows are read-synced, and a
171
+ * "+" that must refuse is a fake affordance.
172
+ *
173
+ * The chip is decorative and says so: the name beside it is real text, so a
174
+ * second announcement of the same fact is noise to a screen reader.
175
+ */
176
+ function DbHead({ label, icon }: { label: string; icon?: FolderIcon }) {
177
+ return (
178
+ <div className="shell-db-head">
179
+ <span className={dbChipClass(icon)} aria-hidden="true">
180
+ {/* The database's own mark when it has one, the cylinder when it does not —
181
+ the same pair the rail row draws, so the header and the nav agree. Both
182
+ paint in the chip's ink (the stylesheet's two overrides): `FolderMark`
183
+ would otherwise stroke its pastel `-deep`, which is measured against the
184
+ WHITE rail and disappears on its own tone. */}
185
+ {icon ? <FolderMark icon={icon} size={16} /> : <DbIcon />}
186
+ </span>
187
+ {/* An `h1`, not a styled span: this is the first time the work surface has NAMED itself,
188
+ and the name of the thing you are looking at is what a heading is for. Every other
189
+ full-pane surface in this shell (`shell-placeholder`) already uses one, and they never
190
+ render together — so the page gains a heading rather than a second one. */}
191
+ <h1 className="shell-db-name">{label}</h1>
192
+ </div>
193
+ );
194
+ }
195
+
196
  /** The ⋯ affordance on the account row. */
197
  function DotsIcon() {
198
  return (
 
230
  );
231
  }
232
 
233
+ /** WAVE 20 item 25 — the Alerts row's mark. A bell, in the same 16x16 stroke
234
+ * vocabulary as its neighbours, so it sits beside the sparkle and the flow rather
235
+ * than arriving from another icon set. */
236
+ function BellIcon() {
237
+ return (
238
+ <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
239
+ <path d="M8 2.4a3.5 3.5 0 0 1 3.5 3.5v2.3l1.2 2H3.3l1.2-2V5.9A3.5 3.5 0 0 1 8 2.4Z" />
240
+ <path d="M6.6 12.4a1.5 1.5 0 0 0 2.8 0" />
241
+ </svg>
242
+ );
243
+ }
244
+
245
  /** Owner item 11 — the rail toggle: three horizontal bars. One glyph for both rails (the
246
  * views rail draws the same geometry), so "minimize a panel" reads as one idea. */
247
  function RailToggleIcon() {
 
419
  // `null` = closed. The SECTION is the open state, so "Settings" and
420
  // "Users" are one component reached two ways rather than two dialogs.
421
  const [settings, setSettings] = useState<SettingsSection | null>(null);
422
+ // ── WAVE 20 items 18/23/26 (R10, C-SHARE): the access editor, for all three kinds ──
423
+ //
424
+ // The dialog is the SHELL's, and it is opened from two places that must not import
425
+ // each other: this frame (a database's ⋯) and the views rail (a view's or folder's
426
+ // ⋯), which is host-neutral `customer-grid/` code. So the rail raises a window event
427
+ // — the same channel the grid already uses for toasts and staleness — and the frame,
428
+ // which owns modals, renders it.
429
+ const [shareFor, setShareFor] = useState<ShareRequest | null>(null);
430
+ // ── WAVE 20 item 25 (C-ALERT): the inbox lives beside the nav, not in it ──────
431
+ //
432
+ // `inbox` is held HERE rather than inside the pane because the badge outlives the
433
+ // pane: the count has to be on screen while the panel is shut, which is the only
434
+ // state in which a badge is useful at all.
435
+ const [alertsOpen, setAlertsOpen] = useState(false);
436
+ const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX);
437
  const route = useHashRoute();
438
  // Owner items 10/11 — the navigation folds to a slim strip: by the toggle in the rail head,
439
  // or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
 
469
  []
470
  );
471
  const tipLeave = useCallback(() => setNavTip(null), []);
472
+ // ── WAVE 20 item 7 (R9): the collapsed strip expands from its own BACKGROUND ──
473
+ //
474
+ // Until now the 56px strip had exactly one way back — the logo — and the owner
475
+ // kept clicking the empty space beside an icon, which did nothing at all. R9:
476
+ // "clicking the collapsed strip's blank/background area EXPANDS it; page icons
477
+ // keep navigating directly."
478
+ //
479
+ // ⚠ THE TEST IS THE TARGET, NOT THE CURRENT ELEMENT. `e.currentTarget === e.target`
480
+ // would only fire on the aside's own few pixels of padding — every gap the user
481
+ // actually clicks belongs to `.shell-nav` or `.shell-nav-list`, which are not
482
+ // interactive but ARE elements. So the rule is inverted: a click that landed on
483
+ // (or inside) a control does what that control does; anything else is background.
484
+ // ⛔ `closest` and not a tag check: the click usually lands on the `<svg>` or the
485
+ // `<span>` INSIDE the link, so testing the target's own tagName would treat every
486
+ // icon click as background and swallow the navigation R9 explicitly preserves.
487
+ const expandOnBlank = useCallback((e: ReactMouseEvent<HTMLElement>) => {
488
+ const hit = e.target as Element | null;
489
+ if (hit?.closest?.('a,button,input,textarea,select,[role="menuitem"],[role="dialog"]')) return;
490
+ setNavCollapsed(false);
491
+ }, []);
492
 
493
  // The window signals the data layer raises (apiContract.ts). It talks to the
494
  // frame this way because `customer-grid/**` is host-neutral — the same tree
 
499
  setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
500
  const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
501
  const onNavMinimize = () => setNavCollapsed(true);
502
+ // C-SHARE: the rail asks, the frame opens. A malformed detail opens NOTHING —
503
+ // `parseShareRequest` fail-closes on an unknown kind rather than launching a
504
+ // dialog whose every save would 400.
505
+ const onShareOpen = (e: Event) =>
506
+ setShareFor(parseShareRequest((e as CustomEvent).detail));
507
  window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
508
  window.addEventListener(DATA_ERROR_EVENT, onDataError);
509
  window.addEventListener(TOAST_EVENT, onToast);
510
  window.addEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
511
+ window.addEventListener(SHARE_OPEN_EVENT, onShareOpen);
512
  return () => {
513
  window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
514
  window.removeEventListener(DATA_ERROR_EVENT, onDataError);
515
  window.removeEventListener(TOAST_EVENT, onToast);
516
  window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
517
+ window.removeEventListener(SHARE_OPEN_EVENT, onShareOpen);
518
  };
519
  }, []);
520
 
 
700
  // holds no copy to update. Painting a new name locally would mean rendering a
701
  // label from one source while every other reader of the nav still had the old
702
  // one; re-asking is one cheap round trip and it cannot disagree with itself.
703
+ // ── C-ALERT: the unread count, and the door that MAKES an alert ───────────────
704
+ //
705
+ // Polled once per session boot and after every write this frame knows about —
706
+ // never on a timer. A nav badge that re-fetches every 30 seconds is a background
707
+ // request per user per minute for a number nobody is looking at; the honest
708
+ // refresh points are "the app just started" and "you just did something".
709
+ useEffect(() => {
710
+ if (who === null) {
711
+ setInbox(EMPTY_INBOX);
712
+ return;
713
+ }
714
+ let dead = false;
715
+ const pull = () => {
716
+ void fetchInbox().then((r) => {
717
+ if (!dead && r.ok) setInbox(r.value);
718
+ });
719
+ };
720
+ pull();
721
+ // ...and again when the tab is looked at, which is the honest substitute for a
722
+ // timer: news arrives while you are elsewhere, and "elsewhere" is exactly when a
723
+ // poll would be wasted. One request per return to the tab, none while it sits.
724
+ window.addEventListener("focus", pull);
725
+ return () => {
726
+ dead = true;
727
+ window.removeEventListener("focus", pull);
728
+ };
729
+ }, [who]);
730
+
731
+ // The rail asks for an alert on a view; the FRAME answers, because the rail does
732
+ // not know its own topic — it holds views, and the scope key is the route's.
733
+ useEffect(() => {
734
+ const onCreate = (e: Event) => {
735
+ const req = parseAlertCreate((e as CustomEvent).detail);
736
+ if (!req) return;
737
+ const key = window.location.hash.replace(/^#\/?/, "");
738
+ const topic =
739
+ key === "product_data" ? "product" : key.startsWith("ut_") ? key : "customer";
740
+ void createAlert(req.viewId, topic, req.label).then((r) => {
741
+ if (!r.ok) {
742
+ // ⚠ The 400 `no_filter` is a real answer and rides through verbatim: a
743
+ // view with no active filter matches everything, so an alert on it could
744
+ // never see an entrant. "Refused, and here is why" beats a dead bell.
745
+ setToast(r.message);
746
+ return;
747
+ }
748
+ setToast(`Alerting on "${req.label}". New records that enter it appear under Alerts.`);
749
+ void fetchInbox().then((got) => {
750
+ if (got.ok) setInbox(got.value);
751
+ });
752
+ });
753
+ };
754
+ window.addEventListener(ALERT_CREATE_EVENT, onCreate);
755
+ return () => window.removeEventListener(ALERT_CREATE_EVENT, onCreate);
756
+ }, []);
757
+
758
  const commitNavMeta = useCallback(async (key: string, patch: NavMetaPatch) => {
759
  const ok = await saveNavMeta(key, patch);
760
  if (ok) setNavEpoch((e) => e + 1);
 
806
  const [newDb, setNewDb] = useState<null | { name: string; busy: boolean; err: string }>(null);
807
  const newDbOpenRef = useRef(false);
808
  newDbOpenRef.current = newDb !== null;
 
809
  const createDb = useCallback(async () => {
810
  setNewDb((cur) => {
811
  if (!cur || cur.busy || !cur.name.trim()) return cur;
 
836
  return { ...cur, busy: true, err: "" };
837
  });
838
  }, []);
839
+ // ⛔ `addRecord` LIVED HERE AND IS DELETED WITH THE BAR THAT CALLED IT (wave 20 item 4,
840
+ // R8 / C-ADDROW). The shell was POSTing `/tables/{key}/rows` itself, clearing the grid's
841
+ // cache and firing ROWS_STALE_EVENT to make the new row appear — a write path owned by
842
+ // the frame, for a surface the grid owns. R8 moves the affordance into the grid as a
843
+ // trailing "+" row (S3's half), where the row it adds is the row you are looking at, so
844
+ // the shell stops being a second writer of table data.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
845
 
846
  if (session.phase === "checking") {
847
  // Deliberately wordless. A "Loading…" line here would flash for one round
 
892
 
893
  return (
894
  <div className="shell-root">
895
+ <aside
896
+ className={"shell-side" + (navCollapsed ? " is-collapsed" : "")}
897
+ // Item 7 (R9) — background click expands. Bound only while collapsed, so the
898
+ // open rail is exactly what it was; the accessible route stays the labelled
899
+ // brand button below ("Expand navigation"), which is what a keyboard reaches.
900
+ onClick={navCollapsed ? expandOnBlank : undefined}
901
+ >
902
  {/* The PRODUCT brand — the same mark the Streamlit host paints, from the
903
  same generated file, so the two shells cannot drift. "Royal Imports"
904
  stays on business documents only. */}
 
988
  </a>
989
  ) : null}
990
 
991
+ {/* WAVE 20 item 25 (C-ALERT) — "Alerts", directly under Automation and above
992
+ the database band, with the unread count on it.
993
+ ⛔ A BUTTON, NOT A NAV LINK, and the distinction is this shell's oldest
994
+ rule: the nav is server-filtered and an undeclared surface is denied, so a
995
+ client-invented `#/alerts` route would be exactly the hard-coded surface
996
+ the frame refuses to have. An inbox is not a granted module — it is this
997
+ account's own notifications about its own views — so it opens a panel, the
998
+ way the AI assistant does. */}
999
+ <button
1000
+ type="button"
1001
+ className={"shell-nav-item shell-nav-alerts" + (alertsOpen ? " is-active" : "")}
1002
+ aria-haspopup="dialog"
1003
+ aria-expanded={alertsOpen}
1004
+ onMouseEnter={navCollapsed ? tipEnter("Alerts") : undefined}
1005
+ onMouseLeave={navCollapsed ? tipLeave : undefined}
1006
+ onClick={() => setAlertsOpen(true)}
1007
+ >
1008
+ <BellIcon />
1009
+ <span className="shell-nav-label">Alerts</span>
1010
+ {badgeText(inbox.unread) ? (
1011
+ <span
1012
+ className="shell-nav-badge"
1013
+ // Not aria-hidden: the count IS the information, and a badge a screen
1014
+ // reader cannot see makes the row read as an empty inbox.
1015
+ aria-label={`${inbox.unread} unread`}
1016
+ >
1017
+ {badgeText(inbox.unread)}
1018
+ </span>
1019
+ ) : null}
1020
+ </button>
1021
+
1022
  <div
1023
  className={
1024
  "shell-nav-list" + (dropTarget === "__root__" ? " is-drop-root" : "")
 
1204
  void commitNavMeta(item.key, { icon })
1205
  }
1206
  onIconClear={() => void commitNavMeta(item.key, { icon: null })}
1207
+ // WAVE 20 item 18 (R10) — share THIS database. Offered on the
1208
+ // user's own tables only: `customer_data` and `product_data` are
1209
+ // registry surfaces whose reach is the permission wall's answer,
1210
+ // not one person's to grant ([[aios-permission-wall]]), and a
1211
+ // dialog that recorded a grant the module gate would then ignore
1212
+ // is the "shared, silently inert" failure in reverse.
1213
+ {...(item.key.startsWith("ut_")
1214
+ ? {
1215
+ onShare: () =>
1216
+ setShareFor({
1217
+ kind: "database",
1218
+ id: item.key,
1219
+ label: item.label,
1220
+ }),
1221
+ }
1222
+ : {})}
1223
  />
1224
  )}
1225
  </div>
 
1346
  // view state never bleeds into the other's. Wave 18 (C3-UT): a `ut_` route is a
1347
  // USER TABLE through the same tree — scope IS the key — with the shell-owned
1348
  // Add-record bar above it (the doc's amendment: zero CustomerGrid edits).
1349
+ // WAVE 20 item 4 (R8): ONE frame for every database. The old branch gave user
1350
+ // tables a header-plus-grid frame and the built-ins a bare grid, which is why
1351
+ // Customer and Product had nowhere to put a name.
1352
+ <div className="shell-db-frame">
1353
+ <DbHead
1354
+ label={active.label}
1355
+ {...(active.icon ? { icon: active.icon } : {})}
1356
+ />
 
 
 
 
 
 
1357
  <div className="shell-grid-host">
1358
  <OverlayProvider>
1359
  <CustomerGrid
 
1513
  />
1514
  ) : null}
1515
 
1516
+ {/* C-ALERT (item 25): the inbox. */}
1517
+ {alertsOpen ? (
1518
+ <AlertsPane
1519
+ onClose={() => setAlertsOpen(false)}
1520
+ onInbox={setInbox}
1521
+ onToast={setToast}
1522
+ onOpenView={(topic, viewId) => {
1523
+ const route = routeForTopic(topic);
1524
+ if (!route) return;
1525
+ window.location.hash = `#/${route}`;
1526
+ // ⚠ HALF A CLICK-THROUGH UNTIL S3 LISTENS (amendment A-S4-4): this lands
1527
+ // the reader on the right TABLE and asks the grid to select the view. The
1528
+ // grid owns view selection — its `activeViewId` is seeded from its own
1529
+ // storage key, which this frame does not know — so until the listener
1530
+ // exists the request is simply not heard. Navigating anyway is the honest
1531
+ // half: a notification that took you nowhere would be worse.
1532
+ window.dispatchEvent(
1533
+ new CustomEvent("aios:view-open", { detail: { topic, viewId } })
1534
+ );
1535
+ }}
1536
+ />
1537
+ ) : null}
1538
+
1539
+ {/* C-SHARE (items 18/23/26): the access editor — one dialog, three kinds. */}
1540
+ {shareFor ? (
1541
+ <ShareDialog
1542
+ kind={shareFor.kind}
1543
+ id={shareFor.id}
1544
+ label={shareFor.label}
1545
+ me={session.user.username}
1546
+ onClose={() => setShareFor(null)}
1547
+ onToast={setToast}
1548
+ />
1549
+ ) : null}
1550
+
1551
  {/* C-SCHEMA: the database schema drawer, over everything but the toast. */}
1552
  {schemaFor ? (
1553
  <SchemaDrawer schemaKey={schemaFor} onClose={() => setSchemaFor(null)} />
web/src/shell/nav.ts CHANGED
@@ -97,6 +97,35 @@ export function parseNavIcon(raw: unknown): FolderIcon | undefined {
97
  return { shape: r.shape as FolderIcon["shape"], tone: r.tone as FolderIcon["tone"] };
98
  }
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  /**
101
  * Read X2's `{pages:[…]}`. An entry with no key or no label is DROPPED rather
102
  * than rendered: a nav row with no name is a door with no sign on it, and
 
97
  return { shape: r.shape as FolderIcon["shape"], tone: r.tone as FolderIcon["tone"] };
98
  }
99
 
100
+ /**
101
+ * WAVE 20 item 4 (R8) — the class list for the universal database header's icon
102
+ * chip: `shell-db-chip`, plus a tone modifier when the database wears a mark.
103
+ *
104
+ * R8 asks for "an icon chip on a bold colour background on EVERY database", and
105
+ * that sentence hides a decision the JSX would otherwise bury: what colour does a
106
+ * database that never chose one get? Three cases, and they are here rather than
107
+ * inline so a gate can hold them (and so the answer is written once, not once per
108
+ * render branch):
109
+ *
110
+ * · no icon at all — every built-in and every un-styled table — takes the BASE
111
+ * chip, which the stylesheet paints in the brand primary. That is the
112
+ * "sensible default where unset" the ruling asks for, and it is why the base
113
+ * class carries a colour instead of leaving the chip transparent.
114
+ * · an icon takes its own TONE at the bold `-deep` weight, so the header agrees
115
+ * with the mark the same database wears in the rail.
116
+ * · an UNRECOGNISED tone falls back to the base, never `--unknown`: emitting a
117
+ * class no stylesheet defines would paint a chip with no background at all,
118
+ * and a white glyph on white is an invisible header. Same fail-safe posture
119
+ * as `parseNavIcon` above — a drifted vocabulary reverts to the default mark
120
+ * rather than breaking the frame.
121
+ */
122
+ export function dbChipClass(icon?: FolderIcon): string {
123
+ const base = "shell-db-chip";
124
+ const tone = icon?.tone;
125
+ if (!tone || !(FOLDER_TONES as readonly string[]).includes(tone)) return base;
126
+ return `${base} ${base}--${tone}`;
127
+ }
128
+
129
  /**
130
  * Read X2's `{pages:[…]}`. An entry with no key or no label is DROPPED rather
131
  * than rendered: a nav row with no name is a door with no sign on it, and
web/src/shell/shareModel.ts ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // shell/shareModel.ts — WAVE 20 items 18/23/26 (owner ruling R10, contract
3
+ // C-SHARE): the manage-access editor's PURE half.
4
+ //
5
+ // Reading and editing a grant set is arithmetic over a list, and every way it
6
+ // can be wrong is silent:
7
+ //
8
+ // · a grant stored against the wrong identifier binds to nobody (see the
9
+ // mailbox amendment A-S4-5 — `people` may arrive as display names while
10
+ // every server-side check compares login usernames);
11
+ // · a PUT that REPLACES, given a list assembled by halves, revokes people
12
+ // nobody meant to revoke — the API has no DELETE verb, so an omission IS a
13
+ // revocation and every edit must carry the whole set forward;
14
+ // · `mayAdminister` read loosely turns "you may edit this view" into "you may
15
+ // decide who else can", which is the privilege escalation R10's server half
16
+ // refuses in as many words.
17
+ //
18
+ // So the model is React-free and lives here, where `verify_login.py` — this
19
+ // codebase's ACCESS gate — can run it under node with negative controls. The
20
+ // dialog is a renderer over these functions and decides nothing.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** The three shareable kinds. Closed vocabulary: the server 400s anything else. */
24
+ export const SHARE_KINDS = ["view", "folder", "database"] as const;
25
+ export type ShareKind = (typeof SHARE_KINDS)[number];
26
+
27
+ /** The two roles, the same two words the view rail already speaks (R10). */
28
+ export const SHARE_ROLES = ["view", "edit"] as const;
29
+ export type ShareRole = (typeof SHARE_ROLES)[number];
30
+
31
+ /** The wildcard entry: "everyone who can already open this surface". */
32
+ export const EVERYONE = "*";
33
+
34
+ export interface ShareEntry {
35
+ user: string;
36
+ role: ShareRole;
37
+ }
38
+
39
+ /** One assignable account. `name` is what a human reads; `user` is what binds. */
40
+ export interface SharePerson {
41
+ user: string;
42
+ name: string;
43
+ }
44
+
45
+ export interface ShareState {
46
+ owner: string | null;
47
+ entries: ShareEntry[];
48
+ /** This session's own role on the object: 'owner' | 'edit' | 'view' | null. */
49
+ role: string | null;
50
+ /** Owner or admin — the only people the server lets change grants. */
51
+ mayAdminister: boolean;
52
+ people: SharePerson[];
53
+ }
54
+
55
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
56
+
57
+ /**
58
+ * `GET /api/v1/share/{kind}/{oid}` → a state the editor can render, FAIL-CLOSED.
59
+ *
60
+ * Two absences matter and they are not the same:
61
+ * · a missing `entries` list is an object nobody has shared yet — an empty
62
+ * editor, which is correct and ordinary;
63
+ * · a missing `mayAdminister` is a server that did not answer the question,
64
+ * and it reads as NO. Defaulting it to yes would open the editor for a
65
+ * collaborator, who would then meet a 403 on save — or, worse, would meet a
66
+ * server that had also been relaxed.
67
+ */
68
+ export function parseShare(body: unknown): ShareState {
69
+ const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
70
+ const rawEntries = Array.isArray(b.entries) ? b.entries : [];
71
+ const entries: ShareEntry[] = [];
72
+ const seen = new Set<string>();
73
+ for (const item of rawEntries) {
74
+ if (!item || typeof item !== "object") continue;
75
+ const e = item as Record<string, unknown>;
76
+ const user = str(e.user).toLowerCase();
77
+ const role = str(e.role).toLowerCase();
78
+ // Same posture as the server's `_clean_entries`: a junk ROLE is dropped, never
79
+ // coerced to a default — a row shown as "can view" that the store holds as
80
+ // something else is a lie the editor would then save back.
81
+ if (!user || !(SHARE_ROLES as readonly string[]).includes(role) || seen.has(user)) continue;
82
+ seen.add(user);
83
+ entries.push({ user, role: role as ShareRole });
84
+ }
85
+ return {
86
+ owner: str(b.owner).toLowerCase() || null,
87
+ entries,
88
+ role: str(b.role).toLowerCase() || null,
89
+ mayAdminister: b.mayAdminister === true,
90
+ people: parsePeople(b.people),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * The assignable accounts.
96
+ *
97
+ * ⚠ TWO SHAPES ACCEPTED, AND THE REASON IS A LIVE CONTRACT QUESTION (A-S4-5).
98
+ * The route serves `users.assignable_people()`, which returns DISPLAY NAMES,
99
+ * while every grant check compares LOGIN USERNAMES — so a picker that stores
100
+ * what it was shown can write a grant that binds to nobody. An object entry
101
+ * (`{username, name}`) resolves that; a bare string is read as a username,
102
+ * because that is the only reading under which the current payload is correct.
103
+ * Nothing here can repair a display name: no payload maps one to an account.
104
+ */
105
+ export function parsePeople(raw: unknown): SharePerson[] {
106
+ if (!Array.isArray(raw)) return [];
107
+ const out: SharePerson[] = [];
108
+ const seen = new Set<string>();
109
+ for (const item of raw) {
110
+ let user = "";
111
+ let name = "";
112
+ if (typeof item === "string") {
113
+ user = item.trim();
114
+ name = user;
115
+ } else if (item && typeof item === "object") {
116
+ const p = item as Record<string, unknown>;
117
+ user = str(p.username) || str(p.user);
118
+ name = str(p.name) || user;
119
+ }
120
+ const key = user.toLowerCase();
121
+ if (!key || seen.has(key)) continue;
122
+ seen.add(key);
123
+ out.push({ user: key, name: name || user });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * Add a person, or change the role of one already listed. The whole list comes
130
+ * back, because the PUT replaces: an editor that returned only its delta would
131
+ * revoke everyone it forgot to mention.
132
+ */
133
+ export function withEntry(entries: ShareEntry[], user: string, role: ShareRole): ShareEntry[] {
134
+ const key = str(user).toLowerCase();
135
+ if (!key || !(SHARE_ROLES as readonly string[]).includes(role)) return entries;
136
+ let found = false;
137
+ const next = entries.map((e) => {
138
+ if (e.user !== key) return e;
139
+ found = true;
140
+ return { user: key, role };
141
+ });
142
+ return found ? next : [...next, { user: key, role }];
143
+ }
144
+
145
+ /** Revoke: the entry's ABSENCE is the revocation (there is no DELETE verb). */
146
+ export function withoutEntry(entries: ShareEntry[], user: string): ShareEntry[] {
147
+ const key = str(user).toLowerCase();
148
+ return entries.filter((e) => e.user !== key);
149
+ }
150
+
151
+ /** The people not yet granted anything — what the "add someone" picker offers. */
152
+ export function addablePeople(people: SharePerson[], entries: ShareEntry[]): SharePerson[] {
153
+ const taken = new Set(entries.map((e) => e.user));
154
+ return people.filter((p) => !taken.has(p.user));
155
+ }
156
+
157
+ /**
158
+ * One sentence naming who can reach this object, for the row that opens the
159
+ * editor. Never a count on its own: "3 people" reads as reassurance, and the
160
+ * fact that matters is whether one of them is EVERYONE.
161
+ */
162
+ export function shareSummary(entries: ShareEntry[]): string {
163
+ if (!entries.length) return "Not shared";
164
+ const everyone = entries.find((e) => e.user === EVERYONE);
165
+ if (everyone) {
166
+ return everyone.role === "edit" ? "Everyone can edit" : "Everyone can view";
167
+ }
168
+ const editors = entries.filter((e) => e.role === "edit").length;
169
+ const people = entries.length === 1 ? "1 person" : `${entries.length} people`;
170
+ return editors ? `${people}, ${editors} can edit` : `${people} can view`;
171
+ }
172
+
173
+ /** The PUT body. One shape, one place — the editor never assembles it inline. */
174
+ export function sharePutBody(entries: ShareEntry[]): { entries: ShareEntry[] } {
175
+ return { entries };
176
+ }
177
+
178
+ /**
179
+ * The shell↔rail channel for "open the access editor for this thing".
180
+ *
181
+ * A window event rather than a prop, because the two callers live in trees that
182
+ * must not import each other: the views rail is host-neutral `customer-grid/`
183
+ * and the dialog is the shell's. The name is a literal in both files until S3
184
+ * publishes it in `apiContract.ts` (amendment A-S4-4) — the constant is here so
185
+ * only one file in this tree spells it.
186
+ */
187
+ export const SHARE_OPEN_EVENT = "aios:share-open";
188
+
189
+ export interface ShareRequest {
190
+ kind: ShareKind;
191
+ id: string;
192
+ /** What to call the thing in the dialog's title — the user's word for it. */
193
+ label: string;
194
+ }
195
+
196
+ /** Read a `SHARE_OPEN_EVENT` detail, dropping anything malformed (fail-closed:
197
+ * an unknown kind is not a new namespace, it is a bug that must not open a
198
+ * dialog that would 400 on save). */
199
+ export function parseShareRequest(detail: unknown): ShareRequest | null {
200
+ if (!detail || typeof detail !== "object") return null;
201
+ const d = detail as Record<string, unknown>;
202
+ const kind = str(d.kind).toLowerCase();
203
+ const id = str(d.id);
204
+ if (!id || !(SHARE_KINDS as readonly string[]).includes(kind)) return null;
205
+ return { kind: kind as ShareKind, id, label: str(d.label) || id };
206
+ }