fsanyoto commited on
Commit
2bdff0c
Β·
verified Β·
1 Parent(s): 26e4b28

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 +44 -14
  4. api/ai_review.py +61 -11
  5. api/automation_engine.py +508 -16
  6. api/main.py +54 -0
  7. api/routes_automation.py +59 -3
  8. api/routes_feedback.py +168 -0
  9. api/routes_keychain.py +108 -0
  10. api/routes_nav.py +0 -0
  11. api/routes_query.py +208 -7
  12. api/routes_starred.py +828 -0
  13. api/routes_statements.py +104 -3
  14. api/routes_tables.py +8 -2
  15. api/routes_usage.py +51 -0
  16. api/usage_ledger.py +281 -0
  17. platform/core/keychain.py +46 -0
  18. web/src/account/FeedbackPage.tsx +246 -0
  19. web/src/account/SubscriptionPage.tsx +47 -0
  20. web/src/account/UsagePage.tsx +297 -0
  21. web/src/account/account.css +372 -0
  22. web/src/account/accountApi.ts +188 -0
  23. web/src/apiContract.ts +18 -0
  24. web/src/assistant/AssistantPage.tsx +9 -0
  25. web/src/automation/AutomationDetail.tsx +141 -0
  26. web/src/automation/automation.css +68 -0
  27. web/src/automation/automationApi.ts +140 -4
  28. web/src/customer-grid/CustomerGrid.tsx +346 -90
  29. web/src/customer-grid/RecordDetail.tsx +39 -0
  30. web/src/customer-grid/ViewSidebar.tsx +48 -112
  31. web/src/customer-grid/counts.ts +49 -0
  32. web/src/customer-grid/overlayPlacement.ts +46 -0
  33. web/src/customer-grid/queryPreview.ts +23 -3
  34. web/src/customer-grid/recordStars.ts +187 -0
  35. web/src/customer-grid/rowStar.css +76 -0
  36. web/src/customer-grid/types.ts +30 -2
  37. web/src/home/HomePage.tsx +179 -15
  38. web/src/index.css +201 -108
  39. web/src/query/QueryPage.tsx +9 -6
  40. web/src/query/query.css +45 -45
  41. web/src/query/queryApi.ts +65 -4
  42. web/src/query/queryParts.tsx +147 -35
  43. web/src/settings/AdminPane.tsx +646 -646
  44. web/src/settings/PermsEditor.tsx +6 -6
  45. web/src/settings/SettingsModal.tsx +0 -0
  46. web/src/settings/permsModel.ts +50 -10
  47. web/src/shell/NavExtras.tsx +986 -995
  48. web/src/shell/Shell.tsx +0 -0
  49. web/src/shell/nav.ts +38 -0
  50. web/src/shell/navExtras.css +676 -704
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "cb05235",
3
  "releases": [
4
  {
5
  "version": "v25",
 
1
  {
2
+ "current": "2b8e675",
3
  "releases": [
4
  {
5
  "version": "v25",
VERSION CHANGED
@@ -1 +1 @@
1
- cb05235
 
1
+ 2b8e675
api/ai_enrich.py CHANGED
@@ -205,19 +205,16 @@ def _usage_tokens(body):
205
  ⚠ RETURNS None WHEN THE PROVIDER DID NOT SAY, and the caller treats that as a real unknown
206
  rather than as zero. A ledger that silently books an unmeasured call at 0 reports a cheaper
207
  run than happened, which is exactly the cost-surprise complaint R13 cites.
 
 
 
 
 
 
 
208
  """
209
- usage = (body or {}).get('usage')
210
- if not isinstance(usage, dict):
211
- return None
212
- for key in ('total_tokens', 'totalTokens'):
213
- got = usage.get(key)
214
- if isinstance(got, int) and not isinstance(got, bool):
215
- return got
216
- ins = usage.get('prompt_tokens', usage.get('input_tokens'))
217
- outs = usage.get('completion_tokens', usage.get('output_tokens'))
218
- if isinstance(ins, int) and isinstance(outs, int):
219
- return ins + outs
220
- return None
221
 
222
 
223
  #: R6's subject: the em dash (U+2014) and the en dash (U+2013), as a regex character class.
@@ -372,7 +369,7 @@ def scheduled_fields(defn):
372
 
373
 
374
  def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, budget=None,
375
- timeout=None, ask=None):
376
  """Fill one `ai_enrich` column over the rows an automatic run is allowed to touch.
377
 
378
  Returns a REPORT and never raises for a vendor problem:
@@ -391,9 +388,17 @@ def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, bu
391
 
392
  ⚠ `ask` is injectable so a gate can exercise the ledger, the ceiling and every failure path
393
  with no network and no vendor bill. It defaults to the real `_ask`.
 
 
 
 
 
 
 
394
  """
395
  import ai_review
396
  import core.user_tables as ut
 
397
 
398
  defn = ut.get(table_key, st=st) or {}
399
  field = next((f for f in ut.ai_enrich_fields(defn) if f.get('key') == str(col_id)), None)
@@ -440,7 +445,11 @@ def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, bu
440
  keys = [f.get('key') for f in (defn.get('fields') or []) if f.get('key')]
441
  report = {'planned': len(plan['run']), 'filled': 0, 'failed': 0,
442
  'skipped': dict(plan['skipped']), 'tokens': 0, 'errors': [], 'limit': None,
443
- 'provider': '', 'model': '', 'problem': ''}
 
 
 
 
444
  if not plan['run']:
445
  return report
446
 
@@ -485,8 +494,14 @@ def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, bu
485
  continue
486
  answer, tokens, problem = caller(provider, model, text[:MAX_CELL_CHARS * 8],
487
  int(cfg.get('maxTokens') or 300), tmo)
 
 
 
 
488
  if isinstance(tokens, int) and not isinstance(tokens, bool):
489
  report['tokens'] += tokens
 
 
490
  if problem or not answer or answer.strip().upper() == 'UNKNOWN':
491
  # β›” THE CELL IS LEFT ALONE. An errored row keeps whatever it held; only the MARK
492
  # changes, so a failed run never destroys a value it could not replace.
@@ -517,4 +532,19 @@ def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, bu
517
  if report['limit'] is None and ceiling and report['tokens'] >= ceiling:
518
  report['limit'] = ceiling_report(report['tokens'], ceiling,
519
  report['filled'], report['planned'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  return report
 
205
  ⚠ RETURNS None WHEN THE PROVIDER DID NOT SAY, and the caller treats that as a real unknown
206
  rather than as zero. A ledger that silently books an unmeasured call at 0 reports a cheaper
207
  run than happened, which is exactly the cost-surprise complaint R13 cites.
208
+
209
+ ⭐⭐ W35-T41 / C7 β€” THE READER MOVED AND THIS IS NOW ONE LINE OVER IT. `usage_ledger.total_from`
210
+ is the same logic, in the module that owns the meter; the alternative was a second reader in a
211
+ second file answering the same question about the same key, which is the shape this repo pays
212
+ for most often ([[one-question-two-normalizers]]). ⚠ The NAME and the None-semantics stay
213
+ exactly as wave 34 wrote them, because `run_field`'s ceiling arithmetic reads this and a gate
214
+ injects against its shape β€” lifting the body, not the signature.
215
  """
216
+ import usage_ledger
217
+ return usage_ledger.total_from(body)
 
 
 
 
 
 
 
 
 
 
218
 
219
 
220
  #: R6's subject: the em dash (U+2014) and the en dash (U+2013), as a regex character class.
 
369
 
370
 
371
  def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, budget=None,
372
+ timeout=None, ask=None, user=''):
373
  """Fill one `ai_enrich` column over the rows an automatic run is allowed to touch.
374
 
375
  Returns a REPORT and never raises for a vendor problem:
 
388
 
389
  ⚠ `ask` is injectable so a gate can exercise the ledger, the ceiling and every failure path
390
  with no network and no vendor bill. It defaults to the real `_ask`.
391
+
392
+ ⭐⭐ W35-T41 / C7 β€” ONE USAGE-LEDGER LINE PER RUN, NOT PER ROW, and the arithmetic is the reason:
393
+ this loop makes one LLM call per row, so a ledger write per call would be hundreds of store
394
+ writes for one click. The run already accumulates `report['tokens']`; `calls` and `unmeasured`
395
+ join it so the meter can say "40 calls, 12,000 tokens, 3 of them unmeasured" instead of a total
396
+ with an unknown shortfall. `user` is the ledger's attribution and is passed by this file's own
397
+ callers in `routes_tables`.
398
  """
399
  import ai_review
400
  import core.user_tables as ut
401
+ import usage_ledger
402
 
403
  defn = ut.get(table_key, st=st) or {}
404
  field = next((f for f in ut.ai_enrich_fields(defn) if f.get('key') == str(col_id)), None)
 
445
  keys = [f.get('key') for f in (defn.get('fields') or []) if f.get('key')]
446
  report = {'planned': len(plan['run']), 'filled': 0, 'failed': 0,
447
  'skipped': dict(plan['skipped']), 'tokens': 0, 'errors': [], 'limit': None,
448
+ 'provider': '', 'model': '', 'problem': '',
449
+ # ⭐ C7's two counters. `calls` is what the meter bills; `unmeasured` is how many of
450
+ # them the provider declined to report tokens for, so a total is never presented as
451
+ # complete when it is a floor.
452
+ 'calls': 0, 'unmeasured': 0}
453
  if not plan['run']:
454
  return report
455
 
 
494
  continue
495
  answer, tokens, problem = caller(provider, model, text[:MAX_CELL_CHARS * 8],
496
  int(cfg.get('maxTokens') or 300), tmo)
497
+ # ⚠ COUNTED HERE, ON EVERY OUTCOME β€” before the `problem` branch below, because a call that
498
+ # errored or answered UNKNOWN still reached the vendor and is still billed. A meter that
499
+ # counts only filled cells reports a cheap run for one that spent its whole budget failing.
500
+ report['calls'] += 1
501
  if isinstance(tokens, int) and not isinstance(tokens, bool):
502
  report['tokens'] += tokens
503
+ else:
504
+ report['unmeasured'] += 1
505
  if problem or not answer or answer.strip().upper() == 'UNKNOWN':
506
  # β›” THE CELL IS LEFT ALONE. An errored row keeps whatever it held; only the MARK
507
  # changes, so a failed run never destroys a value it could not replace.
 
532
  if report['limit'] is None and ceiling and report['tokens'] >= ceiling:
533
  report['limit'] = ceiling_report(report['tokens'], ceiling,
534
  report['filled'], report['planned'])
535
+ # ⭐⭐ C7's LEDGER LINE β€” one per run, after both store writes, and only when the run actually
536
+ # called something. `total=` rather than a split: `_ask`'s contract returns a total and a gate
537
+ # injects against that shape, so splitting it here would mean inventing two numbers from one.
538
+ #
539
+ # ⚠ TWO LINES WHEN A RUN HAS BOTH KINDS, and it is not tidiness. `record` marks a line
540
+ # `unmeasured` only when its total is None, so ONE line carrying 40 calls and a partial total
541
+ # would book all forty as measured and lose the shortfall β€” a total presented as complete when
542
+ # it is a floor, which is the exact failure `_usage_tokens`' None-semantics exists to prevent.
543
+ _measured = report['calls'] - report['unmeasured']
544
+ if _measured > 0:
545
+ usage_ledger.record('field_agent', report['provider'], report['model'],
546
+ total=report['tokens'], calls=_measured, st=st, user=user)
547
+ if report['unmeasured'] > 0:
548
+ usage_ledger.record('field_agent', report['provider'], report['model'],
549
+ total=None, calls=report['unmeasured'], st=st, user=user)
550
  return report
api/ai_review.py CHANGED
@@ -130,6 +130,17 @@ def _parse(text, options):
130
  return "", reason
131
 
132
 
 
 
 
 
 
 
 
 
 
 
 
133
  def _call_openai(p, model, system, user, timeout):
134
  r = requests.post(p["url"], timeout=timeout,
135
  headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
@@ -138,12 +149,14 @@ def _call_openai(p, model, system, user, timeout):
138
  "messages": [{"role": "system", "content": system},
139
  {"role": "user", "content": user}]})
140
  if r.status_code >= 400:
141
- return "", f"{p['name']} answered {r.status_code}"
 
 
142
  body = r.json()
143
  choices = body.get("choices") or []
144
  if not choices:
145
- return "", f"{p['name']} returned no choices"
146
- return str(((choices[0] or {}).get("message") or {}).get("content") or ""), ""
147
 
148
 
149
  def _call_anthropic(p, model, system, user, timeout):
@@ -154,25 +167,34 @@ def _call_anthropic(p, model, system, user, timeout):
154
  json={"model": model, "max_tokens": 300, "system": system,
155
  "messages": [{"role": "user", "content": user}]})
156
  if r.status_code >= 400:
157
- return "", f"anthropic answered {r.status_code}"
158
  body = r.json()
159
  # β›” stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
160
  # reading content[0] before this check turns a refusal into an IndexError inside a run.
 
161
  if body.get("stop_reason") == "refusal":
162
- return "", "anthropic declined to answer this record"
163
  parts = [b.get("text") or "" for b in (body.get("content") or [])
164
  if isinstance(b, dict) and b.get("type") == "text"]
165
  if not parts:
166
- return "", "anthropic returned no text"
167
- return "".join(parts), ""
168
 
169
 
170
- def decide(*, prompt, options, row, fields=(), label="Review", timeout=None):
171
  """Pick this record's next stage. Returns `(choice, meta)`.
172
 
173
  `choice` is "" whenever a person should decide β€” which is every failure mode there is.
174
  `meta` carries `provider`, `model`, `reason` on success, and `problem` on refusal to answer.
 
 
 
 
 
 
 
175
  """
 
176
  opts = [str(o) for o in (options or []) if str(o).strip()]
177
  if not opts:
178
  return "", {"problem": "the review offers no next stages"}
@@ -188,11 +210,22 @@ def decide(*, prompt, options, row, fields=(), label="Review", timeout=None):
188
  problems = []
189
  for p in live:
190
  model = override or p["model"]
 
191
  try:
192
- text, err = (_call_anthropic if p["shape"] == "anthropic" else _call_openai)(
193
  p, model, system, user, tmo)
194
  except Exception as e: # noqa: BLE001
195
  text, err = "", f"{p['name']} failed: {type(e).__name__}"
 
 
 
 
 
 
 
 
 
 
196
  if err:
197
  problems.append(err)
198
  continue # ladder: a dead provider degrades to the next one
@@ -387,12 +420,19 @@ def _flow_from_tool_call(obj):
387
  "actions": out}, ""
388
 
389
 
390
- def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeout=None):
 
391
  """A sentence -> `(draft, refusal_sentence, provider)`. β›” NOTHING IS SAVED HERE.
392
 
393
  Exactly one of `draft` and `refusal_sentence` is truthy β€” the same contract
394
  `web_agent.run_step` keeps, so a caller has no third case to get wrong.
 
 
 
 
 
395
  """
 
396
  text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
397
  if not text:
398
  return None, "type what you want the automation to do", None
@@ -444,12 +484,22 @@ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeou
444
  problems.append(f"{p['name']}: HTTP {r.status_code}")
445
  continue
446
  try:
447
- calls = (((r.json().get("choices") or [{}])[0].get("message") or {})
 
448
  .get("tool_calls") or [])
449
  args = json.loads(calls[0]["function"]["arguments"]) if calls else None
450
  except Exception as e: # noqa: BLE001
451
  problems.append(f"{p['name']}: unreadable answer ({type(e).__name__})")
452
  continue
 
 
 
 
 
 
 
 
 
453
  draft, refusal = _flow_from_tool_call(args)
454
  if draft is None and isinstance(args, dict) and str(args.get("kind")) == "refused":
455
  # β›” A REFUSAL IS AN ANSWER, NOT A FAULT, so it does NOT fall through to a more
 
130
  return "", reason
131
 
132
 
133
+ # ⭐⭐ WAVE 35 Β· W35-T41 / C7 β€” THE TWO TRANSPORTS RETURN THE RESPONSE BODY'S `usage`.
134
+ #
135
+ # β›” THIS FILE'S OWN HEADER USED TO BE THE PRODUCT'S CONFESSION THAT NOTHING COUNTED: `ai_enrich`'s
136
+ # docstring says *"`ai_review.decide` -- the product's only other LLM entry -- has no token
137
+ # accounting of ANY kind"*. R9 asks for ONE meter for every AI surface, so the counting has to reach
138
+ # this transport rather than be bolted onto its caller β€” the caller never sees the body.
139
+ #
140
+ # ⚠ A THIRD RETURN VALUE, NOT A MUTATED ARGUMENT, and both call sites are in `decide` below. The
141
+ # tuple grew from `(text, err)` to `(text, err, usage)`; `usage` is the raw `usage` OBJECT (or None),
142
+ # because reading it is `usage_ledger`'s job and a second reader here would be the two-normalizers
143
+ # shape this repo keeps paying for.
144
  def _call_openai(p, model, system, user, timeout):
145
  r = requests.post(p["url"], timeout=timeout,
146
  headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
 
149
  "messages": [{"role": "system", "content": system},
150
  {"role": "user", "content": user}]})
151
  if r.status_code >= 400:
152
+ # ⚠ NO BODY ON A 4xx/5xx: an error envelope carries no usage, and a provider that refused
153
+ # before running the model has nothing to bill. The call is still COUNTED by `decide`.
154
+ return "", f"{p['name']} answered {r.status_code}", None
155
  body = r.json()
156
  choices = body.get("choices") or []
157
  if not choices:
158
+ return "", f"{p['name']} returned no choices", body
159
+ return str(((choices[0] or {}).get("message") or {}).get("content") or ""), "", body
160
 
161
 
162
  def _call_anthropic(p, model, system, user, timeout):
 
167
  json={"model": model, "max_tokens": 300, "system": system,
168
  "messages": [{"role": "user", "content": user}]})
169
  if r.status_code >= 400:
170
+ return "", f"anthropic answered {r.status_code}", None
171
  body = r.json()
172
  # β›” stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
173
  # reading content[0] before this check turns a refusal into an IndexError inside a run.
174
+ # ⚠ A refusal IS billed and its body carries `usage`, so the body rides back on this branch too.
175
  if body.get("stop_reason") == "refusal":
176
+ return "", "anthropic declined to answer this record", body
177
  parts = [b.get("text") or "" for b in (body.get("content") or [])
178
  if isinstance(b, dict) and b.get("type") == "text"]
179
  if not parts:
180
+ return "", "anthropic returned no text", body
181
+ return "".join(parts), "", body
182
 
183
 
184
+ def decide(*, prompt, options, row, fields=(), label="Review", timeout=None, st=None, user=""):
185
  """Pick this record's next stage. Returns `(choice, meta)`.
186
 
187
  `choice` is "" whenever a person should decide β€” which is every failure mode there is.
188
  `meta` carries `provider`, `model`, `reason` on success, and `problem` on refusal to answer.
189
+
190
+ ⭐ W35-T41 / C7 β€” `st` and `user` are the USAGE LEDGER's target. Both default to absent because
191
+ this function's caller is `automation_engine.ai_decide(rt, ...)`, in another lane's fence: it
192
+ HAS the runtime and does not pass it yet, so until it does, a review's tokens are counted as
193
+ unattributed and REPORTED by `GET /usage` rather than dropped. See `usage_ledger`'s header for
194
+ why they cannot be resolved implicitly (measured: a contextvar does not survive a FastAPI
195
+ dependency).
196
  """
197
+ import usage_ledger # noqa: PLC0415
198
  opts = [str(o) for o in (options or []) if str(o).strip()]
199
  if not opts:
200
  return "", {"problem": "the review offers no next stages"}
 
210
  problems = []
211
  for p in live:
212
  model = override or p["model"]
213
+ body = None
214
  try:
215
+ text, err, body = (_call_anthropic if p["shape"] == "anthropic" else _call_openai)(
216
  p, model, system, user, tmo)
217
  except Exception as e: # noqa: BLE001
218
  text, err = "", f"{p['name']} failed: {type(e).__name__}"
219
+ # ⭐⭐ C7 β€” THE LEDGER LINE, BEFORE ANY BRANCH BELOW READS THE ANSWER.
220
+ # β›” IT IS WRITTEN ON EVERY OUTCOME THAT REACHED A PROVIDER, including a refusal and an
221
+ # unusable answer. A meter that books only successes reports a cheap week for a run that
222
+ # spent its budget being declined β€” and a declined call is billed. The only path that does
223
+ # NOT record is a transport that never reached the vendor (`body is None`), which spent
224
+ # nothing.
225
+ if body is not None:
226
+ ins, outs = usage_ledger.tokens_from(body)
227
+ usage_ledger.record("ai_review", p["name"], model, ins, outs,
228
+ total=usage_ledger.total_from(body), st=st, user=user)
229
  if err:
230
  problems.append(err)
231
  continue # ladder: a dead provider degrades to the next one
 
420
  "actions": out}, ""
421
 
422
 
423
+ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeout=None,
424
+ st=None, user=""):
425
  """A sentence -> `(draft, refusal_sentence, provider)`. β›” NOTHING IS SAVED HERE.
426
 
427
  Exactly one of `draft` and `refusal_sentence` is truthy β€” the same contract
428
  `web_agent.run_step` keeps, so a caller has no third case to get wrong.
429
+
430
+ ⭐ W35-T41 / C7 β€” `st`/`user` are the usage ledger's target, exactly as on `decide` above and for
431
+ the same reason: both of this function's callers (`automation_engine._ai_agent_plan` and
432
+ `routes_automation`'s draft door) are in lane D's fence. C7 says E adds the ledger line here and
433
+ D asserts it; the two keywords are what D has to pass for the line to be attributable.
434
  """
435
+ import usage_ledger # noqa: PLC0415
436
  text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
437
  if not text:
438
  return None, "type what you want the automation to do", None
 
484
  problems.append(f"{p['name']}: HTTP {r.status_code}")
485
  continue
486
  try:
487
+ body = r.json()
488
+ calls = (((body.get("choices") or [{}])[0].get("message") or {})
489
  .get("tool_calls") or [])
490
  args = json.loads(calls[0]["function"]["arguments"]) if calls else None
491
  except Exception as e: # noqa: BLE001
492
  problems.append(f"{p['name']}: unreadable answer ({type(e).__name__})")
493
  continue
494
+ # ⭐⭐ C7 β€” THE LEDGER LINE. Placed after the parse rather than before it so an UNREADABLE
495
+ # answer is not double-counted by the `continue` above... ⚠ which means an unreadable answer
496
+ # is NOT counted at all, and that is a deliberate, disclosed loss: a body this code cannot
497
+ # parse is a body whose `usage` it also cannot trust, and `body` is out of scope in that
498
+ # branch by construction. The 200-with-junk case is rare and named here rather than
499
+ # silently rounded to zero.
500
+ ins, outs = usage_ledger.tokens_from(body)
501
+ usage_ledger.record("automation_draft", p["name"], p["model"], ins, outs,
502
+ total=usage_ledger.total_from(body), st=st, user=user)
503
  draft, refusal = _flow_from_tool_call(args)
504
  if draft is None and isinstance(args, dict) and str(args.get("kind")) == "refused":
505
  # β›” A REFUSAL IS AN ANSWER, NOT A FAULT, so it does NOT fall through to a more
api/automation_engine.py CHANGED
@@ -1782,12 +1782,15 @@ def clean_schedule(raw, previous=None):
1782
  # β›” `terminal` IS THE DEFAULT and every existing automation gets it, because a stored definition
1783
  # that predates this field must not start moving records on its own the day the code ships. A
1784
  # loop is a thing somebody turns on.
1785
- def clean_flow(raw, previous=None, notes=None):
1786
- """Validate the builder's ordered action list. `(flow, error)`."""
 
 
 
1787
  raw = raw if isinstance(raw, dict) else {}
1788
  prev = previous if isinstance(previous, dict) else {}
1789
  actions, err = clean_actions(
1790
- raw.get("actions") if "actions" in raw else prev.get("actions"), notes=notes)
1791
  if err:
1792
  return None, err
1793
  return {"actions": actions}, None
@@ -2062,7 +2065,7 @@ def ig_action_pinned(defn, index):
2062
  return (defn or {}).get("kind") in DISCOVERY_KINDS and index == 0
2063
 
2064
 
2065
- def clean_definition(raw, previous=None, username="", notes=None):
2066
  """Whole-definition validation. Returns `(defn, error)`."""
2067
  raw = raw if isinstance(raw, dict) else {}
2068
  prev = previous or {}
@@ -2239,7 +2242,7 @@ def clean_definition(raw, previous=None, username="", notes=None):
2239
  _prev_table, _prev_enrich = discovery_seed_spec(prev.get("kind"))
2240
  flow_raw = _drop_ig_seeds(
2241
  flow_raw, (prev.get("config") or {}).get("targetTable") or _prev_table, _prev_enrich)
2242
- flow, ferr = clean_flow(flow_raw, prev.get("flow"), notes=notes)
2243
  if ferr:
2244
  return None, ferr
2245
  # ⭐ WAVE 30 β€” widened with the seeder above: TikTok now HAS a pinned step 1, so the rule that
@@ -2315,6 +2318,22 @@ def clean_definition(raw, previous=None, username="", notes=None):
2315
  "state": dict(prev.get("state") or {}),
2316
  "created": prev.get("created") or _iso(),
2317
  "createdBy": prev.get("createdBy") or username,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2318
  }, None
2319
 
2320
 
@@ -2616,7 +2635,9 @@ def create(rt, raw, username="", notes=None):
2616
  # has, instead of letting the pure validator mint a second empty one. See
2617
  # `discover_default_table`; a target the caller named is never rewritten.
2618
  raw = _apply_discover_default(rt, raw)
2619
- defn, err = clean_definition(raw, None, username, notes=notes)
 
 
2620
  if err:
2621
  _unmint(rt, minted)
2622
  return None, err
@@ -2671,7 +2692,7 @@ def patch(rt, auto_id, raw, username="", notes=None):
2671
  # config that has never carried a target through the pure validator, which mints the empty
2672
  # `ut_ig_candidates`. `create` alone would have left the commonest path untouched.
2673
  merged = _apply_discover_default(rt, merged)
2674
- defn, err = clean_definition(merged, prev, username, notes=notes)
2675
  if err:
2676
  return None, err
2677
  defn["id"] = str(auto_id)
@@ -2987,6 +3008,21 @@ def remove(rt, auto_id):
2987
  # that belongs to the WRITE and cannot be removed from here. What is gone is the expensive one.
2988
  # ⚠ AND THE FALLBACK IS DELIBERATE: `all_defs` degrades to the whole read on any failure, so a
2989
  # store that cannot project still deletes correctly, only slower.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2990
  try:
2991
  import core.user_tables as _ut_defs # noqa: PLC0415
2992
  _scan = dict(_ut_defs.all_defs(rt) or {})
@@ -7864,6 +7900,288 @@ def _flow_cap_reason(table_key, rt=None):
7864
  return f". {cause}." + (f" To walk them all: {fix}." if fix else "")
7865
 
7866
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7867
  def run_plain(rt, defn, username="automation", log=print, step=_no_step, rows=None):
7868
  """⭐ WAVE 24 / R6 β€” the runner for an automation with NO machine step: its flow IS the whole
7869
  automation. Returns the same 5-tuple every other runner does, so `run_now` needs no special
@@ -7878,6 +8196,14 @@ def run_plain(rt, defn, username="automation", log=print, step=_no_step, rows=No
7878
  exact defect this module names in three other places. It reports the honest state and says
7879
  which fact is missing.
7880
  """
 
 
 
 
 
 
 
 
7881
  table = _flow_table(defn)
7882
  if not table:
7883
  return ("partial",
@@ -8617,7 +8943,7 @@ AI_AGENT_MAX_STEPS = 12
8617
  _AI_AGENT_CHAT = [None]
8618
 
8619
 
8620
- def _ai_agent_plan(cfg, row, log=print):
8621
  """A description + one record -> `(steps, "")` for `run_plan`, or `(None, sentence)`.
8622
 
8623
  ⭐⭐ W33-T56. This is the whole of the fuzzy step: the instruction and the record's own values
@@ -8646,6 +8972,10 @@ def _ai_agent_plan(cfg, row, log=print):
8646
  catalog=web_only,
8647
  required={k: v for k, v in ACTION_REQUIRED.items() if k in WEB_KINDS},
8648
  triggers=[], tables=[],
 
 
 
 
8649
  chat=_AI_AGENT_CHAT[0])
8650
  if why or not draft:
8651
  return None, (why or "the assistant produced no steps for that instruction")
@@ -8865,6 +9195,30 @@ ACTION_CATALOG = [
8865
  "history"},
8866
  {"kind": "send_email", "label": "Send email", "group": "Connected", "ready": False,
8867
  "detail": "Needs a send scope on the Gmail connection"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8868
  {"kind": "slack", "label": "Send Slack message", "group": "Connected", "ready": False,
8869
  "detail": "Needs the Slack connector"},
8870
  # ── 4. ADVANCED LOGIC ────────────────────────────────────────────────────────────────────
@@ -8905,10 +9259,63 @@ def _connector_meta(key):
8905
  return next((dict(v) for v in TRIGGER_CONNECTOR.values() if v.get("key") == key), None)
8906
 
8907
 
8908
- def action_catalog():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8909
  """The catalog as the wire carries it β€” a copy, because a caller that mutated the module
8910
  constant would change every later reader's answer.
8911
 
 
 
 
 
 
 
8912
  ⭐ WAVE 24 (C-ACT): each row is stamped with its `groupOrder`, DERIVED from
8913
  `ACTION_GROUP_ORDER` rather than hand-written per row, so a group cannot be given two
8914
  different orders by two rows that claim to be in it. A group nobody has ordered sorts LAST
@@ -8933,6 +9340,8 @@ def action_catalog():
8933
  last = max(ACTION_GROUP_ORDER.values()) + 1
8934
  out = []
8935
  for a in ACTION_CATALOG:
 
 
8936
  row = {**a, "groupOrder": ACTION_GROUP_ORDER.get(a.get("group"), last),
8937
  "menu": bool(a.get("menu", True))}
8938
  conn = _connector_meta(a.get("connector"))
@@ -9024,9 +9433,17 @@ def _action_label(act):
9024
  return str(act.get("kind") or "Action")
9025
 
9026
 
9027
- def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None):
9028
  """Validate `flow.actions`. Returns `(actions, error)` β€” refuses, never coerces.
9029
 
 
 
 
 
 
 
 
 
9030
  Ids are STABLE: a caller's well-formed `id` is kept, so selecting an action in the builder
9031
  survives a Save. A missing or colliding one is minted `act_<n>`; minting on every clean would
9032
  move the selection under the person editing it.
@@ -9064,6 +9481,15 @@ def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None):
9064
  if not row["ready"]:
9065
  return None, (f"{row['label']!r} is on the menu but not built yet. "
9066
  f"{row['detail'][0].lower()}{row['detail'][1:]}")
 
 
 
 
 
 
 
 
 
9067
  count[0] += 1
9068
  if count[0] > MAX_ACTIONS:
9069
  return None, f"an automation runs at most {MAX_ACTIONS} actions"
@@ -9105,7 +9531,7 @@ def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None):
9105
  if cerr:
9106
  return None, cerr
9107
  cfg_raw = entry.get("config") if isinstance(entry.get("config"), dict) else {}
9108
- cfg, cerr = _clean_action_config(kind, cfg_raw, depth, seen, count, notes=notes)
9109
  if cerr:
9110
  return None, cerr
9111
  # β›” D-75 β€” THE ALLOWLIST'S OWN DROPS, DIFFED HERE RATHER THAN REPORTED BY EACH ARM. Every
@@ -9125,8 +9551,14 @@ def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None):
9125
  return out, None
9126
 
9127
 
9128
- def _clean_action_config(kind, cfg, depth, seen, count, notes=None):
9129
- """One action's `config`, per kind. Returns `(config, error)`."""
 
 
 
 
 
 
9130
  if kind == "group":
9131
  # ⭐ WAVE 24 Β· C-FORK (owner ruling R8) β€” a group is a FORK now: `{branches: [...]}`,
9132
  # each `{id, label, cond, actions}`. It was one condition with one action list, i.e. an
@@ -9164,7 +9596,7 @@ def _clean_action_config(kind, cfg, depth, seen, count, notes=None):
9164
  # D-75: threaded into the branch too β€” an unconfigured step inside an If is
9165
  # exactly the one a person cannot see, which is `_walk_actions`' own argument.
9166
  kids, kerr = clean_actions(br.get("actions"), depth + 1, seen, count,
9167
- notes=notes)
9168
  if kerr:
9169
  return None, kerr
9170
  if not kids:
@@ -9229,6 +9661,34 @@ def _clean_action_config(kind, cfg, depth, seen, count, notes=None):
9229
  f"unique on it. It writes: " + ", ".join(sorted(clean_vals)))
9230
  out["uniqueOn"] = unique
9231
  return out, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9232
  if kind in ENRICH_KINDS:
9233
  # ⭐ WAVE 30 Β· T08 β€” BOTH networks share this branch. See `ENRICH_KINDS`: the selection, the
9234
  # cooldown, the limit clamps and their reasons are network-agnostic, and a second copy for
@@ -10348,6 +10808,29 @@ def apply_actions(rt, defn, table_key, row_ids, username="automation", log=print
10348
  # the table would silently apply one action's uniqueness rule to the other's rows.
10349
  creates.setdefault((target, str(cfg.get("uniqueOn") or "")), []).append(vals)
10350
  counts["created"] += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10351
  elif kind == "ai_agent":
10352
  # ⭐⭐ W33-T56 (owner item 7, ruling R3) β€” THE FUZZY STEP, AT RUN TIME.
10353
  #
@@ -10378,7 +10861,9 @@ def apply_actions(rt, defn, table_key, row_ids, username="automation", log=print
10378
  f"browser jobs of about 10-30 seconds each.")
10379
  counts["webBlocked"] += 1
10380
  continue
10381
- _plan, _why = _ai_agent_plan(cfg, row, log)
 
 
10382
  if _why:
10383
  # β›” NAMED, NEVER OPAQUE β€” the second half of the `done-when`. "The assistant
10384
  # could not work out how to do that" with the reason attached is actionable;
@@ -11770,9 +12255,16 @@ def ai_decide(rt, defn, act, row, row_id="", log=print):
11770
  (ut_get(rt, ((defn.get("config") or {}).get("targetTable")
11771
  or (defn.get("trigger") or {}).get("table") or "")) or {}
11772
  ).get("fields") or []]
 
 
 
 
 
 
11773
  choice, meta = ai_review.decide(prompt=cfg.get("prompt") or "", options=options,
11774
  row=row, fields=[f for f in fields if f],
11775
- label=cfg.get("label") or "Review")
 
11776
  if not choice:
11777
  if meta.get("problem"):
11778
  log(f"[aios-auto] ai review declined to answer: {meta['problem']}")
 
1782
  # β›” `terminal` IS THE DEFAULT and every existing automation gets it, because a stored definition
1783
  # that predates this field must not start moving records on its own the day the code ships. A
1784
  # loop is a thing somebody turns on.
1785
+ def clean_flow(raw, previous=None, notes=None, rt=None):
1786
+ """Validate the builder's ordered action list. `(flow, error)`.
1787
+
1788
+ ⚠ `rt` is the tenant wall for gated action kinds (W35-T35 / C8) and is simply forwarded.
1789
+ """
1790
  raw = raw if isinstance(raw, dict) else {}
1791
  prev = previous if isinstance(previous, dict) else {}
1792
  actions, err = clean_actions(
1793
+ raw.get("actions") if "actions" in raw else prev.get("actions"), notes=notes, rt=rt)
1794
  if err:
1795
  return None, err
1796
  return {"actions": actions}, None
 
2065
  return (defn or {}).get("kind") in DISCOVERY_KINDS and index == 0
2066
 
2067
 
2068
+ def clean_definition(raw, previous=None, username="", notes=None, rt=None):
2069
  """Whole-definition validation. Returns `(defn, error)`."""
2070
  raw = raw if isinstance(raw, dict) else {}
2071
  prev = previous or {}
 
2242
  _prev_table, _prev_enrich = discovery_seed_spec(prev.get("kind"))
2243
  flow_raw = _drop_ig_seeds(
2244
  flow_raw, (prev.get("config") or {}).get("targetTable") or _prev_table, _prev_enrich)
2245
+ flow, ferr = clean_flow(flow_raw, prev.get("flow"), notes=notes, rt=rt)
2246
  if ferr:
2247
  return None, ferr
2248
  # ⭐ WAVE 30 β€” widened with the seeder above: TikTok now HAS a pinned step 1, so the rule that
 
2318
  "state": dict(prev.get("state") or {}),
2319
  "created": prev.get("created") or _iso(),
2320
  "createdBy": prev.get("createdBy") or username,
2321
+ # ⭐⭐ WAVE 35 Β· T37 β€” `system` IS STICKY ACROSS A PATCH, AND IT IS **NEVER READ FROM `raw`**.
2322
+ #
2323
+ # β›” THE BUG THIS CLOSES WAS ALREADY WRITTEN AND WOULD HAVE SHIPPED. `routes_automation.
2324
+ # delete_automation` refuses any stored row carrying `system`, which is what makes a seeded
2325
+ # system agent undeletable β€” and this function is a WHITELIST that did not carry the key, so
2326
+ # the FIRST EDIT of such an agent silently stripped its marker and made it deletable. The
2327
+ # seed would then mint it again on the next list call: a delete that appears to work and
2328
+ # undoes itself. Found by asserting the round trip rather than by reading the code.
2329
+ #
2330
+ # β›” FROM `prev` ONLY. Reading it from `raw` would let ANY caller POST
2331
+ # `{"system": "anything"}` and mint themselves an automation nobody can delete β€” a
2332
+ # privilege escalation through a field nobody validates. The flag is granted by the seeder
2333
+ # and inherited from the stored row, never asserted by a payload.
2334
+ # ⚠ Same shape and same reason as the pre-set FIELD flag, which DESIGN.md §4 already
2335
+ # describes as "sticky across a PATCH". One idea, one behaviour, two objects.
2336
+ **({"system": _s(prev.get("system"), 40)} if prev.get("system") else {}),
2337
  }, None
2338
 
2339
 
 
2635
  # has, instead of letting the pure validator mint a second empty one. See
2636
  # `discover_default_table`; a target the caller named is never rewritten.
2637
  raw = _apply_discover_default(rt, raw)
2638
+ # W35-T35 (C8): `rt=` is the tenant wall for gated action kinds. Both doors pass it; a door
2639
+ # that forgot would REFUSE the kind, not admit it (`TENANT_GATED_ACTIONS` is fail-closed).
2640
+ defn, err = clean_definition(raw, None, username, notes=notes, rt=rt)
2641
  if err:
2642
  _unmint(rt, minted)
2643
  return None, err
 
2692
  # config that has never carried a target through the pure validator, which mints the empty
2693
  # `ut_ig_candidates`. `create` alone would have left the commonest path untouched.
2694
  merged = _apply_discover_default(rt, merged)
2695
+ defn, err = clean_definition(merged, prev, username, notes=notes, rt=rt)
2696
  if err:
2697
  return None, err
2698
  defn["id"] = str(auto_id)
 
3008
  # that belongs to the WRITE and cannot be removed from here. What is gone is the expensive one.
3009
  # ⚠ AND THE FALLBACK IS DELIBERATE: `all_defs` degrades to the whole read on any failure, so a
3010
  # store that cannot project still deletes correctly, only slower.
3011
+ #
3012
+ # ⭐⭐ WAVE 35 Β· T34 β€” THE RESIDUAL IS NOW MEASURED RATHER THAN ASSERTED, and the count is
3013
+ # better than the paragraph above claims. Whole `user_tables` reads per delete, against a
3014
+ # runtime that has production's `get_projection`:
3015
+ # TAGGED (a discovery flow that spawned columns) β†’ 1 whole + 1 projected
3016
+ # UNTAGGED (a `plain` flow) β†’ 0 whole + 1 projected
3017
+ # The untagged case pays NONE because `if not plan: return` fires before the write. The
3018
+ # tagged case's ONE is `rt.update`'s own strict read, and it is a FLOOR, not a choice:
3019
+ # `core/store.py::get_projection` states that a write must always read whole, because a
3020
+ # read-modify-write handed a rows-less document would upload the tenant with its rows deleted.
3021
+ # β›” Reaching ZERO needs D-179's `{flowId: [(table_key, field_key)]}` index, written where the
3022
+ # presets are spawned β€” a feature, not an edit to this function. Nothing here can do better.
3023
+ # ⚠ THOSE NUMBERS WERE UNOBSERVABLE UNTIL W35-T34: `verify_automation`'s delete-speed double
3024
+ # had no `get_projection`, so `all_defs` fell back and the gate measured 2 and 1 β€” the
3025
+ # PRE-WAVE-33 path β€” for two waves. The double is producer-faithful now and asserts the counts.
3026
  try:
3027
  import core.user_tables as _ut_defs # noqa: PLC0415
3028
  _scan = dict(_ut_defs.all_defs(rt) or {})
 
7900
  return f". {cause}." + (f" To walk them all: {fix}." if fix else "")
7901
 
7902
 
7903
+ # ---------------------------------------------------------------------------------------------
7904
+ # WAVE 35 Β· T36 / CONTRACT C8 / OWNER RULING R10 β€” THE STATEMENTS BATCH: ASSEMBLE, PARK, NEVER SEND
7905
+ #
7906
+ # β›”β›” THE TICKET'S `how:` SAID TO USE "the board's strict review posture (wave 22)". THAT POSTURE
7907
+ # DOES NOT EXIST, and rebuilding it would REVERSE AN OWNER RULING rather than merely miss a symbol.
7908
+ # Wave 27 R3 deleted `board()`, `move_card()`, `stages_for()`, `ensure_stage_field()` and the review
7909
+ # branch of the action walk (the tombstone is above `LANE_OPS`), and its reason is a DATA reason,
7910
+ # verbatim: *"the board wrote MACHINE COLUMNS INTO A TENANT'S OWN TABLE β€” a stage select, an `_at`
7911
+ # stamp and a `_cycles` counter per automation β€” to render a view of state the RUN LOG already
7912
+ # holds."* `retire_automation_stage_fields` and `migrate_ig_tables` still DROP those columns, so a
7913
+ # batch parked in a stage field would be deleted by our own migration.
7914
+ # β‡’ The batch parks on the AUTOMATION DEFINITION, the shape `runs` and `reviews` already use. Zero
7915
+ # machine columns on a customer's database, and the migration has nothing to take away.
7916
+ #
7917
+ # β›” A STATEMENTS FLOW IS **BATCH**-SCOPED, NOT RECORD-SCOPED, and that is why it does not go
7918
+ # through `apply_actions` at all. `run_plain` walks every row of the flow's bound table, so a
7919
+ # per-record arm would assemble the whole worklist once per record; and a statements agent binds NO
7920
+ # table (its customers come from the Odoo AR worklist, not a `ut_*` grid), so `run_plain` would
7921
+ # have answered `partial: "no database is bound yet"` and the step would NEVER HAVE RUN. The
7922
+ # precedent for a kind that defines its own scope is already here: the enrich-only branch below,
7923
+ # whose saved View "ARE the set of records the automation was asked to process".
7924
+ # ⚠ The `_walk` arm for this kind therefore stays a REFUSAL, not a duplicate: a `send_statement`
7925
+ # dropped into an ordinary record-walking flow must say it did nothing, never half-send.
7926
+ #
7927
+ # β›”β›” AND NOTHING HERE SENDS. Not one function in this section imports the mail path. The send door
7928
+ # is `routes_statements`, behind `admin_gate` + `_royal_only`, calling `collections_send.
7929
+ # queue_statement`, whose SAFE_MODE guardrail lives in the DATA LAYER where no route, payload or UI
7930
+ # can bypass it. This module ASSEMBLES and PARKS. A person clicks Send.
7931
+
7932
+ #: How many statements one parked batch holds. Bounded for the reason `MAX_RUNS` and `MAX_REVIEWS`
7933
+ #: are: this rides inside the automation DEFINITION, and an unbounded list is a serialisation cost
7934
+ #: on every read of the automations bucket.
7935
+ #: ⚠ WHEN IT BITES IT IS DISCLOSED, never a silent `[:N]` β€” R6's second sentence, and
7936
+ #: [[no-unverifiable-aggregates]]. `assemble_statements` appends a note naming the number left out.
7937
+ MAX_STATEMENT_BATCH = 200
7938
+
7939
+ #: The collection worklist loader, injectable so a gate can drive this without Odoo credentials.
7940
+ #: Production leaves it None and the real data layer answers. Same shape as `_DRAFT_CHAT`.
7941
+ _STATEMENT_ROWS = [None]
7942
+
7943
+
7944
+ def _collections():
7945
+ """`modules.collections_send`, imported lazily β€” `platform/` is not on the path at import time
7946
+ for every consumer of this module, and this is the only section that needs it."""
7947
+ import modules.collections_send as cs # noqa: PLC0415
7948
+ return cs
7949
+
7950
+
7951
+ def statement_steps(defn):
7952
+ """The ENABLED `send_statement` actions of this flow, top level only.
7953
+
7954
+ ⚠ Top level only, deliberately, and `is_statement_flow` is what makes that safe: a batch flow
7955
+ is exactly one enabled action. A `send_statement` nested inside an If is NOT a batch flow, so
7956
+ it falls to the ordinary record walk and is refused there with a sentence.
7957
+ """
7958
+ return [a for a in (((defn or {}).get("flow") or {}).get("actions") or [])
7959
+ if isinstance(a, dict) and a.get("kind") == "send_statement"
7960
+ and a.get("enabled", True)]
7961
+
7962
+
7963
+ def is_statement_flow(defn):
7964
+ """Is this automation a statements BATCH rather than a record walk?
7965
+
7966
+ β›” EXACTLY ONE ENABLED ACTION, AND IT IS THIS KIND. The narrowness is the safety: a flow that
7967
+ also updates records has record semantics that the batch path would silently drop, so it keeps
7968
+ the ordinary walk (where the arm refuses and says so). This mirrors the enrich-only branch in
7969
+ `run_plain`, which is narrow for the same stated reason.
7970
+ """
7971
+ enabled = [a for a in (((defn or {}).get("flow") or {}).get("actions") or [])
7972
+ if isinstance(a, dict) and a.get("enabled", True)]
7973
+ return len(enabled) == 1 and enabled[0].get("kind") == "send_statement"
7974
+
7975
+
7976
+ def assemble_statements(defn, log=print):
7977
+ """`(batch, notes)` β€” the statements this configuration WOULD send, rendered, plus why anybody
7978
+ was left out. **Reads Odoo read-only. Sends nothing. Writes nothing.**
7979
+
7980
+ Each entry is `{customer, to, subject, html, tier, overdue}`. `html` is rendered by the SAME
7981
+ `render_statement_html` the send door uses, so what a person approves is what goes out β€” a
7982
+ review screen rendering its own approximation of the mail is a review of the wrong thing.
7983
+
7984
+ ⚠ A CUSTOMER WITH NO EMAIL IS *SKIPPED AND NAMED*, never dropped. `routes_statements.send`
7985
+ already separates "we could not" from "there was nowhere to send"; this keeps that distinction
7986
+ at the assembly end, because a batch that silently shrinks is one nobody can reconcile.
7987
+ """
7988
+ cfg = (statement_steps(defn) or [{}])[0].get("config") or {}
7989
+ cs = _collections()
7990
+ notes = []
7991
+ loader = _STATEMENT_ROWS[0]
7992
+ rows = list(loader() if loader else cs.load_collection_list(cs.Odoo()))
7993
+ tier = str(cfg.get("tier") or "").strip()
7994
+ if tier:
7995
+ before = len(rows)
7996
+ rows = [r for r in rows if str(r.get("Tier") or "") == tier]
7997
+ log(f"[aios-auto] statements: {len(rows)} of {before} customers are in tier {tier}")
7998
+ subject_tpl = str(cfg.get("subject") or "") or cs.DEFAULT_SUBJECT
7999
+ intro_tpl = str(cfg.get("intro") or "") or cs.DEFAULT_INTRO
8000
+ footer_tpl = str(cfg.get("footer") or "") or cs.DEFAULT_FOOTER
8001
+ month = _dt.date.today().strftime("%B %Y")
8002
+ batch, no_email = [], []
8003
+ for row in rows:
8004
+ if len(batch) >= MAX_STATEMENT_BATCH:
8005
+ break
8006
+ name = str(row.get("Customer") or "")
8007
+ to = str(row.get("Email") or "").strip()
8008
+ if not to:
8009
+ no_email.append(name)
8010
+ continue
8011
+ try:
8012
+ subject = subject_tpl.format(customer=name, company=cs.COMPANY, month=month)
8013
+ except (KeyError, IndexError):
8014
+ # An unknown placeholder is the person's typo, not a crash. Show what they typed β€”
8015
+ # the same choice `routes_statements.preview` already makes.
8016
+ subject = subject_tpl
8017
+ batch.append({"customer": name, "to": to, "subject": _s(subject, 200),
8018
+ "html": _s(cs.render_statement_html(row, intro_tpl, footer_tpl), 40000),
8019
+ "tier": str(row.get("Tier") or ""), "overdue": row.get("Overdue")})
8020
+ if no_email:
8021
+ shown = ", ".join(no_email[:5])
8022
+ one = len(no_email) == 1
8023
+ notes.append(f"{len(no_email)} customer{'' if one else 's'} "
8024
+ f"{'has' if one else 'have'} no email address on their record and "
8025
+ f"{'is' if one else 'are'} not in this batch "
8026
+ f"({shown}{', and others' if len(no_email) > 5 else ''}). "
8027
+ f"Add an address in Odoo and run it again")
8028
+ left = len(rows) - len(batch) - len(no_email)
8029
+ if left > 0:
8030
+ # R6's second sentence: a limit that bites is REPORTED, with its cause and what to do.
8031
+ notes.append(f"{left} more customers matched but one batch holds "
8032
+ f"{MAX_STATEMENT_BATCH}. Send this batch, then run it again for the rest, or "
8033
+ f"narrow the tier filter")
8034
+ return batch, notes
8035
+
8036
+
8037
+ def park_statements(rt, auto_id, batch, notes):
8038
+ """Write the assembled batch onto the DEFINITION as `pendingStatements`. Replaces, never
8039
+ appends: a batch is THIS run's worklist, and two runs' statements merged into one list is a
8040
+ customer invoiced twice.
8041
+
8042
+ ⚠ `flush="async"` for the reason every writer in this module gives β€” the read-your-writes
8043
+ contract means the next `GET /automations` already sees it, and only the upload is deferred.
8044
+ """
8045
+ entry = {"ts": _iso(), "count": len(batch), "sent": False,
8046
+ "items": list(batch)[:MAX_STATEMENT_BATCH],
8047
+ "notes": [_s(n, 300) for n in (notes or [])][:25]}
8048
+
8049
+ def _up(cur):
8050
+ cur = cur if isinstance(cur, dict) else {}
8051
+ d = cur.get(str(auto_id))
8052
+ if d is not None:
8053
+ d["pendingStatements"] = entry
8054
+ return cur
8055
+
8056
+ _store_update(rt, _up, flush="async")
8057
+ return entry
8058
+
8059
+
8060
+ def clear_statements(rt, auto_id):
8061
+ """Drop a parked batch β€” after it is sent, or when somebody discards it."""
8062
+ def _up(cur):
8063
+ cur = cur if isinstance(cur, dict) else {}
8064
+ d = cur.get(str(auto_id))
8065
+ if d is not None:
8066
+ d.pop("pendingStatements", None)
8067
+ return cur
8068
+
8069
+ _store_update(rt, _up, flush="async")
8070
+
8071
+
8072
+ def run_statements(rt, defn, username="automation", log=print, step=_no_step):
8073
+ """The batch runner: assemble, park, tell somebody. Returns `run_plain`'s 5-tuple.
8074
+
8075
+ β›” THE STATE IS `partial`, NEVER `ok`, AND THAT IS DELIBERATE. `ok` would put a green dot over
8076
+ a job that is only half done β€” the statements exist and NOBODY HAS SENT THEM. `partial` is this
8077
+ module's honest-progress state and the summary says exactly what is waiting, which is what makes
8078
+ the run notification (`_notify_run`) read as "come and look" rather than "done".
8079
+ """
8080
+ if not is_statement_tenant(rt):
8081
+ # Belt and braces behind `clean_actions`' wall: a definition stored before the wall, or
8082
+ # copied between tenants, must not assemble another company's customer list.
8083
+ return ("error", "statements are not configured for this workspace", {}, [], {})
8084
+ step("reading the collection list")
8085
+ try:
8086
+ batch, notes = assemble_statements(defn, log=log)
8087
+ except Exception as exc: # noqa: BLE001
8088
+ return ("error", f"the collection list could not be read: {str(exc)[:200]}", {}, [], {})
8089
+ step("rendering statements")
8090
+ park_statements(rt, defn.get("id"), batch, notes)
8091
+ counts = {"statements": len(batch)}
8092
+ if not batch:
8093
+ return ("partial", "no customers matched, so there is nothing to review", counts, [], {})
8094
+ one = len(batch) == 1
8095
+ return ("partial",
8096
+ f"{len(batch)} statement{'' if one else 's'} "
8097
+ f"{'is' if one else 'are'} ready for review. Nothing has been sent. Open this agent "
8098
+ f"and click Send to release them",
8099
+ counts, [], {})
8100
+
8101
+
8102
+ #: ⭐⭐ WAVE 35 Β· T37 β€” THE SEEDED ROYAL IMPORTS STATEMENTS AGENT.
8103
+ #: A FIXED id, because the row's own EXISTENCE is the idempotency key (see `seed_statements_agent`).
8104
+ STATEMENTS_AGENT_ID = "system:statements"
8105
+ SYSTEM_STATEMENTS = "statements"
8106
+ #: First of the month, 06:00. A monthly statement run is what the collections worklist is for, and
8107
+ #: the hour matches the Odoo sync default so the numbers it reads are the night's.
8108
+ STATEMENTS_DEFAULT_CRON = "0 6 1 * *"
8109
+
8110
+
8111
+ def seed_statements_agent(rt, username="system"):
8112
+ """Mint Royal Imports' "Monthly statements" agent ONCE, **switched OFF**. Returns the
8113
+ definition if it wrote one, else None.
8114
+
8115
+ β›”β›” IT ARRIVES `enabled: False` AND THAT IS THE TICKET'S OWN TRAP, not a preference. An agent
8116
+ that ships enabled has scheduled itself against real customers before a single person has read
8117
+ its configuration β€” and this one's action queues mail to the tenant's actual debtors.
8118
+
8119
+ ⭐ WHY STORED, WHEN WAVE 34's SYSTEM AGENT IS DERIVED. `_odoo_sync_row` gives three reasons for
8120
+ deriving and only one survives contact with THIS agent (raised as `ASK D-11`):
8121
+ Β· "two copies of one cadence" β€” does not apply: Odoo's cadence already lives in the connector
8122
+ config, so a stored copy could drift; a statements schedule has no other home to drift from.
8123
+ Β· "stored means the failure-pause can disable it" β€” INVERTS: auto-pausing statements after K
8124
+ consecutive failures is correct (a dead credential should stop it), where pausing the Odoo
8125
+ cadence would silently break infrastructure.
8126
+ Β· "nothing can seed it" β€” real, and answered here: ONE write per tenant ever, by the
8127
+ CONTAINER (which is what D-195 asks for; what it forbids is a CLI writing while the Space
8128
+ is up), behind the existence check below.
8129
+ And deriving cannot meet T37 anyway: a derived row is not in the automations bucket, so edit,
8130
+ toggle, schedule and run would each need a bespoke door β€” four new mechanisms to avoid one
8131
+ guarded write. The Odoo agent escapes that only because a separate resync loop already does its
8132
+ work.
8133
+
8134
+ β›” THE EXISTENCE CHECK IS THE WHOLE IDEMPOTENCY STORY, AND IT IS SAFE ONLY BECAUSE THE ROW
8135
+ CANNOT BE DELETED. `routes_automation.delete_automation` refuses any stored row carrying
8136
+ `system` (409, with a sentence), so "the row is present" can never become false behind our
8137
+ back. If that refusal is ever relaxed, THIS FUNCTION NEEDS A SEPARATE DURABLE FLAG β€” otherwise
8138
+ deleting the agent resurrects it on the next list call, which is a delete that undoes itself.
8139
+ ⚠ And `system` is STICKY through `clean_definition` (see its return) for the same reason: an
8140
+ edit that stripped the marker would make the row deletable and re-open exactly that hole.
8141
+ """
8142
+ if not is_statement_tenant(rt):
8143
+ return None
8144
+ existing = all_definitions(rt) or {}
8145
+ if STATEMENTS_AGENT_ID in existing:
8146
+ return None
8147
+ raw = {
8148
+ "name": "Monthly statements",
8149
+ "kind": "plain",
8150
+ # ⚠ A SCHEDULE THAT IS OFF, not an absent schedule: the cron is the CONFIGURATION T37 asks
8151
+ # a person to open and read, and a blank one would make them invent it before they could
8152
+ # judge it.
8153
+ "schedule": {"cron": STATEMENTS_DEFAULT_CRON, "enabled": False},
8154
+ "trigger": {"key": "schedule"},
8155
+ "flow": {"actions": [{
8156
+ "id": "act_1", "kind": "send_statement",
8157
+ # ⚠ TIER BLANK = every tier, which is the honest default: narrowing to "A-Urgent" would
8158
+ # be us deciding who gets chased, and the blank is the value the config panel shows as
8159
+ # "all of them" rather than an empty box.
8160
+ "config": {"tier": "", "subject": "", "intro": "", "footer": ""},
8161
+ }]},
8162
+ }
8163
+ defn, err = clean_definition(raw, None, username, rt=rt)
8164
+ if err:
8165
+ return None
8166
+ defn["id"] = STATEMENTS_AGENT_ID
8167
+ # Stamped AFTER the clean, because `clean_definition` reads this key from `prev` only β€” a
8168
+ # payload may never assert it (see that function's note).
8169
+ defn["system"] = SYSTEM_STATEMENTS
8170
+
8171
+ def _up(cur):
8172
+ cur = cur if isinstance(cur, dict) else {}
8173
+ # ⚠ RE-CHECKED INSIDE THE MUTATION. Two concurrent list calls can both pass the read above;
8174
+ # the store applies mutations under its own lock, so this is where "only once" is actually
8175
+ # decided. Without it the second writer would overwrite a row a person may already have
8176
+ # edited, silently resetting their schedule and template.
8177
+ if STATEMENTS_AGENT_ID not in cur:
8178
+ cur[STATEMENTS_AGENT_ID] = defn
8179
+ return cur
8180
+
8181
+ _store_update(rt, _up, flush="async")
8182
+ return defn
8183
+
8184
+
8185
  def run_plain(rt, defn, username="automation", log=print, step=_no_step, rows=None):
8186
  """⭐ WAVE 24 / R6 β€” the runner for an automation with NO machine step: its flow IS the whole
8187
  automation. Returns the same 5-tuple every other runner does, so `run_now` needs no special
 
8196
  exact defect this module names in three other places. It reports the honest state and says
8197
  which fact is missing.
8198
  """
8199
+ # ⭐⭐ WAVE 35 Β· T36 β€” THE BATCH FLOW BRANCHES BEFORE THE TABLE CHECK, and the ORDER is the
8200
+ # whole of it. A statements agent binds no `ut_*` table (its customers come from the Odoo AR
8201
+ # worklist), so leaving this below the `if not table` return would answer "no database is bound
8202
+ # yet" and the step would never run β€” a feature that is whole, gated and unreachable, which is
8203
+ # this repo's most-repeated failure. See the section above `run_plain` for why it is not an
8204
+ # `apply_actions` arm.
8205
+ if is_statement_flow(defn):
8206
+ return run_statements(rt, defn, username=username, log=log, step=step)
8207
  table = _flow_table(defn)
8208
  if not table:
8209
  return ("partial",
 
8943
  _AI_AGENT_CHAT = [None]
8944
 
8945
 
8946
+ def _ai_agent_plan(cfg, row, log=print, st=None, user=""):
8947
  """A description + one record -> `(steps, "")` for `run_plan`, or `(None, sentence)`.
8948
 
8949
  ⭐⭐ W33-T56. This is the whole of the fuzzy step: the instruction and the record's own values
 
8972
  catalog=web_only,
8973
  required={k: v for k, v in ACTION_REQUIRED.items() if k in WEB_KINDS},
8974
  triggers=[], tables=[],
8975
+ # ⭐ W35 Β· C7 (`NOTE E-16`) β€” threaded IN from `_walk` rather than resolved here: this
8976
+ # function is pure over `(cfg, row)` by design and giving it a store handle of its own
8977
+ # would be a second way to reach the tenant.
8978
+ st=st, user=user,
8979
  chat=_AI_AGENT_CHAT[0])
8980
  if why or not draft:
8981
  return None, (why or "the assistant produced no steps for that instruction")
 
9195
  "history"},
9196
  {"kind": "send_email", "label": "Send email", "group": "Connected", "ready": False,
9197
  "detail": "Needs a send scope on the Gmail connection"},
9198
+ # ⭐⭐ WAVE 35 Β· T35 / CONTRACT C8 / OWNER RULING R10 β€” STATEMENTS BECOME AN AGENT STEP.
9199
+ #
9200
+ # Owner item 14: move Statements out of Settings and into the agent automation, "templatic and
9201
+ # easily toggleable". R10 is the half that decides the shape: the step ASSEMBLES and PARKS a
9202
+ # batch in a review stage, and **nothing sends without a human click**. So this row's promise
9203
+ # is deliberately "prepares", not "sends" β€” the label a person reads must not describe an act
9204
+ # the step does not perform.
9205
+ #
9206
+ # β›” ADDED BESIDE `send_email`, NEVER BY REPURPOSING IT (the ticket's own trap, D-295): a
9207
+ # stored automation naming a kind that no longer exists is refused FOREVER rather than dropped
9208
+ # with a reason, so adding a kind is cheap and re-pointing one is not.
9209
+ #
9210
+ # β›” `ready: True` IS WHAT MAKES IT STORABLE β€” `clean_actions` refuses any row whose `ready` is
9211
+ # false β€” and the note at `enrich_tiktok` above is the reason the RUNNER ARM lands in the same
9212
+ # wave rather than after it: `_walk` has no terminal `else`, so a catalog row ahead of its arm
9213
+ # is addable, storable and SILENTLY INERT, which is worse than not existing. T35 lands an arm
9214
+ # that reports it is not configured; T36 makes it park a real batch.
9215
+ #
9216
+ # ⚠ TENANT-GATED, not `ready: False`: `TENANT_GATED_ACTIONS` withholds this row from every
9217
+ # tenant but #0, because the send client behind it is env-credentialed and is tenant #0's.
9218
+ {"kind": "send_statement", "label": "Prepare customer statements", "group": "Connected",
9219
+ "ready": True,
9220
+ "detail": "Assemble this month's statements and park them for review. Nothing is sent until "
9221
+ "somebody opens the batch and clicks Send"},
9222
  {"kind": "slack", "label": "Send Slack message", "group": "Connected", "ready": False,
9223
  "detail": "Needs the Slack connector"},
9224
  # ── 4. ADVANCED LOGIC ────────────────────────────────────────────────────────────────────
 
9259
  return next((dict(v) for v in TRIGGER_CONNECTOR.values() if v.get("key") == key), None)
9260
 
9261
 
9262
+ #: ⭐⭐ WAVE 35 Β· T35 / CONTRACT C8 β€” THE TENANT PREDICATE `routes_statements._royal_only` USES,
9263
+ #: LIFTED SO THERE IS EXACTLY ONE COPY OF IT. C8's words are "the predicate is imported, never
9264
+ #: re-expressed", and this is the direction that import can run: `automation_engine` imports no
9265
+ #: route module and no FastAPI (checked), and breaking that to reach `_royal_only` would drag
9266
+ #: `deps` + `routes_admin` into the engine. So the ENGINE holds the test and the ROUTE calls it.
9267
+ #:
9268
+ #: β›”β›” THIS IS NOT `odoo_relational.is_royal`, AND THE DIFFERENCE IS A SEND. That one asks "is this
9269
+ #: tenant ENTITLED to Odoo databases" over `RI_SLUGS = ("", "royal-imports")` β€” it answers TRUE for
9270
+ #: the EMPTY slug and lower-cases its input. This one is `_royal_only`'s exact test: the runtime's
9271
+ #: own `key`, matched exactly. A tenant whose key never got set would pass `is_royal("")` and then
9272
+ #: queue statements THROUGH TENANT #0'S ENV-CREDENTIALED SEND CLIENT, i.e. email another company's
9273
+ #: customers over Royal Imports' name. Entitlement to READ is not authority to SEND, and the two
9274
+ #: questions keep their two predicates on purpose. Do not "unify" them.
9275
+ ROYAL_TENANT_KEY = "royal-imports"
9276
+
9277
+
9278
+ def is_statement_tenant(rt):
9279
+ """Exactly `routes_statements._royal_only`'s test, as a boolean over the RUNTIME.
9280
+
9281
+ Keyed on the runtime, never on a request field: a tenant is a property of the SESSION, so a
9282
+ payload cannot argue its way into another company's sender.
9283
+ """
9284
+ return getattr(rt, "key", None) == ROYAL_TENANT_KEY
9285
+
9286
+
9287
+ #: Kinds only SOME tenants may see or store, as `{kind: predicate(rt) -> bool}`.
9288
+ #:
9289
+ #: β›” FAIL-CLOSED ON `rt=None`, AND THAT IS THE WHOLE SAFETY ARGUMENT. Every reader below treats an
9290
+ #: absent runtime as "not allowed", so a call site that forgets to pass one makes the action VANISH
9291
+ #: rather than become universal. The opposite default would mean any future caller of
9292
+ #: `action_catalog()` or `clean_actions()` silently offers tenant #0's send door to every tenant β€”
9293
+ #: an omission that is invisible in review and loud only in production [[default-must-pass-its-own-guard]].
9294
+ TENANT_GATED_ACTIONS = {"send_statement": is_statement_tenant}
9295
+
9296
+ #: The dunning buckets a statements step may filter on. ⚠ ONE LITERAL, TWO READERS: this and
9297
+ #: `routes_statements.statements()`'s `"tiers"` field are the same four strings, and a step
9298
+ #: configured against a tier the sender's worklist does not produce would filter to nothing and
9299
+ #: report success. `routes_statements` imports this rather than repeating it.
9300
+ STATEMENT_TIERS = ("A-Urgent", "B-Active", "C-Light", "Monitor")
9301
+
9302
+
9303
+ def _tenant_may_use(kind, rt):
9304
+ """May this runtime see/store this action kind? Ungated kinds are always yes."""
9305
+ gate = TENANT_GATED_ACTIONS.get(str(kind or ""))
9306
+ return True if gate is None else bool(rt is not None and gate(rt))
9307
+
9308
+
9309
+ def action_catalog(rt=None):
9310
  """The catalog as the wire carries it β€” a copy, because a caller that mutated the module
9311
  constant would change every later reader's answer.
9312
 
9313
+ ⭐⭐ WAVE 35 Β· T35 (C8): `rt` filters TENANT-GATED rows out entirely β€” not `ready: False`, not
9314
+ `menu: False`, but ABSENT. A tenant that may not send statements should not learn that the
9315
+ capability exists, and `ready: False` renders as "coming soon", which is a promise we are not
9316
+ making to them. ⚠ Called with no `rt` the gated rows are withheld (fail-closed): see
9317
+ `TENANT_GATED_ACTIONS`.
9318
+
9319
  ⭐ WAVE 24 (C-ACT): each row is stamped with its `groupOrder`, DERIVED from
9320
  `ACTION_GROUP_ORDER` rather than hand-written per row, so a group cannot be given two
9321
  different orders by two rows that claim to be in it. A group nobody has ordered sorts LAST
 
9340
  last = max(ACTION_GROUP_ORDER.values()) + 1
9341
  out = []
9342
  for a in ACTION_CATALOG:
9343
+ if not _tenant_may_use(a.get("kind"), rt):
9344
+ continue
9345
  row = {**a, "groupOrder": ACTION_GROUP_ORDER.get(a.get("group"), last),
9346
  "menu": bool(a.get("menu", True))}
9347
  conn = _connector_meta(a.get("connector"))
 
9433
  return str(act.get("kind") or "Action")
9434
 
9435
 
9436
+ def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None, rt=None):
9437
  """Validate `flow.actions`. Returns `(actions, error)` β€” refuses, never coerces.
9438
 
9439
+ ⭐⭐ WAVE 35 Β· T35 (C8) β€” `rt` IS THE TENANT WALL ON THE STORE SIDE, and it is a separate wall
9440
+ from `action_catalog(rt)`'s. Withholding a row from the MENU stops it being offered; it does
9441
+ not stop a hand-written body naming the kind, and the picker is not a security boundary
9442
+ [[opening-a-route-widens-every-field]]. ⚠ Keyword-only in effect and defaulting to None, so
9443
+ every existing caller is untouched by construction β€” the same shape `notes` used, for the
9444
+ reason this docstring already gives about a signature change failing at RUN, not at import.
9445
+ β›” `rt=None` REFUSES a gated kind rather than allowing it (`TENANT_GATED_ACTIONS`).
9446
+
9447
  Ids are STABLE: a caller's well-formed `id` is kept, so selecting an action in the builder
9448
  survives a Save. A missing or colliding one is minted `act_<n>`; minting on every clean would
9449
  move the selection under the person editing it.
 
9481
  if not row["ready"]:
9482
  return None, (f"{row['label']!r} is on the menu but not built yet. "
9483
  f"{row['detail'][0].lower()}{row['detail'][1:]}")
9484
+ # ⭐⭐ W35-T35 (C8) β€” THE TENANT WALL. Refused, not dropped, and this is the one place in
9485
+ # this function where a refusal is right: D-65's "drop, never refuse" protects a stored
9486
+ # automation from becoming permanently unsavable, and NO tenant this can refuse has ever
9487
+ # been able to store one β€” the kind is withheld from their catalog, so there is no legacy
9488
+ # body to strand. Dropping it silently would instead let a flow save, look saved, and never
9489
+ # do the step the person configured.
9490
+ if not _tenant_may_use(kind, rt):
9491
+ return None, (f"{row['label']!r} is not available in this workspace. It sends as "
9492
+ f"Royal Imports, using Royal Imports' own mail credentials")
9493
  count[0] += 1
9494
  if count[0] > MAX_ACTIONS:
9495
  return None, f"an automation runs at most {MAX_ACTIONS} actions"
 
9531
  if cerr:
9532
  return None, cerr
9533
  cfg_raw = entry.get("config") if isinstance(entry.get("config"), dict) else {}
9534
+ cfg, cerr = _clean_action_config(kind, cfg_raw, depth, seen, count, notes=notes, rt=rt)
9535
  if cerr:
9536
  return None, cerr
9537
  # β›” D-75 β€” THE ALLOWLIST'S OWN DROPS, DIFFED HERE RATHER THAN REPORTED BY EACH ARM. Every
 
9551
  return out, None
9552
 
9553
 
9554
+ def _clean_action_config(kind, cfg, depth, seen, count, notes=None, rt=None):
9555
+ """One action's `config`, per kind. Returns `(config, error)`.
9556
+
9557
+ ⚠ `rt` is threaded ONLY so the `group` arm can hand it back to `clean_actions` for the branch
9558
+ recursion (W35-T35 / C8). Without it a tenant-gated kind is refused at the top level and
9559
+ ACCEPTED inside an If, which is the half of a flow `_walk_actions`' own docstring says people
9560
+ cannot see.
9561
+ """
9562
  if kind == "group":
9563
  # ⭐ WAVE 24 Β· C-FORK (owner ruling R8) β€” a group is a FORK now: `{branches: [...]}`,
9564
  # each `{id, label, cond, actions}`. It was one condition with one action list, i.e. an
 
9596
  # D-75: threaded into the branch too β€” an unconfigured step inside an If is
9597
  # exactly the one a person cannot see, which is `_walk_actions`' own argument.
9598
  kids, kerr = clean_actions(br.get("actions"), depth + 1, seen, count,
9599
+ notes=notes, rt=rt)
9600
  if kerr:
9601
  return None, kerr
9602
  if not kids:
 
9661
  f"unique on it. It writes: " + ", ".join(sorted(clean_vals)))
9662
  out["uniqueOn"] = unique
9663
  return out, None
9664
+ if kind == "send_statement":
9665
+ # ⭐⭐ WAVE 35 Β· T35 / R10 β€” WHAT A STATEMENTS STEP IS CONFIGURED WITH: a customer filter
9666
+ # and a template. Both are the SENDER's own vocabulary (`routes_statements.send` passes
9667
+ # `templates: {subject, intro, footer}` straight to `collections_send.queue_statement`),
9668
+ # so this arm names no field the send door does not already take.
9669
+ #
9670
+ # ⚠ EVERY TEMPLATE FIELD IS OPTIONAL AND EMPTY MEANS "THE DEFAULT", which is exactly how
9671
+ # the send door already reads them (`t.get("subject") or cs.DEFAULT_SUBJECT`). Storing our
9672
+ # own copy of the default instead would freeze today's wording into every stored agent and
9673
+ # silently stop tracking `collections_send`'s.
9674
+ out = {}
9675
+ tier = _s(cfg.get("tier"), 40).strip()
9676
+ # β›” REFUSED, NOT COERCED, AND THE ANSWER LISTS THE REAL ONES β€” the same shape the connector
9677
+ # cadence uses. A tier nobody sends to would filter the batch to zero customers and report
9678
+ # a successful run: a silent no-op is the worst outcome available here, because the person
9679
+ # believes their customers were invoiced.
9680
+ # ⚠ `""` IS LEGAL and means EVERY tier. It is the stored default, so an agent saved before
9681
+ # anybody picks a filter does not change meaning the day this key arrives (D-65's rule).
9682
+ if tier and tier not in STATEMENT_TIERS:
9683
+ return None, (f"{tier!r} is not a collection tier. Pick one of: "
9684
+ + ", ".join(STATEMENT_TIERS) + ", or leave it blank for all of them")
9685
+ out["tier"] = tier
9686
+ for key, cap in (("subject", 200), ("intro", 4000), ("footer", 4000)):
9687
+ out[key] = _s(cfg.get(key), cap)
9688
+ lbl = " ".join(_s(cfg.get("label"), 60).split())
9689
+ if lbl:
9690
+ out["label"] = lbl
9691
+ return out, None
9692
  if kind in ENRICH_KINDS:
9693
  # ⭐ WAVE 30 Β· T08 β€” BOTH networks share this branch. See `ENRICH_KINDS`: the selection, the
9694
  # cooldown, the limit clamps and their reasons are network-agnostic, and a second copy for
 
10808
  # the table would silently apply one action's uniqueness rule to the other's rows.
10809
  creates.setdefault((target, str(cfg.get("uniqueOn") or "")), []).append(vals)
10810
  counts["created"] += 1
10811
+ elif kind == "send_statement":
10812
+ # ⭐⭐ WAVE 35 Β· T35 / R10 β€” THE ARM LANDS WITH THE CATALOG ROW, ON PURPOSE.
10813
+ #
10814
+ # `_walk` has no terminal `else` (see the note on `enrich_tiktok` in the catalog):
10815
+ # an unknown kind is walked, COUNTED, reports the run `ok` and writes nothing. So a
10816
+ # catalog row whose arm arrives in a later ticket is addable, storable and silently
10817
+ # inert β€” a step a person configured, that reports success and does nothing. This
10818
+ # arm exists so that window never opens.
10819
+ #
10820
+ # β›” T35 DOES NOT SEND AND DOES NOT PARK. R10's review stage is W35-T36; until it
10821
+ # lands this says so out loud and counts the record as blocked, which is the same
10822
+ # shape `ai_agent` uses for a step it cannot perform. It must never fall through to
10823
+ # "ok".
10824
+ # β›” AND IT NEVER SENDS FROM HERE, in this wave or any later one. The send door is
10825
+ # `routes_statements`, behind SAFE_MODE + `admin_gate` + the tenant gate, reached by
10826
+ # a human click on the review batch. This arm's whole job is to PREPARE.
10827
+ if "send_statement_pending" not in web_notes:
10828
+ web_notes.append("send_statement_pending")
10829
+ log("[aios-auto] send_statement: statements are assembled for review, not "
10830
+ "sent. The review batch is not configured yet, so nothing was prepared "
10831
+ "and nothing was sent.")
10832
+ counts["webBlocked"] += 1
10833
+ continue
10834
  elif kind == "ai_agent":
10835
  # ⭐⭐ W33-T56 (owner item 7, ruling R3) β€” THE FUZZY STEP, AT RUN TIME.
10836
  #
 
10861
  f"browser jobs of about 10-30 seconds each.")
10862
  counts["webBlocked"] += 1
10863
  continue
10864
+ # W35 Β· C7: `st` + `user` so the model spend is attributed (`NOTE E-16`).
10865
+ _plan, _why = _ai_agent_plan(cfg, row, log, st=rt,
10866
+ user=str(defn.get("createdBy") or ""))
10867
  if _why:
10868
  # β›” NAMED, NEVER OPAQUE β€” the second half of the `done-when`. "The assistant
10869
  # could not work out how to do that" with the reason attached is actionable;
 
12255
  (ut_get(rt, ((defn.get("config") or {}).get("targetTable")
12256
  or (defn.get("trigger") or {}).get("table") or "")) or {}
12257
  ).get("fields") or []]
12258
+ # ⭐ W35 Β· CONTRACT C7 (`NOTE E-16`) β€” ATTRIBUTE THE SPEND. `st=` is what lets the ledger write
12259
+ # to the right tenant's store and `user=` is who it is billed to; without them the call is
12260
+ # counted as UNATTRIBUTED, which is a meter that reports a total nobody can act on.
12261
+ # ⚠ `createdBy` is the honest actor here: an AI review decision is made ON BEHALF of the
12262
+ # automation, by a scheduler, with no person at the keyboard. Naming whoever last edited it
12263
+ # would attribute a nightly run to an editor who was asleep.
12264
  choice, meta = ai_review.decide(prompt=cfg.get("prompt") or "", options=options,
12265
  row=row, fields=[f for f in fields if f],
12266
+ label=cfg.get("label") or "Review",
12267
+ st=rt, user=str(defn.get("createdBy") or ""))
12268
  if not choice:
12269
  if meta.get("problem"):
12270
  log(f"[aios-auto] ai review declined to answer: {meta['problem']}")
api/main.py CHANGED
@@ -82,6 +82,9 @@ import routes_query # noqa: E402 (wave 32 R1/C5 β€” the Query module; E's rout
82
  import routes_publish # noqa: E402 (wave 33 R5/C2 β€” the publish door; C's router, A's line)
83
  import routes_brand # noqa: E402 (wave 33 R4/C2/C6 β€” connector brand marks; G's router, A's line)
84
  import routes_slack # noqa: E402 (wave 33 R4/C2 β€” Manage agent + the Slack door; D's router, A's line)
 
 
 
85
  from core import grid_events # noqa: E402
86
  from deps import Session, module_gate # noqa: E402
87
 
@@ -310,6 +313,22 @@ app.include_router(routes_brand.router) # R4 / C2 / C6 β€” G's router, A's
310
  # file to register them in: "public" here IS the absence of `Depends(require_session)`, which is why
311
  # `verify_api` asserts the absence rather than an entry in a list that does not exist.
312
  app.include_router(routes_slack.router) # R4 / C2 β€” D's router, A's line (Manage agent + Slack)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
 
315
  # --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
@@ -472,6 +491,12 @@ def _seed_and_sync_store():
472
  # is D-107's own hypothesis 3: a partial population trips `MAX_SHRINK` and the rebuild
473
  # refuses β€” correctly, but for a reason that reads like a data loss scare.
474
  # ⚠ Same thread on purpose: it is already a daemon and nothing serves requests behind it.
 
 
 
 
 
 
475
  _pull_meta("boot")
476
  _rebuild_odoo_relational("boot")
477
  # ⭐⭐ W32-T07 β€” owner items 13 and 15, DELIVERED. See `_sweep_automation_schemas`.
@@ -483,6 +508,35 @@ def _seed_and_sync_store():
483
  print(f"[aios-api] datastore seed/sync skipped: {e}")
484
 
485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  def _pull_meta(why):
487
  """Pull Meta Ads into THIS container's mirror, before the relational rebuild reads it.
488
 
 
82
  import routes_publish # noqa: E402 (wave 33 R5/C2 β€” the publish door; C's router, A's line)
83
  import routes_brand # noqa: E402 (wave 33 R4/C2/C6 β€” connector brand marks; G's router, A's line)
84
  import routes_slack # noqa: E402 (wave 33 R4/C2 β€” Manage agent + the Slack door; D's router, A's line)
85
+ import routes_starred # noqa: E402 (wave 35 R4/C2/C3/C5 β€” the star; E's router, E's line)
86
+ import routes_usage # noqa: E402 (wave 35 R9/C7 β€” the AI usage meter; E's router, E's line)
87
+ import routes_feedback # noqa: E402 (wave 35 R8/C6 β€” feedback to the operator plane; E's router)
88
  from core import grid_events # noqa: E402
89
  from deps import Session, module_gate # noqa: E402
90
 
 
313
  # file to register them in: "public" here IS the absence of `Depends(require_session)`, which is why
314
  # `verify_api` asserts the absence rather than an entry in a list that does not exist.
315
  app.include_router(routes_slack.router) # R4 / C2 β€” D's router, A's line (Manage agent + Slack)
316
+ # ⭐⭐ WAVE 35 (R4/R5/R7, contracts C2/C3/C5) β€” THE STAR. Mounted in the SAME change that created
317
+ # `routes_starred.py`, which is the seventh consecutive wave in which this block is the artefact the
318
+ # protocol nearly loses β€” and the first in which the router's own lane also owns `main.py`, so there
319
+ # is no ask/mount pair to drop. `W35-T46` asserts every one of this wave's paths in
320
+ # `app.openapi()["paths"]` and CALLS one route from each.
321
+ # ⚠ Same placement rule as every line above: ABOVE `app.mount("/", _AppStatic(...), html=True)`, or
322
+ # the router answers 404 on GET and 405 on POST while every one of its own tests passes.
323
+ app.include_router(routes_starred.router) # R4 / C2 β€” the star, counts, and record stars
324
+ # β›” R9 SAYS THIS ROUTE REPORTS AND NEVER ENFORCES, so mounting it cannot cut anybody off β€” there is
325
+ # no ceiling anywhere behind it (`usage_ledger` has no refusal in it). The one AI limit the product
326
+ # enforces is per COLUMN and is unchanged (`ai_enrich.ceiling_report`).
327
+ app.include_router(routes_usage.router) # R9 / C7 β€” GET /usage, the one AI meter
328
+ # β›” ITS TWO DOORS CARRY DIFFERENT WALLS ON PURPOSE (R8, and D-221 is the booked precedent): the
329
+ # POST is any authenticated session's own act, the GET is `is_platform_admin` only. Mounting it does
330
+ # not widen anything a tenant admin can reach β€” `verify_api` proves that by having one try.
331
+ app.include_router(routes_feedback.router) # R8 / C6 β€” feedback to the operator plane
332
 
333
 
334
  # --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
 
491
  # is D-107's own hypothesis 3: a partial population trips `MAX_SHRINK` and the rebuild
492
  # refuses β€” correctly, but for a reason that reads like a data loss scare.
493
  # ⚠ Same thread on purpose: it is already a daemon and nothing serves requests behind it.
494
+ # ⭐⭐ W35-T45 / R11 β€” TENANT #0'S ODOO CREDENTIAL MOVES ONTO ITS KEYCHAIN, IN THE CONTAINER.
495
+ # ⚠ BEFORE the relational rebuild, deliberately: the rebuild resolves its connector through
496
+ # `rt.odoo_source()`, so running the migration first means the very next read already goes
497
+ # through the keychain branch and a broken migration is visible in THIS boot's log rather
498
+ # than in tomorrow's. It is idempotent, so every later boot is one `list_entries` read.
499
+ _migrate_env_odoo("boot")
500
  _pull_meta("boot")
501
  _rebuild_odoo_relational("boot")
502
  # ⭐⭐ W32-T07 β€” owner items 13 and 15, DELIVERED. See `_sweep_automation_schemas`.
 
508
  print(f"[aios-api] datastore seed/sync skipped: {e}")
509
 
510
 
511
+ def _migrate_env_odoo(why):
512
+ """⭐⭐ W35-T45 / R11 β€” move tenant #0's environment Odoo credential onto its keychain.
513
+
514
+ β›”β›” IN THE CONTAINER, WHICH IS THE WHOLE REASON THIS IS A BOOT LINE AND NOT A SCRIPT. D-195,
515
+ measured three times: a developer's CLI write to the tenant store is reverted by the running
516
+ Space within a minute (download-modify-upload, last-write-wins) β€” and the write REPORTS SUCCESS
517
+ every time, then a fresh read confirms it, and it is gone by the next poll. A CLI migration would
518
+ be a dry run that lies, and what it would lie about here is a credential.
519
+
520
+ ⚠ IDEMPOTENT AND SCOPED TO TENANT #0 by `routes_keychain.env_odoo_available`, so on every other
521
+ tenant and on every later boot this is one `list_entries` read and a line.
522
+ ⚠ FAIL-QUIET: this must never take a boot down. But it is never SILENT β€” a skip prints its reason,
523
+ because "already migrated", "no keychain key on this deployment" and "the env is incomplete" are
524
+ three different operator actions and a blank Keychain page cannot tell them apart.
525
+ """
526
+ try:
527
+ import routes_keychain as _kc_routes
528
+ from harness import runtime as _runtime
529
+ rep = _kc_routes.migrate_env_odoo(_runtime.get_runtime("royal-imports"))
530
+ if rep.get("done"):
531
+ print(f"[aios-api] odoo credential migrated onto the keychain ({why}): "
532
+ f"entry={rep['entry']} carried_pause={rep['carried_pause']}"
533
+ + (f" PROBLEM: {rep['why']}" if rep.get("why") else ""))
534
+ else:
535
+ print(f"[aios-api] odoo keychain migration skipped ({why}): {rep.get('why')}")
536
+ except Exception as e: # noqa: BLE001
537
+ print(f"[aios-api] odoo keychain migration FAILED ({why}): {type(e).__name__}: {e}")
538
+
539
+
540
  def _pull_meta(why):
541
  """Pull Meta Ads into THIS container's mirror, before the relational rebuild reads it.
542
 
api/routes_automation.py CHANGED
@@ -91,6 +91,27 @@ def _wire(defn, tenant):
91
  # C3 (wave 22): the trigger config rides whole β€” the webhook token included, because
92
  # the person configuring the external caller has to be shown the URL somewhere, and
93
  # this payload is session-gated behind the same wall as everything else here.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  "trigger": defn.get("trigger") or None,
95
  "statusNote": defn.get("statusNote") or "",
96
  # The one-sentence summary (airtable-brief rec 7), composed from the definition so it
@@ -550,6 +571,13 @@ def _tick_state():
550
  #: or delete β€” a new database cannot contain the legacy stage cells this retires.
551
  _BOARD_RETIRED = set()
552
 
 
 
 
 
 
 
 
553
  #: ⭐⭐ WAVE 30 Β· T12 β€” THE PICKER DEFAULT, MEMOISED. `{tenant: (stamp, table_key)}`, the shape
554
  #: `scope_cache` stores.
555
  #:
@@ -702,6 +730,20 @@ def list_automations(session: Session = Depends(_GATE)):
702
  # Idempotent Board retirement removes only engine-marked stage fields and legacy Board
703
  # state. User-created Status/Stage columns remain intact.
704
  engine.retire_automation_board_state(session.runtime, tables=tables)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
705
  defs = engine.all_definitions(session.runtime)
706
  items = [_wire(d, session.tenant) for _, d in
707
  sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())]
@@ -801,7 +843,11 @@ def list_automations(session: Session = Depends(_GATE)):
801
  # own reason instead of omitting them β€” the owner asked for Airtable's full menu, and
802
  # a shorter list would imply those actions do not exist. `clean_actions` REFUSES an
803
  # unready kind, so the faded state is a wall rather than a styling choice.
804
- "actionsCatalog": engine.action_catalog(),
 
 
 
 
805
  # The builder's own vocabulary: how deep a condition tree may nest, how deep groups
806
  # may nest, and the ceilings. B reads these instead of hard-coding the same numbers
807
  # into its "+ Add condition" affordance.
@@ -1109,7 +1155,10 @@ def draft_automation(body: dict = Body(default=None), session: Session = Depends
1109
  # filtering one and not the other describes settings for kinds the model cannot choose. That is
1110
  # not merely untidy: it spends prompt on unreachable options and invites the model to reach for
1111
  # one. `_ai_agent_plan` already narrows both in exactly this way; this follows it.
1112
- menu_catalog = [r for r in engine.action_catalog() if r.get("menu") is not False]
 
 
 
1113
  _offered = {r["kind"] for r in menu_catalog}
1114
  draft, refusal, provider = ai_review.draft_flow(
1115
  prompt=prompt,
@@ -1117,14 +1166,21 @@ def draft_automation(body: dict = Body(default=None), session: Session = Depends
1117
  required={k: v for k, v in engine.ACTION_REQUIRED.items() if k in _offered},
1118
  triggers=_triggers_vocab(session),
1119
  tables=tables,
 
 
 
 
1120
  chat=_DRAFT_CHAT[0])
1121
  if refusal or not draft:
1122
  raise err(400, "draft_refused", refusal or "no automation could be drafted from that")
1123
 
1124
  # ── C7: run the real save-door validator and DIFF it ──────────────────────────────────
1125
  wrote = draft.get("actions") or []
 
 
 
1126
  cleaned, why = engine.clean_actions([{"kind": a.get("kind"), "config": a.get("config") or {}}
1127
- for a in wrote])
1128
  if why or cleaned is None:
1129
  raise err(400, "draft_invalid",
1130
  f"the assistant produced a flow this deployment will not accept: {why}")
 
91
  # C3 (wave 22): the trigger config rides whole β€” the webhook token included, because
92
  # the person configuring the external caller has to be shown the URL somewhere, and
93
  # this payload is session-gated behind the same wall as everything else here.
94
+ # ⭐⭐ WAVE 35 Β· T36 / R10 β€” THE PARKED BATCH, SUMMARISED. Without this line the whole
95
+ # ticket would be unreachable: `_wire` is a WHITELIST, so a key the engine parks on the
96
+ # definition simply never arrives, and the detail pane could not know a batch was waiting
97
+ # ([[reachable-is-not-the-same-as-built]] β€” the exact shape `flow` was caught in above).
98
+ # ⚠ COUNT AND NOTES ONLY, NEVER `items`. A 200-statement batch carries 200 rendered HTML
99
+ # mails; this payload rides on the automations LIST, which every rail paint reads.
100
+ # `GET /admin/statements/agent/{id}` serves the detail when somebody opens the batch.
101
+ "pendingStatements": ({
102
+ "count": int((defn.get("pendingStatements") or {}).get("count") or 0),
103
+ "ts": (defn.get("pendingStatements") or {}).get("ts") or "",
104
+ "notes": list((defn.get("pendingStatements") or {}).get("notes") or [])[:25],
105
+ } if isinstance(defn.get("pendingStatements"), dict) else None),
106
+ # ⭐⭐ WAVE 35 Β· T37 β€” `system` FOR A **STORED** ROW. Until now only SYNTHETIC rows carried
107
+ # it, stamped by their builders AFTER this function (`_odoo_sync_row`, `_field_agent_rows`),
108
+ # so a stored row with the marker had it enforced on the server (`delete_automation` 409s)
109
+ # and INVISIBLE to the client β€” which paints a live-looking Delete that answers with a
110
+ # refusal. `AutomationDetail` already reads `automation.system` to disable that button and
111
+ # explain why; this is the line that lets it.
112
+ # ⚠ Absent stays absent rather than becoming `""`: every ordinary automation is not-a-system
113
+ # -agent, and an empty string is a value a client could accidentally treat as one.
114
+ **({"system": str(defn.get("system") or "")} if defn.get("system") else {}),
115
  "trigger": defn.get("trigger") or None,
116
  "statusNote": defn.get("statusNote") or "",
117
  # The one-sentence summary (airtable-brief rec 7), composed from the definition so it
 
571
  #: or delete β€” a new database cannot contain the legacy stage cells this retires.
572
  _BOARD_RETIRED = set()
573
 
574
+ #: ⭐ WAVE 35 Β· T37 β€” tenants whose statements agent has been considered THIS PROCESS. Same shape as
575
+ #: `_BOARD_RETIRED` and, like it, purely a cost saver: the DURABLE idempotency is the row's own
576
+ #: existence, which `engine.seed_statements_agent` re-checks inside its own store mutation.
577
+ #: ⚠ SO A RESTART RE-CHECKING IS HARMLESS BY CONSTRUCTION, which is the property to preserve. If
578
+ #: this set were ever the only guard, a process restart would mint a second agent.
579
+ _STATEMENTS_SEEDED = set()
580
+
581
  #: ⭐⭐ WAVE 30 Β· T12 β€” THE PICKER DEFAULT, MEMOISED. `{tenant: (stamp, table_key)}`, the shape
582
  #: `scope_cache` stores.
583
  #:
 
730
  # Idempotent Board retirement removes only engine-marked stage fields and legacy Board
731
  # state. User-created Status/Stage columns remain intact.
732
  engine.retire_automation_board_state(session.runtime, tables=tables)
733
+ # ⭐⭐ WAVE 35 Β· T37 / OWNER RULING R10 β€” ROYAL IMPORTS' STATEMENTS AGENT EXISTS BEFORE ANYBODY
734
+ # ASKS FOR IT. It is minted ONCE, by the container, SWITCHED OFF; every subsequent call finds
735
+ # it present and returns immediately. See `engine.seed_statements_agent` for why this one is
736
+ # STORED where wave 34's Odoo agent is derived, and why the row's existence is a sufficient
737
+ # idempotency key (it cannot be deleted).
738
+ # ⚠ GUARDED SO IT CAN NEVER TAKE THE RAIL DOWN. A tenant with no Odoo, a store mid-outage or a
739
+ # validator change must degrade to "no statements agent", never to a 500 on the one route the
740
+ # whole Agents surface polls. The same posture `_odoo_sync_row` takes for the same reason.
741
+ if session.tenant not in _STATEMENTS_SEEDED:
742
+ try:
743
+ engine.seed_statements_agent(session.runtime, username=session.uname or "system")
744
+ except Exception: # noqa: BLE001
745
+ pass
746
+ _STATEMENTS_SEEDED.add(session.tenant)
747
  defs = engine.all_definitions(session.runtime)
748
  items = [_wire(d, session.tenant) for _, d in
749
  sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())]
 
843
  # own reason instead of omitting them β€” the owner asked for Airtable's full menu, and
844
  # a shorter list would imply those actions do not exist. `clean_actions` REFUSES an
845
  # unready kind, so the faded state is a wall rather than a styling choice.
846
+ # ⭐ W35-T35 (C8): `session.runtime` is what withholds a TENANT-GATED row. Passing it
847
+ # here is the whole of "the menu does not offer Send statements to another tenant";
848
+ # the STORE-side wall is `clean_actions(rt=)`, deliberately separate, because a picker
849
+ # is not a security boundary [[opening-a-route-widens-every-field]].
850
+ "actionsCatalog": engine.action_catalog(session.runtime),
851
  # The builder's own vocabulary: how deep a condition tree may nest, how deep groups
852
  # may nest, and the ceilings. B reads these instead of hard-coding the same numbers
853
  # into its "+ Add condition" affordance.
 
1155
  # filtering one and not the other describes settings for kinds the model cannot choose. That is
1156
  # not merely untidy: it spends prompt on unreachable options and invites the model to reach for
1157
  # one. `_ai_agent_plan` already narrows both in exactly this way; this follows it.
1158
+ # W35-T35 (C8): tenant-gated rows are withheld here too, or the DRAFTER would offer a kind
1159
+ # the same tenant's own save door then refuses β€” a flow the assistant proposes and the product
1160
+ # will not accept.
1161
+ menu_catalog = [r for r in engine.action_catalog(session.runtime) if r.get("menu") is not False]
1162
  _offered = {r["kind"] for r in menu_catalog}
1163
  draft, refusal, provider = ai_review.draft_flow(
1164
  prompt=prompt,
 
1166
  required={k: v for k, v in engine.ACTION_REQUIRED.items() if k in _offered},
1167
  triggers=_triggers_vocab(session),
1168
  tables=tables,
1169
+ # ⭐ W35 Β· CONTRACT C7 (`NOTE E-16`) β€” the spend is ATTRIBUTED. Unlike the two engine call
1170
+ # sites, this one has a real person behind it: somebody typed the sentence, so `user` is
1171
+ # the caller rather than the automation's owner.
1172
+ st=session.runtime, user=getattr(session, "uname", "") or "",
1173
  chat=_DRAFT_CHAT[0])
1174
  if refusal or not draft:
1175
  raise err(400, "draft_refused", refusal or "no automation could be drafted from that")
1176
 
1177
  # ── C7: run the real save-door validator and DIFF it ──────────────────────────────────
1178
  wrote = draft.get("actions") or []
1179
+ # W35-T35 (C8): the SAME `rt` the menu was built with. Without it this validator would refuse
1180
+ # a tenant-gated kind it had just offered the model β€” the draft door disagreeing with the save
1181
+ # door about one tenant, which reads as "the assistant produced an invalid flow".
1182
  cleaned, why = engine.clean_actions([{"kind": a.get("kind"), "config": a.get("config") or {}}
1183
+ for a in wrote], rt=session.runtime)
1184
  if why or cleaned is None:
1185
  raise err(400, "draft_invalid",
1186
  f"the assistant produced a flow this deployment will not accept: {why}")
api/routes_feedback.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_feedback.py β€” WAVE 35 (ruling R8, contract C6): FEEDBACK GOES TO THE OPERATOR PLANE.
2
+
3
+ R8: *"Feedback goes to the platform-wide `loopable` operator plane. No tenant sees another's, and
4
+ the submitting tenant cannot edit or delete what it sent."*
5
+
6
+ β›”β›” TWO DOORS, TWO DIFFERENT WALLS, AND THAT ASYMMETRY IS THE WHOLE TICKET. D-221 is already on the
7
+ register for a cross-tenant WRITE gated on `admin_gate` alone, so getting this backwards is a repeat
8
+ rather than a novelty:
9
+
10
+ POST /feedback ANY authenticated session, any tenant. It is the TENANT'S OWN ACT.
11
+ GET /feedback `core.platform_admin.is_platform_admin` ONLY. It is the OPERATOR'S read.
12
+
13
+ A single wall cannot express that. Gating the write on the operator predicate would mean nobody can
14
+ send feedback; gating the read on `admin_gate` would hand every tenant admin every other tenant's
15
+ words. So the walls are declared per route, and `verify_api` drives BOTH from a tenant admin, a
16
+ plain user and a platform admin rather than asserting the source.
17
+
18
+ β›” THE PROVENANCE IS STAMPED SERVER-SIDE AND NEVER READ FROM THE BODY. `tenant`, `user` and `ts`
19
+ come from the verified session and the clock. A body-supplied tenant would let any account file
20
+ words against another company's name, in a store only the operator reads and therefore only the
21
+ operator could ever be misled by.
22
+
23
+ β›” NO EDIT AND NO DELETE EXIST AT ALL β€” not for the tenant (R8 says so) and not for the operator
24
+ either. R8 grants the operator a READ; a delete door is a capability nobody asked for on a store
25
+ whose whole value is that it is append-only. There is nothing here to forget to wall.
26
+
27
+ ⚠ ONE STORE, AND WHERE IT PHYSICALLY LIVES IS STATED RATHER THAN IMPLIED. This uses `core.store`
28
+ with a raw key, which is the same thing `routes_platform_admin` does for `runtime.TENANTS_KEY` and
29
+ for the same reason: a platform fact belongs in the default store, not inside any tenant's
30
+ namespace. ⚠ That default is currently tenant #0's dataset repo (`OS_DATA_REPO`), so "the platform
31
+ store" and "tenant #0's repo" are the same bucket today. That is pre-existing (the control plane's
32
+ tenant records are already there) and it is a hosting fact, not a permission one β€” no tenant route
33
+ can read this key, because none of them names it.
34
+ """
35
+ import time
36
+
37
+ from fastapi import APIRouter, Body, Depends
38
+
39
+ import core.platform_admin as platform_admin
40
+ import core.store as store
41
+ from deps import Session, err, require_session
42
+
43
+ router = APIRouter(prefix="/api/v1")
44
+
45
+ #: The platform-wide bucket. `{"rows": [ {id, tenant, user, category, text, ts} ]}` β€” newest LAST
46
+ #: in the store, newest FIRST on the wire.
47
+ STORE_KEY = "platform_feedback"
48
+
49
+ #: ⭐ THE CATEGORY VOCABULARY IS SERVER-OWNED AND SERVED (B's `ASK B-1 (3)`, agreed). A client
50
+ #: constant beside a server enum drifts, and the drift shows up as a dropdown offering a value the
51
+ #: door refuses. B renders exactly this list; the door accepts exactly these keys.
52
+ CATEGORIES = (
53
+ {"key": "bug", "label": "Something is broken"},
54
+ {"key": "idea", "label": "An idea or a request"},
55
+ {"key": "data", "label": "The numbers look wrong"},
56
+ {"key": "speed", "label": "Something is slow"},
57
+ {"key": "other", "label": "Something else"},
58
+ )
59
+
60
+ MAX_CHARS = 4000
61
+
62
+ #: A ceiling on the whole store. β›” REPORTED AT BOTH ENDS, never a silent drop: the submitter is
63
+ #: refused with the cause, and the operator's own payload says the store is full so they can act.
64
+ #: Dropping the OLDEST rows instead would delete the operator's earliest feedback to make room for
65
+ #: the newest, which is the one direction this store must never move in.
66
+ MAX_ROWS = 5000
67
+
68
+
69
+ def _rows():
70
+ try:
71
+ raw = store.get(STORE_KEY) or {}
72
+ except Exception:
73
+ return []
74
+ rows = raw.get("rows") if isinstance(raw, dict) else None
75
+ return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
76
+
77
+
78
+ @router.get("/feedback/form")
79
+ def feedback_form(session: Session = Depends(require_session)):
80
+ """What the composer renders. Any session β€” every account may send feedback.
81
+
82
+ ⚠ It deliberately returns NO submissions. R8 gives the tenant no read of what it sent, so a
83
+ "your feedback" list would be a second door to a thing the ruling closed. The 201 is the receipt.
84
+ """
85
+ return {"categories": [dict(c) for c in CATEGORIES], "maxChars": MAX_CHARS}
86
+
87
+
88
+ @router.post("/feedback", status_code=201)
89
+ def submit_feedback(body: dict = Body(default=None),
90
+ session: Session = Depends(require_session)):
91
+ """Send one piece of feedback to the operator plane. `{"category": key, "text": str}`.
92
+
93
+ ⚠ ANY authenticated session, deliberately β€” see the header. The provenance is stamped from the
94
+ VERIFIED session, never from the body.
95
+ """
96
+ body = body if isinstance(body, dict) else {}
97
+ category = str(body.get("category") or "").strip()
98
+ text = str(body.get("text") or "").strip()
99
+ if category not in {c["key"] for c in CATEGORIES}:
100
+ # Named, and it names the fix: a client whose dropdown has drifted from this list is a
101
+ # client whose user is about to lose what they typed.
102
+ raise err(400, "unknown_category",
103
+ "that is not one of the feedback categories. Reload the page and try again")
104
+ if not text:
105
+ raise err(400, "bad_request", "type what you want to tell us")
106
+ if len(text) > MAX_CHARS:
107
+ raise err(400, "too_long",
108
+ f"feedback is limited to {MAX_CHARS:,} characters. Yours is {len(text):,}")
109
+ if not store.available():
110
+ raise err(503, "store_unavailable",
111
+ "feedback could not be sent right now. Nothing was saved.")
112
+ if len(_rows()) >= MAX_ROWS:
113
+ raise err(503, "feedback_full",
114
+ "the feedback store is full and we have been told. Nothing was saved, so please "
115
+ "send this again later")
116
+ row = {
117
+ # β›” EVERY ONE OF THESE FOUR IS SERVER-SIDE. A body-supplied tenant or user would let any
118
+ # account file words against somebody else's name in a store only the operator reads.
119
+ "tenant": str(session.tenant),
120
+ "user": str(session.uname),
121
+ "ts": int(time.time()),
122
+ "category": category,
123
+ "text": text,
124
+ }
125
+ row["id"] = f"fb_{row['ts']}_{abs(hash((row['tenant'], row['user'], text))) % 10**8:08d}"
126
+
127
+ def _up(data):
128
+ data = data if isinstance(data, dict) else {}
129
+ rows = data.get("rows")
130
+ data["rows"] = (rows if isinstance(rows, list) else []) + [row]
131
+ return data
132
+
133
+ try:
134
+ # `flush='sync'`: a person pressed Send and is being told it landed. The coalescing mode is
135
+ # for autosaves nobody is waiting on.
136
+ store.update(STORE_KEY, _up)
137
+ except Exception:
138
+ raise err(503, "store_unavailable",
139
+ "feedback could not be sent right now. Nothing was saved.")
140
+ return {"ok": True, "id": row["id"]}
141
+
142
+
143
+ @router.get("/feedback")
144
+ def list_feedback(tenant: str = "", session: Session = Depends(require_session)):
145
+ """R8's operator read: every tenant's feedback, newest first. PLATFORM ADMIN ONLY.
146
+
147
+ β›” THE WALL IS `core.platform_admin.is_platform_admin`, IMPORTED AND CALLED β€” the double lock
148
+ (the record flag AND the `loopable` tenant), never re-expressed here. A tenant admin is not
149
+ admitted: `role: 'admin'` is one company's authority over its own workspace, and this is every
150
+ company's words in one list.
151
+ ⚠ `?tenant=` NARROWS an already-admitted operator's view. It is a convenience, not a wall, and
152
+ it is applied after the gate so it can never be the thing that admits anybody.
153
+ """
154
+ if not platform_admin.is_platform_admin(session.user):
155
+ raise err(403, "forbidden", "this is a Loopable operator surface")
156
+ rows = _rows()
157
+ if tenant:
158
+ want = str(tenant).strip().lower()
159
+ rows = [r for r in rows if str(r.get("tenant") or "").lower() == want]
160
+ rows = sorted(rows, key=lambda r: int(r.get("ts") or 0), reverse=True)
161
+ out = {"rows": [dict(r) for r in rows], "total": len(rows), "capacity": MAX_ROWS,
162
+ "tenants": sorted({str(r.get("tenant") or "") for r in _rows() if r.get("tenant")})}
163
+ if len(_rows()) >= MAX_ROWS:
164
+ # R6's second sentence, aimed at the person who can act: submissions are being REFUSED right
165
+ # now, and the operator is the only one who can see that from here.
166
+ out["note"] = ("the feedback store is at capacity, so new submissions are being refused. "
167
+ "Archive what you have read")
168
+ return out
api/routes_keychain.py CHANGED
@@ -296,6 +296,114 @@ def _rel_reconnect(rt):
296
  return True
297
 
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  def _own_row(session, entry_id):
300
  """The visible row for `entry_id`, or a 404. β›” A 404 rather than a 403 for an entry the
301
  caller cannot see: telling a member that somebody else's personal credential EXISTS is the
 
296
  return True
297
 
298
 
299
+ # ══════════════════════════════════════════════════ W35-T45 / R11: THE ENV -> KEYCHAIN MIGRATION
300
+ #
301
+ # R11: *"Tenant #0's Odoo credential MIGRATES onto the keychain, with the environment kept as
302
+ # fallback."* The owner chose this over a read-only display row WITH THE MIGRATION RISK STATED, so
303
+ # the risk is what this block is mostly about.
304
+ #
305
+ # ⭐ WHAT WAS ALREADY TRUE, CHECKED BEFORE ANY OF IT WAS WRITTEN: the keychain-first-then-env
306
+ # RESOLVER has existed since the 2026-08-04 cutover. `harness/runtime.py::odoo_source` is already
307
+ # (1) a keychain `odoo` entry, (2) tenant #0's compiled env connector, (3) None for anybody else,
308
+ # and `visible_entries` already serves a non-admin the row WITHOUT a preview. So R11 is not "build
309
+ # a resolver" β€” it is "give tenant #0 the ROW", which is the only reason its Keychain page looks
310
+ # empty while its Odoo grids work.
311
+ #
312
+ # β›”β›” AND THE ONE REAL HAZARD IS NOT THE CREDENTIAL, IT IS THE PAUSE FLAG. `odoo_flag_key()` returns
313
+ # the first keychain `odoo` entry id when one exists and `ENV_ODOO_FLAG_KEY` ("odoo-env") otherwise β€”
314
+ # so CREATING THE ENTRY MOVES THE ADDRESS THE PAUSE FLAG LIVES AT. A tenant #0 that was paused under
315
+ # `odoo-env` would come back UNPAUSED, silently, at the first boot after this ships: the connector
316
+ # resumes pulling live Odoo because a migration changed which key the freeze was stored under. That
317
+ # is D-259's exact shape (a pause written under one key and read under another) and
318
+ # [[a-guard-bound-to-a-role-stops-guarding-when-the-role-moves]]. The flag is carried across in the
319
+ # SAME pass, and the carry is asserted.
320
+
321
+
322
+ def _env_odoo_fields():
323
+ """The four env values `odoo_client` reads, or `(None, why)` when they are not all present.
324
+
325
+ β›” ALL FOUR OR NOTHING, and this completeness check is load-bearing rather than defensive.
326
+ Creating the entry makes `odoo_source` resolve through branch 1 INSTEAD of the env β€” so a
327
+ PARTIAL migration would hand the connector `{url, db}` and no key and take tenant #0's Odoo
328
+ offline, on a deployment where it had been working. The env fallback cannot save it, because the
329
+ entry's existence is what turns the fallback off.
330
+ ⚠ The names are `odoo_client.py`'s own (`ODOO_URL`/`ODOO_DB`/`ODOO_USER`/`ODOO_API_KEY`) and the
331
+ field names are `harness/connectors/odoo.py`'s stored shape (`{url, db, user, api_key}`). Two
332
+ vocabularies meet here; nowhere else.
333
+ """
334
+ want = (("url", "ODOO_URL"), ("db", "ODOO_DB"),
335
+ ("user", "ODOO_USER"), ("api_key", "ODOO_API_KEY"))
336
+ got = {field: (os.environ.get(env) or "").strip() for field, env in want}
337
+ missing = sorted(env for field, env in want if not got[field])
338
+ if missing:
339
+ return None, (f"the environment is missing {', '.join(missing)}, and a partial credential "
340
+ f"would take this tenant's Odoo offline rather than migrate it")
341
+ return got, ""
342
+
343
+
344
+ def migrate_env_odoo(rt):
345
+ """R11 β€” put tenant #0's environment Odoo credential on its keychain, once. Returns a report.
346
+
347
+ `{"done": bool, "entry": id|"", "carried_pause": bool, "why": str}` β€” `why` is filled on every
348
+ path including the skips, because "already migrated", "no keychain key on this deployment" and
349
+ "the env is incomplete" are three different operator actions.
350
+
351
+ β›”β›” IT MUST RUN IN THE CONTAINER, WHICH IS WHY `main.py` CALLS IT AND NO SCRIPT DOES. D-195,
352
+ measured three times: a developer's CLI write to the tenant store is reverted by the running
353
+ Space within a minute (download-modify-upload, last-write-wins) β€” and **the write reports success
354
+ every time**, then a fresh read confirms it, and it is gone by the next poll. A CLI migration here
355
+ would be a dry run that lies, and the thing it would lie about is a credential.
356
+
357
+ ⚠ FAIL-QUIET AND IDEMPOTENT. It runs on EVERY boot; the second one must be a no-op and a
358
+ third-party failure must not take the boot down.
359
+ """
360
+ out = {"done": False, "entry": "", "carried_pause": False, "why": ""}
361
+ if not env_odoo_available(rt):
362
+ # Not tenant #0, or this deployment has no env Odoo at all. Both are normal states.
363
+ out["why"] = "this tenant has no environment Odoo credential to migrate"
364
+ return out
365
+ fields, why = _env_odoo_fields()
366
+ if not fields:
367
+ out["why"] = why
368
+ return out
369
+ # β›” READ THE PAUSE FLAG BEFORE THE WRITE. After the entry exists, `odoo_flag_key()` answers the
370
+ # NEW key and the old one is unreachable through the resolver β€” so the only moment this fact can
371
+ # be observed is now. [[undo-capture-before-the-write]] applied to a guard rather than to data.
372
+ try:
373
+ was_paused = bool(((rt.get(_CONNECTOR_FLAGS_KEY) or {})
374
+ .get(_ENV_ODOO_FLAG_KEY) or {}).get("paused"))
375
+ except Exception: # noqa: BLE001
376
+ was_paused = False
377
+ row, why = _kc().ensure_entry_of_type(
378
+ rt, "odoo", "Odoo (migrated from this deployment)", fields, "system")
379
+ if row is None:
380
+ out["why"] = why
381
+ return out
382
+ out["done"], out["entry"] = True, row["id"]
383
+ if was_paused:
384
+ # The freeze followed the credential. Without this the connector silently RESUMES pulling
385
+ # live Odoo at the first boot after the migration.
386
+ def _up(cur):
387
+ cur = cur if isinstance(cur, dict) else {}
388
+ entry = dict(cur.get(row["id"]) or {})
389
+ entry["paused"] = True
390
+ entry["pausedBy"] = "system"
391
+ entry["pausedNote"] = ("carried over from the environment source when the credential "
392
+ "was migrated onto the keychain")
393
+ cur[row["id"]] = entry
394
+ return cur
395
+
396
+ try:
397
+ rt.update(_CONNECTOR_FLAGS_KEY, _up, flush="sync")
398
+ out["carried_pause"] = True
399
+ except Exception as exc: # noqa: BLE001
400
+ # β›” SAID OUT LOUD. A migration that moved the credential and lost the freeze is worse
401
+ # than one that did not run, so this is the one failure that must never be silent.
402
+ out["why"] = (f"the credential migrated but the PAUSE could not be carried over "
403
+ f"({type(exc).__name__}), so this tenant's Odoo is no longer frozen")
404
+ return out
405
+
406
+
407
  def _own_row(session, entry_id):
408
  """The visible row for `entry_id`, or a 404. β›” A 404 rather than a 403 for an entry the
409
  caller cannot see: telling a member that somebody else's personal credential EXISTS is the
api/routes_nav.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_query.py CHANGED
@@ -355,13 +355,24 @@ def _from_chat(answer, provider):
355
  return answer, None, provider, None
356
 
357
 
358
- def _call_model(question, snapshot, model=MODEL_AUTO, chat=None, history=None):
359
  """Return ``(spec, said, provider, reason)`` without any source-data fallback.
360
 
361
  ``said`` is what the assistant SAID: the whole answer when it did not build a view, and the
362
  sentence beside the view when it built one and talked as well. ``reason`` is set only when
363
  something went wrong, so a prose ANSWER and a transport FAILURE are distinguishable one layer
364
  up rather than both arriving as a bare sentence.
 
 
 
 
 
 
 
 
 
 
 
365
  """
366
  requested = str(model or MODEL_AUTO).strip().lower()
367
  if requested not in model_choices():
@@ -384,6 +395,27 @@ def _call_model(question, snapshot, model=MODEL_AUTO, chat=None, history=None):
384
  return None, sentence, None, "model_unavailable" if requested != MODEL_AUTO else "not_configured"
385
 
386
  import requests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  last = None
388
  for provider in providers:
389
  try:
@@ -417,7 +449,12 @@ def _call_model(question, snapshot, model=MODEL_AUTO, chat=None, history=None):
417
  last = f"{provider['name']}: HTTP {response.status_code}"
418
  continue
419
  try:
420
- answer = response.json()["choices"][0]["message"]
 
 
 
 
 
421
  said = _said(answer.get("content"))
422
  calls = answer.get("tool_calls") or []
423
  if calls:
@@ -667,6 +704,11 @@ def _citation_complete(citation):
667
 
668
  def _public_view(view):
669
  source = view["source"]
 
 
 
 
 
670
  return {
671
  "id": view["id"], "viewId": view["id"], "scope": source["database"],
672
  "name": view["name"], "description": str(view.get("description") or ""),
@@ -674,6 +716,12 @@ def _public_view(view):
674
  "explain": view["explain"], "threadId": view["threadId"], "createdAt": view["createdAt"],
675
  "virtual": True, "source": _safe(source), "view": _safe(view["view"]),
676
  "citationIds": list(view["citationIds"]), "numeric": _safe(view["numeric"]),
 
 
 
 
 
 
677
  }
678
 
679
 
@@ -744,14 +792,88 @@ def list_queries(session: Session = Depends(require_session)):
744
  return _public_state(_state(session), session)
745
 
746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  @router.post("/query/{qid}/events")
748
  def mutate_query_workspace(qid: str, body: dict = Body(default=None),
749
  session: Session = Depends(require_session)):
750
  """The only grid-mutation transport for a virtual Query workspace.
751
 
752
- Query artefacts are immutable snapshots: creation and changes belong to an Assistant prompt,
753
- never a source-native grid workspace. Deleting the caller's personal artefact is the one
754
- permitted mutation. The binding key, not a client-supplied source scope, identifies it.
 
 
 
 
 
 
755
  """
756
  qid = str(qid)
757
  body = body if isinstance(body, dict) else {}
@@ -772,9 +894,40 @@ def mutate_query_workspace(qid: str, body: dict = Body(default=None),
772
  raise err(404, "unknown_query", "that Query artefact does not exist")
773
 
774
  event_type = str(event["type"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
775
  if event_type != "view_delete":
776
  raise err(409, "query_workspace_immutable",
777
- "AI-created Query views change only through a new Assistant prompt")
778
  if str(event.get("viewId") or "") != qid:
779
  raise err(400, "query_workspace_mismatch",
780
  "a Query delete must name the same virtual artefact as its binding")
@@ -784,6 +937,43 @@ def mutate_query_workspace(qid: str, body: dict = Body(default=None),
784
  "event": event_type, **deleted}
785
 
786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
787
  def _sources(body, target, session):
788
  raw = body.get("sources") if isinstance(body, dict) else None
789
  sources = [str(item).strip() for item in raw] if isinstance(raw, list) else []
@@ -826,8 +1016,10 @@ def _submit(body, session, chat=None):
826
  assistant_message_id = _new_id("message")
827
  # R16: the turns already on screen go with the question. `state` was read above, BEFORE this
828
  # turn's own messages exist, so the replay is strictly the prior conversation.
 
 
829
  spec, said, provider, reason = _call_model(question, snapshot, model=selected_model, chat=chat,
830
- history=_history(state, thread_id))
831
  view, refusal, refusal_code = _validate(spec, snapshot["fields"]) if spec is not None else (None, said, reason)
832
 
833
  artifact = None
@@ -850,6 +1042,15 @@ def _submit(body, session, chat=None):
850
  "question": question,
851
  "explain": _explain(view, snapshot["fields"]), "createdAt": now, "source": source,
852
  "view": _safe(view), "citationIds": [citation_id], "numeric": numeric,
 
 
 
 
 
 
 
 
 
853
  "model": provider, "requestedModel": selected_model}
854
 
855
  assistant_message = {
 
355
  return answer, None, provider, None
356
 
357
 
358
+ def _call_model(question, snapshot, model=MODEL_AUTO, chat=None, history=None, session=None):
359
  """Return ``(spec, said, provider, reason)`` without any source-data fallback.
360
 
361
  ``said`` is what the assistant SAID: the whole answer when it did not build a view, and the
362
  sentence beside the view when it built one and talked as well. ``reason`` is set only when
363
  something went wrong, so a prose ANSWER and a transport FAILURE are distinguishable one layer
364
  up rather than both arriving as a bare sentence.
365
+
366
+ ⭐⭐ W35-T31 Β· CONTRACT C7 (R9) β€” ``session`` IS HERE ONLY SO THE METER CAN BE TOLD WHOSE CALL
367
+ THIS WAS, and PRD amendment A1 is why it is an argument rather than ambient state: a
368
+ ``ContextVar`` bound in ``deps.require_session`` reads back ``None`` inside this function on the
369
+ SAME thread, so a ledger built on one would have recorded nothing and shown a dashboard of
370
+ zeros indistinguishable from a quiet week.
371
+
372
+ ⚠ OPTIONAL, DELIBERATELY. ``verify_query`` calls this function directly with an injected
373
+ ``chat`` and no session, and a required argument would have made every one of those calls a
374
+ signature change. An omitted session is COUNTED in ``usage_ledger.UNATTRIBUTED`` and reported
375
+ to an admin, never dropped.
376
  """
377
  requested = str(model or MODEL_AUTO).strip().lower()
378
  if requested not in model_choices():
 
395
  return None, sentence, None, "model_unavailable" if requested != MODEL_AUTO else "not_configured"
396
 
397
  import requests
398
+
399
+ import usage_ledger
400
+
401
+ def _book(provider, body):
402
+ """⭐⭐ C7 β€” ONE LEDGER LINE PER PROVIDER RESPONSE THIS FUNCTION READS.
403
+
404
+ β›” HERE, INSIDE THE LADDER, AND NOT AT THE RETURN. The ladder tries providers in order and
405
+ a failed one has already SPENT tokens at that vendor; booking only the winner would report
406
+ a week cheaper than it was, which is the cost-surprise R13 cited. Every 200 this loop reads
407
+ gets a line, including the one whose answer turned out to be empty.
408
+ ⚠ `tokens_from` returns `(None, None)` when the provider did not say, and `None` is carried
409
+ through rather than coerced to 0 β€” a call booked at zero is an unmeasured call presented as
410
+ a free one. `usage_ledger` counts it as UNMEASURED so the total reads as a floor.
411
+ ⚠ `record` never raises, by its own contract, so this cannot break the assistant.
412
+ """
413
+ ins, outs = usage_ledger.tokens_from(body)
414
+ usage_ledger.record(
415
+ "assistant", provider["name"], provider["model"], ins, outs,
416
+ total=usage_ledger.total_from(body),
417
+ st=getattr(session, "runtime", None), user=getattr(session, "uname", ""))
418
+
419
  last = None
420
  for provider in providers:
421
  try:
 
449
  last = f"{provider['name']}: HTTP {response.status_code}"
450
  continue
451
  try:
452
+ body = response.json()
453
+ # C7: booked from the RAW body, before anything below can raise on its shape. A
454
+ # provider that answered 200 and billed for it has spent tokens whether or not this
455
+ # function can read what it said.
456
+ _book(provider, body)
457
+ answer = body["choices"][0]["message"]
458
  said = _said(answer.get("content"))
459
  calls = answer.get("tool_calls") or []
460
  if calls:
 
704
 
705
  def _public_view(view):
706
  source = view["source"]
707
+ # ⭐⭐ W35-T25 Β· CONTRACT C4 β€” `edited` IS DERIVED, NEVER STORED, and that is deliberate.
708
+ # A stored boolean beside two specs is a third source of truth that can disagree with both;
709
+ # the only honest answer to "has this been changed" is "compare it". It also means a Revert
710
+ # that restores the spec clears the badge by construction rather than by remembering to.
711
+ original = view.get("original_spec")
712
  return {
713
  "id": view["id"], "viewId": view["id"], "scope": source["database"],
714
  "name": view["name"], "description": str(view.get("description") or ""),
 
716
  "explain": view["explain"], "threadId": view["threadId"], "createdAt": view["createdAt"],
717
  "virtual": True, "source": _safe(source), "view": _safe(view["view"]),
718
  "citationIds": list(view["citationIds"]), "numeric": _safe(view["numeric"]),
719
+ # ⚠ AN ARTEFACT MADE BEFORE THIS WAVE HAS NO ORIGINAL, so it reports `edited: false` and
720
+ # sends no `original_spec` β€” and the client shows neither the badge nor Revert. C4 is
721
+ # explicit that this is the right answer: a Revert with nothing to revert to is worse
722
+ # than an absent one, and it is the case an NC in `verify_query` covers.
723
+ "original_spec": _safe(original) if isinstance(original, dict) else None,
724
+ "edited": bool(isinstance(original, dict) and _safe(view["view"]) != _safe(original)),
725
  }
726
 
727
 
 
792
  return _public_state(_state(session), session)
793
 
794
 
795
+ # ⭐⭐ W35-T25 Β· CONTRACT C4 (owner item 4 / R3) β€” WHICH SPEC MEMBERS AN EDIT MAY MOVE.
796
+ #
797
+ # β›” AN ALLOW-LIST, AND IT IS THE SECURITY BOUNDARY OF THIS WHOLE TICKET. The client sends a
798
+ # `SavedView`, a shape it composes itself, and merging it wholesale would let a caller rewrite
799
+ # `kind` (which decides the renderer and the citation's own claim), `aggregation` (the number this
800
+ # artefact CITED) or `name`. R3 opens the view SPEC: *"filters, sort, group, visible columns,
801
+ # widths"*, plus the row height and column order that carry them. Nothing else.
802
+ #
803
+ # ⚠ `aggregation` IS DELIBERATELY ABSENT even though a person can change it on an ordinary grid.
804
+ # The artefact's citation records `contributing_record_count` and an op computed from THIS
805
+ # aggregation; letting an edit move it would leave a cited number describing a calculation the
806
+ # artefact no longer performs, which is the one thing every provenance rule in this module exists
807
+ # to prevent.
808
+ #
809
+ # ⚠ A COMMENT, NOT A BARE MODULE-LEVEL STRING. `verify_prose` reads a free-floating string literal
810
+ # as candidate copy, so writing this as a `"""..."""` above the constant put an em dash into the
811
+ # gate's own census as a 296th finding. A `#` block is out of scope by construction.
812
+ QUERY_EDITABLE_SPEC = ("visible", "order", "widths", "filters", "filterConj", "sorts",
813
+ "groupBy", "rowHeightMode", "frozenCount", "colorBy", "display")
814
+
815
+
816
+ def _clean_spec_edit(config, spec, fields):
817
+ """Merge a client view config onto the artefact's spec, cleaned against ITS OWN fields.
818
+
819
+ β›” THE FIELD KEYS COME FROM THE STORED SOURCE, never from the request. The artefact carries
820
+ the snapshot's field list, so this needs no database read and cannot be widened by a caller
821
+ naming a column the snapshot did not have.
822
+ """
823
+ keys = {str(field.get("key")) for field in (fields or []) if isinstance(field, dict)}
824
+ out = copy.deepcopy(spec) if isinstance(spec, dict) else {}
825
+ if not isinstance(config, dict):
826
+ return out
827
+ for member in QUERY_EDITABLE_SPEC:
828
+ if member not in config:
829
+ continue
830
+ value = config[member]
831
+ if member in ("visible", "order"):
832
+ cleaned = [str(key) for key in value if str(key) in keys] if isinstance(value, list) else []
833
+ # ⚠ An EMPTY visible list is refused rather than stored: `_validate` already treats
834
+ # "no columns" as a refusal at creation, and a grid showing nothing is not an edit
835
+ # somebody meant to make.
836
+ if member == "visible" and not cleaned:
837
+ continue
838
+ out[member] = cleaned[:MAX_VISIBLE]
839
+ elif member == "widths":
840
+ out[member] = {str(key): int(width) for key, width in value.items()
841
+ if str(key) in keys and isinstance(width, (int, float))
842
+ and 0 < float(width) <= 2000} if isinstance(value, dict) else {}
843
+ elif member == "filters":
844
+ out[member] = _grid().clean_filter_tree(
845
+ [item for item in value if isinstance(item, dict)], keys) if isinstance(value, list) else []
846
+ elif member == "filterConj":
847
+ out[member] = "or" if value == "or" else "and"
848
+ elif member == "sorts":
849
+ out[member] = [{"colId": item["colId"], "dir": "desc" if item.get("dir") == "desc" else "asc"}
850
+ for item in value if isinstance(item, dict) and item.get("colId") in keys][:3] \
851
+ if isinstance(value, list) else []
852
+ elif member in ("groupBy", "colorBy"):
853
+ out[member] = value if value in keys else None
854
+ elif member == "rowHeightMode":
855
+ out[member] = value if value in ("short", "medium", "tall", "extra") else None
856
+ elif member == "frozenCount":
857
+ out[member] = max(0, min(6, int(value))) if isinstance(value, (int, float)) else 0
858
+ elif member == "display":
859
+ out[member] = _grid()._clean_display(value, keys) if isinstance(value, dict) else out.get("display")
860
+ return out
861
+
862
+
863
  @router.post("/query/{qid}/events")
864
  def mutate_query_workspace(qid: str, body: dict = Body(default=None),
865
  session: Session = Depends(require_session)):
866
  """The only grid-mutation transport for a virtual Query workspace.
867
 
868
+ ⭐⭐ W35-T25 (owner item 4 / R2) β€” `view_upsert` ON THE ARTEFACT ITSELF IS NOW ACCEPTED. It
869
+ used to 409 `query_workspace_immutable` for everything but a delete, on the reading that an AI
870
+ artefact is a snapshot. R2 replaces that reading: the SPEC is the reader's to shape, the
871
+ CITATION and the numbers behind it are not. `QUERY_EDITABLE_SPEC` above is where that line is
872
+ drawn, and `original_spec` is what makes the change undoable.
873
+
874
+ Creating a SECOND view inside the workspace is still refused β€” an artefact holds exactly one β€”
875
+ and deleting the caller's personal artefact still removes it. The binding key, not a
876
+ client-supplied source scope, identifies it.
877
  """
878
  qid = str(qid)
879
  body = body if isinstance(body, dict) else {}
 
894
  raise err(404, "unknown_query", "that Query artefact does not exist")
895
 
896
  event_type = str(event["type"])
897
+ if event_type == "view_upsert":
898
+ sent = event.get("view")
899
+ sent = sent if isinstance(sent, dict) else {}
900
+ # β›” THE ID IS CHECKED THE SAME WAY THE DELETE'S IS. An upsert naming a different view is
901
+ # a create wearing an update's name, and a Query workspace holds exactly one view.
902
+ if str(sent.get("id") or "") != qid:
903
+ raise err(400, "query_workspace_mismatch",
904
+ "a Query view edit must name the same virtual artefact as its binding")
905
+ spec = _clean_spec_edit(sent.get("config"), view.get("view"), (view.get("source") or {}).get("fields"))
906
+
907
+ def apply_edit(raw):
908
+ current = copy.deepcopy(raw) if isinstance(raw, dict) else _blank_state()
909
+ row = (current.get("views") or {}).get(qid)
910
+ if not isinstance(row, dict):
911
+ return current
912
+ # ⚠ BACKFILLED HERE, and only when absent: an artefact created before this wave has no
913
+ # original, and the FIRST edit is the last moment its pre-edit spec still exists. Not
914
+ # backfilling would leave it permanently unrevertable; backfilling unconditionally
915
+ # would make Revert restore the latest edit.
916
+ if not isinstance(row.get("original_spec"), dict):
917
+ row["original_spec"] = _safe(row.get("view"))
918
+ row["view"] = _safe(spec)
919
+ current["views"][qid] = row
920
+ return current
921
+
922
+ if not session.runtime.available():
923
+ raise err(503, "store_unavailable", "the tenant store is unavailable; nothing was saved")
924
+ session.runtime.update(_namespace_key(session), apply_edit, flush="async")
925
+ return {"workspaceBinding": {"kind": "query", "key": qid}, "event": event_type,
926
+ "view": _public_view(apply_edit(_state(session))["views"][qid])}
927
+
928
  if event_type != "view_delete":
929
  raise err(409, "query_workspace_immutable",
930
+ "a Query workspace holds one view, so it cannot take another")
931
  if str(event.get("viewId") or "") != qid:
932
  raise err(400, "query_workspace_mismatch",
933
  "a Query delete must name the same virtual artefact as its binding")
 
937
  "event": event_type, **deleted}
938
 
939
 
940
+ @router.post("/query/{qid}/revert")
941
+ def revert_query(qid: str, session: Session = Depends(require_session)):
942
+ """⭐⭐ CONTRACT C4 (R3) β€” restore the AI's original SPEC, and only the spec.
943
+
944
+ β›” WHAT THIS DOES NOT DO, which the client's confirm says out loud BEFORE it acts: it does not
945
+ undo anything the reader changed in the SOURCE database. A Query view is live now, so a cell
946
+ edit made through it is a real write to a real record β€” reverting a view's filters cannot and
947
+ must not walk those back. R3 is explicit that the dialog states this before it acts, because a
948
+ Revert that silently leaves data changed is worse than one that never offered.
949
+
950
+ ⚠ 409, not 404, when there is no original: the artefact exists and is readable, and the caller
951
+ asked for something that does not exist FOR IT. A 404 would say the artefact is gone.
952
+ """
953
+ qid = str(qid)
954
+ # ⚠ `_artifact_or_404` returns `(state, row)`, not the row. Read it as a pair.
955
+ _state_now, view = _artifact_or_404(session, qid)
956
+ original = view.get("original_spec")
957
+ if not isinstance(original, dict):
958
+ raise err(409, "no_original_spec",
959
+ "this view was created before the assistant kept an original, so there is "
960
+ "nothing to revert to")
961
+
962
+ def restore(raw):
963
+ current = copy.deepcopy(raw) if isinstance(raw, dict) else _blank_state()
964
+ row = (current.get("views") or {}).get(qid)
965
+ if not isinstance(row, dict):
966
+ return current
967
+ row["view"] = _safe(row.get("original_spec"))
968
+ current["views"][qid] = row
969
+ return current
970
+
971
+ if not session.runtime.available():
972
+ raise err(503, "store_unavailable", "the tenant store is unavailable; nothing was reverted")
973
+ session.runtime.update(_namespace_key(session), restore, flush="async")
974
+ return _public_view(restore(_state(session))["views"][qid])
975
+
976
+
977
  def _sources(body, target, session):
978
  raw = body.get("sources") if isinstance(body, dict) else None
979
  sources = [str(item).strip() for item in raw] if isinstance(raw, list) else []
 
1016
  assistant_message_id = _new_id("message")
1017
  # R16: the turns already on screen go with the question. `state` was read above, BEFORE this
1018
  # turn's own messages exist, so the replay is strictly the prior conversation.
1019
+ # ⭐ C7/T31: `session` rides along so the meter can attribute this call. See `_call_model`'s
1020
+ # docstring for why it is an argument and not ambient state (PRD amendment A1).
1021
  spec, said, provider, reason = _call_model(question, snapshot, model=selected_model, chat=chat,
1022
+ history=_history(state, thread_id), session=session)
1023
  view, refusal, refusal_code = _validate(spec, snapshot["fields"]) if spec is not None else (None, said, reason)
1024
 
1025
  artifact = None
 
1042
  "question": question,
1043
  "explain": _explain(view, snapshot["fields"]), "createdAt": now, "source": source,
1044
  "view": _safe(view), "citationIds": [citation_id], "numeric": numeric,
1045
+ # ⭐⭐ W35-T25 Β· CONTRACT C4 (R3) β€” THE AI'S OWN SPEC, WRITTEN ONCE, HERE.
1046
+ # R2 makes a Query view editable, so `view` moves from now on. This is the copy
1047
+ # "Revert to AI original" restores, and the thing `edited` is measured against.
1048
+ # β›” WRITTEN AT CREATE AND NOWHERE ELSE. Re-stamping it on any later write would
1049
+ # make Revert restore the most recent edit β€” a Revert that reverts to nothing,
1050
+ # which C4 names as worse than no Revert at all.
1051
+ # ⚠ `_safe(view)` twice, not the same object twice: `view` is mutable and a
1052
+ # shared reference would let an edit rewrite the original through the alias.
1053
+ "original_spec": _safe(view),
1054
  "model": provider, "requestedModel": selected_model}
1055
 
1056
  assistant_message = {
api/routes_starred.py ADDED
@@ -0,0 +1,828 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_starred.py β€” WAVE 35 (rulings R4/R5/R6/R7, contracts C2/C3/C5): THE STAR.
2
+
3
+ Owner items 8, 9 and 12. **Star and "mark important" are ONE idea and ONE flag** (R4): on screen
4
+ the word is Star, and starring an object lands it on Home and in the new Starred module. Four
5
+ kinds are starrable β€” `database`, `agent`, `query`, `view` β€” and RECORDS get their own door
6
+ (C5, `/starred/records`), because a record's identity is `(database, pid)` rather than a bare id.
7
+
8
+ β›”β›” THE ONE DESIGN RULE THAT DECIDES EVERYTHING BELOW: **A VIEW'S STAR IS NOT STORED HERE.**
9
+ C2 is explicit β€” `kind: "view"` writes the EXISTING `config.important` in that database's own
10
+ `<key>_table_workspace`, and never a second store. R4 says star and mark-important are one flag;
11
+ two stores for one flag is two flags that can disagree, and the badge inside the database and the
12
+ row on Home would then answer differently about the same view. So this file keeps THREE id lists
13
+ (`databases`, `agents`, `queries`) in its own per-user bucket, and for views it is a READER and a
14
+ WRITER of somebody else's bucket rather than an owner of anything.
15
+
16
+ ⚠ WHICH IS WHY `GET /starred` IS NOT FREE, AND EVERY CALLER MUST KNOW. Finding the starred views
17
+ means reading each visible database's view bucket β€” the same N reads `routes_nav._important_counts`
18
+ did before W35-T43 deleted it. Measured on tenant #0 (2026-08-16, recorded on that constant):
19
+ twelve buckets cost **6.2 ms WARM in total** and **7,331 ms of first fetch COLD**. So the scan is
20
+ budgeted, it is spent in the caller's own RECENTS order (a mark lives on a database somebody works
21
+ in), and a database it could not reach is named in `degraded` rather than quietly carrying no star.
22
+ That is R6's second sentence applied to a list: a limit that cannot be removed is REPORTED with its
23
+ cause, never silently enforced. **Call this AFTER first paint.**
24
+
25
+ ⚠ NO INDEX, DELIBERATELY. The obvious fix for the scan is a `starred.views` id list written beside
26
+ the three above. That is the second store C2 forbids: the moment the flag and the index can
27
+ disagree β€” a view deleted, a view unmarked from inside the database, a store write that lands on one
28
+ and not the other β€” the product has two answers to one question and no way to tell which is right.
29
+ The scan is the price of one flag, and it is paid off the critical path.
30
+ """
31
+ import time
32
+
33
+ from fastapi import APIRouter, Body, Depends
34
+
35
+ import routes_nav
36
+ from deps import Session, err, require_session
37
+
38
+ router = APIRouter(prefix="/api/v1")
39
+
40
+ #: The per-user bucket holding the three ID-LIST kinds. `{username: {databases, agents, queries}}`
41
+ #:
42
+ #: ⚠ PER USER (R6), like `nav_recents` and unlike `nav_meta`: what I care about is not a fact about
43
+ #: the object, so a tenant-wide star would put one person's shortlist on everybody's Home.
44
+ _STARRED_KEY = "starred"
45
+
46
+ #: The four kinds C2 declares. `view` is the odd one out β€” see the header.
47
+ KINDS = ("database", "agent", "query", "view")
48
+
49
+ #: The three kinds this file's own bucket holds, and the payload key each answers under.
50
+ _LIST_KINDS = {"database": "databases", "agent": "agents", "query": "queries"}
51
+
52
+ #: A bound on each id list. β›” REPORTED, NEVER SILENT: a POST that would cross it is refused with
53
+ #: the cause and the action (unstar something), rather than being accepted and truncated. A star
54
+ #: silently dropped is a star the user believes they set.
55
+ MAX_PER_KIND = 200
56
+
57
+ #: An id is a store key, a view id or an automation id β€” all of which are short. Bounded so a
58
+ #: hostile client cannot grow one user's document with one very long string.
59
+ _MAX_ID = 120
60
+
61
+ #: The wall-clock ceiling on the WHOLE view scan, checked between databases β€” the same number and
62
+ #: the same reason as the `_IMPORTANT_BUDGET_S` this replaces. It bounds the FIRST request after a
63
+ #: container starts; warm it is not reached.
64
+ VIEW_SCAN_BUDGET_S = 2.5
65
+
66
+
67
+ def _clean_id(raw):
68
+ """One id, or "" β€” the only shape this file stores."""
69
+ return str(raw or "").strip()[:_MAX_ID]
70
+
71
+
72
+ def _clean_list(raw):
73
+ """A stored id list, validated on the way OUT as well as in.
74
+
75
+ Re-validating a read is not redundant: the bucket outlives this code and an older build (or a
76
+ hand edit) could have written a shape this one does not accept. Prune, never invent β€” the
77
+ posture `_clean_nav_prefs` takes one file over.
78
+ """
79
+ if not isinstance(raw, list):
80
+ return []
81
+ out, seen = [], set()
82
+ for item in raw[:MAX_PER_KIND]:
83
+ got = _clean_id(item)
84
+ if got and got not in seen:
85
+ seen.add(got)
86
+ out.append(got)
87
+ return out
88
+
89
+
90
+ def _read_lists(session):
91
+ """This caller's three id lists. A store blip answers empty rather than taking the page down."""
92
+ try:
93
+ mine = (session.runtime.get(_STARRED_KEY) or {}).get(session.uname) or {}
94
+ except Exception:
95
+ return {name: [] for name in _LIST_KINDS.values()}
96
+ if not isinstance(mine, dict):
97
+ return {name: [] for name in _LIST_KINDS.values()}
98
+ return {name: _clean_list(mine.get(name)) for name in _LIST_KINDS.values()}
99
+
100
+
101
+ def _database_keys(session):
102
+ """`(keys, enumerated)` β€” every database this session may open that has a view bucket.
103
+
104
+ ⚠ `routes_nav._visible_database_keys` IS REUSED RATHER THAN RE-DERIVED, and that is not
105
+ tidiness: it merges the compiled registry (behind the account grant AND the tenant catalogue)
106
+ with `user_tables.nav_entries`, the `may_open`-filtered listing over the rows-free projection.
107
+ Writing a second enumeration here is how the folder-drop defect happened (wave 27, item 6): one
108
+ door knew about `ut_*` keys and the other did not, and the disagreeing door answered 200 while
109
+ storing nothing.
110
+ β›” AND IT IS NOT `_placeable_top_keys`, WHICH NEVER APPLIES THE TENANT CATALOGUE β€” right for a
111
+ cosmetic folder placement, wrong for a scan that then opens each database's view bucket. See
112
+ that helper's own note for the second difference (module surfaces have no views to star).
113
+ """
114
+ return routes_nav._visible_database_keys(session)
115
+
116
+
117
+ def _scan_order(session, keys):
118
+ """`keys`, RECENTS-FIRST β€” the order the scan budget is spent in.
119
+
120
+ β›” THE ORDER IS LOAD-BEARING AND W34-T10 LEARNED IT THE EXPENSIVE WAY. A budget spent in
121
+ whatever order the keys happen to arrive is a lottery: measured cold on tenant #0, that block
122
+ spent all of its first draft's budget on ten EMPTY Odoo buckets and was cut off two rows before
123
+ `customer_data`, which holds the only marked view in the tenant. The feature would have been
124
+ correct and shown nothing. A star lives on a database somebody works in, and `nav_recents` is
125
+ exactly that list, newest first.
126
+ """
127
+ recents = routes_nav._read_recents(session.runtime, session.uname, keys)
128
+ order = [r["key"] for r in recents]
129
+ seen = set(order)
130
+ order += [k for k in sorted(keys) if k not in seen]
131
+ return order
132
+
133
+
134
+ def _view_docs(session, order):
135
+ """`({key: workspace_doc}, unread)` β€” each database's view bucket, PROJECTED, within budget.
136
+
137
+ β›” PROJECTED, and the two dropped keys are what makes this affordable: `overlays` is per-record
138
+ cell values and `fields` is the schema stratum, neither of which says anything about a view. On
139
+ `customer_data` they are most of the bucket.
140
+ ⚠ `unread` is a store blip OR the budget β€” reported, never a confident absence.
141
+ """
142
+ import core.view_templates as view_templates
143
+ docs, unread = {}, []
144
+ started = time.perf_counter()
145
+ for key in order:
146
+ ws_key = view_templates.workspace_key(key)
147
+ if not ws_key:
148
+ continue # a module surface or a folder head, not a table at all
149
+ if time.perf_counter() - started > VIEW_SCAN_BUDGET_S:
150
+ unread.append(key)
151
+ continue
152
+ try:
153
+ docs[key] = session.runtime.get_projection(ws_key,
154
+ drop=("overlays", "fields")) or {}
155
+ except Exception:
156
+ unread.append(key)
157
+ return docs, unread
158
+
159
+
160
+ def _refresh_one(session, cache, key):
161
+ """Re-read ONE database's bucket inside a cache the caller already holds.
162
+
163
+ β›” THIS IS WHY A WRITE MAY REUSE A SCAN AT ALL. The star write changes exactly one bucket, so
164
+ every OTHER document in the cache is still true β€” but the one just written is stale by
165
+ definition, and serving it back would answer the POST with the flag's OLD value. That is a lost
166
+ write wearing the face of a failed read ([[refetch-eats-its-own-write]] in reverse), and it is
167
+ the trap that makes "just reuse the cache" wrong without this function.
168
+ """
169
+ import core.view_templates as view_templates
170
+ order, docs, unread = cache
171
+ ws_key = view_templates.workspace_key(key)
172
+ if not ws_key:
173
+ return cache
174
+ try:
175
+ docs[key] = session.runtime.get_projection(ws_key, drop=("overlays", "fields")) or {}
176
+ except Exception:
177
+ docs.pop(key, None)
178
+ if key not in unread:
179
+ unread.append(key)
180
+ return (order, docs, unread)
181
+
182
+
183
+ def _scan_starred_views(session, keys=None, cache=None):
184
+ """`(rows, unread)` β€” every view this caller has starred, newest-worked database first.
185
+
186
+ A row is `{"id", "database", "name", "config"}`. ⚠ `config` is carried so the COUNTS route does
187
+ not have to read every bucket a second time: `_view_docs` is the expensive half of this file
188
+ (7,331 ms cold for twelve buckets) and two consumers asking the same question twice per request
189
+ is the 1+N shape D-175 spent a wave removing from `/nav`. `_starred_views` below drops it for the
190
+ wire.
191
+
192
+ β›” `cache` IS `(order, docs, unread)` FROM AN EARLIER PASS IN THE SAME REQUEST, and it exists
193
+ because the view-star WRITE otherwise scanned every bucket TWICE: once in `_find_view` to locate
194
+ the view, then again to build the answer. That is the same 1+N shape, in the same file, on the
195
+ same expensive read β€” introduced an hour after it was removed one function away. A caller that
196
+ has written must `_refresh_one` the database it wrote before passing its cache.
197
+ """
198
+ if cache is not None:
199
+ order, docs, unread = cache
200
+ else:
201
+ if keys is None:
202
+ keys, _ = _database_keys(session)
203
+ order = _scan_order(session, keys)
204
+ docs, unread = _view_docs(session, order)
205
+ granted = routes_nav._granted_view_ids(session)
206
+ out = []
207
+ for key in order:
208
+ doc = docs.get(key)
209
+ if doc is None:
210
+ continue
211
+ for vid, view in routes_nav._visible_views(doc, session.uname, session.admin,
212
+ granted).items():
213
+ cfg = view.get("config") or {}
214
+ if cfg.get("important") is True:
215
+ out.append({"id": vid, "database": key,
216
+ "name": str(view.get("name") or vid)[:120], "config": cfg})
217
+ return out, unread
218
+
219
+
220
+ def _starred_views(session, keys=None, cache=None):
221
+ """The C2 wire rows β€” `{"id", "database", "name"}`, with the config dropped.
222
+
223
+ ⭐ `name` is a SUPERSET of C2, published to B as `NOTE E-1`: the scan already holds the record,
224
+ so the name is free, and without it Home would render a starred view as a bare id.
225
+ β›” THE CONFIG DOES NOT GO ON THE WIRE. It carries `memberPids` (a curated view's whole pid list)
226
+ and every filter leaf, which is somebody's row set travelling to a client that asked for a label.
227
+ """
228
+ rows, unread = _scan_starred_views(session, keys, cache=cache)
229
+ return ([{"id": r["id"], "database": r["database"], "name": r["name"]} for r in rows],
230
+ unread)
231
+
232
+
233
+ def _query_names(session, qids):
234
+ """`({qid: name}, resolved)` for the caller's own Query artefacts.
235
+
236
+ ⭐ ANSWERS `ASK B-1 (2)`. C2 declares `queries: [qid]` and there is no view/query id -> label map
237
+ anywhere in the client, so Home and Starred would render a starred Query as a bare id. B's only
238
+ alternative was `GET /query`, which drags threads, messages, citations and every saved view
239
+ across the wire to render two strings.
240
+
241
+ ⚠ IT READS LANE C's BUCKET AND CALLS LANE C's HELPERS, WITHOUT EDITING LANE C's FILE. Query
242
+ state is ONE small per-user document (`query_user_<hash>`: threads, messages, citations, views β€”
243
+ no grid rows), and `_namespace_key`/`_state` are pure functions of the session. Re-deriving the
244
+ key here would be a second spelling of a hash, which is the one thing guaranteed to drift.
245
+
246
+ β›” `resolved` IS FALSE WHEN THE READ FAILED, and the caller must not prune on it β€” a store blip
247
+ would otherwise delete somebody's whole starred-query list. Same posture as
248
+ `_clean_nav_prefs(keep_unknown_ut=...)`.
249
+ """
250
+ if not qids:
251
+ return {}, True
252
+ try:
253
+ import routes_query
254
+ views = (routes_query._state(session) or {}).get("views") or {}
255
+ except Exception:
256
+ return {}, False
257
+ out = {}
258
+ for qid in qids:
259
+ row = views.get(qid)
260
+ if isinstance(row, dict):
261
+ out[qid] = str(row.get("name") or qid)[:120]
262
+ return out, True
263
+
264
+
265
+ def _payload(session, keys=None, cache=None):
266
+ """The C2 answer: the three id lists, the starred views, and what could not be read.
267
+
268
+ ⚠ `degraded` SHIPS AS `[]` RATHER THAN BEING OMITTED WHEN EMPTY β€” the same rule `/nav`'s
269
+ `omitted`/`degraded` follow, for the same reason: a key a consumer has to test for is a key a
270
+ consumer forgets to test for.
271
+ ⚠ `cache` is a scan an earlier pass in THIS request already paid for β€” see `_scan_starred_views`.
272
+ """
273
+ lists = _read_lists(session)
274
+ if keys is None:
275
+ keys, _ = _database_keys(session)
276
+ # ⚠ PRUNED ON READ, NEVER ON WRITE β€” `_read_recents`' posture, and both halves are deliberate.
277
+ # A database whose grant was revoked must stop being offered without anybody running a
278
+ # migration, and must come back if the grant does. A key that is not visible therefore reaches
279
+ # the store and never reaches a screen.
280
+ # ⚠ ONLY `databases` is pruned. An agent id or a query id would each cost another store read to
281
+ # validate, and pruning them here buys nothing: this list is the CALLER'S OWN writes, so it can
282
+ # disclose nothing they did not already know, and B/C render only objects they can already see
283
+ # (the way `groupRecents` drops an unreachable recent). Said out loud rather than left as a gap.
284
+ lists["databases"] = [k for k in lists["databases"] if k in keys]
285
+ # ⭐ ASK B-1 (2): the names, and β€” because the read that answers "what is it called" also answers
286
+ # "does it still exist" β€” the same prune the databases get. β›” ONLY WHEN THE READ SUCCEEDED: a
287
+ # store blip that returned no artefacts must not delete the whole starred-query list.
288
+ qnames, qresolved = _query_names(session, lists["queries"])
289
+ if qresolved:
290
+ lists["queries"] = [q for q in lists["queries"] if q in qnames]
291
+ views, unread = _starred_views(session, keys, cache=cache)
292
+ out = {"databases": lists["databases"], "agents": lists["agents"],
293
+ "queries": lists["queries"], "queryNames": qnames,
294
+ "views": views, "degraded": unread}
295
+ if unread:
296
+ # R6's second sentence, in the payload: the CAUSE and what it means, not a bare list.
297
+ out["note"] = (f"{len(unread)} database(s) could not be read in time, so a view you "
298
+ f"starred there is missing from this list. Open one of them once and the "
299
+ f"next load will include it")
300
+ return out
301
+
302
+
303
+ @router.get("/starred")
304
+ def starred(session: Session = Depends(require_session)):
305
+ """C2 β€” everything this caller has starred.
306
+
307
+ ⚠ NOT ON `/nav`'s PATH, and R7 is the ruling: `/nav` is a rows-free projection whose budget was
308
+ already the subject of D-175/D-185/D-288. This route is called AFTER Home/Starred paint.
309
+ """
310
+ return _payload(session)
311
+
312
+
313
+ def _find_view(session, vid, database=""):
314
+ """`(key, ws_key, stratum, view, cache)` for a view this caller can SEE, else None.
315
+
316
+ `stratum` is the username whose block holds it, or `table_store.SHARED_KEY`. `cache` is the
317
+ `(order, docs, unread)` this call paid for β€” but **only when it scanned everything**. With a
318
+ `database` hint it read ONE bucket, which is not a complete answer for the caller's whole
319
+ starred list, so it hands back None and the caller pays for a full pass. Handing back a partial
320
+ cache would silently drop every starred view on every OTHER database.
321
+
322
+ ⚠ `database` IS AN OPTIONAL FAST PATH (a superset of C2, published to B and C): the client
323
+ almost always knows which database the view belongs to, and naming it turns an N-bucket scan
324
+ into one read. Absent, the scan runs β€” because C2's body carries only `kind`/`id`/`on`, and a
325
+ door that REQUIRED the hint would break the contract two lanes are building against.
326
+ """
327
+ import core.table_store as table_store
328
+ import core.view_templates as view_templates
329
+
330
+ keys, _ = _database_keys(session)
331
+ if database:
332
+ # An unknown or invisible key must not become a wider scan. It answers "not found", which
333
+ # is the same answer an id nobody holds gets β€” see the write door's note on why.
334
+ if database not in keys:
335
+ return None
336
+ order, cache = [database], None
337
+ else:
338
+ order = _scan_order(session, keys)
339
+ docs, unread = _view_docs(session, order)
340
+ if not database:
341
+ cache = (order, docs, unread)
342
+ granted = routes_nav._granted_view_ids(session)
343
+ for key in order:
344
+ doc = docs.get(key)
345
+ if doc is None:
346
+ continue
347
+ if str(vid) not in routes_nav._visible_views(doc, session.uname, session.admin, granted):
348
+ continue
349
+ ws_key = view_templates.workspace_key(key)
350
+ # WHICH stratum holds it decides the WALL, so it is resolved here rather than guessed.
351
+ # Personal first: a caller's own block is the common case and needs no grant.
352
+ if isinstance(doc.get(session.uname), dict) and \
353
+ str(vid) in ((doc[session.uname].get("views") or {})):
354
+ return (key, ws_key, session.uname, doc[session.uname]["views"][str(vid)], cache)
355
+ shared = (doc.get(table_store.SHARED_KEY) or {}).get("views") or {}
356
+ if str(vid) in shared:
357
+ return (key, ws_key, table_store.SHARED_KEY, shared[str(vid)], cache)
358
+ for stratum, blob in doc.items():
359
+ if stratum == session.uname or not isinstance(blob, dict):
360
+ continue
361
+ if str(vid) in (blob.get("views") or {}):
362
+ return (key, ws_key, str(stratum), blob["views"][str(vid)], cache)
363
+ return None
364
+
365
+
366
+ def _may_star_view(session, stratum, view):
367
+ """May this caller WRITE the star on this view? Fail-closed.
368
+
369
+ β›” A VIEW ID THE CALLER CANNOT WRITE MUST REFUSE **LOUDLY**, and this is a standing scar:
370
+ `view_upsert` answers **200 while writing nothing** when the id belongs to a view the caller
371
+ cannot see, so a pinned id is effectively tenant-scoped and every symptom of it is silent
372
+ ([[a-view-id-another-user-holds-refuses-silently]]). This function is the reason the write door
373
+ below raises instead of shrugging.
374
+
375
+ THREE HOMES, THREE WALLS:
376
+ Β· the caller's OWN stratum β€” theirs, no further question;
377
+ Β· `__shared__` β€” `table_store._may_edit`, which is the predicate the view door itself uses;
378
+ Β· another user's stratum, reached by a wave-21 grant β€” the grant must be `edit` or `owner`.
379
+ ⚠ A `view` ROLE IS NOT A WRITE GRANT. `_granted_view_ids` admits both roles because it
380
+ answers "can they SEE it"; this answers "may they CHANGE it", and collapsing the two would
381
+ let a read-only grantee mark somebody else's shared view.
382
+ """
383
+ import core.shares as shares
384
+ import core.table_store as table_store
385
+ if stratum == session.uname:
386
+ return True
387
+ if stratum == table_store.SHARED_KEY:
388
+ return bool(table_store._may_edit(view, session.uname, session.admin))
389
+ return shares.role_for("view", str((view or {}).get("id") or ""), session.uname,
390
+ is_admin=session.admin,
391
+ st=session.runtime) in ("edit", "owner")
392
+
393
+
394
+ def _write_view_star(session, ws_key, stratum, vid, on):
395
+ """Set `config.important` IN PLACE, in the view's own bucket. Returns True when it landed.
396
+
397
+ β›”β›” IN PLACE, AND **NOT** THROUGH `grid_events.view_upsert` OR `table_store.save_view` β€” three
398
+ separate reasons, each of which has cost this repo something:
399
+
400
+ 1. `view_upsert` REBUILDS `config` from a literal allowlist and filters `memberPids` against
401
+ `allowed_pids` and `cohortLock` against `cohort_ids`. Routing a flag toggle through it
402
+ means a cohort view whose pool this request did not assemble comes back with its curated
403
+ row set pruned β€” "the 40 accounts we agreed to call" quietly becoming fewer, under the
404
+ same name, with nothing going red.
405
+ 2. `table_store.save_view` re-runs `_unique_name` against every view name in the document.
406
+ A star has no business re-allocating a name; a collision would RENAME the starred view.
407
+ 3. Both are "write the whole view" doors. This writes one boolean.
408
+
409
+ ⚠ SET UNCONDITIONALLY, never "only when True" β€” `grid_events` records the same rule beside its
410
+ own `important` line: a key written only when present means unstarring is silently a no-op and
411
+ the stored `true` survives, i.e. a mark you can set and never clear.
412
+ ⚠ `bool(on)`, so a JSON `"false"` cannot become a star.
413
+ ⚠ FLUSH IS THE DEFAULT (sync). This is a deliberate one-at-a-time act like a folder rename, not
414
+ the autosave hot path `table_store` uses `async` for β€” and a coalesced write is exactly what
415
+ made eighteen 200s land zero rows in wave 33.
416
+ """
417
+ landed = {"ok": False}
418
+
419
+ def _up(data):
420
+ data = data if isinstance(data, dict) else {}
421
+ block = data.get(stratum)
422
+ if not isinstance(block, dict):
423
+ return data
424
+ views = block.get("views")
425
+ if not isinstance(views, dict) or str(vid) not in views:
426
+ return data
427
+ view = views[str(vid)]
428
+ if not isinstance(view, dict):
429
+ return data
430
+ cfg = view.get("config")
431
+ if not isinstance(cfg, dict):
432
+ cfg = {}
433
+ view["config"] = cfg
434
+ cfg["important"] = bool(on)
435
+ landed["ok"] = True
436
+ return data
437
+
438
+ session.runtime.update(ws_key, _up)
439
+ return landed["ok"]
440
+
441
+
442
+ @router.post("/starred")
443
+ def set_starred(body: dict = Body(default=None),
444
+ session: Session = Depends(require_session)):
445
+ """C2 β€” star or unstar ONE object, and answer with the caller's whole new list.
446
+
447
+ Body: `{"kind": "database"|"agent"|"query"|"view", "id": str, "on": bool}`, plus an OPTIONAL
448
+ `"database"` when `kind` is `view` (the fast path β€” see `_find_view`).
449
+
450
+ ⚠ THE WHOLE LIST COMES BACK, not an ack, because the client is optimistic-then-reconciled
451
+ (T15): an ack would leave the browser as the only place that knows what the new state is.
452
+ """
453
+ body = body if isinstance(body, dict) else {}
454
+ kind = str(body.get("kind") or "").strip()
455
+ oid = _clean_id(body.get("id"))
456
+ if kind not in KINDS:
457
+ raise err(400, "bad_request",
458
+ f"kind must be one of {', '.join(KINDS)}")
459
+ if not oid:
460
+ raise err(400, "bad_request", "no object was named")
461
+ if "on" not in body or not isinstance(body.get("on"), bool):
462
+ # Explicit rather than defaulted: a toggle whose default is "star" turns a malformed
463
+ # unstar into a star, which is the one direction a user cannot undo by repeating it.
464
+ raise err(400, "bad_request", "`on` must be true or false")
465
+ on = bool(body["on"])
466
+ if not session.runtime.available():
467
+ raise err(503, "store_unavailable",
468
+ "the tenant store is unavailable. Nothing was saved.")
469
+
470
+ if kind == "view":
471
+ found = _find_view(session, oid, _clean_id(body.get("database")))
472
+ if not found:
473
+ # β›” 404 FOR BOTH "no such view" AND "not yours to see", deliberately: a distinct answer
474
+ # would turn this route into an oracle that sorts real view ids from invented ones.
475
+ raise err(404, "unknown_view", "that view is not available")
476
+ db_key, ws_key, stratum, view, cache = found
477
+ if not _may_star_view(session, stratum, view):
478
+ raise err(403, "forbidden",
479
+ "that view belongs to somebody else and you have read access only")
480
+ try:
481
+ landed = _write_view_star(session, ws_key, stratum, oid, on)
482
+ except Exception:
483
+ raise err(503, "store_unavailable",
484
+ "the star was not saved: the store refused the write.")
485
+ if not landed:
486
+ # The view moved between the read and the write (deleted, shared, unshared). Loud,
487
+ # because a 200 over a write that changed nothing is the defect this file's own
488
+ # `_may_star_view` note is about.
489
+ raise err(409, "view_moved",
490
+ "that view changed while the star was being saved. Try again")
491
+ # β›” REUSE THE SCAN, REFRESH THE ONE BUCKET WE WROTE. Without this the POST scans every view
492
+ # bucket TWICE β€” once to find the view, once to build the answer β€” which is the 1+N shape
493
+ # this file removed from `/starred/counts` an hour earlier, reintroduced by the write path.
494
+ # ⚠ `_refresh_one` is not optional: the written document is stale by definition and serving
495
+ # it back would answer the POST with the flag's OLD value.
496
+ if cache is not None:
497
+ cache = _refresh_one(session, cache, db_key)
498
+ return _payload(session, cache=cache)
499
+
500
+ field = _LIST_KINDS[kind]
501
+ uname = session.uname
502
+ # β›” THE CAP IS ENFORCED INSIDE THE TRANSACTION, NOT BY A READ BEFORE IT, AND THAT IS NOT
503
+ # PEDANTRY. A pre-flight `len(current) >= MAX_PER_KIND` check is advisory: two concurrent stars
504
+ # both pass it, both append, and the document ends up at MAX+1 β€” where `_clean_list`'s
505
+ # `raw[:MAX_PER_KIND]` then drops one **on the next read**. That is a star the user set, saw
506
+ # confirmed, and cannot find, which is exactly the silent truncation R6's second sentence
507
+ # forbids. Refusing inside the read-modify-write makes the ceiling true rather than likely.
508
+ # ⚠ IT RECORDS THE REFUSAL RATHER THAN RAISING FROM INSIDE `_up`: an exception thrown through
509
+ # `store.update` would surface as this route's own 503 "the store refused the write", which is a
510
+ # different and wrong story about a limit the caller can act on.
511
+ refused = {"full": False}
512
+
513
+ def _up(data):
514
+ data = data if isinstance(data, dict) else {}
515
+ mine = dict(data.get(uname) or {}) if isinstance(data.get(uname), dict) else {}
516
+ ids = _clean_list(mine.get(field))
517
+ if on:
518
+ if oid not in ids:
519
+ if len(ids) >= MAX_PER_KIND:
520
+ refused["full"] = True
521
+ return data
522
+ ids.append(oid)
523
+ else:
524
+ ids = [i for i in ids if i != oid]
525
+ if ids:
526
+ mine[field] = ids
527
+ else:
528
+ mine.pop(field, None)
529
+ # A user with nothing starred is REMOVED rather than stored empty β€” a bucket that
530
+ # accumulates `{}` per account is a document that grows forever and says nothing.
531
+ if mine:
532
+ data[uname] = mine
533
+ else:
534
+ data.pop(uname, None)
535
+ return data
536
+
537
+ try:
538
+ session.runtime.update(_STARRED_KEY, _up)
539
+ except Exception:
540
+ raise err(503, "store_unavailable",
541
+ "the star was not saved: the store refused the write.")
542
+ if refused["full"]:
543
+ # R6's second sentence: the cause and the action, never a 200 over a star that was dropped.
544
+ raise err(400, "starred_full",
545
+ f"you have starred the maximum of {MAX_PER_KIND} items of this kind. "
546
+ f"Unstar one to make room")
547
+ return _payload(session)
548
+
549
+
550
+ # ══════════════════════════════════════════════════════════════════ STARRED COUNTS (R7, C3)
551
+ #
552
+ # ⭐ R7: *"Row counts come from a NEW on-demand endpoint called after Home/Starred paint, never from
553
+ # `/nav`. A view whose count is not free shows the CAUSE, never an invented number."*
554
+ #
555
+ # β›” SO EVERY VID LANDS IN EXACTLY ONE OF `counts` AND `notes`, AND THAT IS THE CONTRACT (C3). A vid
556
+ # in neither is a client rendering nothing with no idea why; a vid in both is two answers to one
557
+ # question. `notes` is not a fallback for "we did not get round to it" β€” it is the CAUSE, in a
558
+ # sentence, with what to do about it, which is R6's second sentence applied to a badge.
559
+
560
+
561
+ def _cohort_sizes(session, key, cache):
562
+ """`{cohort_id: size}` for THIS caller's own cohorts on `key`'s topic. Memoised per request.
563
+
564
+ β›” THIS CALLER'S OWN COHORTS ONLY, and the consequence is deliberate: a SHARED view locked to a
565
+ cohort somebody else owns finds no id here, so it is reported UNCOUNTED with that cause rather
566
+ than counted out of a stratum this session cannot see. Widening the read would make the number
567
+ disclose the SIZE of another person's private list, which is a leak wearing a bug fix.
568
+ ⚠ Read through `session.runtime`, NOT `modules.cohort`'s module-level helpers: those call
569
+ `core.store` directly and carry no tenant namespace. The MODULE is asked for the bucket NAME (it
570
+ owns that rule) and this route does the reading, which is the only tenant-correct combination.
571
+ """
572
+ import core.view_templates as view_templates
573
+ import modules.cohort as cohort_mod
574
+ ws_key = view_templates.workspace_key(key) or ""
575
+ suffix = "_table_workspace"
576
+ scope = ws_key[:-len(suffix)] if ws_key.endswith(suffix) else key
577
+ if scope in cache:
578
+ return cache[scope]
579
+ try:
580
+ bucket = session.runtime.get(cohort_mod.key_for(scope)) or {}
581
+ mine = bucket.get(session.uname) or {}
582
+ cache[scope] = {str(cid): len(c.get("members") or [])
583
+ for cid, c in mine.items() if isinstance(c, dict)}
584
+ except Exception:
585
+ cache[scope] = {}
586
+ return cache[scope]
587
+
588
+
589
+ def _count_note(cfg, cohorts):
590
+ """WHY this view's size is not free, as one sentence naming a cause AND a next step.
591
+
592
+ β›” R6's SECOND SENTENCE IS THE SPEC HERE, and a bare dash is the thing it forbids. D-253 was
593
+ booked once already for a limit whose explanation was hover-only, so this has to be a real
594
+ sentence a client can put in a `title` AND an `aria-label`.
595
+ ⚠ THREE DIFFERENT CAUSES, not one generic apology. A view locked to somebody else's list, a
596
+ filtered view and a whole-table view are three different facts, and only one of them is
597
+ something the reader can act on.
598
+ """
599
+ cfg = cfg if isinstance(cfg, dict) else {}
600
+ lock = str(cfg.get("cohortLock") or "").strip()
601
+ if lock:
602
+ return ("This view is locked to a list owned by somebody else, so its size is not "
603
+ "something your account can read. Ask whoever shared it.")
604
+ if cfg.get("filters"):
605
+ return ("Counting a filtered view means reading every record, which this page does not "
606
+ "do. Open the database and the number is beside the view.")
607
+ return ("This view covers the whole database, so its size is the record count. Open the "
608
+ "database to see it.")
609
+
610
+
611
+ @router.get("/starred/counts")
612
+ def starred_counts(session: Session = Depends(require_session)):
613
+ """C3 β€” `{"counts": {vid: int}, "notes": {vid: str}, "degraded": [key]}`.
614
+
615
+ β›” CALLED AFTER THE PAGE PAINTS, NEVER FROM `/nav` (R7). `/nav` is a rows-free projection whose
616
+ budget was the subject of D-175, D-185, D-288 and D-289; W35-T43 takes the last of that off it,
617
+ and putting a counting pass back on the nav's path would undo the whole point.
618
+
619
+ ⚠ THE COUNTER IS `routes_nav._view_record_count`, CALLED β€” not a second implementation. It is the
620
+ one function that knows which shapes are free (a cohort lock resolves to a stored member list; a
621
+ curated `memberPids` carries its own length) and returns None for everything else. A second
622
+ counter here would be free to disagree with the badge inside the database.
623
+ """
624
+ rows, unread = _scan_starred_views(session)
625
+ cache, counts, notes = {}, {}, {}
626
+ for row in rows:
627
+ cfg, key, vid = row["config"], row["database"], row["id"]
628
+ sizes = _cohort_sizes(session, key, cache)
629
+ n = routes_nav._view_record_count(cfg, sizes)
630
+ # β›” EXACTLY ONE OF THE TWO MAPS (C3). The `if/else` is what makes that structural rather
631
+ # than a rule somebody has to remember: there is no path that writes both and none that
632
+ # writes neither.
633
+ if isinstance(n, int):
634
+ counts[vid] = n
635
+ else:
636
+ notes[vid] = _count_note(cfg, sizes)
637
+ return {"counts": counts, "notes": notes, "degraded": unread}
638
+
639
+
640
+ # ══════════════════════════════════════════════════════════════════ RECORD STARS (R5/R6, C5)
641
+ #
642
+ # ⭐ R5: *"Records get their own star, feeding an undeletable 'Starred records' view beside
643
+ # 'All records' on every database."* R6: *"Record stars are PER USER, in the same per-username
644
+ # stratum views already live in."*
645
+ #
646
+ # β›”β›” THE ID LIST IS ITS OWN KEY IN THAT STRATUM AND IT DOES NOT TRAVEL THROUGH THE VIEW DOOR.
647
+ # D-170, measured: on a read-through grid a VIEW WRITE answers **409 `window_required`** β€”
648
+ # `/grid/events` -> `routes_tables.ut_write_ctx` -> `scoped_pids` -> `scoped_pool` ->
649
+ # `routes_odoo_tables.whole_pool` raises `TooBigToMaterialise`, because the shared read/write
650
+ # assembly insists on the pid set. `ut_odoo_order_lines` (255,286 rows) and `ut_odoo_gl_lines`
651
+ # (971,034) serve rows through a window correctly and refuse every view-config write. A record star
652
+ # stored inside a view record would therefore be IMPOSSIBLE on exactly the two biggest databases in
653
+ # the tenant. This writes one key in the workspace bucket and touches no pid set, so it answers 200
654
+ # there.
655
+ #
656
+ # ⚠ AND FOR THE SAME REASON IT DOES NOT VALIDATE THE ID AGAINST THE ROW SET. Checking that a pid
657
+ # exists means materialising the pool, which is the 409 again. `/nav/opened` takes the identical
658
+ # posture and says why: the WRITE is cheap and unvalidated, the READ is what prunes β€” a client
659
+ # intersects `memberPids` with the rows it can see, exactly as a cohort already does
660
+ # (`aios_grid.workspace_wire` drops absent members and reports `missing`).
661
+
662
+ #: The per-user key inside `<database>_table_workspace`. BESIDE `views`, never inside one (R6).
663
+ _RECORDS_KEY = "starredRecords"
664
+
665
+ #: The pinned id of the projected view. ⚠ C's rail pins this too (W35-T30) β€” it is a WIRE constant,
666
+ #: so it is exported rather than spelled twice.
667
+ STARRED_VIEW_ID = "starred-records"
668
+ STARRED_VIEW_NAME = "Starred records"
669
+
670
+ #: A bound per (user, database). Reported on overflow, never silently enforced.
671
+ MAX_STARRED_RECORDS = 500
672
+
673
+
674
+ def _record_ids(doc, uname):
675
+ """This caller's starred record ids on ONE database, validated on the way out.
676
+
677
+ β›” INTS, AND A NON-DIGIT ID IS NOT STORED. Every record identity in this product is a digit
678
+ string β€” a pool `pid` is an int and a `ut_*` row key is `str(record_id).isdigit()` β€” and the
679
+ CLIENT's view engine reads `memberPids` as numbers (a cohort's members are ints). A mixed list
680
+ would make the projection match nothing on the rows it was built for, silently.
681
+ """
682
+ raw = (doc.get(uname) or {}).get(_RECORDS_KEY) if isinstance(doc.get(uname), dict) else None
683
+ if not isinstance(raw, list):
684
+ return []
685
+ out, seen = [], set()
686
+ for item in raw[:MAX_STARRED_RECORDS]:
687
+ try:
688
+ pid = int(item)
689
+ except (TypeError, ValueError):
690
+ continue
691
+ if pid not in seen:
692
+ seen.add(pid)
693
+ out.append(pid)
694
+ return out
695
+
696
+
697
+ def records_view(ids):
698
+ """The **"Starred records"** view, as a SavedView β€” or None when nothing is starred.
699
+
700
+ ⭐ THE SERVER HANDS C THE WHOLE OBJECT (C5) rather than a bare id list, so the rail cannot grow
701
+ a second idea of what this view is. `kind: 'system'` and `locked: True` are what make it
702
+ undeletable, the same two facts that protect `all-customers`.
703
+
704
+ β›” `memberPids` IS THE WHOLE MECHANISM: it makes this a CURATED ROW SET, which is the one shape
705
+ `routes_nav._view_record_count` can count for FREE β€” no rows read, no filter run. That is why C3
706
+ can put a real number beside it and why R7's counts endpoint costs nothing here.
707
+ ⚠ None WHEN EMPTY, not an empty view: a view listing zero records under a name promising some is
708
+ the shape D-229 was booked for. C shows the row only when there is something in it.
709
+ """
710
+ pids = [int(p) for p in (ids or [])]
711
+ if not pids:
712
+ return None
713
+ return {
714
+ "id": STARRED_VIEW_ID,
715
+ "name": STARRED_VIEW_NAME,
716
+ "kind": "system",
717
+ "locked": True,
718
+ "note": "The records you starred on this database. Only you can see this list.",
719
+ "config": {"filters": [], "filterConj": "and", "sorts": [], "groupBy": None,
720
+ "colorBy": None, "rowHeightMode": "short", "order": [], "visible": [],
721
+ "widths": {}, "memberPids": pids},
722
+ }
723
+
724
+
725
+ def _database_or_refuse(session, key):
726
+ """The workspace bucket for a database this caller may OPEN, or a refusal.
727
+
728
+ ⚠ THE SPLIT IS `routes_nav.nav_schema`'s, COPIED IN SHAPE RATHER THAN INVENTED: a `ut_*`
729
+ database is walled by `user_tables.may_open` (its creator or an admin or a grantee), and a
730
+ built-in module by `session.require`, which would 403 every `ut_` key because a user table is
731
+ deliberately not a module.
732
+ """
733
+ import core.view_templates as view_templates
734
+ ws_key = view_templates.workspace_key(key)
735
+ if not ws_key:
736
+ raise err(404, "unknown_database", "that database is not available")
737
+ if key.startswith("ut_"):
738
+ import core.user_tables as user_tables
739
+ if not user_tables.may_open(key, session.uname, session.admin, st=session.runtime):
740
+ raise err(403, "forbidden", "that database belongs to another user")
741
+ else:
742
+ session.require(key)
743
+ return ws_key
744
+
745
+
746
+ def _records_payload(session, key, ws_key):
747
+ try:
748
+ doc = session.runtime.get_projection(ws_key, drop=("overlays", "fields")) or {}
749
+ except Exception:
750
+ doc = {}
751
+ ids = _record_ids(doc, session.uname)
752
+ return {"database": key, "ids": ids, "count": len(ids), "view": records_view(ids)}
753
+
754
+
755
+ @router.get("/starred/records")
756
+ def starred_records(database: str = "", session: Session = Depends(require_session)):
757
+ """C5 β€” the record ids THIS caller starred on ONE database, plus the view to render.
758
+
759
+ ⚠ ONE DATABASE PER CALL, deliberately. Answering "every database" would be the N-bucket scan
760
+ the view half already pays for, on a route a grid calls every time it opens.
761
+ """
762
+ key = _clean_id(database)
763
+ if not key:
764
+ raise err(400, "bad_request", "no database was named")
765
+ return _records_payload(session, key, _database_or_refuse(session, key))
766
+
767
+
768
+ @router.post("/starred/records")
769
+ def set_starred_record(body: dict = Body(default=None),
770
+ session: Session = Depends(require_session)):
771
+ """C5 β€” star or unstar ONE record. Body `{"database": key, "id": str, "on": bool}`.
772
+
773
+ β›” PER USER, IN THE PER-USERNAME STRATUM, AND IT MUST NEVER FALL BACK TO A TENANT-WIDE LIST
774
+ (R6). One person's stars becoming everyone's is the failure mode a shared column has here β€” a
775
+ custom field on these grids is written through a per-username stratum anyway, so a "shared"
776
+ answer would be a deliberate widening, not a shortcut.
777
+ """
778
+ body = body if isinstance(body, dict) else {}
779
+ key = _clean_id(body.get("database"))
780
+ raw_id = _clean_id(body.get("id"))
781
+ if not key:
782
+ raise err(400, "bad_request", "no database was named")
783
+ if not raw_id.isdigit():
784
+ # Named rather than coerced: see `_record_ids` on why a non-numeric id cannot be stored.
785
+ raise err(400, "bad_request", "a record id is a number")
786
+ if "on" not in body or not isinstance(body.get("on"), bool):
787
+ raise err(400, "bad_request", "`on` must be true or false")
788
+ pid, on = int(raw_id), bool(body["on"])
789
+ ws_key = _database_or_refuse(session, key)
790
+ if not session.runtime.available():
791
+ raise err(503, "store_unavailable",
792
+ "the tenant store is unavailable. Nothing was saved.")
793
+ current = _records_payload(session, key, ws_key)["ids"]
794
+ if on and pid not in current and len(current) >= MAX_STARRED_RECORDS:
795
+ raise err(400, "starred_full",
796
+ f"you have starred the maximum of {MAX_STARRED_RECORDS} records on this "
797
+ f"database. Unstar one to make room")
798
+ uname = session.uname
799
+
800
+ def _up(data):
801
+ data = data if isinstance(data, dict) else {}
802
+ block = data.get(uname)
803
+ block = dict(block) if isinstance(block, dict) else {}
804
+ ids = _record_ids({uname: block}, uname)
805
+ if on:
806
+ if pid not in ids:
807
+ ids.append(pid)
808
+ else:
809
+ ids = [i for i in ids if i != pid]
810
+ if ids:
811
+ block[_RECORDS_KEY] = ids
812
+ else:
813
+ block.pop(_RECORDS_KEY, None)
814
+ # ⚠ THE STRATUM IS KEPT WHEN IT STILL HOLDS ANYTHING ELSE. Popping a username whose views
815
+ # and overlays live in the same block would delete somebody's whole workspace to clear one
816
+ # star β€” which is why this writes `block` back rather than replacing it.
817
+ if block:
818
+ data[uname] = block
819
+ else:
820
+ data.pop(uname, None)
821
+ return data
822
+
823
+ try:
824
+ session.runtime.update(ws_key, _up)
825
+ except Exception:
826
+ raise err(503, "store_unavailable",
827
+ "the star was not saved: the store refused the write.")
828
+ return _records_payload(session, key, ws_key)
api/routes_statements.py CHANGED
@@ -31,6 +31,10 @@ from fastapi import APIRouter, Body, Depends
31
  from deps import Session, err
32
  from routes_admin import admin_gate
33
 
 
 
 
 
34
  router = APIRouter(prefix="/api/v1")
35
 
36
  #: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the
@@ -48,8 +52,17 @@ def _royal_only(session: Session) -> Session:
48
  """β›” See the module docstring, guardrail 3. The send client is env-credentialed, so it is
49
  tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company.
50
 
51
- Keyed on the runtime, never on a request field: a tenant is a property of the SESSION."""
52
- if getattr(session.runtime, "key", None) != "royal-imports":
 
 
 
 
 
 
 
 
 
53
  raise err(404, "not_found", "statements are not configured for this workspace")
54
  return session
55
 
@@ -91,7 +104,9 @@ def statements(refresh: int = 0, session: Session = Depends(_gate)):
91
  "replyTo": cs.REPLY_TO, "company": cs.COMPANY},
92
  "templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO,
93
  "footer": cs.DEFAULT_FOOTER},
94
- "tiers": ["A-Urgent", "B-Active", "C-Light", "Monitor"],
 
 
95
  }
96
 
97
 
@@ -166,3 +181,89 @@ def send(body: dict = Body(default=None), session: Session = Depends(_gate)):
166
  failed.append({"customer": name, "error": str(e)[:200]})
167
  return {"sent": sent, "failed": failed, "skipped": skipped,
168
  "safeMode": bool(cs.SAFE_MODE), "test": bool(override)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  from deps import Session, err
32
  from routes_admin import admin_gate
33
 
34
+ # W35-T35 (C8): the tenant predicate, held once. See `_royal_only` for why the import runs this
35
+ # way round. `main.py` already imports both modules, so this adds no load.
36
+ import automation_engine as engine
37
+
38
  router = APIRouter(prefix="/api/v1")
39
 
40
  #: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the
 
52
  """β›” See the module docstring, guardrail 3. The send client is env-credentialed, so it is
53
  tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company.
54
 
55
+ Keyed on the runtime, never on a request field: a tenant is a property of the SESSION.
56
+
57
+ ⭐⭐ WAVE 35 Β· T35 / CONTRACT C8 β€” THE TEST ITSELF NOW LIVES IN ONE PLACE. Statements became an
58
+ agent step this wave, so the automation engine has to answer the same question at two more
59
+ doors (which tenants see the action, which tenants may store it). C8's words are "the predicate
60
+ is imported, never re-expressed", and this is the direction that import can run: the engine
61
+ imports no route module and no FastAPI, so reaching `_royal_only` FROM it would drag `deps` and
62
+ `routes_admin` into a route-free module. The engine holds the boolean; this holds the refusal.
63
+ ⚠ WHAT STAYS HERE IS THE HTTP SHAPE, and that is deliberate β€” a 404 rather than a 403, so the
64
+ surface is invisible rather than forbidden. The engine must not know about status codes."""
65
+ if not engine.is_statement_tenant(session.runtime):
66
  raise err(404, "not_found", "statements are not configured for this workspace")
67
  return session
68
 
 
104
  "replyTo": cs.REPLY_TO, "company": cs.COMPANY},
105
  "templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO,
106
  "footer": cs.DEFAULT_FOOTER},
107
+ # W35-T35: ONE literal, read from the engine, so the sender's worklist and a statements
108
+ # agent's tier filter cannot come to mean different things.
109
+ "tiers": list(engine.STATEMENT_TIERS),
110
  }
111
 
112
 
 
181
  failed.append({"customer": name, "error": str(e)[:200]})
182
  return {"sent": sent, "failed": failed, "skipped": skipped,
183
  "safeMode": bool(cs.SAFE_MODE), "test": bool(override)}
184
+
185
+
186
+ # ---------------------------------------------------------------------------------------------
187
+ # WAVE 35 Β· T36 / CONTRACT C8 / OWNER RULING R10 β€” THE AGENT'S PARKED BATCH.
188
+ #
189
+ # β›”β›” THE AGENT ASSEMBLES; A PERSON CLICKS SEND. `automation_engine.run_statements` renders a batch
190
+ # and parks it on the automation definition, and it imports no mail path at all. THIS is the only
191
+ # door that releases one, and it is deliberately built out of the parts already here:
192
+ # Β· `_gate` β€” `admin_gate` THEN `_royal_only`, byte-for-byte the dependency the other three
193
+ # endpoints use. Not a copy of the checks: the same object.
194
+ # Β· `cs.queue_statement` β€” the same call `send()` above makes, so SAFE_MODE's allow-list refusal
195
+ # happens in the DATA LAYER, where no route, payload or UI can reach around it.
196
+ # β‡’ Nothing about the guardrails is re-expressed here, which is what makes "unchanged" checkable.
197
+ #
198
+ # ⚠ WHY THE BATCH IS RE-RENDERED FROM THE LIVE WORKLIST RATHER THAN SENT AS STORED. The parked
199
+ # `html` is what a person APPROVED and is what the review screen shows; but a balance can move
200
+ # between the parking and the click, and mailing a figure we know to be stale is worse than mailing
201
+ # a fresh one. So the parked batch decides WHO and WITH WHAT WORDS, and the live row decides the
202
+ # NUMBERS β€” the same split `preview` already makes. A customer who has left the worklist entirely
203
+ # (they paid) is reported as skipped rather than invoiced.
204
+
205
+ @router.get("/admin/statements/agent/{auto_id}")
206
+ def agent_batch(auto_id: str, session: Session = Depends(_gate)):
207
+ """What is parked and waiting for a click, for ONE agent. `null` when nothing is."""
208
+ defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {}
209
+ pending = defn.get("pendingStatements")
210
+ if not isinstance(pending, dict):
211
+ return {"pending": None}
212
+ # ⚠ The rendered `html` is NOT returned in the list β€” a 200-statement batch of rendered mail is
213
+ # megabytes, and the review screen needs the count and the names to decide. `preview` already
214
+ # renders ONE on demand.
215
+ return {"pending": {
216
+ "ts": pending.get("ts"), "count": int(pending.get("count") or 0),
217
+ "notes": list(pending.get("notes") or []),
218
+ "items": [{k: v for k, v in it.items() if k != "html"}
219
+ for it in (pending.get("items") or [])],
220
+ "safeMode": bool(_cs().SAFE_MODE)}}
221
+
222
+
223
+ @router.post("/admin/statements/agent/{auto_id}/send")
224
+ def agent_send(auto_id: str, body: dict = Body(default=None),
225
+ session: Session = Depends(_gate)):
226
+ """Release a parked batch. THE CLICK R10 REQUIRES β€” nothing else in the system calls this.
227
+
228
+ β›” IT REFUSES AN EMPTY OR MISSING BATCH rather than answering 200 with nothing sent: a send
229
+ that reports success and mails nobody is the `view_upsert` failure mode (200 OK, zero writes)
230
+ arriving on the one route in this system that talks to a mail server.
231
+ """
232
+ cs = _cs()
233
+ defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {}
234
+ pending = defn.get("pendingStatements")
235
+ items = list((pending or {}).get("items") or []) if isinstance(pending, dict) else []
236
+ if not items:
237
+ raise err(404, "no_batch", "there is nothing parked for this agent to send")
238
+ # ⚠ An explicit subset is allowed (a person unticking a customer on the review screen) but it
239
+ # may only NARROW the parked batch. A name that was never parked cannot be introduced by the
240
+ # payload, or the review stops being what authorised the send.
241
+ want = {str(n) for n in (body or {}).get("customers") or []}
242
+ if want:
243
+ items = [it for it in items if str(it.get("customer")) in want]
244
+ if not items:
245
+ raise err(400, "bad_request", "none of those customers are in the parked batch")
246
+ rows, _ = _rows()
247
+ sent, failed, skipped = [], [], []
248
+ for it in items:
249
+ name = str(it.get("customer") or "")
250
+ row = _find(rows, name)
251
+ if row is None:
252
+ skipped.append({"customer": name,
253
+ "reason": "no longer on the collection list, so nothing is owed"})
254
+ continue
255
+ try:
256
+ # β›” THE SAME DATA-LAYER CALL `send()` MAKES. SafeModeBlocked is raised INSIDE
257
+ # `queue_statement`, so the guardrail cannot be argued with from here.
258
+ mid = cs.queue_statement(cs.Odoo(), row,
259
+ str(it.get("subject") or "") or cs.DEFAULT_SUBJECT,
260
+ cs.DEFAULT_INTRO, cs.DEFAULT_FOOTER)
261
+ sent.append({"customer": name, "to": row.get("Email"), "mailId": mid})
262
+ except Exception as e: # noqa: BLE001
263
+ failed.append({"customer": name, "error": str(e)[:200]})
264
+ # ⚠ CLEARED ONLY WHEN NOTHING IS LEFT TO RETRY. A batch dropped while some of it failed would
265
+ # lose the list of who still needs a statement, and nobody would know to look.
266
+ if sent and not failed:
267
+ engine.clear_statements(session.runtime, auto_id)
268
+ return {"sent": sent, "failed": failed, "skipped": skipped,
269
+ "safeMode": bool(cs.SAFE_MODE), "cleared": bool(sent and not failed)}
api/routes_tables.py CHANGED
@@ -1406,7 +1406,11 @@ def _fire_on_change(table_key, pid, changed, session):
1406
  defn = _ut().get(table_key, st=session.runtime) or {}
1407
  wanted = _ae.on_change_fields(defn, changed.keys())
1408
  for field in wanted:
1409
- _ae.run_field(table_key, field["key"], st=session.runtime, rows=[str(pid)])
 
 
 
 
1410
  except Exception: # noqa: BLE001
1411
  pass
1412
 
@@ -1450,7 +1454,9 @@ def enrich_field(table_key: str, fkey: str, body: dict = Body(default=None),
1450
  report = _ae.run_field(table_key, fkey, st=session.runtime, rows=rows,
1451
  policy=str((body or {}).get("scope") or "") or None,
1452
  # A named row set through THIS door is a person asking.
1453
- manual=rows is not None)
 
 
1454
  if report.get("problem"):
1455
  # A run that could not start at all is not a 200: nothing was attempted, nothing was
1456
  # spent, and the reason is actionable (no provider configured, or the wrong column).
 
1406
  defn = _ut().get(table_key, st=session.runtime) or {}
1407
  wanted = _ae.on_change_fields(defn, changed.keys())
1408
  for field in wanted:
1409
+ # ⭐ W35-T41 / C7 β€” `user` is the usage ledger's attribution. An on-change run is still
1410
+ # somebody's edit spending somebody's tokens, so it is booked against the person who
1411
+ # typed rather than left unattributed.
1412
+ _ae.run_field(table_key, field["key"], st=session.runtime, rows=[str(pid)],
1413
+ user=session.uname)
1414
  except Exception: # noqa: BLE001
1415
  pass
1416
 
 
1454
  report = _ae.run_field(table_key, fkey, st=session.runtime, rows=rows,
1455
  policy=str((body or {}).get("scope") or "") or None,
1456
  # A named row set through THIS door is a person asking.
1457
+ manual=rows is not None,
1458
+ # ⭐ W35-T41 / C7 β€” the usage ledger's attribution.
1459
+ user=session.uname)
1460
  if report.get("problem"):
1461
  # A run that could not start at all is not a 200: nothing was attempted, nothing was
1462
  # spent, and the reason is actionable (no provider configured, or the wrong column).
api/routes_usage.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_usage.py β€” WAVE 35 (ruling R9, contract C7): `GET /api/v1/usage`, the AI meter.
2
+
3
+ Owner item 13's second door. R9: the dashboard REPORTS weekly usage per surface against an
4
+ allowance and does **not** cut anyone off this wave; over the allowance the product still works and
5
+ the bar is red.
6
+
7
+ β›” THIS ROUTE ENFORCES NOTHING AND MUST NOT START. It is the only reader of `usage_ledger`, and the
8
+ ledger has no ceiling in it (see that module's header). A `403 over_allowance` here would turn a
9
+ reporting feature into a wall the owner explicitly did not ask for this wave.
10
+
11
+ ⚠ TWO SCOPES, AND THE WIDER ONE IS ADMIN-ONLY. `?scope=me` (the default) is the caller's own week;
12
+ `?scope=tenant` folds every account and is refused for a non-admin β€” how much AI another employee
13
+ used is not a fact this product hands out sideways.
14
+ """
15
+ from fastapi import APIRouter, Depends
16
+
17
+ import usage_ledger
18
+ from deps import Session, err, require_session
19
+
20
+ router = APIRouter(prefix="/api/v1")
21
+
22
+
23
+ @router.get("/usage")
24
+ def usage(scope: str = "me", session: Session = Depends(require_session)):
25
+ """`{week, resets, allowance, total, over, calls, unmeasured, scope, surfaces: [...]}`.
26
+
27
+ Every one of the four surfaces is present whether or not it was used β€” see
28
+ `usage_ledger.usage`'s note on why a missing row and a quiet week must not look the same.
29
+ """
30
+ wanted = str(scope or "me").strip().lower()
31
+ if wanted not in ("me", "tenant"):
32
+ raise err(400, "bad_request", "scope must be me or tenant")
33
+ if wanted == "tenant" and not session.admin:
34
+ raise err(403, "forbidden",
35
+ "only an administrator can see the whole workspace's AI usage")
36
+ out = usage_ledger.usage(session.runtime, session.uname,
37
+ tenant_wide=(wanted == "tenant"))
38
+ if session.admin and usage_ledger.UNATTRIBUTED["calls"]:
39
+ # β›” THE METER REPORTS ITS OWN BLIND SPOT (R6's second sentence). A call `record()` could not
40
+ # attribute β€” no store handle reached it, or the store refused β€” is absent from every number
41
+ # above, and an operator has to be able to see that rather than infer it.
42
+ # ⚠ ADMIN ONLY, and labelled as a PROCESS figure rather than a tenant one: the counter is a
43
+ # module global in this container, so it spans every tenant this process has served and is
44
+ # not a fact about the workspace being asked about.
45
+ out["unattributed"] = {
46
+ "calls": usage_ledger.UNATTRIBUTED["calls"],
47
+ "surfaces": dict(usage_ledger.UNATTRIBUTED["surfaces"]),
48
+ "note": ("these AI calls reached no ledger target in this container and are missing "
49
+ "from every figure above. It is a wiring gap, not usage"),
50
+ }
51
+ return out
api/usage_ledger.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """usage_ledger.py β€” WAVE 35 (ruling R9, contract C7): ONE METER FOR EVERY AI SURFACE.
2
+
3
+ R9: *"ONE meter for every AI surface. The dashboard REPORTS weekly usage against an allowance and
4
+ does NOT cut anyone off this wave; over the allowance it still works, shown red."*
5
+
6
+ β›” SO THIS FILE MEASURES AND NEVER ENFORCES. There is no ceiling here, no refusal, and no caller of
7
+ this module may branch on its answer. The one limit the product does enforce is per COLUMN and lives
8
+ where it was built (`ai_enrich.ceiling_report`); this is the tenant-wide REPORT, and mixing the two
9
+ would make an over-allowance week silently stop somebody's enrichment run.
10
+
11
+ **THE FOUR SURFACES.** `assistant` (a person asking the Assistant a question) Β· `field_agent` (an AI
12
+ enrichment column filling cells) Β· `ai_review` (a model deciding a review stage) Β· `automation_draft`
13
+ (a sentence becoming a draft flow). They are the four LLM entry points the product has, and the gate
14
+ in `verify_api.py` DERIVES them from the code rather than reading this list, so a fifth entry point
15
+ is caught by the gate and not by somebody remembering to edit a tuple.
16
+
17
+ β›”β›” **`record()` MUST BE HANDED ITS TARGET, AND THAT IS A MEASUREMENT, NOT A STYLE CHOICE.** The
18
+ obvious design is to bind `(runtime, username)` into a `contextvars.ContextVar` inside
19
+ `deps.require_session` β€” every session-authed request passes through it β€” so `record()` could take
20
+ C7's five arguments and resolve the rest itself. **It does not work here and it fails SILENTLY.**
21
+ Measured 2026-08-17 with a FastAPI `TestClient`: a `ContextVar` set inside a sync dependency reads
22
+ back `None` in the handler *and in every function the handler calls*, on the SAME OS thread (17820
23
+ both sides) β€” anyio copies the context per callable, so a `.set()` in the dependency's copy is
24
+ invisible to the endpoint's. An async endpoint reads `None` too. A ledger built that way would have
25
+ recorded nothing, answered no error, and shown a dashboard of zeros that looks exactly like a quiet
26
+ week ([[flag-shipped-without-its-writer]]).
27
+
28
+ ⚠ THEREFORE `st` AND `user` ARE KEYWORD ARGUMENTS AFTER C7'S FIVE, and a call that omits them is
29
+ COUNTED as unattributed and REPORTED rather than dropped β€” R6's second sentence applied to the meter
30
+ itself. A surface whose caller cannot reach a store handle is a gap somebody has to close; a meter
31
+ that hides the gap is worse than one that admits it.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import time
37
+
38
+ #: The store bucket. Small by construction β€” counters only, never a per-call log: a log would grow
39
+ #: without bound on a route that runs per row, and nothing in R9 asks a question a log answers.
40
+ STORE_KEY = "ai_usage"
41
+
42
+ #: The four AI surfaces (C7). ⚠ A surface this list does not name is REFUSED rather than stored under
43
+ #: whatever string arrived, because a typo'd surface is a meter that reads low forever with a second
44
+ #: row nobody looks at.
45
+ SURFACES = ("assistant", "field_agent", "ai_review", "automation_draft")
46
+
47
+ #: The weekly allowance the dashboard measures against (R9). β›” IT IS A REPORTING LINE, NOT A GATE.
48
+ #: Env-tunable so a deployment can set a real number without a release.
49
+ WEEKLY_ALLOWANCE = int(os.environ.get("AIOS_AI_WEEKLY_TOKENS") or 1_000_000)
50
+
51
+ #: How many weeks stay in the bucket. A meter is about this week and the trend behind it; keeping
52
+ #: every week forever turns a counter document into an append-only log by another route.
53
+ MAX_WEEKS = 8
54
+
55
+ #: Calls this PROCESS could not attribute to a store and a user β€” see the header. Reported to an
56
+ #: admin by `GET /usage`, never silently zero.
57
+ UNATTRIBUTED = {"calls": 0, "surfaces": {}}
58
+
59
+
60
+ def week_of(when=None):
61
+ """The ISO week this instant belongs to, as `"2026-W33"`.
62
+
63
+ ⚠ UTC, EXPLICITLY, and never a naive local stamp. `routes_nav` records the same correction for
64
+ its own stamps: a naive local time written on a UTC host and parsed by a non-UTC browser misfiles
65
+ into the wrong bucket with nothing to go red. `%G`/`%V` are the ISO year and ISO week, which is
66
+ the only pair that agrees with itself across a year boundary (`%Y-%W` does not).
67
+ """
68
+ return time.strftime("%G-W%V", time.gmtime(when if when is not None else time.time()))
69
+
70
+
71
+ def resets_at(when=None):
72
+ """The UTC date the CURRENT week's counters roll over, as `"YYYY-MM-DD"`.
73
+
74
+ R9 asks the page to state when the allowance resets, so the server answers it β€” a client that
75
+ computed "next Monday" itself would be a second implementation of the week boundary, and the two
76
+ would disagree for anybody whose clock is not UTC ([[one-question-two-normalizers]]).
77
+ """
78
+ now = when if when is not None else time.time()
79
+ tm = time.gmtime(now)
80
+ # `tm_wday` is 0 for Monday, so days-until-next-Monday is 7 minus however far in we are.
81
+ return time.strftime("%Y-%m-%d", time.gmtime(now + (7 - tm.tm_wday) * 86400))
82
+
83
+
84
+ def tokens_from(body):
85
+ """`(tokens_in, tokens_out)` for one provider response. Either may be None.
86
+
87
+ β›” `None` MEANS THE PROVIDER DID NOT SAY, AND IT IS NOT ZERO. A ledger that books an unmeasured
88
+ call at zero reports a cheaper week than happened, which is exactly the cost-surprise complaint
89
+ R13 cited when the first token accounting was built. `ai_enrich._usage_tokens` has said this
90
+ since wave 34; this is that reader SPLIT, because C7 asks for input and output separately.
91
+
92
+ ⚠ THE TOTAL-ONLY SHAPE IS REAL AND IS NOT A SPLIT. Some providers answer only `total_tokens`.
93
+ That is reported by `record(total=...)` rather than by inventing a split, because halving a
94
+ total would put two numbers on a dashboard that were never measured.
95
+ """
96
+ usage = (body or {}).get("usage")
97
+ if not isinstance(usage, dict):
98
+ return (None, None)
99
+
100
+ def _int(*keys):
101
+ for key in keys:
102
+ got = usage.get(key)
103
+ if isinstance(got, int) and not isinstance(got, bool):
104
+ return got
105
+ return None
106
+
107
+ return (_int("prompt_tokens", "input_tokens"),
108
+ _int("completion_tokens", "output_tokens"))
109
+
110
+
111
+ def total_from(body):
112
+ """One call's TOTAL tokens, or None β€” the shape a caller with no use for the split wants.
113
+
114
+ ⚠ Prefers the provider's own `total_tokens` over a sum, because a provider that reports both may
115
+ count cached or reasoning tokens in the total and in neither half.
116
+ """
117
+ usage = (body or {}).get("usage")
118
+ if isinstance(usage, dict):
119
+ for key in ("total_tokens", "totalTokens"):
120
+ got = usage.get(key)
121
+ if isinstance(got, int) and not isinstance(got, bool):
122
+ return got
123
+ ins, outs = tokens_from(body)
124
+ if ins is None and outs is None:
125
+ return None
126
+ return (ins or 0) + (outs or 0)
127
+
128
+
129
+ def _int_or_none(value):
130
+ if isinstance(value, int) and not isinstance(value, bool):
131
+ return max(0, value)
132
+ return None
133
+
134
+
135
+ def record(surface, provider, model, tokens_in=None, tokens_out=None, *,
136
+ total=None, calls=1, st=None, user=""):
137
+ """Book ONE AI call. Returns True when the line landed durably, False when it could not.
138
+
139
+ C7's signature is the five positional parameters; everything after `*` is this module's own
140
+ extension and each one has a reason:
141
+
142
+ Β· `total` β€” for a caller that knows the total and not the split (`ai_enrich.run_field`
143
+ accumulates a per-run total through an `_ask` contract a gate injects against, and changing
144
+ that contract to carry a split would break the injected shape for no gain here).
145
+ Β· `calls` β€” so a RUN over many rows books one line with its real call count instead of one
146
+ store write per row.
147
+ Β· `st` / `user` β€” the target. Required in practice; see this module's header for the measured
148
+ reason a contextvar cannot supply them.
149
+
150
+ β›” IT NEVER RAISES. A meter that can break the feature it measures is worse than no meter: a
151
+ store blip during an enrichment run would abort the run and lose the cells.
152
+ β›” AND IT NEVER SILENTLY SUCCEEDS. A call it cannot attribute increments `UNATTRIBUTED`, which
153
+ `GET /usage` shows an admin.
154
+ """
155
+ key = str(surface or "").strip()
156
+ if key not in SURFACES:
157
+ # Refused rather than stored: a surface nobody named is a row nobody reads, and the meter
158
+ # would read low forever with no sign of why.
159
+ return False
160
+ ins, outs = _int_or_none(tokens_in), _int_or_none(tokens_out)
161
+ tot = _int_or_none(total)
162
+ if tot is None and (ins is not None or outs is not None):
163
+ tot = (ins or 0) + (outs or 0)
164
+ n = max(0, int(calls) if isinstance(calls, int) and not isinstance(calls, bool) else 1)
165
+ if not n:
166
+ return False
167
+ uname = str(user or "").strip().lower()
168
+ if st is None:
169
+ UNATTRIBUTED["calls"] += n
170
+ UNATTRIBUTED["surfaces"][key] = UNATTRIBUTED["surfaces"].get(key, 0) + n
171
+ return False
172
+ week = week_of()
173
+ prov, mdl = str(provider or "")[:40], str(model or "")[:80]
174
+
175
+ def _up(data):
176
+ data = data if isinstance(data, dict) else {}
177
+ weeks = {w: v for w, v in data.items() if isinstance(v, dict)}
178
+ row = ((weeks.setdefault(week, {})
179
+ .setdefault(uname or "-", {}))
180
+ .setdefault(key, {}))
181
+ row["calls"] = int(row.get("calls") or 0) + n
182
+ if tot is None:
183
+ # β›” "the call happened, the tokens are unknown" IS A STATE, and it has to be
184
+ # representable or the meter quietly under-reports. The dashboard shows it as a count of
185
+ # unmeasured calls beside the token total.
186
+ row["unmeasured"] = int(row.get("unmeasured") or 0) + n
187
+ else:
188
+ row["tokens"] = int(row.get("tokens") or 0) + tot
189
+ if ins is not None:
190
+ row["tokens_in"] = int(row.get("tokens_in") or 0) + ins
191
+ if outs is not None:
192
+ row["tokens_out"] = int(row.get("tokens_out") or 0) + outs
193
+ if prov:
194
+ row["provider"] = prov
195
+ if mdl:
196
+ row["model"] = mdl
197
+ # Oldest weeks first to go. Sorting ISO week strings sorts chronologically by construction,
198
+ # which is the second reason `%G-W%V` is the format rather than a prettier one.
199
+ for stale in sorted(weeks)[:-MAX_WEEKS]:
200
+ weeks.pop(stale, None)
201
+ return weeks
202
+
203
+ try:
204
+ # ⚠ `flush='async'` β€” the coalescing mode ([[store-async-flush]]). This is the hot path of
205
+ # every AI surface in the product (an enrichment run books one line per RUN, but the
206
+ # assistant books one per question), and a blocking hub upload per call would put a network
207
+ # round trip inside the answer a person is waiting for. The mutation applies to the
208
+ # in-process cache immediately, so `GET /usage` reads its own writes.
209
+ st.update(STORE_KEY, _up, flush="async")
210
+ return True
211
+ except Exception: # noqa: BLE001
212
+ UNATTRIBUTED["calls"] += n
213
+ UNATTRIBUTED["surfaces"][key] = UNATTRIBUTED["surfaces"].get(key, 0) + n
214
+ return False
215
+
216
+
217
+ def _blank_row():
218
+ return {"calls": 0, "tokens": 0, "tokens_in": 0, "tokens_out": 0, "unmeasured": 0}
219
+
220
+
221
+ def _fold(rows):
222
+ out = _blank_row()
223
+ for row in rows:
224
+ if not isinstance(row, dict):
225
+ continue
226
+ for field in ("calls", "tokens", "tokens_in", "tokens_out", "unmeasured"):
227
+ got = row.get(field)
228
+ if isinstance(got, int) and not isinstance(got, bool):
229
+ out[field] += max(0, got)
230
+ return out
231
+
232
+
233
+ def usage(st, user="", *, tenant_wide=False, week=None):
234
+ """This week's meter. `{week, resets, allowance, total, over, surfaces: [...], ...}`.
235
+
236
+ `tenant_wide=True` folds every account in the tenant β€” the admin view. Otherwise only `user`.
237
+
238
+ β›” EVERY SURFACE IS PRESENT WHETHER OR NOT IT WAS USED, at zero. A surface a consumer has to test
239
+ for is a surface a consumer forgets to test for, and R9 says ONE meter for every AI surface: a
240
+ missing row and a quiet week would render identically, and only one of them is true.
241
+ β›” NOTHING HERE IS ESTIMATED. A surface with no ledger line reports zero, never a guess
242
+ ([[no-unverifiable-aggregates]]).
243
+ """
244
+ wk = str(week or week_of())
245
+ try:
246
+ stored = st.get(STORE_KEY) or {}
247
+ except Exception: # noqa: BLE001
248
+ stored = {}
249
+ by_week = stored.get(wk) if isinstance(stored, dict) else {}
250
+ by_week = by_week if isinstance(by_week, dict) else {}
251
+ uname = str(user or "").strip().lower()
252
+ if tenant_wide:
253
+ buckets = [b for b in by_week.values() if isinstance(b, dict)]
254
+ else:
255
+ buckets = [by_week.get(uname or "-")] if isinstance(by_week.get(uname or "-"), dict) else []
256
+ surfaces = []
257
+ for key in SURFACES:
258
+ folded = _fold([b.get(key) for b in buckets])
259
+ folded["surface"] = key
260
+ surfaces.append(folded)
261
+ total = sum(s["tokens"] for s in surfaces)
262
+ unmeasured = sum(s["unmeasured"] for s in surfaces)
263
+ out = {
264
+ "week": wk,
265
+ "resets": resets_at(),
266
+ "allowance": WEEKLY_ALLOWANCE,
267
+ "total": total,
268
+ # R9: over the allowance the product STILL WORKS and the bar goes red. This flag is the
269
+ # colour, never a wall β€” nothing in the API reads it.
270
+ "over": bool(WEEKLY_ALLOWANCE and total > WEEKLY_ALLOWANCE),
271
+ "calls": sum(s["calls"] for s in surfaces),
272
+ "unmeasured": unmeasured,
273
+ "scope": "tenant" if tenant_wide else "me",
274
+ "surfaces": surfaces,
275
+ }
276
+ if unmeasured:
277
+ # R6's second sentence: the number is short by an unknown amount and the payload says so
278
+ # rather than presenting a confident total.
279
+ out["note"] = (f"{unmeasured} call(s) this week did not report a token count, so the total "
280
+ f"above is a floor. The provider decides whether to report usage")
281
+ return out
platform/core/keychain.py CHANGED
@@ -166,6 +166,52 @@ def read_fields(rt, entry_id):
166
  return None
167
 
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  def odoo_creds(rt):
170
  """R3's resolver seam: the FIRST odoo-type entry's fields, else None (the caller falls
171
  back to the environment). Deterministic order = insertion-id sort, so 'first' is stable."""
 
166
  return None
167
 
168
 
169
+ def first_entry_of_type(rt, etype):
170
+ """The metadata row of the FIRST entry of `etype`, or None. Never decrypts anything.
171
+
172
+ ⭐ W35-T45 β€” the same "first, by insertion-id sort" rule `_first_creds` resolves on, exposed as
173
+ an IDENTITY question rather than a credential one. `ensure_entry_of_type` needs to know whether
174
+ an entry exists WITHOUT unlocking it, and `list_entries` + a hand-rolled `next()` at each caller
175
+ is how two places come to disagree about which entry is "the" one.
176
+ """
177
+ return next((e for e in list_entries(rt) if e.get('type') == str(etype or '')), None)
178
+
179
+
180
+ def ensure_entry_of_type(rt, etype, label, fields, username):
181
+ """Create ONE entry of `etype` only if the tenant has none. `(row, why)`; `row` is None on skip.
182
+
183
+ ⭐ W35-T45 / R11 β€” the primitive tenant #0's env-to-keychain migration is built from, and the
184
+ reason it lives here is that only this module knows what "already has one" means: `odoo_creds`
185
+ resolves to the FIRST entry of a type, so a second one would be stored, invisible in every
186
+ resolver, and impossible to tell from the one that serves.
187
+
188
+ β›” IDEMPOTENT BY TYPE, NOT BY A FLAG. A migration that keys idempotency on a marker it writes
189
+ itself is one lost marker away from running twice ([[a-migration-that-runs-on-the-next-write]]);
190
+ keying it on the thing it would create cannot double-apply, whatever else fails.
191
+
192
+ β›” AND IT REFUSES ON A LOCKED KEYCHAIN RATHER THAN STORING A SECRET IN THE CLEAR. `add_entry`
193
+ would raise `KeychainLocked`; this answers with a REASON instead, because "there is no Fernet key
194
+ on this deployment" is an operator fact to report, not an exception to propagate out of a boot
195
+ thread where nobody reads it.
196
+
197
+ ⚠ `why` IS RETURNED EVEN ON SUCCESS-BY-SKIP. "Already migrated" and "could not migrate" are
198
+ different operator actions and a bare None cannot tell them apart.
199
+ """
200
+ etype = str(etype or '').strip().lower()
201
+ if etype not in ENTRY_TYPES:
202
+ return None, f'{etype!r} is not a keychain entry type'
203
+ existing = first_entry_of_type(rt, etype)
204
+ if existing:
205
+ return None, f'this tenant already has a {etype} entry ({existing["id"]})'
206
+ if not unlocked():
207
+ return None, ('this deployment has no keychain key, and a credential must never be stored '
208
+ 'in the clear')
209
+ try:
210
+ return add_entry(rt, label, etype, fields, username), ''
211
+ except Exception as exc: # noqa: BLE001
212
+ return None, f'{type(exc).__name__}: {exc}'
213
+
214
+
215
  def odoo_creds(rt):
216
  """R3's resolver seam: the FIRST odoo-type entry's fields, else None (the caller falls
217
  back to the environment). Deterministic order = insertion-id sort, so 'first' is stable."""
web/src/account/FeedbackPage.tsx ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // account/FeedbackPage.tsx β€” WAVE 35 Β· W35-T18 (owner item 13, ruling R8, C6).
3
+ //
4
+ // One composer, one category, one send. R8 puts every submission on the
5
+ // platform-wide operator plane: no tenant sees another's, and the submitting
6
+ // tenant cannot edit or delete what it sent β€” so there is deliberately no "my
7
+ // feedback" list here, and the 201 IS the receipt.
8
+ //
9
+ // β›” THE SHAPE IS THE ASSISTANT'S CENTRED COLUMN, READ AND NOT IMPORTED.
10
+ // `assistant/assistant.css` belongs to another session this wave, so the anatomy
11
+ // is borrowed (a measure-bounded column; a rounded field with the control row
12
+ // inside it; a round send button that goes dark when there is something to send)
13
+ // while every rule here is `.acct-*` in this module's own stylesheet. Same system
14
+ // to the eye, no shared file to collide over.
15
+ //
16
+ // β›” FOUR STATES, AND THE FIRST TWO ARE THE ONES THAT GET SKIPPED. The category
17
+ // list is the SERVER'S (B-1/E-9) β€” there is no local copy to fall back to β€” so
18
+ // while it is in flight there is no form, and if it FAILS there is a sentence and
19
+ // a retry, never an empty dropdown and never an invented list. A page that offers
20
+ // a category the door refuses is worse than a page that says it cannot load.
21
+ //
22
+ // ⚠ THE SURFACE IS SPLIT FROM THE READ, and the reason is evidence rather than
23
+ // taste: `renderToStaticMarkup` never runs an effect, so a shot of the fetching
24
+ // component can only photograph its own spinner. With the whole state as ONE
25
+ // prop, every state this page has β€” loading, failed, ready, sending, sent,
26
+ // refused β€” can be rendered and looked at without a server, which is the only
27
+ // evidence a lane with no server can produce.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ import { useCallback, useEffect, useRef, useState } from "react";
31
+
32
+ import { loadFeedbackForm, sendFeedback } from "./accountApi";
33
+ import type { FeedbackForm } from "./accountApi";
34
+ import "./account.css";
35
+
36
+ /** The send arrow, in the same stroke vocabulary as the rest of the app's small glyphs. */
37
+ function SendIcon() {
38
+ return (
39
+ <svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" className="acct-send-icon">
40
+ <path d="M8 13.2V3.1" />
41
+ <path d="M3.8 7.3 8 3.1l4.2 4.2" />
42
+ </svg>
43
+ );
44
+ }
45
+
46
+ export type FeedbackLoad =
47
+ | { phase: "loading" }
48
+ | { phase: "ready"; form: FeedbackForm }
49
+ | { phase: "failed"; message: string };
50
+
51
+ /** Everything this page holds, in one value, so the surface below is a function of it. */
52
+ export interface FeedbackState {
53
+ load: FeedbackLoad;
54
+ category: string;
55
+ text: string;
56
+ sending: boolean;
57
+ /** The last send SUCCEEDED. Cleared the moment the person types again. */
58
+ sent: boolean;
59
+ /** The last send was REFUSED, and this is the server's own sentence. */
60
+ error: string;
61
+ }
62
+
63
+ export const EMPTY_FEEDBACK: FeedbackState = {
64
+ load: { phase: "loading" },
65
+ category: "",
66
+ text: "",
67
+ sending: false,
68
+ sent: false,
69
+ error: "",
70
+ };
71
+
72
+ export function FeedbackSurface({
73
+ state,
74
+ onText,
75
+ onCategory,
76
+ onSend,
77
+ onRetry,
78
+ }: {
79
+ state: FeedbackState;
80
+ onText: (text: string) => void;
81
+ onCategory: (key: string) => void;
82
+ onSend: () => void;
83
+ onRetry: () => void;
84
+ }) {
85
+ const { load, category, text, sending, sent, error } = state;
86
+ const form = load.phase === "ready" ? load.form : null;
87
+ const tooLong = !!form && form.maxChars > 0 && text.length > form.maxChars;
88
+ const canSend = !!form && !!category && text.trim() !== "" && !tooLong && !sending;
89
+
90
+ return (
91
+ <div className="acct-page">
92
+ <h1 className="acct-title">Feedback</h1>
93
+ <div className="acct-col">
94
+ {load.phase === "loading" ? (
95
+ <div className="acct-loading">
96
+ <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
97
+ </div>
98
+ ) : load.phase === "failed" ? (
99
+ // β›” NOT AN EMPTY FORM. The categories are the server's and there is no local copy, so
100
+ // a failed read means there is nothing to offer; drawing the composer anyway would
101
+ // invite somebody to write a paragraph into a control that cannot send it.
102
+ <div className="acct-failed">
103
+ <p className="acct-note">{load.message}</p>
104
+ <button type="button" className="acct-retry" onClick={onRetry}>
105
+ Try again
106
+ </button>
107
+ </div>
108
+ ) : (
109
+ <>
110
+ {sent ? (
111
+ // The confirmation. It sits ABOVE the composer rather than replacing it, because
112
+ // the next thought is often the same person's, and a page that has to be navigated
113
+ // back to is a page that collects less of what R8 exists to collect.
114
+ <p className="acct-ok" role="status">
115
+ Thanks. That went to the Loopable team.
116
+ </p>
117
+ ) : null}
118
+ {error ? (
119
+ <p className="acct-error" role="alert">
120
+ {error}
121
+ </p>
122
+ ) : null}
123
+
124
+ <div className="acct-field">
125
+ <textarea
126
+ className="acct-prompt"
127
+ rows={5}
128
+ value={text}
129
+ placeholder="What is working, and what is not?"
130
+ aria-label="Your feedback"
131
+ onChange={(e) => onText(e.currentTarget.value)}
132
+ />
133
+ <div className="acct-field-foot">
134
+ {/* β›” ONE dropdown, and its options are the SERVER'S list (B-1/E-9). A client
135
+ constant beside a server enum is two lists that drift, and the day they do
136
+ this control offers a value the door refuses. */}
137
+ <label className="acct-pick">
138
+ <span className="acct-pick-label">About</span>
139
+ <select
140
+ className="acct-select"
141
+ value={category}
142
+ aria-label="Category"
143
+ onChange={(e) => onCategory(e.currentTarget.value)}
144
+ >
145
+ {form?.categories.map((c) => (
146
+ <option key={c.key} value={c.key}>
147
+ {c.label}
148
+ </option>
149
+ ))}
150
+ </select>
151
+ </label>
152
+ {/* The count appears only once there is something to count, and turns red only
153
+ when the limit is actually passed. A counter sitting at 0 on an empty field
154
+ is chrome describing itself. */}
155
+ {text.length > 0 && form && form.maxChars > 0 ? (
156
+ <span className={"acct-count" + (tooLong ? " is-over" : "")}>
157
+ {text.length} / {form.maxChars}
158
+ </span>
159
+ ) : null}
160
+ <button
161
+ type="button"
162
+ className="acct-send"
163
+ disabled={!canSend}
164
+ aria-label="Send feedback"
165
+ title="Send feedback"
166
+ onClick={onSend}
167
+ >
168
+ {sending ? (
169
+ <span className="lp-spin" role="status" aria-label="Sending" />
170
+ ) : (
171
+ <SendIcon />
172
+ )}
173
+ </button>
174
+ </div>
175
+ </div>
176
+ </>
177
+ )}
178
+ </div>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ export default function FeedbackPage() {
184
+ const [state, setState] = useState<FeedbackState>(EMPTY_FEEDBACK);
185
+ const gen = useRef(0);
186
+
187
+ const read = useCallback(() => {
188
+ const mine = gen.current + 1;
189
+ gen.current = mine;
190
+ setState((s) => ({ ...s, load: { phase: "loading" }, error: "" }));
191
+ void loadFeedbackForm().then((r) => {
192
+ if (gen.current !== mine) return;
193
+ if (!r.ok) {
194
+ setState((s) => ({ ...s, load: { phase: "failed", message: r.message } }));
195
+ return;
196
+ }
197
+ setState((s) => ({
198
+ ...s,
199
+ load: { phase: "ready", form: r.value },
200
+ // The first category is selected rather than a "Choose one" placeholder: every category
201
+ // is a legitimate answer, so an unselected state would be a required field pretending to
202
+ // be a choice. ⚠ Only if the server sent one; an empty list selects nothing and the send
203
+ // refuses, which is honest rather than a submit that 400s.
204
+ category: r.value.categories[0]?.key ?? "",
205
+ }));
206
+ });
207
+ }, []);
208
+
209
+ useEffect(() => {
210
+ read();
211
+ return () => {
212
+ gen.current += 1;
213
+ };
214
+ }, [read]);
215
+
216
+ // ⚠ THE FETCH IS FIRED HERE, NEVER INSIDE A `setState` UPDATER. React may call an updater more
217
+ // than once for one dispatch (StrictMode does, deliberately), and a POST living in there would
218
+ // send the same feedback twice with nothing on screen saying so. The updater stays pure; the
219
+ // effectful call reads the state it already has.
220
+ const send = () => {
221
+ if (state.load.phase !== "ready" || state.sending) return;
222
+ const body = state.text.trim();
223
+ if (!state.category || body === "") return;
224
+ setState((s) => ({ ...s, sending: true, error: "" }));
225
+ void sendFeedback(state.category, body).then((r) => {
226
+ // β›” THE COMPOSER IS NOT CLEARED ON A REFUSAL. Clearing it would destroy what the person
227
+ // wrote and leave them holding a sentence about why it did not arrive, which is the worst
228
+ // possible pair. The text stays exactly where it is and the reason goes above it.
229
+ setState((cur) =>
230
+ r.ok
231
+ ? { ...cur, sending: false, sent: true, error: "", text: "" }
232
+ : { ...cur, sending: false, sent: false, error: r.message }
233
+ );
234
+ });
235
+ };
236
+
237
+ return (
238
+ <FeedbackSurface
239
+ state={state}
240
+ onText={(text) => setState((s) => ({ ...s, text, sent: false }))}
241
+ onCategory={(category) => setState((s) => ({ ...s, category }))}
242
+ onSend={send}
243
+ onRetry={read}
244
+ />
245
+ );
246
+ }
web/src/account/SubscriptionPage.tsx ADDED
@@ -0,0 +1,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
+ //
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
+ }
web/src/account/UsagePage.tsx ADDED
@@ -0,0 +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
+ }
web/src/account/account.css ADDED
@@ -0,0 +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
+ }
web/src/account/accountApi.ts ADDED
@@ -0,0 +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
+ }
web/src/apiContract.ts CHANGED
@@ -228,6 +228,24 @@ export interface QueryOpenDetail {
228
  */
229
  export const AUTOMATION_OPEN_EVENT = "aios:automation-open";
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  /** `detail` of {@link AUTOMATION_OPEN_EVENT}. `stageId` is advisory β€” a surface that does not
232
  * scroll to a stage simply selects the automation. */
233
  export interface AutomationOpenDetail {
 
228
  */
229
  export const AUTOMATION_OPEN_EVENT = "aios:automation-open";
230
 
231
+ /**
232
+ * ⭐⭐ WAVE 35 Β· T08 (owner item 11, wiring W6) β€” "the agent list CHANGED". Owner: *"When I delete
233
+ * an Agent, it doesn't sync to the Home at all … fix this for everything we display twice in Home."*
234
+ *
235
+ * β›” THE CAUSE IS AN OWNERSHIP SPLIT, NOT A MISSING REFETCH. The FRAME fetches the agent tiles Home
236
+ * draws, while `AutomationSurface` is "props-free by contract" and fetches its OWN list β€” so a
237
+ * delete inside the module updates the module and nothing tells the frame its copy is stale. Two
238
+ * readers of one server fact, and only one of them was told.
239
+ *
240
+ * ⚠ NO `detail`, DELIBERATELY. This says "your copy is stale", not "here is the new list": a
241
+ * payload would be a THIRD copy of the same fact, and the frame re-asking is one request against a
242
+ * route measured at ~360 ms. The emitter (D, `AutomationSurface`) must dispatch it for delete,
243
+ * rename AND create.
244
+ * ⚠ It is deliberately NOT a `navEpoch` bump: that would re-fetch `/nav`, a whole-document read of
245
+ * a 28.6 MB store (D-175/D-185), to refresh a list of agent tiles.
246
+ */
247
+ export const AGENTS_CHANGED = "aios:agents-changed";
248
+
249
  /** `detail` of {@link AUTOMATION_OPEN_EVENT}. `stageId` is advisory β€” a surface that does not
250
  * scroll to a stage simply selects the automation. */
251
  export interface AutomationOpenDetail {
web/src/assistant/AssistantPage.tsx CHANGED
@@ -675,6 +675,15 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
675
  <QueryWorkspace granted={granted} hostedRail refreshToken={queryRefresh}
676
  selectedId={activeQuery} />
677
  </Suspense>
 
 
 
 
 
 
 
 
 
678
  ) : (
679
  <div className="as-opening">
680
  <h1 className="as-opening-h">No Query views yet</h1>
 
675
  <QueryWorkspace granted={granted} hostedRail refreshToken={queryRefresh}
676
  selectedId={activeQuery} />
677
  </Suspense>
678
+ ) : !loaded ? (
679
+ /* β›” W35-T22, the SECOND state that paints something untrue for a frame. `queryList` is
680
+ empty until `GET /query` lands, so arriving here β€” a `#/query` link, a bookmark, a
681
+ reload β€” asserted "No Query views yet" to somebody who has twenty, then replaced it.
682
+ `loaded` is the flag the chat history one screen down already waits on; the two
683
+ empty states now agree about when they are entitled to speak. */
684
+ <p className="as-working" role="status" aria-label="Loading your Query views">
685
+ <span className="lp-spin" aria-hidden="true" />
686
+ </p>
687
  ) : (
688
  <div className="as-opening">
689
  <h1 className="as-opening-h">No Query views yet</h1>
web/src/automation/AutomationDetail.tsx CHANGED
@@ -50,6 +50,7 @@ import type {
50
  DiscoverEstimate,
51
  DiscoverVocab,
52
  SourcePreview,
 
53
  TickState,
54
  TriggerOption,
55
  UserTable,
@@ -60,6 +61,7 @@ import {
60
  deleteAutomation,
61
  discoverEstimate,
62
  fieldKeyFor,
 
63
  listTables,
64
  oauthStatus,
65
  patchAutomation,
@@ -67,6 +69,8 @@ import {
67
  runAutomation,
68
  runBlock,
69
  runRows,
 
 
70
  toggleNode,
71
  } from "./automationApi";
72
 
@@ -235,6 +239,18 @@ export default function AutomationDetail({
235
  const [touched, setTouched] = useState(false);
236
  const [triggerBusy, setTriggerBusy] = useState(false);
237
  const [oauth, setOauth] = useState<OAuthStatus | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
238
  /**
239
  * ⚠ "SHOW ME THE CRON" IS A VIEW CHOICE, NOT A SCHEDULE CHANGE β€” and forgetting that once
240
  * made the Custom option DEAD. Everything else on the trigger face is derived from the
@@ -431,6 +447,29 @@ export default function AutomationDetail({
431
  return () => ac.abort();
432
  }, []);
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  const table = tables.find((t) => t.key === targetTable) || null;
435
 
436
  /** ITEM 22 / D-70 / R12 β€” may Run be pressed, and what does it say if not. One definition,
@@ -881,6 +920,42 @@ export default function AutomationDetail({
881
  }
882
  };
883
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884
  const openDrill = async (entry: RunEntry) => {
885
  if (drillFor === entry.ts) {
886
  setDrill(null);
@@ -1432,6 +1507,72 @@ export default function AutomationDetail({
1432
  </div>
1433
  ) : null}
1434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1435
  <div className="auto-work">
1436
  {/*
1437
  β›” THE VIEW IS CHOSEN, NEVER INFERRED (C14 leg 1). This block used to be
 
50
  DiscoverEstimate,
51
  DiscoverVocab,
52
  SourcePreview,
53
+ StatementBatch,
54
  TickState,
55
  TriggerOption,
56
  UserTable,
 
61
  deleteAutomation,
62
  discoverEstimate,
63
  fieldKeyFor,
64
+ getAutomation,
65
  listTables,
66
  oauthStatus,
67
  patchAutomation,
 
69
  runAutomation,
70
  runBlock,
71
  runRows,
72
+ sendStatementBatch,
73
+ statementBatch,
74
  toggleNode,
75
  } from "./automationApi";
76
 
 
239
  const [touched, setTouched] = useState(false);
240
  const [triggerBusy, setTriggerBusy] = useState(false);
241
  const [oauth, setOauth] = useState<OAuthStatus | null>(null);
242
+ /**
243
+ * ⭐⭐ WAVE 35 Β· T36 / OWNER RULING R10 β€” THE PARKED STATEMENTS BATCH.
244
+ *
245
+ * ⚠ TWO PIECES OF STATE FOR TWO DIFFERENT FACTS, and collapsing them would lose one. The
246
+ * automation's own `pendingStatements` (count + notes) rides on the LIST and is already here on
247
+ * first paint, so the panel can say "12 statements are ready" with no round trip. `batch` is the
248
+ * per-customer detail, fetched only when a batch exists, because that call reads the live Odoo
249
+ * worklist and a rail paint must never pay for it.
250
+ */
251
+ const [batch, setBatch] = useState<StatementBatch | null>(null);
252
+ const [sending, setSending] = useState(false);
253
+ const parked = automation.pendingStatements || null;
254
  /**
255
  * ⚠ "SHOW ME THE CRON" IS A VIEW CHOICE, NOT A SCHEDULE CHANGE β€” and forgetting that once
256
  * made the Custom option DEAD. Everything else on the trigger face is derived from the
 
447
  return () => ac.abort();
448
  }, []);
449
 
450
+ /**
451
+ * ⭐ W35-T36 β€” fetch the per-customer detail ONLY when a batch is actually parked.
452
+ *
453
+ * ⚠ THE GUARD IS THE POINT. This call reads the live Odoo collection worklist, so firing it on
454
+ * every automation anybody opens would put a multi-model Odoo read behind an ordinary click.
455
+ * `parked` comes free with the list payload, so the cheap fact gates the expensive one.
456
+ * ⚠ Keyed on the COUNT as well as the id: sending part of a batch changes what is parked, and a
457
+ * key of `id` alone would leave the review list showing customers who have just been invoiced.
458
+ */
459
+ useEffect(() => {
460
+ if (!parked || !parked.count) {
461
+ setBatch(null);
462
+ return undefined;
463
+ }
464
+ const ac = new AbortController();
465
+ statementBatch(automation.id, ac.signal)
466
+ .then((r) => setBatch(r.pending))
467
+ // A batch we cannot read is not an error banner over the whole agent: the count and the
468
+ // notes are already on screen from the list payload, and they are the actionable half.
469
+ .catch(() => setBatch(null));
470
+ return () => ac.abort();
471
+ }, [automation.id, parked?.count]);
472
+
473
  const table = tables.find((t) => t.key === targetTable) || null;
474
 
475
  /** ITEM 22 / D-70 / R12 β€” may Run be pressed, and what does it say if not. One definition,
 
920
  }
921
  };
922
 
923
+ /**
924
+ * ⭐⭐ WAVE 35 Β· T36 / R10 β€” THE CLICK THAT SENDS, and the only one in the client.
925
+ *
926
+ * β›” IT SENDS WHAT WAS PARKED, and passes no customer list: the review is what authorised the
927
+ * batch, so the payload must not be able to widen it. The server refuses a name that was not
928
+ * parked for the same reason from the other side.
929
+ * ⚠ THE OUTCOME IS REPORTED PER BUCKET, never as a bare count. `failed` and `skipped` are
930
+ * different facts β€” a guardrail refusal and "they already paid" are both "this one did not go"
931
+ * and only one of them is a problem β€” and a summary that added them together would hide which.
932
+ */
933
+ const releaseBatch = async () => {
934
+ setProblem("");
935
+ setMessage("");
936
+ setSending(true);
937
+ try {
938
+ const out = await sendStatementBatch(automation.id);
939
+ const parts = [`${out.sent.length} sent`];
940
+ if (out.failed.length) parts.push(`${out.failed.length} failed`);
941
+ if (out.skipped.length) parts.push(`${out.skipped.length} skipped`);
942
+ setMessage(parts.join(", "));
943
+ /* β›” HANDS ON A FRESH DEFINITION RATHER THAN RELOADING THE LIST β€” the wave-31 T31 rule that
944
+ every other write door here follows, and `verify_automation_ui` counts the two shapes
945
+ exactly so a new door cannot quietly regress to a re-read. The definition really did
946
+ change (the batch is cleared when nothing is left to retry), so the panel MUST be
947
+ corrected; this reads ONE automation to learn it instead of the whole rail payload.
948
+ ⚠ The read is deliberately AFTER the outcome message is set: a send that worked followed
949
+ by a read that failed is still a send that worked, and the person must be told so. */
950
+ const { automation: fresh } = await getAutomation(automation.id);
951
+ await onSaved(fresh.id, fresh);
952
+ } catch (e) {
953
+ setProblem(e instanceof AutomationError ? e.message : "Those statements were not sent.");
954
+ } finally {
955
+ setSending(false);
956
+ }
957
+ };
958
+
959
  const openDrill = async (entry: RunEntry) => {
960
  if (drillFor === entry.ts) {
961
  setDrill(null);
 
1507
  </div>
1508
  ) : null}
1509
 
1510
+ {/*
1511
+ ⭐⭐ WAVE 35 Β· T36 / OWNER RULING R10 β€” "N STATEMENTS READY", AND THE ONLY SEND CONTROL.
1512
+ Owner item 14 moves Statements out of Settings and into the agent; R10 decides the shape:
1513
+ the run assembles and PARKS, and a person clicks Send.
1514
+
1515
+ β›” THE PANEL IS HERE, ABOVE THE WORK AREA, BECAUSE IT IS THE ONE THING THAT NEEDS DOING.
1516
+ The wave-22 board would have been the natural home and it does not exist (wave 27 R3
1517
+ deleted it, and its reason was that it wrote machine columns into a customer's own table).
1518
+ This is the first thing on the agent instead, which is where the eye already is.
1519
+
1520
+ ⚠ IT RENDERS OFF `parked`, NOT off `batch`. The count arrives with the list payload, so
1521
+ the panel is on screen at first paint; the per-customer list fills in behind it. Gating
1522
+ the whole panel on the second fetch would make the thing a person must act on the thing
1523
+ that appears last.
1524
+ */}
1525
+ {parked && parked.count > 0 ? (
1526
+ <section className="auto-batch" aria-label="Statements waiting for review">
1527
+ <div className="auto-batch-head">
1528
+ <span className="auto-batch-count">
1529
+ {parked.count} {parked.count === 1 ? "statement is" : "statements are"} ready
1530
+ </span>
1531
+ {/* β›” THE SENTENCE IS NOT DECORATION. A person about to email real customers must be
1532
+ told, before they click, that nothing has gone out yet. */}
1533
+ <span className="auto-batch-note">
1534
+ Nothing has been sent. Review the list, then release them.
1535
+ </span>
1536
+ </div>
1537
+ {batch && batch.safeMode ? (
1538
+ /* ⚠ SAFE_MODE IS REPORTED, NEVER DECIDED, HERE β€” the data layer owns it
1539
+ (`collections_send.queue_statement` raises inside the send). Saying so on the
1540
+ button's own panel is what stops a test send reading as a delivery failure. */
1541
+ <p className="auto-batch-safe" role="status">
1542
+ The email guardrail is on, so these go to the allow list rather than to customers.
1543
+ </p>
1544
+ ) : null}
1545
+ {(parked.notes || []).map((n) => (
1546
+ <p className="auto-batch-line" key={n}>{n}</p>
1547
+ ))}
1548
+ {batch && batch.items.length ? (
1549
+ <ul className="auto-batch-list">
1550
+ {batch.items.slice(0, 8).map((it) => (
1551
+ <li key={it.customer}>
1552
+ <span className="auto-batch-cust">{it.customer}</span>
1553
+ <span className="auto-batch-to">{it.to}</span>
1554
+ </li>
1555
+ ))}
1556
+ {batch.items.length > 8 ? (
1557
+ <li className="auto-batch-more">
1558
+ and {batch.items.length - 8} more
1559
+ </li>
1560
+ ) : null}
1561
+ </ul>
1562
+ ) : null}
1563
+ <div className="auto-batch-actions">
1564
+ <button
1565
+ type="button"
1566
+ className="auto-btn is-primary"
1567
+ disabled={sending}
1568
+ onClick={() => void releaseBatch()}
1569
+ >
1570
+ {sending ? "Sending…" : `Send ${parked.count}`}
1571
+ </button>
1572
+ </div>
1573
+ </section>
1574
+ ) : null}
1575
+
1576
  <div className="auto-work">
1577
  {/*
1578
  β›” THE VIEW IS CHOSEN, NEVER INFERRED (C14 leg 1). This block used to be
web/src/automation/automation.css CHANGED
@@ -174,3 +174,71 @@
174
  outline: 2px solid var(--lp-blue-deep);
175
  outline-offset: 1px;
176
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  outline: 2px solid var(--lp-blue-deep);
175
  outline-offset: 1px;
176
  }
177
+
178
+ /* ── the parked statements batch (W35-T36 / owner ruling R10) ─────────────── */
179
+
180
+ /* ⭐⭐ "N STATEMENTS ARE READY", with the ONLY Send control in the product.
181
+ It sits above `.auto-work`, in the banner band, because it is the one thing on this agent that
182
+ needs a person β€” R10's whole shape is that the run assembles and PARKS, and somebody releases.
183
+
184
+ ⚠ IT IS DELIBERATELY NOT A WARNING COLOUR. A parked batch is the system working as designed, and
185
+ painting it amber would teach people to dismiss the panel that guards a real mail send. It is
186
+ the blue tint every other "here is something to look at" surface uses. */
187
+ .auto-batch {
188
+ flex: 0 0 auto;
189
+ margin: 12px 24px 0;
190
+ padding: 12px 14px;
191
+ border: 1px solid var(--lp-blue);
192
+ border-radius: var(--lp-r-md);
193
+ background: var(--lp-blue-tint);
194
+ font-size: var(--lp-fs-sm);
195
+ }
196
+ .auto-batch-head {
197
+ display: flex;
198
+ align-items: baseline;
199
+ flex-wrap: wrap;
200
+ gap: 4px 10px;
201
+ }
202
+ .auto-batch-count {
203
+ font-weight: 650;
204
+ color: var(--lp-ink);
205
+ }
206
+ .auto-batch-note { color: var(--lp-muted); }
207
+
208
+ /* The guardrail line. Same size as a note, because it IS one: the data layer decides, this
209
+ reports (R6's second sentence). */
210
+ .auto-batch-safe,
211
+ .auto-batch-line {
212
+ margin: 6px 0 0;
213
+ color: var(--lp-muted);
214
+ font-size: var(--lp-fs-xs);
215
+ line-height: 1.5;
216
+ max-width: 70ch;
217
+ }
218
+
219
+ /* Who is about to be emailed. ⚠ Bounded in the MARKUP at 8 rows with an "and N more" line rather
220
+ than scrolled here: a 200-row list inside a banner pushes the work area off the screen, and the
221
+ panel's job is to say who and how many, not to be the worklist. */
222
+ .auto-batch-list {
223
+ margin: 8px 0 0;
224
+ padding: 0;
225
+ list-style: none;
226
+ display: flex;
227
+ flex-direction: column;
228
+ gap: 2px;
229
+ font-size: var(--lp-fs-xs);
230
+ }
231
+ .auto-batch-list li {
232
+ display: flex;
233
+ gap: 10px;
234
+ align-items: baseline;
235
+ }
236
+ .auto-batch-cust { color: var(--lp-ink); }
237
+ .auto-batch-to { color: var(--lp-muted); overflow-wrap: anywhere; }
238
+ .auto-batch-more { color: var(--lp-muted); }
239
+
240
+ .auto-batch-actions {
241
+ margin-top: 10px;
242
+ display: flex;
243
+ gap: 8px;
244
+ }
web/src/automation/automationApi.ts CHANGED
@@ -14,7 +14,14 @@
14
  // of the preset list is a list that can offer a schedule the server rejects,
15
  // and the user would see the refusal as "it just doesn't save".
16
  // ---------------------------------------------------------------------------
17
- import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, checkTenant, signal } from "../apiContract";
 
 
 
 
 
 
 
18
 
19
  /**
20
  * ⭐ WAVE 24 / R6 β€” `plain` IS A REAL KIND, and it is the default for every new automation.
@@ -194,6 +201,24 @@ export interface Automation {
194
  * full and the field should go β€” the difference between the two cases is entirely that ratchet.
195
  */
196
  awaitingResults: boolean;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  /**
198
  * ⭐⭐ WAVE 32 Β· T45 (owner item 10) β€” WHICH ACTIONS CANNOT RUN, AND WHAT EACH ONE IS MISSING.
199
  *
@@ -1009,8 +1034,62 @@ export function listTables(abort?: AbortSignal): Promise<{ tables: UserTable[] }
1009
  return send<{ tables: UserTable[] }>("/automations/tables", { signal: abort });
1010
  }
1011
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1012
  export function createAutomation(body: unknown): Promise<{ automation: Automation }> {
1013
- return send("/automations", { method: "POST", body: JSON.stringify(body) });
 
 
 
 
 
 
1014
  }
1015
 
1016
  /**
@@ -1077,14 +1156,29 @@ export function patchAutomation(
1077
  id: string,
1078
  body: unknown
1079
  ): Promise<{ automation: Automation }> {
1080
- return send(`/automations/${encodeURIComponent(id)}`, {
1081
  method: "PATCH",
1082
  body: JSON.stringify(body),
 
 
 
 
 
 
 
1083
  });
1084
  }
1085
 
1086
  export function deleteAutomation(id: string): Promise<{ deleted: string }> {
1087
- return send(`/automations/${encodeURIComponent(id)}`, { method: "DELETE" });
 
 
 
 
 
 
 
 
1088
  }
1089
 
1090
  export function runAutomation(id: string): Promise<{ started: string }> {
@@ -1118,6 +1212,48 @@ export function runRows(id: string): Promise<RunRows> {
1118
  return send(`/automations/${encodeURIComponent(id)}/rows`);
1119
  }
1120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1121
  /** Is a connector connected for THIS user? (C5 β€” per-user keychain slots, R7's seam.) */
1122
  export function oauthStatus(abort?: AbortSignal): Promise<OAuthStatus> {
1123
  return send("/oauth/status", { signal: abort });
 
14
  // of the preset list is a list that can offer a schedule the server rejects,
15
  // and the user would see the refusal as "it just doesn't save".
16
  // ---------------------------------------------------------------------------
17
+ import {
18
+ AGENTS_CHANGED,
19
+ API_V1,
20
+ CREDENTIALS,
21
+ UNAUTHORIZED_EVENT,
22
+ checkTenant,
23
+ signal,
24
+ } from "../apiContract";
25
 
26
  /**
27
  * ⭐ WAVE 24 / R6 β€” `plain` IS A REAL KIND, and it is the default for every new automation.
 
201
  * full and the field should go β€” the difference between the two cases is entirely that ratchet.
202
  */
203
  awaitingResults: boolean;
204
+ /**
205
+ * ⭐⭐ WAVE 35 Β· T36 / OWNER RULING R10 β€” A BATCH OF STATEMENTS IS ASSEMBLED AND WAITING FOR A
206
+ * HUMAN CLICK. `null` when nothing is parked, which is every automation but a statements agent
207
+ * that has run.
208
+ *
209
+ * β›” `null` AND NOT AN EMPTY OBJECT, deliberately: "no batch" and "a batch of zero" are
210
+ * different facts and only one of them is worth putting a panel on screen for.
211
+ *
212
+ * ⚠ COUNT AND NOTES ONLY β€” the rendered mail is NOT here. This type rides on the automations
213
+ * LIST, which every rail paint reads, and a 200-statement batch carries 200 HTML documents.
214
+ * `GET /admin/statements/agent/{id}` serves the per-customer detail when somebody opens it.
215
+ *
216
+ * ⚠ OPTIONAL rather than required, and this is the ONE place that is right in this file: every
217
+ * automation that is not a statements agent legitimately has no such key, so requiring it would
218
+ * make the common case the exception. Contrast `awaitingResults` and `unconfigured` above, which
219
+ * are facts about EVERY automation and are required for exactly that reason.
220
+ */
221
+ pendingStatements?: { count: number; ts: string; notes: string[] } | null;
222
  /**
223
  * ⭐⭐ WAVE 32 Β· T45 (owner item 10) β€” WHICH ACTIONS CANNOT RUN, AND WHAT EACH ONE IS MISSING.
224
  *
 
1034
  return send<{ tables: UserTable[] }>("/automations/tables", { signal: abort });
1035
  }
1036
 
1037
+ /**
1038
+ * ⭐⭐ WAVE 35 Β· T33 / WIRING W6 (owner item 11) β€” "WHEN I DELETE AN AGENT, IT DOESN'T SYNC TO HOME".
1039
+ *
1040
+ * β›” EMITTED FROM THIS MODULE, NOT FROM `AutomationSurface`, AND THAT IS THE WHOLE DESIGN CHOICE.
1041
+ * Every mutation of an agent passes through one of the three doors below, so this is the ONE choke
1042
+ * point a fourth caller cannot forget. The surface has three call sites for the same three writes
1043
+ * and a fourth would arrive with no dispatch and no type error β€” the exact shape of the defect
1044
+ * being fixed, which is two readers of one fact and only one of them told.
1045
+ *
1046
+ * ⚠ THE CONSTANT, NEVER THE STRING (A's `NOTE A-9`): a second spelling is a dispatch into an empty
1047
+ * room, and TypeScript cannot see it. ⚠ And NO `detail`: this means "your copy is stale", not
1048
+ * "here is the new list" β€” a payload would be a third copy of one fact.
1049
+ *
1050
+ * ⚠ FIRED ON SUCCESS ONLY. A failed write changed nothing, and telling the frame to re-read after
1051
+ * a 400 would spend a request to learn the list it already has.
1052
+ */
1053
+ function agentsChanged(): void {
1054
+ // `window` is always present in this bundle; the guard is for the gate's module-level import,
1055
+ // which evaluates this file outside a DOM.
1056
+ if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(AGENTS_CHANGED));
1057
+ }
1058
+
1059
+ /**
1060
+ * ⭐⭐ WAVE 35 Β· T33 β€” DROP ONE ROW FROM THE MEMO.
1061
+ *
1062
+ * β›” THE HOLE THIS FILLS, AND IT IS A REAL ONE: `rememberAutomation` maps and appends but has NEVER
1063
+ * REMOVED. So after a delete the memo is correct only because `AutomationSurface.onDeleted` happens
1064
+ * to call `load()` β€” a CONVENTION, not a structure. Any future delete path that skips the reload
1065
+ * would leave `cachedAutomations()` seeding the next mount with a row the server no longer has: a
1066
+ * deleted agent reappearing on navigation, which is owner item 11 wearing a different coat.
1067
+ *
1068
+ * β›” AND IT IS AN EVICTION, NOT `forgetAutomations()`. Dropping the WHOLE memo would force
1069
+ * `data === null` on the next visit to Agents β€” the cold skeleton W29-T01 exists to prevent and
1070
+ * that wave-31's `rememberAutomation` spends thirty lines of rationale avoiding. One row leaves;
1071
+ * the rest of the paint accelerator stays.
1072
+ * ⚠ `at` is NOT refreshed, for the same reason `rememberAutomation` does not touch it: removing a
1073
+ * row does not make the list newly fetched.
1074
+ */
1075
+ export function forgetAutomation(id: string): AutomationList | null {
1076
+ if (!listMemo || !id) return null;
1077
+ const prev = listMemo.data;
1078
+ const kept = prev.automations.filter((a) => a.id !== id);
1079
+ if (kept.length === prev.automations.length) return null;
1080
+ const data: AutomationList = { ...prev, automations: kept };
1081
+ listMemo = { at: listMemo.at, data };
1082
+ return data;
1083
+ }
1084
+
1085
  export function createAutomation(body: unknown): Promise<{ automation: Automation }> {
1086
+ return send<{ automation: Automation }>("/automations", {
1087
+ method: "POST",
1088
+ body: JSON.stringify(body),
1089
+ }).then((r) => {
1090
+ agentsChanged();
1091
+ return r;
1092
+ });
1093
  }
1094
 
1095
  /**
 
1156
  id: string,
1157
  body: unknown
1158
  ): Promise<{ automation: Automation }> {
1159
+ return send<{ automation: Automation }>(`/automations/${encodeURIComponent(id)}`, {
1160
  method: "PATCH",
1161
  body: JSON.stringify(body),
1162
+ }).then((r) => {
1163
+ // ⚠ A RENAME IS THE CASE THAT MAKES THIS NECESSARY (owner item 11 says "fix this for everything
1164
+ // we display twice in Home"): Home draws the agent's NAME, so a patch that only the module
1165
+ // hears leaves the tile reading the old one. A toggle emits too β€” harmless, and cheaper than a
1166
+ // rule about which fields Home happens to draw today.
1167
+ agentsChanged();
1168
+ return r;
1169
  });
1170
  }
1171
 
1172
  export function deleteAutomation(id: string): Promise<{ deleted: string }> {
1173
+ return send<{ deleted: string }>(`/automations/${encodeURIComponent(id)}`, {
1174
+ method: "DELETE",
1175
+ }).then((r) => {
1176
+ // BOTH halves, and neither is the other's substitute: the eviction keeps THIS module's memo
1177
+ // honest whoever deleted the row, and the event tells the FRAME its separate copy is stale.
1178
+ forgetAutomation(id);
1179
+ agentsChanged();
1180
+ return r;
1181
+ });
1182
  }
1183
 
1184
  export function runAutomation(id: string): Promise<{ started: string }> {
 
1212
  return send(`/automations/${encodeURIComponent(id)}/rows`);
1213
  }
1214
 
1215
+ /** One parked statement, as the review list shows it. The rendered mail is NOT here. */
1216
+ export type StatementItem = {
1217
+ customer: string;
1218
+ to: string;
1219
+ subject: string;
1220
+ tier: string;
1221
+ overdue?: number;
1222
+ };
1223
+
1224
+ export type StatementBatch = {
1225
+ ts: string;
1226
+ count: number;
1227
+ notes: string[];
1228
+ items: StatementItem[];
1229
+ safeMode: boolean;
1230
+ };
1231
+
1232
+ /**
1233
+ * ⭐⭐ WAVE 35 Β· T36 / R10 β€” the parked batch, per agent.
1234
+ *
1235
+ * ⚠ THESE TWO CALLS DO NOT GO TO `/automations`. The statements doors live on
1236
+ * `routes_statements`, behind `admin_gate` + the Royal Imports tenant gate, because that is where
1237
+ * the send guardrails already are and R10's whole point is that they do not move.
1238
+ */
1239
+ export function statementBatch(autoId: string, abort?: AbortSignal):
1240
+ Promise<{ pending: StatementBatch | null }> {
1241
+ return send(`/admin/statements/agent/${encodeURIComponent(autoId)}`, { signal: abort });
1242
+ }
1243
+
1244
+ /**
1245
+ * β›” THE CLICK R10 REQUIRES, and the ONLY caller of the send door in the whole client. Nothing
1246
+ * schedules this, nothing retries it, and the agent's own run cannot reach it: a person opens the
1247
+ * batch and presses the button. `customers` may only NARROW what was parked.
1248
+ */
1249
+ export function sendStatementBatch(autoId: string, customers?: string[]):
1250
+ Promise<{ sent: unknown[]; failed: unknown[]; skipped: unknown[]; cleared: boolean }> {
1251
+ return send(`/admin/statements/agent/${encodeURIComponent(autoId)}/send`, {
1252
+ method: "POST",
1253
+ body: JSON.stringify(customers && customers.length ? { customers } : {}),
1254
+ });
1255
+ }
1256
+
1257
  /** Is a connector connected for THIS user? (C5 β€” per-user keychain slots, R7's seam.) */
1258
  export function oauthStatus(abort?: AbortSignal): Promise<OAuthStatus> {
1259
  return send("/oauth/status", { signal: abort });
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -33,9 +33,18 @@ import { acceptsQueryPreview, routeQueryViewMutation } from "./queryPreview";
33
  // only its own source text could otherwise be checked.
34
  import { WINDOW_ROWS } from "./apiBridge";
35
  import {
36
- EMPTY_WINDOW_PREDICATE, limitSummary, nextWindowOffset, windowedCapabilityNote,
37
- windowedFoldNote, windowPredicateKey,
38
  } from "./counts";
 
 
 
 
 
 
 
 
 
39
  import { defaultViewConfig, useGridColumns } from "./useGridColumns";
40
  import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
41
  unresolvedConditions, useVisibleRows } from "./useVisibleRows";
@@ -114,7 +123,8 @@ import {
114
  planFieldPaste,
115
  pasteRowCount,
116
  } from "./clipboard";
117
- import { cellTipText, expandButtonRect, GROUP_HEADER_FONT, GROUP_LABEL_PAD, headerMarkLayout,
 
118
  headerMarkSizes, tipLeft } from "./overlayPlacement";
119
  import { AnchoredOverlay, BodyPortal, useOverlayLayer } from "./OverlaySurface";
120
  import type { AnchorRect } from "./OverlaySurface";
@@ -378,6 +388,32 @@ function queryPreviewView(binding: QueryVirtualBinding, fields: Field[]): SavedV
378
  : [];
379
  config.groupBy = typeof raw.groupBy === "string" && keys.has(raw.groupBy) ? raw.groupBy : null;
380
  if (raw.display && typeof raw.display === "object") config.display = raw.display as DisplaySpec;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  return {
382
  id: binding.artifactId,
383
  name: typeof raw.name === "string" && raw.name ? raw.name : binding.source.label,
@@ -747,7 +783,31 @@ function CustomerGridSurface({
747
  onEmbeddedSelectionChange,
748
  }: CustomerGridProps = {}) {
749
  const isQueryPreview = queryBinding !== undefined;
750
- const previewReadOnly = embedded || isQueryPreview;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  // Wave 16 C-TOPIC: which TABLE this tree is drawing, derived from the one scope prop.
752
  const topic = topicForScope(scope);
753
  const {
@@ -760,9 +820,15 @@ function CustomerGridSurface({
760
  patchOverlay,
761
  requestWindow,
762
  } = useCustomerData(scope, {
763
- bindSurface: !previewReadOnly,
764
- includeWorkspace: !previewReadOnly,
765
- writable: !previewReadOnly,
 
 
 
 
 
 
766
  });
767
  const embeddedIdsKey = embeddedRecordIds?.join(",") ?? "";
768
  /**
@@ -862,9 +928,26 @@ function CustomerGridSurface({
862
  * ⚠ EMBEDDED GRIDS DO NOT ASK. A linked-record grid inside a modal is not a table anyone
863
  * alerts on, and a second fetch per relation cell would be a workspace read per click.
864
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
865
  const [alerted, setAlerted] = useState<string[]>([]);
866
  useEffect(() => {
867
- if (previewReadOnly) return;
 
 
 
868
  let live = true;
869
  const pull = () => {
870
  void fetchAlerts().then((r) => {
@@ -878,7 +961,7 @@ function CustomerGridSurface({
878
  live = false;
879
  window.removeEventListener("focus", pull);
880
  };
881
- }, [previewReadOnly, scope]);
882
 
883
  const [fields, setFields] = useState<Field[]>([]);
884
  const [views, setViews] = useState<SavedView[]>([]);
@@ -901,6 +984,15 @@ function CustomerGridSurface({
901
  const [expandAt, setExpandAt] = useState<
902
  { pid: number; x: number; y: number; size: number } | null
903
  >(null);
 
 
 
 
 
 
 
 
 
904
  const [detailPid, setDetailPid] = useState<number | null>(null);
905
  const [columnMenu, setColumnMenu] = useState<ColumnMenuState | null>(null);
906
  /** Open choice list for a `select` / `user` cell β€” see onCellClicked. */
@@ -1056,11 +1148,32 @@ function CustomerGridSurface({
1056
  useEffect(() => {
1057
  if (!payload || payloadFields.length === 0 || initializedKey.current === storageKey) return;
1058
  if (isQueryPreview && queryBinding) {
1059
- const virtual = queryPreviewView(queryBinding, payloadFields);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
  setFields(payloadFields);
1061
- setViews([virtual]);
1062
- setActiveViewId(virtual.id);
1063
- setConfig(virtual.config);
1064
  setWorkspaceReady(true);
1065
  initializedKey.current = storageKey;
1066
  return;
@@ -1158,7 +1271,7 @@ function CustomerGridSurface({
1158
  * the config the client is actually filtering with, and sending anything else would ask the
1159
  * host to resolve a question nobody on screen is asking.
1160
  */
1161
- if (!previewReadOnly && reemit.size > 0) {
1162
  const stamped = { ...(local?.reemitted ?? {}) };
1163
  for (const view of initialViews) {
1164
  const key = reemit.get(view.id);
@@ -1173,10 +1286,15 @@ function CustomerGridSurface({
1173
  } else {
1174
  reemittedRef.current = { ...(local?.reemitted ?? {}) };
1175
  }
1176
- }, [payload, payloadFields, storageKey, previewReadOnly, scope, isQueryPreview, queryBinding]);
1177
 
1178
  useEffect(() => {
1179
- if (!workspaceReady || previewReadOnly) return;
 
 
 
 
 
1180
  writeLocal(storageKey, {
1181
  fields,
1182
  views,
@@ -1193,7 +1311,7 @@ function CustomerGridSurface({
1193
  // drop a view this browser created seconds ago.
1194
  viewWrites: pruneTombstones(viewWritesRef.current, Date.now()),
1195
  });
1196
- }, [workspaceReady, storageKey, fields, views, activeViewId, previewReadOnly]);
1197
 
1198
  /**
1199
  * ⭐ THE LIVE WORKSPACE (owner report, 2026-08-04) β€” what has APPEARED since we mounted.
@@ -1218,7 +1336,9 @@ function CustomerGridSurface({
1218
  */
1219
  const hostWorkspaceViews = payload?.workspace?.views;
1220
  useEffect(() => {
1221
- if (!workspaceReady || previewReadOnly) return;
 
 
1222
  const now = Date.now();
1223
  const nextFields = adoptNewFields(
1224
  fields, payloadFields, fieldStampsRef.current.deleted, now
@@ -1228,7 +1348,7 @@ function CustomerGridSurface({
1228
  adoptNewViews(current, hostWorkspaceViews, viewTombstonesRef.current, now,
1229
  (config) => normalizeConfig(config, nextFields))
1230
  );
1231
- }, [workspaceReady, hostWorkspaceViews, payloadFields, fields, previewReadOnly]);
1232
 
1233
  /** Item 3c β€” stamp a def write / a delete. Pruned at every touch so the persisted blob
1234
  * stays a recent window, never an archive. */
@@ -1293,7 +1413,7 @@ function CustomerGridSurface({
1293
  * this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD
1294
  * row.
1295
  */
1296
- const isUserTable = !previewReadOnly && scope.startsWith("ut_");
1297
  const recordsMutable = payload?.recordsMutable !== false;
1298
  const canMutateRecords = isUserTable && recordsMutable;
1299
  /**
@@ -1451,7 +1571,8 @@ function CustomerGridSurface({
1451
 
1452
  // Airtable behavior: configuration changes to the active view autosave.
1453
  useEffect(() => {
1454
- if (!workspaceReady || previewReadOnly) return;
 
1455
  const active = views.find((view) => view.id === activeViewId);
1456
  if (!active || sameConfig(active.config, config)) {
1457
  setSaveState("saved");
@@ -1466,14 +1587,31 @@ function CustomerGridSurface({
1466
  );
1467
  viewWritesRef.current = stampTombstone(viewWritesRef.current, updated.id,
1468
  Date.now());
1469
- emitHostEvent({ id: eventId("view"), type: "view_upsert", view: updated });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1470
  setSaveState("saved");
1471
  saveTimer.current = null;
1472
  }, 420);
1473
  return () => {
1474
  if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
1475
  };
1476
- }, [workspaceReady, activeViewId, config, views, previewReadOnly]);
1477
 
1478
  // Numeric dimensions force a glide relayout when either the component frame
1479
  // or Streamlit's main column changes width (notably sidebar collapse).
@@ -1504,7 +1642,7 @@ function CustomerGridSurface({
1504
  const mode = tableMode(payload?.counts);
1505
  const serverWindowed = mode === "server-windowed";
1506
  // Owner items 4+6 β€” the Cohort page's grid renders without the Views sidebar.
1507
- const hideViews = previewReadOnly || payload?.workspace?.hideViews === true;
1508
  // Wave-6 item 10 β€” how this view displays. A WINDOWED table is always the grid: list/
1509
  // calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule,
1510
  // the same reason grouping is off there).
@@ -1642,8 +1780,8 @@ function CustomerGridSurface({
1642
  // permissions vs the viewer, fail-closed on restricted fields when the viewer is unknown.
1643
  const viewer = payload?.viewer;
1644
  const canEditField = useCallback(
1645
- (f: Field): boolean => !previewReadOnly && recordsMutable && mayEditField(f, viewer),
1646
- [previewReadOnly, recordsMutable, viewer]
1647
  );
1648
  const unresolvedCount = useMemo(
1649
  () => unresolvedConditions(config.filters, { cohortSets, today }),
@@ -1848,6 +1986,37 @@ function CustomerGridSurface({
1848
  () => (serverWindowed ? windowedCapabilityNote(payload?.counts?.matched ?? 0) : null),
1849
  [serverWindowed, payload?.counts?.matched]
1850
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1851
  const displayRows = useMemo(() => {
1852
  const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows;
1853
  // ⚠ APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself β€” and it is the
@@ -2553,9 +2722,19 @@ function CustomerGridSurface({
2553
  // and a `position: fixed` button with no clamp would paint over the chrome on a row
2554
  // nobody can see. The horizontal legs are now defence in depth (a column drag, a box
2555
  // narrower than the frozen strip) rather than the everyday case.
2556
- const at = b
2557
- ? expandButtonRect(b, gridBoxRef.current?.getBoundingClientRect())
2558
- : null;
 
 
 
 
 
 
 
 
 
 
2559
  // Recomputed EVERY move, not memoised on the pid: a scroll can leave the pointer over
2560
  // the same record at a new y, and a button that keeps the pid but not the position
2561
  // floats over the wrong row. Referential stability is preserved by comparing values,
@@ -3172,26 +3351,40 @@ function CustomerGridSurface({
3172
  visibleCols, fieldByKey, canEditField]
3173
  );
3174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3175
  const persistView = useCallback((view: SavedView) => {
3176
- if (queryBinding) {
3177
- const routed = routeQueryViewMutation(queryBinding, scope, {
3178
- id: eventId("query-view-update"),
3179
- type: "view_upsert",
3180
- view: view as unknown as Record<string, unknown>,
3181
- }, { query: mutateQueryWorkspace, native: emitHostEvent });
3182
- if (routed.channel === "refused")
3183
- signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot write a source view.");
3184
- return;
3185
- }
3186
  setViews((current) => {
3187
  const found = current.some((item) => item.id === view.id);
3188
  return found
3189
  ? current.map((item) => (item.id === view.id ? view : item))
3190
  : [...current, view];
3191
  });
3192
- routeQueryViewMutation(undefined, scope, {
3193
- id: eventId("view"), type: "view_upsert", view: view as unknown as Record<string, unknown>,
 
 
3194
  }, { query: mutateQueryWorkspace, native: emitHostEvent });
 
 
 
 
 
 
3195
  }, [queryBinding, scope]);
3196
 
3197
  /**
@@ -3231,7 +3424,19 @@ function CustomerGridSurface({
3231
  const current = views.find((view) => view.id === activeViewId);
3232
  if (current && !sameConfig(current.config, config))
3233
  persistView({ ...current, config });
3234
- const next = views.find((view) => view.id === id);
 
 
 
 
 
 
 
 
 
 
 
 
3235
  if (!next) return;
3236
  setActiveViewId(id);
3237
  setConfig(normalizeConfig(next.config, fields));
@@ -3246,7 +3451,7 @@ function CustomerGridSurface({
3246
  // Owner item 10: opening a view is "I am working now" β€” the frame folds its nav rail.
3247
  signal(NAV_MINIMIZE_EVENT);
3248
  },
3249
- [views, activeViewId, config, persistView, fields, isQueryPreview]
3250
  );
3251
 
3252
  /**
@@ -3650,44 +3855,18 @@ function CustomerGridSurface({
3650
  * order β€” two lists that decide "is there a number here" by different rules would drift, and
3651
  * the drift would show as a row wearing both a count and an excuse.
3652
  */
3653
- /**
3654
- * ⭐⭐ WAVE 33 Β· T16 (owner item 5) β€” THE DATABASE'S OWN TOTAL.
3655
- *
3656
- * Owner, verbatim: *"Let's also mark important the database, meaning that the database should
3657
- * have the sum number of all the views with mark important numbers whenever a minimum of 1
3658
- * view is marked important in a database."*
3659
- *
3660
- * β›” SUMMED OVER `importantIds`, NOT OVER `alertCounts`, and that is the whole correctness of
3661
- * this memo. `alertCounts` is keyed by `alerted βˆͺ important` β€” a view can be in it because
3662
- * somebody set a server-side ALERT on it, which is a different feature. Summing the map
3663
- * wholesale would answer "alerts plus marks" under a label that says marks, and the two sets
3664
- * overlap, so the error would be invisible on any table where they happen to coincide.
3665
  *
3666
- * β›” `partial` IS PART OF THE ANSWER, not a nicety. A marked view gets NO count on a
3667
- * server-windowed grid or while a measure is unresolved (see `countNotes` below), and a sum
3668
- * that silently skipped those would render a confident total that is short by an unknown
3669
- * amount β€” the exact silent-absence D-205 exists to end, arriving one level up. So the total
3670
- * carries how many of its views it could not count, and the rail says so.
3671
  *
3672
- * ⚠ IT IS A SUM OF PER-VIEW COUNTS, WHICH IS WHAT WAS ASKED β€” a record matching three marked
3673
- * views contributes three. That is "the sum of the views' numbers"; a DISTINCT-record total
3674
- * would be a different (and more expensive) question, and would disagree with the per-view
3675
- * badges the user can see and add up themselves.
3676
- * ⚠ NO NEW QUERY (R8): this folds over `alertCounts`, which the grid has already computed.
3677
  */
3678
- const importantTotal = useMemo(() => {
3679
- if (!importantIds.length) return null;
3680
- let sum = 0;
3681
- let counted = 0;
3682
- for (const id of importantIds) {
3683
- const n = alertCounts[id];
3684
- if (typeof n === "number") {
3685
- sum += n;
3686
- counted += 1;
3687
- }
3688
- }
3689
- return { sum, counted, marked: importantIds.length };
3690
- }, [importantIds, alertCounts]);
3691
 
3692
  const countNotes = useMemo(() => {
3693
  const out: Record<string, string> = {};
@@ -5252,7 +5431,7 @@ function CustomerGridSurface({
5252
  const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
5253
  const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN;
5254
  // Item 10 β€” the toolbar's mode switcher (grid-only on windowed tables, see displayMode).
5255
- const modeControl = serverWindowed || previewReadOnly ? undefined : (
5256
  <ModeSwitch
5257
  mode={displayMode}
5258
  onMode={setDisplayMode}
@@ -5386,10 +5565,25 @@ function CustomerGridSurface({
5386
  * fact about ONE surface; folding it into the state would make every consumer's
5387
  * behaviour depend on a drag, and the drag's whole scope is which row sits where.
5388
  */
5389
- views={applyViewOrder(views, folderStamps, Date.now())}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5390
  alertCounts={alertCounts}
5391
  countNotes={countNotes}
5392
- importantTotal={importantTotal}
5393
  activeViewId={activeViewId}
5394
  saveState={saveState}
5395
  onSelect={selectView}
@@ -5655,7 +5849,16 @@ function CustomerGridSurface({
5655
  // canvas, so reaching for it IS an out-of-bounds event and dismissing there would
5656
  // unmount the control under the arriving pointer. Leaving the whole grid box is the
5657
  // honest "you are done with this row".
5658
- onMouseLeave={() => setExpandAt((prev) => (prev === null ? prev : null))}
 
 
 
 
 
 
 
 
 
5659
  >
5660
  {displayMode === "grid" && (
5661
  <>
@@ -5739,7 +5942,7 @@ function CustomerGridSurface({
5739
  height={gridSize.height}
5740
  customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
5741
  headerIcons={HEADER_ICONS}
5742
- rightElement={previewReadOnly ? undefined : (
5743
  // Wave-6 item 4 β€” the "+" of the header row: the create-field form,
5744
  // insert-at-end.
5745
  //
@@ -5933,7 +6136,7 @@ function CustomerGridSurface({
5933
  docs={payload?.docs}
5934
  docPayload={payload?.docPayload}
5935
  onDocAdd={
5936
- !previewReadOnly && payload?.docs
5937
  ? (pid, file) =>
5938
  emitHostEvent({
5939
  id: eventId("docadd"),
@@ -5945,13 +6148,13 @@ function CustomerGridSurface({
5945
  : undefined
5946
  }
5947
  onDocFetch={
5948
- !previewReadOnly && payload?.docs
5949
  ? (pid, docId) =>
5950
  emitHostEvent({ id: eventId("docget"), type: "doc_fetch", pid, docId })
5951
  : undefined
5952
  }
5953
  onDocDelete={
5954
- !previewReadOnly && payload?.docs
5955
  ? (pid, docId) =>
5956
  emitHostEvent({ id: eventId("docdel"), type: "doc_delete", pid, docId })
5957
  : undefined
@@ -6119,6 +6322,43 @@ function CustomerGridSurface({
6119
  </svg>
6120
  </button>
6121
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6122
  {/* Wave-5 item 6 β€” the header description tip. pointer-events:none + aria-hidden:
6123
  it can never become a click target, so the control under it stays clickable
6124
  (the tooltip-swallows-the-next-click trap, paid for once already). */}
@@ -6259,7 +6499,7 @@ function CustomerGridSurface({
6259
  ⚠ The display cap and the server's limits are INDEPENDENT FACTS about one grid: one is
6260
  how much we are painting, the other is what the server could not do. They belong in
6261
  the same strip and neither may suppress the other. */}
6262
- {(foldNote || limitNote || capabilityNote) && (
6263
  <div className="cg-more">
6264
  {foldNote && <span className="cg-more-note">{foldNote}</span>}
6265
  {limitNote && (
@@ -6270,6 +6510,15 @@ function CustomerGridSurface({
6270
  {capabilityNote.short}
6271
  </span>
6272
  )}
 
 
 
 
 
 
 
 
 
6273
  </div>
6274
  )}
6275
  {capped && (displayMode === "grid" || displayMode === "list") && (
@@ -7037,6 +7286,13 @@ function CustomerGridSurface({
7037
  fields={fields}
7038
  record={detailRecord}
7039
  titleKey={lockedKey}
 
 
 
 
 
 
 
7040
  positionLabel={`Record ${dataPosition.toLocaleString()} of ${recordCount.toLocaleString()}`}
7041
  canPrev={neighborExists(-1)}
7042
  canNext={neighborExists(1)}
@@ -7057,7 +7313,7 @@ function CustomerGridSurface({
7057
  // on the NEXT render via `payload.docPayload`; Documents.tsx matches it
7058
  // to its own pending request before touching it.
7059
  onDocAdd={
7060
- !previewReadOnly && payload?.docs
7061
  ? (file) =>
7062
  emitHostEvent({
7063
  id: eventId("docadd"),
@@ -7069,7 +7325,7 @@ function CustomerGridSurface({
7069
  : undefined
7070
  }
7071
  onDocFetch={
7072
- !previewReadOnly && payload?.docs
7073
  ? (docId) =>
7074
  emitHostEvent({
7075
  id: eventId("docget"),
@@ -7080,7 +7336,7 @@ function CustomerGridSurface({
7080
  : undefined
7081
  }
7082
  onDocDelete={
7083
- !previewReadOnly && payload?.docs
7084
  ? (docId) =>
7085
  emitHostEvent({
7086
  id: eventId("docdel"),
 
33
  // only its own source text could otherwise be checked.
34
  import { WINDOW_ROWS } from "./apiBridge";
35
  import {
36
+ EMPTY_WINDOW_PREDICATE, limitSummary, lockedRecordsNote, nextWindowOffset,
37
+ windowedCapabilityNote, windowedFoldNote, windowPredicateKey,
38
  } from "./counts";
39
+ // ⭐ W35-T29/T30 (C5) β€” the record star's client, and its own stylesheet imported HERE, by the
40
+ // module that renders its classes. W35-T22 in this same wave was the cost of doing otherwise.
41
+ import { useRecordStars } from "./recordStars";
42
+ // ⚠ ALIASED, and the collision is worth naming: `./Stars`'s `StarIcon` is the RATING atom (a
43
+ // 5-point star for a 1..5 field) and this one is the record MARK. Two different meanings, one
44
+ // noun, already both in this file β€” importing the second under its own name would have shadowed
45
+ // the first and silently repainted every rating picker.
46
+ import { StarIcon as RecordStarMark } from "../ui/icons";
47
+ import "./rowStar.css";
48
  import { defaultViewConfig, useGridColumns } from "./useGridColumns";
49
  import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
50
  unresolvedConditions, useVisibleRows } from "./useVisibleRows";
 
123
  planFieldPaste,
124
  pasteRowCount,
125
  } from "./clipboard";
126
+ import { cellTipText, expandButtonRect, starButtonRect,
127
+ GROUP_HEADER_FONT, GROUP_LABEL_PAD, headerMarkLayout,
128
  headerMarkSizes, tipLeft } from "./overlayPlacement";
129
  import { AnchoredOverlay, BodyPortal, useOverlayLayer } from "./OverlaySurface";
130
  import type { AnchorRect } from "./OverlaySurface";
 
388
  : [];
389
  config.groupBy = typeof raw.groupBy === "string" && keys.has(raw.groupBy) ? raw.groupBy : null;
390
  if (raw.display && typeof raw.display === "object") config.display = raw.display as DisplaySpec;
391
+ /**
392
+ * ⭐⭐ W35-T25 β€” THE MEMBERS AN EDIT CAN MOVE, READ BACK FROM THE SERVER'S COPY.
393
+ *
394
+ * R2 made the spec editable and `routes_query.QUERY_EDITABLE_SPEC` is the allow-list it may
395
+ * move. Every one of them has to be read HERE too, or the round trip is one-directional: the
396
+ * edit posts, the server stores it, and the next mount rebuilds the view from the AI's members
397
+ * alone β€” so a resize would survive a reload on THIS browser (the local bucket) and vanish on
398
+ * any other, which is the worst of the three possible behaviours because it looks like it works.
399
+ *
400
+ * ⚠ Each is validated against the CURRENT field list, exactly like the members above: the
401
+ * server cleans on write, this cleans on read, and neither trusts the other's vintage.
402
+ */
403
+ if (Array.isArray(raw.order)) {
404
+ const order = raw.order.filter((key): key is string => typeof key === "string" && keys.has(key));
405
+ if (order.length) config.order = [...order, ...config.order.filter((key) => !order.includes(key))];
406
+ }
407
+ if (raw.widths && typeof raw.widths === "object") {
408
+ const widths: Record<string, number> = {};
409
+ for (const [key, width] of Object.entries(raw.widths as Record<string, unknown>))
410
+ if (keys.has(key) && typeof width === "number" && width > 0) widths[key] = width;
411
+ config.widths = widths;
412
+ }
413
+ if (typeof raw.colorBy === "string" && keys.has(raw.colorBy)) config.colorBy = raw.colorBy;
414
+ if (typeof raw.rowHeightMode === "string")
415
+ config.rowHeightMode = raw.rowHeightMode as ViewConfig["rowHeightMode"];
416
+ if (typeof raw.frozenCount === "number") config.frozenCount = clampFrozenCount(raw.frozenCount);
417
  return {
418
  id: binding.artifactId,
419
  name: typeof raw.name === "string" && raw.name ? raw.name : binding.source.label,
 
783
  onEmbeddedSelectionChange,
784
  }: CustomerGridProps = {}) {
785
  const isQueryPreview = queryBinding !== undefined;
786
+ /**
787
+ * ⭐⭐ W35-T23 (owner item 4 / R2) β€” **`previewReadOnly` IS GONE, AND THE SPLIT IS THE TICKET.**
788
+ *
789
+ * It used to be `embedded || isQueryPreview`: ONE flag switching off view persistence, field
790
+ * editing, add-row and the toolbar's right element for two surfaces that are not the same
791
+ * thing. Owner: *"I should be able to interact in each of the View under Query as well, exactly
792
+ * like how I would be able to interact with it under Database view."* R2 makes Query views
793
+ * live β€” resize, sort, group, hide, row height, export, edit a cell, add a row β€” **subject to
794
+ * the SOURCE database's own locks**, which is the predicate that does the work now.
795
+ *
796
+ * β›” TWO QUESTIONS, NOT ONE, AND CONFLATING THEM IS WHAT MADE THE SINGLE FLAG WRONG:
797
+ * Β· **`embedded`** β€” *may this surface WRITE anything at all?* A linked-record grid inside a
798
+ * modal may not, and keeps every refusal it has today. Unchanged, deliberately.
799
+ * Β· **`hostWorkspace`** β€” *does this surface own the SOURCE DATABASE's workspace?* Its views,
800
+ * its `storageKey`, its view rail, its alert list. **A Query surface does NOT**, and that
801
+ * has nothing to do with read-only: the artefact has its own single view, its own storage
802
+ * key (`query:<qid>`) and its own transport. Opening `includeWorkspace` for Query would
803
+ * load the source's view list into a Query surface β€” two surfaces sharing one view list,
804
+ * which is the trap this ticket names by name.
805
+ *
806
+ * ⚠ SO A QUERY SURFACE IS `!embedded && !hostWorkspace`: it writes, and it writes somewhere
807
+ * else. Any new guard added below has to answer WHICH of the two questions it is asking; if
808
+ * the answer is "both", it is probably asking the wrong one.
809
+ */
810
+ const hostWorkspace = !embedded && !isQueryPreview;
811
  // Wave 16 C-TOPIC: which TABLE this tree is drawing, derived from the one scope prop.
812
  const topic = topicForScope(scope);
813
  const {
 
820
  patchOverlay,
821
  requestWindow,
822
  } = useCustomerData(scope, {
823
+ // ⚠ `bindSurface` is the MODULE-scope write target every emitted event carries. A Query
824
+ // surface's cell edits belong to the SOURCE database, so it must bind β€” the linked-record
825
+ // grid must not, because its parent still owns that scope.
826
+ bindSurface: !embedded,
827
+ // β›” The one flag that stays on the WIDE predicate: neither surface reads the source
828
+ // database's own saved views.
829
+ includeWorkspace: hostWorkspace,
830
+ // `writable: false` blocks `patchOverlay`, i.e. every cell edit. R2 wants them in Query.
831
+ writable: !embedded,
832
  });
833
  const embeddedIdsKey = embeddedRecordIds?.join(",") ?? "";
834
  /**
 
928
  * ⚠ EMBEDDED GRIDS DO NOT ASK. A linked-record grid inside a modal is not a table anyone
929
  * alerts on, and a second fetch per relation cell would be a workspace read per click.
930
  */
931
+ /**
932
+ * ⭐⭐ W35-T29 + T30 Β· CONTRACT C5 (R5, R6) β€” the RECORD stars for this database.
933
+ *
934
+ * β›” PER USER, unlike every other star in the product. A database, an agent or a Query view is
935
+ * starred through `config.important` on ONE record, so starring a SHARED view stars it for
936
+ * everybody who can see it; a record star lives in the caller's own per-username stratum and two
937
+ * accounts see two different sets. One word on screen, two scopes underneath (mailbox E-3).
938
+ * β›” AND IT IS ITS OWN DOOR, NOT A VIEW WRITE (D-170): a view write on a read-through grid 409s
939
+ * because it materialises the pool, and this must work on `ut_odoo_gl_lines`.
940
+ *
941
+ * ⚠ OFF FOR AN EMBEDDED GRID. A linked-record picker inside a modal is not a table anybody keeps
942
+ * marks on, and the read would be one request per relation cell.
943
+ */
944
+ const recordStars = useRecordStars(scope, !embedded);
945
  const [alerted, setAlerted] = useState<string[]>([]);
946
  useEffect(() => {
947
+ // `hostWorkspace`, not `embedded`: the alert badges paint on the SOURCE database's view rail,
948
+ // which a Query surface does not render at all (`hideViews`). Fetching them would be a
949
+ // workspace read whose answer has nowhere to go.
950
+ if (!hostWorkspace) return;
951
  let live = true;
952
  const pull = () => {
953
  void fetchAlerts().then((r) => {
 
961
  live = false;
962
  window.removeEventListener("focus", pull);
963
  };
964
+ }, [hostWorkspace, scope]);
965
 
966
  const [fields, setFields] = useState<Field[]>([]);
967
  const [views, setViews] = useState<SavedView[]>([]);
 
984
  const [expandAt, setExpandAt] = useState<
985
  { pid: number; x: number; y: number; size: number } | null
986
  >(null);
987
+ /**
988
+ * ⭐ W35-T29 β€” the same idea at the other end of the same cell: which record the hovered star
989
+ * belongs to, and where it sits. A separate state from `expandAt` because the two have
990
+ * different minimum row heights (this mark is smaller), so a compact row can carry one and not
991
+ * the other, and a shared state would have to pick.
992
+ */
993
+ const [starHover, setStarHover] = useState<
994
+ { pid: number; x: number; y: number; size: number } | null
995
+ >(null);
996
  const [detailPid, setDetailPid] = useState<number | null>(null);
997
  const [columnMenu, setColumnMenu] = useState<ColumnMenuState | null>(null);
998
  /** Open choice list for a `select` / `user` cell β€” see onCellClicked. */
 
1148
  useEffect(() => {
1149
  if (!payload || payloadFields.length === 0 || initializedKey.current === storageKey) return;
1150
  if (isQueryPreview && queryBinding) {
1151
+ /**
1152
+ * ⭐⭐ W35-T23 + T25 (R2/R3) β€” THE SERVER OWNS A QUERY ARTEFACT'S SPEC, AND THIS SURFACE
1153
+ * KEEPS NO SECOND COPY. That is the whole of "a resize survives a reload" now.
1154
+ *
1155
+ * `binding.view` IS the stored spec (`routes_query` accepts a `view_upsert` of the
1156
+ * artefact's own view and `queryPreviewView` reads every editable member back off it), so a
1157
+ * width dragged on one device is there on the next, on any device, without a local bucket.
1158
+ *
1159
+ * β›”β›” THE FIRST DRAFT SEEDED FROM `readLocal(storageKey)` AND IT WAS WRONG IN THE WORST
1160
+ * SHAPE THIS REPO CATALOGUES β€” works, then does not, then works again. The local copy had to
1161
+ * be gated on `binding.edited` so a Revert was not silently undone on that device; but
1162
+ * `binding` is memoised on `active`, and NOTHING in the autosave path refetches the artefact
1163
+ * β€” so after the FIRST edit in a session the flag was still `false`, and navigating away and
1164
+ * back inside that session dropped the width while a full reload brought it back. A second
1165
+ * store for one spec, plus a flag to arbitrate between them, is the shape R4 forbids one
1166
+ * ruling over. There is one store now and nothing to arbitrate.
1167
+ *
1168
+ * ⚠ THE COST, STATED: an edit whose POST fails is lost at the next mount, and the reader is
1169
+ * TOLD (the autosave toasts the failure). That is the honest trade against a local copy that
1170
+ * can disagree with the server about what the artefact is.
1171
+ */
1172
+ const view = queryPreviewView(queryBinding, payloadFields);
1173
  setFields(payloadFields);
1174
+ setViews([view]);
1175
+ setActiveViewId(view.id);
1176
+ setConfig(view.config);
1177
  setWorkspaceReady(true);
1178
  initializedKey.current = storageKey;
1179
  return;
 
1271
  * the config the client is actually filtering with, and sending anything else would ask the
1272
  * host to resolve a question nobody on screen is asking.
1273
  */
1274
+ if (hostWorkspace && reemit.size > 0) {
1275
  const stamped = { ...(local?.reemitted ?? {}) };
1276
  for (const view of initialViews) {
1277
  const key = reemit.get(view.id);
 
1286
  } else {
1287
  reemittedRef.current = { ...(local?.reemitted ?? {}) };
1288
  }
1289
+ }, [payload, payloadFields, storageKey, hostWorkspace, scope, isQueryPreview, queryBinding]);
1290
 
1291
  useEffect(() => {
1292
+ // β›” `hostWorkspace`, and a Query surface is deliberately EXCLUDED (W35-T23 + T25). Its spec
1293
+ // lives on the server, which is the only copy β€” writing a second one here would be a store
1294
+ // with no reader, and gating a read on which of the two is fresher is the bug the init
1295
+ // effect's note above describes. A LOCKED source's own workspace is a different question and
1296
+ // is unaffected: that is `hostWorkspace` true, `recordsMutable` false.
1297
+ if (!workspaceReady || !hostWorkspace) return;
1298
  writeLocal(storageKey, {
1299
  fields,
1300
  views,
 
1311
  // drop a view this browser created seconds ago.
1312
  viewWrites: pruneTombstones(viewWritesRef.current, Date.now()),
1313
  });
1314
+ }, [workspaceReady, storageKey, fields, views, activeViewId, hostWorkspace]);
1315
 
1316
  /**
1317
  * ⭐ THE LIVE WORKSPACE (owner report, 2026-08-04) β€” what has APPEARED since we mounted.
 
1336
  */
1337
  const hostWorkspaceViews = payload?.workspace?.views;
1338
  useEffect(() => {
1339
+ // `hostWorkspace`: this adopts views out of `payload.workspace`, which a Query surface never
1340
+ // fetches. Keyed on `embedded` it would read a workspace that is not there.
1341
+ if (!workspaceReady || !hostWorkspace) return;
1342
  const now = Date.now();
1343
  const nextFields = adoptNewFields(
1344
  fields, payloadFields, fieldStampsRef.current.deleted, now
 
1348
  adoptNewViews(current, hostWorkspaceViews, viewTombstonesRef.current, now,
1349
  (config) => normalizeConfig(config, nextFields))
1350
  );
1351
+ }, [workspaceReady, hostWorkspaceViews, payloadFields, fields, hostWorkspace]);
1352
 
1353
  /** Item 3c β€” stamp a def write / a delete. Pruned at every touch so the persisted blob
1354
  * stays a recent window, never an archive. */
 
1413
  * this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD
1414
  * row.
1415
  */
1416
+ const isUserTable = !embedded && scope.startsWith("ut_");
1417
  const recordsMutable = payload?.recordsMutable !== false;
1418
  const canMutateRecords = isUserTable && recordsMutable;
1419
  /**
 
1571
 
1572
  // Airtable behavior: configuration changes to the active view autosave.
1573
  useEffect(() => {
1574
+ // ⭐⭐ W35-T23 β€” a Query view AUTOSAVES like any other view; only its TRANSPORT differs.
1575
+ if (!workspaceReady || embedded) return;
1576
  const active = views.find((view) => view.id === activeViewId);
1577
  if (!active || sameConfig(active.config, config)) {
1578
  setSaveState("saved");
 
1587
  );
1588
  viewWritesRef.current = stampTombstone(viewWritesRef.current, updated.id,
1589
  Date.now());
1590
+ // β›” THE ROUTER, NEVER A BARE `emitHostEvent`. A raw host event carries the MODULE write
1591
+ // scope, which for a Query surface is the SOURCE database β€” so autosaving a resize would
1592
+ // have written a view into the source's own workspace under the artefact's id. The router
1593
+ // is what keeps "which store does this spec belong to" answered in one place.
1594
+ const routed = routeQueryViewMutation(queryBinding, scope, {
1595
+ id: eventId(queryBinding ? "query-view-update" : "view"),
1596
+ type: "view_upsert",
1597
+ view: updated as unknown as Record<string, unknown>,
1598
+ }, { query: mutateQueryWorkspace, native: emitHostEvent });
1599
+ if (routed.channel === "refused")
1600
+ signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved.");
1601
+ // ⚠ REPORTED, not swallowed. A write that fails in silence reads exactly like a read that
1602
+ // never happened [[lost-write-looks-like-failed-read]], and this is the one path a person
1603
+ // triggers by dragging a column edge.
1604
+ else if (routed.channel === "query")
1605
+ void routed.result.then((result) => {
1606
+ if (!result.ok) signal(TOAST_EVENT, result.message);
1607
+ });
1608
  setSaveState("saved");
1609
  saveTimer.current = null;
1610
  }, 420);
1611
  return () => {
1612
  if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
1613
  };
1614
+ }, [workspaceReady, activeViewId, config, views, embedded, queryBinding, scope]);
1615
 
1616
  // Numeric dimensions force a glide relayout when either the component frame
1617
  // or Streamlit's main column changes width (notably sidebar collapse).
 
1642
  const mode = tableMode(payload?.counts);
1643
  const serverWindowed = mode === "server-windowed";
1644
  // Owner items 4+6 β€” the Cohort page's grid renders without the Views sidebar.
1645
+ const hideViews = !hostWorkspace || payload?.workspace?.hideViews === true;
1646
  // Wave-6 item 10 β€” how this view displays. A WINDOWED table is always the grid: list/
1647
  // calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule,
1648
  // the same reason grouping is off there).
 
1780
  // permissions vs the viewer, fail-closed on restricted fields when the viewer is unknown.
1781
  const viewer = payload?.viewer;
1782
  const canEditField = useCallback(
1783
+ (f: Field): boolean => !embedded && recordsMutable && mayEditField(f, viewer),
1784
+ [embedded, recordsMutable, viewer]
1785
  );
1786
  const unresolvedCount = useMemo(
1787
  () => unresolvedConditions(config.filters, { cohortSets, today }),
 
1986
  () => (serverWindowed ? windowedCapabilityNote(payload?.counts?.matched ?? 0) : null),
1987
  [serverWindowed, payload?.counts?.matched]
1988
  );
1989
+ /**
1990
+ * ⭐⭐ W35-T24 (owner item 4 / R2, R6's second sentence) β€” WHY THERE IS NO TRAILING "+".
1991
+ *
1992
+ * β›” NOT SCOPED TO QUERY, DELIBERATELY, AND THAT IS WIDER THAN THE TICKET ASKED. The predicate
1993
+ * is the SOURCE's (`records_mutable`, DESIGN.md Β§4's locked-database vocabulary), so the answer
1994
+ * belongs to the database rather than to the surface looking at it. Scoping the sentence to
1995
+ * Query would make one database say two different things depending on which door you opened it
1996
+ * through, which is the failure a shared predicate exists to prevent.
1997
+ *
1998
+ * ⚠ `embedded` IS EXEMPT. A linked-record grid inside a modal has no "+" because it is a
1999
+ * picker, not a table, and a footnote apologising for that would be chrome in a dialog.
2000
+ */
2001
+ /**
2002
+ * ⭐ W35-T30 (C5) β€” the list the RAIL renders: this workspace's views, plus the server's
2003
+ * "Starred records" projection when there is one. See the `views=` prop below for why it is
2004
+ * appended here and not merged into `views`.
2005
+ */
2006
+ const railViews = useMemo(() => {
2007
+ const projection = recordStars.stars.view;
2008
+ if (!projection || views.some((view) => view.id === projection.id)) return views;
2009
+ return [...views, projection];
2010
+ }, [views, recordStars.stars.view]);
2011
+ const lockedNote = useMemo(
2012
+ // ⚠ The label is the ARTEFACT's source name when there is one, and otherwise nothing: the
2013
+ // payload carries no database name, and "This database" is unambiguous on a surface whose
2014
+ // header already says which one it is. Guessing a name from the scope key would print
2015
+ // `ut_odoo_customers` at somebody.
2016
+ () => (embedded ? null
2017
+ : lockedRecordsNote(recordsMutable, isUserTable, queryBinding?.source.label)),
2018
+ [embedded, recordsMutable, isUserTable, queryBinding]
2019
+ );
2020
  const displayRows = useMemo(() => {
2021
  const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows;
2022
  // ⚠ APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself β€” and it is the
 
2722
  // and a `position: fixed` button with no clamp would paint over the chrome on a row
2723
  // nobody can see. The horizontal legs are now defence in depth (a column drag, a box
2724
  // narrower than the frozen strip) rather than the everyday case.
2725
+ const boxRect = gridBoxRef.current?.getBoundingClientRect();
2726
+ const at = b ? expandButtonRect(b, boxRect) : null;
2727
+ // W35-T29 β€” the star book-ends the same cell the Expand does, from the SAME bounds. It
2728
+ // has its own minima (smaller mark, left end), so a compact row that cannot carry the
2729
+ // Expand can still carry this one.
2730
+ const starAt = b ? starButtonRect(b, boxRect) : null;
2731
+ setStarHover((prev) =>
2732
+ !starAt
2733
+ ? null
2734
+ : prev && prev.pid === pid && prev.x === starAt.x && prev.y === starAt.y
2735
+ ? prev
2736
+ : { pid, x: starAt.x, y: starAt.y, size: starAt.size }
2737
+ );
2738
  // Recomputed EVERY move, not memoised on the pid: a scroll can leave the pointer over
2739
  // the same record at a new y, and a button that keeps the pid but not the position
2740
  // floats over the wrong row. Referential stability is preserved by comparing values,
 
3351
  visibleCols, fieldByKey, canEditField]
3352
  );
3353
 
3354
+ /**
3355
+ * ⭐⭐ W35-T23 (R2) β€” ONE BODY FOR BOTH SURFACES, and the branch that used to sit here is why.
3356
+ *
3357
+ * It had a `if (queryBinding) { ...refuse; return; }` prologue that never touched `views`, so a
3358
+ * Query surface could not change its own spec at all. R2 makes it live, and the ONLY difference
3359
+ * left is which transport the router picks β€” which is `routeQueryViewMutation`'s whole job, so
3360
+ * asking the question twice (once here, once inside it) was the duplication that made the two
3361
+ * paths drift.
3362
+ *
3363
+ * ⚠ THE STATE UPDATE MUST HAPPEN FOR BOTH. Without it the autosave effect compares `config`
3364
+ * against a `views` entry that never moved, `sameConfig` stays false, and the effect re-fires
3365
+ * every render β€” a POST per frame for the life of the surface.
3366
+ *
3367
+ * ⚠ A view whose id is NOT the artefact's is still refused by the router (a Query workspace
3368
+ * holds exactly one view), so Duplicate cannot smuggle a second one in.
3369
+ */
3370
  const persistView = useCallback((view: SavedView) => {
 
 
 
 
 
 
 
 
 
 
3371
  setViews((current) => {
3372
  const found = current.some((item) => item.id === view.id);
3373
  return found
3374
  ? current.map((item) => (item.id === view.id ? view : item))
3375
  : [...current, view];
3376
  });
3377
+ const routed = routeQueryViewMutation(queryBinding, scope, {
3378
+ id: eventId(queryBinding ? "query-view-update" : "view"),
3379
+ type: "view_upsert",
3380
+ view: view as unknown as Record<string, unknown>,
3381
  }, { query: mutateQueryWorkspace, native: emitHostEvent });
3382
+ if (routed.channel === "refused")
3383
+ signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved.");
3384
+ else if (routed.channel === "query")
3385
+ void routed.result.then((result) => {
3386
+ if (!result.ok) signal(TOAST_EVENT, result.message);
3387
+ });
3388
  }, [queryBinding, scope]);
3389
 
3390
  /**
 
3424
  const current = views.find((view) => view.id === activeViewId);
3425
  if (current && !sameConfig(current.config, config))
3426
  persistView({ ...current, config });
3427
+ /**
3428
+ * ⭐⭐ W35-T30 (C5) β€” "Starred records" IS SELECTABLE AND IS NOT IN `views`, DELIBERATELY.
3429
+ *
3430
+ * β›” IT IS THE SERVER'S PROJECTION, NOT A WORKSPACE VIEW. Half this component keys off
3431
+ * `views`: the autosave, the echo reconcile, `writeLocal`, `uniqueDisplayName`. Merging a
3432
+ * server-owned object into that state would put it into the autosave's comparison and this
3433
+ * browser would start writing a view it does not own β€” the shape D-170 refuses on a
3434
+ * read-through grid, arriving from the client side instead. So it joins the RAIL's list
3435
+ * (see `railViews`) and is resolved here by name, which is the same split the comment on
3436
+ * `applyViewOrder` makes for view ORDER: a rendering fact about one surface.
3437
+ */
3438
+ const next = views.find((view) => view.id === id)
3439
+ ?? (id === recordStars.stars.view?.id ? recordStars.stars.view : undefined);
3440
  if (!next) return;
3441
  setActiveViewId(id);
3442
  setConfig(normalizeConfig(next.config, fields));
 
3451
  // Owner item 10: opening a view is "I am working now" β€” the frame folds its nav rail.
3452
  signal(NAV_MINIMIZE_EVENT);
3453
  },
3454
+ [views, activeViewId, config, persistView, fields, isQueryPreview, recordStars.stars.view]
3455
  );
3456
 
3457
  /**
 
3855
  * order β€” two lists that decide "is there a number here" by different rules would drift, and
3856
  * the drift would show as a row wearing both a count and an excuse.
3857
  */
3858
+ /*
3859
+ * β›”β›” `importantTotal` IS DELETED (W35-T27, owner item 9 / R4).
 
 
 
 
 
 
 
 
 
 
3860
  *
3861
+ * W33-T16 built it to answer *"the database should have the sum number of all the views with
3862
+ * mark important numbers"*, and W34-T20 stripped its label. Owner item 9 removes the count
3863
+ * from every surface EXCEPT the view itself, so the rail badge it fed is gone and this memo
3864
+ * had no reader. Deleted rather than left computing a value nobody renders
3865
+ * [[artifact-with-no-importer]].
3866
  *
3867
+ * ⚠ `importantIds` SURVIVES β€” it also feeds the `alerted βˆͺ important` union just above, which
3868
+ * is what decides which views get counted at all. This deletion is the SUM, not the set.
 
 
 
3869
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
3870
 
3871
  const countNotes = useMemo(() => {
3872
  const out: Record<string, string> = {};
 
5431
  const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
5432
  const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN;
5433
  // Item 10 β€” the toolbar's mode switcher (grid-only on windowed tables, see displayMode).
5434
+ const modeControl = serverWindowed || embedded || isQueryPreview ? undefined : (
5435
  <ModeSwitch
5436
  mode={displayMode}
5437
  onMode={setDisplayMode}
 
5565
  * fact about ONE surface; folding it into the state would make every consumer's
5566
  * behaviour depend on a drag, and the drag's whole scope is which row sits where.
5567
  */
5568
+ /**
5569
+ * ⭐⭐ W35-T30 Β· CONTRACT C5 (R5) β€” "Starred records" JOINS THE RAIL, NOT THE STATE.
5570
+ *
5571
+ * The server hands back the WHOLE view object (`kind: "system"`, `locked: true`,
5572
+ * `config.memberPids`), so this side never mints a second idea of what it is, and
5573
+ * `types.UNDELETABLE_VIEW_IDS` carries its id beside `all-customers` β€” the same two
5574
+ * facts that make that one undeletable make this one.
5575
+ *
5576
+ * β›” `view` IS `null` UNTIL SOMETHING IS STARRED, and the row is not drawn then. A
5577
+ * view promising starred records and listing none is the D-229 shape: a name that
5578
+ * asserts a set, over an empty one.
5579
+ * β›” AND IT IS APPENDED HERE rather than merged into `views` for the reason
5580
+ * `applyViewOrder` states one comment up: `views` is the workspace's list and the
5581
+ * autosave, the echo reconcile and `writeLocal` all key off it. A server-owned object
5582
+ * in that state means this browser starts writing a view it does not own.
5583
+ */
5584
+ views={applyViewOrder(railViews, folderStamps, Date.now())}
5585
  alertCounts={alertCounts}
5586
  countNotes={countNotes}
 
5587
  activeViewId={activeViewId}
5588
  saveState={saveState}
5589
  onSelect={selectView}
 
5849
  // canvas, so reaching for it IS an out-of-bounds event and dismissing there would
5850
  // unmount the control under the arriving pointer. Leaving the whole grid box is the
5851
  // honest "you are done with this row".
5852
+ onMouseLeave={() => {
5853
+ setExpandAt((prev) => (prev === null ? prev : null));
5854
+ // W35-T29: the star control is dismissed with the Expand, and it is HOVER-SCOPED β€”
5855
+ // say so rather than implying otherwise. It is how you SET a star, and it shows
5856
+ // whether the row under the pointer has one. The persistent answer to "which records
5857
+ // are starred" is R5's own design: the undeletable "Starred records" view beside "All
5858
+ // records" (W35-T30). Marking every starred row in the canvas as well would need a
5859
+ // `getBounds` per visible row on every repaint, for a fact the view already answers.
5860
+ setStarHover((prev) => (prev === null ? prev : null));
5861
+ }}
5862
  >
5863
  {displayMode === "grid" && (
5864
  <>
 
5942
  height={gridSize.height}
5943
  customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
5944
  headerIcons={HEADER_ICONS}
5945
+ rightElement={embedded ? undefined : (
5946
  // Wave-6 item 4 β€” the "+" of the header row: the create-field form,
5947
  // insert-at-end.
5948
  //
 
6136
  docs={payload?.docs}
6137
  docPayload={payload?.docPayload}
6138
  onDocAdd={
6139
+ !embedded && payload?.docs
6140
  ? (pid, file) =>
6141
  emitHostEvent({
6142
  id: eventId("docadd"),
 
6148
  : undefined
6149
  }
6150
  onDocFetch={
6151
+ !embedded && payload?.docs
6152
  ? (pid, docId) =>
6153
  emitHostEvent({ id: eventId("docget"), type: "doc_fetch", pid, docId })
6154
  : undefined
6155
  }
6156
  onDocDelete={
6157
+ !embedded && payload?.docs
6158
  ? (pid, docId) =>
6159
  emitHostEvent({ id: eventId("docdel"), type: "doc_delete", pid, docId })
6160
  : undefined
 
6322
  </svg>
6323
  </button>
6324
  )}
6325
+ {/* ⭐⭐ W35-T29 (R5, R6) β€” THE RECORD STAR, at the LEFT end of the row's primary cell,
6326
+ book-ending the Expand at its right end. A real DOM button over the canvas, at
6327
+ glide's own bounds, so the rect IS the hit test and there is no drawn affordance
6328
+ whose click target can drift from its paint ([[ui-invisible-to-assertions]]).
6329
+
6330
+ ⚠ HOVER-SCOPED, AND THAT IS STATED RATHER THAN IMPLIED. This is how you SET a star
6331
+ and it reports whether the row under the pointer has one. "Which records are
6332
+ starred" is answered by the undeletable "Starred records" view beside "All records"
6333
+ (W35-T30), which is R5's own design and costs no per-row bounds call.
6334
+
6335
+ ⚠ `recordStars.ready` gates it: before the read answers, this client does not know
6336
+ the current value, and a control drawn on a guess would flip the wrong way. */}
6337
+ {!embedded && displayMode === "grid" && starHover && recordStars.ready && (
6338
+ <button
6339
+ type="button"
6340
+ className={"cg-row-star" + (recordStars.starred(starHover.pid) ? " is-on" : "")}
6341
+ style={{
6342
+ left: starHover.x,
6343
+ top: starHover.y,
6344
+ width: starHover.size,
6345
+ height: starHover.size,
6346
+ }}
6347
+ aria-pressed={recordStars.starred(starHover.pid)}
6348
+ title={recordStars.starred(starHover.pid) ? "Unstar this record" : "Star this record"}
6349
+ aria-label={
6350
+ recordStars.starred(starHover.pid) ? "Unstar this record" : "Star this record"
6351
+ }
6352
+ // The same `preventDefault` the Expand needs: mousedown would otherwise reach the
6353
+ // canvas underneath, move the selection and repaint the row a frame before the
6354
+ // click lands, which reads as a flicker on every star.
6355
+ onMouseDown={(event) => event.preventDefault()}
6356
+ onClick={() =>
6357
+ recordStars.toggle(starHover.pid, !recordStars.starred(starHover.pid))}
6358
+ >
6359
+ <RecordStarMark size={13} filled={recordStars.starred(starHover.pid)} />
6360
+ </button>
6361
+ )}
6362
  {/* Wave-5 item 6 β€” the header description tip. pointer-events:none + aria-hidden:
6363
  it can never become a click target, so the control under it stays clickable
6364
  (the tooltip-swallows-the-next-click trap, paid for once already). */}
 
6499
  ⚠ The display cap and the server's limits are INDEPENDENT FACTS about one grid: one is
6500
  how much we are painting, the other is what the server could not do. They belong in
6501
  the same strip and neither may suppress the other. */}
6502
+ {(foldNote || limitNote || capabilityNote || lockedNote) && (
6503
  <div className="cg-more">
6504
  {foldNote && <span className="cg-more-note">{foldNote}</span>}
6505
  {limitNote && (
 
6510
  {capabilityNote.short}
6511
  </span>
6512
  )}
6513
+ {/* ⭐⭐ W35-T24 β€” WHY THERE IS NO "+", in the strip that already answers "why is this
6514
+ grid not doing what I expected". ⚠ BOTH `title` AND `aria-label`, because D-253
6515
+ booked "explains why it cannot, on hover only" as a defect once already: a title
6516
+ is not announced and an aria-label does not hover. */}
6517
+ {lockedNote && (
6518
+ <span className="cg-more-note" title={lockedNote.full} aria-label={lockedNote.full}>
6519
+ {lockedNote.short}
6520
+ </span>
6521
+ )}
6522
  </div>
6523
  )}
6524
  {capped && (displayMode === "grid" || displayMode === "list") && (
 
7286
  fields={fields}
7287
  record={detailRecord}
7288
  titleKey={lockedKey}
7289
+ // ⭐ W35-T29 β€” the SAME star state the row control uses, never a second client. An
7290
+ // embedded grid has neither: `recordStars` is disabled there, so `ready` is false and
7291
+ // the drawer draws no control rather than a dead one.
7292
+ starred={recordStars.starred(detailPid)}
7293
+ {...(recordStars.ready
7294
+ ? { onStar: (on: boolean) => recordStars.toggle(detailPid, on) }
7295
+ : {})}
7296
  positionLabel={`Record ${dataPosition.toLocaleString()} of ${recordCount.toLocaleString()}`}
7297
  canPrev={neighborExists(-1)}
7298
  canNext={neighborExists(1)}
 
7313
  // on the NEXT render via `payload.docPayload`; Documents.tsx matches it
7314
  // to its own pending request before touching it.
7315
  onDocAdd={
7316
+ !embedded && payload?.docs
7317
  ? (file) =>
7318
  emitHostEvent({
7319
  id: eventId("docadd"),
 
7325
  : undefined
7326
  }
7327
  onDocFetch={
7328
+ !embedded && payload?.docs
7329
  ? (docId) =>
7330
  emitHostEvent({
7331
  id: eventId("docget"),
 
7336
  : undefined
7337
  }
7338
  onDocDelete={
7339
+ !embedded && payload?.docs
7340
  ? (docId) =>
7341
  emitHostEvent({
7342
  id: eventId("docdel"),
web/src/customer-grid/RecordDetail.tsx CHANGED
@@ -102,7 +102,13 @@ import { Documents } from "./Documents";
102
  import { FIELD_DRAG_TYPE, moveKey, nudgeKey, resolveFieldOrder } from "./recordLayout";
103
  import RecordComments from "./RecordComments";
104
  import { isStreamlitComponent } from "./hostBridge";
 
 
 
105
  import "./RecordDetail.css";
 
 
 
106
 
107
  export interface RecordDetailProps {
108
  fields: Field[];
@@ -116,6 +122,18 @@ export interface RecordDetailProps {
116
  onClose: () => void;
117
  onNotesChange: (key: string, value: string) => void;
118
  onNotesCommit: (key: string, value: string) => void;
 
 
 
 
 
 
 
 
 
 
 
 
119
  /** Wave-5 item 1 β€” the permissions courtesy check rides into the drawer too: a field the
120
  * viewer may not edit renders read-only here, exactly as its cells do. */
121
  viewer?: Viewer;
@@ -286,6 +304,8 @@ export default function RecordDetail({
286
  onClose,
287
  onNotesChange,
288
  onNotesCommit,
 
 
289
  viewer,
290
  docs,
291
  docPayload,
@@ -1133,6 +1153,25 @@ export default function RecordDetail({
1133
  </button>
1134
  <span className="cg-detail-pos">{positionLabel}</span>
1135
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1136
  <button
1137
  type="button"
1138
  className="cg-detail-close"
 
102
  import { FIELD_DRAG_TYPE, moveKey, nudgeKey, resolveFieldOrder } from "./recordLayout";
103
  import RecordComments from "./RecordComments";
104
  import { isStreamlitComponent } from "./hostBridge";
105
+ // ⚠ ALIASED for the same reason `CustomerGrid` aliases it: `./Stars`'s `StarIcon` is the RATING
106
+ // atom and this one is the record MARK. Two meanings, one noun, both reachable in this tree.
107
+ import { StarIcon as RecordStarMark } from "../ui/icons";
108
  import "./RecordDetail.css";
109
+ // The drawer's star wears `.cg-detail-star`, which lives beside the grid's own row star β€” one
110
+ // sheet for one idea, imported by both modules that render its classes (W35-T22's lesson).
111
+ import "./rowStar.css";
112
 
113
  export interface RecordDetailProps {
114
  fields: Field[];
 
122
  onClose: () => void;
123
  onNotesChange: (key: string, value: string) => void;
124
  onNotesCommit: (key: string, value: string) => void;
125
+ /**
126
+ * ⭐ W35-T29 (C5, R5/R6) β€” this record's star, and the two halves travel TOGETHER.
127
+ *
128
+ * β›” BOTH OPTIONAL AND BOTH FROM THE GRID'S OWN `useRecordStars`, never a second client here:
129
+ * one list, one answer. An embedded grid passes neither and the control is ABSENT rather than
130
+ * disabled β€” a dead star is worse than no star.
131
+ * ⚠ `starred` alone would render a control that cannot be changed, and `onStar` alone would
132
+ * render one that cannot say what it currently is; the JSX gates on `onStar` so the pair is
133
+ * effectively all-or-nothing.
134
+ */
135
+ starred?: boolean;
136
+ onStar?: (on: boolean) => void;
137
  /** Wave-5 item 1 β€” the permissions courtesy check rides into the drawer too: a field the
138
  * viewer may not edit renders read-only here, exactly as its cells do. */
139
  viewer?: Viewer;
 
304
  onClose,
305
  onNotesChange,
306
  onNotesCommit,
307
+ starred = false,
308
+ onStar,
309
  viewer,
310
  docs,
311
  docPayload,
 
1153
  </button>
1154
  <span className="cg-detail-pos">{positionLabel}</span>
1155
  </div>
1156
+ {/* ⭐⭐ W35-T29 (R5, R6) β€” THE SAME RECORD STAR AS THE GRID'S, in the drawer.
1157
+ β›” THE SAME STATE, NOT A SECOND ONE: `starred`/`onStar` come from the grid's own
1158
+ `useRecordStars`, so starring here and starring in the row are one act on one list.
1159
+ A drawer with its own client would answer the same question twice and disagree the
1160
+ moment either wrote [[one-evaluator-per-question]].
1161
+ ⚠ ABSENT, not disabled, when the caller passes no handler β€” an embedded grid has no
1162
+ record stars at all, and a dead control is worse than none. */}
1163
+ {onStar && (
1164
+ <button
1165
+ type="button"
1166
+ className={"cg-detail-star" + (starred ? " is-on" : "")}
1167
+ aria-pressed={starred}
1168
+ aria-label={starred ? "Unstar this record" : "Star this record"}
1169
+ title={starred ? "Unstar this record" : "Star this record"}
1170
+ onClick={() => onStar(!starred)}
1171
+ >
1172
+ <RecordStarMark size={16} filled={starred} />
1173
+ </button>
1174
+ )}
1175
  <button
1176
  type="button"
1177
  className="cg-detail-close"
web/src/customer-grid/ViewSidebar.tsx CHANGED
@@ -234,13 +234,10 @@ interface ViewSidebarProps {
234
  /** ⭐ WAVE 33 Β· T14 (D-205) β€” for a counted view that got NO number, the sentence saying why.
235
  * Never both: a row carries a count or a note, and `alertCounts` is checked first. */
236
  countNotes?: Record<string, string>;
237
- /**
238
- * ⭐ WAVE 33 Β· T16 (owner item 5) β€” THIS DATABASE'S total across its MARKED views.
239
- * `null` when nothing here is marked (item 5's "a minimum of 1 view" clause, as data rather
240
- * than as a render condition). `counted < marked` means some marked view had no countable
241
- * number, and the rail must say so rather than present a short total as a whole one.
242
- */
243
- importantTotal?: { sum: number; counted: number; marked: number } | null;
244
  /**
245
  * C4 as AMENDED 2026-07-28 β€” the folder-level bulk "Add to cohort". The caller
246
  * owns the arithmetic (it holds the engine); the rail owns the confirm.
@@ -307,7 +304,6 @@ export default function ViewSidebar({
307
  onViewReorder,
308
  alertCounts,
309
  countNotes,
310
- importantTotal,
311
  folderAddPreview,
312
  onFolderAddToList,
313
  viewer,
@@ -623,47 +619,20 @@ export default function ViewSidebar({
623
  const noteView = noteFor
624
  ? views.find((view) => view.id === noteFor.viewId)
625
  : undefined;
626
- /**
627
- * ⭐⭐ WAVE 34 Β· T20 (R1, contract C1) β€” THE SENTENCE THE BARE NUMBER CANNOT CARRY.
628
- *
629
- * R1 drops the word "Important" from this badge, so what is left on screen is a figure with
630
- * no noun. W33-T16's own note argued the opposite ("an unlabelled figure at the top of a list
631
- * of views reads as a count OF VIEWS") and it was right about the risk; the owner's ruling
632
- * settles the pixels, and this is where the meaning goes instead. Hover and screen reader get
633
- * the whole fact, in one string, on the one element.
634
  *
635
- * β›” THE `counted === 0` BRANCH IS D-252, NOT A TIDY-UP. On a fully server-windowed database
636
- * every marked view returns no number, so `importantTotal` is `{sum: 0, counted: 0, marked: N}`
637
- * β€” TRUTHY β€” and the rail painted "Important 0+". Drop the word and that becomes a bare "0+",
638
- * which is strictly worse: a real zero and an unknown are the same three pixels. So a total
639
- * that could count NOTHING wears the same is-unknown mark a single uncountable view wears
640
- * (T14), and never a manufactured 0.
641
  *
642
- * ⚠ THE CAUSE IS QUOTED, NOT INVENTED. The two reasons a count is missing (a windowed grid, an
643
- * unresolved measure) produce different sentences and this rail cannot tell them apart, so it
644
- * reads the FIRST marked view's own `countNotes` entry rather than asserting one of them. No
645
- * note at all still gets a sentence with a next step, never silence β€” R6's second half.
646
  */
647
- let importantNote = "";
648
- if (importantTotal) {
649
- const { sum, counted, marked } = importantTotal;
650
- const nViews = `${marked} view${marked === 1 ? "" : "s"} marked important in this database`;
651
- if (counted === 0) {
652
- const cause = views
653
- .filter((v) => v.config?.important)
654
- .map((v) => countNotes?.[v.id])
655
- .find(Boolean);
656
- importantNote =
657
- `No number yet for the ${nViews}. `
658
- + (cause || "Open a marked view to see why its own count is missing.");
659
- } else if (counted < marked) {
660
- importantNote =
661
- `${sum.toLocaleString()} records across ${counted} of the ${nViews}. `
662
- + `The rest cannot be counted in the browser, so this total is lower than the real one.`;
663
- } else {
664
- importantNote = `${sum.toLocaleString()} records across the ${nViews}.`;
665
- }
666
- }
667
 
668
  const create = () => {
669
  const value = name.trim();
@@ -796,64 +765,19 @@ export default function ViewSidebar({
796
  />
797
  </svg>
798
  </button>
799
- {/* ⭐⭐ WAVE 33 Β· T16 (owner item 5) β€” THE DATABASE'S OWN TOTAL, at the top of its rail.
800
- Owner: the database should carry the SUM of its marked views' numbers, whenever at
801
- least one view in it is marked.
802
- β›” HERE, and not in the shell's database header, DELIBERATELY: this rail belongs to
803
- the OPEN database, so a total in it needs no database name to be unambiguous.
804
- ⚠ `counted < marked` is a PARTIAL total: some marked view could not be counted (a
805
- windowed grid, an unresolved measure). It says so instead of presenting a short sum
806
- as a whole one, which is D-205's rule one level up.
807
-
808
- ⭐⭐ WAVE 34 · T20 (R1) REVERSED THE LABEL DECISION, AND THE REVERSAL IS THE OWNER'S.
809
- T16 shipped this as the word "Important" plus a badge, on the argument recorded in
810
- its own note: "an unlabelled figure at the top of a list of views reads as a count OF
811
- VIEWS". R1 answers that directly, in the owner's words: the count belongs in the
812
- database NAVIGATION (B's flyout, contract C1), and what stays here is the number
813
- alone, smaller, with no word. The risk T16 named is real and is answered by
814
- `importantNote` rather than by a label: the badge carries the whole sentence in its
815
- `title` AND its `aria-label`, so hover and screen reader both get the noun the pixels
816
- gave up. β›” Do not re-add the word to satisfy the old note; `verify_grid_ux`'s
817
- `[VIEWS RAIL]` scan reds on it, with a control that reproduces the regression. */}
818
- {importantTotal && (
819
- <span
820
- className={
821
- "cg-views-important" +
822
- // ⚠ PARTIAL means "the number you can see is real and short". A total that counted
823
- // NOTHING has no number to be short, so it must not wear the dashed-purple partial
824
- // mark as well as the is-unknown one β€” two statements about one absence.
825
- (importantTotal.counted > 0 && importantTotal.counted < importantTotal.marked
826
- ? " is-partial"
827
- : "")
828
- }
829
- >
830
- {/* ⚠ `role="img"` ON BOTH, and it is not decoration. An `aria-label` on a plain
831
- roleless inline `<span>` is not reliably exposed by assistive technology, and
832
- this badge is now the ONLY carrier of a noun the pixels no longer say. `img` is
833
- the correct role for "a graphic whose whole meaning is its label", and it also
834
- stops a screen reader announcing the bare digits as loose text. */}
835
- {importantTotal.counted === 0 ? (
836
- <span
837
- className="cg-view-count is-unknown"
838
- role="img"
839
- title={importantNote}
840
- aria-label={importantNote}
841
- >
842
- -
843
- </span>
844
- ) : (
845
- <span
846
- className="cg-view-count"
847
- role="img"
848
- title={importantNote}
849
- aria-label={importantNote}
850
- >
851
- {importantTotal.sum.toLocaleString()}
852
- {importantTotal.counted < importantTotal.marked ? "+" : ""}
853
- </span>
854
- )}
855
- </span>
856
- )}
857
  </div>
858
  {/*
859
  I13 β€” the "Views / All changes saved" header block is GONE and everything moved up.
@@ -1499,10 +1423,13 @@ export default function ViewSidebar({
1499
  // same place), so a single sentence about alerting would tell a reader who
1500
  // marked a view that they had created an alert they did not create. The
1501
  // number is the same fact; the promise attached to it is not.
 
 
 
1502
  title={
1503
  view.config?.important
1504
  ? `${alertCounts[view.id].toLocaleString()} records match this view. `
1505
- + `You marked it important.`
1506
  : `${alertCounts[view.id].toLocaleString()} records match this `
1507
  + `view. You are alerted when a new one arrives.`
1508
  }
@@ -2069,16 +1996,25 @@ export default function ViewSidebar({
2069
  holds views; the scope key belongs to the route) β€” and because the server
2070
  refuses a view with no active filter, which is a message the frame is
2071
  already in the business of showing. */}
2072
- {/* ⭐⭐ WAVE 32 Β· T24 (owner item 17, ruling R5, contract C4) β€” "Mark important".
2073
  β›” ABOVE the alert row, which is the done-when's own word and not a nicety: the two
2074
  rows produce THE SAME red pill in the rail, and the cheap, private, instantly
2075
  reversible one has to be reachable before the one that creates a stored server-side
2076
  alert. Offered first, a user who only wanted to keep an eye on a number never has
2077
  to make an alert to get one.
2078
- β›” ONE MARK. R5 is explicit: no second severity, no colour variants β€”
2079
- `verify_icons` asserts the absence of the word so a second one cannot arrive by
2080
  copy-paste from this very row. The label FLIPS rather than the state being implied,
2081
- because "Mark important" on an already-marked view reads as a label for the view. */}
 
 
 
 
 
 
 
 
 
2082
  {onToggleImportant && (
2083
  <button
2084
  type="button"
@@ -2099,11 +2035,11 @@ export default function ViewSidebar({
2099
  `MenuLabel` takes `MenuIconName | ReactNode`, so a glyph no other menu row needs
2100
  costs the shared `MENU_ICONS` vocabulary nothing.
2101
  ⚠ `filled` is what says the view IS marked, so the pair still reads mark/unmark.
2102
- ⚠ `verify_icons`'s T24 leg pins the two LABEL strings and this row's position
2103
- above the alert row β€” neither moves here. */}
2104
  <MenuLabel
2105
  icon={<StarIcon size={16} filled={!!menuView.config?.important} />}
2106
- text={menuView.config?.important ? "Unmark important" : "Mark important"}
2107
  />
2108
  </button>
2109
  )}
 
234
  /** ⭐ WAVE 33 Β· T14 (D-205) β€” for a counted view that got NO number, the sentence saying why.
235
  * Never both: a row carries a count or a note, and `alertCounts` is checked first. */
236
  countNotes?: Record<string, string>;
237
+ /* β›” `importantTotal` IS GONE (W35-T27, owner item 9). It carried W33-T16's summed badge at the
238
+ top of this rail; the owner removed the count from everywhere except the view itself, so the
239
+ prop has no reader and its memo in `CustomerGrid` went with it rather than being left to
240
+ compute a value nobody renders [[artifact-with-no-importer]]. */
 
 
 
241
  /**
242
  * C4 as AMENDED 2026-07-28 β€” the folder-level bulk "Add to cohort". The caller
243
  * owns the arithmetic (it holds the engine); the rail owns the confirm.
 
304
  onViewReorder,
305
  alertCounts,
306
  countNotes,
 
307
  folderAddPreview,
308
  onFolderAddToList,
309
  viewer,
 
619
  const noteView = noteFor
620
  ? views.find((view) => view.id === noteFor.viewId)
621
  : undefined;
622
+ /*
623
+ * β›”β›” `importantNote` IS DELETED WITH THE BADGE IT LABELLED (W35-T27, owner item 9 / R4).
 
 
 
 
 
 
624
  *
625
+ * It existed because W34-T20's ruling took the WORD off the rail's summed badge and left a
626
+ * figure with no noun, so the whole sentence moved into that one element's `title` and
627
+ * `aria-label`. Item 9 removes the badge itself, so there is no element left to label. The
628
+ * per-view badges keep their own `countNotes` sentence (see the view row below) β€” that
629
+ * derivation is separate and is untouched.
 
630
  *
631
+ * ⚠ D-252's lesson does NOT die here, it moves: "a total that could count nothing must not
632
+ * paint a manufactured 0" now lives entirely in the per-view badge and in E's
633
+ * `GET /starred/counts`, whose `notes` map carries the cause for a view whose count is not
634
+ * free (contract C3). Nothing about it is now stated in two places.
635
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
 
637
  const create = () => {
638
  const value = name.trim();
 
765
  />
766
  </svg>
767
  </button>
768
+ {/* β›”β›” THE DATABASE'S OWN TOTAL IS DELETED FROM THIS ROW (W35-T27, owner item 9 / R4).
769
+ Owner, verbatim: *"remove [the important count] anywhere else but the View itself."*
770
+ W33-T16 put a summed badge here and W34-T20 stripped its label; item 9 removes the
771
+ badge outright. **The PER-VIEW badge on each view row is untouched** β€” that is what
772
+ "the View itself" means, and it is the half that survives.
773
+ ⚠ THE ROW IS NOT EMPTY: `.cg-views-top` still carries the minimize toggle, which is
774
+ now its ONLY child and therefore genuinely first (W35-T28). A's W35-T11 flipped this
775
+ row to `justify-content: flex-start` and deleted all four `.cg-views-important*`
776
+ rules; the badge's `margin-left: auto` had been OUTRANKING that flex rule, which is
777
+ why the toggle sat left on a database with a marked view and right on one without.
778
+ β›” `verify_grid_ux.py`'s `[VIEWS RAIL]` scan is INVERTED, not deleted: it asserts the
779
+ badge's ABSENCE from this row and its PRESENCE on the view rows, so re-adding one
780
+ reds it. */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  </div>
782
  {/*
783
  I13 β€” the "Views / All changes saved" header block is GONE and everything moved up.
 
1423
  // same place), so a single sentence about alerting would tell a reader who
1424
  // marked a view that they had created an alert they did not create. The
1425
  // number is the same fact; the promise attached to it is not.
1426
+ // ⭐ W35-T26 (R4): the word on screen is STAR. The stored key is still
1427
+ // `config.important` β€” R4 makes them ONE flag, so renaming the key would
1428
+ // create the second store the ruling forbids.
1429
  title={
1430
  view.config?.important
1431
  ? `${alertCounts[view.id].toLocaleString()} records match this view. `
1432
+ + `You starred it.`
1433
  : `${alertCounts[view.id].toLocaleString()} records match this `
1434
  + `view. You are alerted when a new one arrives.`
1435
  }
 
1996
  holds views; the scope key belongs to the route) β€” and because the server
1997
  refuses a view with no active filter, which is a message the frame is
1998
  already in the business of showing. */}
1999
+ {/* ⭐⭐ WAVE 32 Β· T24 (owner item 17, ruling R5, contract C4) β€” the one mark.
2000
  β›” ABOVE the alert row, which is the done-when's own word and not a nicety: the two
2001
  rows produce THE SAME red pill in the rail, and the cheap, private, instantly
2002
  reversible one has to be reachable before the one that creates a stored server-side
2003
  alert. Offered first, a user who only wanted to keep an eye on a number never has
2004
  to make an alert to get one.
2005
+ β›” ONE MARK. W32-R5 is explicit: no second severity, no colour variants β€”
2006
+ `verify_icons` asserts the absence of a second one so it cannot arrive by
2007
  copy-paste from this very row. The label FLIPS rather than the state being implied,
2008
+ because a "star this" label on an already-starred view reads as a label for the view.
2009
+
2010
+ ⭐⭐ W35-T26 (owner item 9 / R4) β€” THE LABEL IS "STAR" NOW, AND ONLY THE LABEL.
2011
+ Owner: star and mark-important are ONE idea and ONE flag, and the word on screen is
2012
+ **Star**. So this pair reads Star / Unstar, the glyph was ALREADY a star (W33-T15),
2013
+ and the STORED key stays `config.important` β€” renaming the key would create exactly
2014
+ the second store R4 forbids, and would 400 every view already marked.
2015
+ ⚠ `verify_icons.py`'s T24 leg PINS these two strings and is A's file, so it reds on
2016
+ this rename until A retargets it at the CLAIM (one mark, above the alert row) rather
2017
+ than the spelling [[a-gate-that-pins-a-spelling-not-a-claim]]. Raised as `ASK C-10`. */}
2018
  {onToggleImportant && (
2019
  <button
2020
  type="button"
 
2035
  `MenuLabel` takes `MenuIconName | ReactNode`, so a glyph no other menu row needs
2036
  costs the shared `MENU_ICONS` vocabulary nothing.
2037
  ⚠ `filled` is what says the view IS marked, so the pair still reads mark/unmark.
2038
+ ⚠ `verify_icons`'s T24 leg pins this row's position above the alert row, which
2039
+ does not move here; W35-T26 changes only the two LABEL strings. */}
2040
  <MenuLabel
2041
  icon={<StarIcon size={16} filled={!!menuView.config?.important} />}
2042
+ text={menuView.config?.important ? "Unstar" : "Star"}
2043
  />
2044
  </button>
2045
  )}
web/src/customer-grid/counts.ts CHANGED
@@ -317,6 +317,55 @@ export function windowedCapabilityNote(matched: number): { short: string; full:
317
  };
318
  }
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  /**
321
  * The predicate this browser has asked the SERVER to evaluate, as one comparable string.
322
  *
 
317
  };
318
  }
319
 
320
+ /**
321
+ * ⭐⭐ W35-T24 (owner item 4 / R2, and R6's SECOND SENTENCE) β€” WHY THIS GRID HAS NO "+".
322
+ *
323
+ * β›” THE DEFECT THIS EXISTS FOR IS AN ABSENCE, WHICH IS WHY NOTHING CAUGHT IT. A grid whose source
324
+ * cannot take a record simply has no trailing row: correct, and indistinguishable from a table
325
+ * that has not loaded, from a permission the reader lacks, and from a bug. D-258 is the same
326
+ * shape one column over (ten blank columns on a locked connected-source grid with nothing in the
327
+ * payload saying why) and it was booked as a defect, not as restraint. R6's rule is that a limit
328
+ * which cannot be removed must be REPORTED with its cause and a recommended next step.
329
+ *
330
+ * ⚠ TWO DIFFERENT CAUSES, TWO DIFFERENT SENTENCES, because only one of them has a next step:
331
+ * Β· a LOCKED database (`recordsMutable === false`) β€” rows arrive from a connector and are
332
+ * replaced by it, so adding one here would be overwritten. The next step is real: add it in
333
+ * the system that owns the records, and it appears here on the next sync.
334
+ * Β· a BUILT-IN database (not `ut_*`) β€” there is no rows endpoint to POST to at all. The next
335
+ * step is a different door, not this one.
336
+ * Collapsing them into "you cannot add records here" would tell the first reader nothing they can
337
+ * act on, which is the half of R6 that gets dropped.
338
+ *
339
+ * ⚠ THE SHORT FORM IS A SENTENCE, NOT A LABEL. D-253 booked "explains why it cannot, on hover
340
+ * only" as a defect once already; the caller puts `full` in BOTH `title` and `aria-label`, and
341
+ * `short` has to stand alone for a reader who never hovers.
342
+ */
343
+ export function lockedRecordsNote(
344
+ recordsMutable: boolean,
345
+ isUserTable: boolean,
346
+ label?: string,
347
+ ): { short: string; full: string } | null {
348
+ if (recordsMutable && isUserTable) return null;
349
+ const name = (label || "").trim() || "This database";
350
+ if (!recordsMutable) {
351
+ return {
352
+ short: "Records here come from a connected source",
353
+ full:
354
+ `${name} is kept in step with a connected system, so records cannot be added, deleted `
355
+ + `or removed from this grid: the next sync would replace them. Columns you add here are `
356
+ + `yours and are kept. To add a record, add it in the system the data comes from and it `
357
+ + `arrives here on the next sync.`,
358
+ };
359
+ }
360
+ return {
361
+ short: "This database does not take new records here",
362
+ full:
363
+ `${name} is built from data this product assembles rather than from rows people type, so `
364
+ + `there is no place for this grid to put a new record. Everything else works: filter, `
365
+ + `sort, group, add columns and edit the cells you own.`,
366
+ };
367
+ }
368
+
369
  /**
370
  * The predicate this browser has asked the SERVER to evaluate, as one comparable string.
371
  *
web/src/customer-grid/overlayPlacement.ts CHANGED
@@ -423,6 +423,52 @@ export function expandButtonRect(
423
  return { x, y, size };
424
  }
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  /**
427
  * Where a panel of `panel` size should sit relative to `target`, inside `viewport`.
428
  *
 
423
  return { x, y, size };
424
  }
425
 
426
+ // ------------------------------------------------ W35-T29 (R5): the record star, on the row
427
+
428
+ /** Smaller than the Expand button: a mark, not an action with a destination. */
429
+ export const STAR_BTN_SIZE = 18;
430
+ /** From the LEFT edge of the primary cell, mirroring `EXPAND_BTN_INSET` at the other end. */
431
+ export const STAR_BTN_INSET = 4;
432
+
433
+ /**
434
+ * ⭐⭐ W35-T29 (R5) β€” where the row's star sits: **at the LEFT end of the primary cell**, which is
435
+ * the gutter position in this product. Airtable puts the row's own marks there; our Expand button
436
+ * already occupies the RIGHT end of the same cell (`expandButtonRect`), so the two book-end the
437
+ * primary cell and neither can land on the other.
438
+ *
439
+ * β›” THE SAME BOUNDS, FROM THE SAME SOURCE. `primary` is glide's own `getBounds(0, row)`, so
440
+ * freeze, horizontal scroll and row-height mode are handled by the component that owns them
441
+ * rather than re-derived here β€” and because the button is a real `<button>` over the canvas, this
442
+ * rect IS the hit test. A canvas-DRAWN star would need a second copy of this arithmetic for its
443
+ * click target, which is the shape [[ui-invisible-to-assertions]] names.
444
+ *
445
+ * ⚠ ITS MINIMA ARE ITS OWN AND ARE LOWER THAN THE EXPAND'S. This mark is smaller and sits where
446
+ * the text starts rather than where it ends, so a row that cannot carry the Expand can still
447
+ * carry this. Sharing `EXPAND_BTN_MIN_ROW` would have hidden the star on exactly the compact row
448
+ * heights a long list is read at.
449
+ *
450
+ * `null` when the row is too short, or when the rect would fall outside the grid's own box β€”
451
+ * absent rather than clamped, for the same reason the Expand is: a clamped `position: fixed`
452
+ * button sits at the edge pointing at a row whose cell is somewhere else.
453
+ */
454
+ export function starButtonRect(
455
+ primary: { x: number; y: number; width: number; height: number },
456
+ box?: { x: number; y: number; width: number; height: number },
457
+ size = STAR_BTN_SIZE,
458
+ inset = STAR_BTN_INSET
459
+ ): { x: number; y: number; size: number } | null {
460
+ if (primary.height < size + 2) return null;
461
+ // It must leave room for a readable label beside it, or the mark covers the name it marks.
462
+ if (primary.width < size + inset * 2 + EXPAND_BTN_MIN_LABEL) return null;
463
+ const x = Math.round(primary.x + inset);
464
+ const y = Math.round(primary.y + (primary.height - size) / 2);
465
+ if (box) {
466
+ if (x < box.x || x + size > box.x + box.width) return null;
467
+ if (y < box.y || y + size > box.y + box.height) return null;
468
+ }
469
+ return { x, y, size };
470
+ }
471
+
472
  /**
473
  * Where a panel of `panel` size should sit relative to `target`, inside `viewport`.
474
  *
web/src/customer-grid/queryPreview.ts CHANGED
@@ -48,9 +48,23 @@ export type QueryViewMutationRoute<T> =
48
  | { channel: "native" };
49
 
50
  /**
51
- * The sole bridge between existing view callbacks and a virtual Query artefact. Query owns
52
- * the mutation policy: creates and updates are refused locally, while a matching delete travels
53
- * only to E's Query transport. A denied/mismatched binding cannot fall through to `native`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  */
55
  export function routeQueryViewMutation<T>(
56
  binding: QueryVirtualBinding | undefined,
@@ -65,6 +79,12 @@ export function routeQueryViewMutation<T>(
65
  if (!acceptsQueryPreview(binding, scope)) return { channel: "refused", refusal: null };
66
  if (event.type === "view_delete" && event.viewId === binding.artifactId)
67
  return { channel: "query", result: sinks.query(binding, event) };
 
 
 
 
 
 
68
  return {
69
  channel: "refused",
70
  refusal: refuseQueryMutation(binding, event.type === "view_create" ? "create" : "update"),
 
48
  | { channel: "native" };
49
 
50
  /**
51
+ * The sole bridge between existing view callbacks and a virtual Query artefact. Query owns the
52
+ * routing policy: every mutation of the artefact's OWN view travels on E's Query transport and
53
+ * nothing else may. A denied/mismatched binding cannot fall through to `native`.
54
+ *
55
+ * ⭐⭐ W35-T23 (owner item 4 / R2) β€” **`view_upsert` ON THE ARTEFACT ITSELF NOW ROUTES INSTEAD OF
56
+ * BEING REFUSED.** Owner: *"I should be able to interact in each of the View under Query as well,
57
+ * exactly like how I would be able to interact with it under Database view."* Resizing a column,
58
+ * sorting, grouping, hiding and row height are all one thing to this function β€” a view spec being
59
+ * written β€” so making them work is a routing change, not a permission change.
60
+ *
61
+ * β›” TWO REFUSALS SURVIVE ON PURPOSE, AND THEY ARE THE ONES THAT ARE NOT ABOUT READ-ONLY:
62
+ * Β· **`view_create` stays refused.** A Query workspace holds EXACTLY ONE view, the artefact. A
63
+ * second one would have no artefact behind it, no citations, and no id the server could route.
64
+ * Β· **an upsert naming a DIFFERENT id stays refused.** Duplicate-view mints a new id and calls
65
+ * the same door; without this clause it would create that second view by another name.
66
+ * The delete rule is unchanged, and all three now read the same way: the artefact's own id is the
67
+ * only thing this transport will carry.
68
  */
69
  export function routeQueryViewMutation<T>(
70
  binding: QueryVirtualBinding | undefined,
 
79
  if (!acceptsQueryPreview(binding, scope)) return { channel: "refused", refusal: null };
80
  if (event.type === "view_delete" && event.viewId === binding.artifactId)
81
  return { channel: "query", result: sinks.query(binding, event) };
82
+ // ⚠ The id is read off the VIEW here, not off `event.viewId` β€” an upsert carries its subject
83
+ // in the payload, and a caller that set neither must not be treated as naming the artefact.
84
+ if (event.type === "view_upsert"
85
+ && typeof (event.view as { id?: unknown } | undefined)?.id === "string"
86
+ && (event.view as { id: string }).id === binding.artifactId)
87
+ return { channel: "query", result: sinks.query(binding, event) };
88
  return {
89
  channel: "refused",
90
  refusal: refuseQueryMutation(binding, event.type === "view_create" ? "create" : "update"),
web/src/customer-grid/recordStars.ts ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / recordStars.ts β€” W35-T29 + W35-T30, CONTRACT C5 (rulings R5, R6).
3
+ //
4
+ // ⭐⭐ THE RECORD STAR IS A DIFFERENT SCOPE FROM EVERY OTHER STAR IN THIS PRODUCT, and that is
5
+ // the one thing to carry away from this file. A star on a DATABASE, an AGENT or a QUERY is
6
+ // `config.important` on one record, so starring a SHARED view stars it for everybody who can see
7
+ // it (mailbox E-3). A RECORD star is PER USER (R6): it lives in the same per-username stratum
8
+ // views already live in, and two accounts looking at one row see two different answers. One word
9
+ // on screen, two scopes underneath.
10
+ //
11
+ // β›” WHY THIS IS ITS OWN DOOR AND NOT A VIEW WRITE (D-170). A view write on a read-through grid
12
+ // answers 409 `window_required`: `/grid/events` β†’ `ut_write_ctx` β†’ `scoped_pids` materialises the
13
+ // whole pool, and on `ut_odoo_gl_lines` that is 971,034 rows. E's route writes a `starredRecords`
14
+ // key in the per-user stratum and asks for no pid set, so it answers 200 on the big grids β€” proven
15
+ // on a seeded read-through table, with the store showing `['starredRecords']` and NO `views` key.
16
+ //
17
+ // ⚠ AND IT DELIBERATELY DOES NOT VALIDATE THE ID against the row set, for the same reason:
18
+ // checking that a pid exists means materialising the pool, i.e. the 409 again. The READ prunes β€”
19
+ // `memberPids` is intersected with the rows the caller can see, exactly as a cohort already is.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import { useCallback, useEffect, useRef, useState } from "react";
23
+
24
+ import { API_V1, CREDENTIALS, TOAST_EVENT, signal } from "../apiContract";
25
+ import type { SavedView } from "./types";
26
+ import { topicForScope } from "./types";
27
+
28
+ /**
29
+ * β›” E's CONSTANTS, IMPORTED IN SPIRIT AND RESTATED HERE BECAUSE THE WIRE IS THE CONTRACT.
30
+ * `routes_starred.STARRED_VIEW_ID` is `"starred-records"` and `STARRED_VIEW_NAME` is
31
+ * `"Starred records"`. This client never MINTS the view β€” the server hands the whole object back
32
+ * β€” so these exist only to recognise it and to keep it undeletable (`types.UNDELETABLE_VIEW_IDS`).
33
+ */
34
+ export const STARRED_VIEW_ID = "starred-records";
35
+
36
+ export interface RecordStars {
37
+ /** The database key the answer is about, echoed so a late response cannot be misfiled. */
38
+ database: string;
39
+ count: number;
40
+ ids: number[];
41
+ /**
42
+ * ⭐ THE WHOLE VIEW OBJECT, NOT AN ID LIST, and E published it that way on purpose: the rail
43
+ * would otherwise have to mint a second idea of what this view is, and the two would drift.
44
+ * ⚠ `null` WHEN NOTHING IS STARRED. A view promising starred records and listing none is the
45
+ * D-229 shape, so the row is not drawn at all until there is something in it.
46
+ */
47
+ view: SavedView | null;
48
+ }
49
+
50
+ export const NO_RECORD_STARS: RecordStars =
51
+ { database: "", count: 0, ids: [], view: null };
52
+
53
+ /**
54
+ * The database key C5's door takes, from the grid's SCOPE.
55
+ *
56
+ * β›” THESE ARE TWO DIFFERENT SPELLINGS AND CONFLATING THEM IS A BOOKED DEFECT. A grid scope is
57
+ * `customer`; the registry key is `customer_data`. Wave 33 found three inline copies of that map
58
+ * and one of them filed an alert against the wrong topic, 200 OK and silent. `topicForScope` is
59
+ * the one answer, so this file asks it rather than adding a fourth copy.
60
+ */
61
+ export function starDatabaseKey(scope: string): string {
62
+ return topicForScope(scope).key;
63
+ }
64
+
65
+ function parse(body: unknown, fallbackDatabase: string): RecordStars {
66
+ const row = (body ?? {}) as Record<string, unknown>;
67
+ const view = row.view && typeof row.view === "object" ? (row.view as SavedView) : null;
68
+ return {
69
+ database: typeof row.database === "string" && row.database ? row.database : fallbackDatabase,
70
+ count: typeof row.count === "number" ? row.count : 0,
71
+ // PRUNE, NEVER INVENT β€” the same discipline `parseStarred` and `parseNavImportant` follow.
72
+ ids: Array.isArray(row.ids)
73
+ ? row.ids.filter((id): id is number => typeof id === "number" && Number.isFinite(id))
74
+ : [],
75
+ view: view && typeof (view as { id?: unknown }).id === "string" ? view : null,
76
+ };
77
+ }
78
+
79
+ async function callStars(path: string, init: RequestInit, database: string):
80
+ Promise<{ ok: true; value: RecordStars } | { ok: false; message: string }> {
81
+ let res: Response;
82
+ try {
83
+ res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
84
+ } catch {
85
+ return { ok: false, message: "Cannot reach the server." };
86
+ }
87
+ const body = (await res.json().catch(() => null)) as unknown;
88
+ if (!res.ok) {
89
+ const detail = (body as { error?: { message?: string } } | null)?.error?.message;
90
+ return {
91
+ ok: false,
92
+ message: detail && res.status < 500
93
+ ? detail
94
+ : "Something went wrong on our side. The star was not saved.",
95
+ };
96
+ }
97
+ return { ok: true, value: parse(body, database) };
98
+ }
99
+
100
+ export function loadRecordStars(database: string) {
101
+ return callStars(`/starred/records?database=${encodeURIComponent(database)}`, {}, database);
102
+ }
103
+
104
+ export function setRecordStar(database: string, id: number, on: boolean) {
105
+ return callStars("/starred/records", {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ // ⚠ THE ID GOES AS A STRING OF DIGITS. C5 names a NON-NUMERIC id as a 400, and a pid is a
109
+ // number here β€” `String(id)` is the conversion, not `id.toString(36)` or a template with a
110
+ // prefix. The route's own 400 is the wall; this is just not asking for it.
111
+ body: JSON.stringify({ database, id: String(id), on }),
112
+ }, database);
113
+ }
114
+
115
+ /**
116
+ * ⭐ The grid's record stars, read once per database open.
117
+ *
118
+ * β›” IT IS NOT MOUNT-BLOCKING AND IT MUST NOT BECOME SO. This is one small read, unlike
119
+ * `GET /starred` (E measured that one at 7,331 ms cold because it scans every visible database's
120
+ * view bucket) β€” but the grid paints rows long before this answers, and gating the table on it
121
+ * would trade a real table for a spinner over a decoration.
122
+ *
123
+ * ⚠ THE GENERATION GUARD IS NOT OPTIONAL. Two clicks in quick succession, or a database switch
124
+ * mid-flight, leave two responses racing and the slower one wins; the counter is what makes the
125
+ * last INTENT win instead of the last packet. Same shape `useStarred` uses, for the same reason.
126
+ */
127
+ export function useRecordStars(scope: string, enabled: boolean): {
128
+ stars: RecordStars;
129
+ ready: boolean;
130
+ starred: (pid: number) => boolean;
131
+ toggle: (pid: number, on: boolean) => void;
132
+ } {
133
+ const database = starDatabaseKey(scope);
134
+ const [stars, setStars] = useState<RecordStars>(NO_RECORD_STARS);
135
+ const [ready, setReady] = useState(false);
136
+ const gen = useRef(0);
137
+
138
+ useEffect(() => {
139
+ if (!enabled) return;
140
+ const mine = gen.current + 1;
141
+ gen.current = mine;
142
+ setReady(false);
143
+ void loadRecordStars(database).then((result) => {
144
+ if (gen.current !== mine) return;
145
+ // β›” A FAILED READ IS NOT AN EMPTY LIST, but here it degrades to one DELIBERATELY and
146
+ // silently: a toast on every grid open because a decoration could not load would be worse
147
+ // than the missing stars. `ready` stays false, so no control is drawn and nobody is told
148
+ // something is starred when we do not know. A failed WRITE is loud β€” see `toggle`.
149
+ if (result.ok) {
150
+ setStars(result.value);
151
+ setReady(true);
152
+ }
153
+ });
154
+ }, [database, enabled]);
155
+
156
+ const toggle = useCallback((pid: number, on: boolean) => {
157
+ const mine = gen.current + 1;
158
+ gen.current = mine;
159
+ // Optimistic, then reconciled from the server's own answer β€” which is also what carries the
160
+ // updated projection view, so the rail row appears on the first star with no second request.
161
+ setStars((cur) => ({
162
+ ...cur,
163
+ ids: on ? [...cur.ids, pid] : cur.ids.filter((id) => id !== pid),
164
+ count: on ? cur.count + 1 : Math.max(0, cur.count - 1),
165
+ }));
166
+ void setRecordStar(database, pid, on).then((result) => {
167
+ if (gen.current !== mine) return;
168
+ if (result.ok) {
169
+ setStars(result.value);
170
+ return;
171
+ }
172
+ // β›” A REFUSED WRITE SAYS SO AND PUTS THE LIST BACK. Leaving the optimistic flip on screen is
173
+ // the worst of the three options: the reader believes a star was saved that was not, and
174
+ // finds out on their next reload [[lost-write-looks-like-failed-read]]. The 400 this most
175
+ // often carries is `starred_full` past 500, whose message names its own cause.
176
+ signal(TOAST_EVENT, result.message);
177
+ void loadRecordStars(database).then((again) => {
178
+ if (again.ok) setStars(again.value);
179
+ });
180
+ });
181
+ }, [database]);
182
+
183
+ const starred = useCallback(
184
+ (pid: number) => stars.ids.includes(pid), [stars.ids]);
185
+
186
+ return { stars, ready, starred, toggle };
187
+ }
web/src/customer-grid/rowStar.css ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ---------------------------------------------------------------------------
2
+ customer-grid / rowStar.css β€” W35-T29 (rulings R5, R6), the record star.
3
+
4
+ β›” ITS OWN SHEET, AND THE REASON IS OWNERSHIP RATHER THAN TIDINESS. `.cg-row-expand`, the hover
5
+ control this one sits beside, lives in `index.css`, which belongs to another lane this wave. A
6
+ new surface brings its own stylesheet (the wave's own rule) and a change to an existing
7
+ `.cg-*` rule is that lane's ticket.
8
+
9
+ ⚠ IMPORTED BY `CustomerGrid.tsx`, the module that RENDERS these classes β€” never by a lazy
10
+ parent. W35-T22 in this same wave was exactly that mistake one directory over: `query.css` was
11
+ imported only by a `lazy()` page while the component drawing its classes shipped in another
12
+ chunk, so the rail painted unstyled for a network round trip.
13
+ --------------------------------------------------------------------------- */
14
+
15
+ /* The hover control at the LEFT end of the primary cell, mirroring the Expand button at its
16
+ right end. Both are real DOM buttons laid over the canvas at glide's own bounds, so the rect
17
+ IS the hit test and there is no second copy of the geometry to drift from the drawing. */
18
+ .cg-row-star {
19
+ position: fixed;
20
+ z-index: 55;
21
+ display: inline-flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+ padding: 0;
25
+ border: 1px solid transparent;
26
+ border-radius: var(--lp-r-sm);
27
+ background: transparent;
28
+ color: var(--lp-muted);
29
+ cursor: pointer;
30
+ }
31
+
32
+ /* β›” A STARRED ROW'S MARK IS NOT HOVER-ONLY. The hover control is how you SET one; a row that is
33
+ already starred has to say so while the pointer is elsewhere, or the grid cannot answer the
34
+ question the feature exists for. D-253 is a booked defect whose whole content is "satisfied
35
+ only on hover" β€” this is that lesson applied to a mark rather than to an explanation.
36
+ The always-on mark is drawn by the same button with `is-on`; the caller keeps it mounted for a
37
+ starred row whether or not the pointer is over it. */
38
+ .cg-row-star.is-on {
39
+ color: var(--lp-yellow-deep);
40
+ }
41
+
42
+ .cg-row-star:hover {
43
+ border-color: var(--lp-line);
44
+ background: var(--cg-white);
45
+ color: var(--lp-yellow-deep);
46
+ }
47
+
48
+ .cg-row-star:focus-visible {
49
+ outline: 2px solid var(--lp-blue-deep);
50
+ outline-offset: 1px;
51
+ }
52
+
53
+ .cg-row-star:disabled {
54
+ cursor: default;
55
+ opacity: 0.55;
56
+ }
57
+
58
+ /* The drawer's own copy of the control (`RecordDetail`), which is in the flow rather than over a
59
+ canvas β€” so it takes the colour rules above and none of the positioning. */
60
+ .cg-detail-star {
61
+ display: inline-flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ width: 28px;
65
+ height: 28px;
66
+ padding: 0;
67
+ border: 1px solid transparent;
68
+ border-radius: var(--lp-r-sm);
69
+ background: transparent;
70
+ color: var(--lp-muted);
71
+ cursor: pointer;
72
+ }
73
+
74
+ .cg-detail-star.is-on { color: var(--lp-yellow-deep); }
75
+ .cg-detail-star:hover { border-color: var(--lp-line); color: var(--lp-yellow-deep); }
76
+ .cg-detail-star:disabled { cursor: default; opacity: 0.55; }
web/src/customer-grid/types.ts CHANGED
@@ -2772,11 +2772,24 @@ export interface ViewConfig {
2772
  */
2773
  cohortLock?: string;
2774
  /**
2775
- * ⭐⭐ WAVE 32 Β· T24 Β· CONTRACT C4 (owner item 17, ruling R5) β€” **"Mark important".**
2776
  *
2777
  * The view carries a live count of its own matching records, in the red accent, beside its
2778
  * name in the rail.
2779
  *
 
 
 
 
 
 
 
 
 
 
 
 
 
2780
  * β›” **ONE MARK. There is no `critical`, no severity and no colour variants** β€” R5 is explicit,
2781
  * and `verify_icons`' view-menu scan asserts the absence of the word so a second severity
2782
  * cannot arrive by copy-paste. It is a legibility mark, not a lock: it says "keep this number
@@ -2936,7 +2949,22 @@ export function allViewName(scope: string): string {
2936
  */
2937
  export const IG_OVERVIEW_VIEW_ID = "tpl_overview";
2938
 
2939
- export const UNDELETABLE_VIEW_IDS = new Set([ALL_VIEW_ID, IG_OVERVIEW_VIEW_ID]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2940
 
2941
  // ─────────────────────────────────────────────── wave 16 C-TOPIC: the grid's TOPIC
2942
  /**
 
2772
  */
2773
  cohortLock?: string;
2774
  /**
2775
+ * ⭐⭐ WAVE 32 Β· T24 Β· CONTRACT C4 (owner item 17, W32 ruling R5) β€” **the star.**
2776
  *
2777
  * The view carries a live count of its own matching records, in the red accent, beside its
2778
  * name in the rail.
2779
  *
2780
+ * β›”β›” **THE KEY IS `important` AND THE WORD ON SCREEN IS "STAR", AND THAT MISMATCH IS
2781
+ * DELIBERATE (W35-T26, owner item 9 / R4).** Owner: star and mark-important are ONE idea and
2782
+ * ONE flag; the label retires, the flag does not. Renaming this key would create exactly the
2783
+ * second store R4 forbids, and every view already marked would lose its star silently β€” the
2784
+ * server's allowlist reads `cfg.get('important')` by name.
2785
+ * ⚠ So `POST /api/v1/starred {kind: "view"}` (contract C2) resolves to THIS key rather than to
2786
+ * a store of its own, and E asserts that in `api_api` with a control that looks for a second
2787
+ * one. If you are about to add a `starred?: boolean` beside this, stop and re-read R4.
2788
+ * ⚠ AND THE SCOPE IS NOT WHAT THE SCREEN SUGGESTS: this flag lives on the VIEW RECORD, and a
2789
+ * SHARED view has exactly one record β€” so starring a shared view stars it for everybody who can
2790
+ * see it, and any collaborator can clear it (mailbox E-3). A RECORD star is per user; a shared
2791
+ * view's star is tenant-wide. Two scopes behind one word.
2792
+ *
2793
  * β›” **ONE MARK. There is no `critical`, no severity and no colour variants** β€” R5 is explicit,
2794
  * and `verify_icons`' view-menu scan asserts the absence of the word so a second severity
2795
  * cannot arrive by copy-paste. It is a legibility mark, not a lock: it says "keep this number
 
2949
  */
2950
  export const IG_OVERVIEW_VIEW_ID = "tpl_overview";
2951
 
2952
+ /**
2953
+ * ⭐⭐ W35-T30 Β· CONTRACT C5 (R5) β€” the per-user "Starred records" projection.
2954
+ *
2955
+ * β›” THE SERVER MINTS IT AND THIS SIDE ONLY RECOGNISES IT. `routes_starred.STARRED_VIEW_ID`
2956
+ * carries the same literal and hands back the WHOLE view object (id, name, `kind: "system"`,
2957
+ * `locked: true`, `config.memberPids`), so the rail never builds a second idea of what this view
2958
+ * is. The constant is here because the UNDELETABLE set below is what makes it undeletable, on the
2959
+ * same two facts that protect `all-customers`.
2960
+ *
2961
+ * ⚠ ITS ROW SET IS CURATED (`memberPids`), which is also the ONE shape
2962
+ * `routes_nav._view_record_count` counts for FREE β€” so a real number can sit beside it on Home
2963
+ * and in Starred for nothing, rather than a dash and an excuse (contract C3).
2964
+ */
2965
+ export const STARRED_VIEW_ID = "starred-records";
2966
+
2967
+ export const UNDELETABLE_VIEW_IDS = new Set([ALL_VIEW_ID, IG_OVERVIEW_VIEW_ID, STARRED_VIEW_ID]);
2968
 
2969
  // ─────────────────────────────────────────────── wave 16 C-TOPIC: the grid's TOPIC
2970
  /**
web/src/home/HomePage.tsx CHANGED
@@ -31,6 +31,13 @@ import { AGENTS_MODULE_LABEL } from "../inbox/inboxModel";
31
  import { Mark } from "../shell/Brand";
32
  import { dbChipClass } from "../shell/nav";
33
  import type { DatabaseEntry, Recent } from "../shell/nav";
 
 
 
 
 
 
 
34
  import {
35
  HOME_LAYOUT_KEY,
36
  allDatabases,
@@ -82,13 +89,26 @@ function NewDbIcon() {
82
  * silently painted LAST WAVE'S BRAND while a comment claimed parity.
83
  */
84
 
85
- /** Connect a source β€” two links of a chain, which is what a connector is. */
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  function ConnectIcon() {
87
  return (
88
  <svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
89
- <path d="M6.7 9.3 9.3 6.7" />
90
- <path d="M7.6 4.6 9.1 3.1a2.7 2.7 0 0 1 3.8 3.8l-1.5 1.5" />
91
- <path d="M8.4 11.4l-1.5 1.5a2.7 2.7 0 0 1-3.8-3.8l1.5-1.5" />
92
  </svg>
93
  );
94
  }
@@ -144,15 +164,9 @@ function QuickCard({
144
  );
145
  }
146
 
147
- export default function HomePage({
148
- entries,
149
- recents,
150
- automations,
151
- onTemplates,
152
- onNewDatabase,
153
- onConnectors,
154
- onOpenAutomation,
155
- }: {
156
  /**
157
  * The SHAPED, server-filtered nav. The only thing a tile may be drawn from.
158
  *
@@ -183,6 +197,54 @@ export default function HomePage({
183
  onConnectors: () => void;
184
  /** Open one automation: the frame owns the hash, the surface owns which one is selected. */
185
  onOpenAutomation: (id: string) => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  }) {
187
  const [layout, setLayout] = useState<HomeLayout>(() => {
188
  try {
@@ -208,6 +270,23 @@ export default function HomePage({
208
  const now = Math.floor(Date.now() / 1000);
209
  const databases = allDatabases(entries, recents, now);
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  return (
212
  <div className="shell-home">
213
  <h1 className="home-title">Home</h1>
@@ -306,8 +385,12 @@ export default function HomePage({
306
  <h2 className="home-section-title">{section.title}</h2>
307
  <div className={"home-tiles is-" + layout}>
308
  {section.tiles.map((tile) => (
 
 
 
 
 
309
  <a
310
- key={tile.key}
311
  className="home-tile"
312
  href={tile.href}
313
  {...(tile.external ? { target: "_blank", rel: "noreferrer" } : {})}
@@ -342,6 +425,15 @@ export default function HomePage({
342
  {tile.ago ? <span className="home-tile-ago">{tile.ago}</span> : null}
343
  </span>
344
  </a>
 
 
 
 
 
 
 
 
 
345
  ))}
346
  </div>
347
  </section>
@@ -382,8 +474,8 @@ export default function HomePage({
382
  <h2 className="home-section-title">{AGENTS_MODULE_LABEL}</h2>
383
  <div className={"home-tiles is-" + layout}>
384
  {automations.map((a) => (
 
385
  <button
386
- key={a.id}
387
  type="button"
388
  className="home-tile home-tile-auto"
389
  onClick={() => onOpenAutomation(a.id)}
@@ -400,6 +492,53 @@ export default function HomePage({
400
  {a.sub ? <span className="home-tile-ago">{a.sub}</span> : null}
401
  </span>
402
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  ))}
404
  </div>
405
  </section>
@@ -407,3 +546,28 @@ export default function HomePage({
407
  </div>
408
  );
409
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  import { Mark } from "../shell/Brand";
32
  import { dbChipClass } from "../shell/nav";
33
  import type { DatabaseEntry, Recent } from "../shell/nav";
34
+ // ⭐ WAVE 35 Β· W35-T15 (R4, C2) β€” ONE STAR CONTROL, drawn in the module that owns the concept and
35
+ // worn by both surfaces. R4 makes star and "mark important" one idea and one flag; a second star
36
+ // component here would be the "one mark, one meaning" failure DESIGN.md 4 names, and `useStarred`
37
+ // is what keeps Home and Starred from ending up with different ideas of what is starred.
38
+ import { StarButton, Tile } from "../starred/StarredPage";
39
+ import { shapeStarred, useStarred, viewCount } from "../starred/starredApi";
40
+ import type { StarKind, Starred, StarredCounts, StarredLoad } from "../starred/starredApi";
41
  import {
42
  HOME_LAYOUT_KEY,
43
  allDatabases,
 
89
  * silently painted LAST WAVE'S BRAND while a comment claimed parity.
90
  */
91
 
92
+ /**
93
+ * Connect a source β€” a two-prong plug with a cord, which is the thing you put in an outlet.
94
+ *
95
+ * ⭐ WAVE 35 · W35-T15 (owner item 5): *"change the Icon for the connector to look like the cable
96
+ * head that you would put in an electrical outlet."* It drew two links of a CHAIN until now, and
97
+ * so does its twin on the rail (`shell/Shell.tsx::PlugIcon`, whose NAME has been wrong since wave
98
+ * 23). The prongs are the load-bearing part of the drawing; the cord is what stops it reading as
99
+ * a padlock at 16px.
100
+ *
101
+ * β›” THE TWO COPIES MUST NOT DIVERGE and they are in two different fences, so the geometry below
102
+ * was published in mailbox B-5 as an ASK to the session that owns the rail (W35-T04) rather than
103
+ * changed here and hoped about. One glyph, one meaning; two plugs that disagree is worse than the
104
+ * chain, because at least the chain was wrong in both places identically.
105
+ */
106
  function ConnectIcon() {
107
  return (
108
  <svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
109
+ <path d="M6.1 5.2V2.1M9.9 5.2V2.1" />
110
+ <path d="M12.1 5.2v3.1a2.7 2.7 0 0 1-2.7 2.7H6.6a2.7 2.7 0 0 1-2.7-2.7V5.2Z" />
111
+ <path d="M8 11v1.6a1.6 1.6 0 0 0 1.6 1.6h1.5" />
112
  </svg>
113
  );
114
  }
 
164
  );
165
  }
166
 
167
+ /** What the FRAME hands Home. Unchanged by the wave-35 split, and named so both halves take
168
+ * exactly the same shape rather than drifting. */
169
+ export interface HomeProps {
 
 
 
 
 
 
170
  /**
171
  * The SHAPED, server-filtered nav. The only thing a tile may be drawn from.
172
  *
 
197
  onConnectors: () => void;
198
  /** Open one automation: the frame owns the hash, the surface owns which one is selected. */
199
  onOpenAutomation: (id: string) => void;
200
+ /**
201
+ * ⭐ WAVE 35 Β· W35-T16 (owner item 9) β€” open a starred VIEW inside its database.
202
+ *
203
+ * β›” REQUIRED, and the frame's, for the same two reasons `onOpenAutomation` is. Optional would
204
+ * degrade to "clicking a starred view does nothing", indistinguishable from the section never
205
+ * having been built and red in no gate. And the act is a hash change PLUS a request to the grid
206
+ * for which view is selected: the grid's listener DROPS a viewId it has not loaded yet with no
207
+ * acknowledgement, so the emit needs the frame's bounded retry ladder against a MEASURED worst
208
+ * case, not a single hopeful dispatch.
209
+ * ⚠ `Shell.tsx` passes the IDENTICAL handler to Home and to Starred (mailbox A, `DONE B-8`), so
210
+ * a view opened from one cannot behave differently from the same view opened from the other.
211
+ */
212
+ onOpenView: (database: string, viewId: string) => void;
213
+ }
214
+
215
+ /**
216
+ * ⭐ WAVE 35 Β· W35-T15 β€” THE PURE HALF, AND IT EXISTS SO HOME CAN BE LOOKED AT.
217
+ *
218
+ * Home held no state that could not be rendered until the star list arrived. `renderToStaticMarkup`
219
+ * never runs an effect, so a static render of the fetching component can only ever photograph the
220
+ * state where the stars are still unknown, i.e. the one state with no star in it. Splitting the
221
+ * read off is what makes "hover reveals an outline star, a starred tile shows a filled one" a thing
222
+ * a person can SEE without a server. `inbox/_inbox_shot.tsx` paid for this lesson first; the same
223
+ * split is now on `StarredSurface`.
224
+ *
225
+ * ⚠ THE DEFAULT EXPORT'S PROPS ARE UNCHANGED, so the frame's mount is untouched by this.
226
+ */
227
+ export function HomeSurface({
228
+ entries,
229
+ recents,
230
+ automations,
231
+ onTemplates,
232
+ onNewDatabase,
233
+ onConnectors,
234
+ onOpenAutomation,
235
+ onOpenView,
236
+ starLoad,
237
+ starred,
238
+ busy,
239
+ counts,
240
+ toggle,
241
+ }: HomeProps & {
242
+ starLoad: StarredLoad;
243
+ starred: Starred;
244
+ busy: boolean;
245
+ /** C3's row counts, or `null` while the second read is in flight (R7: after paint, never on nav). */
246
+ counts: StarredCounts | null;
247
+ toggle: (kind: StarKind, id: string, on: boolean, database?: string) => void;
248
  }) {
249
  const [layout, setLayout] = useState<HomeLayout>(() => {
250
  try {
 
270
  const now = Math.floor(Date.now() / 1000);
271
  const databases = allDatabases(entries, recents, now);
272
 
273
+ // β›” THE STAR CONTROL IS ABSENT UNTIL THE LIST ARRIVES, deliberately. Drawing an empty star
274
+ // before the list is known would STATE that nothing is starred and then flip under the reader
275
+ // once it lands. An affordance that appears a moment later is a smaller cost than a control
276
+ // that was wrong for a moment, and this one is revealed on hover anyway.
277
+ const known = starLoad.phase === "ready";
278
+ const dbStarred = new Set(starred.databases);
279
+ const agentStarred = new Set(starred.agents);
280
+
281
+ // ⭐ WAVE 35 Β· W35-T16 (owner item 9) β€” the starred VIEWS, resolved by the SAME function
282
+ // Starred uses. Item 9 asks for these views on Home and under Starred; running them through one
283
+ // resolver is what stops the two lists disagreeing about which views exist or what they are
284
+ // called, which is item 11's complaint applied before it can happen.
285
+ // β›” AND THE DROP RULE COMES WITH IT: `shapeStarred` refuses a view whose database is not in
286
+ // `entries`, so a star that outlived its database (deleted, or a grant revoked) draws NOTHING
287
+ // rather than a tile that lands the reader in a 403.
288
+ const starredViews = shapeStarred(starred, entries, automations).views;
289
+
290
  return (
291
  <div className="shell-home">
292
  <h1 className="home-title">Home</h1>
 
385
  <h2 className="home-section-title">{section.title}</h2>
386
  <div className={"home-tiles is-" + layout}>
387
  {section.tiles.map((tile) => (
388
+ // ⚠ THE WRAPPER IS ALWAYS PRESENT, star or no star, so the grid cell is the same
389
+ // box in both states and the page does not re-lay-itself out when the star list
390
+ // lands. `.st-tilewrap` is a NEW rule in `starred/starred.css` scoped to itself;
391
+ // `.home-tile`'s own rule in `index.css` is untouched (that file is A's).
392
+ <div key={tile.key} className="st-tilewrap">
393
  <a
 
394
  className="home-tile"
395
  href={tile.href}
396
  {...(tile.external ? { target: "_blank", rel: "noreferrer" } : {})}
 
425
  {tile.ago ? <span className="home-tile-ago">{tile.ago}</span> : null}
426
  </span>
427
  </a>
428
+ {known ? (
429
+ <StarButton
430
+ on={dbStarred.has(tile.key)}
431
+ label={tile.label}
432
+ busy={busy}
433
+ onToggle={() => toggle("database", tile.key, !dbStarred.has(tile.key))}
434
+ />
435
+ ) : null}
436
+ </div>
437
  ))}
438
  </div>
439
  </section>
 
474
  <h2 className="home-section-title">{AGENTS_MODULE_LABEL}</h2>
475
  <div className={"home-tiles is-" + layout}>
476
  {automations.map((a) => (
477
+ <div key={a.id} className="st-tilewrap">
478
  <button
 
479
  type="button"
480
  className="home-tile home-tile-auto"
481
  onClick={() => onOpenAutomation(a.id)}
 
492
  {a.sub ? <span className="home-tile-ago">{a.sub}</span> : null}
493
  </span>
494
  </button>
495
+ {known ? (
496
+ <StarButton
497
+ on={agentStarred.has(a.id)}
498
+ label={a.name}
499
+ busy={busy}
500
+ onToggle={() => toggle("agent", a.id, !agentStarred.has(a.id))}
501
+ />
502
+ ) : null}
503
+ </div>
504
+ ))}
505
+ </div>
506
+ </section>
507
+ ) : null}
508
+
509
+ {/* ⭐ WAVE 35 Β· W35-T16 (owner item 9) β€” THE STARRED VIEWS.
510
+ Owner: *"remove [the important count] anywhere else but the View itself … Instead, let's
511
+ add these Views under 'Home' and … under 'Starred' module as well."* So the badge leaves
512
+ the rail and the flyout (A's W35-T07, E's W35-T43) and the VIEWS themselves arrive here.
513
+
514
+ β›” IT IS THE SAME `Tile` COMPONENT `StarredPage` DRAWS, not a copy that agrees today.
515
+ Item 11's complaint is that what is shown twice goes out of step, and two renderers for
516
+ one object is how that starts. The chip, the two lines and the star are one piece of code.
517
+
518
+ ⚠ EMPTY RENDERS NOTHING, like every other section here: no starred views is a real
519
+ answer, and a heading over nothing says "there should be something here", which is a
520
+ different and false statement. */}
521
+ {starredViews.length ? (
522
+ <section className="home-section">
523
+ <h2 className="home-section-title">Starred views</h2>
524
+ <div className={"home-tiles is-" + layout}>
525
+ {starredViews.map((row) => (
526
+ <Tile
527
+ key={row.id}
528
+ row={row}
529
+ count={viewCount(row.id, counts)}
530
+ star={
531
+ known ? (
532
+ <StarButton
533
+ on
534
+ label={row.label}
535
+ busy={busy}
536
+ onToggle={() => toggle("view", row.id, false, row.database)}
537
+ />
538
+ ) : undefined
539
+ }
540
+ onOpen={() => onOpenView(row.database, row.id)}
541
+ />
542
  ))}
543
  </div>
544
  </section>
 
546
  </div>
547
  );
548
  }
549
+
550
+ /**
551
+ * The page the frame mounts. Its props are exactly what they were before wave 35.
552
+ *
553
+ * ⭐ THE ONE READ HOME MAKES, and it is deliberately the SAME hook Starred uses. Two copies of
554
+ * "flip it, post it, take the server's answer, put it back if it was refused" is
555
+ * [[one-question-two-normalizers]] with the divergence on screen: one surface optimistic, the
556
+ * other reconciled, and the two disagreeing about what is starred.
557
+ * ⚠ It reads AFTER PAINT (the hook's own `useEffect`, never `useLayoutEffect`), because
558
+ * `GET /starred` scans a view bucket per database β€” C2 forbids a second store for the flag β€” and
559
+ * was measured at 6.2 ms warm and 7,331 ms COLD on tenant #0 (mailbox E-2).
560
+ */
561
+ export default function HomePage(props: HomeProps) {
562
+ const { load: starLoad, starred, busy, counts, toggle } = useStarred();
563
+ return (
564
+ <HomeSurface
565
+ {...props}
566
+ starLoad={starLoad}
567
+ starred={starred}
568
+ busy={busy}
569
+ counts={counts}
570
+ toggle={toggle}
571
+ />
572
+ );
573
+ }
web/src/index.css CHANGED
@@ -317,12 +317,21 @@ body {
317
  /* Item 5 β€” the rail's head is the shared top band: same height as the shell head and the
318
  toolbar, closed by the same hairline. The negative margin bleeds the line to the rail's
319
  true edges past the container padding (overflow:hidden clips the collapsed overshoot). */
 
 
 
 
 
 
 
 
 
320
  .cg-views-top {
321
  flex: 0 0 var(--lp-rail-head-h);
322
  height: var(--lp-rail-head-h);
323
  display: flex;
324
  align-items: center;
325
- justify-content: flex-end;
326
  margin: 0 -8px;
327
  padding: 0 8px;
328
  border-bottom: 1px solid var(--lp-line);
@@ -2947,8 +2956,15 @@ body {
2947
  }
2948
 
2949
  /* ---- the frame ----------------------------------------------------------- */
 
 
 
 
 
 
2950
  .shell-root {
2951
  display: flex;
 
2952
  width: 100%;
2953
  height: 100vh;
2954
  overflow: hidden;
@@ -2956,6 +2972,51 @@ body {
2956
  color: var(--lp-ink);
2957
  background: var(--lp-surface);
2958
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2959
 
2960
  /* WHITE, like the host's `[theme.sidebar]` (owner 2026-07-23: "slick, visible
2961
  light-grey borders") β€” the wash belongs to page bodies, not the rail.
@@ -2975,51 +3036,31 @@ body {
2975
  flex-basis var(--lp-fold-t) var(--lp-fold-e);
2976
  }
2977
 
2978
- /* Item 5 β€” the rail head IS the shared top band (--lp-rail-head-h + the same hairline the
2979
- views rail and the toolbar close with). Item 11's minimize control lives in it, beside the
2980
- brand. Collapsed, the rail is the slim strip: mark, icons, avatar β€” the toggle goes
2981
- (item 2: the logo is the way back), labels fold away smoothly. */
 
2982
  .shell-side-head {
2983
- display: flex;
2984
- align-items: center;
2985
- justify-content: space-between;
2986
  flex: 0 0 var(--lp-rail-head-h);
2987
  height: var(--lp-rail-head-h);
2988
- padding: 0 10px 0 16px;
2989
  border-bottom: 1px solid var(--lp-line);
2990
- transition: padding var(--lp-fold-t) var(--lp-fold-e);
2991
- }
2992
- .shell-rail-toggle {
2993
- width: 28px;
2994
- height: 28px;
2995
- flex: 0 0 28px;
2996
- display: inline-flex;
2997
- align-items: center;
2998
- justify-content: center;
2999
- border: none;
3000
- border-radius: var(--lp-r-md);
3001
- background: transparent;
3002
- color: var(--lp-muted);
3003
- cursor: pointer;
3004
- }
3005
- .shell-rail-toggle:hover {
3006
- background: var(--lp-surface-2);
3007
- color: var(--lp-ink);
3008
- }
3009
- .shell-rail-toggle:focus-visible {
3010
- outline: 2px solid var(--lp-blue-deep);
3011
- outline-offset: 1px;
3012
  }
 
 
 
 
 
 
 
 
3013
  .shell-side.is-collapsed {
3014
  width: var(--lp-rail-min);
3015
  flex-basis: var(--lp-rail-min);
3016
  }
3017
- /* Item 2 β€” collapsed, the three-bars control disappears; clicking the logo expands. */
3018
- .shell-side.is-collapsed .shell-rail-toggle { display: none; }
3019
- /* Geometry note (item 1): nothing re-centers on collapse β€” paddings are tuned so the mark
3020
- and the icons SLIDE to the strip's center as the width animates (a justify-content flip
3021
- would snap them at t=0). Mark 26px β†’ (48-26)/2 = 11; icon x: 6 + 10 = 16 = (48-16)/2. */
3022
- .shell-side.is-collapsed .shell-side-head { padding: 0 11px; }
3023
  .shell-side.is-collapsed .shell-brand { gap: 0; }
3024
  .shell-side.is-collapsed .lp-wordmark,
3025
  .shell-side.is-collapsed .shell-nav-label,
@@ -3039,33 +3080,35 @@ body {
3039
  gap: 0;
3040
  }
3041
  .shell-side.is-collapsed .shell-nav-item.is-child { padding-left: 10px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3042
  .shell-side.is-collapsed .shell-side-bottom { padding: 8px 2px 10px; }
3043
  .shell-side.is-collapsed .shell-account {
3044
  padding: 7px 8px;
3045
  gap: 0;
3046
  }
3047
 
3048
- /* Item 2 β€” the brand is a BUTTON: disabled while expanded (brand, not control), the way
3049
- back while collapsed. One stable element in both states, so the fold can animate it. */
3050
- .shell-brand-btn {
3051
- display: flex;
3052
- align-items: center;
3053
- padding: 0;
3054
- margin: 0;
3055
- border: none;
3056
- background: none;
3057
- color: inherit;
3058
- font: inherit;
3059
- cursor: pointer;
3060
- min-width: 0;
3061
- }
3062
- .shell-brand-btn:disabled { cursor: default; }
3063
- .shell-brand-btn:not(:disabled):hover { opacity: 0.8; }
3064
- .shell-brand-btn:focus-visible {
3065
- outline: 2px solid var(--lp-blue-deep);
3066
- outline-offset: 2px;
3067
- border-radius: var(--lp-r-sm);
3068
- }
3069
  .shell-brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
3070
  /* ⚠ DELIBERATELY OFF THE TYPE SCALE. The wordmark is BRAND, not UI text: 17px is what the app's
3071
  own nav wordmark resolves to (1.02rem against its 17px base), and matching the sibling door
@@ -3174,7 +3217,12 @@ body {
3174
  /* ⚠ NOT in the shared rule above: `.shell-nav-group` is a LABEL, not a control
3175
  ("quieter, no hover, no cursor" β€” its own rule below), and a pointer cursor
3176
  on a row that cannot be clicked promises a destination that does not exist. */
3177
- .shell-nav-item { cursor: pointer; }
 
 
 
 
 
3178
  .shell-nav-item:hover { background: var(--lp-surface-2); }
3179
  .shell-nav-item.is-child { padding-left: 30px; }
3180
  /* The current row is marked the way the host marks it: the selected-row tint,
@@ -3186,9 +3234,47 @@ body {
3186
  never had that, because there the ROW carries the tint. It does here now
3187
  (`.shell-nav-row.is-active`, navExtras.css). What stays here is everything
3188
  that genuinely belongs to the LINK: its weight, and the icon below. */
3189
- .shell-nav-item.is-active {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3190
  font-weight: 600;
3191
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3192
  .shell-nav-item:focus-visible { outline: 2px solid var(--lp-blue-deep); outline-offset: -2px; }
3193
 
3194
  .shell-nav-icon {
@@ -7352,18 +7438,20 @@ a.cg-map-ctl-b { text-decoration: none; }
7352
  It had the height and nothing else: the fold control sat LEFT while the Views rail's sits
7353
  right, and there was no hairline at all β€” so the automation page's top band simply stopped
7354
  halfway across the screen while every database page's ran edge to edge. The three things
7355
- `.cg-views-top` does and this did not: `justify-content: flex-end`, the closing hairline,
7356
  and the negative margin that bleeds that line past the rail's own gutter to its true edges
7357
  (`overflow: hidden` on the rail clips the collapsed overshoot, exactly as it does there).
7358
  ⚠ THE BLEED IS THIS RAIL'S OWN GUTTER, not a copied number: `.cg-views` pads 8px and bleeds
7359
  -8px; this rail pads 10px, so it bleeds -10px. Copying the -8 would leave a 2px gap at each
7360
- end β€” the same defect in a more convincing disguise. */
 
 
7361
  .auto-rail-top {
7362
  flex: 0 0 var(--lp-rail-head-h);
7363
  height: var(--lp-rail-head-h);
7364
  display: flex;
7365
  align-items: center;
7366
- justify-content: flex-end;
7367
  margin: 0 -10px;
7368
  padding: 0 10px;
7369
  border-bottom: 1px solid var(--lp-line);
@@ -10667,7 +10755,11 @@ a.cg-map-ctl-b { text-decoration: none; }
10667
  /* ── Item 7 (R9) β€” the collapsed rail expands from its own background ───────
10668
  The strip's blank area is now a click target, so it says so. Interactive
10669
  children keep their own cursor (the UA default for a link/button is already
10670
- `pointer`), and `.shell-brand-btn` keeps the explicit one it had. */
 
 
 
 
10671
  .shell-side.is-collapsed { cursor: pointer; }
10672
 
10673
  /* ==== /wave20:S4 ==== */
@@ -10993,9 +11085,24 @@ a.cg-map-ctl-b { text-decoration: none; }
10993
  /* ── C10 β€” HOME ───────────────────────────────────────────────────────────────────────────
10994
  `reference/Airtable Home.png`'s anatomy, our tokens. `.shell-main` is `overflow: hidden`, so
10995
  the page owns its own scroll or a tenant with forty recents can reach none of them. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10996
  .shell-home {
10997
  height: 100%;
10998
  overflow-y: auto;
 
10999
  padding: 34px 44px 56px;
11000
  box-sizing: border-box;
11001
  background: var(--lp-wash);
@@ -11069,12 +11176,17 @@ a.cg-map-ctl-b { text-decoration: none; }
11069
  border-radius: var(--lp-r-md);
11070
  background: var(--lp-surface);
11071
  }
 
 
 
 
 
11072
  .home-layout-btn {
11073
  display: inline-flex;
11074
  align-items: center;
11075
  justify-content: center;
11076
- width: 26px;
11077
- height: 22px;
11078
  border: 0;
11079
  border-radius: var(--lp-r-sm);
11080
  background: transparent;
@@ -11084,11 +11196,18 @@ a.cg-map-ctl-b { text-decoration: none; }
11084
  cursor: pointer;
11085
  }
11086
  .home-layout-btn:hover { background: var(--lp-surface-2); }
11087
- /* Blue = selected/active, the fixed semantic (DESIGN.md Β§3). */
11088
- .home-layout-btn.is-on { background: var(--lp-blue-tint); color: var(--lp-blue-deep); }
 
 
 
 
 
 
 
11089
  .home-layout-icon {
11090
- width: 14px;
11091
- height: 14px;
11092
  fill: none;
11093
  stroke: currentColor;
11094
  stroke-width: 1.35;
@@ -12258,50 +12377,24 @@ textarea.cg-json-raw:focus {
12258
  text-align: center;
12259
  line-height: 1;
12260
  }
 
 
 
 
 
 
 
 
 
 
 
 
12261
  /* ⭐ WAVE-33 T14 (D-205) β€” the same mark, saying "no number here, and here is why" (its `title`).
12262
  β›” NOT the solid purple: a filled badge is the product's word for "this many", and wearing it
12263
  over a dash would state a count of nothing. Outlined and muted says "the slot exists, the
12264
  number does not" β€” which is exactly the distinction an ABSENT badge could not draw, and the
12265
  whole of D-205. Same box, so a rail of marked views does not jump when one of them cannot be
12266
  counted. */
12267
- /* ⭐ WAVE-33 T16 (owner item 5) β€” the DATABASE-level important total, at the top of its own rail.
12268
- Sits beside the minimize toggle in `.cg-views-top`, which held only that button. `margin-left:
12269
- auto` pushes it to the right edge so the badge lines up with the per-view badges directly
12270
- below it β€” the total and its parts read as one column, which is the whole point of putting it
12271
- here rather than in the shell's database header. */
12272
- /* ⭐⭐ WAVE 34 Β· T20 (R1, contract C1) β€” THE WORD IS GONE, SO ITS TYPOGRAPHY GOES WITH IT.
12273
- `gap`, `font-size`, `font-weight` and `color` here only ever painted the literal "Important";
12274
- with the label removed they style nothing (the badge sets its own), and left in place they are
12275
- the kind of inert rule a later reader restores a label to fit. What survives is the LAYOUT this
12276
- wrapper is actually for: `margin-left: auto` pushes it to the right edge so the total lines up
12277
- with the per-view badges directly below it, and the class name itself is contract C1's hook
12278
- (`.cg-views-important.is-partial` hangs the dashed-purple partial mark off it, and B's flyout
12279
- row is named against it). */
12280
- .cg-views-important {
12281
- margin-left: auto;
12282
- display: inline-flex;
12283
- align-items: center;
12284
- white-space: nowrap;
12285
- }
12286
- /* R1's "SMALLER", as a box rather than as type. β›” THE FONT CANNOT SHRINK AND THAT IS A REAL
12287
- CONSTRAINT, NOT AN OMISSION: `--lp-fs-3xs` (11px) is the smallest size the scale declares, and
12288
- `verify_ui`'s R5 leg reds ANY literal `font-size` in `web/src` CSS, so the only way to go below
12289
- it is to add a fourth micro size to the type scale for one badge. 20x20 -> 16x16 is a 36% cut
12290
- in area on top of losing the label, which is what the ruling asked for; inventing a 10px step
12291
- under the scale's own floor is not. ⚠ SCOPED BY PARENT on purpose: `.cg-view-count` is shared
12292
- with the per-view badges (and with `.is-unknown`), and shrinking it unscoped would resize every
12293
- count in the rail from a ruling about the header. */
12294
- .cg-views-important .cg-view-count {
12295
- min-width: 16px;
12296
- height: 16px;
12297
- padding: 0 2px;
12298
- }
12299
- /* A PARTIAL total wears the same purple as a whole one β€” the number it shows IS real, it is just
12300
- not all of them β€” and says so with a `+` and its title. Muting it would read as "less
12301
- important" rather than "less complete". */
12302
- .cg-views-important.is-partial .cg-view-count {
12303
- border: 1px dashed var(--lp-purple-deep);
12304
- }
12305
  .cg-view-count.is-unknown {
12306
  background: transparent;
12307
  color: var(--lp-muted);
 
317
  /* Item 5 β€” the rail's head is the shared top band: same height as the shell head and the
318
  toolbar, closed by the same hairline. The negative margin bleeds the line to the rail's
319
  true edges past the container padding (overflow:hidden clips the collapsed overshoot). */
320
+ /* ⭐⭐ WAVE 35 Β· T11 (owner item 1, R1) β€” THE FOLD CONTROL LIVES ON THE LEFT EDGE NOW, in every
321
+ rail the product draws (the shell's, this one, and `.auto-rail-top`). The owner's word is that
322
+ the "III" belongs on the edge the navigation OPENS from, not the edge it ends at.
323
+ ⚠ THE OLD `flex-end` WAS ONLY HALF OF WHAT PLACED THIS BUTTON, which is why flipping it alone
324
+ is not the whole change: the toggle is already `.cg-views-top`'s FIRST child, and the important
325
+ total that used to follow it carried `margin-left: auto`. An auto margin outranks
326
+ `justify-content` entirely, so on a database with a marked view the toggle was ALREADY hard
327
+ left and on every other database it sat right β€” one control, two positions, depending on data.
328
+ With the badge gone (W35-T27) and this at `flex-start` there is one position. */
329
  .cg-views-top {
330
  flex: 0 0 var(--lp-rail-head-h);
331
  height: var(--lp-rail-head-h);
332
  display: flex;
333
  align-items: center;
334
+ justify-content: flex-start;
335
  margin: 0 -8px;
336
  padding: 0 8px;
337
  border-bottom: 1px solid var(--lp-line);
 
2956
  }
2957
 
2958
  /* ---- the frame ----------------------------------------------------------- */
2959
+ /* ⭐⭐ WAVE 35 Β· T01 (owner item 1, R1, contract C1) β€” `.shell-root` IS A COLUMN NOW: the top strip,
2960
+ then the rail-and-content row. It was a plain flex ROW (rail | main) and the strip has to span
2961
+ the whole window, so the row it used to be is now `.shell-frame` one level down.
2962
+ ⚠ The overlay children that follow `.shell-frame` (`.shell-toast`, `.shell-newdb-scrim`) stay
2963
+ correct through this because both are `position: fixed` and therefore out of flow β€” checked, not
2964
+ assumed; a STATIC sibling would have become a third row in this column and eaten height. */
2965
  .shell-root {
2966
  display: flex;
2967
+ flex-direction: column;
2968
  width: 100%;
2969
  height: 100vh;
2970
  overflow: hidden;
 
2972
  color: var(--lp-ink);
2973
  background: var(--lp-surface);
2974
  }
2975
+ /* C1: the ONE persistent band. Full window width, on every module, and nothing in it moves when
2976
+ you navigate β€” which is the whole of ruling R1. */
2977
+ .shell-topbar {
2978
+ flex: 0 0 var(--lp-rail-head-h);
2979
+ height: var(--lp-rail-head-h);
2980
+ display: flex;
2981
+ align-items: center;
2982
+ gap: 10px;
2983
+ padding: 0 10px 0 12px;
2984
+ background: var(--lp-surface);
2985
+ border-bottom: 1px solid var(--lp-line);
2986
+ }
2987
+ /* β›” `min-height: 0` is load-bearing, not tidiness. A flex ITEM defaults to `min-height: auto`,
2988
+ which refuses to shrink below its content β€” so without this the rail's own scroll box and the
2989
+ grid host would size to their content and push the page past 100vh instead of scrolling inside
2990
+ it. The symptom is a page that scrolls as a whole and a grid with no internal scrollbar. */
2991
+ .shell-frame {
2992
+ flex: 1 1 auto;
2993
+ min-height: 0;
2994
+ display: flex;
2995
+ width: 100%;
2996
+ }
2997
+ .shell-topbar-toggle {
2998
+ flex: 0 0 28px;
2999
+ width: 28px;
3000
+ height: 28px;
3001
+ display: inline-flex;
3002
+ align-items: center;
3003
+ justify-content: center;
3004
+ border: 0;
3005
+ border-radius: var(--lp-r-md);
3006
+ background: transparent;
3007
+ color: var(--lp-muted);
3008
+ cursor: pointer;
3009
+ }
3010
+ .shell-topbar-toggle:hover { background: var(--lp-surface-2); color: var(--lp-ink); }
3011
+ .shell-topbar-toggle:focus-visible {
3012
+ outline: 2px solid var(--lp-blue-deep);
3013
+ outline-offset: 1px;
3014
+ }
3015
+ .shell-topbar-brand {
3016
+ display: inline-flex;
3017
+ align-items: center;
3018
+ min-width: 0;
3019
+ }
3020
 
3021
  /* WHITE, like the host's `[theme.sidebar]` (owner 2026-07-23: "slick, visible
3022
  light-grey borders") β€” the wash belongs to page bodies, not the rail.
 
3036
  flex-basis var(--lp-fold-t) var(--lp-fold-e);
3037
  }
3038
 
3039
+ /* ⭐ WAVE 35 Β· T02 β€” WHAT THIS BAND IS NOW. It held the brand and the minimize control; both live
3040
+ in `.shell-topbar` (C1). It survives, empty, ONLY as an alignment band: `DbHead` and
3041
+ `.cg-views-top` are each one `--lp-rail-head-h` tall, so removing it would raise the rail's first
3042
+ nav row a header above the grid beside it on every database page. Same height, same hairline,
3043
+ no contents β€” `justify-content` and the fold `transition` went with the children they positioned. */
3044
  .shell-side-head {
 
 
 
3045
  flex: 0 0 var(--lp-rail-head-h);
3046
  height: var(--lp-rail-head-h);
 
3047
  border-bottom: 1px solid var(--lp-line);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3048
  }
3049
+ /* β›”β›” DELETED WITH T02, AND THIS NOTE IS THE POINT OF THE DELETION: `.shell-rail-toggle` (its base,
3050
+ hover and focus rules) and `.shell-side.is-collapsed .shell-rail-toggle { display: none }`.
3051
+ That last rule is what made wave-20 R9 necessary β€” the toggle vanished when collapsed, so the
3052
+ LOGO had to become the way back. Both the toggle and the logo now sit in the top strip, OUTSIDE
3053
+ `.shell-side`, so the collapsed selector could no longer reach them and the rule would have been
3054
+ an inert line that still reads as load-bearing to the next person. The way back is now simply
3055
+ that the toggle never leaves. Its styling lives on `.shell-topbar-toggle` above.
3056
+ ⚠ `.cg-rail-toggle` is a DIFFERENT control (the views/agents sub-rail) and is untouched. */
3057
  .shell-side.is-collapsed {
3058
  width: var(--lp-rail-min);
3059
  flex-basis: var(--lp-rail-min);
3060
  }
3061
+ /* Geometry note (item 1): nothing re-centers on collapse β€” paddings are tuned so the icons SLIDE
3062
+ to the strip's center as the width animates (a justify-content flip would snap them at t=0).
3063
+ Icon x: 6 + 10 = 16 = (48-16)/2. */
 
 
 
3064
  .shell-side.is-collapsed .shell-brand { gap: 0; }
3065
  .shell-side.is-collapsed .lp-wordmark,
3066
  .shell-side.is-collapsed .shell-nav-label,
 
3080
  gap: 0;
3081
  }
3082
  .shell-side.is-collapsed .shell-nav-item.is-child { padding-left: 10px; }
3083
+ /* ⭐ WAVE 35 Β· T05 (owner item 6) β€” the Templates row sits in the rail's bottom band, above the
3084
+ account button, so it takes the band's own gutter rather than the nav list's. It is a
3085
+ `.shell-nav-item`, so weight, height, hover and the fold all come from the rail's shared rule
3086
+ and this adds only the placement. */
3087
+ .shell-nav-templates {
3088
+ margin: 0 8px 2px;
3089
+ width: calc(100% - 16px);
3090
+ }
3091
+ /* β›” W33-T75's law reaches this row too. `.shell-nav.is-rail-loading > *` cannot select it β€” this
3092
+ button is a SIBLING of `.shell-nav`, not a child β€” so while `/nav` is in flight it would paint in
3093
+ ink under six skeleton bars. HIDDEN, not unmounted, for the same reason the nav rows are: the
3094
+ node stays in the tree so nothing hanging off it is torn down for the length of a fetch. */
3095
+ .shell-nav-templates.is-rail-hidden { display: none; }
3096
+ .shell-side.is-collapsed .shell-nav-templates {
3097
+ margin: 0 6px 2px;
3098
+ width: calc(100% - 12px);
3099
+ padding: 7px 10px;
3100
+ }
3101
  .shell-side.is-collapsed .shell-side-bottom { padding: 8px 2px 10px; }
3102
  .shell-side.is-collapsed .shell-account {
3103
  padding: 7px 8px;
3104
  gap: 0;
3105
  }
3106
 
3107
+ /* ⭐ WAVE 35 Β· T02 β€” `.shell-brand-btn` IS DELETED, and it is the wave-20 R9 decision being
3108
+ retired rather than a style being tidied. The brand was a `<button>` at all only so it could be
3109
+ the way back from a collapsed rail; it is a plain `<div class="shell-topbar-brand">` in the top
3110
+ strip now, and the toggle beside it is the way back in both states. Keeping the button rules
3111
+ would leave the app describing the logo as a control that no longer does anything. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3112
  .shell-brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
3113
  /* ⚠ DELIBERATELY OFF THE TYPE SCALE. The wordmark is BRAND, not UI text: 17px is what the app's
3114
  own nav wordmark resolves to (1.02rem against its 17px base), and matching the sibling door
 
3217
  /* ⚠ NOT in the shared rule above: `.shell-nav-group` is a LABEL, not a control
3218
  ("quieter, no hover, no cursor" β€” its own rule below), and a pointer cursor
3219
  on a row that cannot be clicked promises a destination that does not exist. */
3220
+ /* `position: relative` is for W35-T03's left marker (`.is-active::before`). ⚠ It is safe on EVERY
3221
+ row, checked rather than assumed: the only absolutely-positioned descendant in this subtree is
3222
+ `.shell-side.is-collapsed .shell-nav-badge`, and its containing block is already
3223
+ `.shell-side.is-collapsed .shell-nav-alerts` β€” which IS a `.shell-nav-item`. So the badge keeps
3224
+ the same anchor and does not move; that older rule is now redundant rather than contradicted. */
3225
+ .shell-nav-item { cursor: pointer; position: relative; }
3226
  .shell-nav-item:hover { background: var(--lp-surface-2); }
3227
  .shell-nav-item.is-child { padding-left: 30px; }
3228
  /* The current row is marked the way the host marks it: the selected-row tint,
 
3234
  never had that, because there the ROW carries the tint. It does here now
3235
  (`.shell-nav-row.is-active`, navExtras.css). What stays here is everything
3236
  that genuinely belongs to the LINK: its weight, and the icon below. */
3237
+ /* ⭐⭐ WAVE 35 Β· T03 (owner item 7) β€” "there should be a highlight so user know what module they
3238
+ are in". THE SCOUTED PREMISE WAS HALF TRUE AND THAT IS WHY THIS LOOKED LIKE A NO-OP: the blue
3239
+ tint lives on `.shell-nav-row.is-active` (navExtras.css), and a `.shell-nav-row` only wraps a
3240
+ DATABASE row. A bare MODULE row β€” Home, Inbox, Assistant, Agents, Database, Connectors, and now
3241
+ Starred β€” is an unwrapped `.shell-nav-item`, so all it ever got was `font-weight: 600` and a
3242
+ full-strength icon. Weight alone is the one signal that disappears at a glance, which is exactly
3243
+ the owner's complaint. The tint moves here so a module is marked the way a database already is,
3244
+ and a 3px left marker carries the distinction at the edge of vision.
3245
+ β›” THE `:hover` DUPLICATE IS LOAD-BEARING, NOT BELT-AND-BRACES. `.shell-nav-item:hover` and
3246
+ `.shell-nav-item.is-active` have the SAME specificity (0,2,0), so at rest the active row wins
3247
+ only because it is declared later β€” the source-order luck navExtras.css's own comment warns
3248
+ about, and this nav has lost a rule to source order before. `.is-active:hover` is (0,3,0) and
3249
+ settles it by specificity instead.
3250
+ ⚠ A DATABASE row is unaffected on purpose: its `.shell-nav-row` still paints the same tint
3251
+ underneath, so the two surfaces agree rather than double-painting a different colour. */
3252
+ .shell-nav-item.is-active,
3253
+ .shell-nav-item.is-active:hover {
3254
+ background: var(--lp-blue-tint);
3255
  font-weight: 600;
3256
  }
3257
+ /* ⚠ `--lp-primary`, NOT `--lp-blue-deep`, AND THE REASON IS MEASURED. Session B measured
3258
+ `--lp-blue-deep` (#768fb6) on `--lp-blue-tint` (#edf3fd) at **2.96:1**, under WCAG's 3:1 bar for
3259
+ a graphical object; I reproduced it and checked the neighbours, because the obvious substitute is
3260
+ not one: `--lp-blue-solid` is **2.95:1**, no better. `--lp-primary` (#5433cc) is **6.92:1** on the
3261
+ same tint. β›” Nothing in the battery measures a contrast ratio β€” `verify_ui` checks that font
3262
+ sizes are TOKENS, not that anything is legible β€” so this class of defect is found by measuring or
3263
+ not at all, and this note is the measurement.
3264
+ ⚠ DESIGN.md 3's "blue = selected" is untouched: the ROW TINT is still blue and still carries the
3265
+ meaning. This is the brand accent marking the edge, which is what needs to survive peripheral
3266
+ vision. */
3267
+ .shell-nav-item.is-active::before {
3268
+ content: "";
3269
+ position: absolute;
3270
+ left: 0;
3271
+ top: 50%;
3272
+ transform: translateY(-50%);
3273
+ width: 3px;
3274
+ height: 18px;
3275
+ border-radius: 0 var(--lp-r-sm) var(--lp-r-sm) 0;
3276
+ background: var(--lp-primary);
3277
+ }
3278
  .shell-nav-item:focus-visible { outline: 2px solid var(--lp-blue-deep); outline-offset: -2px; }
3279
 
3280
  .shell-nav-icon {
 
7438
  It had the height and nothing else: the fold control sat LEFT while the Views rail's sits
7439
  right, and there was no hairline at all β€” so the automation page's top band simply stopped
7440
  halfway across the screen while every database page's ran edge to edge. The three things
7441
+ `.cg-views-top` does and this did not: a shared `justify-content`, the closing hairline,
7442
  and the negative margin that bleeds that line past the rail's own gutter to its true edges
7443
  (`overflow: hidden` on the rail clips the collapsed overshoot, exactly as it does there).
7444
  ⚠ THE BLEED IS THIS RAIL'S OWN GUTTER, not a copied number: `.cg-views` pads 8px and bleeds
7445
  -8px; this rail pads 10px, so it bleeds -10px. Copying the -8 would leave a 2px gap at each
7446
+ end β€” the same defect in a more convincing disguise.
7447
+ ⭐ WAVE 35 Β· T11 (owner item 1, R1) β€” and the shared value is `flex-start` now. This rail holds
7448
+ ONLY the toggle, so the flip is the whole change here; no TSX moves. */
7449
  .auto-rail-top {
7450
  flex: 0 0 var(--lp-rail-head-h);
7451
  height: var(--lp-rail-head-h);
7452
  display: flex;
7453
  align-items: center;
7454
+ justify-content: flex-start;
7455
  margin: 0 -10px;
7456
  padding: 0 10px;
7457
  border-bottom: 1px solid var(--lp-line);
 
10755
  /* ── Item 7 (R9) β€” the collapsed rail expands from its own background ───────
10756
  The strip's blank area is now a click target, so it says so. Interactive
10757
  children keep their own cursor (the UA default for a link/button is already
10758
+ `pointer`).
10759
+ ⚠ WAVE 35 · T02 corrected this note: it used to end "and `.shell-brand-btn` keeps the explicit
10760
+ one it had", naming a rule that no longer exists β€” the brand left the rail for the top strip and
10761
+ its button rules were deleted with it. This background click is now a CONVENIENCE rather than
10762
+ the accessible route back, because the toggle in the strip is present in both states. */
10763
  .shell-side.is-collapsed { cursor: pointer; }
10764
 
10765
  /* ==== /wave20:S4 ==== */
 
11085
  /* ── C10 β€” HOME ───────────────────────────────────────────────────────────────────────────
11086
  `reference/Airtable Home.png`'s anatomy, our tokens. `.shell-main` is `overflow: hidden`, so
11087
  the page owns its own scroll or a tenant with forty recents can reach none of them. */
11088
+ /* ⭐⭐ WAVE 35 Β· T10 (owner item 16) β€” "when I toggle between List and Gallery view under Home, the
11089
+ web width itself shift". THE CAUSE IS THIS SCROLLBAR, and it is fixed here rather than by padding
11090
+ the tiles, because padding hides a shift and leaves it.
11091
+ MEASURED on the live build, which is what identifies the container: in GALLERY the content fits
11092
+ and nothing here scrolls (the walk up from `.home-tiles` reaches `html`); in LIST the tiles
11093
+ stack one per row, `.shell-home` overflows and becomes the scroller. So a classic scrollbar
11094
+ appears in exactly one of the two layouts and takes its width out of the content box.
11095
+ `scrollbar-gutter: stable` reserves that space in BOTH, so the column is the same width either
11096
+ way and nothing moves.
11097
+ ⚠ THE PIXEL DELTA DOES NOT REPRODUCE IN HEADLESS CHROMIUM and that is a property of the
11098
+ instrument, not evidence against the diagnosis: headless paints OVERLAY scrollbars, which take
11099
+ no layout width, so the measured delta there is 0 while the owner sees ~15px on Windows Chrome.
11100
+ What the headless run DOES prove is the mechanism β€” the scroller identity flips between the two
11101
+ layouts. */
11102
  .shell-home {
11103
  height: 100%;
11104
  overflow-y: auto;
11105
+ scrollbar-gutter: stable;
11106
  padding: 34px 44px 56px;
11107
  box-sizing: border-box;
11108
  background: var(--lp-wash);
 
11176
  border-radius: var(--lp-r-md);
11177
  background: var(--lp-surface);
11178
  }
11179
+ /* ⭐ WAVE 35 Β· T10 (owner item 10) β€” "that button toggle to be larger". MEASURED at 26x22 before
11180
+ this, which is under every common 32px minimum-target guideline in BOTH axes; it is now 36x32,
11181
+ so the smaller side clears 32. The icon grows with the box (14 -> 16px) or a larger button just
11182
+ frames the same small mark in more empty space, which reads as a bigger gap rather than a bigger
11183
+ control. */
11184
  .home-layout-btn {
11185
  display: inline-flex;
11186
  align-items: center;
11187
  justify-content: center;
11188
+ width: 36px;
11189
+ height: 32px;
11190
  border: 0;
11191
  border-radius: var(--lp-r-sm);
11192
  background: transparent;
 
11196
  cursor: pointer;
11197
  }
11198
  .home-layout-btn:hover { background: var(--lp-surface-2); }
11199
+ /* Blue = selected/active, the fixed semantic (DESIGN.md Β§3) β€” the TINT still carries that meaning.
11200
+ ⚠ THE INK MOVED OFF `--lp-blue-deep`, AND IT IS A MEASUREMENT, NOT A PREFERENCE. Session B
11201
+ measured this exact pair off a real render at **2.96:1** β€” under the 4.5:1 text bar and under the
11202
+ 3:1 graphical bar β€” and raised it because this button is where they copied it FROM. β›” The obvious
11203
+ substitute is not one: `--lp-blue-solid` is **2.95:1**. `--lp-primary` is **6.92:1** on the same
11204
+ tint. This button's whole content is a 16px icon, so its contrast IS its legibility.
11205
+ ⚠ Nothing in the battery measures a contrast ratio β€” `verify_ui` checks that sizes are TOKENS,
11206
+ not that anything is readable β€” so this class of defect is found by measuring or not at all. */
11207
+ .home-layout-btn.is-on { background: var(--lp-blue-tint); color: var(--lp-primary); }
11208
  .home-layout-icon {
11209
+ width: 16px;
11210
+ height: 16px;
11211
  fill: none;
11212
  stroke: currentColor;
11213
  stroke-width: 1.35;
 
12377
  text-align: center;
12378
  line-height: 1;
12379
  }
12380
+ /* β›”β›” WAVE 35 Β· T11 (owner item 9, R4) β€” THE DATABASE-LEVEL TOTAL IS GONE, AND SO IS EVERY RULE
12381
+ THAT DRESSED IT. Owner item 9: the count belongs to the VIEW itself and nowhere else. Wave 33's
12382
+ T16 put a per-database total at the top of this rail and wave 34's T20 stripped its label; both
12383
+ notes are now history and live in `.claude/wiki/waves/wave35/` rather than here.
12384
+ ⚠ FOUR RULES DIED TOGETHER ON PURPOSE, and this note is what stops one of them coming back
12385
+ alone: `.cg-views-important` (the wrapper's `margin-left: auto`, which outranked
12386
+ `.cg-views-top`'s `justify-content` and is why the fold toggle sat left on a database with a
12387
+ marked view and right on one without), the two `.cg-views-important .cg-view-count` size rules,
12388
+ and the `.is-partial` dashed mark. The ELEMENT they styled leaves in W35-T27 (`ViewSidebar.tsx`,
12389
+ session C).
12390
+ ⭐ WHAT SURVIVES, AND IT IS NOT THE SAME THING: `.cg-view-count` and `.cg-view-count.is-unknown`
12391
+ below are the PER-VIEW badges on each view row. Owner item 9 keeps those. */
12392
  /* ⭐ WAVE-33 T14 (D-205) β€” the same mark, saying "no number here, and here is why" (its `title`).
12393
  β›” NOT the solid purple: a filled badge is the product's word for "this many", and wearing it
12394
  over a dash would state a count of nothing. Outlined and muted says "the slot exists, the
12395
  number does not" β€” which is exactly the distinction an ABSENT badge could not draw, and the
12396
  whole of D-205. Same box, so a rail of marked views does not jump when one of them cannot be
12397
  counted. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12398
  .cg-view-count.is-unknown {
12399
  background: transparent;
12400
  color: var(--lp-muted);
web/src/query/QueryPage.tsx CHANGED
@@ -4,13 +4,12 @@ import type { QueryOpenDetail } from "../apiContract";
4
  import { QUERY_OPEN_EVENT } from "../apiContract";
5
  import CustomerGrid from "../customer-grid/CustomerGrid";
6
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
7
- import { queryCitationLabel } from "../customer-grid/queryPreview";
8
  import { DbHead, gridScopeFor } from "../shell/dbFrame";
9
  import { databaseEntries } from "../shell/nav";
10
  import type { NavEntry } from "../shell/nav";
11
  import { bindingFor, deleteQuery, fetchQueries, QUERY_BINDING_EVENT } from "./queryApi";
12
  import type { QueryCitation, SavedQuery } from "./queryApi";
13
- import { QueryEmpty, queryGroups, QueryProvenance, QueryRail } from "./queryParts";
14
  import type { QueryGroup } from "./queryParts";
15
  import "./query.css";
16
 
@@ -134,9 +133,6 @@ export default function QueryPage({ granted, hostedRail, refreshToken, selectedI
134
 
135
  if (!active) return <QueryEmpty loaded={loaded} unnamed={views.length - known.length} />;
136
  const entry = entryOf(active.source.database);
137
- const cited = citations.filter((citation) => active.citationIds.includes(citation.id));
138
- const provenance = cited.map(queryCitationLabel).join(" Β· ")
139
- || `${active.source.label} Β· snapshot ${String(active.source.source_version)} Β· retrieved ${active.source.retrieved_at}`;
140
  return (
141
  <div className="shell-db-frame">
142
  <DbHead label={active.source.label} {...(entry?.icon ? { icon: entry.icon } : {})} />
@@ -146,8 +142,15 @@ export default function QueryPage({ granted, hostedRail, refreshToken, selectedI
146
  <QueryRail groups={groups} activeId={active.id} onSelect={setActiveId}
147
  onDelete={(id) => void remove(id)} onChanged={upsert} />
148
  )}
 
 
 
 
 
 
 
 
149
  <div className="qy-surface">
150
- <QueryProvenance view={active} detail={provenance} />
151
  <div className="qy-grid-host">
152
  <OverlayProvider>
153
  <CustomerGrid key={gridScopeFor(active.source.database)}
 
4
  import { QUERY_OPEN_EVENT } from "../apiContract";
5
  import CustomerGrid from "../customer-grid/CustomerGrid";
6
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
 
7
  import { DbHead, gridScopeFor } from "../shell/dbFrame";
8
  import { databaseEntries } from "../shell/nav";
9
  import type { NavEntry } from "../shell/nav";
10
  import { bindingFor, deleteQuery, fetchQueries, QUERY_BINDING_EVENT } from "./queryApi";
11
  import type { QueryCitation, SavedQuery } from "./queryApi";
12
+ import { QueryEmpty, queryGroups, QueryRail } from "./queryParts";
13
  import type { QueryGroup } from "./queryParts";
14
  import "./query.css";
15
 
 
133
 
134
  if (!active) return <QueryEmpty loaded={loaded} unnamed={views.length - known.length} />;
135
  const entry = entryOf(active.source.database);
 
 
 
136
  return (
137
  <div className="shell-db-frame">
138
  <DbHead label={active.source.label} {...(entry?.icon ? { icon: entry.icon } : {})} />
 
142
  <QueryRail groups={groups} activeId={active.id} onSelect={setActiveId}
143
  onDelete={(id) => void remove(id)} onChanged={upsert} />
144
  )}
145
+ {/* β›” NO PROVENANCE ROW HERE (W35-T21, owner item 4 / R2). The "Sources" disclosure and
146
+ the snapshot line it opened are deleted; a Query view is a database view now, and a
147
+ database view carries no banner. The citations still travel β€” `binding` above is
148
+ built from this artefact AND the fetched citation list β€” and the reader still SEES
149
+ them in the chat, under the answer that used them (`AssistantPage::CitationLine`).
150
+ ⚠ Do NOT paste that builder call into a comment: `verify_grid_ux.py`'s NC mutates the
151
+ FIRST occurrence of it, so a second copy in prose keeps the scan green over a page
152
+ that stopped binding citations [[prose-that-becomes-its-own-marker]]. */}
153
  <div className="qy-surface">
 
154
  <div className="qy-grid-host">
155
  <OverlayProvider>
156
  <CustomerGrid key={gridScopeFor(active.source.database)}
web/src/query/query.css CHANGED
@@ -250,10 +250,53 @@
250
  .qy-rail-row.is-active .qy-rail-name { font-weight: 600; }
251
 
252
  .qy-rail-kind {
 
 
 
 
253
  color: var(--lp-muted);
254
  font-size: var(--lp-fs-2xs);
255
  }
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  /* Hover-revealed, like the grid rail's own row menu β€” visible on focus so it is
258
  reachable from the keyboard rather than only from a pointer. */
259
  .qy-rail-del {
@@ -311,7 +354,8 @@
311
 
312
  .qy-rail-ask:hover { background: var(--lp-surface-2); }
313
 
314
- /* ── the surface: provenance line, then the grid ──────────────────────────── */
 
315
 
316
  .qy-surface {
317
  flex: 1 1 auto;
@@ -328,50 +372,6 @@
328
  overflow: hidden;
329
  }
330
 
331
- .qy-prov {
332
- flex: 0 0 auto;
333
- border-bottom: 1px solid var(--lp-line);
334
- background: var(--lp-surface);
335
- }
336
-
337
- .qy-prov-btn {
338
- display: flex;
339
- align-items: baseline;
340
- gap: 10px;
341
- width: 100%;
342
- padding: 7px 14px;
343
- border: 0;
344
- background: transparent;
345
- font: inherit;
346
- text-align: left;
347
- cursor: pointer;
348
- }
349
-
350
- .qy-prov-q {
351
- flex: 1 1 auto;
352
- min-width: 0;
353
- overflow: hidden;
354
- text-overflow: ellipsis;
355
- white-space: nowrap;
356
- color: var(--lp-muted);
357
- font-size: var(--lp-fs-xs);
358
- }
359
-
360
- .qy-prov-toggle {
361
- flex: 0 0 auto;
362
- color: var(--lp-blue-solid);
363
- font-size: var(--lp-fs-2xs);
364
- font-weight: 600;
365
- }
366
-
367
- .qy-prov-detail {
368
- margin: 0;
369
- padding: 0 14px 9px;
370
- color: var(--lp-muted);
371
- font-size: var(--lp-fs-2xs);
372
- line-height: var(--lp-lh);
373
- word-break: break-word;
374
- }
375
 
376
  /* ── the empty state ──────────────────────────────────────────────────────── */
377
 
 
250
  .qy-rail-row.is-active .qy-rail-name { font-weight: 600; }
251
 
252
  .qy-rail-kind {
253
+ display: flex;
254
+ align-items: center;
255
+ gap: 6px;
256
+ min-width: 0;
257
  color: var(--lp-muted);
258
  font-size: var(--lp-fs-2xs);
259
  }
260
 
261
+ /* ⭐ W35-T25 (R3) β€” the Edited mark. Quiet on purpose: it reports a state the reader created
262
+ themselves, so it belongs beside the kind rather than competing with the view's name. */
263
+ .qy-rail-edited {
264
+ flex: 0 0 auto;
265
+ padding: 0 5px;
266
+ border-radius: var(--lp-r-sm);
267
+ background: var(--lp-surface-2);
268
+ color: var(--lp-muted);
269
+ font-size: var(--lp-fs-2xs);
270
+ font-weight: 600;
271
+ line-height: 15px;
272
+ }
273
+
274
+ .qy-rail-row.is-active .qy-rail-edited { background: var(--lp-surface); }
275
+
276
+ /* ── ⭐⭐ W35-T47 (R4, C2) β€” the SHARED star button, re-placed for a rail row ─────────────────
277
+ *
278
+ * β›” GEOMETRY ONLY. `StarButton` is B's component and `starred.css` owns everything about how it
279
+ * looks; overriding its colour or its glyph here would be a second opinion about one mark. What a
280
+ * rail row genuinely needs different is placement: the shared rule is `position: absolute` on a
281
+ * TILE wrapper, and this row is a flex list whose right end already holds the actions button, so
282
+ * an absolutely positioned star would sit on top of it.
283
+ *
284
+ * ⚠ AND THE REVEAL RULE HAS TO BE RESTATED, not inherited. The shared sheet reveals the star with
285
+ * `.st-tilewrap:hover`, a wrapper that does not exist here β€” so without the row's own hover rule
286
+ * the button would be `opacity: 0` forever except when starred or focused, i.e. a control you can
287
+ * only find by tabbing to it. `.is-on` and `:focus-visible` keep working from the shared sheet.
288
+ */
289
+ .qy-rail-row .st-star {
290
+ position: static;
291
+ transform: none;
292
+ flex: 0 0 auto;
293
+ width: 22px;
294
+ height: 22px;
295
+ margin-right: 2px;
296
+ }
297
+
298
+ .qy-rail-row:hover .st-star { opacity: 1; }
299
+
300
  /* Hover-revealed, like the grid rail's own row menu β€” visible on focus so it is
301
  reachable from the keyboard rather than only from a pointer. */
302
  .qy-rail-del {
 
354
 
355
  .qy-rail-ask:hover { background: var(--lp-surface-2); }
356
 
357
+ /* ── the surface: the grid, and nothing above it ──────────────────────────── */
358
+ /* W35-T21 (owner item 4 / R2): the `.qy-prov*` family is deleted with the component. */
359
 
360
  .qy-surface {
361
  flex: 1 1 auto;
 
372
  overflow: hidden;
373
  }
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
  /* ── the empty state ──────────────────────────────────────────────────────── */
377
 
web/src/query/queryApi.ts CHANGED
@@ -47,6 +47,21 @@ export interface SavedQuery {
47
  view: Record<string, unknown>;
48
  citationIds: string[];
49
  numeric: { label: string; value: number | null; contributing_record_count: number };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
 
52
  export interface QueryThread {
@@ -133,13 +148,32 @@ export interface QueryVirtualBinding {
133
  source: QuerySource;
134
  view: Record<string, unknown>;
135
  citationIds: string[];
136
- /** Complete, clickable provenance for this exact immutable virtual artefact. */
137
  citations: QueryCitation[];
 
 
 
 
 
 
 
 
 
 
 
138
  }
139
 
140
  /**
141
- * Grid previews cannot mutate Query artefacts. A new Assistant prompt creates a new immutable
142
- * artefact; the existing DELETE endpoint is Query-history removal, never a grid event transport.
 
 
 
 
 
 
 
 
143
  */
144
  export type QueryMutationOperation = "create" | "update" | "delete";
145
  export interface QueryMutationRefusal {
@@ -169,9 +203,18 @@ const IMMUTABLE_MUTATION: QueryMutationRefusal = {
169
  durable: false,
170
  transport: "none",
171
  code: "query_view_immutable",
 
 
 
 
 
 
 
 
172
  // "the Assistant" β€” the rail's own word since W34-T13. Copy that names a module must name it
173
  // the way the reader sees it, or the sentence sends them looking for a door that is not there.
174
- message: "This Query view is immutable. Ask the Assistant to create a revised view.",
 
175
  };
176
  export const QUERY_MUTATION_POLICY: QueryMutationPolicy = {
177
  create: IMMUTABLE_MUTATION,
@@ -283,6 +326,12 @@ function saved(value: unknown): SavedQuery | null {
283
  view: (row.view && typeof row.view === "object" ? row.view : {}) as Record<string, unknown>,
284
  citationIds: (row.citationIds as unknown[] || []).filter((id): id is string => typeof id === "string"),
285
  numeric: ((row.numeric && typeof row.numeric === "object" ? row.numeric : {}) as SavedQuery["numeric"]),
 
 
 
 
 
 
286
  };
287
  }
288
 
@@ -392,6 +441,18 @@ export function duplicateQuery(id: string): Promise<Result<SavedQuery>> {
392
  return call(`/query/${encodeURIComponent(id)}/duplicate`, json({}), (raw) => saved(raw) as SavedQuery);
393
  }
394
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  /**
396
  * A DOWNLOAD, not a fetch: an ordinary same-origin link carries the session cookie, and the server
397
  * names the file through `Content-Disposition`. Building a Blob here would mean holding the whole
 
47
  view: Record<string, unknown>;
48
  citationIds: string[];
49
  numeric: { label: string; value: number | null; contributing_record_count: number };
50
+ /**
51
+ * ⭐⭐ W35-T25 Β· CONTRACT C4 (R3) β€” THE AI'S OWN SPEC, AND WHETHER THIS ONE STILL MATCHES IT.
52
+ *
53
+ * `original_spec` is written ONCE, at creation, and is what "Revert to AI original" restores.
54
+ * `edited` is DERIVED on the server by comparing the two, never stored β€” so a revert clears the
55
+ * badge by construction rather than by remembering to.
56
+ *
57
+ * β›” `null` IS A REAL ANSWER AND THE CLIENT MUST HONOUR IT. An artefact created before this wave
58
+ * has no original: it reports `edited: false` and offers no Revert. C4 is explicit that a Revert
59
+ * with nothing to revert to is worse than an absent one.
60
+ * ⚠ The snake_case key is contract C4's own spelling, not a slip. It is the wire name both
61
+ * halves were specified against and renaming it here would be a silent one-sided change.
62
+ */
63
+ original_spec: Record<string, unknown> | null;
64
+ edited: boolean;
65
  }
66
 
67
  export interface QueryThread {
 
148
  source: QuerySource;
149
  view: Record<string, unknown>;
150
  citationIds: string[];
151
+ /** Complete, clickable provenance for this exact virtual artefact. */
152
  citations: QueryCitation[];
153
+ /*
154
+ * β›” NO `edited` HERE, AND THE ABSENCE IS THE DESIGN (W35-T23 + T25).
155
+ *
156
+ * A draft of this carried one, because the grid kept the reader's spec in a local bucket and
157
+ * needed a flag to decide whether that copy or the server's was current. That flag went stale
158
+ * inside a session β€” `binding` is memoised on the artefact and nothing refetches it after an
159
+ * autosave β€” so the first edit of a session survived a full reload and vanished on an in-session
160
+ * remount. The fix was to delete the second store, not to freshen the flag: `view` above IS the
161
+ * artefact's spec, and it is the only copy. The `edited` a reader SEES lives on `SavedQuery`,
162
+ * where the rail reads it, and the renderer has no business asking.
163
+ */
164
  }
165
 
166
  /**
167
+ * The Query workspace's mutation policy.
168
+ *
169
+ * ⭐⭐ W35-T23 (owner item 4 / R2) β€” `update` NO LONGER MEANS "REFUSED", and this block is the
170
+ * contract, so the change is stated here rather than only where it is enforced. A Query view is a
171
+ * live view of its source database: resize, sort, group, hide and row height all write its spec,
172
+ * through `POST /query/{qid}/events` and nowhere else. `create` is still refused β€” an artefact
173
+ * holds exactly one view β€” and `delete` still removes the artefact itself.
174
+ * ⚠ `QUERY_MUTATION_POLICY.update` is kept in the shape because the wire contract has three
175
+ * readers and dropping a member is a breaking change; what it describes is now the refusal a
176
+ * mismatched id gets, not every edit.
177
  */
178
  export type QueryMutationOperation = "create" | "update" | "delete";
179
  export interface QueryMutationRefusal {
 
203
  durable: false,
204
  transport: "none",
205
  code: "query_view_immutable",
206
+ // ⭐⭐ W35-T23 (R2) β€” THE SENTENCE MOVED WITH THE RULE. It read *"This Query view is immutable.
207
+ // Ask the Assistant to create a revised view."* and that is no longer true: a Query view's
208
+ // spec is editable now, and the reader who resized a column would have been told to go and ask
209
+ // a chatbot. What this refusal still covers is the ONE thing R2 did not open β€” a SECOND view
210
+ // inside an artefact that holds exactly one.
211
+ // ⚠ The CODE is unchanged (`query_view_immutable`): it is the wire contract three gates and
212
+ // the server's own refusal key on, and the reason for the refusal did not change, only its
213
+ // scope. Renaming it would be a protocol change dressed as a copy edit.
214
  // "the Assistant" β€” the rail's own word since W34-T13. Copy that names a module must name it
215
  // the way the reader sees it, or the sentence sends them looking for a door that is not there.
216
+ message: "A Query view holds one saved answer, so it cannot take a second view. "
217
+ + "Ask the Assistant for another one.",
218
  };
219
  export const QUERY_MUTATION_POLICY: QueryMutationPolicy = {
220
  create: IMMUTABLE_MUTATION,
 
326
  view: (row.view && typeof row.view === "object" ? row.view : {}) as Record<string, unknown>,
327
  citationIds: (row.citationIds as unknown[] || []).filter((id): id is string => typeof id === "string"),
328
  numeric: ((row.numeric && typeof row.numeric === "object" ? row.numeric : {}) as SavedQuery["numeric"]),
329
+ // ⚠ ABSENT β‡’ NOT EDITED AND NOT REVERTABLE, which is the safe direction: an older server that
330
+ // sends neither key leaves the badge off and the control hidden, rather than offering a Revert
331
+ // whose endpoint will 409.
332
+ original_spec: (row.original_spec && typeof row.original_spec === "object"
333
+ ? row.original_spec : null) as Record<string, unknown> | null,
334
+ edited: row.edited === true,
335
  };
336
  }
337
 
 
441
  return call(`/query/${encodeURIComponent(id)}/duplicate`, json({}), (raw) => saved(raw) as SavedQuery);
442
  }
443
 
444
+ /**
445
+ * ⭐⭐ W35-T25 Β· CONTRACT C4 (R3) β€” "Revert to AI original".
446
+ *
447
+ * β›” IT RESTORES THE VIEW SPEC AND NOTHING ELSE, and the caller must SAY so before it acts. A
448
+ * Query view is live now, so a cell the reader changed through it is a real write to a real
449
+ * record in the source database; putting the filters back cannot walk that back. R3 requires the
450
+ * confirm to state that BEFORE the click, not after.
451
+ */
452
+ export function revertQuery(id: string): Promise<Result<SavedQuery>> {
453
+ return call(`/query/${encodeURIComponent(id)}/revert`, json({}), (raw) => saved(raw) as SavedQuery);
454
+ }
455
+
456
  /**
457
  * A DOWNLOAD, not a fetch: an ordinary same-origin link carries the session cookie, and the server
458
  * names the file through `Content-Disposition`. Building a Blob here would mean holding the whole
web/src/query/queryParts.tsx CHANGED
@@ -1,23 +1,63 @@
1
- import { useState } from "react";
2
 
3
  import { FolderMark } from "../customer-grid/icons";
4
  import { DbIcon } from "../shell/dbFrame";
5
  import { ASSISTANT_ROUTE } from "../shell/nav";
6
  import type { NavEntry } from "../shell/nav";
7
- import { duplicateQuery, exportQueryHref, renameQuery, saveQueryToDatabase } from "./queryApi";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  import type { SavedQuery } from "./queryApi";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  /** Which label of which row is being typed into. One at a time, by construction. */
11
  interface Editing { id: string; field: "name" | "description"; value: string; }
12
 
13
  /**
14
- * β›” THE MENU IS `position: fixed`, AND THAT IS NOT A STYLE PREFERENCE. Its natural home,
15
  * `.qy-rail-scroll`, is `overflow-y: auto`, which clips BOTH axes: an absolutely positioned menu
16
  * on a row near the bottom of the list renders cut off or not at all, so the reader clicks and
17
  * nothing appears. It fails worst on the rows most likely to be used, the ones you scrolled to.
18
  * Fixed positioning takes it out of the scroll box entirely, which costs one measurement.
19
  *
20
- * ⚠ `MENU_H` is an estimate used ONLY to decide whether to flip upward near the viewport floor. It
21
  * does not size anything, so being a little wrong shifts the menu rather than breaking it.
22
  */
23
  const MENU_H = 190;
@@ -51,7 +91,7 @@ export function QueryEmpty({ loaded, unnamed = 0 }: { loaded: boolean; unnamed?:
51
  );
52
  }
53
 
54
- /** The kind glyph a Query row wears β€” the same vocabulary the grid's own view rail uses. */
55
  const KIND_MARK: Record<string, string> = {
56
  grid: "Table", list: "List", chart: "Chart", kanban: "Board",
57
  calendar: "Calendar", timeseries: "Time series", map: "Map",
@@ -65,17 +105,17 @@ export interface QueryGroup {
65
  }
66
 
67
  /**
68
- * ⚠ GROUPED IN THE ORDER THE DATABASES ARE GRANTED, not by artefact age. The rail is a list of
69
  * DATABASES first (owner: "each View correspond to the relevant database"), so a new answer about
70
- * Customers must not move the Customers heading β€” it appears under it. Within a database the
71
  * newest is first, which is the order the server already sends.
72
  *
73
- * ⭐ R14 GAVE THIS LIST A SECOND HOST, which is why the loop is a function and lives HERE rather
74
  * than inside `QueryPage`. The merged Assistant surface renders the same rail in its own left
75
  * panel; two hosts computing "which views belong to which database" from two copies of one loop
76
  * is one question with two normalizers, and the copy that drifts is the one nobody is looking at.
77
  * It sits beside `QueryGroup`, the type it builds, and in a module that does NOT import the grid
78
- * β€” a static import from `QueryPage.tsx` would pull `CustomerGrid` into the chat bundle and undo
79
  * the lazy boundary the Assistant keeps around it.
80
  *
81
  * A view whose source database is not in `entries` is dropped: the caller's grant is the wall.
@@ -102,21 +142,21 @@ export function queryGroups(views: SavedQuery[], entries: NavEntry[]): QueryGrou
102
  }
103
 
104
  /**
105
- * ⭐⭐ OWNER ITEM 3 (2026-08-15) β€” GROUPED BY SOURCE DATABASE, WHICH IS THE WHOLE INSTRUCTION.
106
  * Verbatim: *"these AI generated query should live under the query module. And that each View
107
  * correspond to the relevant database. So we need to remove that dumb dropdown that suddenly
108
  * appears at the top right of the Database when the AI does query. That's not how we should do
109
  * the navigation."*
110
  *
111
- * β›” THE DROPDOWN WAS NOT THE DEFECT β€” THE ABSENCE OF NAVIGATION WAS. Deleting it (which the
112
  * previous pass did) left Query with no way to move between artefacts at all: the page picked
113
  * `known[0]` and every other view the assistant had ever built was unreachable. A surface with
114
  * one implicit selection is not "no dropdown", it is no navigation, and the owner asked for the
115
  * navigation to be done differently rather than removed.
116
  *
117
- * ⚠ AND THIS IS WHERE A DATABASE PUTS ITS VIEWS. `dbFrame.tsx` carries the owner's earlier
118
- * sentence β€” *"the query module should exactly BE looking like the database module, COMPLETELY,
119
- * with all the views etc."* β€” so the rail sits in the slot `CustomerGrid`'s own `ViewSidebar`
120
  * occupies on every other database, at the same `--lp-rail-w`. It is a separate component only
121
  * because THIS list spans several databases and the grid's rail, by construction, cannot: the
122
  * grid is mounted at one scope.
@@ -127,7 +167,7 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
127
  onSelect: (id: string) => void;
128
  onDelete: (id: string) => void;
129
  /**
130
- * ⭐ The rail OWNS the rename/duplicate calls and hands the host the resulting artefact to
131
  * upsert. Two hosts render this list (the standalone page and the merged Assistant surface), so
132
  * putting the calls in the hosts would be one question with two implementations, and the copy
133
  * that drifts is the one nobody is looking at.
@@ -140,10 +180,19 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
140
  const [menuFor, setMenuFor] = useState("");
141
  const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null);
142
  const [editing, setEditing] = useState<Editing | null>(null);
143
- // ⚠ TWO STATES, NOT ONE. A refusal and a confirmation read differently and are coloured
144
  // differently; sharing one slot painted "Saved as ..." in the refusal red.
145
  const [problem, setProblem] = useState("");
146
  const [notice, setNotice] = useState("");
 
 
 
 
 
 
 
 
 
147
  const said = (message: string) => { setNotice(message); setProblem(""); };
148
  const failed = (message: string) => { setProblem(message); setNotice(""); };
149
 
@@ -159,7 +208,7 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
159
  };
160
 
161
  /**
162
- * ⚠ THE REPLY IS SHOWN WHETHER OR NOT ANYTHING WAS DROPPED, and the dropped half is the useful
163
  * one: a database view carries no aggregation, so "sum of deal value" lands as the rows and not
164
  * the sum. Saying so beats a view that looks complete and answers a different question.
165
  */
@@ -171,9 +220,36 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
171
  : `Saved as "${result.value.name}" in this database's own views.`);
172
  };
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  const duplicate = async (id: string) => {
175
  const result = await duplicateQuery(id);
176
- // ⚠ The refusal is the useful half: MAX_ARTIFACTS names its cause and the remedy, and a copy
177
  // that silently did not happen is the failure mode this whole module is written against.
178
  if (!result.ok) { failed(result.message); return; }
179
  said("");
@@ -221,9 +297,41 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
221
  title={view.description || view.question}
222
  >
223
  <span className="qy-rail-name">{view.name}</span>
224
- <span className="qy-rail-kind">{KIND_MARK[view.kind] ?? view.kind}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  </button>
226
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  {confirming === view.id ? (
228
  <button type="button" className="qy-rail-confirm"
229
  onClick={() => { setConfirming(""); onDelete(view.id); }}>
@@ -266,6 +374,15 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
266
  </button>
267
  <button type="button" className="qy-rail-item" role="menuitem"
268
  onClick={() => { setMenuFor(""); void duplicate(view.id); }}>Duplicate</button>
 
 
 
 
 
 
 
 
 
269
  {/* R20's one difference from a database view's menu: this one can BECOME one. */}
270
  <button type="button" className="qy-rail-item" role="menuitem"
271
  onClick={() => { setMenuFor(""); void saveToDatabase(view); }}>
@@ -288,21 +405,16 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
288
  );
289
  }
290
 
291
- /**
292
- * The provenance R9 requires, in one readable line rather than a JSON dump.
 
 
 
293
  *
294
- * ⚠ It is a DISCLOSURE, not a banner: shut by default, so a surface whose job is to look like a
295
- * database looks like one, and one click away for the reader who wants to audit the number.
 
 
 
 
296
  */
297
- export function QueryProvenance({ view, detail }: { view: SavedQuery; detail: string }) {
298
- const [open, setOpen] = useState(false);
299
- return (
300
- <div className={"qy-prov" + (open ? " is-open" : "")}>
301
- <button type="button" className="qy-prov-btn" aria-expanded={open} onClick={() => setOpen(!open)}>
302
- <span className="qy-prov-q">{view.question}</span>
303
- <span className="qy-prov-toggle">{open ? "Hide sources" : "Sources"}</span>
304
- </button>
305
- {open ? <p className="qy-prov-detail">{detail}</p> : null}
306
- </div>
307
- );
308
- }
 
1
+ ο»Ώimport { useState } from "react";
2
 
3
  import { FolderMark } from "../customer-grid/icons";
4
  import { DbIcon } from "../shell/dbFrame";
5
  import { ASSISTANT_ROUTE } from "../shell/nav";
6
  import type { NavEntry } from "../shell/nav";
7
+ /**
8
+ * ⭐⭐ W35-T47 (R4, contract C2) β€” ONE STAR, AND THE BEHAVIOUR IS BORROWED WHOLE.
9
+ *
10
+ * `useStarred` is B's: the read, the optimistic flip, the reconcile, the toast on refusal, the
11
+ * generation guard against two writes racing. R4 makes star and "mark important" ONE idea, so a
12
+ * second client for it here would be the drift that ruling exists to prevent.
13
+ *
14
+ * β›” THE BUTTON'S MARKUP IS INLINE AND THAT IS NOT LAZINESS β€” IMPORTING `StarButton` BREAKS A's
15
+ * GATE. `StarredPage.tsx` imports `shell/Brand`, which reads `import.meta.env.BASE_URL`, and
16
+ * `verify_wiring.py` COMPILES AND RUNS `query/_test/queryRender.test.tsx` under node as CommonJS:
17
+ * `import.meta` is ESM-only syntax, node flips the emitted module to ESM, and the run dies with
18
+ * `ReferenceError: exports is not defined in ES module scope`. Reproduced here first, which is the
19
+ * only reason it is not a red `web_wiring` in somebody else's lane.
20
+ * ⚠ SO WHAT IS COPIED IS EIGHT LINES OF MARKUP, using B's own `.st-star` classes and the shared
21
+ * `StarIcon`, and `query.css` restyles nothing except placement. Posted to B as `NOTE C-16`: if
22
+ * `StarButton` moves into `starredApi`, this becomes an import.
23
+ */
24
+ import { StarIcon } from "../ui/icons";
25
+ import { useStarred } from "../starred/starredApi";
26
+ import { duplicateQuery, exportQueryHref, renameQuery, revertQuery, saveQueryToDatabase } from "./queryApi";
27
  import type { SavedQuery } from "./queryApi";
28
+ /**
29
+ * Ò­Ò­ W35-T22 (owner item 3) Ò€” THE STYLESHEET BELONGS TO THE MODULE THAT RENDERS THE CLASSES, AND
30
+ * THAT IS THE WHOLE FIX. Owner, verbatim: *"When I click 'Query' toggle, before it loads, it shows
31
+ * a really ugly outdated format, fix it."*
32
+ *
33
+ * Ò›” MEASURED, not guessed, on a production build. `query.css` was imported ONLY by `QueryPage`,
34
+ * which the Assistant loads with `lazy()`. Vite splits an async chunk's CSS into its own file, so
35
+ * all 44 `.qy-rail*` rules landed in `QueryPage-*.css` and the boot stylesheet carried ZERO of
36
+ * them. But this module Ò€” which renders every one of those classes Ò€” is imported STATICALLY by
37
+ * `AssistantPage`, and the rail it exports sits OUTSIDE the `<Suspense>` boundary the workspace is
38
+ * behind. So flipping the toggle committed the rail in the same frame while its stylesheet was
39
+ * still one network round trip away: browser-default buttons, default `<p>` margins, a blue
40
+ * underlined link. That is the "outdated format".
41
+ *
42
+ * Γ’Ε‘Β  It is NOT the Suspense fallback (a bare spinner) and NOT the empty state, the two candidates
43
+ * the wave listed. Capture of both states: `.claude/wiki/waves/wave35/proto/T22-query-flash/`.
44
+ *
45
+ * Γ’Ε‘Β  KEEP THE TWIN IMPORT IN `QueryPage.tsx`. Vite emits one stylesheet either way, and the
46
+ * standalone `#/query` host reaches the page without passing through here.
47
+ */
48
+ import "./query.css";
49
 
50
  /** Which label of which row is being typed into. One at a time, by construction. */
51
  interface Editing { id: string; field: "name" | "description"; value: string; }
52
 
53
  /**
54
+ * Ò›” THE MENU IS `position: fixed`, AND THAT IS NOT A STYLE PREFERENCE. Its natural home,
55
  * `.qy-rail-scroll`, is `overflow-y: auto`, which clips BOTH axes: an absolutely positioned menu
56
  * on a row near the bottom of the list renders cut off or not at all, so the reader clicks and
57
  * nothing appears. It fails worst on the rows most likely to be used, the ones you scrolled to.
58
  * Fixed positioning takes it out of the scroll box entirely, which costs one measurement.
59
  *
60
+ * Γ’Ε‘Β  `MENU_H` is an estimate used ONLY to decide whether to flip upward near the viewport floor. It
61
  * does not size anything, so being a little wrong shifts the menu rather than breaking it.
62
  */
63
  const MENU_H = 190;
 
91
  );
92
  }
93
 
94
+ /** The kind glyph a Query row wears Ò€” the same vocabulary the grid's own view rail uses. */
95
  const KIND_MARK: Record<string, string> = {
96
  grid: "Table", list: "List", chart: "Chart", kanban: "Board",
97
  calendar: "Calendar", timeseries: "Time series", map: "Map",
 
105
  }
106
 
107
  /**
108
+ * Γ’Ε‘Β  GROUPED IN THE ORDER THE DATABASES ARE GRANTED, not by artefact age. The rail is a list of
109
  * DATABASES first (owner: "each View correspond to the relevant database"), so a new answer about
110
+ * Customers must not move the Customers heading Ò€” it appears under it. Within a database the
111
  * newest is first, which is the order the server already sends.
112
  *
113
+ * Ò­ R14 GAVE THIS LIST A SECOND HOST, which is why the loop is a function and lives HERE rather
114
  * than inside `QueryPage`. The merged Assistant surface renders the same rail in its own left
115
  * panel; two hosts computing "which views belong to which database" from two copies of one loop
116
  * is one question with two normalizers, and the copy that drifts is the one nobody is looking at.
117
  * It sits beside `QueryGroup`, the type it builds, and in a module that does NOT import the grid
118
+ * Ò€” a static import from `QueryPage.tsx` would pull `CustomerGrid` into the chat bundle and undo
119
  * the lazy boundary the Assistant keeps around it.
120
  *
121
  * A view whose source database is not in `entries` is dropped: the caller's grant is the wall.
 
142
  }
143
 
144
  /**
145
+ * Ò­Ò­ OWNER ITEM 3 (2026-08-15) Ò€” GROUPED BY SOURCE DATABASE, WHICH IS THE WHOLE INSTRUCTION.
146
  * Verbatim: *"these AI generated query should live under the query module. And that each View
147
  * correspond to the relevant database. So we need to remove that dumb dropdown that suddenly
148
  * appears at the top right of the Database when the AI does query. That's not how we should do
149
  * the navigation."*
150
  *
151
+ * Ò›” THE DROPDOWN WAS NOT THE DEFECT Ò€” THE ABSENCE OF NAVIGATION WAS. Deleting it (which the
152
  * previous pass did) left Query with no way to move between artefacts at all: the page picked
153
  * `known[0]` and every other view the assistant had ever built was unreachable. A surface with
154
  * one implicit selection is not "no dropdown", it is no navigation, and the owner asked for the
155
  * navigation to be done differently rather than removed.
156
  *
157
+ * Γ’Ε‘Β  AND THIS IS WHERE A DATABASE PUTS ITS VIEWS. `dbFrame.tsx` carries the owner's earlier
158
+ * sentence Ò€” *"the query module should exactly BE looking like the database module, COMPLETELY,
159
+ * with all the views etc."* Ò€” so the rail sits in the slot `CustomerGrid`'s own `ViewSidebar`
160
  * occupies on every other database, at the same `--lp-rail-w`. It is a separate component only
161
  * because THIS list spans several databases and the grid's rail, by construction, cannot: the
162
  * grid is mounted at one scope.
 
167
  onSelect: (id: string) => void;
168
  onDelete: (id: string) => void;
169
  /**
170
+ * Ò­ The rail OWNS the rename/duplicate calls and hands the host the resulting artefact to
171
  * upsert. Two hosts render this list (the standalone page and the merged Assistant surface), so
172
  * putting the calls in the hosts would be one question with two implementations, and the copy
173
  * that drifts is the one nobody is looking at.
 
180
  const [menuFor, setMenuFor] = useState("");
181
  const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null);
182
  const [editing, setEditing] = useState<Editing | null>(null);
183
+ // Γ’Ε‘Β  TWO STATES, NOT ONE. A refusal and a confirmation read differently and are coloured
184
  // differently; sharing one slot painted "Saved as ..." in the refusal red.
185
  const [problem, setProblem] = useState("");
186
  const [notice, setNotice] = useState("");
187
+ /**
188
+ * ⭐ C2's star list, read AFTER PAINT and never blocking the rail (E-2: `GET /starred` scans
189
+ * every visible database's view bucket and was MEASURED at 7,331 ms on a cold container). The
190
+ * hook fires in an effect and reports `phase`, so the control simply is not drawn until the
191
+ * answer arrives β€” a star toggled from a list nobody has read would be a guess at its own
192
+ * current value.
193
+ */
194
+ const stars = useStarred();
195
+ const starred = new Set(stars.starred.queries);
196
  const said = (message: string) => { setNotice(message); setProblem(""); };
197
  const failed = (message: string) => { setProblem(message); setNotice(""); };
198
 
 
208
  };
209
 
210
  /**
211
+ * Γ’Ε‘Β  THE REPLY IS SHOWN WHETHER OR NOT ANYTHING WAS DROPPED, and the dropped half is the useful
212
  * one: a database view carries no aggregation, so "sum of deal value" lands as the rows and not
213
  * the sum. Saying so beats a view that looks complete and answers a different question.
214
  */
 
220
  : `Saved as "${result.value.name}" in this database's own views.`);
221
  };
222
 
223
+ /**
224
+ * ⭐⭐ W35-T25 Β· CONTRACT C4 (R3) β€” "Revert to AI original", and the sentence comes FIRST.
225
+ *
226
+ * β›” THE CONFIRM SAYS WHAT REVERT DOES **NOT** DO, BEFORE IT ACTS, AND R3 REQUIRES THAT WORDING
227
+ * RATHER THAN SUGGESTING IT. A Query view is live since R2, so a cell somebody changed through
228
+ * it is a real write to a real record in the source database. Putting the filters back cannot
229
+ * walk that back, and a reader who assumed otherwise has been told the opposite of the truth at
230
+ * the one moment they could still have chosen differently.
231
+ *
232
+ * ⚠ `window.confirm`, deliberately, and this is the one place in this module that uses it. Every
233
+ * other destructive door here is a two-click in the row, which works because those are cheap and
234
+ * reversible (a rename, a delete of one saved answer). This one is neither: it discards work with
235
+ * no undo, and a two-click cannot carry a sentence.
236
+ */
237
+ const revert = async (view: SavedQuery) => {
238
+ setMenuFor("");
239
+ const ok = window.confirm(
240
+ `Revert "${view.name}" to the version the assistant built?\n\n`
241
+ + "This restores the filters, sorting, grouping, visible columns and column widths.\n\n"
242
+ + "It does NOT undo any record you changed in the source database. Those edits stay.");
243
+ if (!ok) return;
244
+ const result = await revertQuery(view.id);
245
+ if (!result.ok) { failed(result.message); return; }
246
+ said(`"${result.value.name}" is back to the assistant's version.`);
247
+ onChanged?.(result.value);
248
+ };
249
+
250
  const duplicate = async (id: string) => {
251
  const result = await duplicateQuery(id);
252
+ // Γ’Ε‘Β  The refusal is the useful half: MAX_ARTIFACTS names its cause and the remedy, and a copy
253
  // that silently did not happen is the failure mode this whole module is written against.
254
  if (!result.ok) { failed(result.message); return; }
255
  said("");
 
297
  title={view.description || view.question}
298
  >
299
  <span className="qy-rail-name">{view.name}</span>
300
+ <span className="qy-rail-kind">
301
+ {KIND_MARK[view.kind] ?? view.kind}
302
+ {/* ⭐⭐ W35-T25 (R3) β€” THE EDITED MARK, ON THE ROW THAT NAMES THE VIEW.
303
+ R3 says "its header"; since owner item 3 replaced the top-right dropdown
304
+ with this rail, this row IS a Query view's header, and owner item 4 has
305
+ just deleted the only strip above the grid. Putting the badge back up
306
+ there would re-grow the row the same wave removed.
307
+ ⚠ Absent, not false, for an artefact with no `original_spec`: `edited` is
308
+ derived server-side by comparing against it, so a pre-wave artefact
309
+ reports `false` and shows neither this nor the Revert row (C4). */}
310
+ {view.edited ? (
311
+ <span className="qy-rail-edited"
312
+ title="You changed this view. Revert to AI original is in its menu.">
313
+ Edited
314
+ </span>
315
+ ) : null}
316
+ </span>
317
  </button>
318
  )}
319
+ {/* ⭐ W35-T47 β€” the star, on the row, in BOTH hosts (this rail is the only place a
320
+ Query view is listed, and the standalone page and the Assistant's Query mode
321
+ both render it). Not drawn until `/starred` has answered: see `stars` above. */}
322
+ {stars.load.phase === "ready" ? (
323
+ <button
324
+ type="button"
325
+ className={"st-star" + (starred.has(view.id) ? " is-on" : "")}
326
+ aria-pressed={starred.has(view.id)}
327
+ aria-label={(starred.has(view.id) ? "Unstar " : "Star ") + view.name}
328
+ title={(starred.has(view.id) ? "Unstar " : "Star ") + view.name}
329
+ disabled={stars.busy}
330
+ onClick={() => stars.toggle("query", view.id, !starred.has(view.id))}
331
+ >
332
+ <StarIcon size={15} filled={starred.has(view.id)} />
333
+ </button>
334
+ ) : null}
335
  {confirming === view.id ? (
336
  <button type="button" className="qy-rail-confirm"
337
  onClick={() => { setConfirming(""); onDelete(view.id); }}>
 
374
  </button>
375
  <button type="button" className="qy-rail-item" role="menuitem"
376
  onClick={() => { setMenuFor(""); void duplicate(view.id); }}>Duplicate</button>
377
+ {/* ⭐ R3: armed only when there IS an original AND the view has moved from it.
378
+ A Revert that reverts to nothing is worse than an absent one (C4), and a
379
+ Revert on an unedited view is a control that does nothing. */}
380
+ {view.edited && view.original_spec ? (
381
+ <button type="button" className="qy-rail-item" role="menuitem"
382
+ onClick={() => { void revert(view); }}>
383
+ Revert to AI original
384
+ </button>
385
+ ) : null}
386
  {/* R20's one difference from a database view's menu: this one can BECOME one. */}
387
  <button type="button" className="qy-rail-item" role="menuitem"
388
  onClick={() => { setMenuFor(""); void saveToDatabase(view); }}>
 
405
  );
406
  }
407
 
408
+ /*
409
+ * Ò›”Ò›” `QueryProvenance` IS DELETED (W35-T21, owner item 4 / R2), AND WHAT WENT WITH IT MATTERS.
410
+ * Owner, verbatim: *"there is a 'Source' button under Query as well. The end user doesn't need to
411
+ * see this so delete it."* R2 retires the snapshot framing with it, because the two were one
412
+ * control: the button read "Sources" and the row beside it read the snapshot version.
413
  *
414
+ * Γ’Ε‘Β  THE CITATIONS DID NOT GO. They still reach the grid through `bindingFor(active, citations)`
415
+ * in `QueryPage`, and they are still RENDERED for a reader in the surface that owns the claim Ò€”
416
+ * the chat, where `AssistantPage::CitationLine` prints one clickable line per citation under the
417
+ * answer that used it. What R2 removed is a second provenance surface above a grid, not the
418
+ * auditability R9 asked for. `verify_grid_ux.py::query_preview_shape` asserts exactly that pair:
419
+ * no provenance chrome on this page, citations still bound.
420
  */
 
 
 
 
 
 
 
 
 
 
 
 
web/src/settings/AdminPane.tsx CHANGED
@@ -1,646 +1,646 @@
1
- // ---------------------------------------------------------------------------
2
- // settings / AdminPane.tsx β€” THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 /
3
- // R3+R4, contract C2).
4
- //
5
- // The one surface in this product that looks ACROSS tenants: who our customers
6
- // are, how many people use each workspace, what they have built in it, whether
7
- // their data sources are alive, and what the automation fleet costs.
8
- //
9
- // SELF-CONTAINED BY CONTRACT (C2). It fetches its own data via
10
- // `platformAdminApi` and takes ONE prop, so mounting it is a rail entry plus a
11
- // line in the pane switch β€” `SettingsModal.tsx` is another session's file this
12
- // wave and must not have to learn anything about this pane's data.
13
- //
14
- // ⚠ THE PROP IS TYPED STRUCTURALLY, not as `SessionUser` from `../shell/session`.
15
- // The mount site passes the shell's session user (a wider object), which
16
- // satisfies this shape by structural typing β€” and this pane therefore imports
17
- // nothing from the shell, which is what the wave's cross-fence rule asks for
18
- // while `session.ts` is open on another desk.
19
- //
20
- // β›” EVERY NUMBER HERE IS THE SERVER'S, AND EVERY NUMBER DRILLS. Clicking a count
21
- // opens the rows it was computed from β€” the same collector, projected twice
22
- // ([[no-unverifiable-aggregates]]). A count that could not be READ renders as an
23
- // em dash with the reason, never as a zero: "this customer has no databases" and
24
- // "we could not look" are different facts and the pane refuses to conflate them.
25
- //
26
- // DESIGN: sentence-case micro-labels (R6 β€” no caps in app chrome), short noun
27
- // headers, tabular figures right-aligned, hairlines not shadows, tokens only,
28
- // no emojis (DESIGN.md Β§2–§4).
29
- // ---------------------------------------------------------------------------
30
-
31
- import { useCallback, useEffect, useState } from "react";
32
- import type {
33
- AutomationRow,
34
- ConnectorRow,
35
- DatabaseRow,
36
- FleetCost,
37
- Overview,
38
- PlatformUser,
39
- ReleasesPayload,
40
- } from "./platformAdminApi";
41
- import {
42
- getAutomations,
43
- getAws,
44
- getReleases,
45
- getConnectors,
46
- getDatabases,
47
- getOverview,
48
- getUsers,
49
- } from "./platformAdminApi";
50
-
51
- // --- formatting -------------------------------------------------------------
52
-
53
- const int = (n: number) => n.toLocaleString();
54
-
55
- /** A count that may be unknown. `null` is NOT zero β€” see the api module's header. */
56
- function Count({ n, why }: { n: number | null | undefined; why?: string }) {
57
- if (n === null || n === undefined)
58
- return (
59
- <span className="padmin-unknown" title={why || "This could not be read just now"}>
60
- β€”
61
- </span>
62
- );
63
- return <>{int(n)}</>;
64
- }
65
-
66
- /** "3 days ago" for a stamp, "never" for an absent one. Never a fabricated date.
67
- *
68
- * β›” ONLY FOR OFFSET-BEARING STAMPS. `Date.parse` reads a stamp with no zone as
69
- * BROWSER-LOCAL, so running a container-local timestamp through this reports the
70
- * viewer's UTC offset as elapsed time β€” hours of error in a column people scan,
71
- * and a future-dated stamp that renders as "just now" indefinitely. Everything
72
- * this pane passes here (`last_login`, `last_active`, `generatedAt`) is written
73
- * with an explicit offset; the automation engine's `lastRunAt` is NOT, so it is
74
- * rendered verbatim instead. */
75
- function ago(stamp: string): string {
76
- if (!stamp) return "never";
77
- const t = Date.parse(stamp.includes("T") ? stamp : stamp.replace(" ", "T"));
78
- if (Number.isNaN(t)) return stamp;
79
- const mins = Math.floor((Date.now() - t) / 60000);
80
- if (mins < 2) return "just now";
81
- if (mins < 60) return `${mins} minutes ago`;
82
- const hours = Math.floor(mins / 60);
83
- if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
84
- const days = Math.floor(hours / 24);
85
- if (days < 60) return `${days} day${days === 1 ? "" : "s"} ago`;
86
- return `${Math.floor(days / 30)} months ago`;
87
- }
88
-
89
- type DrillKind = "users" | "databases" | "connectors" | "automations";
90
-
91
- const DRILL_NOUN: Record<DrillKind, string> = {
92
- users: "People",
93
- databases: "Databases",
94
- connectors: "Data sources",
95
- automations: "Automations",
96
- };
97
-
98
- /** The loading shape β€” a skeleton, not a spinner (DESIGN.md Β§4). */
99
- function Skeleton({ rows = 3 }: { rows?: number }) {
100
- return (
101
- <div className="padmin-skel" aria-hidden="true">
102
- {Array.from({ length: rows }, (_, i) => (
103
- <div key={i} className="padmin-skel-row" />
104
- ))}
105
- </div>
106
- );
107
- }
108
-
109
- // --- the pane ---------------------------------------------------------------
110
-
111
- export function AdminPane({ user }: { user: { username: string; name: string; role: string } }) {
112
- const [ov, setOv] = useState<Overview | null>(null);
113
- const [err, setErr] = useState("");
114
- const [fleet, setFleet] = useState<{ rows: AutomationRow[]; cost: FleetCost } | null>(null);
115
-
116
- const [drill, setDrill] = useState<{ kind: DrillKind; tenant: string } | null>(null);
117
- const [drillRows, setDrillRows] = useState<unknown[] | null>(null);
118
- const [drillNote, setDrillNote] = useState("");
119
-
120
- const [aws, setAws] = useState<{ text: string; note: string; available: boolean } | null>(null);
121
- // Wave 20 (R6): the releases panel. Loaded with the overview rather than on demand β€” it is
122
- // two small Hub reads and the first question an operator opens this pane to answer.
123
- const [rel, setRel] = useState<ReleasesPayload | null>(null);
124
- const [awsBusy, setAwsBusy] = useState(false);
125
-
126
- const load = useCallback(() => {
127
- setOv(null);
128
- void getOverview().then((r) => {
129
- if (r.ok) {
130
- setOv(r.data);
131
- setErr("");
132
- } else setErr(r.message);
133
- });
134
- void getAutomations().then((r) => {
135
- if (r.ok) setFleet({ rows: r.data.automations, cost: r.data.cost });
136
- });
137
- void getReleases().then((r) => {
138
- if (r.ok) setRel(r.data);
139
- });
140
- }, []);
141
- useEffect(load, [load]);
142
-
143
- const openDrill = useCallback((kind: DrillKind, tenant: string) => {
144
- setDrill({ kind, tenant });
145
- setDrillRows(null);
146
- setDrillNote("");
147
- const fetcher =
148
- kind === "users"
149
- ? getUsers
150
- : kind === "databases"
151
- ? getDatabases
152
- : kind === "connectors"
153
- ? getConnectors
154
- : getAutomations;
155
- void fetcher(tenant || undefined).then((r) => {
156
- if (!r.ok) {
157
- setDrillRows([]);
158
- setDrillNote(r.message);
159
- return;
160
- }
161
- const d = r.data as Record<string, unknown>;
162
- setDrillRows((d[kind] as unknown[]) ?? []);
163
- // The server's own honest note travels with the rows rather than being
164
- // re-invented here: it is the one that knows WHY a tenant is missing.
165
- const errors = (d.errors as Record<string, string>) ?? {};
166
- const words = Object.entries(errors).map(([t, e]) => `${t}: ${e}`);
167
- setDrillNote(
168
- [typeof d.stampsNote === "string" ? d.stampsNote : "", ...words]
169
- .filter(Boolean)
170
- .join(" Β· ")
171
- );
172
- });
173
- }, []);
174
-
175
- const loadAws = useCallback(() => {
176
- setAwsBusy(true);
177
- void getAws(7).then((r) => {
178
- setAwsBusy(false);
179
- if (r.ok) setAws(r.data.report);
180
- else setAws({ text: "", note: r.message, available: false });
181
- });
182
- }, []);
183
-
184
- return (
185
- <div className="set-pane">
186
- <h3 className="set-h">Loopable admin</h3>
187
- <p className="set-help set-pane-intro">
188
- Every workspace on the platform, signed in as {user.name}. Counts open the rows they were
189
- computed from; anything that could not be read shows a dash and says why, rather than a
190
- zero.
191
- </p>
192
- {err ? <p className="set-error">{err}</p> : null}
193
-
194
- {!ov ? (
195
- <Skeleton rows={4} />
196
- ) : (
197
- <>
198
- <div className="padmin-totals">
199
- <Total label="Workspaces" value={int(ov.totals.tenants)} />
200
- <Total label="People" value={int(ov.totals.users)} />
201
- <Total label="Databases" value={int(ov.totals.databases)} />
202
- <Total label="Records" value={int(ov.totals.rows)} />
203
- <Total
204
- label="Automation cost"
205
- value={fleet ? `$${fleet.cost.usd.toFixed(2)}` : "β€”"}
206
- hint={fleet?.cost.basis}
207
- />
208
- </div>
209
- {ov.totals.unknownTenants > 0 ? (
210
- <p className="set-help padmin-caveat">
211
- {ov.totals.unknownTenants} workspace
212
- {ov.totals.unknownTenants === 1 ? "" : "s"} could not be read, so the totals above
213
- exclude {ov.totals.unknownTenants === 1 ? "it" : "them"}.
214
- </p>
215
- ) : null}
216
-
217
- <div className="padmin-tablewrap">
218
- <table className="padmin-table">
219
- <thead>
220
- <tr>
221
- <th>Workspace</th>
222
- <th>Storage</th>
223
- <th className="padmin-num">People</th>
224
- <th className="padmin-num">Databases</th>
225
- <th className="padmin-num">Records</th>
226
- <th className="padmin-num">Sources</th>
227
- <th className="padmin-num">Automations</th>
228
- </tr>
229
- </thead>
230
- <tbody>
231
- {ov.tenants.map((t) => (
232
- <tr key={t.slug}>
233
- <td>
234
- <span className="padmin-name">{t.name}</span>
235
- <span className="padmin-meta">
236
- {t.slug}
237
- {t.status !== "active" ? ` Β· ${t.status}` : ""}
238
- {t.errors && t.errors.length ? ` Β· ${t.errors[0]}` : ""}
239
- </span>
240
- </td>
241
- <td>
242
- <span className="padmin-meta">
243
- {t.storeRepo && t.storeRepo.includes("/")
244
- ? "own repository"
245
- : t.storePrefix
246
- ? "shared repository"
247
- : "tenant #0 repository"}
248
- {t.keychainLocked ? " Β· keychain locked" : ""}
249
- </span>
250
- </td>
251
- <DrillCell
252
- n={t.users}
253
- onOpen={() => openDrill("users", t.slug)}
254
- title={`${t.admins} administrator${t.admins === 1 ? "" : "s"}`}
255
- />
256
- <DrillCell
257
- n={t.databases}
258
- onOpen={() => openDrill("databases", t.slug)}
259
- why={t.errors?.[0]}
260
- />
261
- <DrillCell
262
- n={t.rows}
263
- onOpen={() => openDrill("databases", t.slug)}
264
- why={t.errors?.[0]}
265
- />
266
- <DrillCell
267
- n={t.connectors}
268
- onOpen={() => openDrill("connectors", t.slug)}
269
- why={t.errors?.[0]}
270
- title={
271
- t.connectorsPaused ? `${t.connectorsPaused} paused` : "none paused"
272
- }
273
- />
274
- <DrillCell
275
- n={t.automations}
276
- onOpen={() => openDrill("automations", t.slug)}
277
- why={t.errors?.[0]}
278
- title={
279
- t.automationsEnabled != null
280
- ? `${t.automationsEnabled} scheduled`
281
- : undefined
282
- }
283
- />
284
- </tr>
285
- ))}
286
- </tbody>
287
- </table>
288
- </div>
289
-
290
- <p className="set-help padmin-caveat">
291
- Read {ago(ov.generatedAt)} in {ov.tookMs} ms.{" "}
292
- {ov.totals.orphanUsers > 0
293
- ? `${ov.totals.orphanUsers} account${
294
- ov.totals.orphanUsers === 1 ? "" : "s"
295
- } belong to a workspace this deployment no longer knows. `
296
- : ""}
297
- <button type="button" className="padmin-link" onClick={load}>
298
- Refresh
299
- </button>
300
- </p>
301
-
302
- {drill ? (
303
- <section className="padmin-drill">
304
- <div className="padmin-drill-head">
305
- <h4 className="padmin-h4">
306
- {DRILL_NOUN[drill.kind]}
307
- {drill.tenant ? ` β€” ${drill.tenant}` : ""}
308
- </h4>
309
- <button type="button" className="padmin-link" onClick={() => setDrill(null)}>
310
- Close
311
- </button>
312
- </div>
313
- {drillRows === null ? (
314
- <Skeleton rows={2} />
315
- ) : drillRows.length === 0 ? (
316
- <p className="set-help">Nothing here yet.</p>
317
- ) : (
318
- <DrillTable kind={drill.kind} rows={drillRows} />
319
- )}
320
- {drillNote ? <p className="set-help padmin-caveat">{drillNote}</p> : null}
321
- </section>
322
- ) : null}
323
-
324
- {fleet ? (
325
- <section className="padmin-block">
326
- {/* Named for what it SHOWS. The fleet's rows live one level down,
327
- behind the Automations count β€” this section is the cost and the
328
- reasoning behind it, so calling it "fleet" would promise a
329
- table that is deliberately not here. */}
330
- <h4 className="padmin-h4">Automation cost</h4>
331
- <p className="set-help">
332
- {fleet.rows.filter((a) => a.enabled).length} scheduled of {fleet.rows.length}
333
- {fleet.cost.fleetRunsPerDay
334
- ? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
335
- : ""}
336
- {fleet.cost.unknownCadence
337
- ? ` (${fleet.cost.unknownCadence} on a custom cadence, not counted)`
338
- : ""}
339
- . {fleet.cost.basis}
340
- </p>
341
- </section>
342
- ) : null}
343
-
344
- {/* ── Wave 20 (owner item 12 / R6): what is running where ──────────────────
345
- READ-ONLY BY RULING. The operator sees both environments and every cut
346
- release; moving one is a CLI command, printed below rather than wired to
347
- a button, so no browser session can rewrite production. */}
348
- <section className="padmin-block">
349
- <h4 className="padmin-h4">Releases</h4>
350
- {!rel ? (
351
- <Skeleton rows={2} />
352
- ) : (
353
- <>
354
- <table className="padmin-table">
355
- <thead>
356
- <tr>
357
- <th>Environment</th>
358
- <th>Version</th>
359
- <th>Stage</th>
360
- <th>Space</th>
361
- </tr>
362
- </thead>
363
- <tbody>
364
- {rel.environments.map((e) => (
365
- <tr key={e.env}>
366
- <td>{e.env === "live" ? "Live (pinned)" : "Staging (follows the tree)"}</td>
367
- {/* ⚠ An unreadable version is a NOTE, never a blank. A blank cell in a
368
- column headed "Version" reads as "nothing is deployed", which about a
369
- production environment is the worst available way to be wrong. */}
370
- <td>{e.version || <span className="set-help">{e.note || "unknown"}</span>}</td>
371
- <td>{e.stage || "β€”"}</td>
372
- {/* The Space ID as TEXT, not a link: the server deliberately builds no
373
- URL (portability C1 β€” a host literal in runtime code hard-codes the
374
- current host into a process designed to move). */}
375
- <td>{e.space}</td>
376
- </tr>
377
- ))}
378
- </tbody>
379
- </table>
380
- {rel.releases.length ? (
381
- <table className="padmin-table">
382
- <thead>
383
- <tr>
384
- <th>Release</th>
385
- <th>Commit</th>
386
- <th>Cut</th>
387
- <th>What shipped</th>
388
- </tr>
389
- </thead>
390
- <tbody>
391
- {rel.releases.map((r) => (
392
- <tr key={r.version}>
393
- <td>{r.version}</td>
394
- <td>{r.sha}</td>
395
- <td>{r.date}</td>
396
- <td>{r.subject}</td>
397
- </tr>
398
- ))}
399
- </tbody>
400
- </table>
401
- ) : (
402
- <p className="set-help">
403
- No tagged releases were readable from either Space.
404
- </p>
405
- )}
406
- <p className="set-help">
407
- To move Live to another version, or roll it back, run:{" "}
408
- <code>{rel.promote}</code>
409
- </p>
410
- </>
411
- )}
412
- </section>
413
-
414
- <section className="padmin-block">
415
- <h4 className="padmin-h4">AWS cron</h4>
416
- {!aws ? (
417
- <p className="set-help">
418
- The external tick that wakes this app on schedule. Reading its usage runs a live
419
- CloudWatch query and takes a few seconds.{" "}
420
- <button
421
- type="button"
422
- className="padmin-link"
423
- onClick={loadAws}
424
- disabled={awsBusy}
425
- >
426
- {awsBusy ? "Reading…" : "Check usage"}
427
- </button>
428
- </p>
429
- ) : aws.available ? (
430
- <pre className="padmin-pre">{aws.text}</pre>
431
- ) : (
432
- <p className="set-help">{aws.note}</p>
433
- )}
434
- </section>
435
- </>
436
- )}
437
- </div>
438
- );
439
- }
440
-
441
- function Total({ label, value, hint }: { label: string; value: string; hint?: string }) {
442
- // A tooltip nobody can see is a tooltip nobody reads: the label carries a
443
- // dotted underline exactly when there is something behind it.
444
- return (
445
- <div className={"padmin-total" + (hint ? " has-hint" : "")} title={hint || undefined}>
446
- <span className="padmin-total-label">{label}</span>
447
- <span className="padmin-total-value">{value}</span>
448
- </div>
449
- );
450
- }
451
-
452
- /** A numeric cell that opens its own rows. Unknown counts are not clickable β€”
453
- * there is nothing to drill INTO when the read failed, and a button that opens
454
- * an empty table would read as "none". */
455
- function DrillCell({
456
- n,
457
- onOpen,
458
- why,
459
- title,
460
- }: {
461
- n: number | null | undefined;
462
- onOpen: () => void;
463
- why?: string;
464
- title?: string;
465
- }) {
466
- if (n === null || n === undefined)
467
- return (
468
- <td className="padmin-num">
469
- <Count n={n} why={why} />
470
- </td>
471
- );
472
- return (
473
- <td className="padmin-num">
474
- <button type="button" className="padmin-drill-btn" onClick={onOpen} title={title}>
475
- {int(n)}
476
- </button>
477
- </td>
478
- );
479
- }
480
-
481
- function DrillTable({ kind, rows }: { kind: DrillKind; rows: unknown[] }) {
482
- if (kind === "users") {
483
- const rs = rows as PlatformUser[];
484
- return (
485
- <div className="padmin-tablewrap">
486
- <table className="padmin-table">
487
- <thead>
488
- <tr>
489
- <th>Person</th>
490
- <th>Workspace</th>
491
- <th>Role</th>
492
- <th>Last sign-in</th>
493
- <th>Last active</th>
494
- </tr>
495
- </thead>
496
- <tbody>
497
- {rs.map((u) => (
498
- <tr key={`${u.tenant}/${u.username}`}>
499
- <td>
500
- <span className="padmin-name">{u.name}</span>
501
- <span className="padmin-meta">
502
- {u.username}
503
- {u.email ? ` Β· ${u.email}` : ""}
504
- {u.active ? "" : " Β· deactivated"}
505
- </span>
506
- </td>
507
- <td>{u.tenant}</td>
508
- <td>
509
- {u.role}
510
- {u.platformAdmin ? " Β· platform" : ""}
511
- </td>
512
- <td>{ago(u.lastLogin)}</td>
513
- <td>{ago(u.lastActive)}</td>
514
- </tr>
515
- ))}
516
- </tbody>
517
- </table>
518
- </div>
519
- );
520
- }
521
- if (kind === "databases") {
522
- const rs = rows as DatabaseRow[];
523
- return (
524
- <div className="padmin-tablewrap">
525
- <table className="padmin-table">
526
- <thead>
527
- <tr>
528
- <th>Database</th>
529
- <th>Workspace</th>
530
- <th>Built from</th>
531
- <th className="padmin-num">Fields</th>
532
- <th className="padmin-num">Records</th>
533
- </tr>
534
- </thead>
535
- <tbody>
536
- {rs.map((d) => (
537
- <tr key={`${d.tenant}/${d.key}`}>
538
- <td>
539
- <span className="padmin-name">{d.label}</span>
540
- <span className="padmin-meta">
541
- {d.key}
542
- {d.createdBy ? ` Β· ${d.createdBy}` : ""}
543
- </span>
544
- </td>
545
- <td>{d.tenant}</td>
546
- <td>{d.source}</td>
547
- <td className="padmin-num">{int(d.fields)}</td>
548
- <td className="padmin-num">{int(d.rowCount)}</td>
549
- </tr>
550
- ))}
551
- </tbody>
552
- </table>
553
- </div>
554
- );
555
- }
556
- if (kind === "connectors") {
557
- const rs = rows as ConnectorRow[];
558
- return (
559
- <div className="padmin-tablewrap">
560
- <table className="padmin-table">
561
- <thead>
562
- <tr>
563
- <th>Source</th>
564
- <th>Workspace</th>
565
- <th>Kind</th>
566
- <th>Status</th>
567
- </tr>
568
- </thead>
569
- <tbody>
570
- {rs.map((c) => (
571
- <tr key={`${c.tenant}/${c.key}`}>
572
- <td>
573
- <span className="padmin-name">{c.label}</span>
574
- <span className="padmin-meta">
575
- {c.source === "env" ? "environment credentials" : "keychain"}
576
- </span>
577
- </td>
578
- <td>{c.tenant}</td>
579
- <td>{c.type}</td>
580
- <td>
581
- <span
582
- className={
583
- "padmin-dot " +
584
- (c.paused ? "is-paused" : c.active ? "is-live" : "is-idle")
585
- }
586
- />
587
- {c.paused ? "paused" : c.active ? "serving" : "stored"}
588
- </td>
589
- </tr>
590
- ))}
591
- </tbody>
592
- </table>
593
- </div>
594
- );
595
- }
596
- const rs = rows as AutomationRow[];
597
- return (
598
- <div className="padmin-tablewrap">
599
- <table className="padmin-table">
600
- <thead>
601
- <tr>
602
- <th>Automation</th>
603
- <th>Workspace</th>
604
- <th>Schedule</th>
605
- <th className="padmin-num">Runs a day</th>
606
- <th>Last run</th>
607
- </tr>
608
- </thead>
609
- <tbody>
610
- {rs.map((a) => (
611
- <tr key={`${a.tenant}/${a.id}`}>
612
- <td>
613
- <span className="padmin-name">{a.name}</span>
614
- <span className="padmin-meta">
615
- {a.kind}
616
- {a.failedRetained
617
- ? ` Β· ${a.failedRetained} of the last ${a.runsRetained} runs failed`
618
- : ""}
619
- </span>
620
- </td>
621
- <td>{a.tenant}</td>
622
- <td>{a.enabled ? a.cron || "scheduled" : "paused"}</td>
623
- <td className="padmin-num">
624
- {a.enabled ? (a.runsPerDay === null ? "custom" : a.runsPerDay) : "β€”"}
625
- </td>
626
- <td>
627
- <span
628
- className={
629
- "padmin-dot " +
630
- (a.state === "error" ? "is-error" : a.state === "ok" ? "is-live" : "is-idle")
631
- }
632
- />
633
- {/* Verbatim, not relative: the engine writes this stamp with no
634
- zone (`automation_engine._iso()` is naive local-to-container),
635
- so "x hours ago" would be wrong by the viewer's offset. */}
636
- {a.lastRunAt ? a.lastRunAt.replace("T", " ") : "never"}
637
- </td>
638
- </tr>
639
- ))}
640
- </tbody>
641
- </table>
642
- </div>
643
- );
644
- }
645
-
646
- export default AdminPane;
 
1
+ // ---------------------------------------------------------------------------
2
+ // settings / AdminPane.tsx β€” THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 /
3
+ // R3+R4, contract C2).
4
+ //
5
+ // The one surface in this product that looks ACROSS tenants: who our customers
6
+ // are, how many people use each workspace, what they have built in it, whether
7
+ // their data sources are alive, and what the automation fleet costs.
8
+ //
9
+ // SELF-CONTAINED BY CONTRACT (C2). It fetches its own data via
10
+ // `platformAdminApi` and takes ONE prop, so mounting it is a rail entry plus a
11
+ // line in the pane switch β€” `SettingsModal.tsx` is another session's file this
12
+ // wave and must not have to learn anything about this pane's data.
13
+ //
14
+ // ⚠ THE PROP IS TYPED STRUCTURALLY, not as `SessionUser` from `../shell/session`.
15
+ // The mount site passes the shell's session user (a wider object), which
16
+ // satisfies this shape by structural typing β€” and this pane therefore imports
17
+ // nothing from the shell, which is what the wave's cross-fence rule asks for
18
+ // while `session.ts` is open on another desk.
19
+ //
20
+ // β›” EVERY NUMBER HERE IS THE SERVER'S, AND EVERY NUMBER DRILLS. Clicking a count
21
+ // opens the rows it was computed from β€” the same collector, projected twice
22
+ // ([[no-unverifiable-aggregates]]). A count that could not be READ renders as an
23
+ // em dash with the reason, never as a zero: "this customer has no databases" and
24
+ // "we could not look" are different facts and the pane refuses to conflate them.
25
+ //
26
+ // DESIGN: sentence-case micro-labels (R6 β€” no caps in app chrome), short noun
27
+ // headers, tabular figures right-aligned, hairlines not shadows, tokens only,
28
+ // no emojis (DESIGN.md Β§2–§4).
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import { useCallback, useEffect, useState } from "react";
32
+ import type {
33
+ AutomationRow,
34
+ ConnectorRow,
35
+ DatabaseRow,
36
+ FleetCost,
37
+ Overview,
38
+ PlatformUser,
39
+ ReleasesPayload,
40
+ } from "./platformAdminApi";
41
+ import {
42
+ getAutomations,
43
+ getAws,
44
+ getReleases,
45
+ getConnectors,
46
+ getDatabases,
47
+ getOverview,
48
+ getUsers,
49
+ } from "./platformAdminApi";
50
+
51
+ // --- formatting -------------------------------------------------------------
52
+
53
+ const int = (n: number) => n.toLocaleString();
54
+
55
+ /** A count that may be unknown. `null` is NOT zero β€” see the api module's header. */
56
+ function Count({ n, why }: { n: number | null | undefined; why?: string }) {
57
+ if (n === null || n === undefined)
58
+ return (
59
+ <span className="padmin-unknown" title={why || "This could not be read just now"}>
60
+ β€”
61
+ </span>
62
+ );
63
+ return <>{int(n)}</>;
64
+ }
65
+
66
+ /** "3 days ago" for a stamp, "never" for an absent one. Never a fabricated date.
67
+ *
68
+ * β›” ONLY FOR OFFSET-BEARING STAMPS. `Date.parse` reads a stamp with no zone as
69
+ * BROWSER-LOCAL, so running a container-local timestamp through this reports the
70
+ * viewer's UTC offset as elapsed time β€” hours of error in a column people scan,
71
+ * and a future-dated stamp that renders as "just now" indefinitely. Everything
72
+ * this pane passes here (`last_login`, `last_active`, `generatedAt`) is written
73
+ * with an explicit offset; the automation engine's `lastRunAt` is NOT, so it is
74
+ * rendered verbatim instead. */
75
+ function ago(stamp: string): string {
76
+ if (!stamp) return "never";
77
+ const t = Date.parse(stamp.includes("T") ? stamp : stamp.replace(" ", "T"));
78
+ if (Number.isNaN(t)) return stamp;
79
+ const mins = Math.floor((Date.now() - t) / 60000);
80
+ if (mins < 2) return "just now";
81
+ if (mins < 60) return `${mins} minutes ago`;
82
+ const hours = Math.floor(mins / 60);
83
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
84
+ const days = Math.floor(hours / 24);
85
+ if (days < 60) return `${days} day${days === 1 ? "" : "s"} ago`;
86
+ return `${Math.floor(days / 30)} months ago`;
87
+ }
88
+
89
+ type DrillKind = "users" | "databases" | "connectors" | "automations";
90
+
91
+ const DRILL_NOUN: Record<DrillKind, string> = {
92
+ users: "People",
93
+ databases: "Databases",
94
+ connectors: "Data sources",
95
+ automations: "Automations",
96
+ };
97
+
98
+ /** The loading shape β€” a skeleton, not a spinner (DESIGN.md Β§4). */
99
+ function Skeleton({ rows = 3 }: { rows?: number }) {
100
+ return (
101
+ <div className="padmin-skel" aria-hidden="true">
102
+ {Array.from({ length: rows }, (_, i) => (
103
+ <div key={i} className="padmin-skel-row" />
104
+ ))}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ // --- the pane ---------------------------------------------------------------
110
+
111
+ export function AdminPane({ user }: { user: { username: string; name: string; role: string } }) {
112
+ const [ov, setOv] = useState<Overview | null>(null);
113
+ const [err, setErr] = useState("");
114
+ const [fleet, setFleet] = useState<{ rows: AutomationRow[]; cost: FleetCost } | null>(null);
115
+
116
+ const [drill, setDrill] = useState<{ kind: DrillKind; tenant: string } | null>(null);
117
+ const [drillRows, setDrillRows] = useState<unknown[] | null>(null);
118
+ const [drillNote, setDrillNote] = useState("");
119
+
120
+ const [aws, setAws] = useState<{ text: string; note: string; available: boolean } | null>(null);
121
+ // Wave 20 (R6): the releases panel. Loaded with the overview rather than on demand β€” it is
122
+ // two small Hub reads and the first question an operator opens this pane to answer.
123
+ const [rel, setRel] = useState<ReleasesPayload | null>(null);
124
+ const [awsBusy, setAwsBusy] = useState(false);
125
+
126
+ const load = useCallback(() => {
127
+ setOv(null);
128
+ void getOverview().then((r) => {
129
+ if (r.ok) {
130
+ setOv(r.data);
131
+ setErr("");
132
+ } else setErr(r.message);
133
+ });
134
+ void getAutomations().then((r) => {
135
+ if (r.ok) setFleet({ rows: r.data.automations, cost: r.data.cost });
136
+ });
137
+ void getReleases().then((r) => {
138
+ if (r.ok) setRel(r.data);
139
+ });
140
+ }, []);
141
+ useEffect(load, [load]);
142
+
143
+ const openDrill = useCallback((kind: DrillKind, tenant: string) => {
144
+ setDrill({ kind, tenant });
145
+ setDrillRows(null);
146
+ setDrillNote("");
147
+ const fetcher =
148
+ kind === "users"
149
+ ? getUsers
150
+ : kind === "databases"
151
+ ? getDatabases
152
+ : kind === "connectors"
153
+ ? getConnectors
154
+ : getAutomations;
155
+ void fetcher(tenant || undefined).then((r) => {
156
+ if (!r.ok) {
157
+ setDrillRows([]);
158
+ setDrillNote(r.message);
159
+ return;
160
+ }
161
+ const d = r.data as Record<string, unknown>;
162
+ setDrillRows((d[kind] as unknown[]) ?? []);
163
+ // The server's own honest note travels with the rows rather than being
164
+ // re-invented here: it is the one that knows WHY a tenant is missing.
165
+ const errors = (d.errors as Record<string, string>) ?? {};
166
+ const words = Object.entries(errors).map(([t, e]) => `${t}: ${e}`);
167
+ setDrillNote(
168
+ [typeof d.stampsNote === "string" ? d.stampsNote : "", ...words]
169
+ .filter(Boolean)
170
+ .join(" Β· ")
171
+ );
172
+ });
173
+ }, []);
174
+
175
+ const loadAws = useCallback(() => {
176
+ setAwsBusy(true);
177
+ void getAws(7).then((r) => {
178
+ setAwsBusy(false);
179
+ if (r.ok) setAws(r.data.report);
180
+ else setAws({ text: "", note: r.message, available: false });
181
+ });
182
+ }, []);
183
+
184
+ return (
185
+ <div className="set-pane">
186
+ <h3 className="set-h">Loopable admin</h3>
187
+ <p className="set-help set-pane-intro">
188
+ Every workspace on the platform, signed in as {user.name}. Counts open the rows they were
189
+ computed from; anything that could not be read shows a dash and says why, rather than a
190
+ zero.
191
+ </p>
192
+ {err ? <p className="set-error">{err}</p> : null}
193
+
194
+ {!ov ? (
195
+ <Skeleton rows={4} />
196
+ ) : (
197
+ <>
198
+ <div className="padmin-totals">
199
+ <Total label="Workspaces" value={int(ov.totals.tenants)} />
200
+ <Total label="People" value={int(ov.totals.users)} />
201
+ <Total label="Databases" value={int(ov.totals.databases)} />
202
+ <Total label="Records" value={int(ov.totals.rows)} />
203
+ <Total
204
+ label="Automation cost"
205
+ value={fleet ? `$${fleet.cost.usd.toFixed(2)}` : "β€”"}
206
+ hint={fleet?.cost.basis}
207
+ />
208
+ </div>
209
+ {ov.totals.unknownTenants > 0 ? (
210
+ <p className="set-help padmin-caveat">
211
+ {ov.totals.unknownTenants} workspace
212
+ {ov.totals.unknownTenants === 1 ? "" : "s"} could not be read, so the totals above
213
+ exclude {ov.totals.unknownTenants === 1 ? "it" : "them"}.
214
+ </p>
215
+ ) : null}
216
+
217
+ <div className="padmin-tablewrap">
218
+ <table className="padmin-table">
219
+ <thead>
220
+ <tr>
221
+ <th>Workspace</th>
222
+ <th>Storage</th>
223
+ <th className="padmin-num">People</th>
224
+ <th className="padmin-num">Databases</th>
225
+ <th className="padmin-num">Records</th>
226
+ <th className="padmin-num">Sources</th>
227
+ <th className="padmin-num">Automations</th>
228
+ </tr>
229
+ </thead>
230
+ <tbody>
231
+ {ov.tenants.map((t) => (
232
+ <tr key={t.slug}>
233
+ <td>
234
+ <span className="padmin-name">{t.name}</span>
235
+ <span className="padmin-meta">
236
+ {t.slug}
237
+ {t.status !== "active" ? ` Β· ${t.status}` : ""}
238
+ {t.errors && t.errors.length ? ` Β· ${t.errors[0]}` : ""}
239
+ </span>
240
+ </td>
241
+ <td>
242
+ <span className="padmin-meta">
243
+ {t.storeRepo && t.storeRepo.includes("/")
244
+ ? "own repository"
245
+ : t.storePrefix
246
+ ? "shared repository"
247
+ : "tenant #0 repository"}
248
+ {t.keychainLocked ? " Β· keychain locked" : ""}
249
+ </span>
250
+ </td>
251
+ <DrillCell
252
+ n={t.users}
253
+ onOpen={() => openDrill("users", t.slug)}
254
+ title={`${t.admins} administrator${t.admins === 1 ? "" : "s"}`}
255
+ />
256
+ <DrillCell
257
+ n={t.databases}
258
+ onOpen={() => openDrill("databases", t.slug)}
259
+ why={t.errors?.[0]}
260
+ />
261
+ <DrillCell
262
+ n={t.rows}
263
+ onOpen={() => openDrill("databases", t.slug)}
264
+ why={t.errors?.[0]}
265
+ />
266
+ <DrillCell
267
+ n={t.connectors}
268
+ onOpen={() => openDrill("connectors", t.slug)}
269
+ why={t.errors?.[0]}
270
+ title={
271
+ t.connectorsPaused ? `${t.connectorsPaused} paused` : "none paused"
272
+ }
273
+ />
274
+ <DrillCell
275
+ n={t.automations}
276
+ onOpen={() => openDrill("automations", t.slug)}
277
+ why={t.errors?.[0]}
278
+ title={
279
+ t.automationsEnabled != null
280
+ ? `${t.automationsEnabled} scheduled`
281
+ : undefined
282
+ }
283
+ />
284
+ </tr>
285
+ ))}
286
+ </tbody>
287
+ </table>
288
+ </div>
289
+
290
+ <p className="set-help padmin-caveat">
291
+ Read {ago(ov.generatedAt)} in {ov.tookMs} ms.{" "}
292
+ {ov.totals.orphanUsers > 0
293
+ ? `${ov.totals.orphanUsers} account${
294
+ ov.totals.orphanUsers === 1 ? "" : "s"
295
+ } belong to a workspace this deployment no longer knows. `
296
+ : ""}
297
+ <button type="button" className="padmin-link" onClick={load}>
298
+ Refresh
299
+ </button>
300
+ </p>
301
+
302
+ {drill ? (
303
+ <section className="padmin-drill">
304
+ <div className="padmin-drill-head">
305
+ <h4 className="padmin-h4">
306
+ {DRILL_NOUN[drill.kind]}
307
+ {drill.tenant ? `: ${drill.tenant}` : ""}
308
+ </h4>
309
+ <button type="button" className="padmin-link" onClick={() => setDrill(null)}>
310
+ Close
311
+ </button>
312
+ </div>
313
+ {drillRows === null ? (
314
+ <Skeleton rows={2} />
315
+ ) : drillRows.length === 0 ? (
316
+ <p className="set-help">Nothing here yet.</p>
317
+ ) : (
318
+ <DrillTable kind={drill.kind} rows={drillRows} />
319
+ )}
320
+ {drillNote ? <p className="set-help padmin-caveat">{drillNote}</p> : null}
321
+ </section>
322
+ ) : null}
323
+
324
+ {fleet ? (
325
+ <section className="padmin-block">
326
+ {/* Named for what it SHOWS. The fleet's rows live one level down,
327
+ behind the Automations count β€” this section is the cost and the
328
+ reasoning behind it, so calling it "fleet" would promise a
329
+ table that is deliberately not here. */}
330
+ <h4 className="padmin-h4">Automation cost</h4>
331
+ <p className="set-help">
332
+ {fleet.rows.filter((a) => a.enabled).length} scheduled of {fleet.rows.length}
333
+ {fleet.cost.fleetRunsPerDay
334
+ ? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
335
+ : ""}
336
+ {fleet.cost.unknownCadence
337
+ ? ` (${fleet.cost.unknownCadence} on a custom cadence, not counted)`
338
+ : ""}
339
+ . {fleet.cost.basis}
340
+ </p>
341
+ </section>
342
+ ) : null}
343
+
344
+ {/* ── Wave 20 (owner item 12 / R6): what is running where ──────────────────
345
+ READ-ONLY BY RULING. The operator sees both environments and every cut
346
+ release; moving one is a CLI command, printed below rather than wired to
347
+ a button, so no browser session can rewrite production. */}
348
+ <section className="padmin-block">
349
+ <h4 className="padmin-h4">Releases</h4>
350
+ {!rel ? (
351
+ <Skeleton rows={2} />
352
+ ) : (
353
+ <>
354
+ <table className="padmin-table">
355
+ <thead>
356
+ <tr>
357
+ <th>Environment</th>
358
+ <th>Version</th>
359
+ <th>Stage</th>
360
+ <th>Space</th>
361
+ </tr>
362
+ </thead>
363
+ <tbody>
364
+ {rel.environments.map((e) => (
365
+ <tr key={e.env}>
366
+ <td>{e.env === "live" ? "Live (pinned)" : "Staging (follows the tree)"}</td>
367
+ {/* ⚠ An unreadable version is a NOTE, never a blank. A blank cell in a
368
+ column headed "Version" reads as "nothing is deployed", which about a
369
+ production environment is the worst available way to be wrong. */}
370
+ <td>{e.version || <span className="set-help">{e.note || "unknown"}</span>}</td>
371
+ <td>{e.stage || "β€”"}</td>
372
+ {/* The Space ID as TEXT, not a link: the server deliberately builds no
373
+ URL (portability C1 β€” a host literal in runtime code hard-codes the
374
+ current host into a process designed to move). */}
375
+ <td>{e.space}</td>
376
+ </tr>
377
+ ))}
378
+ </tbody>
379
+ </table>
380
+ {rel.releases.length ? (
381
+ <table className="padmin-table">
382
+ <thead>
383
+ <tr>
384
+ <th>Release</th>
385
+ <th>Commit</th>
386
+ <th>Cut</th>
387
+ <th>What shipped</th>
388
+ </tr>
389
+ </thead>
390
+ <tbody>
391
+ {rel.releases.map((r) => (
392
+ <tr key={r.version}>
393
+ <td>{r.version}</td>
394
+ <td>{r.sha}</td>
395
+ <td>{r.date}</td>
396
+ <td>{r.subject}</td>
397
+ </tr>
398
+ ))}
399
+ </tbody>
400
+ </table>
401
+ ) : (
402
+ <p className="set-help">
403
+ No tagged releases were readable from either Space.
404
+ </p>
405
+ )}
406
+ <p className="set-help">
407
+ To move Live to another version, or roll it back, run:{" "}
408
+ <code>{rel.promote}</code>
409
+ </p>
410
+ </>
411
+ )}
412
+ </section>
413
+
414
+ <section className="padmin-block">
415
+ <h4 className="padmin-h4">AWS cron</h4>
416
+ {!aws ? (
417
+ <p className="set-help">
418
+ The external tick that wakes this app on schedule. Reading its usage runs a live
419
+ CloudWatch query and takes a few seconds.{" "}
420
+ <button
421
+ type="button"
422
+ className="padmin-link"
423
+ onClick={loadAws}
424
+ disabled={awsBusy}
425
+ >
426
+ {awsBusy ? "Reading…" : "Check usage"}
427
+ </button>
428
+ </p>
429
+ ) : aws.available ? (
430
+ <pre className="padmin-pre">{aws.text}</pre>
431
+ ) : (
432
+ <p className="set-help">{aws.note}</p>
433
+ )}
434
+ </section>
435
+ </>
436
+ )}
437
+ </div>
438
+ );
439
+ }
440
+
441
+ function Total({ label, value, hint }: { label: string; value: string; hint?: string }) {
442
+ // A tooltip nobody can see is a tooltip nobody reads: the label carries a
443
+ // dotted underline exactly when there is something behind it.
444
+ return (
445
+ <div className={"padmin-total" + (hint ? " has-hint" : "")} title={hint || undefined}>
446
+ <span className="padmin-total-label">{label}</span>
447
+ <span className="padmin-total-value">{value}</span>
448
+ </div>
449
+ );
450
+ }
451
+
452
+ /** A numeric cell that opens its own rows. Unknown counts are not clickable β€”
453
+ * there is nothing to drill INTO when the read failed, and a button that opens
454
+ * an empty table would read as "none". */
455
+ function DrillCell({
456
+ n,
457
+ onOpen,
458
+ why,
459
+ title,
460
+ }: {
461
+ n: number | null | undefined;
462
+ onOpen: () => void;
463
+ why?: string;
464
+ title?: string;
465
+ }) {
466
+ if (n === null || n === undefined)
467
+ return (
468
+ <td className="padmin-num">
469
+ <Count n={n} why={why} />
470
+ </td>
471
+ );
472
+ return (
473
+ <td className="padmin-num">
474
+ <button type="button" className="padmin-drill-btn" onClick={onOpen} title={title}>
475
+ {int(n)}
476
+ </button>
477
+ </td>
478
+ );
479
+ }
480
+
481
+ function DrillTable({ kind, rows }: { kind: DrillKind; rows: unknown[] }) {
482
+ if (kind === "users") {
483
+ const rs = rows as PlatformUser[];
484
+ return (
485
+ <div className="padmin-tablewrap">
486
+ <table className="padmin-table">
487
+ <thead>
488
+ <tr>
489
+ <th>Person</th>
490
+ <th>Workspace</th>
491
+ <th>Role</th>
492
+ <th>Last sign-in</th>
493
+ <th>Last active</th>
494
+ </tr>
495
+ </thead>
496
+ <tbody>
497
+ {rs.map((u) => (
498
+ <tr key={`${u.tenant}/${u.username}`}>
499
+ <td>
500
+ <span className="padmin-name">{u.name}</span>
501
+ <span className="padmin-meta">
502
+ {u.username}
503
+ {u.email ? ` Β· ${u.email}` : ""}
504
+ {u.active ? "" : " Β· deactivated"}
505
+ </span>
506
+ </td>
507
+ <td>{u.tenant}</td>
508
+ <td>
509
+ {u.role}
510
+ {u.platformAdmin ? " Β· platform" : ""}
511
+ </td>
512
+ <td>{ago(u.lastLogin)}</td>
513
+ <td>{ago(u.lastActive)}</td>
514
+ </tr>
515
+ ))}
516
+ </tbody>
517
+ </table>
518
+ </div>
519
+ );
520
+ }
521
+ if (kind === "databases") {
522
+ const rs = rows as DatabaseRow[];
523
+ return (
524
+ <div className="padmin-tablewrap">
525
+ <table className="padmin-table">
526
+ <thead>
527
+ <tr>
528
+ <th>Database</th>
529
+ <th>Workspace</th>
530
+ <th>Built from</th>
531
+ <th className="padmin-num">Fields</th>
532
+ <th className="padmin-num">Records</th>
533
+ </tr>
534
+ </thead>
535
+ <tbody>
536
+ {rs.map((d) => (
537
+ <tr key={`${d.tenant}/${d.key}`}>
538
+ <td>
539
+ <span className="padmin-name">{d.label}</span>
540
+ <span className="padmin-meta">
541
+ {d.key}
542
+ {d.createdBy ? ` Β· ${d.createdBy}` : ""}
543
+ </span>
544
+ </td>
545
+ <td>{d.tenant}</td>
546
+ <td>{d.source}</td>
547
+ <td className="padmin-num">{int(d.fields)}</td>
548
+ <td className="padmin-num">{int(d.rowCount)}</td>
549
+ </tr>
550
+ ))}
551
+ </tbody>
552
+ </table>
553
+ </div>
554
+ );
555
+ }
556
+ if (kind === "connectors") {
557
+ const rs = rows as ConnectorRow[];
558
+ return (
559
+ <div className="padmin-tablewrap">
560
+ <table className="padmin-table">
561
+ <thead>
562
+ <tr>
563
+ <th>Source</th>
564
+ <th>Workspace</th>
565
+ <th>Kind</th>
566
+ <th>Status</th>
567
+ </tr>
568
+ </thead>
569
+ <tbody>
570
+ {rs.map((c) => (
571
+ <tr key={`${c.tenant}/${c.key}`}>
572
+ <td>
573
+ <span className="padmin-name">{c.label}</span>
574
+ <span className="padmin-meta">
575
+ {c.source === "env" ? "environment credentials" : "keychain"}
576
+ </span>
577
+ </td>
578
+ <td>{c.tenant}</td>
579
+ <td>{c.type}</td>
580
+ <td>
581
+ <span
582
+ className={
583
+ "padmin-dot " +
584
+ (c.paused ? "is-paused" : c.active ? "is-live" : "is-idle")
585
+ }
586
+ />
587
+ {c.paused ? "paused" : c.active ? "serving" : "stored"}
588
+ </td>
589
+ </tr>
590
+ ))}
591
+ </tbody>
592
+ </table>
593
+ </div>
594
+ );
595
+ }
596
+ const rs = rows as AutomationRow[];
597
+ return (
598
+ <div className="padmin-tablewrap">
599
+ <table className="padmin-table">
600
+ <thead>
601
+ <tr>
602
+ <th>Automation</th>
603
+ <th>Workspace</th>
604
+ <th>Schedule</th>
605
+ <th className="padmin-num">Runs a day</th>
606
+ <th>Last run</th>
607
+ </tr>
608
+ </thead>
609
+ <tbody>
610
+ {rs.map((a) => (
611
+ <tr key={`${a.tenant}/${a.id}`}>
612
+ <td>
613
+ <span className="padmin-name">{a.name}</span>
614
+ <span className="padmin-meta">
615
+ {a.kind}
616
+ {a.failedRetained
617
+ ? ` Β· ${a.failedRetained} of the last ${a.runsRetained} runs failed`
618
+ : ""}
619
+ </span>
620
+ </td>
621
+ <td>{a.tenant}</td>
622
+ <td>{a.enabled ? a.cron || "scheduled" : "paused"}</td>
623
+ <td className="padmin-num">
624
+ {a.enabled ? (a.runsPerDay === null ? "custom" : a.runsPerDay) : "β€”"}
625
+ </td>
626
+ <td>
627
+ <span
628
+ className={
629
+ "padmin-dot " +
630
+ (a.state === "error" ? "is-error" : a.state === "ok" ? "is-live" : "is-idle")
631
+ }
632
+ />
633
+ {/* Verbatim, not relative: the engine writes this stamp with no
634
+ zone (`automation_engine._iso()` is naive local-to-container),
635
+ so "x hours ago" would be wrong by the viewer's offset. */}
636
+ {a.lastRunAt ? a.lastRunAt.replace("T", " ") : "never"}
637
+ </td>
638
+ </tr>
639
+ ))}
640
+ </tbody>
641
+ </table>
642
+ </div>
643
+ );
644
+ }
645
+
646
+ export default AdminPane;
web/src/settings/PermsEditor.tsx CHANGED
@@ -393,7 +393,7 @@ export function PermsEditor({
393
  // to believe an account is restricted when it is not.
394
  if (p.isAdmin) {
395
  notes.push(
396
- "is an administrator β€” the rules will be stored but stay inert until the account is changed to Member"
397
  );
398
  }
399
  // ⚠ A COPY MIGRATES THE TARGET. An un-migrated record runs under the
@@ -404,7 +404,7 @@ export function PermsEditor({
404
  // exotic one.
405
  if (!p.migrated) {
406
  notes.push(
407
- "still uses the previous access model β€” this write moves it to the per-module model"
408
  );
409
  }
410
  // The same disclosure this page already makes for the account it has
@@ -412,7 +412,7 @@ export function PermsEditor({
412
  // does it to somebody the admin never opened.
413
  if (p.orphanModules.length) {
414
  notes.push(
415
- `has rules for ${p.orphanModules.join(", ")}, which this deployment no longer offers β€” those are removed`
416
  );
417
  }
418
  // ⚠ DISCLOSED BEFORE THE WRITE, not only after it. `runCopy` also reports
@@ -489,7 +489,7 @@ export function PermsEditor({
489
  if (dropped.size) {
490
  const list = nameList([...dropped].sort());
491
  problems.push(
492
- `${list} could not be copied β€” the accounts you picked do not offer ${dropped.size === 1 ? "it" : "them"}.`
493
  );
494
  }
495
  if (problems.length) setError(problems.join(" "));
@@ -687,7 +687,7 @@ export function PermsEditor({
687
  disabled={!canCopy || busy}
688
  title={
689
  dirty
690
- ? "Save this account's access first β€” a copy sends what is stored, not what is on screen."
691
  : undefined
692
  }
693
  onClick={() => openCopy({ kind: "all" })}
@@ -709,7 +709,7 @@ export function PermsEditor({
709
  reader who thought to hover. */}
710
  {dirty && copyTargets?.length ? (
711
  <p className="set-help set-copy-why">
712
- Copying is unavailable while there are unsaved changes β€” a copy sends
713
  what is stored, not what is on screen.
714
  </p>
715
  ) : null}
 
393
  // to believe an account is restricted when it is not.
394
  if (p.isAdmin) {
395
  notes.push(
396
+ "is an administrator, so the rules will be stored but stay inert until the account is changed to Member"
397
  );
398
  }
399
  // ⚠ A COPY MIGRATES THE TARGET. An un-migrated record runs under the
 
404
  // exotic one.
405
  if (!p.migrated) {
406
  notes.push(
407
+ "still uses the previous access model, and this write moves it to the per-module model"
408
  );
409
  }
410
  // The same disclosure this page already makes for the account it has
 
412
  // does it to somebody the admin never opened.
413
  if (p.orphanModules.length) {
414
  notes.push(
415
+ `has rules for ${p.orphanModules.join(", ")}, which this deployment no longer offers, so those are removed`
416
  );
417
  }
418
  // ⚠ DISCLOSED BEFORE THE WRITE, not only after it. `runCopy` also reports
 
489
  if (dropped.size) {
490
  const list = nameList([...dropped].sort());
491
  problems.push(
492
+ `${list} could not be copied. The accounts you picked do not offer ${dropped.size === 1 ? "it" : "them"}.`
493
  );
494
  }
495
  if (problems.length) setError(problems.join(" "));
 
687
  disabled={!canCopy || busy}
688
  title={
689
  dirty
690
+ ? "Save this account's access first. A copy sends what is stored, not what is on screen."
691
  : undefined
692
  }
693
  onClick={() => openCopy({ kind: "all" })}
 
709
  reader who thought to hover. */}
710
  {dirty && copyTargets?.length ? (
711
  <p className="set-help set-copy-why">
712
+ Copying is unavailable while there are unsaved changes. A copy sends
713
  what is stored, not what is on screen.
714
  </p>
715
  ) : null}
web/src/settings/SettingsModal.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/settings/permsModel.ts CHANGED
@@ -53,10 +53,22 @@ export type SettingsSection =
53
  // Wave 18 (C7): the tenant's credential store and its data-source status board.
54
  | "keychains"
55
  | "connectors"
56
- /** EXIT-6: the statement-of-account sender, ported off `app.py` when Streamlit
57
- * was deleted. Admin-only for the same reason the Streamlit section was β€”
58
- * it is THE one sanctioned Odoo writer in the product. */
59
- | "statements"
 
 
 
 
 
 
 
 
 
 
 
 
60
  /**
61
  * Wave 19 (R3 / contract C2): the Loopable admin plane β€” the cross-TENANT
62
  * console, visible only to a `platform_admin` account.
@@ -72,6 +84,21 @@ export type SettingsSection =
72
  */
73
  | "padmin";
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  /**
76
  * A non-admin never LANDS on the users pane β€” and therefore never reaches the
77
  * permission editor, which lives inside it.
@@ -84,8 +111,22 @@ export type SettingsSection =
84
  * inline ternary cannot have a negative control, and "the pane a non-admin gets
85
  * bounced out of" is exactly the kind of rule that gets refactored away by
86
  * someone who does not know it is load-bearing.
 
 
 
 
87
  */
88
- export function reachableSection(section: SettingsSection, admin: boolean): SettingsSection {
 
 
 
 
 
 
 
 
 
 
89
  // ⭐⭐ WAVE 32 Β· R4 β€” `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason.
90
  //
91
  // Wave 18 (C7) put them here because "every control inside them would 403 a member" β€” which was
@@ -107,11 +148,10 @@ export function reachableSection(section: SettingsSection, admin: boolean): Sett
107
  // that the routes refuse β€” this list is what stops the EMPTY FRAME, and a new
108
  // admin room added to the rail without a line here is the defect this
109
  // function's header describes.
110
- section === "agents" ||
111
- // EXIT-6: every control in the statements room would 403 a member, and the
112
- // one at the bottom sends customer email. It belongs in this list twice over.
113
- section === "statements";
114
- return adminOnly && !admin ? "account" : section;
115
  }
116
 
117
  // --- the wire (C-PERM) ------------------------------------------------------
 
53
  // Wave 18 (C7): the tenant's credential store and its data-source status board.
54
  | "keychains"
55
  | "connectors"
56
+ /* β›”β›” `"statements"` LEFT THIS UNION (wave 35 Β· T38, owner item 14 / ruling R10).
57
+ *
58
+ * The statement sender is an AGENT now: `Agents -> Monthly statements`, where a scheduled run
59
+ * ASSEMBLES a batch and parks it, and a person releases it with a click. It was here since
60
+ * EXIT-6, ported off `app.py`.
61
+ *
62
+ * ⚠ THE SEND DOOR DID NOT MOVE AND DID NOT WEAKEN. `routes_statements` still holds it, still
63
+ * behind `admin_gate` + the Royal-Imports tenant gate, still calling
64
+ * `collections_send.queue_statement` whose SAFE_MODE allow-list lives in the DATA LAYER. What
65
+ * left is one of two front doors onto it, not a capability.
66
+ *
67
+ * β›” AND A STORED PREFERENCE NAMING IT DEGRADES RATHER THAN BREAKING β€” see `RETIRED_SECTIONS`
68
+ * below. Dropping a union member is a TYPE change; the values people have already saved are
69
+ * data, and refusing one would strand somebody on a blank pane forever (D-65's lesson: a
70
+ * refused stored key locks the door you need in order to fix it).
71
+ */
72
  /**
73
  * Wave 19 (R3 / contract C2): the Loopable admin plane β€” the cross-TENANT
74
  * console, visible only to a `platform_admin` account.
 
84
  */
85
  | "padmin";
86
 
87
+ /**
88
+ * ⭐⭐ WAVE 35 Β· T38 β€” SECTIONS THAT ONCE EXISTED AND NO LONGER DO.
89
+ *
90
+ * β›” A LIST RATHER THAN A DELETION, because a stored value outlives the type that described it.
91
+ * `SettingsSection` is compile-time; the section somebody last had open is DATA, and a person who
92
+ * was in Statements when this shipped must land somewhere real. Without this they would restore
93
+ * into a section no branch renders β€” a blank modal, which reads as a broken product rather than as
94
+ * a room that moved.
95
+ *
96
+ * ⚠ THE ENTRY STAYS EVEN AFTER NOBODY COULD PLAUSIBLY HOLD IT. It costs one string and it is the
97
+ * only record, in code, that the value was ever legal β€” deleting it is how the next reader
98
+ * "cleans up" a degrade path and re-opens the blank pane.
99
+ */
100
+ export const RETIRED_SECTIONS: readonly string[] = ["statements"];
101
+
102
  /**
103
  * A non-admin never LANDS on the users pane β€” and therefore never reaches the
104
  * permission editor, which lives inside it.
 
111
  * inline ternary cannot have a negative control, and "the pane a non-admin gets
112
  * bounced out of" is exactly the kind of rule that gets refactored away by
113
  * someone who does not know it is load-bearing.
114
+ *
115
+ * ⭐ WAVE 35 Β· T38 β€” it is ALSO where a RETIRED section is degraded, and the two rules live in one
116
+ * function on purpose: both answer "this section is not reachable, so where does this person
117
+ * actually land", and splitting them would give the caller two chances to forget one.
118
  */
119
+ export function reachableSection(
120
+ /* ⚠ WIDER THAN `SettingsSection` ON PURPOSE. The caller passes a value that may have been
121
+ STORED under an older build, so typing this parameter to the current union would make the
122
+ retired case unrepresentable β€” and therefore unhandleable β€” at exactly the callsite whose job
123
+ is to handle it. */
124
+ section: SettingsSection | string,
125
+ admin: boolean,
126
+ ): SettingsSection {
127
+ // β›” FIRST, before the admin rule: a retired section has no admin question to answer, and
128
+ // `adminOnly` below would read `false` for it and hand back a section nothing renders.
129
+ if (RETIRED_SECTIONS.includes(String(section))) return "account";
130
  // ⭐⭐ WAVE 32 Β· R4 β€” `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason.
131
  //
132
  // Wave 18 (C7) put them here because "every control inside them would 403 a member" β€” which was
 
148
  // that the routes refuse β€” this list is what stops the EMPTY FRAME, and a new
149
  // admin room added to the rail without a line here is the defect this
150
  // function's header describes.
151
+ section === "agents";
152
+ // ⚠ The cast is safe BECAUSE of the retired-section guard at the top: every value reaching here
153
+ // is either a current member of the union or has already been degraded to "account".
154
+ return adminOnly && !admin ? "account" : (section as SettingsSection);
 
155
  }
156
 
157
  // --- the wire (C-PERM) ------------------------------------------------------
web/src/shell/NavExtras.tsx CHANGED
@@ -1,995 +1,986 @@
1
- // ---------------------------------------------------------------------------
2
- // shell/NavExtras.tsx β€” C-SCHEMA (wave 2026-08-02): the database list's
3
- // three-dots menu, folder heads, and the schema drawer.
4
- //
5
- // Kept out of Shell.tsx so the shell's frame stays readable: Shell owns the
6
- // STATE (prefs, open folders, which schema is open) and the save round-trip;
7
- // these components own only their own popover/drawer chrome. Menus are
8
- // position:fixed against the trigger β€” the rail scrolls and clips, so an
9
- // absolutely-positioned child could never escape it (the same lesson as the
10
- // collapsed-rail tooltip).
11
- // ---------------------------------------------------------------------------
12
-
13
- import { useEffect, useRef, useState } from "react";
14
- import type { ReactNode } from "react";
15
- import { fetchSchema, importantBadge } from "./nav";
16
- import type { NavFolder, NavImportant, SchemaPayload, TableFootprint } from "./nav";
17
- // ⚠ WAVE 19 R8 / C1 β€” IMPORTED, NEVER REDEFINED, and the contract says so in as
18
- // many words ("Shapes/tones = the grid's existing vocabulary verbatim (12
19
- // shapes, 5 tones); B imports, never redefines"). A second copy of this
20
- // vocabulary in the shell is a whitelist that can drift by one member from the
21
- // one the host validates against β€” and a tone the server does not recognise
22
- // degrades to the default, so the user's choice disappears on reload with
23
- // nothing on screen to say why. These are READ-ONLY imports across the session
24
- // fence: `customer-grid/**` is another session's tree this wave (HARD RULE 3).
25
- import { FolderMark } from "../customer-grid/icons";
26
- import { FOLDER_SHAPE_LABELS, FOLDER_TONE_LABELS } from "../customer-grid/iconShapes";
27
- import {
28
- DEFAULT_FOLDER_SHAPE,
29
- DEFAULT_FOLDER_TONE,
30
- FOLDER_SHAPES,
31
- FOLDER_TONES,
32
- } from "../customer-grid/types";
33
- import type { FolderIcon } from "../customer-grid/types";
34
- import "./navExtras.css";
35
-
36
- /**
37
- * WAVE 21 item 12 (ruling R11) β€” HORIZONTAL, and it is the same three dots turned
38
- * 90Β°, not a different mark.
39
- *
40
- * The rail and the views list are read as one surface, and they were drawing the
41
- * "there is a menu here" affordance two different ways: this glyph stacked its
42
- * circles (cx fixed, cy walking) while `ViewSidebar`'s row buttons have always
43
- * rendered three MIDDLE DOTs on the baseline. Same promise, two pictures β€” the
44
- * thing R11 names. Only the axis moves: radius, viewBox and the 14px box in
45
- * `navExtras.css` are untouched, so the hit target and the optical weight are
46
- * exactly what shipped.
47
- */
48
- function DotsIcon() {
49
- return (
50
- <svg viewBox="0 0 16 16" aria-hidden="true" className="shell-dots-icon">
51
- <circle cx="3.2" cy="8" r="1.35" />
52
- <circle cx="8" cy="8" r="1.35" />
53
- <circle cx="12.8" cy="8" r="1.35" />
54
- </svg>
55
- );
56
- }
57
-
58
- function FolderIcon() {
59
- return (
60
- <svg viewBox="0 0 16 16" aria-hidden="true" className="shell-nav-icon">
61
- <path d="M2.2 4.4c0-.66.54-1.2 1.2-1.2h3l1.4 1.6h4.8c.66 0 1.2.54 1.2 1.2v6c0 .66-.54 1.2-1.2 1.2H3.4c-.66 0-1.2-.54-1.2-1.2z" />
62
- </svg>
63
- );
64
- }
65
-
66
- /** A fixed-position popover anchored to a trigger rect, closed on outside
67
- * click or Escape. Small on purpose β€” the shell does not import the grid's
68
- * Popover, so the two chrome trees stay independently deletable. */
69
- function useMenu() {
70
- const [at, setAt] = useState<{ x: number; y: number } | null>(null);
71
- const ref = useRef<HTMLDivElement>(null);
72
- useEffect(() => {
73
- if (!at) return;
74
- const onDown = (e: MouseEvent) => {
75
- if (ref.current && !ref.current.contains(e.target as Node)) setAt(null);
76
- };
77
- const onKey = (e: KeyboardEvent) => {
78
- if (e.key === "Escape") setAt(null);
79
- };
80
- document.addEventListener("mousedown", onDown, true);
81
- document.addEventListener("keydown", onKey, true);
82
- return () => {
83
- document.removeEventListener("mousedown", onDown, true);
84
- document.removeEventListener("keydown", onKey, true);
85
- };
86
- }, [at]);
87
- const openFrom = (el: HTMLElement) => {
88
- const r = el.getBoundingClientRect();
89
- setAt({ x: Math.round(r.right + 4), y: Math.round(r.top) });
90
- };
91
- return { at, ref, openFrom, close: () => setAt(null) };
92
- }
93
-
94
- function MenuShell({
95
- at,
96
- menuRef,
97
- wide,
98
- children,
99
- }: {
100
- at: { x: number; y: number };
101
- menuRef: React.RefObject<HTMLDivElement>;
102
- /** WAVE 21 item 6 β€” the delete confirm needs prose width; a menu of one-line
103
- * actions does not. The CLAMP moves with the class, because a wider panel
104
- * clamped at the narrow width spills off the right edge on exactly the rows
105
- * furthest from it. */
106
- wide?: boolean;
107
- children: ReactNode;
108
- }) {
109
- // Clamp to the viewport so a row near the bottom does not spill the menu off it.
110
- const width = wide ? 320 : 248;
111
- const style = {
112
- left: Math.min(at.x, Math.max(8, window.innerWidth - width)),
113
- top: Math.min(at.y, Math.max(8, window.innerHeight - 260)),
114
- };
115
- return (
116
- <div
117
- className={"shell-navmenu" + (wide ? " is-wide" : "")}
118
- style={style}
119
- ref={menuRef}
120
- role="menu"
121
- >
122
- {children}
123
- </div>
124
- );
125
- }
126
-
127
- /**
128
- * WAVE 19 R8 / C1 β€” the 12x5 swatch picker, the grid's own pattern
129
- * (`ViewSidebar.tsx`'s folder form) rendered inside the nav's popover.
130
- *
131
- * TWO ROWS, NOT A GRID OF 60. Shape and tone are independent choices, so the
132
- * shape row previews in the CURRENT tone and the tone row previews in the
133
- * CURRENT shape β€” every swatch shows what that one click would actually
134
- * produce, which is why picking is one glance rather than a search.
135
- */
136
- function IconPicker({
137
- value,
138
- onPick,
139
- onClear,
140
- }: {
141
- value?: FolderIcon;
142
- onPick: (icon: FolderIcon) => void;
143
- /** Absent β‡’ nothing to clear (the row is already on the default mark). */
144
- onClear?: () => void;
145
- }) {
146
- const shape = value?.shape ?? DEFAULT_FOLDER_SHAPE;
147
- const tone = value?.tone ?? DEFAULT_FOLDER_TONE;
148
- return (
149
- <div className="shell-navmenu-pick" role="group" aria-label="Database icon">
150
- <div className="shell-navmenu-pickrow">
151
- {FOLDER_SHAPES.map((s) => (
152
- <button
153
- key={s}
154
- type="button"
155
- className={"shell-navmenu-swatch" + (value && shape === s ? " is-on" : "")}
156
- aria-pressed={!!value && shape === s}
157
- aria-label={FOLDER_SHAPE_LABELS[s]}
158
- title={FOLDER_SHAPE_LABELS[s]}
159
- onClick={() => onPick({ shape: s, tone })}
160
- >
161
- <FolderMark icon={{ shape: s, tone }} size={15} />
162
- </button>
163
- ))}
164
- </div>
165
- <div className="shell-navmenu-pickrow">
166
- {FOLDER_TONES.map((t) => (
167
- <button
168
- key={t}
169
- type="button"
170
- className={"shell-navmenu-swatch" + (value && tone === t ? " is-on" : "")}
171
- aria-pressed={!!value && tone === t}
172
- aria-label={FOLDER_TONE_LABELS[t]}
173
- title={FOLDER_TONE_LABELS[t]}
174
- onClick={() => onPick({ shape, tone: t })}
175
- >
176
- <FolderMark icon={{ shape, tone: t }} size={15} />
177
- </button>
178
- ))}
179
- </div>
180
- {/* β›” AN EXPLICIT WAY BACK, because this picker deliberately does NOT copy
181
- `cleanFolderIcon`'s "a fully-default icon is no icon" rule. That rule is
182
- right for a FOLDER, whose default mark IS the folder shape; a database's
183
- default mark is the cylinder, so dropping folder+neutral would answer a
184
- user who picked "Folder" with a picture of a database β€” the silent
185
- disappearance the tone whitelist's own note warns about. Absent = the
186
- cylinder, chosen = exactly what was chosen, and clearing is a click that
187
- says what it does. */}
188
- {onClear ? (
189
- <button type="button" className="shell-navmenu-item is-quiet" onClick={onClear}>
190
- Use the default icon
191
- </button>
192
- ) : null}
193
- </div>
194
- );
195
- }
196
-
197
- export function RowMenu({
198
- entryLabel,
199
- canSchema,
200
- onSchema,
201
- canRename,
202
- onRename,
203
- canIcon,
204
- icon,
205
- onIcon,
206
- onIconClear,
207
- onShare,
208
- canDelete,
209
- onLoadFootprint,
210
- onDelete,
211
- }: {
212
- entryLabel: string;
213
- canSchema: boolean;
214
- onSchema: () => void;
215
- /**
216
- * WAVE 19 R8 β€” rename is `ut_*` (custom) DATABASES ONLY, and this flag is how
217
- * the row says so. A built-in label is a compiled registry literal: renaming
218
- * `customer_data` would mean the nav and every other reader of
219
- * `core/registry.py` disagreeing about what the module is called. The SERVER
220
- * refuses a `name` for a non-`ut_` key regardless (C1, fail-closed) β€” this
221
- * only spares the user a control whose write would bounce.
222
- */
223
- canRename?: boolean;
224
- onRename?: (name: string) => void;
225
- /** Icons ride ALL databases (R8), unlike rename. */
226
- canIcon?: boolean;
227
- icon?: FolderIcon;
228
- onIcon?: (icon: FolderIcon) => void;
229
- onIconClear?: () => void;
230
- /** WAVE 20 item 18 (C-SHARE) β€” open the access editor for THIS database.
231
- * Absent = the row does not offer sharing (a group head has nothing to share). */
232
- onShare?: () => void;
233
- /**
234
- * ⭐ WAVE 21 item 6 (ruling R3, contract C3, wiring W-5) β€” may this session
235
- * DELETE this database?
236
- *
237
- * β›” REQUIRED, NOT OPTIONAL, and that is the wave-20 lesson written into a type.
238
- * Four features shipped dead behind 43 green gates because the prop that carried
239
- * them across an ownership fence was `?`-marked: an unmounted optional prop is
240
- * `undefined`, which reads as "the feature is off" and is indistinguishable from
241
- * "the feature was never built". A required prop makes the unmounted case a
242
- * COMPILE ERROR. The value is the server's (`NavPage.canDelete`), narrower than
243
- * `manage` on purpose β€” see the note there.
244
- */
245
- canDelete: boolean;
246
- /** What deleting would destroy. Resolves null when the server would not say β€”
247
- * the face then asks WITHOUT counts rather than inventing zeros. */
248
- onLoadFootprint: () => Promise<TableFootprint | null>;
249
- /** Do it. Resolves the server's own words on refusal, which the face shows in
250
- * place of the confirmation β€” a delete that failed must never look like one
251
- * that worked. */
252
- onDelete: () => Promise<{ ok: boolean; error?: string }>;
253
- }) {
254
- // Wave 14 C-NAVFOLD (ruling R10): the three-dots kept "View schema" ONLY.
255
- // Wave 19 (R8/C1) adds Rename and Change icon β€” the first two things a
256
- // database the USER made should have been able to do.
257
- const { at, ref, openFrom, close } = useMenu();
258
- // Which face the popover is showing. Reset on every open, so a menu that was
259
- // left mid-rename does not reopen into somebody else's half-typed name.
260
- const [mode, setMode] = useState<"menu" | "rename" | "icon" | "delete">("menu");
261
- const [name, setName] = useState(entryLabel);
262
- /**
263
- * WAVE 21 item 6 β€” the delete face's own state. `footprint: undefined` means
264
- * "still asking", `null` means "the server would not say" β€” two different
265
- * things the face words differently, which is why it is not a plain object.
266
- */
267
- const [del, setDel] = useState<{
268
- footprint?: TableFootprint | null;
269
- busy: boolean;
270
- error: string;
271
- }>({ busy: false, error: "" });
272
- const renameOn = !!canRename && !!onRename;
273
- const iconOn = !!canIcon && !!onIcon;
274
- if (!canSchema && !renameOn && !iconOn && !onShare && !canDelete) return null;
275
- const submitRename = () => {
276
- const clean = name.trim().replace(/\s+/g, " ");
277
- if (clean && clean !== entryLabel) onRename?.(clean);
278
- close();
279
- };
280
- /**
281
- * ⚠ THE FOOTPRINT IS FETCHED WHEN THE FACE OPENS, NOT WHEN THE MENU DOES.
282
- * Same reasoning as `PermsEditor.prepareCopy` ("the targets are fetched BEFORE
283
- * the confirm"): the question has to name what is being destroyed, and a
284
- * confirm written from the rail's own knowledge can only name the table. The
285
- * cost is paid by the person who clicked Delete, never by everyone who opened
286
- * a menu.
287
- */
288
- const openDelete = () => {
289
- setDel({ busy: false, error: "" });
290
- setMode("delete");
291
- void onLoadFootprint().then((f) => setDel((cur) => ({ ...cur, footprint: f })));
292
- };
293
- const runDelete = () => {
294
- setDel((cur) => ({ ...cur, busy: true, error: "" }));
295
- void onDelete().then((r) => {
296
- if (r.ok) {
297
- close();
298
- setDel({ busy: false, error: "" });
299
- return;
300
- }
301
- // β›” THE MENU STAYS OPEN ON A REFUSAL. Closing it would leave a rail whose
302
- // row is still there and no statement anywhere about why β€” which reads as
303
- // "the click did nothing" and invites a second one.
304
- setDel((cur) => ({ ...cur, busy: false, error: r.error || "It could not be deleted." }));
305
- });
306
- };
307
- return (
308
- <>
309
- <button
310
- type="button"
311
- className="shell-dots"
312
- aria-label={`Options for ${entryLabel}`}
313
- aria-haspopup="menu"
314
- onClick={(e) => {
315
- e.preventDefault();
316
- e.stopPropagation();
317
- setMode("menu");
318
- setName(entryLabel);
319
- openFrom(e.currentTarget);
320
- }}
321
- >
322
- <DotsIcon />
323
- </button>
324
- {at && (
325
- <MenuShell at={at} menuRef={ref} wide={mode === "delete"}>
326
- {mode === "delete" && canDelete ? (
327
- <DeleteFace
328
- entryLabel={entryLabel}
329
- state={del}
330
- onCancel={() => setMode("menu")}
331
- onConfirm={runDelete}
332
- />
333
- ) : mode === "rename" && renameOn ? (
334
- <form
335
- className="shell-navmenu-form"
336
- onSubmit={(e) => {
337
- e.preventDefault();
338
- submitRename();
339
- }}
340
- >
341
- <input
342
- className="shell-navmenu-input"
343
- autoFocus
344
- maxLength={60}
345
- value={name}
346
- aria-label={`Rename ${entryLabel}`}
347
- onChange={(e) => setName(e.target.value)}
348
- onKeyDown={(e) => {
349
- if (e.key === "Escape") close();
350
- }}
351
- />
352
- <button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
353
- Save
354
- </button>
355
- </form>
356
- ) : mode === "icon" && iconOn ? (
357
- /* ⚠ PICKING DOES NOT CLOSE THE MENU, and that is not a convenience.
358
- Shape and tone are two independent choices: a picker that closed
359
- on the first click would make "a green bolt" a two-open job, and
360
- the second open would have to find the row again. Each click
361
- commits (they are cheap and rare), the rail repaints from the
362
- server, and the swatch marks follow what was actually stored β€” so
363
- a refused write shows up as a mark that does not move, beside the
364
- toast that says why. Clearing DOES close: "use the default" is a
365
- terminal action, and the control removes itself once taken. */
366
- <IconPicker
367
- value={icon}
368
- onPick={(next) => onIcon?.(next)}
369
- {...(icon && onIconClear
370
- ? {
371
- onClear: () => {
372
- close();
373
- onIconClear();
374
- },
375
- }
376
- : {})}
377
- />
378
- ) : (
379
- <>
380
- {renameOn ? (
381
- <button
382
- type="button"
383
- className="shell-navmenu-item"
384
- onClick={() => setMode("rename")}
385
- >
386
- Rename
387
- </button>
388
- ) : null}
389
- {iconOn ? (
390
- <button
391
- type="button"
392
- className="shell-navmenu-item"
393
- onClick={() => setMode("icon")}
394
- >
395
- Change icon
396
- </button>
397
- ) : null}
398
- {canSchema ? (
399
- <button
400
- type="button"
401
- className="shell-navmenu-item"
402
- onClick={() => {
403
- close();
404
- onSchema();
405
- }}
406
- >
407
- View schema
408
- </button>
409
- ) : null}
410
- {/* WAVE 20 item 18 (R10, C-SHARE) β€” a DATABASE shares with the same two
411
- roles a view does. Gated on `onShare`, which the Shell passes only
412
- for a real database row, so a family head or a hand-off link never
413
- offers to share something that has no id on the server. */}
414
- {onShare ? (
415
- <button
416
- type="button"
417
- className="shell-navmenu-item"
418
- onClick={() => {
419
- close();
420
- onShare();
421
- }}
422
- >
423
- Share…
424
- </button>
425
- ) : null}
426
- {/* ⭐ WAVE 21 item 6 (R3) β€” LAST, and the only destructive row in this
427
- menu. Gated on the server's `canDelete` alone: R3 scopes the verb to
428
- the CREATOR or an admin and refuses it outright for connector-backed
429
- databases, so a row that may not be deleted does not say so β€” it
430
- simply has nothing here to click. (An entry that explains why it is
431
- disabled is the right pattern for a thing you could earn; this one
432
- you cannot.) */}
433
- {canDelete ? (
434
- <button
435
- type="button"
436
- className="shell-navmenu-item is-danger"
437
- onClick={openDelete}
438
- >
439
- Delete database…
440
- </button>
441
- ) : null}
442
- </>
443
- )}
444
- </MenuShell>
445
- )}
446
- </>
447
- );
448
- }
449
-
450
- /** One footprint line: "12 records", "1 view". Singular/plural is the whole of
451
- * the formatting, because a count with the wrong noun reads as a bug in the
452
- * count. */
453
- function countLine(n: number, one: string, many: string): string {
454
- return `${n.toLocaleString()} ${n === 1 ? one : many}`;
455
- }
456
-
457
- /**
458
- * ⭐ WAVE 21 item 6 (R3, contract C3) β€” the confirm face: what deleting this
459
- * database destroys, stated before the button that does it.
460
- *
461
- * Shaped after `PermsEditor`'s confirm (`.set-confirm`, the strongest one this
462
- * codebase has) and not after a `window.confirm`: the browser dialog cannot say
463
- * anything specific, and a destructive question whose answer depends on facts
464
- * the asker has not been given is not really being asked.
465
- *
466
- * THREE STATES, worded differently on purpose:
467
- * Β· footprint undefined β€” still asking the server. The button is disabled;
468
- * confirming against numbers that have not arrived is confirming against
469
- * nothing.
470
- * Β· footprint null β€” the server would not say. The question is put
471
- * WITHOUT counts and says so. Inventing zeros here would be a specific,
472
- * checkable claim about the user's data that nobody verified
473
- * ([[no-unverifiable-aggregates]]).
474
- * Β· footprint present β€” every family it names, listed.
475
- *
476
- * ⚠ The automations line is the one that is NOT about destruction: C3 pauses
477
- * bound automations and stamps them "target deleted" rather than removing them,
478
- * so it names them (they will still be there afterwards, switched off) instead
479
- * of counting them away.
480
- */
481
- function DeleteFace({
482
- entryLabel,
483
- state,
484
- onCancel,
485
- onConfirm,
486
- }: {
487
- entryLabel: string;
488
- state: { footprint?: TableFootprint | null; busy: boolean; error: string };
489
- onCancel: () => void;
490
- onConfirm: () => void;
491
- }) {
492
- const f = state.footprint;
493
- const asking = f === undefined;
494
- return (
495
- <div className="shell-navconfirm" role="alertdialog" aria-modal="true"
496
- aria-label={`Delete ${entryLabel}`}>
497
- <h4 className="shell-navconfirm-h">Delete β€œ{entryLabel}”?</h4>
498
- {asking ? (
499
- <p className="shell-navmenu-note">Checking what this database holds…</p>
500
- ) : f === null ? (
501
- <p className="shell-navmenu-note">
502
- The server did not say what this database holds. Deleting it removes its records,
503
- columns, views, comments, documents and sharing β€” permanently.
504
- </p>
505
- ) : (
506
- <>
507
- <p className="shell-navmenu-note">This cannot be undone. It permanently removes:</p>
508
- <ul className="shell-navconfirm-list">
509
- <li>{countLine(f.rows, "record", "records")}</li>
510
- <li>{countLine(f.fields, "column", "columns")}</li>
511
- <li>{countLine(f.views, "view", "views")}</li>
512
- <li>
513
- {countLine(f.sharedUsers, "person it is shared with",
514
- "people it is shared with")}
515
- </li>
516
- </ul>
517
- {f.automations.length ? (
518
- <p className="shell-navmenu-note">
519
- {/* The VERB agrees too. "1 automation write here" was on screen before the
520
- first screenshot was read β€” the kind of thing every assertion passes. */}
521
- {countLine(f.automations.length, "automation", "automations")}{" "}
522
- {f.automations.length === 1 ? "writes" : "write"} here (
523
- {f.automations.map((a) => a.name).join(", ")}) β€” {f.automations.length === 1
524
- ? "it is"
525
- : "they are"}{" "}
526
- switched off, not deleted.
527
- </p>
528
- ) : null}
529
- </>
530
- )}
531
- {state.error ? <p className="shell-navconfirm-err">{state.error}</p> : null}
532
- <div className="shell-navconfirm-actions">
533
- <button
534
- type="button"
535
- className="shell-navconfirm-go"
536
- disabled={asking || state.busy}
537
- onClick={onConfirm}
538
- >
539
- {state.busy ? "Deleting…" : "Delete"}
540
- </button>
541
- <button
542
- type="button"
543
- className="shell-navmenu-item is-quiet"
544
- disabled={state.busy}
545
- onClick={onCancel}
546
- >
547
- Keep it
548
- </button>
549
- </div>
550
- </div>
551
- );
552
- }
553
-
554
- function PlusIcon() {
555
- return (
556
- <svg viewBox="0 0 16 16" aria-hidden="true" className="shell-newthing-plus">
557
- <path d="M8 3.4v9.2M3.4 8h9.2" />
558
- </svg>
559
- );
560
- }
561
-
562
- /**
563
- * Wave 14 C-NAVFOLD gave the list's bottom a quiet "+ New folder" (the Views-rail
564
- * precedent). WAVE 19 R11 made it "+ Create new…" with Folder | Database behind it.
565
- *
566
- * ⭐ WAVE 24 items 1 + 15a (ruling R9) β€” IT LEAVES THE RAIL AND BECOMES THE DATABASE
567
- * FLYOUT'S FOOTER, and it now carries the creatable things: New database Β·
568
- * From a template Β· New folder. The flyout's three standalone `.shell-dbfly-make`
569
- * buttons are deleted; this one control replaces them.
570
- *
571
- * ⭐ WAVE 25 item 5a (ruling R8) β€” "AUTOMATED DATABASE" IS DELETED FROM THIS MENU,
572
- * so it carries THREE rows rather than four. R8: creating a database is one act and
573
- * pointing an automation at it is another, so the automation door lives on the
574
- * automation surface (its rail button and its empty state) and this menu stops
575
- * offering a database that is really an automation.
576
- * ⚠ `From a template` and `New folder` are UNTOUCHED β€” neither was ever an automated
577
- * database, and R9 keeps the template door working here (see the Home card's own note
578
- * on why exactly one of the two template doors is boarded).
579
- *
580
- * ⭐ AND IT IS ALSO ITEM 1's FIX, which is worth stating because it does not look
581
- * like a typography change. MEASURED on staging v12: the six rail rows all render
582
- * 13.8125px / w500 / Inter / rgb(32,36,51) β€” identical, `<a>` and `<button>` alike.
583
- * The ONE row in that band that differed was this one: `.shell-newfolder` declares
584
- * `--lp-fs-2xs` (12.75px) with no `font-weight` (so 400, inherited) and
585
- * `--lp-muted`. Taking it out of the rail is what makes the band read as one set;
586
- * nothing about the other six needed changing, and changing them would have been a
587
- * fix aimed at the wrong row.
588
- *
589
- * ⚠ THE `canFolder` GATE SURVIVES THE MOVE, and it has to. Wave 19's note: folders
590
- * over an empty list are an empty gesture, but a freshly provisioned tenant must
591
- * still be able to create a DATABASE. So with no entries the menu simply omits its
592
- * folder row rather than the control collapsing to a single-purpose button β€” inside
593
- * a panel there is room for a menu whatever the tenant has, which is the one thing
594
- * the 236px rail could not say.
595
- */
596
- export function CreateNewRow({
597
- collapsed,
598
- onExpand,
599
- onCreateFolder,
600
- onNewDatabase,
601
- onFromTemplate,
602
- canFolder,
603
- }: {
604
- collapsed: boolean;
605
- /** Collapsed, the row's one honest behaviour is "open the rail first". */
606
- onExpand: () => void;
607
- onCreateFolder: (name: string) => void;
608
- onNewDatabase: () => void;
609
- /** R9's second row β€” the template picker, over a database you already have. */
610
- onFromTemplate: () => void;
611
- /* β›” `onAutomated` LEFT WITH THE ROW IT OPENED (wave 25 item 5a, R8). */
612
- /** Folders over an empty list are an empty gesture β€” the row is omitted, not the menu. */
613
- canFolder: boolean;
614
- }) {
615
- const { at, ref, openFrom, close } = useMenu();
616
- const [naming, setNaming] = useState(false);
617
- const [name, setName] = useState("");
618
- const submit = () => {
619
- const clean = name.trim().replace(/\s+/g, " ");
620
- if (!clean) return;
621
- onCreateFolder(clean);
622
- setName("");
623
- setNaming(false);
624
- };
625
- // β›” The 56px strip cannot hold a popover β€” the same reason the account row
626
- // expands instead of opening its menu. Preserved from the button this row
627
- // replaced, which did exactly this before opening its dialog.
628
- //
629
- // ⚠ WAVE 24: this branch is now UNREACHABLE FROM THE RAIL (the row is not there
630
- // any more) but it is NOT dead β€” the flyout can be opened with the rail folded,
631
- // and `collapsed` is still passed. Kept rather than deleted, because the panel's
632
- // own note says the folded rail is exactly the state a user who opened it is most
633
- // likely to be in.
634
- if (collapsed) {
635
- return (
636
- <button
637
- type="button"
638
- className="shell-newfolder is-collapsed"
639
- aria-label="Create new"
640
- title="Create new"
641
- onClick={onExpand}
642
- >
643
- <PlusIcon />
644
- </button>
645
- );
646
- }
647
- if (!naming) {
648
- return (
649
- <>
650
- {/* THE LABEL NO LONGER FOLLOWS `canFolder`. In the rail it had to: with one
651
- real branch, "+ Create new…" beside an `aria-haspopup` was a control
652
- promising a choice it was not about to offer. In the footer there are
653
- always at least two (database / template), so the menu is real whatever
654
- the tenant has and the label can simply be true.
655
- ⚠ TWO, NOT THREE, since R8 took the automated row (wave 25) β€” still a
656
- genuine choice, which is the property the label depends on. If a future
657
- ruling takes the template row as well, this reasoning inverts and the
658
- control has to become a plain button again. */}
659
- <button
660
- type="button"
661
- className="shell-newfolder"
662
- aria-haspopup="menu"
663
- onClick={(e) => openFrom(e.currentTarget)}
664
- >
665
- + Create new…
666
- </button>
667
- {at && (
668
- <MenuShell at={at} menuRef={ref}>
669
- <button
670
- type="button"
671
- className="shell-navmenu-item"
672
- onClick={() => {
673
- close();
674
- onNewDatabase();
675
- }}
676
- >
677
- New database
678
- </button>
679
- <button
680
- type="button"
681
- className="shell-navmenu-item"
682
- onClick={() => {
683
- close();
684
- onFromTemplate();
685
- }}
686
- >
687
- From a template
688
- </button>
689
- {canFolder ? (
690
- <button
691
- type="button"
692
- className="shell-navmenu-item"
693
- onClick={() => {
694
- close();
695
- setNaming(true);
696
- }}
697
- >
698
- New folder
699
- </button>
700
- ) : null}
701
- </MenuShell>
702
- )}
703
- </>
704
- );
705
- }
706
- return (
707
- <form
708
- className="shell-navmenu-form shell-newfolder-form"
709
- onSubmit={(e) => {
710
- e.preventDefault();
711
- submit();
712
- }}
713
- >
714
- <input
715
- className="shell-navmenu-input"
716
- autoFocus
717
- maxLength={40}
718
- placeholder="Folder name"
719
- value={name}
720
- onChange={(e) => setName(e.target.value)}
721
- onKeyDown={(e) => {
722
- if (e.key === "Escape") {
723
- setName("");
724
- setNaming(false);
725
- }
726
- }}
727
- />
728
- <button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
729
- Add
730
- </button>
731
- </form>
732
- );
733
- }
734
-
735
- export function FolderHead({
736
- folder,
737
- count,
738
- important,
739
- open,
740
- collapsed,
741
- onToggle,
742
- onRename,
743
- onDelete,
744
- isDrop,
745
- dropProps,
746
- }: {
747
- folder: NavFolder;
748
- count: number;
749
- /** ⭐⭐ WAVE 34 R1 / C1 β€” the SUM of this folder's members' mark-important numbers, set by
750
- * `foldNav` for a CLOSED folder only. Absent β‡’ draw nothing, which is both the open case and
751
- * the far more common "nobody marked anything in here" case. */
752
- important?: NavImportant;
753
- open: boolean;
754
- collapsed: boolean;
755
- onToggle: () => void;
756
- onRename: (name: string) => void;
757
- onDelete: () => void;
758
- /** Wave 14 C-NAVFOLD β€” a database row is being dragged over this folder. */
759
- isDrop?: boolean;
760
- /** dragover/drop handlers, owned by the Shell (it holds the placement writer). */
761
- dropProps?: React.HTMLAttributes<HTMLDivElement>;
762
- }) {
763
- const { at, ref, openFrom, close } = useMenu();
764
- const [renaming, setRenaming] = useState(false);
765
- const [name, setName] = useState(folder.name);
766
- const submit = () => {
767
- const clean = name.trim().replace(/\s+/g, " ");
768
- if (clean && clean !== folder.name) onRename(clean);
769
- setRenaming(false);
770
- close();
771
- };
772
- if (collapsed) {
773
- // The folded rail has no room for folder chrome; members render flat and the
774
- // folder simply waits for the rail to open again.
775
- return null;
776
- }
777
- return (
778
- <div
779
- className={"shell-nav-folder" + (isDrop ? " is-drop" : "")}
780
- {...(dropProps ?? {})}
781
- >
782
- {/* R3: no chevron β€” the Views-rail folder look. Open state reads from the members
783
- below it (and aria-expanded says it aloud); the mark + bold label ARE the row. */}
784
- <button
785
- type="button"
786
- className="shell-nav-folderbtn"
787
- aria-expanded={open}
788
- onClick={onToggle}
789
- >
790
- <FolderIcon />
791
- <span className="shell-nav-label">{folder.name}</span>
792
- <span className="shell-nav-foldercount">{count}</span>
793
- {/* ⭐⭐ WAVE 34 R1 / C1 β€” *"a minimized folder should show the SUM of the numbers"*.
794
- β›” BESIDE `count`, NEVER INSTEAD OF IT. `count` is how many BLOCKS the folder holds and
795
- has meant that since wave 14; this is a sum of RECORDS. Collapsing them into one slot
796
- would make the same numeral mean two things depending on whether anything inside had
797
- ever been marked, which is worse than two numbers.
798
- ⚠ The `title` is what tells them apart for a reader who sees two numerals; the glyph
799
- cannot, and DESIGN.md's "never over-explain" law is about copy on screen, not about a
800
- tooltip that only appears on hover. */}
801
- {importantBadge(important) ? (
802
- <span
803
- className="shell-nav-count"
804
- title={`${importantBadge(important)} in the important views inside this folder`}
805
- >
806
- {importantBadge(important)}
807
- </span>
808
- ) : null}
809
- </button>
810
- <button
811
- type="button"
812
- className="shell-dots"
813
- aria-label={`Options for folder ${folder.name}`}
814
- aria-haspopup="menu"
815
- onClick={(e) => {
816
- e.stopPropagation();
817
- setName(folder.name);
818
- setRenaming(false);
819
- openFrom(e.currentTarget);
820
- }}
821
- >
822
- <DotsIcon />
823
- </button>
824
- {at && (
825
- <MenuShell at={at} menuRef={ref}>
826
- {renaming ? (
827
- <form
828
- className="shell-navmenu-form"
829
- onSubmit={(e) => {
830
- e.preventDefault();
831
- submit();
832
- }}
833
- >
834
- <input
835
- className="shell-navmenu-input"
836
- autoFocus
837
- maxLength={40}
838
- value={name}
839
- onChange={(e) => setName(e.target.value)}
840
- />
841
- <button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
842
- Save
843
- </button>
844
- </form>
845
- ) : (
846
- <button
847
- type="button"
848
- className="shell-navmenu-item"
849
- onClick={() => setRenaming(true)}
850
- >
851
- Rename folder
852
- </button>
853
- )}
854
- <button
855
- type="button"
856
- className="shell-navmenu-item is-danger"
857
- onClick={() => {
858
- close();
859
- onDelete();
860
- }}
861
- >
862
- Delete folder
863
- </button>
864
- </MenuShell>
865
- )}
866
- </div>
867
- );
868
- }
869
-
870
- export function SchemaDrawer({
871
- schemaKey,
872
- onClose,
873
- }: {
874
- schemaKey: string;
875
- onClose: () => void;
876
- }) {
877
- const [state, setState] = useState<
878
- { phase: "loading" } | { phase: "ready"; schema: SchemaPayload } | { phase: "error" }
879
- >({ phase: "loading" });
880
- useEffect(() => {
881
- let cancelled = false;
882
- setState({ phase: "loading" });
883
- fetchSchema(schemaKey).then((schema) => {
884
- if (cancelled) return;
885
- setState(schema ? { phase: "ready", schema } : { phase: "error" });
886
- });
887
- return () => {
888
- cancelled = true;
889
- };
890
- }, [schemaKey]);
891
- useEffect(() => {
892
- const onKey = (e: KeyboardEvent) => {
893
- if (e.key === "Escape") onClose();
894
- };
895
- document.addEventListener("keydown", onKey, true);
896
- return () => document.removeEventListener("keydown", onKey, true);
897
- }, [onClose]);
898
- return (
899
- <div className="shell-schema-backdrop" onMouseDown={onClose}>
900
- <aside
901
- className="shell-schema"
902
- role="dialog"
903
- aria-label="Database schema"
904
- onMouseDown={(e) => e.stopPropagation()}
905
- >
906
- {/* wave17 item 3 (R6) β€” the drawer opens on a mark, not on a sentence. */}
907
- {state.phase === "loading" && (
908
- <div className="shell-schema-empty is-spin">
909
- <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
910
- </div>
911
- )}
912
- {state.phase === "error" && (
913
- <div className="shell-schema-empty">
914
- The schema could not be loaded. Close this panel and try again.
915
- </div>
916
- )}
917
- {state.phase === "ready" && (
918
- <>
919
- <header className="shell-schema-head">
920
- <div>
921
- <div className="shell-schema-title">{state.schema.label}</div>
922
- {state.schema.source && (
923
- <div className="shell-schema-sub">Source Β· {state.schema.source}</div>
924
- )}
925
- </div>
926
- <button
927
- type="button"
928
- className="shell-schema-close"
929
- aria-label="Close schema"
930
- onClick={onClose}
931
- >
932
- Γ—
933
- </button>
934
- </header>
935
- <div className="shell-schema-body">
936
- {state.schema.note && (
937
- <div className="shell-schema-note">{state.schema.note}</div>
938
- )}
939
- {state.schema.fields.length > 0 && (
940
- <>
941
- <div className="shell-schema-kicker">
942
- Fields Β· {state.schema.fields.length}
943
- </div>
944
- <table className="shell-schema-table">
945
- <thead>
946
- <tr>
947
- <th>Field</th>
948
- <th>Type</th>
949
- <th>Source</th>
950
- </tr>
951
- </thead>
952
- <tbody>
953
- {state.schema.fields.map((f) => (
954
- <tr key={f.key}>
955
- <td>
956
- <div className="shell-schema-fname">{f.label}</div>
957
- {f.description && (
958
- <div className="shell-schema-fdesc">{f.description}</div>
959
- )}
960
- {f.options && (
961
- <div className="shell-schema-fdesc">
962
- Choices: {f.options.join(" Β· ")}
963
- </div>
964
- )}
965
- </td>
966
- <td className="shell-schema-type">{f.type}</td>
967
- <td className="shell-schema-type">{f.source}</td>
968
- </tr>
969
- ))}
970
- </tbody>
971
- </table>
972
- </>
973
- )}
974
- {state.schema.measures.length > 0 && (
975
- <>
976
- <div className="shell-schema-kicker">
977
- Semantic measures Β· {state.schema.measures.length}
978
- </div>
979
- <div className="shell-schema-measures">
980
- {state.schema.measures.map((m) => (
981
- <div key={m.key} className="shell-schema-measure">
982
- <span className="shell-schema-fname">{m.label}</span>
983
- <span className="shell-schema-type">{m.type}</span>
984
- </div>
985
- ))}
986
- </div>
987
- </>
988
- )}
989
- </div>
990
- </>
991
- )}
992
- </aside>
993
- </div>
994
- );
995
- }
 
1
+ // ---------------------------------------------------------------------------
2
+ // shell/NavExtras.tsx β€” C-SCHEMA (wave 2026-08-02): the database list's
3
+ // three-dots menu, folder heads, and the schema drawer.
4
+ //
5
+ // Kept out of Shell.tsx so the shell's frame stays readable: Shell owns the
6
+ // STATE (prefs, open folders, which schema is open) and the save round-trip;
7
+ // these components own only their own popover/drawer chrome. Menus are
8
+ // position:fixed against the trigger β€” the rail scrolls and clips, so an
9
+ // absolutely-positioned child could never escape it (the same lesson as the
10
+ // collapsed-rail tooltip).
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { useEffect, useRef, useState } from "react";
14
+ import type { ReactNode } from "react";
15
+ import { fetchSchema } from "./nav";
16
+ import type { NavFolder, SchemaPayload, TableFootprint } from "./nav";
17
+ // ⚠ WAVE 19 R8 / C1 β€” IMPORTED, NEVER REDEFINED, and the contract says so in as
18
+ // many words ("Shapes/tones = the grid's existing vocabulary verbatim (12
19
+ // shapes, 5 tones); B imports, never redefines"). A second copy of this
20
+ // vocabulary in the shell is a whitelist that can drift by one member from the
21
+ // one the host validates against β€” and a tone the server does not recognise
22
+ // degrades to the default, so the user's choice disappears on reload with
23
+ // nothing on screen to say why. These are READ-ONLY imports across the session
24
+ // fence: `customer-grid/**` is another session's tree this wave (HARD RULE 3).
25
+ import { FolderMark } from "../customer-grid/icons";
26
+ import { FOLDER_SHAPE_LABELS, FOLDER_TONE_LABELS } from "../customer-grid/iconShapes";
27
+ import {
28
+ DEFAULT_FOLDER_SHAPE,
29
+ DEFAULT_FOLDER_TONE,
30
+ FOLDER_SHAPES,
31
+ FOLDER_TONES,
32
+ } from "../customer-grid/types";
33
+ import type { FolderIcon } from "../customer-grid/types";
34
+ import "./navExtras.css";
35
+
36
+ /**
37
+ * WAVE 21 item 12 (ruling R11) β€” HORIZONTAL, and it is the same three dots turned
38
+ * 90Β°, not a different mark.
39
+ *
40
+ * The rail and the views list are read as one surface, and they were drawing the
41
+ * "there is a menu here" affordance two different ways: this glyph stacked its
42
+ * circles (cx fixed, cy walking) while `ViewSidebar`'s row buttons have always
43
+ * rendered three MIDDLE DOTs on the baseline. Same promise, two pictures β€” the
44
+ * thing R11 names. Only the axis moves: radius, viewBox and the 14px box in
45
+ * `navExtras.css` are untouched, so the hit target and the optical weight are
46
+ * exactly what shipped.
47
+ */
48
+ function DotsIcon() {
49
+ return (
50
+ <svg viewBox="0 0 16 16" aria-hidden="true" className="shell-dots-icon">
51
+ <circle cx="3.2" cy="8" r="1.35" />
52
+ <circle cx="8" cy="8" r="1.35" />
53
+ <circle cx="12.8" cy="8" r="1.35" />
54
+ </svg>
55
+ );
56
+ }
57
+
58
+ function FolderIcon() {
59
+ return (
60
+ <svg viewBox="0 0 16 16" aria-hidden="true" className="shell-nav-icon">
61
+ <path d="M2.2 4.4c0-.66.54-1.2 1.2-1.2h3l1.4 1.6h4.8c.66 0 1.2.54 1.2 1.2v6c0 .66-.54 1.2-1.2 1.2H3.4c-.66 0-1.2-.54-1.2-1.2z" />
62
+ </svg>
63
+ );
64
+ }
65
+
66
+ /** A fixed-position popover anchored to a trigger rect, closed on outside
67
+ * click or Escape. Small on purpose β€” the shell does not import the grid's
68
+ * Popover, so the two chrome trees stay independently deletable. */
69
+ function useMenu() {
70
+ const [at, setAt] = useState<{ x: number; y: number } | null>(null);
71
+ const ref = useRef<HTMLDivElement>(null);
72
+ useEffect(() => {
73
+ if (!at) return;
74
+ const onDown = (e: MouseEvent) => {
75
+ if (ref.current && !ref.current.contains(e.target as Node)) setAt(null);
76
+ };
77
+ const onKey = (e: KeyboardEvent) => {
78
+ if (e.key === "Escape") setAt(null);
79
+ };
80
+ document.addEventListener("mousedown", onDown, true);
81
+ document.addEventListener("keydown", onKey, true);
82
+ return () => {
83
+ document.removeEventListener("mousedown", onDown, true);
84
+ document.removeEventListener("keydown", onKey, true);
85
+ };
86
+ }, [at]);
87
+ const openFrom = (el: HTMLElement) => {
88
+ const r = el.getBoundingClientRect();
89
+ setAt({ x: Math.round(r.right + 4), y: Math.round(r.top) });
90
+ };
91
+ return { at, ref, openFrom, close: () => setAt(null) };
92
+ }
93
+
94
+ function MenuShell({
95
+ at,
96
+ menuRef,
97
+ wide,
98
+ children,
99
+ }: {
100
+ at: { x: number; y: number };
101
+ menuRef: React.RefObject<HTMLDivElement>;
102
+ /** WAVE 21 item 6 β€” the delete confirm needs prose width; a menu of one-line
103
+ * actions does not. The CLAMP moves with the class, because a wider panel
104
+ * clamped at the narrow width spills off the right edge on exactly the rows
105
+ * furthest from it. */
106
+ wide?: boolean;
107
+ children: ReactNode;
108
+ }) {
109
+ // Clamp to the viewport so a row near the bottom does not spill the menu off it.
110
+ const width = wide ? 320 : 248;
111
+ const style = {
112
+ left: Math.min(at.x, Math.max(8, window.innerWidth - width)),
113
+ top: Math.min(at.y, Math.max(8, window.innerHeight - 260)),
114
+ };
115
+ return (
116
+ <div
117
+ className={"shell-navmenu" + (wide ? " is-wide" : "")}
118
+ style={style}
119
+ ref={menuRef}
120
+ role="menu"
121
+ >
122
+ {children}
123
+ </div>
124
+ );
125
+ }
126
+
127
+ /**
128
+ * WAVE 19 R8 / C1 β€” the 12x5 swatch picker, the grid's own pattern
129
+ * (`ViewSidebar.tsx`'s folder form) rendered inside the nav's popover.
130
+ *
131
+ * TWO ROWS, NOT A GRID OF 60. Shape and tone are independent choices, so the
132
+ * shape row previews in the CURRENT tone and the tone row previews in the
133
+ * CURRENT shape β€” every swatch shows what that one click would actually
134
+ * produce, which is why picking is one glance rather than a search.
135
+ */
136
+ function IconPicker({
137
+ value,
138
+ onPick,
139
+ onClear,
140
+ }: {
141
+ value?: FolderIcon;
142
+ onPick: (icon: FolderIcon) => void;
143
+ /** Absent β‡’ nothing to clear (the row is already on the default mark). */
144
+ onClear?: () => void;
145
+ }) {
146
+ const shape = value?.shape ?? DEFAULT_FOLDER_SHAPE;
147
+ const tone = value?.tone ?? DEFAULT_FOLDER_TONE;
148
+ return (
149
+ <div className="shell-navmenu-pick" role="group" aria-label="Database icon">
150
+ <div className="shell-navmenu-pickrow">
151
+ {FOLDER_SHAPES.map((s) => (
152
+ <button
153
+ key={s}
154
+ type="button"
155
+ className={"shell-navmenu-swatch" + (value && shape === s ? " is-on" : "")}
156
+ aria-pressed={!!value && shape === s}
157
+ aria-label={FOLDER_SHAPE_LABELS[s]}
158
+ title={FOLDER_SHAPE_LABELS[s]}
159
+ onClick={() => onPick({ shape: s, tone })}
160
+ >
161
+ <FolderMark icon={{ shape: s, tone }} size={15} />
162
+ </button>
163
+ ))}
164
+ </div>
165
+ <div className="shell-navmenu-pickrow">
166
+ {FOLDER_TONES.map((t) => (
167
+ <button
168
+ key={t}
169
+ type="button"
170
+ className={"shell-navmenu-swatch" + (value && tone === t ? " is-on" : "")}
171
+ aria-pressed={!!value && tone === t}
172
+ aria-label={FOLDER_TONE_LABELS[t]}
173
+ title={FOLDER_TONE_LABELS[t]}
174
+ onClick={() => onPick({ shape, tone: t })}
175
+ >
176
+ <FolderMark icon={{ shape, tone: t }} size={15} />
177
+ </button>
178
+ ))}
179
+ </div>
180
+ {/* β›” AN EXPLICIT WAY BACK, because this picker deliberately does NOT copy
181
+ `cleanFolderIcon`'s "a fully-default icon is no icon" rule. That rule is
182
+ right for a FOLDER, whose default mark IS the folder shape; a database's
183
+ default mark is the cylinder, so dropping folder+neutral would answer a
184
+ user who picked "Folder" with a picture of a database β€” the silent
185
+ disappearance the tone whitelist's own note warns about. Absent = the
186
+ cylinder, chosen = exactly what was chosen, and clearing is a click that
187
+ says what it does. */}
188
+ {onClear ? (
189
+ <button type="button" className="shell-navmenu-item is-quiet" onClick={onClear}>
190
+ Use the default icon
191
+ </button>
192
+ ) : null}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ export function RowMenu({
198
+ entryLabel,
199
+ canSchema,
200
+ onSchema,
201
+ canRename,
202
+ onRename,
203
+ canIcon,
204
+ icon,
205
+ onIcon,
206
+ onIconClear,
207
+ onShare,
208
+ canDelete,
209
+ onLoadFootprint,
210
+ onDelete,
211
+ }: {
212
+ entryLabel: string;
213
+ canSchema: boolean;
214
+ onSchema: () => void;
215
+ /**
216
+ * WAVE 19 R8 β€” rename is `ut_*` (custom) DATABASES ONLY, and this flag is how
217
+ * the row says so. A built-in label is a compiled registry literal: renaming
218
+ * `customer_data` would mean the nav and every other reader of
219
+ * `core/registry.py` disagreeing about what the module is called. The SERVER
220
+ * refuses a `name` for a non-`ut_` key regardless (C1, fail-closed) β€” this
221
+ * only spares the user a control whose write would bounce.
222
+ */
223
+ canRename?: boolean;
224
+ onRename?: (name: string) => void;
225
+ /** Icons ride ALL databases (R8), unlike rename. */
226
+ canIcon?: boolean;
227
+ icon?: FolderIcon;
228
+ onIcon?: (icon: FolderIcon) => void;
229
+ onIconClear?: () => void;
230
+ /** WAVE 20 item 18 (C-SHARE) β€” open the access editor for THIS database.
231
+ * Absent = the row does not offer sharing (a group head has nothing to share). */
232
+ onShare?: () => void;
233
+ /**
234
+ * ⭐ WAVE 21 item 6 (ruling R3, contract C3, wiring W-5) β€” may this session
235
+ * DELETE this database?
236
+ *
237
+ * β›” REQUIRED, NOT OPTIONAL, and that is the wave-20 lesson written into a type.
238
+ * Four features shipped dead behind 43 green gates because the prop that carried
239
+ * them across an ownership fence was `?`-marked: an unmounted optional prop is
240
+ * `undefined`, which reads as "the feature is off" and is indistinguishable from
241
+ * "the feature was never built". A required prop makes the unmounted case a
242
+ * COMPILE ERROR. The value is the server's (`NavPage.canDelete`), narrower than
243
+ * `manage` on purpose β€” see the note there.
244
+ */
245
+ canDelete: boolean;
246
+ /** What deleting would destroy. Resolves null when the server would not say β€”
247
+ * the face then asks WITHOUT counts rather than inventing zeros. */
248
+ onLoadFootprint: () => Promise<TableFootprint | null>;
249
+ /** Do it. Resolves the server's own words on refusal, which the face shows in
250
+ * place of the confirmation β€” a delete that failed must never look like one
251
+ * that worked. */
252
+ onDelete: () => Promise<{ ok: boolean; error?: string }>;
253
+ }) {
254
+ // Wave 14 C-NAVFOLD (ruling R10): the three-dots kept "View schema" ONLY.
255
+ // Wave 19 (R8/C1) adds Rename and Change icon β€” the first two things a
256
+ // database the USER made should have been able to do.
257
+ const { at, ref, openFrom, close } = useMenu();
258
+ // Which face the popover is showing. Reset on every open, so a menu that was
259
+ // left mid-rename does not reopen into somebody else's half-typed name.
260
+ const [mode, setMode] = useState<"menu" | "rename" | "icon" | "delete">("menu");
261
+ const [name, setName] = useState(entryLabel);
262
+ /**
263
+ * WAVE 21 item 6 β€” the delete face's own state. `footprint: undefined` means
264
+ * "still asking", `null` means "the server would not say" β€” two different
265
+ * things the face words differently, which is why it is not a plain object.
266
+ */
267
+ const [del, setDel] = useState<{
268
+ footprint?: TableFootprint | null;
269
+ busy: boolean;
270
+ error: string;
271
+ }>({ busy: false, error: "" });
272
+ const renameOn = !!canRename && !!onRename;
273
+ const iconOn = !!canIcon && !!onIcon;
274
+ if (!canSchema && !renameOn && !iconOn && !onShare && !canDelete) return null;
275
+ const submitRename = () => {
276
+ const clean = name.trim().replace(/\s+/g, " ");
277
+ if (clean && clean !== entryLabel) onRename?.(clean);
278
+ close();
279
+ };
280
+ /**
281
+ * ⚠ THE FOOTPRINT IS FETCHED WHEN THE FACE OPENS, NOT WHEN THE MENU DOES.
282
+ * Same reasoning as `PermsEditor.prepareCopy` ("the targets are fetched BEFORE
283
+ * the confirm"): the question has to name what is being destroyed, and a
284
+ * confirm written from the rail's own knowledge can only name the table. The
285
+ * cost is paid by the person who clicked Delete, never by everyone who opened
286
+ * a menu.
287
+ */
288
+ const openDelete = () => {
289
+ setDel({ busy: false, error: "" });
290
+ setMode("delete");
291
+ void onLoadFootprint().then((f) => setDel((cur) => ({ ...cur, footprint: f })));
292
+ };
293
+ const runDelete = () => {
294
+ setDel((cur) => ({ ...cur, busy: true, error: "" }));
295
+ void onDelete().then((r) => {
296
+ if (r.ok) {
297
+ close();
298
+ setDel({ busy: false, error: "" });
299
+ return;
300
+ }
301
+ // β›” THE MENU STAYS OPEN ON A REFUSAL. Closing it would leave a rail whose
302
+ // row is still there and no statement anywhere about why β€” which reads as
303
+ // "the click did nothing" and invites a second one.
304
+ setDel((cur) => ({ ...cur, busy: false, error: r.error || "It could not be deleted." }));
305
+ });
306
+ };
307
+ return (
308
+ <>
309
+ <button
310
+ type="button"
311
+ className="shell-dots"
312
+ aria-label={`Options for ${entryLabel}`}
313
+ aria-haspopup="menu"
314
+ onClick={(e) => {
315
+ e.preventDefault();
316
+ e.stopPropagation();
317
+ setMode("menu");
318
+ setName(entryLabel);
319
+ openFrom(e.currentTarget);
320
+ }}
321
+ >
322
+ <DotsIcon />
323
+ </button>
324
+ {at && (
325
+ <MenuShell at={at} menuRef={ref} wide={mode === "delete"}>
326
+ {mode === "delete" && canDelete ? (
327
+ <DeleteFace
328
+ entryLabel={entryLabel}
329
+ state={del}
330
+ onCancel={() => setMode("menu")}
331
+ onConfirm={runDelete}
332
+ />
333
+ ) : mode === "rename" && renameOn ? (
334
+ <form
335
+ className="shell-navmenu-form"
336
+ onSubmit={(e) => {
337
+ e.preventDefault();
338
+ submitRename();
339
+ }}
340
+ >
341
+ <input
342
+ className="shell-navmenu-input"
343
+ autoFocus
344
+ maxLength={60}
345
+ value={name}
346
+ aria-label={`Rename ${entryLabel}`}
347
+ onChange={(e) => setName(e.target.value)}
348
+ onKeyDown={(e) => {
349
+ if (e.key === "Escape") close();
350
+ }}
351
+ />
352
+ <button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
353
+ Save
354
+ </button>
355
+ </form>
356
+ ) : mode === "icon" && iconOn ? (
357
+ /* ⚠ PICKING DOES NOT CLOSE THE MENU, and that is not a convenience.
358
+ Shape and tone are two independent choices: a picker that closed
359
+ on the first click would make "a green bolt" a two-open job, and
360
+ the second open would have to find the row again. Each click
361
+ commits (they are cheap and rare), the rail repaints from the
362
+ server, and the swatch marks follow what was actually stored β€” so
363
+ a refused write shows up as a mark that does not move, beside the
364
+ toast that says why. Clearing DOES close: "use the default" is a
365
+ terminal action, and the control removes itself once taken. */
366
+ <IconPicker
367
+ value={icon}
368
+ onPick={(next) => onIcon?.(next)}
369
+ {...(icon && onIconClear
370
+ ? {
371
+ onClear: () => {
372
+ close();
373
+ onIconClear();
374
+ },
375
+ }
376
+ : {})}
377
+ />
378
+ ) : (
379
+ <>
380
+ {renameOn ? (
381
+ <button
382
+ type="button"
383
+ className="shell-navmenu-item"
384
+ onClick={() => setMode("rename")}
385
+ >
386
+ Rename
387
+ </button>
388
+ ) : null}
389
+ {iconOn ? (
390
+ <button
391
+ type="button"
392
+ className="shell-navmenu-item"
393
+ onClick={() => setMode("icon")}
394
+ >
395
+ Change icon
396
+ </button>
397
+ ) : null}
398
+ {canSchema ? (
399
+ <button
400
+ type="button"
401
+ className="shell-navmenu-item"
402
+ onClick={() => {
403
+ close();
404
+ onSchema();
405
+ }}
406
+ >
407
+ View schema
408
+ </button>
409
+ ) : null}
410
+ {/* WAVE 20 item 18 (R10, C-SHARE) β€” a DATABASE shares with the same two
411
+ roles a view does. Gated on `onShare`, which the Shell passes only
412
+ for a real database row, so a family head or a hand-off link never
413
+ offers to share something that has no id on the server. */}
414
+ {onShare ? (
415
+ <button
416
+ type="button"
417
+ className="shell-navmenu-item"
418
+ onClick={() => {
419
+ close();
420
+ onShare();
421
+ }}
422
+ >
423
+ Share…
424
+ </button>
425
+ ) : null}
426
+ {/* ⭐ WAVE 21 item 6 (R3) β€” LAST, and the only destructive row in this
427
+ menu. Gated on the server's `canDelete` alone: R3 scopes the verb to
428
+ the CREATOR or an admin and refuses it outright for connector-backed
429
+ databases, so a row that may not be deleted does not say so β€” it
430
+ simply has nothing here to click. (An entry that explains why it is
431
+ disabled is the right pattern for a thing you could earn; this one
432
+ you cannot.) */}
433
+ {canDelete ? (
434
+ <button
435
+ type="button"
436
+ className="shell-navmenu-item is-danger"
437
+ onClick={openDelete}
438
+ >
439
+ Delete database…
440
+ </button>
441
+ ) : null}
442
+ </>
443
+ )}
444
+ </MenuShell>
445
+ )}
446
+ </>
447
+ );
448
+ }
449
+
450
+ /** One footprint line: "12 records", "1 view". Singular/plural is the whole of
451
+ * the formatting, because a count with the wrong noun reads as a bug in the
452
+ * count. */
453
+ function countLine(n: number, one: string, many: string): string {
454
+ return `${n.toLocaleString()} ${n === 1 ? one : many}`;
455
+ }
456
+
457
+ /**
458
+ * ⭐ WAVE 21 item 6 (R3, contract C3) β€” the confirm face: what deleting this
459
+ * database destroys, stated before the button that does it.
460
+ *
461
+ * Shaped after `PermsEditor`'s confirm (`.set-confirm`, the strongest one this
462
+ * codebase has) and not after a `window.confirm`: the browser dialog cannot say
463
+ * anything specific, and a destructive question whose answer depends on facts
464
+ * the asker has not been given is not really being asked.
465
+ *
466
+ * THREE STATES, worded differently on purpose:
467
+ * Β· footprint undefined β€” still asking the server. The button is disabled;
468
+ * confirming against numbers that have not arrived is confirming against
469
+ * nothing.
470
+ * Β· footprint null β€” the server would not say. The question is put
471
+ * WITHOUT counts and says so. Inventing zeros here would be a specific,
472
+ * checkable claim about the user's data that nobody verified
473
+ * ([[no-unverifiable-aggregates]]).
474
+ * Β· footprint present β€” every family it names, listed.
475
+ *
476
+ * ⚠ The automations line is the one that is NOT about destruction: C3 pauses
477
+ * bound automations and stamps them "target deleted" rather than removing them,
478
+ * so it names them (they will still be there afterwards, switched off) instead
479
+ * of counting them away.
480
+ */
481
+ function DeleteFace({
482
+ entryLabel,
483
+ state,
484
+ onCancel,
485
+ onConfirm,
486
+ }: {
487
+ entryLabel: string;
488
+ state: { footprint?: TableFootprint | null; busy: boolean; error: string };
489
+ onCancel: () => void;
490
+ onConfirm: () => void;
491
+ }) {
492
+ const f = state.footprint;
493
+ const asking = f === undefined;
494
+ return (
495
+ <div className="shell-navconfirm" role="alertdialog" aria-modal="true"
496
+ aria-label={`Delete ${entryLabel}`}>
497
+ <h4 className="shell-navconfirm-h">Delete β€œ{entryLabel}”?</h4>
498
+ {asking ? (
499
+ <p className="shell-navmenu-note">Checking what this database holds…</p>
500
+ ) : f === null ? (
501
+ <p className="shell-navmenu-note">
502
+ The server did not say what this database holds. Deleting it removes its records,
503
+ columns, views, comments, documents and sharing β€” permanently.
504
+ </p>
505
+ ) : (
506
+ <>
507
+ <p className="shell-navmenu-note">This cannot be undone. It permanently removes:</p>
508
+ <ul className="shell-navconfirm-list">
509
+ <li>{countLine(f.rows, "record", "records")}</li>
510
+ <li>{countLine(f.fields, "column", "columns")}</li>
511
+ <li>{countLine(f.views, "view", "views")}</li>
512
+ <li>
513
+ {countLine(f.sharedUsers, "person it is shared with",
514
+ "people it is shared with")}
515
+ </li>
516
+ </ul>
517
+ {f.automations.length ? (
518
+ <p className="shell-navmenu-note">
519
+ {/* The VERB agrees too. "1 automation write here" was on screen before the
520
+ first screenshot was read β€” the kind of thing every assertion passes. */}
521
+ {countLine(f.automations.length, "automation", "automations")}{" "}
522
+ {f.automations.length === 1 ? "writes" : "write"} here (
523
+ {f.automations.map((a) => a.name).join(", ")}) β€” {f.automations.length === 1
524
+ ? "it is"
525
+ : "they are"}{" "}
526
+ switched off, not deleted.
527
+ </p>
528
+ ) : null}
529
+ </>
530
+ )}
531
+ {state.error ? <p className="shell-navconfirm-err">{state.error}</p> : null}
532
+ <div className="shell-navconfirm-actions">
533
+ <button
534
+ type="button"
535
+ className="shell-navconfirm-go"
536
+ disabled={asking || state.busy}
537
+ onClick={onConfirm}
538
+ >
539
+ {state.busy ? "Deleting…" : "Delete"}
540
+ </button>
541
+ <button
542
+ type="button"
543
+ className="shell-navmenu-item is-quiet"
544
+ disabled={state.busy}
545
+ onClick={onCancel}
546
+ >
547
+ Keep it
548
+ </button>
549
+ </div>
550
+ </div>
551
+ );
552
+ }
553
+
554
+ function PlusIcon() {
555
+ return (
556
+ <svg viewBox="0 0 16 16" aria-hidden="true" className="shell-newthing-plus">
557
+ <path d="M8 3.4v9.2M3.4 8h9.2" />
558
+ </svg>
559
+ );
560
+ }
561
+
562
+ /**
563
+ * Wave 14 C-NAVFOLD gave the list's bottom a quiet "+ New folder" (the Views-rail
564
+ * precedent). WAVE 19 R11 made it "+ Create new…" with Folder | Database behind it.
565
+ *
566
+ * ⭐ WAVE 24 items 1 + 15a (ruling R9) β€” IT LEAVES THE RAIL AND BECOMES THE DATABASE
567
+ * FLYOUT'S FOOTER, and it now carries the creatable things: New database Β·
568
+ * From a template Β· New folder. The flyout's three standalone `.shell-dbfly-make`
569
+ * buttons are deleted; this one control replaces them.
570
+ *
571
+ * ⭐ WAVE 25 item 5a (ruling R8) β€” "AUTOMATED DATABASE" IS DELETED FROM THIS MENU,
572
+ * so it carries THREE rows rather than four. R8: creating a database is one act and
573
+ * pointing an automation at it is another, so the automation door lives on the
574
+ * automation surface (its rail button and its empty state) and this menu stops
575
+ * offering a database that is really an automation.
576
+ * ⚠ `From a template` and `New folder` are UNTOUCHED β€” neither was ever an automated
577
+ * database, and R9 keeps the template door working here (see the Home card's own note
578
+ * on why exactly one of the two template doors is boarded).
579
+ *
580
+ * ⭐ AND IT IS ALSO ITEM 1's FIX, which is worth stating because it does not look
581
+ * like a typography change. MEASURED on staging v12: the six rail rows all render
582
+ * 13.8125px / w500 / Inter / rgb(32,36,51) β€” identical, `<a>` and `<button>` alike.
583
+ * The ONE row in that band that differed was this one: `.shell-newfolder` declares
584
+ * `--lp-fs-2xs` (12.75px) with no `font-weight` (so 400, inherited) and
585
+ * `--lp-muted`. Taking it out of the rail is what makes the band read as one set;
586
+ * nothing about the other six needed changing, and changing them would have been a
587
+ * fix aimed at the wrong row.
588
+ *
589
+ * ⚠ THE `canFolder` GATE SURVIVES THE MOVE, and it has to. Wave 19's note: folders
590
+ * over an empty list are an empty gesture, but a freshly provisioned tenant must
591
+ * still be able to create a DATABASE. So with no entries the menu simply omits its
592
+ * folder row rather than the control collapsing to a single-purpose button β€” inside
593
+ * a panel there is room for a menu whatever the tenant has, which is the one thing
594
+ * the 236px rail could not say.
595
+ */
596
+ export function CreateNewRow({
597
+ collapsed,
598
+ onExpand,
599
+ onCreateFolder,
600
+ onNewDatabase,
601
+ onFromTemplate,
602
+ canFolder,
603
+ }: {
604
+ collapsed: boolean;
605
+ /** Collapsed, the row's one honest behaviour is "open the rail first". */
606
+ onExpand: () => void;
607
+ onCreateFolder: (name: string) => void;
608
+ onNewDatabase: () => void;
609
+ /** R9's second row β€” the template picker, over a database you already have. */
610
+ onFromTemplate: () => void;
611
+ /* β›” `onAutomated` LEFT WITH THE ROW IT OPENED (wave 25 item 5a, R8). */
612
+ /** Folders over an empty list are an empty gesture β€” the row is omitted, not the menu. */
613
+ canFolder: boolean;
614
+ }) {
615
+ const { at, ref, openFrom, close } = useMenu();
616
+ const [naming, setNaming] = useState(false);
617
+ const [name, setName] = useState("");
618
+ const submit = () => {
619
+ const clean = name.trim().replace(/\s+/g, " ");
620
+ if (!clean) return;
621
+ onCreateFolder(clean);
622
+ setName("");
623
+ setNaming(false);
624
+ };
625
+ // β›” The 56px strip cannot hold a popover β€” the same reason the account row
626
+ // expands instead of opening its menu. Preserved from the button this row
627
+ // replaced, which did exactly this before opening its dialog.
628
+ //
629
+ // ⚠ WAVE 24: this branch is now UNREACHABLE FROM THE RAIL (the row is not there
630
+ // any more) but it is NOT dead β€” the flyout can be opened with the rail folded,
631
+ // and `collapsed` is still passed. Kept rather than deleted, because the panel's
632
+ // own note says the folded rail is exactly the state a user who opened it is most
633
+ // likely to be in.
634
+ if (collapsed) {
635
+ return (
636
+ <button
637
+ type="button"
638
+ className="shell-newfolder is-collapsed"
639
+ aria-label="Create new"
640
+ title="Create new"
641
+ onClick={onExpand}
642
+ >
643
+ <PlusIcon />
644
+ </button>
645
+ );
646
+ }
647
+ if (!naming) {
648
+ return (
649
+ <>
650
+ {/* THE LABEL NO LONGER FOLLOWS `canFolder`. In the rail it had to: with one
651
+ real branch, "+ Create new…" beside an `aria-haspopup` was a control
652
+ promising a choice it was not about to offer. In the footer there are
653
+ always at least two (database / template), so the menu is real whatever
654
+ the tenant has and the label can simply be true.
655
+ ⚠ TWO, NOT THREE, since R8 took the automated row (wave 25) β€” still a
656
+ genuine choice, which is the property the label depends on. If a future
657
+ ruling takes the template row as well, this reasoning inverts and the
658
+ control has to become a plain button again. */}
659
+ <button
660
+ type="button"
661
+ className="shell-newfolder"
662
+ aria-haspopup="menu"
663
+ onClick={(e) => openFrom(e.currentTarget)}
664
+ >
665
+ + Create new…
666
+ </button>
667
+ {at && (
668
+ <MenuShell at={at} menuRef={ref}>
669
+ <button
670
+ type="button"
671
+ className="shell-navmenu-item"
672
+ onClick={() => {
673
+ close();
674
+ onNewDatabase();
675
+ }}
676
+ >
677
+ New database
678
+ </button>
679
+ <button
680
+ type="button"
681
+ className="shell-navmenu-item"
682
+ onClick={() => {
683
+ close();
684
+ onFromTemplate();
685
+ }}
686
+ >
687
+ From a template
688
+ </button>
689
+ {canFolder ? (
690
+ <button
691
+ type="button"
692
+ className="shell-navmenu-item"
693
+ onClick={() => {
694
+ close();
695
+ setNaming(true);
696
+ }}
697
+ >
698
+ New folder
699
+ </button>
700
+ ) : null}
701
+ </MenuShell>
702
+ )}
703
+ </>
704
+ );
705
+ }
706
+ return (
707
+ <form
708
+ className="shell-navmenu-form shell-newfolder-form"
709
+ onSubmit={(e) => {
710
+ e.preventDefault();
711
+ submit();
712
+ }}
713
+ >
714
+ <input
715
+ className="shell-navmenu-input"
716
+ autoFocus
717
+ maxLength={40}
718
+ placeholder="Folder name"
719
+ value={name}
720
+ onChange={(e) => setName(e.target.value)}
721
+ onKeyDown={(e) => {
722
+ if (e.key === "Escape") {
723
+ setName("");
724
+ setNaming(false);
725
+ }
726
+ }}
727
+ />
728
+ <button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
729
+ Add
730
+ </button>
731
+ </form>
732
+ );
733
+ }
734
+
735
+ export function FolderHead({
736
+ folder,
737
+ count,
738
+ open,
739
+ collapsed,
740
+ onToggle,
741
+ onRename,
742
+ onDelete,
743
+ isDrop,
744
+ dropProps,
745
+ }: {
746
+ folder: NavFolder;
747
+ count: number;
748
+ /* β›” WAVE 35 Β· T07 (owner item 9) β€” the `important?: NavImportant` prop is DELETED. It carried
749
+ wave-34 R1's per-folder SUM of mark-important numbers; item 9 retires that badge, and `tsc`'s
750
+ unused-locals check is what named this prop the moment its only reader went. */
751
+ open: boolean;
752
+ collapsed: boolean;
753
+ onToggle: () => void;
754
+ onRename: (name: string) => void;
755
+ onDelete: () => void;
756
+ /** Wave 14 C-NAVFOLD β€” a database row is being dragged over this folder. */
757
+ isDrop?: boolean;
758
+ /** dragover/drop handlers, owned by the Shell (it holds the placement writer). */
759
+ dropProps?: React.HTMLAttributes<HTMLDivElement>;
760
+ }) {
761
+ const { at, ref, openFrom, close } = useMenu();
762
+ const [renaming, setRenaming] = useState(false);
763
+ const [name, setName] = useState(folder.name);
764
+ const submit = () => {
765
+ const clean = name.trim().replace(/\s+/g, " ");
766
+ if (clean && clean !== folder.name) onRename(clean);
767
+ setRenaming(false);
768
+ close();
769
+ };
770
+ if (collapsed) {
771
+ // The folded rail has no room for folder chrome; members render flat and the
772
+ // folder simply waits for the rail to open again.
773
+ return null;
774
+ }
775
+ return (
776
+ <div
777
+ className={"shell-nav-folder" + (isDrop ? " is-drop" : "")}
778
+ {...(dropProps ?? {})}
779
+ >
780
+ {/* R3: no chevron β€” the Views-rail folder look. Open state reads from the members
781
+ below it (and aria-expanded says it aloud); the mark + bold label ARE the row. */}
782
+ <button
783
+ type="button"
784
+ className="shell-nav-folderbtn"
785
+ aria-expanded={open}
786
+ onClick={onToggle}
787
+ >
788
+ <FolderIcon />
789
+ <span className="shell-nav-label">{folder.name}</span>
790
+ <span className="shell-nav-foldercount">{count}</span>
791
+ {/* β›”β›” WAVE 35 Β· T07 (owner item 9, R4) β€” THE IMPORTANT SUM IS GONE FROM HERE.
792
+ Owner: remove the count *"anywhere else but the View itself"*. Wave 34's R1/C1 put a
793
+ sum of marked RECORDS beside this folder row; item 9 is later and retires it. The star
794
+ count now reaches the user through Home and the Starred module instead (W35-T16/T17).
795
+ ⚠ `count` STAYS, and the distinction is the whole reason this note is here rather than
796
+ the row simply being deleted: `count` is how many BLOCKS the folder holds and has meant
797
+ that since wave 14 β€” it is not a star count and owner item 9 does not name it. Deleting
798
+ it would retire an unrelated wave-14 feature under cover of this ruling
799
+ (PRD amendment A2). */}
800
+ </button>
801
+ <button
802
+ type="button"
803
+ className="shell-dots"
804
+ aria-label={`Options for folder ${folder.name}`}
805
+ aria-haspopup="menu"
806
+ onClick={(e) => {
807
+ e.stopPropagation();
808
+ setName(folder.name);
809
+ setRenaming(false);
810
+ openFrom(e.currentTarget);
811
+ }}
812
+ >
813
+ <DotsIcon />
814
+ </button>
815
+ {at && (
816
+ <MenuShell at={at} menuRef={ref}>
817
+ {renaming ? (
818
+ <form
819
+ className="shell-navmenu-form"
820
+ onSubmit={(e) => {
821
+ e.preventDefault();
822
+ submit();
823
+ }}
824
+ >
825
+ <input
826
+ className="shell-navmenu-input"
827
+ autoFocus
828
+ maxLength={40}
829
+ value={name}
830
+ onChange={(e) => setName(e.target.value)}
831
+ />
832
+ <button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
833
+ Save
834
+ </button>
835
+ </form>
836
+ ) : (
837
+ <button
838
+ type="button"
839
+ className="shell-navmenu-item"
840
+ onClick={() => setRenaming(true)}
841
+ >
842
+ Rename folder
843
+ </button>
844
+ )}
845
+ <button
846
+ type="button"
847
+ className="shell-navmenu-item is-danger"
848
+ onClick={() => {
849
+ close();
850
+ onDelete();
851
+ }}
852
+ >
853
+ Delete folder
854
+ </button>
855
+ </MenuShell>
856
+ )}
857
+ </div>
858
+ );
859
+ }
860
+
861
+ export function SchemaDrawer({
862
+ schemaKey,
863
+ onClose,
864
+ }: {
865
+ schemaKey: string;
866
+ onClose: () => void;
867
+ }) {
868
+ const [state, setState] = useState<
869
+ { phase: "loading" } | { phase: "ready"; schema: SchemaPayload } | { phase: "error" }
870
+ >({ phase: "loading" });
871
+ useEffect(() => {
872
+ let cancelled = false;
873
+ setState({ phase: "loading" });
874
+ fetchSchema(schemaKey).then((schema) => {
875
+ if (cancelled) return;
876
+ setState(schema ? { phase: "ready", schema } : { phase: "error" });
877
+ });
878
+ return () => {
879
+ cancelled = true;
880
+ };
881
+ }, [schemaKey]);
882
+ useEffect(() => {
883
+ const onKey = (e: KeyboardEvent) => {
884
+ if (e.key === "Escape") onClose();
885
+ };
886
+ document.addEventListener("keydown", onKey, true);
887
+ return () => document.removeEventListener("keydown", onKey, true);
888
+ }, [onClose]);
889
+ return (
890
+ <div className="shell-schema-backdrop" onMouseDown={onClose}>
891
+ <aside
892
+ className="shell-schema"
893
+ role="dialog"
894
+ aria-label="Database schema"
895
+ onMouseDown={(e) => e.stopPropagation()}
896
+ >
897
+ {/* wave17 item 3 (R6) β€” the drawer opens on a mark, not on a sentence. */}
898
+ {state.phase === "loading" && (
899
+ <div className="shell-schema-empty is-spin">
900
+ <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
901
+ </div>
902
+ )}
903
+ {state.phase === "error" && (
904
+ <div className="shell-schema-empty">
905
+ The schema could not be loaded. Close this panel and try again.
906
+ </div>
907
+ )}
908
+ {state.phase === "ready" && (
909
+ <>
910
+ <header className="shell-schema-head">
911
+ <div>
912
+ <div className="shell-schema-title">{state.schema.label}</div>
913
+ {state.schema.source && (
914
+ <div className="shell-schema-sub">Source Β· {state.schema.source}</div>
915
+ )}
916
+ </div>
917
+ <button
918
+ type="button"
919
+ className="shell-schema-close"
920
+ aria-label="Close schema"
921
+ onClick={onClose}
922
+ >
923
+ Γ—
924
+ </button>
925
+ </header>
926
+ <div className="shell-schema-body">
927
+ {state.schema.note && (
928
+ <div className="shell-schema-note">{state.schema.note}</div>
929
+ )}
930
+ {state.schema.fields.length > 0 && (
931
+ <>
932
+ <div className="shell-schema-kicker">
933
+ Fields Β· {state.schema.fields.length}
934
+ </div>
935
+ <table className="shell-schema-table">
936
+ <thead>
937
+ <tr>
938
+ <th>Field</th>
939
+ <th>Type</th>
940
+ <th>Source</th>
941
+ </tr>
942
+ </thead>
943
+ <tbody>
944
+ {state.schema.fields.map((f) => (
945
+ <tr key={f.key}>
946
+ <td>
947
+ <div className="shell-schema-fname">{f.label}</div>
948
+ {f.description && (
949
+ <div className="shell-schema-fdesc">{f.description}</div>
950
+ )}
951
+ {f.options && (
952
+ <div className="shell-schema-fdesc">
953
+ Choices: {f.options.join(" Β· ")}
954
+ </div>
955
+ )}
956
+ </td>
957
+ <td className="shell-schema-type">{f.type}</td>
958
+ <td className="shell-schema-type">{f.source}</td>
959
+ </tr>
960
+ ))}
961
+ </tbody>
962
+ </table>
963
+ </>
964
+ )}
965
+ {state.schema.measures.length > 0 && (
966
+ <>
967
+ <div className="shell-schema-kicker">
968
+ Semantic measures Β· {state.schema.measures.length}
969
+ </div>
970
+ <div className="shell-schema-measures">
971
+ {state.schema.measures.map((m) => (
972
+ <div key={m.key} className="shell-schema-measure">
973
+ <span className="shell-schema-fname">{m.label}</span>
974
+ <span className="shell-schema-type">{m.type}</span>
975
+ </div>
976
+ ))}
977
+ </div>
978
+ </>
979
+ )}
980
+ </div>
981
+ </>
982
+ )}
983
+ </aside>
984
+ </div>
985
+ );
986
+ }
 
 
 
 
 
 
 
 
 
web/src/shell/Shell.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/shell/nav.ts CHANGED
@@ -1148,12 +1148,50 @@ export const QUERY_ROUTE = "query";
1148
  * layout-jump report, which is what that ticket was.
1149
  */
1150
  export const ASSISTANT_ROUTE = "assistant";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1151
  export const CHROME_ROUTES: ReadonlySet<string> = new Set([
1152
  HOME_ROUTE,
1153
  CONNECTORS_ROUTE,
1154
  INBOX_ROUTE,
1155
  QUERY_ROUTE,
1156
  ASSISTANT_ROUTE,
 
 
 
 
1157
  ]);
1158
 
1159
  /**
 
1148
  * layout-jump report, which is what that ticket was.
1149
  */
1150
  export const ASSISTANT_ROUTE = "assistant";
1151
+
1152
+ /**
1153
+ * ⭐⭐ WAVE 35 Β· T06 (owner item 13, contract C6) β€” the three ACCOUNT routes. Owner: *"add below
1154
+ * settings the following button: Feedback …, Usage credits …, and a Subscription module …
1155
+ * Specialized for the Royal Imports tenant, do not show it."*
1156
+ *
1157
+ * β›” THEY MUST JOIN `CHROME_ROUTES` OR THEY RENDER NOTHING BEHIND A 200. Shell's oldest law denies
1158
+ * an undeclared SURFACE; these are chrome β€” they need no grant because they show the caller its own
1159
+ * account, never a tenant's data.
1160
+ * ⚠ `SUBSCRIPTION_ROUTE` is chrome for EVERY tenant here and refused for Royal Imports at the
1161
+ * DISPATCH (C6). Excluding it from this set instead would deny it to everyone, which is a different
1162
+ * bug wearing the same screen.
1163
+ */
1164
+ /**
1165
+ * ⭐⭐ WAVE 35 Β· T09 (owner item 8, R4) β€” Starred. Chrome, for the same reason Home is: the page
1166
+ * lists only objects the caller's own nav already granted, so it renders nothing the server did not
1167
+ * already send. A grant wall here would be a second opinion about a set the server already decided.
1168
+ */
1169
+ export const STARRED_ROUTE = "starred";
1170
+
1171
+ export const FEEDBACK_ROUTE = "feedback";
1172
+ export const USAGE_ROUTE = "usage";
1173
+ export const SUBSCRIPTION_ROUTE = "subscription";
1174
+
1175
+ /**
1176
+ * β›” WAVE 35 Β· T06 β€” the tenant that does NOT get a subscription page. Owner item 13, verbatim:
1177
+ * *"Specialized for the Royal Imports tenant, do not show it."* Tenant #0 is the owner's own
1178
+ * company and is not a paying customer of the platform.
1179
+ * ⚠ ONE constant, read by BOTH halves of C6 (the menu row and the route), because "hidden in the
1180
+ * menu while the route still answers" is not hidden β€” and two copies of a slug is how those two
1181
+ * halves drift apart.
1182
+ */
1183
+ export const NO_SUBSCRIPTION_TENANT = "royal-imports";
1184
+
1185
  export const CHROME_ROUTES: ReadonlySet<string> = new Set([
1186
  HOME_ROUTE,
1187
  CONNECTORS_ROUTE,
1188
  INBOX_ROUTE,
1189
  QUERY_ROUTE,
1190
  ASSISTANT_ROUTE,
1191
+ STARRED_ROUTE,
1192
+ FEEDBACK_ROUTE,
1193
+ USAGE_ROUTE,
1194
+ SUBSCRIPTION_ROUTE,
1195
  ]);
1196
 
1197
  /**
web/src/shell/navExtras.css CHANGED
@@ -1,704 +1,676 @@
1
- /* shell/navExtras.css β€” C-SCHEMA (2026-08-02): nav folders, three-dots, schema drawer.
2
- New classes only; the shell's own rail classes stay in index.css untouched. */
3
-
4
- .shell-nav-row {
5
- position: relative;
6
- display: flex;
7
- align-items: stretch;
8
- min-width: 0;
9
- /* wave17 SHELL (item 12) β€” the row is the painted surface now, so it needs
10
- the shape the `<a>` used to draw. `--lp-r-sm` and not the Views rail's
11
- `--lp-r-md`: the tint must land exactly where the item's hover pill lands
12
- today, and every other pill in this rail is 4px. Item 12 asks the highlight
13
- to reach the β‹―; it does not ask the nav to change shape. */
14
- border-radius: var(--lp-r-sm);
15
- }
16
-
17
- /* The ROW carries hover and current-state, exactly as `.cg-view-row` does in
18
- the Views rail β€” which is the whole of item 12. Hovering the three-dots now
19
- lights the row it belongs to instead of leaving the link beside it cold. */
20
- .shell-nav-row:hover {
21
- background: var(--lp-surface-2);
22
- }
23
-
24
- .shell-nav-row.is-active {
25
- background: var(--lp-blue-tint);
26
- }
27
-
28
- .shell-nav-row > .shell-nav-item {
29
- flex: 1 1 auto;
30
- min-width: 0;
31
- }
32
-
33
- /* ⚠ LOAD-BEARING, not tidiness. `.shell-nav-item:hover` still paints its own
34
- grey β€” it must, because the AI-assistant row is a bare `.shell-nav-item`
35
- with no row around it. Left alone inside a row it would paint that grey
36
- OVER the blue of the current database the moment the pointer crossed the
37
- label: the link half would go grey while the β‹― half stayed blue. The child
38
- selector outranks it (0,3,0 over 0,2,0), so this holds no matter which
39
- stylesheet the bundler emits first β€” the nav has lost a rule to source
40
- order before ([[loopable-nav-logo-toggle]]) and specificity is the fix that
41
- does not depend on being lucky. */
42
- .shell-nav-row > .shell-nav-item:hover {
43
- background: transparent;
44
- }
45
-
46
- .shell-nav-item.is-rail-skeleton {
47
- cursor: default;
48
- pointer-events: none;
49
- }
50
-
51
- /* ⭐⭐ W33-T75 (owner item 1) β€” THE RAIL'S ONE IN-FLIGHT STATE.
52
- β›” THE POINT IS THE `:not()`, NOT THE BARS. While `/nav` is in flight EVERY real row is
53
- hidden, so the rail cannot show five finished rows beside two loading ones β€” which is what
54
- `reference/ERROR 6.png` is a picture of, and what "the navigation loads separately" has meant
55
- in all four reports. They are hidden rather than unmounted because the database flyout portal,
56
- the tooltips and the account menu hang off this subtree (see Shell.tsx's note).
57
- ⚠ Geometry is copied from `.shell-nav-item` in index.css and must stay copied: 34 px min-height,
58
- 8 px gap, 7/8 padding, a 16 px mark. A skeleton row of a different height is the layout jump
59
- this whole ticket is about, one step earlier. */
60
- .shell-nav.is-rail-loading > *:not(.shell-rail-skeleton) {
61
- display: none;
62
- }
63
-
64
- /* ⭐ WAVE 34 R15 β€” "then a subtle line separation". SUBTLE is the whole specification, so this
65
- is a hairline at the rail's own border token and nothing else: no margin collapse games, no
66
- inset, no second colour. It groups Home+Inbox / Assistant+Agents / Database+Connectors.
67
- ⚠ It is a direct child of `.shell-nav`, which is what makes the `is-rail-loading` rule above
68
- hide it with the rows. Do not wrap the groups in `<div>`s to get the same look: that would put
69
- the rows one level deeper and the `> *` in that selector would stop reaching them, silently
70
- bringing back the two-row-states defect the whole rule exists to prevent. */
71
- .shell-nav-sep {
72
- height: 1px;
73
- margin: 6px 8px;
74
- background: var(--lp-line);
75
- }
76
-
77
- .shell-rail-skeleton {
78
- display: flex;
79
- flex-direction: column;
80
- }
81
-
82
- .shell-rail-skeleton-row {
83
- display: flex;
84
- align-items: center;
85
- gap: 8px;
86
- min-height: 34px;
87
- padding: 7px 8px;
88
- box-sizing: border-box;
89
- }
90
-
91
- .shell-rail-skeleton-mark,
92
- .shell-rail-skeleton-bar {
93
- /* One flat tone, no shimmer: an animated sweep is a second thing moving in a rail whose whole
94
- complaint is movement, and `prefers-reduced-motion` would then need a third state. */
95
- background: var(--lp-surface-2);
96
- border-radius: var(--lp-r-sm);
97
- }
98
-
99
- .shell-rail-skeleton-mark {
100
- flex: 0 0 auto;
101
- width: 16px;
102
- height: 16px;
103
- }
104
-
105
- .shell-rail-skeleton-bar {
106
- flex: 0 0 auto;
107
- width: 84px;
108
- height: 9px;
109
- border-radius: 4px;
110
- }
111
-
112
- /* Collapsed, the labels are gone from the real rail, so the skeleton drops its bars too β€”
113
- otherwise the fold animation runs against a width the settled rail never has. */
114
- .shell-side.is-collapsed .shell-rail-skeleton-bar {
115
- display: none;
116
- }
117
-
118
- .shell-nav-row > .shell-dots,
119
- .shell-nav-folder > .shell-dots {
120
- flex: 0 0 auto;
121
- width: 24px;
122
- border: 0;
123
- padding: 0;
124
- margin: 2px 2px 2px 0;
125
- border-radius: 6px;
126
- background: transparent;
127
- color: var(--lp-muted);
128
- cursor: pointer;
129
- opacity: 0;
130
- display: grid;
131
- place-items: center;
132
- }
133
-
134
- .shell-nav-row:hover > .shell-dots,
135
- .shell-nav-row:focus-within > .shell-dots,
136
- .shell-nav-folder:hover > .shell-dots,
137
- .shell-nav-folder:focus-within > .shell-dots {
138
- opacity: 1;
139
- }
140
-
141
- .shell-nav-row > .shell-dots:hover,
142
- .shell-nav-folder > .shell-dots:hover {
143
- background: var(--lp-wash);
144
- color: var(--lp-ink);
145
- }
146
-
147
- .shell-dots-icon {
148
- width: 14px;
149
- height: 14px;
150
- fill: currentColor;
151
- }
152
-
153
- .shell-nav-row.is-foldered > .shell-nav-item {
154
- padding-left: 30px;
155
- }
156
-
157
- /* ── folder heads ─────────────────────────────────────────────────────────── */
158
-
159
- .shell-nav-folder {
160
- display: flex;
161
- align-items: stretch;
162
- min-width: 0;
163
- }
164
-
165
- .shell-nav-folderbtn {
166
- flex: 1 1 auto;
167
- min-width: 0;
168
- display: flex;
169
- align-items: center;
170
- gap: 7px;
171
- border: 0;
172
- background: transparent;
173
- padding: 6px 8px;
174
- border-radius: 7px;
175
- color: var(--lp-ink);
176
- font: inherit;
177
- font-size: var(--lp-fs-xs);
178
- /* Wave 14 R3 β€” the Views-rail folder look: BOLD label, no chevron. */
179
- font-weight: 600;
180
- cursor: pointer;
181
- text-align: left;
182
- }
183
-
184
- .shell-nav-folderbtn:hover {
185
- background: var(--lp-wash);
186
- }
187
-
188
- .shell-nav-foldercount {
189
- margin-left: auto;
190
- /* Wave 14 item 7 β€” the count wears the LABEL's size, muted; nothing "all over the
191
- place with regards to symmetry".
192
- ⚠ wave17 SHELL (item 11b) SUPERSEDES THE SIZE HALF. Wave 14 matched the count
193
- to the label so the row would not look ragged; at the label's own size, next
194
- to a 600-weight name, it stopped reading as an annotation and started reading
195
- as a second piece of the title. Smaller is what makes it recede.
196
- The COLOUR TOKEN IS UNCHANGED on purpose: `--lp-muted` already IS the grey,
197
- and the same owner item asks the Views rail's `cg-fold-count` for the same
198
- treatment. A lighter grey invented here would need a literal that has nowhere
199
- legal to live, and would put the two rails' counts a shade apart β€” which is
200
- the exact symmetry complaint wave 14 was answering. Shrinking the type does
201
- the lightening. */
202
- font-size: var(--lp-fs-3xs);
203
- font-weight: 400;
204
- color: var(--lp-muted);
205
- /* Counts line up column-wise as folders open and close, in both rails. */
206
- font-variant-numeric: tabular-nums;
207
- }
208
-
209
- /* ── ⭐⭐ WAVE 34 R1 / contract C1: the mark-important number, in the database list ──────────
210
- Owner, 2026-08-16: *"the mark important number should be moved into the database navigation,
211
- so when we click database, it shows the list of the databases, each showing their number"* and
212
- *"we don't need the important word, just show the number, make it smaller"*.
213
-
214
- Drawn by TWO callers: a database row (`Shell.tsx`'s flyout) and a COLLAPSED folder head
215
- (`FolderHead` above), both through `nav.ts::importantBadge` so the "is there a number here"
216
- rule is written once.
217
-
218
- ⚠ `--lp-fs-4xs` (0.625rem = 10px against the 17px root), NOT `--lp-fs-3xs` (11.69px) which the
219
- sibling `.shell-nav-foldercount` uses. Two reasons and the first is the ruling: R1 says SMALLER,
220
- and a badge at the same size as the count already beside it is not smaller, it is a second copy.
221
- The second is arithmetic: C1 asks for <=11px and `--lp-fs-3xs` computes to 11.69px, which is
222
- over. The font gate enforces token-or-nothing, never a computed ceiling, so nothing in code
223
- catches picking the wrong token; it is checked here instead.
224
-
225
- ⚠ `margin-left: auto` ON THE ROW, RESET INSIDE A FOLDER HEAD. A database row needs the number
226
- at the right edge so a list of them reads as a column; a folder head already gives `auto` to
227
- `.shell-nav-foldercount`, and a SECOND `auto` there splits the free space between the two
228
- numbers and pushes them apart with a gap in the middle. Hence the adjacency rule below. */
229
- .shell-nav-count {
230
- flex: none;
231
- margin-left: auto;
232
- padding-left: 6px;
233
- font-size: var(--lp-fs-4xs);
234
- font-weight: 400;
235
- color: var(--lp-muted);
236
- font-variant-numeric: tabular-nums;
237
- /* A row is a flex line with an ellipsised label; the number must never be the thing that
238
- shrinks, or a "13" becomes a "1" with no way to tell. */
239
- white-space: nowrap;
240
- }
241
- /* Inside a folder head the number follows the member count, which already holds the right edge. */
242
- .shell-nav-foldercount + .shell-nav-count {
243
- margin-left: 0;
244
- }
245
-
246
- /* ── wave 14 C-NAVFOLD: drag placement (items 4/6) ───────────────────────── */
247
-
248
- .shell-nav-row.is-dragging {
249
- opacity: 0.45;
250
- }
251
-
252
- .shell-nav-folder.is-drop {
253
- background: var(--lp-blue-tint);
254
- border-radius: 7px;
255
- box-shadow: inset 0 0 0 1px var(--lp-blue-deep);
256
- }
257
-
258
- .shell-nav-list.is-drop-root {
259
- border-radius: 9px;
260
- box-shadow: inset 0 0 0 1px var(--lp-line);
261
- }
262
-
263
- .shell-newfolder {
264
- display: block;
265
- width: 100%;
266
- border: 0;
267
- background: transparent;
268
- padding: 6px 8px;
269
- margin-top: 2px;
270
- border-radius: 7px;
271
- font: inherit;
272
- font-size: var(--lp-fs-2xs);
273
- color: var(--lp-muted);
274
- text-align: left;
275
- cursor: pointer;
276
- white-space: nowrap;
277
- }
278
-
279
- .shell-newfolder:hover {
280
- background: var(--lp-wash);
281
- color: var(--lp-ink);
282
- }
283
-
284
- /* WAVE 19 R11 β€” the same row in the folded rail: the "+" alone, centred in the
285
- 56px strip, opening the rail rather than a popover it has no room for. */
286
- .shell-newfolder.is-collapsed {
287
- display: grid;
288
- place-items: center;
289
- padding: 6px 0;
290
- }
291
-
292
- .shell-newthing-plus {
293
- width: 16px;
294
- height: 16px;
295
- fill: none;
296
- stroke: currentColor;
297
- stroke-width: 1.5;
298
- stroke-linecap: round;
299
- }
300
-
301
- .shell-newfolder-form {
302
- padding: 4px 2px 2px;
303
- }
304
-
305
- /* ── the fixed popover menu ───────────────────────────────────────────────── */
306
-
307
- .shell-navmenu {
308
- position: fixed;
309
- z-index: 80;
310
- min-width: 196px;
311
- max-width: 240px;
312
- padding: 5px;
313
- border: 1px solid var(--lp-line);
314
- border-radius: 9px;
315
- background: var(--lp-surface);
316
- box-shadow: 0 10px 34px rgba(20, 30, 43, 0.16);
317
- font-size: var(--lp-fs-xs);
318
- }
319
-
320
- .shell-navmenu-item {
321
- display: flex;
322
- align-items: center;
323
- gap: 8px;
324
- width: 100%;
325
- border: 0;
326
- background: transparent;
327
- padding: 6px 8px;
328
- border-radius: 6px;
329
- font: inherit;
330
- color: var(--lp-ink);
331
- cursor: pointer;
332
- text-align: left;
333
- /* Wave 14 R7 β€” every menu action occupies ONE line, app-wide. */
334
- white-space: nowrap;
335
- }
336
-
337
- .shell-navmenu-item:hover {
338
- background: var(--lp-wash);
339
- }
340
-
341
- .shell-navmenu-item.is-current {
342
- color: var(--lp-muted);
343
- cursor: default;
344
- }
345
-
346
- .shell-navmenu-item.is-danger {
347
- color: var(--lp-red-deep);
348
- }
349
-
350
- /* A secondary action inside a picker, not a menu row in its own right. */
351
- .shell-navmenu-item.is-quiet {
352
- margin-top: 2px;
353
- font-size: var(--lp-fs-3xs);
354
- color: var(--lp-muted);
355
- }
356
-
357
- /* ── WAVE 19 R8 / C1: the database icon picker ─────────────────────────────── */
358
-
359
- .shell-navmenu-pick {
360
- padding: 4px 3px 3px;
361
- }
362
-
363
- /* WRAPS on purpose: twelve 26px swatches is ~312px and the menu is capped at
364
- 240 (a popover wider than the rail it hangs off reads as a panel that has
365
- come loose). Two rows of six is the shape that falls out of the cap, and the
366
- tone row below it is five β€” so the block reads as "shape, then colour"
367
- without a heading having to say so. */
368
- .shell-navmenu-pickrow {
369
- display: flex;
370
- flex-wrap: wrap;
371
- gap: 2px;
372
- }
373
-
374
- .shell-navmenu-pickrow + .shell-navmenu-pickrow {
375
- margin-top: 4px;
376
- padding-top: 5px;
377
- border-top: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
378
- }
379
-
380
- .shell-navmenu-swatch {
381
- flex: 0 0 auto;
382
- display: grid;
383
- place-items: center;
384
- width: 26px;
385
- height: 26px;
386
- border: 1px solid transparent;
387
- border-radius: 6px;
388
- background: transparent;
389
- padding: 0;
390
- font: inherit;
391
- cursor: pointer;
392
- }
393
-
394
- .shell-navmenu-swatch:hover {
395
- background: var(--lp-wash);
396
- }
397
-
398
- /* The current choice is marked by its OUTLINE, never by a fill: the swatch's
399
- whole job is to show the mark in its real colours, and a tinted plate behind
400
- a pastel glyph changes the thing it is previewing. */
401
- .shell-navmenu-swatch.is-on {
402
- border-color: var(--lp-blue-deep);
403
- }
404
-
405
- .shell-navmenu-swatch:focus-visible {
406
- outline: 2px solid var(--lp-blue-deep);
407
- outline-offset: -2px;
408
- }
409
-
410
- .shell-navmenu-tick {
411
- margin-left: auto;
412
- font-size: var(--lp-fs-3xs);
413
- color: var(--lp-muted);
414
- }
415
-
416
- .shell-navmenu-kicker {
417
- padding: 7px 8px 3px;
418
- font-size: var(--lp-fs-3xs);
419
- font-weight: 600;
420
- color: var(--lp-muted);
421
- }
422
-
423
- .shell-navmenu-note {
424
- padding: 4px 8px 6px;
425
- font-size: var(--lp-fs-3xs);
426
- color: var(--lp-muted);
427
- }
428
-
429
- .shell-navmenu-form {
430
- display: flex;
431
- gap: 5px;
432
- padding: 5px 6px 6px;
433
- }
434
-
435
- .shell-navmenu-input {
436
- flex: 1 1 auto;
437
- min-width: 0;
438
- border: 1px solid var(--lp-line);
439
- border-radius: 6px;
440
- padding: 4px 7px;
441
- font: inherit;
442
- font-size: var(--lp-fs-xs);
443
- background: var(--lp-surface);
444
- color: var(--lp-ink);
445
- }
446
-
447
- .shell-navmenu-input:focus {
448
- outline: none;
449
- border-color: var(--lp-blue-deep);
450
- }
451
-
452
- .shell-navmenu-go {
453
- flex: 0 0 auto;
454
- border: 1px solid var(--lp-line);
455
- border-radius: 6px;
456
- background: var(--lp-surface);
457
- padding: 4px 9px;
458
- font: inherit;
459
- font-size: var(--lp-fs-xs);
460
- color: var(--lp-ink);
461
- cursor: pointer;
462
- }
463
-
464
- .shell-navmenu-go:disabled {
465
- color: var(--lp-muted);
466
- cursor: default;
467
- }
468
-
469
- /* ── WAVE 21 item 6 (R3 / C3): the delete-database confirm face ─────────────
470
- Wider than the action menu, and only while it is showing: the panel's job
471
- changes from "pick one of four one-line verbs" to "read what is about to be
472
- destroyed", and R7's one-line law is about MENU ROWS, not about prose. The
473
- width is matched in `MenuShell`'s viewport clamp β€” a wider panel clamped at
474
- the narrow number spills off the right edge on precisely the rows nearest
475
- it. */
476
- .shell-navmenu.is-wide {
477
- max-width: 312px;
478
- }
479
-
480
- .shell-navconfirm {
481
- padding: 3px 2px 2px;
482
- }
483
-
484
- .shell-navconfirm-h {
485
- margin: 0;
486
- padding: 5px 8px 2px;
487
- font-size: var(--lp-fs-xs);
488
- font-weight: 600;
489
- color: var(--lp-ink);
490
- }
491
-
492
- /* The footprint. `disc` rather than the app's usual bare rows because these ARE
493
- a list of things and reading them as one takes a mark β€” this is the one place
494
- in the nav chrome where the user is counting. */
495
- .shell-navconfirm-list {
496
- margin: 0;
497
- padding: 0 8px 4px 24px;
498
- font-size: var(--lp-fs-3xs);
499
- color: var(--lp-ink);
500
- line-height: 1.55;
501
- }
502
-
503
- .shell-navconfirm-err {
504
- margin: 0;
505
- padding: 2px 8px 4px;
506
- font-size: var(--lp-fs-3xs);
507
- color: var(--lp-red-deep);
508
- }
509
-
510
- .shell-navconfirm-actions {
511
- display: flex;
512
- align-items: center;
513
- gap: 6px;
514
- padding: 4px 6px 4px;
515
- }
516
-
517
- /* ⚠ THE DESTRUCTIVE BUTTON IS FILLED, and the way back is the quiet one. The
518
- opposite arrangement (a quiet Delete beside a filled Cancel) is the pattern
519
- that makes people click twice: the eye lands on the filled control, and if
520
- that control is the one that does nothing the second click goes to the one
521
- that does. Weight follows CONSEQUENCE here, not safety β€” the safety is the
522
- sentence above it. */
523
- .shell-navconfirm-go {
524
- flex: 0 0 auto;
525
- border: 1px solid var(--lp-red-deep);
526
- border-radius: 6px;
527
- background: var(--lp-red-deep);
528
- padding: 4px 11px;
529
- font: inherit;
530
- font-size: var(--lp-fs-xs);
531
- color: var(--lp-surface);
532
- cursor: pointer;
533
- }
534
-
535
- .shell-navconfirm-go:disabled {
536
- border-color: var(--lp-line);
537
- background: var(--lp-wash);
538
- color: var(--lp-muted);
539
- cursor: default;
540
- }
541
-
542
- .shell-navconfirm-actions .shell-navmenu-item.is-quiet {
543
- width: auto;
544
- margin-top: 0;
545
- }
546
-
547
- /* ── the schema drawer ────────────────────────────────────────────────────── */
548
-
549
- .shell-schema-backdrop {
550
- position: fixed;
551
- inset: 0;
552
- z-index: 90;
553
- background: rgba(25, 34, 46, 0.28);
554
- }
555
-
556
- .shell-schema {
557
- position: absolute;
558
- top: 0;
559
- right: 0;
560
- bottom: 0;
561
- width: min(430px, 92vw);
562
- display: flex;
563
- flex-direction: column;
564
- background: var(--lp-surface);
565
- border-left: 1px solid var(--lp-line);
566
- box-shadow: -18px 0 48px rgba(20, 30, 43, 0.16);
567
- animation: shell-schema-in 160ms cubic-bezier(0.2, 0, 0, 1);
568
- }
569
-
570
- @keyframes shell-schema-in {
571
- from {
572
- transform: translateX(14px);
573
- opacity: 0;
574
- }
575
- }
576
-
577
- .shell-schema-head {
578
- display: flex;
579
- align-items: flex-start;
580
- justify-content: space-between;
581
- gap: 10px;
582
- padding: 16px 18px 12px;
583
- border-bottom: 1px solid var(--lp-line);
584
- }
585
-
586
- .shell-schema-title {
587
- font-size: var(--lp-fs-md);
588
- font-weight: 600;
589
- color: var(--lp-ink);
590
- }
591
-
592
- .shell-schema-sub {
593
- margin-top: 2px;
594
- font-size: var(--lp-fs-2xs);
595
- color: var(--lp-muted);
596
- }
597
-
598
- .shell-schema-close {
599
- border: 0;
600
- background: transparent;
601
- font-size: var(--lp-fs-md);
602
- line-height: 1;
603
- color: var(--lp-muted);
604
- cursor: pointer;
605
- padding: 2px 6px;
606
- border-radius: 6px;
607
- }
608
-
609
- .shell-schema-close:hover {
610
- background: var(--lp-wash);
611
- color: var(--lp-ink);
612
- }
613
-
614
- .shell-schema-body {
615
- flex: 1 1 auto;
616
- min-height: 0;
617
- overflow-y: auto;
618
- padding: 12px 18px 22px;
619
- }
620
-
621
- .shell-schema-empty {
622
- padding: 26px 18px;
623
- color: var(--lp-muted);
624
- font-size: var(--lp-fs-xs);
625
- }
626
-
627
- /* wave17 SHELL (item 3/R6) β€” the same box, carrying the mark instead of a
628
- sentence. Centred, because a drawer that has not loaded yet has no left edge
629
- of content for it to hang off. */
630
- .shell-schema-empty.is-spin {
631
- display: flex;
632
- justify-content: center;
633
- padding: 40px 18px;
634
- }
635
-
636
- .shell-schema-note {
637
- padding: 9px 11px;
638
- border: 1px solid var(--lp-line);
639
- border-radius: 8px;
640
- background: var(--lp-wash);
641
- color: var(--lp-muted);
642
- font-size: var(--lp-fs-xs);
643
- margin-bottom: 12px;
644
- }
645
-
646
- .shell-schema-kicker {
647
- margin: 14px 0 7px;
648
- font-size: var(--lp-fs-2xs);
649
- font-weight: 600;
650
- color: var(--lp-muted);
651
- }
652
-
653
- .shell-schema-table {
654
- width: 100%;
655
- border-collapse: collapse;
656
- font-size: var(--lp-fs-xs);
657
- }
658
-
659
- .shell-schema-table th {
660
- text-align: left;
661
- font-weight: 600;
662
- font-size: var(--lp-fs-3xs);
663
- color: var(--lp-muted);
664
- padding: 4px 8px 4px 0;
665
- border-bottom: 1px solid var(--lp-line);
666
- }
667
-
668
- .shell-schema-table td {
669
- vertical-align: top;
670
- padding: 7px 8px 7px 0;
671
- border-bottom: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
672
- }
673
-
674
- .shell-schema-fname {
675
- color: var(--lp-ink);
676
- }
677
-
678
- .shell-schema-fdesc {
679
- margin-top: 2px;
680
- font-size: var(--lp-fs-2xs);
681
- color: var(--lp-muted);
682
- line-height: 1.4;
683
- }
684
-
685
- .shell-schema-type {
686
- white-space: nowrap;
687
- color: var(--lp-muted);
688
- font-size: var(--lp-fs-2xs);
689
- }
690
-
691
- .shell-schema-measures {
692
- display: flex;
693
- flex-direction: column;
694
- }
695
-
696
- .shell-schema-measure {
697
- display: flex;
698
- align-items: baseline;
699
- justify-content: space-between;
700
- gap: 10px;
701
- padding: 5px 0;
702
- border-bottom: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
703
- font-size: var(--lp-fs-xs);
704
- }
 
1
+ /* shell/navExtras.css β€” C-SCHEMA (2026-08-02): nav folders, three-dots, schema drawer.
2
+ New classes only; the shell's own rail classes stay in index.css untouched. */
3
+
4
+ .shell-nav-row {
5
+ position: relative;
6
+ display: flex;
7
+ align-items: stretch;
8
+ min-width: 0;
9
+ /* wave17 SHELL (item 12) β€” the row is the painted surface now, so it needs
10
+ the shape the `<a>` used to draw. `--lp-r-sm` and not the Views rail's
11
+ `--lp-r-md`: the tint must land exactly where the item's hover pill lands
12
+ today, and every other pill in this rail is 4px. Item 12 asks the highlight
13
+ to reach the β‹―; it does not ask the nav to change shape. */
14
+ border-radius: var(--lp-r-sm);
15
+ }
16
+
17
+ /* The ROW carries hover and current-state, exactly as `.cg-view-row` does in
18
+ the Views rail β€” which is the whole of item 12. Hovering the three-dots now
19
+ lights the row it belongs to instead of leaving the link beside it cold. */
20
+ .shell-nav-row:hover {
21
+ background: var(--lp-surface-2);
22
+ }
23
+
24
+ .shell-nav-row.is-active {
25
+ background: var(--lp-blue-tint);
26
+ }
27
+
28
+ .shell-nav-row > .shell-nav-item {
29
+ flex: 1 1 auto;
30
+ min-width: 0;
31
+ }
32
+
33
+ /* ⚠ LOAD-BEARING, not tidiness. `.shell-nav-item:hover` still paints its own
34
+ grey β€” it must, because the AI-assistant row is a bare `.shell-nav-item`
35
+ with no row around it. Left alone inside a row it would paint that grey
36
+ OVER the blue of the current database the moment the pointer crossed the
37
+ label: the link half would go grey while the β‹― half stayed blue. The child
38
+ selector outranks it (0,3,0 over 0,2,0), so this holds no matter which
39
+ stylesheet the bundler emits first β€” the nav has lost a rule to source
40
+ order before ([[loopable-nav-logo-toggle]]) and specificity is the fix that
41
+ does not depend on being lucky. */
42
+ .shell-nav-row > .shell-nav-item:hover {
43
+ background: transparent;
44
+ }
45
+
46
+ .shell-nav-item.is-rail-skeleton {
47
+ cursor: default;
48
+ pointer-events: none;
49
+ }
50
+
51
+ /* ⭐⭐ W33-T75 (owner item 1) β€” THE RAIL'S ONE IN-FLIGHT STATE.
52
+ β›” THE POINT IS THE `:not()`, NOT THE BARS. While `/nav` is in flight EVERY real row is
53
+ hidden, so the rail cannot show five finished rows beside two loading ones β€” which is what
54
+ `reference/ERROR 6.png` is a picture of, and what "the navigation loads separately" has meant
55
+ in all four reports. They are hidden rather than unmounted because the database flyout portal,
56
+ the tooltips and the account menu hang off this subtree (see Shell.tsx's note).
57
+ ⚠ Geometry is copied from `.shell-nav-item` in index.css and must stay copied: 34 px min-height,
58
+ 8 px gap, 7/8 padding, a 16 px mark. A skeleton row of a different height is the layout jump
59
+ this whole ticket is about, one step earlier. */
60
+ .shell-nav.is-rail-loading > *:not(.shell-rail-skeleton) {
61
+ display: none;
62
+ }
63
+
64
+ /* ⭐ WAVE 34 R15 β€” "then a subtle line separation". SUBTLE is the whole specification, so this
65
+ is a hairline at the rail's own border token and nothing else: no margin collapse games, no
66
+ inset, no second colour. It groups Home+Inbox / Assistant+Agents / Database+Connectors.
67
+ ⚠ It is a direct child of `.shell-nav`, which is what makes the `is-rail-loading` rule above
68
+ hide it with the rows. Do not wrap the groups in `<div>`s to get the same look: that would put
69
+ the rows one level deeper and the `> *` in that selector would stop reaching them, silently
70
+ bringing back the two-row-states defect the whole rule exists to prevent. */
71
+ .shell-nav-sep {
72
+ height: 1px;
73
+ margin: 6px 8px;
74
+ background: var(--lp-line);
75
+ }
76
+
77
+ .shell-rail-skeleton {
78
+ display: flex;
79
+ flex-direction: column;
80
+ }
81
+
82
+ .shell-rail-skeleton-row {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 8px;
86
+ min-height: 34px;
87
+ padding: 7px 8px;
88
+ box-sizing: border-box;
89
+ }
90
+
91
+ .shell-rail-skeleton-mark,
92
+ .shell-rail-skeleton-bar {
93
+ /* One flat tone, no shimmer: an animated sweep is a second thing moving in a rail whose whole
94
+ complaint is movement, and `prefers-reduced-motion` would then need a third state. */
95
+ background: var(--lp-surface-2);
96
+ border-radius: var(--lp-r-sm);
97
+ }
98
+
99
+ .shell-rail-skeleton-mark {
100
+ flex: 0 0 auto;
101
+ width: 16px;
102
+ height: 16px;
103
+ }
104
+
105
+ .shell-rail-skeleton-bar {
106
+ flex: 0 0 auto;
107
+ width: 84px;
108
+ height: 9px;
109
+ border-radius: 4px;
110
+ }
111
+
112
+ /* Collapsed, the labels are gone from the real rail, so the skeleton drops its bars too β€”
113
+ otherwise the fold animation runs against a width the settled rail never has. */
114
+ .shell-side.is-collapsed .shell-rail-skeleton-bar {
115
+ display: none;
116
+ }
117
+
118
+ .shell-nav-row > .shell-dots,
119
+ .shell-nav-folder > .shell-dots {
120
+ flex: 0 0 auto;
121
+ width: 24px;
122
+ border: 0;
123
+ padding: 0;
124
+ margin: 2px 2px 2px 0;
125
+ border-radius: 6px;
126
+ background: transparent;
127
+ color: var(--lp-muted);
128
+ cursor: pointer;
129
+ opacity: 0;
130
+ display: grid;
131
+ place-items: center;
132
+ }
133
+
134
+ .shell-nav-row:hover > .shell-dots,
135
+ .shell-nav-row:focus-within > .shell-dots,
136
+ .shell-nav-folder:hover > .shell-dots,
137
+ .shell-nav-folder:focus-within > .shell-dots {
138
+ opacity: 1;
139
+ }
140
+
141
+ .shell-nav-row > .shell-dots:hover,
142
+ .shell-nav-folder > .shell-dots:hover {
143
+ background: var(--lp-wash);
144
+ color: var(--lp-ink);
145
+ }
146
+
147
+ .shell-dots-icon {
148
+ width: 14px;
149
+ height: 14px;
150
+ fill: currentColor;
151
+ }
152
+
153
+ .shell-nav-row.is-foldered > .shell-nav-item {
154
+ padding-left: 30px;
155
+ }
156
+
157
+ /* ── folder heads ─────────────────────────────────────────────────────────── */
158
+
159
+ .shell-nav-folder {
160
+ display: flex;
161
+ align-items: stretch;
162
+ min-width: 0;
163
+ }
164
+
165
+ .shell-nav-folderbtn {
166
+ flex: 1 1 auto;
167
+ min-width: 0;
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 7px;
171
+ border: 0;
172
+ background: transparent;
173
+ padding: 6px 8px;
174
+ border-radius: 7px;
175
+ color: var(--lp-ink);
176
+ font: inherit;
177
+ font-size: var(--lp-fs-xs);
178
+ /* Wave 14 R3 β€” the Views-rail folder look: BOLD label, no chevron. */
179
+ font-weight: 600;
180
+ cursor: pointer;
181
+ text-align: left;
182
+ }
183
+
184
+ .shell-nav-folderbtn:hover {
185
+ background: var(--lp-wash);
186
+ }
187
+
188
+ .shell-nav-foldercount {
189
+ margin-left: auto;
190
+ /* Wave 14 item 7 β€” the count wears the LABEL's size, muted; nothing "all over the
191
+ place with regards to symmetry".
192
+ ⚠ wave17 SHELL (item 11b) SUPERSEDES THE SIZE HALF. Wave 14 matched the count
193
+ to the label so the row would not look ragged; at the label's own size, next
194
+ to a 600-weight name, it stopped reading as an annotation and started reading
195
+ as a second piece of the title. Smaller is what makes it recede.
196
+ The COLOUR TOKEN IS UNCHANGED on purpose: `--lp-muted` already IS the grey,
197
+ and the same owner item asks the Views rail's `cg-fold-count` for the same
198
+ treatment. A lighter grey invented here would need a literal that has nowhere
199
+ legal to live, and would put the two rails' counts a shade apart β€” which is
200
+ the exact symmetry complaint wave 14 was answering. Shrinking the type does
201
+ the lightening. */
202
+ font-size: var(--lp-fs-3xs);
203
+ font-weight: 400;
204
+ color: var(--lp-muted);
205
+ /* Counts line up column-wise as folders open and close, in both rails. */
206
+ font-variant-numeric: tabular-nums;
207
+ }
208
+
209
+ /* β›”β›” WAVE 35 Β· T07 (owner item 9, R4) β€” `.shell-nav-count` AND ITS ADJACENCY RULE ARE DELETED.
210
+ They styled wave-34 R1's mark-important number in the database flyout and on a collapsed folder
211
+ head. Owner item 9 is later and explicit: the count belongs to the view itself and nowhere else;
212
+ the marked VIEWS reach the user through Home and the Starred module instead. Both elements are
213
+ gone from `Shell.tsx` and `NavExtras.tsx`, `/nav` stops computing the number at all (W35-T43),
214
+ and `verify_wiring`'s two W34-W1 rows are INVERTED into `DELETIONS` rather than removed.
215
+ ⚠ `.shell-nav-foldercount` SURVIVES and is a different thing: how many BLOCKS a folder holds,
216
+ since wave 14. Deleting it would retire an unrelated feature under cover of this ruling. */
217
+
218
+ /* ── wave 14 C-NAVFOLD: drag placement (items 4/6) ───────────────────────── */
219
+
220
+ .shell-nav-row.is-dragging {
221
+ opacity: 0.45;
222
+ }
223
+
224
+ .shell-nav-folder.is-drop {
225
+ background: var(--lp-blue-tint);
226
+ border-radius: 7px;
227
+ box-shadow: inset 0 0 0 1px var(--lp-blue-deep);
228
+ }
229
+
230
+ .shell-nav-list.is-drop-root {
231
+ border-radius: 9px;
232
+ box-shadow: inset 0 0 0 1px var(--lp-line);
233
+ }
234
+
235
+ .shell-newfolder {
236
+ display: block;
237
+ width: 100%;
238
+ border: 0;
239
+ background: transparent;
240
+ padding: 6px 8px;
241
+ margin-top: 2px;
242
+ border-radius: 7px;
243
+ font: inherit;
244
+ font-size: var(--lp-fs-2xs);
245
+ color: var(--lp-muted);
246
+ text-align: left;
247
+ cursor: pointer;
248
+ white-space: nowrap;
249
+ }
250
+
251
+ .shell-newfolder:hover {
252
+ background: var(--lp-wash);
253
+ color: var(--lp-ink);
254
+ }
255
+
256
+ /* WAVE 19 R11 β€” the same row in the folded rail: the "+" alone, centred in the
257
+ 56px strip, opening the rail rather than a popover it has no room for. */
258
+ .shell-newfolder.is-collapsed {
259
+ display: grid;
260
+ place-items: center;
261
+ padding: 6px 0;
262
+ }
263
+
264
+ .shell-newthing-plus {
265
+ width: 16px;
266
+ height: 16px;
267
+ fill: none;
268
+ stroke: currentColor;
269
+ stroke-width: 1.5;
270
+ stroke-linecap: round;
271
+ }
272
+
273
+ .shell-newfolder-form {
274
+ padding: 4px 2px 2px;
275
+ }
276
+
277
+ /* ── the fixed popover menu ───────────────────────────────────────────────── */
278
+
279
+ .shell-navmenu {
280
+ position: fixed;
281
+ z-index: 80;
282
+ min-width: 196px;
283
+ max-width: 240px;
284
+ padding: 5px;
285
+ border: 1px solid var(--lp-line);
286
+ border-radius: 9px;
287
+ background: var(--lp-surface);
288
+ box-shadow: 0 10px 34px rgba(20, 30, 43, 0.16);
289
+ font-size: var(--lp-fs-xs);
290
+ }
291
+
292
+ .shell-navmenu-item {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: 8px;
296
+ width: 100%;
297
+ border: 0;
298
+ background: transparent;
299
+ padding: 6px 8px;
300
+ border-radius: 6px;
301
+ font: inherit;
302
+ color: var(--lp-ink);
303
+ cursor: pointer;
304
+ text-align: left;
305
+ /* Wave 14 R7 β€” every menu action occupies ONE line, app-wide. */
306
+ white-space: nowrap;
307
+ }
308
+
309
+ .shell-navmenu-item:hover {
310
+ background: var(--lp-wash);
311
+ }
312
+
313
+ .shell-navmenu-item.is-current {
314
+ color: var(--lp-muted);
315
+ cursor: default;
316
+ }
317
+
318
+ .shell-navmenu-item.is-danger {
319
+ color: var(--lp-red-deep);
320
+ }
321
+
322
+ /* A secondary action inside a picker, not a menu row in its own right. */
323
+ .shell-navmenu-item.is-quiet {
324
+ margin-top: 2px;
325
+ font-size: var(--lp-fs-3xs);
326
+ color: var(--lp-muted);
327
+ }
328
+
329
+ /* ── WAVE 19 R8 / C1: the database icon picker ─────────────────────────────── */
330
+
331
+ .shell-navmenu-pick {
332
+ padding: 4px 3px 3px;
333
+ }
334
+
335
+ /* WRAPS on purpose: twelve 26px swatches is ~312px and the menu is capped at
336
+ 240 (a popover wider than the rail it hangs off reads as a panel that has
337
+ come loose). Two rows of six is the shape that falls out of the cap, and the
338
+ tone row below it is five β€” so the block reads as "shape, then colour"
339
+ without a heading having to say so. */
340
+ .shell-navmenu-pickrow {
341
+ display: flex;
342
+ flex-wrap: wrap;
343
+ gap: 2px;
344
+ }
345
+
346
+ .shell-navmenu-pickrow + .shell-navmenu-pickrow {
347
+ margin-top: 4px;
348
+ padding-top: 5px;
349
+ border-top: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
350
+ }
351
+
352
+ .shell-navmenu-swatch {
353
+ flex: 0 0 auto;
354
+ display: grid;
355
+ place-items: center;
356
+ width: 26px;
357
+ height: 26px;
358
+ border: 1px solid transparent;
359
+ border-radius: 6px;
360
+ background: transparent;
361
+ padding: 0;
362
+ font: inherit;
363
+ cursor: pointer;
364
+ }
365
+
366
+ .shell-navmenu-swatch:hover {
367
+ background: var(--lp-wash);
368
+ }
369
+
370
+ /* The current choice is marked by its OUTLINE, never by a fill: the swatch's
371
+ whole job is to show the mark in its real colours, and a tinted plate behind
372
+ a pastel glyph changes the thing it is previewing. */
373
+ .shell-navmenu-swatch.is-on {
374
+ border-color: var(--lp-blue-deep);
375
+ }
376
+
377
+ .shell-navmenu-swatch:focus-visible {
378
+ outline: 2px solid var(--lp-blue-deep);
379
+ outline-offset: -2px;
380
+ }
381
+
382
+ .shell-navmenu-tick {
383
+ margin-left: auto;
384
+ font-size: var(--lp-fs-3xs);
385
+ color: var(--lp-muted);
386
+ }
387
+
388
+ .shell-navmenu-kicker {
389
+ padding: 7px 8px 3px;
390
+ font-size: var(--lp-fs-3xs);
391
+ font-weight: 600;
392
+ color: var(--lp-muted);
393
+ }
394
+
395
+ .shell-navmenu-note {
396
+ padding: 4px 8px 6px;
397
+ font-size: var(--lp-fs-3xs);
398
+ color: var(--lp-muted);
399
+ }
400
+
401
+ .shell-navmenu-form {
402
+ display: flex;
403
+ gap: 5px;
404
+ padding: 5px 6px 6px;
405
+ }
406
+
407
+ .shell-navmenu-input {
408
+ flex: 1 1 auto;
409
+ min-width: 0;
410
+ border: 1px solid var(--lp-line);
411
+ border-radius: 6px;
412
+ padding: 4px 7px;
413
+ font: inherit;
414
+ font-size: var(--lp-fs-xs);
415
+ background: var(--lp-surface);
416
+ color: var(--lp-ink);
417
+ }
418
+
419
+ .shell-navmenu-input:focus {
420
+ outline: none;
421
+ border-color: var(--lp-blue-deep);
422
+ }
423
+
424
+ .shell-navmenu-go {
425
+ flex: 0 0 auto;
426
+ border: 1px solid var(--lp-line);
427
+ border-radius: 6px;
428
+ background: var(--lp-surface);
429
+ padding: 4px 9px;
430
+ font: inherit;
431
+ font-size: var(--lp-fs-xs);
432
+ color: var(--lp-ink);
433
+ cursor: pointer;
434
+ }
435
+
436
+ .shell-navmenu-go:disabled {
437
+ color: var(--lp-muted);
438
+ cursor: default;
439
+ }
440
+
441
+ /* ── WAVE 21 item 6 (R3 / C3): the delete-database confirm face ─────────────
442
+ Wider than the action menu, and only while it is showing: the panel's job
443
+ changes from "pick one of four one-line verbs" to "read what is about to be
444
+ destroyed", and R7's one-line law is about MENU ROWS, not about prose. The
445
+ width is matched in `MenuShell`'s viewport clamp β€” a wider panel clamped at
446
+ the narrow number spills off the right edge on precisely the rows nearest
447
+ it. */
448
+ .shell-navmenu.is-wide {
449
+ max-width: 312px;
450
+ }
451
+
452
+ .shell-navconfirm {
453
+ padding: 3px 2px 2px;
454
+ }
455
+
456
+ .shell-navconfirm-h {
457
+ margin: 0;
458
+ padding: 5px 8px 2px;
459
+ font-size: var(--lp-fs-xs);
460
+ font-weight: 600;
461
+ color: var(--lp-ink);
462
+ }
463
+
464
+ /* The footprint. `disc` rather than the app's usual bare rows because these ARE
465
+ a list of things and reading them as one takes a mark β€” this is the one place
466
+ in the nav chrome where the user is counting. */
467
+ .shell-navconfirm-list {
468
+ margin: 0;
469
+ padding: 0 8px 4px 24px;
470
+ font-size: var(--lp-fs-3xs);
471
+ color: var(--lp-ink);
472
+ line-height: 1.55;
473
+ }
474
+
475
+ .shell-navconfirm-err {
476
+ margin: 0;
477
+ padding: 2px 8px 4px;
478
+ font-size: var(--lp-fs-3xs);
479
+ color: var(--lp-red-deep);
480
+ }
481
+
482
+ .shell-navconfirm-actions {
483
+ display: flex;
484
+ align-items: center;
485
+ gap: 6px;
486
+ padding: 4px 6px 4px;
487
+ }
488
+
489
+ /* ⚠ THE DESTRUCTIVE BUTTON IS FILLED, and the way back is the quiet one. The
490
+ opposite arrangement (a quiet Delete beside a filled Cancel) is the pattern
491
+ that makes people click twice: the eye lands on the filled control, and if
492
+ that control is the one that does nothing the second click goes to the one
493
+ that does. Weight follows CONSEQUENCE here, not safety β€” the safety is the
494
+ sentence above it. */
495
+ .shell-navconfirm-go {
496
+ flex: 0 0 auto;
497
+ border: 1px solid var(--lp-red-deep);
498
+ border-radius: 6px;
499
+ background: var(--lp-red-deep);
500
+ padding: 4px 11px;
501
+ font: inherit;
502
+ font-size: var(--lp-fs-xs);
503
+ color: var(--lp-surface);
504
+ cursor: pointer;
505
+ }
506
+
507
+ .shell-navconfirm-go:disabled {
508
+ border-color: var(--lp-line);
509
+ background: var(--lp-wash);
510
+ color: var(--lp-muted);
511
+ cursor: default;
512
+ }
513
+
514
+ .shell-navconfirm-actions .shell-navmenu-item.is-quiet {
515
+ width: auto;
516
+ margin-top: 0;
517
+ }
518
+
519
+ /* ── the schema drawer ────────────────────────────────────────────────────── */
520
+
521
+ .shell-schema-backdrop {
522
+ position: fixed;
523
+ inset: 0;
524
+ z-index: 90;
525
+ background: rgba(25, 34, 46, 0.28);
526
+ }
527
+
528
+ .shell-schema {
529
+ position: absolute;
530
+ top: 0;
531
+ right: 0;
532
+ bottom: 0;
533
+ width: min(430px, 92vw);
534
+ display: flex;
535
+ flex-direction: column;
536
+ background: var(--lp-surface);
537
+ border-left: 1px solid var(--lp-line);
538
+ box-shadow: -18px 0 48px rgba(20, 30, 43, 0.16);
539
+ animation: shell-schema-in 160ms cubic-bezier(0.2, 0, 0, 1);
540
+ }
541
+
542
+ @keyframes shell-schema-in {
543
+ from {
544
+ transform: translateX(14px);
545
+ opacity: 0;
546
+ }
547
+ }
548
+
549
+ .shell-schema-head {
550
+ display: flex;
551
+ align-items: flex-start;
552
+ justify-content: space-between;
553
+ gap: 10px;
554
+ padding: 16px 18px 12px;
555
+ border-bottom: 1px solid var(--lp-line);
556
+ }
557
+
558
+ .shell-schema-title {
559
+ font-size: var(--lp-fs-md);
560
+ font-weight: 600;
561
+ color: var(--lp-ink);
562
+ }
563
+
564
+ .shell-schema-sub {
565
+ margin-top: 2px;
566
+ font-size: var(--lp-fs-2xs);
567
+ color: var(--lp-muted);
568
+ }
569
+
570
+ .shell-schema-close {
571
+ border: 0;
572
+ background: transparent;
573
+ font-size: var(--lp-fs-md);
574
+ line-height: 1;
575
+ color: var(--lp-muted);
576
+ cursor: pointer;
577
+ padding: 2px 6px;
578
+ border-radius: 6px;
579
+ }
580
+
581
+ .shell-schema-close:hover {
582
+ background: var(--lp-wash);
583
+ color: var(--lp-ink);
584
+ }
585
+
586
+ .shell-schema-body {
587
+ flex: 1 1 auto;
588
+ min-height: 0;
589
+ overflow-y: auto;
590
+ padding: 12px 18px 22px;
591
+ }
592
+
593
+ .shell-schema-empty {
594
+ padding: 26px 18px;
595
+ color: var(--lp-muted);
596
+ font-size: var(--lp-fs-xs);
597
+ }
598
+
599
+ /* wave17 SHELL (item 3/R6) β€” the same box, carrying the mark instead of a
600
+ sentence. Centred, because a drawer that has not loaded yet has no left edge
601
+ of content for it to hang off. */
602
+ .shell-schema-empty.is-spin {
603
+ display: flex;
604
+ justify-content: center;
605
+ padding: 40px 18px;
606
+ }
607
+
608
+ .shell-schema-note {
609
+ padding: 9px 11px;
610
+ border: 1px solid var(--lp-line);
611
+ border-radius: 8px;
612
+ background: var(--lp-wash);
613
+ color: var(--lp-muted);
614
+ font-size: var(--lp-fs-xs);
615
+ margin-bottom: 12px;
616
+ }
617
+
618
+ .shell-schema-kicker {
619
+ margin: 14px 0 7px;
620
+ font-size: var(--lp-fs-2xs);
621
+ font-weight: 600;
622
+ color: var(--lp-muted);
623
+ }
624
+
625
+ .shell-schema-table {
626
+ width: 100%;
627
+ border-collapse: collapse;
628
+ font-size: var(--lp-fs-xs);
629
+ }
630
+
631
+ .shell-schema-table th {
632
+ text-align: left;
633
+ font-weight: 600;
634
+ font-size: var(--lp-fs-3xs);
635
+ color: var(--lp-muted);
636
+ padding: 4px 8px 4px 0;
637
+ border-bottom: 1px solid var(--lp-line);
638
+ }
639
+
640
+ .shell-schema-table td {
641
+ vertical-align: top;
642
+ padding: 7px 8px 7px 0;
643
+ border-bottom: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
644
+ }
645
+
646
+ .shell-schema-fname {
647
+ color: var(--lp-ink);
648
+ }
649
+
650
+ .shell-schema-fdesc {
651
+ margin-top: 2px;
652
+ font-size: var(--lp-fs-2xs);
653
+ color: var(--lp-muted);
654
+ line-height: 1.4;
655
+ }
656
+
657
+ .shell-schema-type {
658
+ white-space: nowrap;
659
+ color: var(--lp-muted);
660
+ font-size: var(--lp-fs-2xs);
661
+ }
662
+
663
+ .shell-schema-measures {
664
+ display: flex;
665
+ flex-direction: column;
666
+ }
667
+
668
+ .shell-schema-measure {
669
+ display: flex;
670
+ align-items: baseline;
671
+ justify-content: space-between;
672
+ gap: 10px;
673
+ padding: 5px 0;
674
+ border-bottom: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
675
+ font-size: var(--lp-fs-xs);
676
+ }