Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/routes_admin.py +17 -3
- api/routes_customers.py +834 -376
- api/routes_nav.py +67 -4
- api/routes_products.py +20 -3
- api/routes_shares.py +65 -6
- api/routes_tables.py +146 -12
- platform/core/perm_scope.py +0 -0
- platform/core/shares.py +45 -3
- platform/core/store_pg.py +166 -3
- platform/core/table_store.py +53 -2
- web/src/customer-grid/CustomerGrid.tsx +104 -16
- web/src/customer-grid/MapView.tsx +303 -27
- web/src/customer-grid/Toolbar.tsx +92 -23
- web/src/customer-grid/mapProjection.ts +141 -0
- web/src/customer-grid/viewModes.tsx +28 -29
- web/src/filter-kit/FilterBuilderPanel.tsx +33 -1
- web/src/index.css +166 -18
- web/src/settings/ModulePermsList.tsx +36 -0
- web/src/settings/permsModel.ts +59 -1
- web/src/shell/Shell.tsx +125 -50
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v29",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "85374f1",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v29",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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 |
-
|
| 1082 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
#
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
#
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
#
|
| 252 |
-
#
|
| 253 |
-
#
|
| 254 |
-
#
|
| 255 |
-
#
|
| 256 |
-
#
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
#
|
| 260 |
-
#
|
| 261 |
-
#
|
| 262 |
-
#
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
#
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 871 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 `[]`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 8 |
-
already speaks, now extended to folders and
|
| 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 |
-
+ "
|
| 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}
|
| 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"
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 /
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 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 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
| 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 |
-
|
| 2760 |
-
|
| 2761 |
-
|
| 2762 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2763 |
updateConfig(next);
|
| 2764 |
},
|
| 2765 |
[config, fieldByKey, updateConfig]
|
|
@@ -6203,8 +6287,10 @@ function CustomerGridSurface({
|
|
| 6203 |
dateChoices={dateFieldChoices}
|
| 6204 |
stackField={kanbanField}
|
| 6205 |
stackChoices={stackFieldChoices}
|
| 6206 |
-
|
| 6207 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 6643 |
-
|
| 6644 |
-
|
| 6645 |
-
|
| 6646 |
-
|
| 6647 |
-
|
| 6648 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 583 |
-
const picked = routeOrder
|
| 584 |
-
|
| 585 |
-
|
|
|
|
|
|
|
| 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 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
*
|
| 927 |
-
* β
|
| 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
|
| 930 |
-
* Expand). The map
|
| 931 |
-
* SET of records was the one view where choosing was impossible, and the
|
| 932 |
-
* "Add to cohort" and the route planner all sat behind a gesture the map did
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 933 |
*
|
| 934 |
-
*
|
| 935 |
-
*
|
| 936 |
-
*
|
| 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
|
| 1326 |
-
|
| 1327 |
</span>
|
| 1328 |
</div>
|
| 1329 |
<div
|
|
@@ -1464,27 +1719,48 @@ export function MapView({
|
|
| 1464 |
role="button"
|
| 1465 |
tabIndex={0}
|
| 1466 |
aria-pressed={on}
|
| 1467 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1474 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1475 |
}}
|
| 1476 |
-
//
|
| 1477 |
-
//
|
| 1478 |
-
//
|
|
|
|
|
|
|
| 1479 |
onDoubleClick={(e) => {
|
| 1480 |
if (movedRef.current) return;
|
| 1481 |
e.preventDefault();
|
| 1482 |
onOpen(p.pid);
|
| 1483 |
}}
|
| 1484 |
onKeyDown={(e) => {
|
| 1485 |
-
// β
|
| 1486 |
-
//
|
| 1487 |
-
//
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 8 |
-
//
|
|
|
|
| 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 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
rowHeightMode: RowHeightMode;
|
| 74 |
onRowHeightMode: (m: RowHeightMode) => void;
|
|
@@ -556,8 +570,9 @@ export default function Toolbar({
|
|
| 556 |
onFieldOrder,
|
| 557 |
deletableKeys,
|
| 558 |
onDeleteField,
|
| 559 |
-
|
| 560 |
-
|
|
|
|
| 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 |
-
{/*
|
| 828 |
-
|
| 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={!
|
| 837 |
-
onChange={() =>
|
|
|
|
|
|
|
|
|
|
| 838 |
/>
|
| 839 |
<span>No color</span>
|
| 840 |
</label>
|
| 841 |
-
|
| 842 |
-
<
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 128 |
* DEF exactly like the two above (never from component state that dies on
|
| 129 |
-
* unmount).
|
| 130 |
-
* which is why
|
| 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
|
| 154 |
-
|
|
|
|
|
|
|
| 155 |
sizeField?: Field;
|
| 156 |
sizeChoices: Field[];
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 "
|
| 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 |
-
{
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/*
|
| 3175 |
-
|
| 3176 |
-
|
| 3177 |
-
|
| 3178 |
-
|
| 3179 |
-
|
| 3180 |
-
|
| 3181 |
-
the
|
| 3182 |
-
|
| 3183 |
-
.
|
| 3184 |
-
|
| 3185 |
-
|
| 3186 |
-
|
| 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 |
-
|
| 3209 |
-
|
| 3210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
// ββ ββ
|
| 1106 |
-
//
|
| 1107 |
-
// R1: *"Fix the navigation error where minimizing gets stuck and cannot be reopened."*
|
| 1108 |
//
|
| 1109 |
-
// β
|
| 1110 |
-
//
|
| 1111 |
-
//
|
| 1112 |
-
//
|
| 1113 |
-
//
|
| 1114 |
-
//
|
| 1115 |
-
//
|
| 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 |
-
//
|
| 1121 |
-
//
|
| 1122 |
-
//
|
|
|
|
|
|
|
|
|
|
| 1123 |
//
|
| 1124 |
-
//
|
| 1125 |
-
//
|
| 1126 |
-
//
|
| 1127 |
-
|
| 1128 |
-
/** Every deliberate expand goes through here, so a fifth expand door cannot
|
| 1129 |
const expandRail = useCallback(() => {
|
| 1130 |
-
railHeldOpen.current = true;
|
| 1131 |
setNavCollapsed(false);
|
| 1132 |
}, []);
|
| 1133 |
-
/**
|
| 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 |
-
// β
|
| 1257 |
-
//
|
| 1258 |
-
//
|
| 1259 |
-
//
|
| 1260 |
-
//
|
|
|
|
|
|
|
| 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 |
-
{/*
|
| 2134 |
-
|
| 2135 |
-
|
| 2136 |
-
|
| 2137 |
-
|
| 2138 |
-
|
| 2139 |
-
|
| 2140 |
-
|
| 2141 |
-
|
| 2142 |
-
|
| 2143 |
-
|
| 2144 |
-
|
| 2145 |
-
|
| 2146 |
-
|
| 2147 |
-
|
| 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`
|
| 2979 |
-
|
| 2980 |
-
|
| 2981 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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. */
|