fsanyoto commited on
Commit
15e3e59
Β·
verified Β·
1 Parent(s): 0429bb4

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "c2d4c3a",
3
  "releases": [
4
  {
5
  "version": "v29",
 
1
  {
2
+ "current": "85374f1",
3
  "releases": [
4
  {
5
  "version": "v29",
VERSION CHANGED
@@ -1 +1 @@
1
- c2d4c3a
 
1
+ 85374f1
api/routes_admin.py CHANGED
@@ -755,9 +755,15 @@ def _clean_perms(v, governed_keys=None, session=None):
755
  f"saved with the bad conditions silently removed, which would store a "
756
  f"weaker wall than the one on screen.")
757
  clean_tree = {"conj": conj, "nodes": cleaned}
 
 
 
 
 
758
  out[key] = {"access": bool(raw.get("access", True)),
759
  "filter": clean_tree,
760
- "hiddenFields": sorted({str(h) for h in hidden})}
 
761
  _refuse_unshapeable_bu(out)
762
  return out
763
 
@@ -1078,8 +1084,16 @@ def get_perms(username: str, session: Session = Depends(admin_gate)):
1078
  _default = (perm_scope.nav_may_open(users._public(uname, rec), k)
1079
  if any(m.get("surface") and m["key"] == k for m in modules) else
1080
  perm_scope.may_read(users._public(uname, rec), k, st=session.runtime))
1081
- perms_out[k] = e if isinstance(e, dict) else {
1082
- "access": bool(_default), "filter": None, "hiddenFields": []}
 
 
 
 
 
 
 
 
1083
  return {"username": uname,
1084
  "perms": perms_out,
1085
  # S3 ask 2 β€” the marker rides the GET. An absent module entry means DENY on a
 
755
  f"saved with the bad conditions silently removed, which would store a "
756
  f"weaker wall than the one on screen.")
757
  clean_tree = {"conj": conj, "nodes": cleaned}
758
+ # ⭐⭐ W38-T19 β€” `metrics` IS THE FOURTH FIELD OF AN ENTRY, AND ITS DEFAULT IS GRANT.
759
+ # `raw.get("metrics", True)` rather than a required key: a PUT composed by an older
760
+ # client, or a copy of a record written before this ticket, must not read as a
761
+ # revocation. `perm_scope.may_metrics` applies the identical rule on the read side, so
762
+ # the validator and the wall cannot disagree about what an absent key means.
763
  out[key] = {"access": bool(raw.get("access", True)),
764
  "filter": clean_tree,
765
+ "hiddenFields": sorted({str(h) for h in hidden}),
766
+ "metrics": bool(raw.get("metrics", True))}
767
  _refuse_unshapeable_bu(out)
768
  return out
769
 
 
1084
  _default = (perm_scope.nav_may_open(users._public(uname, rec), k)
1085
  if any(m.get("surface") and m["key"] == k for m in modules) else
1086
  perm_scope.may_read(users._public(uname, rec), k, st=session.runtime))
1087
+ # ⭐⭐ W38-T19 β€” `metrics` RIDES EVERY ENTRY, BACKFILLED TO GRANTED ON A RECORD WRITTEN
1088
+ # BEFORE THE KEY EXISTED. The wire is TOTAL on purpose: the client's own parse already
1089
+ # reads an absent key as granted, so this line changes no rendering β€” what it changes is
1090
+ # what a HUMAN reads off the payload while debugging, and what the PUT round-trips. The
1091
+ # stored dict is copied rather than mutated: `rec` is the live registry record and
1092
+ # stamping a key onto it here would write a permission nobody saved.
1093
+ perms_out[k] = (dict(e, metrics=bool(e.get("metrics", True)))
1094
+ if isinstance(e, dict) else
1095
+ {"access": bool(_default), "filter": None, "hiddenFields": [],
1096
+ "metrics": True})
1097
  return {"username": uname,
1098
  "perms": perms_out,
1099
  # S3 ask 2 β€” the marker rides the GET. An absent module entry means DENY on a
api/routes_customers.py CHANGED
@@ -1,376 +1,834 @@
1
- """routes_customers.py β€” X2's read + write of the customer table, BU-SCOPED (EXIT-3b / EXIT-2a).
2
-
3
- Two things happen here that did not happen in the pre-wave `main.py`:
4
-
5
- 1. **ROWS ARE SCOPED TO THE SESSION.** The old `/api/customers` served `cl.pool()` β€” the whole
6
- book, to anyone holding the shared APP_PASSWORD. Now the pool is built with
7
- `(team_id, agent_name)` derived from the USER RECORD, so a Royal-only user never receives a
8
- Fisch row and an agent-linked login never receives another rep's book. The scope is applied
9
- at the QUERY, not as a post-filter, so there is no moment at which the other BU's rows exist
10
- in this response.
11
-
12
- 2. **THE OVERLAY FORK IS GONE.** `aios-web/api/data/overlay.json` was a SECOND writable home
13
- for the same user-owned fields the Streamlit app keeps in the tenant store β€” two truths, and
14
- whichever process you asked last was right. Reads and writes now both go through
15
- `modules.customer_data`'s table-workspace functions: ONE store (C1c, ARCHITECTURE Β§1a rule 3).
16
-
17
- ⚠ ONE STORE IS NOT YET ONE CACHE (strangler-period, booked honestly). `core.store.get()` is
18
- cache-first per PROCESS, so a write from the Streamlit container is invisible to a running API
19
- container until its cache is refreshed, and vice versa. Deleting the fork removes the second
20
- SOURCE OF TRUTH; it does not make the two runtimes coherent. The fix is X4/Postgres (task C-4,
21
- owner-blocked on B-3), and no test here may claim read-your-writes ACROSS runtimes β€” a TestClient
22
- proof is single-process and would report green on exactly the thing that is still broken.
23
- """
24
- import time
25
-
26
- from fastapi import APIRouter, Body, Depends
27
-
28
- import scope_cache
29
- from deps import Session, err, module_gate, perms
30
-
31
- router = APIRouter(prefix="/api/v1")
32
-
33
- #: The surface these routes serve. Both legs are gated on it, so a user without the grant gets a
34
- #: 403 rather than an empty table that looks like "you have no customers".
35
- MODULE = "customer_data"
36
-
37
- _CACHE_TTL = 900 # the pool build is slow (Odoo + reconciliation); 15 min, as before
38
-
39
-
40
- def _pool_rows(session: Session):
41
- """The reconciled Odoo pool for this session's SCOPE β€” the slow part, and the only part that
42
- may be shared between users.
43
-
44
- β›” WHAT MAY BE CACHED HERE, AND WHY THE LINE IS EXACTLY HERE. The cache key is
45
- `(team_id, agent)` and the cached value is the raw pool: Odoo-source columns only. That is
46
- genuinely scope-shaped β€” two users with the same BU and the same book are asking the same
47
- question, and `cl.pool()` is expensive (an Odoo pull plus reconciliation).
48
-
49
- The FULL PAYLOAD is NOT cacheable on this key, and caching it here was a real defect I shipped
50
- and then removed. `fields` comes from `fields_from_workspace(ws)` β€” that user's own `custom_`
51
- and `measure_` columns β€” and every overlay cell comes from `ws['overlays']`; the table
52
- workspace is read as `data[username]` and written as `patch_table_overlay(uname, …)`, i.e.
53
- PER USER. So two Royal-only users with no agent link share `(6, None)` and the second one
54
- would have been served the FIRST one's private notes and private columns. The scope key was
55
- right for the pool and wrong for everything wrapped around it.
56
-
57
- It survived a green 129-check battery because every fixture user had a DISTINCT
58
- `(team_id, agent)` pair, so no two of them ever collided on the key β€” the test set could not
59
- express the bug. `verify_api.py` now carries a same-scope second user for exactly this.
60
- """
61
- rt = session.runtime
62
- team_id, agent = _team_agent(session)
63
- return _pool_for(rt, team_id, agent)
64
-
65
-
66
- def _pool_for(rt, team_id, agent):
67
- """The cached pool for an explicit scope β€” session-free so the prewarm thread and the
68
- stale-refresh path can call it. STALE-WHILE-REFRESH (scope_cache): once a copy exists no
69
- request blocks on the 10–30s Odoo rebuild again; only a scope's FIRST-ever build does.
70
-
71
- DEBT-2 (2026-08-04): while the tenant's RESOLVED Odoo source is PAUSED this path never
72
- goes live β€” it serves the in-process copy at any age, else the persisted pause-time
73
- snapshot (restart-safe), else answers 503. Never a WIDER scope's snapshot: handing a
74
- scoped user the consolidated rows would widen their book, which is worse than an error."""
75
- import modules.customer_data as cl
76
- import routes_keychain
77
-
78
- key = ("pool", team_id, agent)
79
-
80
- if routes_keychain.odoo_paused(rt):
81
- hit = rt.pool_cache.get(key)
82
- if hit:
83
- return hit[1]
84
- snap = routes_keychain.load_pool_snapshot(rt, team_id, agent)
85
- if snap is not None:
86
- rt.pool_cache[key] = snap # seed, so the memo stamps stay coherent
87
- return snap[1]
88
- raise err(503, "connector_paused",
89
- "this data source is paused and no snapshot exists for your scope β€” "
90
- "an admin can resume it under Settings β†’ Connectors")
91
-
92
- def _build():
93
- # ⚠ THE SCOPE GOES INTO THE BUILDER. `pool(agent_name, team_id)` is the same reconciled
94
- # builder the Streamlit page uses β€” passing the scope here is what makes the isolation a
95
- # property of the QUERY instead of a filter someone can forget to apply downstream.
96
- return cl.pool(agent, team_id)
97
-
98
- def _evict():
99
- # Bounded: a scope cache that only ever grows is a memory leak in a shared process.
100
- if len(rt.pool_cache) > 16:
101
- for stale in sorted(rt.pool_cache, key=lambda k: rt.pool_cache[k][0])[:8]:
102
- rt.pool_cache.pop(stale, None)
103
-
104
- return scope_cache.get(rt.pool_cache, key, _CACHE_TTL, _build, _evict)
105
-
106
-
107
- def warm_default(rt):
108
- """Boot prewarm: the consolidated pool `(None, None)` β€” the scope every admin and every
109
- all-BU account lands on. Called from main.py's prewarm thread only."""
110
- _pool_for(rt, None, None)
111
-
112
-
113
- def _team_agent(session: Session):
114
- """The `(team_id, agent)` this session's POOL is built with β€” ONE derivation point, used by
115
- `_pool_rows` and `grid_assembly` alike, so the cache key and the query can never disagree.
116
-
117
- β›” WAVE 15 R1: THIS NOW COMES FROM THE PERMANENT FILTER (C-PERM amendment 3). `team_id` is
118
- not a row filter β€” `customer_data._pool_build` passes it into `cust._cust_rev` three times
119
- and into `_cadence_bulk`, so it decides what `rev`/`ly`/`ltm`/`aov`/`status` MEAN. Enforcing
120
- a BU purely as a post-filter would keep the row list right and silently consolidate every
121
- number. So the pushdown survives as a DERIVATION OF the declared filter rather than a second
122
- wall beside it, and `perm_scope.derive_pool_scope` falls back to the legacy `bus`/`agent`
123
- derivation for any record the migration has not reached yet.
124
- """
125
- import core.perm_scope as perm_scope
126
- return perm_scope.derive_pool_scope(session.user, MODULE)
127
-
128
-
129
- def _pool_stamp(rt, team_id, agent):
130
- """The cached pool's build timestamp β€” the DATA STAMP in every measure-memo key, so a pool
131
- refresh invalidates the memoised answers exactly when the underlying rows changed."""
132
- entry = rt.pool_cache.get(("pool", team_id, agent))
133
- return entry[0] if isinstance(entry, tuple) and entry else 0
134
-
135
-
136
- def _measure_err(tag, e):
137
- try:
138
- import harness.telemetry as _tel
139
- _tel.error(f"api:{tag}", e)
140
- except Exception:
141
- pass
142
-
143
-
144
- def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "",
145
- consume_corrections: bool = True):
146
- """ONE assembly of this session's grid state, shared by `/customers`, `/workspace` and the
147
- events route (2026-07-31 β€” the standalone measure gap, owner item 1).
148
-
149
- What it adds over the pre-wave hand-rolled `_payload` loop, and why it replaced it:
150
-
151
- * rows go through `aios_grid.rows_from_pool` β€” the SAME builder the embedded host uses,
152
- so `lat`/`lon` (the Map view's data), `_created` and every derived column ride each row
153
- by construction instead of by a second loop that drifts. The hand loop was written to
154
- mirror the pre-Map contract and silently dropped the coordinates: the shell's Map view
155
- had nothing to plot ("the Map no longer works").
156
- * `derived` carries the cohort column's cells AND the measure columns' values, resolved
157
- through `core.measure_resolve` (EXIT-5's extraction of the `_cl_measure_*` family).
158
- Without them every measure column the owner built rendered BLANK in the shell.
159
- * `measures` (the offer) and `measure_sets` (condition answers) are computed here so the
160
- events route can finally validate measure fields/conditions instead of refusing them
161
- (an empty `measure_offer` made `clean_measure_field` reject every create over HTTP).
162
-
163
- Memos live on the TENANT RUNTIME (`rt.measure_memo` / `rt.mset_memo`) β€” bounded by the
164
- module's own clear-past-cap rule, keyed on (stamp, scope, pool identity, question), nothing
165
- user-shaped in them.
166
- """
167
- import aios_grid
168
- from core import grid_events, measure_resolve
169
-
170
- import core.perm_scope as perm_scope
171
-
172
- rt = session.runtime
173
- team_id, agent = _team_agent(session)
174
- rows_src = _pool_for(rt, team_id, agent)
175
- # β›” THE ROW WALL, APPLIED BEFORE `pids` IS TAKEN. Everything downstream is bounded by that
176
- # frozenset β€” `allowed_pids` for the workspace, cohort membership, measure resolution β€” so
177
- # scoping here means a row this account may not see never enters ANY of them, rather than
178
- # being filtered out of one payload and surviving in another.
179
- #
180
- # Evaluated against the CANONICAL field list, not the per-user assembled one, for two
181
- # reasons: the assembled list is not built yet (it needs `pids`), and a permanent filter may
182
- # only ever name a canonical field anyway β€” `routes_admin._clean_perms` validates it against
183
- # exactly this schema and 400s otherwise. `permits()` denies on anything it cannot answer.
184
- rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, aios_grid.FIELDS)
185
- pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
186
- ws = grid_events.table_workspace(
187
- _ctx_for(session, pids), allowed_pids=pids,
188
- consume_corrections=consume_corrections)
189
- workspace, fields, views, lists = aios_grid.workspace_wire(
190
- ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key)
191
- # THE FIELD WALL β€” a TRANSITIVE closure (C-PERM amendment 5), so hiding a field also hides
192
- # every formula computed FROM it. Formulas evaluate in the browser from `{ref}`s, so
193
- # shipping a dependent formula while withholding its input either leaks the input through
194
- # the formula's value or silently computes a wrong one; only removing both is coherent.
195
- # Applied AFTER workspace_wire because custom + measure columns are what it must cover.
196
- hidden = perm_scope.hidden_keys(session.user, MODULE, fields)
197
- if hidden:
198
- fields = [f for f in fields if f.get("key") not in hidden]
199
- # The field LIST and the ROW payload are two different wires. Narrowing only the first
200
- # would leave the value sitting in the second, where anything can read it.
201
- rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src]
202
-
203
- today = time.strftime("%Y-%m-%d")
204
- stamp = _pool_stamp(rt, team_id, agent)
205
- measures = measure_resolve.offer(team_id, on_error=_measure_err)
206
- measure_sets = measure_resolve.condition_sets(
207
- [v.get("config") or {} for v in (views or [])], None, team_id, pids, today, stamp,
208
- rt.mset_memo, on_error=_measure_err)
209
- # The derived channel: cohort membership cells + measure column values, ONE dict β€” the
210
- # same read-only channel the embed host hands to rows_from_pool.
211
- derived = aios_grid.cohort_cells(lists)
212
- # ⭐ W33-T43 / owner item 12 ("One unique ID per database always"). The customer grid carried
213
- # NO Odoo id column at all, while its retiring twin `ut_odoo_customers` carried `partner_id`
214
- # as its join key β€” so the merge would have lost the one value every Odoo document joins on.
215
- #
216
- # β›” IT IS DERIVED, NOT A POOL COLUMN, AND THAT IS THE WHOLE POINT: a customer row's `pid` IS
217
- # the `res.partner` id (`modules/customer_data.pool` mints it that way and the identity is
218
- # asserted against Odoo in that module). Adding it to the pool would be a SECOND source for
219
- # one fact, which is the class of defect item 12 is about. This channel exists for exactly
220
- # this β€” a value the host knows per render and the pool has no business storing.
221
- for pid in pids:
222
- derived.setdefault(pid, {})["partner_id"] = pid
223
- for pid, cells in measure_resolve.column_values(
224
- fields, team_id, pids, today, stamp, rt.measure_memo,
225
- on_error=_measure_err).items():
226
- derived.setdefault(pid, {}).update(cells)
227
-
228
- return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
229
- "fields": fields, "views": views, "lists": lists, "derived": derived,
230
- "measures": measures, "measure_sets": measure_sets, "today": today,
231
- "team_id": team_id}
232
-
233
-
234
- def _payload(session: Session):
235
- """`{fields, rows, today, docs, pulled_at}` β€” X2's shape, which `verify_fields_contract.py`
236
- referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by
237
- construction); see `grid_assembly` for what that fixed.
238
-
239
- ⭐ `docs` joined the shape in wave 30 (W30-T37 / contract C4). Named here rather than left to
240
- the reader because a docstring that still lists the OLD shape is a stale comment on correct
241
- code β€” this repo's D-73 β€” and it is the first thing anyone greps to learn the payload.
242
-
243
- ⚠ `rows_src` is the SHARED cached list β€” `rows_from_pool` reads it and builds NEW dicts,
244
- never mutating a cached row (the same-scope-second-user leak rule).
245
- """
246
- import aios_grid
247
-
248
- g = grid_assembly(session)
249
- rows = aios_grid.rows_from_pool(
250
- g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
251
- # ⭐ C4 / D-138 (W30-T37) β€” THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door
252
- # (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is
253
- # complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key.
254
- # All six `onDoc*` handlers in `CustomerGrid.tsx` read `payload?.docs ? … : undefined`, so an
255
- # ABSENT key β€” not a broken one β€” is what has been switching the whole feature off.
256
- #
257
- # β›” IMPORTED, NEVER RE-SERIALISED. `core.grid_events.docs_for` is the ONE serialiser and
258
- # `routes_tables` (the `ut_*` scope) calls the SAME function with the same argument order.
259
- # A matching pair here is precisely how the wave-29 close-out reintroduced its own defect in
260
- # the opposite direction inside a single commit ([[one-question-two-normalizers]]).
261
- # ⚠ `g["pids"]` is the row set this session is ALREADY scoped to β€” `docs_for` has no
262
- # "every document in the tenant" mode to reach for, deliberately.
263
- from core import grid_events as _ge
264
- return {"fields": g["fields"], "rows": rows,
265
- # `today` rides the payload because every relative date condition must resolve against
266
- # the TENANT's day, never the browser's β€” a client that falls back to its own clock
267
- # disagrees with the server for everyone west of it.
268
- "today": g["today"],
269
- "docs": _ge.docs_for(g["pids"], scope_key="customer", uname=session.uname,
270
- admin=session.admin, st=session.runtime),
271
- "pulled_at": time.strftime("%Y-%m-%d %H:%M")}
272
-
273
-
274
- def _ctx_for(session: Session, pids):
275
- """An EventCtx for the READ path β€” no fallback workspace, so a store outage is a 503 rather
276
- than a phantom in-memory workspace an API request cannot persist."""
277
- from core import grid_events
278
- return grid_events.EventCtx(
279
- uname=session.uname, allowed_pids=frozenset(pids or ()), fields=[],
280
- # C-PERM: the write wall's field half. Computed from the CANONICAL contract because
281
- # `fields=[]` here β€” the closure only needs the schema, not this user's column list.
282
- hidden_keys=_hidden_for(session),
283
- admin=session.admin, fallback_ws=None, seen_ids={})
284
-
285
-
286
-
287
- def _hidden_for(session: Session):
288
- """The fields this session's permissions hide β€” the write wall's half of C-PERM.
289
-
290
- Read paths strip these from both wires so they cannot be SEEN; this is what stops them
291
- being WRITTEN by a caller who knows the key. Evaluated against the canonical contract, the
292
- same schema `routes_admin._clean_perms` validates a hiddenFields entry against.
293
- """
294
- import aios_grid
295
- import core.perm_scope as perm_scope
296
-
297
- return perm_scope.hidden_keys(session.user, MODULE, aios_grid.FIELDS)
298
-
299
- def allowed_pids(session: Session):
300
- """The pids this session may touch β€” the POOL's own ids, so the write wall and the read scope
301
- can never disagree.
302
-
303
- Reads `_pool_rows` rather than `_payload`: the wall only needs identities, and going through
304
- the full payload would pay for a workspace read and a row assembly on every write.
305
-
306
- β›” THE PERMANENT FILTER APPLIES HERE TOO, AND FORGETTING IT IS A WRITE-WITHOUT-READ HOLE.
307
- `_pool_rows` is built with the DERIVED pushdown, which expresses only what a `(team_id,
308
- agent)` pair can express. Any part of the wall the pushdown cannot carry β€” `revenue > 1000`,
309
- a nested group, a condition on any other column β€” leaves the pool WIDER than the filter. Read
310
- paths close that gap with `apply_row_scope`; without the same call here the write wall would
311
- be the wider set, and a restricted user could PATCH a row this API will not show them.
312
- Same function, same order as `grid_assembly`, so the two walls cannot drift.
313
- """
314
- import core.perm_scope as perm_scope
315
- import aios_grid
316
-
317
- rows = perm_scope.apply_row_scope(_pool_rows(session), session.user, MODULE,
318
- aios_grid.FIELDS)
319
- return frozenset(r["pid"] for r in rows if r.get("pid") is not None)
320
-
321
-
322
- @router.get("/customers")
323
- def customers(session: Session = Depends(module_gate(MODULE))):
324
- return _payload(session)
325
-
326
-
327
- @router.patch("/customers/{pid}")
328
- def patch_customer(pid: int, body: dict = Body(default=None),
329
- session: Session = Depends(module_gate(MODULE))):
330
- """Write the EDITABLE overlay stratum only β€” Odoo stays read-only, forever.
331
-
332
- Routed through `core.grid_events.handle_one` as an `overlay_patch` event rather than writing
333
- the store directly: that handler is where the per-key `permissions.edit` wall, the pid wall
334
- and the truncation rules live, and a second implementation of those would be a second set of
335
- them to keep in step. The response reports what was ACCEPTED, which is not always what was
336
- asked for.
337
- """
338
- from core import grid_events
339
-
340
- updates = dict(body or {})
341
- if not updates:
342
- raise err(400, "empty_patch", "no fields to update")
343
- pool = allowed_pids(session)
344
- if pid not in pool:
345
- # 403, not 404: the pid may well exist β€” it is simply not in this session's book, and
346
- # saying "no such customer" would confirm the opposite to anyone who guessed right.
347
- raise err(403, "out_of_scope", "that customer is not in your book")
348
- payload = _payload(session)
349
- ctx = grid_events.EventCtx(
350
- uname=session.uname, allowed_pids=pool, fields=payload["fields"],
351
- admin=session.admin, fallback_ws=None, seen_ids={},
352
- hidden_keys=_hidden_for(session))
353
- try:
354
- grid_events.handle_one(
355
- {"id": f"patch:{pid}:{time.time_ns()}", "type": "overlay_patch",
356
- "pid": pid, "updates": updates}, ctx)
357
- except grid_events.StoreUnavailable:
358
- raise err(503, "store_unavailable",
359
- "the tenant store is unavailable β€” your change was not saved")
360
-
361
- # What actually landed, read back from the store rather than echoed from the request: a
362
- # refused key or a truncated value must not be reported as accepted.
363
- stored = (grid_events.table_workspace(_ctx_for(session, pool), allowed_pids=None)
364
- .get("overlays") or {}).get(str(pid)) or {}
365
- accepted = {k: stored.get(k) for k in updates if k in stored}
366
- refused = sorted(k for k in updates if k not in accepted or stored.get(k) != str(updates[k]))
367
- # No cache to patch: the overlay stratum is re-read from the store on every `_payload`, so
368
- # read-your-writes within this runtime is a property of the design rather than of a
369
- # write-through step somebody has to remember. (It was a write-through step while the whole
370
- # payload was cached on a scope key β€” the arrangement that leaked one user's notes to
371
- # another. See `_pool_rows`.) Cross-RUNTIME coherence is still not claimed: the module
372
- # docstring says why, and Postgres is the fix.
373
- out = {"ok": True, "pid": pid, "updates": accepted}
374
- if refused:
375
- out["refused"] = refused
376
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_customers.py β€” X2's read + write of the customer table, BU-SCOPED (EXIT-3b / EXIT-2a).
2
+
3
+ Two things happen here that did not happen in the pre-wave `main.py`:
4
+
5
+ 1. **ROWS ARE SCOPED TO THE SESSION.** The old `/api/customers` served `cl.pool()` β€” the whole
6
+ book, to anyone holding the shared APP_PASSWORD. Now the pool is built with
7
+ `(team_id, agent_name)` derived from the USER RECORD, so a Royal-only user never receives a
8
+ Fisch row and an agent-linked login never receives another rep's book. The scope is applied
9
+ at the QUERY, not as a post-filter, so there is no moment at which the other BU's rows exist
10
+ in this response.
11
+
12
+ 2. **THE OVERLAY FORK IS GONE.** `aios-web/api/data/overlay.json` was a SECOND writable home
13
+ for the same user-owned fields the Streamlit app keeps in the tenant store β€” two truths, and
14
+ whichever process you asked last was right. Reads and writes now both go through
15
+ `modules.customer_data`'s table-workspace functions: ONE store (C1c, ARCHITECTURE Β§1a rule 3).
16
+
17
+ ⚠ ONE STORE IS NOT YET ONE CACHE (strangler-period, booked honestly). `core.store.get()` is
18
+ cache-first per PROCESS, so a write from the Streamlit container is invisible to a running API
19
+ container until its cache is refreshed, and vice versa. Deleting the fork removes the second
20
+ SOURCE OF TRUTH; it does not make the two runtimes coherent. The fix is X4/Postgres (task C-4,
21
+ owner-blocked on B-3), and no test here may claim read-your-writes ACROSS runtimes β€” a TestClient
22
+ proof is single-process and would report green on exactly the thing that is still broken.
23
+ """
24
+ import time
25
+
26
+ from fastapi import APIRouter, Body, Depends
27
+
28
+ import scope_cache
29
+ from deps import Session, err, module_gate, perms
30
+
31
+ router = APIRouter(prefix="/api/v1")
32
+
33
+ #: The surface these routes serve. Both legs are gated on it, so a user without the grant gets a
34
+ #: 403 rather than an empty table that looks like "you have no customers".
35
+ MODULE = "customer_data"
36
+
37
+ _CACHE_TTL = 900 # the pool build is slow (Odoo + reconciliation); 15 min, as before
38
+
39
+
40
+ def _pool_rows(session: Session):
41
+ """The reconciled Odoo pool for this session's SCOPE β€” the slow part, and the only part that
42
+ may be shared between users.
43
+
44
+ β›” WHAT MAY BE CACHED HERE, AND WHY THE LINE IS EXACTLY HERE. The cache key is
45
+ `(team_id, agent)` and the cached value is the raw pool: Odoo-source columns only. That is
46
+ genuinely scope-shaped β€” two users with the same BU and the same book are asking the same
47
+ question, and `cl.pool()` is expensive (an Odoo pull plus reconciliation).
48
+
49
+ The FULL PAYLOAD is NOT cacheable on this key, and caching it here was a real defect I shipped
50
+ and then removed. `fields` comes from `fields_from_workspace(ws)` β€” that user's own `custom_`
51
+ and `measure_` columns β€” and every overlay cell comes from `ws['overlays']`; the table
52
+ workspace is read as `data[username]` and written as `patch_table_overlay(uname, …)`, i.e.
53
+ PER USER. So two Royal-only users with no agent link share `(6, None)` and the second one
54
+ would have been served the FIRST one's private notes and private columns. The scope key was
55
+ right for the pool and wrong for everything wrapped around it.
56
+
57
+ It survived a green 129-check battery because every fixture user had a DISTINCT
58
+ `(team_id, agent)` pair, so no two of them ever collided on the key β€” the test set could not
59
+ express the bug. `verify_api.py` now carries a same-scope second user for exactly this.
60
+ """
61
+ rt = session.runtime
62
+ team_id, agent = _team_agent(session)
63
+ return _pool_for(rt, team_id, agent)
64
+
65
+
66
+ def _pool_for(rt, team_id, agent):
67
+ """The cached pool for an explicit scope β€” session-free so the prewarm thread and the
68
+ stale-refresh path can call it. STALE-WHILE-REFRESH (scope_cache): once a copy exists no
69
+ request blocks on the 10–30s Odoo rebuild again; only a scope's FIRST-ever build does.
70
+
71
+ DEBT-2 (2026-08-04): while the tenant's RESOLVED Odoo source is PAUSED this path never
72
+ goes live β€” it serves the in-process copy at any age, else the persisted pause-time
73
+ snapshot (restart-safe), else answers 503. Never a WIDER scope's snapshot: handing a
74
+ scoped user the consolidated rows would widen their book, which is worse than an error."""
75
+ import modules.customer_data as cl
76
+ import routes_keychain
77
+
78
+ key = ("pool", team_id, agent)
79
+
80
+ if routes_keychain.odoo_paused(rt):
81
+ hit = rt.pool_cache.get(key)
82
+ if hit:
83
+ return hit[1]
84
+ snap = routes_keychain.load_pool_snapshot(rt, team_id, agent)
85
+ if snap is not None:
86
+ rt.pool_cache[key] = snap # seed, so the memo stamps stay coherent
87
+ return snap[1]
88
+ raise err(503, "connector_paused",
89
+ "this data source is paused and no snapshot exists for your scope β€” "
90
+ "an admin can resume it under Settings β†’ Connectors")
91
+
92
+ def _build():
93
+ # ⚠ THE SCOPE GOES INTO THE BUILDER. `pool(agent_name, team_id)` is the same reconciled
94
+ # builder the Streamlit page uses β€” passing the scope here is what makes the isolation a
95
+ # property of the QUERY instead of a filter someone can forget to apply downstream.
96
+ return cl.pool(agent, team_id)
97
+
98
+ def _evict():
99
+ # Bounded: a scope cache that only ever grows is a memory leak in a shared process.
100
+ if len(rt.pool_cache) > 16:
101
+ for stale in sorted(rt.pool_cache, key=lambda k: rt.pool_cache[k][0])[:8]:
102
+ rt.pool_cache.pop(stale, None)
103
+
104
+ return scope_cache.get(rt.pool_cache, key, _CACHE_TTL, _build, _evict)
105
+
106
+
107
+ def warm_default(rt):
108
+ """Boot prewarm: the consolidated pool `(None, None)` β€” the scope every admin and every
109
+ all-BU account lands on. Called from main.py's prewarm thread only."""
110
+ _pool_for(rt, None, None)
111
+
112
+
113
+ def _team_agent(session: Session):
114
+ """The `(team_id, agent)` this session's POOL is built with β€” ONE derivation point, used by
115
+ `_pool_rows` and `grid_assembly` alike, so the cache key and the query can never disagree.
116
+
117
+ β›” WAVE 15 R1: THIS NOW COMES FROM THE PERMANENT FILTER (C-PERM amendment 3). `team_id` is
118
+ not a row filter β€” `customer_data._pool_build` passes it into `cust._cust_rev` three times
119
+ and into `_cadence_bulk`, so it decides what `rev`/`ly`/`ltm`/`aov`/`status` MEAN. Enforcing
120
+ a BU purely as a post-filter would keep the row list right and silently consolidate every
121
+ number. So the pushdown survives as a DERIVATION OF the declared filter rather than a second
122
+ wall beside it, and `perm_scope.derive_pool_scope` falls back to the legacy `bus`/`agent`
123
+ derivation for any record the migration has not reached yet.
124
+ """
125
+ import core.perm_scope as perm_scope
126
+ return perm_scope.derive_pool_scope(session.user, MODULE)
127
+
128
+
129
+ def _pool_stamp(rt, team_id, agent):
130
+ """The cached pool's build timestamp β€” the DATA STAMP in every measure-memo key, so a pool
131
+ refresh invalidates the memoised answers exactly when the underlying rows changed."""
132
+ entry = rt.pool_cache.get(("pool", team_id, agent))
133
+ return entry[0] if isinstance(entry, tuple) and entry else 0
134
+
135
+
136
+ def _measure_err(tag, e):
137
+ try:
138
+ import harness.telemetry as _tel
139
+ _tel.error(f"api:{tag}", e)
140
+ except Exception:
141
+ pass
142
+
143
+
144
+ # ══════════════════════════ THE TENANT-WIDE STRATUM, ON THE CUSTOMER TOPIC (W38-T20 / D-425) ══
145
+ #
146
+ # β›”β›” WHY THIS FILE GREW A SHARED STRATUM AT ALL. `core/shared_overlay.py` has been generic since
147
+ # W29-T62, `routes_products.py` has merged it since W30-T36 and `routes_tables.py` since W38-T16 β€”
148
+ # and the CUSTOMER topic had neither a write door nor a read merge. Every user-created column here
149
+ # lives in `data[username]`, so two accounts looking at "the same" column are looking at two
150
+ # columns. That is fine for a private note and fatal for a ROUTE ORDER: a visit sequence one rep
151
+ # can see and their colleague cannot is not a plan, it is a rumour.
152
+ #
153
+ # β›” ONE SPELLING OF THE BUCKET. `modules.customer_data.TABLE_KEY` is the per-user workspace key
154
+ # and `shared_overlay.bucket()` derives `<key>__shared` from it. Resolving it here rather than
155
+ # writing the string means the write door and the read merge cannot disagree about where the
156
+ # values live β€” which is exactly the failure T16 found on the materialised `ut_*` tables, where
157
+ # `patch_shared_cell` wrote into a bucket no reader ever opened.
158
+
159
+
160
+ def _shared_key():
161
+ """The store key this topic's per-user AND tenant-wide strata are both named from."""
162
+ import modules.customer_data as cl
163
+ return cl.TABLE_KEY
164
+
165
+
166
+ def shared_fields(st=None):
167
+ """`{field_key: Field}` β€” the columns this topic shares tenant-wide.
168
+
169
+ Unscoped on purpose, exactly as `shared_overlay.fields` is: a shared column's EXISTENCE is
170
+ tenant-wide by definition. WHO MAY SEE IT is a separate question, answered one layer up by
171
+ `perm_scope.hidden_keys` (the per-field grant wall T16 landed), and WHOSE ROWS by `cells`.
172
+ """
173
+ from core import shared_overlay
174
+ try:
175
+ return shared_overlay.fields(_shared_key(), st=st)
176
+ except Exception: # noqa: BLE001
177
+ # Lenient like every other display read: an unreachable store degrades to "nothing is
178
+ # shared yet", never to a 500 on a grid that would otherwise render. The WALL does not
179
+ # degrade with it β€” `field_grant_hidden` hides a marked column it cannot resolve.
180
+ return {}
181
+
182
+
183
+ def shared_cells(pids, st=None):
184
+ """`{"<pid>": {key: value}}` for the rows named by `pids`, and ONLY those.
185
+
186
+ β›” `pids` IS THE ROW WALL, PASSED AND NEVER DEFAULTED. `shared_overlay.cells` refuses an
187
+ "everything" read by signature for this reason; the set handed in is the one `grid_assembly`
188
+ has already narrowed with `apply_row_scope`, so a cell belonging to the other BU has nothing
189
+ to attach itself to.
190
+ """
191
+ from core import shared_overlay
192
+ try:
193
+ return shared_overlay.cells(_shared_key(), list(pids or ()), st=st)
194
+ except Exception: # noqa: BLE001
195
+ return {}
196
+
197
+
198
+ def _merge_shared_fields(fields, defs):
199
+ """`fields` PLUS the tenant-wide columns this topic declares β€” `routes_tables._ut_shared_fields`
200
+ on the customer topic.
201
+
202
+ ⚠ MERGED BEFORE THE WALL, NEVER AFTER. `hidden_keys` is a TRANSITIVE closure, so it must run
203
+ on the WHOLE contract: a formula over a shared column that reads a hidden one sits outside the
204
+ closure's reach otherwise and carries the hidden value out wearing a second name. It is also
205
+ the only order in which `field_grant_hidden` can ever see the `granted` marker at all β€” merge
206
+ afterwards and the per-field wall is inert while every test still passes.
207
+ ⚠ A key the canonical contract already declares WINS. A shared column is an ADDITION to this
208
+ database's contract, never a redefinition of a column it already has.
209
+ """
210
+ if not defs:
211
+ return fields
212
+ have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
213
+ return list(fields or ()) + [dict(f, source="overlay")
214
+ for k, f in defs.items() if k not in have]
215
+
216
+
217
+ def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "",
218
+ consume_corrections: bool = True):
219
+ """ONE assembly of this session's grid state, shared by `/customers`, `/workspace` and the
220
+ events route (2026-07-31 β€” the standalone measure gap, owner item 1).
221
+
222
+ What it adds over the pre-wave hand-rolled `_payload` loop, and why it replaced it:
223
+
224
+ * rows go through `aios_grid.rows_from_pool` β€” the SAME builder the embedded host uses,
225
+ so `lat`/`lon` (the Map view's data), `_created` and every derived column ride each row
226
+ by construction instead of by a second loop that drifts. The hand loop was written to
227
+ mirror the pre-Map contract and silently dropped the coordinates: the shell's Map view
228
+ had nothing to plot ("the Map no longer works").
229
+ * `derived` carries the cohort column's cells AND the measure columns' values, resolved
230
+ through `core.measure_resolve` (EXIT-5's extraction of the `_cl_measure_*` family).
231
+ Without them every measure column the owner built rendered BLANK in the shell.
232
+ * `measures` (the offer) and `measure_sets` (condition answers) are computed here so the
233
+ events route can finally validate measure fields/conditions instead of refusing them
234
+ (an empty `measure_offer` made `clean_measure_field` reject every create over HTTP).
235
+
236
+ Memos live on the TENANT RUNTIME (`rt.measure_memo` / `rt.mset_memo`) β€” bounded by the
237
+ module's own clear-past-cap rule, keyed on (stamp, scope, pool identity, question), nothing
238
+ user-shaped in them.
239
+ """
240
+ import aios_grid
241
+ from core import grid_events, measure_resolve
242
+
243
+ import core.perm_scope as perm_scope
244
+
245
+ rt = session.runtime
246
+ team_id, agent = _team_agent(session)
247
+ rows_src = _pool_for(rt, team_id, agent)
248
+ # β›” THE ROW WALL, APPLIED BEFORE `pids` IS TAKEN. Everything downstream is bounded by that
249
+ # frozenset β€” `allowed_pids` for the workspace, cohort membership, measure resolution β€” so
250
+ # scoping here means a row this account may not see never enters ANY of them, rather than
251
+ # being filtered out of one payload and surviving in another.
252
+ #
253
+ # Evaluated against the CANONICAL field list, not the per-user assembled one, for two
254
+ # reasons: the assembled list is not built yet (it needs `pids`), and a permanent filter may
255
+ # only ever name a canonical field anyway β€” `routes_admin._clean_perms` validates it against
256
+ # exactly this schema and 400s otherwise. `permits()` denies on anything it cannot answer.
257
+ rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, aios_grid.FIELDS)
258
+ pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
259
+ # ⭐ W38-T20 β€” THE COLUMN DEFINITIONS ARE READ **ONCE** PER ASSEMBLY AND THREADED, because
260
+ # `core.store.get` deep-copies whatever it hands back on every call. Three consumers want this
261
+ # dict on one request (the write ctx's wall, the read merge, and the closure), and letting each
262
+ # take its own copy is the shape D-214 spent a whole ticket removing one document over.
263
+ _defs = shared_fields(st=rt)
264
+ ws = grid_events.table_workspace(
265
+ _ctx_for(session, pids, defs=_defs), allowed_pids=pids,
266
+ consume_corrections=consume_corrections)
267
+ # ⭐⭐ W38-T20 / D-425 β€” THE TENANT-WIDE CELLS, LAYERED OVER THE PER-USER ONES, IN THE
268
+ # ASSEMBLY SO EVERY CONSUMER SEES ONE TRUTH. `routes_grid`'s /workspace route serves
269
+ # `workspace["overlays"] = g["ws"].get("overlays")` verbatim and `_payload` hands the same
270
+ # dict to `rows_from_pool`, so merging HERE reaches both without touching either file.
271
+ #
272
+ # ⚠ SAFE TO MUTATE, and checked rather than assumed (the same check `product_assembly`
273
+ # records): `table_workspace` reads through `store.get`, which deep-copies, so `ws` is a
274
+ # detached copy and nothing writes it back. A shared value can never leak INTO the per-user
275
+ # bucket by way of this merge.
276
+ # ⚠ SHARED WINS PER KEY. The whole point of the stratum is that every reader sees the same
277
+ # number, so a per-user leftover under the same key is stale by construction. It is also what
278
+ # makes D-423 recoverable rather than permanent: a pre-fix per-user edit is shadowed, not
279
+ # promoted.
280
+ _shared = shared_cells(pids, st=rt)
281
+ if _shared:
282
+ _ov = dict(ws.get("overlays") or {})
283
+ for _pid, _cells in _shared.items():
284
+ _ov[_pid] = {**(_ov.get(_pid) or {}), **_cells}
285
+ ws["overlays"] = _ov
286
+ workspace, fields, views, lists = aios_grid.workspace_wire(
287
+ ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key)
288
+ # ⭐⭐ W38-T20 β€” AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this
289
+ # position is load-bearing twice, once for the transitive closure and once because it is the
290
+ # only order in which the per-field grant marker is ever presented to `hidden_keys`.
291
+ fields = _merge_shared_fields(fields, _defs)
292
+ # THE FIELD WALL β€” a TRANSITIVE closure (C-PERM amendment 5), so hiding a field also hides
293
+ # every formula computed FROM it. Formulas evaluate in the browser from `{ref}`s, so
294
+ # shipping a dependent formula while withholding its input either leaks the input through
295
+ # the formula's value or silently computes a wrong one; only removing both is coherent.
296
+ # Applied AFTER workspace_wire because custom + measure columns are what it must cover.
297
+ # ⭐ W38-T20 β€” `st=rt` IS NOT TIDINESS. `perm_scope.field_grant_hidden` resolves a marked
298
+ # column against `object_shares`, and without a tenant handle it reads the module-default
299
+ # bucket: on any tenant but #0 that finds no grant, and no grant on a MARKED column means
300
+ # HIDDEN. So an unthreaded `st` UNDER-shares (a grantee cannot see their own column) rather
301
+ # than over-shares β€” visible and reportable, but still wrong, and `visible_fields`' own note
302
+ # requires the two calls to agree about it.
303
+ hidden = perm_scope.hidden_keys(session.user, MODULE, fields, st=rt)
304
+ if hidden:
305
+ fields = [f for f in fields if f.get("key") not in hidden]
306
+ # The field LIST and the ROW payload are two different wires. Narrowing only the first
307
+ # would leave the value sitting in the second, where anything can read it.
308
+ rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src]
309
+ # β›”β›” AND THE OVERLAY DICT IS A THIRD WIRE, WHICH IS NEW THIS TICKET AND WAS A HOLE
310
+ # BEFORE IT. `rows_from_pool` iterates `fields`, so a narrowed contract already keeps a
311
+ # hidden key off a ROW β€” but `/workspace` serves `ws["overlays"]` RAW, and that dict is
312
+ # where both the per-user cells and (as of the merge above) the tenant-wide ones sit.
313
+ # One narrowing cannot speak for a wire it never touches; `routes_tables` writes the same
314
+ # sentence over its own `shared_cells`, and `routes_odoo_tables` over its `overlays`.
315
+ # ⚠ `ws` AND NOT `workspace`: `routes_grid.workspace` copies the dict ACROSS
316
+ # (`workspace["overlays"] = g["ws"].get("overlays")`) after this returns, so narrowing the
317
+ # source is what reaches the wire. Narrowing the copy would be narrowing a key that gets
318
+ # overwritten a moment later.
319
+ _ov = ws.get("overlays") or {}
320
+ if _ov:
321
+ ws["overlays"] = {pid: {k: v for k, v in (cells or {}).items() if k not in hidden}
322
+ for pid, cells in _ov.items()}
323
+
324
+ today = time.strftime("%Y-%m-%d")
325
+ stamp = _pool_stamp(rt, team_id, agent)
326
+ # ⭐⭐ W38-T19 β€” THE METRICS CAPABILITY, AND THIS GRAIN NEEDS **THREE** GUARDS WHERE THE
327
+ # OTHER TWO NEED ONE. `routes_products` and `routes_tables` funnel their cells and their
328
+ # condition answers through helpers that short-circuit on an empty `offer`, so emptying the
329
+ # offer there stops the whole feature. `core.measure_resolve` does not take an offer at all:
330
+ # `condition_sets` and `column_values` both re-derive their work from the caller's OWN saved
331
+ # views and field list. So gating only the offer here would take the Metric kind off the
332
+ # picker and refuse new creates while an EXISTING Metric column kept computing and an
333
+ # EXISTING measure condition kept resolving β€” a revoked capability still answering, on the
334
+ # grain with the most of them. Three calls, one predicate.
335
+ may_metrics = perm_scope.may_metrics(session.user, MODULE)
336
+ measures = measure_resolve.offer(team_id, on_error=_measure_err) if may_metrics else []
337
+ measure_sets = measure_resolve.condition_sets(
338
+ [v.get("config") or {} for v in (views or [])], None, team_id, pids, today, stamp,
339
+ rt.mset_memo, on_error=_measure_err) if may_metrics else {}
340
+ # The derived channel: cohort membership cells + measure column values, ONE dict β€” the
341
+ # same read-only channel the embed host hands to rows_from_pool.
342
+ derived = aios_grid.cohort_cells(lists)
343
+ # ⭐ W33-T43 / owner item 12 ("One unique ID per database always"). The customer grid carried
344
+ # NO Odoo id column at all, while its retiring twin `ut_odoo_customers` carried `partner_id`
345
+ # as its join key β€” so the merge would have lost the one value every Odoo document joins on.
346
+ #
347
+ # β›” IT IS DERIVED, NOT A POOL COLUMN, AND THAT IS THE WHOLE POINT: a customer row's `pid` IS
348
+ # the `res.partner` id (`modules/customer_data.pool` mints it that way and the identity is
349
+ # asserted against Odoo in that module). Adding it to the pool would be a SECOND source for
350
+ # one fact, which is the class of defect item 12 is about. This channel exists for exactly
351
+ # this β€” a value the host knows per render and the pool has no business storing.
352
+ for pid in pids:
353
+ derived.setdefault(pid, {})["partner_id"] = pid
354
+ # ⭐⭐ W38-T19 β€” SKIPPED ENTIRELY WHEN THE CAPABILITY IS REVOKED, rather than filtered after.
355
+ # `column_values` selects its own subjects (`isinstance(f.get('measure'), dict)`) off the
356
+ # field list, so there is no argument that could narrow it; not calling it is the narrowing.
357
+ # ⚠ THE COLUMN STAYS AND ITS CELLS GO BLANK, which is this channel's OWN documented degrade
358
+ # ("blank is could not compute, 0 is a real zero") and is what the product and user-table
359
+ # grains already do under an empty offer. Deleting the column instead would be a second,
360
+ # louder behaviour for the same fact on one surface out of three, and it would destroy a
361
+ # definition the admin can restore with one tick.
362
+ for pid, cells in (measure_resolve.column_values(
363
+ fields, team_id, pids, today, stamp, rt.measure_memo,
364
+ on_error=_measure_err) if may_metrics else {}).items():
365
+ derived.setdefault(pid, {}).update(cells)
366
+
367
+ return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
368
+ "fields": fields, "views": views, "lists": lists, "derived": derived,
369
+ "measures": measures, "measure_sets": measure_sets, "today": today,
370
+ "team_id": team_id}
371
+
372
+
373
+ def _payload(session: Session):
374
+ """`{fields, rows, today, docs, pulled_at}` β€” X2's shape, which `verify_fields_contract.py`
375
+ referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by
376
+ construction); see `grid_assembly` for what that fixed.
377
+
378
+ ⭐ `docs` joined the shape in wave 30 (W30-T37 / contract C4). Named here rather than left to
379
+ the reader because a docstring that still lists the OLD shape is a stale comment on correct
380
+ code β€” this repo's D-73 β€” and it is the first thing anyone greps to learn the payload.
381
+
382
+ ⚠ `rows_src` is the SHARED cached list β€” `rows_from_pool` reads it and builds NEW dicts,
383
+ never mutating a cached row (the same-scope-second-user leak rule).
384
+ """
385
+ import aios_grid
386
+
387
+ g = grid_assembly(session)
388
+ rows = aios_grid.rows_from_pool(
389
+ g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
390
+ # ⭐ C4 / D-138 (W30-T37) β€” THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door
391
+ # (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is
392
+ # complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key.
393
+ # All six `onDoc*` handlers in `CustomerGrid.tsx` read `payload?.docs ? … : undefined`, so an
394
+ # ABSENT key β€” not a broken one β€” is what has been switching the whole feature off.
395
+ #
396
+ # β›” IMPORTED, NEVER RE-SERIALISED. `core.grid_events.docs_for` is the ONE serialiser and
397
+ # `routes_tables` (the `ut_*` scope) calls the SAME function with the same argument order.
398
+ # A matching pair here is precisely how the wave-29 close-out reintroduced its own defect in
399
+ # the opposite direction inside a single commit ([[one-question-two-normalizers]]).
400
+ # ⚠ `g["pids"]` is the row set this session is ALREADY scoped to β€” `docs_for` has no
401
+ # "every document in the tenant" mode to reach for, deliberately.
402
+ from core import grid_events as _ge
403
+ return {"fields": g["fields"], "rows": rows,
404
+ # `today` rides the payload because every relative date condition must resolve against
405
+ # the TENANT's day, never the browser's β€” a client that falls back to its own clock
406
+ # disagrees with the server for everyone west of it.
407
+ "today": g["today"],
408
+ "docs": _ge.docs_for(g["pids"], scope_key="customer", uname=session.uname,
409
+ admin=session.admin, st=session.runtime),
410
+ "pulled_at": time.strftime("%Y-%m-%d %H:%M")}
411
+
412
+
413
+ def _ctx_for(session: Session, pids, defs=None):
414
+ """An EventCtx for the READ path β€” no fallback workspace, so a store outage is a 503 rather
415
+ than a phantom in-memory workspace an API request cannot persist.
416
+
417
+ `defs` (W38-T20) is this topic's tenant-wide column definitions when the caller already holds
418
+ them; absent, they are read. One read per assembly rather than one per question asked of it.
419
+ """
420
+ from core import grid_events
421
+ return grid_events.EventCtx(
422
+ uname=session.uname, allowed_pids=frozenset(pids or ()), fields=[],
423
+ # C-PERM: the write wall's field half. Computed from the CANONICAL contract PLUS the
424
+ # tenant-wide columns, because `fields=[]` here β€” the closure only needs the schema, not
425
+ # this user's column list, and a runtime column is part of that schema now.
426
+ hidden_keys=_hidden_for(session, defs=defs),
427
+ admin=session.admin, fallback_ws=None, seen_ids={})
428
+
429
+
430
+
431
+ def _hidden_for(session: Session, defs=None):
432
+ """The fields this session's permissions hide β€” the write wall's half of C-PERM.
433
+
434
+ Read paths strip these from both wires so they cannot be SEEN; this is what stops them
435
+ being WRITTEN by a caller who knows the key. Evaluated against the canonical contract, the
436
+ same schema `routes_admin._clean_perms` validates a hiddenFields entry against.
437
+
438
+ ⭐⭐ W38-T20 β€” AND THE TENANT-WIDE COLUMNS ARE PART OF THAT CONTRACT NOW, WHICH IS A WALL AND
439
+ NOT A COMPLETENESS TIDY-UP. `patch_customer` builds its ctx with `fields=payload["fields"]`,
440
+ which carries the merged shared columns, so `grid_events.handle_one` would happily accept an
441
+ `overlay_patch` naming one. The canonical list cannot mention them (they are created at
442
+ runtime), so a wall computed from `aios_grid.FIELDS` alone answers "not hidden" for every
443
+ grant-governed column and the write door is open to a reader who was never granted it.
444
+ ⚠ Widening the field list can only ever ADD to the hidden set, never remove from it: the
445
+ closure hides what it is told to hide plus whatever depends on it.
446
+ """
447
+ import aios_grid
448
+ import core.perm_scope as perm_scope
449
+
450
+ if defs is None:
451
+ defs = shared_fields(st=session.runtime)
452
+ return perm_scope.hidden_keys(
453
+ session.user, MODULE, _merge_shared_fields(list(aios_grid.FIELDS), defs),
454
+ st=session.runtime)
455
+
456
+ def allowed_pids(session: Session):
457
+ """The pids this session may touch β€” the POOL's own ids, so the write wall and the read scope
458
+ can never disagree.
459
+
460
+ Reads `_pool_rows` rather than `_payload`: the wall only needs identities, and going through
461
+ the full payload would pay for a workspace read and a row assembly on every write.
462
+
463
+ β›” THE PERMANENT FILTER APPLIES HERE TOO, AND FORGETTING IT IS A WRITE-WITHOUT-READ HOLE.
464
+ `_pool_rows` is built with the DERIVED pushdown, which expresses only what a `(team_id,
465
+ agent)` pair can express. Any part of the wall the pushdown cannot carry β€” `revenue > 1000`,
466
+ a nested group, a condition on any other column β€” leaves the pool WIDER than the filter. Read
467
+ paths close that gap with `apply_row_scope`; without the same call here the write wall would
468
+ be the wider set, and a restricted user could PATCH a row this API will not show them.
469
+ Same function, same order as `grid_assembly`, so the two walls cannot drift.
470
+ """
471
+ import core.perm_scope as perm_scope
472
+ import aios_grid
473
+
474
+ rows = perm_scope.apply_row_scope(_pool_rows(session), session.user, MODULE,
475
+ aios_grid.FIELDS)
476
+ return frozenset(r["pid"] for r in rows if r.get("pid") is not None)
477
+
478
+
479
+ @router.get("/customers")
480
+ def customers(session: Session = Depends(module_gate(MODULE))):
481
+ return _payload(session)
482
+
483
+
484
+ @router.patch("/customers/{pid}")
485
+ def patch_customer(pid: int, body: dict = Body(default=None),
486
+ session: Session = Depends(module_gate(MODULE))):
487
+ """Write the EDITABLE overlay stratum only β€” Odoo stays read-only, forever.
488
+
489
+ Routed through `core.grid_events.handle_one` as an `overlay_patch` event rather than writing
490
+ the store directly: that handler is where the per-key `permissions.edit` wall, the pid wall
491
+ and the truncation rules live, and a second implementation of those would be a second set of
492
+ them to keep in step. The response reports what was ACCEPTED, which is not always what was
493
+ asked for.
494
+ """
495
+ from core import grid_events
496
+
497
+ updates = dict(body or {})
498
+ if not updates:
499
+ raise err(400, "empty_patch", "no fields to update")
500
+ pool = allowed_pids(session)
501
+ if pid not in pool:
502
+ # 403, not 404: the pid may well exist β€” it is simply not in this session's book, and
503
+ # saying "no such customer" would confirm the opposite to anyone who guessed right.
504
+ raise err(403, "out_of_scope", "that customer is not in your book")
505
+ payload = _payload(session)
506
+ ctx = grid_events.EventCtx(
507
+ uname=session.uname, allowed_pids=pool, fields=payload["fields"],
508
+ admin=session.admin, fallback_ws=None, seen_ids={},
509
+ hidden_keys=_hidden_for(session))
510
+ try:
511
+ grid_events.handle_one(
512
+ {"id": f"patch:{pid}:{time.time_ns()}", "type": "overlay_patch",
513
+ "pid": pid, "updates": updates}, ctx)
514
+ except grid_events.StoreUnavailable:
515
+ raise err(503, "store_unavailable",
516
+ "the tenant store is unavailable β€” your change was not saved")
517
+
518
+ # What actually landed, read back from the store rather than echoed from the request: a
519
+ # refused key or a truncated value must not be reported as accepted.
520
+ stored = (grid_events.table_workspace(_ctx_for(session, pool), allowed_pids=None)
521
+ .get("overlays") or {}).get(str(pid)) or {}
522
+ accepted = {k: stored.get(k) for k in updates if k in stored}
523
+ refused = sorted(k for k in updates if k not in accepted or stored.get(k) != str(updates[k]))
524
+ # No cache to patch: the overlay stratum is re-read from the store on every `_payload`, so
525
+ # read-your-writes within this runtime is a property of the design rather than of a
526
+ # write-through step somebody has to remember. (It was a write-through step while the whole
527
+ # payload was cached on a scope key β€” the arrangement that leaked one user's notes to
528
+ # another. See `_pool_rows`.) Cross-RUNTIME coherence is still not claimed: the module
529
+ # docstring says why, and Postgres is the fix.
530
+ out = {"ok": True, "pid": pid, "updates": accepted}
531
+ if refused:
532
+ out["refused"] = refused
533
+ return out
534
+
535
+
536
+ # ══════════════════════════════════ THE ROUTE-ORDER COLUMN (W38-T20 / ruling R7 / contract C1) ══
537
+ #
538
+ # β›”β›” WHY THIS IS A DOOR HERE AND NOT A KIND IN THE COLUMN MENU. `ColumnMenu.onCreate` is the only
539
+ # persistence the menu has, and it lands a PER-USER `custom_` column through
540
+ # `aios_grid._field_extras`, which is a strict allowlist: a route-order bag created that way is
541
+ # silently stripped and its values are private to their author. That is the wave-19 `image`
542
+ # failure verbatim ("created, named, configured, gone", recorded in `_clean_geocode`'s own
543
+ # docstring) plus a done-when clause that cannot hold, because a colleague reading a per-user
544
+ # stratum gets a clean 200 with nothing in it. The `geocode` pseudo-kind is the precedent for the
545
+ # PICKER; its persistence half does not transfer.
546
+ #
547
+ # ⭐ SO THE COLUMN IS BORN SHARED. `shared_overlay.put_field` stores the definition verbatim (no
548
+ # allowlist), which is what lets the input fingerprint ride the DEFINITION rather than the rows β€”
549
+ # `shared_overlay._value` RAISES on a dict, so `{order, inputsHash}` could never be one cell, and
550
+ # one solve fingerprints the whole cohort identically anyway, so per-row would be the same string
551
+ # written N times.
552
+ #
553
+ # ⭐ AND THE NUMBER ON THE RECORD IS THE INVERSE OF THE PLANNER'S ANSWER. `mapProjection.planRoute`
554
+ # returns `order`, where `order[i]` is WHICH STOP is visited i-th; the cell holds the RANK. The
555
+ # client inverts it with `routeRanks` (gated in `map.test.ts` at the desktop shape, over a fixture
556
+ # chosen so the two differ); this door then refuses anything that is not a clean 1..N, so a
557
+ # truncated or double-posted body cannot land as a half-route.
558
+
559
+ #: The key prefix every route-order column wears. `custom_` and `measure_` are the two existing
560
+ #: created-column namespaces and both are PER USER; this one is tenant-wide, so it takes its own
561
+ #: rather than borrowing a prefix whose readers assume a per-user home.
562
+ ROUTE_KEY_PREFIX = "route_"
563
+
564
+ #: The marker on the stored definition that says WHAT this column is. Read by the GET below and by
565
+ #: the client; never inferred from the key, because a prefix is a naming convention and a
566
+ #: convention is not a declaration.
567
+ ROUTE_KIND = "route_order"
568
+
569
+ #: β›”β›” THE TOPIC A FIELD GRANT IS NAMED UNDER, AND IT IS **NOT** THE STORE BUCKET. Two namespaces
570
+ #: meet on this column and they are spelled differently:
571
+ #:
572
+ #: the STRATUM lives at `shared_overlay.bucket(customer_data.TABLE_KEY)`
573
+ #: = `customer_table_workspace__shared`
574
+ #: the GRANT is named `shares.field_oid(<what hidden_keys was passed>, key)`
575
+ #: = `customer_data:<key>`
576
+ #:
577
+ #: `perm_scope.hidden_keys(user, module, fields)` hands its `module` argument straight through to
578
+ #: `field_grant_hidden`, which builds the oid from it. On a `ut_*` database the module argument IS
579
+ #: the table key, so W38-T16 never had to tell them apart; on a REGISTRY topic they differ, and a
580
+ #: door that claims the grant under the bucket name writes a record the wall will never look for.
581
+ #: MEASURED, not reasoned: the first run of this ticket's gate did exactly that, and user B was
582
+ #: refused a column that had been shared with them through the real share door, with a 200 at
583
+ #: every step ([[one-question-two-normalizers]]).
584
+ SHARE_TOPIC = MODULE
585
+
586
+
587
+ def _route_slug(label):
588
+ """A stable store key from a human label. Lower case, non-alphanumerics collapsed to `_`."""
589
+ import re as _re
590
+ slug = _re.sub(r"[^a-z0-9]+", "_", str(label or "").strip().lower()).strip("_")
591
+ return f"{ROUTE_KEY_PREFIX}{slug[:48]}" if slug else ""
592
+
593
+
594
+ def _route_defs(session: Session):
595
+ """This topic's route-order columns MINUS the ones this session was not granted.
596
+
597
+ β›” THE WALL IS THE SAME ONE THE GRID USES, NOT A SECOND OPINION. `hidden_keys` is where T16
598
+ put the per-field grant check, so filtering on it here means the listing, the grid contract
599
+ and the row payload agree by construction. A column a reader cannot see must not appear here
600
+ either: the done-when says *does not see the field at all*, and a picker that names a column
601
+ whose values are withheld has already leaked its existence.
602
+ """
603
+ import aios_grid
604
+ import core.perm_scope as perm_scope
605
+
606
+ all_defs = shared_fields(st=session.runtime) or {}
607
+ defs = {k: v for k, v in all_defs.items()
608
+ if isinstance(v, dict) and v.get("kind") == ROUTE_KIND}
609
+ if not defs:
610
+ return {}
611
+ hide = perm_scope.hidden_keys(
612
+ session.user, MODULE, _merge_shared_fields(list(aios_grid.FIELDS), all_defs),
613
+ st=session.runtime)
614
+ return {k: v for k, v in defs.items() if k not in hide}
615
+
616
+
617
+ @router.get("/customers/route-order")
618
+ def route_order_list(session: Session = Depends(module_gate(MODULE))):
619
+ """The route-order columns this session may see, with the fingerprint each was solved from.
620
+
621
+ ⭐⭐ THE FINGERPRINT IS WHY THIS ROUTE EXISTS RATHER THAN THE CLIENT READING `fields`.
622
+ Staleness is DERIVED, never stored: what is written down is the INPUT FINGERPRINT the numbers
623
+ were produced from, so *is this order still current* is a question asked at READ time against
624
+ what is on screen NOW, and can never itself be out of date. A stored `stale: true` is a fact
625
+ about a moment that has already passed.
626
+ """
627
+ out = []
628
+ for key, defn in sorted(_route_defs(session).items()):
629
+ route = defn.get("route") if isinstance(defn.get("route"), dict) else {}
630
+ out.append({
631
+ "key": key,
632
+ "label": defn.get("label") or key,
633
+ "inputsHash": str(route.get("inputsHash") or ""),
634
+ "roundTrip": bool(route.get("roundTrip")),
635
+ "startPid": route.get("startPid"),
636
+ "stops": route.get("stops"),
637
+ "solvedAt": route.get("solvedAt") or "",
638
+ "solvedBy": defn.get("createdBy") or "",
639
+ # Who may re-solve it. The same creator-or-admin wall the write door enforces, said on
640
+ # the way out so the client can grey the control instead of discovering a 403.
641
+ "mine": bool(session.admin
642
+ or str(defn.get("createdBy") or "") == session.uname),
643
+ })
644
+ return {"fields": out}
645
+
646
+
647
+ @router.post("/customers/route-order")
648
+ def route_order_write(body: dict = Body(default=None),
649
+ session: Session = Depends(module_gate(MODULE))):
650
+ """Create (or re-solve) a tenant-wide route-order column and fill it, in ONE call.
651
+
652
+ `{label, field?, ranks: {"<pid>": <int>}, inputsHash, roundTrip?, startPid?, stops?}`
653
+
654
+ β›” THE DEFINITION IS WRITTEN FIRST AND THE GRANT CLAIMED SECOND, which is `patch_shared_cell`'s
655
+ order and it is deliberate: the window where a column is MARKED and UNCLAIMED fails CLOSED
656
+ (governed, nobody granted, so only an admin and the creator see it, and an admin can share
657
+ it). The other order would leave a grant record pointing at nothing.
658
+
659
+ β›” RE-SOLVING IS CREATOR-OR-ADMIN, WHICH CREATING IS NOT. Writing these numbers changes what
660
+ every account in the workspace reads, at once, for the whole cohort. That is the wall
661
+ `routes_tables.delete_shared_field` already applies to the destructive half of this stratum,
662
+ and a new door does not get to inherit the loose half of an asymmetry somebody has flagged.
663
+ A permitted teammate READS the numbers; they do not silently re-plan somebody's day.
664
+ """
665
+ from core import shared_overlay
666
+ import core.perm_scope as perm_scope
667
+ from routes_grid import MAX_BULK_ROWS
668
+
669
+ body = body if isinstance(body, dict) else {}
670
+ label = " ".join(str(body.get("label") or "").split())[:120]
671
+ key = str(body.get("field") or "").strip() or _route_slug(label)
672
+ if not key:
673
+ raise err(400, "bad_request", "a name is required for the route order column")
674
+ if not key.startswith(ROUTE_KEY_PREFIX):
675
+ raise err(400, "bad_field_key",
676
+ f"a route order column's key starts with '{ROUTE_KEY_PREFIX}', so it cannot "
677
+ f"collide with a column this database already owns")
678
+ import aios_grid
679
+ if key in {f.get("key") for f in aios_grid.FIELDS}:
680
+ raise err(400, "field_key_taken",
681
+ "this database already has a column with that key")
682
+
683
+ defs = shared_fields(st=session.runtime) or {}
684
+ existing = defs.get(key) if isinstance(defs.get(key), dict) else None
685
+ if existing is not None:
686
+ # β›”β›” A REFUSAL MUST NOT DESCRIBE A COLUMN THE CALLER CANNOT SEE. The two refusals below
687
+ # name the column's KIND and its CREATOR, which is exactly the information the field wall
688
+ # exists to withhold: a stranger who guesses the key would otherwise learn that a route
689
+ # order exists on this database and who planned it. `routes_shares._can_see_object` makes
690
+ # the same choice for the same reason (a non-grantee gets the answer a non-existent id
691
+ # gets). The name is already taken either way, so the honest refusal says only that.
692
+ if key in perm_scope.hidden_keys(
693
+ session.user, MODULE,
694
+ _merge_shared_fields(list(aios_grid.FIELDS), defs), st=session.runtime):
695
+ raise err(400, "field_key_taken",
696
+ "that column name is already in use on this database")
697
+ if existing.get("kind") != ROUTE_KIND:
698
+ raise err(400, "not_a_route_column",
699
+ "that column is shared but it is not a route order column, so re-solving "
700
+ "it would overwrite values this door did not write")
701
+ owner = str(existing.get("createdBy") or "")
702
+ if not session.admin and owner != session.uname:
703
+ raise err(403, "forbidden",
704
+ f"a route order can be re-solved by the person who planned it or by an "
705
+ f"administrator. This one was planned by {owner or 'somebody else'}, and "
706
+ f"re-solving it would change the visit numbers for every account at once")
707
+
708
+ raw = body.get("ranks")
709
+ if not isinstance(raw, dict) or not raw:
710
+ raise err(400, "bad_ranks", 'expected {ranks: {"<pid>": <visit number>}}')
711
+ # β›” REPORTED, NEVER TRUNCATED (standing rule 1's second sentence, and `MAX_BULK_ROWS`' own
712
+ # note). The ceiling is `routes_grid`'s so there is ONE of them, not two that drift.
713
+ if len(raw) > MAX_BULK_ROWS:
714
+ raise err(400, "too_many_rows",
715
+ f"at most {MAX_BULK_ROWS} records per route; this one carried {len(raw)}")
716
+
717
+ pool = allowed_pids(session)
718
+ ranks, not_in_pool, bad_value = {}, [], []
719
+ for raw_pid, value in raw.items():
720
+ try:
721
+ pid = int(raw_pid)
722
+ except (TypeError, ValueError):
723
+ not_in_pool.append(str(raw_pid)[:40])
724
+ continue
725
+ # ⚠ THE POOL IS THE WALL, and it is the SAME predicate the read path applies
726
+ # (`apply_row_scope` inside `allowed_pids`), so a caller cannot number a record they
727
+ # could not be shown, including one in the other business unit.
728
+ if pid not in pool:
729
+ not_in_pool.append(str(raw_pid)[:40])
730
+ continue
731
+ # β›” A BOOL IS AN `int` IN PYTHON and `True` would store as a visit number 1. Excluded by
732
+ # name rather than by hoping nobody sends one.
733
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
734
+ bad_value.append(str(raw_pid)[:40])
735
+ continue
736
+ ranks[pid] = value
737
+
738
+ # β›”β›” A PARTLY HONOURED ROUTE IS NOT A ROUTE, AND THIS REFUSES THE WHOLE CALL RATHER THAN
739
+ # WRITING THE PART IT COULD. Caught by this ticket's own gate: a body carrying one record
740
+ # outside the caller's book still produced a clean 1..N over what was left, so the door minted
741
+ # a permanent tenant-wide column and filled it with a SHORTER route than the one that was
742
+ # solved. Every number in it was plausible and the day was wrong.
743
+ # ⚠ REPORTED, WITH THE SAMPLE, which is standing rule 1's second sentence: the refusal names
744
+ # what it could not take, so the caller can fix it rather than guess.
745
+ if not_in_pool:
746
+ raise err(400, "rows_not_in_your_book",
747
+ f"{len(not_in_pool)} of those records are not in your book, so the route "
748
+ f"cannot be written as it was solved. First few: "
749
+ f"{', '.join(sorted(not_in_pool)[:5])}")
750
+ if bad_value:
751
+ raise err(400, "rows_not_a_visit_number",
752
+ f"a visit number is a whole number from 1 upwards; {len(bad_value)} records "
753
+ f"carried something else. First few: {', '.join(sorted(bad_value)[:5])}")
754
+ if not ranks:
755
+ raise err(400, "no_rows_in_your_book",
756
+ "none of those records are in your book, so there is nothing to number")
757
+ # β›” A ROUTE IS A SEQUENCE, SO THE NUMBERS ARE 1..N WITH NO REPEAT AND NO GAP. A body that
758
+ # arrives truncated, doubled or partly applied would otherwise land as a plausible half
759
+ # route: every row carrying a number, and the day in the wrong order.
760
+ seq = sorted(ranks.values())
761
+ if seq != list(range(1, len(seq) + 1)):
762
+ raise err(400, "not_a_sequence",
763
+ f"a route order is the numbers 1 to {len(seq)}, each used once. This one "
764
+ f"carried {len(seq)} records numbered up to {seq[-1]} with "
765
+ f"{len(seq) - len(set(seq))} repeated")
766
+
767
+ stamp = time.strftime("%Y-%m-%d %H:%M")
768
+ defn = {
769
+ "key": key,
770
+ "label": label or (existing or {}).get("label") or key,
771
+ # β›” `int`, NEVER `text`. A text column sorts 1, 10, 11, 2 β€” fully populated, entirely
772
+ # plausible, wrong, and named by no gate. `patch_shared_cell` defaults to text; this door
773
+ # cannot.
774
+ "type": "int",
775
+ "source": "overlay",
776
+ "shared": True,
777
+ "kind": ROUTE_KIND,
778
+ # ⭐⭐ THE PER-FIELD GRANT MARKER (T16). It is an EXPLICIT write-once declaration and it is
779
+ # what makes the wall fail CLOSED: a grant wall has the opposite absence-polarity to a
780
+ # deny wall, so keying visibility on "does a grant record exist" would publish this column
781
+ # to the whole tenant on one unreadable read of `object_shares`. Stamped, never inferred.
782
+ perm_scope.FIELD_GRANT_MARK: True,
783
+ "createdBy": (existing or {}).get("createdBy") or session.uname,
784
+ # ⭐ THE FINGERPRINT RIDES THE DEFINITION, ONCE. One `planRoute` run solves the whole
785
+ # cohort, so this string is identical for every record in it; per row it would be the same
786
+ # value written N times, and `shared_overlay._value` raises on a dict anyway.
787
+ "route": {
788
+ "inputsHash": str(body.get("inputsHash") or "")[:64],
789
+ "roundTrip": bool(body.get("roundTrip")),
790
+ "startPid": int(body["startPid"]) if isinstance(body.get("startPid"), int)
791
+ and not isinstance(body.get("startPid"), bool) else None,
792
+ "stops": len(ranks),
793
+ "solvedAt": stamp,
794
+ },
795
+ }
796
+ shared_overlay.put_field(_shared_key(), key, defn, st=session.runtime)
797
+ if existing is None:
798
+ try:
799
+ import core.shares as shares
800
+ # ⚠ THE EMPTY ENTRY LIST IS THE POINT. `set_grants` keeps a record with an owner and
801
+ # no entries, so "shared with nobody" is STORED and is a different fact from "never
802
+ # shared". Without the owner the column is unmanageable: `may_administer` fails closed
803
+ # on an ownerless record, so nobody could ever share it.
804
+ shares.set_grants("field", shares.field_oid(SHARE_TOPIC, key), [],
805
+ owner=session.uname, st=session.runtime)
806
+ except Exception: # noqa: BLE001
807
+ # The mark is already written, so a failed claim fails CLOSED: governed, nobody
808
+ # granted, admin-and-creator only. Recoverable. The other order is not.
809
+ pass
810
+
811
+ # β›” AND THE RECORDS THAT LOST THEIR NUMBER ARE CLEARED. A re-solve over a SMALLER cohort
812
+ # would otherwise leave the previous run's ranks sitting on the records that dropped out β€”
813
+ # plausible integers, from a route nobody is driving. `""` is the house spelling of an empty
814
+ # overlay cell (`rows_from_pool` defaults an absent one to exactly that), so this needs no new
815
+ # vocabulary and no tombstone nobody else reads.
816
+ stale = {}
817
+ for raw_pid, cells in (shared_cells(pool, st=session.runtime) or {}).items():
818
+ if not isinstance(cells, dict) or cells.get(key) in (None, ""):
819
+ continue
820
+ try:
821
+ gone = int(raw_pid)
822
+ except (TypeError, ValueError):
823
+ continue
824
+ if gone not in ranks:
825
+ stale[gone] = {key: ""}
826
+ written = shared_overlay.put_rows(
827
+ _shared_key(), {**{p: {key: v} for p, v in ranks.items()}, **stale},
828
+ st=session.runtime)
829
+
830
+ out = {"ok": True, "field": key, "label": defn["label"], "type": "int",
831
+ "stops": len(ranks), "cleared": len(stale),
832
+ "rows_written": len(written), "inputsHash": defn["route"]["inputsHash"],
833
+ "solvedAt": stamp}
834
+ return out
api/routes_nav.py CHANGED
@@ -867,8 +867,22 @@ def save_nav_prefs(body: dict = Body(default=None),
867
  @router.get("/nav/schema/{key}")
868
  def nav_schema(key: str, session: Session = Depends(require_session)):
869
  """The database's schema drawer payload: its field contract + the semantic measures this
870
- session may build with. Fail-closed on the SAME predicate as the nav β€” a key the session
871
- may not open answers 403, never a redacted schema."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
872
  # Wave 18 C3-UT: a user table's schema is its own definition, walled by ITS predicate
873
  # (`may_open`) rather than the module grant machinery β€” `session.require` would 403 every
874
  # ut key because a user table is deliberately not a module.
@@ -878,6 +892,25 @@ def nav_schema(key: str, session: Session = Depends(require_session)):
878
  if not defn or not user_tables.may_open(key, session.uname, session.admin,
879
  st=session.runtime):
880
  raise err(403, "forbidden", "that database belongs to another user")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
  # WAVE 19 (R8) β€” the drawer wears the RENAMED name. A rail that says one thing and a
882
  # schema panel opened from it that says another is the drift a rename is supposed to
883
  # remove, not create.
@@ -888,7 +921,7 @@ def nav_schema(key: str, session: Session = Depends(require_session)):
888
  "fields": [{"key": f["key"], "label": f["label"], "type": f["type"],
889
  "source": f.get("source") or "overlay",
890
  "description": str(f.get("description") or "")}
891
- for f in (defn.get("fields") or [])],
892
  "measures": []}
893
  session.require(key)
894
  pages = perms.nav_pages(session.user) or []
@@ -899,7 +932,26 @@ def nav_schema(key: str, session: Session = Depends(require_session)):
899
  if key in ("customer_data", "cohort", "customers"):
900
  try:
901
  import aios_grid
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
902
  for f in aios_grid.FIELDS:
 
 
903
  entry = {"key": f["key"], "label": f["label"], "type": f["type"],
904
  "source": f["source"],
905
  "description": str(f.get("description") or "")}
@@ -911,8 +963,19 @@ def nav_schema(key: str, session: Session = Depends(require_session)):
911
  measures = []
912
  try:
913
  from core import measure_resolve
 
914
  team_id = perms.scope_team_id(session.user)
915
- for m in measure_resolve.offer(team_id) or []:
 
 
 
 
 
 
 
 
 
 
916
  measures.append({"key": str(m.get("key") or ""),
917
  "label": str(m.get("label") or m.get("key") or ""),
918
  "type": str(m.get("type") or "")})
 
867
  @router.get("/nav/schema/{key}")
868
  def nav_schema(key: str, session: Session = Depends(require_session)):
869
  """The database's schema drawer payload: its field contract + the semantic measures this
870
+ session may build with.
871
+
872
+ TWO WALLS, ANSWERING TWO QUESTIONS, AND THEY FAIL DIFFERENTLY ON PURPOSE. The DATABASE wall
873
+ is the same predicate the nav uses β€” a key this session may not open answers 403, never a
874
+ redacted schema. The FIELD wall runs *after* it, on a database this session may open, and
875
+ its whole job is to narrow: the drawer serves the columns this reader receives everywhere
876
+ else, and no more.
877
+
878
+ ⭐⭐ W38-T18 β€” THE FIELD WALL IS NEW HERE AND THIS DOOR IS WHERE IT WAS MISSING. Every grid
879
+ door has stripped hidden columns from both wires since W36-T21; the schema drawer beside them
880
+ served the raw contract to anyone `require`/`may_open` admitted. Owner instruction 10 β€”
881
+ *"Metrics field lookback and value must abide by the Filter permissioning"* β€” is satisfied on
882
+ the picker side already (the condition kit takes `fields` as a PROP off a payload that is
883
+ walled upstream), so this route is the one place the same reader could still learn the name,
884
+ the label and the VALUE VOCABULARY of a column their grid refuses them.
885
+ """
886
  # Wave 18 C3-UT: a user table's schema is its own definition, walled by ITS predicate
887
  # (`may_open`) rather than the module grant machinery β€” `session.require` would 403 every
888
  # ut key because a user table is deliberately not a module.
 
892
  if not defn or not user_tables.may_open(key, session.uname, session.admin,
893
  st=session.runtime):
894
  raise err(403, "forbidden", "that database belongs to another user")
895
+ # ⭐⭐ W38-T18 β€” THE FIELD WALL ON A `ut_*` DATABASE, WHICH THIS DOOR HAS NEVER RUN.
896
+ # `may_open` answers *IF* you reach the database and says nothing about WHICH COLUMNS, so
897
+ # this drawer handed `defn["fields"]` whole to every account it admitted. The bypass is
898
+ # older than this ticket (it dates to the wall itself); what changed is the CATEGORY it
899
+ # leaks β€” since W38-T16 a hidden `ut_*` column means an administrator's `hiddenFields`
900
+ # *or* a column granted away, and a share door whose schema drawer still names the column
901
+ # tells the ungranted reader exactly what they were refused.
902
+ # ⚠ MERGED BEFORE THE WALL, NEVER AFTER β€” the same order `routes_tables.ut_assembly`
903
+ # runs, and for the reason `_ut_shared_fields` gives one door over: the closure must see
904
+ # the WHOLE contract, or a definition field whose formula reads a hidden tenant-wide
905
+ # column sits outside its reach and carries that value out wearing a second name.
906
+ # β›” ONLY THE DEFINITION'S OWN FIELDS ARE RETURNED. This drawer has never published the
907
+ # shared stratum and this ticket is not where it starts; widening the list handed to the
908
+ # closure can only ever ADD to the hidden set, never remove from it.
909
+ import routes_tables as _rt
910
+ base = list(defn.get("fields") or [])
911
+ hidden = _rt._ut_hidden(session, key,
912
+ _rt._ut_shared_fields(session, key, base),
913
+ st=session.runtime)
914
  # WAVE 19 (R8) β€” the drawer wears the RENAMED name. A rail that says one thing and a
915
  # schema panel opened from it that says another is the drift a rename is supposed to
916
  # remove, not create.
 
921
  "fields": [{"key": f["key"], "label": f["label"], "type": f["type"],
922
  "source": f.get("source") or "overlay",
923
  "description": str(f.get("description") or "")}
924
+ for f in base if f.get("key") not in hidden],
925
  "measures": []}
926
  session.require(key)
927
  pages = perms.nav_pages(session.user) or []
 
932
  if key in ("customer_data", "cohort", "customers"):
933
  try:
934
  import aios_grid
935
+ import routes_customers as _rc
936
+ # ⭐⭐ W38-T18 β€” THE FIELD WALL ON THE CUSTOMER CONTRACT, AND THE `options` LINE FOUR
937
+ # ROWS DOWN IS WHY IT IS A LEAK RATHER THAN AN UNTIDINESS. A schema drawer that names
938
+ # a hidden column has told the reader it exists; one that ships `options` has handed
939
+ # them the VALUE VOCABULARY of every choice and status column their grid refuses β€”
940
+ # the set of statuses the business uses, read straight off a door with no wall.
941
+ # β›” `_hidden_for` IS REUSED, NOT RE-DERIVED, and the difference is not cosmetic.
942
+ # `hidden_keys(user, MODULE, aios_grid.FIELDS)` is the obvious spelling and it is
943
+ # INCOMPLETE: that function's own docstring records that a wall computed from the
944
+ # canonical list alone answers "not hidden" for every runtime column, so the
945
+ # tenant-wide stratum never reaches the closure and a formula over a hidden column
946
+ # comes back. One evaluator, one caller, no second idea of what this session hides.
947
+ # ⚠ INSIDE THE `try`, DELIBERATELY. If the wall cannot be computed the route already
948
+ # answers with an empty contract rather than a full one β€” fail-closed is the only
949
+ # direction a schema door may fail, and the gate's ADMIN control is what stops that
950
+ # silent empty being mistaken for a wall that worked.
951
+ hidden = _rc._hidden_for(session)
952
  for f in aios_grid.FIELDS:
953
+ if f["key"] in hidden:
954
+ continue
955
  entry = {"key": f["key"], "label": f["label"], "type": f["type"],
956
  "source": f["source"],
957
  "description": str(f.get("description") or "")}
 
963
  measures = []
964
  try:
965
  from core import measure_resolve
966
+ import core.perm_scope as _perm_scope
967
  team_id = perms.scope_team_id(session.user)
968
+ # ⭐⭐ W38-T19 β€” THE METRICS CAPABILITY REACHES THIS DRAWER TOO, AND IT IS THE ONE DOOR
969
+ # THE THREE GRID ASSEMBLIES DO NOT SPEAK FOR. This route calls `measure_resolve.offer`
970
+ # ITSELF rather than reading a `measures` key off an assembly, so a wall applied in
971
+ # `routes_customers` / `routes_products` / `routes_tables` narrows every grid and leaves
972
+ # the panel beside them naming the full lookback catalogue β€” the same one-door-short
973
+ # shape W38-T18 fixed for FIELDS in this very function, one key over.
974
+ # ⚠ THE UT_ BRANCH ABOVE RETURNS BEFORE THIS AND ALREADY SERVES `[]`, so there is
975
+ # nothing to narrow there; if it ever starts publishing an offer, it needs this line.
976
+ _offer = ((measure_resolve.offer(team_id) or [])
977
+ if _perm_scope.may_metrics(session.user, key) else [])
978
+ for m in _offer:
979
  measures.append({"key": str(m.get("key") or ""),
980
  "label": str(m.get("label") or m.get("key") or ""),
981
  "type": str(m.get("type") or "")})
api/routes_products.py CHANGED
@@ -137,8 +137,22 @@ def _measure_stamp(rt, team_id):
137
  return entry[0] if isinstance(entry, tuple) and entry else 0
138
 
139
 
140
- def product_measures():
141
- """The measure OFFER for this database, or `[]` when the semantic layer cannot answer.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
  ⭐⭐ W37-T10 / owner item 4 (R1) β€” this is what turns `measures: []` into a real catalogue.
144
  Owner: *"Odoo products should have a lookbsck metrics like sales etc."* The list is DERIVED
@@ -150,6 +164,9 @@ def product_measures():
150
  client's own rule (`offerMeasure={measures.length > 0}`) then hides the Metric kind rather
151
  than offering a column that could only render blank.
152
  """
 
 
 
153
  try:
154
  from harness import semantic as sem
155
  return sem.entity_measures(SEM_TOPIC)
@@ -349,7 +366,7 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
349
 
350
  today = time.strftime("%Y-%m-%d")
351
  stamp = _measure_stamp(session.runtime, team_id)
352
- measures = product_measures()
353
 
354
  # R9: the Cohorts column's cells, built from THIS topic's lists. ⭐ W37-T10 β€” the measure
355
  # half of this same read-only channel is no longer empty; the customer assembly merges its
 
137
  return entry[0] if isinstance(entry, tuple) and entry else 0
138
 
139
 
140
+ def product_measures(session):
141
+ """The measure OFFER for this SESSION on this database, or `[]`.
142
+
143
+ ⭐⭐ W38-T19 β€” `session` IS REQUIRED, NOT DEFAULTED, AND THAT IS THE POINT OF THE EDIT. This
144
+ function was PRINCIPAL-BLIND: it answered the same catalogue to every caller, so there was
145
+ nothing for a per-user capability to be consulted by. A `session=None` default would have
146
+ left the old call shape working and silently ungated β€” the fail-open spelling of the same
147
+ bug, and the one `_module_fields` in `routes_admin` was rewritten to refuse. A caller with
148
+ no principal cannot ask this question and must not get an answer.
149
+
150
+ β›” EMPTYING THE OFFER IS THE WHOLE WALL ON THIS GRAIN, and it is three refusals rather than
151
+ one: `_measure_cells` and `_measure_condition_sets` both return `{}` for a falsy `offer`, and
152
+ `routes_grid` passes this list into `clean_measure_field` as the ADMISSION set, which
153
+ fail-closes `measure_not_offered` on a create. The client's `offerMeasure={measures.length >
154
+ 0}` then takes the Metric kind off the picker for the same reason it hides it on a database
155
+ with no binding.
156
 
157
  ⭐⭐ W37-T10 / owner item 4 (R1) β€” this is what turns `measures: []` into a real catalogue.
158
  Owner: *"Odoo products should have a lookbsck metrics like sales etc."* The list is DERIVED
 
164
  client's own rule (`offerMeasure={measures.length > 0}`) then hides the Metric kind rather
165
  than offering a column that could only render blank.
166
  """
167
+ import core.perm_scope as perm_scope
168
+ if not perm_scope.may_metrics(session.user, MODULE):
169
+ return []
170
  try:
171
  from harness import semantic as sem
172
  return sem.entity_measures(SEM_TOPIC)
 
366
 
367
  today = time.strftime("%Y-%m-%d")
368
  stamp = _measure_stamp(session.runtime, team_id)
369
+ measures = product_measures(session)
370
 
371
  # R9: the Cohorts column's cells, built from THIS topic's lists. ⭐ W37-T10 β€” the measure
372
  # half of this same read-only channel is no longer empty; the customer assembly merges its
api/routes_shares.py CHANGED
@@ -4,9 +4,19 @@
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
@@ -102,6 +112,24 @@ def _owns_object(session, kind, oid):
102
  """
103
  if session.admin:
104
  return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  if kind == "database":
106
  # ⚠ `may_open` is THE resolver for a user table (its own docstring says so) and already
107
  # admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
@@ -157,6 +185,10 @@ def _can_see_object(session, kind, oid):
157
  if kind != "view":
158
  # A folder carries no per-object visibility flag of its own, and a database's `may_open`
159
  # (inside `_owns_object`) already admits grantees. Nothing wider to ask.
 
 
 
 
160
  return False
161
  try:
162
  import core.table_store as table_store
@@ -195,7 +227,7 @@ def _entries_or_400(session, entries):
195
  raise err(400, "unknown_people",
196
  "no active account in this workspace is named "
197
  + ", ".join(sorted(set(unknown)))
198
- + " β€” nothing was shared. Pick people from the list rather than typing a name.")
199
 
200
 
201
  @router.get("/share/mine")
@@ -286,7 +318,7 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None),
286
  entries = body.get("entries")
287
  if not isinstance(entries, list):
288
  raise err(400, "bad_entries",
289
- "entries must be a list of {user, role} β€” send [] to un-share, which is how "
290
  "revoking is expressed")
291
  _entries_or_400(session, entries)
292
  out = shares.set_grants(kind, oid, entries, owner=rec["owner"] or session.uname,
@@ -367,6 +399,32 @@ def _object_ref(session, kind, oid):
367
  a row that opens nowhere.
368
  """
369
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  if kind == "database":
371
  import core.user_tables as ut
372
  defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
@@ -390,4 +448,5 @@ def _object_ref(session, kind, oid):
390
  route, str(oid) if kind == "view" else "")
391
  except Exception: # noqa: BLE001
392
  pass
393
- return ({"view": "A view", "folder": "A folder"}.get(kind, "An item"), None, "")
 
 
4
  PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
5
  GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
6
 
7
+ `kind` ∈ view | folder | database | field. Roles are `view` | `edit` β€” the same two words the
8
+ view rail already speaks, now extended to folders, databases and COLUMNS so there is ONE
9
+ vocabulary in the UI (R10: "the same picker views use").
10
+
11
+ ⭐⭐ **W38-T16 β€” `field` IS THE FOURTH KIND, AND ITS `oid` IS TOPIC-QUALIFIED: `"<table_key>:<field_key>"`**
12
+ (`shares.field_oid`). A bare column key repeats across databases β€” `notes` exists on a dozen β€”
13
+ so a grant stored under one would admit the grantee to every `notes` column in the tenant at
14
+ once. β›” THREE functions in this file branch on kind and ALL THREE need the new one, which is not
15
+ obvious because only two of them fail loudly: `_owns_object` (without it a column's own creator
16
+ is 404'd trying to share the thing they just made) and `_object_ref` (without it `route` is None,
17
+ `_notify_new_grantees` returns early, and the grantee is **granted and never told** β€” owner item
18
+ 18's silent half, reopened one kind over). `_can_see_object` stays deliberately CLOSED for
19
+ anything that is not a view.
20
 
21
  β›” **RE-SHARING IS THE OWNER'S, AND THAT IS ENFORCED HERE, NOT IN THE CLIENT.** `PUT` requires
22
  `shares.may_administer` (owner or admin). A collaborator with `edit` may change an object's
 
112
  """
113
  if session.admin:
114
  return True
115
+ if kind == "field":
116
+ # ⭐⭐ W38-T16 β€” A COLUMN'S OWNER IS ITS `createdBy`, WHICH THE CREATE DOOR ALREADY STAMPS
117
+ # (`routes_tables.patch_shared_cell`) and the DELETE door already reads as its wall (R8 /
118
+ # D-172: creator-or-admin). Read from the same place by all three, so a column cannot be
119
+ # deletable by one person and shareable by another.
120
+ # ⚠ THIS BRANCH IS NOT OPTIONAL AND ITS ABSENCE FAILS SILENTLY IN THE WORST DIRECTION:
121
+ # a brand-new column has no grant record, so `put_share` falls to this predicate β€” and
122
+ # without it the column's own creator is answered `404 no_object` on the first attempt to
123
+ # share the thing they just made.
124
+ table_key, field_key = shares.split_field_oid(oid)
125
+ if not table_key:
126
+ return False
127
+ try:
128
+ import core.shared_overlay as shared_overlay
129
+ defn = (shared_overlay.fields(table_key, st=session.runtime) or {}).get(field_key)
130
+ except Exception: # noqa: BLE001
131
+ return False
132
+ return bool(defn) and str(defn.get("createdBy") or "") == str(session.uname)
133
  if kind == "database":
134
  # ⚠ `may_open` is THE resolver for a user table (its own docstring says so) and already
135
  # admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
 
185
  if kind != "view":
186
  # A folder carries no per-object visibility flag of its own, and a database's `may_open`
187
  # (inside `_owns_object`) already admits grantees. Nothing wider to ask.
188
+ # ⭐ W38-T16 β€” AND `field` KEEPS THIS CLOSED, DELIBERATELY. A grantee never reaches here:
189
+ # `get_share` tests `role is None` first and a grant answers a role, so the only caller
190
+ # left is an account with no relationship to the column at all. Widening it would let any
191
+ # signed-in session enumerate who holds which column on a database they cannot open.
192
  return False
193
  try:
194
  import core.table_store as table_store
 
227
  raise err(400, "unknown_people",
228
  "no active account in this workspace is named "
229
  + ", ".join(sorted(set(unknown)))
230
+ + ". Nothing was shared. Pick people from the list rather than typing a name.")
231
 
232
 
233
  @router.get("/share/mine")
 
318
  entries = body.get("entries")
319
  if not isinstance(entries, list):
320
  raise err(400, "bad_entries",
321
+ "entries must be a list of {user, role}. Send [] to un-share, which is how "
322
  "revoking is expressed")
323
  _entries_or_400(session, entries)
324
  out = shares.set_grants(kind, oid, entries, owner=rec["owner"] or session.uname,
 
399
  a row that opens nowhere.
400
  """
401
  try:
402
+ if kind == "field":
403
+ # ⭐⭐ W38-T16 β€” A COLUMN IS NOT ADDRESSABLE ON ITS OWN, exactly as a view is not: it
404
+ # is a column INSIDE a database, so the target that travels is the DATABASE. Without
405
+ # this branch the function falls through to the view/folder loop, finds nothing,
406
+ # answers `route=None` β€” and `_notify_new_grantees` returns EARLY. The grant lands and
407
+ # the receiver is never told, which is the silent half of owner item 18 reopened one
408
+ # kind over.
409
+ from routes_alerts import route_for_topic
410
+ table_key, field_key = shares.split_field_oid(oid)
411
+ if not table_key:
412
+ return ("A column", None, "")
413
+ try:
414
+ import core.shared_overlay as shared_overlay
415
+ defn = (shared_overlay.fields(table_key, st=session.runtime) or {}).get(field_key)
416
+ except Exception: # noqa: BLE001
417
+ defn = None
418
+ label = str((defn or {}).get("label") or "").strip() or field_key
419
+ # ⚠ TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
420
+ # by whatever the calling door already held: a `ut_*` database uses its bare key,
421
+ # while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
422
+ # `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
423
+ # `customer_data`), so the suffix comes off before it is asked β€” rather than a second
424
+ # route table being written here, which is how the two come apart.
425
+ _WS = "_table_workspace"
426
+ scope = table_key[:-len(_WS)] if table_key.endswith(_WS) else table_key
427
+ return (label, route_for_topic(scope) or None, "")
428
  if kind == "database":
429
  import core.user_tables as ut
430
  defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
 
448
  route, str(oid) if kind == "view" else "")
449
  except Exception: # noqa: BLE001
450
  pass
451
+ return ({"view": "A view", "folder": "A folder",
452
+ "field": "A column"}.get(kind, "An item"), None, "")
api/routes_tables.py CHANGED
@@ -429,11 +429,36 @@ def patch_shared_cell(table_key: str, pid: int, body: dict = Body(default=None),
429
  raise err(400, "bad_request", "a field key is required")
430
  _defn_or_refuse(session, table_key)
431
  from core import shared_overlay
 
432
  if not shared_overlay.is_shared(table_key, key, st=session.runtime):
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  shared_overlay.put_field(table_key, key, {
434
  "key": key, "label": str(body.get("label") or key), "source": "overlay",
435
  "type": str(body.get("type") or "text"), "shared": True,
 
436
  "createdBy": session.uname}, st=session.runtime)
 
 
 
 
 
 
 
 
 
 
437
  try:
438
  # ⚠ `put_cell`, not `put_cells` β€” this door writes exactly ONE cell, and the singular is
439
  # the API that says so. It delegates to the plural, so both stay reachable through the one
@@ -477,6 +502,18 @@ def delete_shared_field(table_key: str, field_key: str,
477
  f"was added by {owner or 'somebody else'}, and dropping it would delete the "
478
  f"value for every account")
479
  dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
 
 
 
 
 
 
 
 
 
 
 
 
480
  return {"ok": True, "dropped": bool(dropped),
481
  "fields": list(shared_overlay.fields(table_key, st=session.runtime))}
482
 
@@ -617,7 +654,7 @@ def ut_write_ctx(session: Session, table_key: str):
617
  # `:2021`); this ctx passed `frozenset()`, so a hidden column was hidden on the READ and fully
618
  # writable on the EVENTS transport β€” a wall on one wire and not the other is the shape
619
  # `strip_row`'s own note warns about, with the sign flipped.
620
- hidden = _ut_hidden(session, table_key, fields_base)
621
  ctx = grid_events.EventCtx(
622
  uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=hidden,
623
  admin=session.admin, fallback_ws=None, seen_ids={}, st=session.runtime,
@@ -626,7 +663,13 @@ def ut_write_ctx(session: Session, table_key: str):
626
  workspace, fields, views, lists = aios_grid.workspace_wire(
627
  ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
628
  fields_base=fields_base)
629
- fields, _rows, hidden = _ut_field_wall(session, table_key, fields, [])
 
 
 
 
 
 
630
  return {"rows_src": [], "pids": pids, "ws": ws, "workspace": workspace,
631
  "fields": fields, "views": views, "lists": lists, "hidden": hidden,
632
  "derived": aios_grid.cohort_cells(lists),
@@ -645,25 +688,74 @@ def ut_write_ctx(session: Session, table_key: str):
645
  # `resolved_ids` (from `measure_sets`) only makes `view_upsert` answer "rerender",
646
  # so the next `/workspace` β€” which DOES compute the sets, in `ut_assembly` β€” resolves
647
  # it. Conservative in the right direction: one extra repaint, never a dropped filter.
648
- "measures": ut_measures(table_key),
 
 
 
 
 
649
  "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
650
  # ⭐ W31-T20 β€” the write door reads this to refuse a PID-BEARING event loudly rather
651
  # than letting `allowed_pids` swallow it as a no-op. See `routes_grid`'s ut_ branch.
652
  "limits": limits, "defn": defn}
653
 
654
 
655
- def _ut_hidden(session, table_key, fields):
656
  """The hidden-field closure for THIS session on THIS database β€” C1's field half, once.
657
 
658
  ⚠ Named rather than inlined at its four call sites for the reason `may_open`'s own note gives:
659
  four spellings of one wall is how two of them come apart. `perm_scope.hidden_keys` is the ONE
660
  evaluator; this is just the `ut_*` caller's shorthand for it.
 
 
 
 
661
  """
662
  import core.perm_scope as perm_scope
663
- return perm_scope.hidden_keys(session.user, table_key, fields)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
 
 
 
665
 
666
- def _ut_field_wall(session, table_key, fields, rows_src):
 
 
 
 
 
 
 
 
 
 
 
 
667
  """`(fields, rows_src, hidden)` with the hidden closure removed from BOTH wires.
668
 
669
  ⭐⭐ W36-T21 β€” the same three lines `routes_customers.grid_assembly` runs for `customer_data`,
@@ -682,7 +774,7 @@ def _ut_field_wall(session, table_key, fields, rows_src):
682
  path with nothing to say ([[one-question-two-normalizers]]).
683
  """
684
  import core.perm_scope as perm_scope
685
- hide = perm_scope.hidden_keys(session.user, table_key, fields)
686
  if not hide:
687
  return fields, rows_src, frozenset()
688
  fields = [f for f in fields if f.get("key") not in hide]
@@ -740,7 +832,7 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
740
  ops = _ops(session, table_key, st=st)
741
  # ⭐⭐ W36-T21 β€” the WRITE half of the field wall. `frozenset()` here meant a column an
742
  # administrator had hidden was still writable through the events transport.
743
- hidden = _ut_hidden(session, table_key, fields_base)
744
  ctx = grid_events.EventCtx(
745
  uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=hidden,
746
  admin=session.admin, fallback_ws=None, seen_ids={},
@@ -753,16 +845,31 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
753
  workspace, fields, views, lists = aios_grid.workspace_wire(
754
  ws, session.uname, set(pids), defs={}, scope_key=table_key,
755
  storage_key=storage_key, fields_base=fields_base)
 
 
 
 
 
 
 
 
 
756
  # ⭐⭐ W36-T21 / R6 β€” the READ half, in `grid_assembly`'s own position: after `workspace_wire`,
757
  # so the closure covers this user's `custom_` and `measure_` columns too.
758
- fields, rows_src, hidden = _ut_field_wall(session, table_key, fields, rows_src)
 
 
 
 
 
 
759
 
760
  # ⭐⭐ W37-T12 / owner item 4 (R1) β€” `measures` IS NO LONGER UNCONDITIONALLY EMPTY. A database
761
  # whose entity topic declares a `measures:` binding (today `ut_odoo_agents`) serves a real
762
  # lookback catalogue AND the cells to go with it; every other user table still serves `[]`,
763
  # which is the honest answer for a hand-typed one rather than a descope.
764
  today = time.strftime("%Y-%m-%d")
765
- measures = ut_measures(table_key)
766
  derived = aios_grid.cohort_cells(lists) # R9: this table's own lists
767
  for _pid, _cells in _ut_measure_cells(table_key, fields, pids, today, measures,
768
  session.runtime.measure_memo).items():
@@ -771,6 +878,10 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
771
  return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
772
  "fields": fields, "views": views, "lists": lists, "hidden": hidden,
773
  "derived": derived,
 
 
 
 
774
  "measures": measures,
775
  "measure_sets": _ut_measure_sets(table_key, views, measures, pids, today),
776
  "today": today,
@@ -787,8 +898,18 @@ def _ut_measure_err(tag, e):
787
  pass
788
 
789
 
790
- def ut_measures(table_key):
791
- """The lookback-measure OFFER for a user database, or `[]` β€” W37-T12 / owner item 4 (R1).
 
 
 
 
 
 
 
 
 
 
792
 
793
  ⭐ THE SAME ENGINE THE PRODUCT GRID USES (`semantic.entity_measures`), reached through the
794
  topic that DESCRIBES this database. A table with no entity topic, or a topic with no
@@ -800,6 +921,9 @@ def ut_measures(table_key):
800
  so a list here would be a second definition of one fact β€” the argument `_rollup_source_offer`
801
  makes about itself one screen down.
802
  """
 
 
 
803
  try:
804
  from harness import semantic as sem
805
  topic = sem.topic_for_grid(table_key)
@@ -1346,6 +1470,16 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
1346
  if isinstance(_ov, dict):
1347
  _cells.update(_ov)
1348
  merged[_pid] = _cells
 
 
 
 
 
 
 
 
 
 
1349
  # ⭐⭐ 2026-08-10 (owner: *"wth is going on, why does it take forever to load now?"*) β€” THE
1350
  # JSON DOCUMENTS DO NOT RIDE THE LIST.
1351
  #
 
429
  raise err(400, "bad_request", "a field key is required")
430
  _defn_or_refuse(session, table_key)
431
  from core import shared_overlay
432
+ import core.perm_scope as perm_scope
433
  if not shared_overlay.is_shared(table_key, key, st=session.runtime):
434
+ # ⭐⭐ W38-T16 / R7 β€” A COLUMN IS BORN OWNED AND PRIVATE, AND THIS IS THE PRODUCT CHANGE.
435
+ # Until now this door minted a column every account in the tenant could read, with no way
436
+ # to say who. `granted` marks it as governed by `core.shares` (see
437
+ # `perm_scope.FIELD_GRANT_MARK` for why an explicit marker and not the absence of a grant
438
+ # record), and the grant record below claims it for the creator.
439
+ #
440
+ # ⚠ THE EMPTY ENTRY LIST IS THE POINT, NOT A PLACEHOLDER. `set_grants` keeps a record that
441
+ # has an owner and no entries, so "shared with nobody" is STORED and is a different fact
442
+ # from "never shared" β€” the same explicit-resolution shape `perms_v` uses. Without the
443
+ # owner the column would be unmanageable: `may_administer` fails closed on an ownerless
444
+ # record, so nobody could ever share or re-share it.
445
+ # ⚠ COLUMNS THAT PREDATE THIS CARRY NO MARK and stay tenant-wide, unchanged. Reading
446
+ # their absence as "granted to nobody" would blank every existing shared column at once.
447
  shared_overlay.put_field(table_key, key, {
448
  "key": key, "label": str(body.get("label") or key), "source": "overlay",
449
  "type": str(body.get("type") or "text"), "shared": True,
450
+ perm_scope.FIELD_GRANT_MARK: True,
451
  "createdBy": session.uname}, st=session.runtime)
452
+ try:
453
+ import core.shares as shares
454
+ shares.set_grants("field", shares.field_oid(table_key, key), [],
455
+ owner=session.uname, st=session.runtime)
456
+ except Exception: # noqa: BLE001
457
+ # β›” THE MARK IS ALREADY WRITTEN, SO A FAILED CLAIM FAILS **CLOSED**: the column is
458
+ # governed and nobody holds a grant, i.e. only an admin sees it. That is recoverable
459
+ # (an admin can share it) and the other order is not β€” a marked column with a claim
460
+ # that landed first and a definition that did not would be a grant on nothing.
461
+ pass
462
  try:
463
  # ⚠ `put_cell`, not `put_cells` β€” this door writes exactly ONE cell, and the singular is
464
  # the API that says so. It delegates to the plural, so both stay reachable through the one
 
502
  f"was added by {owner or 'somebody else'}, and dropping it would delete the "
503
  f"value for every account")
504
  dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
505
+ # ⭐⭐ W38-T16 β€” THE GRANTS DIE WITH THE COLUMN, which is `shares.drop_objects`' whole reason
506
+ # for existing (wave 21, C3): a deleted object's grants would otherwise serve a ghost id into
507
+ # every receiver's "Shared with me" forever, and the ghost 404s on open. It matters twice as
508
+ # much for a field, because `drop_field` scrubs the CELLS so the key can be re-used β€” and a
509
+ # surviving grant record would silently re-arm on the next column that took the name.
510
+ # ⚠ R8 / D-172 IS UNCHANGED ABOVE: delete is creator-or-admin. This ticket did not widen it.
511
+ try:
512
+ import core.shares as shares
513
+ shares.drop_objects([("field", shares.field_oid(table_key, str(field_key)))],
514
+ st=session.runtime)
515
+ except Exception: # noqa: BLE001
516
+ pass
517
  return {"ok": True, "dropped": bool(dropped),
518
  "fields": list(shared_overlay.fields(table_key, st=session.runtime))}
519
 
 
654
  # `:2021`); this ctx passed `frozenset()`, so a hidden column was hidden on the READ and fully
655
  # writable on the EVENTS transport β€” a wall on one wire and not the other is the shape
656
  # `strip_row`'s own note warns about, with the sign flipped.
657
+ hidden = _ut_hidden(session, table_key, fields_base, st=lent)
658
  ctx = grid_events.EventCtx(
659
  uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=hidden,
660
  admin=session.admin, fallback_ws=None, seen_ids={}, st=session.runtime,
 
663
  workspace, fields, views, lists = aios_grid.workspace_wire(
664
  ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
665
  fields_base=fields_base)
666
+ # ⭐⭐ W38-T16 β€” THE SHARED COLUMNS JOIN THE **WRITE** CTX'S CONTRACT TOO, and for the reason
667
+ # W36-T21 gave one wall over: `grid_events` refuses a key by asking `ctx.hidden_keys`, so a
668
+ # column missing from this list is a column the events transport does not know it must
669
+ # refuse. The read door narrowing a name the write door has never heard of is the same
670
+ # one-wire wall with the sign flipped.
671
+ fields = _ut_shared_fields(session, table_key, fields)
672
+ fields, _rows, hidden = _ut_field_wall(session, table_key, fields, [], st=lent)
673
  return {"rows_src": [], "pids": pids, "ws": ws, "workspace": workspace,
674
  "fields": fields, "views": views, "lists": lists, "hidden": hidden,
675
  "derived": aios_grid.cohort_cells(lists),
 
688
  # `resolved_ids` (from `measure_sets`) only makes `view_upsert` answer "rerender",
689
  # so the next `/workspace` β€” which DOES compute the sets, in `ut_assembly` β€” resolves
690
  # it. Conservative in the right direction: one extra repaint, never a dropped filter.
691
+ # ⭐⭐ W38-T19 β€” THE CAPABILITY IS APPLIED HERE TOO, and this is the site that makes
692
+ # the refusal REAL rather than cosmetic: `routes_grid` reads this list as
693
+ # `measure_offer`, and `clean_measure_field` fail-closes `measure_not_offered` over
694
+ # an empty one. A picker hidden on the read door with this list still full would be
695
+ # a control that only stops the honest.
696
+ "measures": ut_measures(table_key, session),
697
  "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
698
  # ⭐ W31-T20 β€” the write door reads this to refuse a PID-BEARING event loudly rather
699
  # than letting `allowed_pids` swallow it as a no-op. See `routes_grid`'s ut_ branch.
700
  "limits": limits, "defn": defn}
701
 
702
 
703
+ def _ut_hidden(session, table_key, fields, st=None):
704
  """The hidden-field closure for THIS session on THIS database β€” C1's field half, once.
705
 
706
  ⚠ Named rather than inlined at its four call sites for the reason `may_open`'s own note gives:
707
  four spellings of one wall is how two of them come apart. `perm_scope.hidden_keys` is the ONE
708
  evaluator; this is just the `ut_*` caller's shorthand for it.
709
+
710
+ ⭐ W38-T16 β€” `st` IS THE LEND THE CALLER ALREADY HOLDS, and passing it is free rather than
711
+ merely tidy: the closure's field-grant leg resolves against `object_shares`, which is exactly
712
+ the bucket `ut_assembly`'s own lend note says the pass is already serving.
713
  """
714
  import core.perm_scope as perm_scope
715
+ return perm_scope.hidden_keys(session.user, table_key, fields, st=st)
716
+
717
+
718
+ def _ut_shared_fields(session, table_key, fields):
719
+ """`fields` PLUS this database's TENANT-WIDE columns β€” the read-side merge, W38-T16.
720
+
721
+ β›”β›” WHY THIS DID NOT EXIST AND WHY THAT WAS A HOLE. `patch_shared_cell` has written into
722
+ `<key>__shared` for any `ut_*` database since W30-T28, and only the READ-THROUGH door
723
+ (`routes_odoo_tables`) ever merged that stratum back. So a shared column written on a
724
+ MATERIALISED user table went into the store and was readable by nobody, through any door β€”
725
+ complete, correct, and invisible ([[reachable-is-not-the-same-as-built]]). This is the other
726
+ half of that door.
727
+
728
+ ⚠ MERGED BEFORE THE WALL, NEVER AFTER, and `routes_odoo_tables` learned this the same way:
729
+ the hidden closure must run on the WHOLE contract, or a formula in a shared column that reads
730
+ a hidden one sits outside its reach and carries the hidden value out wearing a second name.
731
+ ⚠ A key the definition already declares WINS. A shared column is an addition to a database's
732
+ contract, never a redefinition of a column that database already has.
733
+ """
734
+ from core import shared_overlay
735
+ defs = shared_overlay.fields(table_key, st=session.runtime) or {}
736
+ if not defs:
737
+ return fields
738
+ have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
739
+ return list(fields or ()) + [dict(f, source="overlay")
740
+ for k, f in defs.items() if k not in have]
741
+
742
 
743
+ def _ut_shared_cells(session, table_key, pids):
744
+ """`{"<pid>": {key: value}}` for the TENANT-WIDE stratum, over THIS session's row set.
745
 
746
+ β›” `pids` IS THE ROW WALL AND IT IS PASSED, NOT DEFAULTED β€” `shared_overlay.cells` refuses an
747
+ "everything" read by signature for exactly this reason, and the set handed in is the one
748
+ `scoped_pool`/`scoped_pids` already narrowed. A cell for a row this session may not see
749
+ therefore has nothing to attach to.
750
+ """
751
+ from core import shared_overlay
752
+ try:
753
+ return shared_overlay.cells(table_key, list(pids or ()), st=session.runtime)
754
+ except Exception: # noqa: BLE001
755
+ return {}
756
+
757
+
758
+ def _ut_field_wall(session, table_key, fields, rows_src, st=None):
759
  """`(fields, rows_src, hidden)` with the hidden closure removed from BOTH wires.
760
 
761
  ⭐⭐ W36-T21 β€” the same three lines `routes_customers.grid_assembly` runs for `customer_data`,
 
774
  path with nothing to say ([[one-question-two-normalizers]]).
775
  """
776
  import core.perm_scope as perm_scope
777
+ hide = perm_scope.hidden_keys(session.user, table_key, fields, st=st)
778
  if not hide:
779
  return fields, rows_src, frozenset()
780
  fields = [f for f in fields if f.get("key") not in hide]
 
832
  ops = _ops(session, table_key, st=st)
833
  # ⭐⭐ W36-T21 β€” the WRITE half of the field wall. `frozenset()` here meant a column an
834
  # administrator had hidden was still writable through the events transport.
835
+ hidden = _ut_hidden(session, table_key, fields_base, st=st)
836
  ctx = grid_events.EventCtx(
837
  uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=hidden,
838
  admin=session.admin, fallback_ws=None, seen_ids={},
 
845
  workspace, fields, views, lists = aios_grid.workspace_wire(
846
  ws, session.uname, set(pids), defs={}, scope_key=table_key,
847
  storage_key=storage_key, fields_base=fields_base)
848
+ # ⭐⭐ W38-T16 β€” THE TENANT-WIDE STRATUM, ON A MATERIALISED GRID. Merged HERE, between
849
+ # `workspace_wire` and the wall, which is `routes_odoo_tables`' own position for the same two
850
+ # lines and for the same reason: the closure must be recomputed on the MERGED contract or a
851
+ # formula in a shared column reaches past it.
852
+ fields = _ut_shared_fields(session, table_key, fields)
853
+ # ⚠ THE CELLS COST A READ, SO THE ENVELOPE ARM DOES NOT PAY IT. `with_rows=False` renders no
854
+ # row at all (D-174), and `cells()` over the whole pid set would be a scoped read whose
855
+ # result nothing consumes. The COLUMNS still merge above: `/workspace` needs the contract.
856
+ shared_cells = _ut_shared_cells(session, table_key, pids) if with_rows else {}
857
  # ⭐⭐ W36-T21 / R6 β€” the READ half, in `grid_assembly`'s own position: after `workspace_wire`,
858
  # so the closure covers this user's `custom_` and `measure_` columns too.
859
+ fields, rows_src, hidden = _ut_field_wall(session, table_key, fields, rows_src, st=st)
860
+ if hidden and shared_cells:
861
+ # β›” BOTH WIRES, AND THE SHARED STRATUM IS A THIRD ONE. `strip_row` above cleaned the
862
+ # DEFINITION rows; these cells arrived by a different door and one narrowing cannot speak
863
+ # for both β€” the same sentence `routes_odoo_tables` writes over its own `overlays` dict.
864
+ shared_cells = {pid: {k: v for k, v in cells.items() if k not in hidden}
865
+ for pid, cells in shared_cells.items()}
866
 
867
  # ⭐⭐ W37-T12 / owner item 4 (R1) β€” `measures` IS NO LONGER UNCONDITIONALLY EMPTY. A database
868
  # whose entity topic declares a `measures:` binding (today `ut_odoo_agents`) serves a real
869
  # lookback catalogue AND the cells to go with it; every other user table still serves `[]`,
870
  # which is the honest answer for a hand-typed one rather than a descope.
871
  today = time.strftime("%Y-%m-%d")
872
+ measures = ut_measures(table_key, session)
873
  derived = aios_grid.cohort_cells(lists) # R9: this table's own lists
874
  for _pid, _cells in _ut_measure_cells(table_key, fields, pids, today, measures,
875
  session.runtime.measure_memo).items():
 
878
  return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
879
  "fields": fields, "views": views, "lists": lists, "hidden": hidden,
880
  "derived": derived,
881
+ # ⭐ W38-T16 β€” ALWAYS PRESENT, `{}` when this database shares nothing. A key a
882
+ # consumer has to test for is a key a consumer forgets to test for, and the consumer
883
+ # here (`table_rows`) is layering strata in a fixed order.
884
+ "shared_cells": shared_cells,
885
  "measures": measures,
886
  "measure_sets": _ut_measure_sets(table_key, views, measures, pids, today),
887
  "today": today,
 
898
  pass
899
 
900
 
901
+ def ut_measures(table_key, session):
902
+ """The lookback-measure OFFER for a user database FOR THIS SESSION, or `[]` β€” W37-T12 /
903
+ owner item 4 (R1), narrowed by the metrics capability in W38-T19.
904
+
905
+ ⭐⭐ W38-T19 β€” `session` IS REQUIRED, NOT DEFAULTED. The function was PRINCIPAL-BLIND, so a
906
+ per-user capability had nothing to be consulted by. Defaulting it would leave both existing
907
+ call sites compiling and silently ungated, which is the fail-open spelling of the bug rather
908
+ than a fix for it β€” the argument `_module_fields` in `routes_admin` records for its own
909
+ `session`. β›” AND BOTH CALL SITES MATTER, WHICH IS EASY TO GET HALF RIGHT: `ut_assembly` is
910
+ the READ and `ut_write_ctx` is the door `routes_grid` turns into `clean_measure_field`'s
911
+ admission set. Gating only the read would take the kind off the picker and leave the CREATE
912
+ working for anyone who posts the event.
913
 
914
  ⭐ THE SAME ENGINE THE PRODUCT GRID USES (`semantic.entity_measures`), reached through the
915
  topic that DESCRIBES this database. A table with no entity topic, or a topic with no
 
921
  so a list here would be a second definition of one fact β€” the argument `_rollup_source_offer`
922
  makes about itself one screen down.
923
  """
924
+ import core.perm_scope as perm_scope
925
+ if not perm_scope.may_metrics(session.user, table_key):
926
+ return []
927
  try:
928
  from harness import semantic as sem
929
  topic = sem.topic_for_grid(table_key)
 
1470
  if isinstance(_ov, dict):
1471
  _cells.update(_ov)
1472
  merged[_pid] = _cells
1473
+ # ⭐⭐ W38-T16 β€” THE TENANT-WIDE STRATUM GOES ON TOP, and the order is the contract rather
1474
+ # than a preference. A shared column is one the whole permitted audience must read the SAME
1475
+ # value in β€” that is the entire reason `shared_overlay` exists β€” so a stale per-user value
1476
+ # left under the same key must not win. `routes_odoo_tables` layers the two in this exact
1477
+ # order (`overlays.setdefault(pid, {}).update(cells)`) and the two doors must agree.
1478
+ # β›” NEVER `setdefault` ON `merged`: these cells are already narrowed to this session's pids,
1479
+ # but inventing a row id here would put a row in the payload that `rows_src` never admitted.
1480
+ for _pid, _cells in (g.get("shared_cells") or {}).items():
1481
+ if str(_pid) in merged and isinstance(_cells, dict):
1482
+ merged[str(_pid)].update(_cells)
1483
  # ⭐⭐ 2026-08-10 (owner: *"wth is going on, why does it take forever to load now?"*) β€” THE
1484
  # JSON DOCUMENTS DO NOT RIDE THE LIST.
1485
  #
platform/core/perm_scope.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/core/shares.py CHANGED
@@ -41,7 +41,14 @@ 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
  #: Spelled as a single character so it can never collide with a username (usernames are lower-case
@@ -81,6 +88,41 @@ EVERYONE = '*'
81
 
82
  ROLES = ('view', 'edit')
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
  def _st(st):
86
  return st if st is not None else store
@@ -89,8 +131,8 @@ def _st(st):
89
  def _check_kind(kind):
90
  k = str(kind or '').strip().lower()
91
  if k not in KINDS:
92
- raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)} β€” '
93
- f'refusing to invent a namespace from a typo.')
94
  return k
95
 
96
 
 
41
 
42
  #: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
43
  #: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
44
+ #:
45
+ #: ⭐⭐ W38-T16 β€” `field` IS THE FOURTH, AND NOTHING IN THIS FILE BRANCHES ON IT. Every function
46
+ #: below treats `kind` as an opaque bucket key (`_check_kind / grants / set_grants / role_for /
47
+ #: may_see / may_edit / may_administer / shared_with / drop_objects`), so the kind's whole cost
48
+ #: here is this tuple member. That is the point of one registry: the new object's WALL is written
49
+ #: once in `core.perm_scope`, and its DOORS once in `routes_shares.py` β€” never a fourth
50
+ #: permission decision in a fourth file ([[one-evaluator-per-question]]).
51
+ KINDS = ('view', 'folder', 'database', 'field')
52
 
53
  #: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
54
  #: Spelled as a single character so it can never collide with a username (usernames are lower-case
 
88
 
89
  ROLES = ('view', 'edit')
90
 
91
+ #: β›”β›” A FIELD's OBJECT ID IS TOPIC-QUALIFIED, AND THE SEPARATOR IS DECLARED HERE SO THERE IS ONE
92
+ #: SPELLING OF IT. A bare field key is NOT unique: `notes` exists on a dozen databases, and a
93
+ #: grant stored under it would admit a grantee to every `notes` column in the tenant at once β€”
94
+ #: the widening direction, silently, forever. `table_key` is the qualifier because it is the same
95
+ #: identifier `shared_overlay.bucket()` already keys the values by, so the grant and the data it
96
+ #: governs are named by the same string ([[one-question-two-normalizers]]).
97
+ #:
98
+ #: ⚠ A `ut_*` key and a registry topic key both match `[a-z0-9_]+` and neither can contain `:`,
99
+ #: so the split below is unambiguous in both directions.
100
+ FIELD_OID_SEP = ':'
101
+
102
+
103
+ def field_oid(table_key, field_key):
104
+ """`"<table_key>:<field_key>"` β€” the share id of ONE column on ONE database."""
105
+ table = str(table_key or '').strip()
106
+ field = str(field_key or '').strip()
107
+ if not table or not field:
108
+ raise ValueError('shares.field_oid: a field share names BOTH a database and a column. '
109
+ 'A bare field key repeats across tables and would grant all of them')
110
+ return f'{table}{FIELD_OID_SEP}{field}'
111
+
112
+
113
+ def split_field_oid(oid):
114
+ """`(table_key, field_key)` or `(None, None)` for anything that is not a field oid.
115
+
116
+ β›” FAIL-CLOSED ON JUNK, like every other read here: a caller that cannot learn WHICH database
117
+ an id names must not fall back to "the one I happen to be looking at", which is how a grant
118
+ on somebody else's column would be read as a grant on this one.
119
+ """
120
+ raw = str(oid or '')
121
+ table, sep, field = raw.partition(FIELD_OID_SEP)
122
+ if not sep or not table.strip() or not field.strip() or FIELD_OID_SEP in field:
123
+ return (None, None)
124
+ return (table.strip(), field.strip())
125
+
126
 
127
  def _st(st):
128
  return st if st is not None else store
 
131
  def _check_kind(kind):
132
  k = str(kind or '').strip().lower()
133
  if k not in KINDS:
134
+ raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)}. '
135
+ f'This door refuses to invent a namespace from a typo.')
136
  return k
137
 
138
 
platform/core/store_pg.py CHANGED
@@ -40,6 +40,8 @@ import json
40
  import os
41
  import threading
42
 
 
 
43
  _URL_ENV = 'DATABASE_URL'
44
  _LOCK = threading.RLock()
45
  _POOL = {'pool': None, 'url': None, 'schema': None}
@@ -49,6 +51,19 @@ def _url():
49
  return os.environ.get(_URL_ENV) or None
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  def _schema_for(tenant_slug=None):
53
  """`t_<slug>` with `-` normalised to `_` (a hyphen is not legal in an unquoted identifier).
54
 
@@ -57,8 +72,133 @@ def _schema_for(tenant_slug=None):
57
  to tenant #0 so the default behaviour is unchanged, the same rule `harness.datastore.path_for`
58
  follows for the DuckDB filename.
59
  """
60
- slug = (tenant_slug or os.environ.get('AIOS_TENANT') or 'royal-imports').strip().lower()
61
- return 't_' + slug.replace('-', '_')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
 
64
  def _pool():
@@ -181,6 +321,12 @@ def exists(name, tenant_slug=None):
181
 
182
 
183
  def put(name, data, tenant_slug=None):
 
 
 
 
 
 
184
  with _pool().connection() as con:
185
  con.execute(
186
  _q('INSERT INTO {tbl} (key, value) VALUES (%s, %s::jsonb) '
@@ -202,6 +348,7 @@ def update(name, fn, flush='sync', tenant_slug=None):
202
  `flush` is accepted and ignored (see the module docstring): there is no commit budget to
203
  coalesce against, so 'async' has nothing to defer.
204
  """
 
205
  with _pool().connection() as con:
206
  with con.transaction():
207
  row = con.execute(
@@ -220,6 +367,7 @@ def update(name, fn, flush='sync', tenant_slug=None):
220
 
221
 
222
  def upload_bytes(path_in_repo, data, message=None, tenant_slug=None):
 
223
  with _pool().connection() as con:
224
  con.execute(
225
  _q('INSERT INTO {tbl} (path, bytes, message) VALUES (%s, %s, %s) '
@@ -239,7 +387,14 @@ def download_bytes(path_in_repo, tenant_slug=None):
239
 
240
 
241
  def delete_path(path_in_repo, tenant_slug=None):
242
- """Absent is SUCCESS β€” deleting what is already gone is the goal (HF backend's contract)."""
 
 
 
 
 
 
 
243
  with _pool().connection() as con:
244
  con.execute(_q('DELETE FROM {tbl} WHERE path = %s', 'store_blobs', tenant_slug),
245
  (str(path_in_repo),))
@@ -317,6 +472,14 @@ class PgStore:
317
  behaviour argument (FOR UPDATE in `update`, absent-is-success in `delete_path`, `fresh`
318
  ignored because a SELECT has no cache to bypass) is stated once, up there, and cannot drift
319
  between the two entry points.
 
 
 
 
 
 
 
 
320
  """
321
 
322
  def __init__(self, tenant_slug):
 
40
  import os
41
  import threading
42
 
43
+ import core.data_binding as data_binding
44
+
45
  _URL_ENV = 'DATABASE_URL'
46
  _LOCK = threading.RLock()
47
  _POOL = {'pool': None, 'url': None, 'schema': None}
 
51
  return os.environ.get(_URL_ENV) or None
52
 
53
 
54
+ def _effective_slug(tenant_slug=None):
55
+ """The tenant this call ACTUALLY addresses, after every default has been applied.
56
+
57
+ β›”β›” IT IS A SEPARATE FUNCTION BECAUSE THE DEFAULT IS THE HAZARD, AND THE GUARD HAS TO SEE THE
58
+ SAME ONE THE SQL DOES. `_schema_for` used to inline this chain, so the fallback to tenant #0
59
+ happened *inside* the address builder where nothing else could observe it: a process that was
60
+ handed no slug at all silently addressed `t_royal_imports` β€” Royal Imports' real business
61
+ data β€” and the only code that knew had already turned it into an identifier. Resolving once,
62
+ here, is what lets `write_refusal` below refuse the DEFAULT rather than only an explicit slug.
63
+ """
64
+ return (tenant_slug or os.environ.get('AIOS_TENANT') or 'royal-imports').strip().lower()
65
+
66
+
67
  def _schema_for(tenant_slug=None):
68
  """`t_<slug>` with `-` normalised to `_` (a hyphen is not legal in an unquoted identifier).
69
 
 
72
  to tenant #0 so the default behaviour is unchanged, the same rule `harness.datastore.path_for`
73
  follows for the DuckDB filename.
74
  """
75
+ return 't_' + _effective_slug(tenant_slug).replace('-', '_')
76
+
77
+
78
+ # =============================================================================================
79
+ # β›”β›” THE WRITE GUARD β€” D-353. WHY IT KEYS ON A TENANT SLUG AND NOT ON A REPO ID.
80
+ #
81
+ # `core/data_binding.py` is *"the one enforcement door"* and `core/store.py` calls it from six
82
+ # places. This file called it from ZERO, and the settings pane said so out loud: staging reported
83
+ # `writable: false, refusal: "fsanyoto/loopable was not given pg:t_royal_imports"` **while product
84
+ # writes on that same build succeeded** (wave-36 QA, F12). The banner was comparing a pg address
85
+ # against HF-repo-shaped literals β€” a comparison that can never match β€” and nothing on the write
86
+ # path was consulting anything at all.
87
+ #
88
+ # β›” SO THE OBVIOUS PORT IS THE WRONG ONE, AND IT IS WRONG IN THE DANGEROUS DIRECTION. Matching
89
+ # `PgStore.repo` (`pg:t_royal_imports`) against `data_binding.PRODUCTION_STORES` (HF dataset ids)
90
+ # never hits rule 2, never hits rule 3's allowlist either, and lands on rule 4 β€” so it refuses
91
+ # EVERY write from every non-production deployment, including the legitimate ones, while the one
92
+ # case it was written for (a staging build on tenant #0's rows) is refused for the wrong reason
93
+ # and would be "fixed" by adding the pg address to an allowlist. A guard that fires on everything
94
+ # gets its allowlist widened until it fires on nothing.
95
+ #
96
+ # β›”β›” AND THE REPO ID CANNOT DISCRIMINATE HERE EVEN IN PRINCIPLE. Under `hf`, isolation rides on
97
+ # the address: two deployments write two different dataset repos, so comparing repo strings is
98
+ # comparing destinations. Under `pg` there is no per-deployment address to compare β€” `deploy_web`
99
+ # pushes ONE `DATABASE_URL`, `store.handle()` routes by tenant SLUG, staging's `tenants.json` is
100
+ # byte-identical to production's, and `_schema_for` defaults the slug to `royal-imports`. Every
101
+ # deployment therefore resolves the SAME schema for the same tenant. The only two facts that
102
+ # differ between a legitimate write and a catastrophic one are WHICH TENANT is being written and
103
+ # WHICH DEPLOYMENT is writing it β€” so those are the two inputs, and there are no others.
104
+ #
105
+ # ⚠ READS ARE NOT REFUSED, exactly as on the HF side and for the reason stated there: a refused
106
+ # read hands back an empty grid that reads as "no data" rather than as "refused". `get`,
107
+ # `get_projection`, `exists`, `download_bytes` and `revision` are deliberately unguarded.
108
+ #
109
+ # β›” WHAT THIS DOES NOT COVER, said here rather than discovered later: raw SQL through `_pool()`.
110
+ # `ops/seed_pg_from_hf.py` opens a connection directly to apply the DDL, and any future tool can.
111
+ # The guard is on the STORE INTERFACE β€” the four functions every product write crosses β€” not on
112
+ # the database handle. A caller that reaches past the interface reaches past the guard.
113
+ # =============================================================================================
114
+
115
+ #: The tenant slugs whose `t_<slug>` schema holds REAL customer data.
116
+ #:
117
+ #: β›” THIS IS RULE 2, NOT THE WHOLE GUARD β€” the same standing as `data_binding.PRODUCTION_STORES`,
118
+ #: which it mirrors one-for-one (`verify_store_binding.py::section_constants` pins the mapping, so
119
+ #: a fifth tenant added to one list and not the other goes red). It exists to make a DELIBERATE
120
+ #: mis-grant loud: `AIOS_SANDBOX_TENANTS=royal-imports` typed by hand must not open tenant #0.
121
+ #: Rule 4 below is what covers a tenant provisioned after this line was written.
122
+ PRODUCTION_TENANTS = frozenset({
123
+ 'royal-imports', # tenant #0 β€” Royal Imports (royal-imports/cfo-os-data)
124
+ 'nurilab', # Nurilab (royal-imports/aios-nurilab-data)
125
+ 'gtmlab', # GTM Lab (royal-imports/aios-gtmlab-data)
126
+ 'loopable', # Loopable (royal-imports/aios-loopable-data)
127
+ })
128
+
129
+ #: The env a NON-production deployment uses to name the tenant schemas it may write.
130
+ #: Comma-separated slugs. The pg twin of `AIOS_SANDBOX_STORES`, and the polarity is the same one
131
+ #: that keeps the HF guard honest: forgetting it makes a deployment write LESS, never more.
132
+ #:
133
+ #: β›” `AIOS_TENANT` IS DELIBERATELY NOT A GRANT. It is a DEFAULT β€” unset, it resolves to tenant #0 β€”
134
+ #: so treating it as a hand-off would mean "a process that named no tenant is authorised for the
135
+ #: one it did not name". That is `--data-repo`'s exact failure, which is the failure this file's
136
+ #: existence is a response to.
137
+ _SANDBOX_TENANTS_VAR = 'AIOS_SANDBOX_TENANTS'
138
+
139
+
140
+ def sandbox_tenants():
141
+ """The tenant slugs a non-production deployment was explicitly handed."""
142
+ raw = str(os.environ.get(_SANDBOX_TENANTS_VAR) or '')
143
+ return frozenset(s.strip().lower() for s in raw.split(',') if s.strip())
144
+
145
+
146
+ def write_refusal(tenant_slug=None):
147
+ """`None` if this deployment may write this tenant's schema, else the sentence explaining why.
148
+
149
+ The pg twin of `data_binding.write_refusal`, and the four rules are the same four β€” only the
150
+ thing being classified changes, from a dataset repo to a tenant slug:
151
+
152
+ 1. the PRODUCTION deployment -> may write any tenant
153
+ 2. any other deployment writing a PRODUCTION tenant -> REFUSED (a laptop may opt in)
154
+ 3. any other deployment writing a tenant it was HANDED -> allowed
155
+ 4. anything else -> REFUSED
156
+
157
+ ⭐ RULE 2 BEATS RULE 3, deliberately, which is why the order above is the order below: an
158
+ allowlist must not be able to re-open tenant #0. ⚠ And the Space/laptop asymmetry is carried
159
+ over unchanged β€” a Space that is not production gets NO override, because that is the case the
160
+ owner said "EVER" about.
161
+ """
162
+ slug = _effective_slug(tenant_slug)
163
+ if data_binding.is_production_deployment():
164
+ return None # rule 1
165
+ if slug in PRODUCTION_TENANTS: # rule 2
166
+ if data_binding.local_override():
167
+ return None
168
+ who = data_binding.deployment_id() or 'this machine (no Space identity)'
169
+ where = f'{slug!r} ({_schema_for(slug)})'
170
+ if data_binding.is_space():
171
+ return (f'{who} is not the production deployment, so it may not write tenant {where}, '
172
+ f'which holds real customer data. Point this deployment at its own database, '
173
+ f'or name the sandbox tenants it may write in {_SANDBOX_TENANTS_VAR}. There is '
174
+ f'no override for a Space: a build that is not live never writes live data.')
175
+ return (f'{who} may not write tenant {where}, which holds real customer data. Set '
176
+ f'AIOS_TENANT to a sandbox tenant, or set '
177
+ f'{data_binding._LOCAL_OPT_IN_VAR}=1 to opt in deliberately.')
178
+ allow = sandbox_tenants() # rule 3
179
+ if slug in allow:
180
+ return None
181
+ who = data_binding.deployment_id() or 'this machine (no Space identity)'
182
+ listed = ', '.join(sorted(allow)) or '(nothing)'
183
+ return (f'{who} was not given tenant {slug!r}. A non-production deployment may write only the '
184
+ f'tenants it was handed: {listed}. Add it to {_SANDBOX_TENANTS_VAR} if that is '
185
+ f'intended.')
186
+
187
+
188
+ def check_write(tenant_slug=None, operation='write'):
189
+ """Raise `StoreWriteRefused` unless this deployment may write this tenant. The pg door.
190
+
191
+ ⭐ IT RAISES `data_binding.StoreWriteRefused` β€” the SAME type the HF backend raises, not a pg
192
+ twin β€” and that is load-bearing rather than tidy. `api/main.py:185` carries one exception
193
+ handler for that type which answers 503 with the reason, and the type is deliberately NOT a
194
+ subclass of `StoreUnavailable` so it falls through every route that would otherwise DEGRADE to
195
+ a session-scoped fallback and tell the user their change was saved. A new type would have had
196
+ to re-earn both properties, and would have failed the second one silently.
197
+ """
198
+ reason = write_refusal(tenant_slug)
199
+ if reason:
200
+ raise data_binding.StoreWriteRefused(
201
+ f'{operation} to tenant {_effective_slug(tenant_slug)!r} refused: {reason}')
202
 
203
 
204
  def _pool():
 
321
 
322
 
323
  def put(name, data, tenant_slug=None):
324
+ # β›” BEFORE `_pool()`, NOT AFTER. A refusal that fires once a connection is open has already
325
+ # told the database this deployment is here, and on a pooled driver it has already borrowed a
326
+ # slot. More to the point, ordering is the only thing an offline gate can observe: with the
327
+ # pool stubbed to raise, "refused" and "reached the database" are two distinguishable answers,
328
+ # which is what makes the negative control in `verify_store_pg.py` bite without a server.
329
+ check_write(tenant_slug, f'put {name!r}')
330
  with _pool().connection() as con:
331
  con.execute(
332
  _q('INSERT INTO {tbl} (key, value) VALUES (%s, %s::jsonb) '
 
348
  `flush` is accepted and ignored (see the module docstring): there is no commit budget to
349
  coalesce against, so 'async' has nothing to defer.
350
  """
351
+ check_write(tenant_slug, f'update {name!r}') # D-353 β€” see `put`, and before `_pool()`
352
  with _pool().connection() as con:
353
  with con.transaction():
354
  row = con.execute(
 
367
 
368
 
369
  def upload_bytes(path_in_repo, data, message=None, tenant_slug=None):
370
+ check_write(tenant_slug, f'write bytes {path_in_repo!r}') # D-353 β€” before `_pool()`
371
  with _pool().connection() as con:
372
  con.execute(
373
  _q('INSERT INTO {tbl} (path, bytes, message) VALUES (%s, %s, %s) '
 
387
 
388
 
389
  def delete_path(path_in_repo, tenant_slug=None):
390
+ """Absent is SUCCESS β€” deleting what is already gone is the goal (HF backend's contract).
391
+
392
+ ⚠ ABSENT-IS-SUCCESS DOES NOT EXTEND TO REFUSED. A delete this deployment may not make RAISES;
393
+ it does not answer True on the grounds that nothing was removed. The lenient return exists so a
394
+ caller need not care whether a blob was already gone β€” not so a caller can be told a delete it
395
+ was forbidden went fine.
396
+ """
397
+ check_write(tenant_slug, f'delete {path_in_repo!r}') # D-353 β€” before `_pool()`
398
  with _pool().connection() as con:
399
  con.execute(_q('DELETE FROM {tbl} WHERE path = %s', 'store_blobs', tenant_slug),
400
  (str(path_in_repo),))
 
472
  behaviour argument (FOR UPDATE in `update`, absent-is-success in `delete_path`, `fresh`
473
  ignored because a SELECT has no cache to bypass) is stated once, up there, and cannot drift
474
  between the two entry points.
475
+
476
+ ⭐⭐ AND THAT IS WHY THE D-353 GUARD IS NOT REPEATED HERE. `put`, `update`, `upload_bytes` and
477
+ `delete_path` below pass `tenant_slug=self.tenant_slug` down to the module functions, which
478
+ call `check_write` before touching the pool β€” so this class is guarded by delegation rather
479
+ than by four more copies that could drift out of step with the four above. It matters because
480
+ THIS is the object the app actually holds: `store.handle()` returns it under `STORE_BACKEND=pg`
481
+ and `harness.runtime` binds one per tenant, so every product write on the pg path arrives here
482
+ first and leaves through a guarded module function.
483
  """
484
 
485
  def __init__(self, tenant_slug):
platform/core/table_store.py CHANGED
@@ -730,14 +730,65 @@ class TableStore:
730
 
731
  self._update(username, _drop, shared=lambda shared_fields: shared_fields.pop(key, None))
732
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  def patch_overlay(self, username, pid, updates):
734
- """Patch only the external editable stratum; never writes to the source system."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  clean = dict(updates or {})
736
  if not clean:
737
  return
 
 
 
 
 
 
 
 
 
 
 
738
 
739
  def _patch(ws):
740
- ws['overlays'].setdefault(str(int(pid)), {}).update(clean)
741
 
742
  self._update(username, _patch)
743
 
 
730
 
731
  self._update(username, _drop, shared=lambda shared_fields: shared_fields.pop(key, None))
732
 
733
+ def _tenant_wide_keys(self):
734
+ """The columns of THIS table whose values live in the tenant-wide stratum.
735
+
736
+ ⭐ THE CHEAP HALF. A table that shares nothing reads one small per-key file (cache-first
737
+ per process) and answers the empty set, so `patch_overlay` behaves exactly as it did
738
+ before W38-T20 for every database that has no shared column. It is deliberately not
739
+ memoised on the instance: `modules/customer_data.py` and `modules/product_data.py` both
740
+ hold a MODULE-LEVEL `TABLE_OPS`, so a per-instance cache would serve one request's answer
741
+ to the next, and this one decides WHERE a value is written.
742
+ ⚠ LENIENT LIKE EVERY OTHER STRATUM READ, and the failure direction is the safe one: an
743
+ unreachable shared bucket routes the write to the PER-USER stratum, which is the
744
+ pre-ticket behaviour, rather than dropping it.
745
+ """
746
+ try:
747
+ import core.shared_overlay as shared_overlay
748
+ return set(shared_overlay.fields(self.table_key, st=self._st) or ())
749
+ except Exception: # noqa: BLE001
750
+ return set()
751
+
752
  def patch_overlay(self, username, pid, updates):
753
+ """Patch only the external editable stratum; never writes to the source system.
754
+
755
+ ⭐⭐ W38-T20 / D-423 β€” A CELL IN A TENANT-WIDE COLUMN GOES TO THE TENANT-WIDE STRATUM,
756
+ AND WITHOUT THIS SPLIT THE EDIT SILENTLY DISAPPEARS. The read path layers the shared
757
+ stratum OVER the per-user one (it has to: that is what makes every reader see the same
758
+ number). This method wrote PER USER. So the sequence was: type a new value, see it accept,
759
+ come back, and read the shared value again β€” the owner's *"I went back and it all got
760
+ reseted"*, with a successful 200 at every step and nothing in any log.
761
+
762
+ β›” IT IS DECIDED BY WHERE THE COLUMN LIVES, NOT BY WHO IS WRITING OR THROUGH WHICH ROUTE.
763
+ `modules/product_data._ProductTableStore` has done exactly this since W30-T36 against its
764
+ CANONICAL list; the only reason it needed a subclass is that its shared columns are
765
+ declared in a contract file. Columns created at runtime cannot be, so the general form
766
+ asks the stratum itself. Both doors (`PATCH /customers/{pid}` and `POST /grid/events`)
767
+ arrive here through `grid_events._tops(ctx)`, which is why the split belongs at the STORE
768
+ and not at either route: intercepting at one leaves the other writing into the shadow.
769
+
770
+ β›” NO PERMISSION IS ANSWERED HERE. `shared_overlay`'s header is explicit that it is not a
771
+ wall, and neither is this: whether this session may write this key is settled upstream by
772
+ `EventCtx.hidden_keys`, which `routes_customers._hidden_for` now computes over the MERGED
773
+ contract precisely so a grant-governed column is refused before it reaches this line.
774
+ """
775
  clean = dict(updates or {})
776
  if not clean:
777
  return
778
+ wide = self._tenant_wide_keys()
779
+ shared = {k: v for k, v in clean.items() if k in wide}
780
+ personal = {k: v for k, v in clean.items() if k not in wide}
781
+ if shared:
782
+ import core.shared_overlay as shared_overlay
783
+ # ⚠ `st=self._st`, NEVER the module default. The two strata must resolve to the SAME
784
+ # tenant handle, or a value written by one is invisible to the other and the user's
785
+ # edit vanishes the moment they save it (`_ProductTableStore` records the same rule).
786
+ shared_overlay.put_cells(self.table_key, pid, shared, st=self._st)
787
+ if not personal:
788
+ return
789
 
790
  def _patch(ws):
791
+ ws['overlays'].setdefault(str(int(pid)), {}).update(personal)
792
 
793
  self._update(username, _patch)
794
 
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -188,6 +188,28 @@ import TimeSeriesPanel from "./TimeSeriesPanel";
188
  import type { WindowSpec } from "./windows";
189
  import { windowLabel } from "./windows";
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  const ROW_PX: Record<RowHeightMode, number> = { short: 28, medium: 34, tall: 48 };
192
 
193
  /**
@@ -472,6 +494,39 @@ function queryPreviewView(binding: QueryVirtualBinding, fields: Field[]): SavedV
472
  };
473
  }
474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  function readLocal(storageKey: string): LocalWorkspace | null {
476
  try {
477
  const raw = localStorage.getItem(`aios-grid:${storageKey}`);
@@ -2696,6 +2751,28 @@ function CustomerGridSurface({
2696
  * is this repo's most repeated defect (five whole unreachable features in one wave).
2697
  */
2698
  const [chatOpen, setChatOpen] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2699
  /** The config to put back when the chat is asked to undo its own view change (E-4 `onUndoView`).
2700
  * A snapshot rather than a diff: the chat may change several axes at once, and a per-axis undo
2701
  * would restore some of them. */
@@ -2751,15 +2828,22 @@ function CustomerGridSurface({
2751
  const onChatApplyView = useCallback(
2752
  (spec: GridChatViewSpec) => {
2753
  chatUndoRef.current = config;
2754
- const next: ViewConfig = { ...config };
2755
  if (spec.sortBy && fieldByKey.has(spec.sortBy))
2756
  next.sorts = [{ colId: spec.sortBy, dir: "asc" }];
2757
  if (spec.groupBy !== undefined)
2758
  next.groupBy = spec.groupBy && fieldByKey.has(spec.groupBy) ? spec.groupBy : null;
2759
- if (spec.colorBy !== undefined)
2760
- next.display = spec.colorBy && fieldByKey.has(spec.colorBy)
2761
- ? { ...(next.display ?? { mode: "grid" }), colorField: spec.colorBy }
2762
- : next.display;
 
 
 
 
 
 
 
2763
  updateConfig(next);
2764
  },
2765
  [config, fieldByKey, updateConfig]
@@ -6203,8 +6287,10 @@ function CustomerGridSurface({
6203
  dateChoices={dateFieldChoices}
6204
  stackField={kanbanField}
6205
  stackChoices={stackFieldChoices}
6206
- colorField={mapColorField}
6207
- colorChoices={colorFieldChoices}
 
 
6208
  sizeField={mapSizeField}
6209
  sizeChoices={sizeFieldChoices}
6210
  onPickField={setDisplayField}
@@ -6639,15 +6725,17 @@ function CustomerGridSurface({
6639
  onFieldOrder={(keys) => updateConfig({ ...config, order: keys })}
6640
  deletableKeys={deletableKeys}
6641
  onDeleteField={deleteField}
6642
- colorOn={config.colorBy !== null}
6643
- onToggleColor={(on) =>
6644
- updateConfig({
6645
- ...config,
6646
- colorBy: on
6647
- ? fields.find((field) => field.type === "status")?.key ?? null
6648
- : null,
6649
- })
6650
- }
 
 
6651
  rowHeightMode={config.rowHeightMode}
6652
  onRowHeightMode={(rowHeightMode) => updateConfig({ ...config, rowHeightMode })}
6653
  filters={config.filters}
 
188
  import type { WindowSpec } from "./windows";
189
  import { windowLabel } from "./windows";
190
 
191
+ /**
192
+ * ⭐⭐ WAVE 38 Β· T06 (owner instruction 1) β€” "OPEN THE CHAT ABOUT THIS DATA", raised by the SHELL.
193
+ *
194
+ * Owner: *"When the Navigation is minimized: the icon that opens the AI chatbot overlay with the
195
+ * grid…"* The launcher lives in the frame's minimised rail; `chatOpen` and the `<GridChat>` mount
196
+ * live here. So the shell asks and this surface answers, the shape every cross-fence click-through
197
+ * in this app already has (`VIEW_OPEN_EVENT`, `AUTOMATION_OPEN_EVENT`, `QUERY_OPEN_EVENT`).
198
+ *
199
+ * β›” THE CANONICAL DECLARATION IS THIS ONE, and its twin in `shell/Shell.tsx` says why the pair
200
+ * exists rather than one shared constant in `apiContract.ts`: that file was outside the ticket's
201
+ * `files:` list, `customer-grid/**` may not import `shell/**`, and a static import out of this
202
+ * module would pull the spreadsheet engine into the shell's entry chunk past the `lazy()` that
203
+ * keeps it out. `verify_wiring.py`'s W38-T06 row matches the LITERAL on both sides so the two
204
+ * spellings cannot drift apart in silence.
205
+ *
206
+ * β›” NO `retryEmit` LADDER, deliberately. `VIEW_OPEN_EVENT` rides one because it races a fresh hash
207
+ * navigation against a grid that is still mounting. This launcher only exists in a rail that is
208
+ * already beside a mounted grid, so a bare `signal()` is the honest emit β€” the same call the fold
209
+ * signal two fences over makes.
210
+ */
211
+ export const GRID_CHAT_OPEN_EVENT = "aios:grid-chat-open";
212
+
213
  const ROW_PX: Record<RowHeightMode, number> = { short: 28, medium: 34, tall: 48 };
214
 
215
  /**
 
494
  };
495
  }
496
 
497
+ /**
498
+ * ⭐⭐ W38-T07 (owner instruction 12) β€” THE ONE WRITER OF A COLOUR CHOICE, AND IT WRITES BOTH
499
+ * TARGETS.
500
+ *
501
+ * The colour a person picks is READ from two places by two different renderers:
502
+ * Β· `config.colorBy` -> the grid's row wash (`getRowThemeOverride` -> STATUS_ROW_THEME)
503
+ * Β· `config.display.colorField` -> the map's pin colours (MapView -> SERIES)
504
+ * They were written by two different controls, which is exactly the duplication the owner
505
+ * reported. With ONE control, every path that sets a colour has to set both, or the single door
506
+ * has a back door writing half the state: the pins would recolour while the toolbar still read
507
+ * "No color".
508
+ *
509
+ * β›” A PURE FUNCTION ON PURPOSE, not a hook: `onChatApplyView` needs it too (E's chat could
510
+ * already say "colour by status" and it wrote `colorField` ALONE), and a second half-writer is
511
+ * how this defect grows back. Both callers go through here.
512
+ *
513
+ * ⚠ `?? { mode: "grid" }` is exact rather than a guess, and it is the shape `onChatApplyView`
514
+ * already used: `cleanDisplay` returns undefined only when the view stores no display spec at
515
+ * all, and a view with no spec IS in grid mode. Grid WITH refs is a legal stored shape (the W13
516
+ * carry), so a colour picked on the grid survives a trip through Map and back.
517
+ *
518
+ * ⚠ The result is piped back through `cleanDisplay` so that clearing the colour on a view which
519
+ * had no other display ref normalises to `undefined` rather than storing a bare `{mode:"grid"}` β€”
520
+ * the churn `viewEcho`'s whole-object compare would otherwise see forever.
521
+ */
522
+ function withColorField(config: ViewConfig, key: string): ViewConfig {
523
+ const spec = cleanDisplay(config.display) ?? { mode: "grid" as const };
524
+ const display: DisplaySpec = { ...spec };
525
+ if (key) display.colorField = key;
526
+ else delete display.colorField;
527
+ return { ...config, colorBy: key || null, display: cleanDisplay(display) };
528
+ }
529
+
530
  function readLocal(storageKey: string): LocalWorkspace | null {
531
  try {
532
  const raw = localStorage.getItem(`aios-grid:${storageKey}`);
 
2751
  * is this repo's most repeated defect (five whole unreachable features in one wave).
2752
  */
2753
  const [chatOpen, setChatOpen] = useState(false);
2754
+ /**
2755
+ * ⭐⭐ WAVE 38 Β· T06 (owner instruction 1) β€” THE SHELL'S MINIMISED-RAIL LAUNCHER LANDS HERE.
2756
+ *
2757
+ * β›” IT OPENS, IT DOES NOT TOGGLE, and that is a consequence of the fence rather than a taste
2758
+ * call: the shell cannot see `chatOpen`, so a toggle raised from over there would CLOSE a panel
2759
+ * the user was already reading whenever the two states disagreed. "Open" is idempotent and is
2760
+ * the only instruction a caller with no view of the state can honestly give. The views rail's
2761
+ * `cg-chat-toggle` keeps the toggle, because it is inside this fence and can see it.
2762
+ *
2763
+ * β›” THE GUARD IS THE MOUNT'S GUARD, RESTATED, NOT A NEW ONE. `<GridChat>` renders under
2764
+ * `!embedded && !queryBinding` (a linked-record grid in a modal and a Query preview each have no
2765
+ * database of their own for a chat to be about). Flipping `chatOpen` where the panel cannot mount
2766
+ * would set a flag nothing reads, so the LISTENER is what is absent there β€” which is also the
2767
+ * second of the two reasons a click with no grid on screen cannot throw. The first is that
2768
+ * `signal()` on a window with no listener at all is a plain no-op.
2769
+ */
2770
+ useEffect(() => {
2771
+ if (embedded || queryBinding) return;
2772
+ const onOpenChat = () => setChatOpen(true);
2773
+ window.addEventListener(GRID_CHAT_OPEN_EVENT, onOpenChat);
2774
+ return () => window.removeEventListener(GRID_CHAT_OPEN_EVENT, onOpenChat);
2775
+ }, [embedded, queryBinding]);
2776
  /** The config to put back when the chat is asked to undo its own view change (E-4 `onUndoView`).
2777
  * A snapshot rather than a diff: the chat may change several axes at once, and a per-axis undo
2778
  * would restore some of them. */
 
2828
  const onChatApplyView = useCallback(
2829
  (spec: GridChatViewSpec) => {
2830
  chatUndoRef.current = config;
2831
+ let next: ViewConfig = { ...config };
2832
  if (spec.sortBy && fieldByKey.has(spec.sortBy))
2833
  next.sorts = [{ colId: spec.sortBy, dir: "asc" }];
2834
  if (spec.groupBy !== undefined)
2835
  next.groupBy = spec.groupBy && fieldByKey.has(spec.groupBy) ? spec.groupBy : null;
2836
+ /* ⭐⭐ W38-T07 β€” THROUGH THE ONE WRITER. This clause used to set `display.colorField` and
2837
+ nothing else, which was true enough while the map owned a colour picker of its own and
2838
+ the grid's wash had a separate on/off control. With ONE control reading `config.colorBy`,
2839
+ a half-write here would recolour the pins while the control that claims to be the single
2840
+ door still reported "No color" β€” the back door that makes a merged control a lie.
2841
+ ⚠ The GUARD is unchanged on purpose: an absent or unresolvable `colorBy` is still a
2842
+ NO-OP rather than a clear, because E's `readIntent` omits the key when the question said
2843
+ nothing about colour, and reading that omission as "turn colour off" would have the
2844
+ assistant undoing a pick the person made by hand. */
2845
+ if (spec.colorBy && fieldByKey.has(spec.colorBy))
2846
+ next = withColorField(next, spec.colorBy);
2847
  updateConfig(next);
2848
  },
2849
  [config, fieldByKey, updateConfig]
 
6287
  dateChoices={dateFieldChoices}
6288
  stackField={kanbanField}
6289
  stackChoices={stackFieldChoices}
6290
+ /* ⭐ W38-T07 β€” the map's own colour picker is GONE from the mode bar, so the two props that
6291
+ fed it go with it. `mapColorField` survives: `MapView` still reads the encoding, it just
6292
+ no longer offers a second control for choosing it. `colorFieldChoices` survives too and
6293
+ now feeds the toolbar's one Color control. */
6294
  sizeField={mapSizeField}
6295
  sizeChoices={sizeFieldChoices}
6296
  onPickField={setDisplayField}
 
6725
  onFieldOrder={(keys) => updateConfig({ ...config, order: keys })}
6726
  deletableKeys={deletableKeys}
6727
  onDeleteField={deleteField}
6728
+ /* ⭐⭐ W38-T07 (owner instruction 12) β€” THE ONE COLOUR CONTROL. This was `colorOn` plus a
6729
+ boolean toggle whose `true` arm guessed the column: `fields.find(type === "status")`,
6730
+ so on a table with two status columns the person got the first one and had no way to
6731
+ say which. Map view then mounted a picker of its own to answer the same question,
6732
+ which is the duplication reported. The toolbar's control now names the field, and
6733
+ `withColorField` writes BOTH the grid's `colorBy` and the map's `display.colorField`.
6734
+ ⚠ ONE list of eligible columns, shared with the map's own resolution above
6735
+ (`colorFieldChoices`), so "what can carry a colour" has a single definition. */
6736
+ colorField={config.colorBy}
6737
+ colorChoices={colorFieldChoices}
6738
+ onColorField={(key) => updateConfig(withColorField(config, key))}
6739
  rowHeightMode={config.rowHeightMode}
6740
  onRowHeightMode={(rowHeightMode) => updateConfig({ ...config, rowHeightMode })}
6741
  filters={config.filters}
web/src/customer-grid/MapView.tsx CHANGED
@@ -80,13 +80,15 @@ import {
80
  planRoute,
81
  pointInPolygon,
82
  project,
 
 
83
  toScreen,
84
  unproject,
85
  WORLD,
86
  zoomAt,
87
  zoomLimits,
88
  } from "./mapProjection";
89
- import type { GeoStop, Pt, View } from "./mapProjection";
90
  import "./map.css";
91
 
92
  /**
@@ -270,6 +272,28 @@ export function MapView({
270
  >(null);
271
  const [roadBusy, setRoadBusy] = useState(false);
272
  const [roadErr, setRoadErr] = useState<string | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  /**
274
  * W37-T37 β€” the address lookup's own progress, and it exists so the WAIT IS VISIBLE.
275
  *
@@ -574,19 +598,60 @@ export function MapView({
574
  if (i >= 0) start = i;
575
  }
576
  const { order, km } = planRoute(stops, haversineKm, { start, roundTrip });
577
- let ordered = order.map((i) => routable[i]);
 
 
 
 
 
 
 
 
 
 
578
  // W37-T36 β€” a hand-picked order wins over the planner's, but only for the stops that are still
579
  // selected. Anything the user has since deselected drops out; anything newly selected is
580
  // appended in the planner's own order rather than being silently left off the route.
581
  if (routeOrder) {
582
- const byPid = new Map(routable.map((p) => [p.pid, p]));
583
- const picked = routeOrder.map((pid) => byPid.get(pid)).filter(Boolean) as MapPoint[];
584
- const seen = new Set(picked.map((p) => p.pid));
585
- ordered = [...picked, ...ordered.filter((p) => !seen.has(p.pid))];
 
 
586
  }
 
 
 
 
 
 
 
587
  return {
588
  ordered,
589
  km,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
  link: googleRouteUrl(
591
  ordered.map((p) => ({ lat: p.lat, lon: p.lon })),
592
  { roundTrip, coarsePointer }
@@ -665,6 +730,102 @@ export function MapView({
665
  void fetchRoad();
666
  }, [plan, fetchRoad]);
667
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  /** The drawn road, projected once per answer. Straight-line fallback stays in `plan`, so a map
669
  * that never got a road answer still shows the sequence it worked out for itself. */
670
  const roadPath = useMemo(() => {
@@ -922,19 +1083,39 @@ export function MapView({
922
  );
923
 
924
  /**
925
- * D-135 (W37-T30) β€” a pin could be OPENED and never SELECTED.
 
 
 
 
926
  *
927
- * ⭐ THE MAP WAS THE LAST VIEW STILL ON THE OLD INTERACTION MODEL. Owner item 19 moved the whole
928
  * product off click-to-open: in the grid "the single click that used to open a record now
929
- * highlights it" and opening moved to a deliberate affordance (CustomerGrid.tsx, the hover-only
930
- * Expand). The map kept firing `onOpen` on a bare click, so the one view built around choosing a
931
- * SET of records was the one view where choosing was impossible, and the "N selected" bar,
932
- * "Add to cohort" and the route planner all sat behind a gesture the map did not have.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
933
  *
934
- * So: a plain click selects, a modifier click TOGGLES that pin in or out of the set, and opening
935
- * escalates to a double click. Toggle rather than plain accumulate is done here rather than
936
- * asked for from the host, because `onSelectPids` offers "replace" and "add" only, and a second
937
- * shift-click that cannot undo the first is a trap on a map where pins overlap.
938
  */
939
  const pinSelect = useCallback(
940
  (pid: number, additive: boolean) => {
@@ -1274,6 +1455,80 @@ export function MapView({
1274
  Re-optimise
1275
  </button>
1276
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1277
  <button
1278
  type="button"
1279
  className="cg-map-route-go"
@@ -1322,8 +1577,8 @@ export function MapView({
1322
  knew about. It needs shift too β€” alt alone still pans. */}
1323
  <span className="cg-map-hint">
1324
  Scroll to zoom Β· drag to pan Β· {lassoTool ? "shift-drag to lasso" : "shift-drag to select"}{" "}
1325
- Β· shift-alt-drag to extend the selection Β· click a pin to select it Β· shift-click to add
1326
- or remove one Β· double-click to open the record
1327
  </span>
1328
  </div>
1329
  <div
@@ -1464,27 +1719,48 @@ export function MapView({
1464
  role="button"
1465
  tabIndex={0}
1466
  aria-pressed={on}
1467
- aria-label={`${p.title || "Untitled record"}. Click to select, double click to open.`}
 
 
 
 
 
1468
  onMouseEnter={() => setHoverPid(p.pid)}
1469
  onMouseLeave={() => setHoverPid((h) => (h === p.pid ? null : h))}
1470
  onFocus={() => setHoverPid(p.pid)}
1471
  onBlur={() => setHoverPid((h) => (h === p.pid ? null : h))}
1472
  onClick={(e) => {
1473
- if (movedRef.current) return; // a finished pan/box is not a click
1474
- pinSelect(p.pid, e.shiftKey || e.ctrlKey || e.metaKey);
 
 
 
 
 
 
 
 
 
 
 
1475
  }}
1476
- // Opening escalates to a double click now that a single one selects. The two
1477
- // clicks React reports before this one are both `pinSelect` on the SAME pid, so
1478
- // the selection lands where the user pointed either way.
 
 
1479
  onDoubleClick={(e) => {
1480
  if (movedRef.current) return;
1481
  e.preventDefault();
1482
  onOpen(p.pid);
1483
  }}
1484
  onKeyDown={(e) => {
1485
- // ⚠ The keyboard keeps a DIRECT open on Enter rather than mirroring the double
1486
- // click: focus already says which pin, and there is no keyboard gesture that
1487
- // means "twice, quickly". Space is the selection half.
 
 
 
1488
  if (e.key === "Enter") {
1489
  e.preventDefault();
1490
  onOpen(p.pid);
 
80
  planRoute,
81
  pointInPolygon,
82
  project,
83
+ routeFingerprint,
84
+ routeRanks,
85
  toScreen,
86
  unproject,
87
  WORLD,
88
  zoomAt,
89
  zoomLimits,
90
  } from "./mapProjection";
91
+ import type { GeoStop, Pt, RouteStamp, View } from "./mapProjection";
92
  import "./map.css";
93
 
94
  /**
 
272
  >(null);
273
  const [roadBusy, setRoadBusy] = useState(false);
274
  const [roadErr, setRoadErr] = useState<string | null>(null);
275
+ /**
276
+ * ⭐⭐ W38-T20 β€” THE ROUTE-ORDER COLUMNS THIS ACCOUNT MAY SEE, fetched rather than read off the
277
+ * grid's `fields`.
278
+ *
279
+ * β›” TWO REASONS, AND THE SECOND IS THE REAL ONE. `MapView` is handed the field it PLOTS, not
280
+ * the table's field list, so it could not find them anyway; and the useful half is not the
281
+ * column, it is the INPUT FINGERPRINT each column was solved from, which the grid contract has
282
+ * no business carrying to every reader of every row. `GET /customers/route-order` serves the
283
+ * pair behind the same per-field wall the grid uses, so a column this account was not granted
284
+ * is absent here exactly as it is absent there.
285
+ * ⚠ `null` means NOT ASKED YET and `[]` means none exist. They are different states: the first
286
+ * must not render "no saved orders".
287
+ */
288
+ const [routeCols, setRouteCols] = useState<
289
+ { key: string; label: string; inputsHash: string; mine: boolean }[] | null
290
+ >(null);
291
+ /** Which saved column a save writes to. `""` = a new one, named by `saveName`. */
292
+ const [saveTarget, setSaveTarget] = useState("");
293
+ const [saveName, setSaveName] = useState("");
294
+ const [saveBusy, setSaveBusy] = useState(false);
295
+ const [saveMsg, setSaveMsg] = useState<string | null>(null);
296
+ const [saveErr, setSaveErr] = useState<string | null>(null);
297
  /**
298
  * W37-T37 β€” the address lookup's own progress, and it exists so the WAIT IS VISIBLE.
299
  *
 
598
  if (i >= 0) start = i;
599
  }
600
  const { order, km } = planRoute(stops, haversineKm, { start, roundTrip });
601
+ // ⭐⭐ W38-T20 β€” THE HAND-REORDER STAYS IN **INDEX** SPACE, and that is a deliberate change of
602
+ // representation rather than a tidy-up. `stops`, `routable` and `order` are one index space
603
+ // already (`order.map(i => routable[i])` is what zips them back), and the visit NUMBER a
604
+ // record carries is the inverse of a permutation over exactly that space. Rebuilding the
605
+ // sequence out of MapPoints and then recovering indices from it would be a second, silent
606
+ // conversion between the two β€” and W38-T21 prepends a synthetic depot into this same index
607
+ // space, which is far harder to reason about across two representations than one.
608
+ // ⚠ EXACTLY THE OLD BEHAVIOUR: `order` is a permutation of every index and `ordered` was
609
+ // `order.map(i => routable[i])`, so filtering `order` by index-not-seen selects the same
610
+ // stops, in the same sequence, as filtering `ordered` by pid-not-seen did.
611
+ let seq = order;
612
  // W37-T36 β€” a hand-picked order wins over the planner's, but only for the stops that are still
613
  // selected. Anything the user has since deselected drops out; anything newly selected is
614
  // appended in the planner's own order rather than being silently left off the route.
615
  if (routeOrder) {
616
+ const idxByPid = new Map(routable.map((p, i) => [p.pid, i]));
617
+ const picked = routeOrder
618
+ .map((pid) => idxByPid.get(pid))
619
+ .filter((i) => i !== undefined) as number[];
620
+ const seen = new Set(picked);
621
+ seq = [...picked, ...order.filter((i) => !seen.has(i))];
622
  }
623
+ const ordered = seq.map((i) => routable[i]);
624
+ // β›”β›” THE PERMUTATION IS NOT THE RANK (A25 / A44). `seq[i]` is WHICH STOP is visited i-th;
625
+ // the number a record carries is the other way round, and `routeRanks` is the one inversion.
626
+ // It is called HERE, in the shipped path, and not only in the gate: a tested function the
627
+ // product does not use guards nothing (`paintedRadius`'s docstring is this repo's record of
628
+ // that, and the wave-29 client-px conversion is the scar).
629
+ const ranks = routeRanks(seq, routable.length);
630
  return {
631
  ordered,
632
  km,
633
+ /** `{pid: visit number}` β€” what the route-order COLUMN stores, one integer per record. */
634
+ ranksByPid: routable.reduce((acc, p, i) => {
635
+ const n = ranks[i];
636
+ if (n != null) acc[p.pid] = n;
637
+ return acc;
638
+ }, {} as Record<number, number>),
639
+ /**
640
+ * The identity of the INPUTS this order was solved from, so staleness can be DERIVED at
641
+ * read time rather than stored. A saved column carries the fingerprint it was solved with;
642
+ * when this one differs, what is on screen would solve to different numbers.
643
+ */
644
+ fingerprint: routeFingerprint(
645
+ routable.map((p) => ({ pid: p.pid, lat: p.lat, lon: p.lon } as RouteStamp)),
646
+ // ⚠ THE RESOLVED ORIGIN, NOT `routeStartPid`. The picker is null while the westernmost
647
+ // default applies, so keying on it would report STALE the moment somebody explicitly
648
+ // selects the stop that was ALREADY the origin β€” the numbers unchanged, the marker on.
649
+ // What the answer depends on is which stop the planner was actually given.
650
+ { roundTrip, startPid: routable[start].pid, handOrder: routeOrder }
651
+ ),
652
+ /** The origin the planner was given, so the saved column records the same one the
653
+ * fingerprint was taken over. */
654
+ startPid: routable[start].pid,
655
  link: googleRouteUrl(
656
  ordered.map((p) => ({ lat: p.lat, lon: p.lon })),
657
  { roundTrip, coarsePointer }
 
730
  void fetchRoad();
731
  }, [plan, fetchRoad]);
732
 
733
+ // ─────────────────────────────── W38-T20: the order, as a column every colleague reads ───────
734
+ /**
735
+ * The saved route-order columns, refreshed from the server.
736
+ *
737
+ * ⚠ A READ, so it may run without a click, unlike `fetchRoad`. R3 is about not spending a
738
+ * ROUTING request on a render; this asks our own store for a short list of column names and is
739
+ * the only way the STALE marker can exist at all.
740
+ */
741
+ const loadRouteCols = useCallback(async () => {
742
+ try {
743
+ const res = await fetch("/api/v1/customers/route-order", { credentials: "same-origin" });
744
+ const body = res.ok ? await res.json().catch(() => null) : null;
745
+ setRouteCols(Array.isArray(body?.fields) ? body.fields : []);
746
+ } catch {
747
+ // A listing that cannot be fetched is "none known", not an error banner over the map: the
748
+ // planner still works, and the save below reports its own failure in its own words.
749
+ setRouteCols([]);
750
+ }
751
+ }, []);
752
+
753
+ useEffect(() => {
754
+ if (routeOn && routeCols === null) void loadRouteCols();
755
+ }, [routeOn, routeCols, loadRouteCols]);
756
+
757
+ /**
758
+ * Write the visit numbers on screen into a tenant-wide column.
759
+ *
760
+ * β›” THE NUMBER PER RECORD IS `plan.ranksByPid`, WHICH IS THE **INVERSE** OF THE PLANNER'S
761
+ * ANSWER (A25 / A44). `planRoute` returns which stop is visited i-th; a column answers what
762
+ * number THIS record carries. Sending the permutation would fill every row with a plausible
763
+ * integer from somebody else's stop, and nothing downstream could tell.
764
+ *
765
+ * ⚠ THE FINGERPRINT GOES WITH IT, ONCE. One solve covers the whole cohort, so the inputs are
766
+ * identical for every record; the server stores it on the column DEFINITION. Staleness is then
767
+ * derived by comparing it against `plan.fingerprint` on whatever is on screen later.
768
+ */
769
+ const saveRouteOrder = useCallback(async () => {
770
+ if (!plan) return;
771
+ const ranks = plan.ranksByPid;
772
+ if (Object.keys(ranks).length < 2) return;
773
+ setSaveBusy(true);
774
+ setSaveErr(null);
775
+ setSaveMsg(null);
776
+ try {
777
+ const res = await fetch("/api/v1/customers/route-order", {
778
+ method: "POST",
779
+ credentials: "same-origin",
780
+ headers: { "Content-Type": "application/json" },
781
+ body: JSON.stringify({
782
+ field: saveTarget || undefined,
783
+ label: saveTarget
784
+ ? (routeCols || []).find((f) => f.key === saveTarget)?.label || saveTarget
785
+ : saveName.trim(),
786
+ ranks,
787
+ inputsHash: plan.fingerprint,
788
+ roundTrip,
789
+ startPid: plan.startPid,
790
+ }),
791
+ });
792
+ const body = await res.json().catch(() => null);
793
+ if (!res.ok) {
794
+ // ⭐ THE SERVER'S OWN SENTENCE. It knows whether a record was outside this book, whether
795
+ // the numbers were not a clean sequence, or whether somebody else planned this column,
796
+ // and those need three different things from the person reading it.
797
+ setSaveErr(
798
+ (body && body.error && body.error.message) ||
799
+ "The visit order could not be saved."
800
+ );
801
+ return;
802
+ }
803
+ setSaveTarget(String(body?.field || ""));
804
+ setSaveName("");
805
+ setSaveMsg(
806
+ `Saved ${Number(body?.stops || 0).toLocaleString()} visit numbers to ` +
807
+ `${body?.label || "the column"}` +
808
+ (body?.cleared ? `, and cleared ${Number(body.cleared).toLocaleString()} that dropped out` : "")
809
+ );
810
+ await loadRouteCols();
811
+ } catch {
812
+ setSaveErr("The visit order could not be saved.");
813
+ } finally {
814
+ setSaveBusy(false);
815
+ }
816
+ }, [plan, saveTarget, saveName, roundTrip, routeCols, loadRouteCols]);
817
+
818
+ /**
819
+ * The saved column selected for writing, and whether what is on screen would solve differently.
820
+ *
821
+ * β›” STALE IS DERIVED HERE AND STORED NOWHERE. What the server keeps is the fingerprint the
822
+ * numbers were produced FROM, so this comparison is made against the cohort as it is right now
823
+ * and can never itself be out of date. A stored flag would be a claim about a moment that has
824
+ * already passed.
825
+ */
826
+ const savedCol = (routeCols || []).find((f) => f.key === saveTarget) || null;
827
+ const orderStale = !!(savedCol && plan && savedCol.inputsHash !== plan.fingerprint);
828
+
829
  /** The drawn road, projected once per answer. Straight-line fallback stays in `plan`, so a map
830
  * that never got a road answer still shows the sequence it worked out for itself. */
831
  const roadPath = useMemo(() => {
 
1083
  );
1084
 
1085
  /**
1086
+ * β›”β›” W38-T09 (ruling R10) β€” A SINGLE CLICK ON A PIN OPENS THE RECORD. This SUPERSEDES the
1087
+ * single-click-SELECTS half of W37-T30 (D-135), which was built deliberately one wave ago. The
1088
+ * paragraph W37-T30 wrote is kept below rather than deleted, because its argument is still the
1089
+ * argument that has to be traded against, and a reader who only sees the winner cannot tell a
1090
+ * decision from an accident.
1091
  *
1092
+ * ⭐ WHAT W37-T30 ARGUED, AND IT WAS NOT WRONG AT THE TIME. Owner item 19 moved the whole
1093
  * product off click-to-open: in the grid "the single click that used to open a record now
1094
+ * highlights it", and opening moved to a deliberate affordance (CustomerGrid.tsx, the
1095
+ * hover-only Expand). The map went on firing `onOpen` on a bare click, so the one view built
1096
+ * around choosing a SET of records was the one view where choosing was impossible, and the
1097
+ * "N selected" bar, "Add to cohort" and the route planner all sat behind a gesture the map did
1098
+ * not have.
1099
+ *
1100
+ * ⭐ WHAT R10 TRADES IT FOR. A pin is not a row. A row lives in a sheet where the cursor already
1101
+ * means "this cell", so click-to-highlight costs a user nothing; a pin is a target you had to
1102
+ * AIM at, and the thing you wanted after aiming is the record. Every set-building gesture the
1103
+ * map grew for item 19 SURVIVES, and not one of them is the plain click: shift-drag and the
1104
+ * lasso build a cohort, shift-alt-drag extends one, a modifier click toggles a single pin in or
1105
+ * out, and the keyboard keeps Space for select and Enter for open.
1106
+ *
1107
+ * ⚠ SO `pinSelect`'s NON-ADDITIVE BRANCH IS NOW KEYBOARD-ONLY, not dead. A plain Space still
1108
+ * replaces the selection with the focused pin; a pointer no longer has that gesture at all.
1109
+ * Deleting the branch as unreachable would take the keyboard's only "just this one" with it.
1110
+ *
1111
+ * β›” AND `movedRef` IS NOW LOAD-BEARING FOR A WORSE REASON THAN THE ONE IT WAS WRITTEN FOR.
1112
+ * Before R10, a pan or a box-drag that happened to finish on top of a pin mis-SELECTED it.
1113
+ * After R10 the same stray gesture would OPEN a drawer over the map. Whoever next reads that
1114
+ * early return as noise should read this sentence instead: its failure mode got louder.
1115
  *
1116
+ * Toggle rather than plain accumulate is done here rather than asked for from the host, because
1117
+ * `onSelectPids` offers "replace" and "add" only, and a second shift-click that cannot undo the
1118
+ * first is a trap on a map where pins overlap.
 
1119
  */
1120
  const pinSelect = useCallback(
1121
  (pid: number, additive: boolean) => {
 
1455
  Re-optimise
1456
  </button>
1457
  )}
1458
+ {/* ⭐⭐ W38-T20 β€” THE ORDER BECOMES A COLUMN EVERY PERMITTED COLLEAGUE READS.
1459
+ Until now a planned day lived in one browser tab: close it and the sequence
1460
+ is gone, and nobody else ever saw it. The number written per record is the
1461
+ INVERSE of the planner's permutation (see `saveRouteOrder`), the column is
1462
+ tenant-wide and grant-governed, and the fingerprint it was solved from rides
1463
+ the column so a later reader can be told it is out of date instead of being
1464
+ shown yesterday's numbers as though they were today's. */}
1465
+ <label className="cg-map-route-opt">
1466
+ Visit order column
1467
+ <select
1468
+ className="cg-map-route-start"
1469
+ value={saveTarget}
1470
+ aria-label="Which visit order column to write"
1471
+ onChange={(e) => {
1472
+ setSaveTarget(e.target.value);
1473
+ setSaveMsg(null);
1474
+ setSaveErr(null);
1475
+ }}
1476
+ >
1477
+ <option value="">New column</option>
1478
+ {(routeCols || [])
1479
+ .filter((f) => f.mine)
1480
+ .map((f) => (
1481
+ <option key={f.key} value={f.key}>
1482
+ {f.label}
1483
+ </option>
1484
+ ))}
1485
+ </select>
1486
+ </label>
1487
+ {!saveTarget && (
1488
+ <input
1489
+ type="text"
1490
+ /* ⚠ The SELECT's class, deliberately: `index.css` is outside this
1491
+ change's fence, and `.cg-map-route-start` is already the bar's
1492
+ input styling (border, radius, 22 px, inherited font). A new class
1493
+ would render unstyled until somebody remembered the stylesheet. */
1494
+ className="cg-map-route-start"
1495
+ value={saveName}
1496
+ placeholder="Name this order"
1497
+ aria-label="Name this visit order column"
1498
+ maxLength={60}
1499
+ onChange={(e) => setSaveName(e.target.value)}
1500
+ />
1501
+ )}
1502
+ <button
1503
+ type="button"
1504
+ className="cg-map-route-go"
1505
+ disabled={saveBusy || (!saveTarget && !saveName.trim())}
1506
+ onClick={() => void saveRouteOrder()}
1507
+ title={
1508
+ saveTarget
1509
+ ? "Write these visit numbers into the saved column, for everyone it is shared with"
1510
+ : "Save these visit numbers as a column your colleagues can be given"
1511
+ }
1512
+ >
1513
+ {saveBusy ? "Saving" : saveTarget ? "Update visit order" : "Save visit order"}
1514
+ </button>
1515
+ {/* β›” DERIVED, NEVER STORED. The saved column carries the fingerprint of the
1516
+ inputs it was solved from; this compares it against the cohort ON SCREEN
1517
+ NOW. So the marker cannot itself go out of date, and nothing recomputes
1518
+ under somebody mid-day. */}
1519
+ {orderStale && (
1520
+ <span className="cg-map-route-note">
1521
+ Stale. The saved numbers were solved from different stops, so re-solving
1522
+ would change them.
1523
+ </span>
1524
+ )}
1525
+ {(routeCols || []).some((f) => !f.mine) && (
1526
+ <span className="cg-map-route-note">
1527
+ Shared orders you can read but not re-solve are not listed above.
1528
+ </span>
1529
+ )}
1530
+ {saveMsg && <span className="cg-map-route-note">{saveMsg}</span>}
1531
+ {saveErr && <span className="cg-cal-nodate">{saveErr}</span>}
1532
  <button
1533
  type="button"
1534
  className="cg-map-route-go"
 
1577
  knew about. It needs shift too β€” alt alone still pans. */}
1578
  <span className="cg-map-hint">
1579
  Scroll to zoom Β· drag to pan Β· {lassoTool ? "shift-drag to lasso" : "shift-drag to select"}{" "}
1580
+ Β· shift-alt-drag to extend the selection Β· click a pin to open the record Β· shift-click a
1581
+ pin to add or remove it from the selection
1582
  </span>
1583
  </div>
1584
  <div
 
1719
  role="button"
1720
  tabIndex={0}
1721
  aria-pressed={on}
1722
+ // β›” THE ACCESSIBLE NAME IS THE ONLY CHANNEL A NON-SIGHTED USER HAS FOR A PIN'S
1723
+ // gesture: a `<circle>` carries no tooltip, no hover card and no glyph, and the
1724
+ // hint bar above is the SIGHTED half of this same disclosure. It said "Click to
1725
+ // select, double click to open." until R10 made that false.
1726
+ // Policed by `verify_ui.py::pin_label_states_the_gesture`.
1727
+ aria-label={`${p.title || "Untitled record"}. Click to open, shift-click to select.`}
1728
  onMouseEnter={() => setHoverPid(p.pid)}
1729
  onMouseLeave={() => setHoverPid((h) => (h === p.pid ? null : h))}
1730
  onFocus={() => setHoverPid(p.pid)}
1731
  onBlur={() => setHoverPid((h) => (h === p.pid ? null : h))}
1732
  onClick={(e) => {
1733
+ // β›” NO LONGER MERELY "a finished pan is not a click" (R10): a drag that ends
1734
+ // on a pin would now OPEN a drawer, not just move the selection. See the
1735
+ // `pinSelect` doc comment above before touching this line.
1736
+ if (movedRef.current) return;
1737
+ // ⭐ THE SPLIT R10 ASKS FOR, and `verify_map.py::structural_scan` reads it
1738
+ // STRUCTURALLY: the select lives inside this block and the open lives outside
1739
+ // it, which is the one claim a substring search cannot make.
1740
+ const additive = e.shiftKey || e.ctrlKey || e.metaKey;
1741
+ if (additive) {
1742
+ pinSelect(p.pid, true);
1743
+ return;
1744
+ }
1745
+ onOpen(p.pid);
1746
  }}
1747
+ // ⚠ KEPT AFTER R10, AND IT IS NO LONGER AN ESCALATION. A plain click already
1748
+ // opened the record, so this fires `onOpen` a second time on the SAME pid --
1749
+ // idempotent, because the host's handler is a `setDetailPid` identity. It stays
1750
+ // because removing it would make a double click on a pin do nothing at all,
1751
+ // and because a user who learned the old gesture keeps getting the record.
1752
  onDoubleClick={(e) => {
1753
  if (movedRef.current) return;
1754
  e.preventDefault();
1755
  onOpen(p.pid);
1756
  }}
1757
  onKeyDown={(e) => {
1758
+ // ⚠ AFTER R10 THIS IS THE MIRROR, not the exception it used to be: Enter opens
1759
+ // exactly as a plain click now does, and Space is the selection half exactly
1760
+ // as a modifier click is. Space with a modifier toggles; Space alone still
1761
+ // REPLACES the selection, which is the only surface left that can.
1762
+ // β›” No `movedRef` check here, deliberately: a keypress has no drag to
1763
+ // disambiguate, so the guard the pointer needs would only be superstition.
1764
  if (e.key === "Enter") {
1765
  e.preventDefault();
1766
  onOpen(p.pid);
web/src/customer-grid/Toolbar.tsx CHANGED
@@ -4,8 +4,9 @@
4
  // buttons with popovers, a hairline underline, tabular-nums count.
5
  //
6
  // PURELY PRESENTATIONAL. Every piece of state and every setter is a prop; this
7
- // file owns no view-state and knows nothing about glide. The color on/off ->
8
- // status-key mapping lives in CustomerGrid (Toolbar just gets colorOn + toggle).
 
9
  //
10
  // Controls left -> right: Fields (hide/show), Filter+Sort (disabled, next
11
  // batch), Color (by status), Rows (height) | spacer | Search | "N records".
@@ -67,8 +68,21 @@ export interface ToolbarProps {
67
  deletableKeys?: ReadonlySet<string>;
68
  onDeleteField?: (key: string) => void;
69
 
70
- colorOn: boolean;
71
- onToggleColor: (on: boolean) => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  rowHeightMode: RowHeightMode;
74
  onRowHeightMode: (m: RowHeightMode) => void;
@@ -556,8 +570,9 @@ export default function Toolbar({
556
  onFieldOrder,
557
  deletableKeys,
558
  onDeleteField,
559
- colorOn,
560
- onToggleColor,
 
561
  rowHeightMode,
562
  onRowHeightMode,
563
  filters,
@@ -699,6 +714,12 @@ export default function Toolbar({
699
  const groupLabel = groupBy
700
  ? `Group: ${fieldByKey.get(groupBy)?.label ?? "?"}`
701
  : "Group";
 
 
 
 
 
 
702
 
703
  return (
704
  <div className="cg-toolbar">
@@ -727,7 +748,12 @@ export default function Toolbar({
727
  )}
728
  </Popover>
729
 
730
- {/* Filter β€” condition builder */}
 
 
 
 
 
731
  <Popover
732
  label={filterLabel}
733
  icon={<IconFilter />}
@@ -738,7 +764,7 @@ export default function Toolbar({
738
  pairWide={hasComparand}
739
  forceOpenSignal={filterSeed?.n}
740
  >
741
- {() => (
742
  <FilterBuilderPanel
743
  // The COMPLETE list: the kit narrows the picker to the filterable ones itself and
744
  // keeps the full map for resolving a condition saved against a column that has
@@ -751,6 +777,7 @@ export default function Toolbar({
751
  onChange={onFilterTree}
752
  statusValues={statusValues}
753
  cohortLock={cohortLock}
 
754
  footerExtra={
755
  canCopyConfig ? (
756
  <CopyFromViewDoor
@@ -824,29 +851,71 @@ export default function Toolbar({
824
 
825
  <span className="cg-tb-divider" aria-hidden />
826
 
827
- {/* Color β€” by status */}
828
- <Popover label="Color" icon={<IconColor />} active={colorOn}>
829
- {() => (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
830
  <div className="cg-pop-body">
831
- <div className="cg-pop-title">Color</div>
832
  <label className="cg-radio-row">
833
  <input
834
  type="radio"
835
  name="cg-color"
836
- checked={!colorOn}
837
- onChange={() => onToggleColor(false)}
 
 
 
838
  />
839
  <span>No color</span>
840
  </label>
841
- <label className="cg-radio-row">
842
- <input
843
- type="radio"
844
- name="cg-color"
845
- checked={colorOn}
846
- onChange={() => onToggleColor(true)}
847
- />
848
- <span>Color by status</span>
849
- </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
850
  </div>
851
  )}
852
  </Popover>
 
4
  // buttons with popovers, a hairline underline, tabular-nums count.
5
  //
6
  // PURELY PRESENTATIONAL. Every piece of state and every setter is a prop; this
7
+ // file owns no view-state and knows nothing about glide. W38-T07: the Color
8
+ // control is a FIELD PICKER, so the prop is the chosen key plus the eligible
9
+ // columns, and CustomerGrid owns what a chosen key is written into.
10
  //
11
  // Controls left -> right: Fields (hide/show), Filter+Sort (disabled, next
12
  // batch), Color (by status), Rows (height) | spacer | Search | "N records".
 
68
  deletableKeys?: ReadonlySet<string>;
69
  onDeleteField?: (key: string) => void;
70
 
71
+ /**
72
+ * ⭐⭐ W38-T07 (owner instruction 12) β€” THE ONE COLOUR CONTROL, and it names a FIELD rather than
73
+ * answering yes/no. It used to be two radios ("No color" / "Color by status") whose `true` arm
74
+ * made CustomerGrid guess at `fields.find(type === "status")`, so the person never chose; Map
75
+ * view then mounted a SECOND colour picker of its own, which is the duplication this closes.
76
+ *
77
+ * β›” THE KEY, NOT A PALETTE. Two renderers consume the choice and their palettes are
78
+ * structurally different engines β€” see the control's own note further down. This prop carries
79
+ * a column key and nothing about how either of them paints it.
80
+ */
81
+ colorField: string | null;
82
+ /** The columns eligible to carry a colour. Resolved by the caller, shared with the map. */
83
+ colorChoices: Field[];
84
+ /** `""` is the real "no colour" choice, matching every other optional field ref in this app. */
85
+ onColorField: (key: string) => void;
86
 
87
  rowHeightMode: RowHeightMode;
88
  onRowHeightMode: (m: RowHeightMode) => void;
 
570
  onFieldOrder,
571
  deletableKeys,
572
  onDeleteField,
573
+ colorField,
574
+ colorChoices,
575
+ onColorField,
576
  rowHeightMode,
577
  onRowHeightMode,
578
  filters,
 
714
  const groupLabel = groupBy
715
  ? `Group: ${fieldByKey.get(groupBy)?.label ?? "?"}`
716
  : "Group";
717
+ // W38-T07 β€” the trigger NAMES the chosen column, exactly as Group's does one line up. A
718
+ // control that only lights up makes the reader open it to learn which column it picked, and
719
+ // this one now has a column to report.
720
+ const colorLabel = colorField
721
+ ? `Color: ${colorChoices.find((f) => f.key === colorField)?.label ?? "?"}`
722
+ : "Color";
723
 
724
  return (
725
  <div className="cg-toolbar">
 
748
  )}
749
  </Popover>
750
 
751
+ {/* Filter β€” condition builder.
752
+ W38-T02 (owner instruction 8): `Popover` has always manufactured a closer and handed it
753
+ to this render-prop, and this call site has always spelled the prop zero-arg and thrown
754
+ it away. Taking the argument and passing it down is the whole of the header X wiring β€”
755
+ no new plumbing, no new state, and Escape / click-outside keep working because they were
756
+ never this closer's doing in the first place. */}
757
  <Popover
758
  label={filterLabel}
759
  icon={<IconFilter />}
 
764
  pairWide={hasComparand}
765
  forceOpenSignal={filterSeed?.n}
766
  >
767
+ {(close) => (
768
  <FilterBuilderPanel
769
  // The COMPLETE list: the kit narrows the picker to the filterable ones itself and
770
  // keeps the full map for resolving a condition saved against a column that has
 
777
  onChange={onFilterTree}
778
  statusValues={statusValues}
779
  cohortLock={cohortLock}
780
+ onClose={close}
781
  footerExtra={
782
  canCopyConfig ? (
783
  <CopyFromViewDoor
 
851
 
852
  <span className="cg-tb-divider" aria-hidden />
853
 
854
+ {/* ⭐⭐ W38-T07 (owner instruction 12) β€” THE ONE COLOUR CONTROL, PROMOTED TO A FIELD PICKER.
855
+ Owner: *"Under Map view, the 'Colour' dropdown duplicates the existing Color dropdown.
856
+ Use the existing one."* Map view mounted a second colour picker beside the mode switcher,
857
+ so one question had two controls on one screen and neither could see the other's answer.
858
+ This is the survivor; `viewModes.tsx`'s map-only picker is deleted in the same change.
859
+
860
+ β›”β›” ONE PICKER, TWO UNCHANGED CONSUMERS β€” the merge is of the CONTROL, never of the
861
+ PALETTES, and that distinction is the whole safety of this ticket. The two renderers are
862
+ structurally different engines:
863
+ Β· the GRID is identity-keyed β€” `theme.ts::STATUS_ROW_THEME` maps a lowercased status
864
+ value to a hand-tuned rgba wash (`reactivated` deliberately reuses `growing`'s hue
865
+ louder, `dormant` is deliberately near-invisible, and the alpha blend is
866
+ load-bearing because it TINTS the row rather than replacing it);
867
+ Β· the MAP is order-keyed β€” `MapView.tsx::SERIES` hands out 8 opaque fill/line pairs by
868
+ FIRST-SEEN ORDER in the current filtered row set, with an overflow colour after that.
869
+ Driving the grid off `SERIES` would make a status's colour depend on sort and filter
870
+ order and would hard-replace the wash instead of tinting it. So each renderer keeps its
871
+ own palette and this control only says WHICH COLUMN. `theme.ts` needing no edit is the
872
+ proof the merge stayed safe.
873
+
874
+ ⚠ RADIO ROWS, not a nested `FieldSelectButton`: "Group by" three blocks up asks this same
875
+ "which column?" question over a WIDER field set with exactly this markup, so the strip
876
+ already has an idiom for it, and a picker nested inside this popover would cost a second
877
+ open on a list this short. A stale key cannot reach here either β€” `cleanConfig` drops an
878
+ unknown `colorBy` on read and the field-delete paths null it β€” so the `is-missing` slot
879
+ that would have been the picker's one advantage has nothing to show. */}
880
+ <Popover label={colorLabel} icon={<IconColor />} active={!!colorField}>
881
+ {(close) => (
882
  <div className="cg-pop-body">
883
+ <div className="cg-pop-title">Color by</div>
884
  <label className="cg-radio-row">
885
  <input
886
  type="radio"
887
  name="cg-color"
888
+ checked={!colorField}
889
+ onChange={() => {
890
+ onColorField("");
891
+ close();
892
+ }}
893
  />
894
  <span>No color</span>
895
  </label>
896
+ {colorChoices.map((f) => (
897
+ <label key={f.key} className="cg-radio-row">
898
+ <input
899
+ type="radio"
900
+ name="cg-color"
901
+ checked={colorField === f.key}
902
+ onChange={() => {
903
+ onColorField(f.key);
904
+ close();
905
+ }}
906
+ />
907
+ {/* The same type mark every other field list in this strip wears. */}
908
+ <FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
909
+ <span>{f.label}</span>
910
+ </label>
911
+ ))}
912
+ {colorChoices.length === 0 && (
913
+ // Say why the list is short rather than showing a lone "No color" row that reads
914
+ // as a broken control.
915
+ <div className="cg-pop-note">
916
+ This table has no status or single select column to color by.
917
+ </div>
918
+ )}
919
  </div>
920
  )}
921
  </Popover>
web/src/customer-grid/mapProjection.ts CHANGED
@@ -226,6 +226,35 @@ export function paintedStroke(basePx: number, k: number): number {
226
  return hairline(basePx, k) * k;
227
  }
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  /**
230
  * The CSS half of the same invariant: any `.cg-map*` rule that declares
231
  * `vector-effect: non-scaling-stroke` is double-compensating against
@@ -458,6 +487,118 @@ export function planRoute(
458
  return { order, km: tourLength(order, stops, dist, roundTrip) };
459
  }
460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  // --------------------------------------------------- handing the route over
462
  //
463
  // MEASURED 2026-07-29, do not re-derive: Google Maps URLs need NO API key and
 
226
  return hairline(basePx, k) * k;
227
  }
228
 
229
+ /**
230
+ * The bump `MapView.tsx`'s pin `<circle>` applies to the hovered pin's radius attribute. Exported rather than
231
+ * re-typed in the gate, because the number the sweep bounds must be the number the renderer is
232
+ * actually asked for: a mirrored `1.28` that drifts turns the ceiling leg into a leg about a
233
+ * constant that no longer exists.
234
+ */
235
+ export const PIN_HOVER_SCALE = 1.28;
236
+
237
+ /**
238
+ * What the renderer actually paints, in screen units, for a PIN RADIUS at zoom k. The radius twin
239
+ * of `paintedStroke`, and it exists because the map has exactly two size channels and only one of
240
+ * them was ever gated.
241
+ *
242
+ * `MapView.tsx`'s pin `<circle>` writes `r={radius(p) / view.k}`, bumps it by `PIN_HOVER_SCALE` while hovered
243
+ * (`:1460`), and paints it inside `<g transform="… scale(view.k)">`. This models that whole
244
+ * pipeline: pre-divide, hover bump, then the group's scale. It must return `baseR` (times the bump)
245
+ * at EVERY k, and `zoomLimits` gives the gate the real range to sweep.
246
+ *
247
+ * β›” NOT CIRCULAR, and W38-T08 is why the distinction earns its paragraph. `reference/ERROR 7.png`
248
+ * shows a 411 CSS px disc on the map and the ticket reasonably assumed a pin had grown into it. A
249
+ * pin cannot: this expression is size-invariant BY CONSTRUCTION. The sweep in `map.test.ts` is what
250
+ * turns that sentence from an assertion into a measurement, and it bites the way the wave-9 stroke
251
+ * bug bit: delete the `/ k` and the invariant holds at exactly one zoom, which is exactly how many
252
+ * zooms a screenshot shows.
253
+ */
254
+ export function paintedRadius(baseR: number, k: number, hovered = false): number {
255
+ return hairline(baseR, k) * (hovered ? PIN_HOVER_SCALE : 1) * k;
256
+ }
257
+
258
  /**
259
  * The CSS half of the same invariant: any `.cg-map*` rule that declares
260
  * `vector-effect: non-scaling-stroke` is double-compensating against
 
487
  return { order, km: tourLength(order, stops, dist, roundTrip) };
488
  }
489
 
490
+ // ------------------------------------------- the order as a value, per record
491
+ //
492
+ // β›”β›” W38-T20 / AMENDMENTS A25 + A44 β€” THE PERMUTATION IS NOT THE RANK, AND THE
493
+ // DIFFERENCE IS INVISIBLE IN THE OUTPUT.
494
+ //
495
+ // `planRoute` answers `order`, where `order[i]` is WHICH STOP is visited i-th.
496
+ // A COLUMN on the record answers the other question: for THIS record, what
497
+ // number does the rep see on their sheet. That is the INVERSE permutation, and
498
+ // writing one where the other belongs produces a fully populated, entirely
499
+ // plausible, completely wrong column β€” every row carries a number, the numbers
500
+ // are 1..N with no repeats, and every one of them is somebody else's.
501
+ //
502
+ // β›” AND MOST FIXTURES CANNOT TELL THEM APART. `planRoute` pins `order[0] ===
503
+ // start`, so a permutation and its inverse AGREE on the first element by
504
+ // construction. Enumerated over every start-pinned permutation: at n = 3 the
505
+ // two are IDENTICAL for 100% of them β€” a three-stop fixture can never catch
506
+ // this β€” at n = 4 for 66%, at n = 5 for 41%. `ROUTE_WAYPOINTS_MOBILE` is 3, so
507
+ // a test written at the mobile cap sits in the blind case by construction. The
508
+ // gate's fixture is `[0, 2, 3, 1]`: ranks `[1, 4, 2, 3]`, the bug `[1, 3, 4, 2]`.
509
+
510
+ /**
511
+ * `ranks[stopIndex]` = the 1-based position of that stop on the route, or
512
+ * `null` for a stop the tour never reached.
513
+ *
514
+ * β›” `null` AND NEVER `0`. `nearestNeighbourOrder` breaks out when no unvisited
515
+ * stop is reachable, so `order` may be SHORTER than the stop list, and a `0` in
516
+ * an integer column reads as a real visit number that sorts to the front. An
517
+ * unvisited stop has no number; the honest spelling of that is an empty cell.
518
+ *
519
+ * ⚠ Defensive on the way in for one reason only: this feeds a column that other
520
+ * people plan a day from. A repeated or out-of-range index in `order` would
521
+ * otherwise silently overwrite one record's rank with another's, so the FIRST
522
+ * occurrence wins and anything off the end is ignored.
523
+ */
524
+ export function routeRanks(order: number[], stopCount: number): (number | null)[] {
525
+ const n = Math.max(0, Math.floor(stopCount) || 0);
526
+ const ranks = new Array<number | null>(n).fill(null);
527
+ let seat = 0;
528
+ for (const raw of order || []) {
529
+ const i = Math.floor(raw);
530
+ if (!Number.isInteger(i) || i < 0 || i >= n) continue;
531
+ if (ranks[i] != null) continue;
532
+ seat += 1;
533
+ ranks[i] = seat;
534
+ }
535
+ return ranks;
536
+ }
537
+
538
+ /** A stop as the ORDER COLUMN sees it: a record id and where that record is. */
539
+ export interface RouteStamp {
540
+ pid: number;
541
+ lat: number;
542
+ lon: number;
543
+ }
544
+
545
+ /**
546
+ * The identity of the INPUTS an order was solved from.
547
+ *
548
+ * ⭐⭐ STALENESS IS DERIVED, NEVER STORED, and this is the half that gets stored
549
+ * so the derivation is possible. `core/user_tables.py` states the law for the AI
550
+ * columns in the same words: what is written down is the input fingerprint the
551
+ * value was produced FROM, so "is this stale" is a question asked at READ time
552
+ * and can never itself be out of date. A stored `stale: true` is a fact about a
553
+ * moment that has already passed.
554
+ *
555
+ * It answers exactly one question: would solving again, over what is on screen
556
+ * NOW, give different numbers? So it covers everything that decides them β€”
557
+ * which records are in the cohort, where each one is, whether the day returns to
558
+ * its origin, which stop it starts from, and any hand-picked order laid over the
559
+ * planner's. It deliberately does NOT cover the zoom, the selection colour or
560
+ * anything else a render can change.
561
+ *
562
+ * ⚠ CANONICALISED, or it reads STALE on every render for no reason: the stops
563
+ * are sorted by `pid` (the on-screen list is sorted by pid, but a caller's is not
564
+ * promised to be) and coordinates go through `toFixed(6)` β€” the same precision
565
+ * `googleMapsUrl` and `googleRouteUrl` already hand to Google, so this borrows a
566
+ * house constant rather than inventing a tolerance.
567
+ */
568
+ export function routeFingerprint(
569
+ stops: RouteStamp[],
570
+ opts: { roundTrip?: boolean; startPid?: number | null; handOrder?: number[] | null } = {}
571
+ ): string {
572
+ const rows = (stops || [])
573
+ .map((s) => `${Math.trunc(s.pid)}@${s.lat.toFixed(6)},${s.lon.toFixed(6)}`)
574
+ .sort();
575
+ const canon = [
576
+ rows.join(";"),
577
+ opts.roundTrip ? "rt" : "open",
578
+ `s:${opts.startPid == null ? "auto" : Math.trunc(opts.startPid)}`,
579
+ `h:${(opts.handOrder || []).map((p) => Math.trunc(p)).join(",")}`,
580
+ ].join("|");
581
+ return `${_fnv1a(canon, 0x811c9dc5)}${_fnv1a(canon, 0x01000193)}`;
582
+ }
583
+
584
+ /**
585
+ * FNV-1a, 32 bits, as unpadded hex. Called TWICE with different offset bases and
586
+ * the two halves concatenated, which is the whole reason it takes one: a single
587
+ * 32-bit digest collides at roughly one pair in 65,536 by the birthday bound,
588
+ * and this decides whether a rep is told their route is out of date. Two
589
+ * independent bases put that past 2^-50, which is not a security claim and does
590
+ * not need to be β€” a collision here shows a stale column as current, and the
591
+ * user can still re-solve.
592
+ */
593
+ function _fnv1a(text: string, base: number): string {
594
+ let h = base >>> 0;
595
+ for (let i = 0; i < text.length; i++) {
596
+ h ^= text.charCodeAt(i);
597
+ h = Math.imul(h, 0x01000193) >>> 0;
598
+ }
599
+ return h.toString(16).padStart(8, "0");
600
+ }
601
+
602
  // --------------------------------------------------- handing the route over
603
  //
604
  // MEASURED 2026-07-29, do not re-derive: Google Maps URLs need NO API key and
web/src/customer-grid/viewModes.tsx CHANGED
@@ -96,8 +96,6 @@ export function ModeSwitch({
96
  coordField,
97
  coordChoices = [],
98
  onPickCoordField,
99
- colorField,
100
- colorChoices,
101
  sizeField,
102
  sizeChoices,
103
  onPickField,
@@ -124,11 +122,13 @@ export function ModeSwitch({
124
  /** The kanban's effective stack field (resolved by the caller). */
125
  stackField?: Field;
126
  stackChoices: Field[];
127
- /** Wave-8 I3/I5 β€” the map's encodings, resolved by the caller from the VIEW
128
  * DEF exactly like the two above (never from component state that dies on
129
- * unmount). Both are optional encodings, so both offer a "no encoding" row β€”
130
- * which is why their `<select>`s carry an explicit empty option instead of
131
- * relying on a first option, the wave-7 trap. */
 
 
132
  /**
133
  * W37-T34 β€” WHICH COLUMN CARRIES THE LOCATION, resolved by the caller from the view def like
134
  * every field above it. Its cell holds `"lat,lon"` (contract C7).
@@ -150,11 +150,18 @@ export function ModeSwitch({
150
  * nothing. The mounted-but-inert defect is unreachable here rather than merely unlikely.
151
  */
152
  onPickCoordField?: (key: string) => void;
153
- colorField?: Field;
154
- colorChoices: Field[];
 
 
155
  sizeField?: Field;
156
  sizeChoices: Field[];
157
- onPickField: (k: "dateField" | "stackField" | "colorField" | "sizeField", key: string) => void;
 
 
 
 
 
158
  /** Item 3 (C-DISP) β€” cards are clamped unless the view stored the literal `false`. */
159
  clamped?: boolean;
160
  /** `false` = opt out of the clamp; `undefined` = back to the default (the key comes OFF). */
@@ -428,12 +435,13 @@ export function ModeSwitch({
428
  )}
429
  </Popover>
430
  )}
431
- {/* The map's two encodings are OPTIONAL, so "none" is a real choice and not merely the
432
  absence of one: it is a row in the list AND the placeholder the button falls back to,
433
  which is the same state said twice on purpose β€” you can see what you would return to
434
- before you pick it. The "Colour: " prefix lived on every option because a native
435
  select had nowhere else to say which encoding it was; the aria-label and the row
436
- carry that now, so the labels are just the field names. */}
 
437
  {/* W37-T34 β€” the LOCATION picker, and it is deliberately the FIRST of the map's three.
438
  Colour and size are encodings you can live without; this one decides whether there are
439
  any pins at all, so it reads left to right in the order the questions actually arrive.
@@ -455,23 +463,14 @@ export function ModeSwitch({
455
  ]}
456
  />
457
  )}
458
- {mode === "map" && colorChoices.length > 0 && (
459
- <FieldSelectButton
460
- className="cg-mode-field"
461
- ariaLabel="Colour pins by"
462
- placeholder="Colour: none"
463
- // `?? ""` on purpose: "" is the key of the explicit "No colour" row, so the none
464
- // state MARKS itself in the list instead of leaving every row unmarked.
465
- value={colorField?.key ?? ""}
466
- onChange={(key) => onPickField("colorField", key)}
467
- fields={[
468
- { key: "", label: "No colour", type: undefined },
469
- ...colorChoices.map((f) => ({
470
- key: f.key, label: `Colour: ${f.label}`, type: f.type,
471
- })),
472
- ]}
473
- />
474
- )}
475
  {mode === "map" && sizeChoices.length > 0 && (
476
  <FieldSelectButton
477
  className="cg-mode-field"
 
96
  coordField,
97
  coordChoices = [],
98
  onPickCoordField,
 
 
99
  sizeField,
100
  sizeChoices,
101
  onPickField,
 
122
  /** The kanban's effective stack field (resolved by the caller). */
123
  stackField?: Field;
124
  stackChoices: Field[];
125
+ /** Wave-8 I3/I5 β€” the map's SIZE encoding, resolved by the caller from the VIEW
126
  * DEF exactly like the two above (never from component state that dies on
127
+ * unmount). It is an optional encoding, so it offers a "no encoding" row β€”
128
+ * which is why its picker carries an explicit empty option instead of
129
+ * relying on a first option, the wave-7 trap.
130
+ * ⚠ This said "the map's encodingS", plural, while colour was its twin. W38-T07 moved colour
131
+ * to the toolbar's one Color control, so size is the only encoding this component still owns. */
132
  /**
133
  * W37-T34 β€” WHICH COLUMN CARRIES THE LOCATION, resolved by the caller from the view def like
134
  * every field above it. Its cell holds `"lat,lon"` (contract C7).
 
150
  * nothing. The mounted-but-inert defect is unreachable here rather than merely unlikely.
151
  */
152
  onPickCoordField?: (key: string) => void;
153
+ /** ⭐ W38-T07 β€” `colorField`/`colorChoices` are GONE from this component, not merely unused: the
154
+ * toolbar's Color popover is the one colour control now, so a prop here would be a second door
155
+ * standing open for the next lane to mount a twin through. The map still reads its colour from
156
+ * `display.colorField`; that key is written by the toolbar's picker. */
157
  sizeField?: Field;
158
  sizeChoices: Field[];
159
+ /** ⭐ W38-T07 β€” `"colorField"` LEFT THIS UNION, and dropping it is the point rather than tidying
160
+ * up after the deleted picker: the mode bar must not be able to write a colour at all, so a
161
+ * later lane that mounts a colour control here fails to COMPILE instead of shipping the twin
162
+ * the owner just asked us to remove. The host's `setDisplayField` still accepts the wider set
163
+ * (it is the general display-field writer) and stays assignable to this narrower prop. */
164
+ onPickField: (k: "dateField" | "stackField" | "sizeField", key: string) => void;
165
  /** Item 3 (C-DISP) β€” cards are clamped unless the view stored the literal `false`. */
166
  clamped?: boolean;
167
  /** `false` = opt out of the clamp; `undefined` = back to the default (the key comes OFF). */
 
435
  )}
436
  </Popover>
437
  )}
438
+ {/* The map's SIZE encoding is OPTIONAL, so "none" is a real choice and not merely the
439
  absence of one: it is a row in the list AND the placeholder the button falls back to,
440
  which is the same state said twice on purpose β€” you can see what you would return to
441
+ before you pick it. The "Size: " prefix lived on every option because a native
442
  select had nowhere else to say which encoding it was; the aria-label and the row
443
+ carry that now, so the labels are just the field names.
444
+ ⚠ This read "the map's two encodings" while colour was the other; W38-T07 left one. */}
445
  {/* W37-T34 β€” the LOCATION picker, and it is deliberately the FIRST of the map's three.
446
  Colour and size are encodings you can live without; this one decides whether there are
447
  any pins at all, so it reads left to right in the order the questions actually arrive.
 
463
  ]}
464
  />
465
  )}
466
+ {/* ⭐⭐ W38-T07 (owner instruction 12) β€” THE MAP'S OWN COLOUR PICKER USED TO SIT HERE, and it
467
+ is deleted rather than moved. Owner: *"Under Map view, the 'Colour' dropdown duplicates
468
+ the existing Color dropdown. Use the existing one."* The toolbar's Color popover is "the
469
+ existing one"; it was two radios and is now a field picker, and it writes BOTH
470
+ `config.colorBy` (the grid's row wash) AND `display.colorField` (this map's pins), so the
471
+ map still gets its encoding from a control that no longer has a twin.
472
+ β›” THE SIZE PICKER BELOW IS NOT PART OF THIS. It has no duplicate anywhere in the strip
473
+ and the ticket's non-goal names it explicitly: "No size" stays, and it still sizes pins. */}
 
 
 
 
 
 
 
 
 
474
  {mode === "map" && sizeChoices.length > 0 && (
475
  <FieldSelectButton
476
  className="cg-mode-field"
web/src/filter-kit/FilterBuilderPanel.tsx CHANGED
@@ -928,6 +928,18 @@ export interface FilterBuilderPanelProps {
928
  * serves an admin editor should know about.
929
  */
930
  footerExtra?: ReactNode;
 
 
 
 
 
 
 
 
 
 
 
 
931
  }
932
 
933
  export function FilterBuilderPanel({
@@ -941,6 +953,7 @@ export function FilterBuilderPanel({
941
  statusValues,
942
  cohortLock,
943
  footerExtra,
 
944
  }: FilterBuilderPanelProps) {
945
  const nodes = filterTreeNodes(filters);
946
  const conj = filterTreeConj(filters);
@@ -977,7 +990,26 @@ export function FilterBuilderPanel({
977
 
978
  return (
979
  <div className="cg-pop-body cg-builder">
980
- <div className="cg-pop-title">Filter</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
  {/* C-LOCK (item 12) β€” the lock is NOT a condition row: it cannot be written here and
982
  cannot be removed here (the view menu owns it). So it has to be SAID here, above
983
  the conditions it bounds β€” otherwise the count and the conditions disagree and the
 
928
  * serves an admin editor should know about.
929
  */
930
  footerExtra?: ReactNode;
931
+ /**
932
+ * W38-T02 (owner instruction 8) β€” close the panel from its own header.
933
+ *
934
+ * β›” OPTIONAL BY DESIGN, and the reason is the two call sites, not tidiness. The grid toolbar
935
+ * mounts this inside `filter-kit/Popover`, which already manufactures a closer and hands it to
936
+ * its children render-prop; that closer is what arrives here. The admin permission editor
937
+ * (`settings/ModulePermsList`) mounts the very same panel in a BARE `div` with no overlay
938
+ * ancestor at all, and already owns a different way out β€” its own "Hide detail" disclosure.
939
+ * Requiring this prop would force that surface to invent a second, competing closer. So the
940
+ * control paints only where a closer genuinely exists, and its absence is a statement.
941
+ */
942
+ onClose?: () => void;
943
  }
944
 
945
  export function FilterBuilderPanel({
 
953
  statusValues,
954
  cohortLock,
955
  footerExtra,
956
+ onClose,
957
  }: FilterBuilderPanelProps) {
958
  const nodes = filterTreeNodes(filters);
959
  const conj = filterTreeConj(filters);
 
990
 
991
  return (
992
  <div className="cg-pop-body cg-builder">
993
+ {/* W38-T02 (owner instruction 8) β€” the panel's own header row: the label on the left, the
994
+ way out on the right. ⭐ This control is a PANEL control: it dismisses the popover and
995
+ leaves every condition standing exactly as it was. It is NOT one of the small remove
996
+ buttons the rows below carry, which paint the same glyph and mean the opposite thing β€”
997
+ the class and the label are the discriminator, never the character.
998
+ The row deliberately does not wear the column menu's head rule; see `.cg-pop-head` in
999
+ index.css for what borrowing that one would have done to this header. */}
1000
+ <div className="cg-pop-head">
1001
+ <div className="cg-pop-title">Filter</div>
1002
+ {onClose && (
1003
+ <button
1004
+ type="button"
1005
+ className="cg-icon-btn"
1006
+ onClick={onClose}
1007
+ aria-label="Close filter panel"
1008
+ >
1009
+ Γ—
1010
+ </button>
1011
+ )}
1012
+ </div>
1013
  {/* C-LOCK (item 12) β€” the lock is NOT a condition row: it cannot be written here and
1014
  cannot be removed here (the view menu owns it). So it has to be SAID here, above
1015
  the conditions it bounds β€” otherwise the count and the conditions disagree and the
web/src/index.css CHANGED
@@ -278,7 +278,13 @@
278
  rail toggle cannot re-open the rail. That is the same trade `.shell-root:has(.cg-agent-panel)`
279
  already makes and its comment already argues β€” *a preference may decline a COURTESY fold; it may
280
  not decline a layout constraint*. Navigation survives because the strip keeps its icons; what is
281
- lost is the labels, on a window where the alternative was a 312px canvas. */
 
 
 
 
 
 
282
  @media (max-width: 1023px) {
283
  :root { --lp-rail-w: var(--lp-rail-min); }
284
  .shell-side .lp-wordmark,
@@ -762,6 +768,32 @@ body {
762
  without it a narrow viewport would re-create the same wrap inside the ceiling. */
763
  min-width: 186px;
764
  max-width: 260px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765
  padding: 4px;
766
  border: 1px solid var(--lp-line);
767
  border-radius: var(--lp-r-lg);
@@ -1386,6 +1418,34 @@ body > .cg-overlay {
1386
  color: #8a8a96;
1387
  padding: 4px 8px 6px;
1388
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1389
  .cg-pop-list {
1390
  max-height: 320px;
1391
  overflow-y: auto;
@@ -3150,6 +3210,33 @@ body > .cg-overlay {
3150
  align-items: center;
3151
  min-width: 0;
3152
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3153
 
3154
  /* WHITE, like the host's `[theme.sidebar]` (owner 2026-07-23: "slick, visible
3155
  light-grey borders") β€” the wash belongs to page bodies, not the rail.
@@ -3171,20 +3258,40 @@ body > .cg-overlay {
3171
  flex-basis var(--lp-fold-t) var(--lp-fold-e);
3172
  }
3173
 
3174
- /* ⭐ WAVE 35 Β· T02 β€” WHAT THIS BAND IS NOW. It held the brand and the minimize control; both live
3175
- in `.shell-topbar` (C1). It survives, empty, ONLY as an alignment band: the content pane's first
3176
- band is one `--lp-rail-head-h` tall, so removing it would raise the rail's first nav row a header
3177
- above the grid beside it on every database page. Same height, same hairline, no contents β€”
3178
- `justify-content` and the fold `transition` went with the children they positioned.
3179
- ⚠ W36-T51 (owner item 7) β€” THE BAND IT ALIGNS WITH IS NOW `.cg-views-top` ALONE. This sentence
3180
- used to read "`DbHead` and `.cg-views-top` are each one ... tall", and `DbHead` is deleted from
3181
- the database surface. The alignment is unchanged because the views rail still opens on a head of
3182
- the same height; what WOULD orphan this element is that head going, not the title. */
3183
- .shell-side-head {
3184
- flex: 0 0 var(--lp-rail-head-h);
3185
- height: var(--lp-rail-head-h);
3186
- border-bottom: 1px solid var(--lp-line);
3187
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3188
  /* β›”β›” DELETED WITH T02, AND THIS NOTE IS THE POINT OF THE DELETION: `.shell-rail-toggle` (its base,
3189
  hover and focus rules) and `.shell-side.is-collapsed .shell-rail-toggle { display: none }`.
3190
  That last rule is what made wave-20 R9 necessary β€” the toggle vanished when collapsed, so the
@@ -3205,9 +3312,18 @@ body > .cg-overlay {
3205
  the folded LOOK, because every rule below simply takes a second selector. `:has()` is already in
3206
  this file's supported set (`.shell-nav-item:has(.shell-nav-badge)`).
3207
  β›” AND IT DELIBERATELY DOES NOT RIDE `NAV_MINIMIZE_EVENT`, WHICH IS THE OBVIOUS AND WRONG CHANNEL.
3208
- `Shell.tsx`'s listener declines that signal whenever `railHeldOpen` is up (W34-T12/R1 β€” "an expand
3209
- has to survive the next click"), so a user who had once clicked the logo open would get the 187px
3210
- grid anyway. A preference may decline a COURTESY fold; it may not decline a layout constraint.
 
 
 
 
 
 
 
 
 
3211
  ⚠ WHICH IS ALSO WHY `.shell-side.is-collapsed { cursor: pointer }` FURTHER DOWN IS THE ONE FOLD
3212
  RULE NOT EXTENDED HERE: a rail folded by the panel must not offer a click that would re-open it
3213
  into the space the panel is standing in. The user's own fold keeps its way back; this one has no
@@ -3296,6 +3412,38 @@ body > .cg-overlay {
3296
  gap: 0;
3297
  }
3298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3299
  /* ⭐ WAVE 35 Β· T02 β€” `.shell-brand-btn` IS DELETED, and it is the wave-20 R9 decision being
3300
  retired rather than a style being tidied. The brand was a `<button>` at all only so it could be
3301
  the way back from a collapsed rail; it is a plain `<div class="shell-topbar-brand">` in the top
 
278
  rail toggle cannot re-open the rail. That is the same trade `.shell-root:has(.cg-agent-panel)`
279
  already makes and its comment already argues β€” *a preference may decline a COURTESY fold; it may
280
  not decline a layout constraint*. Navigation survives because the strip keeps its icons; what is
281
+ lost is the labels, on a window where the alternative was a 312px canvas.
282
+ ⚠ W38-T05 β€” THE QUOTED MAXIM IS UNCHANGED; THE EVIDENCE UNDER IT IS NOT, so follow the pointer
283
+ before quoting it a third time. That comment used to ground the sentence in a decline that really
284
+ existed (`railHeldOpen`); W38-T04 deleted the ref, so it is grounded STRUCTURALLY now β€” the event
285
+ is a courtesy channel by construction, not a channel that happens to have a guard on it today.
286
+ This back-reference is why that comment had to be rewritten rather than merely corrected: a quote
287
+ outlives the sentence it was taken from, and the two must not drift. */
288
  @media (max-width: 1023px) {
289
  :root { --lp-rail-w: var(--lp-rail-min); }
290
  .shell-side .lp-wordmark,
 
768
  without it a narrow viewport would re-create the same wrap inside the ceiling. */
769
  min-width: 186px;
770
  max-width: 260px;
771
+ /* ⭐ W38-T01 / owner item 16 (2026-08-20), "ERROR 8.png" β€” THE PANEL HAS TO BE A SCROLL BOX,
772
+ not merely a capped one. `OverlaySurface.tsx::AnchoredOverlay::updatePosition` stamps an
773
+ inline `maxHeight` on EVERY anchored panel (unconditionally, from
774
+ `overlayPlacement.ts::computeOverlayPosition`, where it is the space actually left above
775
+ or below the anchor in the VISUAL viewport). `max-height` with `overflow: visible` clamps
776
+ the BOX and nothing else: the white background, the border and the shadow all stop at the
777
+ cap while the remaining `role="menuitem"` buttons keep laying out below it, transparent,
778
+ over the view rail. That is exactly what the owner photographed at 150% zoom β€” the shadow
779
+ cuts clean across the Star / "Alert me about new records" seam, with five rows rendering
780
+ underneath it and no background at all. Zoom is what makes it bite: it shrinks
781
+ `visualViewport.height`, so the cap lands mid-menu instead of past its last item.
782
+ ⭐ ONE RULE, FOUR PANELS: `ViewSidebar.tsx` stamps this class on the create flyout (:1049),
783
+ the folder menu (:1720), the saved-view menu (:1943) and the export pane (:2286).
784
+ ⚠ `overscroll-behavior` is NOT precedent here β€” it appears nowhere else in this file. It is
785
+ the other half of the repair and not decoration: without it, a wheel that reaches the end
786
+ of a now-scrollable menu chains straight into the view rail underneath and scrolls the
787
+ list the menu is anchored to, out from under its own anchor.
788
+ Siblings that already carry the `overflow-y` half: `.cg-column-menu` (which belts it with a
789
+ CSS ceiling too) and `.cg-pop` (which, like this one, leans on the inline `maxHeight`).
790
+ No CSS `max-height` is added here on purpose β€” all four of these are AnchoredOverlays, so
791
+ the inline cap is always present, and a `100vh` ceiling would be a second, disagreeing
792
+ number. `overflow-x` is left implicit, matching both siblings: `placed.maxWidth` is the
793
+ whole viewport minus margins rather than a per-panel clamp, and every row is closed
794
+ vocabulary under `nowrap` with `.cg-mi-text`'s ellipsis as the backstop. */
795
+ overflow-y: auto;
796
+ overscroll-behavior: contain;
797
  padding: 4px;
798
  border: 1px solid var(--lp-line);
799
  border-radius: var(--lp-r-lg);
 
1418
  color: #8a8a96;
1419
  padding: 4px 8px 6px;
1420
  }
1421
+ /* W38-T02 (owner instruction 8) β€” the header ROW a popover panel wears when it carries a close
1422
+ control beside its label. It lays the two children out and does NOTHING else.
1423
+
1424
+ β›” WHY THIS EXISTS INSTEAD OF REUSING `.cg-column-head`, which is the obvious candidate and is
1425
+ where the button markup was copied from: that rule carries `min-height: 42px`, a
1426
+ `border-bottom`, and `align-items: flex-start`. Borrowing it would have near-doubled the Filter
1427
+ header's height and ruled a line under it β€” an unrequested restyle of the very header the owner
1428
+ has already had an opinion about (see the item-11 note on `.cg-pop-title` above), smuggled in
1429
+ under a ticket that asked only for a close button. No gate in this repo measures painted layout,
1430
+ so that change would have shipped silently.
1431
+
1432
+ `.cg-pop-title` itself is untouched on purpose: it is shared with the view-note popover and the
1433
+ view menu, both of which use it WITHOUT this row. The title keeps its own padding; the row adds
1434
+ only the right-hand breathing space the button needs, so a panel with no close control paints
1435
+ byte-identically to before. */
1436
+ .cg-pop-head {
1437
+ display: flex;
1438
+ align-items: center;
1439
+ justify-content: space-between;
1440
+ gap: 6px;
1441
+ padding-right: 4px;
1442
+ }
1443
+ .cg-pop-head .cg-pop-title {
1444
+ min-width: 0;
1445
+ overflow: hidden;
1446
+ text-overflow: ellipsis;
1447
+ white-space: nowrap;
1448
+ }
1449
  .cg-pop-list {
1450
  max-height: 320px;
1451
  overflow-y: auto;
 
3210
  align-items: center;
3211
  min-width: 0;
3212
  }
3213
+ /* ⭐⭐ WAVE 38 Β· T06 (owner instruction 1) β€” THE LOGO SITS LOWER WHILE THE RAIL IS MINIMIZED, and
3214
+ it does so WITHOUT MOVING A SINGLE NODE. Owner: *"When the Navigation is minimized: … move the
3215
+ logo downward."*
3216
+ β›” WHY `:has()` AND NOT A JSX MOVE. `.shell-topbar` is a PRECEDING SIBLING of `.shell-frame`,
3217
+ and `.shell-side` lives inside `.shell-frame` β€” so the brand's box is structurally invariant to
3218
+ the fold and no descendant selector can reach it from the rail. CSS has no previous-sibling
3219
+ combinator either. Physically relocating `.shell-topbar-brand` into `.shell-side` would have
3220
+ worked, at the cost of emptying the one persistent strip that W35-T01's ruling R1 exists to
3221
+ protect (and of reddening `verify_ui.py`'s `W35-T01 (C1)` leg, which asserts the strip carries
3222
+ BOTH its toggle and its brand). `:has()` is already this stylesheet's vocabulary for exactly
3223
+ this shape of reach β€” see `.shell-root:has(.cg-agent-panel) .shell-side` below β€” so the strip
3224
+ keeps its contract and the brand still answers to the fold.
3225
+ β›” `align-self`, NOT `transform: translateY()`. This band has no `overflow: hidden`, so a
3226
+ transform large enough to be legible would paint the mark across the border and onto the grid
3227
+ below. Bottom-alignment cannot overflow the band whatever the lockup's height turns out to be:
3228
+ the mark is a 26px `<img>` in a 44px band today (9px of slack, so ~7px of travel), and if the
3229
+ lockup ever grows the drop shrinks to nothing rather than spilling. The 2px keeps it off the
3230
+ strip's own border.
3231
+ ⚠ KEYED TO `.is-collapsed` ONLY, and the asymmetry is deliberate. Every fold rule further down
3232
+ is doubled across `.is-collapsed` and `:has(.cg-agent-panel)` because both genuinely narrow the
3233
+ rail. This one is not a fold rule: the owner asked for a logo that moves when THE USER minimises
3234
+ the navigation, and it is gated on the same `navCollapsed` state its sibling launcher in
3235
+ `Shell.tsx` is. A chat panel opening should not walk the brand down the strip. */
3236
+ .shell-root:has(.shell-side.is-collapsed) .shell-topbar-brand {
3237
+ align-self: flex-end;
3238
+ padding-bottom: 2px;
3239
+ }
3240
 
3241
  /* WHITE, like the host's `[theme.sidebar]` (owner 2026-07-23: "slick, visible
3242
  light-grey borders") β€” the wash belongs to page bodies, not the rail.
 
3258
  flex-basis var(--lp-fold-t) var(--lp-fold-e);
3259
  }
3260
 
3261
+ /* β›”β›” WAVE 38 Β· T05 (owner instruction 2) β€” `.shell-side-head` IS DELETED, RULE AND ELEMENT BOTH.
3262
+ Owner: *"Move the 'Home' button into the empty space above it, and every other module button
3263
+ upward, so there is no awkward gap."* The gap WAS this rule β€” an empty band reserving
3264
+ `--lp-rail-head-h` at the top of the rail, so `<nav>` and Home with it opened a full header below
3265
+ the top strip. The rule is gone, so `<nav>` is now `.shell-side`'s first child.
3266
+
3267
+ β›” WHAT THE OLD COMMENT ARGUED, AND WHY IT NO LONGER WINS β€” this was a decision, not a tidy-up.
3268
+ It said the band aligned the rail's first nav row with the content pane's own first band, so that
3269
+ `.shell-side-head`, `.cg-views-top` and `.cg-toolbar` were three segments of ONE horizontal line
3270
+ (item 5 of wave 35). That was true. Two things settle it against the band anyway: the owner asked
3271
+ for exactly the row this element prevented, and β€” measured, not assumed β€” `.cg-views-top` mounts
3272
+ ONLY in `customer-grid/ViewSidebar.tsx` and `.auto-rail-top` ONLY in
3273
+ `automation/AutomationSurface.tsx`. On Home, Starred, Inbox and Connectors there is no second
3274
+ band anywhere on the page, so this one was an empty box closed by a hairline that stopped at the
3275
+ rail's own right edge, aligned with nothing. It was an artifact on the landing page the owner
3276
+ opens on, and a stub on most of the rest.
3277
+
3278
+ ⚠ THE COST, NAMED RATHER THAN ARGUED AWAY: on a grid surface and on Agents, that SECOND
3279
+ horizontal line no longer crosses the left rail β€” it now begins at the views rail. The FIRST line
3280
+ is untouched, which is why the loss is bounded: `.shell-topbar` is a full-width sibling of
3281
+ `.shell-frame` and its own `border-bottom` still runs edge to edge above all three rails.
3282
+
3283
+ β›” COLLAPSING IT TO THE HAIRLINE (`height: 0`, border kept) WAS THE OTHER CANDIDATE, AND IT IS
3284
+ STRICTLY WORSE RATHER THAN A MILDER VERSION OF THIS β€” work the geometry before reaching for it.
3285
+ A zero-height band paints its `border-bottom` at the rail's TOP, 1px under `.shell-topbar`'s own
3286
+ border: two adjacent hairlines, a doubled line across the rail's width while the rest of the app
3287
+ draws 1px. And it does NOT buy back the line one head lower, because there is no longer a band
3288
+ reaching down to that y. So it breaks the continuity in exactly the place removal does and adds a
3289
+ visible defect on top. It is removal plus a bug, not a compromise.
3290
+
3291
+ ⚠ `.cg-views-top` IS DELIBERATELY UNTOUCHED, above at its own rule. It still opens flush under
3292
+ the top strip on `--lp-rail-head-h`, which is the no-change condition this instruction carried.
3293
+ Realigning the two rails was never on the table: they share the identical token, so any edit that
3294
+ raised Home to the views rail's CONTENT would have had to move `.cg-views-top` too. */
3295
  /* β›”β›” DELETED WITH T02, AND THIS NOTE IS THE POINT OF THE DELETION: `.shell-rail-toggle` (its base,
3296
  hover and focus rules) and `.shell-side.is-collapsed .shell-rail-toggle { display: none }`.
3297
  That last rule is what made wave-20 R9 necessary β€” the toggle vanished when collapsed, so the
 
3312
  the folded LOOK, because every rule below simply takes a second selector. `:has()` is already in
3313
  this file's supported set (`.shell-nav-item:has(.shell-nav-badge)`).
3314
  β›” AND IT DELIBERATELY DOES NOT RIDE `NAV_MINIMIZE_EVENT`, WHICH IS THE OBVIOUS AND WRONG CHANNEL.
3315
+ A preference may decline a COURTESY fold; it may not decline a layout constraint.
3316
+ ⚠ W38-T05 β€” THE EVIDENCE FOR THAT SENTENCE CHANGED; THE SENTENCE DID NOT. It used to continue
3317
+ that `Shell.tsx`'s listener declines the signal whenever `railHeldOpen` is up (W34-T12/R1, "an
3318
+ expand has to survive the next click"), so a user who had once clicked the logo open would get the
3319
+ 187px grid anyway. W38-T04 revoked R1 and deleted `railHeldOpen` outright: that listener now folds
3320
+ unconditionally and there is nothing left to decline anything, so the old clause was simply false.
3321
+ β›” THE RULE BELOW IS UNAFFECTED AND STAYS β€” read why before "simplifying" it onto the event. The
3322
+ argument was never that a decline exists today; it is that `NAV_MINIMIZE_EVENT` is a COURTESY
3323
+ channel by construction. Every wave that has touched that handler has reached for a guard in front
3324
+ of it, and `verify_ui.py`'s `W38-T04 unconditional` leg exists precisely because the next one will
3325
+ too. A layout constraint routed through it would be one edit away from being declinable again.
3326
+ `:has()` cannot be declined by anything, which is the whole reason it is the channel here.
3327
  ⚠ WHICH IS ALSO WHY `.shell-side.is-collapsed { cursor: pointer }` FURTHER DOWN IS THE ONE FOLD
3328
  RULE NOT EXTENDED HERE: a rail folded by the panel must not offer a click that would re-open it
3329
  into the space the panel is standing in. The user's own fold keeps its way back; this one has no
 
3412
  gap: 0;
3413
  }
3414
 
3415
+ /* ⭐⭐ WAVE 38 Β· T06 (owner instruction 1) β€” THE MINIMISED RAIL'S CHAT LAUNCHER. Owner: *"When the
3416
+ Navigation is minimized: the icon that opens the AI chatbot overlay with the grid…"*
3417
+ β›” IT IS DELIBERATELY NOT A `.shell-nav-item`, and that is a gate fact rather than a taste one.
3418
+ TWO gates derive their subject from the rail instead of listing it: `verify_ui.py`'s
3419
+ `nav_row_classes` treats every class worn in the same `className` literal as `shell-nav-item`
3420
+ as a ROW and forbids it its own type declaration, and `verify_wiring.py`'s `_ROW_LITERAL` scan
3421
+ reads the same literals to work out each row's loading state. This control is neither a
3422
+ destination nor a row: it exists in one state only, it never carries a label, and enrolling it
3423
+ in a rail-row roster would make both gates answer questions about it that have no meaning.
3424
+ ⚠ NO MARGIN AND NO WIDTH, and that is measured rather than styled. This control is a CHILD of
3425
+ `.shell-nav`, so the collapsed rail's 6px gutter is already `.shell-nav`'s own `padding: 8px 6px
3426
+ 0`; `padding: 7px 10px` then lands the 16px glyph at x = 6 + 10 = 16 = (48-16)/2, the strip's
3427
+ centre and the exact x every `.shell-nav-item` icon takes (the geometry note on the fold rules
3428
+ above states that sum). The first draft copied `.shell-nav-templates`' `margin: 0 6px` and would
3429
+ have sat 6px right of every other icon: that row is a SIBLING of `.shell-nav` and has to supply
3430
+ its own gutter, which is precisely the difference. Height, radius and hover are the rail row's,
3431
+ so the column reads as one column whatever tag each control happens to be. */
3432
+ .shell-rail-chat {
3433
+ display: flex;
3434
+ align-items: center;
3435
+ margin: 0 0 2px;
3436
+ padding: 7px 10px;
3437
+ min-height: 34px;
3438
+ border: 0;
3439
+ border-radius: var(--lp-r-sm);
3440
+ background: transparent;
3441
+ color: var(--lp-ink);
3442
+ cursor: pointer;
3443
+ }
3444
+ .shell-rail-chat:hover { background: var(--lp-surface-2); }
3445
+ .shell-rail-chat:focus-visible { outline: 2px solid var(--lp-blue-deep); outline-offset: -2px; }
3446
+
3447
  /* ⭐ WAVE 35 Β· T02 β€” `.shell-brand-btn` IS DELETED, and it is the wave-20 R9 decision being
3448
  retired rather than a style being tidied. The brand was a `<button>` at all only so it could be
3449
  the way back from a collapsed rail; it is a plain `<div class="shell-topbar-brand">` in the top
web/src/settings/ModulePermsList.tsx CHANGED
@@ -42,6 +42,18 @@ export interface ModulePermsListProps {
42
  onFilter: (key: string, next: FilterTree | null) => void;
43
  onToggleHidden: (key: string, fieldKey: string) => void;
44
  onSetHidden: (key: string, keys: string[]) => void;
 
 
 
 
 
 
 
 
 
 
 
 
45
  /** People this tenant can name in a `user` condition. Absent β‡’ the panel says so. */
46
  userOptions?: string[];
47
  /** Anything the CALLER wants in a row's head β€” the account editor's "Copy to…"
@@ -58,6 +70,7 @@ export function ModulePermsList({
58
  onFilter,
59
  onToggleHidden,
60
  onSetHidden,
 
61
  userOptions,
62
  headExtra,
63
  emptyNote,
@@ -129,6 +142,29 @@ export function ModulePermsList({
129
  </span>
130
  </div>
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  {/* R9's fail-closed rendering: no readable schema means the access
133
  toggle and nothing else. The record it saves says the same thing β€”
134
  no filter, no hidden fields β€” so the editor and the payload cannot
 
42
  onFilter: (key: string, next: FilterTree | null) => void;
43
  onToggleHidden: (key: string, fieldKey: string) => void;
44
  onSetHidden: (key: string, keys: string[]) => void;
45
+ /**
46
+ * ⭐⭐ W38-T19 β€” the per-database METRICS capability. Absent means the caller does not govern
47
+ * it and no box is drawn, which is the account editor's and the agent editor's genuine
48
+ * difference rather than a stub: an agent's reads run through `agent_rows` / `agent_may_read`
49
+ * and never open a grid door, so a Metrics toggle in that room would be a stored rule nothing
50
+ * applies, the exact class W36's contract C2 deleted.
51
+ *
52
+ * β›” OPTIONAL, AND FORCED RATHER THAN CHOSEN. `manage-agent/ManageAgentPane.tsx` mounts this
53
+ * same component and is outside W38-T19's fence; a required prop stops it compiling, which
54
+ * reds `web_ui` on a build break in a file that ticket may not repair.
55
+ */
56
+ onMetrics?: (key: string, on: boolean) => void;
57
  /** People this tenant can name in a `user` condition. Absent β‡’ the panel says so. */
58
  userOptions?: string[];
59
  /** Anything the CALLER wants in a row's head β€” the account editor's "Copy to…"
 
70
  onFilter,
71
  onToggleHidden,
72
  onSetHidden,
73
+ onMetrics,
74
  userOptions,
75
  headExtra,
76
  emptyNote,
 
142
  </span>
143
  </div>
144
 
145
+ {/* ⭐⭐ W38-T19 β€” THE METRICS CAPABILITY, one box per database.
146
+ β›” GATED ON `on && !schemaless` FOR TWO DIFFERENT REASONS, not one.
147
+ `on`: a capability inside a database nobody may open is a control
148
+ that decides nothing, and the row already says "No access".
149
+ `!schemaless`: an app SURFACE has no grid and therefore no measure
150
+ door, so a box there would store a rule nothing reads β€” R9's rule
151
+ that this editor never writes a restriction it could not honestly
152
+ show, applied to a capability instead of to a filter.
153
+ ⚠ It sits OUTSIDE `.set-perm-headend` deliberately. That rule is
154
+ `space-between` and lives in `index.css`, which this ticket's fence
155
+ does not contain; a new flex child there would need CSS that cannot
156
+ be written, so the box takes its own line under the head. */}
157
+ {on && !schemaless && onMetrics ? (
158
+ <label className="set-check">
159
+ <input
160
+ type="checkbox"
161
+ checked={entry?.metrics !== false}
162
+ onChange={(e) => onMetrics(m.key, e.target.checked)}
163
+ />
164
+ <span>Metric fields (lookback measures over this database)</span>
165
+ </label>
166
+ ) : null}
167
+
168
  {/* R9's fail-closed rendering: no readable schema means the access
169
  toggle and nothing else. The record it saves says the same thing β€”
170
  no filter, no hidden fields β€” so the editor and the payload cannot
web/src/settings/permsModel.ts CHANGED
@@ -167,6 +167,28 @@ export interface PermsEntry {
167
  filter: FilterTree | null;
168
  /** Field keys this account never receives. Server-stripped from every wire. */
169
  hiddenFields: string[];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  }
171
 
172
  export type PermsRecord = Record<string, PermsEntry>;
@@ -355,6 +377,14 @@ export function parseEntry(raw: unknown): PermsEntry {
355
  access: r.access === true,
356
  filter: parseFilter(r.filter),
357
  hiddenFields: normalizeHidden(asStringArray(r.hiddenFields)),
 
 
 
 
 
 
 
 
358
  };
359
  }
360
 
@@ -468,6 +498,12 @@ export function setFilter(rec: PermsRecord, key: string, filter: FilterTree | nu
468
  return withEntry(rec, key, { filter: empty ? null : filter });
469
  }
470
 
 
 
 
 
 
 
471
  export function setHidden(rec: PermsRecord, key: string, hidden: readonly string[]): PermsRecord {
472
  return withEntry(rec, key, { hiddenFields: normalizeHidden(hidden) });
473
  }
@@ -526,7 +562,23 @@ export function toPutBody(payload: PermsPayload, rec: PermsRecord): { perms: Per
526
  access: e.access,
527
  filter: e.filter,
528
  hiddenFields: normalizeHidden(e.hiddenFields.filter((k) => k !== locked)),
 
 
 
 
 
 
529
  }
 
 
 
 
 
 
 
 
 
 
530
  : { access: e.access, filter: null, hiddenFields: [] };
531
  }
532
  return { perms };
@@ -643,10 +695,16 @@ export function moduleSummary(entry: PermsEntry | undefined, schemaless = false)
643
  if (schemaless) return "Full access";
644
  const conds = countLeaves(entry.filter);
645
  const hidden = entry.hiddenFields.length;
646
- if (!conds && !hidden) return "Full access";
 
 
 
 
 
647
  const parts: string[] = [];
648
  if (conds) parts.push(`${conds} condition${conds === 1 ? "" : "s"}`);
649
  if (hidden) parts.push(`${hidden} field${hidden === 1 ? "" : "s"} hidden`);
 
650
  return parts.join(", ");
651
  }
652
 
 
167
  filter: FilterTree | null;
168
  /** Field keys this account never receives. Server-stripped from every wire. */
169
  hiddenFields: string[];
170
+ /**
171
+ * ⭐⭐ W38-T19 β€” MAY THIS ACCOUNT BUILD AND RECEIVE **METRIC** COLUMNS on this database?
172
+ * `false` empties the measure offer at every grid door, which takes the Metric kind off the
173
+ * field picker AND fail-closes a create with `measure_not_offered`.
174
+ *
175
+ * β›” OPTIONAL, AND THE CHOICE WAS **FORCED** RATHER THAN PREFERRED. Required is the loud
176
+ * option and it is the one this file would otherwise take: `tsc` would then name every
177
+ * `PermsEntry`-shaped literal that forgot the key. But `web/verify_login.py` compiles
178
+ * `src/shell/_test/shell.test.ts` in the same `npx tsc` call as this file's own suite, and
179
+ * that file passes a bare `{access, filter, hiddenFields}` literal straight into
180
+ * `moduleSummary(entry: PermsEntry | undefined)`. A required key reds `web_login` at COMPILE
181
+ * time ("tsc emitted nothing") in a file W38-T19's fence does not contain, with no legal
182
+ * repair. Optional is what the fence permits.
183
+ *
184
+ * ⚠ SO THE DEFAULT IS CENTRALISED INSTEAD, IN EXACTLY TWO PLACES, and both spell it the same
185
+ * way: `parseEntry` reads the wire (`metrics: r.metrics !== false`) and `toPutBody` writes it
186
+ * (`metrics: e.metrics !== false`). Absence GRANTS at both ends, matching
187
+ * `perm_scope.may_metrics` and `_clean_perms` on the server, so a record written before this
188
+ * key existed reads as unrestricted rather than as a silent mass revocation. Every literal
189
+ * that omits the key therefore means the one thing it could safely mean.
190
+ */
191
+ metrics?: boolean;
192
  }
193
 
194
  export type PermsRecord = Record<string, PermsEntry>;
 
377
  access: r.access === true,
378
  filter: parseFilter(r.filter),
379
  hiddenFields: normalizeHidden(asStringArray(r.hiddenFields)),
380
+ // ⭐⭐ W38-T19 β€” `!== false`, NOT `=== true`, AND THE TWO ARE OPPOSITE HERE. `access` reads
381
+ // absence as DENY because a migrated record declares every governed database, so a missing
382
+ // `access` is a decision. No `metrics` key was STORABLE before this ticket, so EVERY record
383
+ // in every tenant is missing one: reading absence as deny would paint the box unticked for
384
+ // every account on the day this shipped, and the next save of any unrelated change would
385
+ // write that revocation for real. The server's `may_metrics` and `_clean_perms` default the
386
+ // same direction, so the editor shows what the wall will actually do.
387
+ metrics: r.metrics !== false,
388
  };
389
  }
390
 
 
498
  return withEntry(rec, key, { filter: empty ? null : filter });
499
  }
500
 
501
+ /** ⭐⭐ W38-T19 β€” the Metrics capability toggle. A plain `withEntry` patch like every other
502
+ * setter, so the draft stays immutable and `isDirty` sees the change through `toPutBody`. */
503
+ export function setMetrics(rec: PermsRecord, key: string, metrics: boolean): PermsRecord {
504
+ return withEntry(rec, key, { metrics });
505
+ }
506
+
507
  export function setHidden(rec: PermsRecord, key: string, hidden: readonly string[]): PermsRecord {
508
  return withEntry(rec, key, { hiddenFields: normalizeHidden(hidden) });
509
  }
 
562
  access: e.access,
563
  filter: e.filter,
564
  hiddenFields: normalizeHidden(e.hiddenFields.filter((k) => k !== locked)),
565
+ // ⭐⭐ W38-T19 β€” ALWAYS EMITTED, never left to the server's default. A PUT is a
566
+ // whole-record replace and this editor is the last thing the record passes through,
567
+ // so a key it withholds is a key whose meaning is decided somewhere else. Emitting it
568
+ // is also what keeps `isDirty` honest: that function compares the SENT SHAPE, and a
569
+ // capability the payload never carries could be toggled all day without lighting Save.
570
+ metrics: e.metrics !== false,
571
  }
572
+ // ⭐⭐ W38-T19 β€” THE SCHEMA-LESS ARM EMITS **NO** `metrics` KEY, and the omission is the
573
+ // rule rather than an oversight. R9: access is the WHOLE rule for a module with no
574
+ // readable schema. An Assistant or Agents row has no grid and therefore no measure door,
575
+ // this editor renders no Metrics box for it, and a whole-record COPY is the one path that
576
+ // could otherwise carry a revocation onto it β€” a restriction the admin was never shown.
577
+ // ⚠ ABSENT IS NOT UNDECIDED HERE, IT IS GRANTED, and it resolves in exactly one place:
578
+ // `_clean_perms` stores `bool(raw.get("metrics", True))`, so an omitted key persists as
579
+ // `true`. Sending `true` explicitly would be the same value by a longer road, and it
580
+ // would break `shell/_test/shell.test.ts`'s "a schema-less module saves access only" β€”
581
+ // a leg whose CLAIM is right and which this file must not make false.
582
  : { access: e.access, filter: null, hiddenFields: [] };
583
  }
584
  return { perms };
 
695
  if (schemaless) return "Full access";
696
  const conds = countLeaves(entry.filter);
697
  const hidden = entry.hiddenFields.length;
698
+ // ⭐⭐ W38-T19 β€” A REVOKED CAPABILITY IS A RESTRICTION AND THE COLLAPSED ROW HAS TO SAY SO.
699
+ // The whole point of this sentence is that an admin can read a database's rule without
700
+ // opening it; a row reading "Full access" over an account that cannot build a Metric column
701
+ // is the summary lying, which is the failure mode this function's own header is about.
702
+ const noMetrics = entry.metrics === false;
703
+ if (!conds && !hidden && !noMetrics) return "Full access";
704
  const parts: string[] = [];
705
  if (conds) parts.push(`${conds} condition${conds === 1 ? "" : "s"}`);
706
  if (hidden) parts.push(`${hidden} field${hidden === 1 ? "" : "s"} hidden`);
707
+ if (noMetrics) parts.push("Metrics off");
708
  return parts.join(", ");
709
  }
710
 
web/src/shell/Shell.tsx CHANGED
@@ -235,6 +235,29 @@ const NAV_DRAG_TYPE = "application/x-loopable-nav";
235
  /** A stable identity for "no nav yet" β€” see its use below. */
236
  const NO_ENTRIES: NavEntry[] = [];
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  /**
239
  * WAVE 23 C10 β€” the Database flyout's anchor, CLAMPED to the viewport.
240
  *
@@ -1102,37 +1125,33 @@ function ShellFrame() {
1102
  // storage can be blocked; the toggle still works for the session
1103
  }
1104
  }, [navCollapsed]);
1105
- // ── ⭐⭐ W34-T12 (ruling R1): AN EXPAND HAS TO SURVIVE THE NEXT CLICK ──────────────────────
1106
- //
1107
- // R1: *"Fix the navigation error where minimizing gets stuck and cannot be reopened."*
1108
  //
1109
- // β›” THE CAUSE IS NOT A MISSING CONTROL, AND THAT WAS WORTH PROVING BEFORE CHANGING ANYTHING
1110
- // (`proto/nav-stuck/README.md` records the trace). Every one of the six `setNavCollapsed` call
1111
- // sites was read; the brand button below is rendered unconditionally, is enabled exactly when
1112
- // the rail is collapsed, and no `.is-collapsed` rule hides it. There is no state with no way
1113
- // back. What there is: `NAV_MINIMIZE_EVENT` fires from `CustomerGrid` on EVERY cell click and
1114
- // EVERY view switch, unconditionally and undebounced (owner item 10, 2026-07-31 β€” "I am working
1115
- // now"), and the listener always SETS. So the user clicks the logo, the rail opens, they touch
1116
- // one cell, and it shuts again. The control works; its EFFECT never survives. From the seat that
1117
- // is "cannot be reopened", and no gate can see it because the code does exactly what it was
1118
- // asked to do in July.
1119
  //
1120
- // ⚠ SO KEEP THE FOLD AND MAKE THE EXPAND STICK, rather than revoking owner item 10. This ref is
1121
- // raised by every DELIBERATE expand and lowered by the explicit Minimize button; the automatic
1122
- // fold declines while it is up.
 
 
 
1123
  //
1124
- // β›” NOT PERSISTED, DELIBERATELY. `navCollapsed` rides localStorage; this does not. Persisting it
1125
- // would retire owner item 10 permanently the first time anybody clicked the logo β€” a ruling
1126
- // revoked by a preference. A reload starts it down, so the first fold still happens per visit.
1127
- const railHeldOpen = useRef(false);
1128
- /** Every deliberate expand goes through here, so a fifth expand door cannot forget the flag. */
1129
  const expandRail = useCallback(() => {
1130
- railHeldOpen.current = true;
1131
  setNavCollapsed(false);
1132
  }, []);
1133
- /** Asking for the fold is also asking for the AUTOMATIC fold back. */
1134
  const collapseRail = useCallback(() => {
1135
- railHeldOpen.current = false;
1136
  setNavCollapsed(true);
1137
  }, []);
1138
  // Owner item 3 (2026-07-31) β€” the collapsed strip names its icons on hover. A real DOM
@@ -1253,13 +1272,14 @@ function ShellFrame() {
1253
  const onDataError = (e: Event) =>
1254
  setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
1255
  const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
1256
- // ⭐ W34-T12 (R1) β€” THE AUTOMATIC FOLD DECLINES ONCE THE USER HAS PUT THE RAIL BACK.
1257
- // Owner item 10's fold is intact for the first grid click of a visit; after a deliberate
1258
- // expand it stops firing, so the logo's effect survives the next cell click instead of being
1259
- // undone by it. Reading a ref here is what keeps this listener's `[]` deps honest β€” a state
1260
- // value would have to be in the dependency array, re-registering the listener on every fold.
 
 
1261
  const onNavMinimize = () => {
1262
- if (railHeldOpen.current) return;
1263
  setNavCollapsed(true);
1264
  };
1265
  // C-SHARE: the rail asks, the frame opens. A malformed detail opens NOTHING β€”
@@ -2130,22 +2150,24 @@ function ShellFrame() {
2130
  // brand button below ("Expand navigation"), which is what a keyboard reaches.
2131
  onClick={navCollapsed ? expandOnBlank : undefined}
2132
  >
2133
- {/* ⭐ WAVE 35 Β· T02 β€” THE HEAD IS AN ALIGNMENT BAND NOW AND NOTHING ELSE. The brand and
2134
- the toggle both moved into `.shell-topbar` above (C1), and contract C1 keeps this
2135
- element on its `--lp-rail-head-h` token deliberately: the content pane's own first
2136
- band is one `--lp-rail-head-h` tall, so deleting this would start the rail's first nav
2137
- row a header higher than the grid beside it and every database page would read as
2138
- misaligned. It is a spacer with the shared hairline, not a leftover.
2139
- ⚠ W36-T51 β€” THAT BAND USED TO BE NAMED AS `DbHead` OR `.cg-views-top`, AND `DbHead` IS
2140
- NOW DELETED FROM THIS SURFACE (owner item 7). The alignment survives because
2141
- `.cg-views-top` is still there and still one band tall; it is the VIEWS rail's head
2142
- that this spacer now lines up with. β›” So the thing that would orphan this element is
2143
- the views rail losing its head, NOT the database losing its title β€” a different edit
2144
- from the one a reader of the old sentence would have gone looking for.
2145
- ⚠ It is `aria-hidden` because it now has no content and no purpose a screen reader can
2146
- use; announcing an empty banner is noise. */}
2147
- <div className="shell-side-head" aria-hidden="true" />
2148
-
 
 
2149
  <nav className={"shell-nav" + (railLoading ? " is-rail-loading" : "")}>
2150
  {/* ⭐⭐ W33-T75 β€” THE ONE IN-FLIGHT STATE. `is-rail-loading` hides every sibling below
2151
  (`navExtras.css`), so a row cannot paint ahead of its neighbours. Rendering the rows
@@ -2153,6 +2175,55 @@ function ShellFrame() {
2153
  the tooltips and the account menu all hang off this subtree, and unmounting them for
2154
  the length of a fetch would tear down state that has nothing to do with the nav. */}
2155
  {railLoading ? <RailSkeleton /> : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2156
  {/* ⭐ WAVE 23 item 9 (R7, contract C10) β€” HOME, the new landing, at the top of the rail.
2157
  An `<a>` to a CHROME route: it needs no grant because it renders nothing the server
2158
  did not already send (nav.ts' `CHROME_ROUTES` note carries the full argument, and
@@ -2975,10 +3046,14 @@ function ShellFrame() {
2975
  comments up. `.shell-db-frame` is the rail-and-content GEOMETRY; dropping it collapses
2976
  the box. Only the 44px title band is reclaimed, and the grid takes it: `.shell-grid-
2977
  host` is `height: 100%` inside a column that now has one fewer fixed row.
2978
- ⚠ `.shell-side-head` (index.css) IS NOT ORPHANED BY THIS, checked rather than assumed:
2979
- its comment says it exists to align the rail's first nav row with a 44px band, and the
2980
- band it now aligns with is `.cg-views-top`, which is still there and still
2981
- `--lp-rail-head-h` tall. It would only become dead if the VIEWS rail lost its head.
 
 
 
 
2982
  β›” `DbHead` ITSELF SURVIVES UNTIL W36-T67 LANDS. `query/QueryPage.tsx:138` still mounts
2983
  it and that file is session D's fence β€” a component cannot be deleted while another
2984
  fence imports it, which is why T51 is blocked-by T67 rather than the reverse. */
 
235
  /** A stable identity for "no nav yet" β€” see its use below. */
236
  const NO_ENTRIES: NavEntry[] = [];
237
 
238
+ /**
239
+ * ⭐⭐ WAVE 38 Β· T06 (owner instruction 1) β€” THE SHELLβ†’GRID CHANNEL FOR "open the chat".
240
+ *
241
+ * The frame owns the minimised rail; `CustomerGrid` owns `chatOpen` and mounts `<GridChat>`. So
242
+ * the launcher does what every other cross-fence click-through in this frame does: it raises a
243
+ * window event and the surface answers if it can, exactly as `VIEW_OPEN_EVENT` and
244
+ * `AUTOMATION_OPEN_EVENT` already do. Nothing here learns the grid's state, which is the point:
245
+ * the shell cannot know whether a chat is already open, so this signal OPENS and never toggles.
246
+ *
247
+ * β›”β›” THE STRING IS TYPED TWICE, AND THAT IS A CONSTRAINT, NOT A PREFERENCE. Its natural home is
248
+ * `apiContract.ts` beside the other four signals β€” the host-neutral leaf both trees already
249
+ * import β€” and that file is outside this ticket's `files:` list. The two legal alternatives are
250
+ * both worse: `customer-grid/**` may not import `shell/**` (it is host-neutral by its own rule,
251
+ * which is what lets the grid run inside another host), and a STATIC import of a constant out of
252
+ * `CustomerGrid.tsx` would drag the whole spreadsheet engine into the shell's entry chunk, undoing
253
+ * the `lazy()` at :60 that keeps it out. So the canonical declaration is
254
+ * `CustomerGrid.tsx::GRID_CHAT_OPEN_EVENT`, this is its twin, and the divergence they invite is
255
+ * closed by a control rather than by a comment: `verify_wiring.py`'s W38-T06 row matches the
256
+ * LITERAL `aios:grid-chat-open` on both sides, so a typo in either spelling reds `web_wiring`.
257
+ * ⚠ Move both to `apiContract.ts` the next time a ticket owns that file. See PENDING.
258
+ */
259
+ const GRID_CHAT_OPEN_EVENT = "aios:grid-chat-open";
260
+
261
  /**
262
  * WAVE 23 C10 β€” the Database flyout's anchor, CLAMPED to the viewport.
263
  *
 
1125
  // storage can be blocked; the toggle still works for the session
1126
  }
1127
  }, [navCollapsed]);
1128
+ // ── ⭐⭐ W38-T04 (ruling R9): A CELL CLICK ALWAYS FOLDS THE RAIL ───────────────────────────
 
 
1129
  //
1130
+ // β›” WAVE 34's R1 IS REVOKED. That ruling read owner item 10's fold as a COURTESY a deliberate
1131
+ // expand was allowed to decline, and this block used to hold a `railHeldOpen` ref that every
1132
+ // expand raised, the explicit fold lowered, and the automatic fold consulted before it set.
1133
+ // Wave 38 item 3 overrules it, verbatim: *"Clicking any cell must minimize the main navigation.
1134
+ // This was the previous behavior; make sure it is fixed forever."* R9 reads "fixed forever"
1135
+ // LITERALLY β€” no held-open state, no preference, no ref may decline the fold, because anything
1136
+ // able to decline it is a way for the behaviour to stop being fixed.
 
 
 
1137
  //
1138
+ // β›” THE REF WAS DELETED, NOT LEFT INERT, and that is the deliberate half of this edit. A
1139
+ // `railHeldOpen` that every expand still raised while nothing read it would compile clean β€” it
1140
+ // is WRITTEN, so no unused-local diagnostic ever sees it β€” and would read to the next person as
1141
+ // a live rule with a missing consumer, which is an invitation to "repair" the decline back in.
1142
+ // A revoked ruling leaves no residue. (`proto/nav-stuck/README.md` keeps R1's original trace;
1143
+ // its finding β€” that no expand CONTROL was ever missing β€” is still true and still worth having.)
1144
  //
1145
+ // ⚠ WHAT SURVIVES R1 IS THE ONE-DOOR SHAPE, and `verify_ui.py` still enforces it: every expand
1146
+ // goes through `expandRail`, every deliberate collapse through `collapseRail`, so a fifth
1147
+ // control cannot set `navCollapsed` behind the two named doors. R1's decline is gone; the
1148
+ // plumbing that kept its call sites countable is not.
1149
+ /** Every deliberate expand goes through here, so a fifth expand door cannot be added quietly. */
1150
  const expandRail = useCallback(() => {
 
1151
  setNavCollapsed(false);
1152
  }, []);
1153
+ /** …and every deliberate fold through here. The AUTOMATIC fold has its own handler, below. */
1154
  const collapseRail = useCallback(() => {
 
1155
  setNavCollapsed(true);
1156
  }, []);
1157
  // Owner item 3 (2026-07-31) β€” the collapsed strip names its icons on hover. A real DOM
 
1272
  const onDataError = (e: Event) =>
1273
  setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
1274
  const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
1275
+ // ⭐⭐ W38-T04 (R9) β€” THE AUTOMATIC FOLD IS UNCONDITIONAL, AND THAT IS THE WHOLE RULE.
1276
+ // `NAV_MINIMIZE_EVENT` fires from `CustomerGrid` on EVERY cell click and EVERY view switch;
1277
+ // this is its only listener, and it sets, every time, with nothing in front of it. W34-T12's
1278
+ // `railHeldOpen` guard stood here and is deleted (see the block by `expandRail` for why the
1279
+ // ref went with it). β›” ANY early return, condition or stored preference added between this
1280
+ // line and the setter re-creates the behaviour owner item 3 asked to be fixed FOREVER β€” and
1281
+ // `verify_ui.py`'s `W38-T04 unconditional` leg exists to go red the moment one appears.
1282
  const onNavMinimize = () => {
 
1283
  setNavCollapsed(true);
1284
  };
1285
  // C-SHARE: the rail asks, the frame opens. A malformed detail opens NOTHING β€”
 
2150
  // brand button below ("Expand navigation"), which is what a keyboard reaches.
2151
  onClick={navCollapsed ? expandOnBlank : undefined}
2152
  >
2153
+ {/* β›”β›” WAVE 38 Β· T05 (owner instruction 2) β€” `<div className="shell-side-head" aria-hidden />`
2154
+ STOOD HERE AND IS DELETED, WITH ITS RULE IN `index.css`. Owner: *"Move the 'Home' button
2155
+ into the empty space above it, and every other module button upward, so there is no
2156
+ awkward gap."* That empty space was this element: an `aria-hidden` spacer holding
2157
+ `--lp-rail-head-h`, so `<nav>` β€” and Home as its first row β€” opened a full header below
2158
+ the top strip. `<nav>` is now `.shell-side`'s first child, Home is the rail's topmost
2159
+ row, and every row below rises by the same one head, because they are literal
2160
+ source-order siblings and nothing else about them moved.
2161
+ ⚠ IT WAS NOT A LEFTOVER, AND THE COMMENT THAT STOOD HERE WAS RIGHT: it aligned this
2162
+ rail's first row with the content pane's first band. The tombstone on `.shell-side-head`
2163
+ in `index.css` carries the cost that was accepted and the alternative that was rejected.
2164
+ The short version is that the band it aligned with (`.cg-views-top`, `.auto-rail-top`)
2165
+ exists only on grid and Agents surfaces, so on Home β€” where the owner is looking β€” it
2166
+ aligned with nothing at all.
2167
+ ⚠ NOTHING ELSE IN THE RAIL KEYED ON IT, checked rather than assumed: neither `index.css`
2168
+ nor `shell/navExtras.css` carries a `.shell-side >`, `:first-child` or `:nth-child` rule
2169
+ into `.shell-side`, so `<nav>` becoming the first child changes no other selector's
2170
+ match. `verify_ui.py`'s `W38-T05 no-rail-head` leg keeps the element from coming back. */}
2171
  <nav className={"shell-nav" + (railLoading ? " is-rail-loading" : "")}>
2172
  {/* ⭐⭐ W33-T75 β€” THE ONE IN-FLIGHT STATE. `is-rail-loading` hides every sibling below
2173
  (`navExtras.css`), so a row cannot paint ahead of its neighbours. Rendering the rows
 
2175
  the tooltips and the account menu all hang off this subtree, and unmounting them for
2176
  the length of a fetch would tear down state that has nothing to do with the nav. */}
2177
  {railLoading ? <RailSkeleton /> : null}
2178
+ {/* ⭐⭐ WAVE 38 Β· T06 (owner instruction 1) β€” THE MINIMISED RAIL'S CHAT LAUNCHER. Owner:
2179
+ *"When the Navigation is minimized: the icon that opens the AI chatbot overlay with
2180
+ the grid, and move the logo downward."*
2181
+ β›” IT OPENS THE GRID'S OWN OVERLAY, NOT `#/assistant`. Those are two different doors:
2182
+ the assistant is a route with no table under it, while `<GridChat>` is docked beside
2183
+ the grid it is about and reads the rows already on screen. This raises the same signal
2184
+ the views rail's `cg-chat-toggle` flips locally, so both launchers land on one panel
2185
+ rather than the app growing a second chat.
2186
+ β›” COLLAPSED ONLY, because expanded the rail already has room for the views rail's own
2187
+ launcher and a second control for one panel in one window is a coin toss for the
2188
+ reader. `navCollapsed` is the same state the brand's downward nudge keys on
2189
+ (`index.css`, `.shell-root:has(.shell-side.is-collapsed) .shell-topbar-brand`), so the
2190
+ two halves of the owner's sentence appear and disappear together.
2191
+ ⚠ A `<button>`, which is what keeps `expandOnBlank` off it: the rail's background click
2192
+ returns early on `closest('a,button,…')`, so this opens the chat WITHOUT also expanding
2193
+ the rail it was minimised into. Verified against that handler, not assumed from its
2194
+ name β€” `.shell-side-bottom` next door had to become a sibling for the mirror-image
2195
+ reason, and its comment records the cost.
2196
+ ⚠ THE TIP IS NOT DECORATION. Collapsed, `.shell-nav-label` folds to zero width, so
2197
+ every rail row buys its name back through `tipEnter`; a glyph without one is the
2198
+ unlabelled-icon trap the Templates row's comment names. Unconditional here rather than
2199
+ ternary, because this control exists only in the state that needs it.
2200
+ ⚠ Clicking it with no grid mounted is a NO-OP BY CONSTRUCTION, twice over: `signal()`
2201
+ dispatches into a window with no listener, and the grid's listener is itself absent on
2202
+ an embedded or Query surface, matching the `!embedded && !queryBinding` guard that
2203
+ decides whether `<GridChat>` can mount at all (`CustomerGrid.tsx`, the chat dock). */}
2204
+ {navCollapsed ? (
2205
+ <button
2206
+ type="button"
2207
+ className="shell-rail-chat"
2208
+ aria-label="Ask about this data"
2209
+ title="Ask about this data"
2210
+ onClick={() => signal(GRID_CHAT_OPEN_EVENT)}
2211
+ onMouseEnter={tipEnter("Ask about this data")}
2212
+ onMouseLeave={tipLeave}
2213
+ >
2214
+ {/* The views rail's own chat glyph, redrawn identically. One mark, one meaning
2215
+ (DESIGN.md Β§4) β€” two doors onto one panel must not wear two different icons. */}
2216
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
2217
+ <path
2218
+ d="M13.4 9.2c0 1.5-1.3 2.7-2.9 2.7H6.9l-3 2.1v-2.3a2.8 2.8 0 0 1-1.3-2.3V5.1c0-1.5 1.3-2.7 2.9-2.7h5c1.6 0 2.9 1.2 2.9 2.7z"
2219
+ stroke="currentColor"
2220
+ strokeWidth="1.3"
2221
+ strokeLinejoin="round"
2222
+ />
2223
+ <path d="M5.9 7.2h4.2" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
2224
+ </svg>
2225
+ </button>
2226
+ ) : null}
2227
  {/* ⭐ WAVE 23 item 9 (R7, contract C10) β€” HOME, the new landing, at the top of the rail.
2228
  An `<a>` to a CHROME route: it needs no grant because it renders nothing the server
2229
  did not already send (nav.ts' `CHROME_ROUTES` note carries the full argument, and
 
3046
  comments up. `.shell-db-frame` is the rail-and-content GEOMETRY; dropping it collapses
3047
  the box. Only the 44px title band is reclaimed, and the grid takes it: `.shell-grid-
3048
  host` is `height: 100%` inside a column that now has one fewer fixed row.
3049
+ ⚠ `.shell-side-head` WAS CHECKED HERE AND FOUND LIVE. W38-T05 HAS SINCE DELETED IT,
3050
+ and this line is corrected rather than left standing, because a comment asserting the
3051
+ liveness of an element that no longer exists is the precise failure the "checked
3052
+ rather than assumed" habit was adopted to prevent. β›” THE REASONING WAS SOUND AND ITS
3053
+ CONCLUSION STILL HOLDS FOR T51: the band aligned with the VIEWS rail's head, not with
3054
+ the database title, so nothing T51 did orphaned it. What retired it was owner
3055
+ instruction 2 of wave 38 β€” the rail's empty top band IS the "awkward gap" β€” which is a
3056
+ later and unrelated ruling. Its tombstone in `index.css` carries the full argument.
3057
  β›” `DbHead` ITSELF SURVIVES UNTIL W36-T67 LANDS. `query/QueryPage.tsx:138` still mounts
3058
  it and that file is session D's fence β€” a component cannot be deleted while another
3059
  fence imports it, which is why T51 is blocked-by T67 rather than the reverse. */