fsanyoto commited on
Commit
f2db836
Β·
verified Β·
1 Parent(s): aef86ea

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. RELEASES.json +1 -1
  2. VERSION +1 -1
  3. api/ai_enrich.py +96 -0
  4. api/ai_review.py +69 -25
  5. api/main.py +13 -0
  6. api/odoo_relational.py +27 -2
  7. api/providers.py +138 -11
  8. api/routes_admin.py +0 -0
  9. api/routes_geo.py +369 -0
  10. api/routes_nav.py +0 -0
  11. api/routes_products.py +200 -8
  12. api/routes_script_views.py +439 -329
  13. api/routes_tables.py +189 -5
  14. platform/aios_grid.py +394 -5
  15. platform/aios_grid_fields.json +548 -534
  16. platform/core/grid_events.py +31 -1
  17. platform/core/user_tables.py +46 -0
  18. platform/core/users.py +532 -525
  19. platform/harness/datastore.py +0 -0
  20. platform/harness/semantic.py +0 -0
  21. platform/model/metrics/sales.yml +149 -108
  22. platform/model/metrics/stock.yml +57 -0
  23. platform/model/topics/odoo_agents.yml +32 -0
  24. platform/model/topics/odoo_products.yml +51 -0
  25. platform/model/topics/sales_lines.yml +13 -0
  26. platform/model/topics/stock_moves.yml +72 -0
  27. platform/modules/agent.py +385 -277
  28. platform/modules/product_data.py +411 -0
  29. platform/modules/products.py +172 -0
  30. requirements.txt +55 -47
  31. web/index.html +46 -14
  32. web/src/App.tsx +41 -30
  33. web/src/account/SubscriptionPage.tsx +49 -47
  34. web/src/account/UsagePage.tsx +297 -297
  35. web/src/account/account.css +372 -372
  36. web/src/account/accountApi.ts +188 -188
  37. web/src/customer-grid/CatalogView.tsx +0 -0
  38. web/src/customer-grid/ColumnMenu.tsx +0 -0
  39. web/src/customer-grid/CustomerGrid.tsx +0 -0
  40. web/src/customer-grid/GridChat.tsx +512 -0
  41. web/src/customer-grid/MapView.tsx +0 -0
  42. web/src/customer-grid/ScriptViewPanel.tsx +85 -1
  43. web/src/customer-grid/Toolbar.tsx +10 -0
  44. web/src/customer-grid/catalog.css +91 -0
  45. web/src/customer-grid/chatDock.css +52 -0
  46. web/src/customer-grid/gridChat.css +294 -0
  47. web/src/customer-grid/gridChat.ts +461 -0
  48. web/src/customer-grid/map.css +209 -0
  49. web/src/customer-grid/mapProjection.ts +677 -632
  50. web/src/customer-grid/scriptView.css +509 -479
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "9445b77",
3
  "releases": [
4
  {
5
  "version": "v29",
 
1
  {
2
+ "current": "2de35b7",
3
  "releases": [
4
  {
5
  "version": "v29",
VERSION CHANGED
@@ -1 +1 @@
1
- 9445b77
 
1
+ 2de35b7
api/ai_enrich.py CHANGED
@@ -314,6 +314,102 @@ def _ask(provider, model, prompt, max_tokens, timeout):
314
  return '', None, f"{provider['name']} failed: {type(exc).__name__}"
315
 
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  def on_change_fields(defn, changed_keys):
318
  """Which `ai_enrich` columns in this table want a run because one of their inputs moved.
319
 
 
314
  return '', None, f"{provider['name']} failed: {type(exc).__name__}"
315
 
316
 
317
+ # ===========================================================================
318
+ # W37-T37 (R4 / C7) -- THE GEOCODE FIELD's pure half: turning a record into ONE query string.
319
+ #
320
+ # β›” WHY IT IS HERE AND NOT IN `routes_geo.py`. This module's contract, stated in its own header,
321
+ # is that everything in it is a pure function of data it is handed -- which is what lets a gate
322
+ # exercise it with no network, no store and no server. Composing an address is exactly that shape.
323
+ # `routes_geo.py` owns the part that cannot be pure: the outbound call, the throttle and the cache.
324
+ #
325
+ # ⭐ WHY IT COMPOSES AT ALL, rather than sending the one column the field names. The field config
326
+ # is `{addressField, country?}` -- a single column -- but a customers table stores an address across
327
+ # six of them. B MEASURED the fill rates on the real 3,641-row mirror (W37-T11):
328
+ #
329
+ # street 96.2% Β· city 96.0% Β· zip 94.6% Β· country 95.6% Β· state 92.0% Β· street2 5.2%
330
+ # street AND city together: 95.9% <- the pair this feature actually needs
331
+ #
332
+ # Sending `street` alone would ask a global geocoder to place "123 Main Street" with no town, which
333
+ # resolves confidently and often wrongly. So the named column LEADS and the conventional siblings
334
+ # follow, which is also how a person would write the address down.
335
+ # ===========================================================================
336
+
337
+ #: The sibling columns appended after the named one, in the order an address is spoken. ⚠ A LIST
338
+ #: rather than a formatted string: a template would have to decide what to do about the ~4% of rows
339
+ #: missing any one part, and the answer is always "leave it out", never "leave a gap".
340
+ GEOCODE_PARTS = ('street2', 'city', 'state', 'zip', 'postal_code', 'country')
341
+
342
+ #: β›” B's SECOND MEASURED TRAP. Odoo serves `state` as its DISPLAY name, country code included:
343
+ #: `New York (US)`, `British Columbia (CA)`. A naive join yields "SCARSDALE, New York (US)", and the
344
+ #: parenthetical is noise to a geocoder at best. Stripped here, once, rather than at each caller.
345
+ _PAREN_TAIL = re.compile(r'\s*\([^)]*\)\s*$')
346
+
347
+
348
+ def geocode_query(row, cfg, seen_parts=None):
349
+ """One record plus a geocode field's config -> the query string, or '' when there is nothing.
350
+
351
+ β›” RETURNS '' RATHER THAN A PARTIAL GUESS when the named column is empty. B measured ~150 of
352
+ 3,641 customers with no street at all, and the honest UI for those is "no address on file", not
353
+ a request that spends a second of a shared service's budget to place a bare postcode.
354
+
355
+ ⚠ `seen_parts=None` RESOLVES `GEOCODE_PARTS` AT CALL TIME, and that is not style. It was
356
+ `seen_parts=GEOCODE_PARTS`, which binds the tuple once at import, so the module constant stopped
357
+ being the live answer the moment anything wanted to vary it: the gate's own negative control set
358
+ `ai_enrich.GEOCODE_PARTS = ()` and the function carried on composing the full address. A default
359
+ argument that captures a module constant is a SECOND COPY of it with an earlier timestamp, and
360
+ the copy is the one that runs ([[a-constant-two-features-share]]). Caught by that control."""
361
+ if not isinstance(row, dict) or not isinstance(cfg, dict):
362
+ return ''
363
+ if seen_parts is None:
364
+ seen_parts = GEOCODE_PARTS
365
+ key = str(cfg.get('addressField') or '').strip()
366
+ if not key:
367
+ return ''
368
+ lead = _clean_part(row.get(key))
369
+ if not lead:
370
+ return ''
371
+ parts = [lead]
372
+ for k in seen_parts:
373
+ if k == key:
374
+ continue
375
+ v = _clean_part(row.get(k))
376
+ # ⚠ The dedupe is not tidiness. `country` is often both the field's own value and already
377
+ # present in the street line on an imported record, and "USA, USA" measurably degrades a
378
+ # free-form geocoder's confidence.
379
+ if v and v.lower() not in {p.lower() for p in parts}:
380
+ parts.append(v)
381
+ return ', '.join(parts)
382
+
383
+
384
+ def _clean_part(v):
385
+ """One address component, trimmed and stripped of a trailing country parenthetical."""
386
+ if v is None:
387
+ return ''
388
+ s = re.sub(r'\s+', ' ', str(v)).strip()
389
+ if not s or s.lower() in ('false', 'none', 'null'):
390
+ return ''
391
+ return _PAREN_TAIL.sub('', s).strip()
392
+
393
+
394
+ def geocode_plan(rows, cfg, coord_key=None, overwrite=False):
395
+ """Which records a run would actually ask about, and why the rest are being left out.
396
+
397
+ β›” THIS IS THE R3 GUARD IN ITS ARITHMETIC FORM. It is handed the records a PERSON chose; it
398
+ never reads a table. `skipped` is returned rather than swallowed so the surface can say "142 to
399
+ look up, 9 have no address, 31 already have coordinates" instead of a bare number."""
400
+ run, skipped = [], {}
401
+ for rid, row in (rows or {}).items():
402
+ q = geocode_query(row, cfg)
403
+ if not q:
404
+ skipped['no_address'] = skipped.get('no_address', 0) + 1
405
+ continue
406
+ if coord_key and not overwrite and str((row or {}).get(coord_key) or '').strip():
407
+ skipped['already_located'] = skipped.get('already_located', 0) + 1
408
+ continue
409
+ run.append((str(rid), q))
410
+ return {'run': run, 'skipped': skipped}
411
+
412
+
413
  def on_change_fields(defn, changed_keys):
414
  """Which `ai_enrich` columns in this table want a run because one of their inputs moved.
415
 
api/ai_review.py CHANGED
@@ -17,15 +17,23 @@ the work. Nothing here can move a card somewhere the review does not already per
17
  last rather than absent β€” it is the quality backstop, not the default. `AIOS_AI_REVIEW_PROVIDER`
18
  pins one; `AIOS_AI_MODEL` overrides the model.
19
 
20
- ⚠ WHY RAW HTTP RATHER THAN THE `anthropic` SDK, stated because it is a deliberate deviation from
21
- the /claude-api skill's default and not an oversight. Three of the four legs are OpenAI-chat-shaped
22
- endpoints with no shared SDK, so a ladder built on the SDK would be one SDK leg beside three
23
- hand-rolled ones β€” two implementations of the same call, the seam this repo keeps closing. And
24
- `aios-web/api/requirements.txt` is PINNED to what the verify battery proves ([[pin-deps-space-
25
- rebuilds]]): adding a dependency there rebuilds the container, which is a real deploy risk to take
26
- for one leg of an optional feature. `requests` is already a dependency and `harness/analyst.py`
27
- established per-provider raw HTTP as the house pattern. Booked as a DEBT line so the integrator
28
- can overturn it deliberately rather than by drift.
 
 
 
 
 
 
 
 
29
 
30
  The Messages shape below is the current one: `x-api-key` + `anthropic-version: 2023-06-01`, and
31
  `stop_reason: "refusal"` is checked BEFORE reading `content` β€” a refusal answers HTTP 200 with an
@@ -41,9 +49,13 @@ import requests
41
 
42
  #: The ladder. Order IS the policy (R14) β€” cheapest capable first, Anthropic last.
43
  PROVIDERS = [
 
 
 
 
44
  {"name": "groq", "env": "GROQ_API_KEY", "shape": "openai",
45
  "url": "https://api.groq.com/openai/v1/chat/completions",
46
- "model": "llama-3.3-70b-versatile"},
47
  {"name": "cerebras", "env": "CEREBRAS_API_KEY", "shape": "openai",
48
  "url": "https://api.cerebras.ai/v1/chat/completions",
49
  "model": "gpt-oss-120b"},
@@ -161,16 +173,35 @@ def _call_openai(p, model, system, user, timeout):
161
  return str(((choices[0] or {}).get("message") or {}).get("content") or ""), "", body
162
 
163
 
 
 
 
 
 
 
164
  def _call_anthropic(p, model, system, user, timeout):
165
- r = requests.post(p["url"], timeout=timeout,
166
- headers={"x-api-key": os.environ[p["env"]].strip(),
167
- "anthropic-version": ANTHROPIC_VERSION,
168
- "content-type": "application/json"},
169
- json={"model": model, "max_tokens": 300, "system": system,
170
- "messages": [{"role": "user", "content": user}]})
171
- if r.status_code >= 400:
172
- return "", f"anthropic answered {r.status_code}", None
173
- body = r.json()
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  # β›” stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
175
  # reading content[0] before this check turns a refusal into an IndexError inside a run.
176
  # ⚠ A refusal IS billed and its body carries `usage`, so the body rides back on this branch too.
@@ -444,6 +475,9 @@ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeou
444
  `routes_automation`'s draft door) are in lane D's fence. C7 says E adds the ledger line here and
445
  D asserts it; the two keywords are what D has to pass for the line to be attributable.
446
  """
 
 
 
447
  import usage_ledger # noqa: PLC0415
448
  text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
449
  if not text:
@@ -487,13 +521,18 @@ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeou
487
  # 402), so without this branch the drafter cannot answer at all.
488
  # ⚠ NO `effort` ON THIS DOOR β€” `output_config.effort` errors on Haiku 4.5, the tier this
489
  # ladder runs. The parameter is the caller's to send, which is why the wire takes it.
 
 
 
 
 
490
  try:
491
  if p["shape"] == "anthropic":
492
  req = _prov.anthropic_request(
493
  model=p["model"], key=os.environ[p["env"]].strip(), system=None,
494
  messages=messages, tools=tools, max_tokens=1500, tool_choice="required")
495
- r = requests.post(req["url"], timeout=tmo,
496
- headers=req["headers"], json=req["json"])
497
  else:
498
  r = requests.post(p["url"], timeout=tmo,
499
  headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
@@ -501,19 +540,24 @@ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeou
501
  json={"model": p["model"], "messages": messages, "tools": tools,
502
  "tool_choice": "required", "temperature": 0.1,
503
  "max_tokens": 1500})
 
 
504
  except Exception as e: # noqa: BLE001
505
  problems.append(f"{p['name']}: {type(e).__name__}")
506
  continue
507
- if r.status_code != 200:
508
  # β›” A SENTENCE, NOT `HTTP 402` (R4's third clause). The owner read the status codes off
509
  # this very door. `refusal_sentence` also decides whether this was a CREDIT failure, and
510
  # the memo makes the next turn SKIP the empty account instead of paying for it again.
511
- if _prov.is_credit_failure(r.status_code, r.text):
 
 
 
512
  _prov.mark_no_credit(p["name"])
513
- problems.append(_prov.refusal_sentence(p["name"], r.status_code, r.text))
514
  continue
515
  try:
516
- body = r.json()
517
  if p["shape"] == "anthropic":
518
  _text, args, _refused = _prov.anthropic_read(body)
519
  if _refused:
 
17
  last rather than absent β€” it is the quality backstop, not the default. `AIOS_AI_REVIEW_PROVIDER`
18
  pins one; `AIOS_AI_MODEL` overrides the model.
19
 
20
+ ⭐⭐ THE ANTHROPIC LEG IS ON THE OFFICIAL SDK NOW (D-346, W37-T39, 2026-08-19). β›” THIS PARAGRAPH
21
+ USED TO ARGUE THE OPPOSITE and is rewritten rather than deleted, because a comment left asserting
22
+ the reverse of its own code is [[two-gates-can-assert-opposite-things]] with no gate to catch it.
23
+ The retired text said raw HTTP was *"a deliberate deviation … booked as a DEBT line so the
24
+ integrator can overturn it deliberately rather than by drift"*. This is that overturn.
25
+
26
+ Its two reasons were real and are both answered rather than waved away:
27
+ 1. *"one SDK leg beside three hand-rolled ones is two implementations of the same call."* It is
28
+ not, because the leg does not live here: `providers.anthropic_send` owns the wire, prefers
29
+ the SDK, and normalises BOTH transports to one `(status, body)` pair. This file gained no
30
+ Anthropic knowledge β€” it lost some.
31
+ 2. *"adding a pinned dependency rebuilds the container, a real deploy risk."* Still true, so the
32
+ import is LAZY and its absence is a FALLBACK, not a crash: with no `anthropic` package the
33
+ same raw POST runs and `LAST_ANTHROPIC_TRANSPORT` reports `'http'`. The pin is owed in BOTH
34
+ manifests ([[pin-deps-space-rebuilds]]) and is outside every worker fence this wave.
35
+ ⚠ The other three legs stay OpenAI-chat-shaped over `requests` β€” they have no shared SDK, and that
36
+ half of the original reasoning never expired.
37
 
38
  The Messages shape below is the current one: `x-api-key` + `anthropic-version: 2023-06-01`, and
39
  `stop_reason: "refusal"` is checked BEFORE reading `content` β€” a refusal answers HTTP 200 with an
 
49
 
50
  #: The ladder. Order IS the policy (R14) β€” cheapest capable first, Anthropic last.
51
  PROVIDERS = [
52
+ # β›”β›” D-345, FIXED W37-T39 (2026-08-19). This read `llama-3.3-70b-versatile` and Groq RETIRED
53
+ # it: a real POST with the live key answers HTTP 404 `model_not_found`, and the id is absent
54
+ # from `GET /openai/v1/models`. ⚠ THE `openai/` PREFIX IS PART OF GROQ'S ID and is NOT a typo
55
+ # for the cerebras rung below, which serves the same family bare as `gpt-oss-120b`.
56
  {"name": "groq", "env": "GROQ_API_KEY", "shape": "openai",
57
  "url": "https://api.groq.com/openai/v1/chat/completions",
58
+ "model": "openai/gpt-oss-120b"},
59
  {"name": "cerebras", "env": "CEREBRAS_API_KEY", "shape": "openai",
60
  "url": "https://api.cerebras.ai/v1/chat/completions",
61
  "model": "gpt-oss-120b"},
 
173
  return str(((choices[0] or {}).get("message") or {}).get("content") or ""), "", body
174
 
175
 
176
+ #: What transport the last Anthropic call actually used: `'sdk'`, `'http'`, or `''` before any.
177
+ #: ⚠ PROCESS-LOCAL AND FOR REPORTING ONLY. It exists so `GET /meta` and `verify_web_agent` can say
178
+ #: WHICH path ran rather than inferring it from a requirements file nobody in this lane can edit.
179
+ LAST_ANTHROPIC_TRANSPORT = ""
180
+
181
+
182
  def _call_anthropic(p, model, system, user, timeout):
183
+ """One classification turn on the Messages API, through `providers.anthropic_send`.
184
+
185
+ ⭐ D-346 (W37-T39): this used to hand-roll the POST. It now goes through the shared wire, which
186
+ prefers the OFFICIAL `anthropic` SDK and falls back to the same raw POST when the package is
187
+ absent. The shape of what comes back is unchanged, deliberately: `anthropic_send` normalises
188
+ both transports to `(status, body)`, so every line below this call is untouched.
189
+ """
190
+ global LAST_ANTHROPIC_TRANSPORT
191
+ import providers as _prov # noqa: PLC0415
192
+ # ⚠ NO TOOLS ON THIS PATH. The builder omits `tool_choice` for exactly that case, so there is
193
+ # nothing to strip here β€” the rule lives in `anthropic_request`, beside the thing it constrains.
194
+ req = _prov.anthropic_request(
195
+ model=model, key=os.environ[p["env"]].strip(), system=system,
196
+ messages=[{"role": "user", "content": user}], tools=[], max_tokens=300)
197
+ status, body, transport = _prov.anthropic_send(req, timeout=timeout)
198
+ LAST_ANTHROPIC_TRANSPORT = transport
199
+ if status >= 400 or status == 0:
200
+ # ⭐ THE SENTENCE COMES FROM THE LADDER, NOT FROM HERE. `refusal_sentence` already names the
201
+ # vendor and the action for every status R4 enumerated, so a bare "anthropic answered 402"
202
+ # (which is what this line used to say, and what the owner quoted back at us) cannot recur.
203
+ return "", _prov.refusal_sentence("anthropic", status, json.dumps(body)[:400]), None
204
+ body = body or {}
205
  # β›” stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
206
  # reading content[0] before this check turns a refusal into an IndexError inside a run.
207
  # ⚠ A refusal IS billed and its body carries `usage`, so the body rides back on this branch too.
 
475
  `routes_automation`'s draft door) are in lane D's fence. C7 says E adds the ledger line here and
476
  D asserts it; the two keywords are what D has to pass for the line to be attributable.
477
  """
478
+ # ⚠ DECLARED AT THE TOP OF THE FUNCTION, not beside the assignment inside the provider loop.
479
+ # It parses either way; a reader scanning for the declaration does not look inside a `for`.
480
+ global LAST_ANTHROPIC_TRANSPORT
481
  import usage_ledger # noqa: PLC0415
482
  text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
483
  if not text:
 
521
  # 402), so without this branch the drafter cannot answer at all.
522
  # ⚠ NO `effort` ON THIS DOOR β€” `output_config.effort` errors on Haiku 4.5, the tier this
523
  # ladder runs. The parameter is the caller's to send, which is why the wire takes it.
524
+ # ⭐ D-346 (W37-T39): BOTH shapes now yield the SAME `(status, body)` pair, so every branch
525
+ # below reads one thing. The anthropic side gets there through `anthropic_send` (official
526
+ # SDK when installed, the identical raw POST when not); the openai side still posts here
527
+ # because there is no shared SDK across three different vendors on that wire.
528
+ global LAST_ANTHROPIC_TRANSPORT
529
  try:
530
  if p["shape"] == "anthropic":
531
  req = _prov.anthropic_request(
532
  model=p["model"], key=os.environ[p["env"]].strip(), system=None,
533
  messages=messages, tools=tools, max_tokens=1500, tool_choice="required")
534
+ status, body, transport = _prov.anthropic_send(req, timeout=tmo)
535
+ LAST_ANTHROPIC_TRANSPORT = transport
536
  else:
537
  r = requests.post(p["url"], timeout=tmo,
538
  headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
 
540
  json={"model": p["model"], "messages": messages, "tools": tools,
541
  "tool_choice": "required", "temperature": 0.1,
542
  "max_tokens": 1500})
543
+ status = r.status_code
544
+ body = r.json() if (r.content and r.status_code == 200) else {"_text": r.text}
545
  except Exception as e: # noqa: BLE001
546
  problems.append(f"{p['name']}: {type(e).__name__}")
547
  continue
548
+ if status != 200:
549
  # β›” A SENTENCE, NOT `HTTP 402` (R4's third clause). The owner read the status codes off
550
  # this very door. `refusal_sentence` also decides whether this was a CREDIT failure, and
551
  # the memo makes the next turn SKIP the empty account instead of paying for it again.
552
+ # ⚠ The body is re-serialised for the substring test because the SDK path never had a
553
+ # `.text` β€” that is the one thing this normalisation costs, and it costs nothing else.
554
+ detail = json.dumps(body)[:600] if isinstance(body, dict) else str(body)[:600]
555
+ if _prov.is_credit_failure(status, detail):
556
  _prov.mark_no_credit(p["name"])
557
+ problems.append(_prov.refusal_sentence(p["name"], status, detail))
558
  continue
559
  try:
560
+ body = body or {}
561
  if p["shape"] == "anthropic":
562
  _text, args, _refused = _prov.anthropic_read(body)
563
  if _refused:
api/main.py CHANGED
@@ -87,6 +87,7 @@ import routes_usage # noqa: E402 (wave 35 R9/C7 β€” the AI usage meter; E's ro
87
  import routes_feedback # noqa: E402 (wave 35 R8/C6 β€” feedback to the operator plane; E's router)
88
  import routes_agent_harness # noqa: E402 (wave 36 R8/C4 β€” the agent harness store; E's router)
89
  import routes_script_views # noqa: E402 (wave 36 R3/R10/C3 β€” script Views; E's router)
 
90
  from core import grid_events # noqa: E402
91
  # D-315 / D-305 (2026-08-18) β€” the two store REFUSALS get their own app-level handlers below.
92
  # ⚠ Neither is a `StoreUnavailable` subclass, on purpose: routes that degrade a store outage into a
@@ -395,6 +396,18 @@ app.include_router(routes_agent_harness.router) # R8 / C4 β€” versioned agent h
395
  # Mounting it does not put a ten-second wait anywhere near the event loop; the router's own header
396
  # says why that is not a style choice.
397
  app.include_router(routes_script_views.router) # R3 / R10 / C3 β€” code-script database Views
 
 
 
 
 
 
 
 
 
 
 
 
398
 
399
 
400
  # --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
 
87
  import routes_feedback # noqa: E402 (wave 35 R8/C6 β€” feedback to the operator plane; E's router)
88
  import routes_agent_harness # noqa: E402 (wave 36 R8/C4 β€” the agent harness store; E's router)
89
  import routes_script_views # noqa: E402 (wave 36 R3/R10/C3 β€” script Views; E's router)
90
+ import routes_geo # noqa: E402 (wave 37 R14/A11 β€” the keyless map provider seam; D's router)
91
  from core import grid_events # noqa: E402
92
  # D-315 / D-305 (2026-08-18) β€” the two store REFUSALS get their own app-level handlers below.
93
  # ⚠ Neither is a `StoreUnavailable` subclass, on purpose: routes that degrade a store outage into a
 
396
  # Mounting it does not put a ten-second wait anywhere near the event loop; the router's own header
397
  # says why that is not a style choice.
398
  app.include_router(routes_script_views.router) # R3 / R10 / C3 β€” code-script database Views
399
+ # ⭐⭐ WAVE 37 (R14, amendment A11) β€” THE KEYLESS MAP PROVIDER SEAM, mounted on `ASK D-1` in the
400
+ # same wave that created `routes_geo.py`. NINTH consecutive wave in which this block is the
401
+ # artefact the protocol nearly loses, and the first in which the ask that carries it was itself
402
+ # lost: D raised it inside a ``` fence, so `wave_board.py` blanked it and the bus never delivered
403
+ # it (D-366). The integrator found it reading the peer mailbox by hand.
404
+ # ⚠ Placement, as for every line above: ABOVE `app.mount("/", _AppStatic(...), html=True)` at the
405
+ # end of this file, or GET answers 404 and POST answers 405 while every one of its own gates is
406
+ # green. D is adding a `web_map` leg that reads THIS FILE and asserts this line β€” keep it.
407
+ # β›” R14 put the provider behind a seam precisely so swapping Google in later is a config change:
408
+ # tiles = OpenStreetMap Β· routing = OSRM Β· geocoding = Nominatim, none of them keyed, all of them
409
+ # rate-limited by POLITENESS rather than by a bill (~1 req/s, and bulk harvesting is forbidden).
410
+ app.include_router(routes_geo.router) # R14 / A11 β€” D's router, A's line (map providers)
411
 
412
 
413
  # --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
api/odoo_relational.py CHANGED
@@ -636,8 +636,25 @@ def customer_fields():
636
  "default": True, "pinned": True},
637
  {"key": JOIN_KEY, "label": "Odoo ID", "type": "int", "source": "overlay",
638
  "default": False, "description": "The `res.partner` id. Also this row's id."},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
  {"key": "city", "label": "City", "type": "text", "source": "overlay", "default": True},
640
  {"key": "state", "label": "State", "type": "text", "source": "overlay", "default": True},
 
641
  {"key": "country", "label": "Country", "type": "text", "source": "overlay",
642
  "default": False},
643
  {"key": "agent", "label": "Sales agent", "type": "text", "source": "overlay",
@@ -976,8 +993,13 @@ def read_customers(cur, excluded=None):
976
  # the mirror carries. Absent β‡’ the leg is dropped WHOLE, exactly like the agent join above.
977
  rank_leg = (" OR (p.customer_rank > 0 AND p.active) "
978
  if {"customer_rank", "active"} <= have else "")
 
 
 
 
979
  sql = (f"SELECT p.id, p.name, {_col(have, 'p.city')}, {_col(have, 'p.state_name')}, "
980
- f" {_col(have, 'p.country_name')}, {agent}, {agent_id_col} "
 
981
  "FROM res_partner p "
982
  f"{join}"
983
  "WHERE p.id IN ("
@@ -988,13 +1010,16 @@ def read_customers(cur, excluded=None):
988
  f"{rank_leg}")
989
  out = []
990
  for r in cur.execute(sql).fetchall():
991
- (pid, name, city, state, country, agent, agent_id) = r
992
  out.append({
993
  "_id": str(pid),
994
  "customer": str(name or ""),
995
  JOIN_KEY: int(pid),
 
 
996
  "city": str(city or ""),
997
  "state": str(state or ""),
 
998
  "country": str(country or ""),
999
  "agent": str(agent or ""),
1000
  AGENT_JOIN_KEY: int(agent_id) if agent_id else "",
 
636
  "default": True, "pinned": True},
637
  {"key": JOIN_KEY, "label": "Odoo ID", "type": "int", "source": "overlay",
638
  "default": False, "description": "The `res.partner` id. Also this row's id."},
639
+ # ⭐⭐ W37-T11 / D-165 β€” THE POSTAL ADDRESS, which this table declared nowhere and served
640
+ # never. `verify_odoo_relational._prove_customer_address` recorded the blocker in its own
641
+ # docstring on 2026-08-12 β€” *"`street`/`street2`/`zip` are not among [the mirror's 16
642
+ # columns] … blocked on a `sync_all()` backfill no ticket in wave 30 owns"*. That backfill
643
+ # HAS since run (`_sync_state` carries `res_partner.cols.…,street,street2,…,zip`, done
644
+ # 2026-08-15), so the half that could not be built now can be.
645
+ # ⛔ THIS IS ALSO LANE D'S DEPENDENCY. R4 puts address→coordinates in an enrichment field,
646
+ # and a geocoder pointed at a column that does not exist enriches nothing.
647
+ # ⚠ `default: False` on the postal lines, `True` on city/state β€” the same split
648
+ # `vendor_fields` made and for the same reason: four address columns on by default push
649
+ # the useful ones off the first screen. A locked database still allows fields, so every
650
+ # one is a click away in the picker.
651
+ {"key": "street", "label": "Street", "type": "text", "source": "overlay",
652
+ "default": False},
653
+ {"key": "street2", "label": "Street 2", "type": "text", "source": "overlay",
654
+ "default": False},
655
  {"key": "city", "label": "City", "type": "text", "source": "overlay", "default": True},
656
  {"key": "state", "label": "State", "type": "text", "source": "overlay", "default": True},
657
+ {"key": "zip", "label": "ZIP", "type": "text", "source": "overlay", "default": False},
658
  {"key": "country", "label": "Country", "type": "text", "source": "overlay",
659
  "default": False},
660
  {"key": "agent", "label": "Sales agent", "type": "text", "source": "overlay",
 
993
  # the mirror carries. Absent β‡’ the leg is dropped WHOLE, exactly like the agent join above.
994
  rank_leg = (" OR (p.customer_rank > 0 AND p.active) "
995
  if {"customer_rank", "active"} <= have else "")
996
+ # ⭐⭐ W37-T11 / D-165 β€” the POSTAL columns join city/state/country here. They ride `_col` like
997
+ # every other optional column, so a mirror predating the 2026-08-15 backfill degrades the CELL
998
+ # to blank rather than failing the spawn with a Binder error (see `columns()`'s header for the
999
+ # live 500 that rule was written after).
1000
  sql = (f"SELECT p.id, p.name, {_col(have, 'p.city')}, {_col(have, 'p.state_name')}, "
1001
+ f" {_col(have, 'p.country_name')}, {agent}, {agent_id_col}, "
1002
+ f" {_col(have, 'p.street')}, {_col(have, 'p.street2')}, {_col(have, 'p.zip')} "
1003
  "FROM res_partner p "
1004
  f"{join}"
1005
  "WHERE p.id IN ("
 
1010
  f"{rank_leg}")
1011
  out = []
1012
  for r in cur.execute(sql).fetchall():
1013
+ (pid, name, city, state, country, agent, agent_id, street, street2, zipc) = r
1014
  out.append({
1015
  "_id": str(pid),
1016
  "customer": str(name or ""),
1017
  JOIN_KEY: int(pid),
1018
+ "street": str(street or ""),
1019
+ "street2": str(street2 or ""),
1020
  "city": str(city or ""),
1021
  "state": str(state or ""),
1022
+ "zip": str(zipc or ""),
1023
  "country": str(country or ""),
1024
  "agent": str(agent or ""),
1025
  AGENT_JOIN_KEY: int(agent_id) if agent_id else "",
api/providers.py CHANGED
@@ -39,6 +39,12 @@ import time
39
  from dataclasses import dataclass, field
40
  from typing import Callable
41
 
 
 
 
 
 
 
42
  #: ⚠ APPROXIMATE, AND DELIBERATELY SO. These are list prices per RECORD in USD, used only to RANK
43
  #: providers and to estimate a run's spend for the operator. They are not billing truth β€” the
44
  #: vendor's own dashboard is. Wrong by 2x still ranks correctly; wrong by 100x does not, which is
@@ -352,6 +358,8 @@ LLM_PROVIDERS: dict[str, LlmProvider] = {
352
  # ⚠ Claude Opus 5, $5 / $25 per million tokens (2026-06 list). Override per deployment with
353
  # `AIOS_ANTHROPIC_MODEL` β€” the id is read at call time, so a cheaper tier
354
  # (`claude-sonnet-5`, $3 / $15) is an environment change, not a release.
 
 
355
  model=os.environ.get("AIOS_ANTHROPIC_MODEL") or "claude-opus-5",
356
  wire="anthropic",
357
  caps={
@@ -367,24 +375,48 @@ LLM_PROVIDERS: dict[str, LlmProvider] = {
367
  "cerebras": LlmProvider(
368
  name="cerebras", label="Cerebras", env="CEREBRAS_API_KEY",
369
  url="https://api.cerebras.ai/v1/chat/completions",
 
 
 
 
370
  model="gpt-oss-120b", wire="openai",
371
  caps={
 
 
 
 
 
372
  "llm_tool_calling": Capability(True, 0.0,
373
  "DECLARED, not measured: this account's tool-capable "
374
- "model per the wave-32 ladder note"),
 
375
  "llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
376
  "llm_json_mode": Capability(True, 0.0, "response_format json_object"),
377
  }),
378
  "groq": LlmProvider(
379
  name="groq", label="Groq", env="GROQ_API_KEY",
 
 
 
 
 
 
 
 
 
 
380
  url="https://api.groq.com/openai/v1/chat/completions",
381
- model="llama-3.3-70b-versatile", wire="openai",
382
  caps={
 
 
 
 
 
 
383
  "llm_tool_calling": Capability(True, 0.0,
384
- "DECLARED, not measured. ⚠ `routes_query._FAILED_GEN` "
385
- "exists because SOME provider on this wire answers 400 "
386
- "with the call in `failed_generation`; the repo never "
387
- "recorded which. Flip this to False the day it is"),
388
  "llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
389
  "llm_json_mode": Capability(True, 0.0, "response_format json_object"),
390
  }),
@@ -393,7 +425,11 @@ LLM_PROVIDERS: dict[str, LlmProvider] = {
393
  url="https://openrouter.ai/api/v1/chat/completions",
394
  model="openai/gpt-4o-mini", wire="openai",
395
  caps={
396
- "llm_tool_calling": Capability(True, 0.0, "DECLARED, not measured"),
 
 
 
 
397
  "llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
398
  "llm_json_mode": Capability(True, 0.0, "response_format json_object"),
399
  }),
@@ -538,16 +574,25 @@ def anthropic_request(*, model, key, system, messages, tools, max_tokens,
538
  turns = [{"role": ("assistant" if m.get("role") == "assistant" else "user"),
539
  "content": str(m.get("content") or "")}
540
  for m in messages if m.get("role") != "system" and str(m.get("content") or "").strip()]
 
 
 
541
  body = {
542
  "model": str(model),
543
  "max_tokens": int(max_tokens),
544
  "system": system_text,
545
  "messages": turns,
546
- "tools": [{"name": t["function"]["name"],
547
- "description": t["function"]["description"],
548
- "input_schema": t["function"]["parameters"]} for t in (tools or [])],
549
- "tool_choice": {"type": "any" if tool_choice in ("required", "any") else "auto"},
550
  }
 
 
 
 
 
 
 
 
 
551
  if effort:
552
  body["output_config"] = {"effort": str(effort)}
553
  return {"url": LLM_PROVIDERS["anthropic"].url,
@@ -557,6 +602,88 @@ def anthropic_request(*, model, key, system, messages, tools, max_tokens,
557
  "json": body}
558
 
559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  def anthropic_read(body):
561
  """`(text, tool_input, refusal)` out of a Messages API answer.
562
 
 
39
  from dataclasses import dataclass, field
40
  from typing import Callable
41
 
42
+ # ⚠ TOP-LEVEL AND SAFE, unlike the `anthropic` import in `anthropic_sdk()`: `requests` is pinned in
43
+ # BOTH manifests and is already a transitive dependency of `huggingface_hub`, so it cannot be the
44
+ # line that stops a container booting. That asymmetry is the whole reason the two are imported
45
+ # differently β€” see `anthropic_sdk()`.
46
+ import requests
47
+
48
  #: ⚠ APPROXIMATE, AND DELIBERATELY SO. These are list prices per RECORD in USD, used only to RANK
49
  #: providers and to estimate a run's spend for the operator. They are not billing truth β€” the
50
  #: vendor's own dashboard is. Wrong by 2x still ranks correctly; wrong by 100x does not, which is
 
358
  # ⚠ Claude Opus 5, $5 / $25 per million tokens (2026-06 list). Override per deployment with
359
  # `AIOS_ANTHROPIC_MODEL` β€” the id is read at call time, so a cheaper tier
360
  # (`claude-sonnet-5`, $3 / $15) is an environment change, not a release.
361
+ # ⭐ W37-T39 re-verified this default against the vendor rather than the docs: a real
362
+ # Messages POST answers HTTP 200, and `claude-opus-5` is in `GET /v1/models`.
363
  model=os.environ.get("AIOS_ANTHROPIC_MODEL") or "claude-opus-5",
364
  wire="anthropic",
365
  caps={
 
375
  "cerebras": LlmProvider(
376
  name="cerebras", label="Cerebras", env="CEREBRAS_API_KEY",
377
  url="https://api.cerebras.ai/v1/chat/completions",
378
+ # ⚠ STILL A LIVE ID, and W37-T39 checked rather than assumed: `gpt-oss-120b` is one of the
379
+ # two ids `GET https://api.cerebras.ai/v1/models` returns. What it is NOT is callable on
380
+ # this account β€” a real POST answers `HTTP 402 payment_required`, which `mark_no_credit`
381
+ # already knows how to survive (R4: skipped, not tried).
382
  model="gpt-oss-120b", wire="openai",
383
  caps={
384
+ # ⚠ STILL "DECLARED, NOT MEASURED", DELIBERATELY. The 2026-08-19 sweep could not
385
+ # measure this rung: a 402 comes back before any tool is considered, so there is no
386
+ # observation to record. Leaving the old wording is the honest answer β€” upgrading it
387
+ # to MEASURED beside its two neighbours would be claiming an account balance as
388
+ # evidence about a capability.
389
  "llm_tool_calling": Capability(True, 0.0,
390
  "DECLARED, not measured: this account's tool-capable "
391
+ "model per the wave-32 ladder note. ⚠ 2026-08-19: the "
392
+ "account answers 402, so this stays unmeasured"),
393
  "llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
394
  "llm_json_mode": Capability(True, 0.0, "response_format json_object"),
395
  }),
396
  "groq": LlmProvider(
397
  name="groq", label="Groq", env="GROQ_API_KEY",
398
+ # β›”β›” D-345, FIXED W37-T39 (2026-08-19): this read `llama-3.3-70b-versatile` and Groq
399
+ # RETIRED it. Measured, not inferred β€” a real POST with the live key answered
400
+ # `HTTP 404 {"code":"model_not_found"}`, and `GET /openai/v1/models` returns 13 ids with
401
+ # that one absent. ⚠ THE STATUS IS THE EVIDENCE AND 404 IS THE ONLY ONE THAT LICENSES A
402
+ # RENAME: cerebras answered 402 on the same sweep, which is a BILLING fact about the
403
+ # account and says nothing about its model id (`gpt-oss-120b` is in its /models list).
404
+ # "Fixing" an id behind a 401/402 is how a second bug ships behind a green probe.
405
+ # ⚠ THE PREFIX IS NOT A TYPO: Groq serves this model as `openai/gpt-oss-120b` while
406
+ # Cerebras serves the same family bare as `gpt-oss-120b`. The two rungs are DELIBERATELY
407
+ # spelled differently and a sweep that "normalises" them breaks one of them.
408
  url="https://api.groq.com/openai/v1/chat/completions",
409
+ model="openai/gpt-oss-120b", wire="openai",
410
  caps={
411
+ # ⭐ MEASURED 2026-08-19, W37-T39, and this comment used to say the opposite. The old
412
+ # text read *"DECLARED, not measured … `routes_query._FAILED_GEN` exists because SOME
413
+ # provider on this wire answers 400 with the call in `failed_generation`; the repo
414
+ # never recorded which. Flip this to False the day it is"*. It is recorded now: on
415
+ # this id Groq answers HTTP 200 with a TYPED `tool_calls` block carrying parsed
416
+ # `arguments`, so it is not the provider `_FAILED_GEN` apologises for.
417
  "llm_tool_calling": Capability(True, 0.0,
418
+ "MEASURED 2026-08-19: HTTP 200, typed tool_calls block "
419
+ "with parsed arguments; no failed_generation recovery"),
 
 
420
  "llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
421
  "llm_json_mode": Capability(True, 0.0, "response_format json_object"),
422
  }),
 
425
  url="https://openrouter.ai/api/v1/chat/completions",
426
  model="openai/gpt-4o-mini", wire="openai",
427
  caps={
428
+ # ⭐ MEASURED 2026-08-19, W37-T39 (was "DECLARED, not measured"): HTTP 200 with a typed
429
+ # `tool_calls` block. So on the OpenAI wire BOTH reachable rungs tool-call cleanly, and
430
+ # `_FAILED_GEN` is still holding a door nobody on this ladder has been seen to use.
431
+ "llm_tool_calling": Capability(True, 0.0,
432
+ "MEASURED 2026-08-19: HTTP 200, typed tool_calls block"),
433
  "llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
434
  "llm_json_mode": Capability(True, 0.0, "response_format json_object"),
435
  }),
 
574
  turns = [{"role": ("assistant" if m.get("role") == "assistant" else "user"),
575
  "content": str(m.get("content") or "")}
576
  for m in messages if m.get("role") != "system" and str(m.get("content") or "").strip()]
577
+ wire_tools = [{"name": t["function"]["name"],
578
+ "description": t["function"]["description"],
579
+ "input_schema": t["function"]["parameters"]} for t in (tools or [])]
580
  body = {
581
  "model": str(model),
582
  "max_tokens": int(max_tokens),
583
  "system": system_text,
584
  "messages": turns,
585
+ "tools": wire_tools,
 
 
 
586
  }
587
+ # β›” W37-T39: `tool_choice` IS OMITTED WHEN THERE ARE NO TOOLS, and the builder decides that
588
+ # rather than each caller. The Messages API refuses `tool_choice` beside an empty `tools` list,
589
+ # so this used to be a `req["json"].pop("tool_choice", None)` at the one call site that sends no
590
+ # tools β€” a rule living outside the thing it constrains, which is [[limit-with-no-enforcer]]:
591
+ # the next no-tools caller inherits a 400 that reads like a model problem, not a shape problem.
592
+ # ⚠ Safe for all three callers today, checked rather than assumed: `routes_query` and
593
+ # `ai_review.draft_flow` both pass a non-empty tool list and keep the key exactly as before.
594
+ if wire_tools:
595
+ body["tool_choice"] = {"type": "any" if tool_choice in ("required", "any") else "auto"}
596
  if effort:
597
  body["output_config"] = {"effort": str(effort)}
598
  return {"url": LLM_PROVIDERS["anthropic"].url,
 
602
  "json": body}
603
 
604
 
605
+ #: Cached result of `import anthropic`: the module, or `False` once it is known to be absent.
606
+ #: ⚠ `None` is NOT the "absent" sentinel β€” a plain falsy check would re-attempt the import on every
607
+ #: call, and a failed import is not cheap. `False` says "asked and answered".
608
+ _SDK: object = None
609
+
610
+
611
+ def anthropic_sdk():
612
+ """The official `anthropic` SDK, or `None` if this deployment does not carry it.
613
+
614
+ ⭐⭐ D-346, W37-T39. THIS FUNCTION IS THE WHOLE OF THE FIX AND ALSO THE WHOLE OF ITS RISK.
615
+ `aios-web/requirements.txt` is what the Dockerfile installs and it is OUTSIDE every lane's
616
+ fence this wave, so the pin is another session's edit. A hard `import anthropic` at module
617
+ scope would therefore turn a missing line in a manifest into a container that cannot boot at
618
+ all: the API imports this module on every request path.
619
+
620
+ So the import is LAZY and its absence is REPORTED rather than fatal β€” `anthropic_send` falls
621
+ back to the raw `requests` POST that has always worked, and says which transport ran. When the
622
+ pin lands the SDK path takes over with no further change.
623
+ β›” The fallback is a BRIDGE, not a second implementation: both transports send the SAME body
624
+ `anthropic_request()` built and return the SAME `(status, body)` pair, so `anthropic_read`,
625
+ `is_credit_failure` and `refusal_sentence` each keep exactly one normalizer
626
+ ([[one-question-two-normalizers]]).
627
+ """
628
+ global _SDK
629
+ if _SDK is None:
630
+ try:
631
+ import anthropic as _mod # noqa: PLC0415 - deliberately lazy; see the docstring
632
+ _SDK = _mod
633
+ except Exception:
634
+ _SDK = False
635
+ return _SDK or None
636
+
637
+
638
+ def anthropic_send(req, timeout=None, use_sdk=None):
639
+ """POST one `anthropic_request()` dict. Returns `(status, body, transport)`.
640
+
641
+ ⭐ `transport` is `'sdk'` or `'http'` and it is RETURNED, not logged and forgotten: a reader
642
+ who cannot tell which path ran cannot tell whether the requirements pin reached the container
643
+ [[report-the-cause-before-you-fix-it]]. β›” A gate must assert on THIS RETURN VALUE, never on
644
+ `ai_review.LAST_ANTHROPIC_TRANSPORT` β€” that global is a convenience for `GET /meta`, and a
645
+ check keyed to it passes on a stale value written by an earlier check in the same process.
646
+
647
+ `status` is an int and `body` is the parsed JSON dict in BOTH paths, including on an error β€”
648
+ the SDK raises where `requests` returns, and normalising that difference here is the only
649
+ reason this function exists rather than the caller branching on transport.
650
+
651
+ ⚠ `use_sdk=False` forces the fallback. It exists so a gate can exercise BOTH transports
652
+ without assigning to `_SDK`: a test that mutates a module global and then throws leaves every
653
+ later check in that process running on the wrong path and passing for the wrong reason.
654
+ """
655
+ sdk = None if use_sdk is False else anthropic_sdk()
656
+ payload = dict(req.get("json") or {})
657
+ if sdk is None:
658
+ r = requests.post(req["url"], headers=req["headers"], json=payload,
659
+ timeout=(timeout or 60))
660
+ try:
661
+ return r.status_code, (r.json() if r.content else {}), "http"
662
+ except Exception:
663
+ return r.status_code, {"_text": (r.text or "")[:2000]}, "http"
664
+
665
+ key = (req.get("headers") or {}).get("x-api-key") or ""
666
+ try:
667
+ client = sdk.Anthropic(api_key=key, timeout=float(timeout or 60))
668
+ msg = client.messages.create(**payload)
669
+ # ⚠ `.model_dump()` is what makes ONE reader serve both transports: it hands back the same
670
+ # wire-shaped dict the raw POST parses out of the response body.
671
+ return 200, msg.model_dump(), "sdk"
672
+ except Exception as exc:
673
+ # β›” THE STATUS IS THE PRODUCT HERE. Every sentence the reader sees is chosen by
674
+ # `refusal_sentence(status)`, so an exception that loses its code turns "Anthropic is out
675
+ # of credit" into "Anthropic did not answer" β€” the exact regression R4 was written to end.
676
+ status = int(getattr(exc, "status_code", 0) or 0)
677
+ body = getattr(exc, "body", None)
678
+ if not isinstance(body, dict):
679
+ body = {"_text": str(exc)[:2000]}
680
+ if not status:
681
+ # No status at all = it never reached the vendor (DNS, TLS, timeout). 408 is the one
682
+ # code `_STATUS_WORDS` already words as a reachability problem rather than a refusal.
683
+ status = 408 if isinstance(exc, getattr(sdk, "APIConnectionError", ())) else 0
684
+ return status, body, "sdk"
685
+
686
+
687
  def anthropic_read(body):
688
  """`(text, tool_input, refusal)` out of a Messages API answer.
689
 
api/routes_admin.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_geo.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_geo.py β€” the MAP PROVIDER SEAM (wave 37, R3/R14, tickets T35/T36/T37).
2
+
3
+ ⭐ WHY THIS FILE EXISTS AT ALL, and why the map does not simply call a vendor.
4
+
5
+ R14 (owner, 2026-08-19): no Google Maps API key this wave. The map work is not dropped, it is
6
+ retargeted to providers that need no key: OpenStreetMap tiles, OSRM routing, Nominatim geocoding.
7
+ The deferral only holds if swapping a paid vendor back in is a CONFIG change rather than a rewrite,
8
+ so every provider is a (base URL + attribution) pair read from the environment and served from ONE
9
+ door. The renderer receives a URL template and a credit line; it knows nothing about who is behind
10
+ them. That is the seam.
11
+
12
+ tiles AIOS_MAP_TILE_URL default https://tile.openstreetmap.org/{z}/{x}/{y}.png
13
+ geocode AIOS_GEOCODE_URL default https://nominatim.openstreetmap.org/search
14
+ routing AIOS_ROUTE_URL default https://router.project-osrm.org/route/v1/driving/
15
+
16
+ β›” R3'S ON-DEMAND RULE, AND WHY IT IS STRUCTURAL HERE RATHER THAN A COMMENT.
17
+ R3 forbids geocoding a whole table automatically. Under R14 the reason moves from money to manners
18
+ and gets sharper: Nominatim's usage policy allows roughly one request per second and explicitly
19
+ forbids bulk harvesting, so a loop over 3,636 customers does not produce a bill, it produces a
20
+ BLOCKED tenant. Three mechanisms enforce it, none of them advisory:
21
+
22
+ 1. `/geo/geocode` takes an explicit LIST OF ADDRESSES from the caller. It cannot read a table, so
23
+ there is no code path from "a database exists" to "its rows were geocoded". Somebody had to
24
+ choose the records.
25
+ 2. `MAX_BATCH` refuses an oversized list with a named error rather than truncating it. The client
26
+ drives the loop and can therefore SHOW the wait, which is the half of the ticket a comment
27
+ cannot satisfy.
28
+ 3. `_throttle()` blocks in-process until the minimum interval has elapsed, so even a caller that
29
+ ignores everything above cannot exceed the published rate.
30
+
31
+ ⭐ AND A CACHE, because the policy asks for one: an address geocoded once is answered from memory
32
+ for the rest of the container's life. It is the cheapest way to be a good citizen and it makes a
33
+ re-run of the same records free rather than merely legal.
34
+
35
+ ⚠ NOTHING HERE PROXIES TILES. Tiles are fetched by the BROWSER, straight from the provider, which
36
+ is what every OSM client does and what the tile policy expects. Proxying them through this app
37
+ would put a free tier in the path of every pan and would breach the same policy it looks like it is
38
+ respecting. This door serves the tile URL, never the tile.
39
+ """
40
+ import os
41
+ import re
42
+ import threading
43
+ import time
44
+ from collections import OrderedDict
45
+
46
+ import requests
47
+ from fastapi import APIRouter, Body, Depends
48
+
49
+ from deps import Session, err, require_session
50
+
51
+ router = APIRouter(prefix="/api/v1")
52
+
53
+ # --------------------------------------------------------------------- the seam
54
+ #
55
+ # Every value below is an environment override with a keyless default. Pointing the map at a paid
56
+ # vendor is three variables on the Space and no code change, which is what makes R14 a deferral.
57
+
58
+ _TILE_URL_DEFAULT = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
59
+ _TILE_CREDIT_DEFAULT = "Β© OpenStreetMap contributors"
60
+ _TILE_CREDIT_HREF_DEFAULT = "https://www.openstreetmap.org/copyright"
61
+ _GEOCODE_URL_DEFAULT = "https://nominatim.openstreetmap.org/search"
62
+ _ROUTE_URL_DEFAULT = "https://router.project-osrm.org/route/v1/driving/"
63
+
64
+ #: Both policies require a real, identifying User-Agent. A generic one is how a shared service
65
+ #: decides an application is a scraper, so this carries the product name and a contact URL.
66
+ _UA_DEFAULT = "AIOS-Loopable/1.0 (+https://runloopable.com)"
67
+
68
+ #: Nominatim publishes one request per second. 1100 ms leaves room for clock jitter rather than
69
+ #: sitting exactly on the published edge.
70
+ _MIN_INTERVAL_MS_DEFAULT = 1100
71
+
72
+ #: The most addresses one call may carry. Small on purpose: the client loops and shows progress,
73
+ #: and no single request can sit on the connection for a minute waiting out the throttle.
74
+ MAX_BATCH = 25
75
+
76
+ #: Tile zoom the provider actually serves. OSM stops at 19.
77
+ _TILE_MAX_Z_DEFAULT = 19
78
+
79
+ _CACHE_MAX = 4096
80
+
81
+
82
+ def _env(name, default):
83
+ return (os.environ.get(name) or "").strip() or default
84
+
85
+
86
+ def _env_int(name, default):
87
+ try:
88
+ n = int((os.environ.get(name) or "").strip())
89
+ return n if n > 0 else default
90
+ except (TypeError, ValueError):
91
+ return default
92
+
93
+
94
+ def provider_config():
95
+ """The whole seam as one dict. `GET /geo/providers` serves it and the gate reads it."""
96
+ return {
97
+ "tiles": {
98
+ "url": _env("AIOS_MAP_TILE_URL", _TILE_URL_DEFAULT),
99
+ "attribution": _env("AIOS_MAP_TILE_ATTRIBUTION", _TILE_CREDIT_DEFAULT),
100
+ "attributionUrl": _env("AIOS_MAP_TILE_ATTRIBUTION_URL", _TILE_CREDIT_HREF_DEFAULT),
101
+ "maxZoom": _env_int("AIOS_MAP_TILE_MAX_Z", _TILE_MAX_Z_DEFAULT),
102
+ },
103
+ "geocode": {
104
+ "available": True,
105
+ "maxBatch": MAX_BATCH,
106
+ "minIntervalMs": _env_int("AIOS_GEOCODE_MIN_INTERVAL_MS", _MIN_INTERVAL_MS_DEFAULT),
107
+ # ⭐⭐ THE NUMBER THE SURFACE MUST QUOTE, AND IT IS NOT THE RATE LIMIT.
108
+ # T37 asks for the estimated wait to be shown BEFORE a run. The obvious source is
109
+ # `minIntervalMs`, and it is wrong: 1.1 s is a floor on POLITENESS, not a prediction of
110
+ # LATENCY. One real lookup was MEASURED at 3.28 s end to end on 2026-08-19. Quoting the
111
+ # limit would promise a minute and take three, and a progress estimate that runs out
112
+ # before the work does reads as a hang rather than as a wait.
113
+ "secondsPerAddress": float(_env("AIOS_GEOCODE_SECONDS_EACH", "3.3")),
114
+ "attribution": _env("AIOS_GEOCODE_ATTRIBUTION", _TILE_CREDIT_DEFAULT),
115
+ },
116
+ "route": {
117
+ "available": True,
118
+ "attribution": _env("AIOS_ROUTE_ATTRIBUTION", "Routing by OSRM"),
119
+ },
120
+ }
121
+
122
+
123
+ # ------------------------------------------------------------------ the throttle
124
+ #
125
+ # ⚠ ONE lock and ONE timestamp for the whole process, shared by geocoding and routing, because the
126
+ # rate limit belongs to the SERVICE and not to the endpoint. Two independent throttles would each
127
+ # stay honest and together double the published rate.
128
+
129
+ _gate = threading.Lock()
130
+ _last_call = [0.0]
131
+
132
+
133
+ def _throttle(min_interval_ms):
134
+ """Block until the minimum interval since the last outbound call has elapsed.
135
+
136
+ Returns the seconds actually waited, so a caller can report the wait rather than hide it."""
137
+ wait = 0.0
138
+ with _gate:
139
+ gap = min_interval_ms / 1000.0
140
+ now = time.monotonic()
141
+ due = _last_call[0] + gap
142
+ if now < due:
143
+ wait = due - now
144
+ time.sleep(wait)
145
+ _last_call[0] = time.monotonic()
146
+ return wait
147
+
148
+
149
+ # --------------------------------------------------------------------- the cache
150
+
151
+ _cache = OrderedDict()
152
+ _cache_lock = threading.Lock()
153
+
154
+
155
+ def _cache_key(q, country):
156
+ return (re.sub(r"\s+", " ", str(q or "")).strip().lower(), str(country or "").strip().lower())
157
+
158
+
159
+ def _cache_get(key):
160
+ with _cache_lock:
161
+ if key not in _cache:
162
+ return None
163
+ _cache.move_to_end(key)
164
+ return _cache[key]
165
+
166
+
167
+ def _cache_put(key, value):
168
+ with _cache_lock:
169
+ _cache[key] = value
170
+ _cache.move_to_end(key)
171
+ while len(_cache) > _CACHE_MAX:
172
+ _cache.popitem(last=False)
173
+
174
+
175
+ def geocode_one(address, country=None, timeout=12):
176
+ """One address to {lat, lon, label} or None.
177
+
178
+ β›” THE ONLY PLACE AN OUTBOUND GEOCODE HAPPENS. `ai_enrich.py`'s geocode field calls this rather
179
+ than reaching for `requests` itself, so the throttle and the cache cannot be walked around by a
180
+ second caller. A cache hit costs no request and no wait."""
181
+ q = re.sub(r"\s+", " ", str(address or "")).strip()
182
+ if not q:
183
+ return None
184
+ key = _cache_key(q, country)
185
+ hit = _cache_get(key)
186
+ if hit is not None:
187
+ return dict(hit) if hit else None
188
+
189
+ cfg = provider_config()["geocode"]
190
+ _throttle(cfg["minIntervalMs"])
191
+ params = {"q": q, "format": "jsonv2", "limit": 1}
192
+ if country:
193
+ params["countrycodes"] = str(country).strip().lower()
194
+ try:
195
+ r = requests.get(
196
+ _env("AIOS_GEOCODE_URL", _GEOCODE_URL_DEFAULT),
197
+ params=params,
198
+ headers={"User-Agent": _env("AIOS_GEO_USER_AGENT", _UA_DEFAULT),
199
+ "Accept": "application/json"},
200
+ timeout=timeout,
201
+ )
202
+ r.raise_for_status()
203
+ rows = r.json()
204
+ except Exception:
205
+ # ⚠ A transport failure is NOT cached. Caching it would turn one bad minute into a
206
+ # permanently empty column that no re-run could ever repair.
207
+ return None
208
+ if not isinstance(rows, list) or not rows:
209
+ # A genuine "no such place" IS cached, as a negative: asking again gets the same answer and
210
+ # spends another second of a shared service's budget to hear it.
211
+ _cache_put(key, {})
212
+ return None
213
+ top = rows[0] or {}
214
+ try:
215
+ lat = float(top.get("lat"))
216
+ lon = float(top.get("lon"))
217
+ except (TypeError, ValueError):
218
+ _cache_put(key, {})
219
+ return None
220
+ if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
221
+ _cache_put(key, {})
222
+ return None
223
+ out = {"lat": lat, "lon": lon, "label": str(top.get("display_name") or q)}
224
+ _cache_put(key, out)
225
+ return dict(out)
226
+
227
+
228
+ # ---------------------------------------------------------------------- the doors
229
+
230
+
231
+ @router.get("/geo/providers")
232
+ def geo_providers(session: Session = Depends(require_session)):
233
+ """What the map should draw with, and who to credit for it.
234
+
235
+ Session gated like every other door here. The values are not secret, but an unauthenticated
236
+ endpoint on this app is a door somebody eventually hangs something else on."""
237
+ return provider_config()
238
+
239
+
240
+ @router.post("/geo/geocode")
241
+ def geo_geocode(body: dict = Body(...), session: Session = Depends(require_session)):
242
+ """Turn a bounded list of addresses into coordinates.
243
+
244
+ β›” Takes ADDRESSES, never a table key, a view id or a filter. There is deliberately no shape of
245
+ request that means "geocode everything", which is R3 expressed as an API rather than as a
246
+ warning."""
247
+ items = body.get("addresses")
248
+ # ⭐ THE SECOND SHAPE, AND IT EXISTS TO KEEP ONE COMPOSER. A caller may send whole RECORDS plus
249
+ # the geocode field's config instead of finished address strings, and the server builds the
250
+ # query with `ai_enrich.geocode_query`. The alternative was a TypeScript twin of that function
251
+ # on the client, and an address composer that exists twice will disagree the first time either
252
+ # copy learns about a new column ([[one-question-two-normalizers]]) -- which is exactly the trap
253
+ # B's measured `New York (US)` suffix would spring, silently, in whichever copy forgot.
254
+ if items is None:
255
+ records = body.get("records")
256
+ cfg = body.get("config") or {}
257
+ if isinstance(records, list) and records:
258
+ import ai_enrich
259
+ items = []
260
+ for rec in records:
261
+ if not isinstance(rec, dict):
262
+ continue
263
+ q = ai_enrich.geocode_query(rec.get("row") or {}, cfg)
264
+ # β›” A record with no usable address is REPORTED, not dropped: it comes back
265
+ # `found: false` with an empty address, so the caller can name it on screen instead
266
+ # of quietly returning fewer answers than it asked questions.
267
+ items.append({"key": rec.get("key"), "address": q})
268
+ if not body.get("country") and cfg.get("country"):
269
+ body = {**body, "country": cfg.get("country")}
270
+ if not isinstance(items, list) or not items:
271
+ raise err(400, "no_addresses", "Send at least one address to look up.")
272
+ if len(items) > MAX_BATCH:
273
+ raise err(
274
+ 400,
275
+ "batch_too_large",
276
+ f"Look up at most {MAX_BATCH} addresses per request. "
277
+ f"The map sends them in batches of {MAX_BATCH} so the wait stays visible.",
278
+ )
279
+ country = body.get("country")
280
+ out = []
281
+ started = time.monotonic()
282
+ for raw in items:
283
+ if isinstance(raw, dict):
284
+ key, addr = raw.get("key"), raw.get("address")
285
+ else:
286
+ key, addr = None, raw
287
+ # β›” AN EMPTY QUERY COSTS NO REQUEST AND CARRIES ITS OWN REASON. Nominatim's policy is a
288
+ # budget shared with everyone else using it, and asking it to place "" would spend a second
289
+ # of that budget to be told nothing. `reason` is what lets the surface say "no address on
290
+ # file" rather than "not found", which are different facts and want different actions.
291
+ if not str(addr or "").strip():
292
+ out.append({"key": key, "address": "", "found": False, "lat": None, "lon": None,
293
+ "label": None, "reason": "no_address"})
294
+ continue
295
+ hit = geocode_one(addr, country=country)
296
+ out.append({"key": key, "address": str(addr or ""), "found": bool(hit),
297
+ "reason": None if hit else "not_found",
298
+ "lat": hit["lat"] if hit else None,
299
+ "lon": hit["lon"] if hit else None,
300
+ "label": hit["label"] if hit else None})
301
+ elapsed = time.monotonic() - started
302
+ return {"results": out,
303
+ "found": sum(1 for r in out if r["found"]),
304
+ "asked": len(out),
305
+ "secondsElapsed": round(elapsed, 2),
306
+ "attribution": provider_config()["geocode"]["attribution"]}
307
+
308
+
309
+ @router.post("/geo/route")
310
+ def geo_route(body: dict = Body(...), session: Session = Depends(require_session)):
311
+ """Road distance, duration and the drawn line, for stops ALREADY put in order.
312
+
313
+ ⭐ THE ORDERING IS NOT DONE HERE. `mapProjection.planRoute` sequences the stops on the client,
314
+ for free, and keeps working when this service does not answer. This door adds the half that
315
+ arithmetic cannot produce: what the ROADS actually cost. Splitting it that way is why the Start
316
+ picker and the round-trip toggle keep working with no network at all."""
317
+ stops = body.get("stops")
318
+ if not isinstance(stops, list) or len(stops) < 2:
319
+ raise err(400, "too_few_stops", "A route needs at least two stops with a location.")
320
+ if len(stops) > 25:
321
+ raise err(400, "too_many_stops",
322
+ "Route at most 25 stops at once. Narrow the selection and try again.")
323
+ pairs = []
324
+ for s in stops:
325
+ try:
326
+ lat, lon = float(s["lat"]), float(s["lon"])
327
+ except (TypeError, ValueError, KeyError, IndexError):
328
+ raise err(400, "bad_stop", "Every stop needs a numeric latitude and longitude.")
329
+ if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
330
+ raise err(400, "bad_stop", "Every stop needs a latitude and longitude on the globe.")
331
+ pairs.append(f"{lon:.6f},{lat:.6f}")
332
+
333
+ base = _env("AIOS_ROUTE_URL", _ROUTE_URL_DEFAULT)
334
+ _throttle(_env_int("AIOS_GEOCODE_MIN_INTERVAL_MS", _MIN_INTERVAL_MS_DEFAULT))
335
+ try:
336
+ r = requests.get(
337
+ base.rstrip("/") + "/" + ";".join(pairs),
338
+ params={"overview": "simplified", "geometries": "geojson", "steps": "false"},
339
+ headers={"User-Agent": _env("AIOS_GEO_USER_AGENT", _UA_DEFAULT)},
340
+ timeout=20,
341
+ )
342
+ r.raise_for_status()
343
+ data = r.json()
344
+ except Exception:
345
+ raise err(502, "route_service_unavailable",
346
+ "The routing service did not answer. The stop order and the straight line "
347
+ "distance are still on the map.")
348
+ routes = (data or {}).get("routes") or []
349
+ if not routes:
350
+ raise err(
351
+ 502,
352
+ "no_route",
353
+ "No road route connects these stops. They may be on different land masses, or one of "
354
+ "them may be far from any road.",
355
+ )
356
+ top = routes[0]
357
+ geom = ((top.get("geometry") or {}).get("coordinates")) or []
358
+ line = []
359
+ for c in geom:
360
+ try:
361
+ line.append([float(c[0]), float(c[1])])
362
+ except (TypeError, ValueError, IndexError):
363
+ continue
364
+ return {
365
+ "km": round(float(top.get("distance") or 0.0) / 1000.0, 1),
366
+ "minutes": int(round(float(top.get("duration") or 0.0) / 60.0)),
367
+ "line": line,
368
+ "attribution": provider_config()["route"]["attribution"],
369
+ }
api/routes_nav.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_products.py CHANGED
@@ -115,15 +115,190 @@ def scoped_pool(session: Session):
115
  return pids, team_id, rows_src, fields_base
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  def product_assembly(session: Session, scope: str = "product", storage_key: str = "",
119
  consume_corrections: bool = True):
120
  """The product topic's mirror of `routes_customers.grid_assembly` β€” SAME g-dict keys, so
121
  the /workspace and events routes consume either interchangeably.
122
 
123
- One deliberate absence, a topic fact rather than a gap:
124
- * `measures`/`measure_sets` are EMPTY β€” `core.measure_resolve` is CUSTOMER-grain (the
125
- C-TOPIC v1 descope, booked in the wave doc); the events ctx therefore refuses measure
126
- creates on this surface, which is the correct fail-closed shape.
 
 
 
 
 
 
 
 
 
 
127
 
128
  ⭐ WAVE 19 / R9 β€” `lists` IS NO LONGER EMPTY. Wave 16 passed `with_cohorts=False` because
129
  there was one customer-keyed cohort bucket and product pids are CRC32 hashes of SKU codes;
@@ -172,12 +347,29 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
172
  fields = [f for f in fields if f.get("key") not in hidden]
173
  rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src]
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
176
  "fields": fields, "views": views, "lists": lists,
177
- # R9: the Cohorts column's cells, built from THIS topic's lists. Same read-only
178
- # `derived` channel the customer assembly uses β€” the measure half stays empty.
179
- "derived": aios_grid.cohort_cells(lists),
180
- "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
181
  "team_id": team_id}
182
 
183
 
 
115
  return pids, team_id, rows_src, fields_base
116
 
117
 
118
+ #: The semantic ENTITY topic this grid IS. `model/topics/odoo_products.yml` names the same store
119
+ #: key in its `grid:` field, so the binding is stated at both ends rather than inferred.
120
+ TOPIC = "product_data"
121
+ SEM_TOPIC = "odoo_products"
122
+
123
+
124
+ def _measure_err(tag, e):
125
+ try:
126
+ import harness.telemetry as _tel
127
+ _tel.error(f"api:{tag}", e)
128
+ except Exception:
129
+ pass
130
+
131
+
132
+ def _measure_stamp(rt, team_id):
133
+ """The cached pool's build timestamp β€” the DATA STAMP in every memo key, so a pool refresh
134
+ invalidates memoised measure answers exactly when the underlying rows changed. Same
135
+ discipline as `routes_customers._pool_stamp`, over THIS route's cache key."""
136
+ entry = rt.pool_cache.get(("product_pool", team_id))
137
+ return entry[0] if isinstance(entry, tuple) and entry else 0
138
+
139
+
140
+ def product_measures():
141
+ """The measure OFFER for this database, or `[]` when the semantic layer cannot answer.
142
+
143
+ ⭐⭐ W37-T10 / owner item 4 (R1) β€” this is what turns `measures: []` into a real catalogue.
144
+ Owner: *"Odoo products should have a lookbsck metrics like sales etc."* The list is DERIVED
145
+ from `model/topics/odoo_products.yml`'s `measures:` binding, never written here β€” a second
146
+ list would be a second definition of the same fact.
147
+
148
+ ⚠ `[]` ON FAILURE IS THE CORRECT DEGRADE and it is not silent: `entity_measures` refuses a key
149
+ it cannot prove, the reasons are readable through `semantic.entity_measure_refusals`, and the
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)
156
+ except Exception as e: # noqa: BLE001
157
+ _measure_err("product-measure-offer", e)
158
+ return []
159
+
160
+
161
+ def _measure_cells(fields, rows_src, team_id, today, stamp, memo, offer):
162
+ """`{pid: {field key: value}}` for every measure column on this grid.
163
+
164
+ β›” THIS IS THE HALF THAT MAKES THE FEATURE REAL. Serving the OFFER alone flips the client's
165
+ `offerMeasure` on and lets a user mint a Metric column that would then be blank forever β€”
166
+ this repo's most repeated defect ([[reachable-is-not-the-same-as-built]]). Nothing else
167
+ computes product-grain measure values: `core.measure_resolve.column_values` is CUSTOMER-grain
168
+ (it resolves through `harness.measure_filter`, whose entity is the partner), which is exactly
169
+ what this route's docstring used to record as an honest absence.
170
+
171
+ ⭐ ONE QUERY PER WINDOW, not per column. Two Metric columns over the same 90 days are one
172
+ grouped scan; the fan-out to field keys happens in memory afterwards.
173
+
174
+ β›” THE JOIN IS BY SKU CODE, per contract C1's trap. `sales_lines.product_code` reproduces this
175
+ pool's identity (`default_code`, or `pid:<odoo id>` for the 33 codeless actives) and `pid` is
176
+ a CRC32 of it β€” so the map is `row['code'] -> row['pid']`, taken off the rows we already hold.
177
+ Grouping by Odoo's `product_id` instead would key cells on a value no grid row carries.
178
+ """
179
+ import aios_grid
180
+
181
+ mfields = aios_grid.measure_fields_of(fields)
182
+ if not mfields or not offer:
183
+ return {}
184
+ from harness import semantic as sem
185
+ from harness import windows as _wn
186
+
187
+ by_key = {m["key"]: m for m in offer}
188
+ codes = {r["pid"]: r["code"] for r in rows_src if r.get("pid") is not None}
189
+
190
+ # Group the columns by WINDOW: same window, same scan.
191
+ by_window = {}
192
+ for f in mfields:
193
+ spec = f.get("measure") or {}
194
+ if spec.get("key") not in by_key:
195
+ continue # not offered for this caller -> the column stays blank, honestly
196
+ w = _wn.normalize(spec.get("window"))
197
+ rng = _wn.resolve(spec.get("window"), today) if w is not None else None
198
+ if rng is None:
199
+ # β›” NEVER WIDEN TO ALL TIME. An unresolvable window is an unanswerable column, and
200
+ # silently answering a different question is worse than a blank one.
201
+ continue
202
+ by_window.setdefault((rng[0], rng[1]), []).append(f)
203
+
204
+ out = {}
205
+ for (dfrom, dto), group in by_window.items():
206
+ keys = tuple(sorted({f["measure"]["key"] for f in group}))
207
+ mkey = ("entity", SEM_TOPIC, stamp, team_id, dfrom, dto, keys)
208
+ if mkey not in memo:
209
+ try:
210
+ memo[mkey] = sem.entity_measure_values(
211
+ SEM_TOPIC, list(keys), date_from=dfrom, date_to=dto, team_id=team_id,
212
+ # β›” SERVICES ARE IN THIS CATALOGUE (37 active), so they must NOT be excluded
213
+ # here. The service filter exists to stop Delivery Charges polluting a SKU
214
+ # RANKING; on a per-row column the row IS the service product, and excluding
215
+ # it would print a FALSE 0 β€” which under C1's rule asserts "sold nothing".
216
+ exclude_services=False, offer=offer)
217
+ except Exception as e: # noqa: BLE001
218
+ _measure_err("product-measure-column", e)
219
+ # Same memo discipline as `measure_resolve.column_values`: a PERMANENT failure
220
+ # memoises, a TRANSIENT one (store still warming after a restart) must not, or
221
+ # the cells stay blank for the rest of the session long after the data landed.
222
+ transient = False
223
+ try:
224
+ from harness import datastore as _ds
225
+ transient = not _ds.ready()
226
+ except Exception: # noqa: BLE001
227
+ transient = False
228
+ if transient:
229
+ continue
230
+ memo[mkey] = None
231
+ vals = memo[mkey]
232
+ if vals is None:
233
+ continue
234
+ # C1's empty-window rule, applied to EVERY row β€” not only the ones with a group. 72% of
235
+ # this catalogue has no group in a 90-day window, so this loop IS the common case.
236
+ filled = {pid: sem.entity_zero_fill(vals.get(code), list(keys), offer)
237
+ for pid, code in codes.items()}
238
+ for f in group:
239
+ k, fkey = f["measure"]["key"], f["key"]
240
+ for pid, cell in filled.items():
241
+ if k in cell:
242
+ out.setdefault(pid, {})[fkey] = cell[k]
243
+ if len(memo) > 100: # a lifetime cache, not a leak
244
+ memo.clear()
245
+ return out
246
+
247
+
248
+ def _measure_condition_sets(views, offer, rows_src, team_id, today):
249
+ """`{rule id: [pid, …]}` for every measure CONDITION across this session's saved views.
250
+
251
+ ⚠ THE ANSWER IS KEYED BY pid, NOT BY SKU CODE. `entity_measure_sets` answers in the SOURCE
252
+ dim's own values (the code) because it cannot know how a grid hashes identity; the map back
253
+ is this route's job, exactly as it is for the column cells.
254
+ """
255
+ if not offer:
256
+ return {}
257
+ from harness import semantic as sem
258
+
259
+ admitted = {m["key"] for m in offer}
260
+ by_id = {}
261
+ for v in views or []:
262
+ cfg = (v.get("config") or {})
263
+ for rule in sem.entity_measure_leaves(cfg.get("filters"), admitted):
264
+ rid = str(rule.get("id") or "")
265
+ if rid:
266
+ by_id[rid] = rule # last statement of the question wins
267
+ if not by_id:
268
+ return {}
269
+ pid_by_code = {r["code"]: r["pid"] for r in rows_src if r.get("pid") is not None}
270
+ try:
271
+ sets = sem.entity_measure_sets(
272
+ SEM_TOPIC, list(by_id.values()), today, team_id=team_id,
273
+ keys_by_id=list(pid_by_code), offer=offer, exclude_services=False)
274
+ except Exception as e: # noqa: BLE001
275
+ _measure_err("product-measure-sets", e)
276
+ return {}
277
+ # A code the pool does not carry is dropped rather than keyed on None β€” the same rule the
278
+ # cell path applies, for the same reason.
279
+ return {rid: sorted(pid_by_code[c] for c in codes if c in pid_by_code)
280
+ for rid, codes in sets.items()}
281
+
282
+
283
  def product_assembly(session: Session, scope: str = "product", storage_key: str = "",
284
  consume_corrections: bool = True):
285
  """The product topic's mirror of `routes_customers.grid_assembly` β€” SAME g-dict keys, so
286
  the /workspace and events routes consume either interchangeably.
287
 
288
+ ⭐⭐ W37-T10 / OWNER ITEM 4 (R1) β€” `measures` IS NO LONGER EMPTY, and the header above it used
289
+ to record the opposite as a topic fact. It was one: `core.measure_resolve` is CUSTOMER-grain,
290
+ so nothing here could resolve a product-grain measure. What changed is that the ENTITY path
291
+ exists now (`semantic.entity_measures` / `entity_measure_values`, bound in
292
+ `model/topics/odoo_products.yml`), so this surface serves a real six-metric catalogue AND the
293
+ cells to go with it β€” the offer and the resolver landing together, deliberately, because an
294
+ offer whose values nobody computes is a column that renders blank forever.
295
+
296
+ One deliberate absence remains, and it is a real one:
297
+ * `measure_sets` stays EMPTY β€” that is the measure CONDITION channel (a filter answered
298
+ server-side as a pid set), which still runs through the customer-grain
299
+ `measure_resolve.condition_sets`. So a Metric COLUMN works here; a Metric FILTER does
300
+ not yet. Stated rather than left to be discovered: `clean_filter_tree` drops a measure
301
+ condition whose key is not in `measure_keys`, so the refusal is already fail-closed.
302
 
303
  ⭐ WAVE 19 / R9 β€” `lists` IS NO LONGER EMPTY. Wave 16 passed `with_cohorts=False` because
304
  there was one customer-keyed cohort bucket and product pids are CRC32 hashes of SKU codes;
 
347
  fields = [f for f in fields if f.get("key") not in hidden]
348
  rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src]
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
356
+ # measure columns here for exactly the same reason and through the same door.
357
+ derived = aios_grid.cohort_cells(lists)
358
+ for pid, cells in _measure_cells(fields, rows_src, team_id, today, stamp,
359
+ session.runtime.measure_memo, measures).items():
360
+ derived.setdefault(pid, {}).update(cells)
361
+
362
+ # β›” THE CONDITION CHANNEL SHIPS WITH THE OFFER, NOT AFTER IT. `routes_grid` derives
363
+ # `measure_keys` from this same list, so the moment `measures` is non-empty the client's
364
+ # filter builder offers measure conditions β€” and an id missing from `measureSets` renders as
365
+ # PENDING ("Calculating…") and matches nothing, permanently. Serving the offer without this
366
+ # would trade one honest absence for a spinner that never resolves.
367
+ measure_sets = _measure_condition_sets(views, measures, rows_src, team_id, today)
368
+
369
  return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
370
  "fields": fields, "views": views, "lists": lists,
371
+ "derived": derived,
372
+ "measures": measures, "measure_sets": measure_sets, "today": today,
 
 
373
  "team_id": team_id}
374
 
375
 
api/routes_script_views.py CHANGED
@@ -1,329 +1,439 @@
1
- """routes_script_views.py β€” CONTRACT C3: a database View that is a PYTHON SCRIPT (R3 / R5 / R10).
2
-
3
- Owner item 6, verbatim (2026-08-18): *"Add code script as an interface (database View) so a user
4
- can build whatever they want through the Agent chat interface. be able to create any dashboard
5
- they want. User should have the ability to see the code AND the dashboard output of course… Limit
6
- the code script View per database… Any agent can add into more AI script, so we can see different
7
- versions or different things the AI code for us."*
8
-
9
- GET /api/v1/script-views?database=K the views bound to ONE database
10
- POST /api/v1/script-views create one {database, name?, source}
11
- GET /api/v1/script-views/{id} one view, its source and its history
12
- PUT /api/v1/script-views/{id} a NEW VERSION of the source
13
- DELETE /api/v1/script-views/{id} drop it
14
- POST /api/v1/script-views/{id}/run run it -> {ok, spec | error, stdout, ms}
15
-
16
- ⭐⭐ **R3 IS "SCOPED, NOT CAPPED", AND THE TWO HALVES POINT OPPOSITE WAYS.** *Scoped*: a view may
17
- read ONLY the database it lives in, and a script that names another database is REFUSED with a
18
- message naming both. *Not capped*: there is **no limit on how many script views a database may
19
- carry**, because that is how an agent offers three attempts and the owner picks one. So nothing
20
- below counts views. What IS bounded is what makes them big β€” one source is capped, and one view's
21
- edit history is capped and REPORTS what it dropped.
22
-
23
- β›” **THE RUN IS THE CALLER'S, NEVER THE AUTHOR'S (R5).** `script_sandbox.run_view` is handed
24
- `session.user`, so a script written by an administrator and opened by a scoped analyst reads the
25
- ANALYST's rows. The author decides what the code does; the reader decides what it can see.
26
- ⚠ And the reverse case is safe rather than lucky: a narrowly-scoped author cannot write a script
27
- that exfiltrates anything, because the only thing a script can return is a render spec drawn on
28
- the screen of the person who ran it. There is no network, no file and no second reader.
29
-
30
- β›” **`run` IS `def`, NOT `async def`.** It waits on a subprocess for up to ten seconds; as a
31
- coroutine that would block the event loop for every other request in the container. FastAPI runs a
32
- plain `def` in the threadpool, which is what makes one slow script one slow REQUEST.
33
- """
34
- import threading
35
- from datetime import datetime, timezone
36
-
37
- from fastapi import APIRouter, Body, Depends
38
-
39
- from deps import Session, err, require_session
40
-
41
- router = APIRouter(prefix="/api/v1")
42
-
43
- #: The tenant's script views: `{id: record}`. Per tenant, so it rides `runtime.store_key`.
44
- VIEWS_KEY = "script_views"
45
-
46
- MAX_NAME = 80
47
- MAX_SOURCE_BYTES = 128 * 1024
48
-
49
- #: Edit history per view. ⚠ NOT a cap on the NUMBER of views (R3 forbids that) β€” a cap on how far
50
- #: back ONE view's source is kept. Past this the oldest go and `trimmed` counts them, so a reader
51
- #: can see the history is partial instead of concluding the view was only ever saved twice.
52
- MAX_HISTORY = 40
53
-
54
- #: β›” HOW MANY SCRIPTS MAY BE RUNNING IN THIS CONTAINER AT ONCE, and it is a REPORTED refusal
55
- #: rather than a queue. Each run is a real subprocess with a ten-second wall clock; without this,
56
- #: holding down refresh forks until the box gives up, and the tenant's ONE FastAPI process is what
57
- #: gives up. A 429 that says so is honest; an unbounded fork is not.
58
- MAX_CONCURRENT_RUNS = 4
59
- _RUN_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_RUNS)
60
-
61
-
62
- def _now():
63
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
64
-
65
-
66
- def _all(rt):
67
- """`{id: record}` for one tenant. `{}` on any failure β€” an unreadable bucket must degrade to
68
- "this database has no script views", never to a 500 on the view rail."""
69
- try:
70
- found = rt.get(VIEWS_KEY) or {}
71
- except Exception: # noqa: BLE001
72
- return {}
73
- return found if isinstance(found, dict) else {}
74
-
75
-
76
- def _database_ok(session, database):
77
- """Does this database EXIST, and may this caller read it? Answered by C1, never by a list.
78
-
79
- β›” `perm_scope.may_read` ALONE IS NOT ENOUGH and the reason is easy to miss: it answers True
80
- for an ADMIN on any key at all, including one no database answers to. So a create validated
81
- with `may_read` would let an administrator bind a view to a typo and leave an orphan nothing
82
- can ever run. `scoped_fields` is the cheap half of C1 (a `ut_*` definition, no rows) and it
83
- RAISES `UnknownTable`, which is exactly the question being asked.
84
- """
85
- import core.perm_scope as perm_scope
86
- try:
87
- perm_scope.scoped_fields(session.user, database, st=session.runtime)
88
- except perm_scope.UnknownTable:
89
- raise err(404, "no_database", f"there is no database '{database}' in this workspace")
90
- except perm_scope.Denied:
91
- raise err(403, "forbidden", f"your account may not read '{database}'")
92
- except perm_scope.Unresolvable as exc:
93
- # The database is real and cannot be served under this call's constraints. Standing rule
94
- # 1's second sentence: report the cause and the recommendation, never a bare refusal.
95
- raise err(409, "unresolvable", str(exc)) from None
96
-
97
-
98
- def _clean_source(raw):
99
- source = str(raw or "")
100
- if not source.strip():
101
- raise err(400, "no_source", "a script view needs some code")
102
- if len(source.encode("utf-8", "replace")) > MAX_SOURCE_BYTES:
103
- raise err(413, "source_too_long",
104
- f"a script view is at most {MAX_SOURCE_BYTES // 1024} KB of code")
105
- return source
106
-
107
-
108
- def _row(rec, *, source=False):
109
- """One view as the list door reports it. ⚠ NO SOURCE unless asked: the rail lists names."""
110
- out = {"id": rec.get("id") or "", "database": rec.get("database") or "",
111
- "name": rec.get("name") or "", "author": rec.get("author") or "",
112
- "version": int(rec.get("version") or 1),
113
- "created": rec.get("created") or "", "updated": rec.get("updated") or "",
114
- "versions": len(rec.get("history") or []) + 1,
115
- "trimmed": int(rec.get("trimmed") or 0)}
116
- if source:
117
- out["source"] = rec.get("source") or ""
118
- out["history"] = [{"version": int(h.get("version") or 0), "author": h.get("author") or "",
119
- "created": h.get("created") or "",
120
- "bytes": len(str(h.get("source") or "").encode("utf-8", "replace"))}
121
- for h in reversed(rec.get("history") or []) if isinstance(h, dict)]
122
- return out
123
-
124
-
125
- def _limits():
126
- return {"maxSourceBytes": MAX_SOURCE_BYTES, "maxName": MAX_NAME,
127
- "maxHistory": MAX_HISTORY, "maxConcurrentRuns": MAX_CONCURRENT_RUNS,
128
- # ⭐ SAID OUT LOUD, because R3's "not capped" half is the one a reader assumes wrong.
129
- "maxViewsPerDatabase": None}
130
-
131
-
132
- def _put(session, view_id, mutate):
133
- """Read-modify-write ONE view, synchronously β€” the client re-reads the rail immediately."""
134
- def _set(cur):
135
- cur = dict(cur or {})
136
- nxt = mutate(cur.get(view_id) if isinstance(cur.get(view_id), dict) else None)
137
- if nxt is None:
138
- cur.pop(view_id, None)
139
- else:
140
- cur[view_id] = nxt
141
- return cur
142
-
143
- session.runtime.update(VIEWS_KEY, _set, flush="sync")
144
-
145
-
146
- def _mine_or_admin(session, rec):
147
- """Who may EDIT or DELETE a view: its author, or an administrator.
148
-
149
- ⚠ RUNNING IS A DIFFERENT QUESTION and deliberately wider β€” anybody who may read the database
150
- may run any view on it, under their OWN scope. That is the whole of "so we can see different
151
- versions or different things the AI code for us": a colleague's attempt is worth nothing if
152
- only its author can open it.
153
- """
154
- if session.admin or str(rec.get("author") or "") == session.uname:
155
- return
156
- raise err(403, "forbidden", "only the author or an administrator can change this script view")
157
-
158
-
159
- # ── the routes ────────────────────────────────────────────────────────────────────────────────
160
- @router.get("/script-views")
161
- def list_script_views(database: str = "", session: Session = Depends(require_session)):
162
- """Every script view bound to ONE database, newest first. `database` is required."""
163
- key = str(database or "").strip()
164
- if not key:
165
- raise err(400, "no_database", "name the database whose script views you want")
166
- _database_ok(session, key)
167
- rows = [_row(rec) for rec in _all(session.runtime).values()
168
- if isinstance(rec, dict) and str(rec.get("database") or "") == key]
169
- rows.sort(key=lambda r: (r["created"], r["id"]), reverse=True)
170
- return {"database": key, "views": rows, "limits": _limits()}
171
-
172
-
173
- @router.post("/script-views")
174
- def create_script_view(body: dict = Body(default=None),
175
- session: Session = Depends(require_session)):
176
- """Create one. β›” THE SOURCE IS CHECKED BEFORE IT IS STORED, not first at run time.
177
-
178
- A script view that cannot be run is a broken feature the reader discovers by pressing a button,
179
- and the agent that wrote it is long gone by then. `check_source` is pure and costs no process,
180
- so the refusal arrives while the author still has the code in front of them.
181
- """
182
- import secrets # noqa: PLC0415
183
- import core.script_sandbox as sandbox # noqa: PLC0415
184
-
185
- body = body if isinstance(body, dict) else {}
186
- database = str(body.get("database") or "").strip()
187
- if not database:
188
- raise err(400, "no_database", "a script view is bound to one database")
189
- _database_ok(session, database)
190
- source = _clean_source(body.get("source"))
191
- refusal = sandbox.check_source(source)
192
- if refusal is not None:
193
- raise err(400, refusal.code, refusal.message)
194
-
195
- view_id = "sv_" + secrets.token_urlsafe(9)
196
- rec = {"id": view_id, "database": database,
197
- "name": " ".join(str(body.get("name") or "Script view").split())[:MAX_NAME],
198
- "source": source, "author": session.uname, "version": 1,
199
- "created": _now(), "updated": _now(), "history": [], "trimmed": 0}
200
- _put(session, view_id, lambda _prior: rec)
201
- fresh = _all(session.runtime).get(view_id)
202
- if not isinstance(fresh, dict):
203
- # The store took the write and did not record it. A 200 here would tell the author their
204
- # script was saved when it was not.
205
- raise err(503, "store_unavailable", "the script view was NOT created")
206
- return {"view": _row(fresh, source=True), "limits": _limits()}
207
-
208
-
209
- @router.get("/script-views/{view_id}")
210
- def get_script_view(view_id: str, session: Session = Depends(require_session)):
211
- """One view WITH its source and its edit history. Owner item 6's *"see the code"* half."""
212
- rec = _all(session.runtime).get(str(view_id))
213
- if not isinstance(rec, dict):
214
- raise err(404, "no_view", "there is no script view with that id")
215
- _database_ok(session, str(rec.get("database") or ""))
216
- return {"view": _row(rec, source=True), "limits": _limits()}
217
-
218
-
219
- @router.put("/script-views/{view_id}")
220
- def update_script_view(view_id: str, body: dict = Body(default=None),
221
- session: Session = Depends(require_session)):
222
- """A NEW VERSION of one view's source. The prior source is KEPT, never replaced in place."""
223
- import core.script_sandbox as sandbox # noqa: PLC0415
224
-
225
- view_id = str(view_id)
226
- rec = _all(session.runtime).get(view_id)
227
- if not isinstance(rec, dict):
228
- raise err(404, "no_view", "there is no script view with that id")
229
- _mine_or_admin(session, rec)
230
- body = body if isinstance(body, dict) else {}
231
- source = _clean_source(body.get("source"))
232
- refusal = sandbox.check_source(source)
233
- if refusal is not None:
234
- raise err(400, refusal.code, refusal.message)
235
-
236
- def _mutate(prior):
237
- prior = dict(prior or rec)
238
- history = list(prior.get("history") or [])
239
- history.append({"version": int(prior.get("version") or 1),
240
- "source": prior.get("source") or "",
241
- "author": prior.get("author") or "", "created": prior.get("updated") or ""})
242
- dropped = max(0, len(history) - MAX_HISTORY)
243
- prior["history"] = history[dropped:] if dropped else history
244
- prior["trimmed"] = int(prior.get("trimmed") or 0) + dropped
245
- prior["source"] = source
246
- prior["version"] = int(prior.get("version") or 1) + 1
247
- prior["updated"] = _now()
248
- if body.get("name"):
249
- prior["name"] = " ".join(str(body["name"]).split())[:MAX_NAME]
250
- return prior
251
-
252
- _put(session, view_id, _mutate)
253
- fresh = _all(session.runtime).get(view_id)
254
- if not isinstance(fresh, dict):
255
- raise err(503, "store_unavailable", "the new version was NOT saved")
256
- return {"view": _row(fresh, source=True), "limits": _limits()}
257
-
258
-
259
- @router.delete("/script-views/{view_id}")
260
- def delete_script_view(view_id: str, session: Session = Depends(require_session)):
261
- view_id = str(view_id)
262
- rec = _all(session.runtime).get(view_id)
263
- if not isinstance(rec, dict):
264
- raise err(404, "no_view", "there is no script view with that id")
265
- _mine_or_admin(session, rec)
266
- _put(session, view_id, lambda _prior: None)
267
- return {"deleted": view_id}
268
-
269
-
270
- @router.post("/script-views/{view_id}/run")
271
- def run_script_view(view_id: str, body: dict = Body(default=None),
272
- session: Session = Depends(require_session)):
273
- """CONTRACT C3's run door: `{ok, spec | error, stdout, ms}`.
274
-
275
- β›” `spec` IS A DESCRIPTION THE CLIENT DRAWS. It is never HTML and never text the browser
276
- executes β€” the sandbox refuses a spec carrying an `html`, `script`, `src`, `href` or `on*` key
277
- before this function ever sees it, so a renderer cannot be talked into running something by a
278
- script that was itself perfectly well behaved.
279
-
280
- β›” AND IT IS PLAIN `def`, NOT `async def` β€” see this module's header. A ten-second subprocess
281
- wait on the event loop is a ten-second outage for the whole container.
282
- """
283
- import core.script_sandbox as sandbox # noqa: PLC0415
284
-
285
- rec = _all(session.runtime).get(str(view_id))
286
- if not isinstance(rec, dict):
287
- raise err(404, "no_view", "there is no script view with that id")
288
- database = str(rec.get("database") or "")
289
- # A DRAFT run: the editor sends unsaved code so the author can try it before committing to it.
290
- # It is checked exactly as a stored one is, because "unsaved" is not a permission.
291
- draft = (body or {}).get("source") if isinstance(body, dict) else None
292
- source = _clean_source(draft) if draft else str(rec.get("source") or "")
293
- # ⚠ NO `_database_ok` CALL HERE. `run_view` asks C1 the identical question a line later, and
294
- # asking twice builds a registry topic's pool twice. The three refusals are translated below
295
- # instead, which is the same wall reached through the same door.
296
-
297
- if not _RUN_SLOTS.acquire(blocking=False):
298
- raise err(429, "busy",
299
- f"{MAX_CONCURRENT_RUNS} script views are already running on this server. "
300
- f"Try again in a moment")
301
- try:
302
- out = sandbox.run_view(session.user, database, source, st=session.runtime)
303
- finally:
304
- _RUN_SLOTS.release()
305
-
306
- # β›” C1'S THREE REFUSALS ARE HTTP STATUSES, NOT `ok:false`. "You may not read this database"
307
- # answered 200 would be a permission decision the client has to go looking for, and every
308
- # other door in this app answers 403 for it. Everything BELOW this line is a well-formed
309
- # request whose ANSWER is that the script did not produce a view β€” that is a 200 with
310
- # `ok:false`, the shape `routes_web_agent.test` already uses for the same reason.
311
- if out.get("code") == "unknown_table":
312
- raise err(404, "no_database", out.get("message") or f"there is no database '{database}'")
313
- if out.get("code") == "denied":
314
- raise err(403, "forbidden", out.get("message") or "your account may not read that database")
315
- if out.get("code") == "unresolvable":
316
- refusal = err(409, "unresolvable", out.get("message") or "these rows cannot be served")
317
- refusal.detail["error"]["limit"] = out.get("limit") or {}
318
- raise refusal
319
-
320
- answer = {"ok": bool(out.get("ok")), "spec": out.get("spec"),
321
- "stdout": out.get("stdout") or "", "truncated": bool(out.get("truncated")),
322
- "ms": int(out.get("ms") or 0), "code": out.get("code") or "",
323
- # ⭐ `caps` RIDES ON THE ANSWER (standing rule 1). On a POSIX host all three limits
324
- # were applied; on a Windows host the memory and CPU ones were not, and a screen
325
- # that claims an enforcement which did not happen is the failure the rule is about.
326
- "caps": out.get("caps") or {}}
327
- if not answer["ok"]:
328
- answer["error"] = out.get("message") or "the script view did not produce a view"
329
- return answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_script_views.py β€” CONTRACT C3: a database View that is a PYTHON SCRIPT (R3 / R5 / R10).
2
+
3
+ Owner item 6, verbatim (2026-08-18): *"Add code script as an interface (database View) so a user
4
+ can build whatever they want through the Agent chat interface. be able to create any dashboard
5
+ they want. User should have the ability to see the code AND the dashboard output of course… Limit
6
+ the code script View per database… Any agent can add into more AI script, so we can see different
7
+ versions or different things the AI code for us."*
8
+
9
+ GET /api/v1/script-views?database=K the views bound to ONE database
10
+ POST /api/v1/script-views create one {database, name?, source}
11
+ GET /api/v1/script-views/{id} one view, its source and its history
12
+ PUT /api/v1/script-views/{id} a NEW VERSION of the source
13
+ DELETE /api/v1/script-views/{id} drop it
14
+ POST /api/v1/script-views/{id}/run run it -> {ok, spec | error, stdout, ms}
15
+ POST /api/v1/script-views/{id}/revert go back to an earlier version {version}
16
+
17
+ ⭐⭐ **R3 IS "SCOPED, NOT CAPPED", AND THE TWO HALVES POINT OPPOSITE WAYS.** *Scoped*: a view may
18
+ read ONLY the database it lives in, and a script that names another database is REFUSED with a
19
+ message naming both. *Not capped*: there is **no limit on how many script views a database may
20
+ carry**, because that is how an agent offers three attempts and the owner picks one. So nothing
21
+ below counts views. What IS bounded is what makes them big β€” one source is capped, and one view's
22
+ edit history is capped and REPORTS what it dropped.
23
+
24
+ β›” **THE RUN IS THE CALLER'S, NEVER THE AUTHOR'S (R5).** `script_sandbox.run_view` is handed
25
+ `session.user`, so a script written by an administrator and opened by a scoped analyst reads the
26
+ ANALYST's rows. The author decides what the code does; the reader decides what it can see.
27
+ ⚠ And the reverse case is safe rather than lucky: a narrowly-scoped author cannot write a script
28
+ that exfiltrates anything, because the only thing a script can return is a render spec drawn on
29
+ the screen of the person who ran it. There is no network, no file and no second reader.
30
+
31
+ β›” **`run` IS `def`, NOT `async def`.** It waits on a subprocess for up to ten seconds; as a
32
+ coroutine that would block the event loop for every other request in the container. FastAPI runs a
33
+ plain `def` in the threadpool, which is what makes one slow script one slow REQUEST.
34
+ """
35
+ import threading
36
+ from datetime import datetime, timezone
37
+
38
+ from fastapi import APIRouter, Body, Depends
39
+
40
+ from deps import Session, err, require_session
41
+
42
+ router = APIRouter(prefix="/api/v1")
43
+
44
+ #: The tenant's script views: `{id: record}`. Per tenant, so it rides `runtime.store_key`.
45
+ VIEWS_KEY = "script_views"
46
+
47
+ MAX_NAME = 80
48
+ MAX_SOURCE_BYTES = 128 * 1024
49
+
50
+ #: Edit history per view. ⚠ NOT a cap on the NUMBER of views (R3 forbids that) β€” a cap on how far
51
+ #: back ONE view's source is kept. Past this the oldest go and `trimmed` counts them, so a reader
52
+ #: can see the history is partial instead of concluding the view was only ever saved twice.
53
+ MAX_HISTORY = 40
54
+
55
+ #: β›” HOW MANY SCRIPTS MAY BE RUNNING IN THIS CONTAINER AT ONCE, and it is a REPORTED refusal
56
+ #: rather than a queue. Each run is a real subprocess with a ten-second wall clock; without this,
57
+ #: holding down refresh forks until the box gives up, and the tenant's ONE FastAPI process is what
58
+ #: gives up. A 429 that says so is honest; an unbounded fork is not.
59
+ MAX_CONCURRENT_RUNS = 4
60
+ _RUN_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_RUNS)
61
+
62
+
63
+ def _now():
64
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
65
+
66
+
67
+ def _all(rt):
68
+ """`{id: record}` for one tenant. `{}` on any failure β€” an unreadable bucket must degrade to
69
+ "this database has no script views", never to a 500 on the view rail."""
70
+ try:
71
+ found = rt.get(VIEWS_KEY) or {}
72
+ except Exception: # noqa: BLE001
73
+ return {}
74
+ return found if isinstance(found, dict) else {}
75
+
76
+
77
+ def _database_ok(session, database):
78
+ """Does this database EXIST, and may this caller read it? Answered by C1, never by a list.
79
+
80
+ β›” `perm_scope.may_read` ALONE IS NOT ENOUGH and the reason is easy to miss: it answers True
81
+ for an ADMIN on any key at all, including one no database answers to. So a create validated
82
+ with `may_read` would let an administrator bind a view to a typo and leave an orphan nothing
83
+ can ever run. `scoped_fields` is the cheap half of C1 (a `ut_*` definition, no rows) and it
84
+ RAISES `UnknownTable`, which is exactly the question being asked.
85
+ """
86
+ import core.perm_scope as perm_scope
87
+ try:
88
+ perm_scope.scoped_fields(session.user, database, st=session.runtime)
89
+ except perm_scope.UnknownTable:
90
+ raise err(404, "no_database", f"there is no database '{database}' in this workspace")
91
+ except perm_scope.Denied:
92
+ raise err(403, "forbidden", f"your account may not read '{database}'")
93
+ except perm_scope.Unresolvable as exc:
94
+ # The database is real and cannot be served under this call's constraints. Standing rule
95
+ # 1's second sentence: report the cause and the recommendation, never a bare refusal.
96
+ raise err(409, "unresolvable", str(exc)) from None
97
+
98
+
99
+ def _clean_source(raw, *, allow_empty=False):
100
+ """The stored source, or a 400. `allow_empty` is CREATE's alone and the asymmetry is the point.
101
+
102
+ ⭐⭐ W37-T41 β€” WHY CREATE MAY BE EMPTY AND SAVE MAY NOT.
103
+ Picking "Custom View" in the mode picker mints the view immediately, before a line of code
104
+ exists, so that `mode === 'script'` always implies a real id and no reader has to carry a
105
+ "the id might be missing" branch (the create semantics handed to lane C in mailbox E-7).
106
+ A view that has been created and not yet written is therefore a REAL, legible state: the editor
107
+ shows its starter placeholder and the Run control is right there.
108
+ A PUT is a different act. It appends a VERSION to a history capped at 40, and blanking a
109
+ working script by saving nothing over it is not an edit anybody means to make. So the guard
110
+ stays exactly where it was on that door, and a person who wants the view gone deletes it.
111
+
112
+ β›” THIS WAS FOUND BY THE GATE, NOT BY READING. `core.script_sandbox.check_source("")` returns
113
+ None, so "an empty script is storable" looked true and was written into a mailbox answer another
114
+ lane was about to build on. The refusal was HERE, one layer above, in a function the sandbox
115
+ knows nothing about. Two validators for one question, disagreeing
116
+ [[one-question-two-normalizers]] β€” and the one that would have bitten a person is the one no
117
+ unit of this feature was asserting.
118
+ """
119
+ source = str(raw or "")
120
+ if not source.strip() and not allow_empty:
121
+ raise err(400, "no_source", "a script view needs some code")
122
+ if len(source.encode("utf-8", "replace")) > MAX_SOURCE_BYTES:
123
+ raise err(413, "source_too_long",
124
+ f"a script view is at most {MAX_SOURCE_BYTES // 1024} KB of code")
125
+ return source
126
+
127
+
128
+ def _row(rec, *, source=False):
129
+ """One view as the list door reports it. ⚠ NO SOURCE unless asked: the rail lists names."""
130
+ out = {"id": rec.get("id") or "", "database": rec.get("database") or "",
131
+ "name": rec.get("name") or "", "author": rec.get("author") or "",
132
+ "version": int(rec.get("version") or 1),
133
+ "created": rec.get("created") or "", "updated": rec.get("updated") or "",
134
+ "versions": len(rec.get("history") or []) + 1,
135
+ "trimmed": int(rec.get("trimmed") or 0),
136
+ # ⭐ W37-T46: which version this one was RESTORED from, when it was. Present on the
137
+ # row (not only the history) because the editor's header is where a person reads "what
138
+ # am I looking at", and "v5, restored from v2" is the sentence that makes a roll-back
139
+ # legible as an event rather than as a coincidence of matching code.
140
+ "restoredFrom": rec.get("restoredFrom")}
141
+ if source:
142
+ out["source"] = rec.get("source") or ""
143
+ out["history"] = [{"version": int(h.get("version") or 0), "author": h.get("author") or "",
144
+ "created": h.get("created") or "",
145
+ "bytes": len(str(h.get("source") or "").encode("utf-8", "replace"))}
146
+ for h in reversed(rec.get("history") or []) if isinstance(h, dict)]
147
+ return out
148
+
149
+
150
+ def _limits():
151
+ return {"maxSourceBytes": MAX_SOURCE_BYTES, "maxName": MAX_NAME,
152
+ "maxHistory": MAX_HISTORY, "maxConcurrentRuns": MAX_CONCURRENT_RUNS,
153
+ # ⭐ SAID OUT LOUD, because R3's "not capped" half is the one a reader assumes wrong.
154
+ "maxViewsPerDatabase": None}
155
+
156
+
157
+ def _put(session, view_id, mutate):
158
+ """Read-modify-write ONE view, synchronously β€” the client re-reads the rail immediately."""
159
+ def _set(cur):
160
+ cur = dict(cur or {})
161
+ nxt = mutate(cur.get(view_id) if isinstance(cur.get(view_id), dict) else None)
162
+ if nxt is None:
163
+ cur.pop(view_id, None)
164
+ else:
165
+ cur[view_id] = nxt
166
+ return cur
167
+
168
+ session.runtime.update(VIEWS_KEY, _set, flush="sync")
169
+
170
+
171
+ def _mine_or_admin(session, rec):
172
+ """Who may EDIT or DELETE a view: its author, or an administrator.
173
+
174
+ ⚠ RUNNING IS A DIFFERENT QUESTION and deliberately wider β€” anybody who may read the database
175
+ may run any view on it, under their OWN scope. That is the whole of "so we can see different
176
+ versions or different things the AI code for us": a colleague's attempt is worth nothing if
177
+ only its author can open it.
178
+ """
179
+ if session.admin or str(rec.get("author") or "") == session.uname:
180
+ return
181
+ raise err(403, "forbidden", "only the author or an administrator can change this script view")
182
+
183
+
184
+ # ── the routes ────────────────────────────────────────────────────────────────────────────────
185
+ @router.get("/script-views")
186
+ def list_script_views(database: str = "", session: Session = Depends(require_session)):
187
+ """Every script view bound to ONE database, newest first. `database` is required."""
188
+ key = str(database or "").strip()
189
+ if not key:
190
+ raise err(400, "no_database", "name the database whose script views you want")
191
+ _database_ok(session, key)
192
+ rows = [_row(rec) for rec in _all(session.runtime).values()
193
+ if isinstance(rec, dict) and str(rec.get("database") or "") == key]
194
+ rows.sort(key=lambda r: (r["created"], r["id"]), reverse=True)
195
+ return {"database": key, "views": rows, "limits": _limits()}
196
+
197
+
198
+ @router.post("/script-views")
199
+ def create_script_view(body: dict = Body(default=None),
200
+ session: Session = Depends(require_session)):
201
+ """Create one. β›” THE SOURCE IS CHECKED BEFORE IT IS STORED, not first at run time.
202
+
203
+ A script view that cannot be run is a broken feature the reader discovers by pressing a button,
204
+ and the agent that wrote it is long gone by then. `check_source` is pure and costs no process,
205
+ so the refusal arrives while the author still has the code in front of them.
206
+ """
207
+ import secrets # noqa: PLC0415
208
+ import core.script_sandbox as sandbox # noqa: PLC0415
209
+
210
+ body = body if isinstance(body, dict) else {}
211
+ database = str(body.get("database") or "").strip()
212
+ if not database:
213
+ raise err(400, "no_database", "a script view is bound to one database")
214
+ _database_ok(session, database)
215
+ # ⭐ is CREATE's alone - see . The picker mints a view the
216
+ # moment a person chooses the mode, so the empty source is the normal first state, not a slip.
217
+ source = _clean_source(body.get("source"), allow_empty=True)
218
+ refusal = sandbox.check_source(source)
219
+ if refusal is not None:
220
+ raise err(400, refusal.code, refusal.message)
221
+
222
+ view_id = "sv_" + secrets.token_urlsafe(9)
223
+ rec = {"id": view_id, "database": database,
224
+ "name": " ".join(str(body.get("name") or "Script view").split())[:MAX_NAME],
225
+ "source": source, "author": session.uname, "version": 1,
226
+ "created": _now(), "updated": _now(), "history": [], "trimmed": 0}
227
+ _put(session, view_id, lambda _prior: rec)
228
+ fresh = _all(session.runtime).get(view_id)
229
+ if not isinstance(fresh, dict):
230
+ # The store took the write and did not record it. A 200 here would tell the author their
231
+ # script was saved when it was not.
232
+ raise err(503, "store_unavailable", "the script view was NOT created")
233
+ return {"view": _row(fresh, source=True), "limits": _limits()}
234
+
235
+
236
+ @router.get("/script-views/{view_id}")
237
+ def get_script_view(view_id: str, session: Session = Depends(require_session)):
238
+ """One view WITH its source and its edit history. Owner item 6's *"see the code"* half."""
239
+ rec = _all(session.runtime).get(str(view_id))
240
+ if not isinstance(rec, dict):
241
+ raise err(404, "no_view", "there is no script view with that id")
242
+ _database_ok(session, str(rec.get("database") or ""))
243
+ return {"view": _row(rec, source=True), "limits": _limits()}
244
+
245
+
246
+ @router.put("/script-views/{view_id}")
247
+ def update_script_view(view_id: str, body: dict = Body(default=None),
248
+ session: Session = Depends(require_session)):
249
+ """A NEW VERSION of one view's source. The prior source is KEPT, never replaced in place."""
250
+ import core.script_sandbox as sandbox # noqa: PLC0415
251
+
252
+ view_id = str(view_id)
253
+ rec = _all(session.runtime).get(view_id)
254
+ if not isinstance(rec, dict):
255
+ raise err(404, "no_view", "there is no script view with that id")
256
+ _mine_or_admin(session, rec)
257
+ body = body if isinstance(body, dict) else {}
258
+ source = _clean_source(body.get("source"))
259
+ refusal = sandbox.check_source(source)
260
+ if refusal is not None:
261
+ raise err(400, refusal.code, refusal.message)
262
+
263
+ def _mutate(prior):
264
+ prior = dict(prior or rec)
265
+ history = list(prior.get("history") or [])
266
+ history.append({"version": int(prior.get("version") or 1),
267
+ "source": prior.get("source") or "",
268
+ "author": prior.get("author") or "", "created": prior.get("updated") or ""})
269
+ dropped = max(0, len(history) - MAX_HISTORY)
270
+ prior["history"] = history[dropped:] if dropped else history
271
+ prior["trimmed"] = int(prior.get("trimmed") or 0) + dropped
272
+ prior["source"] = source
273
+ prior["version"] = int(prior.get("version") or 1) + 1
274
+ prior["updated"] = _now()
275
+ if body.get("name"):
276
+ prior["name"] = " ".join(str(body["name"]).split())[:MAX_NAME]
277
+ return prior
278
+
279
+ _put(session, view_id, _mutate)
280
+ fresh = _all(session.runtime).get(view_id)
281
+ if not isinstance(fresh, dict):
282
+ raise err(503, "store_unavailable", "the new version was NOT saved")
283
+ return {"view": _row(fresh, source=True), "limits": _limits()}
284
+
285
+
286
+ @router.post("/script-views/{view_id}/revert")
287
+ def revert_script_view(view_id: str, body: dict = Body(default=None),
288
+ session: Session = Depends(require_session)):
289
+ """Put one view back to an earlier version. D-338 / W37-T46.
290
+
291
+ ⭐⭐ A ROLL-BACK IS A NEW VERSION, NEVER A DELETE OF THE ONES AFTER IT, and that is the whole
292
+ point of the ticket rather than an implementation detail. The history is what lets a person
293
+ trust an agent with their code: if reverting destroyed the versions it stepped over, one
294
+ mistaken revert would cost exactly what the history existed to protect, and there would be no
295
+ way back from the way back. So this appends, and `restoredFrom` records where the text came
296
+ from β€” the same posture the agent-harness roll-back already takes, so a reader who has seen one
297
+ is not surprised by the other.
298
+
299
+ β›” IT LIVES ON THE SERVER BECAUSE THE HISTORY DOES. The list door deliberately serves history
300
+ entries WITHOUT their source (`_row`: the rail lists names), so a client cannot assemble an old
301
+ version's text to re-PUT it. Handing the source out just so the client could send it straight
302
+ back would widen a payload for a round trip that does not need to exist, and would make the
303
+ revert non-atomic: two calls, and a failure between them leaves the person looking at code that
304
+ is not what is stored.
305
+ ⚠ `_clean_source` is NOT re-run. The text being restored was already validated when it was
306
+ first saved, and a source that a later, stricter rule would now reject is exactly the source a
307
+ person is most likely to want back. `check_source` still runs, because the SANDBOX's refusal is
308
+ about what the code would DO and that must never be bypassed by a route.
309
+ """
310
+ import core.script_sandbox as sandbox # noqa: PLC0415
311
+
312
+ view_id = str(view_id)
313
+ rec = _all(session.runtime).get(view_id)
314
+ if not isinstance(rec, dict):
315
+ raise err(404, "no_view", "there is no script view with that id")
316
+ _mine_or_admin(session, rec)
317
+ body = body if isinstance(body, dict) else {}
318
+ try:
319
+ want = int(body.get("version"))
320
+ except (TypeError, ValueError):
321
+ raise err(400, "no_version", "say which version to go back to") from None
322
+
323
+ history = [h for h in (rec.get("history") or []) if isinstance(h, dict)]
324
+ match = next((h for h in history if int(h.get("version") or 0) == want), None)
325
+ if match is None:
326
+ # β›” TWO REASONS A VERSION IS MISSING AND THEY ARE NOT THE SAME FACT. One was never
327
+ # written; the other was TRIMMED off the 40-deep history and the record even counts how
328
+ # many. Saying "that version is gone" for the first would be a lie a person could act on.
329
+ trimmed = int(rec.get("trimmed") or 0)
330
+ if trimmed and want <= trimmed:
331
+ raise err(410, "version_trimmed",
332
+ f"version {want} is older than this view's history keeps "
333
+ f"({trimmed} earlier versions have been dropped)")
334
+ raise err(404, "no_version", f"this view has no version {want}")
335
+
336
+ source = str(match.get("source") or "")
337
+ refusal = sandbox.check_source(source)
338
+ if refusal is not None:
339
+ # An older version that today's sandbox refuses. The person is told which version and why,
340
+ # rather than being handed a 400 about code they did not just type.
341
+ raise err(400, refusal.code, f"version {want} cannot be restored: {refusal.message}")
342
+
343
+ def _mutate(prior):
344
+ prior = dict(prior or rec)
345
+ history_now = list(prior.get("history") or [])
346
+ history_now.append({"version": int(prior.get("version") or 1),
347
+ "source": prior.get("source") or "",
348
+ "author": prior.get("author") or "",
349
+ "created": prior.get("updated") or ""})
350
+ dropped = max(0, len(history_now) - MAX_HISTORY)
351
+ prior["history"] = history_now[dropped:] if dropped else history_now
352
+ prior["trimmed"] = int(prior.get("trimmed") or 0) + dropped
353
+ prior["source"] = source
354
+ prior["version"] = int(prior.get("version") or 1) + 1
355
+ prior["updated"] = _now()
356
+ # ⚠ ON THE RECORD, so the history reads as WHAT HAPPENED rather than as a version that
357
+ # mysteriously matches an older one. Without it a reader sees v5 and v2 with identical
358
+ # code and no way to tell a revert from a coincidence.
359
+ prior["restoredFrom"] = want
360
+ return prior
361
+
362
+ _put(session, view_id, _mutate)
363
+ fresh = _all(session.runtime).get(view_id)
364
+ if not isinstance(fresh, dict):
365
+ raise err(503, "store_unavailable", "the roll-back was NOT saved")
366
+ return {"view": _row(fresh, source=True), "restoredFrom": want, "limits": _limits()}
367
+
368
+
369
+ @router.delete("/script-views/{view_id}")
370
+ def delete_script_view(view_id: str, session: Session = Depends(require_session)):
371
+ view_id = str(view_id)
372
+ rec = _all(session.runtime).get(view_id)
373
+ if not isinstance(rec, dict):
374
+ raise err(404, "no_view", "there is no script view with that id")
375
+ _mine_or_admin(session, rec)
376
+ _put(session, view_id, lambda _prior: None)
377
+ return {"deleted": view_id}
378
+
379
+
380
+ @router.post("/script-views/{view_id}/run")
381
+ def run_script_view(view_id: str, body: dict = Body(default=None),
382
+ session: Session = Depends(require_session)):
383
+ """CONTRACT C3's run door: `{ok, spec | error, stdout, ms}`.
384
+
385
+ β›” `spec` IS A DESCRIPTION THE CLIENT DRAWS. It is never HTML and never text the browser
386
+ executes β€” the sandbox refuses a spec carrying an `html`, `script`, `src`, `href` or `on*` key
387
+ before this function ever sees it, so a renderer cannot be talked into running something by a
388
+ script that was itself perfectly well behaved.
389
+
390
+ β›” AND IT IS PLAIN `def`, NOT `async def` β€” see this module's header. A ten-second subprocess
391
+ wait on the event loop is a ten-second outage for the whole container.
392
+ """
393
+ import core.script_sandbox as sandbox # noqa: PLC0415
394
+
395
+ rec = _all(session.runtime).get(str(view_id))
396
+ if not isinstance(rec, dict):
397
+ raise err(404, "no_view", "there is no script view with that id")
398
+ database = str(rec.get("database") or "")
399
+ # A DRAFT run: the editor sends unsaved code so the author can try it before committing to it.
400
+ # It is checked exactly as a stored one is, because "unsaved" is not a permission.
401
+ draft = (body or {}).get("source") if isinstance(body, dict) else None
402
+ source = _clean_source(draft) if draft else str(rec.get("source") or "")
403
+ # ⚠ NO `_database_ok` CALL HERE. `run_view` asks C1 the identical question a line later, and
404
+ # asking twice builds a registry topic's pool twice. The three refusals are translated below
405
+ # instead, which is the same wall reached through the same door.
406
+
407
+ if not _RUN_SLOTS.acquire(blocking=False):
408
+ raise err(429, "busy",
409
+ f"{MAX_CONCURRENT_RUNS} script views are already running on this server. "
410
+ f"Try again in a moment")
411
+ try:
412
+ out = sandbox.run_view(session.user, database, source, st=session.runtime)
413
+ finally:
414
+ _RUN_SLOTS.release()
415
+
416
+ # β›” C1'S THREE REFUSALS ARE HTTP STATUSES, NOT `ok:false`. "You may not read this database"
417
+ # answered 200 would be a permission decision the client has to go looking for, and every
418
+ # other door in this app answers 403 for it. Everything BELOW this line is a well-formed
419
+ # request whose ANSWER is that the script did not produce a view β€” that is a 200 with
420
+ # `ok:false`, the shape `routes_web_agent.test` already uses for the same reason.
421
+ if out.get("code") == "unknown_table":
422
+ raise err(404, "no_database", out.get("message") or f"there is no database '{database}'")
423
+ if out.get("code") == "denied":
424
+ raise err(403, "forbidden", out.get("message") or "your account may not read that database")
425
+ if out.get("code") == "unresolvable":
426
+ refusal = err(409, "unresolvable", out.get("message") or "these rows cannot be served")
427
+ refusal.detail["error"]["limit"] = out.get("limit") or {}
428
+ raise refusal
429
+
430
+ answer = {"ok": bool(out.get("ok")), "spec": out.get("spec"),
431
+ "stdout": out.get("stdout") or "", "truncated": bool(out.get("truncated")),
432
+ "ms": int(out.get("ms") or 0), "code": out.get("code") or "",
433
+ # ⭐ `caps` RIDES ON THE ANSWER (standing rule 1). On a POSIX host all three limits
434
+ # were applied; on a Windows host the memory and CPU ones were not, and a screen
435
+ # that claims an enforcement which did not happen is the failure the rule is about.
436
+ "caps": out.get("caps") or {}}
437
+ if not answer["ok"]:
438
+ answer["error"] = out.get("message") or "the script view did not produce a view"
439
+ return answer
api/routes_tables.py CHANGED
@@ -630,7 +630,23 @@ def ut_write_ctx(session: Session, table_key: str):
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),
633
- "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  # ⭐ W31-T20 β€” the write door reads this to refuse a PID-BEARING event loudly rather
635
  # than letting `allowed_pids` swallow it as a no-op. See `routes_grid`'s ut_ branch.
636
  "limits": limits, "defn": defn}
@@ -741,16 +757,154 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
741
  # so the closure covers this user's `custom_` and `measure_` columns too.
742
  fields, rows_src, hidden = _ut_field_wall(session, table_key, fields, rows_src)
743
 
 
 
 
 
 
 
 
 
 
 
 
744
  return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
745
  "fields": fields, "views": views, "lists": lists, "hidden": hidden,
746
- # R9: the Cohorts column's cells from this table's own lists.
747
- "derived": aios_grid.cohort_cells(lists),
748
- "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
 
749
  # ⚠ ALWAYS PRESENT, EMPTY WHEN THERE IS NOTHING TO SAY β€” a key a consumer has to test
750
  # for is a key a consumer forgets to test for, and this one carries a refusal.
751
  "limits": limits, "defn": defn}
752
 
753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754
  def ut_label(defn, key, meta=None):
755
  """THE name of a user table, resolved ONCE (wave 20, item 6a).
756
 
@@ -857,11 +1011,38 @@ def _rollup_source_offer():
857
  "description": m.get("description") or ""})
858
 
859
  out = []
 
860
  for tkey, t in sorted(topics.items()):
861
  dims = ((t.get("store") or {}).get("dims") or {})
862
  measures = by_topic.get(tkey) or []
863
  if not dims or not measures:
864
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
865
  out.append({
866
  "key": tkey,
867
  "label": t.get("label") or tkey,
@@ -883,7 +1064,10 @@ def _rollup_source_offer():
883
  windows = [{"key": k, "label": W.WINDOW_LABELS.get(k, k).format(n="N")}
884
  for k in ut.ROLLUP_SOURCE_WINDOWS]
885
  offer = {"topics": out, "windows": windows}
886
- _ROLLUP_CACHE["offer"] = offer
 
 
 
887
  return offer
888
 
889
 
 
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),
633
+ # ⭐ W37-T12 β€” the OFFER, because `routes_grid` reads `g["measures"]` as the ADMISSION
634
+ # list for `clean_measure_field`: without it a Metric create on this surface is
635
+ # refused `measure_not_offered` even though the read door offers the kind.
636
+ # β›” THE CELLS ARE DELIBERATELY ABSENT HERE. This ctx validates a write and
637
+ # materialises no rows at all (W30-T30 removed a full table build from the write
638
+ # path); computing measure values would put that work straight back. `measure_sets`
639
+ # stays empty for the same reason β€” a write is not a render.
640
+ # ⭐ AND THE ASYMMETRY IS SAFE, WHICH IS NOT OBVIOUS AND WAS CHECKED RATHER THAN
641
+ # ASSUMED. `routes_grid._ctx` derives TWO things from this dict and they have
642
+ # different jobs: `measure_keys` (from `measures`) is the ADMISSION list
643
+ # `clean_filter_tree` uses β€” non-empty here, so a saved measure CONDITION survives the
644
+ # write, which is the failure that comment records for the customer path. Empty
645
+ # `resolved_ids` (from `measure_sets`) only makes `view_upsert` answer "rerender",
646
+ # so the next `/workspace` β€” which DOES compute the sets, in `ut_assembly` β€” resolves
647
+ # it. Conservative in the right direction: one extra repaint, never a dropped filter.
648
+ "measures": ut_measures(table_key),
649
+ "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
650
  # ⭐ W31-T20 β€” the write door reads this to refuse a PID-BEARING event loudly rather
651
  # than letting `allowed_pids` swallow it as a no-op. See `routes_grid`'s ut_ branch.
652
  "limits": limits, "defn": defn}
 
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():
769
+ derived.setdefault(_pid, {}).update(_cells)
770
+
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,
777
  # ⚠ ALWAYS PRESENT, EMPTY WHEN THERE IS NOTHING TO SAY β€” a key a consumer has to test
778
  # for is a key a consumer forgets to test for, and this one carries a refusal.
779
  "limits": limits, "defn": defn}
780
 
781
 
782
+ def _ut_measure_err(tag, e):
783
+ try:
784
+ import harness.telemetry as _tel
785
+ _tel.error(f"api:{tag}", e)
786
+ except Exception: # noqa: BLE001
787
+ pass
788
+
789
+
790
+ def ut_measures(table_key):
791
+ """The lookback-measure OFFER for a user database, or `[]` β€” W37-T12 / owner item 4 (R1).
792
+
793
+ ⭐ THE SAME ENGINE THE PRODUCT GRID USES (`semantic.entity_measures`), reached through the
794
+ topic that DESCRIBES this database. A table with no entity topic, or a topic with no
795
+ `measures:` binding, gets `[]` β€” which is the honest answer for a hand-typed database and is
796
+ what keeps the Metric kind hidden there (`ColumnMenu`'s `offerMeasure={measures.length > 0}`).
797
+
798
+ β›” NOT A `{table_key: [keys]}` MAP IN THIS FILE. The binding is declared in the model
799
+ (`model/topics/odoo_agents.yml`'s `measures:` block) and the topic already names its own grid,
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)
806
+ return sem.entity_measures(topic) if topic else []
807
+ except Exception as e: # noqa: BLE001
808
+ _ut_measure_err("ut-measure-offer", e)
809
+ return []
810
+
811
+
812
+ def _ut_measure_cells(table_key, fields, pids, today, offer, memo, team_id=None):
813
+ """`{pid: {field key: value}}` for this database's Metric columns.
814
+
815
+ ⭐ THE JOIN NEEDS NO MAP HERE, and that is worth stating because the product grid DOES need
816
+ one. `sales_lines.agent` is `rp.agent_id` β€” a `res.partner` id β€” and a `ut_odoo_agents` row's
817
+ `pid` IS its `res.partner` id (`scoped_pool` sets `pid = int(rid)` and `read_agents` keys
818
+ `_id` on the partner id). So the group key and the pid are the same integer. The product grid
819
+ keys on `default_code` instead, which is exactly why contract C1 calls the join key a trap:
820
+ the right answer differs per database and must be read off the binding, never assumed.
821
+ """
822
+ import aios_grid
823
+
824
+ mfields = aios_grid.measure_fields_of(fields)
825
+ if not mfields or not offer:
826
+ return {}
827
+ from harness import semantic as sem
828
+ from harness import windows as _wn
829
+
830
+ topic = sem.topic_for_grid(table_key)
831
+ by_key = {m["key"]: m for m in offer}
832
+ by_window = {}
833
+ for f in mfields:
834
+ spec = f.get("measure") or {}
835
+ if spec.get("key") not in by_key:
836
+ continue
837
+ rng = _wn.resolve(spec.get("window"), today) if _wn.normalize(spec.get("window")) else None
838
+ if rng is None:
839
+ continue # an unresolvable window is unanswerable β€” never widen to all time
840
+ by_window.setdefault((rng[0], rng[1]), []).append(f)
841
+
842
+ out = {}
843
+ for (dfrom, dto), group in by_window.items():
844
+ keys = tuple(sorted({f["measure"]["key"] for f in group}))
845
+ mkey = ("entity", topic, table_key, today, team_id, dfrom, dto, keys)
846
+ if mkey not in memo:
847
+ try:
848
+ memo[mkey] = sem.entity_measure_values(topic, list(keys), date_from=dfrom,
849
+ date_to=dto, team_id=team_id, offer=offer)
850
+ except Exception as e: # noqa: BLE001
851
+ _ut_measure_err("ut-measure-column", e)
852
+ transient = False
853
+ try:
854
+ from harness import datastore as _ds
855
+ transient = not _ds.ready()
856
+ except Exception: # noqa: BLE001
857
+ transient = False
858
+ if transient:
859
+ continue # a warming store must not memoise as a permanent failure
860
+ memo[mkey] = None
861
+ vals = memo[mkey]
862
+ if vals is None:
863
+ continue
864
+ # C1's empty-window rule for EVERY row, not only the grouped ones.
865
+ filled = {pid: sem.entity_zero_fill(vals.get(pid), list(keys), offer) for pid in pids}
866
+ for f in group:
867
+ k, fkey = f["measure"]["key"], f["key"]
868
+ for pid, cell in filled.items():
869
+ if k in cell:
870
+ out.setdefault(pid, {})[fkey] = cell[k]
871
+ if len(memo) > 100:
872
+ memo.clear()
873
+ return out
874
+
875
+
876
+ def _ut_measure_sets(table_key, views, offer, pids, today, team_id=None):
877
+ """`{rule id: [pid, …]}` for the measure CONDITIONS in this database's saved views.
878
+
879
+ β›” SHIPS WITH THE OFFER, NEVER AFTER IT. `routes_grid` derives `measure_keys` from the same
880
+ list, so a non-empty `measures` also opens the measure condition in the filter builder β€” and a
881
+ rule id missing from `measureSets` renders PENDING ("Calculating…") and matches nothing,
882
+ permanently. Offer and sets are one feature.
883
+ """
884
+ if not offer:
885
+ return {}
886
+ from harness import semantic as sem
887
+
888
+ topic = sem.topic_for_grid(table_key)
889
+ admitted = {m["key"] for m in offer}
890
+ by_id = {}
891
+ for v in views or []:
892
+ for rule in sem.entity_measure_leaves((v.get("config") or {}).get("filters"), admitted):
893
+ rid = str(rule.get("id") or "")
894
+ if rid:
895
+ by_id[rid] = rule
896
+ if not by_id:
897
+ return {}
898
+ try:
899
+ sets = sem.entity_measure_sets(topic, list(by_id.values()), today, team_id=team_id,
900
+ keys_by_id=list(pids), offer=offer)
901
+ except Exception as e: # noqa: BLE001
902
+ _ut_measure_err("ut-measure-sets", e)
903
+ return {}
904
+ return {rid: sorted(int(k) for k in ks if str(k).lstrip("-").isdigit())
905
+ for rid, ks in sets.items()}
906
+
907
+
908
  def ut_label(defn, key, meta=None):
909
  """THE name of a user table, resolved ONCE (wave 20, item 6a).
910
 
 
1011
  "description": m.get("description") or ""})
1012
 
1013
  out = []
1014
+ indeterminate = False
1015
  for tkey, t in sorted(topics.items()):
1016
  dims = ((t.get("store") or {}).get("dims") or {})
1017
  measures = by_topic.get(tkey) or []
1018
  if not dims or not measures:
1019
  continue
1020
+ # β›”β›” W37-T13 / /validate-wave 2026-08-19 β€” THE SOURCE TABLE MUST BE LIVE, AND THIS DOOR
1021
+ # DID NOT ASK. The docstring above already promises *"ONLY COMBINATIONS THAT CAN RESOLVE
1022
+ # ARE OFFERED"*; adding `stock_move` to `datastore.ENTITIES` broke that promise on the one
1023
+ # door nobody re-read. `semantic._one_binding_offer` β€” the MEASURE-CATALOG door β€” refuses
1024
+ # the same bindings with a stated cause, so the wave shipped **two doors onto one
1025
+ # vocabulary with only one of them guarded**, and the unguarded one is the picker.
1026
+ # MEASURED: `_rollup_source_offer()` listed `stock_moves` with both measures and all four
1027
+ # dims while `royal.duckdb` has no `stock_move` table at all, so choosing it raised
1028
+ # `CatalogException: Table with name stock_move does not exist!` β€” five of the fifty
1029
+ # (topic, measure, dim) triples in `verify_odoo_relational`. A user meets a raw catalog
1030
+ # error, or a column that looks configured and is blank forever
1031
+ # ([[permitted-is-not-answerable]]).
1032
+ # ⚠ REUSES `_source_ready` rather than re-deriving readiness: one question, one
1033
+ # normalizer. `None` (the store could not be asked) refuses TOO β€” fail closed, exactly as
1034
+ # the measure door does.
1035
+ _tbl = (t.get("store") or {}).get("table")
1036
+ if _tbl:
1037
+ _rdy = sem._source_ready(_tbl)
1038
+ if _rdy is not True:
1039
+ # β›” AND AN INDETERMINATE ANSWER MUST NOT BE CACHED. `_source_ready`'s own comment
1040
+ # records the measured version of this: a cold container with no mirror yet
1041
+ # refused every binding, the refusal was memoised, and the grid served no measures
1042
+ # long after the mirror arrived. Here the same shape would freeze a truncated
1043
+ # PICKER for the life of the process.
1044
+ indeterminate = indeterminate or (_rdy is None)
1045
+ continue
1046
  out.append({
1047
  "key": tkey,
1048
  "label": t.get("label") or tkey,
 
1064
  windows = [{"key": k, "label": W.WINDOW_LABELS.get(k, k).format(n="N")}
1065
  for k in ut.ROLLUP_SOURCE_WINDOWS]
1066
  offer = {"topics": out, "windows": windows}
1067
+ # ⚠ Cache only a DEFINITE answer (see the readiness block above): if any topic was dropped
1068
+ # because the store could not be asked, this offer is a snapshot of an unknown, not a fact.
1069
+ if not indeterminate:
1070
+ _ROLLUP_CACHE["offer"] = offer
1071
  return offer
1072
 
1073
 
platform/aios_grid.py CHANGED
@@ -24,6 +24,7 @@ Design notes:
24
  injected-HTML path remains a read/local-write fallback when component assets are absent.
25
  """
26
  import json
 
27
  import re
28
  from pathlib import Path
29
 
@@ -89,6 +90,22 @@ def _round(v):
89
  return round(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  # Types a USER may create from the column menu (owner item 7, 2026-07-26). Mirrors
93
  # customer-grid/types.ts CREATABLE_TYPES; verify_fields_contract.py holds the two in step.
94
  # select β€” a single-select with its own `options` (the "Status" a user wants to add; distinct
@@ -415,6 +432,52 @@ def _clean_formula(raw, valid_keys=None):
415
  return s
416
 
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  def _field_extras(saved, ftype):
419
  """createdBy / permissions / format / scope β€” the validated passthrough the created strata
420
  share (wave 5). `createdBy` is only ever WRITTEN host-side (the handler stamps it); here it
@@ -434,6 +497,27 @@ def _field_extras(saved, ftype):
434
  out["format"] = fmt
435
  if saved.get("scope") == "cohort":
436
  out["scope"] = "cohort"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  corrected_from = saved.get("labelCorrectedFrom")
438
  correction_id = saved.get("labelCorrectionId")
439
  if (isinstance(corrected_from, str) and corrected_from.strip()
@@ -752,7 +836,10 @@ def rows_from_pool(pool_rows, fields=None, overlays=None, derived=None):
752
  if field.get("derived"):
753
  continue # not on the pool row β€” filled from `derived` below
754
  v = r.get(k)
755
- row[k] = v if field["type"] in {"text", "status", "date"} else _round(v)
 
 
 
756
  saved = overlays.get(str(pid), {}) or {}
757
  for field in overlay_fields:
758
  row[field["key"]] = saved.get(field["key"], "")
@@ -789,6 +876,19 @@ COHORT_FIELD = '__cohort__'
789
  #: a set op reaching a column leaf would fall through the client engine's switch to "no
790
  #: narrowing", so keeping the vocabularies apart makes the existing fail-closed drop do the work.
791
  COHORT_OPS = {'anyOf', 'allOf', 'noneOf'}
 
 
 
 
 
 
 
 
 
 
 
 
 
792
  #: The single-cohort ops this leaf shipped with, kept as PERMANENT aliases and REWRITTEN here:
793
  #: `is part of [one]` is `is any of [that one]`, so a saved view keeps answering and upgrades the
794
  #: next time it is written. Mirrors types.ts COHORT_OP_ALIASES.
@@ -912,8 +1012,32 @@ def _clean_rhs(raw, valid_keys):
912
  #: written into `iconShapes.ts`, and now machine-enforced in BOTH directions by
913
  #: `verify_icons.py::mode_parity` β€” offering an unmounted mode is red, and mounting an unoffered
914
  #: one is red too, so the hold cannot outlive its reason the way wave 27's did).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915
  DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'chart',
916
- 'timeseries', 'catalog', 'swipe', 'form'}
 
 
 
 
 
917
 
918
  #: ⭐⭐ WAVE-29 R7 (owner item 10, contracts C3/C4) β€” THE FORM INTERFACE's stored spec, at
919
  #: `views[<id>].config.display.form`. The public door (`aios-web/api/routes_forms.py`) has read
@@ -926,6 +1050,166 @@ DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'char
926
  #: so one tenant setting its token to another tenant's value would silently receive that tenant's
927
  #: submissions. The token is minted server-side and lives in a bucket no client write can reach;
928
  #: `_clean_form` drops any `token` key that arrives here, rather than validating its shape.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
929
  FORM_ACCESS = ('public', 'emails')
930
  MAX_FORM_FIELDS = 60
931
  MAX_FORM_EMAILS = 200
@@ -1373,7 +1657,11 @@ def _clean_display(raw, valid_keys):
1373
  # saved 'dashboard' view reads back as 'chart' from here on; nothing writes 'dashboard'.
1374
  mode = _LEGACY_MODES.get(mode, mode)
1375
  out = {'mode': mode}
1376
- for ref in ('dateField', 'stackField', 'titleField', 'colorField', 'sizeField'):
 
 
 
 
1377
  if raw.get(ref) in valid_keys:
1378
  out[ref] = raw[ref]
1379
  # ── C-DISP (wave 2026-08-02) ─────────────────────────────────────────────────────────
@@ -1508,6 +1796,12 @@ def _clean_display(raw, valid_keys):
1508
  catalogs = _clean_catalogs(raw.get('catalogs'), valid_keys)
1509
  if catalogs:
1510
  out['catalogs'] = catalogs
 
 
 
 
 
 
1511
  # ── ⭐ WAVE-27 C3 (item 8 / R2): the swipe binding ────────────────────────────────────
1512
  # `{fieldKey, leftOption, rightOption}` β€” WHOLE-KEY drop, never a partial one, and that
1513
  # asymmetry against `charts`/`calendarMetrics` above is the point rather than an oversight.
@@ -1536,6 +1830,26 @@ def _clean_display(raw, valid_keys):
1536
  # `kanbanClamp` law) wearing a control that promises a choice.
1537
  if left and right and left.casefold() != right.casefold():
1538
  out['swipe'] = {'fieldKey': f_key, 'leftOption': left, 'rightOption': right}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1539
  # ── ⭐⭐ WAVE-29 R7 (item 10): the FORM spec β€” see `_clean_form` for why the token is not here.
1540
  form = _clean_form(raw.get('form'), valid_keys)
1541
  if form:
@@ -1743,7 +2057,59 @@ def clean_item_folders(raw, folders, valid_ids):
1743
  return out
1744
 
1745
 
1746
- def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1747
  """Recursively validate an UNTRUSTED filter tree (conditions + nested groups).
1748
 
1749
  Returns a clean tree of leaf conditions ({colId, op, value, value2}) and
@@ -1762,6 +2128,14 @@ def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
1762
  condition that can only match nothing, so `List is not [deleted]` would show an empty table
1763
  forever with no way to tell why. `None` means this host has no cohorts, and then every
1764
  cohort leaf is dropped: fail-closed, like every other unknown key.
 
 
 
 
 
 
 
 
1765
  """
1766
  if budget is None:
1767
  budget = [MAX_FILTER_NODES]
@@ -1776,7 +2150,7 @@ def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
1776
  continue # too deep -> drop
1777
  budget[0] -= 1
1778
  children = clean_filter_tree(node['children'], valid_keys,
1779
- depth + 1, budget, cohort_ids)
1780
  if children:
1781
  out.append({'conj': 'or' if node.get('conj') == 'or' else 'and',
1782
  'children': children})
@@ -1793,6 +2167,21 @@ def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
1793
  out.append({'colId': COHORT_FIELD, 'op': op,
1794
  'value': ','.join(named), 'value2': ''})
1795
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1796
  if node.get('colId') in valid_keys and node.get('op') in FILTER_OPS:
1797
  budget[0] -= 1
1798
  # `or ''` would be wrong here: it maps every FALSY value to '', and '' is the
 
24
  injected-HTML path remains a read/local-write fallback when component assets are absent.
25
  """
26
  import json
27
+ import math
28
  import re
29
  from pathlib import Path
30
 
 
90
  return round(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
91
 
92
 
93
+ def _round2(v):
94
+ """D-153 (W37, asked by lane B) β€” MONEY AND PERCENT KEEP THEIR DECIMALS.
95
+
96
+ `_round`'s whole-number convention is right for counts and wrong for currency: `first_cost`
97
+ 3.47 shipped as `3`, so every money cell on every Odoo grid was a whole dollar.
98
+
99
+ ⚠ `pct` IS AFFECTED TOO, and that is the half most likely to be dropped. `cells.ts` renders a
100
+ percent as `num(v).toFixed(1) + "%"`, so a `yoy_pct` of 12.34 was `round()`ed to 12 and painted
101
+ "12.0%" β€” one decimal of a number that HAS one, which reads as precision rather than as loss.
102
+ β›” NOT written as a `_round(v, nd=0)` default argument, deliberately: `round(v, 0)` returns a
103
+ FLOAT where `round(v)` returns an INT, so every integer column's wire shape would change under
104
+ an edit that looks purely cosmetic.
105
+ """
106
+ return round(v, 2) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
107
+
108
+
109
  # Types a USER may create from the column menu (owner item 7, 2026-07-26). Mirrors
110
  # customer-grid/types.ts CREATABLE_TYPES; verify_fields_contract.py holds the two in step.
111
  # select β€” a single-select with its own `options` (the "Status" a user wants to add; distinct
 
432
  return s
433
 
434
 
435
+ def _clean_geocode(raw):
436
+ """One `geocode` bag β†’ the stored shape, or None (dropped).
437
+
438
+ ⭐⭐ W37-T22 (owner item 15 / ruling R4, contract C7). THE ONE validator for the geocode
439
+ pseudo-kind, called by BOTH field doors: `_field_extras` here (the Odoo grids) and
440
+ `core/user_tables._clean_field` via `_ag_geocode` (every `ut_*` user database).
441
+
442
+ β›” IT WAS INLINE IN `_field_extras` UNTIL 2026-08-19 AND THAT IS WHY IT SHIPPED HALF-DONE.
443
+ `user_tables._clean_field` is a strict allowlist that returns only the bags it NAMES, so a
444
+ geocode column created on a user database was stored WITHOUT its `{addressField}` β€” the
445
+ *"created, named, configured, gone"* failure the `image` type shipped as in wave 19. Extracting
446
+ it rather than copying the rules is [[one-question-two-normalizers]]: a second copy is how the
447
+ two doors come to disagree about what a valid bag is.
448
+
449
+ β›” WHOLE-KEY DROP (the `swipe` construction): a geocode column with no address column is not
450
+ a degraded column, it is a column no run can ever fill, and the honest state for that is the
451
+ unconfigured one the client refuses at `canCreate`.
452
+
453
+ β›” THE FIELD'S `type` IS `text` AND THERE IS NO `geocode` FieldType. The kind is a PSEUDO-KIND
454
+ in the create picker (the `measure` construction), so `CUSTOM_FIELD_TYPES`, `UT_FIELD_TYPES`,
455
+ `CREATABLE_TYPES`, the shape/label tables and the cell renderer are all untouched β€” three
456
+ parity gates chain over those sets and none permits a partial landing. This is also why the
457
+ bag takes `format`'s posture and NOT `formula`'s: there is no type to pair it with, so an
458
+ absent bag is an ordinary text column rather than a refusal.
459
+
460
+ ⚠ `addressField` is NOT checked against the table's keys: neither door is given `valid_keys`,
461
+ and the split this module already draws puts that loss on the surface that RENDERS. The
462
+ enricher must SAY the address column is gone rather than geocode an empty string.
463
+ """
464
+ if not isinstance(raw, dict):
465
+ return None
466
+ addr = raw.get("addressField")
467
+ if not isinstance(addr, str) or not addr.strip():
468
+ return None
469
+ out = {"addressField": addr.strip()[:80]}
470
+ # D-3 β€” the optional ISO 3166-1 alpha-2 hint, mapped straight onto the geocoder's
471
+ # `countrycodes` parameter. NORMALISED to upper case and stored only when it is exactly two
472
+ # letters: absent means "anywhere", and `""` would be that state's second spelling. A
473
+ # three-letter or numeric code is REFUSED rather than truncated, because a truncated code is
474
+ # a valid code for a different country.
475
+ cc = raw.get("country")
476
+ if isinstance(cc, str) and re.fullmatch(r"[A-Za-z]{2}", cc.strip() or ""):
477
+ out["country"] = cc.strip().upper()
478
+ return out
479
+
480
+
481
  def _field_extras(saved, ftype):
482
  """createdBy / permissions / format / scope β€” the validated passthrough the created strata
483
  share (wave 5). `createdBy` is only ever WRITTEN host-side (the handler stamps it); here it
 
497
  out["format"] = fmt
498
  if saved.get("scope") == "cohort":
499
  out["scope"] = "cohort"
500
+ # ── ⭐⭐ W37-T22 (owner item 15 / ruling R4, contract C7): THE GEOCODE COLUMN'S CONFIG ──
501
+ #
502
+ # `{addressField}` β€” which column this one reads. WHOLE-KEY drop (the `swipe` construction):
503
+ # a geocode column with no address column is not a degraded one, it is a column no run can
504
+ # ever fill, and the honest state for that is the unconfigured one the client refuses at
505
+ # `canCreate`.
506
+ #
507
+ # β›” THE FIELD'S `type` IS `text` AND THERE IS NO `geocode` FieldType. The kind is a PSEUDO-KIND
508
+ # in the create picker (the `measure` construction), so `CUSTOM_FIELD_TYPES`, `UT_FIELD_TYPES`,
509
+ # `CREATABLE_TYPES`, the shape/label tables and the cell renderer are all untouched β€” three
510
+ # parity gates chain over those sets and none permits a partial landing.
511
+ # ⚠ `addressField` is NOT checked against this table's keys: `_field_extras` is not given
512
+ # `valid_keys`, and the split this module already draws puts that loss on the surface that
513
+ # RENDERS ("what each mode MEANS ... is the client's business"). The enricher must SAY the
514
+ # address column is gone rather than geocode an empty string.
515
+ # ⚠ EXTRACTED to `_clean_geocode` 2026-08-19 (/validate-wave, W37-T22 clause 1). The rules
516
+ # did not change; what changed is that `core/user_tables.py` now reaches THIS function through
517
+ # `_ag_geocode` instead of carrying a second copy. See `_clean_geocode`'s own note.
518
+ geo = _clean_geocode(saved.get("geocode"))
519
+ if geo:
520
+ out["geocode"] = geo
521
  corrected_from = saved.get("labelCorrectedFrom")
522
  correction_id = saved.get("labelCorrectionId")
523
  if (isinstance(corrected_from, str) and corrected_from.strip()
 
836
  if field.get("derived"):
837
  continue # not on the pool row β€” filled from `derived` below
838
  v = r.get(k)
839
+ # D-153 β€” three roundings, not two: text/status/date pass through verbatim, currency
840
+ # and pct keep two decimals (`_round2`), everything else stays a whole number.
841
+ row[k] = v if field["type"] in {"text", "status", "date"} else (
842
+ _round2(v) if field["type"] in {"currency", "pct"} else _round(v))
843
  saved = overlays.get(str(pid), {}) or {}
844
  for field in overlay_fields:
845
  row[field["key"]] = saved.get(field["key"], "")
 
876
  #: a set op reaching a column leaf would fall through the client engine's switch to "no
877
  #: narrowing", so keeping the vocabularies apart makes the existing fail-closed drop do the work.
878
  COHORT_OPS = {'anyOf', 'allOf', 'noneOf'}
879
+
880
+ #: ⭐⭐ WAVE-37 T26 (owner item 14, contract C4) β€” THE VIEW-MEMBERSHIP LEAF's reserved
881
+ #: pseudo-column and its operators. Mirrors `customer-grid/types.ts` VIEW_FIELD / VIEW_OPS.
882
+ #:
883
+ #: β›” DISJOINT FROM `FILTER_OPS`, for the reason COHORT_OPS is: `matchFilter` answers an operator
884
+ #: it does not know by NOT NARROWING, so an op that reached a column leaf would widen the result
885
+ #: under an authoritative count. Keeping the two vocabularies apart means a column leaf carrying
886
+ #: `inView` is dropped by the same fail-closed path that drops `dropTable`.
887
+ #: ⚠ The leaf names exactly ONE view (singular where a cohort leaf names a set), so there is no
888
+ #: all-or-nothing rule to get wrong here β€” the id either resolves or the leaf goes.
889
+ VIEW_FIELD = '__view__'
890
+ VIEW_OPS = {'inView', 'notInView'}
891
+ MAX_VIEW_ID = 64
892
  #: The single-cohort ops this leaf shipped with, kept as PERMANENT aliases and REWRITTEN here:
893
  #: `is part of [one]` is `is any of [that one]`, so a saved view keeps answering and upgrades the
894
  #: next time it is written. Mirrors types.ts COHORT_OP_ALIASES.
 
1012
  #: written into `iconShapes.ts`, and now machine-enforced in BOTH directions by
1013
  #: `verify_icons.py::mode_parity` β€” offering an unmounted mode is red, and mounting an unoffered
1014
  #: one is red too, so the hold cannot outlive its reason the way wave 27's did).
1015
+ #: ⭐⭐ WAVE-37 T20 (owner item 10, ruling R2, contract C2) β€” 'script', and the reason it lands
1016
+ #: here is NOT the one the PRD gives. The PRD says the Custom View is invisible because this set
1017
+ #: omits the name so `_clean_display` drops the mode on every write. MEASURED, that write never
1018
+ #: happens: a script view is a rail PROJECTION built client-side by
1019
+ #: `CustomerGrid.tsx::scriptProjectionView`, its panel is gated on `activeScriptId !== null`
1020
+ #: (a row in E's per-database store), there is no `displayMode === "script"` branch at all, and the
1021
+ #: autosave effect reads `views.find(...)` while the projections are appended to `railViews` ALONE.
1022
+ #: Nothing was ever dropped, because nothing was ever sent.
1023
+ #: ⭐ WHAT THE NAME ACTUALLY BUYS is the PRECONDITION for the create door the owner asked for: a
1024
+ #: script view can only be offered through the display-mode picker once a view STORED as
1025
+ #: `mode: 'script'` survives a round trip, and until this line it could not. The picker entry
1026
+ #: itself is W37-T41 (lane E) plus `CREATABLE_MODES`, and `_test/icons.test.ts::HELD_MODES` keeps
1027
+ #: the hold with its release condition rewritten to say so.
1028
+ #: ⚠ CONTRACT C2 MAKES THIS SET AND `customer-grid/types.ts::DISPLAY_MODES` IDENTICAL NAME FOR
1029
+ #: NAME, and `verify_fields_contract.py` now asserts it in BOTH directions. That retires the staged
1030
+ #: hold every mode from `timeseries` to `form` was landed with β€” a client-first name is now RED
1031
+ #: rather than merely risky. The protection the hold gave is structural instead: both halves are in
1032
+ #: one lane's fence, so they ship in one change, and whether a mode may be CREATED is a separate
1033
+ #: question answered by `CREATABLE_MODES` / `HELD_MODES`.
1034
  DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'chart',
1035
+ 'timeseries', 'catalog', 'swipe', 'form', 'script'}
1036
+
1037
+ #: W37-T20 (C2) β€” the cap on `display.script.id`. Script-view ids are minted server-side as
1038
+ #: `'sv_' + secrets.token_urlsafe(9)` (`routes_script_views.py::create_script_view`), so ~15 chars;
1039
+ #: 64 is a bound on a hostile payload, not an opinion about the format.
1040
+ MAX_SCRIPT_ID = 64
1041
 
1042
  #: ⭐⭐ WAVE-29 R7 (owner item 10, contracts C3/C4) β€” THE FORM INTERFACE's stored spec, at
1043
  #: `views[<id>].config.display.form`. The public door (`aios-web/api/routes_forms.py`) has read
 
1050
  #: so one tenant setting its token to another tenant's value would silently receive that tenant's
1051
  #: submissions. The token is minted server-side and lives in a bucket no client write can reach;
1052
  #: `_clean_form` drops any `token` key that arrives here, rather than validating its shape.
1053
+ #: ⭐⭐ WAVE-37 T21 (owner items 1/12, rulings R5+R6, contract C3) β€” THE PER-VIEW CATALOG SPEC,
1054
+ #: stored at `views[<id>].config.display.catalog`.
1055
+ #:
1056
+ #: β›”β›” IT IS **`catalog`**, SINGULAR, AND IT IS NOT `catalogs`. Both keys live on `display`, they
1057
+ #: are one letter apart, and that is the single most likely mistake anybody touching this file will
1058
+ #: make. They are different things and BOTH are live:
1059
+ #: Β· `catalogs` (wave-18 C6) β€” a LIST of up to 12 print artifacts, each with its own paper,
1060
+ #: brand and pages of kind cover/intro/section/gallery. Authored CONTENT. UNTOUCHED by T21.
1061
+ #: Β· `catalog` (wave-37 C3) β€” THIS view's catalog SETTINGS: which column is the picture, which
1062
+ #: is the title (R5: per view, so two catalog views on one database may differ), plus the page
1063
+ #: order and the free-placed items R6 asks for.
1064
+ #: Neither replaces the other, and neither validator reads the other's key.
1065
+ #:
1066
+ #: β›” THE MIGRATION IS A RULE ABOUT WHAT THIS FUNCTION MUST **NOT** DO (R6, C3). An item authored
1067
+ #: before free placement has no `x`/`y`, and an ABSENT coordinate must read back ABSENT β€” never
1068
+ #: normalised to 0. Defaulting to zero would pile every pre-existing item at the origin, which is
1069
+ #: "a catalog that silently loses its layout is a FAIL, not a migration" stated as code. The
1070
+ #: RENDERER turns an absent coordinate into flow position N; the host's whole job is to not destroy
1071
+ #: the distinction it reads.
1072
+ #: ⚠ `w`/`h` carry the OPPOSITE asymmetry, deliberately: a zero WIDTH is an invisible item, so 0
1073
+ #: is refused and absent means "the renderer's default size". A zero X is the top-left corner and is
1074
+ #: a real, storable position. Two keys of one shape with two rules β€” the `kanbanClamp` family β€”
1075
+ #: which is why each is written out rather than looped.
1076
+ MAX_CATALOG_SECTIONS = 20
1077
+ #: A bound on a hostile payload, not an opinion about the renderer's coordinate space: D owns what
1078
+ #: the numbers MEAN. Stored to 2 decimals, finer than any print surface resolves.
1079
+ MAX_CATALOG_COORD = 10000
1080
+ #: The page `order` bound. ABSENT => that page's ARRAY INDEX, and `order` WINS where present; ties
1081
+ #: break by array index. Decided HERE rather than left to the renderer, because two readers guessing
1082
+ #: differently about page order is exactly the divergence contract C3 exists to close.
1083
+ MAX_CATALOG_ORDER = 9999
1084
+
1085
+
1086
+ def _clean_catalog_coord(raw, allow_zero):
1087
+ """One free-placement number, or None. `allow_zero` False is the w/h leg (see the header).
1088
+
1089
+ β›” THE ROUNDING IS `floor(v * 100 + 0.5) / 100` AND NOT `round(v, 2)`, AND THAT IS THE WHOLE
1090
+ POINT OF THIS FUNCTION EXISTING SEPARATELY. Python's `round` is BANKER'S rounding and
1091
+ JavaScript's `Math.round` is half-up-toward-+Infinity, so the two engines disagree on every
1092
+ exact half: `round(0.125, 2)` is 0.12 here and 0.13 in the browser. One dragged item landing two
1093
+ hundredths apart on the two engines is a `display` object that never compares equal β€” the
1094
+ silent mirror drift contract C2 exists to prevent, arriving through arithmetic instead of
1095
+ through a missing key. The expression below is `Math.floor(v * 100 + 0.5) / 100` written in
1096
+ Python, evaluated in the same IEEE doubles, so the two agree bit for bit.
1097
+ ⚠ AND AN INTEGRAL RESULT IS EMITTED AS AN `int`: `12.0` serialises as `12.0` in Python and
1098
+ `12` in JSON.stringify, which is one stored value with two spellings.
1099
+ """
1100
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
1101
+ return None
1102
+ if raw != raw or raw in (float("inf"), float("-inf")): # NaN / +-inf
1103
+ return None
1104
+ val = math.floor(float(raw) * 100 + 0.5) / 100
1105
+ if val > MAX_CATALOG_COORD or val < -MAX_CATALOG_COORD:
1106
+ return None
1107
+ if not allow_zero and val <= 0:
1108
+ return None
1109
+ return int(val) if val == int(val) else val
1110
+
1111
+
1112
+ def _clean_catalog_item(raw):
1113
+ """One free-placed item: `{code, x?, y?, w?, h?}`. `code` REQUIRED; every coordinate optional.
1114
+
1115
+ Key EMISSION ORDER is fixed and mirrored byte-for-byte by `types.ts::cleanCatalogSpec` β€” an
1116
+ accepted save has to read back identically on both engines, and this pair is where that is
1117
+ decided.
1118
+ """
1119
+ if not isinstance(raw, dict):
1120
+ return None
1121
+ code = raw.get("code")
1122
+ if not isinstance(code, str) or not code.strip():
1123
+ return None
1124
+ out = {"code": code.strip()[:CATALOG_CODE_MAX]}
1125
+ for key, allow_zero in (("x", True), ("y", True), ("w", False), ("h", False)):
1126
+ val = _clean_catalog_coord(raw.get(key), allow_zero)
1127
+ if val is not None:
1128
+ out[key] = val
1129
+ return out
1130
+
1131
+
1132
+ def _clean_catalog_spec(raw, valid_keys):
1133
+ """C3 β€” `display.catalog`, fail-closed. See the header block above for what it is NOT.
1134
+
1135
+ Field refs follow the `dateField`/`stackField` rule this module already runs: a ref naming a
1136
+ deleted column is dropped INDIVIDUALLY and the renderer falls back to its per-mode default. It
1137
+ never costs the user their pages β€” the `charts` per-entry-drop precedent, applied to the one
1138
+ payload here that is AUTHORED rather than derived from the rows.
1139
+ """
1140
+ if not isinstance(raw, dict):
1141
+ return None
1142
+ out = {}
1143
+ for ref in ("imageField", "titleField"):
1144
+ if raw.get(ref) in valid_keys:
1145
+ out[ref] = raw[ref]
1146
+ pages_raw = raw.get("pages")
1147
+ pages = []
1148
+ if isinstance(pages_raw, list):
1149
+ # ONE cumulative item budget across the whole spec, spent in page order then section order
1150
+ # β€” the `_clean_catalogs` construction, so a 40-page catalog cannot smuggle 20,000 items
1151
+ # past a per-page cap.
1152
+ budget = MAX_CATALOG_CODES
1153
+ seen_pages = set()
1154
+ for raw_page in pages_raw:
1155
+ if len(pages) >= MAX_CATALOG_PAGES:
1156
+ break
1157
+ if not isinstance(raw_page, dict):
1158
+ continue
1159
+ pid = str(raw_page.get("id") or "").strip()[:CATALOG_ID_MAX]
1160
+ if not pid or pid in seen_pages:
1161
+ continue
1162
+ seen_pages.add(pid)
1163
+ page = {"id": pid}
1164
+ order = raw_page.get("order")
1165
+ if (isinstance(order, int) and not isinstance(order, bool)
1166
+ and 0 <= order <= MAX_CATALOG_ORDER):
1167
+ page["order"] = order
1168
+ sections_raw = raw_page.get("sections")
1169
+ sections = []
1170
+ if isinstance(sections_raw, list):
1171
+ seen_sections = set()
1172
+ for raw_section in sections_raw:
1173
+ if len(sections) >= MAX_CATALOG_SECTIONS:
1174
+ break
1175
+ if not isinstance(raw_section, dict):
1176
+ continue
1177
+ sid = str(raw_section.get("id") or "").strip()[:CATALOG_ID_MAX]
1178
+ if not sid or sid in seen_sections:
1179
+ continue
1180
+ seen_sections.add(sid)
1181
+ section = {"id": sid}
1182
+ items_raw = raw_section.get("items")
1183
+ items = []
1184
+ if isinstance(items_raw, list) and budget > 0:
1185
+ for raw_item in items_raw:
1186
+ item = _clean_catalog_item(raw_item)
1187
+ if item is None:
1188
+ continue
1189
+ items.append(item)
1190
+ budget -= 1
1191
+ if budget <= 0:
1192
+ break
1193
+ # ⚠ NOT de-duplicated by `code`. The same product legitimately appears twice in
1194
+ # one section at two positions β€” that is what free placement IS β€” and wave-18's
1195
+ # "deduped WITHIN a page" rule was written for a LISTING, where a repeat is a
1196
+ # mistake. Here it is the feature.
1197
+ if items:
1198
+ section["items"] = items
1199
+ sections.append(section)
1200
+ # An EMPTY section is KEPT: a person adds a New Section and then drags into it, and
1201
+ # dropping it would delete the thing they just made, between two autosaves.
1202
+ if sections:
1203
+ page["sections"] = sections
1204
+ pages.append(page)
1205
+ if pages:
1206
+ out["pages"] = pages
1207
+ # A spec that reduces to nothing is DROPPED, never stored as `{}`. "No image field, no title
1208
+ # field, no pages" IS the unconfigured state, and `{}` would be its second spelling β€” the
1209
+ # no-churn law every literal-only key in `_clean_display` follows.
1210
+ return out or None
1211
+
1212
+
1213
  FORM_ACCESS = ('public', 'emails')
1214
  MAX_FORM_FIELDS = 60
1215
  MAX_FORM_EMAILS = 200
 
1657
  # saved 'dashboard' view reads back as 'chart' from here on; nothing writes 'dashboard'.
1658
  mode = _LEGACY_MODES.get(mode, mode)
1659
  out = {'mode': mode}
1660
+ # ⭐ W37-T28 (owner item 15) β€” `coordField`: ONE column holding `"lat,lon"`, so a map is no
1661
+ # longer a customers-only feature. It joins this loop rather than getting a clause of its own
1662
+ # because it IS a field ref with the same drop-if-deleted rule; `customer-grid/types.ts`
1663
+ # gains it in the SAME change, which is contract C2's whole point.
1664
+ for ref in ('dateField', 'stackField', 'titleField', 'colorField', 'sizeField', 'coordField'):
1665
  if raw.get(ref) in valid_keys:
1666
  out[ref] = raw[ref]
1667
  # ── C-DISP (wave 2026-08-02) ─────────────────────────────────────────────────────────
 
1796
  catalogs = _clean_catalogs(raw.get('catalogs'), valid_keys)
1797
  if catalogs:
1798
  out['catalogs'] = catalogs
1799
+ # ── ⭐⭐ WAVE-37 C3 (T21) β€” `catalog`, SINGULAR, and it is NOT the line above it. See the
1800
+ # header on `_clean_catalog_spec` for the two-keys-one-letter-apart warning; this call site is
1801
+ # where a reader is most likely to "fix" one into the other.
1802
+ catalog = _clean_catalog_spec(raw.get('catalog'), valid_keys)
1803
+ if catalog:
1804
+ out['catalog'] = catalog
1805
  # ── ⭐ WAVE-27 C3 (item 8 / R2): the swipe binding ────────────────────────────────────
1806
  # `{fieldKey, leftOption, rightOption}` β€” WHOLE-KEY drop, never a partial one, and that
1807
  # asymmetry against `charts`/`calendarMetrics` above is the point rather than an oversight.
 
1830
  # `kanbanClamp` law) wearing a control that promises a choice.
1831
  if left and right and left.casefold() != right.casefold():
1832
  out['swipe'] = {'fieldKey': f_key, 'leftOption': left, 'rightOption': right}
1833
+ # ── ⭐⭐ WAVE-37 T20 (owner item 10 / R2, contract C2): WHICH SCRIPT THIS VIEW RENDERS ──
1834
+ #
1835
+ # `{id}` β€” the row in E's per-database script store (`/api/v1/script-views`) whose source this
1836
+ # view draws. WHOLE-KEY drop, the `swipe` construction rather than the `charts` one: a script
1837
+ # view with no id is not a degraded script view, it is a panel with nothing to run, and the
1838
+ # honest state for that is the unconfigured one the client already has a surface for.
1839
+ #
1840
+ # β›” THE ID IS DELIBERATELY NOT CHECKED AGAINST `valid_keys`, AND THAT IS NOT AN OVERSIGHT.
1841
+ # `valid_keys` is this table's FIELD keys; a script id names a record in a DIFFERENT store that
1842
+ # this function cannot reach. So the host bounds the shape and nothing else, and the renderer
1843
+ # owns the one loss this is blind to β€” the script row was deleted β€” which it must SHOW rather
1844
+ # than fall back to another script (the `viewModes.tsx` house rule `_clean_swipe`'s note cites).
1845
+ # ⚠ Mirrored key-for-key by `customer-grid/types.ts::cleanDisplay`. That file rebuilds the
1846
+ # config key by key on every autosave, so a key accepted HERE and unknown THERE is erased on the
1847
+ # next column resize β€” which is the half of the round trip this docstring's W33 note warns about.
1848
+ script = raw.get('script')
1849
+ if isinstance(script, dict):
1850
+ s_id = script.get('id')
1851
+ if isinstance(s_id, str) and s_id.strip():
1852
+ out['script'] = {'id': s_id.strip()[:MAX_SCRIPT_ID]}
1853
  # ── ⭐⭐ WAVE-29 R7 (item 10): the FORM spec β€” see `_clean_form` for why the token is not here.
1854
  form = _clean_form(raw.get('form'), valid_keys)
1855
  if form:
 
2057
  return out
2058
 
2059
 
2060
+ def view_refs_of(nodes):
2061
+ """C4 β€” every view id a tree REFERS to, deduped, in order. Mirrors `types.ts::viewRefsOf`.
2062
+
2063
+ ⚠ An INACTIVE leaf (no view chosen yet) is not a reference, on both engines: counting one
2064
+ would refuse a save the moment somebody opened the condition and had not picked anything.
2065
+ """
2066
+ out = []
2067
+ for node in nodes or []:
2068
+ if not isinstance(node, dict):
2069
+ continue
2070
+ if 'children' in node:
2071
+ for vid in view_refs_of(node.get('children')):
2072
+ if vid not in out:
2073
+ out.append(vid)
2074
+ continue
2075
+ if node.get('colId') != VIEW_FIELD:
2076
+ continue
2077
+ vid = str(node.get('value') or '').strip()[:MAX_VIEW_ID]
2078
+ if vid and vid not in out:
2079
+ out.append(vid)
2080
+ return out
2081
+
2082
+
2083
+ def view_filter_cycle(view_id, nodes, filters_of):
2084
+ """⭐⭐ W37-T26 / C4 β€” THE CYCLE REFUSAL's host half. Mirrors `types.ts::viewFilterCycle`.
2085
+
2086
+ View X filtered on "is in View Y" where Y is filtered on X cannot be resolved by anything:
2087
+ X needs Y needs X. Returns the CYCLE PATH (ids, starting and ending at `view_id`) or None.
2088
+
2089
+ β›” THE CLIENT REFUSES THE SAME SHAPE AND THAT IS NOT DUPLICATION. The browser's guard is what
2090
+ a PERSON sees and is the only one that can explain itself; this one is what makes the rule true
2091
+ for a caller that POSTs a view directly, which is every guard-in-the-browser's blind spot.
2092
+ ⚠ A view referring to ITSELF is a cycle of length one and is caught by the same walk β€” also
2093
+ the case a person reaches most easily, by duplicating a view.
2094
+ """
2095
+ def walk(current, path, seen):
2096
+ refs = view_refs_of(nodes if current == view_id else filters_of(current))
2097
+ for nxt in refs:
2098
+ if nxt == view_id:
2099
+ return path + [nxt]
2100
+ if nxt in seen:
2101
+ continue
2102
+ seen.add(nxt)
2103
+ found = walk(nxt, path + [nxt], seen)
2104
+ if found:
2105
+ return found
2106
+ return None
2107
+
2108
+ return walk(view_id, [view_id], {view_id})
2109
+
2110
+
2111
+ def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None,
2112
+ visible_view_ids=None):
2113
  """Recursively validate an UNTRUSTED filter tree (conditions + nested groups).
2114
 
2115
  Returns a clean tree of leaf conditions ({colId, op, value, value2}) and
 
2128
  condition that can only match nothing, so `List is not [deleted]` would show an empty table
2129
  forever with no way to tell why. `None` means this host has no cohorts, and then every
2130
  cohort leaf is dropped: fail-closed, like every other unknown key.
2131
+
2132
+ ⭐⭐ W37-T26 (C4) β€” `visible_view_ids` is the same parameter one leaf over: the views this
2133
+ caller may name in a `__view__` membership leaf. A leaf naming anything else is DROPPED here
2134
+ for the identical reason, and `None` (this host has no views to offer) drops every one.
2135
+ ⚠ DEFAULTS TO `None` SO EVERY EXISTING CALLER IS BYTE-IDENTICAL. Three callers pass nothing
2136
+ (`routes_admin`, `routes_query` twice) and each of them is a surface with no saved-view
2137
+ vocabulary of its own; under the fail-closed rule they drop the leaf, which is correct rather
2138
+ than merely safe.
2139
  """
2140
  if budget is None:
2141
  budget = [MAX_FILTER_NODES]
 
2150
  continue # too deep -> drop
2151
  budget[0] -= 1
2152
  children = clean_filter_tree(node['children'], valid_keys,
2153
+ depth + 1, budget, cohort_ids, visible_view_ids)
2154
  if children:
2155
  out.append({'conj': 'or' if node.get('conj') == 'or' else 'and',
2156
  'children': children})
 
2167
  out.append({'colId': COHORT_FIELD, 'op': op,
2168
  'value': ','.join(named), 'value2': ''})
2169
  continue
2170
+ if node.get('colId') == VIEW_FIELD: # C4: a view-membership leaf
2171
+ # β›” THE SAME ALL-OR-NOTHING THE COHORT ARM ABOVE APPLIES, AND FOR THE SAME REASON.
2172
+ # A leaf naming a view this caller cannot see is DROPPED rather than kept, because a
2173
+ # kept one can only ever match nothing: `View is not [a view you cannot see]` would
2174
+ # show an empty table forever with no way to tell why. Dropping it puts the view back
2175
+ # in its honest unfiltered state, which is what `clean_filter_tree`'s own docstring
2176
+ # already promises for cohorts.
2177
+ # ⚠ `visible_views` is what the caller passes; `None` means this host does not know,
2178
+ # and then EVERY view leaf is dropped β€” fail-closed, like every other unknown key.
2179
+ v_op = node.get('op')
2180
+ v_id = str(node.get('value') or '').strip()[:MAX_VIEW_ID]
2181
+ if v_op in VIEW_OPS and v_id and v_id in (visible_view_ids or ()):
2182
+ budget[0] -= 1
2183
+ out.append({'colId': VIEW_FIELD, 'op': v_op, 'value': v_id, 'value2': ''})
2184
+ continue
2185
  if node.get('colId') in valid_keys and node.get('op') in FILTER_OPS:
2186
  budget[0] -= 1
2187
  # `or ''` would be wrong here: it maps every FALSY value to '', and '' is the
platform/aios_grid_fields.json CHANGED
@@ -1,534 +1,548 @@
1
- {
2
- "_comment": "CANONICAL field contract for the AIOS Airtable-style grid β€” the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description β€” every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β€” no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` β€” not `is empty` β€” finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED β€” LTM's replacement is a creatable Sales measure column (the demo column IS Sales Β· the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED β€” every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
3
- "fields": [
4
- {
5
- "key": "customer",
6
- "label": "Customer",
7
- "type": "text",
8
- "source": "odoo",
9
- "pinned": true,
10
- "default": true,
11
- "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
12
- },
13
- {
14
- "key": "partner_id",
15
- "label": "Odoo ID",
16
- "type": "int",
17
- "source": "odoo",
18
- "derived": true,
19
- "default": false,
20
- "description": "The Odoo res.partner id β€” the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
21
- },
22
- {
23
- "key": "odoo_status",
24
- "label": "Odoo record",
25
- "type": "status",
26
- "source": "odoo",
27
- "default": false,
28
- "options": [
29
- "Active",
30
- "Archived"
31
- ],
32
- "description": "Whether this customer still exists in Odoo. Archived means deleted there."
33
- },
34
- {
35
- "key": "agent",
36
- "label": "Agent",
37
- "type": "text",
38
- "source": "odoo",
39
- "default": true,
40
- "description": "The sales agent who owns this account."
41
- },
42
- {
43
- "key": "dba",
44
- "label": "DBA",
45
- "type": "select",
46
- "source": "odoo",
47
- "default": false,
48
- "options": [
49
- "Fisch",
50
- "Royal",
51
- "Both"
52
- ],
53
- "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
54
- },
55
- {
56
- "key": "salesperson",
57
- "label": "Salesperson",
58
- "type": "text",
59
- "source": "odoo",
60
- "default": false,
61
- "description": "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
62
- },
63
- {
64
- "key": "street",
65
- "label": "Street",
66
- "type": "text",
67
- "source": "odoo",
68
- "default": false,
69
- "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
70
- },
71
- {
72
- "key": "street2",
73
- "label": "Street 2",
74
- "type": "text",
75
- "source": "odoo",
76
- "default": false,
77
- "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
78
- },
79
- {
80
- "key": "city",
81
- "label": "City",
82
- "type": "text",
83
- "source": "odoo",
84
- "default": true,
85
- "description": "City on the customer's Odoo address."
86
- },
87
- {
88
- "key": "state",
89
- "label": "State",
90
- "type": "text",
91
- "source": "odoo",
92
- "default": true,
93
- "description": "State or province on the customer's Odoo address."
94
- },
95
- {
96
- "key": "country",
97
- "label": "Country",
98
- "type": "text",
99
- "source": "odoo",
100
- "default": false,
101
- "description": "Country on the customer's Odoo address."
102
- },
103
- {
104
- "key": "zip",
105
- "label": "ZIP",
106
- "type": "text",
107
- "source": "odoo",
108
- "default": false,
109
- "description": "Postal code on the customer's Odoo address."
110
- },
111
- {
112
- "key": "customer_since",
113
- "label": "Customer since",
114
- "type": "date",
115
- "source": "odoo",
116
- "default": false,
117
- "description": "When this customer was first set up in Odoo."
118
- },
119
- {
120
- "key": "tags",
121
- "label": "Tags",
122
- "type": "text",
123
- "source": "odoo",
124
- "default": false,
125
- "description": "Odoo labels on this customer, comma-separated."
126
- },
127
- {
128
- "key": "pricelist",
129
- "label": "Price list",
130
- "type": "text",
131
- "source": "odoo",
132
- "default": false,
133
- "description": "The price list this customer buys on."
134
- },
135
- {
136
- "key": "payment_terms",
137
- "label": "Payment terms",
138
- "type": "text",
139
- "source": "odoo",
140
- "default": false,
141
- "description": "Payment terms on this customer's account β€” Net 30, for example."
142
- },
143
- {
144
- "key": "last_order",
145
- "label": "Last order",
146
- "type": "date",
147
- "source": "odoo",
148
- "default": true,
149
- "description": "Date of the most recent confirmed order."
150
- },
151
- {
152
- "key": "overdue_days",
153
- "label": "Overdue days",
154
- "type": "int",
155
- "source": "odoo",
156
- "default": true,
157
- "description": "How many days late this customer is running against their own usual ordering rhythm."
158
- },
159
- {
160
- "_note": "filterable:false β€” DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β€” see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
161
- "key": "est_missed",
162
- "label": "Est. missed $",
163
- "type": "currency",
164
- "source": "odoo",
165
- "default": true,
166
- "agg": "sum",
167
- "filterable": false,
168
- "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
169
- },
170
- {
171
- "_note": "wave 21 R1 β€” KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β€” 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
172
- "key": "ar_open",
173
- "label": "AR current $",
174
- "type": "currency",
175
- "source": "odoo",
176
- "default": false,
177
- "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
178
- },
179
- {
180
- "key": "ar_overdue",
181
- "label": "AR overdue $",
182
- "type": "currency",
183
- "source": "odoo",
184
- "default": false,
185
- "description": "Invoiced money past due β€” same basis as the Collections page."
186
- },
187
- {
188
- "_note": "wave 21 R1 β€” the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β€” no second oracle.",
189
- "key": "ar_outstanding",
190
- "label": "AR outstanding $",
191
- "type": "currency",
192
- "source": "odoo",
193
- "default": false,
194
- "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
195
- },
196
- {
197
- "key": "ar_exposure",
198
- "label": "Credit exposure $",
199
- "type": "currency",
200
- "source": "odoo",
201
- "default": false,
202
- "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
203
- },
204
- {
205
- "key": "ar_aged_1_30",
206
- "label": "1-30 days $",
207
- "type": "currency",
208
- "source": "odoo",
209
- "default": false,
210
- "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
211
- },
212
- {
213
- "key": "ar_aged_31_60",
214
- "label": "31-60 days $",
215
- "type": "currency",
216
- "source": "odoo",
217
- "default": false,
218
- "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
219
- },
220
- {
221
- "key": "ar_aged_61_90",
222
- "label": "61-90 days $",
223
- "type": "currency",
224
- "source": "odoo",
225
- "default": false,
226
- "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
227
- },
228
- {
229
- "key": "ar_aged_90_plus",
230
- "label": "90+ days $",
231
- "type": "currency",
232
- "source": "odoo",
233
- "default": false,
234
- "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
235
- },
236
- {
237
- "key": "days_to_pay",
238
- "label": "Days to pay",
239
- "type": "int",
240
- "source": "odoo",
241
- "default": false,
242
- "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
243
- },
244
- {
245
- "key": "top_category",
246
- "label": "Top category",
247
- "type": "text",
248
- "source": "odoo",
249
- "default": false,
250
- "description": "The category this customer spent the most on in the last 12 months."
251
- },
252
- {
253
- "key": "top_category_pct",
254
- "label": "Top category %",
255
- "type": "pct",
256
- "source": "odoo",
257
- "default": false,
258
- "description": "Share of last-12-months spend that went to the top category."
259
- },
260
- {
261
- "key": "sku_count",
262
- "label": "SKUs bought",
263
- "type": "int",
264
- "source": "odoo",
265
- "default": false,
266
- "description": "Distinct products bought in the last 12 months."
267
- },
268
- {
269
- "key": "top_sku",
270
- "label": "Top SKU",
271
- "type": "text",
272
- "source": "odoo",
273
- "default": false,
274
- "description": "The product this customer spent the most on in the last 12 months."
275
- },
276
- {
277
- "key": "days_since",
278
- "label": "Days since order",
279
- "type": "int",
280
- "source": "odoo",
281
- "default": false,
282
- "description": "Days since the last confirmed order."
283
- },
284
- {
285
- "key": "typical_gap_days",
286
- "label": "Typical gap days",
287
- "type": "int",
288
- "source": "odoo",
289
- "default": false,
290
- "description": "Days this customer usually goes between orders, from their own history."
291
- },
292
- {
293
- "key": "notes",
294
- "label": "Notes",
295
- "type": "text",
296
- "source": "overlay",
297
- "default": false,
298
- "description": "Your notes on this customer. Saved in this app only, visible only to you."
299
- }
300
- ],
301
- "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
302
- "_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
303
- "product_data": {
304
- "identity": "pid",
305
- "business_key": "code",
306
- "fields": [
307
- {
308
- "key": "code",
309
- "label": "SKU",
310
- "type": "text",
311
- "source": "odoo",
312
- "pinned": true,
313
- "default": true,
314
- "description": "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
315
- },
316
- {
317
- "key": "product",
318
- "label": "Product",
319
- "type": "text",
320
- "source": "odoo",
321
- "default": true,
322
- "description": "Product name as it appears in Odoo."
323
- },
324
- {
325
- "key": "category",
326
- "label": "Category",
327
- "type": "select",
328
- "source": "odoo",
329
- "default": true,
330
- "description": "Product category; '(uncategorized)' when Odoo carries none."
331
- },
332
- {
333
- "key": "supplier",
334
- "label": "Supplier",
335
- "type": "text",
336
- "source": "overlay",
337
- "default": true,
338
- "description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
339
- "shared": true
340
- },
341
- {
342
- "key": "origin_country",
343
- "label": "Country",
344
- "type": "text",
345
- "source": "overlay",
346
- "default": false,
347
- "description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
348
- "shared": true
349
- },
350
- {
351
- "key": "lead_days",
352
- "label": "Lead time (days)",
353
- "type": "int",
354
- "source": "overlay",
355
- "default": true,
356
- "description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
357
- "shared": true
358
- },
359
- {
360
- "key": "first_cost",
361
- "label": "First cost",
362
- "type": "currency",
363
- "source": "overlay",
364
- "default": false,
365
- "description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
366
- "shared": true
367
- },
368
- {
369
- "key": "price_fisch",
370
- "label": "Fisch price",
371
- "type": "currency",
372
- "source": "odoo",
373
- "description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
374
- },
375
- {
376
- "key": "price_royal_1",
377
- "label": "Royal 1 price",
378
- "type": "currency",
379
- "source": "odoo",
380
- "description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
381
- },
382
- {
383
- "key": "price_royal_2",
384
- "label": "Royal 2 price",
385
- "type": "currency",
386
- "source": "odoo",
387
- "description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
388
- },
389
- {
390
- "key": "rev_ytd",
391
- "label": "Revenue YTD",
392
- "type": "currency",
393
- "source": "odoo",
394
- "default": true,
395
- "description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
396
- },
397
- {
398
- "key": "rev_ly",
399
- "label": "Revenue LY",
400
- "type": "currency",
401
- "source": "odoo",
402
- "description": "Same period last year β€” seasonal wholesale compares like for like."
403
- },
404
- {
405
- "key": "yoy_pct",
406
- "label": "YoY %",
407
- "type": "pct",
408
- "source": "odoo",
409
- "description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
410
- },
411
- {
412
- "key": "qty_ytd",
413
- "label": "Units YTD",
414
- "type": "int",
415
- "source": "odoo",
416
- "description": "Units sold year to date."
417
- },
418
- {
419
- "key": "orders_ytd",
420
- "label": "Orders YTD",
421
- "type": "int",
422
- "source": "odoo",
423
- "description": "Distinct orders containing this SKU, year to date."
424
- },
425
- {
426
- "key": "on_hand",
427
- "label": "On hand",
428
- "type": "int",
429
- "source": "odoo",
430
- "description": "Units in stock. CONSOLIDATED β€” one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
431
- },
432
- {
433
- "key": "unit_cost",
434
- "label": "Unit cost",
435
- "type": "currency",
436
- "source": "odoo",
437
- "description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
438
- },
439
- {
440
- "key": "inv_value",
441
- "label": "Stock value",
442
- "type": "currency",
443
- "source": "odoo",
444
- "description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
445
- },
446
- {
447
- "key": "qty_ltm",
448
- "label": "Units LTM",
449
- "type": "int",
450
- "source": "odoo",
451
- "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
452
- },
453
- {
454
- "key": "dos",
455
- "label": "Days of supply",
456
- "type": "int",
457
- "source": "odoo",
458
- "description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
459
- },
460
- {
461
- "key": "cover_gap_d",
462
- "label": "Cover gap (days)",
463
- "type": "int",
464
- "source": "odoo",
465
- "default": false,
466
- "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
467
- },
468
- {
469
- "key": "stock_bucket",
470
- "label": "Stock status",
471
- "type": "select",
472
- "source": "odoo",
473
- "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
474
- },
475
- {
476
- "key": "needs_pricing",
477
- "label": "Needs pricing",
478
- "type": "select",
479
- "source": "overlay",
480
- "default": false,
481
- "options": [
482
- "Yes"
483
- ],
484
- "shared": true,
485
- "description": "Team-maintained. A SKU carries β€œYes” when it appears on the NEEDS PRICING tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
486
- },
487
- {
488
- "key": "march_pricelist",
489
- "label": "March pricelist",
490
- "type": "select",
491
- "source": "overlay",
492
- "default": false,
493
- "options": [
494
- "Yes"
495
- ],
496
- "shared": true,
497
- "description": "Team-maintained. A SKU carries β€œYes” when it appears on the March Pricelist tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
498
- },
499
- {
500
- "key": "price_changes",
501
- "label": "Price changes",
502
- "type": "select",
503
- "source": "overlay",
504
- "default": false,
505
- "options": [
506
- "Yes"
507
- ],
508
- "shared": true,
509
- "description": "Team-maintained. A SKU carries β€œYes” when it appears on the Price Changes tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
510
- },
511
- {
512
- "key": "closeouts",
513
- "label": "Closeouts",
514
- "type": "select",
515
- "source": "overlay",
516
- "default": false,
517
- "options": [
518
- "Yes"
519
- ],
520
- "shared": true,
521
- "description": "Team-maintained. A SKU carries β€œYes” when it appears on the Closeouts tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
522
- },
523
- {
524
- "key": "notes",
525
- "label": "Notes",
526
- "type": "text",
527
- "source": "overlay",
528
- "default": false,
529
- "shared": true,
530
- "description": "Team-maintained. The 2027 workbook's own non-Odoo columns (Product Description, Packing) folded into one field. Shared with everyone in the workspace."
531
- }
532
- ]
533
- }
534
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "CANONICAL field contract for the AIOS Airtable-style grid β€” the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description β€” every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β€” no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` β€” not `is empty` β€” finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED β€” LTM's replacement is a creatable Sales measure column (the demo column IS Sales Β· the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED β€” every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
3
+ "fields": [
4
+ {
5
+ "key": "customer",
6
+ "label": "Customer",
7
+ "type": "text",
8
+ "source": "odoo",
9
+ "pinned": true,
10
+ "default": true,
11
+ "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
12
+ },
13
+ {
14
+ "key": "partner_id",
15
+ "label": "Odoo ID",
16
+ "type": "int",
17
+ "source": "odoo",
18
+ "derived": true,
19
+ "default": false,
20
+ "description": "The Odoo res.partner id β€” the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
21
+ },
22
+ {
23
+ "key": "odoo_status",
24
+ "label": "Odoo record",
25
+ "type": "status",
26
+ "source": "odoo",
27
+ "default": false,
28
+ "options": [
29
+ "Active",
30
+ "Archived"
31
+ ],
32
+ "description": "Whether this customer still exists in Odoo. Archived means deleted there."
33
+ },
34
+ {
35
+ "key": "agent",
36
+ "label": "Agent",
37
+ "type": "text",
38
+ "source": "odoo",
39
+ "default": true,
40
+ "description": "The sales agent who owns this account."
41
+ },
42
+ {
43
+ "key": "dba",
44
+ "label": "DBA",
45
+ "type": "select",
46
+ "source": "odoo",
47
+ "default": false,
48
+ "options": [
49
+ "Fisch",
50
+ "Royal",
51
+ "Both"
52
+ ],
53
+ "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
54
+ },
55
+ {
56
+ "key": "salesperson",
57
+ "label": "Salesperson",
58
+ "type": "text",
59
+ "source": "odoo",
60
+ "default": false,
61
+ "description": "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
62
+ },
63
+ {
64
+ "key": "street",
65
+ "label": "Street",
66
+ "type": "text",
67
+ "source": "odoo",
68
+ "default": false,
69
+ "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
70
+ },
71
+ {
72
+ "key": "street2",
73
+ "label": "Street 2",
74
+ "type": "text",
75
+ "source": "odoo",
76
+ "default": false,
77
+ "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
78
+ },
79
+ {
80
+ "key": "city",
81
+ "label": "City",
82
+ "type": "text",
83
+ "source": "odoo",
84
+ "default": true,
85
+ "description": "City on the customer's Odoo address."
86
+ },
87
+ {
88
+ "key": "state",
89
+ "label": "State",
90
+ "type": "text",
91
+ "source": "odoo",
92
+ "default": true,
93
+ "description": "State or province on the customer's Odoo address."
94
+ },
95
+ {
96
+ "key": "country",
97
+ "label": "Country",
98
+ "type": "text",
99
+ "source": "odoo",
100
+ "default": false,
101
+ "description": "Country on the customer's Odoo address."
102
+ },
103
+ {
104
+ "key": "zip",
105
+ "label": "ZIP",
106
+ "type": "text",
107
+ "source": "odoo",
108
+ "default": false,
109
+ "description": "Postal code on the customer's Odoo address."
110
+ },
111
+ {
112
+ "key": "customer_since",
113
+ "label": "Customer since",
114
+ "type": "date",
115
+ "source": "odoo",
116
+ "default": false,
117
+ "description": "When this customer was first set up in Odoo."
118
+ },
119
+ {
120
+ "key": "tags",
121
+ "label": "Tags",
122
+ "type": "text",
123
+ "source": "odoo",
124
+ "default": false,
125
+ "description": "Odoo labels on this customer, comma-separated."
126
+ },
127
+ {
128
+ "key": "pricelist",
129
+ "label": "Price list",
130
+ "type": "text",
131
+ "source": "odoo",
132
+ "default": false,
133
+ "description": "The price list this customer buys on."
134
+ },
135
+ {
136
+ "key": "payment_terms",
137
+ "label": "Payment terms",
138
+ "type": "text",
139
+ "source": "odoo",
140
+ "default": false,
141
+ "description": "Payment terms on this customer's account β€” Net 30, for example."
142
+ },
143
+ {
144
+ "key": "last_order",
145
+ "label": "Last order",
146
+ "type": "date",
147
+ "source": "odoo",
148
+ "default": true,
149
+ "description": "Date of the most recent confirmed order."
150
+ },
151
+ {
152
+ "key": "overdue_days",
153
+ "label": "Overdue days",
154
+ "type": "int",
155
+ "source": "odoo",
156
+ "default": true,
157
+ "description": "How many days late this customer is running against their own usual ordering rhythm."
158
+ },
159
+ {
160
+ "_note": "filterable:false β€” DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β€” see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
161
+ "key": "est_missed",
162
+ "label": "Est. missed $",
163
+ "type": "currency",
164
+ "source": "odoo",
165
+ "default": true,
166
+ "agg": "sum",
167
+ "filterable": false,
168
+ "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
169
+ },
170
+ {
171
+ "_note": "wave 21 R1 β€” KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β€” 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
172
+ "key": "ar_open",
173
+ "label": "AR current $",
174
+ "type": "currency",
175
+ "source": "odoo",
176
+ "default": false,
177
+ "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
178
+ },
179
+ {
180
+ "key": "ar_overdue",
181
+ "label": "AR overdue $",
182
+ "type": "currency",
183
+ "source": "odoo",
184
+ "default": false,
185
+ "description": "Invoiced money past due β€” same basis as the Collections page."
186
+ },
187
+ {
188
+ "_note": "wave 21 R1 β€” the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β€” no second oracle.",
189
+ "key": "ar_outstanding",
190
+ "label": "AR outstanding $",
191
+ "type": "currency",
192
+ "source": "odoo",
193
+ "default": false,
194
+ "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
195
+ },
196
+ {
197
+ "key": "ar_exposure",
198
+ "label": "Credit exposure $",
199
+ "type": "currency",
200
+ "source": "odoo",
201
+ "default": false,
202
+ "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
203
+ },
204
+ {
205
+ "key": "ar_aged_1_30",
206
+ "label": "1-30 days $",
207
+ "type": "currency",
208
+ "source": "odoo",
209
+ "default": false,
210
+ "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
211
+ },
212
+ {
213
+ "key": "ar_aged_31_60",
214
+ "label": "31-60 days $",
215
+ "type": "currency",
216
+ "source": "odoo",
217
+ "default": false,
218
+ "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
219
+ },
220
+ {
221
+ "key": "ar_aged_61_90",
222
+ "label": "61-90 days $",
223
+ "type": "currency",
224
+ "source": "odoo",
225
+ "default": false,
226
+ "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
227
+ },
228
+ {
229
+ "key": "ar_aged_90_plus",
230
+ "label": "90+ days $",
231
+ "type": "currency",
232
+ "source": "odoo",
233
+ "default": false,
234
+ "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
235
+ },
236
+ {
237
+ "key": "days_to_pay",
238
+ "label": "Days to pay",
239
+ "type": "int",
240
+ "source": "odoo",
241
+ "default": false,
242
+ "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
243
+ },
244
+ {
245
+ "key": "top_category",
246
+ "label": "Top category",
247
+ "type": "text",
248
+ "source": "odoo",
249
+ "default": false,
250
+ "description": "The category this customer spent the most on in the last 12 months."
251
+ },
252
+ {
253
+ "key": "top_category_pct",
254
+ "label": "Top category %",
255
+ "type": "pct",
256
+ "source": "odoo",
257
+ "default": false,
258
+ "description": "Share of last-12-months spend that went to the top category."
259
+ },
260
+ {
261
+ "key": "sku_count",
262
+ "label": "SKUs bought",
263
+ "type": "int",
264
+ "source": "odoo",
265
+ "default": false,
266
+ "description": "Distinct products bought in the last 12 months."
267
+ },
268
+ {
269
+ "key": "top_sku",
270
+ "label": "Top SKU",
271
+ "type": "text",
272
+ "source": "odoo",
273
+ "default": false,
274
+ "description": "The product this customer spent the most on in the last 12 months."
275
+ },
276
+ {
277
+ "key": "days_since",
278
+ "label": "Days since order",
279
+ "type": "int",
280
+ "source": "odoo",
281
+ "default": false,
282
+ "description": "Days since the last confirmed order."
283
+ },
284
+ {
285
+ "key": "typical_gap_days",
286
+ "label": "Typical gap days",
287
+ "type": "int",
288
+ "source": "odoo",
289
+ "default": false,
290
+ "description": "Days this customer usually goes between orders, from their own history."
291
+ },
292
+ {
293
+ "key": "notes",
294
+ "label": "Notes",
295
+ "type": "text",
296
+ "source": "overlay",
297
+ "default": false,
298
+ "description": "Your notes on this customer. Saved in this app only, visible only to you."
299
+ }
300
+ ],
301
+ "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
302
+ "_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
303
+ "product_data": {
304
+ "identity": "pid",
305
+ "business_key": "code",
306
+ "fields": [
307
+ {
308
+ "key": "code",
309
+ "label": "SKU",
310
+ "type": "text",
311
+ "source": "odoo",
312
+ "pinned": true,
313
+ "default": true,
314
+ "description": "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
315
+ },
316
+ {
317
+ "key": "product",
318
+ "label": "Product",
319
+ "type": "text",
320
+ "source": "odoo",
321
+ "default": true,
322
+ "description": "Product name as it appears in Odoo."
323
+ },
324
+ {
325
+ "key": "category",
326
+ "label": "Category",
327
+ "type": "select",
328
+ "source": "odoo",
329
+ "default": true,
330
+ "description": "Product category; '(uncategorized)' when Odoo carries none."
331
+ },
332
+ {
333
+ "key": "supplier",
334
+ "label": "Supplier",
335
+ "type": "text",
336
+ "source": "overlay",
337
+ "default": true,
338
+ "description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
339
+ "shared": true
340
+ },
341
+ {
342
+ "key": "origin_country",
343
+ "label": "Country",
344
+ "type": "text",
345
+ "source": "overlay",
346
+ "default": false,
347
+ "description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
348
+ "shared": true
349
+ },
350
+ {
351
+ "key": "lead_days",
352
+ "label": "Lead time (days)",
353
+ "type": "int",
354
+ "source": "overlay",
355
+ "default": true,
356
+ "description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
357
+ "shared": true
358
+ },
359
+ {
360
+ "key": "first_cost",
361
+ "label": "First cost",
362
+ "type": "currency",
363
+ "source": "overlay",
364
+ "default": false,
365
+ "description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
366
+ "shared": true
367
+ },
368
+ {
369
+ "key": "price_fisch",
370
+ "label": "Fisch price",
371
+ "type": "currency",
372
+ "source": "odoo",
373
+ "description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
374
+ },
375
+ {
376
+ "key": "price_royal_1",
377
+ "label": "Royal 1 price",
378
+ "type": "currency",
379
+ "source": "odoo",
380
+ "description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
381
+ },
382
+ {
383
+ "key": "price_royal_2",
384
+ "label": "Royal 2 price",
385
+ "type": "currency",
386
+ "source": "odoo",
387
+ "description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
388
+ },
389
+ {
390
+ "key": "tier_prices",
391
+ "label": "Tier prices",
392
+ "type": "json",
393
+ "source": "odoo",
394
+ "description": "Every live pricelist that prices this SKU today, as [{pricelist, unit_price}] at qty 1. Blank when no list prices it. This is the honest SET; the three Fisch/Royal columns beside it are the DECLARED subset and cannot show a price on a list the contract does not name."
395
+ },
396
+ {
397
+ "key": "units",
398
+ "label": "Units",
399
+ "type": "json",
400
+ "source": "odoo",
401
+ "description": "The units of measure this SKU is really sold in, as [{name, qty}] where qty is in the product's own unit. Blank means it is sold in ONE unit, not that data is missing - only 1,150 of 5,873 active SKUs (19.6%) carry a unit tier."
402
+ },
403
+ {
404
+ "key": "rev_ytd",
405
+ "label": "Revenue YTD",
406
+ "type": "currency",
407
+ "source": "odoo",
408
+ "default": true,
409
+ "description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
410
+ },
411
+ {
412
+ "key": "rev_ly",
413
+ "label": "Revenue LY",
414
+ "type": "currency",
415
+ "source": "odoo",
416
+ "description": "Same period last year β€” seasonal wholesale compares like for like."
417
+ },
418
+ {
419
+ "key": "yoy_pct",
420
+ "label": "YoY %",
421
+ "type": "pct",
422
+ "source": "odoo",
423
+ "description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
424
+ },
425
+ {
426
+ "key": "qty_ytd",
427
+ "label": "Units YTD",
428
+ "type": "int",
429
+ "source": "odoo",
430
+ "description": "Units sold year to date."
431
+ },
432
+ {
433
+ "key": "orders_ytd",
434
+ "label": "Orders YTD",
435
+ "type": "int",
436
+ "source": "odoo",
437
+ "description": "Distinct orders containing this SKU, year to date."
438
+ },
439
+ {
440
+ "key": "on_hand",
441
+ "label": "On hand",
442
+ "type": "int",
443
+ "source": "odoo",
444
+ "description": "Units in stock. CONSOLIDATED β€” one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
445
+ },
446
+ {
447
+ "key": "unit_cost",
448
+ "label": "Unit cost",
449
+ "type": "currency",
450
+ "source": "odoo",
451
+ "description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
452
+ },
453
+ {
454
+ "key": "inv_value",
455
+ "label": "Stock value",
456
+ "type": "currency",
457
+ "source": "odoo",
458
+ "description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
459
+ },
460
+ {
461
+ "key": "qty_ltm",
462
+ "label": "Units LTM",
463
+ "type": "int",
464
+ "source": "odoo",
465
+ "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
466
+ },
467
+ {
468
+ "key": "dos",
469
+ "label": "Days of supply",
470
+ "type": "int",
471
+ "source": "odoo",
472
+ "description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
473
+ },
474
+ {
475
+ "key": "cover_gap_d",
476
+ "label": "Cover gap (days)",
477
+ "type": "int",
478
+ "source": "odoo",
479
+ "default": false,
480
+ "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
481
+ },
482
+ {
483
+ "key": "stock_bucket",
484
+ "label": "Stock status",
485
+ "type": "select",
486
+ "source": "odoo",
487
+ "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
488
+ },
489
+ {
490
+ "key": "needs_pricing",
491
+ "label": "Needs pricing",
492
+ "type": "select",
493
+ "source": "overlay",
494
+ "default": false,
495
+ "options": [
496
+ "Yes"
497
+ ],
498
+ "shared": true,
499
+ "description": "Team-maintained. A SKU carries β€œYes” when it appears on the NEEDS PRICING tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
500
+ },
501
+ {
502
+ "key": "march_pricelist",
503
+ "label": "March pricelist",
504
+ "type": "select",
505
+ "source": "overlay",
506
+ "default": false,
507
+ "options": [
508
+ "Yes"
509
+ ],
510
+ "shared": true,
511
+ "description": "Team-maintained. A SKU carries β€œYes” when it appears on the March Pricelist tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
512
+ },
513
+ {
514
+ "key": "price_changes",
515
+ "label": "Price changes",
516
+ "type": "select",
517
+ "source": "overlay",
518
+ "default": false,
519
+ "options": [
520
+ "Yes"
521
+ ],
522
+ "shared": true,
523
+ "description": "Team-maintained. A SKU carries β€œYes” when it appears on the Price Changes tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
524
+ },
525
+ {
526
+ "key": "closeouts",
527
+ "label": "Closeouts",
528
+ "type": "select",
529
+ "source": "overlay",
530
+ "default": false,
531
+ "options": [
532
+ "Yes"
533
+ ],
534
+ "shared": true,
535
+ "description": "Team-maintained. A SKU carries β€œYes” when it appears on the Closeouts tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β€” the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
536
+ },
537
+ {
538
+ "key": "notes",
539
+ "label": "Notes",
540
+ "type": "text",
541
+ "source": "overlay",
542
+ "default": false,
543
+ "shared": true,
544
+ "description": "Team-maintained. The 2027 workbook's own non-Odoo columns (Product Description, Packing) folded into one field. Shared with everyone in the workspace."
545
+ }
546
+ ]
547
+ }
548
+ }
platform/core/grid_events.py CHANGED
@@ -801,9 +801,39 @@ def handle_one(event, ctx):
801
  # else is dropped here. Leaving it in would persist a condition the engine can only
802
  # answer with "nothing", so `Cohort is not [a list I deleted]` would show an empty table
803
  # forever with nothing on screen explaining why.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
804
  filters = _ag.clean_filter_tree(cfg.get('filters'),
805
  valid_keys | set(measure_keys),
806
- cohort_ids=set(cohort_ids))
 
807
  sorts = []
808
  for rule in list(cfg.get('sorts') or [])[:20]:
809
  if (isinstance(rule, dict) and rule.get('colId') in valid_keys
 
801
  # else is dropped here. Leaving it in would persist a condition the engine can only
802
  # answer with "nothing", so `Cohort is not [a list I deleted]` would show an empty table
803
  # forever with nothing on screen explaining why.
804
+ # ⭐⭐ W37-T26 / CONTRACT C4 β€” `visible_view_ids` is the same PERMISSION list one leaf over:
805
+ # a `__view__` membership leaf naming a view this caller cannot see is dropped here, for
806
+ # the identical reason the cohort line above gives. Without it the arm is fail-closed and
807
+ # every view leaf is dropped on save, so the condition would vanish on the next read β€”
808
+ # D-90's shape exactly.
809
+ # ⚠ `ctx.visible_views` is already populated (`routes_grid.py` passes it, `:714` reads it),
810
+ # so this needs no new plumbing; it is the value nobody had handed to the validator.
811
+ # ⚠ Edited by lane C, not this file's owner: `platform/core/grid_events.py` is in NO fence
812
+ # and lane C was the last session open. See `mailbox/C.md` NOTE C-24.
813
+ _view_ids = {str(v.get('id')) for v in (visible_views or ())
814
+ if isinstance(v, dict) and v.get('id')}
815
+ # β›”β›” THE CYCLE REFUSAL'S HOST HALF (C4), and it is the ticket's `done-when` rather than a
816
+ # guard bolted on afterwards. View X filtered on "is in View Y" where Y is filtered on X
817
+ # cannot be resolved by anything: X needs Y needs X. The browser refuses it too, with a
818
+ # sentence a person reads; this one is what makes the rule true for a caller that POSTs a
819
+ # view directly, which is every guard-in-the-browser's blind spot.
820
+ # ⚠ REFUSED, NOT REPAIRED. Dropping the offending leaf would save a view the person did not
821
+ # write and leave the panel showing a condition the config does not carry.
822
+ _cycle = _ag.view_filter_cycle(
823
+ view_id, cfg.get('filters'),
824
+ lambda vid: next((v.get('config', {}).get('filters')
825
+ for v in (visible_views or ())
826
+ if isinstance(v, dict) and str(v.get('id')) == vid), None))
827
+ if _cycle:
828
+ _refuse(ctx, 'view_cycle',
829
+ 'that would make a loop: ' + ' refers to '.join(_cycle) +
830
+ '. A view cannot filter on a view that filters on it, because neither can '
831
+ 'be worked out without the other.', view_id)
832
+ return False
833
  filters = _ag.clean_filter_tree(cfg.get('filters'),
834
  valid_keys | set(measure_keys),
835
+ cohort_ids=set(cohort_ids),
836
+ visible_view_ids=_view_ids)
837
  sorts = []
838
  for rule in list(cfg.get('sorts') or [])[:20]:
839
  if (isinstance(rule, dict) and rule.get('colId') in valid_keys
platform/core/user_tables.py CHANGED
@@ -732,6 +732,24 @@ def _ag_formula(raw):
732
  return _agf._clean_formula(raw)
733
 
734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  #: Display formats a field may declare. β›” DISPLAY ONLY β€” none of these touches the stored value,
736
  #: which stays the scalar the fold or the mapper wrote. That separation is the whole safety of the
737
  #: feature: a `thousands` setting can never make a number wrong, only easier to read.
@@ -934,6 +952,16 @@ def clean_fields(raw):
934
  entry['aiEnrich'] = ae
935
  # C3: derived, never typed beside the config. Same call as the other door.
936
  entry['automation'] = _field_agent_binding(entry)
 
 
 
 
 
 
 
 
 
 
937
  if entry is None:
938
  seen.discard(key)
939
  continue
@@ -2255,6 +2283,24 @@ def _clean_field(raw, previous=None):
2255
  # a `field_agent` bag that disagrees with the column. Every OTHER automation bag is untouched.
2256
  if ftype == 'ai_enrich':
2257
  out['automation'] = _field_agent_binding(out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2258
  return out
2259
 
2260
 
 
732
  return _agf._clean_formula(raw)
733
 
734
 
735
+ def _ag_geocode(raw):
736
+ """`aios_grid._clean_geocode`, reached exactly the way `_ag_formula` reaches its validator.
737
+
738
+ ⭐⭐ W37-T22 clause 1, landed by /validate-wave 2026-08-19. The geocode PSEUDO-KIND was
739
+ offered in `ColumnMenu` and cleaned in `aios_grid._field_extras`, but THIS module β€” the
740
+ allowlist every `ut_*` create and patch goes through β€” named no `geocode` arm, so the bag was
741
+ stripped on every user database and the kind had to be gated OFF there. That gap is what made
742
+ the ticket's *"the kind is offered on ANY database that has a text field"* clause false.
743
+
744
+ ⚠ ONE call site for the function-local import, shared by BOTH field doors (`clean_fields` and
745
+ `_clean_field`), for the reason `_ag_formula` states: `core` must not import `aios_grid` at
746
+ module level, and a third and fourth copy of `import aios_grid as _agX` is how one of them
747
+ ends up calling a different validator.
748
+ """
749
+ import aios_grid as _agg
750
+ return _agg._clean_geocode(raw)
751
+
752
+
753
  #: Display formats a field may declare. β›” DISPLAY ONLY β€” none of these touches the stored value,
754
  #: which stays the scalar the fold or the mapper wrote. That separation is the whole safety of the
755
  #: feature: a `thousands` setting can never make a number wrong, only easier to read.
 
952
  entry['aiEnrich'] = ae
953
  # C3: derived, never typed beside the config. Same call as the other door.
954
  entry['automation'] = _field_agent_binding(entry)
955
+ # ⭐⭐ W37-T22 (R4 / C7) β€” THE GEOCODE BAG RIDES THIS DOOR TOO, written in the SAME change
956
+ # as `_clean_field`'s arm rather than a wave later. That is the only difference between
957
+ # this entry and the six above it (`pinned`, `code`, `link`/`rollup`, `formula`,
958
+ # `aiEnrich`), every one of which was a bag this validator dropped until somebody found a
959
+ # column that rendered and computed nothing forever.
960
+ # ⚠ NO type/bag pairing: geocode is a pseudo-kind on a `text` column (see `_clean_geocode`).
961
+ if entry is not None:
962
+ geo = (_ag_geocode(f.get('geocode')) if f.get('geocode') is not None else None)
963
+ if geo and ftype == 'text':
964
+ entry['geocode'] = geo
965
  if entry is None:
966
  seen.discard(key)
967
  continue
 
2283
  # a `field_agent` bag that disagrees with the column. Every OTHER automation bag is untouched.
2284
  if ftype == 'ai_enrich':
2285
  out['automation'] = _field_agent_binding(out)
2286
+ # ⭐⭐ W37-T22 (R4 / C7) β€” THE GEOCODE PSEUDO-KIND'S BAG, added 2026-08-19 by /validate-wave.
2287
+ # Posture is `format`'s and NOT `formula`'s, and that is the whole design: geocode has no
2288
+ # FieldType to pair with (the column's `type` is `text`), so an absent bag is an ordinary text
2289
+ # column rather than a refusal. Enforcing a pairing here would refuse every text column in
2290
+ # every user database.
2291
+ # ⚠ `'geocode' in raw` rather than `raw.get('geocode')`, exactly like `format` and
2292
+ # `description`: an explicit `{}`/None CLEARS the address column while OMITTING the key keeps
2293
+ # what is stored. A PATCH that sends only a label must not silently unconfigure the column β€”
2294
+ # that is the same rule `agg` and `optionColors` are written for.
2295
+ # ⚠ Inheritance is guarded on `ftype == 'text'` for the reason `prev_type == ftype` guards
2296
+ # `link`/`rollup`/`formula`: retyping a geocode column to a number must not resurrect an
2297
+ # address binding through `prev` that nothing will ever read again.
2298
+ geocode_raw = (raw.get('geocode') if 'geocode' in raw
2299
+ else (prev.get('geocode') if ftype == 'text' else None))
2300
+ if geocode_raw is not None:
2301
+ geo = _ag_geocode(geocode_raw)
2302
+ if geo and ftype == 'text':
2303
+ out['geocode'] = geo
2304
  return out
2305
 
2306
 
platform/core/users.py CHANGED
@@ -1,525 +1,532 @@
1
- """Per-user accounts for the platform, persisted in the HF Dataset store (users.json).
2
-
3
- Passwords are salted + PBKDF2-HMAC-SHA256 (200k iterations) β€” never stored or logged in plaintext.
4
- A bootstrap 'admin' account is seeded from APP_PASSWORD so the owner can always log in and create
5
- users; APP_PASSWORD also works as an emergency master for 'admin' if the registry is unreachable.
6
-
7
- Each account carries BU access ('all' or a list of team-ids [5=Fisch, 6=Royal]) which drives
8
- allowed_bus() β€” the basis for per-Business-Unit permissioning (a Royal-only user never sees Fisch).
9
- """
10
- import os
11
- import hmac
12
- import hashlib
13
- import secrets
14
-
15
- import core.store as store
16
-
17
- BU_LABELS = {5: 'Fisch', 6: 'Royal'}
18
- _ITER = 200_000
19
-
20
- #: Wave 15 C-PERM β€” the explicit-resolution marker. Mirrors `core.perm_scope.PERMS_VERSION`;
21
- #: kept as a literal here so `users` does not import the permission layer it is read by.
22
- PERMS_VERSION = 1
23
-
24
-
25
- def _hash(pw, salt):
26
- return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex()
27
-
28
-
29
- def _record(pw, name, role, bus, active=True, modules='all', agent=None, email=None,
30
- perms=None, tenant='royal-imports', platform_admin=False):
31
- salt = secrets.token_hex(16)
32
- rec = {'salt': salt, 'hash': _hash(pw, salt), 'name': name, 'role': role,
33
- 'bus': bus, 'active': active, 'modules': modules,
34
- 'agent': agent or None, 'email': email or None,
35
- # Wave 18 (C1-TENANT, R1): the account's COMPANY. Absent == 'royal-imports' on every
36
- # pre-wave record β€” no migration. Login binds the session to THIS value; the posted
37
- # tenant field can hint but never override it.
38
- 'tenant': str(tenant or 'royal-imports').strip().lower()}
39
- if platform_admin is True:
40
- # Wave 19 (R3): the PLATFORM-operator flag β€” half of `core.platform_admin`'s double lock
41
- # (the other half is `tenant == 'loopable'`). Written ONLY for True, so every record that
42
- # is not deliberately promoted keeps its pre-wave shape and answers False by absence.
43
- # There is no UI writer and there never should be: it is set by provisioning, on purpose.
44
- rec['platform_admin'] = True
45
- if perms is not None:
46
- # Wave 15 C-PERM. A record written WITH perms is migrated by construction β€” the marker
47
- # and the block are set together, here, so no writer can create one without the other.
48
- # (`core.perm_scope` reads an unmarked record as legacy, so a block without its marker
49
- # would be silently ignored; a marker without a block would deny everything.)
50
- rec['perms'] = perms
51
- rec['perms_v'] = PERMS_VERSION
52
- return rec
53
-
54
-
55
- def registry():
56
- return store.get('users')
57
-
58
-
59
- def ensure_bootstrap():
60
- """Seed an 'admin' account from APP_PASSWORD ONLY on a truly fresh store (no users file yet).
61
- Idempotent; no-op if the store is unavailable (the app then falls back to the master-password
62
- path in verify()).
63
-
64
- Critically, this NEVER overwrites an existing registry: it seeds only when store.exists('users')
65
- is definitively False. A transient read failure at startup used to return {} and make this
66
- re-seed just {admin} over the real accounts β€” that is the bug that wiped users on restart."""
67
- if not store.available():
68
- return
69
- if store.exists('users'): # present, or uncertain -> never clobber
70
- return
71
- try:
72
- reg = store.get('users', fresh=True)
73
- except Exception:
74
- return
75
- if reg:
76
- return
77
- master = os.environ.get('APP_PASSWORD', '')
78
- if not master:
79
- return
80
- try:
81
- store.put('users', {'admin': _record(master, 'Administrator', 'admin', 'all')})
82
- except Exception:
83
- pass
84
-
85
-
86
- def _public(username, u):
87
- return {'username': username, 'name': u.get('name', username),
88
- 'role': u.get('role', 'user'), 'bus': u.get('bus', 'all'),
89
- 'modules': u.get('modules', 'all'),
90
- 'agent': u.get('agent'), 'email': u.get('email'),
91
- # Wave 18 (C1-TENANT): the session's tenant binding travels on the projection or it
92
- # does not travel β€” the same rule the perms block states below.
93
- 'tenant': str(u.get('tenant') or 'royal-imports').strip().lower(),
94
- # Wave 14 C-AVATAR: the profile photo is public-safe by definition (it is served
95
- # to every grid session via the workspace map); without it here the API session's
96
- # user record silently drops it and /me can never show your own photo.
97
- 'avatar': u.get('avatar') or None,
98
- # β›” WAVE 15 C-PERM β€” THE WALL TRAVELS ON THIS PROJECTION OR IT DOES NOT TRAVEL.
99
- # `deps._user_for` builds every API session from `_public()`, so a `perms` block
100
- # dropped here is a restricted account served as an unrestricted one β€” silently, on
101
- # every route, with nothing to notice. `perms_v` must ride ALONG WITH it and for the
102
- # same reason inverted: the marker without the block denies everything, the block
103
- # without the marker is ignored. Two keys, one fact, never separated.
104
- # `verify_api` asserts a restricted user's SESSION OBJECT carries both, at the mount
105
- # rather than by grep β€” a projection is exactly the kind of wiring that looks
106
- # present in three files and is absent in the one that runs.
107
- **({'perms': u['perms']} if isinstance(u.get('perms'), dict) else {}),
108
- **({'perms_v': int(u['perms_v'] or 0)} if u.get('perms_v') else {}),
109
- # β›” WAVE 19 R3 β€” THE SAME RULE, ON A NEW FIELD. `deps._user_for` builds every API
110
- # session from this projection, so the platform-admin flag travels here or
111
- # `core.platform_admin.is_platform_admin(session.user)` is blind and the Loopable
112
- # admin plane 403s its own operator. Carried ONLY when the record says True, so a
113
- # session dict for any other account is byte-identical to its pre-wave shape.
114
- # Not a client leak: `routes_auth._public_user` is a whitelist projection and does
115
- # not name this key, so it reaches no browser via /login or /me β€” the client's copy
116
- # is the separate `platformAdmin` bool on GET /settings, which is derived from this.
117
- **({'platform_admin': True} if u.get('platform_admin') is True else {}),
118
- 'epoch': int(u.get('epoch') or 0)}
119
-
120
-
121
- # ------------------------------------------------------------------ session revocation (X3)
122
- # The API's session cookie is SIGNED AND STATELESS: there is no server-side session table to
123
- # delete from, so "log this user out everywhere" needs a number that lives with the account. The
124
- # cookie carries the epoch it was minted under; bumping the account's epoch makes every
125
- # outstanding cookie for that user fail verification on its next use. Absent == 0, so every
126
- # record written before this wave is valid without a migration.
127
- def epoch(username):
128
- """The current session epoch for `username`. None when there is no such account.
129
-
130
- None is NOT 0. 0 is "this account exists and has never been revoked"; None is "no record" β€”
131
- which the session verifier must treat as a reason to refuse, not as a default to compare
132
- against. (The APP_PASSWORD emergency-master admin has no record at all; the verifier handles
133
- that case explicitly rather than inventing an epoch for it here.)
134
- """
135
- username = (username or '').strip().lower()
136
- try:
137
- u = (store.get('users') or {}).get(username)
138
- except Exception:
139
- return None
140
- return int((u or {}).get('epoch') or 0) if u else None
141
-
142
-
143
- def bump_epoch(username):
144
- """Revoke every outstanding API session for this account."""
145
- username = (username or '').strip().lower()
146
-
147
- def _set(reg):
148
- u = reg.get(username)
149
- if u:
150
- u['epoch'] = int(u.get('epoch') or 0) + 1
151
- return reg
152
- store.update('users', _set)
153
-
154
-
155
- def verify(username, pw):
156
- """Return a public user dict on success, else None. APP_PASSWORD is an emergency master for the
157
- 'admin' login even if the store is unreachable, so the owner is never locked out."""
158
- username = (username or '').strip().lower()
159
- if not username or not pw:
160
- return None
161
- master = os.environ.get('APP_PASSWORD', '')
162
- try:
163
- # read fresh so accounts created moments ago (UI or out-of-band) are recognised at once
164
- reg = store.get('users', fresh=True)
165
- except Exception:
166
- reg = {}
167
- u = reg.get(username)
168
- if u is None and '@' in username:
169
- # Wave 18 (R1): the login box takes a username OR an email β€” admin@nurilab.id signs in
170
- # without knowing the slug an admin chose. First case-insensitive email match wins;
171
- # ambiguity is an admin data problem, not a login feature.
172
- for k, r in reg.items():
173
- if isinstance(r, dict) and str(r.get('email') or '').strip().lower() == username:
174
- username, u = k, r
175
- break
176
- if u and u.get('active', True) and hmac.compare_digest(_hash(pw, u['salt']), u['hash']):
177
- return _public(username, u)
178
- # emergency master: admin + APP_PASSWORD always works (covers first run / store outage)
179
- if username == 'admin' and master and hmac.compare_digest(str(pw), master):
180
- # ⚠ CARRY THE RECORD'S CURRENT EPOCH when there is a record to read, so the session cookie
181
- # the API mints from this dict AGREES with the stored account.
182
- #
183
- # This is not what stops the emergency lockout β€” `deps._user_for`'s master fallback does
184
- # that, and a negative control confirmed the lockout is gone with or without this line.
185
- # What it fixes is subtler and is a SCOPE question: a cookie whose epoch disagrees with the
186
- # record falls through to that master fallback, which hands back a SYNTHETIC identity
187
- # (`bus: 'all'`, `modules: 'all'`). An admin whose record narrows either field would
188
- # therefore be silently WIDENED to consolidated, all-module access for the life of that
189
- # session. Matching the epoch means the record branch wins and the account's real scope
190
- # applies, leaving the master fallback as the true last resort it is meant to be.
191
- # 0 when the store is unreachable, which is the case this branch was written for.
192
- return {'username': 'admin', 'name': 'Administrator', 'role': 'admin', 'bus': 'all',
193
- 'modules': 'all', 'epoch': int((reg.get('admin') or {}).get('epoch') or 0)}
194
- return None
195
-
196
-
197
- def create_user(username, pw, name, role='user', bus='all', modules='all',
198
- agent=None, email=None, tenant=None, platform_admin=None):
199
- """Create β€” or, from app.py's dialog, OVERWRITE β€” an account.
200
-
201
- β›” X3: OVERWRITING AN ACCOUNT MUST NOT RESURRECT ITS OLD SESSIONS. `_record()` builds a fresh
202
- record with no `epoch` key, i.e. absent == 0. So re-saving an existing username used to reset
203
- the epoch to 0, and every cookie minted before that account's last password rotation started
204
- verifying again β€” a silent un-revocation. `app.py`'s "Add / update a user" calls this function
205
- for BOTH add and update, so the hole was reachable from the shipped UI.
206
-
207
- Epoch revocation is only ever as strong as the NARROWEST write path that touches the record, so
208
- the carry-and-bump lives here rather than in each caller: an overwrite is at least as
209
- session-invalidating as a password change, and it usually IS one.
210
- """
211
- username = (username or '').strip().lower()
212
- if not username or not pw:
213
- raise ValueError('username and password are required')
214
-
215
- def _add(reg):
216
- prior = reg.get(username) or {}
217
- rec = _record(pw, name or username, role, bus, modules=modules,
218
- agent=agent, email=email,
219
- # An overwrite that names no tenant KEEPS the account's company β€” a
220
- # rename must never quietly move a user between tenants.
221
- tenant=(tenant or prior.get('tenant') or 'royal-imports'),
222
- # Wave 19 (R3): CARRIED, for the same reason `epoch` is carried below β€”
223
- # `_record` builds a FRESH record, so re-running the provisioner (it is
224
- # documented as idempotent) or saving an account through this function
225
- # would silently DEMOTE a platform admin and lock the operator out of
226
- # their own plane. None = leave as it was; True/False = set it deliberately.
227
- platform_admin=(prior.get('platform_admin') is True
228
- if platform_admin is None else platform_admin is True))
229
- if prior:
230
- rec['epoch'] = int(prior.get('epoch') or 0) + 1
231
- reg[username] = rec
232
- return reg
233
- store.update('users', _add)
234
-
235
-
236
- def set_password(username, pw):
237
- username = (username or '').strip().lower()
238
-
239
- def _set(reg):
240
- u = reg.get(username)
241
- if u:
242
- u['salt'] = secrets.token_hex(16)
243
- u['hash'] = _hash(pw, u['salt'])
244
- # X3: a password change revokes every outstanding API session for the account. Bumped
245
- # INSIDE the same read-modify-write as the hash so the two can never disagree β€” a
246
- # separate update() could rotate the password and leave old cookies live if the second
247
- # write failed.
248
- u['epoch'] = int(u.get('epoch') or 0) + 1
249
- return reg
250
- store.update('users', _set)
251
-
252
-
253
- def set_active(username, active):
254
- username = (username or '').strip().lower()
255
-
256
- def _set(reg):
257
- if username in reg:
258
- reg[username]['active'] = bool(active)
259
- # X3: DEACTIVATION must kill live sessions, not just future logins β€” otherwise a
260
- # disabled account keeps working until its cookie expires. Bumped on reactivation too:
261
- # cheap, and it means a re-enabled account never resurrects a stale cookie.
262
- reg[username]['epoch'] = int(reg[username].get('epoch') or 0) + 1
263
- return reg
264
- store.update('users', _set)
265
-
266
-
267
- def set_platform_admin(username, on):
268
- """Promote/demote a PLATFORM administrator (wave 19, R3) without touching the password.
269
-
270
- The narrow write, deliberately: `create_user` is the destructive path (fresh salt, fresh
271
- hash, bumped epoch) and promoting somebody should not sign them out or rotate a credential.
272
- Cleared by REMOVING the key, so a demoted record goes back to its pre-wave shape rather than
273
- carrying a `False` that reads as "somebody considered this".
274
-
275
- ⚠ This is the flag only. It grants nothing on its own β€” `core.platform_admin` also demands
276
- the `loopable` tenant, and there is no code path anywhere that moves an account between
277
- tenants, which is what makes the second lock hold.
278
- """
279
- username = (username or '').strip().lower()
280
-
281
- def _set(reg):
282
- u = reg.get(username)
283
- if u:
284
- if on is True:
285
- u['platform_admin'] = True
286
- else:
287
- u.pop('platform_admin', None)
288
- return reg
289
- store.update('users', _set)
290
-
291
-
292
- # ------------------------------------------------------------------ activity stamps (wave 19 R4)
293
- # "When did this account last sign in, and is anyone actually using it?" β€” the two questions the
294
- # Loopable admin plane exists to answer and that NOTHING in the product could answer before this
295
- # wave (there is no login history, no audit log, no request log anywhere).
296
- #
297
- # β›” THIS IS THE FIRST HIGH-FREQUENCY WRITER `users.json` HAS EVER HAD, and that bucket also holds
298
- # every password hash, `active`, `epoch` and the permission blocks. Three rules follow, and the
299
- # second one is a correctness rule, not a performance one:
300
- #
301
- # 1. **In-place mutation of ONE key.** Never `_record()`, never a whole-record replace: a stamp
302
- # that rebuilt the record would reset the salt/hash (locking the user out) or the epoch
303
- # (silently un-revoking every cookie ever minted for them). `set_password`'s docstring
304
- # explains why that class of bug is worth naming out loud.
305
- #
306
- # 2. **SYNCHRONOUS FLUSH β€” deliberately NOT the async path, and this reverses my first draft.**
307
- # `store.update(flush='async')` rebases on the PROCESS CACHE once a key is `_owned`
308
- # (`core/store.py:284`) and its worker uploads that whole cached blob. For a
309
- # table-workspace key, written by one process, that is exactly right. For `users` it is a
310
- # silent-revert machine: tenant #0's Streamlit host writes the SAME file, so an API process
311
- # holding a cache from an hour ago would, on its next stamp, upload a blob in which a
312
- # password rotation or a deactivation performed in the other host simply never happened.
313
- # A telemetry stamp must not be able to resurrect a disabled account. `flush='sync'` does a
314
- # FRESH strict read inside the store's lock and then uploads, which is the same discipline
315
- # every other `users` writer already uses.
316
- #
317
- # 3. **OFF THE REQUEST THREAD, so rule 2 costs nothing.** A sync commit is a hub round-trip, and
318
- # neither a sign-in nor a random request an hour later should wait for it. Each stamp runs on
319
- # a short-lived daemon thread; `flush_stamps()` is how a test or a shutdown waits for them.
320
- # Everything is fail-silent: a stamp is telemetry and may never turn a good login into a
321
- # failed one β€” the plane shows an honest "never" instead.
322
- def _now_iso():
323
- import datetime as _dt
324
- return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec='seconds')
325
-
326
-
327
- #: Live stamp threads, so `flush_stamps()` can join them. Bounded by construction β€” one thread per
328
- #: stamp, and a stamp is at most one per login plus one per account per hour per process.
329
- _STAMPS = []
330
- _STAMPS_LOCK = __import__('threading').Lock()
331
-
332
-
333
- def _stamp(username, fields):
334
- """Merge `fields` into ONE account record, on a background thread, with a fresh read."""
335
- username = (username or '').strip().lower()
336
- if not username or not fields:
337
- return None
338
-
339
- def _set(reg):
340
- u = reg.get(username)
341
- if isinstance(u, dict):
342
- u.update(fields)
343
- return reg
344
-
345
- def _work():
346
- try:
347
- store.update('users', _set) # sync: fresh strict read + blocking upload
348
- except Exception:
349
- pass # a lost stamp is a lost stamp, never an error
350
- import threading
351
- t = threading.Thread(target=_work, daemon=True, name=f'user-stamp:{username}')
352
- with _STAMPS_LOCK:
353
- _STAMPS[:] = [x for x in _STAMPS if x.is_alive()]
354
- _STAMPS.append(t)
355
- t.start()
356
- return t
357
-
358
-
359
- def flush_stamps(timeout=10.0):
360
- """Block until outstanding stamp writes have been applied. For gates and shutdown hooks β€”
361
- the app never needs it, exactly like `core.store.flush`."""
362
- with _STAMPS_LOCK:
363
- pending = list(_STAMPS)
364
- for t in pending:
365
- t.join(timeout=timeout)
366
- return all(not t.is_alive() for t in pending)
367
-
368
-
369
- def touch_login(username, when=None):
370
- """Stamp `last_login` (ISO-8601, UTC, OFFSET-BEARING) on a SUCCESSFUL login.
371
-
372
- `username` must be the RESOLVED account key, not what the person typed: `verify()` accepts an
373
- email address and resolves it to the registry key, so stamping the typed identifier would
374
- write a stamp onto a key that does not exist and create a phantom account in the registry.
375
-
376
- `last_active` rides along β€” signing in IS activity, and setting both here means the plane's
377
- two columns agree the moment somebody logs in rather than an hour later.
378
- """
379
- stamp = when or _now_iso()
380
- return _stamp(username, {'last_login': stamp, 'last_active': stamp})
381
-
382
-
383
- def touch_active(username, when=None):
384
- """Stamp `last_active` β€” "this session did something". Throttled BY THE CALLER (`deps.py`
385
- holds a process-local last-seen map), so this is not a store round-trip per request."""
386
- return _stamp(username, {'last_active': when or _now_iso()})
387
-
388
-
389
- def set_access(username, role=None, bus=None, modules=None, agent=None, email=None,
390
- name=None, perms=None):
391
- """Update access fields. agent/email: pass '' to clear, None to leave unchanged β€”
392
- the user↔agent link scopes the Customer List page / digests to that agent's book.
393
-
394
- `name` follows the same None-means-unchanged idiom. It is here because it had no setter at all:
395
- a display name could previously only be changed by re-creating the record through
396
- `create_user`, i.e. by also resetting the password (and, before the fix above, the session
397
- epoch). Y4's `PATCH {name?}` needs the narrow write, not the destructive one."""
398
- username = (username or '').strip().lower()
399
-
400
- def _set(reg):
401
- u = reg.get(username)
402
- if u:
403
- if name is not None:
404
- u['name'] = name
405
- if role is not None:
406
- u['role'] = role
407
- if bus is not None:
408
- u['bus'] = bus
409
- if modules is not None:
410
- u['modules'] = modules
411
- if agent is not None:
412
- u['agent'] = agent or None
413
- if email is not None:
414
- u['email'] = email or None
415
- if perms is not None:
416
- # Wave 15 C-PERM. Writing perms MIGRATES the record: the marker goes on in the
417
- # same read-modify-write, so a record can never end up with one and not the
418
- # other (see `_record`). Whole-block replace, matching the PUT route's shape β€”
419
- # a merge would make "remove this restriction" unexpressible.
420
- u['perms'] = perms
421
- u['perms_v'] = PERMS_VERSION
422
- return reg
423
- store.update('users', _set)
424
-
425
-
426
- def allowed_bus_labels(user):
427
- """BU labels this user may select. 'all' -> All+Fisch+Royal; a single BU -> just that BU (no
428
- 'All', so the other BU is never reachable); multiple -> All + each."""
429
- bus = (user or {}).get('bus', 'all')
430
- if bus == 'all':
431
- return ['All', 'Fisch', 'Royal']
432
- labels = [BU_LABELS[b] for b in bus if b in BU_LABELS]
433
- if not labels:
434
- return ['All', 'Fisch', 'Royal']
435
- return (['All'] + labels) if len(labels) > 1 else labels
436
-
437
-
438
- def assignable_people(tenant=None):
439
- """Display names for `user`-typed overlay columns β€” the tenant's ACTIVE accounts.
440
-
441
- Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
442
- call rather than persisted with the column: a snapshot would keep offering people who
443
- have left and never offer people who joined. Deactivated accounts are excluded; a value
444
- already stored on a row is untouched β€” history should still say who owned something.
445
-
446
- Wave 18 (C1-TENANT): pass `tenant` to scope the choices to ONE company β€” the user registry
447
- is a global control-plane bucket, and a Nurilab picker offering Royal's staff is a
448
- cross-tenant name leak.
449
-
450
- β›”β›” WAVE 33 (W33-T37) β€” A BLANK `tenant` NOW RETURNS NOTHING. It used to skip the filter and
451
- return EVERY tenant's staff: `if want and …` is structurally fail-OPEN, and the exemption was
452
- written for "None = unscoped (the Streamlit host, tenant #0's process)" β€” a host DELETED at
453
- EXIT-6. So the sanctioned caller no longer exists, and what was left is a whole-platform
454
- roster one forgotten kwarg away, with no error to notice
455
- ([[gate-must-go-red-not-crash]]'s sibling: a wall that answers instead of refusing).
456
- ⚠ The direction is the safety: this can only ever NARROW. All six live call sites pass
457
- `session.tenant`, which `aios_session.read` refuses to admit blank, so nothing legitimate
458
- changes β€” and a future caller that forgets gets an empty picker somebody notices instead of a
459
- leak nobody does.
460
- """
461
- return sorted({str(u.get('name') or n) for n, u in _tenant_accounts(tenant)})
462
-
463
-
464
- def set_avatar(username, data_url):
465
- """Set (or clear, with None/'') the user's profile photo β€” a data URL (wave 14 C-AVATAR,
466
- [[loopable-wave14-split]] item 11). Stored VERBATIM; the API route owns validation (mime +
467
- decoded size) because this value is served back to every grid session. Cleared by removing
468
- the key, so records without a photo keep their pre-wave shape."""
469
- username = (username or '').strip().lower()
470
-
471
- def _set(reg):
472
- u = reg.get(username)
473
- if u:
474
- if data_url:
475
- u['avatar'] = str(data_url)
476
- else:
477
- u.pop('avatar', None)
478
- return reg
479
- store.update('users', _set)
480
-
481
-
482
- def avatar_map(tenant=None):
483
- """{display name -> avatar data URL} for ACTIVE accounts with a photo β€” the companion of
484
- `assignable_people()`, keyed by the SAME vocabulary: a `user` cell stores the display
485
- name, so the display name is the only join a renderer has. Two active accounts sharing a
486
- display name share one option; the first WITH a photo wins the key rather than a coin
487
- flip deciding whether the option has a face. `tenant` scopes it exactly as
488
- `assignable_people(tenant)` does, and for the same leak β€” including wave 33's fail-closed
489
- blank, which both take from the ONE resolver below rather than each writing `if want and …`.
490
- """
491
- out = {}
492
- for username, u in sorted(_tenant_accounts(tenant), key=lambda kv: kv[0]):
493
- av = u.get('avatar')
494
- nm = str(u.get('name') or username)
495
- if av and nm not in out:
496
- out[nm] = str(av)
497
- return out
498
-
499
-
500
- def _tenant_accounts(tenant):
501
- """`[(username, record), …]` β€” the ACTIVE accounts of exactly ONE tenant. FAIL-CLOSED.
502
-
503
- β›” THE ONE PLACE THE TENANT FILTER FOR THE USER REGISTRY IS WRITTEN. It was written twice,
504
- identically, in `assignable_people` and `avatar_map`, and both copies were fail-OPEN on a
505
- blank slug in the same way (`if want and …`). Two copies of a wall is two places for one of
506
- them to be fixed [[one-question-two-normalizers]]; `routes_shares._people` is a THIRD copy of
507
- the same predicate and is lane C's to fold in.
508
-
509
- ⚠ `str(u.get('tenant') or 'royal-imports')` is kept on the RECORD side deliberately: a
510
- pre-wave-18 account genuinely has no `tenant` key and IS tenant #0's, and `users._public`
511
- normalises it the same way. What changed is the WANT side β€” a blank want is now a refusal
512
- rather than a wildcard.
513
- """
514
- want = str(tenant or '').strip().lower()
515
- if not want:
516
- # Not an error and not everything: nobody. See `assignable_people`'s note β€” the one
517
- # caller this exemption was written for (the Streamlit host) was deleted at EXIT-6.
518
- return []
519
- try:
520
- reg = registry() or {}
521
- except Exception:
522
- return []
523
- return [(n, u) for n, u in reg.items()
524
- if isinstance(u, dict) and u.get('active') is not False
525
- and str(u.get('tenant') or 'royal-imports').strip().lower() == want]
 
 
 
 
 
 
 
 
1
+ """Per-user accounts for the platform, persisted in the HF Dataset store (users.json).
2
+
3
+ Passwords are salted + PBKDF2-HMAC-SHA256 (200k iterations) β€” never stored or logged in plaintext.
4
+ A bootstrap 'admin' account is seeded from APP_PASSWORD so the owner can always log in and create
5
+ users; APP_PASSWORD also works as an emergency master for 'admin' if the registry is unreachable.
6
+
7
+ Each account carries BU access ('all' or a list of team-ids [5=Fisch, 6=Royal]) which drives
8
+ allowed_bus() β€” the basis for per-Business-Unit permissioning (a Royal-only user never sees Fisch).
9
+ """
10
+ import os
11
+ import hmac
12
+ import hashlib
13
+ import secrets
14
+
15
+ import core.store as store
16
+
17
+ BU_LABELS = {5: 'Fisch', 6: 'Royal'}
18
+ _ITER = 200_000
19
+
20
+ #: Wave 15 C-PERM β€” the explicit-resolution marker. Mirrors `core.perm_scope.PERMS_VERSION`;
21
+ #: kept as a literal here so `users` does not import the permission layer it is read by.
22
+ PERMS_VERSION = 1
23
+
24
+
25
+ def _hash(pw, salt):
26
+ return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex()
27
+
28
+
29
+ def _record(pw, name, role, bus, active=True, modules='all', agent=None, email=None,
30
+ perms=None, tenant='royal-imports', platform_admin=False):
31
+ salt = secrets.token_hex(16)
32
+ rec = {'salt': salt, 'hash': _hash(pw, salt), 'name': name, 'role': role,
33
+ 'bus': bus, 'active': active, 'modules': modules,
34
+ 'agent': agent or None, 'email': email or None,
35
+ # Wave 18 (C1-TENANT, R1): the account's COMPANY. Absent == 'royal-imports' on every
36
+ # pre-wave record β€” no migration. Login binds the session to THIS value; the posted
37
+ # tenant field can hint but never override it.
38
+ 'tenant': str(tenant or 'royal-imports').strip().lower()}
39
+ if platform_admin is True:
40
+ # Wave 19 (R3): the PLATFORM-operator flag β€” half of `core.platform_admin`'s double lock
41
+ # (the other half is `tenant == 'loopable'`). Written ONLY for True, so every record that
42
+ # is not deliberately promoted keeps its pre-wave shape and answers False by absence.
43
+ # There is no UI writer and there never should be: it is set by provisioning, on purpose.
44
+ rec['platform_admin'] = True
45
+ if perms is not None:
46
+ # Wave 15 C-PERM. A record written WITH perms is migrated by construction β€” the marker
47
+ # and the block are set together, here, so no writer can create one without the other.
48
+ # (`core.perm_scope` reads an unmarked record as legacy, so a block without its marker
49
+ # would be silently ignored; a marker without a block would deny everything.)
50
+ rec['perms'] = perms
51
+ rec['perms_v'] = PERMS_VERSION
52
+ return rec
53
+
54
+
55
+ def registry():
56
+ return store.get('users')
57
+
58
+
59
+ def ensure_bootstrap():
60
+ """Seed an 'admin' account from APP_PASSWORD ONLY on a truly fresh store (no users file yet).
61
+ Idempotent; no-op if the store is unavailable (the app then falls back to the master-password
62
+ path in verify()).
63
+
64
+ Critically, this NEVER overwrites an existing registry: it seeds only when store.exists('users')
65
+ is definitively False. A transient read failure at startup used to return {} and make this
66
+ re-seed just {admin} over the real accounts β€” that is the bug that wiped users on restart."""
67
+ if not store.available():
68
+ return
69
+ if store.exists('users'): # present, or uncertain -> never clobber
70
+ return
71
+ try:
72
+ reg = store.get('users', fresh=True)
73
+ except Exception:
74
+ return
75
+ if reg:
76
+ return
77
+ master = os.environ.get('APP_PASSWORD', '')
78
+ if not master:
79
+ return
80
+ try:
81
+ store.put('users', {'admin': _record(master, 'Administrator', 'admin', 'all')})
82
+ except Exception:
83
+ pass
84
+
85
+
86
+ def _public(username, u):
87
+ return {'username': username, 'name': u.get('name', username),
88
+ 'role': u.get('role', 'user'), 'bus': u.get('bus', 'all'),
89
+ 'modules': u.get('modules', 'all'),
90
+ 'agent': u.get('agent'), 'email': u.get('email'),
91
+ # Wave 18 (C1-TENANT): the session's tenant binding travels on the projection or it
92
+ # does not travel β€” the same rule the perms block states below.
93
+ 'tenant': str(u.get('tenant') or 'royal-imports').strip().lower(),
94
+ # Wave 14 C-AVATAR: the profile photo is public-safe by definition (it is served
95
+ # to every grid session via the workspace map); without it here the API session's
96
+ # user record silently drops it and /me can never show your own photo.
97
+ 'avatar': u.get('avatar') or None,
98
+ # β›” WAVE 15 C-PERM β€” THE WALL TRAVELS ON THIS PROJECTION OR IT DOES NOT TRAVEL.
99
+ # `deps._user_for` builds every API session from `_public()`, so a `perms` block
100
+ # dropped here is a restricted account served as an unrestricted one β€” silently, on
101
+ # every route, with nothing to notice. `perms_v` must ride ALONG WITH it and for the
102
+ # same reason inverted: the marker without the block denies everything, the block
103
+ # without the marker is ignored. Two keys, one fact, never separated.
104
+ # `verify_api` asserts a restricted user's SESSION OBJECT carries both, at the mount
105
+ # rather than by grep β€” a projection is exactly the kind of wiring that looks
106
+ # present in three files and is absent in the one that runs.
107
+ **({'perms': u['perms']} if isinstance(u.get('perms'), dict) else {}),
108
+ **({'perms_v': int(u['perms_v'] or 0)} if u.get('perms_v') else {}),
109
+ # β›” WAVE 19 R3 β€” THE SAME RULE, ON A NEW FIELD. `deps._user_for` builds every API
110
+ # session from this projection, so the platform-admin flag travels here or
111
+ # `core.platform_admin.is_platform_admin(session.user)` is blind and the Loopable
112
+ # admin plane 403s its own operator. Carried ONLY when the record says True, so a
113
+ # session dict for any other account is byte-identical to its pre-wave shape.
114
+ # Not a client leak: `routes_auth._public_user` is a whitelist projection and does
115
+ # not name this key, so it reaches no browser via /login or /me β€” the client's copy
116
+ # is the separate `platformAdmin` bool on GET /settings, which is derived from this.
117
+ **({'platform_admin': True} if u.get('platform_admin') is True else {}),
118
+ 'epoch': int(u.get('epoch') or 0)}
119
+
120
+
121
+ # ------------------------------------------------------------------ session revocation (X3)
122
+ # The API's session cookie is SIGNED AND STATELESS: there is no server-side session table to
123
+ # delete from, so "log this user out everywhere" needs a number that lives with the account. The
124
+ # cookie carries the epoch it was minted under; bumping the account's epoch makes every
125
+ # outstanding cookie for that user fail verification on its next use. Absent == 0, so every
126
+ # record written before this wave is valid without a migration.
127
+ def epoch(username):
128
+ """The current session epoch for `username`. None when there is no such account.
129
+
130
+ None is NOT 0. 0 is "this account exists and has never been revoked"; None is "no record" β€”
131
+ which the session verifier must treat as a reason to refuse, not as a default to compare
132
+ against. (The APP_PASSWORD emergency-master admin has no record at all; the verifier handles
133
+ that case explicitly rather than inventing an epoch for it here.)
134
+ """
135
+ username = (username or '').strip().lower()
136
+ try:
137
+ u = (store.get('users') or {}).get(username)
138
+ except Exception:
139
+ return None
140
+ return int((u or {}).get('epoch') or 0) if u else None
141
+
142
+
143
+ def bump_epoch(username):
144
+ """Revoke every outstanding API session for this account."""
145
+ username = (username or '').strip().lower()
146
+
147
+ def _set(reg):
148
+ u = reg.get(username)
149
+ if u:
150
+ u['epoch'] = int(u.get('epoch') or 0) + 1
151
+ return reg
152
+ store.update('users', _set)
153
+
154
+
155
+ def verify(username, pw):
156
+ """Return a public user dict on success, else None. APP_PASSWORD is an emergency master for the
157
+ 'admin' login even if the store is unreachable, so the owner is never locked out."""
158
+ username = (username or '').strip().lower()
159
+ if not username or not pw:
160
+ return None
161
+ master = os.environ.get('APP_PASSWORD', '')
162
+ try:
163
+ # read fresh so accounts created moments ago (UI or out-of-band) are recognised at once
164
+ reg = store.get('users', fresh=True)
165
+ except Exception:
166
+ reg = {}
167
+ u = reg.get(username)
168
+ # β›” KEY FIRST, EMAIL SECOND, AND SINCE WAVE 37 THAT ORDER IS A DECISION RATHER THAN AN ACCIDENT.
169
+ # R8 lets a username BE an email address, so a typed string can now match a registry KEY and a
170
+ # different account's `email` field at the same time. The key wins, here, by construction: this
171
+ # branch is only reached when `reg.get(username)` missed. `routes_admin.py::create_user` refuses
172
+ # to CREATE either collision (`username_shadows_email` / `email_shadows_username`) so the
173
+ # ambiguity cannot be introduced through the product; this line is what decides it for any
174
+ # record that arrived some other way.
175
+ if u is None and '@' in username:
176
+ # Wave 18 (R1): the login box takes a username OR an email β€” admin@nurilab.id signs in
177
+ # without knowing the slug an admin chose. First case-insensitive email match wins;
178
+ # ambiguity is an admin data problem, not a login feature.
179
+ for k, r in reg.items():
180
+ if isinstance(r, dict) and str(r.get('email') or '').strip().lower() == username:
181
+ username, u = k, r
182
+ break
183
+ if u and u.get('active', True) and hmac.compare_digest(_hash(pw, u['salt']), u['hash']):
184
+ return _public(username, u)
185
+ # emergency master: admin + APP_PASSWORD always works (covers first run / store outage)
186
+ if username == 'admin' and master and hmac.compare_digest(str(pw), master):
187
+ # ⚠ CARRY THE RECORD'S CURRENT EPOCH when there is a record to read, so the session cookie
188
+ # the API mints from this dict AGREES with the stored account.
189
+ #
190
+ # This is not what stops the emergency lockout β€” `deps._user_for`'s master fallback does
191
+ # that, and a negative control confirmed the lockout is gone with or without this line.
192
+ # What it fixes is subtler and is a SCOPE question: a cookie whose epoch disagrees with the
193
+ # record falls through to that master fallback, which hands back a SYNTHETIC identity
194
+ # (`bus: 'all'`, `modules: 'all'`). An admin whose record narrows either field would
195
+ # therefore be silently WIDENED to consolidated, all-module access for the life of that
196
+ # session. Matching the epoch means the record branch wins and the account's real scope
197
+ # applies, leaving the master fallback as the true last resort it is meant to be.
198
+ # 0 when the store is unreachable, which is the case this branch was written for.
199
+ return {'username': 'admin', 'name': 'Administrator', 'role': 'admin', 'bus': 'all',
200
+ 'modules': 'all', 'epoch': int((reg.get('admin') or {}).get('epoch') or 0)}
201
+ return None
202
+
203
+
204
+ def create_user(username, pw, name, role='user', bus='all', modules='all',
205
+ agent=None, email=None, tenant=None, platform_admin=None):
206
+ """Create β€” or, from app.py's dialog, OVERWRITE β€” an account.
207
+
208
+ β›” X3: OVERWRITING AN ACCOUNT MUST NOT RESURRECT ITS OLD SESSIONS. `_record()` builds a fresh
209
+ record with no `epoch` key, i.e. absent == 0. So re-saving an existing username used to reset
210
+ the epoch to 0, and every cookie minted before that account's last password rotation started
211
+ verifying again β€” a silent un-revocation. `app.py`'s "Add / update a user" calls this function
212
+ for BOTH add and update, so the hole was reachable from the shipped UI.
213
+
214
+ Epoch revocation is only ever as strong as the NARROWEST write path that touches the record, so
215
+ the carry-and-bump lives here rather than in each caller: an overwrite is at least as
216
+ session-invalidating as a password change, and it usually IS one.
217
+ """
218
+ username = (username or '').strip().lower()
219
+ if not username or not pw:
220
+ raise ValueError('username and password are required')
221
+
222
+ def _add(reg):
223
+ prior = reg.get(username) or {}
224
+ rec = _record(pw, name or username, role, bus, modules=modules,
225
+ agent=agent, email=email,
226
+ # An overwrite that names no tenant KEEPS the account's company β€” a
227
+ # rename must never quietly move a user between tenants.
228
+ tenant=(tenant or prior.get('tenant') or 'royal-imports'),
229
+ # Wave 19 (R3): CARRIED, for the same reason `epoch` is carried below β€”
230
+ # `_record` builds a FRESH record, so re-running the provisioner (it is
231
+ # documented as idempotent) or saving an account through this function
232
+ # would silently DEMOTE a platform admin and lock the operator out of
233
+ # their own plane. None = leave as it was; True/False = set it deliberately.
234
+ platform_admin=(prior.get('platform_admin') is True
235
+ if platform_admin is None else platform_admin is True))
236
+ if prior:
237
+ rec['epoch'] = int(prior.get('epoch') or 0) + 1
238
+ reg[username] = rec
239
+ return reg
240
+ store.update('users', _add)
241
+
242
+
243
+ def set_password(username, pw):
244
+ username = (username or '').strip().lower()
245
+
246
+ def _set(reg):
247
+ u = reg.get(username)
248
+ if u:
249
+ u['salt'] = secrets.token_hex(16)
250
+ u['hash'] = _hash(pw, u['salt'])
251
+ # X3: a password change revokes every outstanding API session for the account. Bumped
252
+ # INSIDE the same read-modify-write as the hash so the two can never disagree β€” a
253
+ # separate update() could rotate the password and leave old cookies live if the second
254
+ # write failed.
255
+ u['epoch'] = int(u.get('epoch') or 0) + 1
256
+ return reg
257
+ store.update('users', _set)
258
+
259
+
260
+ def set_active(username, active):
261
+ username = (username or '').strip().lower()
262
+
263
+ def _set(reg):
264
+ if username in reg:
265
+ reg[username]['active'] = bool(active)
266
+ # X3: DEACTIVATION must kill live sessions, not just future logins β€” otherwise a
267
+ # disabled account keeps working until its cookie expires. Bumped on reactivation too:
268
+ # cheap, and it means a re-enabled account never resurrects a stale cookie.
269
+ reg[username]['epoch'] = int(reg[username].get('epoch') or 0) + 1
270
+ return reg
271
+ store.update('users', _set)
272
+
273
+
274
+ def set_platform_admin(username, on):
275
+ """Promote/demote a PLATFORM administrator (wave 19, R3) without touching the password.
276
+
277
+ The narrow write, deliberately: `create_user` is the destructive path (fresh salt, fresh
278
+ hash, bumped epoch) and promoting somebody should not sign them out or rotate a credential.
279
+ Cleared by REMOVING the key, so a demoted record goes back to its pre-wave shape rather than
280
+ carrying a `False` that reads as "somebody considered this".
281
+
282
+ ⚠ This is the flag only. It grants nothing on its own β€” `core.platform_admin` also demands
283
+ the `loopable` tenant, and there is no code path anywhere that moves an account between
284
+ tenants, which is what makes the second lock hold.
285
+ """
286
+ username = (username or '').strip().lower()
287
+
288
+ def _set(reg):
289
+ u = reg.get(username)
290
+ if u:
291
+ if on is True:
292
+ u['platform_admin'] = True
293
+ else:
294
+ u.pop('platform_admin', None)
295
+ return reg
296
+ store.update('users', _set)
297
+
298
+
299
+ # ------------------------------------------------------------------ activity stamps (wave 19 R4)
300
+ # "When did this account last sign in, and is anyone actually using it?" β€” the two questions the
301
+ # Loopable admin plane exists to answer and that NOTHING in the product could answer before this
302
+ # wave (there is no login history, no audit log, no request log anywhere).
303
+ #
304
+ # β›” THIS IS THE FIRST HIGH-FREQUENCY WRITER `users.json` HAS EVER HAD, and that bucket also holds
305
+ # every password hash, `active`, `epoch` and the permission blocks. Three rules follow, and the
306
+ # second one is a correctness rule, not a performance one:
307
+ #
308
+ # 1. **In-place mutation of ONE key.** Never `_record()`, never a whole-record replace: a stamp
309
+ # that rebuilt the record would reset the salt/hash (locking the user out) or the epoch
310
+ # (silently un-revoking every cookie ever minted for them). `set_password`'s docstring
311
+ # explains why that class of bug is worth naming out loud.
312
+ #
313
+ # 2. **SYNCHRONOUS FLUSH β€” deliberately NOT the async path, and this reverses my first draft.**
314
+ # `store.update(flush='async')` rebases on the PROCESS CACHE once a key is `_owned`
315
+ # (`core/store.py:284`) and its worker uploads that whole cached blob. For a
316
+ # table-workspace key, written by one process, that is exactly right. For `users` it is a
317
+ # silent-revert machine: tenant #0's Streamlit host writes the SAME file, so an API process
318
+ # holding a cache from an hour ago would, on its next stamp, upload a blob in which a
319
+ # password rotation or a deactivation performed in the other host simply never happened.
320
+ # A telemetry stamp must not be able to resurrect a disabled account. `flush='sync'` does a
321
+ # FRESH strict read inside the store's lock and then uploads, which is the same discipline
322
+ # every other `users` writer already uses.
323
+ #
324
+ # 3. **OFF THE REQUEST THREAD, so rule 2 costs nothing.** A sync commit is a hub round-trip, and
325
+ # neither a sign-in nor a random request an hour later should wait for it. Each stamp runs on
326
+ # a short-lived daemon thread; `flush_stamps()` is how a test or a shutdown waits for them.
327
+ # Everything is fail-silent: a stamp is telemetry and may never turn a good login into a
328
+ # failed one β€” the plane shows an honest "never" instead.
329
+ def _now_iso():
330
+ import datetime as _dt
331
+ return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec='seconds')
332
+
333
+
334
+ #: Live stamp threads, so `flush_stamps()` can join them. Bounded by construction β€” one thread per
335
+ #: stamp, and a stamp is at most one per login plus one per account per hour per process.
336
+ _STAMPS = []
337
+ _STAMPS_LOCK = __import__('threading').Lock()
338
+
339
+
340
+ def _stamp(username, fields):
341
+ """Merge `fields` into ONE account record, on a background thread, with a fresh read."""
342
+ username = (username or '').strip().lower()
343
+ if not username or not fields:
344
+ return None
345
+
346
+ def _set(reg):
347
+ u = reg.get(username)
348
+ if isinstance(u, dict):
349
+ u.update(fields)
350
+ return reg
351
+
352
+ def _work():
353
+ try:
354
+ store.update('users', _set) # sync: fresh strict read + blocking upload
355
+ except Exception:
356
+ pass # a lost stamp is a lost stamp, never an error
357
+ import threading
358
+ t = threading.Thread(target=_work, daemon=True, name=f'user-stamp:{username}')
359
+ with _STAMPS_LOCK:
360
+ _STAMPS[:] = [x for x in _STAMPS if x.is_alive()]
361
+ _STAMPS.append(t)
362
+ t.start()
363
+ return t
364
+
365
+
366
+ def flush_stamps(timeout=10.0):
367
+ """Block until outstanding stamp writes have been applied. For gates and shutdown hooks β€”
368
+ the app never needs it, exactly like `core.store.flush`."""
369
+ with _STAMPS_LOCK:
370
+ pending = list(_STAMPS)
371
+ for t in pending:
372
+ t.join(timeout=timeout)
373
+ return all(not t.is_alive() for t in pending)
374
+
375
+
376
+ def touch_login(username, when=None):
377
+ """Stamp `last_login` (ISO-8601, UTC, OFFSET-BEARING) on a SUCCESSFUL login.
378
+
379
+ `username` must be the RESOLVED account key, not what the person typed: `verify()` accepts an
380
+ email address and resolves it to the registry key, so stamping the typed identifier would
381
+ write a stamp onto a key that does not exist and create a phantom account in the registry.
382
+
383
+ `last_active` rides along β€” signing in IS activity, and setting both here means the plane's
384
+ two columns agree the moment somebody logs in rather than an hour later.
385
+ """
386
+ stamp = when or _now_iso()
387
+ return _stamp(username, {'last_login': stamp, 'last_active': stamp})
388
+
389
+
390
+ def touch_active(username, when=None):
391
+ """Stamp `last_active` β€” "this session did something". Throttled BY THE CALLER (`deps.py`
392
+ holds a process-local last-seen map), so this is not a store round-trip per request."""
393
+ return _stamp(username, {'last_active': when or _now_iso()})
394
+
395
+
396
+ def set_access(username, role=None, bus=None, modules=None, agent=None, email=None,
397
+ name=None, perms=None):
398
+ """Update access fields. agent/email: pass '' to clear, None to leave unchanged β€”
399
+ the user↔agent link scopes the Customer List page / digests to that agent's book.
400
+
401
+ `name` follows the same None-means-unchanged idiom. It is here because it had no setter at all:
402
+ a display name could previously only be changed by re-creating the record through
403
+ `create_user`, i.e. by also resetting the password (and, before the fix above, the session
404
+ epoch). Y4's `PATCH {name?}` needs the narrow write, not the destructive one."""
405
+ username = (username or '').strip().lower()
406
+
407
+ def _set(reg):
408
+ u = reg.get(username)
409
+ if u:
410
+ if name is not None:
411
+ u['name'] = name
412
+ if role is not None:
413
+ u['role'] = role
414
+ if bus is not None:
415
+ u['bus'] = bus
416
+ if modules is not None:
417
+ u['modules'] = modules
418
+ if agent is not None:
419
+ u['agent'] = agent or None
420
+ if email is not None:
421
+ u['email'] = email or None
422
+ if perms is not None:
423
+ # Wave 15 C-PERM. Writing perms MIGRATES the record: the marker goes on in the
424
+ # same read-modify-write, so a record can never end up with one and not the
425
+ # other (see `_record`). Whole-block replace, matching the PUT route's shape β€”
426
+ # a merge would make "remove this restriction" unexpressible.
427
+ u['perms'] = perms
428
+ u['perms_v'] = PERMS_VERSION
429
+ return reg
430
+ store.update('users', _set)
431
+
432
+
433
+ def allowed_bus_labels(user):
434
+ """BU labels this user may select. 'all' -> All+Fisch+Royal; a single BU -> just that BU (no
435
+ 'All', so the other BU is never reachable); multiple -> All + each."""
436
+ bus = (user or {}).get('bus', 'all')
437
+ if bus == 'all':
438
+ return ['All', 'Fisch', 'Royal']
439
+ labels = [BU_LABELS[b] for b in bus if b in BU_LABELS]
440
+ if not labels:
441
+ return ['All', 'Fisch', 'Royal']
442
+ return (['All'] + labels) if len(labels) > 1 else labels
443
+
444
+
445
+ def assignable_people(tenant=None):
446
+ """Display names for `user`-typed overlay columns β€” the tenant's ACTIVE accounts.
447
+
448
+ Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
449
+ call rather than persisted with the column: a snapshot would keep offering people who
450
+ have left and never offer people who joined. Deactivated accounts are excluded; a value
451
+ already stored on a row is untouched β€” history should still say who owned something.
452
+
453
+ Wave 18 (C1-TENANT): pass `tenant` to scope the choices to ONE company β€” the user registry
454
+ is a global control-plane bucket, and a Nurilab picker offering Royal's staff is a
455
+ cross-tenant name leak.
456
+
457
+ β›”β›” WAVE 33 (W33-T37) β€” A BLANK `tenant` NOW RETURNS NOTHING. It used to skip the filter and
458
+ return EVERY tenant's staff: `if want and …` is structurally fail-OPEN, and the exemption was
459
+ written for "None = unscoped (the Streamlit host, tenant #0's process)" β€” a host DELETED at
460
+ EXIT-6. So the sanctioned caller no longer exists, and what was left is a whole-platform
461
+ roster one forgotten kwarg away, with no error to notice
462
+ ([[gate-must-go-red-not-crash]]'s sibling: a wall that answers instead of refusing).
463
+ ⚠ The direction is the safety: this can only ever NARROW. All six live call sites pass
464
+ `session.tenant`, which `aios_session.read` refuses to admit blank, so nothing legitimate
465
+ changes β€” and a future caller that forgets gets an empty picker somebody notices instead of a
466
+ leak nobody does.
467
+ """
468
+ return sorted({str(u.get('name') or n) for n, u in _tenant_accounts(tenant)})
469
+
470
+
471
+ def set_avatar(username, data_url):
472
+ """Set (or clear, with None/'') the user's profile photo β€” a data URL (wave 14 C-AVATAR,
473
+ [[loopable-wave14-split]] item 11). Stored VERBATIM; the API route owns validation (mime +
474
+ decoded size) because this value is served back to every grid session. Cleared by removing
475
+ the key, so records without a photo keep their pre-wave shape."""
476
+ username = (username or '').strip().lower()
477
+
478
+ def _set(reg):
479
+ u = reg.get(username)
480
+ if u:
481
+ if data_url:
482
+ u['avatar'] = str(data_url)
483
+ else:
484
+ u.pop('avatar', None)
485
+ return reg
486
+ store.update('users', _set)
487
+
488
+
489
+ def avatar_map(tenant=None):
490
+ """{display name -> avatar data URL} for ACTIVE accounts with a photo β€” the companion of
491
+ `assignable_people()`, keyed by the SAME vocabulary: a `user` cell stores the display
492
+ name, so the display name is the only join a renderer has. Two active accounts sharing a
493
+ display name share one option; the first WITH a photo wins the key rather than a coin
494
+ flip deciding whether the option has a face. `tenant` scopes it exactly as
495
+ `assignable_people(tenant)` does, and for the same leak β€” including wave 33's fail-closed
496
+ blank, which both take from the ONE resolver below rather than each writing `if want and …`.
497
+ """
498
+ out = {}
499
+ for username, u in sorted(_tenant_accounts(tenant), key=lambda kv: kv[0]):
500
+ av = u.get('avatar')
501
+ nm = str(u.get('name') or username)
502
+ if av and nm not in out:
503
+ out[nm] = str(av)
504
+ return out
505
+
506
+
507
+ def _tenant_accounts(tenant):
508
+ """`[(username, record), …]` β€” the ACTIVE accounts of exactly ONE tenant. FAIL-CLOSED.
509
+
510
+ β›” THE ONE PLACE THE TENANT FILTER FOR THE USER REGISTRY IS WRITTEN. It was written twice,
511
+ identically, in `assignable_people` and `avatar_map`, and both copies were fail-OPEN on a
512
+ blank slug in the same way (`if want and …`). Two copies of a wall is two places for one of
513
+ them to be fixed [[one-question-two-normalizers]]; `routes_shares._people` is a THIRD copy of
514
+ the same predicate and is lane C's to fold in.
515
+
516
+ ⚠ `str(u.get('tenant') or 'royal-imports')` is kept on the RECORD side deliberately: a
517
+ pre-wave-18 account genuinely has no `tenant` key and IS tenant #0's, and `users._public`
518
+ normalises it the same way. What changed is the WANT side β€” a blank want is now a refusal
519
+ rather than a wildcard.
520
+ """
521
+ want = str(tenant or '').strip().lower()
522
+ if not want:
523
+ # Not an error and not everything: nobody. See `assignable_people`'s note β€” the one
524
+ # caller this exemption was written for (the Streamlit host) was deleted at EXIT-6.
525
+ return []
526
+ try:
527
+ reg = registry() or {}
528
+ except Exception:
529
+ return []
530
+ return [(n, u) for n, u in reg.items()
531
+ if isinstance(u, dict) and u.get('active') is not False
532
+ and str(u.get('tenant') or 'royal-imports').strip().lower() == want]
platform/harness/datastore.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/harness/semantic.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/model/metrics/sales.yml CHANGED
@@ -1,108 +1,149 @@
1
- # Metrics: sales β€” the core wholesale metrics, defined ONCE (OM-0). Every surface (pages, the
2
- # metric dictionary, the Analyst, MCP) resolves these by key through harness/semantic.py.
3
- # Fields per metric:
4
- # key/label/description β€” identity + the human definition (visibility = trust)
5
- # agg + field β€” sum | count_distinct over the topic's entity
6
- # agg: ratio β€” numerator/denominator are metric KEYS (resolved recursively)
7
- # agg: derived + expr β€” arithmetic over metric keys (safe parser; +,-,*,/ and parens only)
8
- # format β€” usd | int | pct (rendering hint for surfaces)
9
- # ai_context β€” what a small model must know to use the metric correctly
10
- # validate β€” the INDEPENDENT Odoo cross-check contract (named method implemented in
11
- # harness/semantic.py _VALIDATORS; a metric that can't tie out says so)
12
- topic: sales_lines
13
- metrics:
14
- - key: revenue
15
- label: Revenue
16
- agg: sum
17
- field: price_subtotal
18
- format: usd
19
- description: "Untaxed revenue of confirmed wholesale order lines (the owner's 'sales' number)."
20
- ai_context: "Always untaxed; excludes Amazon (GIFTWARE DEALS) and unconfirmed orders. Filter one BU via team_id (Fisch=5, Royal=6)."
21
- validate:
22
- method: order_level_revenue
23
- note: "Ξ£ line price_subtotal must equal Ξ£ parent-order amount_untaxed under the same scope, to the cent (line vs order basis β€” the built-in cross-check)."
24
-
25
- - key: units
26
- label: Units sold
27
- agg: sum
28
- field: product_uom_qty
29
- format: int
30
- description: "Total quantity across confirmed wholesale order lines."
31
- ai_context: "Mixed UoMs are summed as ordered quantity; for weight/case analysis convert per product UoM first."
32
-
33
- - key: margin
34
- label: Gross margin $
35
- agg: sum
36
- field: margin
37
- format: usd
38
- description: "Line revenue minus line cost (Odoo Margin module), summed."
39
- ai_context: "margin is read_group-aggregatable (Margin module installed). COGS = revenue - margin. purchase_price is per-UNIT cost β€” never sum it as a total."
40
-
41
- - key: cogs
42
- label: COGS
43
- agg: derived
44
- expr: "revenue - margin"
45
- format: usd
46
- description: "Cost of goods sold, derived: revenue minus gross margin."
47
- ai_context: "Derived, not pulled β€” Odoo carries cost on lines as margin; COGS is the difference."
48
-
49
- - key: margin_pct
50
- label: Gross margin %
51
- agg: ratio
52
- numerator: margin
53
- denominator: revenue
54
- format: pct
55
- description: "Gross margin as a share of revenue."
56
- ai_context: "Guarded against zero revenue (returns 0). Compare across BUs/categories at the same scope only."
57
-
58
- - key: orders
59
- label: Orders
60
- topic: sales_orders
61
- agg: count
62
- format: int
63
- description: "Confirmed wholesale orders in the window (order-header count β€” what the scorecards show)."
64
- ai_context: "Order-level count; slightly higher than distinct-orders-from-lines because a few confirmed orders carry zero lines (a data-health artifact the reconciliation contract counts exactly)."
65
- validate:
66
- method: order_count
67
- note: "Order-level count must equal distinct line-parent orders + line-less orders, exactly (surfaces empty orders instead of hiding them in a tolerance)."
68
-
69
- - key: customers
70
- label: Active customers
71
- agg: count_distinct
72
- field: order_partner_id
73
- format: int
74
- description: "Distinct customers with at least one confirmed wholesale order line in the window."
75
- ai_context: "Customer = the order's partner. Agent attribution uses res.partner.agent_ids, NOT the order user_id."
76
-
77
- - key: aov
78
- label: Average order value
79
- agg: ratio
80
- numerator: revenue
81
- denominator: orders
82
- format: usd
83
- description: "Revenue per distinct order."
84
- ai_context: "Ratio of two registered metrics at identical scope; never average per-order averages."
85
-
86
- # ⭐ Wave 21 R2 β€” the FULLY-INVOICED basis, as separate metrics (owner ruling: picker entries,
87
- # not a basis dropdown). Same topics, same scope, ONE predicate narrower: the order's Odoo
88
- # invoice_status = 'invoiced'. `store_filter_sql` filters the store path (sum/count CASE);
89
- # `live_domain` filters the live path β€” BOTH or store_parity compares two different questions.
90
- - key: revenue_invoiced
91
- label: Sales β€” fully invoiced
92
- agg: sum
93
- field: price_subtotal
94
- store_filter_sql: "o.invoice_status = 'invoiced'"
95
- live_domain: [["order_id.invoice_status", "=", "invoiced"]]
96
- format: usd
97
- description: "Untaxed revenue of confirmed wholesale order lines whose parent order Odoo marks fully invoiced (invoice_status = invoiced)."
98
- ai_context: "Same scope as revenue, narrowed by the ORDER-level fully-invoiced flag. This is NOT posted-invoice-line revenue: a partially invoiced order is excluded entirely until Odoo flips the flag. Reconciled store-vs-live per BU by store_parity."
99
-
100
- - key: orders_invoiced
101
- label: Orders β€” fully invoiced
102
- topic: sales_orders
103
- agg: count
104
- store_filter_sql: "o.invoice_status = 'invoiced'"
105
- live_domain: [["invoice_status", "=", "invoiced"]]
106
- format: int
107
- description: "Confirmed wholesale orders Odoo marks fully invoiced (invoice_status = invoiced)."
108
- ai_context: "Order-header count under the orders scope plus the fully-invoiced flag; partially invoiced and not-yet-invoiced orders are excluded. Reconciled store-vs-live per BU by store_parity."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Metrics: sales β€” the core wholesale metrics, defined ONCE (OM-0). Every surface (pages, the
2
+ # metric dictionary, the Analyst, MCP) resolves these by key through harness/semantic.py.
3
+ # Fields per metric:
4
+ # key/label/description β€” identity + the human definition (visibility = trust)
5
+ # agg + field β€” sum | count_distinct over the topic's entity
6
+ # agg: ratio β€” numerator/denominator are metric KEYS (resolved recursively)
7
+ # agg: derived + expr β€” arithmetic over metric keys (safe parser; +,-,*,/ and parens only)
8
+ # format β€” usd | int | pct (rendering hint for surfaces)
9
+ # ai_context β€” what a small model must know to use the metric correctly
10
+ # validate β€” the INDEPENDENT Odoo cross-check contract (named method implemented in
11
+ # harness/semantic.py _VALIDATORS; a metric that can't tie out says so)
12
+ # empty β€” W37 C1's EMPTY-WINDOW FAMILY. See below; a metric an ENTITY topic
13
+ # offers as a lookback column MUST declare one.
14
+ #
15
+ # β›”β›” `empty:` β€” WHAT A ROW WITH NO ACTIVITY IN THE WINDOW RENDERS AS (wave 37, contract C1).
16
+ # Measured and load-bearing: only 1,646 of 5,836 active products sold in the last 90 days, so a
17
+ # per-SKU lookback metric has NO GROUP for 72% of the catalogue. Get the default wrong and every
18
+ # product grid reads as broken. Two values, and the difference is whether the blank cell would be
19
+ # a TRUE STATEMENT:
20
+ # empty: zero ADDITIVE β€” units, revenue, margin $, COGS. "It sold nothing" is a real
21
+ # measurement, so 0 is the honest cell and a blank would hide a fact.
22
+ # empty: blank RATIO / DERIVED-FROM-A-RATIO β€” GM %, ASP. A 0% margin on zero sales is a
23
+ # FALSE statement, not a missing one. β›” AND THE GUARD IS THE DENOMINATOR, NOT
24
+ # THE MISSING GROUP: `semantic._post_compute` returns 0.0 for `num/0`, so a SKU
25
+ # that DID sell at $0 would print "0.0%" with a group behind it. The resolver
26
+ # blanks on a zero denominator, which is the only reading that is never a lie.
27
+ # ⚠ A metric with no `empty:` is REFUSED by `entity_measures()` rather than defaulted β€” a
28
+ # defaulted family is a guess about truth, and C1 says a metric that cannot say which family it
29
+ # is in does not ship.
30
+ topic: sales_lines
31
+ metrics:
32
+ - key: revenue
33
+ label: Revenue
34
+ agg: sum
35
+ field: price_subtotal
36
+ format: usd
37
+ empty: zero
38
+ description: "Untaxed revenue of confirmed wholesale order lines (the owner's 'sales' number)."
39
+ ai_context: "Always untaxed; excludes Amazon (GIFTWARE DEALS) and unconfirmed orders. Filter one BU via team_id (Fisch=5, Royal=6)."
40
+ validate:
41
+ method: order_level_revenue
42
+ note: "Ξ£ line price_subtotal must equal Ξ£ parent-order amount_untaxed under the same scope, to the cent (line vs order basis β€” the built-in cross-check)."
43
+
44
+ - key: units
45
+ label: Units sold
46
+ agg: sum
47
+ field: product_uom_qty
48
+ format: int
49
+ empty: zero
50
+ description: "Total quantity across confirmed wholesale order lines."
51
+ ai_context: "Mixed UoMs are summed as ordered quantity; for weight/case analysis convert per product UoM first."
52
+
53
+ - key: margin
54
+ label: Gross margin $
55
+ agg: sum
56
+ field: margin
57
+ format: usd
58
+ empty: zero
59
+ description: "Line revenue minus line cost (Odoo Margin module), summed."
60
+ ai_context: "margin is read_group-aggregatable (Margin module installed). COGS = revenue - margin. purchase_price is per-UNIT cost β€” never sum it as a total."
61
+
62
+ - key: cogs
63
+ label: COGS
64
+ agg: derived
65
+ expr: "revenue - margin"
66
+ format: usd
67
+ empty: zero
68
+ description: "Cost of goods sold, derived: revenue minus gross margin."
69
+ ai_context: "Derived, not pulled β€” Odoo carries cost on lines as margin; COGS is the difference."
70
+
71
+ - key: margin_pct
72
+ label: Gross margin %
73
+ agg: ratio
74
+ numerator: margin
75
+ denominator: revenue
76
+ format: pct
77
+ empty: blank
78
+ description: "Gross margin as a share of revenue."
79
+ ai_context: "Compare across BUs/categories at the same scope only. ⚠ TWO ZERO-REVENUE BEHAVIOURS, deliberately: the scalar/analyst path returns 0 (semantic._post_compute's guard), while an ENTITY LOOKBACK COLUMN renders BLANK (empty: blank) β€” on a grid, a printed 0.0% beside 5,836 products would assert a margin nobody measured."
80
+
81
+ # ⭐ W37-T10 β€” ASP, the fifth SHIP-FIRST per-SKU metric (proto/P3). Blended $18.76 over 1,646
82
+ # SKUs in the 90 days to 2026-08-19. A RATIO of two same-topic base measures, so it survives a
83
+ # GROUPED store_query (only a CROSS-TOPIC component is refused β€” that is what stops `aov`,
84
+ # whose denominator `orders` lives on sales_orders).
85
+ # ⚠ UNITS ARE AS-ORDERED, mixed UoM. `units` says so and this inherits it: a SKU sold in cases
86
+ # and in singles has an ASP blended across both, which is the true average selling price of a
87
+ # line unit and NOT a per-piece price.
88
+ - key: asp
89
+ label: Average selling price
90
+ agg: ratio
91
+ numerator: revenue
92
+ denominator: units
93
+ format: usd
94
+ empty: blank
95
+ description: "Revenue per unit sold β€” the blended average selling price."
96
+ ai_context: "revenue Γ· units at identical scope. Mixed units of measure are summed as ordered quantity, so this is per ordered unit, not per piece. Blank when nothing sold: an ASP of $0 on zero units is a false statement, not a missing one."
97
+
98
+ - key: orders
99
+ label: Orders
100
+ topic: sales_orders
101
+ agg: count
102
+ format: int
103
+ description: "Confirmed wholesale orders in the window (order-header count β€” what the scorecards show)."
104
+ ai_context: "Order-level count; slightly higher than distinct-orders-from-lines because a few confirmed orders carry zero lines (a data-health artifact the reconciliation contract counts exactly)."
105
+ validate:
106
+ method: order_count
107
+ note: "Order-level count must equal distinct line-parent orders + line-less orders, exactly (surfaces empty orders instead of hiding them in a tolerance)."
108
+
109
+ - key: customers
110
+ label: Active customers
111
+ agg: count_distinct
112
+ field: order_partner_id
113
+ format: int
114
+ empty: zero
115
+ description: "Distinct customers with at least one confirmed wholesale order line in the window."
116
+ ai_context: "Customer = the order's partner. Agent attribution uses res.partner.agent_ids, NOT the order user_id."
117
+
118
+ - key: aov
119
+ label: Average order value
120
+ agg: ratio
121
+ numerator: revenue
122
+ denominator: orders
123
+ format: usd
124
+ description: "Revenue per distinct order."
125
+ ai_context: "Ratio of two registered metrics at identical scope; never average per-order averages."
126
+
127
+ # ⭐ Wave 21 R2 β€” the FULLY-INVOICED basis, as separate metrics (owner ruling: picker entries,
128
+ # not a basis dropdown). Same topics, same scope, ONE predicate narrower: the order's Odoo
129
+ # invoice_status = 'invoiced'. `store_filter_sql` filters the store path (sum/count CASE);
130
+ # `live_domain` filters the live path β€” BOTH or store_parity compares two different questions.
131
+ - key: revenue_invoiced
132
+ label: Sales β€” fully invoiced
133
+ agg: sum
134
+ field: price_subtotal
135
+ store_filter_sql: "o.invoice_status = 'invoiced'"
136
+ live_domain: [["order_id.invoice_status", "=", "invoiced"]]
137
+ format: usd
138
+ description: "Untaxed revenue of confirmed wholesale order lines whose parent order Odoo marks fully invoiced (invoice_status = invoiced)."
139
+ ai_context: "Same scope as revenue, narrowed by the ORDER-level fully-invoiced flag. This is NOT posted-invoice-line revenue: a partially invoiced order is excluded entirely until Odoo flips the flag. Reconciled store-vs-live per BU by store_parity."
140
+
141
+ - key: orders_invoiced
142
+ label: Orders β€” fully invoiced
143
+ topic: sales_orders
144
+ agg: count
145
+ store_filter_sql: "o.invoice_status = 'invoiced'"
146
+ live_domain: [["invoice_status", "=", "invoiced"]]
147
+ format: int
148
+ description: "Confirmed wholesale orders Odoo marks fully invoiced (invoice_status = invoiced)."
149
+ ai_context: "Order-header count under the orders scope plus the fully-invoiced flag; partially invoiced and not-yet-invoiced orders are excluded. Reconciled store-vs-live per BU by store_parity."
platform/model/metrics/stock.yml ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Metrics: stock β€” physical movement (W37-T13, owner item 4 / ruling R1).
2
+ #
3
+ # β›”β›” THE DIRECTION LIVES IN `store_filter_sql`, WHICH IS WHY THESE ARE TWO METRICS AND NOT ONE
4
+ # METRIC WITH A PARAMETER. The model's filtered-measure support compiles `store_filter_sql` into
5
+ # `sum(CASE WHEN <predicate> THEN <field> ELSE 0 END)`, so IN and OUT are the SAME sum over the
6
+ # SAME rows under two different predicates β€” one scan answers both, and neither can drift from the
7
+ # other's scope because they share every other clause.
8
+ #
9
+ # β›” EACH PREDICATE IS TWO-SIDED, and that is the ticket's headline trap (`proto/P1-stock-moves.md`):
10
+ # `internal -> internal` is **58% of all moves**. A one-sided test (`destination is internal`) counts
11
+ # every internal transfer as an arrival AND a one-sided `source is internal` counts it as a
12
+ # departure β€” both columns roughly double, nothing errors, and the totals stay internally
13
+ # consistent with each other, which is what makes it survive review.
14
+ #
15
+ # ⚠ `usage` COMES FROM `stock_location`, NOT from the move. `location_id` mirrors as an id plus a
16
+ # NAME; there is no usage on the move row, so `stock_location` is joined in the topic precisely to
17
+ # make this predicate expressible. If that join is ever dropped, these two metrics do not go red β€”
18
+ # they go NULL-predicate, which SQL treats as false, and both columns silently read ZERO.
19
+ #
20
+ # ⚠ ADJUSTMENTS AND SCRAP ARE INCLUDED, DELIBERATELY, AND THE ALTERNATIVE WAS MEASURED. They are
21
+ # ~30% of IN and ~20% of OUT, and the two obvious exclusions are NOT equivalent β€” the 5,320-unit
22
+ # gap between them is entirely `Virtual Locations/Scrap`. A physical arrival is an arrival however
23
+ # it was booked, so the honest default is to count it and let the `source`/`destination` dims
24
+ # separate it. Excluding it silently would be a different number under the same label.
25
+ topic: stock_moves
26
+ metrics:
27
+ - key: stock_in
28
+ label: Stock moved in
29
+ agg: sum
30
+ field: quantity_done
31
+ store_filter_sql: "sd.usage = 'internal' AND COALESCE(sl.usage, '') <> 'internal'"
32
+ format: int
33
+ empty: zero
34
+ description: "Units that ARRIVED at an internal location from outside it, over the window."
35
+ ai_context: "Receipts, returns inward and inventory adjustments upward. Two-sided by construction: an internal-to-internal transfer is NOT an arrival. Quantities are in each product's own unit, so never sum this across SKUs."
36
+
37
+ - key: stock_out
38
+ label: Stock moved out
39
+ agg: sum
40
+ field: quantity_done
41
+ store_filter_sql: "sl.usage = 'internal' AND COALESCE(sd.usage, '') <> 'internal'"
42
+ format: int
43
+ empty: zero
44
+ description: "Units that LEFT an internal location for outside it, over the window."
45
+ ai_context: "Deliveries, scrap and adjustments downward. Two-sided by construction: an internal-to-internal transfer is NOT a departure. Quantities are in each product's own unit, so never sum this across SKUs."
46
+
47
+ # ⚠ NET IS DERIVED, never a third sum. Deriving it guarantees `net = in - out` exactly, where a
48
+ # separately-summed net could disagree with its own two components under a filter and nothing
49
+ # would say which was right.
50
+ - key: stock_net
51
+ label: Stock moved net
52
+ agg: derived
53
+ expr: "stock_in - stock_out"
54
+ format: int
55
+ empty: zero
56
+ description: "Arrivals minus departures over the window β€” the period change in units held."
57
+ ai_context: "Derived from stock_in and stock_out, so it always reconciles to them. It is a MOVEMENT figure, not the on-hand balance: the balance is `product_data.on_hand` from stock.quant."
platform/model/topics/odoo_agents.yml CHANGED
@@ -21,6 +21,38 @@ store:
21
  # NO date_col β€” a registry is not a dated event stream. Stated rather than
22
  # omitted, so its absence reads as a fact and not as an unfinished file.
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  # key / label / type / kind, derived from the grid contract. `kind` says where the
25
  # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
26
  # from another topic; a `link` points at another database.
 
21
  # NO date_col β€” a registry is not a dated event stream. Stated rather than
22
  # omitted, so its absence reads as a fact and not as an unfinished file.
23
 
24
+ # ⭐⭐ W37-T12 / owner item 4 (R1) β€” THE LOOKBACK-MEASURE BINDING, the agent half of
25
+ # *"It should apply to Odoo agents database as well."* Same contract as
26
+ # `odoo_products.yml`: a FACT topic, and the dim of that topic which carries THIS
27
+ # entity's identity.
28
+ #
29
+ # β›”β›” WHICH AGENT SOURCE, STATED β€” because there are THREE in this Odoo and they name
30
+ # DIFFERENT PEOPLE (`proto/P3-metric-catalog.md`: 9 agents carry route-1 revenue, 12
31
+ # carry commission lines, 17 carry the flag). This binds ROUTE 1, the CUSTOMER-MASTER
32
+ # BOOK: `sales_lines.agent` is `rp.agent_id`, the customer's assigned agent, so every
33
+ # order of that customer counts toward their agent. That is "whose book is this" and it
34
+ # is the right question for a column on the AGENT REGISTRY.
35
+ # ⚠ It is NOT commission. `account.invoice.line.agent` (topic `commission_lines`) is the
36
+ # per-INVOICE-LINE credited agent and answers a different question; `sales_lines.yml`'s
37
+ # own `agent` dim comment carries the full disagreement and says never to "fix" one by
38
+ # reading the other.
39
+ #
40
+ # ⭐ THE JOIN NEEDS NO NEW DIM, unlike the product side. `sales_lines.agent` declares a
41
+ # `name_col`, so `store_query` emits `agent_id` β€” which IS `res.partner.id`, which IS
42
+ # this grid's `odoo_id` identity. Product needed `product_code` because its grid keys on
43
+ # `default_code`; this one already keys on the same integer the fact topic groups by.
44
+ measures:
45
+ source: sales_lines
46
+ dim: agent
47
+ keys: [revenue, units, margin, cogs, margin_pct, asp, customers]
48
+ not_yet:
49
+ - key: orders / aov
50
+ cause: "`orders` lives on topic `sales_orders`, so under a `group_by` it is a CROSS-TOPIC measure and `semantic.store_query` refuses it (scalar-only). `aov` inherits the refusal through its denominator"
51
+ fix: "add an ORDER-COUNT metric to `sales_lines` itself β€” `count_distinct` over `l.order_id` β€” which answers the same question at this grain in one pass"
52
+ - key: commission_amount
53
+ cause: "the commission basis lives on `account.invoice.line.agent` (topic `commission_lines`), a different topic AND a different grain; `invoice_lines` deduplicates to at most one agent per line"
54
+ fix: "bind a SECOND measures block per source once the contract allows more than one, and label the columns so the two routes can never be read as the same number"
55
+
56
  # key / label / type / kind, derived from the grid contract. `kind` says where the
57
  # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
58
  # from another topic; a `link` points at another database.
platform/model/topics/odoo_products.yml CHANGED
@@ -26,6 +26,57 @@ store:
26
  category: {label: "Category"}
27
  supplier: {label: "Supplier"}
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  # key / label / type / kind, derived from the grid contract. `kind` says where the
30
  # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
31
  # from another topic; a `link` points at another database.
 
26
  category: {label: "Category"}
27
  supplier: {label: "Supplier"}
28
 
29
+ # ⭐⭐ W37-T10 / owner item 4 (ruling R1) β€” THE LOOKBACK-MEASURE BINDING. Owner: *"Odoo products
30
+ # should have a lookbsck metrics like sales etc."* This block is what turns `measures: []` on the
31
+ # product workspace into a real catalogue, and it is DECLARED HERE rather than hand-written in a
32
+ # route for the reason `_rollup_source_offer` gives about itself: a second list is a second
33
+ # definition of the same fact, and the two drift the day somebody adds a metric.
34
+ #
35
+ # β›” THE JOIN KEY IS THE TRAP CONTRACT C1 NAMES, and this is where it is answered. `source` is a
36
+ # FACT topic; `dim` is the dim of THAT topic which carries THIS entity's identity. `sales_lines`
37
+ # has two candidates and only one is right: `product` groups by Odoo's `product_id`, while this
38
+ # database's identity (see `scope.identity` above) is `default_code`. Binding to `product` would
39
+ # key every cell on a number no grid row carries β€” a column of nulls that reads as "never sold".
40
+ #
41
+ # ⚠ A SINGLE-TOPIC GROUPED QUERY, which is why `semantic.store_query`'s cross-topic refusal does
42
+ # not bite: `revenue` lives ON `sales_lines` and we group `sales_lines` by its OWN dim. The same
43
+ # refusal is real and DOES bite `aov`/`orders` (denominator `orders` lives on `sales_orders`), so
44
+ # those two are absent from `keys` below and `entity_measures()` re-proves that rather than
45
+ # trusting this list.
46
+ # ⭐ A LIST SINCE W37-T13: an entity's columns legitimately come from more than ONE fact topic.
47
+ # Sales movement is `sales_lines`; physical movement is `stock_moves`. Same grid, same join key
48
+ # (`product_code`), different tables β€” forcing stock into the sales topic would have defined a
49
+ # metric against rows it does not have.
50
+ measures:
51
+ - source: sales_lines
52
+ dim: product_code
53
+ # ⭐ The SHIP-FIRST six of `proto/P3-metric-catalog.md`, and they cost ONE grouped query
54
+ # together (measured 0.22 s over 1,670 groups against the mirror; 2.55 s live).
55
+ keys: [units, revenue, margin, cogs, margin_pct, asp]
56
+ # β›” REPORTED, NOT SILENTLY DROPPED (standing rule 1's second sentence, applied to a catalogue).
57
+ # Each of these is a real metric `proto/P3-metric-catalog.md` measured and this wave does not
58
+ # ship, with the CAUSE and the fix β€” so the next session extends the list instead of
59
+ # re-measuring, and nobody reads the six as "all Odoo can answer".
60
+ not_yet:
61
+ - key: days_since_last_sale
62
+ cause: "needs `agg: max` over a DATE, which `semantic._measure_sql` does not implement (sum | count | count_distinct only), and a date-typed measure could not render anyway: `aios_grid.MEASURE_FIELD_TYPES` is {currency, int, pct}"
63
+ fix: "add `agg: max` to `_measure_sql` and express the metric as an INT β€” `date_diff('day', max(date), today)` β€” so it renders as a number of days rather than a date. Measured live at 4.61 s over 3,348 SKUs"
64
+ - key: distinct_orders
65
+ cause: "`__count` on the live path is LINE count, not ORDER count (12,707 lines vs 12,645 distinct SKU-order pairs); the honest figure needs a 2-level groupby measured at 10.87 s live"
66
+ fix: "on the STORE path this is `count(DISTINCT l.order_id)` in one pass β€” add it as a `count_distinct` metric on `sales_lines` with field `order_id`"
67
+
68
+ # ⭐⭐ W37-T13 β€” PHYSICAL MOVEMENT. `stock.move` and `stock_location` are mirrored now (the
69
+ # `not_yet` entry that used to sit here said "not mirrored, so there is no store binding to
70
+ # group"; that is what changed). The direction rule is TWO-SIDED and lives in the metrics'
71
+ # `store_filter_sql` β€” see `model/metrics/stock.yml`, which carries the 58% trap in full.
72
+ - source: stock_moves
73
+ dim: product_code
74
+ keys: [stock_in, stock_out, stock_net]
75
+ not_yet:
76
+ - key: stock_in_excl_adjustments
77
+ cause: "adjustments and scrap are ~30% of IN / ~20% of OUT and the two obvious exclusions are NOT equivalent - the 5,320-unit gap between them is entirely `Virtual Locations/Scrap`, so the choice is a business ruling rather than a filter"
78
+ fix: "ask the owner which exclusion they mean, then add it as a SEPARATE metric with `store_filter_sql` narrowed on `sl.usage`/`sd.usage` - never by changing what `stock_in` means"
79
+
80
  # key / label / type / kind, derived from the grid contract. `kind` says where the
81
  # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
82
  # from another topic; a `link` points at another database.
platform/model/topics/sales_lines.yml CHANGED
@@ -42,6 +42,19 @@ store:
42
  dims:
43
  order_partner: {col: "l.order_partner_id", name_col: "l.order_partner_name", label: "Customer"}
44
  product: {col: "l.product_id", name_col: "l.product_name", label: "Product"}
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  # team names via CASE (crm_team is not synced; ids are baked tenant scope like the team filter)
46
  team: {col: "o.team_id",
47
  name_col: "CASE o.team_id WHEN 5 THEN 'Fisch' WHEN 6 THEN 'Royal' ELSE CAST(o.team_id AS VARCHAR) END",
 
42
  dims:
43
  order_partner: {col: "l.order_partner_id", name_col: "l.order_partner_name", label: "Customer"}
44
  product: {col: "l.product_id", name_col: "l.product_name", label: "Product"}
45
+ # ⭐⭐ W37-T10 β€” THE SKU-CODE DIM, added because a per-SKU metric has to group by the key the
46
+ # PRODUCT GRID actually carries. `product` above groups by Odoo's `product_id`; the
47
+ # `odoo_products` database's identity is `default_code` (its own topic file says so), and a
48
+ # metric keyed on the wrong one is a column of nulls that reads as "this never sold".
49
+ # ⚠ `COALESCE(NULLIF(...))` REPRODUCES THE GRID'S IDENTITY EXACTLY, including the codeless
50
+ # fallback: `modules/product_data.py` keys an uncoded active product as `pid:<odoo id>`, so
51
+ # this expression has to as well or those rows silently lose their metrics.
52
+ # ⭐ It also MERGES a re-SKUed pair: an archived record and the active one sharing a code are
53
+ # one product to the grid, and grouping by `product_id` would split them into two.
54
+ # ⚠ NO `name_col` β€” the code IS its own label, so `store_query` emits the bare `product_code`
55
+ # (the value-keyed shape `rollup_sql.group_values` handles beside `payment_state`).
56
+ product_code: {col: "COALESCE(NULLIF(pp.default_code, ''), 'pid:' || CAST(pp.id AS VARCHAR))",
57
+ label: "SKU code"}
58
  # team names via CASE (crm_team is not synced; ids are baked tenant scope like the team filter)
59
  team: {col: "o.team_id",
60
  name_col: "CASE o.team_id WHEN 5 THEN 'Fisch' WHEN 6 THEN 'Royal' ELSE CAST(o.team_id AS VARCHAR) END",
platform/model/topics/stock_moves.yml ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ⭐⭐ W37-T13 (owner: stock moved IN and OUT, per SKU, over a lookback window).
2
+ #
3
+ # A DATED EVENT STREAM at move grain β€” the counterpart to `odoo_products`, which is a
4
+ # catalogue and says so ("a catalogue has no date dimension; ask sales_lines for
5
+ # movement"). This is the topic that answers movement for stock, as `sales_lines` does
6
+ # for revenue.
7
+ #
8
+ # β›” EVERY CLAUSE HERE WAS MEASURED, in `proto/P1-stock-moves.md`, against live Odoo.
9
+ # Do not "simplify" one without re-reading it β€” three of them are traps that produce a
10
+ # plausible wrong number rather than an error.
11
+ key: stock_moves
12
+ label: Stock movement
13
+ entity: stock.move
14
+ domain_builder: ~
15
+ scope:
16
+ state: "done moves ONLY β€” a draft/waiting/assigned move is an intention, not a movement"
17
+ direction: >-
18
+ DIRECTION IS A PROPERTY OF THE PAIR OF LOCATIONS, never of one. A move is IN when it
19
+ arrives at an internal location from a non-internal one, and OUT when it leaves an
20
+ internal location for a non-internal one. internal->internal is 58% of all moves and
21
+ is NEITHER; counting it as both is the defect a one-sided domain produces.
22
+ not_picking_code: >-
23
+ `picking_code` is NOT the discriminator. It is store=False, so a groupby faults, and
24
+ ('picking_code','=',False) returns 0 while 5,800 pickingless done moves exist - the
25
+ count that would warn you also reads 0.
26
+ scale: "236k rows over 1,095 days; cost is FLAT in window length (30d 2.1s .. 1095d 4.0s live)"
27
+ no_bu: >-
28
+ `stock.move` has no team_id. Stock is HQ-consolidated, like AR and Inventory - stated
29
+ as a fact rather than left to read as a missing filter.
30
+
31
+ grain: "one row per done stock move; time-filterable by move date; NOT BU-filterable"
32
+
33
+ store:
34
+ table: stock_move
35
+ alias: sm
36
+ # β›” BOTH LOCATION JOINS, and they are the whole of the direction rule. `sl` is where it
37
+ # came FROM, `sd` is where it went TO. Dropping either makes every direction metric
38
+ # silently one-sided.
39
+ join: >-
40
+ LEFT JOIN stock_location sl ON sl.id = sm.location_id
41
+ LEFT JOIN stock_location sd ON sd.id = sm.location_dest_id
42
+ LEFT JOIN product_product pp ON pp.id = sm.product_id
43
+ date_col: "sm.date"
44
+ # NO team_col β€” see scope.no_bu. A BU-scoped caller is REFUSED by `store_query` with a
45
+ # sentence rather than being served a company-wide number wearing a unit's label.
46
+ scope_sql: "sm.state = 'done'"
47
+ dims:
48
+ # ⭐ THE SAME CODE-KEYED SHAPE `sales_lines.product_code` uses, and for the same reason:
49
+ # the product grid's identity is `default_code`, with `pid:<odoo id>` for the codeless
50
+ # actives. A dim keyed on Odoo's `product_id` would key every cell on a value no grid
51
+ # row carries (contract C1's join-key trap).
52
+ product_code: {col: "COALESCE(NULLIF(pp.default_code, ''), 'pid:' || CAST(pp.id AS VARCHAR))",
53
+ label: "SKU code"}
54
+ product: {col: "sm.product_id", name_col: "pp.name", label: "Product"}
55
+ source: {col: "sm.location_id", name_col: "sl.complete_name", label: "From location"}
56
+ destination: {col: "sm.location_dest_id", name_col: "sd.complete_name", label: "To location"}
57
+
58
+ ai_context: >
59
+ Physical stock movement at move grain, from Odoo `stock.move`, done moves only.
60
+ Use it for "what came in", "what went out", "what moved" over a window, per SKU or per location.
61
+ β›” DIRECTION IS A PAIR TEST, never a single location: `stock_in` counts moves ARRIVING at an
62
+ internal location from a non-internal one, `stock_out` counts moves LEAVING an internal location
63
+ for a non-internal one, and internal-to-internal transfers (58% of all moves) are in NEITHER.
64
+ Quantities are `quantity_done` β€” what actually moved β€” in the product's own unit of measure, so
65
+ units are NOT comparable across SKUs and must never be summed into one total across products.
66
+ β›” THIS TOPIC HAS NO BUSINESS UNIT. `stock.move` carries no team, so a question scoped to Fisch
67
+ or Royal cannot be answered here and must be refused rather than answered company-wide.
68
+ ⚠ Adjustments (`inventory` locations) and scrap are ~30% of IN and ~20% of OUT. `stock_in` and
69
+ `stock_out` INCLUDE them, because a physical arrival is an arrival however it was booked; use
70
+ the `source`/`destination` dims to separate them, and say which you did.
71
+ For SALES movement use sales_lines (revenue and units ordered); this topic is the warehouse,
72
+ and the two legitimately disagree because an order is not a shipment.
platform/modules/agent.py CHANGED
@@ -1,277 +1,385 @@
1
- """Agent module β€” per-agent (res.partner.agent_ids) analytics.
2
-
3
- An *agent* owns a **book** of customers (the same attribute the Customers module slices by). This
4
- module reports that book the way Sales/Customers/SKU report the whole company: a period scorecard
5
- with custom date windows (Today / WTD / Last week / MTD / QTD / YTD / any custom range), a sales
6
- trend, returns, top SKUs (with profit/order) and the FULL customer list β€” INCLUDING inactive
7
- accounts (no recent orders) so a rep sees who they've stopped selling to.
8
-
9
- Scope: reuses the Sales `order_domain` (Fisch+Royal, excluded accounts removed, state sale/done)
10
- so numbers tie to every other module. Returns are consolidated (credit notes aren't BU-tagged);
11
- everything else is BU-filterable via team_id.
12
- """
13
- import sys
14
- import datetime as dt
15
- from pathlib import Path
16
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17
- import core.odoo as O
18
- import core.periods as P
19
- import modules.sales as sales_mod
20
- import modules.customers as cust_mod
21
-
22
-
23
- def options(t=None, team_id=None):
24
- """Agent names with book activity β€” for the page/drawer picker."""
25
- return cust_mod.agent_options(t, team_id)
26
-
27
-
28
- def _book(name):
29
- """frozenset of every partner id assigned to the agent (incl. inactive). None only for 'All'."""
30
- return cust_mod.agent_partner_ids(name)
31
-
32
-
33
- def _rev(date_from, date_to, team_id, book):
34
- return O.sum_field('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
35
- 'amount_untaxed')
36
-
37
-
38
- def _orders(date_from, date_to, team_id, book):
39
- return O.get_odoo().search_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book))
40
-
41
-
42
- def _custs(date_from, date_to, team_id, book):
43
- return O.distinct_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
44
- 'partner_id')
45
-
46
-
47
- # ------------------------------------------------------------ period scorecard (custom dating)
48
- # Today / WTD / Last week / MTD / QTD / YTD β€” each carries its window so the UI can decompose it
49
- # and so "what did this agent sell this/last week" is a click, not a date-math exercise.
50
- _PERIODS = [('Today', 'today'), ('Week to date', 'wtd'), ('Last week', 'lwk'),
51
- ('Month to date', 'mtd'), ('Quarter to date', 'qtd'), ('Year to date', 'ytd')]
52
-
53
-
54
- def _window(key, t):
55
- if key == 'today':
56
- return P._d(t), P._d(t)
57
- if key == 'lwk': # the full prior Mon–Sun week
58
- f, _tt = P.wtd(t)
59
- start = dt.date.fromisoformat(f) - dt.timedelta(days=7)
60
- return start.isoformat(), (dt.date.fromisoformat(f) - dt.timedelta(days=1)).isoformat()
61
- return {'wtd': P.wtd, 'mtd': P.mtd, 'qtd': P.qtd, 'ytd': P.ytd}[key](t)
62
-
63
-
64
- def scorecard(name, t=None, team_id=None):
65
- """Book revenue (+ YoY same-period), orders for Today / WTD / Last week / MTD / QTD / YTD."""
66
- t = t or P.today()
67
- book = _book(name)
68
- out = []
69
- for label, key in _PERIODS:
70
- f, tt = _window(key, t)
71
- wk = key in ('today', 'wtd', 'lwk') # weekday-align the short windows' LY compare
72
- cf, ct = P.shift_year(f, tt, weeks=wk)
73
- rev, rev_ly = _rev(f, tt, team_id, book), _rev(cf, ct, team_id, book)
74
- out.append({'key': key, 'label': label, 'date_from': f, 'date_to': tt, 'cmp_from': cf, 'cmp_to': ct,
75
- 'revenue': rev, 'revenue_ly': rev_ly, 'yoy_pct': P.yoy_pct(rev, rev_ly),
76
- 'orders': _orders(f, tt, team_id, book)})
77
- return out
78
-
79
-
80
- def headline(name, date_from, date_to, team_id=None):
81
- """Book KPIs for an ARBITRARY window (custom dating): revenue + YoY (same window LY), orders,
82
- active customers, AOV and returns $ / return rate."""
83
- book = _book(name)
84
- cf, ct = P.shift_year(date_from, date_to, weeks=False)
85
- rev, rev_ly = _rev(date_from, date_to, team_id, book), _rev(cf, ct, team_id, book)
86
- orders = _orders(date_from, date_to, team_id, book)
87
- ret = sales_mod._returns_amt(date_from, date_to, book)
88
- return {'date_from': date_from, 'date_to': date_to, 'cmp_from': cf, 'cmp_to': ct,
89
- 'revenue': rev, 'revenue_ly': rev_ly,
90
- 'yoy_pct': P.yoy_pct(rev, rev_ly), 'orders': orders,
91
- 'customers': _custs(date_from, date_to, team_id, book), 'aov': (rev / orders) if orders else 0.0,
92
- 'returns': ret, 'return_rate_pct': (ret / rev * 100.0) if rev else 0.0}
93
-
94
-
95
- # ------------------------------------------------------------ sales trend
96
- def _book_monthly_rev(book, date_from, date_to, team_id=None):
97
- g = O.read_group('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
98
- ['amount_untaxed:sum'], ['date_order:month'], lazy=False)
99
- out = {}
100
- for r in g:
101
- ym = ((r.get('__range') or {}).get('date_order:month') or {}).get('from', '')[:7]
102
- if ym:
103
- out[ym] = r.get('amount_untaxed') or 0.0
104
- return out
105
-
106
-
107
- def monthly(name, n=13, t=None, team_id=None):
108
- """Book sales per month vs the same month last year (one month-grouped query over 2 years)."""
109
- t = t or P.today()
110
- book = _book(name)
111
- mrev = _book_monthly_rev(book, dt.date(t.year - 2, t.month, 1).isoformat(), t.isoformat(), team_id)
112
- rows = []
113
- for ym, _s, _e in P.month_starts(n, t):
114
- y, m = int(ym[:4]) - 1, int(ym[5:7])
115
- this, last = mrev.get(ym, 0.0), mrev.get(f'{y:04d}-{m:02d}', 0.0)
116
- rows.append({'month': ym, 'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)})
117
- return rows
118
-
119
-
120
- # ------------------------------------------------------------ full customer book (incl. inactive)
121
- def customers(name, t=None, team_id=None):
122
- """EVERY customer in the agent's book, including inactive accounts (no YTD/LY orders) β€” those
123
- show $0 with status 'Inactive'/'Dormant'. Each row is clickable to the customer drawer and
124
- carries recency so a rep can see who's gone quiet. Sorted by YTD revenue desc (inactive last)."""
125
- t = t or P.today()
126
- book = _book(name)
127
- yf, yt = P.ytd(t)
128
- lf, lt = P.ytd_last_year(t)
129
- this = cust_mod._cust_rev(yf, yt, team_id, book)
130
- last = cust_mod._cust_rev(lf, lt, team_id, book)
131
- lastord = cust_mod._last_order_dates(None, None, team_id, book) # all-time last order = recency
132
- ids = list(book) if book is not None else list(set(this) | set(last))
133
- attrs = cust_mod._partner_attrs(set(ids))
134
- namemap = {r['id']: r.get('name') for r in O.search_read('res.partner', [('id', 'in', ids)], ['name'])}
135
- rows = []
136
- for p in ids:
137
- tr = this.get(p, {}).get('rev', 0.0)
138
- lr = last.get(p, {}).get('rev', 0.0)
139
- a = attrs.get(p, {})
140
- lo = lastord.get(p, '')
141
- recency = (t - dt.date.fromisoformat(lo)).days if lo else None
142
- status = 'Active' if tr > 0 else ('Dormant' if (lr > 0 or lo) else 'Inactive')
143
- rows.append({'pid': p, 'customer': namemap.get(p) or (this.get(p) or last.get(p) or {}).get('name', '?'),
144
- 'rev_ytd': tr, 'rev_ly': lr, 'change': tr - lr, 'yoy_pct': P.yoy_pct(tr, lr),
145
- 'orders': this.get(p, {}).get('orders', 0), 'last_order': lo, 'recency_days': recency,
146
- 'status': status, 'city': a.get('city', '(none)'), 'state': a.get('state', '(none)'),
147
- 'agent': a.get('agent', name)})
148
- rows.sort(key=lambda r: (r['rev_ytd'] <= 0, -r['rev_ytd'], -(r['rev_ly'])))
149
- return rows
150
-
151
-
152
- # ------------------------------------------------------------ top SKUs (with profit/order)
153
- def top_skus(name, t=None, team_id=None, top=30):
154
- """The book's top SKUs YTD (line-level), each with revenue, units, margin and profit/order."""
155
- t = t or P.today()
156
- book = _book(name)
157
- if book is not None and not book:
158
- return []
159
- yf, yt = P.ytd(t)
160
- lex = [('order_partner_id', 'in', list(book))] if book is not None else None
161
- return sales_mod.decompose(yf, yt, team_id, line_extra=lex, top=top)['skus']
162
-
163
-
164
- # ------------------------------------------------------------ returns (book-scoped, consolidated)
165
- def returns_trend(name, n=13, t=None):
166
- return sales_mod.returns_monthly(n, t, partner_ids=_book(name))
167
-
168
-
169
- def returns_headline(name, t=None):
170
- return sales_mod.returns_headline(t, partner_ids=_book(name))
171
-
172
-
173
- # ------------------------------------------------------------ all-agents rollup (the page table)
174
- def _returns_by_agent(t=None):
175
- """{agent_name: returns$ YTD} β€” credit notes mapped to each customer's agent."""
176
- by_p = sales_mod.returns_by_partner(t)
177
- attrs = cust_mod._partner_attrs(list(by_p))
178
- agg = {}
179
- for pid, amt in by_p.items():
180
- a = (attrs.get(pid) or {}).get('agent') or '(none)'
181
- agg[a] = agg.get(a, 0.0) + amt
182
- return agg
183
-
184
-
185
- def rollup(t=None, team_id=None):
186
- """Every agent ranked by YTD book revenue (+ YoY, customers, orders) with returns $ and return
187
- rate. Reuses the Customers MECE agent rollup, so Ξ£(agents) == total YTD revenue."""
188
- rows = cust_mod.by_dimension('agent', t, team_id=team_id)
189
- ret = _returns_by_agent(t)
190
- for r in rows:
191
- r['agent'] = r['group']
192
- r['returns'] = ret.get(r['group'], 0.0)
193
- r['return_rate_pct'] = (r['returns'] / r['revenue'] * 100.0) if r.get('revenue') else 0.0
194
- return rows
195
-
196
-
197
- # ------------------------------------------------------------ VALIDATION
198
- def validate(t=None, team_id=None):
199
- """Reconcile the agent rollup to Odoo. (1) Ξ£(agent book revenue) == total YTD revenue β€” the
200
- rollup is MECE over customers. (2) A sampled agent's scorecard YTD == its headline YTD."""
201
- t = t or P.today()
202
- yf, yt = P.ytd(t)
203
- checks = []
204
- rows = rollup(t, team_id=team_id)
205
- agent_sum = sum(r['revenue'] for r in rows)
206
- total = O.sum_field('sale.order', sales_mod.order_domain(yf, yt, team_id), 'amount_untaxed')
207
- checks.append({'check': 'YTD revenue: Ξ£(agent book) == total', 'a': round(agent_sum, 2),
208
- 'b': round(total, 2), 'gap': round(agent_sum - total, 2),
209
- 'ok': abs(agent_sum - total) <= max(1.0, 0.001 * (total or 1))})
210
- # sampled agent: scorecard YTD == headline YTD for the same window
211
- sample = next((r['agent'] for r in rows if r['agent'] not in ('(none)',)), None)
212
- if sample:
213
- sc_ytd = next((s['revenue'] for s in scorecard(sample, t, team_id) if s['key'] == 'ytd'), 0.0)
214
- hl = headline(sample, yf, yt, team_id)['revenue']
215
- checks.append({'check': f'Agent "{sample}": scorecard YTD == headline YTD', 'a': round(sc_ytd, 2),
216
- 'b': round(hl, 2), 'gap': round(sc_ytd - hl, 2), 'ok': abs(sc_ytd - hl) <= 1.0})
217
- return checks
218
-
219
-
220
- # ------------------------------------------------------------ INVOICE-LINE ATTRIBUTION (2026-07-28)
221
- # A SECOND agent source. Everything above this line attributes by BOOK β€” the customer's assigned
222
- # agent (res.partner.agent_ids) β€” over confirmed SALES ORDERS. This section attributes per INVOICE
223
- # LINE, from the OCA sale-commission module, via the semantic layer (topics invoice_lines /
224
- # commission_lines). The two disagree on purpose and answer different questions:
225
- #
226
- # book -> "whose customer is this / who owns the relationship" (order basis)
227
- # invoice -> "what was actually credited to an agent on the billing" + the ONLY source that can
228
- # say what is NOT allocated to an agent (invoice basis)
229
- #
230
- # ⚠ A NAME ON A COMMISSION LINE IS NOT NECESSARILY AN AGENT β€” `res.partner.agent` is the flag.
231
- # "Anna" and "Shantal Erlich" are internal SALESPEOPLE who carry commission lines; the `agent`
232
- # dim excludes them and `include_salespeople` folds them back in as a clearly-labelled variant.
233
- # See [[invoice-line-agent-commission]].
234
-
235
- _ALLOC_LABEL = {'agent': 'Allocated to an agent', 'salesperson': 'Salesperson only',
236
- 'none': 'Not allocated'}
237
-
238
-
239
- def invoice_line_rollup(t=None, team_id=None, include_salespeople=False):
240
- """Per-name invoice-line revenue + the MECE allocation split, for a YTD window.
241
-
242
- Returns {'by_agent': [...], 'allocation': [...], 'total': float, 'allocated': float,
243
- 'unallocated': float, 'basis': str} β€” or {'error': msg} when the tenant store is not
244
- ready (this path is store-only; there is no live fallback that stays honest about the
245
- unallocated bucket).
246
- """
247
- import harness.semantic as S
248
- t = t or P.today()
249
- yf, yt = P.ytd(t)
250
- dim = 'commission_name' if include_salespeople else 'agent'
251
- try:
252
- by = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=[dim],
253
- date_from=yf, date_to=yt, team_id=team_id, limit=200).get('rows') or []
254
- alloc = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=['allocation'],
255
- date_from=yf, date_to=yt, team_id=team_id, limit=10).get('rows') or []
256
- tot = (S.store_query('invoice_lines', ['invoiced_line_sales'], date_from=yf, date_to=yt,
257
- team_id=team_id).get('rows') or [{}])[0].get('invoiced_line_sales') or 0.0
258
- except Exception as e: # store not ready / model error β€” say so, don't fake
259
- return {'error': str(e)}
260
- # store_query row shape: the DIM KEY carries the display NAME and `<dim>_id` the raw value
261
- # (allocation -> 'Salesperson only', allocation_id -> 'salesperson'). Reading `<dim>_name`
262
- # returns None for every row and silently renders the whole table as "no agent".
263
- rows = [{'agent': (r.get(dim) or '(no agent on the line)'),
264
- 'revenue': r.get('invoiced_line_sales') or 0.0} for r in by]
265
- rows.sort(key=lambda r: -r['revenue'])
266
- amap = {r.get('allocation_id') or 'none': (r.get('invoiced_line_sales') or 0.0) for r in alloc}
267
- allocated = amap.get('agent', 0.0)
268
- return {
269
- 'by_agent': rows,
270
- 'allocation': [{'bucket': _ALLOC_LABEL[k], 'revenue': amap.get(k, 0.0)}
271
- for k in ('agent', 'salesperson', 'none') if k in amap or True],
272
- 'total': tot, 'allocated': allocated, 'unallocated': tot - allocated,
273
- 'strict_none': amap.get('none', 0.0), 'salesperson_only': amap.get('salesperson', 0.0),
274
- 'window': (yf, yt),
275
- 'basis': ('invoice line Β· commission names incl. salespeople' if include_salespeople
276
- else 'invoice line Β· real agents only'),
277
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent module β€” per-agent (res.partner.agent_ids) analytics.
2
+
3
+ An *agent* owns a **book** of customers (the same attribute the Customers module slices by). This
4
+ module reports that book the way Sales/Customers/SKU report the whole company: a period scorecard
5
+ with custom date windows (Today / WTD / Last week / MTD / QTD / YTD / any custom range), a sales
6
+ trend, returns, top SKUs (with profit/order) and the FULL customer list β€” INCLUDING inactive
7
+ accounts (no recent orders) so a rep sees who they've stopped selling to.
8
+
9
+ Scope: reuses the Sales `order_domain` (Fisch+Royal, excluded accounts removed, state sale/done)
10
+ so numbers tie to every other module. Returns are consolidated (credit notes aren't BU-tagged);
11
+ everything else is BU-filterable via team_id.
12
+ """
13
+ import sys
14
+ import datetime as dt
15
+ from pathlib import Path
16
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17
+ import core.odoo as O
18
+ import core.periods as P
19
+ import modules.sales as sales_mod
20
+ import modules.customers as cust_mod
21
+
22
+
23
+ def options(t=None, team_id=None):
24
+ """Agent names with book activity β€” for the page/drawer picker."""
25
+ return cust_mod.agent_options(t, team_id)
26
+
27
+
28
+ def _book(name):
29
+ """frozenset of every partner id assigned to the agent (incl. inactive). None only for 'All'."""
30
+ return cust_mod.agent_partner_ids(name)
31
+
32
+
33
+ def _rev(date_from, date_to, team_id, book):
34
+ return O.sum_field('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
35
+ 'amount_untaxed')
36
+
37
+
38
+ def _orders(date_from, date_to, team_id, book):
39
+ return O.get_odoo().search_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book))
40
+
41
+
42
+ def _custs(date_from, date_to, team_id, book):
43
+ return O.distinct_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
44
+ 'partner_id')
45
+
46
+
47
+ # ------------------------------------------------------------ period scorecard (custom dating)
48
+ # Today / WTD / Last week / MTD / QTD / YTD β€” each carries its window so the UI can decompose it
49
+ # and so "what did this agent sell this/last week" is a click, not a date-math exercise.
50
+ _PERIODS = [('Today', 'today'), ('Week to date', 'wtd'), ('Last week', 'lwk'),
51
+ ('Month to date', 'mtd'), ('Quarter to date', 'qtd'), ('Year to date', 'ytd')]
52
+
53
+
54
+ def _window(key, t):
55
+ if key == 'today':
56
+ return P._d(t), P._d(t)
57
+ if key == 'lwk': # the full prior Mon–Sun week
58
+ f, _tt = P.wtd(t)
59
+ start = dt.date.fromisoformat(f) - dt.timedelta(days=7)
60
+ return start.isoformat(), (dt.date.fromisoformat(f) - dt.timedelta(days=1)).isoformat()
61
+ return {'wtd': P.wtd, 'mtd': P.mtd, 'qtd': P.qtd, 'ytd': P.ytd}[key](t)
62
+
63
+
64
+ def scorecard(name, t=None, team_id=None):
65
+ """Book revenue (+ YoY same-period), orders for Today / WTD / Last week / MTD / QTD / YTD."""
66
+ t = t or P.today()
67
+ book = _book(name)
68
+ out = []
69
+ for label, key in _PERIODS:
70
+ f, tt = _window(key, t)
71
+ wk = key in ('today', 'wtd', 'lwk') # weekday-align the short windows' LY compare
72
+ cf, ct = P.shift_year(f, tt, weeks=wk)
73
+ rev, rev_ly = _rev(f, tt, team_id, book), _rev(cf, ct, team_id, book)
74
+ out.append({'key': key, 'label': label, 'date_from': f, 'date_to': tt, 'cmp_from': cf, 'cmp_to': ct,
75
+ 'revenue': rev, 'revenue_ly': rev_ly, 'yoy_pct': P.yoy_pct(rev, rev_ly),
76
+ 'orders': _orders(f, tt, team_id, book)})
77
+ return out
78
+
79
+
80
+ def headline(name, date_from, date_to, team_id=None):
81
+ """Book KPIs for an ARBITRARY window (custom dating): revenue + YoY (same window LY), orders,
82
+ active customers, AOV and returns $ / return rate."""
83
+ book = _book(name)
84
+ cf, ct = P.shift_year(date_from, date_to, weeks=False)
85
+ rev, rev_ly = _rev(date_from, date_to, team_id, book), _rev(cf, ct, team_id, book)
86
+ orders = _orders(date_from, date_to, team_id, book)
87
+ ret = sales_mod._returns_amt(date_from, date_to, book)
88
+ return {'date_from': date_from, 'date_to': date_to, 'cmp_from': cf, 'cmp_to': ct,
89
+ 'revenue': rev, 'revenue_ly': rev_ly,
90
+ 'yoy_pct': P.yoy_pct(rev, rev_ly), 'orders': orders,
91
+ 'customers': _custs(date_from, date_to, team_id, book), 'aov': (rev / orders) if orders else 0.0,
92
+ 'returns': ret, 'return_rate_pct': (ret / rev * 100.0) if rev else 0.0}
93
+
94
+
95
+ # ------------------------------------------------------------ sales trend
96
+ def _book_monthly_rev(book, date_from, date_to, team_id=None):
97
+ g = O.read_group('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
98
+ ['amount_untaxed:sum'], ['date_order:month'], lazy=False)
99
+ out = {}
100
+ for r in g:
101
+ ym = ((r.get('__range') or {}).get('date_order:month') or {}).get('from', '')[:7]
102
+ if ym:
103
+ out[ym] = r.get('amount_untaxed') or 0.0
104
+ return out
105
+
106
+
107
+ def monthly(name, n=13, t=None, team_id=None):
108
+ """Book sales per month vs the same month last year (one month-grouped query over 2 years)."""
109
+ t = t or P.today()
110
+ book = _book(name)
111
+ mrev = _book_monthly_rev(book, dt.date(t.year - 2, t.month, 1).isoformat(), t.isoformat(), team_id)
112
+ rows = []
113
+ for ym, _s, _e in P.month_starts(n, t):
114
+ y, m = int(ym[:4]) - 1, int(ym[5:7])
115
+ this, last = mrev.get(ym, 0.0), mrev.get(f'{y:04d}-{m:02d}', 0.0)
116
+ rows.append({'month': ym, 'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)})
117
+ return rows
118
+
119
+
120
+ # ------------------------------------------------------------ full customer book (incl. inactive)
121
+ def customers(name, t=None, team_id=None):
122
+ """EVERY customer in the agent's book, including inactive accounts (no YTD/LY orders) β€” those
123
+ show $0 with status 'Inactive'/'Dormant'. Each row is clickable to the customer drawer and
124
+ carries recency so a rep can see who's gone quiet. Sorted by YTD revenue desc (inactive last)."""
125
+ t = t or P.today()
126
+ book = _book(name)
127
+ yf, yt = P.ytd(t)
128
+ lf, lt = P.ytd_last_year(t)
129
+ this = cust_mod._cust_rev(yf, yt, team_id, book)
130
+ last = cust_mod._cust_rev(lf, lt, team_id, book)
131
+ lastord = cust_mod._last_order_dates(None, None, team_id, book) # all-time last order = recency
132
+ ids = list(book) if book is not None else list(set(this) | set(last))
133
+ attrs = cust_mod._partner_attrs(set(ids))
134
+ namemap = {r['id']: r.get('name') for r in O.search_read('res.partner', [('id', 'in', ids)], ['name'])}
135
+ rows = []
136
+ for p in ids:
137
+ tr = this.get(p, {}).get('rev', 0.0)
138
+ lr = last.get(p, {}).get('rev', 0.0)
139
+ a = attrs.get(p, {})
140
+ lo = lastord.get(p, '')
141
+ recency = (t - dt.date.fromisoformat(lo)).days if lo else None
142
+ status = 'Active' if tr > 0 else ('Dormant' if (lr > 0 or lo) else 'Inactive')
143
+ rows.append({'pid': p, 'customer': namemap.get(p) or (this.get(p) or last.get(p) or {}).get('name', '?'),
144
+ 'rev_ytd': tr, 'rev_ly': lr, 'change': tr - lr, 'yoy_pct': P.yoy_pct(tr, lr),
145
+ 'orders': this.get(p, {}).get('orders', 0), 'last_order': lo, 'recency_days': recency,
146
+ 'status': status, 'city': a.get('city', '(none)'), 'state': a.get('state', '(none)'),
147
+ 'agent': a.get('agent', name)})
148
+ rows.sort(key=lambda r: (r['rev_ytd'] <= 0, -r['rev_ytd'], -(r['rev_ly'])))
149
+ return rows
150
+
151
+
152
+ # ------------------------------------------------------------ top SKUs (with profit/order)
153
+ def top_skus(name, t=None, team_id=None, top=30):
154
+ """The book's top SKUs YTD (line-level), each with revenue, units, margin and profit/order."""
155
+ t = t or P.today()
156
+ book = _book(name)
157
+ if book is not None and not book:
158
+ return []
159
+ yf, yt = P.ytd(t)
160
+ lex = [('order_partner_id', 'in', list(book))] if book is not None else None
161
+ return sales_mod.decompose(yf, yt, team_id, line_extra=lex, top=top)['skus']
162
+
163
+
164
+ # ------------------------------------------------------------ returns (book-scoped, consolidated)
165
+ def returns_trend(name, n=13, t=None):
166
+ return sales_mod.returns_monthly(n, t, partner_ids=_book(name))
167
+
168
+
169
+ def returns_headline(name, t=None):
170
+ return sales_mod.returns_headline(t, partner_ids=_book(name))
171
+
172
+
173
+ # ------------------------------------------------------------ all-agents rollup (the page table)
174
+ def _returns_by_agent(t=None):
175
+ """{agent_name: returns$ YTD} β€” credit notes mapped to each customer's agent."""
176
+ by_p = sales_mod.returns_by_partner(t)
177
+ attrs = cust_mod._partner_attrs(list(by_p))
178
+ agg = {}
179
+ for pid, amt in by_p.items():
180
+ a = (attrs.get(pid) or {}).get('agent') or '(none)'
181
+ agg[a] = agg.get(a, 0.0) + amt
182
+ return agg
183
+
184
+
185
+ def rollup(t=None, team_id=None):
186
+ """Every agent ranked by YTD book revenue (+ YoY, customers, orders) with returns $ and return
187
+ rate. Reuses the Customers MECE agent rollup, so Ξ£(agents) == total YTD revenue."""
188
+ rows = cust_mod.by_dimension('agent', t, team_id=team_id)
189
+ ret = _returns_by_agent(t)
190
+ for r in rows:
191
+ r['agent'] = r['group']
192
+ r['returns'] = ret.get(r['group'], 0.0)
193
+ r['return_rate_pct'] = (r['returns'] / r['revenue'] * 100.0) if r.get('revenue') else 0.0
194
+ return rows
195
+
196
+
197
+ # ------------------------------------------------------------ VALIDATION
198
+ def validate(t=None, team_id=None):
199
+ """Reconcile the agent rollup to Odoo. (1) Ξ£(agent book revenue) == total YTD revenue β€” the
200
+ rollup is MECE over customers. (2) A sampled agent's scorecard YTD == its headline YTD."""
201
+ t = t or P.today()
202
+ yf, yt = P.ytd(t)
203
+ checks = []
204
+ rows = rollup(t, team_id=team_id)
205
+ agent_sum = sum(r['revenue'] for r in rows)
206
+ total = O.sum_field('sale.order', sales_mod.order_domain(yf, yt, team_id), 'amount_untaxed')
207
+ checks.append({'check': 'YTD revenue: Ξ£(agent book) == total', 'a': round(agent_sum, 2),
208
+ 'b': round(total, 2), 'gap': round(agent_sum - total, 2),
209
+ 'ok': abs(agent_sum - total) <= max(1.0, 0.001 * (total or 1))})
210
+ # sampled agent: scorecard YTD == headline YTD for the same window
211
+ sample = next((r['agent'] for r in rows if r['agent'] not in ('(none)',)), None)
212
+ if sample:
213
+ sc_ytd = next((s['revenue'] for s in scorecard(sample, t, team_id) if s['key'] == 'ytd'), 0.0)
214
+ hl = headline(sample, yf, yt, team_id)['revenue']
215
+ checks.append({'check': f'Agent "{sample}": scorecard YTD == headline YTD', 'a': round(sc_ytd, 2),
216
+ 'b': round(hl, 2), 'gap': round(sc_ytd - hl, 2), 'ok': abs(sc_ytd - hl) <= 1.0})
217
+ checks.extend(validate_measures(t=t, team_id=team_id))
218
+ return checks
219
+
220
+
221
+ def validate_measures(t=None, team_id=None, days=90):
222
+ """⭐⭐ W37-T12 β€” the minted PER-AGENT lookback columns, against a DIRECT Odoo aggregate.
223
+
224
+ β›” WHICH AGENT SOURCE, AND THE TICKET REQUIRES IT SAID OUT LOUD: this reconciles ROUTE 1, the
225
+ CUSTOMER-MASTER BOOK (`res.partner.agent_ids` -> the mirror's `res_partner.agent_id`, which is
226
+ `sales_lines`' `agent` dim). It is NOT the OCA commission route below β€” measured 2026-08-19,
227
+ 9 agents carry route-1 revenue against 12 on commission lines and 17 carrying the flag, so the
228
+ two produce materially different rankings and a check that mixed them would be comparing two
229
+ different questions and calling the gap an error.
230
+
231
+ ⚠ THE ORACLE IS THE ORDER HEADER, not the mirror the columns are served from. `sale.order`
232
+ grouped by `partner_id`, mapped to each customer's agent CLIENT-SIDE β€” a different model, a
233
+ different grain and a different code path from `store_query`'s line-level sum, so agreement
234
+ between them is evidence rather than tautology.
235
+ ⚠ Windowed to the MIRROR'S newest order, like `product_data.validate_measures`, so the
236
+ residual is about EDITS to a shared period and not about orders the mirror has never seen.
237
+ """
238
+ from harness import datastore as DS
239
+ from harness import semantic as sem
240
+
241
+ t = t or P.today()
242
+ checks = []
243
+ try:
244
+ if not DS.ready():
245
+ return [{'check': 'agent lookback measures reconcile to Odoo', 'a': 'no mirror',
246
+ 'b': '-', 'ok': False,
247
+ 'detail': 'the tenant store is not readable, so this is UNPROVEN, which standing rule '
248
+ '8 does not accept as green'}]
249
+ except Exception as e: # noqa: BLE001
250
+ return [{'check': 'agent lookback measures reconcile to Odoo', 'a': type(e).__name__,
251
+ 'b': '-', 'ok': False, 'detail': str(e)[:200]}]
252
+
253
+ offer = sem.entity_measures('odoo_agents')
254
+ checks.append({'check': 'the agent measure OFFER is non-empty and every key resolves '
255
+ '(owner item 4 / R1)',
256
+ 'a': len(offer), 'b': '>0', 'ok': bool(offer),
257
+ 'detail': {'keys': [m['key'] for m in offer],
258
+ 'refused': sem.entity_measure_refusals('odoo_agents')}})
259
+ if not offer:
260
+ return checks
261
+
262
+ con = DS.ro_cursor()
263
+ try:
264
+ newest = con.execute('SELECT max(date_order) FROM sale_order').fetchone()
265
+ finally:
266
+ con.close()
267
+ d_to = t - dt.timedelta(days=2)
268
+ if newest and newest[0]:
269
+ try:
270
+ d_to = min(d_to, dt.date.fromisoformat(str(newest[0])[:10]) - dt.timedelta(days=1))
271
+ except ValueError:
272
+ pass
273
+ d_from = d_to - dt.timedelta(days=days)
274
+ DF, DT = d_from.isoformat(), d_to.isoformat()
275
+
276
+ ours = sem.entity_measure_values('odoo_agents', ['revenue'], date_from=DF, date_to=DT,
277
+ team_id=team_id, offer=offer)
278
+ # THE ORACLE β€” order headers, grouped by customer, mapped to that customer's agent here.
279
+ o = O.get_odoo()
280
+ grp = o.read_group('sale.order', sales_mod.order_domain(DF, DT, team_id),
281
+ ['partner_id', 'amount_untaxed:sum'], ['partner_id'], lazy=False)
282
+ pids = sorted({r['partner_id'][0] for r in grp if r.get('partner_id')})
283
+ agent_of = {}
284
+ for i in range(0, len(pids), 500):
285
+ for p in o.search_read('res.partner', [('id', 'in', pids[i:i + 500])],
286
+ ['id', 'agent_ids']):
287
+ ag = (p.get('agent_ids') or [])
288
+ if ag:
289
+ agent_of[p['id']] = ag[0] # the Customers-module convention: agent_ids[0]
290
+ theirs = {}
291
+ for r in grp:
292
+ if not r.get('partner_id'):
293
+ continue
294
+ a = agent_of.get(r['partner_id'][0])
295
+ if a is not None:
296
+ theirs[a] = theirs.get(a, 0.0) + r['amount_untaxed']
297
+
298
+ ours_tot = round(sum(c.get('revenue', 0) for c in ours.values()), 2)
299
+ theirs_tot = round(sum(theirs.values()), 2)
300
+ # ⚠ ORDER-HEADER vs LINE-SUM is a REAL basis difference (an order's untaxed total includes
301
+ # lines this topic's service filter drops), so the tolerance is a stated 3% rather than a
302
+ # cent β€” and the FIGURE is reported so a drift is readable instead of absorbed.
303
+ gap = ours_tot - theirs_tot
304
+ checks.append({
305
+ 'check': 'per-agent revenue (BOOK route) vs an INDEPENDENT Odoo order-header aggregate '
306
+ 'mapped through res.partner.agent_ids',
307
+ 'a': ours_tot, 'b': theirs_tot, 'gap': round(gap, 2),
308
+ 'ok': bool(theirs_tot) and abs(gap) <= 0.03 * theirs_tot,
309
+ 'detail': {'window': [DF, DT], 'agents_ours': len(ours), 'agents_theirs': len(theirs),
310
+ 'gap_pct': round(gap / theirs_tot * 100, 3) if theirs_tot else None,
311
+ 'route': 'customer-master book (res.partner.agent_ids), NOT the OCA '
312
+ 'commission table; the two name different people'}})
313
+ # β›” AND PER AGENT, because a total can agree while every row is keyed wrong β€” the join-key
314
+ # trap contract C1 names. Here the key is the `res.partner` id at both ends.
315
+ off_by = sorted(((abs(ours.get(a, {}).get('revenue', 0.0) - v), a)
316
+ for a, v in theirs.items() if v), reverse=True)
317
+ bad = [(a, round(ours.get(a, {}).get('revenue', 0.0), 2), round(theirs[a], 2))
318
+ for d, a in off_by if d > 0.03 * theirs[a]]
319
+ checks.append({
320
+ 'check': 'each agent\'s own figure ties (the C1 join-key test: a wrong key agrees in '
321
+ 'total and disagrees on every row)',
322
+ 'a': len(bad), 'b': 0, 'ok': not bad,
323
+ 'detail': {'worst': [(a, ours_v, th_v) for a, ours_v, th_v in bad[:5]],
324
+ 'agents_compared': len(theirs)}})
325
+ return checks
326
+
327
+
328
+ # ------------------------------------------------------------ INVOICE-LINE ATTRIBUTION (2026-07-28)
329
+ # A SECOND agent source. Everything above this line attributes by BOOK β€” the customer's assigned
330
+ # agent (res.partner.agent_ids) β€” over confirmed SALES ORDERS. This section attributes per INVOICE
331
+ # LINE, from the OCA sale-commission module, via the semantic layer (topics invoice_lines /
332
+ # commission_lines). The two disagree on purpose and answer different questions:
333
+ #
334
+ # book -> "whose customer is this / who owns the relationship" (order basis)
335
+ # invoice -> "what was actually credited to an agent on the billing" + the ONLY source that can
336
+ # say what is NOT allocated to an agent (invoice basis)
337
+ #
338
+ # ⚠ A NAME ON A COMMISSION LINE IS NOT NECESSARILY AN AGENT β€” `res.partner.agent` is the flag.
339
+ # "Anna" and "Shantal Erlich" are internal SALESPEOPLE who carry commission lines; the `agent`
340
+ # dim excludes them and `include_salespeople` folds them back in as a clearly-labelled variant.
341
+ # See [[invoice-line-agent-commission]].
342
+
343
+ _ALLOC_LABEL = {'agent': 'Allocated to an agent', 'salesperson': 'Salesperson only',
344
+ 'none': 'Not allocated'}
345
+
346
+
347
+ def invoice_line_rollup(t=None, team_id=None, include_salespeople=False):
348
+ """Per-name invoice-line revenue + the MECE allocation split, for a YTD window.
349
+
350
+ Returns {'by_agent': [...], 'allocation': [...], 'total': float, 'allocated': float,
351
+ 'unallocated': float, 'basis': str} β€” or {'error': msg} when the tenant store is not
352
+ ready (this path is store-only; there is no live fallback that stays honest about the
353
+ unallocated bucket).
354
+ """
355
+ import harness.semantic as S
356
+ t = t or P.today()
357
+ yf, yt = P.ytd(t)
358
+ dim = 'commission_name' if include_salespeople else 'agent'
359
+ try:
360
+ by = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=[dim],
361
+ date_from=yf, date_to=yt, team_id=team_id, limit=200).get('rows') or []
362
+ alloc = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=['allocation'],
363
+ date_from=yf, date_to=yt, team_id=team_id, limit=10).get('rows') or []
364
+ tot = (S.store_query('invoice_lines', ['invoiced_line_sales'], date_from=yf, date_to=yt,
365
+ team_id=team_id).get('rows') or [{}])[0].get('invoiced_line_sales') or 0.0
366
+ except Exception as e: # store not ready / model error β€” say so, don't fake
367
+ return {'error': str(e)}
368
+ # store_query row shape: the DIM KEY carries the display NAME and `<dim>_id` the raw value
369
+ # (allocation -> 'Salesperson only', allocation_id -> 'salesperson'). Reading `<dim>_name`
370
+ # returns None for every row and silently renders the whole table as "no agent".
371
+ rows = [{'agent': (r.get(dim) or '(no agent on the line)'),
372
+ 'revenue': r.get('invoiced_line_sales') or 0.0} for r in by]
373
+ rows.sort(key=lambda r: -r['revenue'])
374
+ amap = {r.get('allocation_id') or 'none': (r.get('invoiced_line_sales') or 0.0) for r in alloc}
375
+ allocated = amap.get('agent', 0.0)
376
+ return {
377
+ 'by_agent': rows,
378
+ 'allocation': [{'bucket': _ALLOC_LABEL[k], 'revenue': amap.get(k, 0.0)}
379
+ for k in ('agent', 'salesperson', 'none') if k in amap or True],
380
+ 'total': tot, 'allocated': allocated, 'unallocated': tot - allocated,
381
+ 'strict_none': amap.get('none', 0.0), 'salesperson_only': amap.get('salesperson', 0.0),
382
+ 'window': (yf, yt),
383
+ 'basis': ('invoice line Β· commission names incl. salespeople' if include_salespeople
384
+ else 'invoice line Β· real agents only'),
385
+ }
platform/modules/product_data.py CHANGED
@@ -51,6 +51,7 @@ template and this deliberately mirrors its signature and its row shape.
51
  carries no team, so a catalogue cannot be BU-shaped at all β€” which is exactly why the two
52
  sources are JOINED rather than merged.
53
  """
 
54
  import json
55
  import zlib
56
  from pathlib import Path
@@ -391,6 +392,17 @@ def pool(team_id=None, t=None):
391
  bu_share = {} if consolidated else _bu_ltm_share(t, team_id)
392
  sup = supplier_master()
393
  prices, _price_report = _pricelist_by_code()
 
 
 
 
 
 
 
 
 
 
 
394
  cat = _catalogue_by_code()
395
  # The LEFT side of the join, indexed by the same code key. β›” A code here that the catalogue
396
  # does not carry belongs to a product no ACTIVE record claims β€” archived, and R12 keeps those
@@ -437,6 +449,19 @@ def pool(team_id=None, t=None):
437
  p = prices.get(code) or {}
438
  for _col, _name in products.PRICELIST_COLUMNS:
439
  row[_col] = p.get(_col)
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  # Wave 17 R3 β€” the supplier master, on every pull (it is catalogue data, not stock).
441
  s = sup.get(code) or {}
442
  row.update({
@@ -801,4 +826,390 @@ def validate(team_id=None, t=None):
801
  # have silently dropped. 0 today does not make that filter correct.
802
  "cover_gap_rounds_to_zero": len(rounding_would_miss)},
803
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
804
  return checks
 
51
  carries no team, so a catalogue cannot be BU-shaped at all β€” which is exactly why the two
52
  sources are JOINED rather than merged.
53
  """
54
+ import datetime as _dt
55
  import json
56
  import zlib
57
  from pathlib import Path
 
392
  bu_share = {} if consolidated else _bu_ltm_share(t, team_id)
393
  sup = supplier_master()
394
  prices, _price_report = _pricelist_by_code()
395
+ # ⭐ W37-T14 / T15. ⚠ MEASURED COST, stated because it lands on a scope's FIRST build:
396
+ # tier prices 15.1 s + packagings 8.1 s on top of the ~44 s consolidated build. Only the
397
+ # first build for a scope blocks (`routes_products._pool_for`'s stale-while-refresh), and
398
+ # both degrade to `{}` on a read failure rather than taking the grid down β€” the same
399
+ # asymmetry `pricelist_by_code` documents: a column is not the ROW SET.
400
+ try:
401
+ _prods = products.active_products() # ONE read, shared by both (see its docstring)
402
+ except Exception: # noqa: BLE001
403
+ _prods = None
404
+ tiers, _tier_report = products.tier_prices_by_code(_prods)
405
+ packs, _pack_report = products.packagings_by_code(_prods)
406
  cat = _catalogue_by_code()
407
  # The LEFT side of the join, indexed by the same code key. β›” A code here that the catalogue
408
  # does not carry belongs to a product no ACTIVE record claims β€” archived, and R12 keeps those
 
449
  p = prices.get(code) or {}
450
  for _col, _name in products.PRICELIST_COLUMNS:
451
  row[_col] = p.get(_col)
452
+ # ⭐⭐ W37-T14 / T15 β€” THE HONEST SETS, beside the three declared columns rather than
453
+ # instead of them. The columns above answer "what does Fisch charge"; these answer "how
454
+ # many prices/units does this SKU actually have", which the columns structurally cannot:
455
+ # they are named after THIS tenant's lists, so a price on any other list is invisible.
456
+ # ⚠ A `json` cell is a STRING on the wire (the type's own contract), so these are dumped
457
+ # here rather than handed over as lists β€” a bare list would round-trip through the overlay
458
+ # as something `grid_events` refuses to re-parse.
459
+ # ⚠ BLANK, NOT "[]" β€” an empty cell reads as "sold in one unit / not priced on any list",
460
+ # and an empty JSON array on screen reads as a bug.
461
+ _tiers = tiers.get(code)
462
+ row["tier_prices"] = json.dumps(_tiers, ensure_ascii=False) if _tiers else None
463
+ _units = packs.get(code)
464
+ row["units"] = json.dumps(_units, ensure_ascii=False) if _units else None
465
  # Wave 17 R3 β€” the supplier master, on every pull (it is catalogue data, not stock).
466
  s = sup.get(code) or {}
467
  row.update({
 
826
  # have silently dropped. 0 today does not make that filter correct.
827
  "cover_gap_rounds_to_zero": len(rounding_would_miss)},
828
  })
829
+ checks.extend(validate_measures(t=t, team_id=team_id,
830
+ pool_codes={r["code"] for r in rows}))
831
+ checks.extend(validate_price_and_unit_cells(rows))
832
+ checks.extend(validate_stock_measures(t=t))
833
+ return checks
834
+
835
+
836
+ def validate_stock_measures(t=None, days=90, sample=4):
837
+ """⭐⭐ W37-T13 β€” `stock_in` / `stock_out` per SKU, against a DIRECT live Odoo `read_group`.
838
+
839
+ β›” THE TWO-SIDED DOMAIN IS REPRODUCED ON THE LIVE SIDE, and getting it wrong there would hide
840
+ exactly the defect this validates. `location_id.usage` is a DOT-PATH FILTER, which Odoo
841
+ supports; a dot-path GROUPBY faults, which is why the direction is expressed as two separate
842
+ filtered reads rather than one grouped-by-usage read (`proto/P1-stock-moves.md`, gotcha 1).
843
+
844
+ β›” AND THE DIRECTION SPLIT IS ASSERTED, NOT ASSUMED. A one-sided domain produces IN == OUT for
845
+ every internal transfer, so a run where the two columns agree everywhere is the signature of
846
+ the bug rather than of a quiet warehouse. The last leg requires a SKU where they genuinely
847
+ differ β€” the ticket's own `done-when` clause, and the only one a total cannot fake.
848
+ """
849
+ from harness import datastore as DS
850
+ from harness import semantic as sem
851
+
852
+ t = t or P.today()
853
+ checks = []
854
+ offer = sem.entity_measures("odoo_products")
855
+ keys = [m["key"] for m in offer if m["key"].startswith("stock_")]
856
+ if not keys:
857
+ why = [r for r in sem.entity_measure_refusals("odoo_products")
858
+ if str(r.get("key", "")).startswith("stock_")]
859
+ return [{"check": "the product catalogue offers Stock moved in / out (W37-T13)",
860
+ "ours": 0, "theirs": 3, "ok": False,
861
+ # ⭐ The refusal carries its own cause β€” reported, not inferred from an absence.
862
+ "detail": {"refusals": why or "no stock binding declared"}}]
863
+ checks.append({"check": "the product measure catalogue offers the stock-movement keys "
864
+ "(W37-T13)", "ours": sorted(keys), "theirs": 3,
865
+ "ok": {"stock_in", "stock_out"} <= set(keys)})
866
+
867
+ con = DS.ro_cursor()
868
+ try:
869
+ newest = con.execute("SELECT max(date) FROM stock_move").fetchone()
870
+ finally:
871
+ con.close()
872
+ d_to = t - _dt.timedelta(days=2)
873
+ if newest and newest[0]:
874
+ try:
875
+ d_to = min(d_to, _dt.date.fromisoformat(str(newest[0])[:10]) - _dt.timedelta(days=1))
876
+ except ValueError:
877
+ pass
878
+ d_from = d_to - _dt.timedelta(days=days)
879
+ DF, DT = d_from.isoformat(), d_to.isoformat()
880
+ ours = sem.entity_measure_values("odoo_products", ["stock_in", "stock_out"],
881
+ date_from=DF, date_to=DT, offer=offer)
882
+
883
+ o = O.get_odoo()
884
+ base = [("state", "=", "done"),
885
+ ("date", ">=", f"{DF} 00:00:00"), ("date", "<=", f"{DT} 23:59:59")]
886
+ IN = base + [("location_dest_id.usage", "=", "internal"),
887
+ ("location_id.usage", "!=", "internal")]
888
+ OUT = base + [("location_id.usage", "=", "internal"),
889
+ ("location_dest_id.usage", "!=", "internal")]
890
+ live = {}
891
+ for dom, side in ((IN, "stock_in"), (OUT, "stock_out")):
892
+ for r in o.read_group("stock.move", dom, ["product_id", "quantity_done:sum"],
893
+ ["product_id"], lazy=False):
894
+ if not r.get("product_id"):
895
+ continue
896
+ live.setdefault(r["product_id"][0], {})[side] = r["quantity_done"]
897
+ code_of = _codes_of_odoo_products(o, list(live))
898
+ by_code = {}
899
+ for pid_, v in live.items():
900
+ c = code_of.get(pid_, f"pid:{pid_}")
901
+ d = by_code.setdefault(c, {"stock_in": 0.0, "stock_out": 0.0})
902
+ for k in ("stock_in", "stock_out"):
903
+ d[k] += v.get(k, 0.0)
904
+
905
+ for side in ("stock_in", "stock_out"):
906
+ a = round(sum(c.get(side, 0) for c in ours.values()), 2)
907
+ b = round(sum(c.get(side, 0) for c in by_code.values()), 2)
908
+ checks.append({
909
+ "check": f"{side}: the mirror's per-SKU total vs a DIRECT Odoo read_group under the "
910
+ f"SAME two-sided location domain",
911
+ "ours": a, "theirs": b, "gap": round(a - b, 2),
912
+ "ok": bool(b) and abs(a - b) <= 0.02 * b,
913
+ "detail": {"window": [DF, DT], "skus_ours": len(ours), "skus_odoo": len(by_code)}})
914
+
915
+ # β›” THE NAMED SKU, and it is chosen for DIFFERING β€” see the docstring.
916
+ diff = sorted(((abs((c.get("stock_in") or 0) - (c.get("stock_out") or 0)), k)
917
+ for k, c in ours.items()), reverse=True)[:sample]
918
+ named = []
919
+ for _d, k in diff:
920
+ named.append({"sku": k,
921
+ "ours": {s: round(ours[k].get(s, 0), 2) for s in ("stock_in", "stock_out")},
922
+ "odoo": {s: round((by_code.get(k) or {}).get(s, 0), 2)
923
+ for s in ("stock_in", "stock_out")}})
924
+ off = [n for n in named
925
+ if any(abs(n["ours"][s] - n["odoo"][s]) > max(0.01, 0.02 * (n["odoo"][s] or 1))
926
+ for s in ("stock_in", "stock_out"))]
927
+ checks.append({
928
+ "check": f"each NAMED SKU's in/out ties to Odoo ({len(named)} SKUs, picked for the "
929
+ f"largest in-vs-out difference)",
930
+ "ours": len(off), "theirs": 0, "ok": not off,
931
+ "detail": {"named": named[:3], "mismatched": off[:2]}})
932
+ genuinely_split = [n for n in named if n["ours"]["stock_in"] != n["ours"]["stock_out"]]
933
+ checks.append({
934
+ "check": "β›” the DIRECTION SPLIT is real: at least one SKU where IN and OUT genuinely "
935
+ "differ. A one-sided domain makes them equal for every internal transfer, so "
936
+ "all-equal is the SIGNATURE OF THE BUG, not a quiet warehouse",
937
+ "ours": len(genuinely_split), "theirs": ">=1", "ok": bool(genuinely_split),
938
+ "detail": {"example": genuinely_split[0] if genuinely_split else None}})
939
+ return checks
940
+
941
+
942
+ def validate_price_and_unit_cells(rows, sample=6):
943
+ """⭐⭐ W37-T14 / T15 β€” the `Tier prices` and `Units` cells, against a FRESH Odoo read.
944
+
945
+ β›” THE ORACLE IS ASKED PER SKU, not in bulk, and deliberately so: the builders group a
946
+ bulk read in Python, so re-running the same bulk read would re-run the same grouping and
947
+ could only ever agree with itself. Asking Odoo for ONE SKU's price rules is a different
948
+ question shape and can actually disagree ([[no-unverifiable-aggregates]]).
949
+
950
+ ⚠ WHAT IS NOT PROVEN HERE, said rather than implied: that a PERSON sees the cell. These are
951
+ `json` columns on the product grid and the render is the client's; the data half is what a
952
+ module `validate()` can reach.
953
+ """
954
+ checks = []
955
+ priced = [r for r in rows if r.get("tier_prices")]
956
+ united = [r for r in rows if r.get("units")]
957
+ checks.append({
958
+ "check": "the product grid serves a Tier-prices cell (W37-T14) and a Units cell (T15); "
959
+ "a DECLARED column that is never filled is the defect these replace",
960
+ "ours": {"with_tier_prices": len(priced), "with_units": len(united), "rows": len(rows)},
961
+ "theirs": ">0 each",
962
+ # ⚠ Units are legitimately sparse (19.6% measured), so the floor is existence, not a rate.
963
+ "ok": bool(priced) and bool(united),
964
+ "detail": {"multi_price_skus": sum(1 for r in priced
965
+ if len(json.loads(r["tier_prices"])) > 1)},
966
+ })
967
+ if not priced:
968
+ return checks
969
+ o = O.get_odoo()
970
+ pls = {p["id"]: str(p.get("name") or "").strip()
971
+ for p in O.search_read("product.pricelist", [], ["id", "name"])}
972
+ today = P.today().isoformat()
973
+ # Prefer SKUs that carry MORE THAN ONE price β€” the ticket's own subject.
974
+ cand = sorted(priced, key=lambda r: -len(json.loads(r["tier_prices"])))[:sample]
975
+ bad = []
976
+ for r in cand:
977
+ mine = sorted((t["pricelist"], round(float(t["unit_price"]), 2))
978
+ for t in json.loads(r["tier_prices"]))
979
+ pid = r.get("product_id")
980
+ tmpl = None
981
+ if pid:
982
+ rec = o.search_read("product.product", [("id", "=", pid)], ["product_tmpl_id"])
983
+ tmpl = O.m2o_id(rec[0].get("product_tmpl_id")) if rec else None
984
+ dom = [("compute_price", "=", "fixed"),
985
+ "|", ("date_start", "=", False), ("date_start", "<=", today),
986
+ "|", ("date_end", "=", False), ("date_end", ">=", today),
987
+ ("fixed_price", ">", 0),
988
+ "|", ("product_id", "=", pid), ("product_tmpl_id", "=", tmpl)]
989
+ live = o.search_read("product.pricelist.item", dom,
990
+ ["pricelist_id", "fixed_price", "min_quantity", "applied_on"])
991
+ best = {}
992
+ for it in live:
993
+ nm = pls.get(O.m2o_id(it.get("pricelist_id")), "?")
994
+ q = it.get("min_quantity") or 0.0
995
+ if nm not in best or q < best[nm][0]:
996
+ best[nm] = (q, round(it.get("fixed_price") or 0.0, 2))
997
+ theirs = sorted((nm, v) for nm, (q, v) in best.items())
998
+ # ⚠ A code carried by TWO active products legitimately holds MORE entries than a single
999
+ # product's rules (D-309 / `2112-12`), so ours is a SUPERSET, never an equality.
1000
+ if not set(theirs) <= set(mine):
1001
+ bad.append({"sku": r.get("code"), "ours": mine, "odoo": theirs})
1002
+ checks.append({
1003
+ "check": f"each sampled SKU's Tier prices contain every live Odoo price for it "
1004
+ f"({len(cand)} SKUs, chosen for having the MOST prices)",
1005
+ "ours": len(bad), "theirs": 0, "ok": not bad,
1006
+ "detail": {"mismatches": bad[:3],
1007
+ "sampled": [r.get("code") for r in cand]},
1008
+ })
1009
+ return checks
1010
+
1011
+
1012
+ def _codes_of_odoo_products(o, ids):
1013
+ """`{odoo product id: the identity key the GRID uses}` β€” `default_code`, or `pid:<id>`.
1014
+
1015
+ ⚠ BATCHED, 500 at a time. One call per id is ~1,700 XML-RPC round trips on this window and
1016
+ turns a 5-second reconciliation into a coffee break.
1017
+ ⚠ `active in [True, False]`: a re-SKUed line points at the ARCHIVED record and the grid
1018
+ merges it under the surviving code, so an active-only read would key it `pid:<id>` and
1019
+ manufacture a mismatch this check would then report as a defect.
1020
+ """
1021
+ ids = sorted({i for i in ids if i})
1022
+ out = {}
1023
+ for i in range(0, len(ids), 500):
1024
+ for p in o.search_read('product.product',
1025
+ [('id', 'in', ids[i:i + 500]), ('active', 'in', [True, False])],
1026
+ ['default_code']):
1027
+ out[p['id']] = p.get('default_code') or f"pid:{p['id']}"
1028
+ return {i: out.get(i, f"pid:{i}") for i in ids}
1029
+
1030
+
1031
+ def validate_measures(t=None, team_id=None, days=90, pool_codes=None):
1032
+ """⭐⭐ W37-T10 β€” THE MINTED LOOKBACK MEASURES, against a DIRECT Odoo `read_group`.
1033
+
1034
+ Standing rule 8: a number that does not tie to Odoo does not ship. These columns are minted
1035
+ from the tenant MIRROR (`semantic.entity_measure_values`), so the oracle has to be the live
1036
+ ERP and nothing derived from the mirror β€” otherwise both sides come from the same place and
1037
+ the check cannot fail, which is the self-sealing shape `validate()`'s own header records
1038
+ costing a wave.
1039
+
1040
+ β›”β›” THE MIRROR IS BEHIND LIVE, ALWAYS, AND THAT IS NOT A DEFECT β€” so a bare equality here
1041
+ would be RED every day and would teach everyone to ignore it. The reconciliation is therefore
1042
+ two-legged, and the second leg is the one that carries the meaning:
1043
+
1044
+ leg 1 totals agree within the lag, and the lag is REPORTED as a number, not a tolerance;
1045
+ leg 2 ⭐ EVERY line-level difference traces to a line ODOO WROTE AFTER THE MIRROR'S OWN
1046
+ WATERMARK. This is what makes the check falsifiable: a join bug produces differences
1047
+ on lines the mirror holds perfectly, and leg 2 goes red on the first one.
1048
+
1049
+ ⚠ Do NOT "fix" leg 2 by filtering the live side on `write_date <= watermark` and comparing
1050
+ totals β€” MEASURED 2026-08-19, that is a far worse instrument: confirming an order touches its
1051
+ lines' `write_date` without changing a value, so the filter drops thousands of lines the
1052
+ mirror holds correctly and the gap grows from $425 to $140,030.
1053
+ """
1054
+ from harness import datastore as DS
1055
+ from harness import semantic as sem
1056
+
1057
+ t = t or P.today()
1058
+ checks = []
1059
+ try:
1060
+ if not DS.ready():
1061
+ return [{"check": "product lookback measures reconcile to Odoo",
1062
+ "ours": "no mirror", "theirs": "-", "ok": False,
1063
+ "detail": "the tenant store is not readable, so the measures are UNPROVEN, "
1064
+ "and an unproven aggregate is exactly what standing rule 8 bars"}]
1065
+ except Exception as e: # noqa: BLE001
1066
+ return [{"check": "product lookback measures reconcile to Odoo",
1067
+ "ours": f"{type(e).__name__}", "theirs": "-", "ok": False, "detail": str(e)[:200]}]
1068
+
1069
+ # β›” THE WINDOW END COMES FROM THE MIRROR, NOT FROM `today`, and this was measured the wrong
1070
+ # way round first. A window ending today reaches past what the mirror has ever seen: orders
1071
+ # placed since the last sync exist live and NOWHERE in the store, so the totals leg reported
1072
+ # a 2.69% "lag" that was really "the last three days do not exist here yet". Anchoring on the
1073
+ # mirror's own newest order makes the comparison one about EDITS to a shared period β€” which
1074
+ # is the only difference that could indicate a join bug.
1075
+ con = DS.ro_cursor()
1076
+ try:
1077
+ _newest = con.execute("SELECT max(date_order) FROM sale_order").fetchone()
1078
+ finally:
1079
+ con.close()
1080
+ d_to = t - _dt.timedelta(days=2)
1081
+ if _newest and _newest[0]:
1082
+ _n = str(_newest[0])[:10]
1083
+ try:
1084
+ # one day INSIDE the mirror's newest order: the final day may be half-synced.
1085
+ d_to = min(d_to, _dt.date.fromisoformat(_n) - _dt.timedelta(days=1))
1086
+ except ValueError:
1087
+ pass
1088
+ d_from = d_to - _dt.timedelta(days=days)
1089
+ DF, DT = d_from.isoformat(), d_to.isoformat()
1090
+
1091
+ offer = sem.entity_measures("odoo_products")
1092
+ checks.append({
1093
+ "check": "the product measure OFFER is non-empty and every key it names resolves in the "
1094
+ "semantic model (owner item 4 / R1)",
1095
+ "ours": len(offer), "theirs": ">0", "ok": len(offer) > 0,
1096
+ "detail": {"keys": [m["key"] for m in offer],
1097
+ # ⭐ The REPORTING half of standing rule 1: a declared key that dropped out
1098
+ # says why, rather than being quietly absent from a list nobody diffs.
1099
+ "refused": sem.entity_measure_refusals("odoo_products")},
1100
+ })
1101
+ if not offer:
1102
+ return checks
1103
+ missing_family = [m["key"] for m in offer if m.get("empty") not in ("zero", "blank")]
1104
+ checks.append({
1105
+ "check": "every offered measure declares an EMPTY-WINDOW family (C1: additive->0, "
1106
+ "ratio->blank), because 72% of this catalogue has no group in a 90-day window",
1107
+ "ours": len(missing_family), "theirs": 0, "ok": not missing_family,
1108
+ "detail": {"undeclared": missing_family},
1109
+ })
1110
+
1111
+ store = sem.entity_measure_values("odoo_products", ["revenue", "units", "margin"],
1112
+ date_from=DF, date_to=DT, exclude_services=False,
1113
+ offer=offer)
1114
+ o = O.get_odoo()
1115
+ # β›” ASKED OF ODOO DIRECTLY, grouped by Odoo's OWN product id β€” deliberately NOT by the SKU
1116
+ # code the mirror joins on, so the oracle cannot inherit our join key.
1117
+ g = o.read_group('sale.order.line', O.sale_line_domain(DF, DT),
1118
+ ['product_id', 'price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'],
1119
+ ['product_id'], lazy=False)
1120
+ live_tot = {"revenue": round(sum(r['price_subtotal'] for r in g), 2),
1121
+ "units": round(sum(r['product_uom_qty'] for r in g), 2),
1122
+ "margin": round(sum(r['margin'] for r in g), 2)}
1123
+ ours_tot = {k: round(sum(v.get(k, 0) for v in store.values()), 2)
1124
+ for k in ("revenue", "units", "margin")}
1125
+
1126
+ # leg 2 β€” the falsifiable one. Every discrepant LINE must post-date the mirror's watermark.
1127
+ con = DS.ro_cursor()
1128
+ try:
1129
+ wm = con.execute("SELECT cursor_wd FROM _sync_state WHERE entity = 'sale_order_line'"
1130
+ ).fetchone()
1131
+ wm = wm[0] if wm else None
1132
+ rows = con.execute(
1133
+ "SELECT l.id, l.price_subtotal FROM sale_order_line l "
1134
+ "JOIN sale_order o ON o.id = l.order_id "
1135
+ "WHERE o.state IN ('sale','done') AND o.team_id IN (5,6) "
1136
+ " AND l.product_id IS NOT NULL "
1137
+ " AND l.order_partner_id NOT IN "
1138
+ " (SELECT id FROM res_partner WHERE name LIKE 'GIFTWARE%') "
1139
+ " AND CAST(o.date_order AS TIMESTAMP) >= ? AND CAST(o.date_order AS TIMESTAMP) <= ?",
1140
+ [f"{DF} 00:00:00", f"{DT} 23:59:59"]).fetchall()
1141
+ finally:
1142
+ con.close()
1143
+ mine = {r[0]: (r[1] or 0.0) for r in rows}
1144
+ theirs = {r['id']: (r['price_subtotal'] or 0.0) for r in
1145
+ o.search_read('sale.order.line', O.sale_line_domain(DF, DT),
1146
+ ['id', 'price_subtotal', 'write_date'])}
1147
+ wd = {r['id']: str(r['write_date']) for r in
1148
+ o.search_read('sale.order.line', O.sale_line_domain(DF, DT), ['id', 'write_date'])}
1149
+ discrepant = [i for i, v in theirs.items()
1150
+ if i not in mine or abs(mine[i] - v) >= 0.005]
1151
+ unexplained = [i for i in discrepant if not wm or wd.get(i, '') <= str(wm)]
1152
+ checks.append({
1153
+ "check": "every per-SKU measure difference vs live Odoo traces to a line Odoo wrote "
1154
+ "AFTER the mirror's watermark, because a join bug would differ on a mirrored line",
1155
+ "ours": len(unexplained), "theirs": 0, "ok": not unexplained,
1156
+ "detail": {"lines_compared": len(theirs), "discrepant": len(discrepant),
1157
+ "explained_by_mirror_lag": len(discrepant) - len(unexplained),
1158
+ "watermark": str(wm), "window": [DF, DT],
1159
+ "unexplained_line_ids": unexplained[:10]},
1160
+ })
1161
+ # β›”β›” THE LEG THAT CATCHES A WRONG JOIN KEY, and neither leg above can. Both of those sum the
1162
+ # same LINES whatever dim they were grouped by, so swapping `dim: product_code` for
1163
+ # `dim: product` (contract C1's trap, an Odoo id where the grid carries a SKU code) leaves
1164
+ # them both green while every cell on the screen goes blank. This one asks: does the answer
1165
+ # arrive under a key a GRID ROW ACTUALLY HAS, and is the value right FOR THAT SKU?
1166
+ code_of = _codes_of_odoo_products(o, [r['product_id'][0] for r in g if r.get('product_id')])
1167
+ by_code = {}
1168
+ for r in g:
1169
+ if not r.get('product_id'):
1170
+ continue
1171
+ c = code_of.get(r['product_id'][0], f"pid:{r['product_id'][0]}")
1172
+ by_code[c] = by_code.get(c, 0.0) + r['price_subtotal']
1173
+ # ⚠ The pool is the CALLER'S when it has one (it has already paid for it), and read fresh
1174
+ # otherwise β€” `validate_measures` is runnable on its own, and a leg that silently skips when
1175
+ # called directly is a leg nobody runs ([[gate-can-report-green-on-nothing]]).
1176
+ pool_codes = set(pool_codes) if pool_codes is not None else {
1177
+ r["code"] for r in pool(team_id=team_id, t=t)}
1178
+ keyed_to_a_row = [c for c in store if c in pool_codes]
1179
+ top = sorted(store.items(), key=lambda kv: -(kv[1].get("revenue") or 0))[:10]
1180
+ spot = [{"sku": c,
1181
+ "ours": round(v.get("revenue") or 0.0, 2),
1182
+ "odoo": round(by_code.get(c, 0.0), 2)} for c, v in top]
1183
+ # A named SKU may legitimately differ by a line Odoo edited after the watermark; the
1184
+ # assertion is that MOST of the top ten tie exactly and NONE is off by an order of magnitude,
1185
+ # which is what a mis-keyed join looks like (0.00 against a five-figure number).
1186
+ exact = sum(1 for s in spot if abs(s["ours"] - s["odoo"]) < 0.005)
1187
+ checks.append({
1188
+ "check": "the measure answer is KEYED TO THE GRID'S OWN IDENTITY (C1's join-key trap): "
1189
+ "every group key is a SKU code a pool row carries, and the top-10 SKUs' revenue "
1190
+ "ties to Odoo for THAT SKU",
1191
+ "ours": {"keys_matching_a_pool_row": len(keyed_to_a_row), "of": len(store),
1192
+ "top10_exact": exact},
1193
+ "theirs": {"keys_matching_a_pool_row": len(store), "of": len(store), "top10_exact": 10},
1194
+ # ⚠ Not `== len(store)`: a code whose ONLY product record is archived legitimately has
1195
+ # revenue and no grid row (R12 keeps archived out), which `validate()`'s own
1196
+ # "outside the grid" leg already reconciles. A mis-keyed join lands at ~0, not at 99%.
1197
+ "ok": len(store) > 0 and len(keyed_to_a_row) / len(store) > 0.95 and exact >= 8,
1198
+ "detail": {"spot_checks": spot,
1199
+ "keys_with_no_pool_row": sorted(set(store) - pool_codes)[:10]},
1200
+ })
1201
+ checks.append({
1202
+ "check": "product lookback totals vs a DIRECT Odoo read_group, where the residual is mirror "
1203
+ "lag and is REPORTED as a figure, never absorbed into a tolerance",
1204
+ "ours": ours_tot, "theirs": live_tot,
1205
+ # The assertion is on leg 2; this leg is red only if the lag is implausibly large, which
1206
+ # is the shape that means "the mirror stopped" rather than "the mirror is a day behind".
1207
+ "ok": abs(ours_tot["revenue"] - live_tot["revenue"]) <= max(
1208
+ 0.01, live_tot["revenue"] * 0.02),
1209
+ "detail": {"revenue_lag": round(ours_tot["revenue"] - live_tot["revenue"], 2),
1210
+ "revenue_lag_pct": round(
1211
+ (ours_tot["revenue"] - live_tot["revenue"]) / live_tot["revenue"] * 100, 4)
1212
+ if live_tot["revenue"] else None,
1213
+ "skus_in_grid_answer": len(store)},
1214
+ })
1215
  return checks
platform/modules/products.py CHANGED
@@ -393,6 +393,178 @@ def pricelist_by_code():
393
  return out, report
394
 
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  def catalogue_count():
397
  """The INDEPENDENT population oracle: Odoo's own count of active products.
398
 
 
393
  return out, report
394
 
395
 
396
+ def active_products(limit=50000):
397
+ """`[{id, default_code, product_tmpl_id}]` for every ACTIVE product β€” read ONCE and shared.
398
+
399
+ β›” RAISES ON A SHORT PULL, like `catalogue()` and for the same reason: a truncated product read
400
+ renders as a plausible smaller set of priced SKUs with nothing reporting it.
401
+ ⭐ It exists so `tier_prices_by_code` and `packagings_by_code` can be called from ONE pool
402
+ build without each paying for its own copy of the same 5,873-row read β€” measured at ~4 s each
403
+ on this connection, on a path that is somebody's first page load.
404
+ """
405
+ dom = [('active', '=', True)]
406
+ prods = O.search_read('product.product', dom, ['id', 'default_code', 'product_tmpl_id'],
407
+ limit=limit)
408
+ n = O.get_odoo().search_count('product.product', dom)
409
+ if len(prods) != n:
410
+ raise ValueError(f"products.active_products: the product pull is TRUNCATED. Read "
411
+ f"{len(prods)} rows against a search_count of {n}.")
412
+ return prods
413
+
414
+
415
+ def tier_prices_by_code(prods=None):
416
+ """`({code: [{pricelist, unit_price}]}, report)` β€” EVERY live price a SKU really has.
417
+
418
+ ⭐⭐ W37-T14 (owner: multiple prices per SKU). `pricelist_by_code` above answers a DIFFERENT
419
+ question and both are needed: it fills three DECLARED columns (`price_fisch`, `price_royal_1`,
420
+ `price_royal_2`) and therefore cannot show a price on a list the contract does not name. This
421
+ one is the honest set β€” one entry per pricelist that actually prices this SKU today.
422
+
423
+ β›” IT READS EVERY LIVE PRICELIST, not `PRICELIST_COLUMNS`. Hardcoding this tenant's three list
424
+ names into "how many prices does a SKU have" is exactly what the ticket forbids, and it is what
425
+ would make the answer wrong for tenant #1 on the day they are onboarded.
426
+
427
+ β›” ROWS ARE TEMPLATE-SCOPED (`proto/P2-pricing-uom.md`): joining on `product_id` drops 99.9% of
428
+ price rows, because `applied_on` is `1_product` on 10,454 of 10,468 items and `1_product` means
429
+ the TEMPLATE. A variant rule (`0_product_variant`, 13 rows) is the more specific statement and
430
+ wins, matching `pricecomp._tier_for` and `pricelist_by_code`.
431
+
432
+ β›” FILTER BY `pricelist_id`, NEVER BY `active`: the default `product.pricelist.item` count hides
433
+ 2,580 archived items, so an `active` filter reads as a smaller, plausible, wrong set.
434
+
435
+ ⚠ THE BASE TIER, at qty 1, for the same reason `pricelist_by_code` gives: a catalogue cell has
436
+ no quantity in hand. Measured: only 10 of 10,468 items carry a quantity break at all, so this
437
+ is very nearly the whole story rather than a simplification.
438
+ ⚠ CARDINALITY 1..3 TODAY, mode 3 β€” and `Royal 2` carries 2,605 price rows against **0 customers
439
+ and 0 orders**, so a SKU reading "3 tiers" is catalogue-true and commercially misleading. The
440
+ entry keeps the list NAME so a reader can see which tier it is rather than a bare count.
441
+ """
442
+ report = {"lists": [], "rules_total": 0, "rules_not_fixed": 0, "rules_out_of_date": 0,
443
+ "rules_global": 0, "skus_with_no_price": 0, "identity_breaks": 0}
444
+ try:
445
+ pls = {p['id']: str(p.get('name') or '').strip()
446
+ for p in O.search_read('product.pricelist', [], ['id', 'name'])}
447
+ report["lists"] = sorted(pls.values())
448
+ today = P.today().isoformat()
449
+ dom = [('pricelist_id', 'in', sorted(pls)), ('compute_price', '=', 'fixed'),
450
+ ('applied_on', 'in', ['0_product_variant', '1_product']),
451
+ '|', ('date_start', '=', False), ('date_start', '<=', today),
452
+ '|', ('date_end', '=', False), ('date_end', '>=', today),
453
+ ('fixed_price', '>', 0)]
454
+ rules = O.search_read('product.pricelist.item', dom,
455
+ ['pricelist_id', 'product_id', 'product_tmpl_id', 'applied_on',
456
+ 'fixed_price', 'min_quantity'])
457
+
458
+ # R6's second sentence: what this reader cannot see is COUNTED, never dropped.
459
+ def _n(extra):
460
+ try:
461
+ return O.get_odoo().search_count('product.pricelist.item', extra)
462
+ except Exception: # noqa: BLE001
463
+ return -1 # -1 reads as "not measured", never as zero
464
+ report["rules_total"] = _n([])
465
+ report["rules_not_fixed"] = _n([('compute_price', '!=', 'fixed')])
466
+ report["rules_global"] = _n([('applied_on', '=', '3_global')])
467
+ report["rules_out_of_date"] = _n(['|', ('date_end', '!=', False),
468
+ ('date_start', '!=', False)])
469
+
470
+ by_var, by_tmpl = {}, {}
471
+ for r in rules:
472
+ plid = O.m2o_id(r.get('pricelist_id'))
473
+ if r.get('applied_on') == '0_product_variant' and r.get('product_id'):
474
+ by_var.setdefault((plid, O.m2o_id(r['product_id'])), []).append(r)
475
+ elif r.get('product_tmpl_id'):
476
+ by_tmpl.setdefault((plid, O.m2o_id(r['product_tmpl_id'])), []).append(r)
477
+
478
+ prods = active_products() if prods is None else prods
479
+ except Exception as e: # noqa: BLE001
480
+ report["error"] = f"{type(e).__name__}: {str(e)[:200]}"
481
+ return {}, report
482
+
483
+ out = {}
484
+ for p in prods:
485
+ code = (str(p['default_code']).strip() if p.get('default_code') else f"pid:{p['id']}")
486
+ tmpl = O.m2o_id(p.get('product_tmpl_id'))
487
+ tiers = []
488
+ for plid, name in sorted(pls.items(), key=lambda kv: kv[1].lower()):
489
+ cands = by_var.get((plid, p['id'])) or by_tmpl.get((plid, tmpl))
490
+ if not cands:
491
+ continue
492
+ base = min(cands, key=lambda r: r.get('min_quantity') or 0.0)
493
+ tiers.append({"pricelist": name, "unit_price": round(base.get('fixed_price') or 0.0, 2)})
494
+ if tiers:
495
+ # ⚠ A code carried by TWO active products (D-309: `2112-12`) MERGES here, because the
496
+ # grid is keyed by code and one code is one row. The prices are UNIONED rather than
497
+ # one silently winning β€” a SKU that really does have two different Fisch prices should
498
+ # show both, and hiding one is how a $60 price disappeared behind a $24 one.
499
+ prior = out.get(code)
500
+ if prior is None:
501
+ out[code] = tiers
502
+ else:
503
+ seen = {(t["pricelist"], t["unit_price"]) for t in prior}
504
+ for t in tiers:
505
+ if (t["pricelist"], t["unit_price"]) not in seen:
506
+ prior.append(t)
507
+ report["identity_breaks"] += 1
508
+ else:
509
+ report["skus_with_no_price"] += 1
510
+ return out, report
511
+
512
+
513
+ def packagings_by_code(prods=None):
514
+ """`({code: [{name, qty}]}, report)` β€” the UNITS a SKU is really sold in (W37-T15).
515
+
516
+ β›” IT COMES FROM `product.packaging` ALONE, and `proto/P2-pricing-uom.md` measured why the
517
+ obvious alternative is a dead end: the `uom.uom` Config category holds 26 conversion-bearing
518
+ units (`Case-Packed 12` … `Pallet-Packed 4,800`) referenced by **0 products, 0 sale lines and
519
+ 0 stock moves**, and every unit actually in use has `factor_inv = 1`. Building on `uom.uom`
520
+ conversions yields a column of 1s.
521
+
522
+ β›” `qty > 1` IS A FILTER, NOT A TIDY-UP. 6,283 of 7,471 packaging rows are `qty = 1.0` β€” a
523
+ packaging that packs one of something is not a unit tier β€” plus real junk (one-character
524
+ names). Without it "units per SKU" reads **5,859** SKUs instead of **1,150**.
525
+
526
+ β›” 133 ROWS ARE ORPHANS (`product_id = False`) and would collapse under a `groupby(product_id)`
527
+ into ONE fake product carrying 133 packagings. Dropped, and counted.
528
+
529
+ ⚠ HONEST EMPTY: only **1,150 of 5,873 SKUs (19.6%)** have any unit tier, so this cell is
530
+ legitimately blank for 80% of the catalogue β€” "this SKU is sold in one unit", never "missing".
531
+ The 19.6% is not decoration: units are transacted on 77.9% of confirmed sale lines.
532
+ ⚠ `qty` is denominated in the product's OWN `uom_id` (matched on 7,338/7,338).
533
+ """
534
+ report = {"rows_total": 0, "rows_orphan": 0, "rows_qty_le_1": 0, "skus_with_units": 0}
535
+ try:
536
+ rows = O.search_read('product.packaging', [], ['id', 'name', 'qty', 'product_id'])
537
+ except Exception as e: # noqa: BLE001
538
+ report["error"] = f"{type(e).__name__}: {str(e)[:200]}"
539
+ return {}, report
540
+ report["rows_total"] = len(rows)
541
+ by_pid = {}
542
+ for r in rows:
543
+ pid = O.m2o_id(r.get('product_id'))
544
+ if not pid:
545
+ report["rows_orphan"] += 1
546
+ continue
547
+ if (r.get('qty') or 0) <= 1:
548
+ report["rows_qty_le_1"] += 1
549
+ continue
550
+ by_pid.setdefault(pid, []).append(
551
+ {"name": str(r.get('name') or '').strip(), "qty": float(r.get('qty') or 0)})
552
+ try:
553
+ prods = active_products() if prods is None else prods
554
+ except Exception as e: # noqa: BLE001
555
+ report["error"] = f"{type(e).__name__}: {str(e)[:200]}"
556
+ return {}, report
557
+ out = {}
558
+ for p in prods:
559
+ got = by_pid.get(p['id'])
560
+ if not got:
561
+ continue
562
+ code = (str(p['default_code']).strip() if p.get('default_code') else f"pid:{p['id']}")
563
+ out.setdefault(code, []).extend(sorted(got, key=lambda u: u["qty"]))
564
+ report["skus_with_units"] = len(out)
565
+ return out, report
566
+
567
+
568
  def catalogue_count():
569
  """The INDEPENDENT population oracle: Odoo's own count of active products.
570
 
requirements.txt CHANGED
@@ -1,47 +1,55 @@
1
- # AIOS web API β€” the FastAPI backend + the platform data-layer deps it reuses.
2
- #
3
- # β›” STREAMLIT IS GONE (EXIT-6, 2026-08-04) β€” do not add it back. The line here read
4
- # `streamlit==1.58.0` with the comment "included only because the shared modules/core may import
5
- # it at load; it is never served". That stopped being true when the shared layer was cleaned, and
6
- # it stayed in THIS file β€” the one the Dockerfile actually installs β€” while `api/requirements.txt`
7
- # had already dropped it and a gate reported the omission "verified". ~250 MB of image for a
8
- # package nothing imported.
9
- #
10
- # The lesson is the file, not the package: the PINNED intent and the SHIPPED manifest are two
11
- # different documents, and checking only the one you wrote is how the other rots.
12
- # `api/verify_no_streamlit.py` now checks BOTH, and proves the API imports with streamlit,
13
- # plotly, altair, openpyxl, reportlab and jinja2 all blocked at sys.meta_path.
14
- # No altair/plotly/reportlab either β€” those were Streamlit UI/export only. openpyxl RETURNED in
15
- # wave 21 (C5) as a LAZY dep of routes_uploads.py β€” the boot proof above still holds because the
16
- # import lives inside the handler, and the gate now asserts this line exists here.
17
- fastapi>=0.139
18
- uvicorn[standard]>=0.30
19
- python-dotenv>=1.0
20
- pandas>=2.0
21
- requests>=2.28
22
- huggingface_hub>=0.20
23
- duckdb>=1.0
24
- pyyaml>=6.0
25
- pillow>=10.0
26
- beautifulsoup4>=4.12
27
- lxml>=5.0
28
- cryptography>=42.0
29
- # ⭐ WAVE 20 (R1 / D-4) β€” THE POSTGRES DRIVER, AND IT IS LOAD-BEARING IN THIS FILE SPECIFICALLY.
30
- # `core/store_pg.py` is the store backend from this wave on, and it is deliberately FAIL-CLOSED:
31
- # `_pool()` raises rather than falling back to the HF file store, so a container that gets
32
- # `STORE_BACKEND=pg` without this line does not degrade β€” it refuses every request that touches
33
- # the store, which is every authenticated request.
34
- #
35
- # β›” AND THIS IS THE FILE THAT MATTERS: the Dockerfile does `COPY requirements.txt` from the
36
- # Space root, i.e. THIS manifest, not `api/requirements.txt`. That header's own streamlit story
37
- # is the same defect in the other direction β€” the pinned intent and the shipped manifest are two
38
- # documents. psycopg is now in BOTH, and `ops/verify_portability.py` gates the pair.
39
- psycopg[binary,pool]>=3.2
40
- # ⭐ WAVE 21 (item 11, C5) β€” .xlsx preview for Select-from-file; lazy-imported in
41
- # routes_uploads.py only. verify_no_streamlit asserts this line in BOTH manifests.
42
- openpyxl>=3.1
43
- # β›” AND ITS TRANSPORT, learned from a RUNTIME_ERROR on the first v6 boot: FastAPI demands
44
- # python-multipart AT IMPORT TIME for any route declaring File(...)/Form(...). Every local gate
45
- # was green because the dev box happens to have it for unrelated reasons β€” the container
46
- # installs exactly this file. The environment-parity twin of the streamlit lesson above.
47
- python-multipart>=0.0.20
 
 
 
 
 
 
 
 
 
1
+ # AIOS web API β€” the FastAPI backend + the platform data-layer deps it reuses.
2
+ #
3
+ # β›” STREAMLIT IS GONE (EXIT-6, 2026-08-04) β€” do not add it back. The line here read
4
+ # `streamlit==1.58.0` with the comment "included only because the shared modules/core may import
5
+ # it at load; it is never served". That stopped being true when the shared layer was cleaned, and
6
+ # it stayed in THIS file β€” the one the Dockerfile actually installs β€” while `api/requirements.txt`
7
+ # had already dropped it and a gate reported the omission "verified". ~250 MB of image for a
8
+ # package nothing imported.
9
+ #
10
+ # The lesson is the file, not the package: the PINNED intent and the SHIPPED manifest are two
11
+ # different documents, and checking only the one you wrote is how the other rots.
12
+ # `api/verify_no_streamlit.py` now checks BOTH, and proves the API imports with streamlit,
13
+ # plotly, altair, openpyxl, reportlab and jinja2 all blocked at sys.meta_path.
14
+ # No altair/plotly/reportlab either β€” those were Streamlit UI/export only. openpyxl RETURNED in
15
+ # wave 21 (C5) as a LAZY dep of routes_uploads.py β€” the boot proof above still holds because the
16
+ # import lives inside the handler, and the gate now asserts this line exists here.
17
+ fastapi>=0.139
18
+ uvicorn[standard]>=0.30
19
+ python-dotenv>=1.0
20
+ pandas>=2.0
21
+ requests>=2.28
22
+ huggingface_hub>=0.20
23
+ duckdb>=1.0
24
+ pyyaml>=6.0
25
+ pillow>=10.0
26
+ beautifulsoup4>=4.12
27
+ lxml>=5.0
28
+ cryptography>=42.0
29
+ # ⭐ WAVE 20 (R1 / D-4) β€” THE POSTGRES DRIVER, AND IT IS LOAD-BEARING IN THIS FILE SPECIFICALLY.
30
+ # `core/store_pg.py` is the store backend from this wave on, and it is deliberately FAIL-CLOSED:
31
+ # `_pool()` raises rather than falling back to the HF file store, so a container that gets
32
+ # `STORE_BACKEND=pg` without this line does not degrade β€” it refuses every request that touches
33
+ # the store, which is every authenticated request.
34
+ #
35
+ # β›” AND THIS IS THE FILE THAT MATTERS: the Dockerfile does `COPY requirements.txt` from the
36
+ # Space root, i.e. THIS manifest, not `api/requirements.txt`. That header's own streamlit story
37
+ # is the same defect in the other direction β€” the pinned intent and the shipped manifest are two
38
+ # documents. psycopg is now in BOTH, and `ops/verify_portability.py` gates the pair.
39
+ psycopg[binary,pool]>=3.2
40
+ # ⭐ WAVE 21 (item 11, C5) β€” .xlsx preview for Select-from-file; lazy-imported in
41
+ # routes_uploads.py only. verify_no_streamlit asserts this line in BOTH manifests.
42
+ openpyxl>=3.1
43
+ # β›” AND ITS TRANSPORT, learned from a RUNTIME_ERROR on the first v6 boot: FastAPI demands
44
+ # python-multipart AT IMPORT TIME for any route declaring File(...)/Form(...). Every local gate
45
+ # was green because the dev box happens to have it for unrelated reasons β€” the container
46
+ # installs exactly this file. The environment-parity twin of the streamlit lesson above.
47
+ python-multipart>=0.0.20
48
+ # ⭐ WAVE 37 (D-346, answering `ASK E-1`) β€” THE OFFICIAL ANTHROPIC SDK. `ai_review.py` called the
49
+ # Messages API over raw `requests`, which meant re-implementing retries, streaming and error
50
+ # taxonomy by hand and drifting from them silently. E ships the SDK behind a LAZY import with the
51
+ # raw-HTTP call still there as the fallback, so the feature works with or without this line β€” what
52
+ # the line does is turn the fallback into dead weight instead of the live path.
53
+ # ⚠ BOTH MANIFESTS, NOT ONE. This file's own streamlit story at the top is exactly what pinning
54
+ # one of them looks like a year later.
55
+ anthropic>=0.96
web/index.html CHANGED
@@ -1,14 +1,46 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>Loopable</title>
8
- </head>
9
- <body>
10
- <div id="root"></div>
11
- <div id="portal"></div>
12
- <script type="module" src="/src/main.tsx"></script>
13
- </body>
14
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Loopable</title>
8
+ </head>
9
+ <body>
10
+ <!-- ⭐⭐ WAVE 37 Β· T48 β€” THE BOOT SKELETON, AND EVERY CHOICE HERE IS FORCED BY WHEN IT HAS TO
11
+ PAINT. Measured on the deployed build (proto/P4-first-load.md Β§A.2): `#root` was EMPTY and
12
+ the screen white for 6.0–8.0 s of an 11.6–16.0 s first open, while one 580 KB entry chunk
13
+ downloaded and parsed. The `<body>` was two empty divs, so there was nothing to show.
14
+
15
+ β›” INLINE STYLES, NOT A CLASS. `index.css` is its own ~324 KB render-blocking chunk that
16
+ took 3.3 s in that same trace β€” a skeleton styled from it would wait for the exact asset
17
+ whose arrival it exists to cover, and paint white until then. Inline is the only thing that
18
+ can render from the HTML alone.
19
+
20
+ β›” INSIDE `#root`, NOT BESIDE IT. `createRoot(...).render()` REPLACES the container's
21
+ children, so this is removed by the mount itself. A skeleton outside `#root` needs JS to
22
+ take it away, which is a second thing that can fail and a flash-of-two-uis if it does.
23
+
24
+ ⚠ NO ANIMATION. A shimmer would be a keyframe the CSS chunk has not delivered yet, and a
25
+ static block that never moves is honest about a page that is still downloading; a frozen
26
+ shimmer would read as a hung app. `aria-hidden` + `role="presentation"`: a screen reader
27
+ should hear the app, not its scaffolding. -->
28
+ <div id="root"><div aria-hidden="true" role="presentation" style="height:100vh;display:flex;flex-direction:column;background:#ffffff">
29
+ <div style="height:44px;flex:none;border-bottom:1px solid #e8e8ea;display:flex;align-items:center;padding:0 14px">
30
+ <div style="width:22px;height:22px;border-radius:11px;background:#efedf9"></div>
31
+ <div style="width:96px;height:12px;margin-left:10px;border-radius:3px;background:#efedf9"></div>
32
+ </div>
33
+ <div style="flex:1;display:flex;min-height:0">
34
+ <div style="width:288px;flex:none;border-right:1px solid #e8e8ea;padding:14px 12px">
35
+ <div style="height:11px;width:64%;border-radius:3px;background:#f1f0f4;margin-bottom:14px"></div>
36
+ <div style="height:11px;width:78%;border-radius:3px;background:#f4f3f7;margin-bottom:10px"></div>
37
+ <div style="height:11px;width:54%;border-radius:3px;background:#f4f3f7;margin-bottom:10px"></div>
38
+ <div style="height:11px;width:70%;border-radius:3px;background:#f4f3f7"></div>
39
+ </div>
40
+ <div style="flex:1;background:#fafafb"></div>
41
+ </div>
42
+ </div></div>
43
+ <div id="portal"></div>
44
+ <script type="module" src="/src/main.tsx"></script>
45
+ </body>
46
+ </html>
web/src/App.tsx CHANGED
@@ -1,30 +1,41 @@
1
- import CustomerGrid from "./customer-grid/CustomerGrid";
2
- import { OverlayProvider } from "./customer-grid/OverlaySurface";
3
- import { isStreamlitComponent } from "./customer-grid/hostBridge";
4
- import Shell from "./shell/Shell";
5
-
6
- // HOSTED = either Streamlit path (the component iframe, or the legacy injected
7
- // payload). There the surrounding app owns all chrome and this tree renders the
8
- // BARE grid, byte-for-byte the behavior the embed always had. Only the
9
- // standalone build gets the shell (wave 4, the strangler frame). Computed once
10
- // at module level, like useCustomerData's own mode constants β€” the host mode
11
- // cannot change within a page lifetime.
12
- // EXPORTED (EXIT wave 1): main.tsx needs the same answer to decide whether to
13
- // install the standalone event sink, and two copies of a mode predicate is how
14
- // the two halves of "standalone-only" end up disagreeing.
15
- export const HOSTED =
16
- isStreamlitComponent() ||
17
- typeof (window as unknown as { __AIOS_GRID__?: unknown }).__AIOS_GRID__ !== "undefined";
18
-
19
- export default function App() {
20
- if (HOSTED) {
21
- return (
22
- <div style={{ width: "100%", height: "100%", overflow: "hidden" }}>
23
- <OverlayProvider>
24
- <CustomerGrid />
25
- </OverlayProvider>
26
- </div>
27
- );
28
- }
29
- return <Shell />;
30
- }
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Suspense, lazy } from "react";
2
+ // ⭐⭐ WAVE 37 Β· T48 β€” LAZY, AND IT IS THE LAST OF THE THREE EAGER IMPORTS. `CustomerGrid` (7,715
3
+ // lines) plus glide-data-grid sat in the ENTRY chunk because these three files imported it at the
4
+ // top level, while twelve other surfaces were already `lazy()`. Measured on the deployed build
5
+ // (`proto/P4-first-load.md` Β§A.2): the entry chunk was 580,102 B on the wire and took 5.6-5.8 s,
6
+ // during which `#root` was EMPTY and the screen was white β€” 6.0-8.0 s of the 11.6-16.0 s first open.
7
+ // ⚠ THE EMBED PAYS THIS TOO, and it is the one path where the grid IS the page, so its fallback is
8
+ // deliberately the same skeleton shape the shell uses rather than `null`: a blank iframe is
9
+ // indistinguishable from a broken one.
10
+ const CustomerGrid = lazy(() => import("./customer-grid/CustomerGrid"));
11
+ import { OverlayProvider } from "./customer-grid/OverlaySurface";
12
+ import { isStreamlitComponent } from "./customer-grid/hostBridge";
13
+ import Shell from "./shell/Shell";
14
+
15
+ // HOSTED = either Streamlit path (the component iframe, or the legacy injected
16
+ // payload). There the surrounding app owns all chrome and this tree renders the
17
+ // BARE grid, byte-for-byte the behavior the embed always had. Only the
18
+ // standalone build gets the shell (wave 4, the strangler frame). Computed once
19
+ // at module level, like useCustomerData's own mode constants β€” the host mode
20
+ // cannot change within a page lifetime.
21
+ // EXPORTED (EXIT wave 1): main.tsx needs the same answer to decide whether to
22
+ // install the standalone event sink, and two copies of a mode predicate is how
23
+ // the two halves of "standalone-only" end up disagreeing.
24
+ export const HOSTED =
25
+ isStreamlitComponent() ||
26
+ typeof (window as unknown as { __AIOS_GRID__?: unknown }).__AIOS_GRID__ !== "undefined";
27
+
28
+ export default function App() {
29
+ if (HOSTED) {
30
+ return (
31
+ <div style={{ width: "100%", height: "100%", overflow: "hidden" }}>
32
+ <OverlayProvider>
33
+ <Suspense fallback={<div className="lp-boot" aria-hidden="true" />}>
34
+ <CustomerGrid />
35
+ </Suspense>
36
+ </OverlayProvider>
37
+ </div>
38
+ );
39
+ }
40
+ return <Shell />;
41
+ }
web/src/account/SubscriptionPage.tsx CHANGED
@@ -1,47 +1,49 @@
1
- // ---------------------------------------------------------------------------
2
- // account/SubscriptionPage.tsx β€” WAVE 35 Β· W35-T20 (owner item 13, contract C6).
3
- //
4
- // Owner item 13, verbatim: *"add below settings the following button: Feedback …,
5
- // Usage credits …, and a Subscription module … Specialized for the Royal Imports
6
- // tenant, do not show it."*
7
- //
8
- // This is the third of those three, and the only one with nothing behind it yet.
9
- // So it says so, in the words this app already uses for a boarded door
10
- // (`Shell.tsx`'s templates note: "Under construction. … will open here in a later
11
- // release."). One voice for one state β€” DESIGN.md 1's "variety is a defect".
12
- //
13
- // β›” THIS PAGE DOES NOT HIDE ITSELF, AND THAT IS THE TICKET'S OWN TRAP.
14
- // C6 puts the Royal Imports rule in TWO places that are both the frame's: the
15
- // account MENU does not draw the row, and the ROUTE refuses. A third opinion here
16
- // would be a third thing to keep in step, and the day they disagree the reader
17
- // gets a menu row that opens a page which says it does not exist. The page renders
18
- // the same for every tenant that can reach it, because reaching it IS the decision.
19
- //
20
- // β›” AND IT TAKES NO PROPS, WHICH IS ALSO A DECISION rather than an omission.
21
- // The wave's rule is that a prop must be REQUIRED, never optional β€” an optional one
22
- // degrades to "the page does not exist", which is indistinguishable from never
23
- // built. That rule constrains the props that EXIST; it does not ask for a prop that
24
- // carries nothing. There is nothing this page needs from the frame: it holds no
25
- // state, makes no call, and takes no decision the frame has already taken. A
26
- // `tenant` prop here would be exactly the second opinion the paragraph above
27
- // refuses. The mount is proven by A's `verify_wiring` row (W2), not by a signature.
28
- // ---------------------------------------------------------------------------
29
-
30
- import "./account.css";
31
-
32
- export default function SubscriptionPage() {
33
- return (
34
- <div className="acct-page">
35
- <h1 className="acct-title">Subscription</h1>
36
- <div className="acct-col">
37
- {/* ⚠ TWO SHORT SENTENCES AND NO TOUR (DESIGN.md 4 / wave-22 R13: the app does not
38
- narrate itself). The reader arrived here on purpose and wants one fact: is there
39
- anything to do? There is not, and every further word would be spent on someone
40
- who is already leaving. No feature list, no roadmap, no "in the meantime". */}
41
- <p className="acct-note">
42
- Under construction. Plans, billing and invoices will open here in a later release.
43
- </p>
44
- </div>
45
- </div>
46
- );
47
- }
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // account/SubscriptionPage.tsx β€” WAVE 35 Β· W35-T20 (owner item 13, contract C6).
3
+ //
4
+ // Owner item 13, verbatim: *"add below settings the following button: Feedback …,
5
+ // Usage credits …, and a Subscription module … Specialized for the Royal Imports
6
+ // tenant, do not show it."*
7
+ // β›” "verbatim" IS THE POINT: W37-T02 renamed that surface to "Usage" and this quote KEEPS the
8
+ // owner's own wording. A quote edited to match a later rename is no longer evidence of anything.
9
+ //
10
+ // This is the third of those three, and the only one with nothing behind it yet.
11
+ // So it says so, in the words this app already uses for a boarded door
12
+ // (`Shell.tsx`'s templates note: "Under construction. … will open here in a later
13
+ // release."). One voice for one state β€” DESIGN.md 1's "variety is a defect".
14
+ //
15
+ // β›” THIS PAGE DOES NOT HIDE ITSELF, AND THAT IS THE TICKET'S OWN TRAP.
16
+ // C6 puts the Royal Imports rule in TWO places that are both the frame's: the
17
+ // account MENU does not draw the row, and the ROUTE refuses. A third opinion here
18
+ // would be a third thing to keep in step, and the day they disagree the reader
19
+ // gets a menu row that opens a page which says it does not exist. The page renders
20
+ // the same for every tenant that can reach it, because reaching it IS the decision.
21
+ //
22
+ // β›” AND IT TAKES NO PROPS, WHICH IS ALSO A DECISION rather than an omission.
23
+ // The wave's rule is that a prop must be REQUIRED, never optional β€” an optional one
24
+ // degrades to "the page does not exist", which is indistinguishable from never
25
+ // built. That rule constrains the props that EXIST; it does not ask for a prop that
26
+ // carries nothing. There is nothing this page needs from the frame: it holds no
27
+ // state, makes no call, and takes no decision the frame has already taken. A
28
+ // `tenant` prop here would be exactly the second opinion the paragraph above
29
+ // refuses. The mount is proven by A's `verify_wiring` row (W2), not by a signature.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ import "./account.css";
33
+
34
+ export default function SubscriptionPage() {
35
+ return (
36
+ <div className="acct-page">
37
+ <h1 className="acct-title">Subscription</h1>
38
+ <div className="acct-col">
39
+ {/* ⚠ TWO SHORT SENTENCES AND NO TOUR (DESIGN.md 4 / wave-22 R13: the app does not
40
+ narrate itself). The reader arrived here on purpose and wants one fact: is there
41
+ anything to do? There is not, and every further word would be spent on someone
42
+ who is already leaving. No feature list, no roadmap, no "in the meantime". */}
43
+ <p className="acct-note">
44
+ Under construction. Plans, billing and invoices will open here in a later release.
45
+ </p>
46
+ </div>
47
+ </div>
48
+ );
49
+ }
web/src/account/UsagePage.tsx CHANGED
@@ -1,297 +1,297 @@
1
- // ---------------------------------------------------------------------------
2
- // account/UsagePage.tsx β€” WAVE 35 Β· W35-T19 (owner item 13, ruling R9, C6/C7).
3
- //
4
- // R9: ONE meter for every AI surface. It REPORTS weekly usage against an
5
- // allowance and does not cut anyone off this wave; over the allowance the
6
- // product still works and the bar is red.
7
- //
8
- // β›”β›” THIS PAGE MAY NOT INVENT A FIGURE, AND THAT IS THE WHOLE TICKET.
9
- // Every number here comes off `GET /usage` β€” the week, the reset date, the
10
- // allowance, each surface's tokens and calls. Nothing is derived from a clock,
11
- // nothing is estimated, and a surface with no ledger lines shows **0**, not a
12
- // blank and not an omission (E-9 guarantees all four surfaces are always
13
- // present, so there is no missing-row branch to get wrong).
14
- // [[no-unverifiable-aggregates]]
15
- //
16
- // β›” AND `unmeasured` IS RENDERED, NOT SWALLOWED. Those are real calls whose
17
- // provider declined to report a token count, so the total beside them is a
18
- // FLOOR. A page that hides them presents the floor as a complete figure, which
19
- // is the cost-surprise failure one step removed β€” the same reason
20
- // `usage_ledger` books an unmeasured call as unknown rather than as zero.
21
- //
22
- // ⚠ SURFACE/READ SPLIT, as on Home and Starred: `renderToStaticMarkup` runs no
23
- // effect, so a shot of the fetching component photographs only its spinner.
24
- // ---------------------------------------------------------------------------
25
-
26
- import { useCallback, useEffect, useRef, useState } from "react";
27
-
28
- import { fmt } from "../ui/fmt";
29
- import { loadUsage } from "./accountApi";
30
- import type { Usage, UsageSurface } from "./accountApi";
31
- import "./account.css";
32
-
33
- /**
34
- * The four surfaces C7 names, in the order R9 lists them.
35
- *
36
- * ⚠ AN UNKNOWN KEY IS HUMANISED, NEVER DROPPED. Dropping it would hide real usage from a meter
37
- * whose whole job is to account for it, and printing the raw key would put an internal identifier
38
- * on screen. The day a fifth LLM entry point lands (this product added two in one wave) it shows
39
- * up here as a readable name with the right number beside it, and somebody can then decide what
40
- * to call it.
41
- */
42
- const SURFACE_LABEL: Record<string, string> = {
43
- assistant: "Assistant",
44
- field_agent: "Field agents",
45
- ai_review: "Reviews",
46
- automation_draft: "Drafting",
47
- };
48
- const ORDER = ["assistant", "field_agent", "ai_review", "automation_draft"];
49
-
50
- export function surfaceLabel(key: string): string {
51
- const known = SURFACE_LABEL[key];
52
- if (known) return known;
53
- const words = key.replace(/[_-]+/g, " ").trim();
54
- return words ? words.charAt(0).toUpperCase() + words.slice(1) : key;
55
- }
56
-
57
- /** C7's order first, then anything the server added, alphabetically. */
58
- export function orderSurfaces(surfaces: UsageSurface[]): UsageSurface[] {
59
- const rank = (s: UsageSurface) => {
60
- const i = ORDER.indexOf(s.surface);
61
- return i === -1 ? ORDER.length : i;
62
- };
63
- return [...surfaces].sort(
64
- (a, b) => rank(a) - rank(b) || surfaceLabel(a.surface).localeCompare(surfaceLabel(b.surface))
65
- );
66
- }
67
-
68
- /**
69
- * The bar's fill, as a percentage, CLAMPED to 100.
70
- *
71
- * ⚠ The clamp is what keeps "over the allowance" a COLOUR rather than a bar that overflows its
72
- * own track and paints across the page. The number above the bar is unclamped and is where the
73
- * overage is actually read.
74
- * ⚠ An allowance of 0 (unset) yields 0 rather than a division by zero: no allowance means the
75
- * meter has nothing to measure against, and a full red bar would be an assertion nobody made.
76
- */
77
- export function barPct(value: number, of: number): number {
78
- if (!(of > 0)) return 0;
79
- return Math.max(0, Math.min(100, (value / of) * 100));
80
- }
81
-
82
- export type UsageLoad =
83
- | { phase: "loading" }
84
- | { phase: "ready"; usage: Usage }
85
- | { phase: "failed"; message: string };
86
-
87
- function Bar({ pct, over }: { pct: number; over: boolean }) {
88
- return (
89
- <span className="acct-bar" aria-hidden="true">
90
- <span className={"acct-bar-fill" + (over ? " is-over" : "")} style={{ width: `${pct}%` }} />
91
- </span>
92
- );
93
- }
94
-
95
- export function UsageSurfaceRows({ usage }: { usage: Usage }) {
96
- // ⚠ SCALED TO THE LARGEST SURFACE, NOT TO THE WEEK'S TOTAL, and the difference is legibility
97
- // rather than accuracy. `total` is the week's whole figure and can legitimately exceed the sum
98
- // of these four (unattributed calls are counted there and belong to nobody), so scaling to it
99
- // shrinks every bar by an amount the reader cannot see the cause of. Scaled to the biggest row,
100
- // the bars answer the question a row of bars is actually asked: which surface is spending most.
101
- // The absolute number sits beside each one, so nothing here is only readable as a shape.
102
- const biggest = usage.surfaces.reduce((m, s) => Math.max(m, s.tokens), 0);
103
- return (
104
- <div className="acct-rows">
105
- {orderSurfaces(usage.surfaces).map((s) => (
106
- <div key={s.surface} className="acct-row">
107
- <span className="acct-row-name">{surfaceLabel(s.surface)}</span>
108
- <Bar pct={barPct(s.tokens, biggest)} over={false} />
109
- <span className="acct-row-num">{fmt.int(s.tokens)}</span>
110
- <span className="acct-row-sub">
111
- {fmt.int(s.calls)} {s.calls === 1 ? "call" : "calls"}
112
- {/* β›” NOT HOVER-ONLY. A surface whose provider declined to report tokens has a
113
- token number that is a floor, and D-253 is a booked defect whose entire content
114
- is "the explanation was satisfied only on hover". */}
115
- {s.unmeasured > 0 ? `, ${fmt.int(s.unmeasured)} not measured` : ""}
116
- </span>
117
- </div>
118
- ))}
119
- </div>
120
- );
121
- }
122
-
123
- export function UsageView({
124
- load,
125
- scope,
126
- canSeeTenant,
127
- onScope,
128
- onRetry,
129
- }: {
130
- load: UsageLoad;
131
- scope: "me" | "tenant";
132
- /**
133
- * Whether the workspace view is reachable AT ALL for this account.
134
- *
135
- * β›” THE TOGGLE IS NOT DRAWN WHEN IT IS NOT, and that is R8's fake-affordance rule applied to a
136
- * permission: `?scope=tenant` is admin-only and 403s for everybody else, so an always-visible
137
- * control would be a button most accounts can only be refused by.
138
- */
139
- canSeeTenant: boolean;
140
- onScope: (scope: "me" | "tenant") => void;
141
- onRetry: () => void;
142
- }) {
143
- return (
144
- <div className="acct-page">
145
- <h1 className="acct-title">Usage credits</h1>
146
- <div className="acct-col">
147
- {canSeeTenant ? (
148
- <div className="acct-scope" role="group" aria-label="Whose usage">
149
- {(["me", "tenant"] as const).map((key) => (
150
- <button
151
- key={key}
152
- type="button"
153
- className={"acct-scope-btn" + (scope === key ? " is-on" : "")}
154
- aria-pressed={scope === key}
155
- onClick={() => onScope(key)}
156
- >
157
- {key === "me" ? "You" : "Workspace"}
158
- </button>
159
- ))}
160
- </div>
161
- ) : null}
162
- {load.phase === "loading" ? (
163
- <div className="acct-loading">
164
- <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
165
- </div>
166
- ) : load.phase === "failed" ? (
167
- // β›” NOT A ZERO METER. A failed read and a quiet week look identical on screen unless
168
- // this branch exists, and a meter reading zero is the most reassuring possible lie.
169
- <div className="acct-failed">
170
- <p className="acct-note">{load.message}</p>
171
- <button type="button" className="acct-retry" onClick={onRetry}>
172
- Try again
173
- </button>
174
- </div>
175
- ) : (
176
- <>
177
- <div className="acct-meter">
178
- {/* β›” THE METER SAYS WHOSE USAGE IT IS, IN WORDS, and that is not decoration. The
179
- allowance is a WORKSPACE number; the total beside it is this account's own
180
- unless the scope says otherwise. A meter that shows a personal figure against a
181
- shared limit without saying so is a ratio nobody can read correctly, and it is
182
- the one way this page can be wrong while every number on it is right. */}
183
- <div className="acct-meter-head">
184
- <span className="acct-meter-who">
185
- {load.usage.scope === "tenant" ? "This workspace" : "You"}, this week
186
- </span>
187
- </div>
188
- <div className="acct-meter-head">
189
- <span className={"acct-meter-value" + (load.usage.over ? " is-over" : "")}>
190
- {fmt.int(load.usage.total)}
191
- </span>
192
- <span className="acct-meter-of">
193
- of {fmt.int(load.usage.allowance)} tokens
194
- </span>
195
- </div>
196
- <Bar pct={barPct(load.usage.total, load.usage.allowance)} over={load.usage.over} />
197
- <div className="acct-meter-foot">
198
- {/* Both facts are the SERVER'S: the week it counted and the date it rolls over.
199
- A week boundary computed in the browser can disagree with the bucketing that
200
- produced the number above it, and then the page states a period nobody
201
- measured. */}
202
- <span>
203
- Week {load.usage.week}. Resets {fmt.date(load.usage.resets)}.
204
- </span>
205
- <span className="acct-meter-calls">
206
- {fmt.int(load.usage.calls)} {load.usage.calls === 1 ? "call" : "calls"}
207
- </span>
208
- </div>
209
- </div>
210
-
211
- {/* β›” R9's whole point, in one line: over the allowance it STILL WORKS. The bar is
212
- red, nothing is cut off, and the sentence says which of those two is true so
213
- nobody has to infer it from a colour. */}
214
- {load.usage.over ? (
215
- <p className="acct-error" role="status">
216
- This workspace is over its weekly allowance. Nothing is cut off; the AI surfaces
217
- keep working.
218
- </p>
219
- ) : null}
220
-
221
- {/* The server's own sentence, present only when something really was unmeasured. */}
222
- {load.usage.note ? <p className="acct-note acct-note--inline">{load.usage.note}</p> : null}
223
-
224
- <h2 className="acct-section">By surface</h2>
225
- <UsageSurfaceRows usage={load.usage} />
226
-
227
- {load.usage.unattributed > 0 ? (
228
- // ⚠ LABELLED A WIRING GAP, NOT USAGE (E-9). These are AI calls this container could
229
- // not attribute to an account. Folding them into somebody's total would be an
230
- // invented attribution, and leaving them out entirely would understate the week.
231
- <p className="acct-note acct-note--inline">
232
- {fmt.int(load.usage.unattributed)} tokens this week could not be attributed to an
233
- account. That is a wiring gap in the meter, not somebody's usage.
234
- </p>
235
- ) : null}
236
- </>
237
- )}
238
- </div>
239
- </div>
240
- );
241
- }
242
-
243
- export default function UsagePage() {
244
- const [load, setLoad] = useState<UsageLoad>({ phase: "loading" });
245
- const [scope, setScope] = useState<"me" | "tenant">("me");
246
- /**
247
- * Whether the workspace view exists for this account, ANSWERED BY ASKING ONCE.
248
- *
249
- * β›” WHY A PROBE RATHER THAN A ROLE CHECK. The frame knows the role, but these pages take no
250
- * props by contract (C6's trap: a page that decides its own visibility is a second opinion that
251
- * will one day disagree with the route). The route itself is the authority, so the page asks it
252
- * once: a 200 means the door is open and the toggle is real; a 403 means it is not and no
253
- * control is drawn. ⚠ That 403 is an ANSWER, not an error β€” it is never shown to the reader.
254
- * ⭐ AND IT IS WHAT GIVES `?scope=tenant` A DOOR. E built it admin-only; without this it would
255
- * be a finished capability with no caller, which is this repo's most-repeated failure
256
- * [[reachable-is-not-the-same-as-built]].
257
- */
258
- const [canSeeTenant, setCanSeeTenant] = useState(false);
259
- const gen = useRef(0);
260
-
261
- const read = useCallback((which: "me" | "tenant") => {
262
- const mine = gen.current + 1;
263
- gen.current = mine;
264
- setLoad({ phase: "loading" });
265
- void loadUsage(which === "tenant" ? "tenant" : undefined).then((r) => {
266
- if (gen.current !== mine) return;
267
- setLoad(r.ok ? { phase: "ready", usage: r.value } : { phase: "failed", message: r.message });
268
- });
269
- }, []);
270
-
271
- useEffect(() => {
272
- read(scope);
273
- return () => {
274
- gen.current += 1;
275
- };
276
- }, [read, scope]);
277
-
278
- useEffect(() => {
279
- let live = true;
280
- void loadUsage("tenant").then((r) => {
281
- if (live) setCanSeeTenant(r.ok);
282
- });
283
- return () => {
284
- live = false;
285
- };
286
- }, []);
287
-
288
- return (
289
- <UsageView
290
- load={load}
291
- scope={scope}
292
- canSeeTenant={canSeeTenant}
293
- onScope={setScope}
294
- onRetry={() => read(scope)}
295
- />
296
- );
297
- }
 
1
+ // ---------------------------------------------------------------------------
2
+ // account/UsagePage.tsx β€” WAVE 35 Β· W35-T19 (owner item 13, ruling R9, C6/C7).
3
+ //
4
+ // R9: ONE meter for every AI surface. It REPORTS weekly usage against an
5
+ // allowance and does not cut anyone off this wave; over the allowance the
6
+ // product still works and the bar is red.
7
+ //
8
+ // β›”β›” THIS PAGE MAY NOT INVENT A FIGURE, AND THAT IS THE WHOLE TICKET.
9
+ // Every number here comes off `GET /usage` β€” the week, the reset date, the
10
+ // allowance, each surface's tokens and calls. Nothing is derived from a clock,
11
+ // nothing is estimated, and a surface with no ledger lines shows **0**, not a
12
+ // blank and not an omission (E-9 guarantees all four surfaces are always
13
+ // present, so there is no missing-row branch to get wrong).
14
+ // [[no-unverifiable-aggregates]]
15
+ //
16
+ // β›” AND `unmeasured` IS RENDERED, NOT SWALLOWED. Those are real calls whose
17
+ // provider declined to report a token count, so the total beside them is a
18
+ // FLOOR. A page that hides them presents the floor as a complete figure, which
19
+ // is the cost-surprise failure one step removed β€” the same reason
20
+ // `usage_ledger` books an unmeasured call as unknown rather than as zero.
21
+ //
22
+ // ⚠ SURFACE/READ SPLIT, as on Home and Starred: `renderToStaticMarkup` runs no
23
+ // effect, so a shot of the fetching component photographs only its spinner.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ import { useCallback, useEffect, useRef, useState } from "react";
27
+
28
+ import { fmt } from "../ui/fmt";
29
+ import { loadUsage } from "./accountApi";
30
+ import type { Usage, UsageSurface } from "./accountApi";
31
+ import "./account.css";
32
+
33
+ /**
34
+ * The four surfaces C7 names, in the order R9 lists them.
35
+ *
36
+ * ⚠ AN UNKNOWN KEY IS HUMANISED, NEVER DROPPED. Dropping it would hide real usage from a meter
37
+ * whose whole job is to account for it, and printing the raw key would put an internal identifier
38
+ * on screen. The day a fifth LLM entry point lands (this product added two in one wave) it shows
39
+ * up here as a readable name with the right number beside it, and somebody can then decide what
40
+ * to call it.
41
+ */
42
+ const SURFACE_LABEL: Record<string, string> = {
43
+ assistant: "Assistant",
44
+ field_agent: "Field agents",
45
+ ai_review: "Reviews",
46
+ automation_draft: "Drafting",
47
+ };
48
+ const ORDER = ["assistant", "field_agent", "ai_review", "automation_draft"];
49
+
50
+ export function surfaceLabel(key: string): string {
51
+ const known = SURFACE_LABEL[key];
52
+ if (known) return known;
53
+ const words = key.replace(/[_-]+/g, " ").trim();
54
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : key;
55
+ }
56
+
57
+ /** C7's order first, then anything the server added, alphabetically. */
58
+ export function orderSurfaces(surfaces: UsageSurface[]): UsageSurface[] {
59
+ const rank = (s: UsageSurface) => {
60
+ const i = ORDER.indexOf(s.surface);
61
+ return i === -1 ? ORDER.length : i;
62
+ };
63
+ return [...surfaces].sort(
64
+ (a, b) => rank(a) - rank(b) || surfaceLabel(a.surface).localeCompare(surfaceLabel(b.surface))
65
+ );
66
+ }
67
+
68
+ /**
69
+ * The bar's fill, as a percentage, CLAMPED to 100.
70
+ *
71
+ * ⚠ The clamp is what keeps "over the allowance" a COLOUR rather than a bar that overflows its
72
+ * own track and paints across the page. The number above the bar is unclamped and is where the
73
+ * overage is actually read.
74
+ * ⚠ An allowance of 0 (unset) yields 0 rather than a division by zero: no allowance means the
75
+ * meter has nothing to measure against, and a full red bar would be an assertion nobody made.
76
+ */
77
+ export function barPct(value: number, of: number): number {
78
+ if (!(of > 0)) return 0;
79
+ return Math.max(0, Math.min(100, (value / of) * 100));
80
+ }
81
+
82
+ export type UsageLoad =
83
+ | { phase: "loading" }
84
+ | { phase: "ready"; usage: Usage }
85
+ | { phase: "failed"; message: string };
86
+
87
+ function Bar({ pct, over }: { pct: number; over: boolean }) {
88
+ return (
89
+ <span className="acct-bar" aria-hidden="true">
90
+ <span className={"acct-bar-fill" + (over ? " is-over" : "")} style={{ width: `${pct}%` }} />
91
+ </span>
92
+ );
93
+ }
94
+
95
+ export function UsageSurfaceRows({ usage }: { usage: Usage }) {
96
+ // ⚠ SCALED TO THE LARGEST SURFACE, NOT TO THE WEEK'S TOTAL, and the difference is legibility
97
+ // rather than accuracy. `total` is the week's whole figure and can legitimately exceed the sum
98
+ // of these four (unattributed calls are counted there and belong to nobody), so scaling to it
99
+ // shrinks every bar by an amount the reader cannot see the cause of. Scaled to the biggest row,
100
+ // the bars answer the question a row of bars is actually asked: which surface is spending most.
101
+ // The absolute number sits beside each one, so nothing here is only readable as a shape.
102
+ const biggest = usage.surfaces.reduce((m, s) => Math.max(m, s.tokens), 0);
103
+ return (
104
+ <div className="acct-rows">
105
+ {orderSurfaces(usage.surfaces).map((s) => (
106
+ <div key={s.surface} className="acct-row">
107
+ <span className="acct-row-name">{surfaceLabel(s.surface)}</span>
108
+ <Bar pct={barPct(s.tokens, biggest)} over={false} />
109
+ <span className="acct-row-num">{fmt.int(s.tokens)}</span>
110
+ <span className="acct-row-sub">
111
+ {fmt.int(s.calls)} {s.calls === 1 ? "call" : "calls"}
112
+ {/* β›” NOT HOVER-ONLY. A surface whose provider declined to report tokens has a
113
+ token number that is a floor, and D-253 is a booked defect whose entire content
114
+ is "the explanation was satisfied only on hover". */}
115
+ {s.unmeasured > 0 ? `, ${fmt.int(s.unmeasured)} not measured` : ""}
116
+ </span>
117
+ </div>
118
+ ))}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ export function UsageView({
124
+ load,
125
+ scope,
126
+ canSeeTenant,
127
+ onScope,
128
+ onRetry,
129
+ }: {
130
+ load: UsageLoad;
131
+ scope: "me" | "tenant";
132
+ /**
133
+ * Whether the workspace view is reachable AT ALL for this account.
134
+ *
135
+ * β›” THE TOGGLE IS NOT DRAWN WHEN IT IS NOT, and that is R8's fake-affordance rule applied to a
136
+ * permission: `?scope=tenant` is admin-only and 403s for everybody else, so an always-visible
137
+ * control would be a button most accounts can only be refused by.
138
+ */
139
+ canSeeTenant: boolean;
140
+ onScope: (scope: "me" | "tenant") => void;
141
+ onRetry: () => void;
142
+ }) {
143
+ return (
144
+ <div className="acct-page">
145
+ <h1 className="acct-title">Usage</h1>
146
+ <div className="acct-col">
147
+ {canSeeTenant ? (
148
+ <div className="acct-scope" role="group" aria-label="Whose usage">
149
+ {(["me", "tenant"] as const).map((key) => (
150
+ <button
151
+ key={key}
152
+ type="button"
153
+ className={"acct-scope-btn" + (scope === key ? " is-on" : "")}
154
+ aria-pressed={scope === key}
155
+ onClick={() => onScope(key)}
156
+ >
157
+ {key === "me" ? "You" : "Workspace"}
158
+ </button>
159
+ ))}
160
+ </div>
161
+ ) : null}
162
+ {load.phase === "loading" ? (
163
+ <div className="acct-loading">
164
+ <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
165
+ </div>
166
+ ) : load.phase === "failed" ? (
167
+ // β›” NOT A ZERO METER. A failed read and a quiet week look identical on screen unless
168
+ // this branch exists, and a meter reading zero is the most reassuring possible lie.
169
+ <div className="acct-failed">
170
+ <p className="acct-note">{load.message}</p>
171
+ <button type="button" className="acct-retry" onClick={onRetry}>
172
+ Try again
173
+ </button>
174
+ </div>
175
+ ) : (
176
+ <>
177
+ <div className="acct-meter">
178
+ {/* β›” THE METER SAYS WHOSE USAGE IT IS, IN WORDS, and that is not decoration. The
179
+ allowance is a WORKSPACE number; the total beside it is this account's own
180
+ unless the scope says otherwise. A meter that shows a personal figure against a
181
+ shared limit without saying so is a ratio nobody can read correctly, and it is
182
+ the one way this page can be wrong while every number on it is right. */}
183
+ <div className="acct-meter-head">
184
+ <span className="acct-meter-who">
185
+ {load.usage.scope === "tenant" ? "This workspace" : "You"}, this week
186
+ </span>
187
+ </div>
188
+ <div className="acct-meter-head">
189
+ <span className={"acct-meter-value" + (load.usage.over ? " is-over" : "")}>
190
+ {fmt.int(load.usage.total)}
191
+ </span>
192
+ <span className="acct-meter-of">
193
+ of {fmt.int(load.usage.allowance)} tokens
194
+ </span>
195
+ </div>
196
+ <Bar pct={barPct(load.usage.total, load.usage.allowance)} over={load.usage.over} />
197
+ <div className="acct-meter-foot">
198
+ {/* Both facts are the SERVER'S: the week it counted and the date it rolls over.
199
+ A week boundary computed in the browser can disagree with the bucketing that
200
+ produced the number above it, and then the page states a period nobody
201
+ measured. */}
202
+ <span>
203
+ Week {load.usage.week}. Resets {fmt.date(load.usage.resets)}.
204
+ </span>
205
+ <span className="acct-meter-calls">
206
+ {fmt.int(load.usage.calls)} {load.usage.calls === 1 ? "call" : "calls"}
207
+ </span>
208
+ </div>
209
+ </div>
210
+
211
+ {/* β›” R9's whole point, in one line: over the allowance it STILL WORKS. The bar is
212
+ red, nothing is cut off, and the sentence says which of those two is true so
213
+ nobody has to infer it from a colour. */}
214
+ {load.usage.over ? (
215
+ <p className="acct-error" role="status">
216
+ This workspace is over its weekly allowance. Nothing is cut off; the AI surfaces
217
+ keep working.
218
+ </p>
219
+ ) : null}
220
+
221
+ {/* The server's own sentence, present only when something really was unmeasured. */}
222
+ {load.usage.note ? <p className="acct-note acct-note--inline">{load.usage.note}</p> : null}
223
+
224
+ <h2 className="acct-section">By surface</h2>
225
+ <UsageSurfaceRows usage={load.usage} />
226
+
227
+ {load.usage.unattributed > 0 ? (
228
+ // ⚠ LABELLED A WIRING GAP, NOT USAGE (E-9). These are AI calls this container could
229
+ // not attribute to an account. Folding them into somebody's total would be an
230
+ // invented attribution, and leaving them out entirely would understate the week.
231
+ <p className="acct-note acct-note--inline">
232
+ {fmt.int(load.usage.unattributed)} tokens this week could not be attributed to an
233
+ account. That is a wiring gap in the meter, not somebody's usage.
234
+ </p>
235
+ ) : null}
236
+ </>
237
+ )}
238
+ </div>
239
+ </div>
240
+ );
241
+ }
242
+
243
+ export default function UsagePage() {
244
+ const [load, setLoad] = useState<UsageLoad>({ phase: "loading" });
245
+ const [scope, setScope] = useState<"me" | "tenant">("me");
246
+ /**
247
+ * Whether the workspace view exists for this account, ANSWERED BY ASKING ONCE.
248
+ *
249
+ * β›” WHY A PROBE RATHER THAN A ROLE CHECK. The frame knows the role, but these pages take no
250
+ * props by contract (C6's trap: a page that decides its own visibility is a second opinion that
251
+ * will one day disagree with the route). The route itself is the authority, so the page asks it
252
+ * once: a 200 means the door is open and the toggle is real; a 403 means it is not and no
253
+ * control is drawn. ⚠ That 403 is an ANSWER, not an error β€” it is never shown to the reader.
254
+ * ⭐ AND IT IS WHAT GIVES `?scope=tenant` A DOOR. E built it admin-only; without this it would
255
+ * be a finished capability with no caller, which is this repo's most-repeated failure
256
+ * [[reachable-is-not-the-same-as-built]].
257
+ */
258
+ const [canSeeTenant, setCanSeeTenant] = useState(false);
259
+ const gen = useRef(0);
260
+
261
+ const read = useCallback((which: "me" | "tenant") => {
262
+ const mine = gen.current + 1;
263
+ gen.current = mine;
264
+ setLoad({ phase: "loading" });
265
+ void loadUsage(which === "tenant" ? "tenant" : undefined).then((r) => {
266
+ if (gen.current !== mine) return;
267
+ setLoad(r.ok ? { phase: "ready", usage: r.value } : { phase: "failed", message: r.message });
268
+ });
269
+ }, []);
270
+
271
+ useEffect(() => {
272
+ read(scope);
273
+ return () => {
274
+ gen.current += 1;
275
+ };
276
+ }, [read, scope]);
277
+
278
+ useEffect(() => {
279
+ let live = true;
280
+ void loadUsage("tenant").then((r) => {
281
+ if (live) setCanSeeTenant(r.ok);
282
+ });
283
+ return () => {
284
+ live = false;
285
+ };
286
+ }, []);
287
+
288
+ return (
289
+ <UsageView
290
+ load={load}
291
+ scope={scope}
292
+ canSeeTenant={canSeeTenant}
293
+ onScope={setScope}
294
+ onRetry={() => read(scope)}
295
+ />
296
+ );
297
+ }
web/src/account/account.css CHANGED
@@ -1,372 +1,372 @@
1
- /* ─────────────────────────────────────────────────────────────────────────────────────────
2
- account/account.css β€” WAVE 35, owner item 13: the three surfaces that hang off the
3
- account button (Feedback, Usage credits, Subscription).
4
-
5
- β›” ITS OWN STYLESHEET, NOT index.css. That file belongs to the INTEGRATOR for the whole
6
- wave, and the wave's own rule is that a new surface brings its own sheet. Every selector
7
- here is .acct-*; nothing in this file redefines a .home-*, .shell-* or .cg-* rule, so
8
- nothing here can win or lose a source-order fight with the shared sheet.
9
-
10
- ⚠ .acct-page RESTATES .shell-home's five box declarations rather than reusing that class,
11
- and that is deliberate rather than lazy. .shell-home is Home's box by name and by its own
12
- region header; borrowing it would make every future Home layout change silently a change
13
- to three account pages. The VALUES are copied on purpose β€” DESIGN.md 2's sibling rule
14
- ("a panel matches its sibling's rendered values, not merely the scale") is what makes the
15
- account pages read as the same system as Home, and .shell-main is overflow:hidden, so a
16
- page that does not own its own scroll box simply loses everything below the fold.
17
-
18
- ⚠ EVERY font-size IS A --lp-fs-* TOKEN. verify_ui globs src/**.css (every stylesheet in
19
- the tree, not just index.css) and goes RED on a literal β€” wave-21 R5.
20
- ───────────────────────────────────────────────────────────────────────────────────────── */
21
-
22
- .acct-page {
23
- height: 100%;
24
- overflow-y: auto;
25
- padding: 34px 44px 56px;
26
- box-sizing: border-box;
27
- background: var(--lp-wash);
28
- }
29
-
30
- /* A short plain noun, same size and weight as .home-title, because it is the same role on a
31
- sibling surface. DESIGN.md 2: headers are 1 to 3 words, never a sentence. */
32
- .acct-title {
33
- margin: 0 0 20px;
34
- font-size: var(--lp-fs-lg);
35
- font-weight: 650;
36
- color: var(--lp-ink);
37
- }
38
-
39
- /* THE ONE COLUMN. Every account surface is a reading-width column, not a full-bleed page:
40
- a composer, a meter list and a note are all narrow objects, and stretching them to a
41
- 1600px window is how a page stops looking like it was designed. The reference the owner
42
- gave for the assistant uses the same centred column for the same reason. */
43
- .acct-col {
44
- max-width: 640px;
45
- }
46
-
47
- /* ── the honest note (Subscription today; any boarded account surface tomorrow) ─────────
48
- ONE containment layer: a hairline panel on the wash, no card inside a card, no shadow
49
- (DESIGN.md 4). It is a panel rather than a bare paragraph so the emptiness reads as
50
- deliberate instead of as a page that failed to load. */
51
- .acct-note {
52
- margin: 0;
53
- padding: 16px 18px;
54
- border: 1px solid var(--lp-line);
55
- border-radius: var(--lp-r-md);
56
- background: var(--lp-surface);
57
- font-size: var(--lp-fs-xs);
58
- line-height: var(--lp-lh);
59
- color: var(--lp-muted);
60
- }
61
-
62
- /* ── loading, and the way back from a failed read ─────────────────────────────────────────── */
63
- .acct-loading {
64
- display: flex;
65
- align-items: center;
66
- justify-content: center;
67
- min-height: 180px;
68
- }
69
- .acct-failed {
70
- display: flex;
71
- align-items: center;
72
- gap: 12px;
73
- }
74
- /* DESIGN.md 4's quiet button, with the UA chrome reset explicitly (the wave-19 "mystery outer
75
- lines and tinted fill" rule). */
76
- .acct-retry {
77
- flex: 0 0 auto;
78
- padding: 6px 12px;
79
- border: 1px solid var(--lp-line);
80
- border-radius: var(--lp-r-md);
81
- background: var(--lp-surface);
82
- color: var(--lp-ink);
83
- font: inherit;
84
- font-size: var(--lp-fs-2xs);
85
- font-weight: 600;
86
- cursor: pointer;
87
- }
88
- .acct-retry:hover {
89
- background: var(--lp-surface-2);
90
- border-color: var(--lp-blue-deep);
91
- }
92
-
93
- /* ── the two answers a write can give ─────────────────────────────────────────────────────
94
- Green = positive, red = negative: the fixed semantics (DESIGN.md 3), on the DEEP companions
95
- because a pastel on white is not a light aesthetic, it is an unreadable one. */
96
- .acct-ok,
97
- .acct-error {
98
- margin: 0 0 12px;
99
- padding: 9px 12px;
100
- border-radius: var(--lp-r-md);
101
- font-size: var(--lp-fs-2xs);
102
- line-height: var(--lp-lh);
103
- }
104
- .acct-ok {
105
- border: 1px solid var(--lp-green);
106
- background: var(--lp-green-tint);
107
- color: var(--lp-green-deep);
108
- }
109
- .acct-error {
110
- border: 1px solid var(--lp-red);
111
- background: var(--lp-red-tint);
112
- color: var(--lp-red-deep);
113
- }
114
-
115
- /* ── the composer ─────────────────────────────��───────────────────────────────────────────
116
- The ASSISTANT'S anatomy in this module's own rules: one rounded field that contains its own
117
- control row, so the category and the send button read as part of the thing being written
118
- rather than as chrome around it. `assistant.css` is another session's file this wave and is
119
- deliberately not imported; the shape is borrowed, the rules are local.
120
- ⚠ The radius is smaller than the assistant's 22px pill on purpose: this field is a paragraph
121
- box, not a one-line prompt, and a heavily rounded tall box reads as a speech bubble. */
122
- .acct-field {
123
- border: 1px solid var(--lp-line);
124
- border-radius: var(--lp-r-lg);
125
- background: var(--lp-surface);
126
- padding: 12px 10px 10px 14px;
127
- }
128
- .acct-field:focus-within {
129
- border-color: var(--lp-blue-solid);
130
- }
131
- .acct-prompt {
132
- display: block;
133
- width: 100%;
134
- border: 0;
135
- outline: none;
136
- padding: 0 6px 0 0;
137
- background: transparent;
138
- color: var(--lp-ink);
139
- font: inherit;
140
- font-size: var(--lp-fs-sm);
141
- line-height: var(--lp-lh);
142
- resize: vertical;
143
- box-sizing: border-box;
144
- }
145
- .acct-prompt::placeholder {
146
- color: var(--lp-muted);
147
- }
148
- .acct-field-foot {
149
- display: flex;
150
- align-items: center;
151
- gap: 10px;
152
- margin-top: 10px;
153
- }
154
- .acct-pick {
155
- display: inline-flex;
156
- align-items: center;
157
- gap: 6px;
158
- min-width: 0;
159
- }
160
- .acct-pick-label {
161
- color: var(--lp-muted);
162
- font-size: var(--lp-fs-2xs);
163
- }
164
- .acct-select {
165
- max-width: 220px;
166
- padding: 4px 8px;
167
- border: 1px solid var(--lp-line);
168
- border-radius: var(--lp-r-sm);
169
- background: var(--lp-surface);
170
- color: var(--lp-ink);
171
- font: inherit;
172
- font-size: var(--lp-fs-2xs);
173
- cursor: pointer;
174
- }
175
- /* Tabular figures so the count does not jitter as it climbs. Red only once the limit is actually
176
- passed: a counter that warns before anything is wrong trains people to ignore it. */
177
- .acct-count {
178
- color: var(--lp-muted);
179
- font-size: var(--lp-fs-3xs);
180
- font-variant-numeric: tabular-nums;
181
- }
182
- .acct-count.is-over {
183
- color: var(--lp-red-deep);
184
- font-weight: 600;
185
- }
186
- /* The send button takes the ink fill when it can send and the wash when it cannot, which is the
187
- assistant's own rule: the control says whether there is anything to send before it is pressed. */
188
- .acct-send {
189
- flex: 0 0 auto;
190
- margin-left: auto;
191
- width: 30px;
192
- height: 30px;
193
- display: inline-flex;
194
- align-items: center;
195
- justify-content: center;
196
- border: 0;
197
- border-radius: var(--lp-r-pill);
198
- background: var(--lp-ink);
199
- color: var(--lp-surface);
200
- font: inherit;
201
- cursor: pointer;
202
- }
203
- .acct-send:disabled {
204
- background: var(--lp-surface-2);
205
- color: var(--lp-muted);
206
- cursor: default;
207
- }
208
- .acct-send-icon {
209
- fill: none;
210
- stroke: currentColor;
211
- stroke-width: 1.7;
212
- stroke-linecap: round;
213
- stroke-linejoin: round;
214
- }
215
-
216
- /* ── the usage meter (R9) ─────────────────────────────────────────────────────────────────
217
- One containment layer, hairline, no shadow. The bar is the only new SHAPE on these pages, and
218
- the ticket asks for it by name ("over the allowance the bar is red"). */
219
- .acct-note--inline {
220
- margin: 12px 0 0;
221
- padding: 9px 12px;
222
- font-size: var(--lp-fs-2xs);
223
- }
224
- .acct-scope {
225
- display: inline-flex;
226
- gap: 2px;
227
- margin-bottom: 14px;
228
- padding: 2px;
229
- border: 1px solid var(--lp-line);
230
- border-radius: var(--lp-r-md);
231
- background: var(--lp-surface);
232
- }
233
- /* Selection is TINT plus WEIGHT with the ink left alone, which is `.shell-nav-item.is-active`'s
234
- rule. ⚠ NOT `--lp-blue-deep` on `--lp-blue-tint`: that pair measures 2.96:1, under the 4.5:1
235
- text bar and under the 3:1 graphical one, and this control holds words. */
236
- .acct-scope-btn {
237
- padding: 4px 12px;
238
- border: 0;
239
- border-radius: var(--lp-r-sm);
240
- background: transparent;
241
- color: var(--lp-muted);
242
- font: inherit;
243
- font-size: var(--lp-fs-2xs);
244
- font-weight: 600;
245
- cursor: pointer;
246
- }
247
- .acct-scope-btn:hover {
248
- background: var(--lp-surface-2);
249
- }
250
- .acct-scope-btn.is-on {
251
- background: var(--lp-blue-tint);
252
- color: var(--lp-ink);
253
- }
254
-
255
- .acct-meter {
256
- padding: 16px 18px;
257
- border: 1px solid var(--lp-line);
258
- border-radius: var(--lp-r-md);
259
- background: var(--lp-surface);
260
- }
261
- .acct-meter-head {
262
- display: flex;
263
- align-items: baseline;
264
- gap: 8px;
265
- flex-wrap: wrap;
266
- }
267
- .acct-meter-who {
268
- font-size: var(--lp-fs-2xs);
269
- font-weight: 600;
270
- color: var(--lp-muted);
271
- margin-bottom: 4px;
272
- }
273
- /* Tabular figures on every financial-grade number (DESIGN.md 2), so a total that climbs does not
274
- make the label beside it dance. Weight 650, never a display weight. */
275
- .acct-meter-value {
276
- font-size: var(--lp-fs-xl);
277
- font-weight: 650;
278
- color: var(--lp-ink);
279
- font-variant-numeric: tabular-nums;
280
- }
281
- /* Red = negative/over, the fixed semantic, on the deep companion. */
282
- .acct-meter-value.is-over {
283
- color: var(--lp-red-deep);
284
- }
285
- .acct-meter-of {
286
- font-size: var(--lp-fs-xs);
287
- color: var(--lp-muted);
288
- font-variant-numeric: tabular-nums;
289
- }
290
- .acct-meter-foot {
291
- display: flex;
292
- align-items: baseline;
293
- justify-content: space-between;
294
- gap: 12px;
295
- margin-top: 8px;
296
- font-size: var(--lp-fs-2xs);
297
- color: var(--lp-muted);
298
- }
299
- .acct-meter-calls {
300
- flex: 0 0 auto;
301
- font-variant-numeric: tabular-nums;
302
- }
303
-
304
- /* The track is the wash so an empty meter still reads as a meter rather than as a missing
305
- element; the fill is the brand at rest and red once the allowance is passed. */
306
- .acct-bar {
307
- display: block;
308
- width: 100%;
309
- height: 8px;
310
- margin-top: 10px;
311
- border-radius: var(--lp-r-pill);
312
- background: var(--lp-wash);
313
- overflow: hidden;
314
- }
315
- .acct-bar-fill {
316
- display: block;
317
- height: 100%;
318
- border-radius: var(--lp-r-pill);
319
- background: var(--lp-primary);
320
- transition: width 0.18s var(--lp-fold-e);
321
- }
322
- .acct-bar-fill.is-over {
323
- background: var(--lp-red-deep);
324
- }
325
-
326
- /* A short plain noun, muted, sentence case, matching `.home-section-title` exactly: the same role
327
- on a sibling surface takes the same rendered values (DESIGN.md 2). */
328
- .acct-section {
329
- margin: 22px 0 9px;
330
- font-size: var(--lp-fs-2xs);
331
- font-weight: 600;
332
- color: var(--lp-muted);
333
- }
334
- .acct-rows {
335
- display: flex;
336
- flex-direction: column;
337
- gap: 2px;
338
- }
339
- /* One row per surface: name, share bar, tokens, calls. Fixed columns so the four numbers form a
340
- column a reader can scan rather than four numbers at four different left edges. */
341
- .acct-row {
342
- display: grid;
343
- grid-template-columns: 120px 1fr 92px 150px;
344
- align-items: center;
345
- gap: 12px;
346
- padding: 8px 2px;
347
- border-bottom: 1px solid var(--lp-line);
348
- }
349
- .acct-row:last-child {
350
- border-bottom: 0;
351
- }
352
- .acct-row-name {
353
- font-size: var(--lp-fs-xs);
354
- font-weight: 600;
355
- color: var(--lp-ink);
356
- }
357
- /* Numbers right-aligned, text left (DESIGN.md 2), tabular so the column lines up digit for digit. */
358
- .acct-row-num {
359
- text-align: right;
360
- font-size: var(--lp-fs-xs);
361
- color: var(--lp-ink);
362
- font-variant-numeric: tabular-nums;
363
- }
364
- .acct-row-sub {
365
- font-size: var(--lp-fs-2xs);
366
- color: var(--lp-muted);
367
- font-variant-numeric: tabular-nums;
368
- }
369
- .acct-row .acct-bar {
370
- margin-top: 0;
371
- height: 6px;
372
- }
 
1
+ /* ─────────────────────────────────────────────────────────────────────────────────────────
2
+ account/account.css β€” WAVE 35, owner item 13: the three surfaces that hang off the
3
+ account button (Feedback, Usage, Subscription).
4
+
5
+ β›” ITS OWN STYLESHEET, NOT index.css. That file belongs to the INTEGRATOR for the whole
6
+ wave, and the wave's own rule is that a new surface brings its own sheet. Every selector
7
+ here is .acct-*; nothing in this file redefines a .home-*, .shell-* or .cg-* rule, so
8
+ nothing here can win or lose a source-order fight with the shared sheet.
9
+
10
+ ⚠ .acct-page RESTATES .shell-home's five box declarations rather than reusing that class,
11
+ and that is deliberate rather than lazy. .shell-home is Home's box by name and by its own
12
+ region header; borrowing it would make every future Home layout change silently a change
13
+ to three account pages. The VALUES are copied on purpose β€” DESIGN.md 2's sibling rule
14
+ ("a panel matches its sibling's rendered values, not merely the scale") is what makes the
15
+ account pages read as the same system as Home, and .shell-main is overflow:hidden, so a
16
+ page that does not own its own scroll box simply loses everything below the fold.
17
+
18
+ ⚠ EVERY font-size IS A --lp-fs-* TOKEN. verify_ui globs src/**.css (every stylesheet in
19
+ the tree, not just index.css) and goes RED on a literal β€” wave-21 R5.
20
+ ───────────────────────────────────────────────────────────────────────────────────────── */
21
+
22
+ .acct-page {
23
+ height: 100%;
24
+ overflow-y: auto;
25
+ padding: 34px 44px 56px;
26
+ box-sizing: border-box;
27
+ background: var(--lp-wash);
28
+ }
29
+
30
+ /* A short plain noun, same size and weight as .home-title, because it is the same role on a
31
+ sibling surface. DESIGN.md 2: headers are 1 to 3 words, never a sentence. */
32
+ .acct-title {
33
+ margin: 0 0 20px;
34
+ font-size: var(--lp-fs-lg);
35
+ font-weight: 650;
36
+ color: var(--lp-ink);
37
+ }
38
+
39
+ /* THE ONE COLUMN. Every account surface is a reading-width column, not a full-bleed page:
40
+ a composer, a meter list and a note are all narrow objects, and stretching them to a
41
+ 1600px window is how a page stops looking like it was designed. The reference the owner
42
+ gave for the assistant uses the same centred column for the same reason. */
43
+ .acct-col {
44
+ max-width: 640px;
45
+ }
46
+
47
+ /* ── the honest note (Subscription today; any boarded account surface tomorrow) ─────────
48
+ ONE containment layer: a hairline panel on the wash, no card inside a card, no shadow
49
+ (DESIGN.md 4). It is a panel rather than a bare paragraph so the emptiness reads as
50
+ deliberate instead of as a page that failed to load. */
51
+ .acct-note {
52
+ margin: 0;
53
+ padding: 16px 18px;
54
+ border: 1px solid var(--lp-line);
55
+ border-radius: var(--lp-r-md);
56
+ background: var(--lp-surface);
57
+ font-size: var(--lp-fs-xs);
58
+ line-height: var(--lp-lh);
59
+ color: var(--lp-muted);
60
+ }
61
+
62
+ /* ── loading, and the way back from a failed read ─────────────────────────────────────────── */
63
+ .acct-loading {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ min-height: 180px;
68
+ }
69
+ .acct-failed {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 12px;
73
+ }
74
+ /* DESIGN.md 4's quiet button, with the UA chrome reset explicitly (the wave-19 "mystery outer
75
+ lines and tinted fill" rule). */
76
+ .acct-retry {
77
+ flex: 0 0 auto;
78
+ padding: 6px 12px;
79
+ border: 1px solid var(--lp-line);
80
+ border-radius: var(--lp-r-md);
81
+ background: var(--lp-surface);
82
+ color: var(--lp-ink);
83
+ font: inherit;
84
+ font-size: var(--lp-fs-2xs);
85
+ font-weight: 600;
86
+ cursor: pointer;
87
+ }
88
+ .acct-retry:hover {
89
+ background: var(--lp-surface-2);
90
+ border-color: var(--lp-blue-deep);
91
+ }
92
+
93
+ /* ── the two answers a write can give ─────────────────────────────────────────────────────
94
+ Green = positive, red = negative: the fixed semantics (DESIGN.md 3), on the DEEP companions
95
+ because a pastel on white is not a light aesthetic, it is an unreadable one. */
96
+ .acct-ok,
97
+ .acct-error {
98
+ margin: 0 0 12px;
99
+ padding: 9px 12px;
100
+ border-radius: var(--lp-r-md);
101
+ font-size: var(--lp-fs-2xs);
102
+ line-height: var(--lp-lh);
103
+ }
104
+ .acct-ok {
105
+ border: 1px solid var(--lp-green);
106
+ background: var(--lp-green-tint);
107
+ color: var(--lp-green-deep);
108
+ }
109
+ .acct-error {
110
+ border: 1px solid var(--lp-red);
111
+ background: var(--lp-red-tint);
112
+ color: var(--lp-red-deep);
113
+ }
114
+
115
+ /* ── the composer ─────────────────────────────────────────────────────────────────────────
116
+ The ASSISTANT'S anatomy in this module's own rules: one rounded field that contains its own
117
+ control row, so the category and the send button read as part of the thing being written
118
+ rather than as chrome around it. `assistant.css` is another session's file this wave and is
119
+ deliberately not imported; the shape is borrowed, the rules are local.
120
+ ⚠ The radius is smaller than the assistant's 22px pill on purpose: this field is a paragraph
121
+ box, not a one-line prompt, and a heavily rounded tall box reads as a speech bubble. */
122
+ .acct-field {
123
+ border: 1px solid var(--lp-line);
124
+ border-radius: var(--lp-r-lg);
125
+ background: var(--lp-surface);
126
+ padding: 12px 10px 10px 14px;
127
+ }
128
+ .acct-field:focus-within {
129
+ border-color: var(--lp-blue-solid);
130
+ }
131
+ .acct-prompt {
132
+ display: block;
133
+ width: 100%;
134
+ border: 0;
135
+ outline: none;
136
+ padding: 0 6px 0 0;
137
+ background: transparent;
138
+ color: var(--lp-ink);
139
+ font: inherit;
140
+ font-size: var(--lp-fs-sm);
141
+ line-height: var(--lp-lh);
142
+ resize: vertical;
143
+ box-sizing: border-box;
144
+ }
145
+ .acct-prompt::placeholder {
146
+ color: var(--lp-muted);
147
+ }
148
+ .acct-field-foot {
149
+ display: flex;
150
+ align-items: center;
151
+ gap: 10px;
152
+ margin-top: 10px;
153
+ }
154
+ .acct-pick {
155
+ display: inline-flex;
156
+ align-items: center;
157
+ gap: 6px;
158
+ min-width: 0;
159
+ }
160
+ .acct-pick-label {
161
+ color: var(--lp-muted);
162
+ font-size: var(--lp-fs-2xs);
163
+ }
164
+ .acct-select {
165
+ max-width: 220px;
166
+ padding: 4px 8px;
167
+ border: 1px solid var(--lp-line);
168
+ border-radius: var(--lp-r-sm);
169
+ background: var(--lp-surface);
170
+ color: var(--lp-ink);
171
+ font: inherit;
172
+ font-size: var(--lp-fs-2xs);
173
+ cursor: pointer;
174
+ }
175
+ /* Tabular figures so the count does not jitter as it climbs. Red only once the limit is actually
176
+ passed: a counter that warns before anything is wrong trains people to ignore it. */
177
+ .acct-count {
178
+ color: var(--lp-muted);
179
+ font-size: var(--lp-fs-3xs);
180
+ font-variant-numeric: tabular-nums;
181
+ }
182
+ .acct-count.is-over {
183
+ color: var(--lp-red-deep);
184
+ font-weight: 600;
185
+ }
186
+ /* The send button takes the ink fill when it can send and the wash when it cannot, which is the
187
+ assistant's own rule: the control says whether there is anything to send before it is pressed. */
188
+ .acct-send {
189
+ flex: 0 0 auto;
190
+ margin-left: auto;
191
+ width: 30px;
192
+ height: 30px;
193
+ display: inline-flex;
194
+ align-items: center;
195
+ justify-content: center;
196
+ border: 0;
197
+ border-radius: var(--lp-r-pill);
198
+ background: var(--lp-ink);
199
+ color: var(--lp-surface);
200
+ font: inherit;
201
+ cursor: pointer;
202
+ }
203
+ .acct-send:disabled {
204
+ background: var(--lp-surface-2);
205
+ color: var(--lp-muted);
206
+ cursor: default;
207
+ }
208
+ .acct-send-icon {
209
+ fill: none;
210
+ stroke: currentColor;
211
+ stroke-width: 1.7;
212
+ stroke-linecap: round;
213
+ stroke-linejoin: round;
214
+ }
215
+
216
+ /* ── the usage meter (R9) ─────────────────────────────────────────────────────────────────
217
+ One containment layer, hairline, no shadow. The bar is the only new SHAPE on these pages, and
218
+ the ticket asks for it by name ("over the allowance the bar is red"). */
219
+ .acct-note--inline {
220
+ margin: 12px 0 0;
221
+ padding: 9px 12px;
222
+ font-size: var(--lp-fs-2xs);
223
+ }
224
+ .acct-scope {
225
+ display: inline-flex;
226
+ gap: 2px;
227
+ margin-bottom: 14px;
228
+ padding: 2px;
229
+ border: 1px solid var(--lp-line);
230
+ border-radius: var(--lp-r-md);
231
+ background: var(--lp-surface);
232
+ }
233
+ /* Selection is TINT plus WEIGHT with the ink left alone, which is `.shell-nav-item.is-active`'s
234
+ rule. ⚠ NOT `--lp-blue-deep` on `--lp-blue-tint`: that pair measures 2.96:1, under the 4.5:1
235
+ text bar and under the 3:1 graphical one, and this control holds words. */
236
+ .acct-scope-btn {
237
+ padding: 4px 12px;
238
+ border: 0;
239
+ border-radius: var(--lp-r-sm);
240
+ background: transparent;
241
+ color: var(--lp-muted);
242
+ font: inherit;
243
+ font-size: var(--lp-fs-2xs);
244
+ font-weight: 600;
245
+ cursor: pointer;
246
+ }
247
+ .acct-scope-btn:hover {
248
+ background: var(--lp-surface-2);
249
+ }
250
+ .acct-scope-btn.is-on {
251
+ background: var(--lp-blue-tint);
252
+ color: var(--lp-ink);
253
+ }
254
+
255
+ .acct-meter {
256
+ padding: 16px 18px;
257
+ border: 1px solid var(--lp-line);
258
+ border-radius: var(--lp-r-md);
259
+ background: var(--lp-surface);
260
+ }
261
+ .acct-meter-head {
262
+ display: flex;
263
+ align-items: baseline;
264
+ gap: 8px;
265
+ flex-wrap: wrap;
266
+ }
267
+ .acct-meter-who {
268
+ font-size: var(--lp-fs-2xs);
269
+ font-weight: 600;
270
+ color: var(--lp-muted);
271
+ margin-bottom: 4px;
272
+ }
273
+ /* Tabular figures on every financial-grade number (DESIGN.md 2), so a total that climbs does not
274
+ make the label beside it dance. Weight 650, never a display weight. */
275
+ .acct-meter-value {
276
+ font-size: var(--lp-fs-xl);
277
+ font-weight: 650;
278
+ color: var(--lp-ink);
279
+ font-variant-numeric: tabular-nums;
280
+ }
281
+ /* Red = negative/over, the fixed semantic, on the deep companion. */
282
+ .acct-meter-value.is-over {
283
+ color: var(--lp-red-deep);
284
+ }
285
+ .acct-meter-of {
286
+ font-size: var(--lp-fs-xs);
287
+ color: var(--lp-muted);
288
+ font-variant-numeric: tabular-nums;
289
+ }
290
+ .acct-meter-foot {
291
+ display: flex;
292
+ align-items: baseline;
293
+ justify-content: space-between;
294
+ gap: 12px;
295
+ margin-top: 8px;
296
+ font-size: var(--lp-fs-2xs);
297
+ color: var(--lp-muted);
298
+ }
299
+ .acct-meter-calls {
300
+ flex: 0 0 auto;
301
+ font-variant-numeric: tabular-nums;
302
+ }
303
+
304
+ /* The track is the wash so an empty meter still reads as a meter rather than as a missing
305
+ element; the fill is the brand at rest and red once the allowance is passed. */
306
+ .acct-bar {
307
+ display: block;
308
+ width: 100%;
309
+ height: 8px;
310
+ margin-top: 10px;
311
+ border-radius: var(--lp-r-pill);
312
+ background: var(--lp-wash);
313
+ overflow: hidden;
314
+ }
315
+ .acct-bar-fill {
316
+ display: block;
317
+ height: 100%;
318
+ border-radius: var(--lp-r-pill);
319
+ background: var(--lp-primary);
320
+ transition: width 0.18s var(--lp-fold-e);
321
+ }
322
+ .acct-bar-fill.is-over {
323
+ background: var(--lp-red-deep);
324
+ }
325
+
326
+ /* A short plain noun, muted, sentence case, matching `.home-section-title` exactly: the same role
327
+ on a sibling surface takes the same rendered values (DESIGN.md 2). */
328
+ .acct-section {
329
+ margin: 22px 0 9px;
330
+ font-size: var(--lp-fs-2xs);
331
+ font-weight: 600;
332
+ color: var(--lp-muted);
333
+ }
334
+ .acct-rows {
335
+ display: flex;
336
+ flex-direction: column;
337
+ gap: 2px;
338
+ }
339
+ /* One row per surface: name, share bar, tokens, calls. Fixed columns so the four numbers form a
340
+ column a reader can scan rather than four numbers at four different left edges. */
341
+ .acct-row {
342
+ display: grid;
343
+ grid-template-columns: 120px 1fr 92px 150px;
344
+ align-items: center;
345
+ gap: 12px;
346
+ padding: 8px 2px;
347
+ border-bottom: 1px solid var(--lp-line);
348
+ }
349
+ .acct-row:last-child {
350
+ border-bottom: 0;
351
+ }
352
+ .acct-row-name {
353
+ font-size: var(--lp-fs-xs);
354
+ font-weight: 600;
355
+ color: var(--lp-ink);
356
+ }
357
+ /* Numbers right-aligned, text left (DESIGN.md 2), tabular so the column lines up digit for digit. */
358
+ .acct-row-num {
359
+ text-align: right;
360
+ font-size: var(--lp-fs-xs);
361
+ color: var(--lp-ink);
362
+ font-variant-numeric: tabular-nums;
363
+ }
364
+ .acct-row-sub {
365
+ font-size: var(--lp-fs-2xs);
366
+ color: var(--lp-muted);
367
+ font-variant-numeric: tabular-nums;
368
+ }
369
+ .acct-row .acct-bar {
370
+ margin-top: 0;
371
+ height: 6px;
372
+ }
web/src/account/accountApi.ts CHANGED
@@ -1,188 +1,188 @@
1
- // ---------------------------------------------------------------------------
2
- // account/accountApi.ts β€” WAVE 35, owner item 13: the wire behind the two account
3
- // surfaces that have one (Feedback, contract C6/R8; Usage credits, C7/R9).
4
- //
5
- // β›” THE CATEGORY VOCABULARY IS THE SERVER'S AND IS FETCHED, NOT DECLARED HERE.
6
- // That is a decision, not an omission: a client constant beside a server enum is
7
- // two lists that drift, and the day they do the dropdown offers a value the door
8
- // refuses. Asked and settled in mailbox B-1 / E-9 β€” the server serves the list,
9
- // this file renders it, and there is no local fallback to drift TO.
10
- //
11
- // β›” AND THERE IS NO TENANT-SIDE READ OF A SUBMISSION AT ALL (R8). No "my
12
- // feedback" list, no edit, no delete: the operator plane is the only reader. The
13
- // 201 is the receipt. If that ever feels like a gap, it is R8, not a missing
14
- // endpoint.
15
- //
16
- // ⚠ NOTHING ON THE USAGE SIDE IS COMPUTED HERE. The allowance, the week and the
17
- // reset date all ride the payload (E-9), because a week boundary computed in the
18
- // browser can disagree with the server's own bucketing β€” and then the page shows
19
- // a number that traces to no ledger line, which is exactly what R9's meter must
20
- // never do.
21
- // ---------------------------------------------------------------------------
22
-
23
- import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, checkTenant, signal } from "../apiContract";
24
-
25
- /** The same discriminated result the other feature modules use. */
26
- export type Result<T> =
27
- | { ok: true; value: T }
28
- | { ok: false; status: number; message: string };
29
-
30
- async function call<T>(
31
- path: string,
32
- init: RequestInit,
33
- read: (body: unknown) => T
34
- ): Promise<Result<T>> {
35
- let res: Response;
36
- try {
37
- res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
38
- } catch {
39
- return { ok: false, status: 0, message: "Cannot reach the server." };
40
- }
41
- if (checkTenant(res)) return { ok: false, status: 0, message: "Reloading." };
42
- if (res.status === 401) signal(UNAUTHORIZED_EVENT);
43
- const body = (await res.json().catch(() => null)) as unknown;
44
- if (!res.ok) {
45
- // ⚠ A 4xx MESSAGE IS POLICY AND IS SHOWN; a 5xx message is an internal detail and is not.
46
- // `session.ts` states the same rule for sign-in, and E names each 400 case ("unknown
47
- // category", "empty text", "over maxChars"), so the server's sentence is the useful one.
48
- const detail = (body as { error?: { message?: string } } | null)?.error?.message;
49
- return {
50
- ok: false,
51
- status: res.status,
52
- message:
53
- res.status >= 500 || !detail
54
- ? res.status >= 500
55
- ? "Something went wrong on our side. Try again in a moment."
56
- : `The server answered ${res.status}.`
57
- : detail,
58
- };
59
- }
60
- return { ok: true, value: read(body) };
61
- }
62
-
63
- const row = (v: unknown): Record<string, unknown> =>
64
- v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
65
- const str = (v: unknown): string => (typeof v === "string" ? v : "");
66
- const int = (v: unknown): number =>
67
- typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
68
-
69
- // ── FEEDBACK (R8, C6) ───────────────────────────────────────────────────────────────────────
70
-
71
- export interface FeedbackCategory {
72
- key: string;
73
- label: string;
74
- }
75
-
76
- export interface FeedbackForm {
77
- categories: FeedbackCategory[];
78
- maxChars: number;
79
- }
80
-
81
- /**
82
- * `GET /api/v1/feedback/form` β€” the category list and the length limit, both the server's.
83
- *
84
- * β›” A CATEGORY WITH NO LABEL IS DROPPED, never rendered as its key: an internal identifier in a
85
- * dropdown is the same defect `parsePages` refuses one layer up ("a nav row with no name is a
86
- * door with no sign on it"). And an EMPTY list is a real answer the caller must handle, not a
87
- * reason to invent one.
88
- */
89
- export function parseFeedbackForm(body: unknown): FeedbackForm {
90
- const r = row(body);
91
- const categories = (Array.isArray(r.categories) ? r.categories : [])
92
- .map(row)
93
- .filter((c) => str(c.key) !== "" && str(c.label) !== "")
94
- .map((c) => ({ key: str(c.key), label: str(c.label) }));
95
- return { categories, maxChars: int(r.maxChars) };
96
- }
97
-
98
- export function loadFeedbackForm(): Promise<Result<FeedbackForm>> {
99
- return call("/feedback/form", {}, parseFeedbackForm);
100
- }
101
-
102
- export function sendFeedback(category: string, text: string): Promise<Result<{ id: string }>> {
103
- return call(
104
- "/feedback",
105
- {
106
- method: "POST",
107
- headers: { "Content-Type": "application/json" },
108
- body: JSON.stringify({ category, text }),
109
- },
110
- (b) => ({ id: str(row(b).id) })
111
- );
112
- }
113
-
114
- // ── USAGE (R9, C7) ──────────────────────────────────────────────────────────────────────────
115
-
116
- /** One AI surface's week. Every surface is present at zero when unused (E-9), so nothing here
117
- * branches on a missing row. */
118
- export interface UsageSurface {
119
- surface: string;
120
- calls: number;
121
- tokens: number;
122
- tokensIn: number;
123
- tokensOut: number;
124
- /**
125
- * Calls whose provider declined to report a token count.
126
- *
127
- * β›” NOT ZERO-PADDING. These are real calls with an UNKNOWN cost, which is why the total beside
128
- * them is a FLOOR rather than a figure. A page that hides this presents the floor as complete,
129
- * and that is the cost-surprise failure one step removed.
130
- */
131
- unmeasured: number;
132
- }
133
-
134
- export interface Usage {
135
- /** ISO year-week, UTC. A string that sorts chronologically, so nothing here parses a date. */
136
- week: string;
137
- /** The UTC date the counters roll over, as the server states it. */
138
- resets: string;
139
- allowance: number;
140
- total: number;
141
- over: boolean;
142
- calls: number;
143
- unmeasured: number;
144
- /** `"me"` or `"tenant"`. Which question the numbers answer, decided by the server. */
145
- scope: string;
146
- surfaces: UsageSurface[];
147
- /** The server's own sentence about `unmeasured`. Empty when there is nothing unmeasured. */
148
- note: string;
149
- /**
150
- * Admin only: AI calls this container could not attribute to an account.
151
- *
152
- * ⚠ LABEL IT A WIRING GAP, NOT USAGE (E-9). It is the meter reporting on ITSELF, and folding it
153
- * into somebody's total would be an invented attribution.
154
- */
155
- unattributed: number;
156
- }
157
-
158
- export function parseUsage(body: unknown): Usage {
159
- const r = row(body);
160
- const surfaces = (Array.isArray(r.surfaces) ? r.surfaces : [])
161
- .map(row)
162
- .filter((s) => str(s.surface) !== "")
163
- .map((s) => ({
164
- surface: str(s.surface),
165
- calls: int(s.calls),
166
- tokens: int(s.tokens),
167
- tokensIn: int(s.tokens_in),
168
- tokensOut: int(s.tokens_out),
169
- unmeasured: int(s.unmeasured),
170
- }));
171
- return {
172
- week: str(r.week),
173
- resets: str(r.resets),
174
- allowance: int(r.allowance),
175
- total: int(r.total),
176
- over: r.over === true,
177
- calls: int(r.calls),
178
- unmeasured: int(r.unmeasured),
179
- scope: str(r.scope) || "me",
180
- surfaces,
181
- note: str(r.note),
182
- unattributed: int(r.unattributed),
183
- };
184
- }
185
-
186
- export function loadUsage(scope?: "tenant"): Promise<Result<Usage>> {
187
- return call(scope === "tenant" ? "/usage?scope=tenant" : "/usage", {}, parseUsage);
188
- }
 
1
+ // ---------------------------------------------------------------------------
2
+ // account/accountApi.ts β€” WAVE 35, owner item 13: the wire behind the two account
3
+ // surfaces that have one (Feedback, contract C6/R8; Usage, C7/R9).
4
+ //
5
+ // β›” THE CATEGORY VOCABULARY IS THE SERVER'S AND IS FETCHED, NOT DECLARED HERE.
6
+ // That is a decision, not an omission: a client constant beside a server enum is
7
+ // two lists that drift, and the day they do the dropdown offers a value the door
8
+ // refuses. Asked and settled in mailbox B-1 / E-9 β€” the server serves the list,
9
+ // this file renders it, and there is no local fallback to drift TO.
10
+ //
11
+ // β›” AND THERE IS NO TENANT-SIDE READ OF A SUBMISSION AT ALL (R8). No "my
12
+ // feedback" list, no edit, no delete: the operator plane is the only reader. The
13
+ // 201 is the receipt. If that ever feels like a gap, it is R8, not a missing
14
+ // endpoint.
15
+ //
16
+ // ⚠ NOTHING ON THE USAGE SIDE IS COMPUTED HERE. The allowance, the week and the
17
+ // reset date all ride the payload (E-9), because a week boundary computed in the
18
+ // browser can disagree with the server's own bucketing β€” and then the page shows
19
+ // a number that traces to no ledger line, which is exactly what R9's meter must
20
+ // never do.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, checkTenant, signal } from "../apiContract";
24
+
25
+ /** The same discriminated result the other feature modules use. */
26
+ export type Result<T> =
27
+ | { ok: true; value: T }
28
+ | { ok: false; status: number; message: string };
29
+
30
+ async function call<T>(
31
+ path: string,
32
+ init: RequestInit,
33
+ read: (body: unknown) => T
34
+ ): Promise<Result<T>> {
35
+ let res: Response;
36
+ try {
37
+ res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
38
+ } catch {
39
+ return { ok: false, status: 0, message: "Cannot reach the server." };
40
+ }
41
+ if (checkTenant(res)) return { ok: false, status: 0, message: "Reloading." };
42
+ if (res.status === 401) signal(UNAUTHORIZED_EVENT);
43
+ const body = (await res.json().catch(() => null)) as unknown;
44
+ if (!res.ok) {
45
+ // ⚠ A 4xx MESSAGE IS POLICY AND IS SHOWN; a 5xx message is an internal detail and is not.
46
+ // `session.ts` states the same rule for sign-in, and E names each 400 case ("unknown
47
+ // category", "empty text", "over maxChars"), so the server's sentence is the useful one.
48
+ const detail = (body as { error?: { message?: string } } | null)?.error?.message;
49
+ return {
50
+ ok: false,
51
+ status: res.status,
52
+ message:
53
+ res.status >= 500 || !detail
54
+ ? res.status >= 500
55
+ ? "Something went wrong on our side. Try again in a moment."
56
+ : `The server answered ${res.status}.`
57
+ : detail,
58
+ };
59
+ }
60
+ return { ok: true, value: read(body) };
61
+ }
62
+
63
+ const row = (v: unknown): Record<string, unknown> =>
64
+ v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
65
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
66
+ const int = (v: unknown): number =>
67
+ typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
68
+
69
+ // ── FEEDBACK (R8, C6) ───────────────────────────────────────────────────────────────────────
70
+
71
+ export interface FeedbackCategory {
72
+ key: string;
73
+ label: string;
74
+ }
75
+
76
+ export interface FeedbackForm {
77
+ categories: FeedbackCategory[];
78
+ maxChars: number;
79
+ }
80
+
81
+ /**
82
+ * `GET /api/v1/feedback/form` β€” the category list and the length limit, both the server's.
83
+ *
84
+ * β›” A CATEGORY WITH NO LABEL IS DROPPED, never rendered as its key: an internal identifier in a
85
+ * dropdown is the same defect `parsePages` refuses one layer up ("a nav row with no name is a
86
+ * door with no sign on it"). And an EMPTY list is a real answer the caller must handle, not a
87
+ * reason to invent one.
88
+ */
89
+ export function parseFeedbackForm(body: unknown): FeedbackForm {
90
+ const r = row(body);
91
+ const categories = (Array.isArray(r.categories) ? r.categories : [])
92
+ .map(row)
93
+ .filter((c) => str(c.key) !== "" && str(c.label) !== "")
94
+ .map((c) => ({ key: str(c.key), label: str(c.label) }));
95
+ return { categories, maxChars: int(r.maxChars) };
96
+ }
97
+
98
+ export function loadFeedbackForm(): Promise<Result<FeedbackForm>> {
99
+ return call("/feedback/form", {}, parseFeedbackForm);
100
+ }
101
+
102
+ export function sendFeedback(category: string, text: string): Promise<Result<{ id: string }>> {
103
+ return call(
104
+ "/feedback",
105
+ {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({ category, text }),
109
+ },
110
+ (b) => ({ id: str(row(b).id) })
111
+ );
112
+ }
113
+
114
+ // ── USAGE (R9, C7) ──────────────────────────────────────────────────────────────────────────
115
+
116
+ /** One AI surface's week. Every surface is present at zero when unused (E-9), so nothing here
117
+ * branches on a missing row. */
118
+ export interface UsageSurface {
119
+ surface: string;
120
+ calls: number;
121
+ tokens: number;
122
+ tokensIn: number;
123
+ tokensOut: number;
124
+ /**
125
+ * Calls whose provider declined to report a token count.
126
+ *
127
+ * β›” NOT ZERO-PADDING. These are real calls with an UNKNOWN cost, which is why the total beside
128
+ * them is a FLOOR rather than a figure. A page that hides this presents the floor as complete,
129
+ * and that is the cost-surprise failure one step removed.
130
+ */
131
+ unmeasured: number;
132
+ }
133
+
134
+ export interface Usage {
135
+ /** ISO year-week, UTC. A string that sorts chronologically, so nothing here parses a date. */
136
+ week: string;
137
+ /** The UTC date the counters roll over, as the server states it. */
138
+ resets: string;
139
+ allowance: number;
140
+ total: number;
141
+ over: boolean;
142
+ calls: number;
143
+ unmeasured: number;
144
+ /** `"me"` or `"tenant"`. Which question the numbers answer, decided by the server. */
145
+ scope: string;
146
+ surfaces: UsageSurface[];
147
+ /** The server's own sentence about `unmeasured`. Empty when there is nothing unmeasured. */
148
+ note: string;
149
+ /**
150
+ * Admin only: AI calls this container could not attribute to an account.
151
+ *
152
+ * ⚠ LABEL IT A WIRING GAP, NOT USAGE (E-9). It is the meter reporting on ITSELF, and folding it
153
+ * into somebody's total would be an invented attribution.
154
+ */
155
+ unattributed: number;
156
+ }
157
+
158
+ export function parseUsage(body: unknown): Usage {
159
+ const r = row(body);
160
+ const surfaces = (Array.isArray(r.surfaces) ? r.surfaces : [])
161
+ .map(row)
162
+ .filter((s) => str(s.surface) !== "")
163
+ .map((s) => ({
164
+ surface: str(s.surface),
165
+ calls: int(s.calls),
166
+ tokens: int(s.tokens),
167
+ tokensIn: int(s.tokens_in),
168
+ tokensOut: int(s.tokens_out),
169
+ unmeasured: int(s.unmeasured),
170
+ }));
171
+ return {
172
+ week: str(r.week),
173
+ resets: str(r.resets),
174
+ allowance: int(r.allowance),
175
+ total: int(r.total),
176
+ over: r.over === true,
177
+ calls: int(r.calls),
178
+ unmeasured: int(r.unmeasured),
179
+ scope: str(r.scope) || "me",
180
+ surfaces,
181
+ note: str(r.note),
182
+ unattributed: int(r.unattributed),
183
+ };
184
+ }
185
+
186
+ export function loadUsage(scope?: "tenant"): Promise<Result<Usage>> {
187
+ return call(scope === "tenant" ? "/usage?scope=tenant" : "/usage", {}, parseUsage);
188
+ }
web/src/customer-grid/CatalogView.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/ColumnMenu.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/CustomerGrid.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/GridChat.tsx ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / GridChat.tsx β€” the docked chat for ANY database view (W37-T40, R2, R10, C6).
3
+ //
4
+ // Owner items 10 and 11 are ONE feature (ruling R2): the chat IS the builder. This is its shell β€”
5
+ // the conversation surface every later ticket in the lane hangs a power off. T42 gives it the
6
+ // schema to answer from, T43 the view controls, T44 the Custom View's code, T45 the confirmed
7
+ // record edit.
8
+ //
9
+ // β›”β›” IT REACHES INTO NOTHING. Contract C6: every action is a callback the host supplies, and
10
+ // EVERY PROP IS REQUIRED. An optional prop is how a panel ships mounted-but-inert β€” the feature is
11
+ // whole, the gate is green, and the one line that would have passed the callback was never
12
+ // written. This repo shipped five of those in a single wave. A required prop makes the omission a
13
+ // compile error in the mounting file, which is where a person can see it.
14
+ //
15
+ // ⚠ THE SHAPE IS THE ONE THIS PRODUCT ALREADY HAS, not a third chat look. `AutomationChat` and
16
+ // `AssistantPage` both render a centred opening while the transcript is empty, then a log with the
17
+ // SAME composer moved to the foot; `FieldAgentChat` reuses the same composer classes rather than
18
+ // drawing a second one. Copying that is not laziness, it is the reason the owner cannot tell which
19
+ // surface was built by whom.
20
+ //
21
+ // ⚠ NO `import.meta.env` ANYWHERE IN THIS FILE'S GRAPH, and the constraint is load-bearing rather
22
+ // than stylistic: the gate renders this component from a CommonJS build, and `import.meta` cannot
23
+ // be required from one. `AutomationChat`'s own header records that exact crash. The imports below
24
+ // are `react` + `./gridChat` + the stylesheet, and nothing else.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ import { useCallback, useEffect, useRef, useState } from "react";
28
+
29
+ import {
30
+ answered,
31
+ asked,
32
+ composerHint,
33
+ editSummary,
34
+ emptyChat,
35
+ failed,
36
+ isBusy,
37
+ noted,
38
+ proposedEdits,
39
+ readIntent,
40
+ runSentence,
41
+ wrote,
42
+ } from "./gridChat";
43
+ import type {
44
+ GridChatAnswer,
45
+ GridChatEdit,
46
+ GridChatField,
47
+ GridChatRunResult,
48
+ GridChatState,
49
+ GridChatTurn,
50
+ } from "./gridChat";
51
+ import "./gridChat.css";
52
+
53
+ // ⭐ RE-EXPORTED SO THE MOUNTING FILE TAKES ONE IMPORT. The types live in `gridChat.ts` (they have
54
+ // to: the state machine is typed by them and that module must stay React-free), but a host that
55
+ // had to import the component from here and its types from there would be one refactor away from
56
+ // the two drifting. Published to C in mailbox E-4 as a single import line.
57
+ export type {
58
+ GridChatAnswer,
59
+ GridChatEdit,
60
+ GridChatField,
61
+ GridChatRunResult,
62
+ GridChatState,
63
+ GridChatTurn,
64
+ };
65
+
66
+ /**
67
+ * What the host asks the chat to do about the view. ⚠ DELIBERATELY NARROW: this is the vocabulary
68
+ * `assistant build_view` already speaks, minus what it cannot express. There is no `rhs` in that
69
+ * tool schema, so a field-versus-field condition is not sayable
70
+ * [[assistant-build-view-is-a-subset]] β€” W37-T43 has to SAY it cannot rather than fail quietly.
71
+ */
72
+ export interface GridChatViewSpec {
73
+ /**
74
+ * ⚠ RESERVED AND DELIBERATELY NOT POPULATED BY `readIntent` YET. The panel understands
75
+ * sort/group/colour from a sentence; a real FILTER needs the condition vocabulary the host owns,
76
+ * so this is the slot C fills rather than a field the panel quietly leaves empty. Named here so
77
+ * it reads as a contract with a known gap instead of a channel nobody noticed was dead
78
+ * [[flag-shipped-without-its-writer]].
79
+ */
80
+ filter?: unknown;
81
+ sortBy?: string;
82
+ groupBy?: string;
83
+ colorBy?: string;
84
+ }
85
+
86
+ export interface GridChatProps {
87
+ /** The host owns the toggle. The panel never closes itself except through `onClose`. */
88
+ open: boolean;
89
+ onClose: () => void;
90
+ /** What this chat is reading, in the person's words. Appears in the header and the placeholder. */
91
+ databaseLabel: string;
92
+ /** The columns the chat may reason about. The host decides; the chat never discovers. */
93
+ schema: GridChatField[];
94
+ rowCount: number;
95
+ /**
96
+ * β›” R10's BOUND, EXPRESSED AS AN AFFORDANCE. `false` means the edit power is not OFFERED, not
97
+ * that it is offered and then refused. The chat is not a privilege escalation: a caller whose
98
+ * own permissions would refuse the write must not be invited to compose one
99
+ * [[permitted-is-not-answerable]].
100
+ */
101
+ canEdit: boolean;
102
+ /** (a) answer a question about this database. */
103
+ onDescribe: (question: string) => Promise<GridChatAnswer>;
104
+ /** (b) change filter, sort, group or colour on the ACTIVE view. */
105
+ onApplyView: (spec: GridChatViewSpec) => void;
106
+ /** (b, undo half) put the view back the way it was. */
107
+ onUndoView: () => void;
108
+ /** (d) write the cells the person just confirmed. β›” Reached only from the confirm control. */
109
+ onWriteCells: (edits: GridChatEdit[]) => Promise<void>;
110
+ /** (c) run the Custom View's code and report what happened. */
111
+ onRunScript: (code: string) => Promise<GridChatRunResult>;
112
+ }
113
+
114
+ /** The send mark. β›” The Assistant's own path, point for point: a second arrow drawn slightly
115
+ * differently is precisely how two surfaces stop looking like one product. */
116
+ function SendMark() {
117
+ return (
118
+ <svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none">
119
+ <path d="M8 13V3.6M4 7.4 8 3.4l4 4" stroke="currentColor" strokeWidth="1.5"
120
+ strokeLinecap="round" strokeLinejoin="round" />
121
+ </svg>
122
+ );
123
+ }
124
+
125
+ export default function GridChat(props: GridChatProps) {
126
+ const {
127
+ open, onClose, databaseLabel, schema, rowCount, canEdit,
128
+ onDescribe, onApplyView, onUndoView, onWriteCells, onRunScript,
129
+ } = props;
130
+
131
+ const [state, setState] = useState<GridChatState>(emptyChat);
132
+ const [draft, setDraft] = useState("");
133
+ const [writing, setWriting] = useState(false);
134
+ const logRef = useRef<HTMLDivElement | null>(null);
135
+ const busy = isBusy(state);
136
+
137
+ // ⚠ THE UNUSED-POWER GUARD. `onApplyView`, `onUndoView` and `onRunScript` are wired by W37-T43
138
+ // and W37-T44; naming them here keeps them REQUIRED props from the first mount, so C's wiring
139
+ // lands with the shell rather than being remembered two tickets later. `void` is the honest
140
+ // spelling of "held, not yet called" and it survives `noUnusedLocals`.
141
+ // ⚠ WHERE EACH CALLBACK IS ACTUALLY REACHED, because saying so is the only thing that stops a
142
+ // required prop becoming a decorative one: `onApplyView` from `send` (T43), `onUndoView` from the
143
+ // Undo control in the foot row, `onRunScript` from `runScript` below, which `send` calls when the
144
+ // host returns generated code (T44), `onWriteCells` from the confirm and the undo (T45, R10).
145
+
146
+ // The transcript follows the newest turn. Guarded on the ref because the log is not rendered at
147
+ // all while the conversation is empty (see the opening below).
148
+ useEffect(() => {
149
+ const log = logRef.current;
150
+ if (log) log.scrollTop = log.scrollHeight;
151
+ }, [state.turns.length, busy]);
152
+
153
+ // β›” DECLARED BEFORE `send`, AND THE ORDER IS LOAD-BEARING RATHER THAN TIDY. `send`'s
154
+ // dependency array names `runScript`, and a dependency array is evaluated AT RENDER, not
155
+ // when the callback fires. With `runScript` declared after `send` that array reads a `const`
156
+ // in its temporal dead zone and the panel throws `Cannot access 'runScript' before
157
+ // initialization` on its FIRST paint. ⚠ `tsc` does not catch it: it is a runtime TDZ, and
158
+ // the build was green with the crash in it.
159
+ /**
160
+ * ⭐ W37-T44 β€” THE CHAT WRITES THE CUSTOM VIEW'S CODE AND RUNS IT.
161
+ * β›” THE SANDBOX IS NOT WIDENED TO MAKE A GENERATED SCRIPT WORK. `onRunScript` is the host's
162
+ * ordinary run door, the same one a hand-typed script goes through, so a script the chat wrote
163
+ * has exactly the powers a person's own script has and no more. A failure is REPORTED and the
164
+ * code is left in the editor to be fixed by hand, never blanked.
165
+ */
166
+ const runScript = useCallback((code: string) => {
167
+ void (async () => {
168
+ try {
169
+ const result = await onRunScript(code);
170
+ setState((s) => noted(s, runSentence(result)));
171
+ } catch (err) {
172
+ const why = err instanceof Error && err.message ? err.message : "";
173
+ setState((s) => noted(s, runSentence({ ok: false, error: why })));
174
+ }
175
+ })();
176
+ }, [onRunScript]);
177
+
178
+ const send = useCallback(() => {
179
+ const said = draft.trim();
180
+ if (!said || busy) return;
181
+ setState((s) => asked(s, said));
182
+ setDraft("");
183
+
184
+ // ⭐⭐ W37-T42/T43/T44 β€” READ THE SENTENCE BEFORE SPENDING ANYTHING ON IT.
185
+ // β›” THE REFUSAL BRANCH IS THE POINT OF T42 AND IT COSTS NOTHING. D-339's defect is an agent
186
+ // that cannot read the database answering from words alone and inventing a number. The panel
187
+ // HOLDS the schema, so a question naming a field this database does not have is answered here,
188
+ // truthfully, without a model call that could only guess.
189
+ const intent = readIntent(schema, databaseLabel, said);
190
+ if (intent.refusal) {
191
+ setState((s) => answered(s, said, { text: intent.refusal!, unanswerable: true }));
192
+ return;
193
+ }
194
+ // ⚠ ...and a question the panel already knows the answer to is not a paid round trip either.
195
+ if (intent.localAnswer) {
196
+ setState((s) => answered(s, said, { text: intent.localAnswer! }));
197
+ return;
198
+ }
199
+ if (intent.kind === "view") {
200
+ // T43: the VIEW changes through the host's callback (C6), and what could NOT be expressed is
201
+ // named rather than dropped [[assistant-build-view-is-a-subset]].
202
+ if (intent.view) onApplyView(intent.view);
203
+ const applied = intent.view
204
+ ? "The view has been changed. Ask again or press Undo to put it back."
205
+ : "I could not tell which field you meant, so the view is unchanged.";
206
+ setState((s) => answered(s, said, {
207
+ text: intent.cannot ? `${applied} ${intent.cannot}` : applied,
208
+ }));
209
+ return;
210
+ }
211
+
212
+ void (async () => {
213
+ try {
214
+ const answer = await onDescribe(said);
215
+ // β›” THE PROPOSAL BRANCH IS HERE AND NOT IN THE HOST. A host that returned edits and also
216
+ // wrote them would make the confirm cosmetic; the panel decides that edits mean "show the
217
+ // person first", so there is exactly one place that decision can be got wrong.
218
+ // ⚠ NO CAST. `GridChatAnswer.edits` is a declared part of the contract now, so the host
219
+ // reads the same channel this line does; it used to be an `as` and the two could drift.
220
+ const edits = answer?.edits;
221
+ setState((s) => (canEdit && Array.isArray(edits) && edits.length
222
+ ? proposedEdits(s, said, answer.text, edits)
223
+ : answered(s, said, answer)));
224
+ // β›” W37-T44 β€” AND THIS BRANCH IS THE WHOLE POINT OF THE TICKET. The first draft classified
225
+ // a build request correctly and then had nothing consume the classification: `runScript`
226
+ // was defined and `void`ed, so `onRunScript` was unreachable from every path while the
227
+ // ticket claimed it routed. That is [[reachable-is-not-the-same-as-built]], and `void` is
228
+ // what hid it from tsc. The host authors the code; the panel runs it and reports.
229
+ if (typeof answer?.script === "string" && answer.script.trim())
230
+ runScript(answer.script);
231
+ } catch (err) {
232
+ const sentence = err instanceof Error ? err.message : "";
233
+ setState((s) => failed(s, said, sentence));
234
+ }
235
+ })();
236
+ }, [draft, busy, canEdit, onDescribe, onApplyView, runScript, schema, databaseLabel]);
237
+
238
+ const confirmWrite = useCallback((edits: GridChatEdit[]) => {
239
+ if (writing) return;
240
+ setWriting(true);
241
+ void (async () => {
242
+ try {
243
+ await onWriteCells(edits);
244
+ // β›” `wrote` rather than `noted`: the note it appends CARRIES THE INVERSE, built from the
245
+ // `before` values the person confirmed. An undo assembled after the write would have
246
+ // nothing left to read [[capture-the-inverse-before-the-write]].
247
+ setState((s) => wrote(s, edits));
248
+ } catch (err) {
249
+ const sentence = err instanceof Error && err.message
250
+ ? err.message
251
+ : "That write was refused, so nothing changed.";
252
+ setState((s) => noted(s, sentence));
253
+ } finally {
254
+ setWriting(false);
255
+ }
256
+ })();
257
+ }, [onWriteCells, writing]);
258
+
259
+ if (!open) return null;
260
+ return (
261
+ <GridChatView
262
+ state={state}
263
+ draft={draft}
264
+ writing={writing}
265
+ databaseLabel={databaseLabel}
266
+ schema={schema}
267
+ rowCount={rowCount}
268
+ canEdit={canEdit}
269
+ logRef={logRef}
270
+ onClose={onClose}
271
+ onDraft={setDraft}
272
+ onSend={send}
273
+ onUndoView={onUndoView}
274
+ onConfirmWrite={confirmWrite}
275
+ onDeclineWrite={() => setState((s) => noted(s, "Nothing was written."))}
276
+ />
277
+ );
278
+ }
279
+
280
+ /**
281
+ * ⭐⭐ THE PRESENTATIONAL HALF, AND IT IS SPLIT OUT FOR A REASON A GATE CAN USE.
282
+ *
283
+ * `<GridChat>` owns the state; this owns the pixels and takes the state as a prop. That split is
284
+ * what makes T40's `done-when` observable at all: the only renderer available in this repo is
285
+ * `react-dom/server`, which fires no events and runs no effects, so a gate can never DRIVE a
286
+ * stateful component into its in-flight or error state. It can render THIS one into any state it
287
+ * likes, because the state is an argument.
288
+ *
289
+ * β›” THE HOST NEVER IMPORTS THIS. `GridChat` is the default export and the contract C6 describes;
290
+ * every prop on it is required, and nothing here weakens that. Splitting the render out is the
291
+ * opposite of an optional prop: it adds a seam for the gate without adding a way for a mount to
292
+ * be silently inert.
293
+ */
294
+ export function GridChatView(props: {
295
+ state: GridChatState;
296
+ draft: string;
297
+ writing: boolean;
298
+ databaseLabel: string;
299
+ schema: GridChatField[];
300
+ rowCount: number;
301
+ canEdit: boolean;
302
+ // ⚠ Typed exactly as `useRef<HTMLDivElement | null>(null)` produces it, and OPTIONAL only
303
+ // here: a server render has no DOM to scroll, so the gate omits it. The stateful shell
304
+ // above always passes one.
305
+ logRef?: React.Ref<HTMLDivElement>;
306
+ onClose: () => void;
307
+ onDraft: (text: string) => void;
308
+ onSend: () => void;
309
+ onUndoView: () => void;
310
+ onConfirmWrite: (edits: GridChatEdit[]) => void;
311
+ onDeclineWrite: () => void;
312
+ }) {
313
+ const {
314
+ state, draft, writing, databaseLabel, schema, rowCount, canEdit, logRef,
315
+ onClose, onDraft, onSend, onUndoView, onConfirmWrite, onDeclineWrite,
316
+ } = props;
317
+ const busy = isBusy(state);
318
+ const hint = composerHint(databaseLabel, canEdit);
319
+
320
+ const composer = (
321
+ <div className="cg-chat-composer">
322
+ <textarea
323
+ className="cg-chat-input"
324
+ value={draft}
325
+ rows={2}
326
+ placeholder={hint}
327
+ aria-label={hint}
328
+ disabled={busy}
329
+ onChange={(e) => onDraft(e.target.value)}
330
+ onKeyDown={(e) => {
331
+ if (e.key === "Enter" && !e.shiftKey) {
332
+ e.preventDefault();
333
+ onSend();
334
+ }
335
+ }}
336
+ />
337
+ <div className="cg-chat-composer-foot">
338
+ {/* ⚠ THE SCOPE LINE, NOT A DISCLAIMER. It says what the chat is reading, which is the one
339
+ thing a person cannot tell by looking at a docked panel. */}
340
+ <span className="cg-chat-scope">
341
+ {rowCount === 1 ? "1 record" : `${rowCount} records`}
342
+ {schema.length ? `, ${schema.length === 1 ? "1 field" : `${schema.length} fields`}` : ""}
343
+ </span>
344
+ <span className="cg-chat-spacer" />
345
+ {/* ⭐ W37-T43's "and the change is undoable", as a control rather than a promise. It sits
346
+ beside Send because that is where the person is after asking for a view change, and it
347
+ calls the host's own undo so there is one place a view can be put back. */}
348
+ <button
349
+ type="button"
350
+ className="cg-chat-ghost"
351
+ onClick={onUndoView}
352
+ title="Put the view back the way it was"
353
+ >
354
+ Undo view
355
+ </button>
356
+ <button
357
+ type="button"
358
+ className="cg-chat-send"
359
+ onClick={onSend}
360
+ disabled={busy || draft.trim() === ""}
361
+ aria-label="Send"
362
+ title="Send"
363
+ >
364
+ <SendMark />
365
+ </button>
366
+ </div>
367
+ </div>
368
+ );
369
+
370
+ return (
371
+ <aside className="cg-chat" aria-label={`Chat about ${databaseLabel}`}>
372
+ <header className="cg-chat-head">
373
+ <span className="cg-chat-title">{databaseLabel}</span>
374
+ <button
375
+ type="button"
376
+ className="cg-chat-close"
377
+ onClick={onClose}
378
+ aria-label="Close the chat"
379
+ title="Close the chat"
380
+ >
381
+ <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="none">
382
+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
383
+ strokeLinecap="round" />
384
+ </svg>
385
+ </button>
386
+ </header>
387
+
388
+ {/* β›” THE LOG IS NOT RENDERED AT ALL WHEN EMPTY, and `hidden` would be the bug. `.cg-chat-log`
389
+ sets `display: flex`, and an author rule beats the user-agent `[hidden] { display: none }`
390
+ whatever its specificity. `hidden` would take the log out of the accessibility tree and
391
+ leave it PAINTED: an empty flex band shoving the centred opening off centre. That exact
392
+ defect was found and written down one wave ago in `AutomationChat`, and it is invisible to
393
+ any assertion that reads markup as text [[ui-invisible-to-assertions]]. */}
394
+ {state.turns.length ? (
395
+ <div className="cg-chat-log" ref={logRef}>
396
+ {state.turns.map((turn, i) => (
397
+ <div className="cg-chat-turn" key={i}>
398
+ <div
399
+ className={
400
+ "cg-chat-msg " +
401
+ (turn.error ? "is-error" : turn.who === "you" ? "is-you"
402
+ : turn.who === "note" ? "is-note" : "is-assistant")
403
+ }
404
+ >
405
+ {turn.text}
406
+ </div>
407
+
408
+ {/* ⭐ UNDO, OFFERED FROM THE TRANSCRIPT. R10 says the person confirms before a write;
409
+ this is the other half of taking them seriously. A confirmed write they did not
410
+ mean stays reversible in the place they are already looking, rather than behind a
411
+ toast that faded while they read the result.
412
+ β›” IT ROUTES THROUGH THE SAME `onWriteCells` the original went through, so the
413
+ undo passes the caller's permission wall exactly as the write did. An undo with
414
+ its own door would be a second write path, and the one nobody tested. */}
415
+ {canEdit && turn.undo?.length ? (
416
+ <div className="cg-chat-undo">
417
+ <button
418
+ type="button"
419
+ className="cg-chat-ghost"
420
+ disabled={writing}
421
+ onClick={() => onConfirmWrite(turn.undo || [])}
422
+ >
423
+ Put it back
424
+ </button>
425
+ </div>
426
+ ) : null}
427
+
428
+ {/* β›”β›” R10 ON SCREEN: EXACTLY WHAT WILL BE WRITTEN, BEFORE ANYTHING IS. Not a count,
429
+ not a summary of a summary. The person sees the record, the field, the old value
430
+ and the new one, and nothing reaches `onWriteCells` until they press Write. */}
431
+ {/* β›”β›” `canEdit` IS CHECKED HERE, IN THE VIEW, AND NOT ONLY WHERE THE PROPOSAL IS
432
+ BUILT. The stateful shell already refuses to build a proposal for a caller who
433
+ may not write, so this looked redundant and was left out of the first draft; the
434
+ gate caught it. The wall has to sit where the CONTROL is drawn, because the view
435
+ renders whatever state it is handed and a second caller, a replayed transcript or
436
+ a permission that changed mid-session would otherwise paint a Write button for
437
+ somebody the store is going to refuse [[permitted-is-not-answerable]]. A wall
438
+ that depends on its caller's discipline is not a wall. */}
439
+ {canEdit && turn.edits?.length ? (
440
+ <div className="cg-chat-edits">
441
+ <p className="cg-chat-edits-head">{editSummary(turn.edits)}</p>
442
+ <table className="cg-chat-edits-table">
443
+ <thead>
444
+ <tr><th>Record</th><th>Field</th><th>Now</th><th>Would become</th></tr>
445
+ </thead>
446
+ <tbody>
447
+ {turn.edits.map((edit, j) => (
448
+ <tr key={j}>
449
+ <td>{edit.rowLabel || edit.rowId}</td>
450
+ <td>{edit.fieldLabel || edit.field}</td>
451
+ <td className="cg-chat-before">{edit.before || "empty"}</td>
452
+ <td className="cg-chat-after">{edit.after || "empty"}</td>
453
+ </tr>
454
+ ))}
455
+ </tbody>
456
+ </table>
457
+ <div className="cg-chat-edits-act">
458
+ <button
459
+ type="button"
460
+ className="cg-chat-ghost"
461
+ disabled={writing}
462
+ onClick={onDeclineWrite}
463
+ >
464
+ Leave it
465
+ </button>
466
+ <button
467
+ type="button"
468
+ className="cg-chat-go"
469
+ disabled={writing}
470
+ onClick={() => onConfirmWrite(turn.edits || [])}
471
+ >
472
+ {writing ? "Writing" : "Write these"}
473
+ </button>
474
+ </div>
475
+ </div>
476
+ ) : null}
477
+ </div>
478
+ ))}
479
+
480
+ {/* ⚠ `.lp-spin` is the ONE loading mark in this application. A second spinner vocabulary
481
+ in a new surface is how an app stops having one. */}
482
+ {busy ? (
483
+ <div className="cg-chat-pending" role="status">
484
+ <span className="lp-spin" aria-hidden="true" />
485
+ Reading {databaseLabel}
486
+ </div>
487
+ ) : null}
488
+ </div>
489
+ ) : null}
490
+
491
+ {/* The centred opening, exactly the shape `AssistantPage` and `AutomationChat` already use.
492
+ ⚠ NO DECORATIVE MARK: the reference image has a soft dot ring, and neither of this
493
+ product's two existing chat surfaces does. A mark on the third one is the difference the
494
+ owner would notice. */}
495
+ {state.turns.length ? null : (
496
+ <div className="cg-chat-opening">
497
+ <h2 className="cg-chat-opening-h">How can I help?</h2>
498
+ <p className="cg-chat-opening-p">
499
+ {canEdit
500
+ ? "Ask about the records in front of you, change what the view shows, or say what to change. Nothing is written until you confirm it."
501
+ : "Ask about the records in front of you, or change what the view shows."}
502
+ </p>
503
+ {composer}
504
+ </div>
505
+ )}
506
+
507
+ {/* ⚠ ONE composer const, rendered in exactly one place at a time. Two on screen at once is
508
+ the defect this pattern exists to make impossible. */}
509
+ {state.turns.length ? <div className="cg-chat-foot">{composer}</div> : null}
510
+ </aside>
511
+ );
512
+ }
web/src/customer-grid/MapView.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/ScriptViewPanel.tsx CHANGED
@@ -113,6 +113,23 @@ function ScriptRefusal({ run }: { run: ScriptRun }) {
113
  );
114
  }
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  export function ScriptOutput({ run }: { run: ScriptRun | null }) {
117
  if (run === null)
118
  return <p className="cg-script-empty">Run the script to see what it draws.</p>;
@@ -165,16 +182,31 @@ export const ScriptView = memo(function ScriptView({
165
  run,
166
  saving,
167
  readOnly = false,
 
 
168
  onRun,
169
  onSave,
 
170
  }: {
171
  view: ScriptViewRecord | null;
172
  running: boolean;
173
  run: ScriptRun | null;
174
  saving?: boolean;
175
  readOnly?: boolean;
 
 
 
 
 
176
  onRun: (draft: string) => void;
177
  onSave?: (source: string) => void;
 
 
 
 
 
 
 
178
  }) {
179
  const [draft, setDraft] = useState(view?.source ?? "");
180
  const loadedId = useRef<string | null>(null);
@@ -186,11 +218,16 @@ export const ScriptView = memo(function ScriptView({
186
  setDraft(view.source);
187
  }, [view]);
188
  const dirty = view !== null && draft !== view.source;
 
 
 
 
 
189
  if (view === null)
190
  return (
191
  <div className="cg-script">
192
  <p className="cg-script-empty cg-script-empty--pad">
193
- This script view could not be opened.
194
  </p>
195
  </div>
196
  );
@@ -202,6 +239,9 @@ export const ScriptView = memo(function ScriptView({
202
  <span>Code</span>
203
  <span className="cg-script-meta">
204
  {`v${view.version}`}
 
 
 
205
  {dirty ? ", unsaved changes" : ""}
206
  </span>
207
  <span className="cg-script-spacer" />
@@ -222,11 +262,55 @@ export const ScriptView = memo(function ScriptView({
222
  {running ? "Running" : "Run"}
223
  </button>
224
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  <textarea
226
  className="cg-script-code"
227
  value={draft}
228
  spellCheck={false}
229
  readOnly={readOnly}
 
230
  aria-label="Script source"
231
  onChange={(e) => setDraft(e.target.value)}
232
  />
 
113
  );
114
  }
115
 
116
+ /**
117
+ * ⭐⭐ W37-T41 β€” THE SHAPE OF A SCRIPT, SHOWN AS A PLACEHOLDER RATHER THAN PREFILLED.
118
+ *
119
+ * Picking Custom View mints a view with an EMPTY source (verified: `script_sandbox.check_source("")`
120
+ * accepts it, which is why "mint on pick" was safe to hand lane C in mailbox E-7). Without this, the
121
+ * person who has just created one meets a blank box and a Run button, which is not an invitation.
122
+ *
123
+ * β›” A PLACEHOLDER, NEVER A PREFILLED `value`. Prefilling would make the view DIRTY the moment it
124
+ * opened, arm Save on code nobody wrote, and put a version nobody asked for into a 40-deep history.
125
+ * A placeholder vanishes on the first keystroke and is never stored.
126
+ * ⚠ It is the two lines the sandbox's own fixture uses, so the example a person is shown is the
127
+ * example the gate proves runs [[test-double-built-from-the-producer]].
128
+ */
129
+ const SCRIPT_STARTER =
130
+ "rows = scoped_table()\nemit({'kind': 'kpi', 'label': 'Records', 'value': len(rows)})";
131
+
132
+
133
  export function ScriptOutput({ run }: { run: ScriptRun | null }) {
134
  if (run === null)
135
  return <p className="cg-script-empty">Run the script to see what it draws.</p>;
 
182
  run,
183
  saving,
184
  readOnly = false,
185
+ loading = false,
186
+ reverting = false,
187
  onRun,
188
  onSave,
189
+ onRevert,
190
  }: {
191
  view: ScriptViewRecord | null;
192
  running: boolean;
193
  run: ScriptRun | null;
194
  saving?: boolean;
195
  readOnly?: boolean;
196
+ /** ⚠ The host knows whether a fetch is outstanding; the panel cannot tell that from a null view.
197
+ * Defaulted so no existing caller breaks, which is safe HERE because the default is the older,
198
+ * more pessimistic sentence rather than a silently disabled feature. */
199
+ loading?: boolean;
200
+ reverting?: boolean;
201
  onRun: (draft: string) => void;
202
  onSave?: (source: string) => void;
203
+ /**
204
+ * ⭐ W37-T46 / D-338. Optional for the SAME reason `onSave` is: a read-only reader gets the
205
+ * editor without the controls, and a caller that cannot revert simply does not pass this. The
206
+ * control is not rendered at all when it is absent, rather than rendered and refused
207
+ * [[permitted-is-not-answerable]].
208
+ */
209
+ onRevert?: (version: number) => void;
210
  }) {
211
  const [draft, setDraft] = useState(view?.source ?? "");
212
  const loadedId = useRef<string | null>(null);
 
218
  setDraft(view.source);
219
  }, [view]);
220
  const dirty = view !== null && draft !== view.source;
221
+ // β›” W37-T41: "STILL LOADING" AND "COULD NOT BE OPENED" ARE DIFFERENT FACTS AND USED TO SHARE A
222
+ // SENTENCE. With mint-on-pick (E-7) the normal path now passes through `view === null` for as
223
+ // long as the fetch takes, so the old copy told every person who created a Custom View that it
224
+ // was broken, for a moment, every time. A state that renames itself is exactly what cost this
225
+ // product a QA pass one wave ago [[a-store-outage-renames-itself-at-every-layer]].
226
  if (view === null)
227
  return (
228
  <div className="cg-script">
229
  <p className="cg-script-empty cg-script-empty--pad">
230
+ {loading === true ? "Opening this view." : "This script view could not be opened."}
231
  </p>
232
  </div>
233
  );
 
239
  <span>Code</span>
240
  <span className="cg-script-meta">
241
  {`v${view.version}`}
242
+ {/* ⚠ "v5" alone cannot tell a roll-back from a coincidence of matching code. This is
243
+ the sentence that makes the history read as what happened. */}
244
+ {view.restoredFrom ? `, restored from v${view.restoredFrom}` : ""}
245
  {dirty ? ", unsaved changes" : ""}
246
  </span>
247
  <span className="cg-script-spacer" />
 
262
  {running ? "Running" : "Run"}
263
  </button>
264
  </div>
265
+ {/* ⭐⭐ W37-T41 β€” THE PLACEHOLDER IS THE CREATE FLOW'S WHOLE EMPTY STATE, and it is the
266
+ half that made item 10 worth reopening. Picking Custom View mints a view with an
267
+ EMPTY source (verified: `script_sandbox.check_source("")` accepts it, which is why
268
+ "mint on pick" was safe to hand lane C in mailbox E-7). Without this the person who
269
+ just created one meets a blank box and a Run button, which is not an invitation, it
270
+ is a puzzle.
271
+ β›” A PLACEHOLDER, NOT A PREFILLED `value`. Prefilling would make the view DIRTY the
272
+ moment it opened, arm Save on code nobody wrote, and put a version nobody asked for
273
+ into a 40-deep history. It vanishes on the first keystroke and is never saved. */}
274
+ {/* ⭐⭐ W37-T46 / D-338 β€” THE CONTROL THAT WAS MISSING. The API already returned
275
+ `history` newest-first and the header already showed `v<n>`; there was simply no way
276
+ to go back, which is the whole of D-338. It matters more now that a chat writes the
277
+ code (R2): generation without roll-back means one bad answer costs somebody their
278
+ view, and the person cannot tell in advance which answer that will be.
279
+ β›” THE STRIP IS ABSENT, NOT DISABLED, WHEN THERE IS NOTHING TO GO BACK TO. A first
280
+ version has no history, and a row of dead controls reads as a broken feature rather
281
+ than as an empty one.
282
+ ⚠ `trimmed` is PRINTED when it is non-zero. A history capped at 40 that silently
283
+ showed 40 would let a reader conclude the view was only ever saved 40 times; saying
284
+ how many were dropped is the difference between a partial record and a wrong one. */}
285
+ {(view.history?.length ?? 0) > 0 ? (
286
+ <div className="cg-script-history" aria-label="Earlier versions">
287
+ <span className="cg-script-history-lead">Earlier</span>
288
+ {[...(view.history ?? [])].map((h) => (
289
+ <button
290
+ type="button"
291
+ key={h.version}
292
+ className="cg-script-history-item"
293
+ disabled={readOnly || reverting || onRevert === undefined}
294
+ title={`Go back to version ${h.version}, saved by ${h.author || "somebody"}`}
295
+ onClick={() => onRevert?.(h.version)}
296
+ >
297
+ {`v${h.version}`}
298
+ </button>
299
+ ))}
300
+ {view.trimmed > 0 ? (
301
+ <span className="cg-script-history-trimmed">
302
+ {`${view.trimmed} older ${view.trimmed === 1 ? "version" : "versions"} dropped`}
303
+ </span>
304
+ ) : null}
305
+ {reverting ? <span className="cg-script-history-trimmed">Going back</span> : null}
306
+ </div>
307
+ ) : null}
308
  <textarea
309
  className="cg-script-code"
310
  value={draft}
311
  spellCheck={false}
312
  readOnly={readOnly}
313
+ placeholder={SCRIPT_STARTER}
314
  aria-label="Script source"
315
  onChange={(e) => setDraft(e.target.value)}
316
  />
web/src/customer-grid/Toolbar.tsx CHANGED
@@ -111,6 +111,14 @@ export interface ToolbarProps {
111
  unresolvedCount?: number;
112
  /** Owner item 5 β€” the cohorts this user has, for `Where [Cohort] [is part of] […]`. */
113
  lists?: { id: string; name: string }[];
 
 
 
 
 
 
 
 
114
  /**
115
  * Item 12 (C-LOCK) β€” this view is LOCKED to a cohort. Present = say so in the filter
116
  * builder, because the lock narrows the list and is not one of the conditions shown there:
@@ -565,6 +573,7 @@ export default function Toolbar({
565
  pendingMeasureCount = 0,
566
  unresolvedCount = 0,
567
  lists = [],
 
568
  cohortLock,
569
  copyViews,
570
  onCopyConfig,
@@ -737,6 +746,7 @@ export default function Toolbar({
737
  fields={fields}
738
  measures={measures}
739
  cohorts={lists}
 
740
  filters={filterTree}
741
  onChange={onFilterTree}
742
  statusValues={statusValues}
 
111
  unresolvedCount?: number;
112
  /** Owner item 5 β€” the cohorts this user has, for `Where [Cohort] [is part of] […]`. */
113
  lists?: { id: string; name: string }[];
114
+ /** ⭐⭐ W37-T26 (C4) β€” the views a `Where [View] [is] […]` condition may name. A PASS-THROUGH:
115
+ * this toolbar mounts `FilterBuilderPanel`, so without these two lines the leaf evaluates,
116
+ * persists and refuses cycles while being impossible to REACH β€” mounted-but-never-offered.
117
+ * ⚠ Empty = not offered, which is what a server-windowed grid passes (the SQL compiler has no
118
+ * arm for the pseudo-column, so a leaf there would be INACTIVE and inactive means every row).
119
+ * ⚠ Edited by lane C, not this file's owner: `Toolbar.tsx` is in NO fence and lane C was the
120
+ * last session open. See `mailbox/C.md` NOTE C-24. */
121
+ viewChoices?: { id: string; name: string }[];
122
  /**
123
  * Item 12 (C-LOCK) β€” this view is LOCKED to a cohort. Present = say so in the filter
124
  * builder, because the lock narrows the list and is not one of the conditions shown there:
 
573
  pendingMeasureCount = 0,
574
  unresolvedCount = 0,
575
  lists = [],
576
+ viewChoices = [],
577
  cohortLock,
578
  copyViews,
579
  onCopyConfig,
 
746
  fields={fields}
747
  measures={measures}
748
  cohorts={lists}
749
+ viewChoices={viewChoices}
750
  filters={filterTree}
751
  onChange={onFilterTree}
752
  statusValues={statusValues}
web/src/customer-grid/catalog.css ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* customer-grid/catalog.css β€” W37-T32/T33, the catalog designer's new gestures.
2
+ *
3
+ * β›” WHY THIS FILE AND NOT index.css: contract C8. `index.css` is 12k+ lines and belongs to lane A
4
+ * this wave, so every other lane adds styles in its own per-surface stylesheet imported by its own
5
+ * component. `scriptView.css`, `navExtras.css` and this wave's `map.css` are the precedent.
6
+ *
7
+ * ⚠ NOTHING PRINT-RELATED MAY LAND HERE WITHOUT ITS SCAN COMING WITH IT. `catalog.test.ts` reads
8
+ * `src/index.css` BY NAME for the print-isolation rules (`html.cg-cat-printing body > *`,
9
+ * `print-color-adjust: exact`, the tokens declared inside `.cg-cat-page`). A print rule moved into
10
+ * this file would be invisible to that check, which is a gate quietly getting smaller. Everything
11
+ * below is SCREEN-ONLY designer chrome, on purpose.
12
+ */
13
+
14
+ /* --- dragging a page to reorder it (T32 / R6) -----------------------------
15
+ *
16
+ * β›” PAGES REORDER, THEY DO NOT FREE-FLOAT. A printed document is a sequence, so a page has a
17
+ * position IN that sequence; only the images inside a section carry free coordinates (T33). The
18
+ * affordance therefore has to read as "this row moves between other rows", never as "this thing
19
+ * floats", which is why the drop target is a LINE and not a box.
20
+ */
21
+
22
+ .cg-cat-pagerow {
23
+ /* The grab affordance sits on the row, not on the button inside it: a draggable <button> starts
24
+ a drag in some browsers, fires a click in others, and does neither reliably in Firefox. */
25
+ cursor: grab;
26
+ position: relative;
27
+ border-radius: 5px;
28
+ }
29
+
30
+ .cg-cat-pagerow:active {
31
+ cursor: grabbing;
32
+ }
33
+
34
+ .cg-cat-pagerow.is-dragging {
35
+ /* Faded, NOT hidden. A row that vanishes while you hold it makes the list jump under the cursor
36
+ and you lose the place you were aiming at. */
37
+ opacity: 0.42;
38
+ }
39
+
40
+ .cg-cat-pagerow.is-dropzone::before {
41
+ /* The insertion LINE. A highlighted box would say "drop it into this page"; a line above the row
42
+ says "it lands here, before this one", which is what actually happens. */
43
+ content: "";
44
+ position: absolute;
45
+ left: 2px;
46
+ right: 2px;
47
+ top: -2px;
48
+ height: 2px;
49
+ border-radius: 2px;
50
+ background: #1f4e78;
51
+ }
52
+
53
+ /* --- free-placed items inside a section (T33 / R6) ------------------------
54
+ *
55
+ * β›”β›” A COORDINATE-FREE ITEM IS NOT AN ITEM AT THE ORIGIN. Everything authored before this wave
56
+ * has no `x`/`y`, and the renderer reads that absence as "flow position N" so an existing catalog
57
+ * keeps printing exactly as it did. These rules only ever apply to an item that HAS coordinates;
58
+ * the flow layout is untouched and stays the default.
59
+ */
60
+
61
+ /* β›”β›” DELIBERATELY *NOT* `position: relative`, AND THIS IS THE LOAD-BEARING LINE.
62
+ *
63
+ * It was relative, which made the GRID the containing block for every placed item. The grid's
64
+ * height comes from its in-flow content, and placing an item REMOVES it from that flow: place them
65
+ * all and the grid collapses to nothing, so `top: 96%` becomes 96% of zero and every placed image
66
+ * piles at the top. A feedback loop between the layout and the coordinates it is measured in.
67
+ *
68
+ * With the grid static, an absolutely-positioned item resolves against `.cg-cat-page`, which is
69
+ * `position: relative` with an EXPLICIT `height: var(--cat-h)` and `overflow: hidden`. That box is
70
+ * the sheet of paper: fixed, independent of how much flows inside it, and identical in the designer
71
+ * and in the print tree. A percentage of it means the same thing forever.
72
+ * ⚠ The class stays because it MARKS the state for the gate and for the eye. It must not acquire a
73
+ * `position` later; that is the bug above, coming back.
74
+ */
75
+ .cg-cat-slot-free {
76
+ position: static;
77
+ }
78
+
79
+ .cg-cat-item-free {
80
+ position: absolute;
81
+ cursor: grab;
82
+ }
83
+
84
+ .cg-cat-item-free:active {
85
+ cursor: grabbing;
86
+ }
87
+
88
+ .cg-cat-item-free.is-dragging {
89
+ opacity: 0.55;
90
+ outline: 1px dashed #1f4e78;
91
+ }
web/src/customer-grid/chatDock.css ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* customer-grid/chatDock.css β€” W37-T29 (contract C6 + C8): the chat's DOCK and its LAUNCHER.
2
+ *
3
+ * β›” A SEPARATE SHEET FROM E's `gridChat.css` ON PURPOSE, and the split is the contract rather
4
+ * than taste. C8 hands `index.css` to lane A alone and tells every other lane to add styles in its
5
+ * own per-surface sheet; E owns how the PANEL looks and this file owns where the HOST puts it and
6
+ * how a person opens it. Two lanes editing one sheet is the collision C8 exists to prevent.
7
+ *
8
+ * β›”β›” THE LAUNCHER IS THE WHOLE REASON THIS FILE EXISTS. A panel with no control that opens it is
9
+ * mounted-but-never-offered, which is this repo's most repeated defect: one wave shipped five whole,
10
+ * correct, unreachable features past every gate. E's sheet cannot carry the launcher, because the
11
+ * launcher is not part of the panel.
12
+ * ⚠ Tokens only, never fresh hexes (DESIGN.md: navy #1F4E78 + gold #C8A24B). No emojis, no
13
+ * ALL-CAPS chrome.
14
+ */
15
+
16
+ /* The dock: a flex child of `.cg-shell`, seated between the views rail and the grid, so the panel
17
+ reads as part of the workspace rather than as an overlay floating on top of it. E's `.cg-chat`
18
+ brings its own width floor, ceiling and left border; this wrapper only owns the seat. */
19
+ .cg-chat-dock {
20
+ display: flex;
21
+ min-height: 0;
22
+ align-self: stretch;
23
+ }
24
+
25
+ /* The launcher. A rail-height strip rather than a floating bubble: a bubble would sit over the
26
+ grid's own rows, which is the one place a person is trying to read. */
27
+ .cg-chat-open {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: 6px;
31
+ align-self: flex-start;
32
+ margin: 8px 0 0 8px;
33
+ padding: 6px 10px;
34
+ border: 1px solid var(--lp-border, #e4e7ec);
35
+ border-radius: 6px;
36
+ background: var(--lp-surface, #ffffff);
37
+ color: var(--lp-ink, #1f2937);
38
+ font-size: var(--lp-fs-xs);
39
+ line-height: 1.2;
40
+ cursor: pointer;
41
+ white-space: nowrap;
42
+ }
43
+
44
+ .cg-chat-open:hover {
45
+ border-color: var(--lp-navy, #1f4e78);
46
+ color: var(--lp-navy, #1f4e78);
47
+ }
48
+
49
+ .cg-chat-open:focus-visible {
50
+ outline: 2px solid var(--lp-gold, #c8a24b);
51
+ outline-offset: 1px;
52
+ }
web/src/customer-grid/gridChat.css ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* customer-grid/gridChat.css β€” the docked chat panel (W37-T40, contract C8).
2
+ *
3
+ * β›” MODULE-LOCAL BY CONTRACT, not by preference. C8 hands `index.css` (12k+ lines) to lane A
4
+ * alone this wave, and every other lane adds styles in its own per-surface sheet imported by its
5
+ * own component. The precedent already exists next door in `scriptView.css` and `navExtras.css`.
6
+ * ⚠ Colours come from the design tokens (navy #1F4E78 + gold #C8A24B, DESIGN.md), never from fresh
7
+ * hexes: a surface that invents its own palette is the thing the constitution exists to prevent.
8
+ * Only the tokens this file actually uses are named.
9
+ * β›”β›” NO FALLBACK LITERAL INSIDE A `font:` SHORTHAND, and this sheet shipped ten of them before
10
+ * `web_ui` said so (lane A, mailbox A-47). A fallback there is a SECOND SOURCE OF TRUTH for a size
11
+ * that silently becomes THE size the day the token is renamed, and `index.css` always loads, so it
12
+ * can never actually fire. Sizes are therefore `var(--lp-fs-xs)` bare; colours keep their fallback
13
+ * because a missing colour is a legibility failure rather than a silent wrong number.
14
+ * ⚠ NO EMOJIS AND NO ALL-CAPS CHROME (DESIGN.md). */
15
+
16
+ .cg-chat {
17
+ display: flex;
18
+ flex-direction: column;
19
+ height: 100%;
20
+ min-height: 0;
21
+ /* ⚠ A FLOOR AND A CEILING, NOT A FIXED WIDTH. The host docks this beside a grid whose columns
22
+ are the reason the person is here; a panel that cannot give width back squeezes the thing it
23
+ is talking about. Below the floor the transcript stops being readable, so the floor is real. */
24
+ min-width: 280px;
25
+ max-width: 420px;
26
+ flex: 0 0 340px;
27
+ background: var(--lp-surface, #ffffff);
28
+ border-left: 1px solid var(--lp-border, #e4e7ec);
29
+ }
30
+
31
+ /* Below 1024px a docked panel and a grid cannot both be usable, so the panel takes the surface.
32
+ ⚠ It does NOT hide itself: hiding would leave the host's toggle saying "open" with nothing on
33
+ screen, which is a worse failure than a full-width panel the person can close. */
34
+ @media (max-width: 1024px) {
35
+ .cg-chat { flex: 1 1 auto; max-width: none; border-left: 0; }
36
+ }
37
+
38
+ .cg-chat-head {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 8px;
42
+ padding: 10px 12px;
43
+ border-bottom: 1px solid var(--lp-border, #e4e7ec);
44
+ background: var(--lp-surface, #ffffff);
45
+ }
46
+
47
+ .cg-chat-title {
48
+ font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
49
+ color: var(--lp-text, #1d2939);
50
+ /* The database name can be long and the header is narrow. Truncating beats wrapping into a
51
+ two-line header that shifts the whole panel down. */
52
+ overflow: hidden;
53
+ text-overflow: ellipsis;
54
+ white-space: nowrap;
55
+ flex: 1 1 auto;
56
+ min-width: 0;
57
+ }
58
+
59
+ .cg-chat-close {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ width: 24px;
64
+ height: 24px;
65
+ border: 0;
66
+ border-radius: 6px;
67
+ background: transparent;
68
+ color: var(--lp-text-muted, #667085);
69
+ cursor: pointer;
70
+ }
71
+ .cg-chat-close:hover { background: var(--lp-surface-sunken, #f2f4f7); color: var(--lp-text, #1d2939); }
72
+
73
+ /* ── the transcript ─────────────────────────────────────────────────────── */
74
+
75
+ .cg-chat-log {
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 10px;
79
+ flex: 1 1 auto;
80
+ min-height: 0;
81
+ overflow-y: auto;
82
+ padding: 14px 12px;
83
+ }
84
+
85
+ .cg-chat-turn { display: flex; flex-direction: column; gap: 6px; }
86
+
87
+ .cg-chat-msg {
88
+ max-width: 92%;
89
+ padding: 8px 10px;
90
+ border-radius: 10px;
91
+ font: 400 var(--lp-fs-xs)/1.55 Inter, system-ui, sans-serif;
92
+ white-space: pre-wrap;
93
+ /* ⚠ A pasted column name or an id has no spaces to break on and would otherwise push the panel
94
+ wider than its own max-width. */
95
+ overflow-wrap: anywhere;
96
+ }
97
+
98
+ .cg-chat-msg.is-you {
99
+ align-self: flex-end;
100
+ background: var(--lp-navy, #1f4e78);
101
+ color: #ffffff;
102
+ }
103
+ .cg-chat-msg.is-assistant {
104
+ align-self: flex-start;
105
+ background: var(--lp-surface-sunken, #f2f4f7);
106
+ color: var(--lp-text, #1d2939);
107
+ }
108
+ /* ⚠ The panel talking about ITSELF (a write landed, a proposal was declined) is neither side of
109
+ the conversation, so it is centred and quiet rather than dressed as an answer. */
110
+ .cg-chat-msg.is-note {
111
+ align-self: center;
112
+ max-width: 100%;
113
+ background: transparent;
114
+ color: var(--lp-text-muted, #667085);
115
+ font-size: var(--lp-fs-2xs, 11px);
116
+ text-align: center;
117
+ padding: 2px 4px;
118
+ }
119
+ /* β›” An error is a KIND OF TURN, in the transcript where it can be re-read while the person
120
+ rephrases. Not a toast that has already faded by then. */
121
+ .cg-chat-msg.is-error {
122
+ align-self: flex-start;
123
+ background: #fef3f2;
124
+ color: #b42318;
125
+ border: 1px solid #fecdca;
126
+ }
127
+
128
+ .cg-chat-pending {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 8px;
132
+ padding: 2px 2px 4px;
133
+ font: 400 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
134
+ color: var(--lp-text-muted, #667085);
135
+ }
136
+
137
+ /* ── the write confirm (R10) ────────────────────────────────────────────── */
138
+
139
+ .cg-chat-edits {
140
+ border: 1px solid var(--lp-border, #e4e7ec);
141
+ border-radius: 10px;
142
+ background: var(--lp-surface, #ffffff);
143
+ padding: 10px;
144
+ }
145
+
146
+ .cg-chat-edits-head {
147
+ margin: 0 0 8px;
148
+ font: 600 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
149
+ color: var(--lp-text, #1d2939);
150
+ }
151
+
152
+ .cg-chat-edits-table {
153
+ width: 100%;
154
+ border-collapse: collapse;
155
+ font: 400 var(--lp-fs-3xs)/1.45 Inter, system-ui, sans-serif;
156
+ /* A proposal can name more cells than fit; it scrolls rather than being clipped, because a
157
+ confirm the person cannot fully read is not a confirm. */
158
+ display: block;
159
+ max-height: 220px;
160
+ overflow: auto;
161
+ }
162
+ .cg-chat-edits-table th {
163
+ text-align: left;
164
+ font-weight: 600;
165
+ color: var(--lp-text-muted, #667085);
166
+ padding: 0 8px 4px 0;
167
+ position: sticky;
168
+ top: 0;
169
+ background: var(--lp-surface, #ffffff);
170
+ }
171
+ .cg-chat-edits-table td {
172
+ padding: 3px 8px 3px 0;
173
+ color: var(--lp-text, #1d2939);
174
+ border-top: 1px solid var(--lp-border, #e4e7ec);
175
+ vertical-align: top;
176
+ overflow-wrap: anywhere;
177
+ }
178
+ /* ⚠ The two values are told apart by WEIGHT and colour, never by colour alone: a red/green pair
179
+ is unreadable to a substantial minority of people, and this is a confirm before a write. */
180
+ .cg-chat-before { color: var(--lp-text-muted, #667085); text-decoration: line-through; }
181
+ .cg-chat-after { color: var(--lp-navy, #1f4e78); font-weight: 600; }
182
+
183
+ /* ⚠ Centred under the note it belongs to, and quiet: an undo control that shouted would read as
184
+ a warning that something went wrong, when what happened is exactly what the person confirmed. */
185
+ .cg-chat-undo { display: flex; justify-content: center; margin-top: 2px; }
186
+
187
+ .cg-chat-edits-act {
188
+ display: flex;
189
+ justify-content: flex-end;
190
+ gap: 8px;
191
+ margin-top: 10px;
192
+ }
193
+
194
+ .cg-chat-ghost,
195
+ .cg-chat-go {
196
+ border-radius: 7px;
197
+ padding: 5px 12px;
198
+ font: 500 var(--lp-fs-2xs)/1.2 Inter, system-ui, sans-serif;
199
+ cursor: pointer;
200
+ }
201
+ .cg-chat-ghost {
202
+ border: 1px solid var(--lp-border, #e4e7ec);
203
+ background: var(--lp-surface, #ffffff);
204
+ color: var(--lp-text, #1d2939);
205
+ }
206
+ .cg-chat-go {
207
+ border: 1px solid var(--lp-navy, #1f4e78);
208
+ background: var(--lp-navy, #1f4e78);
209
+ color: #ffffff;
210
+ }
211
+ .cg-chat-ghost:disabled,
212
+ .cg-chat-go:disabled { opacity: 0.55; cursor: default; }
213
+
214
+ /* ── the opening and the composer ───────────────────────────────────────── */
215
+
216
+ .cg-chat-opening {
217
+ display: flex;
218
+ flex-direction: column;
219
+ justify-content: center;
220
+ flex: 1 1 auto;
221
+ min-height: 0;
222
+ gap: 10px;
223
+ padding: 16px 12px;
224
+ text-align: center;
225
+ }
226
+
227
+ .cg-chat-opening-h {
228
+ margin: 0;
229
+ font: 600 var(--lp-fs-lg)/1.3 Inter, system-ui, sans-serif;
230
+ color: var(--lp-text, #1d2939);
231
+ }
232
+
233
+ .cg-chat-opening-p {
234
+ margin: 0 0 6px;
235
+ font: 400 var(--lp-fs-2xs)/1.55 Inter, system-ui, sans-serif;
236
+ color: var(--lp-text-muted, #667085);
237
+ }
238
+
239
+ .cg-chat-foot { padding: 10px 12px 12px; border-top: 1px solid var(--lp-border, #e4e7ec); }
240
+
241
+ .cg-chat-composer {
242
+ border: 1px solid var(--lp-border, #e4e7ec);
243
+ border-radius: 12px;
244
+ background: var(--lp-surface, #ffffff);
245
+ padding: 8px;
246
+ text-align: left;
247
+ }
248
+ .cg-chat-composer:focus-within { border-color: var(--lp-navy, #1f4e78); }
249
+
250
+ .cg-chat-input {
251
+ width: 100%;
252
+ border: 0;
253
+ outline: 0;
254
+ resize: none;
255
+ background: transparent;
256
+ color: var(--lp-text, #1d2939);
257
+ font: 400 var(--lp-fs-xs)/1.5 Inter, system-ui, sans-serif;
258
+ }
259
+ .cg-chat-input::placeholder { color: var(--lp-text-muted, #667085); }
260
+ .cg-chat-input:disabled { color: var(--lp-text-muted, #667085); }
261
+
262
+ /* ⚠ The foot row sits INSIDE the composer's border, which is the shape `cg-agent-composer-foot`
263
+ already established. A second card outline under the input would read as two controls. */
264
+ .cg-chat-composer-foot {
265
+ display: flex;
266
+ align-items: center;
267
+ gap: 8px;
268
+ margin-top: 6px;
269
+ }
270
+
271
+ .cg-chat-scope {
272
+ font: 400 var(--lp-fs-3xs)/1.3 Inter, system-ui, sans-serif;
273
+ color: var(--lp-text-muted, #667085);
274
+ }
275
+
276
+ .cg-chat-spacer { flex: 1 1 auto; }
277
+
278
+ .cg-chat-send {
279
+ display: inline-flex;
280
+ align-items: center;
281
+ justify-content: center;
282
+ width: 26px;
283
+ height: 26px;
284
+ border: 0;
285
+ border-radius: 50%;
286
+ background: var(--lp-navy, #1f4e78);
287
+ color: #ffffff;
288
+ cursor: pointer;
289
+ }
290
+ .cg-chat-send:disabled {
291
+ background: var(--lp-surface-sunken, #f2f4f7);
292
+ color: var(--lp-text-muted, #667085);
293
+ cursor: default;
294
+ }
web/src/customer-grid/gridChat.ts ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / gridChat.ts β€” the chat panel's state, as pure functions (W37-T40, R2, C6).
3
+ //
4
+ // ⭐⭐ WHY THE STATE LIVES HERE AND NOT IN THE COMPONENT. `<GridChat>` has to demonstrate three
5
+ // things its `done-when` names: a message history, an in-flight state and an error state. If those
6
+ // transitions live in `useState` callbacks inside the .tsx, the only way to assert on them is to
7
+ // render React and match emitted markup β€” which pins the compiler's output rather than the
8
+ // behaviour, and goes red on a refactor that changed nothing a person can see. Here they are plain
9
+ // functions over a plain object, so the gate checks the STATE MACHINE without React in the process
10
+ // at all, and the render only has to prove the three states paint.
11
+ //
12
+ // β›”β›” NOTHING IN THIS FILE TALKS TO THE NETWORK, THE GRID, OR THE STORE β€” contract C6, and it is
13
+ // structural rather than a promise. There is no `fetch` here and no import of anything that has
14
+ // one: every action the chat can take arrives as a callback the host supplies. That is what lets
15
+ // the host WALL the chat by simply not passing a power, instead of the chat asking permission and
16
+ // being told no [[permitted-is-not-answerable]].
17
+ //
18
+ // ⚠ NO EM DASH OR EN DASH IN ANY STRING THAT REACHES A SCREEN (standing rule 2). The sentences
19
+ // below are user-facing copy, and `web_prose` cannot see a wrapped JSX sentence, so the discipline
20
+ // is on the author. Commas, colons and full stops carry the same weight and are not AI slop.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** One column the chat is allowed to know about. The host decides what goes in this list. */
24
+ export interface GridChatField {
25
+ key: string;
26
+ label: string;
27
+ type: string;
28
+ }
29
+
30
+ /**
31
+ * What a host callback hands back for a question.
32
+ *
33
+ * β›” `text` IS REQUIRED AND `unanswerable` IS THE HONEST OUT. D-339's lesson is that an agent
34
+ * which cannot read the database answers from words alone and invents a number; a host that cannot
35
+ * answer must be able to SAY so rather than return an empty string the panel renders as silence.
36
+ */
37
+ export interface GridChatAnswer {
38
+ text: string;
39
+ /** Set when the question named something this database does not have. Rendered as an answer,
40
+ * not as a failure: the person asked a reasonable question about the wrong column. */
41
+ unanswerable?: boolean;
42
+ /**
43
+ * ⭐⭐ R10's PROPOSAL CHANNEL, DECLARED RATHER THAN CAST. The panel reads this to decide that an
44
+ * answer is a proposed WRITE and must be shown for confirmation instead of rendered as prose.
45
+ *
46
+ * β›” IT WAS A CAST FIRST, AND THAT WAS THE BUG-IN-WAITING. `GridChat` read
47
+ * `(answer as {edits?: ...}).edits` while this interface said nothing about it, so the contract
48
+ * lived in one file and the reader in another. The host reads the PUBLISHED type; a host that
49
+ * never sees `edits` never sends `edits`, the whole confirm path is dead on arrival, and nothing
50
+ * anywhere fails [[flag-shipped-without-its-writer]]. Typing it is the difference between a
51
+ * channel and a coincidence.
52
+ * ⚠ Its presence is a PROPOSAL, never an instruction: nothing is written until the person
53
+ * confirms, and a caller with `canEdit:false` is not shown the control at all.
54
+ */
55
+ edits?: GridChatEdit[];
56
+ /**
57
+ * ⭐⭐ W37-T44's GENERATION CHANNEL. The host authored a Custom View's code; the panel RUNS it
58
+ * through `onRunScript` and reports what happened.
59
+ *
60
+ * β›” DECLARED, NOT INFERRED, FOR THE SAME REASON `edits` IS. A channel the reader consumes and
61
+ * the published type does not mention is a channel the host never fills, and nothing anywhere
62
+ * fails [[flag-shipped-without-its-writer]] β€” which is exactly what a `void`ed callback and a
63
+ * confident `observed:` line looked like in the first draft of this ticket.
64
+ * ⚠ AUTHORING IS THE HOST'S, RUNNING IS THE PANEL'S. That split is what keeps the sandbox
65
+ * un-widened: the code goes through the ordinary run door a hand-typed script uses.
66
+ */
67
+ script?: string;
68
+ }
69
+
70
+ /** The outcome of running a Custom View's code. `error` is a sentence, never a stack trace. */
71
+ export interface GridChatRunResult {
72
+ ok: boolean;
73
+ error?: string;
74
+ }
75
+
76
+ /** One cell the chat proposes to write. R10: the person sees `before` and `after` before anything. */
77
+ export interface GridChatEdit {
78
+ rowId: string;
79
+ rowLabel: string;
80
+ field: string;
81
+ fieldLabel: string;
82
+ before: string;
83
+ after: string;
84
+ }
85
+
86
+ /** Who said it. `note` is the panel talking about itself (a confirm outcome, a cancelled write). */
87
+ export type GridChatWho = "you" | "assistant" | "note";
88
+
89
+ /**
90
+ * One turn. ⚠ `error` is a KIND OF TURN rather than a banner, and that is deliberate: a failed
91
+ * question is part of the conversation a person is having. They will rephrase and try again, and a
92
+ * toast that has already faded cannot be re-read while they do. `AutomationChat` made the same
93
+ * call for the same reason; two chat surfaces disagreeing about where failure lives is how one
94
+ * product stops looking like one product.
95
+ */
96
+ export interface GridChatTurn {
97
+ who: GridChatWho;
98
+ text: string;
99
+ error?: boolean;
100
+ /** Present only on a turn that is proposing writes. The panel renders the before/after list. */
101
+ edits?: GridChatEdit[];
102
+ /**
103
+ * ⭐⭐ THE INVERSE OF A WRITE THAT ALREADY HAPPENED, CARRIED ON THE NOTE THAT REPORTS IT.
104
+ *
105
+ * β›” IT IS BUILT FROM THE PROPOSAL, BEFORE THE WRITE, NOT READ BACK AFTER IT
106
+ * [[capture-the-inverse-before-the-write]]. Once the cells are written the old values are gone
107
+ * from the store, so an undo assembled afterwards can only re-read what it just wrote and undo
108
+ * nothing. Every `GridChatEdit` already carries `before` because the person had to SEE it in
109
+ * order to confirm, which means the inverse costs nothing extra and cannot be stale.
110
+ */
111
+ undo?: GridChatEdit[];
112
+ }
113
+
114
+ /**
115
+ * The whole panel state.
116
+ *
117
+ * ⭐ `pending` IS THE IN-FLIGHT STATE AND IT IS THE QUESTION ITSELF, not a boolean. A boolean can
118
+ * go stale against the transcript: two rapid sends with `busy: true` leave no record of which
119
+ * question is actually outstanding, and the answer lands under whichever turn happens to be last.
120
+ * Holding the text means the panel can always say what it is waiting on, and `answered()` can
121
+ * refuse an answer that arrived after the person moved on.
122
+ */
123
+ export interface GridChatState {
124
+ turns: GridChatTurn[];
125
+ pending: string | null;
126
+ }
127
+
128
+ export function emptyChat(): GridChatState {
129
+ return { turns: [], pending: null };
130
+ }
131
+
132
+ export function isBusy(state: GridChatState): boolean {
133
+ return state.pending !== null;
134
+ }
135
+
136
+ /**
137
+ * The person sent something. Returns the new state, or the SAME state when there is nothing to
138
+ * send or a question is already outstanding.
139
+ *
140
+ * β›” ONE QUESTION AT A TIME, AND THE REFUSAL IS SILENT BY DESIGN. The composer disables its own
141
+ * send button while `pending`, so a second send can only arrive from a key repeat or a double
142
+ * click. Answering that with an error turn would put a scolding message in a transcript the person
143
+ * did not mean to write in. Returning the same object also lets the caller skip the re-render.
144
+ */
145
+ export function asked(state: GridChatState, said: string): GridChatState {
146
+ const text = String(said || "").trim();
147
+ if (!text || state.pending !== null) return state;
148
+ return { turns: [...state.turns, { who: "you", text }], pending: text };
149
+ }
150
+
151
+ /**
152
+ * A host callback answered. `forQuestion` is the text `asked()` returned in `pending`.
153
+ *
154
+ * β›” AN ANSWER TO A QUESTION NOBODY IS WAITING ON IS DROPPED. A slow first answer that lands after
155
+ * the person cancelled and asked something else would otherwise be appended under the new question
156
+ * and read as its answer. Checking the text is enough here because `asked()` refuses to start a
157
+ * second question while one is outstanding, so at most one can ever be in flight.
158
+ */
159
+ export function answered(
160
+ state: GridChatState,
161
+ forQuestion: string,
162
+ answer: GridChatAnswer,
163
+ ): GridChatState {
164
+ if (state.pending === null || state.pending !== forQuestion) return state;
165
+ const said = String(answer?.text || "").trim();
166
+ return {
167
+ turns: [...state.turns, {
168
+ who: "assistant",
169
+ // ⚠ An empty answer is a defect in the host, not a blank bubble for the reader to interpret.
170
+ text: said || "That came back empty, so there is nothing to show. Try asking again.",
171
+ error: !said,
172
+ }],
173
+ pending: null,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * A host callback failed. `sentence` is what the person reads.
179
+ *
180
+ * β›” THE SENTENCE IS THE PRODUCT. `HTTP 402` on a screen tells a reader nothing they can do, and
181
+ * the owner has quoted a raw status back at us twice. A caller with nothing better than an
182
+ * exception name passes nothing and gets the fallback below, which at least says what happened and
183
+ * what to do next.
184
+ */
185
+ export function failed(
186
+ state: GridChatState,
187
+ forQuestion: string,
188
+ sentence?: string,
189
+ ): GridChatState {
190
+ if (state.pending === null || state.pending !== forQuestion) return state;
191
+ const said = String(sentence || "").trim();
192
+ return {
193
+ turns: [...state.turns, {
194
+ who: "assistant",
195
+ text: said || "That did not go through. Nothing was changed, so it is safe to try again.",
196
+ error: true,
197
+ }],
198
+ pending: null,
199
+ };
200
+ }
201
+
202
+ /** The panel telling the person what it just did. Never in flight, never an error. */
203
+ export function noted(state: GridChatState, text: string): GridChatState {
204
+ const said = String(text || "").trim();
205
+ if (!said) return state;
206
+ return { ...state, turns: [...state.turns, { who: "note", text: said }] };
207
+ }
208
+
209
+ /**
210
+ * The assistant is proposing cell writes and wants a confirm (R10).
211
+ *
212
+ * ⭐ THE PROPOSAL IS A TURN, not a modal. A modal would cover the transcript the person needs in
213
+ * order to judge the proposal, and it would vanish on a mis-click taking the reasoning with it.
214
+ * β›” NOTHING IS WRITTEN BY THIS FUNCTION. It appends a turn carrying the before/after list; the
215
+ * host's `onWriteCells` is reached only from the confirm control, which is W37-T45's half.
216
+ */
217
+ export function proposedEdits(
218
+ state: GridChatState,
219
+ forQuestion: string,
220
+ text: string,
221
+ edits: GridChatEdit[],
222
+ ): GridChatState {
223
+ if (state.pending === null || state.pending !== forQuestion) return state;
224
+ const rows = (edits || []).filter((e) => e && e.rowId && e.field);
225
+ if (!rows.length) return answered(state, forQuestion, { text });
226
+ return {
227
+ turns: [...state.turns, {
228
+ who: "assistant",
229
+ text: String(text || "").trim() || "Here is exactly what this would write.",
230
+ edits: rows,
231
+ }],
232
+ pending: null,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * The inverse of a set of edits: what would put every cell back.
238
+ *
239
+ * β›” CALL THIS BEFORE THE WRITE, NOT AFTER. It reads `before`, which only exists on the proposal
240
+ * the person confirmed; after the write the store holds `after` and there is nothing left to
241
+ * invert from [[capture-the-inverse-before-the-write]].
242
+ * ⚠ `rowLabel` and `fieldLabel` are carried through unchanged, so an undo confirm reads in the
243
+ * same words the original proposal did rather than in keys.
244
+ */
245
+ export function invertEdits(edits: GridChatEdit[]): GridChatEdit[] {
246
+ return (edits || [])
247
+ .filter((e) => e && e.rowId && e.field)
248
+ .map((e) => ({ ...e, before: e.after, after: e.before }));
249
+ }
250
+
251
+ /**
252
+ * A confirmed write landed. The note it appends CARRIES ITS OWN INVERSE, so undo is offered from
253
+ * the transcript rather than from a toast that has already gone.
254
+ */
255
+ export function wrote(state: GridChatState, edits: GridChatEdit[]): GridChatState {
256
+ const rows = (edits || []).filter((e) => e && e.rowId && e.field);
257
+ if (!rows.length) return state;
258
+ return {
259
+ ...state,
260
+ turns: [...state.turns, {
261
+ who: "note",
262
+ text: `Written. ${editSummary(rows)}`,
263
+ undo: invertEdits(rows),
264
+ }],
265
+ };
266
+ }
267
+
268
+ /**
269
+ * The one-line summary a confirm control shows beside the before/after list.
270
+ *
271
+ * ⚠ It counts CELLS and RECORDS separately because they are different risks: twenty cells on one
272
+ * record is a correction, one cell on twenty records is a sweep. A single "20 changes" hides which
273
+ * of those the person is about to approve.
274
+ */
275
+ export function editSummary(edits: GridChatEdit[]): string {
276
+ const rows = (edits || []).filter((e) => e && e.rowId && e.field);
277
+ if (!rows.length) return "Nothing to write.";
278
+ const records = new Set(rows.map((e) => e.rowId)).size;
279
+ const cellWord = rows.length === 1 ? "cell" : "cells";
280
+ const recordWord = records === 1 ? "record" : "records";
281
+ return `${rows.length} ${cellWord} on ${records} ${recordWord}.`;
282
+ }
283
+
284
+ /**
285
+ * What the composer invites the person to type.
286
+ *
287
+ * ⚠ IT NAMES THE DATABASE. A chat docked beside a grid is ambiguous about what it is reading the
288
+ * moment a person has two tabs open, and the fix is one word in the placeholder rather than a
289
+ * second header line.
290
+ */
291
+ export function composerHint(databaseLabel: string, canEdit: boolean): string {
292
+ const where = String(databaseLabel || "").trim() || "this database";
293
+ return canEdit
294
+ ? `Ask about ${where}, or say what to change`
295
+ : `Ask about ${where}`;
296
+ }
297
+
298
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
299
+ // ⭐⭐ THE INTENT LAYER (W37-T42 Β· T43 Β· T44) β€” WHAT THE CHAT KNOWS WITHOUT SPENDING ANYTHING
300
+ //
301
+ // β›”β›” ITS JOB IS TO REFUSE HONESTLY BEFORE A MODEL CALL, NOT TO BE CLEVER. D-339's lesson is that
302
+ // an agent which cannot read the database answers from WORDS ALONE and invents a number; the fix
303
+ // is not a better guess, it is knowing what this database actually has and saying so when the
304
+ // question names something it does not. The panel holds the schema, so that answer costs nothing
305
+ // and cannot be wrong in the direction that matters.
306
+ // ⚠ EVERYTHING HERE IS PURE AND LOCAL. Anything that needs a model, a query or a write goes out
307
+ // through a host callback (C6). This layer only decides WHICH callback, and catches the questions
308
+ // that should never reach one.
309
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
310
+
311
+ /** What the panel decided a sentence was asking for. */
312
+ export type GridChatIntentKind = "schema" | "view" | "script" | "ask";
313
+
314
+ export interface GridChatIntent {
315
+ kind: GridChatIntentKind;
316
+ /** A complete answer the panel can give with NO model call. Present only when it truly can. */
317
+ localAnswer?: string;
318
+ /** Set when the sentence names something this database does not have. */
319
+ refusal?: string;
320
+ /** For `view`: what the panel understood, in the vocabulary the host's callback takes. */
321
+ view?: { sortBy?: string; groupBy?: string; colorBy?: string };
322
+ /** For `view`: what was asked for and CANNOT be expressed, named rather than dropped. */
323
+ cannot?: string;
324
+ }
325
+
326
+ const norm = (s: string) => String(s || "").toLowerCase().trim();
327
+
328
+ /**
329
+ * Which known fields a sentence mentions, by label or by key.
330
+ *
331
+ * ⚠ LABEL FIRST AND LONGEST FIRST. A person types the label they can see ("Sales rep"), not the
332
+ * key (`agent_id`), and matching short keys first would let a field called `n` match the word "in".
333
+ */
334
+ export function fieldsNamed(schema: GridChatField[], said: string): GridChatField[] {
335
+ const text = norm(said);
336
+ if (!text) return [];
337
+ return (schema || [])
338
+ .filter((f) => f && (f.label || f.key))
339
+ .slice()
340
+ .sort((a, b) => (b.label || b.key).length - (a.label || a.key).length)
341
+ .filter((f) => {
342
+ const label = norm(f.label);
343
+ const key = norm(f.key);
344
+ return (label.length > 2 && text.includes(label))
345
+ || (key.length > 2 && text.includes(key));
346
+ });
347
+ }
348
+
349
+ /**
350
+ * A quoted name that this database does not have, if the sentence contains one.
351
+ *
352
+ * β›” ONLY QUOTED NAMES, DELIBERATELY. Guessing that any capitalised phrase is a column would refuse
353
+ * perfectly good questions ("how many are in New York?"), and a refusal that fires on a real
354
+ * question is worse than a model call that answers it. Quotes are the one place a person has said
355
+ * "this is a name" out loud, so the panel can be certain enough to answer instead of asking.
356
+ * ⚠ This is the D-339 half the panel can do alone. Everything unquoted still goes to the host,
357
+ * which knows the data and can say `unanswerable` with better information than a regex has.
358
+ */
359
+ export function unknownFieldNamed(schema: GridChatField[], said: string): string | null {
360
+ const quoted = String(said || "").match(/"[^"]{1,60}"|'[^']{1,60}'/g) || [];
361
+ const known = new Set((schema || []).flatMap((f) => [norm(f.label), norm(f.key)]));
362
+ for (const raw of quoted) {
363
+ const typed = raw.slice(1, -1).trim();
364
+ // β›” COMPARE LOWERCASED, RETURN AS TYPED, AND THE FIRST DRAFT DID NOT. It returned the
365
+ // normalised form, so somebody who wrote "Supplier Rep" was told there is no field called
366
+ // "supplier rep" β€” their own words handed back mangled, which reads as the system having
367
+ // misheard them rather than as an honest answer. Matching is case-insensitive because a person
368
+ // should not have to match a label's capitalisation; the MESSAGE quotes them exactly.
369
+ if (typed && !known.has(norm(typed))) return typed;
370
+ }
371
+ return null;
372
+ }
373
+
374
+ /** The plain-language field list, for a question the panel can answer with no model call at all. */
375
+ export function schemaSentence(schema: GridChatField[], databaseLabel: string): string {
376
+ const where = String(databaseLabel || "").trim() || "this database";
377
+ const names = (schema || []).map((f) => f.label || f.key).filter(Boolean);
378
+ if (!names.length) return where + " has no fields the chat can see.";
379
+ const word = names.length === 1 ? "field" : "fields";
380
+ return where + " has " + names.length + " " + word + ": " + names.join(", ") + ".";
381
+ }
382
+
383
+ /** Sentences that are asking what is here, rather than asking about the data. */
384
+ const SCHEMA_WORDS = ["what fields", "which fields", "what columns", "which columns",
385
+ "list the fields", "list the columns", "what can you see"];
386
+ /** Sentences asking for the VIEW to change rather than for an answer. */
387
+ const VIEW_WORDS = ["sort by", "sorted by", "group by", "grouped by", "colour by", "color by",
388
+ "show me only", "filter"];
389
+ /** Sentences asking for code. */
390
+ const SCRIPT_WORDS = ["build me", "make me", "write a script", "custom view", "dashboard",
391
+ "calculator", "chart"];
392
+
393
+ /**
394
+ * Read a sentence. β›” THE ORDER IS THE POLICY: refuse first, answer locally second, route third.
395
+ *
396
+ * ⚠ A DELIBERATELY SHALLOW READER. It is not trying to understand the question, because the host's
397
+ * model does that. It is trying to catch the two cases where going to the model is WRONG: a name
398
+ * this database does not have (which would come back as an invented number), and a question the
399
+ * panel already knows the answer to (which would be a paid round trip for a list it is holding).
400
+ */
401
+ export function readIntent(
402
+ schema: GridChatField[],
403
+ databaseLabel: string,
404
+ said: string,
405
+ ): GridChatIntent {
406
+ const text = norm(said);
407
+ const where = String(databaseLabel || "").trim() || "this database";
408
+ const missing = unknownFieldNamed(schema, said);
409
+ if (missing) {
410
+ const names = (schema || []).map((f) => f.label || f.key).filter(Boolean);
411
+ return {
412
+ kind: "ask",
413
+ refusal: 'There is no field called "' + missing + '" in ' + where + ". "
414
+ + (names.length
415
+ ? "The fields here are: " + names.join(", ") + "."
416
+ : "This database has no fields the chat can see."),
417
+ };
418
+ }
419
+ if (SCHEMA_WORDS.some((w) => text.includes(w)))
420
+ return { kind: "schema", localAnswer: schemaSentence(schema, databaseLabel) };
421
+
422
+ if (VIEW_WORDS.some((w) => text.includes(w))) {
423
+ const named = fieldsNamed(schema, said);
424
+ const view: { sortBy?: string; groupBy?: string; colorBy?: string } = {};
425
+ if (named.length) {
426
+ if (text.includes("group")) view.groupBy = named[0].key;
427
+ else if (text.includes("colour") || text.includes("color")) view.colorBy = named[0].key;
428
+ else if (text.includes("sort")) view.sortBy = named[0].key;
429
+ }
430
+ // β›” WHAT IT CANNOT SAY, SAID OUT LOUD. `assistant build_view` has no `rhs` in its tool schema,
431
+ // so a condition comparing one field to another is not expressible anywhere in this product
432
+ // [[assistant-build-view-is-a-subset]]. Dropping it silently would leave a person looking at a
433
+ // view that quietly ignored half their sentence, which is the failure this note exists to stop.
434
+ const fieldVsField = named.length >= 2 && /\b(than|versus|vs\.?|compared to)\b/.test(text);
435
+ return {
436
+ kind: "view",
437
+ view: Object.keys(view).length ? view : undefined,
438
+ cannot: fieldVsField
439
+ ? "This build cannot compare one field against another in a view filter, so that part was "
440
+ + "left out. Everything else was applied."
441
+ : undefined,
442
+ };
443
+ }
444
+ if (SCRIPT_WORDS.some((w) => text.includes(w))) return { kind: "script" };
445
+ return { kind: "ask" };
446
+ }
447
+
448
+ /**
449
+ * The sentence shown when a script the chat wrote is run.
450
+ *
451
+ * β›” THE VIEW IS NOT BLANKED ON FAILURE (W37-T44's own `done-when`). A script that raised is a fact
452
+ * about the code, and the code is still there to be edited; replacing the panel with nothing would
453
+ * take away the one thing the person needs in order to fix it.
454
+ */
455
+ export function runSentence(result: GridChatRunResult): string {
456
+ if (result && result.ok) return "That ran. The view is showing what it drew.";
457
+ const why = String((result && result.error) || "").trim();
458
+ return why
459
+ ? "That script did not run: " + why + " The code is still in the editor, so you can change it."
460
+ : "That script did not produce a view. The code is still in the editor, so you can change it.";
461
+ }
web/src/customer-grid/map.css ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* customer-grid/map.css β€” W37-T35/T36, the real-tile basemap and its credit line.
2
+ *
3
+ * β›” WHY THIS FILE EXISTS AND NOT A BLOCK IN index.css: contract C8. `index.css` is 12k+ lines and
4
+ * belongs to lane A this wave, so every other lane adds styles in its own per-surface stylesheet
5
+ * imported by its own component. `scriptView.css` and `navExtras.css` are the precedent.
6
+ *
7
+ * ⚠ NOTHING HERE MAY ADD `vector-effect: non-scaling-stroke` TO A `.cg-map*` RULE. The map divides
8
+ * every stroke by the zoom in the markup (`hairline`), so a CSS rule that also cancels the scale
9
+ * double-compensates, and that is the wave-9 "blurry when zoomed in" bug. `map.test.ts` scans
10
+ * `index.css` for it and cannot see this file, so `verify_map.py::css_scan` applies the same rule
11
+ * here. A new stylesheet that escapes an existing gate is a gate quietly getting smaller.
12
+ */
13
+
14
+ /* --- the tile layer -------------------------------------------------------
15
+ *
16
+ * Tiles are <image> elements INSIDE the same transformed <g> the pins live in, so they pan and
17
+ * zoom with the geography for free and no second camera can drift out of step with the first.
18
+ */
19
+
20
+ .cg-map-tiles {
21
+ /* A raster basemap is a photograph of a map, so it must never intercept a gesture: every
22
+ pointer event belongs to the pins and the marquee above it. */
23
+ pointer-events: none;
24
+ /* ⭐ THE DARK-MODE SEAM, and it is deliberately INERT today. This app ships no dark theme (there
25
+ is not one `prefers-color-scheme` rule in `index.css`), so an inverting media query here would
26
+ turn the basemap negative for anyone whose OS is dark while the rest of the product stayed
27
+ light, which is a defect rather than a feature. Setting `--cg-tile-filter` is one line on the
28
+ day a theme arrives. */
29
+ filter: var(--cg-tile-filter, none);
30
+ }
31
+
32
+ .cg-map-tile {
33
+ /* Tile edges are cut to the pixel, so any smoothing at the seam paints a visible grid over the
34
+ whole basemap. */
35
+ image-rendering: -webkit-optimize-contrast;
36
+ }
37
+
38
+ /* The tile the browser has not fetched yet. Without this a pan shows the page background through
39
+ the gaps, which reads as holes in the map rather than as loading. */
40
+ .cg-map-tilebed {
41
+ fill: #eef2f7;
42
+ }
43
+
44
+ /* --- attribution ----------------------------------------------------------
45
+ *
46
+ * β›” THIS IS A LICENCE TERM, NOT DECORATION. OpenStreetMap's terms require visible credit wherever
47
+ * its tiles are shown, and the same applies to whatever provider replaces it: the text and the
48
+ * link both come from the server's provider config, so a swap carries its own credit with it and
49
+ * cannot leave the previous one on screen.
50
+ */
51
+
52
+ .cg-map-credit {
53
+ position: absolute;
54
+ right: 6px;
55
+ bottom: 4px;
56
+ z-index: 2;
57
+ padding: 1px 6px;
58
+ border-radius: 4px;
59
+ background: rgba(255, 255, 255, 0.82);
60
+ /* β›” A TOKEN, NOT A LITERAL (R5, and A-53 caught the 10.5px this replaced). A hardcoded size
61
+ becomes THE size the day the type scale moves, so a stylesheet that invents one is a second
62
+ source of truth. `--lp-fs-4xs` is the 10px step, which is what a credit line wants. */
63
+ font: 400 var(--lp-fs-4xs)/1.5 Inter, system-ui, sans-serif;
64
+ color: #5b6270;
65
+ letter-spacing: 0.1px;
66
+ }
67
+
68
+ .cg-map-credit a {
69
+ color: #4a5568;
70
+ text-decoration: none;
71
+ }
72
+
73
+ .cg-map-credit a:hover {
74
+ text-decoration: underline;
75
+ }
76
+
77
+ /* --- the route line (T36) -------------------------------------------------
78
+ *
79
+ * The ROAD geometry, as distinct from `.cg-map-route-line`, which is the straight-line plan drawn
80
+ * from arithmetic alone. Two different claims about the same stops, so two different looks: the
81
+ * road route is solid and confident, the straight-line plan stays dashed and provisional.
82
+ */
83
+
84
+ .cg-map-road {
85
+ fill: none;
86
+ stroke: #1f4e78;
87
+ stroke-opacity: 0.85;
88
+ stroke-linecap: round;
89
+ stroke-linejoin: round;
90
+ }
91
+
92
+ .cg-map-road-halo {
93
+ fill: none;
94
+ stroke: #ffffff;
95
+ stroke-opacity: 0.75;
96
+ stroke-linecap: round;
97
+ stroke-linejoin: round;
98
+ }
99
+
100
+ /* --- the ordered stop list (T36) ------------------------------------------
101
+ *
102
+ * The route's stops as a row of numbered chips with move controls. Inline in the map bar rather
103
+ * than in a panel: reordering is a thing people do WHILE looking at the map, and a dialog over the
104
+ * picture would hide the answer they are reordering against.
105
+ */
106
+
107
+ .cg-map-stoplist {
108
+ display: inline-flex;
109
+ align-items: center;
110
+ gap: 4px;
111
+ flex-wrap: wrap;
112
+ max-width: 460px;
113
+ }
114
+
115
+ .cg-map-stopitem {
116
+ display: inline-flex;
117
+ align-items: center;
118
+ gap: 2px;
119
+ padding: 1px 2px 1px 4px;
120
+ border: 1px solid #dfe3ea;
121
+ border-radius: 11px;
122
+ background: #fff;
123
+ font: 500 var(--lp-fs-3xs)/1.6 Inter, system-ui, sans-serif;
124
+ color: #2c3340;
125
+ }
126
+
127
+ .cg-map-stopn {
128
+ display: inline-grid;
129
+ place-items: center;
130
+ width: 15px;
131
+ height: 15px;
132
+ border-radius: 50%;
133
+ background: #1f4e78;
134
+ color: #fff;
135
+ font-size: var(--lp-fs-5xs);
136
+ font-weight: 600;
137
+ }
138
+
139
+ .cg-map-stopname {
140
+ max-width: 108px;
141
+ overflow: hidden;
142
+ text-overflow: ellipsis;
143
+ white-space: nowrap;
144
+ }
145
+
146
+ .cg-map-stopmove {
147
+ display: inline-grid;
148
+ place-items: center;
149
+ width: 15px;
150
+ height: 15px;
151
+ padding: 0;
152
+ border: 0;
153
+ border-radius: 3px;
154
+ background: none;
155
+ color: #6b7280;
156
+ cursor: pointer;
157
+ }
158
+
159
+ .cg-map-stopmove:hover:not(:disabled) {
160
+ background: #eef2f7;
161
+ color: #1f4e78;
162
+ }
163
+
164
+ .cg-map-stopmove:disabled {
165
+ /* Visible but inert at the ends of the list. Removing the control instead would make the row
166
+ change width as a stop moves, so the button you are aiming at walks away from the cursor. */
167
+ opacity: 0.28;
168
+ cursor: default;
169
+ }
170
+
171
+ .cg-map-stopmove svg {
172
+ width: 11px;
173
+ height: 11px;
174
+ fill: none;
175
+ stroke: currentColor;
176
+ stroke-width: 1.7;
177
+ stroke-linecap: round;
178
+ stroke-linejoin: round;
179
+ }
180
+
181
+ /* --- the address lookup (T37) ---------------------------------------------
182
+ *
183
+ * β›” THE PROGRESS LINE IS A REQUIREMENT, NOT A COURTESY. The free geocoder allows about one
184
+ * request a second, so a run over a real selection takes minutes. A spinner would make that read
185
+ * as a hang; a count that moves reads as work. The note beside it says WHY it is slow, because
186
+ * "this product is slow" and "this service asks us to be polite" are different facts.
187
+ */
188
+
189
+ .cg-map-geo {
190
+ display: inline-flex;
191
+ align-items: center;
192
+ gap: 8px;
193
+ }
194
+
195
+ .cg-map-geo-run {
196
+ font: 500 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
197
+ color: #1f4e78;
198
+ }
199
+
200
+ .cg-map-geo-note {
201
+ font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
202
+ color: #6b7280;
203
+ }
204
+
205
+ /* The lookup offered from the EMPTY state. A map with nothing on it is exactly where this feature
206
+ is needed, and the control used to be unreachable there (see MapView's `geoControl`). */
207
+ .cg-map-geo-empty {
208
+ margin-top: 10px;
209
+ }
web/src/customer-grid/mapProjection.ts CHANGED
@@ -1,632 +1,677 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / mapProjection.ts
3
- // Wave-8 I2/I6 β€” the map's geometry, split from MapView so it can be tested
4
- // under node without React.
5
- //
6
- // WHY THIS EXISTS AS ITS OWN LAYER. The wave-7 map derived its projection from
7
- // the DATA's bounding box (MapView.tsx:96-118, an equirectangular fit). That is
8
- // fine for a static scatter and wrong for everything wave 8 asks of it:
9
- //
10
- // - the map re-projected on every filter change, so the whole picture jumped
11
- // whenever a condition was edited;
12
- // - box-select needs a stable screen<->point mapping DURING a drag, which a
13
- // projection memoised over `points` is not;
14
- // - a data-fit projection distorts real geography β€” state outlines drawn
15
- // through it visibly skew once you zoom into one metro.
16
- //
17
- // So the two concerns are separated:
18
- // PROJECTION fixed Web Mercator, world -> a fixed square. Never changes.
19
- // VIEW {k, tx, ty} β€” zoom and pan, an affine transform ON TOP.
20
- // The old data-fit becomes the INITIAL VIEW value rather than the projection,
21
- // which is what makes zoom/pan and hit-testing tractable at all.
22
- // ---------------------------------------------------------------------------
23
-
24
- /** Base resolution of the projected world square, in SVG user units. */
25
- export const WORLD = 4096;
26
-
27
- /** Mercator blows up at the poles; every web map clamps. */
28
- const MAX_LAT = 85.05112878;
29
-
30
- export interface Pt {
31
- x: number;
32
- y: number;
33
- }
34
-
35
- /** A pan/zoom transform: screen = view.t + view.k * projected. */
36
- export interface View {
37
- k: number;
38
- tx: number;
39
- ty: number;
40
- }
41
-
42
- /** lon/lat -> the fixed projected plane (0..WORLD on both axes). */
43
- export function project(lon: number, lat: number): Pt {
44
- const clamped = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
45
- const rad = (clamped * Math.PI) / 180;
46
- const x = (lon + 180) / 360;
47
- const y = 0.5 - Math.log(Math.tan(Math.PI / 4 + rad / 2)) / (2 * Math.PI);
48
- return { x: x * WORLD, y: y * WORLD };
49
- }
50
-
51
- /** The fixed projected plane -> lon/lat. The exact inverse of `project`. */
52
- export function unproject(p: Pt): { lon: number; lat: number } {
53
- const t = 0.5 - p.y / WORLD;
54
- const rad = 2 * Math.atan(Math.exp(2 * Math.PI * t)) - Math.PI / 2;
55
- return { lon: (p.x / WORLD) * 360 - 180, lat: (rad * 180) / Math.PI };
56
- }
57
-
58
- /** Screen -> the fixed projected plane, through a view. The inverse of `toScreen`. */
59
- export function fromScreen(s: Pt, view: View): Pt {
60
- return { x: (s.x - view.tx) / view.k, y: (s.y - view.ty) / view.k };
61
- }
62
-
63
- // -------------------------------------------------------- the Google hand-off
64
- //
65
- // The honest answer to "I need to see the actual street". A licence-free offline
66
- // vector basemap stops at roughly metro scale (see BASEMAP_DETAIL_K), and the
67
- // alternative β€” bundling a tile renderer β€” costs 270 KB gz, an API key or a
68
- // hosted planet file, and sends every customer's coordinates to a third party on
69
- // every pan. A LINK costs none of that: nothing ships, nothing is fetched, and
70
- // the coordinates travel only if the user deliberately clicks.
71
- //
72
- // ⚠ Every one of these must be rendered with rel="noopener noreferrer" β€” that
73
- // strips the Referer, so the destination never learns which tenant or which
74
- // deployment the click came from, and it denies the opened tab window.opener.
75
-
76
- /**
77
- * Google Maps' zoom level for our zoom `k`.
78
- *
79
- * Google measures a world 256 * 2^z px wide; ours is WORLD units wide, painted
80
- * at `k` and then ~1.08 CSS px per unit. Equating the two:
81
- * 256 * 2^z = WORLD * k * 1.08 -> z = log2(WORLD * 1.08 * k / 256)
82
- * Clamped to Google's own 0..21. A fitted US book (k~1.36) hands over z5, the
83
- * country; the detail cap (k=32) hands over z9, a metro β€” i.e. the hand-off
84
- * starts exactly where our basemap runs out, which is the point of it.
85
- */
86
- export function googleZoomForK(k: number): number {
87
- if (!Number.isFinite(k) || k <= 0) return 4;
88
- const z = Math.log2((WORLD * 1.08 * k) / 256);
89
- return Math.max(0, Math.min(21, Math.round(z)));
90
- }
91
-
92
- /** True only for a coordinate Google can actually be sent. */
93
- export function isPlottable(lat: number | null, lon: number | null): boolean {
94
- return (
95
- lat != null && lon != null &&
96
- Number.isFinite(lat) && Number.isFinite(lon) &&
97
- Math.abs(lat) <= 90 && Math.abs(lon) <= 180
98
- );
99
- }
100
-
101
- /**
102
- * A dropped pin at exactly the coordinate WE plotted.
103
- *
104
- * ⚠ Deliberately by lat/lon and never by customer name or address: a name search
105
- * can resolve somewhere else entirely, and then the app's map and the link
106
- * disagree about where a customer is. The pin is the geocode; the link is the
107
- * same geocode. `api=1` is Google's documented, stable URL contract.
108
- */
109
- export function googleMapsUrl(lat: number, lon: number, zoom?: number): string {
110
- const at = `${lat.toFixed(6)},${lon.toFixed(6)}`;
111
- return zoom == null
112
- ? `https://www.google.com/maps/search/?api=1&query=${at}`
113
- : `https://www.google.com/maps/@${at},${Math.round(zoom)}z`;
114
- }
115
-
116
- /** Directions to a customer β€” the version a rep on the road actually wants. */
117
- export function googleDirectionsUrl(lat: number, lon: number): string {
118
- return `https://www.google.com/maps/dir/?api=1&destination=${lat.toFixed(6)},${lon.toFixed(6)}`;
119
- }
120
-
121
- /** Projected point -> screen, through a view. */
122
- export function toScreen(p: Pt, view: View): Pt {
123
- return { x: view.tx + p.x * view.k, y: view.ty + p.y * view.k };
124
- }
125
-
126
- /**
127
- * The view that frames `pts` inside a w x h viewport with `pad` px of margin.
128
- * `minSpan` stops a single point (or one city) from zooming to street level:
129
- * one customer should still look like a PLACE, not a full-bleed dot β€” the
130
- * wave-7 behaviour, kept.
131
- */
132
- export function fitView(
133
- pts: Pt[],
134
- w: number,
135
- h: number,
136
- pad = 40,
137
- minSpan = WORLD / 90
138
- ): View | null {
139
- if (pts.length === 0) return null;
140
- let minX = Infinity;
141
- let maxX = -Infinity;
142
- let minY = Infinity;
143
- let maxY = -Infinity;
144
- for (const p of pts) {
145
- minX = Math.min(minX, p.x);
146
- maxX = Math.max(maxX, p.x);
147
- minY = Math.min(minY, p.y);
148
- maxY = Math.max(maxY, p.y);
149
- }
150
- let spanX = Math.max(maxX - minX, minSpan);
151
- let spanY = Math.max(maxY - minY, minSpan);
152
- const cx = (minX + maxX) / 2;
153
- const cy = (minY + maxY) / 2;
154
- spanX *= 1.16; // breathing room so edge pins are not on the frame
155
- spanY *= 1.16;
156
- const k = Math.min((w - pad * 2) / spanX, (h - pad * 2) / spanY);
157
- return { k, tx: w / 2 - cx * k, ty: h / 2 - cy * k };
158
- }
159
-
160
- /** Zoom by `factor` while holding the point under (mx, my) still β€” the gesture
161
- * every map has and the reason zoom cannot be a plain scale on the group. */
162
- export function zoomAt(view: View, factor: number, mx: number, my: number, kMin: number, kMax: number): View {
163
- const k = Math.max(kMin, Math.min(kMax, view.k * factor));
164
- if (k === view.k) return view;
165
- return {
166
- k,
167
- tx: mx - ((mx - view.tx) * k) / view.k,
168
- ty: my - ((my - view.ty) * k) / view.k,
169
- };
170
- }
171
-
172
- // ---------------------------------------------------------------- stroke width
173
- //
174
- // ⚠ THE MAP HAS EXACTLY ONE STROKE-WIDTH MECHANISM, AND THIS IS IT.
175
- //
176
- // Everything painted inside `<g transform="... scale(k)">` is scaled by k, so a
177
- // line meant to read 1.1 px on screen must be handed 1.1/k. That is `hairline`.
178
- // SVG offers a SECOND way to the same end β€” the CSS `vector-effect:
179
- // non-scaling-stroke`, which makes the renderer ignore the transform when it
180
- // strokes. Either works. Using BOTH cancels the zoom twice, and the line then
181
- // gets THINNER the further you zoom IN.
182
- //
183
- // Wave 9 found exactly that, and it is the "blurry when zoomed" bug the owner
184
- // reported. `.cg-map-land` carried the CSS property AND `hair(1.1)`. Measured
185
- // against the real constants (WORLD 4096, viewBox 1000x620, ~1.10 CSS px per
186
- // viewBox unit, fitted k ~1.359): the coastline painted 0.89 CSS px at the
187
- // fitted view, 0.445 at 2x, 0.089 at 10x and 0.015 at the zoom cap β€” below
188
- // ~0.5 px a stroke is an anti-aliased smear and below ~0.2 px a ghost. The
189
- // graticule, the lakes and the pins were all correct, because they use
190
- // `hairline` alone. The bug hid precisely because two idioms coexisted on
191
- // different elements of the same picture.
192
- //
193
- // So the rule is singular now, and it is GATED rather than merely commented:
194
- // `paintedStroke` must be flat across the whole zoom range, and
195
- // `scalingConflicts` re-reads the real stylesheet so the CSS half cannot come
196
- // back either. If a future element genuinely wants `non-scaling-stroke`, that
197
- // is a deliberate change to this rule β€” change the comment and the gate, not
198
- // just the stylesheet.
199
-
200
- /** Stroke width to hand an element drawn INSIDE the zoomed group. */
201
- export function hairline(basePx: number, k: number): number {
202
- return basePx / k;
203
- }
204
-
205
- /**
206
- * A dash pattern for a line drawn INSIDE the zoomed group.
207
- *
208
- * ⚠ Exactly the same trap as stroke width, and it caught me: `stroke-dasharray`
209
- * in CSS is in USER units, so inside `scale(k)` a "5 4" dash becomes 5k on and
210
- * 4k off. At a regional fit that is a 70 px dash and a 55 px gap β€” the route
211
- * line renders as a few disconnected strokes floating between the stops, which
212
- * reads as a broken polyline rather than a scaled dash. Every length handed to
213
- * the transformed group goes through `hairline`, dashes included.
214
- */
215
- export function dashPattern(onPx: number, offPx: number, k: number): string {
216
- return `${hairline(onPx, k)} ${hairline(offPx, k)}`;
217
- }
218
-
219
- /**
220
- * What the renderer actually paints, in screen units, for a `hairline` width at
221
- * zoom k β€” i.e. the attribute multiplied by the group's scale. Not circular: it
222
- * models the SVG pipeline, which is the thing the invariant is about. It must
223
- * return `basePx` at EVERY k, and the gate sweeps the range to prove it.
224
- */
225
- 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
232
- * `hairline`. Returns the offending selectors (empty = clean) so the gate can
233
- * name them. Comments are stripped first so a commented-out example cannot trip
234
- * it.
235
- */
236
- export function scalingConflicts(css: string): string[] {
237
- const bad: string[] = [];
238
- for (const chunk of css.replace(/\/\*[\s\S]*?\*\//g, "").split("}")) {
239
- const brace = chunk.indexOf("{");
240
- if (brace < 0) continue;
241
- const selector = chunk.slice(0, brace);
242
- if (!selector.includes(".cg-map")) continue;
243
- if (/vector-effect\s*:[^;]*non-scaling/i.test(chunk.slice(brace + 1)))
244
- bad.push(selector.trim().replace(/\s+/g, " "));
245
- }
246
- return bad;
247
- }
248
-
249
- /**
250
- * How far in the VENDORED basemap is still worth showing, as an absolute zoom.
251
- *
252
- * DERIVED, not chosen by feel. The geometry in mapGeometry.ts is Natural Earth
253
- * 50m simplified at 0.02 degrees, giving a ~13 km median vertex spacing. With
254
- * the viewBox painting ~1.08 CSS px per unit at latitude 39, one screen pixel is
255
- * ~7041/k metres, so a 13 km segment measures ~1.85*k pixels. At k = 32 that is
256
- * a ~60 px straight run and ~10 px of simplification error β€” coarse but still
257
- * unmistakably a shape. Past it the coastline degenerates into long straight
258
- * lines and the user is zooming into an empty polygon, which is the opposite of
259
- * the sharpness this was asked for.
260
- *
261
- * ⚠ This is an HONESTY limit and it is the reason the map stops where it does:
262
- * street-level detail is not available from any licence-free offline vector set.
263
- * It needs a tile provider β€” a runtime network dependency, an API key and an
264
- * attribution obligation β€” which is the owner's call, not a silent addition.
265
- */
266
- export const BASEMAP_DETAIL_K = 32;
267
-
268
- /**
269
- * The camera's zoom range for a given fitted zoom.
270
- *
271
- * `kMin` β€” zoom OUT to four times the data's own extent for context, but never
272
- * past the point where the whole projected world already fits: beyond that
273
- * there is nothing further to reveal, only empty margin. (The wave-8 rule was a
274
- * flat `fit.k * 0.6`, which locked you in at barely half a step out.)
275
- *
276
- * `kMax` β€” how far IN. This is a HONESTY limit as much as a UX one: zooming
277
- * past the resolution of the basemap actually vendored just shows a bigger
278
- * empty polygon, so `detailK` caps it. Pass `Infinity` for no cap.
279
- */
280
- export function zoomLimits(
281
- fitK: number,
282
- viewH: number,
283
- detailK = Infinity
284
- ): { kMin: number; kMax: number } {
285
- const worldFit = viewH / WORLD;
286
- const kMin = Math.min(fitK, Math.max(worldFit, fitK * 0.25));
287
- return { kMin, kMax: Math.max(fitK, Math.min(fitK * 60, detailK)) };
288
- }
289
-
290
- /**
291
- * Graticule opacity at zoom k. The 10-degree grid earns its place on a
292
- * zoomed-OUT world view, where it is the only thing giving scale. Once wave 9
293
- * vendored real state borders it became noise the moment you zoom into the
294
- * country: two competing line systems over the same picture. So it fades out
295
- * before the borders take over rather than fighting them.
296
- */
297
- export function graticuleOpacity(k: number): number {
298
- return Math.max(0, Math.min(1, (1.5 - k) / 0.9));
299
- }
300
-
301
- /**
302
- * Where to put a hover card of `w` x `h` for a pin at (sx, sy), in screen space.
303
- *
304
- * Prefers ABOVE the pin, flips below when there is no room, and clamps inside
305
- * the viewport on both axes β€” a card that runs off the frame is a card whose
306
- * numbers cannot be read, and the pins nearest the edge are exactly the ones a
307
- * territory question is usually about.
308
- */
309
- export function cardBox(
310
- sx: number, sy: number, w: number, h: number,
311
- viewW: number, viewH: number, gap = 14, pad = 6
312
- ): Pt {
313
- const above = sy - h - gap;
314
- // ⚠ Both axes clamp UNCONDITIONALLY. Clamping only the "flipped below" branch
315
- // looks right and is not: a pin panned off the BOTTOM of the frame still has
316
- // acres of room "above" it, passes the room check, and places the card far
317
- // below the viewport. Caught by the every-corner leg, never by a screenshot.
318
- return {
319
- x: Math.max(pad, Math.min(viewW - w - pad, sx - w / 2)),
320
- y: Math.max(pad, Math.min(viewH - h - pad, above >= pad ? above : sy + gap)),
321
- };
322
- }
323
-
324
- // ---------------------------------------------------------- route planning
325
- //
326
- // I18-R. Sequencing a visit order is a TRAVELLING SALESMAN problem, and it is
327
- // pure arithmetic: no data, no service, no dependency, no cost. The half that
328
- // costs money is turning an order into ROAD distances, and this deliberately
329
- // does not attempt that β€” see `routeNote` and the mailbox's tier analysis
330
- // (Google's route matrix bills per element: a 30-stop run is ~$4.50, a full
331
- // 1,550-customer matrix ~$12,000).
332
-
333
- /** A stop, in the coordinates the host geocoded β€” never projected units. */
334
- export interface GeoStop {
335
- lat: number;
336
- lon: number;
337
- }
338
-
339
- /**
340
- * ⚠ THE SEAM. Everything below takes distance as a FUNCTION and knows nothing
341
- * else about it. Swapping in real road distances later (a self-hosted OSRM
342
- * matrix, precomputed and cached) is then a one-line change at the call site
343
- * rather than a rewrite of the sequencer.
344
- */
345
- export type StopDistance = (a: GeoStop, b: GeoStop) => number;
346
-
347
- const EARTH_R_KM = 6371.0088;
348
-
349
- /**
350
- * Great-circle distance in km.
351
- *
352
- * ⚠ MUST be computed on lon/lat, NOT as euclidean distance in projected WORLD
353
- * units. Mercator stretches by 1/cos(latitude): across this book's range
354
- * (lat 25-49) that is a 0.91 -> 0.66 swing, ~38%, which systematically ranks
355
- * north-south pairs against east-west ones. The resulting route looks entirely
356
- * plausible and is wrong, which is the worst kind of wrong.
357
- */
358
- export const haversineKm: StopDistance = (a, b) => {
359
- const rad = Math.PI / 180;
360
- const dLat = (b.lat - a.lat) * rad;
361
- const dLon = (b.lon - a.lon) * rad;
362
- const s =
363
- Math.sin(dLat / 2) ** 2 +
364
- Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
365
- return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(s)));
366
- };
367
-
368
- /** Total length of a tour. `roundTrip` adds the closing edge back to the start. */
369
- export function tourLength(
370
- order: number[], stops: GeoStop[], dist: StopDistance, roundTrip = false
371
- ): number {
372
- if (order.length < 2) return 0;
373
- let km = 0;
374
- for (let i = 1; i < order.length; i++) km += dist(stops[order[i - 1]], stops[order[i]]);
375
- if (roundTrip) km += dist(stops[order[order.length - 1]], stops[order[0]]);
376
- return km;
377
- }
378
-
379
- /** Greedy construction: from `start`, repeatedly hop to the nearest unvisited stop. */
380
- export function nearestNeighbourOrder(
381
- stops: GeoStop[], dist: StopDistance, start = 0
382
- ): number[] {
383
- const n = stops.length;
384
- if (n === 0) return [];
385
- const from = Math.max(0, Math.min(n - 1, Math.round(start) || 0));
386
- const seen = new Array<boolean>(n).fill(false);
387
- const order = [from];
388
- seen[from] = true;
389
- for (let k = 1; k < n; k++) {
390
- const last = order[order.length - 1];
391
- let best = -1;
392
- let bestD = Infinity;
393
- for (let i = 0; i < n; i++) {
394
- if (seen[i]) continue;
395
- const d = dist(stops[last], stops[i]);
396
- if (d < bestD) { bestD = d; best = i; }
397
- }
398
- if (best < 0) break;
399
- seen[best] = true;
400
- order.push(best);
401
- }
402
- return order;
403
- }
404
-
405
- /**
406
- * 2-opt: repeatedly reverse a segment when doing so shortens the tour.
407
- *
408
- * Index 0 is PINNED β€” it is the origin the user chose, and silently re-rooting
409
- * their route would be a worse bug than a slightly longer one. Only strictly
410
- * improving moves are accepted, which is what makes "never returns a tour
411
- * longer than the one it was given" a guarantee the gate can assert rather than
412
- * a hope.
413
- */
414
- export function twoOptOrder(
415
- order: number[], stops: GeoStop[], dist: StopDistance,
416
- roundTrip = false, maxPasses = 24
417
- ): number[] {
418
- const n = order.length;
419
- const cur = order.slice();
420
- if (n < 4) return cur;
421
- const D = (a: number, b: number) => dist(stops[a], stops[b]);
422
- for (let pass = 0; pass < maxPasses; pass++) {
423
- let improved = false;
424
- for (let i = 1; i < n - 1; i++) {
425
- for (let j = i + 1; j < n; j++) {
426
- const a = cur[i - 1], b = cur[i], c = cur[j];
427
- let delta: number;
428
- if (j === n - 1 && !roundTrip) {
429
- // Reversing the tail of an OPEN path only re-hangs the entry edge:
430
- // there is no closing edge to pay for.
431
- delta = D(a, c) - D(a, b);
432
- } else {
433
- const d = cur[(j + 1) % n];
434
- delta = D(a, c) + D(b, d) - D(a, b) - D(c, d);
435
- }
436
- if (delta < -1e-9) {
437
- for (let lo = i, hi = j; lo < hi; lo++, hi--) {
438
- const t = cur[lo]; cur[lo] = cur[hi]; cur[hi] = t;
439
- }
440
- improved = true;
441
- }
442
- }
443
- }
444
- if (!improved) break;
445
- }
446
- return cur;
447
- }
448
-
449
- /** Construct then improve. Returns the visit order and its length. */
450
- export function planRoute(
451
- stops: GeoStop[], dist: StopDistance,
452
- opts: { start?: number; roundTrip?: boolean } = {}
453
- ): { order: number[]; km: number } {
454
- const roundTrip = !!opts.roundTrip;
455
- if (stops.length === 0) return { order: [], km: 0 };
456
- const nn = nearestNeighbourOrder(stops, dist, opts.start ?? 0);
457
- const order = twoOptOrder(nn, stops, dist, roundTrip);
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
464
- // cost NOTHING, but they carry at most 9 waypoints on desktop and 3 on mobile
465
- // browsers, inside a 2,048-character URL.
466
-
467
- export const ROUTE_WAYPOINTS_DESKTOP = 9;
468
- export const ROUTE_WAYPOINTS_MOBILE = 3;
469
- export const MAX_MAPS_URL = 2048;
470
-
471
- /** How many STOPS can ride the free URL: the waypoints plus the two endpoints
472
- * (a round trip returns to its origin, so the origin is not also a waypoint). */
473
- export function routeStopCap(coarsePointer: boolean, roundTrip = false): number {
474
- const w = coarsePointer ? ROUTE_WAYPOINTS_MOBILE : ROUTE_WAYPOINTS_DESKTOP;
475
- return roundTrip ? w + 1 : w + 2;
476
- }
477
-
478
- /**
479
- * Build the free Google directions URL for an ORDERED list of stops.
480
- *
481
- * Returns `used` alongside the url so the caller can say "first 11 of 23" on
482
- * screen. It never silently drops a stop; truncation is a fact the UI states
483
- * ([[no-unverifiable-aggregates]]). Shrinks further if the character budget
484
- * binds, which it can with a long tail of 6-dp coordinates.
485
- */
486
- export function googleRouteUrl(
487
- stops: GeoStop[],
488
- opts: { roundTrip?: boolean; coarsePointer?: boolean } = {}
489
- ): { url: string; used: number } | null {
490
- if (stops.length < 2) return null;
491
- const roundTrip = !!opts.roundTrip;
492
- const at = (s: GeoStop) => `${s.lat.toFixed(6)},${s.lon.toFixed(6)}`;
493
- let used = Math.min(stops.length, routeStopCap(!!opts.coarsePointer, roundTrip));
494
- for (;;) {
495
- const chosen = stops.slice(0, used);
496
- const origin = chosen[0];
497
- const dest = roundTrip ? origin : chosen[chosen.length - 1];
498
- const mids = roundTrip ? chosen.slice(1) : chosen.slice(1, -1);
499
- const url =
500
- `https://www.google.com/maps/dir/?api=1&origin=${at(origin)}` +
501
- `&destination=${at(dest)}` +
502
- (mids.length ? `&waypoints=${mids.map(at).join("|")}` : "") +
503
- `&travelmode=driving`;
504
- if (url.length <= MAX_MAPS_URL || used <= 2) return { url, used };
505
- used -= 1;
506
- }
507
- }
508
-
509
- /** An svg's own bounding box in client px β€” the `getBoundingClientRect()` half
510
- * of the conversion below, taken as plain data so the maths stays testable
511
- * without a DOM. */
512
- export interface FrameRect {
513
- left: number;
514
- top: number;
515
- width: number;
516
- height: number;
517
- }
518
-
519
- /**
520
- * Client px -> the svg's own user-space coords. Every pointer gesture on the
521
- * map β€” marquee, rubber band, cursor-anchored wheel zoom β€” starts here.
522
- *
523
- * ⚠ It is NOT `(client / frame) * viewBox`. MapView paints with
524
- * `preserveAspectRatio="xMidYMid meet"`, so the viewBox is scaled UNIFORMLY by
525
- * the tighter of the two axes and then CENTRED, leaving a letterbox band on the
526
- * other axis. Wave 8 (`f6f45a6`) bolted a stretch-to-fill conversion onto that
527
- * `meet` svg: the binding axis came out right and the other carried BOTH a
528
- * wrong scale and a missing offset. On a 1400x600 frame the full 0..1000
529
- * x-range collapsed into ~154..846 β€” so a marquee at either edge caught
530
- * NOTHING, the rubber band lagged the cursor by ~150 px, and cursor-anchored
531
- * zoom drifted, all from this one function. `zoomAt` and the hit test were
532
- * always correct; they were being handed the wrong point.
533
- *
534
- * β›” The result is deliberately NOT clamped to the viewBox. A drag that begins
535
- * in the letterbox band is a real gesture β€” everything from the painted edge
536
- * inward must still be caught β€” and clamping re-breaks exactly the edge
537
- * marquee this exists to fix.
538
- */
539
- export function clientToUser(
540
- clientX: number,
541
- clientY: number,
542
- rect: FrameRect,
543
- viewW: number,
544
- viewH: number
545
- ): Pt {
546
- const s = Math.min(rect.width / viewW, rect.height / viewH);
547
- if (!(s > 0) || !Number.isFinite(s)) return { x: 0, y: 0 };
548
- const offX = (rect.width - viewW * s) / 2;
549
- const offY = (rect.height - viewH * s) / 2;
550
- return { x: (clientX - rect.left - offX) / s, y: (clientY - rect.top - offY) / s };
551
- }
552
-
553
- /**
554
- * Is `p` inside the closed polygon `poly`? Even-odd ray casting (the crossing
555
- * number), in the same user-space units `toScreen` returns.
556
- *
557
- * β›” It is NOT a bounding-box test, and that difference IS the feature. A lasso
558
- * drawn as a C or a horseshoe must EXCLUDE whatever sits in its mouth β€”
559
- * otherwise it is the rectangle marquee wearing a lasso's name, which is the
560
- * one thing a person drawing a loop by hand would never expect. The gate
561
- * asserts exactly that case, over a shape whose bounding box gives a different
562
- * answer: a convex test polygon would make the control inert.
563
- *
564
- * The ray is cast along +x from `p`, and each edge that straddles `p.y` and
565
- * crosses to the LEFT of nothing / RIGHT of `p.x` flips the parity. A point
566
- * exactly on a vertex or an edge may fall either way: this selects pins under a
567
- * hand-drawn path, where a half-pixel tie carries no meaning and an epsilon to
568
- * break it would be a number nobody could justify.
569
- */
570
- export function pointInPolygon(p: Pt, poly: Pt[]): boolean {
571
- if (poly.length < 3) return false;
572
- let inside = false;
573
- for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
574
- const a = poly[i];
575
- const b = poly[j];
576
- const straddles = a.y > p.y !== b.y > p.y;
577
- if (straddles && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) inside = !inside;
578
- }
579
- return inside;
580
- }
581
-
582
- /**
583
- * The axis-aligned bounds of a freehand path, in `normRect`'s own {x0,y0,x1,y1}
584
- * shape so one mis-click rule can measure either gesture. An empty path is a
585
- * zero box rather than an Infinity one β€” the caller's "did this move at all"
586
- * test must answer NO, not NaN.
587
- */
588
- export function pathBounds(pts: Pt[]) {
589
- if (pts.length === 0) return { x0: 0, y0: 0, x1: 0, y1: 0 };
590
- let x0 = pts[0].x;
591
- let y0 = pts[0].y;
592
- let x1 = pts[0].x;
593
- let y1 = pts[0].y;
594
- for (const p of pts) {
595
- if (p.x < x0) x0 = p.x;
596
- if (p.x > x1) x1 = p.x;
597
- if (p.y < y0) y0 = p.y;
598
- if (p.y > y1) y1 = p.y;
599
- }
600
- return { x0, y0, x1, y1 };
601
- }
602
-
603
- /** Screen-space rect (any two corners) -> normalized {x0,y0,x1,y1}. */
604
- export function normRect(ax: number, ay: number, bx: number, by: number) {
605
- return {
606
- x0: Math.min(ax, bx),
607
- y0: Math.min(ay, by),
608
- x1: Math.max(ax, bx),
609
- y1: Math.max(ay, by),
610
- };
611
- }
612
-
613
- /**
614
- * Bubble radius for a value under a sqrt scale (I5). AREA is proportional to
615
- * the value, which is the only honest way to size a circle β€” radius-proportional
616
- * bubbles overstate large values by the square, the classic bubble-chart lie.
617
- * `null`/non-finite gets `rNull`: a value-less row is drawn small, never hidden
618
- * and never faked (rule 8b).
619
- */
620
- export function bubbleRadius(
621
- v: number | null,
622
- min: number,
623
- max: number,
624
- rMin: number,
625
- rMax: number,
626
- rNull: number
627
- ): number {
628
- if (v == null || !Number.isFinite(v)) return rNull;
629
- if (!(max > min)) return (rMin + rMax) / 2;
630
- const t = Math.max(0, Math.min(1, (v - min) / (max - min)));
631
- return Math.sqrt(rMin * rMin + t * (rMax * rMax - rMin * rMin));
632
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / mapProjection.ts
3
+ // Wave-8 I2/I6 β€” the map's geometry, split from MapView so it can be tested
4
+ // under node without React.
5
+ //
6
+ // WHY THIS EXISTS AS ITS OWN LAYER. The wave-7 map derived its projection from
7
+ // the DATA's bounding box (MapView.tsx:96-118, an equirectangular fit). That is
8
+ // fine for a static scatter and wrong for everything wave 8 asks of it:
9
+ //
10
+ // - the map re-projected on every filter change, so the whole picture jumped
11
+ // whenever a condition was edited;
12
+ // - box-select needs a stable screen<->point mapping DURING a drag, which a
13
+ // projection memoised over `points` is not;
14
+ // - a data-fit projection distorts real geography β€” state outlines drawn
15
+ // through it visibly skew once you zoom into one metro.
16
+ //
17
+ // So the two concerns are separated:
18
+ // PROJECTION fixed Web Mercator, world -> a fixed square. Never changes.
19
+ // VIEW {k, tx, ty} β€” zoom and pan, an affine transform ON TOP.
20
+ // The old data-fit becomes the INITIAL VIEW value rather than the projection,
21
+ // which is what makes zoom/pan and hit-testing tractable at all.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** Base resolution of the projected world square, in SVG user units. */
25
+ export const WORLD = 4096;
26
+
27
+ /** Mercator blows up at the poles; every web map clamps. */
28
+ const MAX_LAT = 85.05112878;
29
+
30
+ export interface Pt {
31
+ x: number;
32
+ y: number;
33
+ }
34
+
35
+ /** A pan/zoom transform: screen = view.t + view.k * projected. */
36
+ export interface View {
37
+ k: number;
38
+ tx: number;
39
+ ty: number;
40
+ }
41
+
42
+ /** lon/lat -> the fixed projected plane (0..WORLD on both axes). */
43
+ export function project(lon: number, lat: number): Pt {
44
+ const clamped = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
45
+ const rad = (clamped * Math.PI) / 180;
46
+ const x = (lon + 180) / 360;
47
+ const y = 0.5 - Math.log(Math.tan(Math.PI / 4 + rad / 2)) / (2 * Math.PI);
48
+ return { x: x * WORLD, y: y * WORLD };
49
+ }
50
+
51
+ /** The fixed projected plane -> lon/lat. The exact inverse of `project`. */
52
+ export function unproject(p: Pt): { lon: number; lat: number } {
53
+ const t = 0.5 - p.y / WORLD;
54
+ const rad = 2 * Math.atan(Math.exp(2 * Math.PI * t)) - Math.PI / 2;
55
+ return { lon: (p.x / WORLD) * 360 - 180, lat: (rad * 180) / Math.PI };
56
+ }
57
+
58
+ /** Screen -> the fixed projected plane, through a view. The inverse of `toScreen`. */
59
+ export function fromScreen(s: Pt, view: View): Pt {
60
+ return { x: (s.x - view.tx) / view.k, y: (s.y - view.ty) / view.k };
61
+ }
62
+
63
+ // -------------------------------------------------------- the Google hand-off
64
+ //
65
+ // The honest answer to "I need to see the actual street". A licence-free offline
66
+ // vector basemap stops at roughly metro scale (see BASEMAP_DETAIL_K), and the
67
+ // alternative β€” bundling a tile renderer β€” costs 270 KB gz, an API key or a
68
+ // hosted planet file, and sends every customer's coordinates to a third party on
69
+ // every pan. A LINK costs none of that: nothing ships, nothing is fetched, and
70
+ // the coordinates travel only if the user deliberately clicks.
71
+ //
72
+ // ⚠ Every one of these must be rendered with rel="noopener noreferrer" β€” that
73
+ // strips the Referer, so the destination never learns which tenant or which
74
+ // deployment the click came from, and it denies the opened tab window.opener.
75
+
76
+ /**
77
+ * Google Maps' zoom level for our zoom `k`.
78
+ *
79
+ * Google measures a world 256 * 2^z px wide; ours is WORLD units wide, painted
80
+ * at `k` and then ~1.08 CSS px per unit. Equating the two:
81
+ * 256 * 2^z = WORLD * k * 1.08 -> z = log2(WORLD * 1.08 * k / 256)
82
+ * Clamped to Google's own 0..21. A fitted US book (k~1.36) hands over z5, the
83
+ * country; the detail cap (k=32) hands over z9, a metro β€” i.e. the hand-off
84
+ * starts exactly where our basemap runs out, which is the point of it.
85
+ */
86
+ export function googleZoomForK(k: number): number {
87
+ if (!Number.isFinite(k) || k <= 0) return 4;
88
+ const z = Math.log2((WORLD * 1.08 * k) / 256);
89
+ return Math.max(0, Math.min(21, Math.round(z)));
90
+ }
91
+
92
+ /** True only for a coordinate Google can actually be sent. */
93
+ export function isPlottable(lat: number | null, lon: number | null): boolean {
94
+ return (
95
+ lat != null && lon != null &&
96
+ Number.isFinite(lat) && Number.isFinite(lon) &&
97
+ Math.abs(lat) <= 90 && Math.abs(lon) <= 180
98
+ );
99
+ }
100
+
101
+ /**
102
+ * A dropped pin at exactly the coordinate WE plotted.
103
+ *
104
+ * ⚠ Deliberately by lat/lon and never by customer name or address: a name search
105
+ * can resolve somewhere else entirely, and then the app's map and the link
106
+ * disagree about where a customer is. The pin is the geocode; the link is the
107
+ * same geocode. `api=1` is Google's documented, stable URL contract.
108
+ */
109
+ export function googleMapsUrl(lat: number, lon: number, zoom?: number): string {
110
+ const at = `${lat.toFixed(6)},${lon.toFixed(6)}`;
111
+ return zoom == null
112
+ ? `https://www.google.com/maps/search/?api=1&query=${at}`
113
+ : `https://www.google.com/maps/@${at},${Math.round(zoom)}z`;
114
+ }
115
+
116
+ /** Directions to a customer β€” the version a rep on the road actually wants. */
117
+ export function googleDirectionsUrl(lat: number, lon: number): string {
118
+ return `https://www.google.com/maps/dir/?api=1&destination=${lat.toFixed(6)},${lon.toFixed(6)}`;
119
+ }
120
+
121
+ /** Projected point -> screen, through a view. */
122
+ export function toScreen(p: Pt, view: View): Pt {
123
+ return { x: view.tx + p.x * view.k, y: view.ty + p.y * view.k };
124
+ }
125
+
126
+ /**
127
+ * The view that frames `pts` inside a w x h viewport with `pad` px of margin.
128
+ * `minSpan` stops a single point (or one city) from zooming to street level:
129
+ * one customer should still look like a PLACE, not a full-bleed dot β€” the
130
+ * wave-7 behaviour, kept.
131
+ */
132
+ export function fitView(
133
+ pts: Pt[],
134
+ w: number,
135
+ h: number,
136
+ pad = 40,
137
+ minSpan = WORLD / 90
138
+ ): View | null {
139
+ if (pts.length === 0) return null;
140
+ let minX = Infinity;
141
+ let maxX = -Infinity;
142
+ let minY = Infinity;
143
+ let maxY = -Infinity;
144
+ for (const p of pts) {
145
+ minX = Math.min(minX, p.x);
146
+ maxX = Math.max(maxX, p.x);
147
+ minY = Math.min(minY, p.y);
148
+ maxY = Math.max(maxY, p.y);
149
+ }
150
+ let spanX = Math.max(maxX - minX, minSpan);
151
+ let spanY = Math.max(maxY - minY, minSpan);
152
+ const cx = (minX + maxX) / 2;
153
+ const cy = (minY + maxY) / 2;
154
+ spanX *= 1.16; // breathing room so edge pins are not on the frame
155
+ spanY *= 1.16;
156
+ const k = Math.min((w - pad * 2) / spanX, (h - pad * 2) / spanY);
157
+ return { k, tx: w / 2 - cx * k, ty: h / 2 - cy * k };
158
+ }
159
+
160
+ /** Zoom by `factor` while holding the point under (mx, my) still β€” the gesture
161
+ * every map has and the reason zoom cannot be a plain scale on the group. */
162
+ export function zoomAt(view: View, factor: number, mx: number, my: number, kMin: number, kMax: number): View {
163
+ const k = Math.max(kMin, Math.min(kMax, view.k * factor));
164
+ if (k === view.k) return view;
165
+ return {
166
+ k,
167
+ tx: mx - ((mx - view.tx) * k) / view.k,
168
+ ty: my - ((my - view.ty) * k) / view.k,
169
+ };
170
+ }
171
+
172
+ // ---------------------------------------------------------------- stroke width
173
+ //
174
+ // ⚠ THE MAP HAS EXACTLY ONE STROKE-WIDTH MECHANISM, AND THIS IS IT.
175
+ //
176
+ // Everything painted inside `<g transform="... scale(k)">` is scaled by k, so a
177
+ // line meant to read 1.1 px on screen must be handed 1.1/k. That is `hairline`.
178
+ // SVG offers a SECOND way to the same end β€” the CSS `vector-effect:
179
+ // non-scaling-stroke`, which makes the renderer ignore the transform when it
180
+ // strokes. Either works. Using BOTH cancels the zoom twice, and the line then
181
+ // gets THINNER the further you zoom IN.
182
+ //
183
+ // Wave 9 found exactly that, and it is the "blurry when zoomed" bug the owner
184
+ // reported. `.cg-map-land` carried the CSS property AND `hair(1.1)`. Measured
185
+ // against the real constants (WORLD 4096, viewBox 1000x620, ~1.10 CSS px per
186
+ // viewBox unit, fitted k ~1.359): the coastline painted 0.89 CSS px at the
187
+ // fitted view, 0.445 at 2x, 0.089 at 10x and 0.015 at the zoom cap β€” below
188
+ // ~0.5 px a stroke is an anti-aliased smear and below ~0.2 px a ghost. The
189
+ // graticule, the lakes and the pins were all correct, because they use
190
+ // `hairline` alone. The bug hid precisely because two idioms coexisted on
191
+ // different elements of the same picture.
192
+ //
193
+ // So the rule is singular now, and it is GATED rather than merely commented:
194
+ // `paintedStroke` must be flat across the whole zoom range, and
195
+ // `scalingConflicts` re-reads the real stylesheet so the CSS half cannot come
196
+ // back either. If a future element genuinely wants `non-scaling-stroke`, that
197
+ // is a deliberate change to this rule β€” change the comment and the gate, not
198
+ // just the stylesheet.
199
+
200
+ /** Stroke width to hand an element drawn INSIDE the zoomed group. */
201
+ export function hairline(basePx: number, k: number): number {
202
+ return basePx / k;
203
+ }
204
+
205
+ /**
206
+ * A dash pattern for a line drawn INSIDE the zoomed group.
207
+ *
208
+ * ⚠ Exactly the same trap as stroke width, and it caught me: `stroke-dasharray`
209
+ * in CSS is in USER units, so inside `scale(k)` a "5 4" dash becomes 5k on and
210
+ * 4k off. At a regional fit that is a 70 px dash and a 55 px gap β€” the route
211
+ * line renders as a few disconnected strokes floating between the stops, which
212
+ * reads as a broken polyline rather than a scaled dash. Every length handed to
213
+ * the transformed group goes through `hairline`, dashes included.
214
+ */
215
+ export function dashPattern(onPx: number, offPx: number, k: number): string {
216
+ return `${hairline(onPx, k)} ${hairline(offPx, k)}`;
217
+ }
218
+
219
+ /**
220
+ * What the renderer actually paints, in screen units, for a `hairline` width at
221
+ * zoom k β€” i.e. the attribute multiplied by the group's scale. Not circular: it
222
+ * models the SVG pipeline, which is the thing the invariant is about. It must
223
+ * return `basePx` at EVERY k, and the gate sweeps the range to prove it.
224
+ */
225
+ 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
232
+ * `hairline`. Returns the offending selectors (empty = clean) so the gate can
233
+ * name them. Comments are stripped first so a commented-out example cannot trip
234
+ * it.
235
+ */
236
+ export function scalingConflicts(css: string): string[] {
237
+ const bad: string[] = [];
238
+ for (const chunk of css.replace(/\/\*[\s\S]*?\*\//g, "").split("}")) {
239
+ const brace = chunk.indexOf("{");
240
+ if (brace < 0) continue;
241
+ const selector = chunk.slice(0, brace);
242
+ if (!selector.includes(".cg-map")) continue;
243
+ if (/vector-effect\s*:[^;]*non-scaling/i.test(chunk.slice(brace + 1)))
244
+ bad.push(selector.trim().replace(/\s+/g, " "));
245
+ }
246
+ return bad;
247
+ }
248
+
249
+ /**
250
+ * How far in the VENDORED basemap is still worth showing, as an absolute zoom.
251
+ *
252
+ * DERIVED, not chosen by feel. The geometry in mapGeometry.ts is Natural Earth
253
+ * 50m simplified at 0.02 degrees, giving a ~13 km median vertex spacing. With
254
+ * the viewBox painting ~1.08 CSS px per unit at latitude 39, one screen pixel is
255
+ * ~7041/k metres, so a 13 km segment measures ~1.85*k pixels. At k = 32 that is
256
+ * a ~60 px straight run and ~10 px of simplification error β€” coarse but still
257
+ * unmistakably a shape. Past it the coastline degenerates into long straight
258
+ * lines and the user is zooming into an empty polygon, which is the opposite of
259
+ * the sharpness this was asked for.
260
+ *
261
+ * ⚠ This is an HONESTY limit and it is the reason the map stops where it does:
262
+ * street-level detail is not available from any licence-free offline vector set.
263
+ * It needs a tile provider β€” a runtime network dependency, an API key and an
264
+ * attribution obligation β€” which is the owner's call, not a silent addition.
265
+ */
266
+ export const BASEMAP_DETAIL_K = 32;
267
+
268
+ /**
269
+ * The camera's zoom range for a given fitted zoom.
270
+ *
271
+ * `kMin` β€” zoom OUT to four times the data's own extent for context, but never
272
+ * past the point where the whole projected world already fits: beyond that
273
+ * there is nothing further to reveal, only empty margin. (The wave-8 rule was a
274
+ * flat `fit.k * 0.6`, which locked you in at barely half a step out.)
275
+ *
276
+ * `kMax` β€” how far IN. This is a HONESTY limit as much as a UX one: zooming
277
+ * past the resolution of the basemap actually vendored just shows a bigger
278
+ * empty polygon, so `detailK` caps it. Pass `Infinity` for no cap.
279
+ */
280
+ export function zoomLimits(
281
+ fitK: number,
282
+ viewH: number,
283
+ detailK = Infinity
284
+ ): { kMin: number; kMax: number } {
285
+ const worldFit = viewH / WORLD;
286
+ const kMin = Math.min(fitK, Math.max(worldFit, fitK * 0.25));
287
+ return { kMin, kMax: Math.max(fitK, Math.min(fitK * 60, detailK)) };
288
+ }
289
+
290
+ /**
291
+ * Graticule opacity at zoom k. The 10-degree grid earns its place on a
292
+ * zoomed-OUT world view, where it is the only thing giving scale. Once wave 9
293
+ * vendored real state borders it became noise the moment you zoom into the
294
+ * country: two competing line systems over the same picture. So it fades out
295
+ * before the borders take over rather than fighting them.
296
+ */
297
+ export function graticuleOpacity(k: number): number {
298
+ return Math.max(0, Math.min(1, (1.5 - k) / 0.9));
299
+ }
300
+
301
+ /**
302
+ * Where to put a hover card of `w` x `h` for a pin at (sx, sy), in screen space.
303
+ *
304
+ * Prefers ABOVE the pin, flips below when there is no room, and clamps inside
305
+ * the viewport on both axes β€” a card that runs off the frame is a card whose
306
+ * numbers cannot be read, and the pins nearest the edge are exactly the ones a
307
+ * territory question is usually about.
308
+ */
309
+ export function cardBox(
310
+ sx: number, sy: number, w: number, h: number,
311
+ viewW: number, viewH: number, gap = 14, pad = 6
312
+ ): Pt {
313
+ const above = sy - h - gap;
314
+ // ⚠ Both axes clamp UNCONDITIONALLY. Clamping only the "flipped below" branch
315
+ // looks right and is not: a pin panned off the BOTTOM of the frame still has
316
+ // acres of room "above" it, passes the room check, and places the card far
317
+ // below the viewport. Caught by the every-corner leg, never by a screenshot.
318
+ return {
319
+ x: Math.max(pad, Math.min(viewW - w - pad, sx - w / 2)),
320
+ y: Math.max(pad, Math.min(viewH - h - pad, above >= pad ? above : sy + gap)),
321
+ };
322
+ }
323
+
324
+ // ---------------------------------------------------------- route planning
325
+ //
326
+ // I18-R. Sequencing a visit order is a TRAVELLING SALESMAN problem, and it is
327
+ // pure arithmetic: no data, no service, no dependency, no cost. The half that
328
+ // costs money is turning an order into ROAD distances, and this deliberately
329
+ // does not attempt that β€” see `routeNote` and the mailbox's tier analysis
330
+ // (Google's route matrix bills per element: a 30-stop run is ~$4.50, a full
331
+ // 1,550-customer matrix ~$12,000).
332
+
333
+ /** A stop, in the coordinates the host geocoded β€” never projected units. */
334
+ export interface GeoStop {
335
+ lat: number;
336
+ lon: number;
337
+ }
338
+
339
+ /**
340
+ * ⚠ THE SEAM. Everything below takes distance as a FUNCTION and knows nothing
341
+ * else about it. Swapping in real road distances later (a self-hosted OSRM
342
+ * matrix, precomputed and cached) is then a one-line change at the call site
343
+ * rather than a rewrite of the sequencer.
344
+ */
345
+ export type StopDistance = (a: GeoStop, b: GeoStop) => number;
346
+
347
+ const EARTH_R_KM = 6371.0088;
348
+
349
+ /**
350
+ * Great-circle distance in km.
351
+ *
352
+ * ⚠ MUST be computed on lon/lat, NOT as euclidean distance in projected WORLD
353
+ * units. Mercator stretches by 1/cos(latitude): across this book's range
354
+ * (lat 25-49) that is a 0.91 -> 0.66 swing, ~38%, which systematically ranks
355
+ * north-south pairs against east-west ones. The resulting route looks entirely
356
+ * plausible and is wrong, which is the worst kind of wrong.
357
+ */
358
+ export const haversineKm: StopDistance = (a, b) => {
359
+ const rad = Math.PI / 180;
360
+ const dLat = (b.lat - a.lat) * rad;
361
+ const dLon = (b.lon - a.lon) * rad;
362
+ const s =
363
+ Math.sin(dLat / 2) ** 2 +
364
+ Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
365
+ return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(s)));
366
+ };
367
+
368
+ /** Total length of a tour. `roundTrip` adds the closing edge back to the start. */
369
+ export function tourLength(
370
+ order: number[], stops: GeoStop[], dist: StopDistance, roundTrip = false
371
+ ): number {
372
+ if (order.length < 2) return 0;
373
+ let km = 0;
374
+ for (let i = 1; i < order.length; i++) km += dist(stops[order[i - 1]], stops[order[i]]);
375
+ if (roundTrip) km += dist(stops[order[order.length - 1]], stops[order[0]]);
376
+ return km;
377
+ }
378
+
379
+ /** Greedy construction: from `start`, repeatedly hop to the nearest unvisited stop. */
380
+ export function nearestNeighbourOrder(
381
+ stops: GeoStop[], dist: StopDistance, start = 0
382
+ ): number[] {
383
+ const n = stops.length;
384
+ if (n === 0) return [];
385
+ const from = Math.max(0, Math.min(n - 1, Math.round(start) || 0));
386
+ const seen = new Array<boolean>(n).fill(false);
387
+ const order = [from];
388
+ seen[from] = true;
389
+ for (let k = 1; k < n; k++) {
390
+ const last = order[order.length - 1];
391
+ let best = -1;
392
+ let bestD = Infinity;
393
+ for (let i = 0; i < n; i++) {
394
+ if (seen[i]) continue;
395
+ const d = dist(stops[last], stops[i]);
396
+ if (d < bestD) { bestD = d; best = i; }
397
+ }
398
+ if (best < 0) break;
399
+ seen[best] = true;
400
+ order.push(best);
401
+ }
402
+ return order;
403
+ }
404
+
405
+ /**
406
+ * 2-opt: repeatedly reverse a segment when doing so shortens the tour.
407
+ *
408
+ * Index 0 is PINNED β€” it is the origin the user chose, and silently re-rooting
409
+ * their route would be a worse bug than a slightly longer one. Only strictly
410
+ * improving moves are accepted, which is what makes "never returns a tour
411
+ * longer than the one it was given" a guarantee the gate can assert rather than
412
+ * a hope.
413
+ */
414
+ export function twoOptOrder(
415
+ order: number[], stops: GeoStop[], dist: StopDistance,
416
+ roundTrip = false, maxPasses = 24
417
+ ): number[] {
418
+ const n = order.length;
419
+ const cur = order.slice();
420
+ if (n < 4) return cur;
421
+ const D = (a: number, b: number) => dist(stops[a], stops[b]);
422
+ for (let pass = 0; pass < maxPasses; pass++) {
423
+ let improved = false;
424
+ for (let i = 1; i < n - 1; i++) {
425
+ for (let j = i + 1; j < n; j++) {
426
+ const a = cur[i - 1], b = cur[i], c = cur[j];
427
+ let delta: number;
428
+ if (j === n - 1 && !roundTrip) {
429
+ // Reversing the tail of an OPEN path only re-hangs the entry edge:
430
+ // there is no closing edge to pay for.
431
+ delta = D(a, c) - D(a, b);
432
+ } else {
433
+ const d = cur[(j + 1) % n];
434
+ delta = D(a, c) + D(b, d) - D(a, b) - D(c, d);
435
+ }
436
+ if (delta < -1e-9) {
437
+ for (let lo = i, hi = j; lo < hi; lo++, hi--) {
438
+ const t = cur[lo]; cur[lo] = cur[hi]; cur[hi] = t;
439
+ }
440
+ improved = true;
441
+ }
442
+ }
443
+ }
444
+ if (!improved) break;
445
+ }
446
+ return cur;
447
+ }
448
+
449
+ /** Construct then improve. Returns the visit order and its length. */
450
+ export function planRoute(
451
+ stops: GeoStop[], dist: StopDistance,
452
+ opts: { start?: number; roundTrip?: boolean } = {}
453
+ ): { order: number[]; km: number } {
454
+ const roundTrip = !!opts.roundTrip;
455
+ if (stops.length === 0) return { order: [], km: 0 };
456
+ const nn = nearestNeighbourOrder(stops, dist, opts.start ?? 0);
457
+ const order = twoOptOrder(nn, stops, dist, roundTrip);
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
464
+ // cost NOTHING, but they carry at most 9 waypoints on desktop and 3 on mobile
465
+ // browsers, inside a 2,048-character URL.
466
+
467
+ export const ROUTE_WAYPOINTS_DESKTOP = 9;
468
+ export const ROUTE_WAYPOINTS_MOBILE = 3;
469
+ export const MAX_MAPS_URL = 2048;
470
+
471
+ /** How many STOPS can ride the free URL: the waypoints plus the two endpoints
472
+ * (a round trip returns to its origin, so the origin is not also a waypoint). */
473
+ export function routeStopCap(coarsePointer: boolean, roundTrip = false): number {
474
+ const w = coarsePointer ? ROUTE_WAYPOINTS_MOBILE : ROUTE_WAYPOINTS_DESKTOP;
475
+ return roundTrip ? w + 1 : w + 2;
476
+ }
477
+
478
+ /**
479
+ * Build the free Google directions URL for an ORDERED list of stops.
480
+ *
481
+ * Returns `used` alongside the url so the caller can say "first 11 of 23" on
482
+ * screen. It never silently drops a stop; truncation is a fact the UI states
483
+ * ([[no-unverifiable-aggregates]]). Shrinks further if the character budget
484
+ * binds, which it can with a long tail of 6-dp coordinates.
485
+ */
486
+ export function googleRouteUrl(
487
+ stops: GeoStop[],
488
+ opts: { roundTrip?: boolean; coarsePointer?: boolean } = {}
489
+ ): { url: string; used: number } | null {
490
+ if (stops.length < 2) return null;
491
+ const roundTrip = !!opts.roundTrip;
492
+ const at = (s: GeoStop) => `${s.lat.toFixed(6)},${s.lon.toFixed(6)}`;
493
+ let used = Math.min(stops.length, routeStopCap(!!opts.coarsePointer, roundTrip));
494
+ for (;;) {
495
+ const chosen = stops.slice(0, used);
496
+ const origin = chosen[0];
497
+ const dest = roundTrip ? origin : chosen[chosen.length - 1];
498
+ const mids = roundTrip ? chosen.slice(1) : chosen.slice(1, -1);
499
+ const url =
500
+ `https://www.google.com/maps/dir/?api=1&origin=${at(origin)}` +
501
+ `&destination=${at(dest)}` +
502
+ (mids.length ? `&waypoints=${mids.map(at).join("|")}` : "") +
503
+ `&travelmode=driving`;
504
+ if (url.length <= MAX_MAPS_URL || used <= 2) return { url, used };
505
+ used -= 1;
506
+ }
507
+ }
508
+
509
+ /** An svg's own bounding box in client px β€” the `getBoundingClientRect()` half
510
+ * of the conversion below, taken as plain data so the maths stays testable
511
+ * without a DOM. */
512
+ export interface FrameRect {
513
+ left: number;
514
+ top: number;
515
+ width: number;
516
+ height: number;
517
+ }
518
+
519
+ /**
520
+ * Client px -> the svg's own user-space coords. Every pointer gesture on the
521
+ * map β€” marquee, rubber band, cursor-anchored wheel zoom β€” starts here.
522
+ *
523
+ * ⚠ It is NOT `(client / frame) * viewBox`. MapView paints with
524
+ * `preserveAspectRatio="xMidYMid meet"`, so the viewBox is scaled UNIFORMLY by
525
+ * the tighter of the two axes and then CENTRED, leaving a letterbox band on the
526
+ * other axis. Wave 8 (`f6f45a6`) bolted a stretch-to-fill conversion onto that
527
+ * `meet` svg: the binding axis came out right and the other carried BOTH a
528
+ * wrong scale and a missing offset. On a 1400x600 frame the full 0..1000
529
+ * x-range collapsed into ~154..846 β€” so a marquee at either edge caught
530
+ * NOTHING, the rubber band lagged the cursor by ~150 px, and cursor-anchored
531
+ * zoom drifted, all from this one function. `zoomAt` and the hit test were
532
+ * always correct; they were being handed the wrong point.
533
+ *
534
+ * β›” The result is deliberately NOT clamped to the viewBox. A drag that begins
535
+ * in the letterbox band is a real gesture β€” everything from the painted edge
536
+ * inward must still be caught β€” and clamping re-breaks exactly the edge
537
+ * marquee this exists to fix.
538
+ */
539
+ export function clientToUser(
540
+ clientX: number,
541
+ clientY: number,
542
+ rect: FrameRect,
543
+ viewW: number,
544
+ viewH: number
545
+ ): Pt {
546
+ const s = Math.min(rect.width / viewW, rect.height / viewH);
547
+ if (!(s > 0) || !Number.isFinite(s)) return { x: 0, y: 0 };
548
+ const offX = (rect.width - viewW * s) / 2;
549
+ const offY = (rect.height - viewH * s) / 2;
550
+ return { x: (clientX - rect.left - offX) / s, y: (clientY - rect.top - offY) / s };
551
+ }
552
+
553
+ /**
554
+ * Is `p` inside the closed polygon `poly`? Even-odd ray casting (the crossing
555
+ * number), in the same user-space units `toScreen` returns.
556
+ *
557
+ * β›” It is NOT a bounding-box test, and that difference IS the feature. A lasso
558
+ * drawn as a C or a horseshoe must EXCLUDE whatever sits in its mouth β€”
559
+ * otherwise it is the rectangle marquee wearing a lasso's name, which is the
560
+ * one thing a person drawing a loop by hand would never expect. The gate
561
+ * asserts exactly that case, over a shape whose bounding box gives a different
562
+ * answer: a convex test polygon would make the control inert.
563
+ *
564
+ * The ray is cast along +x from `p`, and each edge that straddles `p.y` and
565
+ * crosses to the LEFT of nothing / RIGHT of `p.x` flips the parity. A point
566
+ * exactly on a vertex or an edge may fall either way: this selects pins under a
567
+ * hand-drawn path, where a half-pixel tie carries no meaning and an epsilon to
568
+ * break it would be a number nobody could justify.
569
+ */
570
+ export function pointInPolygon(p: Pt, poly: Pt[]): boolean {
571
+ if (poly.length < 3) return false;
572
+ let inside = false;
573
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
574
+ const a = poly[i];
575
+ const b = poly[j];
576
+ const straddles = a.y > p.y !== b.y > p.y;
577
+ if (straddles && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) inside = !inside;
578
+ }
579
+ return inside;
580
+ }
581
+
582
+ /**
583
+ * The axis-aligned bounds of a freehand path, in `normRect`'s own {x0,y0,x1,y1}
584
+ * shape so one mis-click rule can measure either gesture. An empty path is a
585
+ * zero box rather than an Infinity one β€” the caller's "did this move at all"
586
+ * test must answer NO, not NaN.
587
+ */
588
+ export function pathBounds(pts: Pt[]) {
589
+ if (pts.length === 0) return { x0: 0, y0: 0, x1: 0, y1: 0 };
590
+ let x0 = pts[0].x;
591
+ let y0 = pts[0].y;
592
+ let x1 = pts[0].x;
593
+ let y1 = pts[0].y;
594
+ for (const p of pts) {
595
+ if (p.x < x0) x0 = p.x;
596
+ if (p.x > x1) x1 = p.x;
597
+ if (p.y < y0) y0 = p.y;
598
+ if (p.y > y1) y1 = p.y;
599
+ }
600
+ return { x0, y0, x1, y1 };
601
+ }
602
+
603
+ /** Screen-space rect (any two corners) -> normalized {x0,y0,x1,y1}. */
604
+ export function normRect(ax: number, ay: number, bx: number, by: number) {
605
+ return {
606
+ x0: Math.min(ax, bx),
607
+ y0: Math.min(ay, by),
608
+ x1: Math.max(ax, bx),
609
+ y1: Math.max(ay, by),
610
+ };
611
+ }
612
+
613
+ /**
614
+ * Bubble radius for a value under a sqrt scale (I5). AREA is proportional to
615
+ * the value, which is the only honest way to size a circle β€” radius-proportional
616
+ * bubbles overstate large values by the square, the classic bubble-chart lie.
617
+ * `null`/non-finite gets `rNull`: a value-less row is drawn small, never hidden
618
+ * and never faked (rule 8b).
619
+ */
620
+ export function bubbleRadius(
621
+ v: number | null,
622
+ min: number,
623
+ max: number,
624
+ rMin: number,
625
+ rMax: number,
626
+ rNull: number
627
+ ): number {
628
+ if (v == null || !Number.isFinite(v)) return rNull;
629
+ if (!(max > min)) return (rMin + rMax) / 2;
630
+ const t = Math.max(0, Math.min(1, (v - min) / (max - min)));
631
+ return Math.sqrt(rMin * rMin + t * (rMax * rMax - rMin * rMin));
632
+ }
633
+
634
+ /**
635
+ * W37-T34 / contract C7 β€” ONE cell holding BOTH numbers: `"40.712780,-74.006110"`.
636
+ *
637
+ * ⭐ IT LIVES HERE AND NOT IN MapView.tsx, AND THAT PLACEMENT IS THE POINT. `MapView.tsx`
638
+ * imports `./cells`, which imports glide, so nothing in it can be loaded by a node gate: a pure
639
+ * function stranded there is unreachable by every one of this gate's compiled-artifact
640
+ * mutations. That is the wave-29 scar verbatim, where a client-px conversion living in the .tsx
641
+ * shipped broken and green for four waves. Amendment A15 moved this file into lane D's fence
642
+ * precisely so this function could sit where a test can reach it.
643
+ *
644
+ * ⭐ WHY A PAIR IN ONE CELL RATHER THAN TWO COLUMNS. The map was built for the customers table,
645
+ * which happens to carry `lat` and `lon` as separate mirrored columns, and reading those two names
646
+ * directly is the whole reason a map could not be opened on anything else. The geocode field (R4)
647
+ * writes a coordinate as a VALUE, so it is one cell like every other cell: sortable, exportable,
648
+ * readable by a formula, and copyable between databases. Two columns would have made the map's
649
+ * schema a requirement on every table that ever wants a pin.
650
+ *
651
+ * Tolerant on the way IN and strict on the way OUT: any precision, an optional space after the
652
+ * comma, and a leading plus are all accepted because a person will type them, while anything off
653
+ * the globe is refused rather than clamped. Clamping would put a pin at a real place that the data
654
+ * never named, which is worse than no pin ([[no-unverifiable-aggregates]]).
655
+ */
656
+ export function parseLatLon(v: unknown): { lat: number; lon: number } | null {
657
+ if (v == null) return null;
658
+ const s = String(v).trim();
659
+ if (!s) return null;
660
+ const parts = s.split(",");
661
+ if (parts.length !== 2) return null;
662
+ const lat = Number(parts[0].trim());
663
+ const lon = Number(parts[1].trim());
664
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
665
+ if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
666
+ return { lat, lon };
667
+ }
668
+
669
+ /** Minutes as a person says them. 95 reads as "1 h 35 m"; nobody plans a day in minutes past an
670
+ * hour, and "95 min" is a number you have to do arithmetic on before it means anything. */
671
+ export function formatDuration(minutes: number): string {
672
+ const m = Math.max(0, Math.round(minutes));
673
+ if (m < 60) return `${m} min`;
674
+ const h = Math.floor(m / 60);
675
+ const rest = m % 60;
676
+ return rest === 0 ? `${h} h` : `${h} h ${rest} min`;
677
+ }
web/src/customer-grid/scriptView.css CHANGED
@@ -1,479 +1,509 @@
1
- /* customer-grid/scriptView.css β€” owner item 6, the code-script View.
2
- *
3
- * β›” MODULE-LOCAL BY RULING, not by preference. `index.css` is a 12k-line shared file and this
4
- * wave's PRD hands it to ONE session; a new surface that grew it would be three lanes editing the
5
- * monolith at once. It is also item 13's smallest real win: the monolith stops growing.
6
- * ⚠ Colours come from the design tokens (navy #1F4E78 + gold #C8A24B, DESIGN.md), never from
7
- * fresh hexes: a surface that invents its own palette is the thing the constitution exists to
8
- * prevent. Only the tokens this file actually uses are named. */
9
-
10
- .cg-script {
11
- display: flex;
12
- flex-direction: column;
13
- height: 100%;
14
- min-height: 0;
15
- background: var(--lp-surface, #ffffff);
16
- }
17
-
18
- /* The two halves the owner asked for by name: "the code AND the dashboard output of course."
19
- Side by side above 900px, stacked below it, and the SPLIT is the point either way. */
20
- .cg-script-body {
21
- display: grid;
22
- grid-template-columns: minmax(280px, 5fr) minmax(320px, 7fr);
23
- gap: 1px;
24
- flex: 1 1 auto;
25
- min-height: 0;
26
- background: var(--lp-border, #e4e7ec);
27
- }
28
- @media (max-width: 900px) {
29
- .cg-script-body { grid-template-columns: 1fr; grid-template-rows: minmax(180px, 2fr) 3fr; }
30
- }
31
-
32
- .cg-script-pane {
33
- display: flex;
34
- flex-direction: column;
35
- min-width: 0;
36
- min-height: 0;
37
- background: var(--lp-surface, #ffffff);
38
- }
39
-
40
- .cg-script-pane-head {
41
- display: flex;
42
- align-items: center;
43
- gap: 8px;
44
- padding: 8px 12px;
45
- border-bottom: 1px solid var(--lp-border, #e4e7ec);
46
- font: 500 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
47
- color: var(--lp-text-muted, #667085);
48
- }
49
- .cg-script-pane-head .cg-script-spacer { flex: 1 1 auto; }
50
-
51
- /* ⚠ A textarea, not a highlighted editor. A syntax highlighter is a second parser of the same
52
- source, and the two disagree the first time either learns a token; the owner asked to SEE the
53
- code, which a monospaced pane does. */
54
- .cg-script-code {
55
- flex: 1 1 auto;
56
- min-height: 0;
57
- width: 100%;
58
- resize: none;
59
- border: 0;
60
- outline: none;
61
- padding: 12px;
62
- font: 400 var(--lp-fs-xs)/1.6 "SFMono-Regular", Menlo, Consolas, monospace;
63
- color: var(--lp-text, #1d2939);
64
- background: var(--lp-surface-sunken, #fbfcfd);
65
- tab-size: 4;
66
- white-space: pre;
67
- overflow: auto;
68
- }
69
- .cg-script-code:focus-visible { box-shadow: inset 0 0 0 2px var(--lp-navy, #1f4e78); }
70
-
71
- .cg-script-out {
72
- flex: 1 1 auto;
73
- min-height: 0;
74
- overflow: auto;
75
- padding: 14px 16px;
76
- }
77
-
78
- .cg-script-run {
79
- display: inline-flex;
80
- align-items: center;
81
- gap: 6px;
82
- height: 26px;
83
- padding: 0 12px;
84
- border: 0;
85
- border-radius: 6px;
86
- background: var(--lp-navy, #1f4e78);
87
- color: #ffffff;
88
- font: 500 var(--lp-fs-2xs)/1 Inter, system-ui, sans-serif;
89
- cursor: pointer;
90
- }
91
- .cg-script-run:disabled { opacity: 0.55; cursor: default; }
92
- .cg-script-ghost {
93
- height: 26px;
94
- padding: 0 10px;
95
- border: 1px solid var(--lp-border, #e4e7ec);
96
- border-radius: 6px;
97
- background: transparent;
98
- color: var(--lp-text, #1d2939);
99
- font: 500 var(--lp-fs-2xs)/1 Inter, system-ui, sans-serif;
100
- cursor: pointer;
101
- }
102
- .cg-script-ghost:disabled { opacity: 0.5; cursor: default; }
103
-
104
- .cg-script-meta {
105
- font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
106
- color: var(--lp-text-muted, #667085);
107
- }
108
-
109
- /* A refusal is CONTENT, not a toast: the reader asked a question and this is the answer, so it
110
- stays on screen next to the code that caused it. */
111
- .cg-script-refusal {
112
- border: 1px solid var(--lp-border, #e4e7ec);
113
- border-left: 3px solid var(--lp-gold, #c8a24b);
114
- border-radius: 6px;
115
- padding: 12px 14px;
116
- background: var(--lp-surface-sunken, #fbfcfd);
117
- }
118
- .cg-script-refusal h4 {
119
- margin: 0 0 6px;
120
- font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
121
- color: var(--lp-text, #1d2939);
122
- }
123
- .cg-script-refusal p { margin: 0 0 4px; font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif; }
124
- .cg-script-refusal dl {
125
- margin: 8px 0 0;
126
- display: grid;
127
- grid-template-columns: auto 1fr;
128
- gap: 2px 10px;
129
- font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
130
- }
131
- .cg-script-refusal dt { color: var(--lp-text-muted, #667085); }
132
- .cg-script-refusal dd { margin: 0; }
133
-
134
- .cg-script-title {
135
- margin: 0 0 10px;
136
- font: 600 var(--lp-fs-sm)/1.4 Inter, system-ui, sans-serif;
137
- color: var(--lp-text, #1d2939);
138
- }
139
-
140
- .cg-script-table { width: 100%; border-collapse: collapse; }
141
- .cg-script-table th,
142
- .cg-script-table td {
143
- border-bottom: 1px solid var(--lp-border, #e4e7ec);
144
- padding: 6px 10px;
145
- text-align: left;
146
- font: 400 var(--lp-fs-xs)/1.5 Inter, system-ui, sans-serif;
147
- vertical-align: top;
148
- }
149
- .cg-script-table th {
150
- font-weight: 600;
151
- color: var(--lp-text-muted, #667085);
152
- position: sticky;
153
- top: 0;
154
- background: var(--lp-surface, #ffffff);
155
- }
156
-
157
- .cg-script-metrics {
158
- display: grid;
159
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
160
- gap: 10px;
161
- }
162
- .cg-script-metric {
163
- border: 1px solid var(--lp-border, #e4e7ec);
164
- border-radius: 8px;
165
- padding: 10px 12px;
166
- }
167
- .cg-script-metric-label {
168
- font: 500 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
169
- color: var(--lp-text-muted, #667085);
170
- }
171
- .cg-script-metric-value {
172
- margin-top: 4px;
173
- font: 600 var(--lp-fs-lg)/1.2 Inter, system-ui, sans-serif;
174
- color: var(--lp-text, #1d2939);
175
- }
176
- .cg-script-metric-note {
177
- margin-top: 2px;
178
- font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
179
- color: var(--lp-text-muted, #667085);
180
- }
181
-
182
- .cg-script-bars { display: flex; flex-direction: column; gap: 8px; }
183
- .cg-script-bar { display: grid; grid-template-columns: minmax(80px, 22%) 1fr auto; gap: 10px;
184
- align-items: center; font: 400 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif; }
185
- .cg-script-bar-track {
186
- height: 10px;
187
- border-radius: 5px;
188
- background: var(--lp-surface-sunken, #f2f4f7);
189
- overflow: hidden;
190
- }
191
- .cg-script-bar-fill { height: 100%; border-radius: 5px; background: var(--lp-navy, #1f4e78); }
192
- .cg-script-bar-value { color: var(--lp-text-muted, #667085); font-variant-numeric: tabular-nums; }
193
-
194
- .cg-script-text {
195
- margin: 0;
196
- font: 400 var(--lp-fs-xs)/1.7 Inter, system-ui, sans-serif;
197
- color: var(--lp-text, #1d2939);
198
- white-space: pre-wrap;
199
- word-break: break-word;
200
- }
201
-
202
- .cg-script-stdout {
203
- margin: 14px 0 0;
204
- padding: 10px 12px;
205
- border-radius: 6px;
206
- background: var(--lp-surface-sunken, #fbfcfd);
207
- border: 1px solid var(--lp-border, #e4e7ec);
208
- font: 400 var(--lp-fs-3xs)/1.6 "SFMono-Regular", Menlo, Consolas, monospace;
209
- color: var(--lp-text-muted, #667085);
210
- white-space: pre-wrap;
211
- word-break: break-word;
212
- max-height: 220px;
213
- overflow: auto;
214
- }
215
-
216
- .cg-script-empty {
217
- font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
218
- color: var(--lp-text-muted, #667085);
219
- }
220
- /* The whole-panel empty state needs its own padding: there is no `.cg-script-out` around it.
221
- Kept HERE rather than inline, so the renderer's one and only computed style stays the bar
222
- width, which is arithmetic on a validated number and is asserted as the only one. */
223
- .cg-script-empty--pad { padding: 16px; }
224
-
225
- /* ═══════════════════════════════════════════════════════════════════════════════════════════
226
- W36-T05 β€” THE VIEW AGENT PANEL, left of the View navigation.
227
-
228
- β›” `.cg-agent-panel` IS A CROSS-FENCE CLASS NAME (wiring W8, F's DONE B-3). `index.css` folds
229
- the main rail on `.shell-root:has(.cg-agent-panel) .shell-side`. Renaming it here silently
230
- un-folds the rail and leaves 187px of grid at 1440, with nothing going red. Its WIDTH lives
231
- here because the panel is B's; the FOLD lives in index.css because the rail is F's.
232
- ⚠ ~565px is the reference's own column at 1919px wide, and it is what R14's arithmetic is
233
- built on: 48 (folded nav) + 565 (this) + 344 (views rail) = 957, leaving 483px of grid.
234
- ═══════════════════════════════════════════════════════════════════════════════════════════ */
235
- .cg-agent-panel {
236
- display: flex;
237
- flex-direction: column;
238
- flex: 0 0 565px;
239
- width: 565px;
240
- max-width: 46vw;
241
- min-height: 0;
242
- border-right: 1px solid var(--lp-border, #e4e7ec);
243
- background: var(--lp-surface, #ffffff);
244
- }
245
-
246
- .cg-agent-head {
247
- display: flex;
248
- align-items: center;
249
- gap: 8px;
250
- padding: 10px 14px;
251
- border-bottom: 1px solid var(--lp-border, #e4e7ec);
252
- }
253
- .cg-agent-title { font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif; color: var(--lp-text, #1d2939); }
254
- .cg-agent-spacer { flex: 1 1 auto; }
255
- .cg-agent-icon {
256
- display: inline-flex;
257
- align-items: center;
258
- justify-content: center;
259
- width: 26px;
260
- height: 26px;
261
- border: 0;
262
- border-radius: 6px;
263
- background: transparent;
264
- color: var(--lp-text-muted, #667085);
265
- cursor: pointer;
266
- }
267
- .cg-agent-icon:hover { background: var(--lp-surface-sunken, #f2f4f7); }
268
-
269
- .cg-agent-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 16px; }
270
-
271
- /* The empty state: centred, one soft mark, one question. Whitespace is the design. */
272
- .cg-agent-empty {
273
- height: 100%;
274
- display: flex;
275
- flex-direction: column;
276
- align-items: center;
277
- justify-content: center;
278
- gap: 14px;
279
- }
280
- .cg-agent-mark {
281
- width: 54px;
282
- height: 54px;
283
- border-radius: 50%;
284
- border: 2px dotted var(--lp-gold, #c8a24b);
285
- opacity: 0.55;
286
- }
287
- .cg-agent-ask {
288
- margin: 0;
289
- font: 500 var(--lp-fs-md)/1.4 Inter, system-ui, sans-serif;
290
- color: var(--lp-text-muted, #667085);
291
- }
292
-
293
- .cg-agent-listhead {
294
- margin: 0 0 8px;
295
- font: 500 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
296
- color: var(--lp-text-muted, #667085);
297
- }
298
- .cg-agent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column;
299
- gap: 4px; }
300
- /* The suggestion row from the reference: icon tile, bold title, muted subtitle, an arrow right. */
301
- .cg-agent-row {
302
- display: flex;
303
- align-items: center;
304
- gap: 10px;
305
- width: 100%;
306
- padding: 8px 10px;
307
- border: 0;
308
- border-radius: 8px;
309
- background: transparent;
310
- text-align: left;
311
- cursor: pointer;
312
- }
313
- .cg-agent-row:hover { background: var(--lp-surface-sunken, #f2f4f7); }
314
- .cg-agent-row-tile {
315
- display: inline-flex;
316
- align-items: center;
317
- justify-content: center;
318
- width: 28px;
319
- height: 28px;
320
- flex: 0 0 28px;
321
- border-radius: 7px;
322
- background: var(--lp-surface-sunken, #f2f4f7);
323
- color: var(--lp-navy, #1f4e78);
324
- }
325
- .cg-agent-row-text { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
326
- .cg-agent-row-title {
327
- font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
328
- color: var(--lp-text, #1d2939);
329
- overflow: hidden;
330
- text-overflow: ellipsis;
331
- white-space: nowrap;
332
- }
333
- .cg-agent-row-sub {
334
- font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
335
- color: var(--lp-text-muted, #667085);
336
- }
337
- .cg-agent-row-go { color: var(--lp-text-muted, #667085); }
338
-
339
- .cg-agent-error {
340
- margin: 12px 0 0;
341
- font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
342
- color: var(--lp-text, #1d2939);
343
- border-left: 3px solid var(--lp-gold, #c8a24b);
344
- padding-left: 10px;
345
- }
346
-
347
- /* ONE card border around the input AND its affordances, which is what the reference does. */
348
- .cg-agent-composer {
349
- margin: 0 16px;
350
- border: 1px solid var(--lp-border, #e4e7ec);
351
- border-radius: 12px;
352
- background: var(--lp-surface, #ffffff);
353
- }
354
- .cg-agent-input {
355
- display: block;
356
- width: 100%;
357
- border: 0;
358
- outline: none;
359
- resize: none;
360
- padding: 12px 14px 6px;
361
- background: transparent;
362
- font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
363
- color: var(--lp-text, #1d2939);
364
- }
365
- .cg-agent-composer:focus-within { border-color: var(--lp-navy, #1f4e78); }
366
- .cg-agent-composer-foot {
367
- display: flex;
368
- align-items: center;
369
- gap: 8px;
370
- padding: 6px 8px 8px 12px;
371
- }
372
- .cg-agent-pill {
373
- font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
374
- color: var(--lp-text-muted, #667085);
375
- border: 1px solid var(--lp-border, #e4e7ec);
376
- border-radius: 999px;
377
- padding: 3px 9px;
378
- }
379
- .cg-agent-send {
380
- display: inline-flex;
381
- align-items: center;
382
- justify-content: center;
383
- width: 30px;
384
- height: 30px;
385
- border: 0;
386
- border-radius: 50%;
387
- background: var(--lp-navy, #1f4e78);
388
- color: #ffffff;
389
- cursor: pointer;
390
- }
391
- .cg-agent-send:disabled { opacity: 0.4; cursor: default; }
392
-
393
- .cg-agent-foot {
394
- margin: 8px 16px 14px;
395
- font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
396
- color: var(--lp-text-muted, #667085);
397
- }
398
-
399
- /* The robot, beside the views rail's own minimize toggle (W35-T28 keeps that one FIRST). */
400
- .cg-agent-open {
401
- display: inline-flex;
402
- align-items: center;
403
- justify-content: center;
404
- width: 26px;
405
- height: 26px;
406
- border: 0;
407
- border-radius: 6px;
408
- background: transparent;
409
- color: var(--lp-text-muted, #667085);
410
- cursor: pointer;
411
- }
412
- .cg-agent-open:hover { background: var(--lp-surface-sunken, #f2f4f7); }
413
- .cg-agent-open.is-on { background: var(--lp-surface-sunken, #f2f4f7); color: var(--lp-navy, #1f4e78); }
414
-
415
- /* ═══════════════════════════════════════════════════════════════════════════════════════════
416
- W36-T07 β€” THE FIELD AGENT, inside the column menu's create pane.
417
- ⚠ It reuses the `cg-agent-*` composer above rather than growing a second chat look. What is
418
- added here is only what a conversation inside a 320px menu needs that a full-height panel
419
- does not: a bounded scroll, a proposal card, and the two-button accept row.
420
- ═══════════════════════════════════════════════════════════════════════════════════════════ */
421
- .cg-fagent { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
422
- .cg-fagent-body { max-height: 320px; overflow: auto; }
423
- .cg-fagent-turns { list-style: none; margin: 0 0 10px; padding: 0; display: flex;
424
- flex-direction: column; gap: 6px; }
425
- .cg-fagent-turn { display: flex; justify-content: flex-end; }
426
- .cg-fagent-said {
427
- max-width: 88%;
428
- padding: 6px 10px;
429
- border-radius: 10px 10px 2px 10px;
430
- background: var(--lp-surface-sunken, #f2f4f7);
431
- font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
432
- color: var(--lp-text, #1d2939);
433
- word-break: break-word;
434
- }
435
- .cg-fagent-card {
436
- border: 1px solid var(--lp-border, #e4e7ec);
437
- border-left: 3px solid var(--lp-gold, #c8a24b);
438
- border-radius: 8px;
439
- padding: 10px 12px;
440
- }
441
- .cg-fagent-card-head {
442
- margin: 0 0 6px;
443
- font: 600 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
444
- color: var(--lp-text, #1d2939);
445
- }
446
- .cg-fagent-def {
447
- margin: 0;
448
- display: grid;
449
- grid-template-columns: auto 1fr;
450
- gap: 2px 10px;
451
- font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
452
- }
453
- .cg-fagent-def dt { color: var(--lp-text-muted, #667085); }
454
- .cg-fagent-def dd { margin: 0; color: var(--lp-text, #1d2939); }
455
- .cg-fagent-why,
456
- .cg-fagent-missing {
457
- margin: 8px 0 0;
458
- padding-left: 16px;
459
- font: 400 var(--lp-fs-3xs)/1.55 Inter, system-ui, sans-serif;
460
- color: var(--lp-text-muted, #667085);
461
- }
462
- /* What is still OPEN reads differently from what was decided: the reader has to answer these. */
463
- .cg-fagent-missing { color: var(--lp-text, #1d2939); }
464
- .cg-fagent-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
465
- /* The agent's door in the create pane. A quiet row, not a second primary button. */
466
- .cg-fagent-open {
467
- display: inline-flex;
468
- align-items: center;
469
- gap: 6px;
470
- width: 100%;
471
- padding: 7px 10px;
472
- border: 1px dashed var(--lp-border, #e4e7ec);
473
- border-radius: 8px;
474
- background: transparent;
475
- color: var(--lp-text-muted, #667085);
476
- font: 500 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
477
- cursor: pointer;
478
- }
479
- .cg-fagent-open:hover { border-color: var(--lp-navy, #1f4e78); color: var(--lp-navy, #1f4e78); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* customer-grid/scriptView.css β€” owner item 6, the code-script View.
2
+ *
3
+ * β›” MODULE-LOCAL BY RULING, not by preference. `index.css` is a 12k-line shared file and this
4
+ * wave's PRD hands it to ONE session; a new surface that grew it would be three lanes editing the
5
+ * monolith at once. It is also item 13's smallest real win: the monolith stops growing.
6
+ * ⚠ Colours come from the design tokens (navy #1F4E78 + gold #C8A24B, DESIGN.md), never from
7
+ * fresh hexes: a surface that invents its own palette is the thing the constitution exists to
8
+ * prevent. Only the tokens this file actually uses are named. */
9
+
10
+ .cg-script {
11
+ display: flex;
12
+ flex-direction: column;
13
+ height: 100%;
14
+ min-height: 0;
15
+ background: var(--lp-surface, #ffffff);
16
+ }
17
+
18
+ /* The two halves the owner asked for by name: "the code AND the dashboard output of course."
19
+ Side by side above 900px, stacked below it, and the SPLIT is the point either way. */
20
+ .cg-script-body {
21
+ display: grid;
22
+ grid-template-columns: minmax(280px, 5fr) minmax(320px, 7fr);
23
+ gap: 1px;
24
+ flex: 1 1 auto;
25
+ min-height: 0;
26
+ background: var(--lp-border, #e4e7ec);
27
+ }
28
+ @media (max-width: 900px) {
29
+ .cg-script-body { grid-template-columns: 1fr; grid-template-rows: minmax(180px, 2fr) 3fr; }
30
+ }
31
+
32
+ .cg-script-pane {
33
+ display: flex;
34
+ flex-direction: column;
35
+ min-width: 0;
36
+ min-height: 0;
37
+ background: var(--lp-surface, #ffffff);
38
+ }
39
+
40
+ .cg-script-pane-head {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 8px;
44
+ padding: 8px 12px;
45
+ border-bottom: 1px solid var(--lp-border, #e4e7ec);
46
+ font: 500 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
47
+ color: var(--lp-text-muted, #667085);
48
+ }
49
+ .cg-script-pane-head .cg-script-spacer { flex: 1 1 auto; }
50
+
51
+ /* ⚠ A textarea, not a highlighted editor. A syntax highlighter is a second parser of the same
52
+ source, and the two disagree the first time either learns a token; the owner asked to SEE the
53
+ code, which a monospaced pane does. */
54
+ .cg-script-code {
55
+ flex: 1 1 auto;
56
+ min-height: 0;
57
+ width: 100%;
58
+ resize: none;
59
+ border: 0;
60
+ outline: none;
61
+ padding: 12px;
62
+ font: 400 var(--lp-fs-xs)/1.6 "SFMono-Regular", Menlo, Consolas, monospace;
63
+ color: var(--lp-text, #1d2939);
64
+ background: var(--lp-surface-sunken, #fbfcfd);
65
+ tab-size: 4;
66
+ white-space: pre;
67
+ overflow: auto;
68
+ }
69
+ .cg-script-code:focus-visible { box-shadow: inset 0 0 0 2px var(--lp-navy, #1f4e78); }
70
+
71
+ .cg-script-out {
72
+ flex: 1 1 auto;
73
+ min-height: 0;
74
+ overflow: auto;
75
+ padding: 14px 16px;
76
+ }
77
+
78
+ .cg-script-run {
79
+ display: inline-flex;
80
+ align-items: center;
81
+ gap: 6px;
82
+ height: 26px;
83
+ padding: 0 12px;
84
+ border: 0;
85
+ border-radius: 6px;
86
+ background: var(--lp-navy, #1f4e78);
87
+ color: #ffffff;
88
+ font: 500 var(--lp-fs-2xs)/1 Inter, system-ui, sans-serif;
89
+ cursor: pointer;
90
+ }
91
+ .cg-script-run:disabled { opacity: 0.55; cursor: default; }
92
+ .cg-script-ghost {
93
+ height: 26px;
94
+ padding: 0 10px;
95
+ border: 1px solid var(--lp-border, #e4e7ec);
96
+ border-radius: 6px;
97
+ background: transparent;
98
+ color: var(--lp-text, #1d2939);
99
+ font: 500 var(--lp-fs-2xs)/1 Inter, system-ui, sans-serif;
100
+ cursor: pointer;
101
+ }
102
+ .cg-script-ghost:disabled { opacity: 0.5; cursor: default; }
103
+
104
+ .cg-script-meta {
105
+ font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
106
+ color: var(--lp-text-muted, #667085);
107
+ }
108
+
109
+ /* A refusal is CONTENT, not a toast: the reader asked a question and this is the answer, so it
110
+ stays on screen next to the code that caused it. */
111
+ .cg-script-refusal {
112
+ border: 1px solid var(--lp-border, #e4e7ec);
113
+ border-left: 3px solid var(--lp-gold, #c8a24b);
114
+ border-radius: 6px;
115
+ padding: 12px 14px;
116
+ background: var(--lp-surface-sunken, #fbfcfd);
117
+ }
118
+ .cg-script-refusal h4 {
119
+ margin: 0 0 6px;
120
+ font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
121
+ color: var(--lp-text, #1d2939);
122
+ }
123
+ .cg-script-refusal p { margin: 0 0 4px; font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif; }
124
+ .cg-script-refusal dl {
125
+ margin: 8px 0 0;
126
+ display: grid;
127
+ grid-template-columns: auto 1fr;
128
+ gap: 2px 10px;
129
+ font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
130
+ }
131
+ .cg-script-refusal dt { color: var(--lp-text-muted, #667085); }
132
+ .cg-script-refusal dd { margin: 0; }
133
+
134
+ .cg-script-title {
135
+ margin: 0 0 10px;
136
+ font: 600 var(--lp-fs-sm)/1.4 Inter, system-ui, sans-serif;
137
+ color: var(--lp-text, #1d2939);
138
+ }
139
+
140
+ .cg-script-table { width: 100%; border-collapse: collapse; }
141
+ .cg-script-table th,
142
+ .cg-script-table td {
143
+ border-bottom: 1px solid var(--lp-border, #e4e7ec);
144
+ padding: 6px 10px;
145
+ text-align: left;
146
+ font: 400 var(--lp-fs-xs)/1.5 Inter, system-ui, sans-serif;
147
+ vertical-align: top;
148
+ }
149
+ .cg-script-table th {
150
+ font-weight: 600;
151
+ color: var(--lp-text-muted, #667085);
152
+ position: sticky;
153
+ top: 0;
154
+ background: var(--lp-surface, #ffffff);
155
+ }
156
+
157
+ .cg-script-metrics {
158
+ display: grid;
159
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
160
+ gap: 10px;
161
+ }
162
+ .cg-script-metric {
163
+ border: 1px solid var(--lp-border, #e4e7ec);
164
+ border-radius: 8px;
165
+ padding: 10px 12px;
166
+ }
167
+ .cg-script-metric-label {
168
+ font: 500 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
169
+ color: var(--lp-text-muted, #667085);
170
+ }
171
+ .cg-script-metric-value {
172
+ margin-top: 4px;
173
+ font: 600 var(--lp-fs-lg)/1.2 Inter, system-ui, sans-serif;
174
+ color: var(--lp-text, #1d2939);
175
+ }
176
+ .cg-script-metric-note {
177
+ margin-top: 2px;
178
+ font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
179
+ color: var(--lp-text-muted, #667085);
180
+ }
181
+
182
+ .cg-script-bars { display: flex; flex-direction: column; gap: 8px; }
183
+ .cg-script-bar { display: grid; grid-template-columns: minmax(80px, 22%) 1fr auto; gap: 10px;
184
+ align-items: center; font: 400 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif; }
185
+ .cg-script-bar-track {
186
+ height: 10px;
187
+ border-radius: 5px;
188
+ background: var(--lp-surface-sunken, #f2f4f7);
189
+ overflow: hidden;
190
+ }
191
+ .cg-script-bar-fill { height: 100%; border-radius: 5px; background: var(--lp-navy, #1f4e78); }
192
+ .cg-script-bar-value { color: var(--lp-text-muted, #667085); font-variant-numeric: tabular-nums; }
193
+
194
+ .cg-script-text {
195
+ margin: 0;
196
+ font: 400 var(--lp-fs-xs)/1.7 Inter, system-ui, sans-serif;
197
+ color: var(--lp-text, #1d2939);
198
+ white-space: pre-wrap;
199
+ word-break: break-word;
200
+ }
201
+
202
+ .cg-script-stdout {
203
+ margin: 14px 0 0;
204
+ padding: 10px 12px;
205
+ border-radius: 6px;
206
+ background: var(--lp-surface-sunken, #fbfcfd);
207
+ border: 1px solid var(--lp-border, #e4e7ec);
208
+ font: 400 var(--lp-fs-3xs)/1.6 "SFMono-Regular", Menlo, Consolas, monospace;
209
+ color: var(--lp-text-muted, #667085);
210
+ white-space: pre-wrap;
211
+ word-break: break-word;
212
+ max-height: 220px;
213
+ overflow: auto;
214
+ }
215
+
216
+ .cg-script-empty {
217
+ font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
218
+ color: var(--lp-text-muted, #667085);
219
+ }
220
+ /* The whole-panel empty state needs its own padding: there is no `.cg-script-out` around it.
221
+ Kept HERE rather than inline, so the renderer's one and only computed style stays the bar
222
+ width, which is arithmetic on a validated number and is asserted as the only one. */
223
+ .cg-script-empty--pad { padding: 16px; }
224
+
225
+ /* ═══════════════════════════════════════════════════════════════════════════════════════════
226
+ W36-T05 β€” THE VIEW AGENT PANEL, left of the View navigation.
227
+
228
+ β›” `.cg-agent-panel` IS A CROSS-FENCE CLASS NAME (wiring W8, F's DONE B-3). `index.css` folds
229
+ the main rail on `.shell-root:has(.cg-agent-panel) .shell-side`. Renaming it here silently
230
+ un-folds the rail and leaves 187px of grid at 1440, with nothing going red. Its WIDTH lives
231
+ here because the panel is B's; the FOLD lives in index.css because the rail is F's.
232
+ ⚠ ~565px is the reference's own column at 1919px wide, and it is what R14's arithmetic is
233
+ built on: 48 (folded nav) + 565 (this) + 344 (views rail) = 957, leaving 483px of grid.
234
+ ═══════════════════════════════════════════════════════════════════════════════════════════ */
235
+ .cg-agent-panel {
236
+ display: flex;
237
+ flex-direction: column;
238
+ flex: 0 0 565px;
239
+ width: 565px;
240
+ max-width: 46vw;
241
+ min-height: 0;
242
+ border-right: 1px solid var(--lp-border, #e4e7ec);
243
+ background: var(--lp-surface, #ffffff);
244
+ }
245
+
246
+ .cg-agent-head {
247
+ display: flex;
248
+ align-items: center;
249
+ gap: 8px;
250
+ padding: 10px 14px;
251
+ border-bottom: 1px solid var(--lp-border, #e4e7ec);
252
+ }
253
+ .cg-agent-title { font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif; color: var(--lp-text, #1d2939); }
254
+ .cg-agent-spacer { flex: 1 1 auto; }
255
+ .cg-agent-icon {
256
+ display: inline-flex;
257
+ align-items: center;
258
+ justify-content: center;
259
+ width: 26px;
260
+ height: 26px;
261
+ border: 0;
262
+ border-radius: 6px;
263
+ background: transparent;
264
+ color: var(--lp-text-muted, #667085);
265
+ cursor: pointer;
266
+ }
267
+ .cg-agent-icon:hover { background: var(--lp-surface-sunken, #f2f4f7); }
268
+
269
+ .cg-agent-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 16px; }
270
+
271
+ /* The empty state: centred, one soft mark, one question. Whitespace is the design. */
272
+ .cg-agent-empty {
273
+ height: 100%;
274
+ display: flex;
275
+ flex-direction: column;
276
+ align-items: center;
277
+ justify-content: center;
278
+ gap: 14px;
279
+ }
280
+ .cg-agent-mark {
281
+ width: 54px;
282
+ height: 54px;
283
+ border-radius: 50%;
284
+ border: 2px dotted var(--lp-gold, #c8a24b);
285
+ opacity: 0.55;
286
+ }
287
+ .cg-agent-ask {
288
+ margin: 0;
289
+ font: 500 var(--lp-fs-md)/1.4 Inter, system-ui, sans-serif;
290
+ color: var(--lp-text-muted, #667085);
291
+ }
292
+
293
+ .cg-agent-listhead {
294
+ margin: 0 0 8px;
295
+ font: 500 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
296
+ color: var(--lp-text-muted, #667085);
297
+ }
298
+ .cg-agent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column;
299
+ gap: 4px; }
300
+ /* The suggestion row from the reference: icon tile, bold title, muted subtitle, an arrow right. */
301
+ .cg-agent-row {
302
+ display: flex;
303
+ align-items: center;
304
+ gap: 10px;
305
+ width: 100%;
306
+ padding: 8px 10px;
307
+ border: 0;
308
+ border-radius: 8px;
309
+ background: transparent;
310
+ text-align: left;
311
+ cursor: pointer;
312
+ }
313
+ .cg-agent-row:hover { background: var(--lp-surface-sunken, #f2f4f7); }
314
+ .cg-agent-row-tile {
315
+ display: inline-flex;
316
+ align-items: center;
317
+ justify-content: center;
318
+ width: 28px;
319
+ height: 28px;
320
+ flex: 0 0 28px;
321
+ border-radius: 7px;
322
+ background: var(--lp-surface-sunken, #f2f4f7);
323
+ color: var(--lp-navy, #1f4e78);
324
+ }
325
+ .cg-agent-row-text { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
326
+ .cg-agent-row-title {
327
+ font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
328
+ color: var(--lp-text, #1d2939);
329
+ overflow: hidden;
330
+ text-overflow: ellipsis;
331
+ white-space: nowrap;
332
+ }
333
+ .cg-agent-row-sub {
334
+ font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
335
+ color: var(--lp-text-muted, #667085);
336
+ }
337
+ .cg-agent-row-go { color: var(--lp-text-muted, #667085); }
338
+
339
+ .cg-agent-error {
340
+ margin: 12px 0 0;
341
+ font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
342
+ color: var(--lp-text, #1d2939);
343
+ border-left: 3px solid var(--lp-gold, #c8a24b);
344
+ padding-left: 10px;
345
+ }
346
+
347
+ /* ONE card border around the input AND its affordances, which is what the reference does. */
348
+ .cg-agent-composer {
349
+ margin: 0 16px;
350
+ border: 1px solid var(--lp-border, #e4e7ec);
351
+ border-radius: 12px;
352
+ background: var(--lp-surface, #ffffff);
353
+ }
354
+ .cg-agent-input {
355
+ display: block;
356
+ width: 100%;
357
+ border: 0;
358
+ outline: none;
359
+ resize: none;
360
+ padding: 12px 14px 6px;
361
+ background: transparent;
362
+ font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
363
+ color: var(--lp-text, #1d2939);
364
+ }
365
+ .cg-agent-composer:focus-within { border-color: var(--lp-navy, #1f4e78); }
366
+ .cg-agent-composer-foot {
367
+ display: flex;
368
+ align-items: center;
369
+ gap: 8px;
370
+ padding: 6px 8px 8px 12px;
371
+ }
372
+ .cg-agent-pill {
373
+ font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
374
+ color: var(--lp-text-muted, #667085);
375
+ border: 1px solid var(--lp-border, #e4e7ec);
376
+ border-radius: 999px;
377
+ padding: 3px 9px;
378
+ }
379
+ .cg-agent-send {
380
+ display: inline-flex;
381
+ align-items: center;
382
+ justify-content: center;
383
+ width: 30px;
384
+ height: 30px;
385
+ border: 0;
386
+ border-radius: 50%;
387
+ background: var(--lp-navy, #1f4e78);
388
+ color: #ffffff;
389
+ cursor: pointer;
390
+ }
391
+ .cg-agent-send:disabled { opacity: 0.4; cursor: default; }
392
+
393
+ .cg-agent-foot {
394
+ margin: 8px 16px 14px;
395
+ font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
396
+ color: var(--lp-text-muted, #667085);
397
+ }
398
+
399
+ /* The robot, beside the views rail's own minimize toggle (W35-T28 keeps that one FIRST). */
400
+ .cg-agent-open {
401
+ display: inline-flex;
402
+ align-items: center;
403
+ justify-content: center;
404
+ width: 26px;
405
+ height: 26px;
406
+ border: 0;
407
+ border-radius: 6px;
408
+ background: transparent;
409
+ color: var(--lp-text-muted, #667085);
410
+ cursor: pointer;
411
+ }
412
+ .cg-agent-open:hover { background: var(--lp-surface-sunken, #f2f4f7); }
413
+ .cg-agent-open.is-on { background: var(--lp-surface-sunken, #f2f4f7); color: var(--lp-navy, #1f4e78); }
414
+
415
+ /* ═══════════════════════════════════════════════════════════════════════════════════════════
416
+ W36-T07 β€” THE FIELD AGENT, inside the column menu's create pane.
417
+ ⚠ It reuses the `cg-agent-*` composer above rather than growing a second chat look. What is
418
+ added here is only what a conversation inside a 320px menu needs that a full-height panel
419
+ does not: a bounded scroll, a proposal card, and the two-button accept row.
420
+ ═══════════════════════════════════════════════════════════════════════════════════════════ */
421
+ .cg-fagent { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
422
+ .cg-fagent-body { max-height: 320px; overflow: auto; }
423
+ .cg-fagent-turns { list-style: none; margin: 0 0 10px; padding: 0; display: flex;
424
+ flex-direction: column; gap: 6px; }
425
+ .cg-fagent-turn { display: flex; justify-content: flex-end; }
426
+ .cg-fagent-said {
427
+ max-width: 88%;
428
+ padding: 6px 10px;
429
+ border-radius: 10px 10px 2px 10px;
430
+ background: var(--lp-surface-sunken, #f2f4f7);
431
+ font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
432
+ color: var(--lp-text, #1d2939);
433
+ word-break: break-word;
434
+ }
435
+ .cg-fagent-card {
436
+ border: 1px solid var(--lp-border, #e4e7ec);
437
+ border-left: 3px solid var(--lp-gold, #c8a24b);
438
+ border-radius: 8px;
439
+ padding: 10px 12px;
440
+ }
441
+ .cg-fagent-card-head {
442
+ margin: 0 0 6px;
443
+ font: 600 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
444
+ color: var(--lp-text, #1d2939);
445
+ }
446
+ .cg-fagent-def {
447
+ margin: 0;
448
+ display: grid;
449
+ grid-template-columns: auto 1fr;
450
+ gap: 2px 10px;
451
+ font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
452
+ }
453
+ .cg-fagent-def dt { color: var(--lp-text-muted, #667085); }
454
+ .cg-fagent-def dd { margin: 0; color: var(--lp-text, #1d2939); }
455
+ .cg-fagent-why,
456
+ .cg-fagent-missing {
457
+ margin: 8px 0 0;
458
+ padding-left: 16px;
459
+ font: 400 var(--lp-fs-3xs)/1.55 Inter, system-ui, sans-serif;
460
+ color: var(--lp-text-muted, #667085);
461
+ }
462
+ /* What is still OPEN reads differently from what was decided: the reader has to answer these. */
463
+ .cg-fagent-missing { color: var(--lp-text, #1d2939); }
464
+ .cg-fagent-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
465
+ /* The agent's door in the create pane. A quiet row, not a second primary button. */
466
+ .cg-fagent-open {
467
+ display: inline-flex;
468
+ align-items: center;
469
+ gap: 6px;
470
+ width: 100%;
471
+ padding: 7px 10px;
472
+ border: 1px dashed var(--lp-border, #e4e7ec);
473
+ border-radius: 8px;
474
+ background: transparent;
475
+ color: var(--lp-text-muted, #667085);
476
+ font: 500 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
477
+ cursor: pointer;
478
+ }
479
+ .cg-fagent-open:hover { border-color: var(--lp-navy, #1f4e78); color: var(--lp-navy, #1f4e78); }
480
+
481
+ /* ── W37-T46 / D-338: the roll-back strip ───────────────────────────────────
482
+ ⚠ It sits between the head row and the code, where a person looking for "what came before" is
483
+ already looking. A separate panel or a modal would put the history somewhere you have to go
484
+ rather than somewhere you are. */
485
+ .cg-script-history {
486
+ display: flex;
487
+ align-items: center;
488
+ flex-wrap: wrap;
489
+ gap: 6px;
490
+ padding: 6px 12px;
491
+ border-bottom: 1px solid var(--lp-border, #e4e7ec);
492
+ background: var(--lp-surface-sunken, #fbfcfd);
493
+ }
494
+ .cg-script-history-lead,
495
+ .cg-script-history-trimmed {
496
+ font: 400 var(--lp-fs-3xs)/1.3 Inter, system-ui, sans-serif;
497
+ color: var(--lp-text-muted, #667085);
498
+ }
499
+ .cg-script-history-item {
500
+ border: 1px solid var(--lp-border, #e4e7ec);
501
+ border-radius: 6px;
502
+ background: var(--lp-surface, #ffffff);
503
+ color: var(--lp-navy, #1f4e78);
504
+ font: 500 var(--lp-fs-3xs)/1.2 Inter, system-ui, sans-serif;
505
+ padding: 2px 7px;
506
+ cursor: pointer;
507
+ }
508
+ .cg-script-history-item:hover:enabled { border-color: var(--lp-navy, #1f4e78); }
509
+ .cg-script-history-item:disabled { opacity: 0.5; cursor: default; }