fsanyoto commited on
Commit
c3e4cb4
Β·
verified Β·
1 Parent(s): 87cbc14

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
Files changed (50) hide show
  1. RELEASES.json +7 -1
  2. VERSION +1 -1
  3. api/ai_review.py +207 -0
  4. api/automation_engine.py +1089 -94
  5. api/main.py +21 -0
  6. api/routes_automation.py +66 -3
  7. api/routes_connectors.py +176 -0
  8. api/routes_forms.py +399 -0
  9. api/routes_nav.py +132 -1
  10. api/routes_tables.py +28 -4
  11. api/routes_templates.py +148 -0
  12. platform/aios_grid.py +7 -1
  13. platform/core/alerts.py +38 -0
  14. platform/core/grid_events.py +22 -0
  15. platform/core/keychain.py +7 -1
  16. platform/core/user_tables.py +33 -3
  17. platform/core/view_templates.py +296 -0
  18. web/src/alerts/AlertsPane.tsx +38 -3
  19. web/src/alerts/alertsModel.ts +49 -0
  20. web/src/apiContract.ts +46 -0
  21. web/src/automation/AutomationBoard.tsx +19 -1
  22. web/src/automation/AutomationBuilder.tsx +1558 -0
  23. web/src/automation/AutomationCreate.tsx +123 -3
  24. web/src/automation/AutomationDetail.tsx +0 -0
  25. web/src/automation/AutomationSurface.tsx +178 -16
  26. web/src/automation/AutomationTrigger.tsx +64 -23
  27. web/src/automation/CondBuilder.tsx +344 -0
  28. web/src/automation/automationApi.ts +177 -21
  29. web/src/connectors/ConnectorsPage.tsx +274 -0
  30. web/src/customer-grid/ColumnMenu.tsx +12 -3
  31. web/src/customer-grid/CustomerGrid.tsx +47 -0
  32. web/src/customer-grid/JsonViewer.tsx +361 -0
  33. web/src/customer-grid/RecordDetail.css +46 -0
  34. web/src/customer-grid/RecordDetail.tsx +60 -1
  35. web/src/customer-grid/cells.ts +64 -2
  36. web/src/customer-grid/display.ts +92 -0
  37. web/src/customer-grid/iconShapes.ts +524 -489
  38. web/src/customer-grid/theme.ts +75 -0
  39. web/src/customer-grid/types.ts +81 -4
  40. web/src/filter-kit/ops.ts +14 -0
  41. web/src/forms/FormPublic.tsx +266 -0
  42. web/src/home/HomePage.tsx +299 -0
  43. web/src/home/TemplatePicker.tsx +217 -0
  44. web/src/home/homeModel.ts +208 -0
  45. web/src/index.css +0 -0
  46. web/src/settings/permsModel.ts +4 -0
  47. web/src/shell/Shell.tsx +727 -123
  48. web/src/shell/nav.ts +123 -4
  49. web/src/viz/seriesData.ts +5 -0
  50. web/src/viz/types.ts +9 -1
RELEASES.json CHANGED
@@ -1,6 +1,12 @@
1
  {
2
- "current": "v11 (3126617)",
3
  "releases": [
 
 
 
 
 
 
4
  {
5
  "version": "v11",
6
  "sha": "3126617",
 
1
  {
2
+ "current": "v12 (4908f39)",
3
  "releases": [
4
+ {
5
+ "version": "v12",
6
+ "sha": "4908f39",
7
+ "date": "2026-08-06",
8
+ "subject": "release v12"
9
+ },
10
  {
11
  "version": "v11",
12
  "sha": "3126617",
VERSION CHANGED
@@ -1 +1 @@
1
- v11 (3126617)
 
1
+ v12 (4908f39)
api/ai_review.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI REVIEW β€” let a model decide which stage a card moves to (wave 23, owner ruling R4/R14).
2
+
3
+ ⭐ WHAT THIS IS. A review stage holds a record until somebody decides where it goes next. R4 made
4
+ that somebody optionally a MODEL: the engine hands over the record's own values, the review's
5
+ prompt, and the list of stages the review is allowed to send a card to, and gets back ONE of
6
+ those stage labels plus a one-line reason. Every decision is written to the same `reviews` audit
7
+ log a human click writes to, tagged `by: "ai"` with the provider and model that made it.
8
+
9
+ β›” FAIL-CLOSED IN EVERY DIRECTION, and this is the whole safety story. No key configured, a
10
+ network failure, a slow answer, a malformed answer, or an answer naming a stage the review does
11
+ not offer β€” all return `("", {...})`, and the caller leaves the card exactly where a human would
12
+ have found it. The feature can be absent, broken or wrong and the worst outcome is a person doing
13
+ the work. Nothing here can move a card somewhere the review does not already permit.
14
+
15
+ ⭐ CHEAP FIRST (owner R14, verbatim: *"Claude is a bit too expensive"*). The ladder is
16
+ `groq β†’ cerebras β†’ openrouter β†’ anthropic`; the first CONFIGURED provider wins, and Anthropic is
17
+ last rather than absent β€” it is the quality backstop, not the default. `AIOS_AI_REVIEW_PROVIDER`
18
+ pins one; `AIOS_AI_MODEL` overrides the model.
19
+
20
+ ⚠ WHY RAW HTTP RATHER THAN THE `anthropic` SDK, stated because it is a deliberate deviation from
21
+ the /claude-api skill's default and not an oversight. Three of the four legs are OpenAI-chat-shaped
22
+ endpoints with no shared SDK, so a ladder built on the SDK would be one SDK leg beside three
23
+ hand-rolled ones β€” two implementations of the same call, the seam this repo keeps closing. And
24
+ `aios-web/api/requirements.txt` is PINNED to what the verify battery proves ([[pin-deps-space-
25
+ rebuilds]]): adding a dependency there rebuilds the container, which is a real deploy risk to take
26
+ for one leg of an optional feature. `requests` is already a dependency and `harness/analyst.py`
27
+ established per-provider raw HTTP as the house pattern. Booked as a DEBT line so the integrator
28
+ can overturn it deliberately rather than by drift.
29
+
30
+ The Messages shape below is the current one: `x-api-key` + `anthropic-version: 2023-06-01`, and
31
+ `stop_reason: "refusal"` is checked BEFORE reading `content` β€” a refusal answers HTTP 200 with an
32
+ empty content list, so code that indexes `content[0]` unconditionally breaks on it.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import os
38
+ import re
39
+
40
+ import requests
41
+
42
+ #: The ladder. Order IS the policy (R14) β€” cheapest capable first, Anthropic last.
43
+ PROVIDERS = [
44
+ {"name": "groq", "env": "GROQ_API_KEY", "shape": "openai",
45
+ "url": "https://api.groq.com/openai/v1/chat/completions",
46
+ "model": "llama-3.3-70b-versatile"},
47
+ {"name": "cerebras", "env": "CEREBRAS_API_KEY", "shape": "openai",
48
+ "url": "https://api.cerebras.ai/v1/chat/completions",
49
+ "model": "gpt-oss-120b"},
50
+ {"name": "openrouter", "env": "OPENROUTER_API_KEY", "shape": "openai",
51
+ "url": "https://openrouter.ai/api/v1/chat/completions",
52
+ "model": "openai/gpt-4o-mini"},
53
+ # ⚠ haiku-class DELIBERATELY, not the default Opus tier: R14 put Anthropic on this ladder as
54
+ # the backstop for a one-line classification, and this is the cheapest current Claude that
55
+ # does it well. A bigger model here would be spending the owner's money to pick between two
56
+ # labels it already has in front of it.
57
+ {"name": "anthropic", "env": "ANTHROPIC_API_KEY", "shape": "anthropic",
58
+ "url": "https://api.anthropic.com/v1/messages",
59
+ "model": "claude-haiku-4-5"},
60
+ ]
61
+ ANTHROPIC_VERSION = "2023-06-01"
62
+ TIMEOUT_SECONDS = float(os.environ.get("AIOS_AI_REVIEW_TIMEOUT") or 20)
63
+ MAX_FIELD_CHARS = 200 # per value handed to the model
64
+ MAX_FIELDS = 30 # columns handed to the model
65
+ MAX_REASON = 200
66
+
67
+
68
+ def ladder():
69
+ """The providers that are actually usable here, in order. Empty = the feature is off."""
70
+ pin = (os.environ.get("AIOS_AI_REVIEW_PROVIDER") or "").strip().lower()
71
+ live = [p for p in PROVIDERS if (os.environ.get(p["env"]) or "").strip()]
72
+ if pin:
73
+ live = [p for p in live if p["name"] == pin]
74
+ return live
75
+
76
+
77
+ def configured():
78
+ return bool(ladder())
79
+
80
+
81
+ def _record_text(row, fields):
82
+ """The record, as the model sees it. Values are truncated and the column set is bounded β€”
83
+ an automation table can carry a 32 KB JSON blob per row (C7) and a review decision does not
84
+ need it. Machine bookkeeping columns are dropped: a stage cell naming the stage the card is
85
+ sitting at would be the model reading its own question back."""
86
+ keys = [k for k in (fields or list((row or {}).keys()))
87
+ if not str(k).startswith("stage_")][:MAX_FIELDS]
88
+ lines = []
89
+ for k in keys:
90
+ v = (row or {}).get(k)
91
+ if v is None or str(v).strip() == "":
92
+ continue
93
+ lines.append(f"{k}: {str(v)[:MAX_FIELD_CHARS]}")
94
+ return "\n".join(lines) or "(this record has no filled-in values)"
95
+
96
+
97
+ def _instruction(prompt, options, label):
98
+ return (
99
+ f"You are deciding what happens to one record waiting at a review step called "
100
+ f"{label!r} in a workflow.\n\n"
101
+ f"The person who built this workflow told you: {prompt}\n\n"
102
+ f"Choose EXACTLY ONE of these next steps, by its exact name:\n"
103
+ + "\n".join(f"- {o}" for o in options)
104
+ + "\n\nAnswer with one line of JSON and nothing else:\n"
105
+ '{"choice": "<one name from the list above>", "reason": "<one short sentence>"}\n'
106
+ "If the record does not give you enough to decide, answer "
107
+ '{"choice": "", "reason": "why not"} and a person will decide instead.'
108
+ )
109
+
110
+
111
+ def _parse(text, options):
112
+ """The model's line β†’ `(choice, reason)`. A choice that is not one of the offered stages is
113
+ DISCARDED, not fuzzy-matched: the offered list is a permission boundary, and a near-miss
114
+ resolved by string distance is how a card ends up somewhere nobody authorised."""
115
+ raw = str(text or "").strip()
116
+ obj = None
117
+ m = re.search(r"\{.*\}", raw, re.S)
118
+ if m:
119
+ try:
120
+ obj = json.loads(m.group(0))
121
+ except (ValueError, TypeError):
122
+ obj = None
123
+ if not isinstance(obj, dict):
124
+ return "", ""
125
+ choice = str(obj.get("choice") or "").strip()
126
+ reason = str(obj.get("reason") or "").strip()[:MAX_REASON]
127
+ for opt in options:
128
+ if choice.lower() == str(opt).lower():
129
+ return str(opt), reason # the OFFERED spelling wins, never the model's
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()}",
136
+ "Content-Type": "application/json"},
137
+ json={"model": model, "max_tokens": 300, "temperature": 0,
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):
150
+ r = requests.post(p["url"], timeout=timeout,
151
+ headers={"x-api-key": os.environ[p["env"]].strip(),
152
+ "anthropic-version": ANTHROPIC_VERSION,
153
+ "content-type": "application/json"},
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"}
179
+ if not str(prompt or "").strip():
180
+ return "", {"problem": "the review has no prompt for the model to follow"}
181
+ live = ladder()
182
+ if not live:
183
+ return "", {"problem": "no AI provider is configured on this deployment"}
184
+ system = _instruction(prompt, opts, label)
185
+ user = "Here is the record:\n\n" + _record_text(row, fields)
186
+ tmo = float(timeout or TIMEOUT_SECONDS)
187
+ override = (os.environ.get("AIOS_AI_MODEL") or "").strip()
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
199
+ choice, reason = _parse(text, opts)
200
+ if not choice:
201
+ # The provider ANSWERED and declined (or answered unusably). That is a decision about
202
+ # this record, not a fault in the provider, so it does NOT fall through to a more
203
+ # expensive one β€” the card goes to a human, which is what the model just asked for.
204
+ return "", {"provider": p["name"], "model": model,
205
+ "problem": reason or "the model did not choose one of the stages"}
206
+ return choice, {"provider": p["name"], "model": model, "reason": reason}
207
+ return "", {"problem": "; ".join(problems)[:300] or "no provider answered"}
api/automation_engine.py CHANGED
@@ -24,6 +24,7 @@ the skill version takes a URL from a developer on a CLI; this takes one from a r
24
  from __future__ import annotations
25
 
26
  import datetime as _dt
 
27
  import ipaddress
28
  import json
29
  import os
@@ -949,6 +950,59 @@ def clean_schedule(raw, previous=None):
949
  return out
950
 
951
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
952
  def clean_definition(raw, previous=None, username=""):
953
  """Whole-definition validation. Returns `(defn, error)`."""
954
  raw = raw if isinstance(raw, dict) else {}
@@ -973,10 +1027,19 @@ def clean_definition(raw, previous=None, username=""):
973
  else prev.get("trigger"), prev.get("trigger"))
974
  if terr:
975
  return None, terr
 
 
 
 
976
  return {
977
  "id": _s(prev.get("id") or raw.get("id"), 40),
978
  "name": name, "kind": kind, "config": config, "schedule": schedule,
979
  "trigger": trigger,
 
 
 
 
 
980
  "status": prev.get("status") or {"state": "idle", "lastRunAt": "", "lastSummary": ""},
981
  "runs": list(prev.get("runs") or [])[:MAX_RUNS],
982
  # C3-A2(5): the review-decision audit survives a Save the way `state` does β€” an
@@ -1027,10 +1090,61 @@ def _new_id(existing):
1027
  return f"auto_{n}"
1028
 
1029
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1030
  def create(rt, raw, username=""):
1031
  existing = all_definitions(rt)
1032
  if len(existing) >= MAX_AUTOMATIONS:
1033
  return None, f"this workspace is at the {MAX_AUTOMATIONS}-automation limit"
 
 
 
 
 
 
 
 
1034
  defn, err = clean_definition(raw, None, username)
1035
  if err:
1036
  return None, err
@@ -1054,7 +1168,12 @@ def patch(rt, auto_id, raw, username=""):
1054
  if prev is None:
1055
  return None, "no such automation"
1056
  merged = dict(prev)
1057
- for k in ("name", "config", "schedule", "kind", "trigger"):
 
 
 
 
 
1058
  if k in (raw or {}):
1059
  merged[k] = raw[k]
1060
  defn, err = clean_definition(merged, prev, username)
@@ -3082,15 +3201,27 @@ def graph(defn):
3082
 
3083
  on = bool(sched.get("enabled"))
3084
  trg = defn.get("trigger") or {}
3085
- if trg.get("key") in ("event_field", "record_created", "webhook", "email"):
3086
  # C3 (wave 22): the trigger node SAYS which trigger this flow has β€” the board and rail
3087
- # read the same node, so an event-triggered flow must not read "Schedule".
 
 
 
3088
  t_on = bool(trg.get("enabled", True)) and not trg.get("paused")
3089
  sub = TRIGGER_LABELS.get(trg["key"], trg["key"])
3090
- det = {"event_field": f"{trg.get('table', '')}.{trg.get('field', '')}",
 
 
 
 
 
3091
  "record_created": str(trg.get("table") or ""),
 
 
3092
  "webhook": "POST the hook URL to fire it",
3093
  "email": str(trg.get("query") or "")}.get(trg["key"], "")
 
 
3094
  if trg.get("paused"):
3095
  det = str(defn.get("statusNote") or "paused")
3096
  nodes = [node("trigger", "trigger", sub,
@@ -3279,6 +3410,91 @@ MAX_LANES = 6
3279
  LANE_OPS = ("=", "!=", ">", ">=", "<", "<=", "includes", "not_includes",
3280
  "is_empty", "is_not_empty")
3281
  LANE_NULLARY_OPS = ("is_empty", "is_not_empty")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3282
  #: Lane labels a flow already uses for its fixed stages. A lane literally called "Review" would
3283
  #: collide with the stage the label is a choice FOR, and the board could no longer tell a
3284
  #: routed card from a gated one.
@@ -3319,25 +3535,10 @@ def clean_lanes(raw):
3319
  lid = f"{base}_{n}"
3320
  n += 1
3321
  seen_ids.add(lid)
3322
- cond_raw = entry.get("when")
3323
- cond = None
3324
- if cond_raw not in (None, ""):
3325
- if not isinstance(cond_raw, dict):
3326
- return None, f"the condition on {label!r} must be an object"
3327
- field = _s(cond_raw.get("field"), 80).strip()
3328
- op = _s(cond_raw.get("op") or cond_raw.get("operator"), 20).strip()
3329
- if not field:
3330
- return None, f"the condition on {label!r} names no field"
3331
- if op not in LANE_OPS:
3332
- return None, (f"{op or 'that comparison'!r} is not one of: "
3333
- + ", ".join(LANE_OPS))
3334
- cond = {"field": field, "op": op}
3335
- if op not in LANE_NULLARY_OPS:
3336
- v = cond_raw.get("value")
3337
- if v is None or (isinstance(v, str) and not v.strip()):
3338
- return None, f"give a value to compare {field!r} against on {label!r}"
3339
- cond["value"] = v.strip() if isinstance(v, str) else v
3340
- else:
3341
  defaults += 1
3342
  if defaults > 1:
3343
  return None, ("only one lane may have no condition β€” it is the catch-all, "
@@ -3354,7 +3555,11 @@ def _lane_num(v):
3354
 
3355
 
3356
  def lane_match(cond, row):
3357
- """Does `row` satisfy one lane condition? None-condition matches everything (the catch-all).
 
 
 
 
3358
 
3359
  β›” REFUSE-NEVER-COERCE, per record: an ordering comparison whose either side does not parse
3360
  as a number answers False β€” the record simply does not enter the lane β€” never "treat blank
@@ -3363,6 +3568,11 @@ def lane_match(cond, row):
3363
  """
3364
  if cond is None:
3365
  return True
 
 
 
 
 
3366
  raw = (row or {}).get(cond.get("field"))
3367
  op = cond.get("op")
3368
  if op == "is_empty":
@@ -3398,6 +3608,219 @@ def route_record(lanes, row):
3398
  return ""
3399
 
3400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3401
  def stages_for(defn):
3402
  """One automation β†’ `(stages, routes)` β€” C1's vocabulary, derived from `graph()` so the
3403
  board can never disagree with the runner about what the flow does.
@@ -3475,12 +3898,335 @@ def stages_for(defn):
3475
  routes.append({"from": "write", "to": lane["id"],
3476
  "label": lane["label"] if cond is None else _lane_sentence(cond),
3477
  "condition": dict(cond) if cond else None})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3478
  return stages, routes
3479
 
3480
 
3481
- def _lane_sentence(cond):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3482
  if cond is None:
3483
- return "Everything else"
 
 
 
 
 
 
 
 
 
3484
  v = cond.get("value")
3485
  return f"{cond.get('field')} {cond.get('op')}" + ("" if v is None else f" {v}")
3486
 
@@ -3527,14 +4273,27 @@ def ensure_stage_field(rt, table_key, defn, username="automation"):
3527
  "Declined": {"tracked": ""}}
3528
  field = {"key": fkey, "label": f"Stage β€” {defn.get('name') or defn.get('id')}"[:80],
3529
  "type": "select", "source": "overlay", "options": choices, "automation": auto}
3530
-
3531
- have = None
 
 
 
 
 
 
 
 
 
 
 
 
3532
  for f in (ut_get(rt, key) or {}).get("fields") or []:
3533
  if f.get("key") == fkey:
3534
  have = f
3535
- break
 
3536
  if have is not None and have.get("options") == choices and \
3537
- (have.get("automation") or {}) == auto:
3538
  return fkey # nothing would change β€” save the commit
3539
 
3540
  def _up(cur):
@@ -3547,8 +4306,14 @@ def ensure_stage_field(rt, table_key, defn, username="automation"):
3547
  f["options"] = choices
3548
  f["automation"] = auto
3549
  f["type"] = "select"
3550
- return cur
3551
- t["fields"].append(field)
 
 
 
 
 
 
3552
  return cur
3553
 
3554
  rt.update(UT_STORE_KEY, _up, flush="sync")
@@ -3732,12 +4497,24 @@ def move_card(rt, auto_id, row_id, to, username="", admin=False):
3732
  MAX_REVIEWS = 100
3733
 
3734
 
3735
- def review_audit(rt, auto_id, row_id, from_label, to_label, username):
 
3736
  """C3-A2(5): a review decision is AUDITED β€” who moved which card where, when. Appended to
3737
  the definition (newest first, bounded) by BOTH doors: `move_card` here, and the grid door
3738
- in `core.grid_events`, which writes the same shape mechanically off the field's `flowId`."""
 
 
 
 
 
 
3739
  entry = {"ts": _iso(), "user": _s(username, 80), "rowId": str(row_id),
3740
- "from": _s(from_label, 60), "to": _s(to_label, 60)}
 
 
 
 
 
3741
 
3742
  def _up(cur):
3743
  cur = cur if isinstance(cur, dict) else {}
@@ -3972,12 +4749,45 @@ def purge_subject(rt, handle):
3972
  # EVER (a high-water mark over row ids, so an undo-restored row cannot re-fire);
3973
  # event_field fires every transition.
3974
 
3975
- TRIGGER_KEYS = ("manual", "schedule", "event_field", "record_created", "webhook", "email")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3976
  TRIGGER_LABELS = {
3977
- "manual": "Manual", "schedule": "On a schedule",
3978
- "event_field": "When a field changes", "record_created": "When a record is created",
3979
- "webhook": "When the webhook is called", "email": "When an email arrives",
 
 
 
 
 
 
 
 
3980
  }
 
 
 
 
 
 
 
3981
  #: The settle window for field-change bursts (A2(1)). 0 evaluates INLINE β€” the gates run there,
3982
  #: and so would a deployment that prefers immediacy over coalescing.
3983
  EVENT_SETTLE_SECONDS = float(os.environ.get("AIOS_EVENT_SETTLE_SECONDS") or 15)
@@ -4016,6 +4826,11 @@ def clean_trigger(raw, previous=None):
4016
  prev = previous if isinstance(previous, dict) else {}
4017
  key = _s(raw.get("key") or raw.get("kind") or prev.get("key") or prev.get("kind"),
4018
  30).strip()
 
 
 
 
 
4019
  if key not in TRIGGER_KEYS:
4020
  return None, (f"{key or 'that trigger'!r} is not one of: " + ", ".join(TRIGGER_KEYS))
4021
  if key in ("manual", "schedule"):
@@ -4026,40 +4841,51 @@ def clean_trigger(raw, previous=None):
4026
  "enabled": bool(raw["enabled"]) if "enabled" in raw else
4027
  bool(prev.get("enabled", True)),
4028
  "paused": bool(raw["paused"]) if "paused" in raw else bool(prev.get("paused"))}
4029
- if key in ("event_field", "record_created"):
4030
  table = _s(raw.get("table") if "table" in raw else prev.get("table"), 60).strip()
4031
  if table and not table.startswith(UT_PREFIX):
4032
  return None, ("event triggers watch blank databases (ut_*) this wave β€” "
4033
  f"{table!r} is not one")
4034
  out["table"] = table
4035
  if key == "event_field":
4036
- fkey = _s(raw.get("field") if "field" in raw else prev.get("field"), 80).strip()
4037
- out["field"] = fkey
4038
- when_raw = raw.get("when") if "when" in raw else prev.get("when")
4039
- if when_raw in (None, ""):
4040
- out["when"] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4041
  else:
4042
- if not isinstance(when_raw, dict):
4043
- return None, "the trigger condition must be an object"
4044
- if not fkey:
4045
- return None, "name the watched field before adding a condition on it"
4046
- cf = _s(when_raw.get("field"), 80).strip() or fkey
4047
- if cf != fkey:
4048
- # The edge is evaluated over the WRITTEN value (see the section header); a
4049
- # condition on a different field would need a row image this evaluation does
4050
- # not have β€” refused with the reason, never quietly evaluated wrong.
4051
- return None, ("this wave a trigger condition tests the WATCHED field itself β€” "
4052
- f"the condition names {cf!r} but the trigger watches {fkey!r}")
4053
- op = _s(when_raw.get("op") or when_raw.get("operator"), 20).strip()
4054
- if op not in LANE_OPS:
4055
- return None, f"{op or 'that comparison'!r} is not one of: " + ", ".join(LANE_OPS)
4056
- cond = {"field": fkey, "op": op}
4057
- if op not in LANE_NULLARY_OPS:
4058
- v = when_raw.get("value")
4059
- if v is None or (isinstance(v, str) and not v.strip()):
4060
- return None, f"give a value to compare {fkey!r} against"
4061
- cond["value"] = v.strip() if isinstance(v, str) else v
4062
- out["when"] = cond
4063
  if key == "webhook":
4064
  # The token is MINTED here, once, and survives every later patch β€” rotating it on
4065
  # every Save would silently break the external caller the URL was given to.
@@ -4067,12 +4893,26 @@ def clean_trigger(raw, previous=None):
4067
  if key == "email":
4068
  out["query"] = _s(raw.get("query") if "query" in raw else prev.get("query"),
4069
  200).strip() or "in:inbox is:unread"
4070
- out["configured"] = bool(
4071
- out.get("table")) if key == "record_created" else bool(
4072
- out.get("table") and out.get("field")) if key == "event_field" else True
4073
  return out, None
4074
 
4075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4076
  def _secrets_token():
4077
  import secrets as _sec
4078
  return _sec.token_urlsafe(24)
@@ -4126,17 +4966,31 @@ _SETTLE = {}
4126
  _SETTLE_LOCK = threading.Lock()
4127
 
4128
 
4129
- def _settle_buffer(rt, tenant, auto_id, row_id, after, log=print):
 
 
 
 
 
 
 
 
 
 
 
 
 
4130
  key = (tenant, str(auto_id))
 
4131
  if EVENT_SETTLE_SECONDS <= 0:
4132
  with _SETTLE_LOCK:
4133
  buf = _SETTLE.setdefault(key, {"rows": {}})
4134
- buf["rows"][str(row_id)] = after
4135
  _settle_eval(rt, tenant, auto_id, log=log)
4136
  return
4137
  with _SETTLE_LOCK:
4138
  buf = _SETTLE.setdefault(key, {"rows": {}})
4139
- buf["rows"][str(row_id)] = after
4140
  timer = buf.get("timer")
4141
  if timer is not None:
4142
  timer.cancel() # the burst continues β€” push the window out
@@ -4147,24 +5001,88 @@ def _settle_buffer(rt, tenant, auto_id, row_id, after, log=print):
4147
  timer.start()
4148
 
4149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4150
  def _settle_eval(rt, tenant, auto_id, log=print):
4151
  """Evaluate one settled burst: edge over per-record armed state, flood hold, then fire."""
4152
  with _SETTLE_LOCK:
4153
  buf = _SETTLE.pop((tenant, str(auto_id)), None)
4154
- rows = (buf or {}).get("rows") or {}
4155
- if not rows:
 
4156
  return
4157
  d = all_definitions(rt).get(str(auto_id))
4158
  trg = (d or {}).get("trigger") or {}
4159
- if trg.get("key") != "event_field" or trg.get("paused") or not trg.get("enabled", True):
 
 
 
 
 
 
 
 
 
 
4160
  return
4161
- when = trg.get("when")
4162
  fired = []
4163
- if when:
 
4164
  disarmed = set((d.get("state") or {}).get("eventDisarmed") or [])
4165
  nxt = set(disarmed)
4166
- for rid, after in rows.items():
4167
- if lane_match(when, {when.get("field"): after}):
 
 
 
 
4168
  if rid not in disarmed:
4169
  fired.append(rid) # falseβ†’true THIS evaluation: the edge
4170
  nxt.add(rid)
@@ -4175,7 +5093,7 @@ def _settle_eval(rt, tenant, auto_id, log=print):
4175
  if not fired:
4176
  return
4177
  else:
4178
- fired = list(rows) # "the field changed" β€” the burst is the edge
4179
  if len(fired) > FLOOD_LIMIT:
4180
  _commit_run(rt, auto_id, "partial",
4181
  f"the trigger matched {len(fired)} records in one evaluation β€” more than "
@@ -4188,14 +5106,21 @@ def _settle_eval(rt, tenant, auto_id, log=print):
4188
  def _seed_event_state(rt, defn):
4189
  """A2(1)'s enable rule: records ALREADY matching when the trigger is set start DISARMED, so
4190
  turning the trigger on fires nothing β€” the first fire needs a real falseβ†’true transition.
4191
- Evaluated over the definition rows (the machine-written truth this trigger class watches)."""
 
 
 
 
 
4192
  trg = (defn or {}).get("trigger") or {}
4193
- if trg.get("key") != "event_field" or not trg.get("when") or not trg.get("configured"):
 
4194
  return
4195
- when = trg.get("when")
4196
- t = ut_get(rt, trg.get("table") or "")
4197
- matching = sorted(str(rid) for rid, row in ((t or {}).get("rows") or {}).items()
4198
- if lane_match(when, {when.get("field"): (row or {}).get(trg.get("field"))}))
 
4199
  set_state(rt, defn.get("id"), {"eventDisarmed": matching[:5000]})
4200
 
4201
 
@@ -4212,27 +5137,76 @@ def grid_hook(evt):
4212
  table = str(evt.get("table") or "")
4213
  kind = str(evt.get("type") or "")
4214
  defs = all_definitions(st)
 
 
4215
  for aid, d in defs.items():
4216
  trg = (d or {}).get("trigger") or {}
4217
- if trg.get("paused") or not trg.get("enabled", True):
 
 
 
 
4218
  continue
4219
- if kind == "record_created" and trg.get("key") == "record_created" \
4220
- and str(trg.get("table") or "") == table:
4221
- rid = str(evt.get("rowId") or "")
4222
  hw = _ig_int((d.get("state") or {}).get("rcHighwater")) or 0
4223
  ridn = int(rid) if rid.isdigit() else None
4224
  if ridn is None or ridn <= hw:
4225
  continue # once per record EVER (A2(4)) β€” undo-proof
4226
  set_state(st, aid, {"rcHighwater": ridn})
4227
  trigger_fire(st, tenant, aid)
4228
- elif kind == "event_field" and trg.get("key") == "event_field" \
4229
- and str(trg.get("table") or "") == table \
4230
- and str(trg.get("field") or "") == str(evt.get("field") or ""):
4231
- _settle_buffer(st, tenant, aid, str(evt.get("rowId") or ""), evt.get("after"))
 
 
 
 
 
 
 
 
 
4232
  except Exception as e: # noqa: BLE001
4233
  print(f"[aios-auto] trigger hook failed: {type(e).__name__}: {e}")
4234
 
4235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4236
  def hook_fire(rt, tenant, auto_id, token):
4237
  """The webhook trigger's decision, separated from FastAPI so the gate can drive it.
4238
  Returns `(status, payload)` β€” 404 unknown, 403 wrong/missing token or wrong trigger kind,
@@ -4393,6 +5367,27 @@ def run_now(rt, tenant, auto_id, username="automation", log=print):
4393
  log(f"[aios-auto] run {auto_id} failed: {type(e).__name__}: {e}")
4394
  return _commit_run(rt, auto_id, "error",
4395
  f"{type(e).__name__}: {str(e)[:200]}", {}, False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4396
  return _commit_run(rt, auto_id, state, summary, counts, state != "error", affected,
4397
  steps)
4398
  finally:
 
24
  from __future__ import annotations
25
 
26
  import datetime as _dt
27
+ import hmac
28
  import ipaddress
29
  import json
30
  import os
 
950
  return out
951
 
952
 
953
+ # ── WAVE 23 Β· C5 β€” the ENDING, and the cycle counter that makes a loop countable. ─────────────
954
+ # The owner's words: "we can make this automation a loop, so the ending should always be defined,
955
+ # either it ends somewhere deterministic like Closed/Failed, or it goes to reset automatically,
956
+ # or the user have to click a button to reset, or after a certain amount of time it can
957
+ # automatically reset to first cycle."
958
+ #
959
+ # β›” `terminal` IS THE DEFAULT and every existing automation gets it, because a stored definition
960
+ # that predates this field must not start moving records on its own the day the code ships. A
961
+ # loop is a thing somebody turns on.
962
+ ENDING_MODES = ("terminal", "auto_reset", "manual_reset", "timed_reset")
963
+ MAX_RESET_HOURS = 720 # 30 days; past that "it resets eventually" is a lie
964
+ #: The per-record cycle counter's cell suffix, beside `stage_<id>` and `stage_<id>_at`.
965
+ CYCLES_SUFFIX = "_cycles"
966
+
967
+
968
+ def cycles_field_key(auto_id):
969
+ return stage_field_key(auto_id) + CYCLES_SUFFIX
970
+
971
+
972
+ def clean_ending(raw, previous=None):
973
+ """Validate `{mode, hours?}`. Returns `(ending, error)`."""
974
+ raw = raw if isinstance(raw, dict) else {}
975
+ prev = previous if isinstance(previous, dict) else {}
976
+ mode = _s(raw.get("mode") or prev.get("mode"), 20).strip() or "terminal"
977
+ if mode not in ENDING_MODES:
978
+ return None, f"{mode!r} is not one of: " + ", ".join(ENDING_MODES)
979
+ out = {"mode": mode}
980
+ if mode == "timed_reset":
981
+ try:
982
+ hours = int(raw.get("hours") if raw.get("hours") is not None else prev.get("hours")
983
+ or 24)
984
+ except (TypeError, ValueError):
985
+ return None, "the reset delay must be a whole number of hours"
986
+ if hours < 1 or hours > MAX_RESET_HOURS:
987
+ return None, f"the reset delay is between 1 and {MAX_RESET_HOURS} hours"
988
+ out["hours"] = hours
989
+ return out, None
990
+
991
+
992
+ def clean_flow(raw, previous=None):
993
+ """Validate `{actions, ending}` β€” the builder's half of a definition. `(flow, error)`."""
994
+ raw = raw if isinstance(raw, dict) else {}
995
+ prev = previous if isinstance(previous, dict) else {}
996
+ actions, err = clean_actions(raw.get("actions") if "actions" in raw else prev.get("actions"))
997
+ if err:
998
+ return None, err
999
+ ending, err = clean_ending(raw.get("ending") if "ending" in raw else prev.get("ending"),
1000
+ prev.get("ending"))
1001
+ if err:
1002
+ return None, err
1003
+ return {"actions": actions, "ending": ending}, None
1004
+
1005
+
1006
  def clean_definition(raw, previous=None, username=""):
1007
  """Whole-definition validation. Returns `(defn, error)`."""
1008
  raw = raw if isinstance(raw, dict) else {}
 
1027
  else prev.get("trigger"), prev.get("trigger"))
1028
  if terr:
1029
  return None, terr
1030
+ flow, ferr = clean_flow(raw.get("flow") if "flow" in raw else prev.get("flow"),
1031
+ prev.get("flow"))
1032
+ if ferr:
1033
+ return None, ferr
1034
  return {
1035
  "id": _s(prev.get("id") or raw.get("id"), 40),
1036
  "name": name, "kind": kind, "config": config, "schedule": schedule,
1037
  "trigger": trigger,
1038
+ # WAVE 23 Β· C4/C5 β€” the BUILDER's half: the ordered actions a record walks and what
1039
+ # happens when it reaches the end. Absent on every definition written before this wave,
1040
+ # which is exactly why `clean_flow` defaults it to "no actions, terminal ending": the
1041
+ # code shipping must not put existing automations into a loop.
1042
+ "flow": flow,
1043
  "status": prev.get("status") or {"state": "idle", "lastRunAt": "", "lastSummary": ""},
1044
  "runs": list(prev.get("runs") or [])[:MAX_RUNS],
1045
  # C3-A2(5): the review-decision audit survives a Save the way `state` does β€” an
 
1090
  return f"auto_{n}"
1091
 
1092
 
1093
+ #: C2's three answers to "which database does this automation work on?" β€” the FIRST question the
1094
+ #: create wizard asks (owner item 3: the kind is not the first thing a person picks, the data is).
1095
+ TARGET_MODES = ("existing", "new", "automated")
1096
+
1097
+
1098
+ def resolve_target(rt, raw, username=""):
1099
+ """C2: turn the wizard's `target` into a bound table key. Returns `(config_patch, error)`.
1100
+
1101
+ - `existing` β€” bind a database that is already there.
1102
+ - `new` β€” mint a blank one now, so the automation has somewhere to write before its first
1103
+ run instead of conjuring a table the person never agreed to.
1104
+ - `automated` β€” leave it to the runner: a scraping automation MINTS its target on first run
1105
+ (`ut_ensure`), stamped `source: "Automation"`, which is what makes it a machine database.
1106
+ Nothing is created here, deliberately β€” an empty table created up front for a scrape that
1107
+ never runs is litter nobody can explain.
1108
+ """
1109
+ if not isinstance(raw, dict) or not raw:
1110
+ return {}, None # no target block = the pre-wizard shape
1111
+ mode = _s(raw.get("mode"), 20).strip() or "existing"
1112
+ if mode not in TARGET_MODES:
1113
+ return None, f"{mode!r} is not one of: " + ", ".join(TARGET_MODES)
1114
+ if mode == "automated":
1115
+ label = " ".join(_s(raw.get("label"), 60).split())
1116
+ return ({"targetLabel": label} if label else {}), None
1117
+ if mode == "existing":
1118
+ key = _s(raw.get("table"), 60).strip()
1119
+ if not key:
1120
+ return None, "choose the database this automation works on"
1121
+ t = ut_get(rt, key)
1122
+ if t is None:
1123
+ return None, f"{key!r} is not a database in this workspace"
1124
+ return {"targetTable": key, "targetLabel": t.get("label") or key}, None
1125
+ label = " ".join(_s(raw.get("label"), 60).split())
1126
+ if not label:
1127
+ return None, "name the new database"
1128
+ import core.user_tables as _ut_new
1129
+ key = _ut_new.create(label, username or "automation", st=rt)
1130
+ if not key:
1131
+ return None, ("the database could not be created β€” this workspace may be at its table "
1132
+ "limit")
1133
+ return {"targetTable": key, "targetLabel": label}, None
1134
+
1135
+
1136
  def create(rt, raw, username=""):
1137
  existing = all_definitions(rt)
1138
  if len(existing) >= MAX_AUTOMATIONS:
1139
  return None, f"this workspace is at the {MAX_AUTOMATIONS}-automation limit"
1140
+ raw = dict(raw or {})
1141
+ # C2 runs BEFORE validation so a `new` target's freshly-minted key is part of the config
1142
+ # `clean_definition` sees β€” one write, not a create-then-patch the client has to sequence.
1143
+ patch_cfg, terr = resolve_target(rt, raw.pop("target", None), username)
1144
+ if terr:
1145
+ return None, terr
1146
+ if patch_cfg:
1147
+ raw["config"] = {**(raw.get("config") or {}), **patch_cfg}
1148
  defn, err = clean_definition(raw, None, username)
1149
  if err:
1150
  return None, err
 
1168
  if prev is None:
1169
  return None, "no such automation"
1170
  merged = dict(prev)
1171
+ # ⚠ A HAND-MAINTAINED PATCHABLE-KEY LIST, and it is the silent-drop seat of this module: a
1172
+ # key missing here is accepted by the route, validated by `clean_definition`, and then
1173
+ # DISCARDED β€” the client shows a saved flow that the store never received, and nothing goes
1174
+ # red. `flow` joined it in the same edit that created `flow` (wave 23), which is the only
1175
+ # ordering that never has a window where the bug exists.
1176
+ for k in ("name", "config", "schedule", "kind", "trigger", "flow"):
1177
  if k in (raw or {}):
1178
  merged[k] = raw[k]
1179
  defn, err = clean_definition(merged, prev, username)
 
3201
 
3202
  on = bool(sched.get("enabled"))
3203
  trg = defn.get("trigger") or {}
3204
+ if trg.get("key") in TRIGGER_KEYS and trg.get("key") not in ("manual", "schedule"):
3205
  # C3 (wave 22): the trigger node SAYS which trigger this flow has β€” the board and rail
3206
+ # read the same node, so an event-triggered flow must not read "Schedule". The membership
3207
+ # test is the KEY SET, not a hand-listed tuple: wave 23 added four triggers and the old
3208
+ # tuple would have quietly rendered every one of them as "Schedule" (they are stored, so
3209
+ # the else-branch was reachable) β€” green, compiling, and wrong on screen.
3210
  t_on = bool(trg.get("enabled", True)) and not trg.get("paused")
3211
  sub = TRIGGER_LABELS.get(trg["key"], trg["key"])
3212
+ watched = ", ".join(trg.get("fields") or []) or "any field"
3213
+ det = {"event_field": (f"{trg.get('table', '')}"
3214
+ + (f".{trg.get('field')}" if trg.get("field") else "")
3215
+ + (f" β€” {_lane_sentence(trg.get('when'))}"
3216
+ if trg.get("when") else "")),
3217
+ "record_updated": f"{trg.get('table', '')} β€” {watched}",
3218
  "record_created": str(trg.get("table") or ""),
3219
+ "enters_view": f"{trg.get('table', '')} β€” view {trg.get('viewId') or 'unset'}",
3220
+ "form_submitted": str(trg.get("table") or ""),
3221
  "webhook": "POST the hook URL to fire it",
3222
  "email": str(trg.get("query") or "")}.get(trg["key"], "")
3223
+ if not trg.get("configured", True):
3224
+ det = "Finish setting this trigger up before it can fire"
3225
  if trg.get("paused"):
3226
  det = str(defn.get("statusNote") or "paused")
3227
  nodes = [node("trigger", "trigger", sub,
 
3410
  LANE_OPS = ("=", "!=", ">", ">=", "<", "<=", "includes", "not_includes",
3411
  "is_empty", "is_not_empty")
3412
  LANE_NULLARY_OPS = ("is_empty", "is_not_empty")
3413
+
3414
+ # ─────────────────────────────────────────────────────────────────────────────────────────────
3415
+ # WAVE 23 Β· C4 β€” CONDITION TREES. `Cond = leaf | {all: [Cond…]} | {any: [Cond…]}`
3416
+ #
3417
+ # A leaf is exactly what wave 22 called a lane condition (`{field, op, value?}`), which is why
3418
+ # this generalisation needed no migration: a stored leaf IS a valid tree, `cond_match` dispatches
3419
+ # on shape, and nothing at rest is rewritten until the owner saves that automation. (The doc
3420
+ # calls this "legacy single-condition lanes auto-wrap as {all:[leaf]} on read" β€” wrapping is the
3421
+ # same function applied one level up, so the cheaper honest version is to evaluate the leaf where
3422
+ # it lies and never touch the bytes.)
3423
+ #
3424
+ # β›” DEPTH IS BOUNDED AND THE BOUND IS ENFORCED AT WRITE TIME, not at evaluation time. An
3425
+ # unbounded tree is an unbounded evaluation on a hook that runs inside somebody's keystroke, and
3426
+ # "the server got slow" is the failure nobody traces back to a filter somebody nested 40 deep.
3427
+ # β›” REFUSE-NEVER-COERCE all the way down (`clean_predicates`' discipline): an empty group, an
3428
+ # unknown comparison, a valueless compare are all REFUSED with the reason named. A group that
3429
+ # quietly dropped its unanswerable leaf would WIDEN β€” the tri-state scar the filter engine
3430
+ # carries ([[cg-filter-engine-sql-port]]), reproduced here where nothing would report it.
3431
+ MAX_COND_DEPTH = 3
3432
+ MAX_COND_CHILDREN = 12
3433
+ COND_GROUP_KEYS = ("all", "any")
3434
+
3435
+
3436
+ def clean_cond(raw, depth=0, where=""):
3437
+ """Validate one condition TREE. Returns `(cond|None, error)`; `(None, None)` means "no
3438
+ condition", which is legal everywhere a condition is optional (the catch-all lane, an
3439
+ unconditioned trigger, an action group that always runs)."""
3440
+ if raw in (None, "", {}):
3441
+ return None, None
3442
+ at = f" on {where}" if where else ""
3443
+ if not isinstance(raw, dict):
3444
+ return None, f"the condition{at} must be an object"
3445
+ conj = [k for k in COND_GROUP_KEYS if k in raw]
3446
+ if len(conj) > 1:
3447
+ return None, (f"the condition{at} sets both 'all' and 'any' β€” a group is one or the "
3448
+ f"other")
3449
+ if conj:
3450
+ key = conj[0]
3451
+ if depth + 1 > MAX_COND_DEPTH:
3452
+ return None, (f"conditions nest at most {MAX_COND_DEPTH} levels deep β€” "
3453
+ f"the group{at} is deeper")
3454
+ kids_raw = raw.get(key)
3455
+ if not isinstance(kids_raw, list) or not kids_raw:
3456
+ return None, f"the '{key}' group{at} needs at least one condition inside it"
3457
+ if len(kids_raw) > MAX_COND_CHILDREN:
3458
+ return None, (f"a group holds at most {MAX_COND_CHILDREN} conditions β€” "
3459
+ f"the '{key}' group{at} has {len(kids_raw)}")
3460
+ kids = []
3461
+ for child in kids_raw:
3462
+ c, err = clean_cond(child, depth + 1, where)
3463
+ if err:
3464
+ return None, err
3465
+ if c is None:
3466
+ return None, (f"an empty condition sits inside the '{key}' group{at} β€” "
3467
+ f"finish it or remove it")
3468
+ kids.append(c)
3469
+ return {key: kids}, None
3470
+ field = _s(raw.get("field"), 80).strip()
3471
+ op = _s(raw.get("op") or raw.get("operator"), 20).strip()
3472
+ if not field:
3473
+ return None, f"the condition{at} names no field"
3474
+ if op not in LANE_OPS:
3475
+ return None, f"{op or 'that comparison'!r} is not one of: " + ", ".join(LANE_OPS)
3476
+ cond = {"field": field, "op": op}
3477
+ if op not in LANE_NULLARY_OPS:
3478
+ v = raw.get("value")
3479
+ if v is None or (isinstance(v, str) and not v.strip()):
3480
+ return None, f"give a value to compare {field!r} against{at}"
3481
+ cond["value"] = v.strip() if isinstance(v, str) else v
3482
+ return cond, None
3483
+
3484
+
3485
+ def cond_fields(cond):
3486
+ """Every field name a tree reads β€” the set a caller must have on the row image before the
3487
+ answer means anything."""
3488
+ if not isinstance(cond, dict):
3489
+ return set()
3490
+ for key in COND_GROUP_KEYS:
3491
+ if key in cond:
3492
+ out = set()
3493
+ for child in cond.get(key) or []:
3494
+ out |= cond_fields(child)
3495
+ return out
3496
+ f = cond.get("field")
3497
+ return {f} if f else set()
3498
  #: Lane labels a flow already uses for its fixed stages. A lane literally called "Review" would
3499
  #: collide with the stage the label is a choice FOR, and the board could no longer tell a
3500
  #: routed card from a gated one.
 
3535
  lid = f"{base}_{n}"
3536
  n += 1
3537
  seen_ids.add(lid)
3538
+ cond, cerr = clean_cond(entry.get("when"), where=repr(label))
3539
+ if cerr:
3540
+ return None, cerr
3541
+ if cond is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3542
  defaults += 1
3543
  if defaults > 1:
3544
  return None, ("only one lane may have no condition β€” it is the catch-all, "
 
3555
 
3556
 
3557
  def lane_match(cond, row):
3558
+ """Does `row` satisfy this condition TREE? None matches everything (the catch-all).
3559
+
3560
+ Named for the lane that first needed it; it is now C4's whole evaluator, and every wave-22
3561
+ caller (`route_record`, `_settle_eval`, `_seed_event_state`) gained tree support by keeping
3562
+ this name rather than growing a second entry point that could disagree with it.
3563
 
3564
  β›” REFUSE-NEVER-COERCE, per record: an ordering comparison whose either side does not parse
3565
  as a number answers False β€” the record simply does not enter the lane β€” never "treat blank
 
3568
  """
3569
  if cond is None:
3570
  return True
3571
+ if isinstance(cond, dict):
3572
+ if "all" in cond:
3573
+ return all(lane_match(c, row) for c in cond.get("all") or [])
3574
+ if "any" in cond:
3575
+ return any(lane_match(c, row) for c in cond.get("any") or [])
3576
  raw = (row or {}).get(cond.get("field"))
3577
  op = cond.get("op")
3578
  if op == "is_empty":
 
3608
  return ""
3609
 
3610
 
3611
+ # ─────────────────────────────────────────────────────────────────────────────────────────────
3612
+ # WAVE 23 Β· C4 β€” ACTIONS (owner ruling R3). The half of the Airtable builder that DOES things.
3613
+ #
3614
+ # `flow.actions` is an ordered list walked per RECORD, after the flow's machine steps have run.
3615
+ # A `group` holds nested actions behind a condition β€” Airtable's "conditional action group",
3616
+ # which the owner explicitly asked to be NESTABLE (R3 supersedes the wave-22 research brief's
3617
+ # rec-8 "no nesting" verdict; that recommendation was about keeping a CANVAS legible, and this
3618
+ # builder is a column, not a canvas).
3619
+ #
3620
+ # β›” THE CATALOG IS SERVER-OWNED AND INCLUDES WHAT WE HAVE NOT BUILT. `ACTION_CATALOG` carries a
3621
+ # `ready` flag per kind, so the "+ Add advanced logic or action" menu paints Send email / Slack /
3622
+ # Run script / Generate with AI faded with a reason instead of omitting them β€” the same honesty
3623
+ # rule the trigger list follows, and the same enforcement: `clean_actions` REFUSES an unready
3624
+ # kind with a sentence, so the faded state is a wall and not a styling choice.
3625
+ MAX_ACTIONS = 20 # per automation, counting nested ones
3626
+ MAX_GROUP_DEPTH = 2 # a group inside a group inside a group is a flowchart, not a flow
3627
+ MAX_ACTION_VALUES = 20 # cells one update/create action may write
3628
+ FIND_LIMIT_MAX = 100
3629
+ ACTION_KINDS = ("group", "update_record", "create_record", "find_records", "review")
3630
+ ACTION_CATALOG = [
3631
+ {"kind": "group", "label": "Conditional logic", "group": "Advanced logic", "ready": True,
3632
+ "detail": "Run different actions based off a certain condition"},
3633
+ {"kind": "update_record", "label": "Update record", "group": "Actions", "ready": True,
3634
+ "detail": "Write values onto the record walking the flow"},
3635
+ {"kind": "create_record", "label": "Create record", "group": "Actions", "ready": True,
3636
+ "detail": "Add a row to another database"},
3637
+ {"kind": "find_records", "label": "Find records", "group": "Actions", "ready": True,
3638
+ "detail": "Look rows up by condition; the run log opens them"},
3639
+ {"kind": "review", "label": "Manual review", "group": "Advanced logic", "ready": True,
3640
+ "detail": "Hold the record for a person β€” or an AI β€” to decide where it goes next"},
3641
+ {"kind": "repeating_group", "label": "Repeating group", "group": "Advanced logic",
3642
+ "ready": False, "detail": "Run the same actions on every item in a list"},
3643
+ {"kind": "send_email", "label": "Send email", "group": "Actions", "ready": False,
3644
+ "detail": "Needs a send scope on the Gmail connection"},
3645
+ {"kind": "slack", "label": "Send Slack message", "group": "Actions", "ready": False,
3646
+ "detail": "Needs the Slack connector"},
3647
+ {"kind": "run_script", "label": "Run script", "group": "Actions", "ready": False,
3648
+ "detail": "Not built β€” a sandbox is its own decision"},
3649
+ {"kind": "generate_ai", "label": "Generate with AI", "group": "Actions", "ready": False,
3650
+ "detail": "Review stages can already be decided by AI"},
3651
+ ]
3652
+ REVIEW_DECIDERS = ("user", "ai")
3653
+ MAX_AI_PROMPT = 600
3654
+
3655
+
3656
+ def action_catalog():
3657
+ """The catalog as the wire carries it β€” a copy, because a caller that mutated the module
3658
+ constant would change every later reader's answer."""
3659
+ return [dict(a) for a in ACTION_CATALOG]
3660
+
3661
+
3662
+ def _action_label(act):
3663
+ for row in ACTION_CATALOG:
3664
+ if row["kind"] == act.get("kind"):
3665
+ return row["label"]
3666
+ return str(act.get("kind") or "Action")
3667
+
3668
+
3669
+ def clean_actions(raw, depth=0, _seen=None, _count=None):
3670
+ """Validate `flow.actions`. Returns `(actions, error)` β€” refuses, never coerces.
3671
+
3672
+ Ids are STABLE: a caller's well-formed `id` is kept, so selecting an action in the builder
3673
+ survives a Save. A missing or colliding one is minted `act_<n>`; minting on every clean would
3674
+ move the selection under the person editing it.
3675
+ """
3676
+ if raw in (None, ""):
3677
+ return [], None
3678
+ if not isinstance(raw, list):
3679
+ return None, "actions must be a list"
3680
+ seen = _seen if _seen is not None else set()
3681
+ count = _count if _count is not None else [0]
3682
+ out = []
3683
+ for entry in raw:
3684
+ if not isinstance(entry, dict):
3685
+ return None, "each action must be an object with a kind"
3686
+ kind = _s(entry.get("kind"), 30).strip()
3687
+ row = next((r for r in ACTION_CATALOG if r["kind"] == kind), None)
3688
+ if row is None:
3689
+ return None, (f"{kind or 'that action'!r} is not one of: "
3690
+ + ", ".join(a["kind"] for a in ACTION_CATALOG))
3691
+ if not row["ready"]:
3692
+ return None, (f"{row['label']!r} is on the menu but not built yet β€” "
3693
+ f"{row['detail'][0].lower()}{row['detail'][1:]}")
3694
+ count[0] += 1
3695
+ if count[0] > MAX_ACTIONS:
3696
+ return None, f"an automation runs at most {MAX_ACTIONS} actions"
3697
+ aid = _s(entry.get("id"), 40).strip()
3698
+ if not re.fullmatch(r"act_[a-z0-9_]{1,32}", aid or "") or aid in seen:
3699
+ n = 1
3700
+ while f"act_{n}" in seen:
3701
+ n += 1
3702
+ aid = f"act_{n}"
3703
+ seen.add(aid)
3704
+ when, cerr = clean_cond(entry.get("when"), where=f"action {aid}")
3705
+ if cerr:
3706
+ return None, cerr
3707
+ cfg_raw = entry.get("config") if isinstance(entry.get("config"), dict) else {}
3708
+ cfg, cerr = _clean_action_config(kind, cfg_raw, depth, seen, count)
3709
+ if cerr:
3710
+ return None, cerr
3711
+ out.append({"id": aid, "kind": kind,
3712
+ "enabled": bool(entry.get("enabled", True)),
3713
+ "when": when, "config": cfg})
3714
+ return out, None
3715
+
3716
+
3717
+ def _clean_action_config(kind, cfg, depth, seen, count):
3718
+ """One action's `config`, per kind. Returns `(config, error)`."""
3719
+ if kind == "group":
3720
+ if depth + 1 > MAX_GROUP_DEPTH:
3721
+ return None, (f"conditional groups nest at most {MAX_GROUP_DEPTH} deep β€” "
3722
+ f"past that a flow is a flowchart and belongs on the board")
3723
+ cond, cerr = clean_cond(cfg.get("cond"), where="the group")
3724
+ if cerr:
3725
+ return None, cerr
3726
+ kids, kerr = clean_actions(cfg.get("actions"), depth + 1, seen, count)
3727
+ if kerr:
3728
+ return None, kerr
3729
+ if not kids:
3730
+ return None, "a conditional group with no actions inside it does nothing"
3731
+ return {"cond": cond, "actions": kids}, None
3732
+ if kind in ("update_record", "create_record"):
3733
+ values = cfg.get("values")
3734
+ if not isinstance(values, dict) or not values:
3735
+ return None, f"the {kind.replace('_', ' ')} action writes no values"
3736
+ if len(values) > MAX_ACTION_VALUES:
3737
+ return None, f"one action writes at most {MAX_ACTION_VALUES} cells"
3738
+ clean_vals = {}
3739
+ for k, v in values.items():
3740
+ key = _s(k, 80).strip()
3741
+ if not key:
3742
+ return None, "a value is written to a field with no name"
3743
+ if v is not None and not isinstance(v, (str, int, float, bool)):
3744
+ return None, (f"the value for {key!r} must be text or a number β€” "
3745
+ f"an automation writes cells, not objects")
3746
+ clean_vals[key] = _s(v, 500) if isinstance(v, str) else v
3747
+ out = {"values": clean_vals}
3748
+ if kind == "create_record":
3749
+ table = _s(cfg.get("table"), 60).strip()
3750
+ if not table:
3751
+ return None, "the create record action names no database"
3752
+ if not table.startswith(UT_PREFIX):
3753
+ return None, (f"actions write to blank databases (ut_*) this wave β€” "
3754
+ f"{table!r} is not one")
3755
+ out["table"] = table
3756
+ return out, None
3757
+ if kind == "find_records":
3758
+ table = _s(cfg.get("table"), 60).strip()
3759
+ if not table:
3760
+ return None, "the find records action names no database"
3761
+ cond, cerr = clean_cond(cfg.get("cond"), where="the find")
3762
+ if cerr:
3763
+ return None, cerr
3764
+ try:
3765
+ limit = int(cfg.get("limit") or 25)
3766
+ except (TypeError, ValueError):
3767
+ return None, "the find records limit must be a whole number"
3768
+ if limit < 1 or limit > FIND_LIMIT_MAX:
3769
+ return None, f"the find records limit is between 1 and {FIND_LIMIT_MAX}"
3770
+ return {"table": table, "cond": cond, "limit": limit}, None
3771
+ if kind == "review":
3772
+ by = _s(cfg.get("decidedBy"), 10).strip() or "user"
3773
+ if by not in REVIEW_DECIDERS:
3774
+ return None, f"a review is decided by one of: {', '.join(REVIEW_DECIDERS)}"
3775
+ label = " ".join(_s(cfg.get("label"), 60).split()) or "Review"
3776
+ if label.strip().lower() in _RESERVED_STAGE_LABELS and label != "Review":
3777
+ return None, f"{label!r} is the name of a fixed stage β€” name the review something else"
3778
+ nxt = cfg.get("next")
3779
+ outs = []
3780
+ if isinstance(nxt, list):
3781
+ for entry in nxt[:MAX_LANES]:
3782
+ lbl = " ".join(_s(entry, 60).split())
3783
+ if lbl and lbl not in outs:
3784
+ outs.append(lbl)
3785
+ if not outs:
3786
+ outs = ["Approved", "Declined"]
3787
+ prompt = _s(cfg.get("prompt"), MAX_AI_PROMPT).strip()
3788
+ if by == "ai" and not prompt:
3789
+ return None, ("an AI review needs a prompt β€” tell it what to decide, or set the "
3790
+ "review back to a person")
3791
+ return {"decidedBy": by, "label": label, "next": outs, "prompt": prompt}, None
3792
+ return {}, None
3793
+
3794
+
3795
+ def walk_actions(actions):
3796
+ """Every action in the tree, depth-first, groups included. One walker, so "how many actions
3797
+ does this flow have" and "which review stages exist" cannot answer differently."""
3798
+ for act in actions or []:
3799
+ yield act
3800
+ if act.get("kind") == "group":
3801
+ for kid in walk_actions((act.get("config") or {}).get("actions")):
3802
+ yield kid
3803
+
3804
+
3805
+ def review_actions(defn):
3806
+ """The review actions of one definition, in flow order β€” the stages a card can REST at."""
3807
+ flow = (defn or {}).get("flow") or {}
3808
+ return [a for a in walk_actions(flow.get("actions")) if a.get("kind") == "review"
3809
+ and a.get("enabled", True)]
3810
+
3811
+
3812
+ def interpolate(text, row):
3813
+ """`{{field_key}}` β†’ the record's value. Unknown keys resolve to EMPTY, deliberately: an
3814
+ action that wrote the literal `{{status}}` into a cell because somebody typo'd the key would
3815
+ put template syntax in front of a customer, and a blank is the visible failure."""
3816
+ if not isinstance(text, str) or "{{" not in text:
3817
+ return text
3818
+ def _sub(m):
3819
+ v = (row or {}).get(m.group(1).strip())
3820
+ return "" if v is None else str(v)
3821
+ return re.sub(r"\{\{([^}]{1,80})\}\}", _sub, text)
3822
+
3823
+
3824
  def stages_for(defn):
3825
  """One automation β†’ `(stages, routes)` β€” C1's vocabulary, derived from `graph()` so the
3826
  board can never disagree with the runner about what the flow does.
 
3898
  routes.append({"from": "write", "to": lane["id"],
3899
  "label": lane["label"] if cond is None else _lane_sentence(cond),
3900
  "condition": dict(cond) if cond else None})
3901
+
3902
+ # ── WAVE 23 Β· C4/C6 β€” REVIEW ACTIONS ARE STAGES. ─────────────────────────────────────────
3903
+ # A review action is the one action kind that HOLDS a record rather than doing something to
3904
+ # it, so it has to exist on the board: a flow whose review lived only in the builder would
3905
+ # gate records at a place the kanban cannot show, which is the whole complaint the board was
3906
+ # built to answer. Its declared exits become assignable stages too β€” a gate with nowhere to
3907
+ # go is a hole records fall into.
3908
+ tail = max((s["col"] for s in stages), default=0)
3909
+ for i, act in enumerate(review_actions(defn)):
3910
+ acfg = act.get("config") or {}
3911
+ rid = act["id"]
3912
+ stages.append({"id": rid, "label": acfg.get("label") or "Review", "kind": "review",
3913
+ "col": tail + 1 + (i * 2), "row": 0, "enabled": True, "toggle": "",
3914
+ "status": "idle", "panel": "review_action", "posture": "strict",
3915
+ "record": True, "assignable": True, "actionId": rid,
3916
+ "decidedBy": acfg.get("decidedBy") or "user",
3917
+ "detail": ("An AI decides where each card goes β€” a person can override it"
3918
+ if acfg.get("decidedBy") == "ai"
3919
+ else "Cards wait here for a person to move them on")})
3920
+ for j, label in enumerate(acfg.get("next") or []):
3921
+ oid = f"{rid}_out_{j}"
3922
+ stages.append({"id": oid, "label": label, "kind": "action",
3923
+ "col": tail + 2 + (i * 2), "row": j, "enabled": True, "toggle": "",
3924
+ "status": "idle", "panel": "review_action", "posture": "auto",
3925
+ "record": True, "assignable": True, "actionId": rid,
3926
+ "detail": f"Where {acfg.get('label') or 'Review'} sends a card"})
3927
+ routes.append({"from": rid, "to": oid, "label": label, "condition": None})
3928
  return stages, routes
3929
 
3930
 
3931
+ def terminal_stage_ids(stages, routes):
3932
+ """Stages a record can REST at with nowhere further to go β€” the ends C5's `ending` acts on.
3933
+ Derived from the routes rather than declared, so a flow cannot gain an exit without this
3934
+ noticing (and a lane that stops being terminal stops being reset)."""
3935
+ assignable = {s["id"] for s in stages if s.get("assignable")}
3936
+ onward = {r["from"] for r in routes if r["to"] in assignable and r["to"] != r["from"]}
3937
+ return [sid for sid in assignable if sid not in onward]
3938
+
3939
+
3940
+ def _stage_of(stages, sid):
3941
+ for s in stages:
3942
+ if s["id"] == sid:
3943
+ return s
3944
+ return None
3945
+
3946
+
3947
+ # ─────────────────────────────────────────────────────────────────────────────────────────────
3948
+ # WAVE 23 Β· C4/C5/C6 β€” THE ACTION RUNNER. One record at a time, actions in declared order.
3949
+ #
3950
+ # β›” ONE COALESCED WRITE PER TABLE PER RUN. The runner accumulates patches in memory and commits
3951
+ # them once at the end (the 256-commits/hr budget every writer in this module respects). A
3952
+ # per-action `rt.update` would be correct and would also spend the tenant's whole commit budget
3953
+ # on one busy flow.
3954
+ # β›” A REVIEW SUSPENDS THAT RECORD AND NOTHING ELSE. When a card reaches a review action the
3955
+ # runner stamps its stage and stops walking THAT record β€” later actions belong to the branch a
3956
+ # human (or the AI) has not chosen yet. Other records keep going; a gate is not a global pause.
3957
+ # β›” EVERY WRITE HERE IS MACHINE-ORIGIN and goes through this module's own writers, never the
3958
+ # human doors β€” which is exactly what keeps A2(2)'s loop prevention structural: an action's write
3959
+ # cannot fire an event trigger, its own or a sibling's.
3960
+
3961
+ def _act_row_patch(patches, table, rid, values):
3962
+ patches.setdefault(table, {}).setdefault(str(rid), {}).update(values)
3963
+
3964
+
3965
+ def _flow_table(defn):
3966
+ """The database a flow's actions and ending operate on: the automation's own target, or the
3967
+ table its event trigger watches when the flow has no target of its own."""
3968
+ cfg = (defn or {}).get("config") or {}
3969
+ if defn.get("kind") == "discover_instagram":
3970
+ return cfg.get("targetTable") or DISCOVER_TABLE
3971
+ return cfg.get("targetTable") or ((defn.get("trigger") or {}).get("table") or "")
3972
+
3973
+
3974
+ def apply_actions(rt, defn, table_key, row_ids, username="automation", log=print):
3975
+ """Walk `flow.actions` for each of `row_ids`, then apply the ENDING. Returns the run counts.
3976
+
3977
+ ⚠ The ending runs even when there are NO actions, and that is not a detail: a discovery
3978
+ automation's terminal stages (`tracked`/`declined`) come from `stages_for`, not from actions,
3979
+ so an early return on an empty action list would have silently disabled the owner's loop
3980
+ feature on the flagship flow β€” the one kind most likely to want `auto_reset`. Caught in
3981
+ review; the tests all happened to pass a non-empty action list, which is why it was green.
3982
+ """
3983
+ flow = (defn or {}).get("flow") or {}
3984
+ actions = flow.get("actions") or []
3985
+ counts = {"actionsRun": 0, "updated": 0, "created": 0, "found": 0, "review": 0,
3986
+ "aiDecided": 0, "cycles": 0}
3987
+ if not table_key:
3988
+ return counts
3989
+ table = str(table_key)
3990
+ rows = dict((ut_get(rt, table) or {}).get("rows") or {})
3991
+ if not rows:
3992
+ return counts
3993
+ stages, routes = stages_for(defn)
3994
+ skey = stage_field_key(defn.get("id"))
3995
+ stamp = _iso()
3996
+ patches, creates, ai_budget = {}, {}, [AI_DECISIONS_PER_RUN]
3997
+
3998
+ def _walk(acts, row, rid, depth=0):
3999
+ """Returns True when the record was SUSPENDED (a review holds it)."""
4000
+ for act in acts:
4001
+ if not act.get("enabled", True):
4002
+ continue
4003
+ if not lane_match(act.get("when"), row):
4004
+ continue
4005
+ kind = act.get("kind")
4006
+ cfg = act.get("config") or {}
4007
+ counts["actionsRun"] += 1
4008
+ if kind == "group":
4009
+ if not lane_match(cfg.get("cond"), row):
4010
+ continue
4011
+ if _walk(cfg.get("actions") or [], row, rid, depth + 1):
4012
+ return True
4013
+ elif kind == "update_record":
4014
+ vals = {k: interpolate(v, row) for k, v in (cfg.get("values") or {}).items()}
4015
+ row.update(vals) # later actions see the write, as they must
4016
+ _act_row_patch(patches, table, rid, vals)
4017
+ counts["updated"] += 1
4018
+ elif kind == "create_record":
4019
+ target = str(cfg.get("table") or "")
4020
+ vals = {k: interpolate(v, row) for k, v in (cfg.get("values") or {}).items()}
4021
+ creates.setdefault(target, []).append(vals)
4022
+ counts["created"] += 1
4023
+ elif kind == "find_records":
4024
+ found = find_records(rt, cfg.get("table"), cfg.get("cond"),
4025
+ int(cfg.get("limit") or 25))
4026
+ counts["found"] += len(found)
4027
+ elif kind == "review":
4028
+ label = cfg.get("label") or "Review"
4029
+ at = str(row.get(skey) or "").strip()
4030
+ if at == label:
4031
+ return True # already waiting here β€” do not re-stamp
4032
+ if at and at in (cfg.get("next") or []):
4033
+ # β›” ALREADY DECIDED β€” the card went through this gate on an earlier run, so
4034
+ # the flow continues past it. Without this the next run would stamp a
4035
+ # finished card back to the review gate and hold it there again, forever:
4036
+ # a card could never leave a flow that runs on a schedule. Found by this
4037
+ # wave's own terminal-ending negative control, which is the argument for
4038
+ # writing the NC in the same change as the feature.
4039
+ continue
4040
+ decided = ""
4041
+ if cfg.get("decidedBy") == "ai" and ai_budget[0] > 0:
4042
+ ai_budget[0] -= 1
4043
+ decided = ai_decide(rt, defn, act, row, rid, log=log)
4044
+ if decided:
4045
+ counts["aiDecided"] += 1
4046
+ _act_row_patch(patches, table, rid,
4047
+ {skey: decided, skey + "_at": stamp})
4048
+ row[skey] = decided
4049
+ continue # the AI moved it on β€” keep walking
4050
+ _act_row_patch(patches, table, rid, {skey: label, skey + "_at": stamp})
4051
+ row[skey] = label
4052
+ counts["review"] += 1
4053
+ return True
4054
+ return False
4055
+
4056
+ held = []
4057
+ if actions:
4058
+ for rid in list(row_ids or [])[:FLOOD_LIMIT]:
4059
+ row = dict(rows.get(str(rid)) or {})
4060
+ if not row:
4061
+ continue
4062
+ if _walk(actions, row, str(rid)):
4063
+ held.append(str(rid))
4064
+ counts["cycles"] = _apply_ending(rt, defn, table, rows, patches, stages, routes, stamp)
4065
+ _commit_action_writes(rt, table, patches, creates, username, log)
4066
+ if held:
4067
+ notify_review(rt, defn, held, log=log)
4068
+ return counts
4069
+
4070
+
4071
+ #: C6 cap: AI decisions ONE run may make. A flow that suddenly matches 500 records must not turn
4072
+ #: into 500 paid calls before anybody notices β€” the rest simply wait for a human, which is the
4073
+ #: behaviour the feature degrades to everywhere else too.
4074
+ AI_DECISIONS_PER_RUN = 25
4075
+
4076
+
4077
+ def ai_decide(rt, defn, act, row, row_id="", log=print):
4078
+ """R4/C6: let the model pick this card's next stage. Returns the chosen stage LABEL, or ""
4079
+ to leave the card for a person.
4080
+
4081
+ β›” FAIL-CLOSED IN EVERY DIRECTION: no provider configured, a network failure, a malformed
4082
+ answer, or a label the review does not offer all return "" β€” and "" means the card sits at
4083
+ the review gate exactly as it would with no AI at all. The feature can be broken, absent or
4084
+ wrong and the worst outcome is a human doing the work.
4085
+ """
4086
+ cfg = act.get("config") or {}
4087
+ options = list(cfg.get("next") or [])
4088
+ if not options:
4089
+ return ""
4090
+ try:
4091
+ import ai_review
4092
+ except Exception as e: # noqa: BLE001
4093
+ log(f"[aios-auto] ai review unavailable: {type(e).__name__}: {e}")
4094
+ return ""
4095
+ fields = [f.get("key") for f in
4096
+ (ut_get(rt, ((defn.get("config") or {}).get("targetTable")
4097
+ or (defn.get("trigger") or {}).get("table") or "")) or {}
4098
+ ).get("fields") or []]
4099
+ choice, meta = ai_review.decide(prompt=cfg.get("prompt") or "", options=options,
4100
+ row=row, fields=[f for f in fields if f],
4101
+ label=cfg.get("label") or "Review")
4102
+ if not choice:
4103
+ if meta.get("problem"):
4104
+ log(f"[aios-auto] ai review declined to answer: {meta['problem']}")
4105
+ return ""
4106
+ review_audit(rt, defn.get("id"), row_id, cfg.get("label") or "Review",
4107
+ choice, meta.get("provider") or "ai", by="ai",
4108
+ note=meta.get("reason") or "", model=meta.get("model") or "")
4109
+ return choice
4110
+
4111
+
4112
+ def notify_review(rt, defn, row_ids, log=print):
4113
+ """C6: one notification per RUN naming the count β€” never one per record.
4114
+
4115
+ A discovery pass that gates 300 candidates would otherwise put 300 rows in somebody's inbox
4116
+ and the inbox would stop being read, which costs more than the notification gains. The board
4117
+ is where the individual cards live; the bell says "there is work", once.
4118
+ """
4119
+ if not row_ids:
4120
+ return
4121
+ owner = str((defn or {}).get("createdBy") or "").strip()
4122
+ if not owner:
4123
+ return
4124
+ n = len(row_ids)
4125
+ try:
4126
+ import core.alerts as alerts
4127
+ alerts.notify(owner,
4128
+ f"{defn.get('name') or 'An automation'} β€” {n} "
4129
+ f"record{'' if n == 1 else 's'} waiting for review",
4130
+ topic="automation", key=str(defn.get("id") or ""), st=rt)
4131
+ except Exception as e: # noqa: BLE001
4132
+ log(f"[aios-auto] review notification failed: {type(e).__name__}: {e}")
4133
+
4134
+
4135
+ def find_records(rt, table_key, cond, limit=25):
4136
+ """The `find_records` action's read: matching row ids, bounded and DISCLOSED (the caller puts
4137
+ the count in the run log, where it drills β€” [[no-unverifiable-aggregates]])."""
4138
+ rows = (ut_get(rt, str(table_key or "")) or {}).get("rows") or {}
4139
+ out = []
4140
+ for rid, row in rows.items():
4141
+ if lane_match(cond, row or {}):
4142
+ out.append(str(rid))
4143
+ if len(out) >= max(1, min(int(limit or 25), FIND_LIMIT_MAX)):
4144
+ break
4145
+ return out
4146
+
4147
+
4148
+ def _apply_ending(rt, defn, table, rows, patches, stages, routes, stamp):
4149
+ """C5: send terminal cards back round, per the ending mode. Returns cycles started.
4150
+
4151
+ A RESET CLEARS THE STAMP rather than writing a start stage, so the card falls back to the
4152
+ DERIVED bucket `_card_stage` would give a record that had never been stamped at all β€” which
4153
+ is what "back to the first cycle" means, and it needs no opinion about which stage is first.
4154
+ """
4155
+ ending = ((defn or {}).get("flow") or {}).get("ending") or {}
4156
+ mode = ending.get("mode") or "terminal"
4157
+ if mode in ("terminal", "manual_reset"):
4158
+ return 0
4159
+ terminals = set(terminal_stage_ids(stages, routes))
4160
+ if not terminals:
4161
+ return 0
4162
+ labels = {s["label"] for s in stages if s["id"] in terminals}
4163
+ skey = stage_field_key(defn.get("id"))
4164
+ cutoff = None
4165
+ if mode == "timed_reset":
4166
+ cutoff = _now() - _dt.timedelta(hours=int(ending.get("hours") or 24))
4167
+ started = 0
4168
+ for rid, row in rows.items():
4169
+ merged = {**(row or {}), **((patches.get(table) or {}).get(str(rid)) or {})}
4170
+ if str(merged.get(skey) or "").strip() not in labels:
4171
+ continue
4172
+ if cutoff is not None:
4173
+ at = _parse_iso(str(merged.get(skey + "_at") or ""))
4174
+ if at is None or at > cutoff:
4175
+ continue
4176
+ n = (_ig_int(merged.get(cycles_field_key(defn.get("id")))) or 0) + 1
4177
+ _act_row_patch(patches, table, rid,
4178
+ {skey: "", skey + "_at": stamp,
4179
+ cycles_field_key(defn.get("id")): n})
4180
+ started += 1
4181
+ return started
4182
+
4183
+
4184
+ def _commit_action_writes(rt, table, patches, creates, username, log):
4185
+ """The ONE write. Row patches merge into the target's rows; creates append to their own
4186
+ tables through the ordinary cap-respecting path."""
4187
+ if not patches and not creates:
4188
+ return
4189
+ for tkey, rowpatch in (patches or {}).items():
4190
+ cur = dict((ut_get(rt, tkey) or {}).get("rows") or {})
4191
+ for rid, vals in rowpatch.items():
4192
+ cur[str(rid)] = {**(cur.get(str(rid)) or {}), **vals}
4193
+ ut_write_rows(rt, tkey, cur)
4194
+ for tkey, new_rows in (creates or {}).items():
4195
+ t = ut_get(rt, tkey)
4196
+ if t is None:
4197
+ log(f"[aios-auto] create_record: {tkey} no longer exists β€” {len(new_rows)} skipped")
4198
+ continue
4199
+ cur = dict(t.get("rows") or {})
4200
+ nxt = max([int(r) for r in cur if str(r).isdigit()] or [0]) + 1
4201
+ cap = row_cap(tkey)
4202
+ for vals in new_rows:
4203
+ if len(cur) >= cap:
4204
+ log(f"[aios-auto] create_record: {tkey} at its {cap}-row cap")
4205
+ break
4206
+ cur[str(nxt)] = dict(vals)
4207
+ nxt += 1
4208
+ ut_write_rows(rt, tkey, cur)
4209
+
4210
+
4211
+ def _lane_sentence(cond, top=True):
4212
+ """One condition tree β†’ the sentence the board and the field definition carry.
4213
+
4214
+ C4: a GROUP renders as its children joined by "and"/"or" and parenthesised when nested, so a
4215
+ lane label never claims a flat comparison the tree does not make. The server composes it for
4216
+ the same reason it composes every other stage `detail` β€” a client paraphrase of a structure
4217
+ the engine evaluates is a second implementation of the same sentence, free to drift from it.
4218
+ """
4219
  if cond is None:
4220
+ return "Everything else" if top else ""
4221
+ if isinstance(cond, dict):
4222
+ for key, joiner in (("all", " and "), ("any", " or ")):
4223
+ if key in cond:
4224
+ parts = [_lane_sentence(c, False) for c in cond.get(key) or []]
4225
+ parts = [p for p in parts if p]
4226
+ if not parts:
4227
+ return ""
4228
+ inner = joiner.join(parts)
4229
+ return inner if top or len(parts) == 1 else f"({inner})"
4230
  v = cond.get("value")
4231
  return f"{cond.get('field')} {cond.get('op')}" + ("" if v is None else f" {v}")
4232
 
 
4273
  "Declined": {"tracked": ""}}
4274
  field = {"key": fkey, "label": f"Stage β€” {defn.get('name') or defn.get('id')}"[:80],
4275
  "type": "select", "source": "overlay", "options": choices, "automation": auto}
4276
+ # ⭐ WAVE 23 (C5, D-35's minimum) β€” THE CYCLE COLUMN, created beside the stage field when
4277
+ # the flow can loop. The owner asked to SEE how many times a record has been round, and
4278
+ # `_apply_ending` was writing the cell without anything ever declaring the column: the value
4279
+ # would sit in the row, correct and invisible, because a ut grid renders the DEFINITION's
4280
+ # fields. A machine-written cell with no field definition is the shape of a number nobody
4281
+ # can find. Tagged `automation` so it greys out and refuses human writes like every other
4282
+ # machine column (item 4 / R9).
4283
+ cyc_key = cycles_field_key(defn.get("id"))
4284
+ loops = (((defn.get("flow") or {}).get("ending") or {}).get("mode") or "terminal") != \
4285
+ "terminal"
4286
+ cyc_field = {"key": cyc_key, "label": "Cycles"[:80], "type": "int", "source": "overlay",
4287
+ "automation": {"flowId": str(defn.get("id") or ""), "cyclesField": True}}
4288
+
4289
+ have, have_cyc = None, False
4290
  for f in (ut_get(rt, key) or {}).get("fields") or []:
4291
  if f.get("key") == fkey:
4292
  have = f
4293
+ if f.get("key") == cyc_key:
4294
+ have_cyc = True
4295
  if have is not None and have.get("options") == choices and \
4296
+ (have.get("automation") or {}) == auto and have_cyc == loops:
4297
  return fkey # nothing would change β€” save the commit
4298
 
4299
  def _up(cur):
 
4306
  f["options"] = choices
4307
  f["automation"] = auto
4308
  f["type"] = "select"
4309
+ break
4310
+ else:
4311
+ t["fields"].append(field)
4312
+ if loops and not any(f.get("key") == cyc_key for f in t["fields"]):
4313
+ t["fields"].append(cyc_field)
4314
+ # ⚠ A flow switched BACK to `terminal` keeps its cycle column rather than dropping it:
4315
+ # the counts already in those cells are a record of work that really happened, and a
4316
+ # schema change must not be a way to lose data nobody asked to delete.
4317
  return cur
4318
 
4319
  rt.update(UT_STORE_KEY, _up, flush="sync")
 
4497
  MAX_REVIEWS = 100
4498
 
4499
 
4500
+ def review_audit(rt, auto_id, row_id, from_label, to_label, username,
4501
+ by="user", note="", model=""):
4502
  """C3-A2(5): a review decision is AUDITED β€” who moved which card where, when. Appended to
4503
  the definition (newest first, bounded) by BOTH doors: `move_card` here, and the grid door
4504
+ in `core.grid_events`, which writes the same shape mechanically off the field's `flowId`.
4505
+
4506
+ ⭐ WAVE 23 (R4/C6): an AI decision writes THE SAME ROW with `by: "ai"` plus the model that
4507
+ made it and the one-line reason it gave. One audit log, not two β€” a reader asking "who
4508
+ decided this card" must not have to know there are two places to look, and the moment an
4509
+ AI decision is invisible beside a human one the log stops being an audit.
4510
+ """
4511
  entry = {"ts": _iso(), "user": _s(username, 80), "rowId": str(row_id),
4512
+ "from": _s(from_label, 60), "to": _s(to_label, 60),
4513
+ "by": "ai" if by == "ai" else "user"}
4514
+ if note:
4515
+ entry["note"] = _s(note, 300)
4516
+ if model:
4517
+ entry["model"] = _s(model, 60)
4518
 
4519
  def _up(cur):
4520
  cur = cur if isinstance(cur, dict) else {}
 
4749
  # EVER (a high-water mark over row ids, so an undo-restored row cannot re-fire);
4750
  # event_field fires every transition.
4751
 
4752
+ # ── WAVE 23 Β· C3 β€” the trigger vocabulary v2 (owner ruling R2). ───────────────────────────────
4753
+ # Airtable's phrasing, because the owner asked for Airtable's builder and a trigger list that
4754
+ # renames the same events is a second vocabulary to learn for no gain.
4755
+ #
4756
+ # ⚠ `event_field` KEPT ITS KEY and changed its LABEL to "When a record matches conditions".
4757
+ # Renaming the key would have orphaned every stored trigger in production for a caption; the key
4758
+ # is the contract with the store, the label is the contract with the reader, and they are allowed
4759
+ # to disagree. What genuinely widened is its SHAPE: the watched field is now OPTIONAL, so the
4760
+ # trigger covers Airtable's condition-only form (any write to the table, evaluated against a C4
4761
+ # tree) as well as wave 22's watch-one-field form. Both are the same edge rule underneath.
4762
+ #
4763
+ # β›” PLANNED β‰  STORABLE. `button_clicked` / `comment_added` ride the wire so the picker can show
4764
+ # them faded with a reason (R2: "never a dead control") β€” and `clean_trigger` REFUSES them with a
4765
+ # sentence. A vocabulary that renders an option the validator rejects is the wave-9 silent-drop
4766
+ # class wearing a friendlier face; here the two lists are separate on purpose and the refusal
4767
+ # names the state rather than pretending the key is unknown.
4768
+ TRIGGER_KEYS = ("manual", "schedule", "event_field", "record_updated", "record_created",
4769
+ "enters_view", "webhook", "email", "form_submitted")
4770
+ TRIGGER_PLANNED = ("button_clicked", "comment_added")
4771
  TRIGGER_LABELS = {
4772
+ "manual": "Manual",
4773
+ "schedule": "At a scheduled time",
4774
+ "event_field": "When a record matches conditions",
4775
+ "record_updated": "When a record is updated",
4776
+ "record_created": "When a record is created",
4777
+ "enters_view": "When a record enters a view",
4778
+ "webhook": "When a webhook is received",
4779
+ "email": "When an email arrives",
4780
+ "form_submitted": "When a form is submitted",
4781
+ "button_clicked": "When a button is clicked",
4782
+ "comment_added": "When a comment is added",
4783
  }
4784
+ #: Triggers that watch a database and therefore need one named before they can fire.
4785
+ TRIGGER_TABLE_KEYS = ("event_field", "record_updated", "record_created", "enters_view",
4786
+ "form_submitted")
4787
+ #: Triggers the ROW HOOKS drive (as opposed to the tick, or an inbound HTTP call). Named once so
4788
+ #: `grid_hook` and the gates read the same list instead of two matching `in (...)` tuples.
4789
+ TRIGGER_ROW_KEYS = ("event_field", "record_updated", "record_created", "enters_view")
4790
+ MAX_WATCH_FIELDS = 12
4791
  #: The settle window for field-change bursts (A2(1)). 0 evaluates INLINE β€” the gates run there,
4792
  #: and so would a deployment that prefers immediacy over coalescing.
4793
  EVENT_SETTLE_SECONDS = float(os.environ.get("AIOS_EVENT_SETTLE_SECONDS") or 15)
 
4826
  prev = previous if isinstance(previous, dict) else {}
4827
  key = _s(raw.get("key") or raw.get("kind") or prev.get("key") or prev.get("kind"),
4828
  30).strip()
4829
+ if key in TRIGGER_PLANNED:
4830
+ # Declared on the wire, refused at the door β€” see the TRIGGER_PLANNED note. The sentence
4831
+ # says WHY rather than "unknown trigger", because the picker legitimately showed it.
4832
+ return None, (f"{TRIGGER_LABELS[key]!r} is on the list but not built yet β€” "
4833
+ f"it renders so you can see it is coming, and it cannot be saved")
4834
  if key not in TRIGGER_KEYS:
4835
  return None, (f"{key or 'that trigger'!r} is not one of: " + ", ".join(TRIGGER_KEYS))
4836
  if key in ("manual", "schedule"):
 
4841
  "enabled": bool(raw["enabled"]) if "enabled" in raw else
4842
  bool(prev.get("enabled", True)),
4843
  "paused": bool(raw["paused"]) if "paused" in raw else bool(prev.get("paused"))}
4844
+ if key in TRIGGER_TABLE_KEYS:
4845
  table = _s(raw.get("table") if "table" in raw else prev.get("table"), 60).strip()
4846
  if table and not table.startswith(UT_PREFIX):
4847
  return None, ("event triggers watch blank databases (ut_*) this wave β€” "
4848
  f"{table!r} is not one")
4849
  out["table"] = table
4850
  if key == "event_field":
4851
+ # The watched field is OPTIONAL now (C3-v2): named β‡’ "when THIS field changes and the
4852
+ # record matches"; unnamed β‡’ Airtable's plain "when a record matches conditions",
4853
+ # evaluated on any write to the table. The condition is a C4 TREE over the WHOLE row β€”
4854
+ # wave 22 could only test the written value, so it refused a condition on any other
4855
+ # field; `_settle_eval` now reads the row itself, which is what lifted that restriction.
4856
+ out["field"] = _s(raw.get("field") if "field" in raw else prev.get("field"), 80).strip()
4857
+ cond, cerr = clean_cond(raw.get("when") if "when" in raw else prev.get("when"),
4858
+ where="the trigger")
4859
+ if cerr:
4860
+ return None, cerr
4861
+ out["when"] = cond
4862
+ if key == "record_updated":
4863
+ # Airtable's shape: watch named fields, or leave the list empty for "any field". Empty
4864
+ # is the WIDER reading and it is the default there too, so it stays the default here.
4865
+ watch_raw = raw.get("fields") if "fields" in raw else prev.get("fields")
4866
+ if watch_raw in (None, ""):
4867
+ watch = []
4868
+ elif not isinstance(watch_raw, list):
4869
+ return None, "the watched-field list must be a list of field keys"
4870
  else:
4871
+ watch = [_s(f, 80).strip() for f in watch_raw if _s(f, 80).strip()]
4872
+ if len(watch) > MAX_WATCH_FIELDS:
4873
+ return None, (f"a record-updated trigger watches at most {MAX_WATCH_FIELDS} "
4874
+ f"fields β€” leave the list empty to watch every field")
4875
+ out["fields"] = watch
4876
+ cond, cerr = clean_cond(raw.get("when") if "when" in raw else prev.get("when"),
4877
+ where="the trigger")
4878
+ if cerr:
4879
+ return None, cerr
4880
+ out["when"] = cond
4881
+ if key == "enters_view":
4882
+ out["viewId"] = _s(raw.get("viewId") if "viewId" in raw else prev.get("viewId"),
4883
+ 80).strip()
4884
+ if key == "form_submitted":
4885
+ # Blank = any form on that database. Naming one narrows to it, which is what a table
4886
+ # carrying an intake form AND a correction form needs.
4887
+ out["formToken"] = _s(raw.get("formToken") if "formToken" in raw
4888
+ else prev.get("formToken"), 64).strip()
 
 
 
4889
  if key == "webhook":
4890
  # The token is MINTED here, once, and survives every later patch β€” rotating it on
4891
  # every Save would silently break the external caller the URL was given to.
 
4893
  if key == "email":
4894
  out["query"] = _s(raw.get("query") if "query" in raw else prev.get("query"),
4895
  200).strip() or "in:inbox is:unread"
4896
+ out["configured"] = _trigger_configured(out)
 
 
4897
  return out, None
4898
 
4899
 
4900
+ def _trigger_configured(trg):
4901
+ """Is this trigger complete enough to fire? One reader, because "configured" is asserted in
4902
+ three places (the wire, the graph node, the hooks) and three copies of a boolean is how a
4903
+ surface says "ready" about a trigger the engine skips."""
4904
+ key = str((trg or {}).get("key") or "")
4905
+ if key in TRIGGER_TABLE_KEYS and not trg.get("table"):
4906
+ return False
4907
+ if key == "event_field":
4908
+ # Neither a watched field nor a condition = "fire on anything, ever" β€” which is not a
4909
+ # trigger, it is a description of the table. Refuse to call that configured.
4910
+ return bool(trg.get("field") or trg.get("when"))
4911
+ if key == "enters_view":
4912
+ return bool(trg.get("viewId"))
4913
+ return True
4914
+
4915
+
4916
  def _secrets_token():
4917
  import secrets as _sec
4918
  return _sec.token_urlsafe(24)
 
4966
  _SETTLE_LOCK = threading.Lock()
4967
 
4968
 
4969
+ def _settle_buffer(rt, tenant, auto_id, row_id, field="", after=None, log=print):
4970
+ """Buffer ONE touched row for a coalesced evaluation, carrying the written cell.
4971
+
4972
+ ⚠ WAVE 23 β€” THE WRITTEN VALUE IS STILL LOAD-BEARING, and an earlier draft of this wave
4973
+ dropped it on the theory that `_settle_eval` could just read the row. It cannot, and the
4974
+ reason is worth stating because it is invisible from this file: an ordinary ut cell typed at
4975
+ the grid door lands in the editor's PER-USER OVERLAY stratum
4976
+ (`grid_events.overlay_patch` β†’ `table_store.patch_overlay`), not in the `user_tables`
4977
+ definition rows. Only stage-field writes go through `patch_cells` to the shared rows. So for
4978
+ the common case the value that just changed exists ONLY in this event, and a definition-row
4979
+ read sees the pre-write value β€” the trigger would evaluate stale and never fire.
4980
+ `_settle_eval` therefore MERGES: the definition row underneath (which is what lets a C4 tree
4981
+ read the record's other columns) with the written cells on top.
4982
+ """
4983
  key = (tenant, str(auto_id))
4984
+ cell = {str(field): after} if field else {}
4985
  if EVENT_SETTLE_SECONDS <= 0:
4986
  with _SETTLE_LOCK:
4987
  buf = _SETTLE.setdefault(key, {"rows": {}})
4988
+ buf["rows"].setdefault(str(row_id), {}).update(cell)
4989
  _settle_eval(rt, tenant, auto_id, log=log)
4990
  return
4991
  with _SETTLE_LOCK:
4992
  buf = _SETTLE.setdefault(key, {"rows": {}})
4993
+ buf["rows"].setdefault(str(row_id), {}).update(cell)
4994
  timer = buf.get("timer")
4995
  if timer is not None:
4996
  timer.cancel() # the burst continues β€” push the window out
 
5001
  timer.start()
5002
 
5003
 
5004
+ def view_filter(rt, table_key, view_id):
5005
+ """`(tree, fields, problem)` for one saved view on a user table β€” the substrate the
5006
+ `enters_view` trigger tests membership against (C3-v2 / owner R2).
5007
+
5008
+ Personal strata first (`find_view`), then the shared bucket, because a view somebody shared
5009
+ is exactly the kind an automation gets pointed at. A view that has been deleted answers a
5010
+ PROBLEM rather than an empty tree: an empty tree matches everything, so degrading to one
5011
+ would turn "when a record enters Overdue" into "on every write", which is the widening this
5012
+ module refuses everywhere else.
5013
+ """
5014
+ key = str(table_key or "")
5015
+ vid = str(view_id or "").strip()
5016
+ if not key or not vid:
5017
+ return None, [], "the trigger names no view"
5018
+ try:
5019
+ import core.table_store as table_store
5020
+ tops = table_store.make(f"{key}_table_workspace", st=rt)
5021
+ found = tops.find_view(vid)
5022
+ view = (found[1] if found else None) or tops.shared_view(vid)
5023
+ except Exception as e: # noqa: BLE001
5024
+ return None, [], f"the view could not be read ({type(e).__name__})"
5025
+ if not isinstance(view, dict):
5026
+ return None, [], f"view {vid!r} no longer exists on {key}"
5027
+ cfg = view.get("config") or {}
5028
+ tree = {"nodes": cfg.get("filters") or [], "conj": cfg.get("filterConj") or "and"}
5029
+ return tree, list((ut_get(rt, key) or {}).get("fields") or []), ""
5030
+
5031
+
5032
+ def _row_gate(rt, defn, trg):
5033
+ """The MATCH gate for a row-event trigger: `(row -> bool) | None`, plus a problem string.
5034
+
5035
+ None means the trigger has NO match gate β€” the write itself is the event (wave 22's "the
5036
+ field changed", and `record_updated` over any field). A problem means the gate cannot be
5037
+ built, and the caller must then fire NOTHING: a gate we cannot evaluate is not a gate that
5038
+ passes.
5039
+ """
5040
+ key = trg.get("key")
5041
+ if key == "enters_view":
5042
+ tree, fields, problem = view_filter(rt, trg.get("table"), trg.get("viewId"))
5043
+ if problem:
5044
+ return None, problem
5045
+ import harness.filter_eval as filter_eval
5046
+ return (lambda row: filter_eval.matches(tree, row, fields)), ""
5047
+ when = trg.get("when")
5048
+ if when:
5049
+ return (lambda row: lane_match(when, row)), ""
5050
+ return None, ""
5051
+
5052
+
5053
  def _settle_eval(rt, tenant, auto_id, log=print):
5054
  """Evaluate one settled burst: edge over per-record armed state, flood hold, then fire."""
5055
  with _SETTLE_LOCK:
5056
  buf = _SETTLE.pop((tenant, str(auto_id)), None)
5057
+ written = dict((buf or {}).get("rows") or {})
5058
+ touched = list(written)
5059
+ if not touched:
5060
  return
5061
  d = all_definitions(rt).get(str(auto_id))
5062
  trg = (d or {}).get("trigger") or {}
5063
+ if trg.get("key") not in ("event_field", "record_updated", "enters_view") \
5064
+ or trg.get("paused") or not trg.get("enabled", True) \
5065
+ or not trg.get("configured", True):
5066
+ return
5067
+ gate, problem = _row_gate(rt, d, trg)
5068
+ if problem:
5069
+ # LOUD, once, and it does not fire. A trigger pointed at a deleted view is a broken
5070
+ # automation, not a quiet no-op β€” the note is what the surface shows instead of "On".
5071
+ if (d.get("statusNote") or "") != problem:
5072
+ _pause_trigger(rt, auto_id, problem)
5073
+ log(f"[aios-auto] trigger gate: {auto_id} {problem}")
5074
  return
 
5075
  fired = []
5076
+ if gate is not None:
5077
+ rows = (ut_get(rt, trg.get("table") or "") or {}).get("rows") or {}
5078
  disarmed = set((d.get("state") or {}).get("eventDisarmed") or [])
5079
  nxt = set(disarmed)
5080
+ for rid in touched:
5081
+ # The merge (see `_settle_buffer`): the shared definition row underneath so a C4
5082
+ # tree can read the record's other columns, the just-written cells on top because
5083
+ # for an ordinary ut column this event is the ONLY place that value exists yet.
5084
+ if gate({**(rows.get(rid) or rows.get(str(rid)) or {}),
5085
+ **(written.get(rid) or {})}):
5086
  if rid not in disarmed:
5087
  fired.append(rid) # falseβ†’true THIS evaluation: the edge
5088
  nxt.add(rid)
 
5093
  if not fired:
5094
  return
5095
  else:
5096
+ fired = list(touched) # "the field changed" β€” the burst is the edge
5097
  if len(fired) > FLOOD_LIMIT:
5098
  _commit_run(rt, auto_id, "partial",
5099
  f"the trigger matched {len(fired)} records in one evaluation β€” more than "
 
5106
  def _seed_event_state(rt, defn):
5107
  """A2(1)'s enable rule: records ALREADY matching when the trigger is set start DISARMED, so
5108
  turning the trigger on fires nothing β€” the first fire needs a real falseβ†’true transition.
5109
+ Evaluated over the definition rows (the machine-written truth this trigger class watches).
5110
+
5111
+ Wave 23: one seeder for all three gated triggers, reading the SAME `_row_gate` the evaluation
5112
+ reads. Two implementations of "does this row match" is how a seed disagrees with the edge it
5113
+ is supposed to arm, and the symptom would be a flood of fires the moment somebody enables it.
5114
+ """
5115
  trg = (defn or {}).get("trigger") or {}
5116
+ if trg.get("key") not in ("event_field", "record_updated", "enters_view") \
5117
+ or not trg.get("configured"):
5118
  return
5119
+ gate, problem = _row_gate(rt, defn, trg)
5120
+ if gate is None or problem:
5121
+ return # no match gate β‡’ nothing to arm; a broken gate seeds nothing
5122
+ rows = (ut_get(rt, trg.get("table") or "") or {}).get("rows") or {}
5123
+ matching = sorted(str(rid) for rid, row in rows.items() if gate(row or {}))
5124
  set_state(rt, defn.get("id"), {"eventDisarmed": matching[:5000]})
5125
 
5126
 
 
5137
  table = str(evt.get("table") or "")
5138
  kind = str(evt.get("type") or "")
5139
  defs = all_definitions(st)
5140
+ field = str(evt.get("field") or "")
5141
+ rid = str(evt.get("rowId") or "")
5142
  for aid, d in defs.items():
5143
  trg = (d or {}).get("trigger") or {}
5144
+ key = str(trg.get("key") or "")
5145
+ if trg.get("paused") or not trg.get("enabled", True) \
5146
+ or not trg.get("configured", True) \
5147
+ or key not in TRIGGER_ROW_KEYS \
5148
+ or str(trg.get("table") or "") != table:
5149
  continue
5150
+ if kind == "record_created" and key == "record_created":
 
 
5151
  hw = _ig_int((d.get("state") or {}).get("rcHighwater")) or 0
5152
  ridn = int(rid) if rid.isdigit() else None
5153
  if ridn is None or ridn <= hw:
5154
  continue # once per record EVER (A2(4)) β€” undo-proof
5155
  set_state(st, aid, {"rcHighwater": ridn})
5156
  trigger_fire(st, tenant, aid)
5157
+ elif kind == "event_field" and key == "event_field" and (
5158
+ not trg.get("field") or str(trg.get("field")) == field):
5159
+ # A NAMED field narrows to writes on it (wave 22's shape); an unnamed one is
5160
+ # Airtable's plain condition trigger and watches every column.
5161
+ _settle_buffer(st, tenant, aid, rid, field, evt.get("after"))
5162
+ elif kind == "event_field" and key == "record_updated" and (
5163
+ not (trg.get("fields") or []) or field in (trg.get("fields") or [])):
5164
+ _settle_buffer(st, tenant, aid, rid, field, evt.get("after"))
5165
+ elif key == "enters_view":
5166
+ # BOTH kinds feed it: a row can enter a view by being edited into its filter or
5167
+ # by being CREATED already inside it. Listening only to edits would silently miss
5168
+ # every new record β€” the half of the definition a reader assumes is covered.
5169
+ _settle_buffer(st, tenant, aid, rid, field, evt.get("after"))
5170
  except Exception as e: # noqa: BLE001
5171
  print(f"[aios-auto] trigger hook failed: {type(e).__name__}: {e}")
5172
 
5173
 
5174
+ def form_fired(rt, table_key, row_id, values=None, form_token=""):
5175
+ """⭐ THE FROZEN SIGNATURE session D calls from the public form door (contract C9/W23-W7).
5176
+
5177
+ One submitted form row β†’ every `form_submitted` automation watching that database fires.
5178
+ Returns the list of automation ids that started, so the door can log what it set off (and so
5179
+ the gate can assert it, rather than asserting a side effect nobody can see).
5180
+
5181
+ β›” THIS IS A HUMAN DOOR, deliberately: an anonymous submission is a person filling in a form,
5182
+ so it fires triggers exactly like typing into a cell does. The loop-prevention law is not
5183
+ weakened by that β€” the engine's own writers still never reach here (only `routes_forms` calls
5184
+ it), so an automation cannot create a form row and re-fire itself.
5185
+
5186
+ `values` is accepted and unused today: the row is already written when this is called, and
5187
+ the flow reads it from the table. It stays in the signature because the caller HAS it and a
5188
+ later refire policy ("only when field X was submitted") needs it β€” a parameter added later
5189
+ would mean changing D's call site in a wave that does not own it.
5190
+ """
5191
+ started, table = [], str(table_key or "")
5192
+ if not table:
5193
+ return started
5194
+ tenant = str(getattr(rt, "key", "") or "royal-imports")
5195
+ for aid, d in all_definitions(rt).items():
5196
+ trg = (d or {}).get("trigger") or {}
5197
+ if trg.get("key") != "form_submitted" or trg.get("paused") \
5198
+ or not trg.get("enabled", True) or not trg.get("configured", True):
5199
+ continue
5200
+ if str(trg.get("table") or "") != table:
5201
+ continue
5202
+ want = str(trg.get("formToken") or "")
5203
+ if want and not hmac.compare_digest(want, str(form_token or "")):
5204
+ continue # this automation watches a DIFFERENT form on that table
5205
+ if trigger_fire(rt, tenant, aid):
5206
+ started.append(aid)
5207
+ return started
5208
+
5209
+
5210
  def hook_fire(rt, tenant, auto_id, token):
5211
  """The webhook trigger's decision, separated from FastAPI so the gate can drive it.
5212
  Returns `(status, payload)` β€” 404 unknown, 403 wrong/missing token or wrong trigger kind,
 
5367
  log(f"[aios-auto] run {auto_id} failed: {type(e).__name__}: {e}")
5368
  return _commit_run(rt, auto_id, "error",
5369
  f"{type(e).__name__}: {str(e)[:200]}", {}, False)
5370
+ # ⭐ WAVE 23 (C4/C5) β€” THE FLOW RUNS HERE, after the machine steps and before the run is
5371
+ # committed, over the records this run actually touched. ONE call site rather than three
5372
+ # inside the runners: every kind gets actions and endings for free, and a fourth runner
5373
+ # cannot forget to opt in.
5374
+ #
5375
+ # ⚠ Its absence was the wave's most expensive near-miss: actions were stored, validated,
5376
+ # wired to the wire and covered by twelve gate checks that all called `apply_actions`
5377
+ # DIRECTLY β€” so the whole feature was green and unreachable. A person would have built a
5378
+ # flow, pressed Run now, and watched nothing happen. The gate now drives `run_now`.
5379
+ #
5380
+ # A failing action must not fail the RUN: the machine steps already wrote their rows and
5381
+ # reporting that as an error would misdescribe what happened. It degrades to `partial`
5382
+ # with the reason in the summary β€” the cap_note discipline.
5383
+ try:
5384
+ a_counts = apply_actions(rt, defn, _flow_table(defn), affected or [],
5385
+ username=username, log=log)
5386
+ counts = {**(counts or {}), **{k: v for k, v in a_counts.items() if v}}
5387
+ except Exception as e: # noqa: BLE001
5388
+ log(f"[aios-auto] actions on {auto_id} failed: {type(e).__name__}: {e}")
5389
+ state = "partial" if state != "error" else state
5390
+ summary = f"{summary} β€” the actions did not finish ({type(e).__name__})"
5391
  return _commit_run(rt, auto_id, state, summary, counts, state != "error", affected,
5392
  steps)
5393
  finally:
api/main.py CHANGED
@@ -71,6 +71,9 @@ import routes_records # noqa: E402
71
  import routes_shares # noqa: E402 (wave 20 R10 β€” grants for views, folders and databases)
72
  import routes_uploads # noqa: E402 (wave 21 C5 β€” tabular preview for Select-from-file)
73
  import routes_tables # noqa: E402 (wave 18 C3-UT β€” user-created databases over the wire)
 
 
 
74
  from core import grid_events # noqa: E402
75
  from deps import Session, module_gate # noqa: E402
76
 
@@ -202,6 +205,24 @@ app.include_router(routes_platform_admin.router)
202
  app.include_router(routes_alerts.router)
203
  app.include_router(routes_shares.router)
204
  app.include_router(routes_uploads.router)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
 
207
  # --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
 
71
  import routes_shares # noqa: E402 (wave 20 R10 β€” grants for views, folders and databases)
72
  import routes_uploads # noqa: E402 (wave 21 C5 β€” tabular preview for Select-from-file)
73
  import routes_tables # noqa: E402 (wave 18 C3-UT β€” user-created databases over the wire)
74
+ import routes_connectors # noqa: E402 (wave 23 C11 β€” the connectors directory; SESSION A's router)
75
+ import routes_forms # noqa: E402 (wave 23 C9 β€” the PUBLIC form door; SESSION D's router)
76
+ import routes_templates # noqa: E402 (wave 23 C12 β€” template apply doors; SESSION E's router)
77
  from core import grid_events # noqa: E402
78
  from deps import Session, module_gate # noqa: E402
79
 
 
205
  app.include_router(routes_alerts.router)
206
  app.include_router(routes_shares.router)
207
  app.include_router(routes_uploads.router)
208
+ # ⭐ WAVE 23 β€” THE THREE NEW ROUTERS. Mounted here, above the static catch-all at the bottom of this
209
+ # file, because `app.mount("/", _AppStatic(...), html=True)` swallows everything it is reached by:
210
+ # a router included AFTER it answers 404 forever while importing fine, type-checking fine and
211
+ # passing its own gate. That is the wave-20 declared-but-unmounted shape with a different cause.
212
+ #
213
+ # β›” ALL THREE EXISTED, COMPLETE AND GATED, WITH NO MOUNT until the close-out audit β€” three finished
214
+ # features that would have shipped dead. The workers each posted a mount ask and said they would
215
+ # signal "ready" first; C waited for a signal that never came while the files landed anyway. **The
216
+ # lesson is not "read the mailbox harder": `verify_api`'s enumeration is what caught it, so the
217
+ # control is that the enumeration must NAME every router in this file.**
218
+ app.include_router(routes_templates.router) # item 7 / C12 β€” template registry, session-gated
219
+ app.include_router(routes_connectors.router) # item 10 / C11 β€” the connectors directory
220
+ # ⚠ routes_forms is the FIRST NEW PUBLIC DOOR since the `_DEV_FIXTURES` scar (see :257 below). Its
221
+ # two paths are DELIBERATELY unauthenticated β€” a form is filled in by someone with no account β€” so
222
+ # it joins `/api/health`, the automation hook and the tick on the exempt list. It resolves a token
223
+ # by scanning tenants with a constant-time compare and answers a uniform 403, so a bad token cannot
224
+ # distinguish "no such form" from "not yours", and it never echoes a tenant, table or slug.
225
+ app.include_router(routes_forms.router) # item 8 / C9 β€” the PUBLIC form door
226
 
227
 
228
  # --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
api/routes_automation.py CHANGED
@@ -26,6 +26,11 @@ router = APIRouter(prefix="/api/v1")
26
  # C5 (wave 22): the OAuth connector surface rides INSIDE this router β€” main.py belongs to no
27
  # session this wave, and this router is already mounted there. `/api/v1` + `/oauth/...`.
28
  router.include_router(routes_oauth.router)
 
 
 
 
 
29
 
30
  #: The registry key this surface carries (C-AUTONAV β€” A adds the row; the gate is live now, so
31
  #: the day the row lands the wall is already the one that was tested).
@@ -66,13 +71,27 @@ def _wire(defn, tenant):
66
  # client, for the same reason `cronPresets` does: the engine that RUNS the steps is the
67
  # only thing entitled to say what the steps are.
68
  "graph": engine.graph(defn),
 
 
 
 
 
 
 
69
  }
70
 
71
 
72
  def _triggers_vocab(session):
73
- """C3's server-owned trigger list: `[{key, label, ready, needs}]`, keys EXACTLY
74
- `engine.TRIGGER_KEYS`. `ready:false` + `needs` renders as a not-configured state β€” never a
75
- dead control, never a client-side union."""
 
 
 
 
 
 
 
76
  tick_on = _tick_state()["enabled"]
77
  g = oauth_connect.status(session.runtime, session.uname).get("google") or {}
78
  email_ready = bool(g.get("configured")) and bool(g.get("connected")) \
@@ -83,14 +102,18 @@ def _triggers_vocab(session):
83
  "manual": (True, ""),
84
  "schedule": (tick_on, "" if tick_on else "arm_tick"),
85
  "event_field": (True, ""),
 
86
  "record_created": (True, ""),
 
87
  "webhook": (True, ""),
88
  "email": (email_ready, email_needs),
 
89
  }
90
  out = []
91
  for k in engine.TRIGGER_KEYS:
92
  ready, needs = per[k]
93
  row = {"key": k, "label": engine.TRIGGER_LABELS[k], "ready": ready, "needs": needs,
 
94
  # A3(3): the connect affordance is SERVER-COMPOSED β€” the client never maps a
95
  # `needs` token to a route, so B's CONNECT_PROVIDERS shim deletes itself.
96
  "connect": None}
@@ -98,9 +121,23 @@ def _triggers_vocab(session):
98
  row["connect"] = {"provider": "google",
99
  "startUrl": "/api/v1/oauth/google/start"}
100
  out.append(row)
 
 
 
101
  return out
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
104
  def _tick_state():
105
  """⭐ WAVE 21 (C6 amendment A1) β€” can a SCHEDULE fire on this deployment?
106
 
@@ -167,6 +204,28 @@ def list_automations(session: Session = Depends(_GATE)):
167
  "nullaryLaneOps": list(engine.LANE_NULLARY_OPS),
168
  "maxLanes": engine.MAX_LANES,
169
  "cardCap": engine.BOARD_CARD_CAP},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  # Wave 21 C6-A1: {"enabled": bool, "source": "in-process"|"external"|""} β€” see
171
  # `_tick_state` for why one boolean off AIOS_AUTOMATIONS alone would lie.
172
  "tick": _tick_state()}
@@ -244,6 +303,10 @@ def get_automation(auto_id: str, session: Session = Depends(_GATE)):
244
 
245
  @router.post("/automations")
246
  def create_automation(body: dict = Body(default=None), session: Session = Depends(_GATE)):
 
 
 
 
247
  if not session.runtime.available():
248
  raise err(503, "store_unavailable", "the tenant store is unavailable β€” nothing was saved")
249
  defn, error = engine.create(session.runtime, body or {}, username=session.uname)
 
26
  # C5 (wave 22): the OAuth connector surface rides INSIDE this router β€” main.py belongs to no
27
  # session this wave, and this router is already mounted there. `/api/v1` + `/oauth/...`.
28
  router.include_router(routes_oauth.router)
29
+ # ⭐ WAVE 23 (C11): the connectors directory rides this router for the same reason the OAuth
30
+ # routes do β€” it is mounted already, so E's page needs no main.py change to reach it. (C still
31
+ # owns main.py; this avoids an unnecessary ask across a fence.)
32
+ import routes_connectors # noqa: E402
33
+ router.include_router(routes_connectors.router)
34
 
35
  #: The registry key this surface carries (C-AUTONAV β€” A adds the row; the gate is live now, so
36
  #: the day the row lands the wall is already the one that was tested).
 
71
  # client, for the same reason `cronPresets` does: the engine that RUNS the steps is the
72
  # only thing entitled to say what the steps are.
73
  "graph": engine.graph(defn),
74
+ # ⭐ WAVE 23 (C4/C5) β€” THE BUILDER'S OWN STATE, and its absence here was a silent-drop
75
+ # bug caught in review rather than by a gate: `flow` was stored, patchable and validated,
76
+ # but never sent back. B would have saved a flow through PATCH, got a 200, and watched
77
+ # every action vanish on reload β€” the classic "it didn't save" with nothing red anywhere.
78
+ # Defaulted rather than conditional so a pre-wave definition reads as an empty flow with
79
+ # a terminal ending instead of `undefined`, which the client would have to special-case.
80
+ "flow": defn.get("flow") or {"actions": [], "ending": {"mode": "terminal"}},
81
  }
82
 
83
 
84
  def _triggers_vocab(session):
85
+ """C3's server-owned trigger list: `[{key, label, ready, needs, planned}]`, keys EXACTLY
86
+ `engine.TRIGGER_KEYS` + `engine.TRIGGER_PLANNED`. `ready:false` + `needs` renders as a
87
+ not-configured state β€” never a dead control, never a client-side union.
88
+
89
+ ⭐ WAVE 23 (R2): the list is the WHOLE Airtable-parity vocabulary, and the two triggers we
90
+ have not built ride it with `planned: true`. That is the honest version of "show all, wire
91
+ eight": the picker paints them faded with a reason instead of a shorter list that quietly
92
+ implies the missing ones do not exist. `clean_trigger` refuses them, so the faded state is
93
+ enforced at the door and not merely in the client's `disabled` attribute.
94
+ """
95
  tick_on = _tick_state()["enabled"]
96
  g = oauth_connect.status(session.runtime, session.uname).get("google") or {}
97
  email_ready = bool(g.get("configured")) and bool(g.get("connected")) \
 
102
  "manual": (True, ""),
103
  "schedule": (tick_on, "" if tick_on else "arm_tick"),
104
  "event_field": (True, ""),
105
+ "record_updated": (True, ""),
106
  "record_created": (True, ""),
107
+ "enters_view": (True, ""),
108
  "webhook": (True, ""),
109
  "email": (email_ready, email_needs),
110
+ "form_submitted": (True, ""),
111
  }
112
  out = []
113
  for k in engine.TRIGGER_KEYS:
114
  ready, needs = per[k]
115
  row = {"key": k, "label": engine.TRIGGER_LABELS[k], "ready": ready, "needs": needs,
116
+ "planned": False,
117
  # A3(3): the connect affordance is SERVER-COMPOSED β€” the client never maps a
118
  # `needs` token to a route, so B's CONNECT_PROVIDERS shim deletes itself.
119
  "connect": None}
 
121
  row["connect"] = {"provider": "google",
122
  "startUrl": "/api/v1/oauth/google/start"}
123
  out.append(row)
124
+ for k in engine.TRIGGER_PLANNED:
125
+ out.append({"key": k, "label": engine.TRIGGER_LABELS[k], "ready": False,
126
+ "needs": "coming_soon", "planned": True, "connect": None})
127
  return out
128
 
129
 
130
+ def _ai_ready():
131
+ """Can an AI review actually decide on this deployment? (C6/R14.) A boolean, never the
132
+ provider list and never a key β€” the surface needs to say "not configured" honestly, and that
133
+ needs exactly one bit."""
134
+ try:
135
+ import ai_review
136
+ return bool(ai_review.configured())
137
+ except Exception: # noqa: BLE001
138
+ return False
139
+
140
+
141
  def _tick_state():
142
  """⭐ WAVE 21 (C6 amendment A1) β€” can a SCHEDULE fire on this deployment?
143
 
 
204
  "nullaryLaneOps": list(engine.LANE_NULLARY_OPS),
205
  "maxLanes": engine.MAX_LANES,
206
  "cardCap": engine.BOARD_CARD_CAP},
207
+ # ⭐ WAVE 23 C4 (R3) β€” the ACTION MENU, including what we have not built. Each row
208
+ # carries `ready`, so B paints "Send email" and "Run script" faded with the server's
209
+ # own reason instead of omitting them β€” the owner asked for Airtable's full menu, and
210
+ # a shorter list would imply those actions do not exist. `clean_actions` REFUSES an
211
+ # unready kind, so the faded state is a wall rather than a styling choice.
212
+ "actionsCatalog": engine.action_catalog(),
213
+ # The builder's own vocabulary: how deep a condition tree may nest, how deep groups
214
+ # may nest, and the ceilings. B reads these instead of hard-coding the same numbers
215
+ # into its "+ Add condition" affordance.
216
+ "flow": {"condOps": list(engine.LANE_OPS),
217
+ "nullaryCondOps": list(engine.LANE_NULLARY_OPS),
218
+ "maxCondDepth": engine.MAX_COND_DEPTH,
219
+ "maxCondChildren": engine.MAX_COND_CHILDREN,
220
+ "maxGroupDepth": engine.MAX_GROUP_DEPTH,
221
+ "maxActions": engine.MAX_ACTIONS,
222
+ "endingModes": list(engine.ENDING_MODES),
223
+ "maxResetHours": engine.MAX_RESET_HOURS,
224
+ "reviewDeciders": list(engine.REVIEW_DECIDERS),
225
+ # Honest, per-deployment: with no LLM key configured the AI-review option
226
+ # renders as not-configured rather than as a control that silently holds
227
+ # every card for a human (C6's fail-closed path, made visible).
228
+ "aiReady": _ai_ready()},
229
  # Wave 21 C6-A1: {"enabled": bool, "source": "in-process"|"external"|""} β€” see
230
  # `_tick_state` for why one boolean off AIOS_AUTOMATIONS alone would lie.
231
  "tick": _tick_state()}
 
303
 
304
  @router.post("/automations")
305
  def create_automation(body: dict = Body(default=None), session: Session = Depends(_GATE)):
306
+ """Create an automation. ⭐ WAVE 23 (C2): the body may carry
307
+ `target: {mode: "existing"|"new"|"automated", table?, label?}` β€” the wizard's FIRST question,
308
+ answered before the kind. `new` mints the blank database in the same call, so the automation
309
+ is never saved pointing at a table that does not exist yet."""
310
  if not session.runtime.available():
311
  raise err(503, "store_unavailable", "the tenant store is unavailable β€” nothing was saved")
312
  defn, error = engine.create(session.runtime, body or {}, username=session.uname)
api/routes_connectors.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """THE CONNECTORS DIRECTORY (wave 23, contract C11 / owner ruling R8).
2
+
3
+ ⭐ WHAT THE OWNER ASKED FOR, verbatim: *"Build a connector module where it stores everything… BUT
4
+ if its not yet integrated make it faded grey or something so i can use it as a to-do list for
5
+ integrations."* So this endpoint deliberately answers with things we have NOT built. A directory
6
+ that listed only working connectors would be honest about each row and dishonest about the shape
7
+ of the product β€” the owner wants to see the map, including the empty parts.
8
+
9
+ β›” THE ROW SET IS COMPOSED HERE, ONCE, FROM THE REGISTRIES THAT ALREADY EXIST β€” the OAuth
10
+ provider registry (`oauth_connect.PROVIDERS`), the keychain's entry types, the automation source
11
+ registry, and the env-configured built-ins. It is NOT a hand-written list of cards. That matters
12
+ because the alternative rots in a specific way: a provider gets wired up somewhere and the
13
+ directory keeps calling it "planned" for a wave or two, which is exactly the misinformation this
14
+ surface exists to prevent. The only hand-written part is `PLANNED` β€” the things that have no
15
+ registry entry anywhere yet, which is precisely what "not yet integrated" means.
16
+
17
+ ⚠ STATE IS PER-READER. `connected` is a fact about THIS user for a per-user credential (OAuth
18
+ slots are per-user by R7) and about the tenant for a shared one (Odoo, Bright Data). A directory
19
+ that reported another user's Gmail as "connected" would be both wrong and a small disclosure.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+
25
+ from fastapi import APIRouter, Depends
26
+
27
+ import oauth_connect
28
+ from deps import Session, require_session
29
+
30
+ # β›” THE PREFIX IS LOAD-BEARING AND IT WAS MISSING (found at the wave-23 close-out audit).
31
+ # This router declared a bare `APIRouter()`, so its one path was served at `/connectors/directory`
32
+ # while `ConnectorsPage.tsx` fetches `${API_V1}/connectors/directory` β€” the directory would have
33
+ # 404'd in production with every gate green. Every other router in this package self-declares
34
+ # `prefix="/api/v1"`; this was the only one that did not.
35
+ #
36
+ # ⚠ AND THE WIRING GATE COULD NOT SEE IT. `verify_wiring`'s W23-W4 row matches the SERVER side on
37
+ # the substring `connectors/directory` and the CLIENT side on `${API_V1}/connectors/directory` β€”
38
+ # both matched, both sides "wired", prefixes disagreeing. A cross-fence row that greps each side
39
+ # independently proves the two files mention the same path SEGMENT, never that they agree on the
40
+ # same URL. `verify_api` now asserts the prefix on every mounted router instead.
41
+ router = APIRouter(prefix="/api/v1")
42
+
43
+ #: The to-do list (R8). Order is the owner's reading order, not alphabetical: the things most
44
+ #: likely to come next first. `queue` mirrors the standing provider queue in wave22-split C5-A2
45
+ #: so the two cannot drift into disagreeing about what is coming.
46
+ PLANNED = [
47
+ {"key": "slack", "label": "Slack", "kind": "oauth",
48
+ "desc": "Send messages into a channel when an automation reaches a step."},
49
+ {"key": "airtable", "label": "Airtable", "kind": "oauth",
50
+ "desc": "Import an existing base as a database."},
51
+ {"key": "outlook", "label": "Microsoft Outlook", "kind": "oauth",
52
+ "desc": "The Outlook half of the email trigger."},
53
+ {"key": "quickbooks", "label": "QuickBooks", "kind": "oauth",
54
+ "desc": "Accounting actuals beside the Odoo sales data."},
55
+ {"key": "xero", "label": "Xero", "kind": "oauth",
56
+ "desc": "Accounting actuals for tenants who do not use QuickBooks."},
57
+ {"key": "google_sheets", "label": "Google Sheets", "kind": "oauth",
58
+ "desc": "Read a sheet as a database; the Google connection already exists."},
59
+ {"key": "google_drive", "label": "Google Drive", "kind": "oauth",
60
+ "desc": "Attach files from Drive to records."},
61
+ {"key": "google_calendar", "label": "Google Calendar", "kind": "oauth",
62
+ "desc": "Turn dated records into calendar entries."},
63
+ {"key": "tiktok", "label": "TikTok", "kind": "token",
64
+ "desc": "The second social source beside Instagram."},
65
+ {"key": "meta", "label": "WhatsApp and Instagram (Meta)", "kind": "oauth",
66
+ "desc": "Blocked on Meta business verification; Instagram data comes via Bright Data."},
67
+ ]
68
+
69
+ #: Token connectors an admin can wire up TODAY by pasting a key β€” no OAuth client, no app review.
70
+ TOKEN_CONNECTORS = [
71
+ {"key": "stripe", "label": "Stripe", "entry": "stripe",
72
+ "desc": "Payments and payouts against your customer records.",
73
+ "hint": "Create a restricted key with read access in the Stripe dashboard."},
74
+ {"key": "shopify", "label": "Shopify", "entry": "shopify",
75
+ "desc": "Orders and products from a Shopify storefront.",
76
+ "hint": "Create a custom app in your store admin and copy its Admin API token."},
77
+ ]
78
+
79
+
80
+ def _entry_types(rt):
81
+ """Which keychain entry types this tenant has actually stored. One read, and it never raises
82
+ β€” a LOCKED keychain must degrade to "nothing connected", never to a 500 on a page whose whole
83
+ job is to tell you what is connected."""
84
+ try:
85
+ import core.keychain as kc
86
+ return {str(e.get("type") or "") for e in (kc.list_entries(rt) or [])}
87
+ except Exception: # noqa: BLE001
88
+ return set()
89
+
90
+
91
+ def directory(rt, uname, is_admin=False):
92
+ """The composed row set. Pure over the session, so the gate drives it without a request."""
93
+ rows = []
94
+ have = _entry_types(rt)
95
+
96
+ # --- Odoo: the reference connector. Env credentials are tenant #0's only (the R3 rule), so
97
+ # a stored keychain entry is what makes it connected for anybody else.
98
+ rows.append({
99
+ "key": "odoo", "label": "Odoo", "kind": "builtin",
100
+ "desc": "Sales, invoices, products and customers from your Odoo ERP.",
101
+ "state": "connected" if ("odoo" in have or os.environ.get("ODOO_URL")) else "available",
102
+ "manage": "keychain" if is_admin else "",
103
+ })
104
+
105
+ # --- OAuth providers, straight off the registry (so a new entry appears here for free).
106
+ st = oauth_connect.status(rt, uname)
107
+ for slug, meta in oauth_connect.PROVIDERS.items():
108
+ row_st = st.get(slug) or {}
109
+ rows.append({
110
+ "key": slug, "label": meta.get("label") or slug.title(), "kind": "oauth",
111
+ "desc": "Gmail for the email trigger; the same connection unlocks Sheets, Drive "
112
+ "and Calendar later." if slug == "google" else "",
113
+ # THREE distinct states, and the distinction is the point: a provider nobody has
114
+ # configured on this deployment is not the same as one this user has not connected,
115
+ # and until now both rendered identically (the C11 status() gap).
116
+ "state": ("connected" if row_st.get("connected") and not row_st.get("reconnect")
117
+ else "reconnect" if row_st.get("reconnect")
118
+ else "available" if row_st.get("configured")
119
+ else "unconfigured"),
120
+ "connectedAs": row_st.get("email") or "",
121
+ "startUrl": f"/api/v1/oauth/{slug}/start",
122
+ "manage": "oauth",
123
+ "needs": "" if row_st.get("configured") else "the owner registers the client",
124
+ })
125
+
126
+ # --- Token connectors (R8): connectable today, and honest that data flows arrive later.
127
+ for t in TOKEN_CONNECTORS:
128
+ rows.append({
129
+ "key": t["key"], "label": t["label"], "kind": "token", "desc": t["desc"],
130
+ "hint": t["hint"],
131
+ "state": "connected" if t["entry"] in have else "available",
132
+ "manage": "keychain" if is_admin else "",
133
+ # Said once, on the row, rather than in a paragraph somewhere (R13): storing the key
134
+ # is real and useful, and it is not the same as the data being on screen.
135
+ "note": "Storing the key connects the account; reading its data lands in a later "
136
+ "release.",
137
+ })
138
+
139
+ # --- Built-ins that are configuration, not credentials.
140
+ import automation_engine as _eng
141
+ rows.append({
142
+ "key": "brightdata", "label": "Bright Data", "kind": "builtin",
143
+ "desc": "Instagram profile and post capture for the discovery automations.",
144
+ "state": "connected" if _eng.bd_ready() else "unconfigured",
145
+ "needs": "" if _eng.bd_ready() else "AIOS_BRIGHTDATA_KEY is not set on this deployment",
146
+ "manage": "",
147
+ })
148
+ rows.append({
149
+ "key": "webhooks", "label": "Webhooks", "kind": "builtin",
150
+ "desc": "Let another system start an automation by calling a URL.",
151
+ "state": "connected", "manage": "automation",
152
+ })
153
+
154
+ for p in PLANNED:
155
+ rows.append({**p, "state": "planned",
156
+ "needs": "not built yet", "manage": ""})
157
+ return rows
158
+
159
+
160
+ @router.get("/connectors/directory")
161
+ def connectors_directory(session: Session = Depends(require_session)):
162
+ """C11: every connector this platform knows about, with its state for THIS reader.
163
+
164
+ Session-gated rather than admin-gated on purpose: a non-admin should be able to see what the
165
+ workspace is connected to (and connect their OWN per-user accounts, which is the whole point
166
+ of R7's per-user slots). `manage` is what carries admin-ness β€” it is empty for a non-admin,
167
+ so the client renders the card without an action rather than offering a door that 403s.
168
+ """
169
+ # ⚠ `session.admin`, NOT `is_admin`. A `getattr(session, "is_admin", False)` here reads as
170
+ # careful and is silently False for every admin on earth β€” the manage actions would simply
171
+ # never appear, with nothing to catch it. Attribute name checked against `deps.Session`.
172
+ rows = directory(session.runtime, session.uname, bool(session.admin))
173
+ return {"connectors": rows,
174
+ "counts": {"connected": sum(1 for r in rows if r["state"] == "connected"),
175
+ "available": sum(1 for r in rows if r["state"] == "available"),
176
+ "planned": sum(1 for r in rows if r["state"] == "planned")}}
api/routes_forms.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_forms.py β€” ⭐ wave-23 item 8 (contract W23-C9): THE PUBLIC FORM DOOR.
2
+
3
+ β›” BOTH ROUTES HERE ARE UNAUTHENTICATED, AND THAT IS THE POINT. A form link goes to somebody who
4
+ has no account β€” a supplier, a lead, a warehouse hand with a phone β€” so `Depends(require_session)`
5
+ is exactly what must NOT be on them. Everything that would normally be decided by a session is
6
+ therefore decided by the TOKEN, and this file is the whole of that decision:
7
+
8
+ * the token is minted server-side (`secrets.token_urlsafe(24)` β€” 192 bits) and stored on ONE
9
+ view. There is no enumeration to brute-force and no id in the URL to increment.
10
+ * a wrong token answers ONE thing for every tenant, every table and every typo: 403
11
+ `bad_form_token`. It never leaks whether a form exists, whether a tenant exists, or which of
12
+ the two you got wrong β€” the automation-hook pattern (`routes_automation.py:359-381`) one
13
+ level up, because a public door that answers 404-vs-403 is an oracle.
14
+ * the payload a valid token buys is the SMALLEST thing that can render a form: title,
15
+ description, submit label, and the fields with their labels, types and options. No tenant
16
+ slug, no table key, no row count, no field key that is not on the form, no username.
17
+
18
+ WHAT AN ATTACKER WITH A VALID TOKEN CAN DO, stated plainly because it is the honest boundary:
19
+ append rows to ONE user table, up to the per-token daily cap, with values that pass this file's
20
+ validation. They cannot read a row, cannot see any other field, cannot reach another table, and
21
+ cannot cause an automation to run as anybody (`form_fired` is a HUMAN door by A's design β€” see
22
+ its contract note in the split doc).
23
+
24
+ ⚠ THE `_DEV_FIXTURES` AUDIT (main.py:257-295) applied to this router, because that scar is
25
+ exactly "a thing became publicly reachable and nobody looked": the only bytes these routes can
26
+ return are the ones assembled in `_public_form` below, field by field. There is no passthrough of
27
+ a stored dict anywhere in this file β€” every response is built from named keys.
28
+
29
+ ⚠ PROTECTIONS ARE IN-PROCESS AND THEREFORE PER WORKER. One Space process today, so the sliding
30
+ window is real; the day there are two, both halves of a limit are halved-per-worker rather than
31
+ bypassed. Booked as DEBT rather than described as a guarantee β€” a rate limit that quietly stops
32
+ being one is worse than none.
33
+ """
34
+ import hmac
35
+ import secrets
36
+ import time
37
+
38
+ from fastapi import APIRouter, Request
39
+
40
+ from deps import err
41
+
42
+ router = APIRouter(prefix="/api/v1")
43
+
44
+ #: The mint. 24 bytes url-safe = 32 characters, 192 bits β€” the same shape the automation hook's
45
+ #: per-automation token uses, so there is ONE token strength in the product rather than two.
46
+ TOKEN_BYTES = 24
47
+
48
+ #: The honeypot's field name. It is rendered by `FormPublic.tsx` as a real input, visually
49
+ #: hidden and `autocomplete="off"` + `tabIndex={-1}` β€” a human never sees it and a keyboard user
50
+ #: never lands on it, so anything that fills it is a script walking the DOM. ⚠ Named for
51
+ #: something a bot WANTS to fill; a field called `honeypot` is one `if` away from being skipped.
52
+ HONEYPOT_FIELD = "website_url"
53
+
54
+ #: v1 request protections (contract C9). Per IP and per token respectively.
55
+ RATE_WINDOW_S = 60
56
+ RATE_PER_WINDOW = 30
57
+ DAILY_PER_TOKEN = 500
58
+ MAX_BODY_BYTES = 16 * 1024
59
+ #: One text answer's ceiling. Generous for a paragraph, far below the body cap, and it exists so
60
+ #: a single 15 KB answer cannot be smuggled past a body check that only measures the whole.
61
+ MAX_VALUE_LEN = 4000
62
+
63
+ #: `{ip: [timestamps]}` and `{token: (day, count)}`. Module-level and unbounded-by-design within
64
+ #: a day: an IP list is pruned to the window on every touch, and the token map holds one small
65
+ #: tuple per form that was submitted to today.
66
+ _HITS: dict = {}
67
+ _DAILY: dict = {}
68
+
69
+
70
+ def _rate_ok(ip: str, now: float) -> bool:
71
+ """A sliding window, not a fixed bucket. A fixed one lets a caller spend the whole allowance
72
+ at 11:59:59 and the whole next allowance at 12:00:00 β€” double the rate at the boundary,
73
+ which is the moment a script is most likely to be hammering."""
74
+ seen = [t for t in _HITS.get(ip, ()) if now - t < RATE_WINDOW_S]
75
+ seen.append(now)
76
+ _HITS[ip] = seen
77
+ # ⚠ Prune the WHOLE map occasionally, or a process that has served a million IPs holds a
78
+ # million lists forever. Cheap and amortised: only when the map is already large.
79
+ if len(_HITS) > 4096:
80
+ for k in [k for k, v in _HITS.items() if not v or now - v[-1] > RATE_WINDOW_S]:
81
+ _HITS.pop(k, None)
82
+ return len(seen) <= RATE_PER_WINDOW
83
+
84
+
85
+ def _daily_ok(token: str, now: float) -> bool:
86
+ day = int(now // 86400)
87
+ d, n = _DAILY.get(token, (day, 0))
88
+ if d != day:
89
+ d, n = day, 0
90
+ n += 1
91
+ _DAILY[token] = (d, n)
92
+ return n <= DAILY_PER_TOKEN
93
+
94
+
95
+ def _client_ip(request: Request) -> str:
96
+ """⚠ `X-Forwarded-For`'s FIRST entry, and only because this app runs behind exactly one
97
+ proxy (the HF Space router). It is caller-controlled, so it is a rate-limit key and NOTHING
98
+ else β€” never a permission input. Falls back to the socket peer."""
99
+ fwd = request.headers.get("x-forwarded-for") or ""
100
+ first = fwd.split(",")[0].strip()
101
+ return first or (request.client.host if request.client else "?")
102
+
103
+
104
+ def _refuse():
105
+ """THE ONE REFUSAL. Every failure to resolve a token answers this β€” wrong token, disabled
106
+ form, deleted view, deleted table, a tenant whose store is down. Callers must not branch a
107
+ more specific message out of it: the difference between "no such form" and "that form is
108
+ disabled" tells an enumerator which tokens are real."""
109
+ return err(403, "bad_form_token", "that form link is not valid")
110
+
111
+
112
+ def _same(a: str, b: str) -> bool:
113
+ """Constant-time. A `==` here leaks a token's prefix through timing, one character at a
114
+ time, which is the whole reason the automation hook compares this way too."""
115
+ return hmac.compare_digest(str(a or ""), str(b or ""))
116
+
117
+
118
+ # --- resolution ------------------------------------------------------------------------------
119
+ #
120
+ # ⚠ A FULL SCAN, deliberately, and its cost is BOOKED (DEBT β€” "the form-token index"). The
121
+ # automation hook resolves the same way (`routes_automation.py:370` walks `known_tenants()`)
122
+ # because the alternative — a global token→tenant map — is a second index that has to be written
123
+ # on every mint, regenerate, view delete and table delete, and an index that misses one of those
124
+ # is a live form that answers 403 or, far worse, a dead token that still resolves. With one
125
+ # process, a handful of tenants and a 30/min ceiling in front of it, the scan is affordable; the
126
+ # index becomes worth its risk when either number grows.
127
+
128
+
129
+ def _iter_form_views(rt):
130
+ """Yield `(table_key, view_id, form_spec)` for every form-enabled view in one tenant.
131
+
132
+ A form spec lives at `view.config.display.form` and carries the token β€” see W23-C9. Reads
133
+ only; nothing here writes, so a malformed stratum is skipped rather than repaired.
134
+ """
135
+ import core.user_tables as user_tables
136
+
137
+ try:
138
+ tables = user_tables.all_tables(st=rt) or {}
139
+ except Exception: # noqa: BLE001
140
+ return
141
+ for key in list(tables):
142
+ try:
143
+ bucket = rt.get(f"{key}_table_workspace") or {}
144
+ except Exception: # noqa: BLE001
145
+ continue
146
+ if not isinstance(bucket, dict):
147
+ continue
148
+ for ws in bucket.values():
149
+ if not isinstance(ws, dict):
150
+ continue
151
+ for view_id, view in (ws.get("views") or {}).items():
152
+ if not isinstance(view, dict):
153
+ continue
154
+ display = ((view.get("config") or {}).get("display") or {})
155
+ spec = display.get("form")
156
+ if isinstance(spec, dict) and spec.get("token"):
157
+ yield str(key), str(view_id), spec
158
+
159
+
160
+ def _resolve(token: str):
161
+ """`(runtime, tenant_slug, table_key, form_spec)` for a token, or None.
162
+
163
+ ⚠ THE LOOP DOES NOT SHORT-CIRCUIT ON THE TENANT, only on the match. A `break` on the first
164
+ tenant whose store answered would make "the third tenant's form" depend on the first two
165
+ being healthy, and a form that stops working because somebody else's store blipped is the
166
+ kind of failure nobody can reproduce.
167
+ """
168
+ from harness import runtime as _rt
169
+
170
+ if not token or len(token) < 16:
171
+ return None
172
+ for slug in _rt.known_tenants():
173
+ try:
174
+ rt = _rt.get_runtime(slug)
175
+ except Exception: # noqa: BLE001
176
+ continue
177
+ for table_key, _view_id, spec in _iter_form_views(rt):
178
+ if _same(str(spec.get("token") or ""), token):
179
+ return rt, slug, table_key, spec
180
+ return None
181
+
182
+
183
+ # --- the public shape -------------------------------------------------------------------------
184
+
185
+
186
+ def _public_form(rt, table_key: str, spec: dict) -> dict:
187
+ """The ONLY bytes a valid token buys. Built key by key β€” there is no `**stored` anywhere in
188
+ this function, which is what makes "nothing else leaks" a property of the code rather than a
189
+ promise about what happens to be in the bag today.
190
+
191
+ ⚠ The field ORDER is the form's own (`spec['fields']`), not the table's: the builder let
192
+ somebody arrange these questions, and re-deriving the order from the schema would silently
193
+ rearrange a form every time a column was added.
194
+ """
195
+ import core.user_tables as user_tables
196
+
197
+ defn = user_tables.get(table_key, st=rt) or {}
198
+ by_key = {f.get("key"): f for f in (defn.get("fields") or []) if isinstance(f, dict)}
199
+ required = {str(k) for k in (spec.get("required") or [])}
200
+ out = []
201
+ for key in (spec.get("fields") or []):
202
+ f = by_key.get(str(key))
203
+ # A field deleted since the form was built is DROPPED, never rendered as a dead input β€”
204
+ # and never an error: losing one column must not take a live form offline.
205
+ if not f or f.get("source") not in (None, "overlay"):
206
+ continue
207
+ row = {"key": str(f.get("key")), "label": str(f.get("label") or f.get("key")),
208
+ "type": str(f.get("type") or "text"), "required": str(key) in required}
209
+ options = f.get("options")
210
+ if isinstance(options, list) and options:
211
+ row["options"] = [str(o) for o in options][:200]
212
+ if f.get("type") == "rating":
213
+ row["max"] = int(f.get("max") or 5)
214
+ out.append(row)
215
+ return {
216
+ "title": str(spec.get("title") or "")[:200],
217
+ "desc": str(spec.get("desc") or "")[:1000],
218
+ "submitLabel": str(spec.get("submitLabel") or "")[:60] or "Submit",
219
+ "fields": out,
220
+ "honeypot": HONEYPOT_FIELD,
221
+ }
222
+
223
+
224
+ # --- validation -------------------------------------------------------------------------------
225
+
226
+
227
+ def _clean_values(fields: list, raw: dict):
228
+ """`(values, error_sentence)` β€” REFUSE, NEVER COERCE (C9, and the engine's own law).
229
+
230
+ The temptation with a public form is to be forgiving: read "12 units" as 12, "yes" as
231
+ checked, "tomorrow" as a date. Every one of those writes a number nobody typed into somebody
232
+ else's database, and the person who submitted the form is not there to see it happen. So a
233
+ value that does not answer its own field's question is refused with a sentence naming the
234
+ FIELD LABEL β€” the only name the submitter has ever seen.
235
+ """
236
+ out = {}
237
+ for f in fields:
238
+ key, label, ftype = f["key"], f["label"], f["type"]
239
+ v = raw.get(key)
240
+ text = "" if v is None else str(v)
241
+ text = text.strip() if isinstance(v, str) else text
242
+ if len(text) > MAX_VALUE_LEN:
243
+ return None, f"β€œ{label}” is too long β€” {MAX_VALUE_LEN} characters at most."
244
+ if text == "":
245
+ if f.get("required"):
246
+ return None, f"β€œ{label}” is required."
247
+ continue
248
+ if ftype in ("int", "currency", "pct", "rating"):
249
+ try:
250
+ num = float(text.replace(",", ""))
251
+ except ValueError:
252
+ return None, f"β€œ{label}” must be a number."
253
+ if ftype == "rating":
254
+ top = int(f.get("max") or 5)
255
+ if not (1 <= num <= top) or num != int(num):
256
+ return None, f"β€œ{label}” must be a whole number from 1 to {top}."
257
+ text = str(int(num)) if ftype in ("int", "rating") else str(num)
258
+ elif ftype == "checkbox":
259
+ # The storage contract is '1' or blank (types.ts). Anything a checkbox can actually
260
+ # send is one of these four spellings; anything else is not a checkbox answer.
261
+ if text.lower() not in ("1", "true", "on", "yes", "0", "false", "off", "no"):
262
+ return None, f"β€œ{label}” must be checked or unchecked."
263
+ text = "1" if text.lower() in ("1", "true", "on", "yes") else ""
264
+ elif ftype in ("select", "status"):
265
+ options = f.get("options") or []
266
+ if options and text not in options:
267
+ return None, f"β€œ{label}” must be one of the listed choices."
268
+ elif ftype == "multiselect":
269
+ options = f.get("options") or []
270
+ parts = [p.strip() for p in text.split(",") if p.strip()]
271
+ if options and any(p not in options for p in parts):
272
+ return None, f"β€œ{label}” must be chosen from the listed choices."
273
+ text = ", ".join(parts)
274
+ elif ftype == "date":
275
+ # ISO only. A public form has no timezone, no locale and no user to ask, so
276
+ # "03/04/2026" is genuinely ambiguous and guessing it wrong is a silent data error.
277
+ if len(text) != 10 or text[4] != "-" or text[7] != "-":
278
+ return None, f"β€œ{label}” must be a date."
279
+ try:
280
+ time.strptime(text, "%Y-%m-%d")
281
+ except ValueError:
282
+ return None, f"β€œ{label}” must be a real date."
283
+ elif ftype == "email":
284
+ if "@" not in text or text.startswith("@") or text.endswith("@"):
285
+ return None, f"β€œ{label}” must be an email address."
286
+ out[key] = text
287
+ return out, ""
288
+
289
+
290
+ # --- the routes -------------------------------------------------------------------------------
291
+
292
+
293
+ @router.get("/forms/{token}")
294
+ def get_form(token: str, request: Request):
295
+ """Render-time payload for a public form. Rate-limited like the POST: an unauthenticated GET
296
+ that walks every tenant's workspace is the cheapest way to make this process do work."""
297
+ if not _rate_ok(_client_ip(request), time.time()):
298
+ raise err(429, "too_many_requests", "too many requests β€” wait a moment and try again")
299
+ found = _resolve(token)
300
+ if not found:
301
+ raise _refuse()
302
+ rt, _slug, table_key, spec = found
303
+ return _public_form(rt, table_key, spec)
304
+
305
+
306
+ async def _bounded_body(request: Request) -> dict:
307
+ """The request body, READ WITH A BOUND β€” never `Body(...)`, never `await request.body()`.
308
+
309
+ β›” THE FIRST VERSION OF THIS ROUTE TOOK `body: dict = Body(default=None)` AND ITS 16 KB CAP
310
+ CAPPED NOTHING. FastAPI reads and JSON-parses the WHOLE body before the handler's first line
311
+ runs, so a `content-length` check inside the handler is inspected after the allocation it
312
+ claims to prevent β€” and `content-length` is caller-supplied anyway, so omitting it or sending
313
+ chunked left no bound at all. On the one unauthenticated write path in the product.
314
+
315
+ ⚠ It was GREEN, and that is the part worth remembering: the gate leg set the header itself,
316
+ so it only ever exercised the honest path. A protection whose test supplies the very value it
317
+ is protecting against is testing its own politeness.
318
+
319
+ Streaming with a running total is the actual bound: the read STOPS at the ceiling rather than
320
+ discovering afterwards that it should have.
321
+ """
322
+ size, chunks = 0, []
323
+ async for chunk in request.stream():
324
+ size += len(chunk)
325
+ if size > MAX_BODY_BYTES:
326
+ raise err(413, "body_too_large", "that submission is too large")
327
+ chunks.append(chunk)
328
+ import json
329
+ try:
330
+ parsed = json.loads(b"".join(chunks) or b"{}")
331
+ except ValueError:
332
+ raise err(400, "no_values", "that submission could not be read")
333
+ return parsed if isinstance(parsed, dict) else {}
334
+
335
+
336
+ @router.post("/forms/{token}")
337
+ async def submit_form(token: str, request: Request):
338
+ """One submission: validate, append the row, fire `form_submitted`.
339
+
340
+ ⚠ THE ORDER IS LOAD-BEARING. The row is written FIRST and the trigger fired second, off the
341
+ id the write returned β€” so an automation can never run against a record that does not exist,
342
+ and a trigger that raises cannot cost the submitter the answer they just typed.
343
+ """
344
+ now = time.time()
345
+ if not _rate_ok(_client_ip(request), now):
346
+ raise err(429, "too_many_requests", "too many requests β€” wait a moment and try again")
347
+ body = await _bounded_body(request)
348
+ found = _resolve(token)
349
+ if not found:
350
+ raise _refuse()
351
+ rt, _slug, table_key, spec = found
352
+
353
+ values = (body or {}).get("values")
354
+ if not isinstance(values, dict):
355
+ raise err(400, "no_values", "that submission was empty")
356
+
357
+ # β›” THE HONEYPOT ANSWERS 200 AND WRITES NOTHING. A 403 would tell the script it was
358
+ # detected, and the next version of it simply stops filling the field. A success it can
359
+ # never verify is the only answer that costs the operator nothing to give.
360
+ if str(values.get(HONEYPOT_FIELD) or "").strip():
361
+ return {"ok": True}
362
+
363
+ form = _public_form(rt, table_key, spec)
364
+ if not form["fields"]:
365
+ raise _refuse()
366
+ clean, problem = _clean_values(form["fields"], values)
367
+ if problem:
368
+ raise err(400, "invalid_submission", problem)
369
+
370
+ # ⚠ THE DAILY CAP IS SPENT ON A WRITE, NOT ON A REQUEST, and the order is the whole point.
371
+ # Counted before validation β€” where it was first β€” 500 malformed POSTs take a LIVE form
372
+ # offline for 24 hours, and one address can send them in about 17 minutes under the per-IP
373
+ # window. The cap exists to bound what reaches the tenant's table; pacing abuse is the
374
+ # window's job, and a refusal costs the attacker the same either way.
375
+ if not _daily_ok(token, time.time()):
376
+ raise err(429, "form_daily_cap",
377
+ "this form has reached today's submission limit β€” try again tomorrow")
378
+
379
+ import core.user_tables as user_tables
380
+
381
+ # ⚠ THE STAMP. `user_tables.add_row` filters values to the table's own field keys, so a
382
+ # `created_by` pair passed in `values` would simply be dropped β€” the row itself has no meta
383
+ # slot today. The `username` argument is the channel that DOES survive: it rides the
384
+ # `record_created` row event, so every trigger and audit downstream sees `form:<prefix>`
385
+ # rather than a blank actor. Booked as a dated amendment in the split doc; a persistent
386
+ # per-row stamp needs `add_row` to grow one, which is SESSION A's file.
387
+ actor = f"form:{token[:8]}"
388
+ row_id = user_tables.add_row(table_key, clean, username=actor, st=rt)
389
+ if not row_id:
390
+ raise err(409, "not_accepted", "that submission could not be saved β€” try again later")
391
+
392
+ # W23-W7 β€” A's FROZEN signature, called verbatim (CP1-a). Failure here must not turn a
393
+ # SAVED row into an error for the submitter: the answer is theirs and it is already stored.
394
+ try:
395
+ import automation_engine as engine
396
+ engine.form_fired(rt, table_key, row_id, values=clean, form_token=token)
397
+ except Exception: # noqa: BLE001
398
+ pass
399
+ return {"ok": True}
api/routes_nav.py CHANGED
@@ -10,6 +10,8 @@ The full rule set β€” archived is invisible to everyone, `group_only` rows are e
10
  `prefs.json` Library preference is deliberately NOT applied β€” is documented on
11
  `core.perms.nav_pages`, which is where it belongs: one place, both callers.
12
  """
 
 
13
  from fastapi import APIRouter, Body, Depends
14
 
15
  from deps import Session, err, perms, require_session
@@ -50,6 +52,33 @@ _ICON_SHAPES = frozenset({"folder", "star", "flag", "tag", "bookmark", "grid",
50
  _ICON_TONES = frozenset({"neutral", "blue", "green", "yellow", "red"})
51
  _MAX_NAV_NAME = 60
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  def _clean_nav_prefs(raw, page_keys):
55
  """Validated wholesale replacement, the `clean_folders` posture: prune, never invent.
@@ -131,6 +160,43 @@ def _read_nav_meta(runtime):
131
  return out
132
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  @router.get("/nav")
135
  def nav(session: Session = Depends(require_session)):
136
  """`{pages: [{key,label,source?,chrome}], landing}`.
@@ -220,7 +286,72 @@ def nav(session: Session = Depends(require_session)):
220
  landing = perms.landing_page(session.user)
221
  if landing and not any(p.get("key") == landing for p in pages):
222
  landing = pages[0].get("key")
223
- return {"pages": pages, "landing": landing}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
 
226
  @router.post("/nav/meta")
 
10
  `prefs.json` Library preference is deliberately NOT applied β€” is documented on
11
  `core.perms.nav_pages`, which is where it belongs: one place, both callers.
12
  """
13
+ import time
14
+
15
  from fastapi import APIRouter, Body, Depends
16
 
17
  from deps import Session, err, perms, require_session
 
52
  _ICON_TONES = frozenset({"neutral", "blue", "green", "yellow", "red"})
53
  _MAX_NAV_NAME = 60
54
 
55
+ #: WAVE 23 (contract C10 / ruling R7) β€” the Home landing's RECENTS.
56
+ #:
57
+ #: PER-USER, like `nav_prefs` two buckets up and unlike `nav_meta`: what I opened last is
58
+ #: nobody else's business, and a tenant-wide "recently opened" would be a surveillance feature
59
+ #: rather than a convenience one.
60
+ #:
61
+ #: β›” A MAP KEYED BY PAGE, NOT AN APPEND LOG, and the difference is the whole feature.
62
+ #: `{username: {pageKey: <epoch seconds>}}` β€” re-opening a database OVERWRITES its stamp. An
63
+ #: append-only list capped at 50 fills with fifty copies of the same ten databases inside one
64
+ #: working session, and the cap then evicts OLDEST-FIRST: the tenth database you touched falls
65
+ #: off the list while forty slots hold repeat visits to the first. Keying by page makes
66
+ #: "recent" mean what the word means, and makes the cap bound the number of DATABASES
67
+ #: remembered rather than the number of clicks.
68
+ _NAV_RECENTS_KEY = "nav_recents"
69
+ _MAX_RECENTS = 50
70
+
71
+ #: ⚠ EPOCH SECONDS (UTC by definition), never a formatted stamp β€” and this is a correction of a
72
+ #: precedent, not a preference. `user_tables.create` writes
73
+ #: `datetime.now().strftime('%Y-%m-%dT%H:%M:%S')`: naive LOCAL time, no offset. A browser parses
74
+ #: that string as its OWN local time, so on a UTC host read by a non-UTC reader "opened 30
75
+ #: minutes ago" renders as "opened 7 hours ago" and the Today / Past-7-days buckets misfile β€”
76
+ #: with nothing to go red, because both ends are internally consistent. An integer instant has
77
+ #: no such reading. (D-18 made the same correction for notification stamps AFTER the defect
78
+ #: shipped; this is that lesson applied before it.)
79
+ def _now() -> int:
80
+ return int(time.time())
81
+
82
 
83
  def _clean_nav_prefs(raw, page_keys):
84
  """Validated wholesale replacement, the `clean_folders` posture: prune, never invent.
 
160
  return out
161
 
162
 
163
+ def _read_recents(runtime, uname, allowed):
164
+ """This user's recents, newest first, PRUNED to what they may currently see.
165
+
166
+ ⚠ PRUNED ON READ, never on write, and both halves of that are deliberate:
167
+
168
+ Β· the WRITE happens on every route open β€” it is the one hot path this file has β€” so it
169
+ must not build the whole nav to validate one key;
170
+ Β· a key whose grant was REVOKED must stop being offered without anyone running a
171
+ migration, and must come back if the grant does. That is `_clean_nav_prefs`' own
172
+ prune-never-invent posture, applied to a second bucket for the same reason.
173
+
174
+ A key that is not in `allowed` therefore reaches the store and never reaches a screen β€”
175
+ which also means a stuffed key cannot be used to discover what exists: it comes back only
176
+ if the session could already see it.
177
+ """
178
+ try:
179
+ stored = (runtime.get(_NAV_RECENTS_KEY) or {}).get(uname) or {}
180
+ except Exception:
181
+ return [] # a store blip must not take the nav down with it
182
+ if not isinstance(stored, dict):
183
+ return []
184
+ out = []
185
+ for key, at in stored.items():
186
+ key = str(key)
187
+ if allowed is not None and key not in allowed:
188
+ continue
189
+ try:
190
+ at = int(at)
191
+ except (TypeError, ValueError):
192
+ continue # a stamp this build cannot read is not a stamp
193
+ if at <= 0:
194
+ continue
195
+ out.append({"key": key, "at": at})
196
+ out.sort(key=lambda r: r["at"], reverse=True)
197
+ return out[:_MAX_RECENTS]
198
+
199
+
200
  @router.get("/nav")
201
  def nav(session: Session = Depends(require_session)):
202
  """`{pages: [{key,label,source?,chrome}], landing}`.
 
286
  landing = perms.landing_page(session.user)
287
  if landing and not any(p.get("key") == landing for p in pages):
288
  landing = pages[0].get("key")
289
+ # WAVE 23 (C10 / R7) β€” the Home landing's recents, on the payload the client already asks
290
+ # for. A second round trip for a list this short, computed from a bucket this route is
291
+ # already holding the store open for, would be a request per page load for no gain.
292
+ #
293
+ # β›” `allowed` IS THE ASSEMBLED PAGE LIST, not `_visible_top_keys`. The latter reads
294
+ # `perms.nav_pages` alone, which does NOT include this tenant's `ut_*` databases β€” those are
295
+ # merged into `pages` above, by this route, after the permission wall. Pruning against the
296
+ # narrower set would have silently dropped every user database from Home's recents: the
297
+ # exact surface R7 is about, invisible, with every gate green.
298
+ recents = _read_recents(session.runtime, session.uname,
299
+ {str(p.get("key", "")) for p in pages})
300
+ return {"pages": pages, "landing": landing, "recents": recents}
301
+
302
+
303
+ @router.post("/nav/opened")
304
+ def nav_opened(body: dict = Body(default=None),
305
+ session: Session = Depends(require_session)):
306
+ """WAVE 23 (C10) β€” stamp a page as JUST OPENED. Fire-and-forget from the client.
307
+
308
+ The client calls this on every route commit, so this is the only write in this file on a
309
+ hot path, and three things follow from that:
310
+
311
+ Β· `flush='async'` β€” the coalescing mode ([[store-async-flush]]). A blocking upload per
312
+ page open against an HF-Dataset-backed store would put a network round trip inside every
313
+ navigation. `nav_prefs`/`nav_meta` stay `sync` because a folder rename is not a hot path;
314
+ this is.
315
+ Β· NO VALIDATION OF THE KEY against the nav. Building the page list to check one string
316
+ would make the stamp cost more than the navigation that triggered it β€” and it would buy
317
+ nothing, because the READ prunes to what the session may currently see. An unknown or
318
+ revoked key is stored and never served back.
319
+ Β· THE MAP IS CAPPED HERE TOO. Read-side capping alone would let a hostile or buggy client
320
+ grow one user's document without bound; `_MAX_RECENTS` entries survive, oldest first to
321
+ go, which is the same rule the read applies.
322
+ """
323
+ body = body if isinstance(body, dict) else {}
324
+ key = str(body.get("key") or "").strip()[:60]
325
+ if not key:
326
+ raise err(400, "bad_request", "no page was named")
327
+ if not session.runtime.available():
328
+ raise err(503, "store_unavailable",
329
+ "the tenant store is unavailable β€” nothing was recorded")
330
+ stamp, uname = _now(), session.uname
331
+
332
+ def _up(data):
333
+ data = data if isinstance(data, dict) else {}
334
+ mine = dict(data.get(uname) or {}) if isinstance(data.get(uname), dict) else {}
335
+ mine[key] = stamp
336
+ if len(mine) > _MAX_RECENTS:
337
+ # Oldest first. `int(v)` guarded: a stamp an older build wrote in another shape
338
+ # sorts as 0 and is the first thing evicted, which is the right answer for a value
339
+ # this route can no longer read.
340
+ def _at(item):
341
+ try:
342
+ return int(item[1])
343
+ except (TypeError, ValueError):
344
+ return 0
345
+ mine = dict(sorted(mine.items(), key=_at, reverse=True)[:_MAX_RECENTS])
346
+ data[uname] = mine
347
+ return data
348
+
349
+ try:
350
+ session.runtime.update(_NAV_RECENTS_KEY, _up, flush='async')
351
+ except Exception:
352
+ raise err(503, "store_unavailable",
353
+ "the tenant store refused the write β€” nothing was recorded")
354
+ return {"key": key, "at": stamp}
355
 
356
 
357
  @router.post("/nav/meta")
api/routes_tables.py CHANGED
@@ -375,13 +375,37 @@ def add_field(table_key: str, body: dict = Body(default=None),
375
  ut = _ut()
376
  field = ut.add_field(table_key, body or {}, st=session.runtime)
377
  if not field:
378
- raise err(400, "refused",
379
- f"the column was refused β€” check the name and type, or the table may be at "
380
- f"its {ut.MAX_FIELDS}-column cap (types: "
381
- f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
 
 
 
 
382
  return {"field": field}
383
 
384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  @router.patch("/tables/{table_key}/fields/{fkey}")
386
  def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
387
  session: Session = Depends(require_session)):
 
375
  ut = _ut()
376
  field = ut.add_field(table_key, body or {}, st=session.runtime)
377
  if not field:
378
+ # ⭐ D-46 CLOSED (wave 23) β€” the C8 flow law gets its OWN sentence. `add_field` answers
379
+ # None for every refusal, so this route said "check the name and type" to somebody whose
380
+ # name and type were fine and whose automation column named a flow that does not exist.
381
+ # A refusal that misdirects is worse than a bare 400: it sends the reader to look at the
382
+ # one thing that was never wrong. Checked HERE, in the route's own words, because the
383
+ # law itself stays enforced in `user_tables.flow_bound` β€” this narrates it, never
384
+ # re-implements it (a second copy of the rule is how two doors start disagreeing).
385
+ raise err(400, "refused", _refusal_sentence(ut, session, body or {}))
386
  return {"field": field}
387
 
388
 
389
+ def _refusal_sentence(ut, session, body):
390
+ """Why was this column refused? The specific reason when we can name one, the general list
391
+ otherwise β€” never a specific-sounding guess."""
392
+ bag = (body or {}).get("automation")
393
+ if isinstance(bag, dict):
394
+ flow = str(bag.get("flowId") or "").strip()
395
+ if not flow:
396
+ return ("an automation column has to name the automation that fills it β€” pick a "
397
+ "flow, or make this an ordinary column")
398
+ if not ut.flow_bound(bag, st=session.runtime):
399
+ return (f"this column names automation {flow!r}, which does not exist in this "
400
+ f"workspace β€” it may have been deleted; pick a flow that is still there")
401
+ kind = str((body or {}).get("type") or "").strip()
402
+ if kind and kind not in ut.UT_FIELD_TYPES:
403
+ return (f"{kind!r} is not a column type here (types: "
404
+ f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
405
+ return (f"the column was refused β€” check the name and type, or the table may be at its "
406
+ f"{ut.MAX_FIELDS}-column cap (types: {', '.join(sorted(ut.UT_FIELD_TYPES))})")
407
+
408
+
409
  @router.patch("/tables/{table_key}/fields/{fkey}")
410
  def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
411
  session: Session = Depends(require_session)):
api/routes_templates.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_templates.py β€” WAVE 23 item 7 (ruling R10, contract C12): the template doors.
2
+
3
+ GET /api/v1/templates?table=<page key> what can be applied HERE
4
+ POST /api/v1/templates/{key}/apply {table} apply it, to the CALLING USER's own views
5
+
6
+ Both session-gated. Neither is public and neither is admin-only: applying a template writes the
7
+ caller's PERSONAL saved views, which is something every user does to their own workspace all day
8
+ (`table_store.save_view(..., shared=False)`). The registry itself is platform-curated code
9
+ (`platform/core/view_templates.py`) β€” R10 explicitly rules out an end-user authoring UI this
10
+ wave, so there is no write door for the registry at all.
11
+
12
+ β›” THE WALL IS THE SAME PREDICATE THE NAV USES, per topic, and it is applied before anything is
13
+ read or written:
14
+ Β· `ut_*` β€” `user_tables.may_open` (creator, admin, or a share grant), exactly as
15
+ `routes_tables._defn_or_refuse` does. `session.require` would 403 every ut key, because a
16
+ user table is deliberately not a module (the split `/nav/schema/{key}` already makes).
17
+ Β· a built-in topic β€” `session.require(key)`, the module grant.
18
+ A key that is neither is a 404 with the same sentence for both: this route is not a directory of
19
+ what exists.
20
+
21
+ ⚠ REFUSE, NEVER PARTIALLY APPLY. `view_templates.missing_columns` runs against the TARGET's live
22
+ contract, and a template naming a column the target does not have comes back 400 with the
23
+ columns listed. The alternative β€” writing the views anyway β€” is the `_seed_wave17` failure
24
+ verbatim: `clean_filter_tree` DROPS a leaf on an unknown column, and a view whose only condition
25
+ was dropped shows EVERY row under a name that promises a shortlist.
26
+ """
27
+ import uuid
28
+
29
+ from fastapi import APIRouter, Body, Depends
30
+
31
+ from deps import Session, err, perms, require_session
32
+
33
+ router = APIRouter(prefix="/api/v1")
34
+
35
+
36
+ def _templates():
37
+ import core.view_templates as view_templates
38
+ return view_templates
39
+
40
+
41
+ def _target_or_refuse(session, table_key):
42
+ """`(field_keys, source_label)` for a table this session may open β€” or a refusal.
43
+
44
+ The field keys are the LIVE contract, read the same way each topic's own reader reads it, so
45
+ a template can never be offered against a column list this end assembled by hand.
46
+ """
47
+ key = str(table_key or '').strip()
48
+ if not key:
49
+ raise err(400, "bad_request", "no database was named")
50
+ if key.startswith('ut_'):
51
+ import core.user_tables as user_tables
52
+ defn = user_tables.get(key, st=session.runtime)
53
+ if not defn:
54
+ raise err(404, "unknown_table", "that database does not exist")
55
+ if not user_tables.may_open(key, session.uname, session.admin, st=session.runtime):
56
+ raise err(403, "forbidden", "that database belongs to another user")
57
+ fields = [str(f.get('key')) for f in (defn.get('fields') or []) if f.get('key')]
58
+ # A user table has no fixed topic. Its SOURCE label is left blank so `offer` gates on
59
+ # columns alone β€” which is the honest answer for a table whose shape its owner decides,
60
+ # and is exactly why the column test is the real contract (view_templates' own note).
61
+ return fields, ''
62
+ if key not in ('customer_data', 'product_data'):
63
+ raise err(404, "unknown_table", "that database does not exist")
64
+ session.require(key)
65
+ import aios_grid
66
+ if key == 'product_data':
67
+ return [f['key'] for f in aios_grid.product_fields()], 'odoo_product'
68
+ return [f['key'] for f in aios_grid.FIELDS], 'odoo_customer'
69
+
70
+
71
+ @router.get("/templates")
72
+ def list_templates(table: str = "", session: Session = Depends(require_session)):
73
+ """What can be applied to THIS database. `{table, templates: [...]}`.
74
+
75
+ Filtered by the target's real columns, so the picker cannot offer something the apply door
76
+ would refuse β€” one predicate, two callers (`view_templates.offer` wraps
77
+ `missing_columns`, which is the same function the POST below re-runs).
78
+ """
79
+ fields, source = _target_or_refuse(session, table)
80
+ vt = _templates()
81
+ return {"table": table, "templates": vt.offer(fields, source or None)}
82
+
83
+
84
+ @router.post("/templates/{key}/apply")
85
+ def apply_template(key: str, body: dict = Body(default=None),
86
+ session: Session = Depends(require_session)):
87
+ """Apply a template to a database, as the CALLING USER's own saved views.
88
+
89
+ IDEMPOTENT BY PINNED VIEW ID (`tpl_<template>_<suffix>`): a second click updates the same
90
+ views rather than minting "Past due 2". ⚠ That matters more than it sounds β€” `save_view`
91
+ de-duplicates NAMES by appending a number, so an unpinned id would make every re-apply a
92
+ fresh copy and the store would fill with numbered near-duplicates nobody asked for.
93
+ """
94
+ body = body if isinstance(body, dict) else {}
95
+ fields, _source = _target_or_refuse(session, body.get("table"))
96
+ vt = _templates()
97
+ tpl = vt.get(key)
98
+ if not tpl:
99
+ raise err(404, "unknown_template", "that template does not exist")
100
+ missing = vt.missing_columns(tpl, fields)
101
+ if missing:
102
+ # NAMED, not counted. "3 columns are missing" is a sentence the reader cannot act on;
103
+ # the column keys are what tells them this is the wrong database for this template.
104
+ raise err(400, "missing_columns",
105
+ "this database does not have the columns that template needs: "
106
+ + ", ".join(missing))
107
+ ws_key = vt.workspace_key(body.get("table"))
108
+ if not ws_key:
109
+ raise err(404, "unknown_table", "that database does not exist")
110
+ if not session.runtime.available():
111
+ raise err(503, "store_unavailable",
112
+ "the tenant store is unavailable β€” nothing was applied")
113
+ import core.table_store as table_store
114
+ ops = table_store.make(ws_key, st=session.runtime)
115
+ applied = []
116
+ for view in tpl.get('views') or ():
117
+ # β›” A COPY PER APPLY, and `createdBy` STAMPED HERE. The registry's dicts are module-level
118
+ # β€” mutating one would write this caller's username into the template every other tenant
119
+ # then reads, which is a cross-tenant leak with no symptom until two people apply the
120
+ # same template. Same invariant `routes_nav`'s in-place merge documents from the other
121
+ # side.
122
+ payload = dict(view)
123
+ payload['config'] = dict(view.get('config') or {})
124
+ payload['createdBy'] = session.uname
125
+ saved = ops.save_view(session.uname, payload, shared=False)
126
+ applied.append({"id": payload['id'], "name": saved.get('name') or payload['name']})
127
+
128
+ alert_id = None
129
+ if tpl.get('alert') and applied:
130
+ # The template's own view, watched. `core.alerts.create` stores the view BY ID rather
131
+ # than a copy of its filter tree, so an alert made here keeps meaning "tell me about
132
+ # this view" even after its owner edits it.
133
+ topic = ('product' if body.get("table") == 'product_data'
134
+ else 'customer' if body.get("table") == 'customer_data'
135
+ else str(body.get("table")))
136
+ try:
137
+ import core.alerts as alerts
138
+ alert_id = f"al_{uuid.uuid4().hex[:12]}"
139
+ alerts.create(alert_id, view_id=applied[0]["id"], topic=topic,
140
+ owner=session.uname, label=applied[0]["name"],
141
+ st=session.runtime)
142
+ except Exception:
143
+ # ⚠ THE VIEWS ARE ALREADY WRITTEN AND THAT IS THE POINT: a failed alert must not
144
+ # un-apply a template that worked. The response says which half landed rather than
145
+ # reporting a total failure over a partial success.
146
+ alert_id = None
147
+ return {"key": key, "table": body.get("table"), "views": applied,
148
+ "alert": alert_id, "alerted": bool(alert_id)}
platform/aios_grid.py CHANGED
@@ -109,9 +109,15 @@ def _round(v):
109
  #: megabyte-per-row tax on the whole store. Editable by NATURE (deliberately NOT in
110
  #: `READONLY_CUSTOM_TYPES`): the ref is what the upload endpoint hands back, and the client PATCHes
111
  #: it through the ordinary overlay wall rather than the asset route writing cells behind it.
 
 
 
 
 
 
112
  CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
113
  "checkbox", "phone", "email", "url", "rating", "created_time", "formula",
114
- "automation", "image"}
115
  #: User-created types whose CELLS are read-only: their values are computed (formula β€” client
116
  #: side, any error degrades to BLANK) or system-owned (created_time = the row's `_created`).
117
  #: Emitted with the cohort column's read-only mechanism β€” `source: 'odoo'` + `derived` β€” so
 
109
  #: megabyte-per-row tax on the whole store. Editable by NATURE (deliberately NOT in
110
  #: `READONLY_CUSTOM_TYPES`): the ref is what the upload endpoint hands back, and the client PATCHes
111
  #: it through the ordinary overlay wall rather than the asset route writing cells behind it.
112
+ #: ⭐ WAVE 23 (C7) β€” `json` joined: a cell holding a whole DOCUMENT (an Instagram comment thread,
113
+ #: a webhook payload, a scraped blob) that opens in its own viewer instead of being flattened
114
+ #: into one unreadable line. It is EDITABLE by nature, like `image`: the value is still a plain
115
+ #: string on the wire, so it rides the ordinary overlay wall β€” what makes it a json field is that
116
+ #: `grid_events` REFUSES a write that does not parse (a column promising structure must not
117
+ #: silently hold something that isn't).
118
  CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
119
  "checkbox", "phone", "email", "url", "rating", "created_time", "formula",
120
+ "automation", "image", "json"}
121
  #: User-created types whose CELLS are read-only: their values are computed (formula β€” client
122
  #: side, any error degrades to BLANK) or system-owned (created_time = the row's `_created`).
123
  #: Emitted with the cohort column's read-only mechanism β€” `source: 'odoo'` + `derived` β€” so
platform/core/alerts.py CHANGED
@@ -209,6 +209,44 @@ def _queue(rec, pids, labels, st=None):
209
  _st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
210
 
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  def inbox(user, st=None, limit=100):
213
  """`{'unread': int, 'items': [...]}` β€” newest first, for the badge and the pane."""
214
  try:
 
209
  _st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
210
 
211
 
212
+ def notify(owner, label, *, topic='automation', key='', row_id='', detail='', st=None):
213
+ """⭐ WAVE 23 (C6) β€” put ONE notification in a named user's inbox, for a producer that is not
214
+ a view-alert evaluation.
215
+
216
+ The automation engine needs the bell to ring when records arrive at a review gate, and that
217
+ is not an alert over a view: there is no filter tree, no `matched` set, no entrant diff. It
218
+ reached for `_queue` directly during the wave, which would have made a private function a
219
+ cross-module contract β€” so this is the public door instead, and `_queue` stays the internal
220
+ half of `evaluate()`.
221
+
222
+ The inbox shape is UNCHANGED, deliberately: `AlertsPane` reads one list, and a second row
223
+ shape would mean a client that must branch on where a notification came from. `alertId`
224
+ carries the producer's key so a click-through can route (W23-W5).
225
+ """
226
+ owner = str(owner or '').strip()
227
+ if not owner or not label:
228
+ return None
229
+ at = _now_iso()
230
+ item = {'id': f"{key or topic}:{row_id or 'n'}:{at}",
231
+ 'alertId': str(key or ''), 'viewId': '', 'topic': str(topic),
232
+ 'rowId': str(row_id or ''), 'label': str(detail or label),
233
+ 'alertLabel': str(label), 'at': at, 'read': False}
234
+
235
+ def _apply(data):
236
+ box = list(data.get(owner) or [])
237
+ box.append(item)
238
+ if len(box) > MAX_NOTIFICATIONS:
239
+ unread = [n for n in box if not n.get('read')]
240
+ read = [n for n in box if n.get('read')]
241
+ keep_read = read[max(0, len(unread) + len(read) - MAX_NOTIFICATIONS):]
242
+ box = sorted(unread + keep_read, key=lambda n: str(n.get('at') or ''))
243
+ data[owner] = box
244
+ return data
245
+
246
+ _st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
247
+ return item
248
+
249
+
250
  def inbox(user, st=None, limit=100):
251
  """`{'unread': int, 'items': [...]}` β€” newest first, for the badge and the pane."""
252
  try:
platform/core/grid_events.py CHANGED
@@ -38,11 +38,13 @@ so the import stays and the violation is RECORDED rather than hidden. It is the
38
  down, and this note is the marker for that work.
39
  """
40
  import datetime as dt
 
41
  from dataclasses import dataclass, field
42
  from typing import MutableMapping, Optional
43
 
44
  import core.store as store
45
  import core.table_store as _tstore # wave-9 I17: the shared-view authorisation predicates
 
46
  import core.users as users
47
  import harness.telemetry as _tel # _tel.error() = THE error sink (all caught errors log)
48
  import modules.cohort as cohort_mod
@@ -1703,6 +1705,26 @@ def handle_one(event, ctx):
1703
  else:
1704
  refused = True
1705
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1706
  clean = str(value)[:10000]
1707
  if isinstance(value, str) and clean != value:
1708
  # Truncated: the stored truth is shorter than the client's copy. (An
 
38
  down, and this note is the marker for that work.
39
  """
40
  import datetime as dt
41
+ import json as _json_ev # wave 23 (C7): the json field's parse wall
42
  from dataclasses import dataclass, field
43
  from typing import MutableMapping, Optional
44
 
45
  import core.store as store
46
  import core.table_store as _tstore # wave-9 I17: the shared-view authorisation predicates
47
+ import core.user_tables as _ut_limits # wave 23 (C7): MAX_JSON_CELL β€” one ceiling, one definer
48
  import core.users as users
49
  import harness.telemetry as _tel # _tel.error() = THE error sink (all caught errors log)
50
  import modules.cohort as cohort_mod
 
1705
  else:
1706
  refused = True
1707
  continue
1708
+ # ⭐ WAVE 23 (C7) β€” the json wall, BEFORE the generic 10 000-char truncation, which
1709
+ # would otherwise cut a document in half and store the halves as valid text. A json
1710
+ # column promises its readers a parseable document; a write that is not one is
1711
+ # REFUSED with the cell unchanged rather than silently kept as a string nothing can
1712
+ # open. Blank stays legal β€” an empty cell is "no document", not a malformed one.
1713
+ if (field_by_key.get(key) or {}).get('type') == 'json':
1714
+ raw_j = value if isinstance(value, str) else _json_ev.dumps(value)
1715
+ if not str(raw_j).strip():
1716
+ updates[key] = ''
1717
+ continue
1718
+ if len(str(raw_j)) > _ut_limits.MAX_JSON_CELL:
1719
+ refused = True
1720
+ continue
1721
+ try:
1722
+ _json_ev.loads(raw_j)
1723
+ except (ValueError, TypeError):
1724
+ refused = True
1725
+ continue
1726
+ updates[key] = str(raw_j)
1727
+ continue
1728
  clean = str(value)[:10000]
1729
  if isinstance(value, str) and clean != value:
1730
  # Truncated: the stored truth is shorter than the client's copy. (An
platform/core/keychain.py CHANGED
@@ -27,7 +27,13 @@ import os
27
  import secrets as _secrets
28
 
29
  KEY = 'keychain'
30
- ENTRY_TYPES = {'odoo', 'generic'}
 
 
 
 
 
 
31
  #: which stored field feeds the masked preview, per type (first present wins)
32
  _PREVIEW_FIELDS = ('api_key', 'value', 'password', 'token')
33
  MAX_ENTRIES = 40
 
27
  import secrets as _secrets
28
 
29
  KEY = 'keychain'
30
+ #: ⭐ WAVE 23 (C11/R8) β€” `stripe` and `shopify` joined. They are TOKEN connectors, not OAuth
31
+ #: ones, and that is the whole reason they could ship in this wave: both issue a long-lived
32
+ #: restricted key / custom-app token an admin pastes in, so they need no OAuth client, no
33
+ #: consent screen and no app review β€” the three things that make every other provider on the
34
+ #: standing queue a multi-week errand. A type here is what lets the connectors directory show
35
+ #: them as CONNECTABLE rather than as another faded to-do.
36
+ ENTRY_TYPES = {'odoo', 'generic', 'stripe', 'shopify'}
37
  #: which stored field feeds the masked preview, per type (first present wins)
38
  _PREVIEW_FIELDS = ('api_key', 'value', 'password', 'token')
39
  MAX_ENTRIES = 40
platform/core/user_tables.py CHANGED
@@ -58,8 +58,15 @@ KEY_PREFIX = 'ut_'
58
  #: landed in the per-user workspace stratum and the picker could not see them β€” the owner's
59
  #: "Choose a column..." stays empty. Local literal rather than an aios_grid import so this
60
  #: module stays dependency-light for the API's boot path.
 
 
 
61
  UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date',
62
- 'checkbox', 'phone', 'email', 'url', 'rating', 'automation'}
 
 
 
 
63
 
64
 
65
  def _st(st):
@@ -186,8 +193,8 @@ def delete(table_key, st=None):
186
  is no longer optional. Cleaned here, in one place: definition (`user_tables`) Β· the
187
  workspace bucket (`<key>_table_workspace`, every stratum incl. `__shared__` β€” views,
188
  custom fields, overlays, folders) Β· cohorts (`<key>_cohorts`) Β· record comments
189
- (`<key>_record_comments`) Β· docs metadata (`<key>_docs`; dataset BYTES under
190
- `docs/<key>/…` become unreachable and are left to storage β€” disclosed, not hidden) Β·
191
  `nav_meta[<key>]` Β· share grants (the database grant plus a view grant per view that
192
  lived in this bucket, via `shares.drop_objects`) Β· alert definitions on this topic.
193
  Bound AUTOMATIONS are the API layer's to disable (platform must not import the engine).
@@ -210,11 +217,34 @@ def delete(table_key, st=None):
210
  view_ids |= set((ws.get('views') or {}).keys())
211
  except Exception:
212
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  for b in (ws_key, f'{key}_cohorts', f'{key}_record_comments', f'{key}_docs'):
214
  try:
215
  s.update(b, lambda cur: {}, flush='async')
216
  except Exception:
217
  pass
 
 
 
 
 
 
218
 
219
  def _drop_meta(cur):
220
  if isinstance(cur, dict):
 
58
  #: landed in the per-user workspace stratum and the picker could not see them β€” the owner's
59
  #: "Choose a column..." stays empty. Local literal rather than an aios_grid import so this
60
  #: module stays dependency-light for the API's boot path.
61
+ #: ⭐ `json` JOINED IN WAVE 23 (C7) β€” and it belongs here rather than staying grid-only the way
62
+ #: `image` did, because the databases that need it are exactly these: a scrape lands a comment
63
+ #: thread or a webhook payload against a row, and the automation engine writes those cells.
64
  UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date',
65
+ 'checkbox', 'phone', 'email', 'url', 'rating', 'automation', 'json'}
66
+ #: C7's ceiling. A cell is still a scalar on the wire (`Row` values are strings/numbers) β€” what
67
+ #: this bounds is the DOCUMENT inside it. 32 KB holds a long comment thread and refuses a payload
68
+ #: somebody meant to store as a file.
69
+ MAX_JSON_CELL = 32 * 1024
70
 
71
 
72
  def _st(st):
 
193
  is no longer optional. Cleaned here, in one place: definition (`user_tables`) Β· the
194
  workspace bucket (`<key>_table_workspace`, every stratum incl. `__shared__` β€” views,
195
  custom fields, overlays, folders) Β· cohorts (`<key>_cohorts`) Β· record comments
196
+ (`<key>_record_comments`) Β· docs metadata (`<key>_docs`) AND the dataset BYTES each
197
+ metadata row names (D-36, closed wave 23 β€” wave 21 left those unreachable-but-present) Β·
198
  `nav_meta[<key>]` Β· share grants (the database grant plus a view grant per view that
199
  lived in this bucket, via `shares.drop_objects`) Β· alert definitions on this topic.
200
  Bound AUTOMATIONS are the API layer's to disable (platform must not import the engine).
 
217
  view_ids |= set((ws.get('views') or {}).keys())
218
  except Exception:
219
  pass
220
+ # ⭐ D-36 CLOSED (wave 23) β€” the document BYTES, not just their metadata. Wave 21 cleaned
221
+ # `<key>_docs` and disclosed that the blobs under `docs/<key>/…` were left on the dataset;
222
+ # "unreachable" is not "deleted", and for a tenant asking us to delete a database the
223
+ # difference is the whole promise. Read the metadata FIRST (it carries each blob's exact
224
+ # stored `path`, so nothing is guessed from a naming convention that could drift), then
225
+ # clear the bucket, then delete the blobs. Best-effort per file: a storage blip must not
226
+ # resurrect the table or abort the rest of the sweep β€” an undeleted blob is residue we can
227
+ # sweep again, an aborted delete is a table the user asked to be gone.
228
+ doc_paths = []
229
+ try:
230
+ for _pid, _rows in (s.get(f'{key}_docs') or {}).items():
231
+ for _doc in (_rows or []):
232
+ p = str((_doc or {}).get('path') or '')
233
+ if p:
234
+ doc_paths.append(p)
235
+ except Exception:
236
+ pass
237
  for b in (ws_key, f'{key}_cohorts', f'{key}_record_comments', f'{key}_docs'):
238
  try:
239
  s.update(b, lambda cur: {}, flush='async')
240
  except Exception:
241
  pass
242
+ for p in doc_paths:
243
+ try:
244
+ import core.store as _store_bytes
245
+ _store_bytes.delete_path(p)
246
+ except Exception:
247
+ pass
248
 
249
  def _drop_meta(cur):
250
  if isinstance(cur, dict):
platform/core/view_templates.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """view_templates.py β€” WAVE 23 item 7 (ruling R10, contract C12): the PLATFORM-CURATED
2
+ template registry.
3
+
4
+ WHAT A TEMPLATE IS, precisely, because the word is overloaded: a named bundle of SAVED VIEWS
5
+ that a user applies to a database they already have. It does NOT create a table, it does not add
6
+ columns, and it does not connect anything. It is the answer to "this database has 30 columns and
7
+ I do not know which twelve matter for chasing money" β€” a starting layout, authored once by the
8
+ platform, applied by whoever wants it.
9
+
10
+ β›” THE ONE RULE THAT DECIDES EVERY DESIGN CHOICE HERE β€” `_seed_wave17.py`'s :9-20 law:
11
+ **only VIEWS are shared; `fields` and `overlays` are strictly per-user.** So a SHARED view that
12
+ filtered on a user-created column would, for every other account, name a column that does not
13
+ exist β€” and an unknown column is an INACTIVE leaf in the tri-state filter engine, which is
14
+ IGNORED, which WIDENS. A "Collections focus" that quietly showed the whole customer book to
15
+ everyone but its author, with nothing going red.
16
+
17
+ Two consequences, both load-bearing:
18
+
19
+ 1. **Applying writes the CALLING USER's own views** (`save_view(..., shared=False)`), never
20
+ shared ones. A template is a convenience, not an administrative act, and the moment it
21
+ wrote into the shared bucket it would need an admin wall and a name-collision policy across
22
+ the tenant.
23
+ 2. **Every column a template names is checked against the TARGET's live contract before
24
+ anything is written**, and a template whose columns are missing is REFUSED with them named
25
+ β€” never applied partially, never applied with the offending leaf dropped. That check is
26
+ also what makes eligibility honest: `source` below is a label for grouping, the COLUMNS are
27
+ the gate, and the two cannot disagree because only one of them is consulted.
28
+
29
+ IDEMPOTENT BY PINNED ID. Every view carries `tpl_<template>_<suffix>`, so re-applying updates
30
+ the same views instead of minting "Collections focus 2". `save_view` renames on collision, which
31
+ would otherwise turn a second click into a second copy.
32
+
33
+ Pure-ish by construction: `TEMPLATES` and every builder are data, so `verify_home.py` can assert
34
+ their shape without a store.
35
+ """
36
+
37
+ #: The topic a template is FOR. A coarse grouping label used to filter the picker's list; the
38
+ #: real gate is `missing_columns` below (a table whose contract has the columns can take the
39
+ #: template, whatever we called its source).
40
+ SOURCES = ('odoo_customer', 'odoo_product', 'instagram', 'any')
41
+
42
+ #: The workspace bucket a topic's views live in. `<key>_table_workspace` for user tables
43
+ #: (`core/user_tables.py:211` builds the same string when it deletes them), and the two built-ins
44
+ #: keep the names their modules have always used.
45
+ _WS_KEYS = {
46
+ 'customer_data': 'customer_table_workspace',
47
+ 'product_data': 'product_table_workspace',
48
+ }
49
+
50
+
51
+ def workspace_key(table_key):
52
+ """The `core.table_store` key for a page key, or None if it is not a table at all."""
53
+ key = str(table_key or '')
54
+ if key in _WS_KEYS:
55
+ return _WS_KEYS[key]
56
+ if key.startswith('ut_'):
57
+ return f'{key}_table_workspace'
58
+ return None
59
+
60
+
61
+ def _view(view_id, name, note, visible, *, mode='grid', stack_field=None, sorts=(),
62
+ filters=(), group_by=None, conj='and'):
63
+ """One saved view, in the shape `table_store.save_view` stores and the grid reads.
64
+
65
+ ⚠ THE SHAPE IS COPIED FROM `_seed_wave17._view` ON PURPOSE, field for field, including the
66
+ keys that look inert (`widths`, `memberPids`, `rowHeightMode`). The grid's own cleaner fills
67
+ defaults for what is missing, but a view assembled from a DIFFERENT skeleton is a second
68
+ definition of what a view is β€” and the two drift on the day one of them gains a key.
69
+
70
+ `permissions.edit` is absent: `table_store.is_shared` reads it, and a view with no
71
+ permissions block is PERSONAL, which is the only thing this file is allowed to write.
72
+
73
+ β›” THE MODE LIVES AT `config.display.mode`, AND THE FIRST DRAFT PUT IT AT
74
+ `config.displayMode` β€” A KEY NOTHING READS. The gate caught nothing, because it asserted the
75
+ VALUE against `aios_grid.DISPLAY_MODES` and never the PATH: `"kanban" in DISPLAY_MODES` is
76
+ true wherever you happen to have written it. The Instagram "Review board" would have been
77
+ created as a plain GRID under a name promising a board β€” the view exists, the filter works,
78
+ the kanban never happens, and nothing goes red. Exactly the silent-drop class as a dropped
79
+ filter leaf, one field over. Corrected against `aios_grid._clean_display`, which is now RUN
80
+ over every template in `verify_home` rather than consulted for a vocabulary.
81
+
82
+ ⚠ AND `grid` STORES NOTHING. `_clean_display` returns None for it by design ("grid is the
83
+ absent default, so storing it would be a second way to say nothing"), so writing
84
+ `display: {'mode': 'grid'}` would be a key the cleaner strips on the next save β€” a value that
85
+ exists until something touches it.
86
+ """
87
+ display = None
88
+ if mode != 'grid':
89
+ display = {'mode': mode}
90
+ if stack_field:
91
+ # The kanban lane column. `_clean_display` accepts any ref that NAMES a real field
92
+ # and leaves what the mode means to the client, which degrades a wrong-typed ref to
93
+ # its own default rather than erroring.
94
+ display['stackField'] = stack_field
95
+ return {
96
+ 'id': view_id, 'name': name, 'kind': 'custom', 'locked': False, 'note': note,
97
+ 'config': {
98
+ 'filters': list(filters), 'filterConj': conj, 'sorts': list(sorts),
99
+ 'groupBy': group_by, 'colorBy': None, 'rowHeightMode': 'short',
100
+ 'order': list(visible), 'visible': list(visible), 'widths': {},
101
+ 'memberPids': [], **({'display': display} if display else {}),
102
+ },
103
+ }
104
+
105
+
106
+ # ── THE REGISTRY ────────────────────────────────────────────────────────────────────────────
107
+ #
108
+ # ⚠ FIRST BATCH (C12). Every column named below was checked against the live contract when this
109
+ # file was written β€” `aios_grid_fields.json` for customer, `aios_grid.product_fields()` for
110
+ # product, `automation_engine.CANDIDATE_FIELDS` for the Instagram candidate pool β€” but the check
111
+ # that MATTERS runs at apply time, against the target the user actually picked, because a
112
+ # contract can move and a ut_ table's columns are whatever its owner made.
113
+
114
+ TEMPLATES = [
115
+ {
116
+ 'key': 'collections_focus',
117
+ 'label': 'Collections focus',
118
+ 'desc': 'Everyone with money past due, largest first, with the aging buckets beside it.',
119
+ 'source': 'odoo_customer',
120
+ 'views': [
121
+ _view(
122
+ 'tpl_collections_focus_overdue', 'Past due',
123
+ 'Customers with an overdue balance, largest first. The four aging columns sum '
124
+ 'to AR overdue; a balance inside the grace period counts as open, not overdue.',
125
+ ['customer', 'agent', 'ar_overdue', 'ar_aged_1_30', 'ar_aged_31_60',
126
+ 'ar_aged_61_90', 'ar_aged_90_plus', 'ar_open', 'days_to_pay',
127
+ 'payment_terms', 'last_order'],
128
+ sorts=[{'colId': 'ar_overdue', 'dir': 'desc'}],
129
+ filters=[{'colId': 'ar_overdue', 'op': 'gt', 'value': '0'}],
130
+ ),
131
+ _view(
132
+ 'tpl_collections_focus_worst', 'Over 90 days',
133
+ 'The part of the book that is no longer a payment-terms conversation.',
134
+ ['customer', 'agent', 'ar_aged_90_plus', 'ar_overdue', 'ar_exposure',
135
+ 'days_to_pay', 'last_order'],
136
+ sorts=[{'colId': 'ar_aged_90_plus', 'dir': 'desc'}],
137
+ filters=[{'colId': 'ar_aged_90_plus', 'op': 'gt', 'value': '0'}],
138
+ ),
139
+ ],
140
+ },
141
+ {
142
+ 'key': 'dba_missing',
143
+ 'label': 'DBA is empty',
144
+ # The owner's own example (C12). One view, one job, and it is a DATA-QUALITY view: the
145
+ # brand a customer buys from decides which BU-scoped account can see them at all, so a
146
+ # blank DBA is a customer nobody's book contains.
147
+ 'desc': 'Customers with no brand recorded β€” they fall outside every scoped book.',
148
+ 'source': 'odoo_customer',
149
+ 'alert': True,
150
+ 'views': [
151
+ _view(
152
+ 'tpl_dba_missing_blank', 'DBA is empty',
153
+ 'Customers whose DBA is blank. The brand decides which scoped account sees a '
154
+ 'customer, so a blank one is invisible to every book except an unscoped view.',
155
+ # ⚠ SORTED BY OPEN RECEIVABLE, NOT BY SALES. This view first named `sales_ltm`,
156
+ # which DOES NOT EXIST β€” the customer contract carries no LTM sales column (the
157
+ # 32 keys in `aios_grid_fields.json`; `verify_home` caught it against the live
158
+ # contract on the first run). `ar_open` is the nearest real column that answers
159
+ # the same question β€” "which of these matters most" β€” and it answers it in
160
+ # money that is actually outstanding rather than in history.
161
+ ['customer', 'dba', 'agent', 'ar_open', 'last_order', 'days_since'],
162
+ sorts=[{'colId': 'ar_open', 'dir': 'desc'}],
163
+ # ⚠ `isEmpty`, and it is the one op that is safe here. A `select` column's blank
164
+ # is not the empty string in every row shape, and `eq ''` would miss the nulls β€”
165
+ # `isEmpty`/`isNotEmpty` are evaluated type-independently BEFORE any coercion
166
+ # (the same reason `_seed_wave17`'s buy list leans on them).
167
+ filters=[{'colId': 'dba', 'op': 'isEmpty'}],
168
+ ),
169
+ ],
170
+ },
171
+ {
172
+ 'key': 'buy_list_companion',
173
+ 'label': 'Buy-list companion',
174
+ 'desc': 'What is running out, and what it costs to bring in β€” beside the buy list.',
175
+ 'source': 'odoo_product',
176
+ 'views': [
177
+ _view(
178
+ 'tpl_buy_list_companion_cover', 'Cover gap',
179
+ 'SKUs whose days of supply is already shorter than their supplier lead time. '
180
+ 'Stock columns are consolidated across the one physical warehouse, so this is '
181
+ 'empty for a BU-scoped account.',
182
+ ['code', 'product', 'cover_gap_d', 'dos', 'lead_days', 'supplier', 'on_hand',
183
+ 'qty_ltm', 'first_cost', 'origin_country'],
184
+ sorts=[{'colId': 'cover_gap_d', 'dir': 'asc'}],
185
+ # The same four leaves the wave-17 seed uses, and for the same reason: a bare
186
+ # `dos < lead_days` reads a SKU with NO days-of-supply as `0 < 30` (the client
187
+ # engine's `toNum(null)` is 0) and puts every never-selling product on the list.
188
+ filters=[{'colId': 'dos', 'op': 'isNotEmpty'},
189
+ {'colId': 'lead_days', 'op': 'isNotEmpty'},
190
+ {'colId': 'lead_days', 'op': 'gt', 'value': '0'},
191
+ {'colId': 'dos', 'op': 'lt',
192
+ 'rhs': {'kind': 'field', 'colId': 'lead_days'}}],
193
+ ),
194
+ _view(
195
+ 'tpl_buy_list_companion_nosupplier', 'No supplier on file',
196
+ 'SKUs we cannot reorder because nobody is recorded as selling them to us.',
197
+ ['code', 'product', 'supplier', 'on_hand', 'qty_ltm', 'first_cost'],
198
+ sorts=[{'colId': 'qty_ltm', 'dir': 'desc'}],
199
+ filters=[{'colId': 'supplier', 'op': 'isEmpty'}],
200
+ ),
201
+ ],
202
+ },
203
+ {
204
+ 'key': 'ig_candidates',
205
+ 'label': 'Candidates review',
206
+ 'desc': 'Discovered profiles as a review board, plus the ones worth reading first.',
207
+ 'source': 'instagram',
208
+ 'views': [
209
+ _view(
210
+ 'tpl_ig_candidates_board', 'Review board',
211
+ 'Discovered profiles grouped by category. Tick "Tracked" on the ones worth '
212
+ 'keeping β€” that column is written by a person, never by the automation.',
213
+ ['handle', 'full_name', 'followers', 'avg_engagement', 'verified', 'category',
214
+ 'tracked', 'profile_url'],
215
+ # `stackField`, NOT `groupBy` β€” the kanban lane column is a `display` ref, while
216
+ # `groupBy` is the GRID's row grouping. They are two different features that both
217
+ # sound like "group by", and only one of them makes lanes.
218
+ mode='kanban', stack_field='category',
219
+ sorts=[{'colId': 'followers', 'dir': 'desc'}],
220
+ ),
221
+ _view(
222
+ 'tpl_ig_candidates_engaged', 'High engagement',
223
+ 'Profiles whose audience actually responds, biggest first. Engagement, not '
224
+ 'follower count, is what a small account can be good at.',
225
+ ['handle', 'full_name', 'avg_engagement', 'followers', 'category', 'bio',
226
+ 'external_url', 'tracked'],
227
+ sorts=[{'colId': 'avg_engagement', 'dir': 'desc'}],
228
+ filters=[{'colId': 'avg_engagement', 'op': 'gt', 'value': '0'}],
229
+ ),
230
+ ],
231
+ },
232
+ ]
233
+
234
+
235
+ def get(key):
236
+ """One template by key, or None."""
237
+ return next((t for t in TEMPLATES if t['key'] == str(key)), None)
238
+
239
+
240
+ def columns_named(template):
241
+ """Every column key a template's views reference β€” visible, filtered, sorted, grouped, and
242
+ the right-hand side of a field-vs-field comparison.
243
+
244
+ β›” THE `rhs` IS THE ONE PEOPLE FORGET, and `_seed_wave17` learned it the expensive way:
245
+ `clean_filter_tree` KEEPS a leaf whose rhs names an unknown column β€” it drops the rhs and
246
+ leaves the rule β€” so `dos < lead_days` silently becomes `dos < ""`. The leaf count is
247
+ unchanged, so a check that counted leaves would pass while the view answered a different
248
+ question. Naming the rhs here is what makes the eligibility test see it.
249
+ """
250
+ out = set()
251
+ for view in template.get('views') or ():
252
+ cfg = view.get('config') or {}
253
+ out.update(cfg.get('visible') or ())
254
+ for s in cfg.get('sorts') or ():
255
+ if isinstance(s, dict) and s.get('colId'):
256
+ out.add(s['colId'])
257
+ if cfg.get('groupBy'):
258
+ out.add(cfg['groupBy'])
259
+ for f in cfg.get('filters') or ():
260
+ if not isinstance(f, dict):
261
+ continue
262
+ if f.get('colId'):
263
+ out.add(f['colId'])
264
+ rhs = f.get('rhs')
265
+ if isinstance(rhs, dict) and rhs.get('colId'):
266
+ out.add(rhs['colId'])
267
+ return out
268
+
269
+
270
+ def missing_columns(template, field_keys):
271
+ """The columns this template needs that the target does not have. Empty β‡’ it can be applied.
272
+
273
+ THE ELIGIBILITY TEST AND THE REFUSAL TEST ARE THE SAME FUNCTION, deliberately: a picker that
274
+ offered a template the apply door would then refuse is a control that lies, and two separate
275
+ predicates is how those two answers drift apart.
276
+ """
277
+ return sorted(columns_named(template) - set(field_keys or ()))
278
+
279
+
280
+ def offer(field_keys, source=None):
281
+ """Every template that CAN be applied to a target with these columns.
282
+
283
+ `source` narrows further when the caller knows it (the picker passes the target's own), but
284
+ a template whose columns are all present is offered regardless of label β€” the columns are
285
+ the contract and the label is a grouping.
286
+ """
287
+ out = []
288
+ for t in TEMPLATES:
289
+ if missing_columns(t, field_keys):
290
+ continue
291
+ if source and t['source'] not in ('any', source):
292
+ continue
293
+ out.append({'key': t['key'], 'label': t['label'], 'desc': t['desc'],
294
+ 'source': t['source'], 'views': len(t.get('views') or ()),
295
+ 'alert': bool(t.get('alert'))})
296
+ return out
web/src/alerts/AlertsPane.tsx CHANGED
@@ -19,7 +19,14 @@ import {
19
  markRead,
20
  runAlert,
21
  } from "./alertsApi";
22
- import { EMPTY_INBOX, applyRead, inboxOrder, routeForTopic, stampText } from "./alertsModel";
 
 
 
 
 
 
 
23
  import type { Alert, Inbox, Notification } from "./alertsModel";
24
 
25
  /** The clock face this pane uses for its one glyph β€” drawn, never an emoji. */
@@ -35,12 +42,23 @@ function BellIcon() {
35
  export default function AlertsPane({
36
  onClose,
37
  onOpenView,
 
38
  onInbox,
39
  onToast,
40
  }: {
41
  onClose: () => void;
42
  /** Navigate to the alert's view. The FRAME owns routing; this pane owns the row. */
43
  onOpenView: (topic: string, viewId: string) => void;
 
 
 
 
 
 
 
 
 
 
44
  /** Hand the freshly-read inbox back so the nav badge and the pane agree. */
45
  onInbox: (inbox: Inbox) => void;
46
  onToast: (message: string) => void;
@@ -114,6 +132,16 @@ export default function AlertsPane({
114
  publish(applyRead(inbox, [n.id], true));
115
  void markRead([n.id], true);
116
  }
 
 
 
 
 
 
 
 
 
 
117
  // A route this client cannot resolve opens NOTHING and says so β€” alerts
118
  // outlive the surfaces they were made from.
119
  if (!routeForTopic(n.topic)) {
@@ -123,7 +151,7 @@ export default function AlertsPane({
123
  onOpenView(n.topic, n.viewId);
124
  onClose();
125
  },
126
- [inbox, publish, onOpenView, onClose, onToast]
127
  );
128
 
129
  const rows = inboxOrder(inbox.items);
@@ -166,7 +194,14 @@ export default function AlertsPane({
166
  type="button"
167
  className="alerts-row-main"
168
  onClick={() => open(n)}
169
- title={`Open ${n.alertLabel || "the alert's view"}`}
 
 
 
 
 
 
 
170
  >
171
  <span className="alerts-row-label">{n.label}</span>
172
  <span className="alerts-row-meta">
 
19
  markRead,
20
  runAlert,
21
  } from "./alertsApi";
22
+ import {
23
+ EMPTY_INBOX,
24
+ applyRead,
25
+ inboxOrder,
26
+ isAutomationReview,
27
+ routeForTopic,
28
+ stampText,
29
+ } from "./alertsModel";
30
  import type { Alert, Inbox, Notification } from "./alertsModel";
31
 
32
  /** The clock face this pane uses for its one glyph β€” drawn, never an emoji. */
 
42
  export default function AlertsPane({
43
  onClose,
44
  onOpenView,
45
+ onOpenAutomation,
46
  onInbox,
47
  onToast,
48
  }: {
49
  onClose: () => void;
50
  /** Navigate to the alert's view. The FRAME owns routing; this pane owns the row. */
51
  onOpenView: (topic: string, viewId: string) => void;
52
+ /**
53
+ * WAVE 23 (C6, wiring W23-W5) β€” the same division for a REVIEW notification: the frame routes
54
+ * to `#/automation` and asks the surface to select this automation.
55
+ *
56
+ * β›” REQUIRED, NOT OPTIONAL, and that is the wave-20 lesson written into a type. An optional
57
+ * callback the frame forgot to pass degrades to "clicking a review notification does nothing",
58
+ * which is indistinguishable from "the feature was never built" and goes red in no gate. A
59
+ * required prop fails `tsc` the moment it is unmounted.
60
+ */
61
+ onOpenAutomation: (autoId: string, stageId?: string) => void;
62
  /** Hand the freshly-read inbox back so the nav badge and the pane agree. */
63
  onInbox: (inbox: Inbox) => void;
64
  onToast: (message: string) => void;
 
132
  publish(applyRead(inbox, [n.id], true));
133
  void markRead([n.id], true);
134
  }
135
+ // β›” WAVE 23 C6 β€” THE KIND BRANCH COMES FIRST, AND ITS ORDER IS THE WHOLE FIX. A review
136
+ // notification has no `topic` a grid could render, so the view guard below would have
137
+ // caught every one of them and told the reader their table was "no longer available" β€”
138
+ // a confidently wrong sentence about a feature that is working. The destination decides
139
+ // which guard applies, so the destination is decided first.
140
+ if (isAutomationReview(n)) {
141
+ onOpenAutomation(n.autoId!, n.stageId);
142
+ onClose();
143
+ return;
144
+ }
145
  // A route this client cannot resolve opens NOTHING and says so β€” alerts
146
  // outlive the surfaces they were made from.
147
  if (!routeForTopic(n.topic)) {
 
151
  onOpenView(n.topic, n.viewId);
152
  onClose();
153
  },
154
+ [inbox, publish, onOpenView, onOpenAutomation, onClose, onToast]
155
  );
156
 
157
  const rows = inboxOrder(inbox.items);
 
194
  type="button"
195
  className="alerts-row-main"
196
  onClick={() => open(n)}
197
+ // The hover names the DESTINATION, which is now two different places.
198
+ // "Open the alert's view" over a review notification described a journey
199
+ // the click does not take.
200
+ title={
201
+ isAutomationReview(n)
202
+ ? "Open the automation"
203
+ : `Open ${n.alertLabel || "the alert's view"}`
204
+ }
205
  >
206
  <span className="alerts-row-label">{n.label}</span>
207
  <span className="alerts-row-meta">
web/src/alerts/alertsModel.ts CHANGED
@@ -31,6 +31,29 @@ export interface Notification {
31
  * would re-introduce the browser-clock drift the offset exists to remove. */
32
  at: string;
33
  read: boolean;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
  /** One alert, as `GET /api/v1/alerts` sends it. */
@@ -87,6 +110,14 @@ export function parseInbox(body: unknown): Inbox {
87
  alertLabel: str(n.alertLabel),
88
  at: str(n.at),
89
  read: n.read === true,
 
 
 
 
 
 
 
 
90
  });
91
  }
92
  return { unread: Math.max(0, num(b.unread)), items };
@@ -134,6 +165,24 @@ export function routeForTopic(topic: string): string | null {
134
  return null;
135
  }
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  /** Newest first, and unread before read at the same instant β€” the inbox order.
138
  * Stable: two notifications from one write keep the server's order. */
139
  export function inboxOrder(items: Notification[]): Notification[] {
 
31
  * would re-introduce the browser-clock drift the offset exists to remove. */
32
  at: string;
33
  read: boolean;
34
+ /**
35
+ * WAVE 23 (contract C6) β€” WHAT KIND of notification this is.
36
+ *
37
+ * Absent (and every notification written before this wave) means the original one: a record
38
+ * ENTERED a watched view, routed by `topic` + `viewId`. `"automation_review"` means a card
39
+ * arrived at a review stage and routes by `autoId` instead β€” a different destination reached
40
+ * from the same list.
41
+ *
42
+ * β›” A STRING, NEVER A UNION, and that is the wave-9 law rather than laziness: the vocabulary
43
+ * is the SERVER's, and a client union over it turns "the server grew a kind" into "the client
44
+ * silently drops the row". Unknown kinds fall through to the view route, which is exactly what
45
+ * they did before this field existed.
46
+ */
47
+ kind?: string;
48
+ /** `automation_review` only: the automation whose review stage a card reached. Absent on
49
+ * every other kind β€” and an `automation_review` row that arrives WITHOUT one opens nothing
50
+ * rather than guessing, the same posture `viewId` gets. */
51
+ autoId?: string;
52
+ /** Advisory. A surface that does not scroll to a stage simply selects the automation. */
53
+ stageId?: string;
54
+ /** How many cards arrived in the batch. C6 queues ONE notification per run naming the count,
55
+ * never one per record β€” so this is the number the row's own text is built from. */
56
+ count?: number;
57
  }
58
 
59
  /** One alert, as `GET /api/v1/alerts` sends it. */
 
110
  alertLabel: str(n.alertLabel),
111
  at: str(n.at),
112
  read: n.read === true,
113
+ // WAVE 23 C6 β€” ADDITIVE and spread-conditional, exactly like `NavPage`'s flags: a payload
114
+ // that predates this wave keeps today's shape rather than gaining four `undefined` keys,
115
+ // and an `automation_review` row missing its `autoId` is left WITHOUT one rather than
116
+ // with an empty string that would render as a real destination.
117
+ ...(str(n.kind) ? { kind: str(n.kind) } : {}),
118
+ ...(str(n.autoId) ? { autoId: str(n.autoId) } : {}),
119
+ ...(str(n.stageId) ? { stageId: str(n.stageId) } : {}),
120
+ ...(num(n.count) > 0 ? { count: num(n.count) } : {}),
121
  });
122
  }
123
  return { unread: Math.max(0, num(b.unread)), items };
 
165
  return null;
166
  }
167
 
168
+ /** WAVE 23 C6 β€” the kind literal the server queues for a card reaching a review stage.
169
+ * ONE place, so the pane's branch and the gate's fixture cannot disagree about the word. */
170
+ export const AUTOMATION_REVIEW_KIND = "automation_review";
171
+
172
+ /**
173
+ * Does this notification route to an AUTOMATION rather than to a view?
174
+ *
175
+ * β›” BOTH HALVES ARE REQUIRED, and the second is the one that matters. A row whose kind says
176
+ * `automation_review` but whose `autoId` never arrived (a server mid-deploy, a truncated
177
+ * payload) would otherwise take the automation branch and dispatch an open request naming
178
+ * nothing β€” a click that appears to work and silently does not. Failing this test sends it down
179
+ * the view branch, where `routeForTopic` refuses out loud. Fail-closed in the direction that
180
+ * still says something.
181
+ */
182
+ export function isAutomationReview(n: Notification): boolean {
183
+ return n.kind === AUTOMATION_REVIEW_KIND && !!str(n.autoId).trim();
184
+ }
185
+
186
  /** Newest first, and unread before read at the same instant β€” the inbox order.
187
  * Stable: two notifications from one write keep the server's order. */
188
  export function inboxOrder(items: Notification[]): Notification[] {
web/src/apiContract.ts CHANGED
@@ -101,3 +101,49 @@ export interface ViewOpenDetail {
101
  topic: string;
102
  viewId: string;
103
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  topic: string;
102
  viewId: string;
103
  }
104
+
105
+ /**
106
+ * WAVE 23 (contract C6, wiring W23-W5) — the shell→automation channel for a review
107
+ * notification's click-through.
108
+ *
109
+ * β›” THE SAME SHAPE AS `VIEW_OPEN_EVENT` ABOVE, FOR THE SAME REASON, and wave 20 is why both
110
+ * exist as constants in this leaf rather than as a string typed twice. A card arriving at a
111
+ * review stage queues an `automation_review` notification naming `{autoId, stageId, count}`;
112
+ * clicking it has to do TWO things that live on opposite sides of an ownership fence β€” route to
113
+ * `#/automation` (the SHELL's hash) and select that automation in the rail (`AutomationSurface`'s
114
+ * own `activeId`, which the shell cannot see and must not learn). So the frame navigates and
115
+ * then ASKS, and the surface answers if it can.
116
+ *
117
+ * ⚠ THE LISTENER IS THE HALF THAT CAN BE ABSENT. Wave 20 shipped item 25's click-through with
118
+ * this exact shape and NO listener β€” the event was dispatched into nothing, every gate green,
119
+ * the notification landing the reader on the right table and doing nothing else. Declared here
120
+ * on the wave's first day precisely so the surface can wire the listener while it is being
121
+ * built rather than at close-out; the wiring row (W23-W5) asserts both ends.
122
+ *
123
+ * An automation the reader can no longer open must do NOTHING rather than throw β€” the listener
124
+ * checks its own list first, exactly as the grid does for a view it cannot see.
125
+ */
126
+ export const AUTOMATION_OPEN_EVENT = "aios:automation-open";
127
+
128
+ /** `detail` of {@link AUTOMATION_OPEN_EVENT}. `stageId` is advisory β€” a surface that does not
129
+ * scroll to a stage simply selects the automation. */
130
+ export interface AutomationOpenDetail {
131
+ autoId: string;
132
+ stageId?: string;
133
+ }
134
+
135
+ /**
136
+ * WAVE 23 (contracts C2 + C10) β€” "create an AUTOMATED database", asked from outside the
137
+ * automation surface.
138
+ *
139
+ * R7 puts an "Automated database" door on Home and in the Database flyout, and C2 makes the
140
+ * automation create wizard the thing behind it β€” because an automated database is created BY
141
+ * the automation that fills it, not by a name box that then needs one. The shell cannot open
142
+ * that wizard directly (it is the surface's own state), so it routes to `#/automation` and
143
+ * raises this; the surface opens its create flow.
144
+ *
145
+ * ⚠ NO DETAIL, deliberately. "Open the create wizard" is the whole message. Anything more β€”
146
+ * a preselected kind, a name β€” would be the shell deciding what the wizard's first step says,
147
+ * which is the surface's contract (C2's three cards), not the frame's.
148
+ */
149
+ export const AUTOMATION_CREATE_EVENT = "aios:automation-create";
web/src/automation/AutomationBoard.tsx CHANGED
@@ -176,7 +176,25 @@ export default function AutomationBoard({
176
  }, 0);
177
  }}
178
  >
179
- <span className="autob-card-title">{card.title}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  {card.sub ? <span className="autob-card-sub">{card.sub}</span> : null}
181
  {card.movedAt ? (
182
  <span className="autob-card-when">{whenText(card.movedAt)}</span>
 
176
  }, 0);
177
  }}
178
  >
179
+ <span className="autob-card-title">
180
+ {card.title}
181
+ {/*
182
+ ⭐ C5's CYCLE BADGE. A record that has been round the flow more than once is a
183
+ different fact from a record on its first pass β€” on a reset-mode automation it is
184
+ the difference between "working" and "looping" β€” so the card says so.
185
+ ⚠ FROM 2 UPWARDS ONLY, and that is what makes an ABSENT count safe: every card
186
+ would otherwise wear "x1", which is noise on the common case and, worse, a claim
187
+ about a number the server has not sent yet (see `BoardCard.cycles`).
188
+ */}
189
+ {typeof card.cycles === "number" && card.cycles >= 2 ? (
190
+ <span
191
+ className="autob-card-cycles"
192
+ title={`This record has been round this flow ${card.cycles} times`}
193
+ >
194
+ &times;{card.cycles}
195
+ </span>
196
+ ) : null}
197
+ </span>
198
  {card.sub ? <span className="autob-card-sub">{card.sub}</span> : null}
199
  {card.movedAt ? (
200
  <span className="autob-card-when">{whenText(card.movedAt)}</span>
web/src/automation/AutomationBuilder.tsx ADDED
@@ -0,0 +1,1558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // automation/AutomationBuilder.tsx β€” the BUILDER (owner item 3b, contracts
3
+ // C1/C2/C4, ruling R1: "exact layout, Loopable skin").
4
+ //
5
+ // THE ANATOMY IS AIRTABLE'S, 1:1, and it is the owner's reference (images 1-9):
6
+ // a centre column of TRIGGER β†’ ACTIONS with a spine down the left carrying each
7
+ // step's status chip, dashed add-boxes at the end of each section, and a
8
+ // Properties panel on the right whose sections are Trigger details /
9
+ // Configuration / Test step. Every colour, size and weight is OURS (R1,
10
+ // DESIGN.md) β€” no Airtable blue, no caps micro-labels (R6: "TRIGGER" is
11
+ // "Trigger" here, and de-capping takes the letter-spacing with it).
12
+ //
13
+ // β›” IT RENDERS SYNCHRONOUSLY FROM WHAT IT WAS HANDED (C14 leg 1). No fetch, no
14
+ // effect, no second frame: the trigger comes from `automation.trigger`, the
15
+ // actions from `automation.flow`, the machine steps from `automation.graph` β€”
16
+ // all three already on the list payload the rail used to draw the row you
17
+ // clicked. THIS IS THE GHOST FIX. The old detail rendered Steps, then swapped to
18
+ // the Board when `/board` answered, so switching automations painted the
19
+ // PREVIOUS one's shape inside the new one's frame for as long as a fetch takes.
20
+ // A view that cannot be in two shapes cannot show you the wrong one.
21
+ //
22
+ // β›” NO CLIENT UNION OVER A SERVER VOCABULARY, anywhere. Trigger keys, action
23
+ // kinds, comparison operators, ending modes, review deciders and every ceiling
24
+ // ride `GET /automations`. What this file owns is pixels and wording.
25
+ //
26
+ // WHAT PERSISTS WHEN. A discrete choice writes immediately β€” picking a trigger,
27
+ // adding an action, flipping a switch β€” because a control you have to remember
28
+ // to Save is not a control (R9). FREE TEXT does not: a value, a prompt or a name
29
+ // commits on BLUR, or every keystroke would PATCH a half-typed condition and the
30
+ // server would refuse most of them out loud (the cron-string precedent in
31
+ // AutomationTrigger).
32
+ // ---------------------------------------------------------------------------
33
+ import type { ReactNode } from "react";
34
+ import { useState } from "react";
35
+
36
+ import type {
37
+ Action,
38
+ ActionCatalogRow,
39
+ Automation,
40
+ Cond,
41
+ FlowVocab,
42
+ GraphNode,
43
+ OAuthStatus,
44
+ TriggerOption,
45
+ UserTable,
46
+ } from "./automationApi";
47
+ import CondBuilder, { condComplete } from "./CondBuilder";
48
+
49
+ interface Props {
50
+ automation: Automation;
51
+ /** The server's trigger vocabulary (C3). Absent is a state this file states, never fills in. */
52
+ triggers?: TriggerOption[];
53
+ catalog: ActionCatalogRow[];
54
+ vocab?: FlowVocab;
55
+ tables: UserTable[];
56
+ oauth: OAuthStatus | null;
57
+ /** True while a write is in flight β€” drives the "All changes saved" stamp and disables edits. */
58
+ busy: boolean;
59
+ /** Write a partial definition. The caller owns the request and prints the refusal verbatim. */
60
+ onPatch: (body: Record<string, unknown>) => void;
61
+ onToggleNode: (nodeId: string) => void;
62
+ /**
63
+ * β›” CHOOSING A TRIGGER IS NOT A `{trigger:{key}}` PATCH, and getting that wrong is invisible.
64
+ * `schedule` is what today's engine actually READS, so a pick has to write BOTH halves or
65
+ * "At a scheduled time" would store a key and leave the cron switched off β€” a trigger that
66
+ * says it is scheduled and never fires. The detail already owns that two-write shape
67
+ * (`pickTrigger`); the builder calls it rather than composing a second copy of it.
68
+ */
69
+ onPickTrigger: (key: string) => void;
70
+ onRunNow: () => void;
71
+ /**
72
+ * The MACHINE steps' config bodies (source / columns / capture / find / write). They live in
73
+ * `AutomationDetail` and are unchanged by this wave, so they are passed IN rather than moved:
74
+ * a builder that re-implemented them would be a second copy of five panels whose only job is
75
+ * to agree with the engine.
76
+ */
77
+ renderNodePanel: (node: GraphNode) => ReactNode;
78
+ /**
79
+ * The SCHEDULE half of the trigger face (`AutomationTrigger` with its picker hidden). Passed
80
+ * in for the same reason the node panels are: the cron round-trip, the server's preset list
81
+ * and the tick-honesty note exist exactly once, and both this view and the board render the
82
+ * same element rather than two copies of three things that go wrong invisibly.
83
+ */
84
+ scheduleFace: ReactNode;
85
+ }
86
+
87
+ type Sel = { kind: "trigger" | "action" | "node"; id: string };
88
+
89
+ /** Airtable phrases the empty state's shortcuts; ours come from the SERVER's own list (image 1). */
90
+ const SUGGESTED = 6;
91
+
92
+ /**
93
+ * A trigger's icon, PER KIND. Drawn, never an emoji (owner constant).
94
+ *
95
+ * ⚠ THE FIRST VERSION DREW ONE GLYPH FOR ALL ELEVEN, and reading the suggested list settled it:
96
+ * six identical arrows down the left edge is decoration, not information β€” the eye learns
97
+ * nothing and the column just gets wider. Airtable differentiates them (images 1/2) for the
98
+ * same reason. Unknown keys keep the neutral arrow, so a trigger the engine adds tomorrow
99
+ * renders plainly rather than not at all (the silent-drop rule, applied to icons).
100
+ */
101
+ function TriggerMark({ kind = "" }: { kind?: string }) {
102
+ const common = {
103
+ width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
104
+ strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
105
+ "aria-hidden": true,
106
+ };
107
+ if (kind === "schedule")
108
+ return (
109
+ <svg {...common}>
110
+ <circle cx="8" cy="8" r="5.6" />
111
+ <path d="M8 5.2V8l2 1.4" />
112
+ </svg>
113
+ );
114
+ if (kind === "event_field")
115
+ return (
116
+ <svg {...common}>
117
+ <path d="M2.8 3.4h10.4l-4 4.6v4.2l-2.4 1.2V8z" />
118
+ </svg>
119
+ );
120
+ if (kind === "record_created")
121
+ return (
122
+ <svg {...common}>
123
+ <rect x="3" y="2.6" width="10" height="10.8" rx="1.8" />
124
+ <path d="M8 5.8v4.4M5.8 8h4.4" />
125
+ </svg>
126
+ );
127
+ if (kind === "record_updated")
128
+ return (
129
+ <svg {...common}>
130
+ <rect x="3" y="2.6" width="10" height="10.8" rx="1.8" />
131
+ <path d="M5.8 6.4h4.4M5.8 9.2h2.6" />
132
+ </svg>
133
+ );
134
+ if (kind === "enters_view")
135
+ return (
136
+ <svg {...common}>
137
+ <path d="M1.8 8s2.4-4 6.2-4 6.2 4 6.2 4-2.4 4-6.2 4-6.2-4-6.2-4z" />
138
+ <circle cx="8" cy="8" r="1.7" />
139
+ </svg>
140
+ );
141
+ if (kind === "webhook")
142
+ return (
143
+ <svg {...common}>
144
+ <path d="M6.6 9.4 4.9 11a2.6 2.6 0 1 1-1.5-4.4" />
145
+ <path d="M9.4 6.6 11.1 5a2.6 2.6 0 1 1 1.5 4.4" />
146
+ <path d="M6.2 8h3.6" />
147
+ </svg>
148
+ );
149
+ if (kind === "email")
150
+ return (
151
+ <svg {...common}>
152
+ <rect x="2.2" y="3.6" width="11.6" height="8.8" rx="1.6" />
153
+ <path d="m2.6 4.6 5.4 3.8 5.4-3.8" />
154
+ </svg>
155
+ );
156
+ if (kind === "form_submitted")
157
+ return (
158
+ <svg {...common}>
159
+ <rect x="3" y="2.6" width="10" height="10.8" rx="1.8" />
160
+ <path d="M5.8 6h4.4M5.8 8.6h2.2M9 11l1.2 1.2L12.6 9.8" />
161
+ </svg>
162
+ );
163
+ if (kind === "button_clicked")
164
+ return (
165
+ <svg {...common}>
166
+ <path d="M6 3.2v6.2l1.6-1.4 1.4 3 1.6-.8-1.4-2.8 2.2-.2z" />
167
+ </svg>
168
+ );
169
+ if (kind === "comment_added")
170
+ return (
171
+ <svg {...common}>
172
+ <path d="M13.4 9.4a1.6 1.6 0 0 1-1.6 1.6H5.4L2.6 13.4V4.2a1.6 1.6 0 0 1 1.6-1.6h7.6a1.6 1.6 0 0 1 1.6 1.6z" />
173
+ </svg>
174
+ );
175
+ return (
176
+ <svg {...common}>
177
+ <path d="M3 3v6.2a2 2 0 0 0 2 2h7.4" />
178
+ <path d="M10.2 9.2 12.8 11.2 10.2 13.2" />
179
+ </svg>
180
+ );
181
+ }
182
+
183
+ function ActionMark({ kind }: { kind: string }) {
184
+ const common = {
185
+ width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
186
+ strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
187
+ "aria-hidden": true,
188
+ };
189
+ if (kind === "group")
190
+ return (
191
+ <svg {...common}>
192
+ <path d="M2.6 8h3l2.6-3.6h5M8.2 11.6H5.6m2.6 0h5" />
193
+ <path d="M11.6 2.6 13.4 4.4 11.6 6.2M11.6 9.8l1.8 1.8-1.8 1.8" />
194
+ </svg>
195
+ );
196
+ if (kind === "review")
197
+ return (
198
+ <svg {...common}>
199
+ <circle cx="8" cy="5.6" r="2.6" />
200
+ <path d="M3 13.4c.7-2.3 2.6-3.6 5-3.6s4.3 1.3 5 3.6" />
201
+ </svg>
202
+ );
203
+ if (kind === "create_record")
204
+ return (
205
+ <svg {...common}>
206
+ <rect x="2.6" y="2.6" width="10.8" height="10.8" rx="2" />
207
+ <path d="M8 5.6v4.8M5.6 8h4.8" />
208
+ </svg>
209
+ );
210
+ if (kind === "find_records")
211
+ return (
212
+ <svg {...common}>
213
+ <circle cx="7.2" cy="7.2" r="4" />
214
+ <path d="m10.2 10.2 3.2 3.2" />
215
+ </svg>
216
+ );
217
+ /*
218
+ ⚠ THE MACHINE KINDS ARE HERE TOO, and leaving them out was a real defect for one render:
219
+ the engine's nodes are `source | capture | branch | write`, none of which matched above, so
220
+ "Write to the database" and "Fetch the page" both drew the PENCIL β€” an edit glyph on a step
221
+ that reads a web page and a step that writes a database. Caught by reading the screenshot,
222
+ not by a gate ([[ui-invisible-to-assertions]]): the icon was present, legible and wrong.
223
+ These are the shapes the retired `AutomationSteps.KindMark` used, kept with their meanings.
224
+ */
225
+ if (kind === "source")
226
+ return (
227
+ <svg {...common}>
228
+ <circle cx="8" cy="8" r="5.6" />
229
+ <path d="M2.6 8h10.8M8 2.4c1.5 1.7 2.2 3.6 2.2 5.6S9.5 12.3 8 13.6C6.5 12.3 5.8 10 5.8 8s.7-3.9 2.2-5.6z" />
230
+ </svg>
231
+ );
232
+ if (kind === "write")
233
+ return (
234
+ <svg {...common}>
235
+ <ellipse cx="8" cy="4.2" rx="4.8" ry="1.9" />
236
+ <path d="M3.2 4.2v7.6c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9V4.2M3.2 8c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9" />
237
+ </svg>
238
+ );
239
+ if (kind === "capture")
240
+ return (
241
+ <svg {...common}>
242
+ <path d="M8 2.6v7.2M5.2 7l2.8 2.8L10.8 7M3 12.4h10" />
243
+ </svg>
244
+ );
245
+ if (kind === "branch")
246
+ return (
247
+ <svg {...common}>
248
+ <path d="M2.6 8h3.2l2.6-3.6h5M8.4 11.6H5.8m2.6 0h5" />
249
+ <path d="M11.6 2.6 13.4 4.4 11.6 6.2M11.6 9.8l1.8 1.8-1.8 1.8" />
250
+ </svg>
251
+ );
252
+ if (kind === "update_record")
253
+ return (
254
+ <svg {...common}>
255
+ <path d="M11.4 2.8 13.2 4.6 6 11.8l-2.6.8.8-2.6z" />
256
+ <path d="M3 13.6h10" />
257
+ </svg>
258
+ );
259
+ if (kind === "send_email")
260
+ return (
261
+ <svg {...common}>
262
+ <rect x="2.2" y="3.6" width="11.6" height="8.8" rx="1.6" />
263
+ <path d="m2.6 4.6 5.4 3.8 5.4-3.8" />
264
+ </svg>
265
+ );
266
+ if (kind === "slack")
267
+ return (
268
+ <svg {...common}>
269
+ <path d="M13.4 9.4a1.6 1.6 0 0 1-1.6 1.6H5.4L2.6 13.4V4.2a1.6 1.6 0 0 1 1.6-1.6h7.6a1.6 1.6 0 0 1 1.6 1.6z" />
270
+ </svg>
271
+ );
272
+ if (kind === "run_script")
273
+ return (
274
+ <svg {...common}>
275
+ <path d="M5.6 5.4 3 8l2.6 2.6M10.4 5.4 13 8l-2.6 2.6M9 3.4 7 12.6" />
276
+ </svg>
277
+ );
278
+ if (kind === "generate_ai")
279
+ return (
280
+ <svg {...common}>
281
+ <path d="M8 2.4l1.5 3.4 3.4 1.5-3.4 1.5L8 12.2 6.5 8.8 3.1 7.3l3.4-1.5z" />
282
+ </svg>
283
+ );
284
+ if (kind === "repeating_group")
285
+ return (
286
+ <svg {...common}>
287
+ <path d="M3 8a5 5 0 0 1 5-5c2 0 3.4 1 4.3 2.4M13 8a5 5 0 0 1-5 5c-2 0-3.4-1-4.3-2.4" />
288
+ <path d="M12.4 2.6v2.9h-2.9M3.6 13.4v-2.9h2.9" />
289
+ </svg>
290
+ );
291
+ /*
292
+ β›” THE FALLBACK IS NEUTRAL, and it was not the first time round. The default used to BE the
293
+ pencil β€” a real member of the set (`update_record`) β€” so every kind with no arm silently
294
+ borrowed "edit": `send_email`, `run_script` and `repeating_group` all drew it in the action
295
+ menu, which reads as three actions that modify a record. A fallback that is a real member
296
+ cannot be told apart from a match; this one can.
297
+ */
298
+ return (
299
+ <svg {...common}>
300
+ <rect x="3.2" y="3.2" width="9.6" height="9.6" rx="2.2" />
301
+ <circle cx="8" cy="8" r="1.4" />
302
+ </svg>
303
+ );
304
+ }
305
+
306
+ /** The label a table key reads as. A raw `ut_9f3a…` on a card is a key, not a subtitle. */
307
+ function tableLabel(tables: UserTable[], key: string): string {
308
+ if (!key) return "";
309
+ return tables.find((t) => t.key === key)?.label || key;
310
+ }
311
+
312
+ export default function AutomationBuilder({
313
+ automation,
314
+ triggers,
315
+ catalog,
316
+ vocab,
317
+ tables,
318
+ oauth,
319
+ busy,
320
+ onPatch,
321
+ onToggleNode,
322
+ onPickTrigger,
323
+ onRunNow,
324
+ renderNodePanel,
325
+ scheduleFace,
326
+ }: Props) {
327
+ const [sel, setSel] = useState<Sel>({ kind: "trigger", id: "" });
328
+ const [picking, setPicking] = useState(false);
329
+ const [adding, setAdding] = useState(false);
330
+ /** The trigger key awaiting confirmation (image 6) β€” a change that can invalidate config. */
331
+ const [confirmKey, setConfirmKey] = useState("");
332
+
333
+ const trigger = automation.trigger || null;
334
+ const triggerKey = trigger?.key || (automation.schedule?.enabled ? "schedule" : "manual");
335
+ const options = triggers || [];
336
+ const picked = options.find((t) => t.key === triggerKey) || null;
337
+ const actions = automation.flow?.actions || [];
338
+ const nodes = (automation.graph?.nodes || []).filter((n) => n.kind !== "trigger");
339
+ const ops = vocab?.condOps || [];
340
+ const nullaryOps = vocab?.nullaryCondOps || [];
341
+ const table = tables.find((t) => t.key === (trigger?.table || "")) || null;
342
+
343
+ /**
344
+ * β›” HAS ANYTHING BEEN CHOSEN? β€” and the first version of this line was wrong in a way only
345
+ * a screenshot showed.
346
+ *
347
+ * It read `!!trigger || key === "schedule" || key === "manual"`, and `triggerKey` DERIVES
348
+ * "manual" whenever nothing is stored β€” so it was true for every automation ever, and the
349
+ * Airtable empty state (image 1) was unreachable code that every gate was happy with.
350
+ *
351
+ * The honest test is what the DEFINITION holds, not what the picker displays: the engine
352
+ * deliberately stores no trigger for manual/schedule ("`schedule` already owns the cron"), so
353
+ * `trigger === null && !schedule.enabled` IS "nobody has decided yet". Manual is what that
354
+ * state DOES, not a thing you pick out of it β€” which is why the suggested list below omits
355
+ * it: an option whose selection stores nothing would bounce straight back to this state on
356
+ * the next reload, and a control that undoes itself is worse than no control.
357
+ */
358
+ const chosen = !!trigger || !!automation.schedule?.enabled;
359
+ const configured = !trigger || trigger.configured !== false;
360
+
361
+ /** Write one key of the trigger, keeping the rest β€” the engine merges against `previous`. */
362
+ const patchTrigger = (patch: Record<string, unknown>) =>
363
+ onPatch({ trigger: { key: triggerKey, ...patch } });
364
+
365
+ const patchFlow = (next: Action[]) =>
366
+ onPatch({ flow: { actions: next, ending: automation.flow?.ending || { mode: "terminal" } } });
367
+
368
+ /** Replace one action anywhere in the tree, groups included β€” ids are stable server-side. */
369
+ const editAction = (id: string, change: (a: Action) => Action) => {
370
+ const walk = (list: Action[]): Action[] =>
371
+ list.map((a) => {
372
+ if (a.id === id) return change(a);
373
+ const kids = (a.config as { actions?: Action[] })?.actions;
374
+ if (a.kind === "group" && Array.isArray(kids))
375
+ return { ...a, config: { ...a.config, actions: walk(kids) } };
376
+ return a;
377
+ });
378
+ patchFlow(walk(actions));
379
+ };
380
+
381
+ const dropAction = (id: string) => {
382
+ const walk = (list: Action[]): Action[] =>
383
+ list
384
+ .filter((a) => a.id !== id)
385
+ .map((a) => {
386
+ const kids = (a.config as { actions?: Action[] })?.actions;
387
+ return a.kind === "group" && Array.isArray(kids)
388
+ ? { ...a, config: { ...a.config, actions: walk(kids) } }
389
+ : a;
390
+ });
391
+ patchFlow(walk(actions));
392
+ if (sel.kind === "action" && sel.id === id) setSel({ kind: "trigger", id: "" });
393
+ };
394
+
395
+ /**
396
+ * β›” A NEW ACTION'S CONFIG MUST BE ONE THE SERVER ACCEPTS β€” and the first version of this
397
+ * function got it wrong for FOUR of the five kinds, which is worth writing down because every
398
+ * gate was green and the harness could not see it (SSR renders no click).
399
+ *
400
+ * MEASURED against `clean_actions` itself (`python -c` over the engine module, no server):
401
+ * `{values:{"":""}}` -> "a value is written to a field with no name"
402
+ * `{table:"",values:…}` -> the same, twice
403
+ * `{table:""}` (find) -> "the find records action names no database"
404
+ * `{cond:…,actions:[]}` -> "a conditional group with no actions inside it does nothing"
405
+ * So clicking "Add β†’ Update record" would have produced a red banner and NO CARD. This is the
406
+ * wave-22 discovery-predicate scar reproduced exactly ([[cg-condition-builder-items]] β€” every
407
+ * default must be one the server takes), in a file whose own comment cites it.
408
+ *
409
+ * The seeds below are built from data this component already holds, and `seedFor` returns
410
+ * NULL when it cannot build a legal one. A kind with no legal seed is offered DISABLED with
411
+ * the reason, rather than offered and refused: the two states look identical to a user and
412
+ * only one of them is honest.
413
+ */
414
+ const walkFields = table?.fields || [];
415
+ const firstField = walkFields[0]?.key || "";
416
+ const firstTable = tables[0] || null;
417
+ const firstTableField = firstTable?.fields?.[0]?.key || "";
418
+
419
+ const seedFor = (kind: string): Record<string, unknown> | null => {
420
+ if (kind === "review")
421
+ return { decidedBy: "user", label: "Review", next: ["Approved", "Declined"], prompt: "" };
422
+ if (kind === "update_record")
423
+ return firstField ? { values: { [firstField]: "" } } : null;
424
+ if (kind === "create_record")
425
+ return firstTable && firstTableField
426
+ ? { table: firstTable.key, values: { [firstTableField]: "" } }
427
+ : null;
428
+ if (kind === "find_records")
429
+ return firstTable ? { table: firstTable.key, cond: null, limit: 25 } : null;
430
+ if (kind === "group")
431
+ // A group with no children "does nothing" and is refused, so it is born with one β€” the
432
+ // cheapest legal child, not a review, because a review would also mint a board stage that
433
+ // nobody asked for by clicking "Conditional logic".
434
+ return firstField
435
+ ? { cond: null,
436
+ actions: [{ id: "", kind: "update_record", enabled: true, when: null,
437
+ config: { values: { [firstField]: "" } } }] }
438
+ : null;
439
+ return null;
440
+ };
441
+
442
+ /** Why a kind cannot be added yet β€” the server's own precondition, said before the click. */
443
+ const seedBlock = (kind: string): string => {
444
+ if (kind === "update_record" || kind === "group")
445
+ return "Name the trigger's database first β€” this needs a column to write.";
446
+ if (kind === "create_record" || kind === "find_records")
447
+ return "There is no blank database to point at yet.";
448
+ return "";
449
+ };
450
+
451
+ const addAction = (kind: string, into?: string) => {
452
+ const seed = seedFor(kind);
453
+ if (!seed) return;
454
+ const fresh: Action = { id: "", kind, enabled: true, when: null, config: seed };
455
+ if (!into) {
456
+ patchFlow([...actions, fresh]);
457
+ } else {
458
+ editAction(into, (a) => ({
459
+ ...a,
460
+ config: {
461
+ ...a.config,
462
+ actions: [...(((a.config as { actions?: Action[] }).actions) || []), fresh],
463
+ },
464
+ }));
465
+ }
466
+ setAdding(false);
467
+ };
468
+
469
+ // ── the centre column ────────────────────────────────────────────────────────────────────
470
+ const chip = () => {
471
+ if (!chosen) return null;
472
+ if (!configured)
473
+ return (
474
+ <span className="autox-chip is-warn" title="This trigger cannot fire until it is finished">
475
+ Finish configuration
476
+ </span>
477
+ );
478
+ if (trigger?.paused)
479
+ return <span className="autox-chip is-warn">Paused</span>;
480
+ if (picked && picked.ready === false)
481
+ return <span className="autox-chip is-warn">Not set up</span>;
482
+ const last = automation.runs?.[0];
483
+ if (last)
484
+ return (
485
+ <span className="autox-chip is-ok" title={last.summary}>
486
+ {last.ok ? "Last run succeeded" : "Last run failed"}
487
+ </span>
488
+ );
489
+ return null;
490
+ };
491
+
492
+ const actionCard = (a: Action, depth: number): ReactNode => {
493
+ const row = catalog.find((c) => c.kind === a.kind);
494
+ const kids = ((a.config as { actions?: Action[] }).actions) || [];
495
+ const isGroup = a.kind === "group";
496
+ return (
497
+ <div className="autox-actionwrap" key={a.id}>
498
+ <div
499
+ className={
500
+ "autox-card" +
501
+ (sel.kind === "action" && sel.id === a.id ? " is-selected" : "") +
502
+ (a.enabled ? "" : " is-off") +
503
+ (isGroup ? " is-group" : "")
504
+ }
505
+ >
506
+ <button
507
+ type="button"
508
+ className="autox-card-hit"
509
+ aria-pressed={sel.kind === "action" && sel.id === a.id}
510
+ onClick={() => setSel({ kind: "action", id: a.id })}
511
+ >
512
+ <span className="autox-card-mark">
513
+ <ActionMark kind={a.kind} />
514
+ </span>
515
+ <span className="autox-card-text">
516
+ <span className="autox-card-title">
517
+ {isGroup ? "If conditions are met" : row?.label || a.kind}
518
+ </span>
519
+ {isGroup ? null : (
520
+ <span className="autox-card-sub">{actionSub(a, tables)}</span>
521
+ )}
522
+ </span>
523
+ </button>
524
+ <button
525
+ type="button"
526
+ className="autox-card-drop"
527
+ disabled={busy}
528
+ aria-label={`Remove ${row?.label || a.kind}`}
529
+ title={`Remove ${row?.label || a.kind}`}
530
+ onClick={() => dropAction(a.id)}
531
+ >
532
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
533
+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
534
+ strokeLinecap="round" />
535
+ </svg>
536
+ </button>
537
+ </div>
538
+
539
+ {/* A GROUP HOLDS ITS OWN COLUMN (image 9) β€” nested cards and their own dashed add. The
540
+ nesting ceiling is the SERVER's `maxGroupDepth`, so this affordance disappears at
541
+ exactly the depth `clean_actions` refuses. */}
542
+ {isGroup ? (
543
+ <div className="autox-nest">
544
+ {kids.map((k) => actionCard(k, depth + 1))}
545
+ {depth + 1 < (vocab?.maxGroupDepth ?? 2) || !kids.length ? (
546
+ <button
547
+ type="button"
548
+ className="autox-add is-nested"
549
+ disabled={busy || !seedFor("update_record")}
550
+ title={seedFor("update_record") ? undefined : seedBlock("update_record")}
551
+ onClick={() => addAction("update_record", a.id)}
552
+ >
553
+ + Add an action in this group
554
+ </button>
555
+ ) : null}
556
+ </div>
557
+ ) : null}
558
+ </div>
559
+ );
560
+ };
561
+
562
+ return (
563
+ <>
564
+ <div className="autox-flow">
565
+ {/* ── TRIGGER ─────────────────────────────────────────────────────────────────── */}
566
+ <div className="autox-step">
567
+ <div className="autox-side">
568
+ <span className="autox-tag">Trigger</span>
569
+ {chip()}
570
+ </div>
571
+ <div className="autox-main">
572
+ {chosen && options.length ? (
573
+ <div
574
+ className={
575
+ "autox-card is-trigger" + (sel.kind === "trigger" ? " is-selected" : "")
576
+ }
577
+ >
578
+ <button
579
+ type="button"
580
+ className="autox-card-hit"
581
+ aria-pressed={sel.kind === "trigger"}
582
+ onClick={() => setSel({ kind: "trigger", id: "" })}
583
+ >
584
+ <span className="autox-card-mark">
585
+ <TriggerMark kind={triggerKey} />
586
+ </span>
587
+ <span className="autox-card-text">
588
+ <span className="autox-card-title">
589
+ {picked?.label || triggerKey}
590
+ </span>
591
+ {trigger?.table ? (
592
+ <span className="autox-card-sub">
593
+ In {tableLabel(tables, trigger.table)}
594
+ </span>
595
+ ) : null}
596
+ </span>
597
+ </button>
598
+ </div>
599
+ ) : (
600
+ /*
601
+ THE EMPTY STATE (image 1): a dashed add-box and the server's own suggested
602
+ triggers. One line of chrome, no tour (R13) β€” the list IS the explanation, and
603
+ it is the server's list so it cannot describe a trigger we removed.
604
+ */
605
+ <>
606
+ <button
607
+ type="button"
608
+ className="autox-add is-empty"
609
+ disabled={busy || !options.length}
610
+ onClick={() => setPicking(true)}
611
+ >
612
+ + Add trigger
613
+ </button>
614
+ {options.length ? (
615
+ <div className="autox-suggest">
616
+ <p className="autox-suggest-head">Suggested triggers</p>
617
+ {options
618
+ .filter((t) => t.ready !== false && !t.planned && t.key !== "manual")
619
+ .slice(0, SUGGESTED)
620
+ .map((t) => (
621
+ <button
622
+ key={t.key}
623
+ type="button"
624
+ className="autox-suggest-row"
625
+ disabled={busy}
626
+ onClick={() => onPickTrigger(t.key)}
627
+ >
628
+ <span className="autox-card-mark">
629
+ <TriggerMark kind={t.key} />
630
+ </span>
631
+ {t.label}
632
+ </button>
633
+ ))}
634
+ </div>
635
+ ) : (
636
+ <p className="auto-note">This server did not offer a trigger list.</p>
637
+ )}
638
+ {/* ONE LINE (R13), and it is what is TRUE of this state rather than a caption
639
+ for the box above it: with nothing chosen, Run now is the only thing that
640
+ starts this automation. Airtable's empty state means "it cannot run"; ours
641
+ does not, and saying so is the difference between a screen that is honest
642
+ and one that merely looks the same. */}
643
+ <p className="auto-hint">Until then, only Run now starts it.</p>
644
+ </>
645
+ )}
646
+ </div>
647
+ </div>
648
+
649
+ {/* ── ACTIONS ─────────────────────────────────────────────────────────────────── */}
650
+ <div className="autox-step">
651
+ <div className="autox-side">
652
+ <span className="autox-tag">Actions</span>
653
+ </div>
654
+ <div className="autox-main">
655
+ {actions.map((a) => actionCard(a, 0))}
656
+
657
+ {/* THE MACHINE STEPS (C1) β€” engine-derived, read-only cards. They are what this
658
+ automation IS (fetch a page, write a database); the flow's actions are what the
659
+ owner adds on top. Rendering them from `graph()` is the same law the numbered
660
+ steps were built on: the surface cannot show a step the engine stopped running. */}
661
+ {nodes.map((n) => (
662
+ <div
663
+ key={n.id}
664
+ className={
665
+ "autox-card is-machine" +
666
+ (sel.kind === "node" && sel.id === n.id ? " is-selected" : "") +
667
+ (n.enabled ? "" : " is-off")
668
+ }
669
+ >
670
+ <button
671
+ type="button"
672
+ className="autox-card-hit"
673
+ aria-pressed={sel.kind === "node" && sel.id === n.id}
674
+ title={n.detail || n.subtitle}
675
+ onClick={() => setSel({ kind: "node", id: n.id })}
676
+ >
677
+ <span className="autox-card-mark">
678
+ <ActionMark kind={n.kind} />
679
+ </span>
680
+ <span className="autox-card-text">
681
+ <span className="autox-card-title">{n.title}</span>
682
+ <span className="autox-card-sub">{n.subtitle}</span>
683
+ </span>
684
+ <span className={`auto-dot is-${n.status}`} aria-label={`Last run: ${n.status}`} />
685
+ </button>
686
+ {n.toggle ? (
687
+ <button
688
+ type="button"
689
+ className={"auto-step-switch" + (n.enabled ? " is-on" : "")}
690
+ disabled={busy}
691
+ aria-pressed={n.enabled}
692
+ aria-label={`${n.enabled ? "Turn off" : "Turn on"} ${n.title}`}
693
+ title={`${n.enabled ? "Turn off" : "Turn on"} ${n.title}`}
694
+ onClick={() => onToggleNode(n.id)}
695
+ >
696
+ <span className="auto-step-switch-knob" />
697
+ </button>
698
+ ) : null}
699
+ </div>
700
+ ))}
701
+
702
+ <button
703
+ type="button"
704
+ className="autox-add"
705
+ disabled={busy || !catalog.length}
706
+ onClick={() => setAdding((v) => !v)}
707
+ >
708
+ + Add advanced logic or action
709
+ </button>
710
+
711
+ {/* THE ACTION MENU (images 7/8): the server's catalog, grouped by its own `group`
712
+ key, with `ready:false` rows faded and carrying the server's reason. A shorter
713
+ menu would imply those actions do not exist β€” the owner asked to see all of
714
+ them, and `clean_actions` refuses the unready ones at the door, so faded is a
715
+ wall rather than a decoration. */}
716
+ {adding ? (
717
+ <div className="autox-menu" role="menu">
718
+ {[...new Set(catalog.map((c) => c.group))].map((group) => (
719
+ <div className="autox-menu-group" key={group}>
720
+ <p className="autox-menu-head">{group}</p>
721
+ {catalog
722
+ .filter((c) => c.group === group)
723
+ .map((c) => (
724
+ <button
725
+ key={c.kind}
726
+ type="button"
727
+ className={
728
+ "autox-menu-row" +
729
+ (c.ready && seedFor(c.kind) ? "" : " is-planned")
730
+ }
731
+ disabled={!c.ready || busy || !seedFor(c.kind)}
732
+ title={
733
+ c.ready && !seedFor(c.kind) ? seedBlock(c.kind) : c.detail
734
+ }
735
+ onClick={() => addAction(c.kind)}
736
+ >
737
+ <span className="autox-card-mark">
738
+ <ActionMark kind={c.kind} />
739
+ </span>
740
+ <span className="autox-menu-text">
741
+ <span className="autox-menu-label">
742
+ {c.label}
743
+ {!c.ready ? (
744
+ <span className="autox-soon">Coming soon</span>
745
+ ) : !seedFor(c.kind) ? (
746
+ /* NOT the same state as "coming soon", so not the same word:
747
+ this one is built and waiting on THIS automation, and the
748
+ title says exactly what is missing. */
749
+ <span className="autox-soon">Needs a database</span>
750
+ ) : null}
751
+ </span>
752
+ <span className="autox-menu-detail">{c.detail}</span>
753
+ </span>
754
+ </button>
755
+ ))}
756
+ </div>
757
+ ))}
758
+ </div>
759
+ ) : null}
760
+ </div>
761
+ </div>
762
+ </div>
763
+
764
+ {/* ── PROPERTIES ──────────────────────────────────────────────────────────────────── */}
765
+ <aside className="auto-panel autox-props" aria-label="Properties">
766
+ <div className="auto-panel-head">
767
+ <h2>Properties</h2>
768
+ <span className={"autox-saved" + (busy ? " is-busy" : "")}>
769
+ {busy ? "Saving…" : "All changes saved"}
770
+ </span>
771
+ </div>
772
+
773
+ {sel.kind === "trigger" ? (
774
+ <TriggerProps
775
+ automation={automation}
776
+ triggerKey={triggerKey}
777
+ options={options}
778
+ picked={picked}
779
+ table={table}
780
+ tables={tables}
781
+ ops={ops}
782
+ nullaryOps={nullaryOps}
783
+ vocab={vocab}
784
+ oauth={oauth}
785
+ busy={busy}
786
+ onAskChange={setConfirmKey}
787
+ onPatchTrigger={patchTrigger}
788
+ onPickTrigger={onPickTrigger}
789
+ onRunNow={onRunNow}
790
+ scheduleFace={scheduleFace}
791
+ />
792
+ ) : sel.kind === "action" ? (
793
+ <ActionProps
794
+ action={findAction(actions, sel.id)}
795
+ catalog={catalog}
796
+ tables={tables}
797
+ walkFields={walkFields}
798
+ ops={ops}
799
+ nullaryOps={nullaryOps}
800
+ vocab={vocab}
801
+ busy={busy}
802
+ onEdit={(change) => editAction(sel.id, change)}
803
+ />
804
+ ) : (
805
+ nodePanel(nodes, sel.id, renderNodePanel)
806
+ )}
807
+ </aside>
808
+
809
+ {/* THE CONFIRM (image 6). A trigger change can invalidate the configuration under it, so
810
+ it ASKS β€” and it says what it will cost rather than "are you sure". */}
811
+ {confirmKey ? (
812
+ <div className="autox-confirm" role="dialog" aria-label="Change the trigger">
813
+ <p className="autox-confirm-title">Change the trigger?</p>
814
+ <p className="autox-confirm-body">
815
+ Anything configured for {picked?.label || triggerKey} is dropped.
816
+ </p>
817
+ <div className="autox-confirm-acts">
818
+ <button type="button" className="auto-btn" onClick={() => setConfirmKey("")}>
819
+ Cancel
820
+ </button>
821
+ <button
822
+ type="button"
823
+ className="auto-btn is-danger"
824
+ onClick={() => {
825
+ onPickTrigger(confirmKey);
826
+ setConfirmKey("");
827
+ }}
828
+ >
829
+ Change trigger
830
+ </button>
831
+ </div>
832
+ </div>
833
+ ) : null}
834
+
835
+ {/* The picker, opened from the empty state's dashed box (image 2). */}
836
+ {picking ? (
837
+ <div className="autox-menu is-trigger" role="menu">
838
+ {options.map((t) => (
839
+ <button
840
+ key={t.key}
841
+ type="button"
842
+ className={"autox-menu-row" + (t.planned ? " is-planned" : "")}
843
+ disabled={!!t.planned || busy}
844
+ onClick={() => {
845
+ onPickTrigger(t.key);
846
+ setPicking(false);
847
+ }}
848
+ >
849
+ <span className="autox-card-mark">
850
+ <TriggerMark kind={t.key} />
851
+ </span>
852
+ <span className="autox-menu-text">
853
+ <span className="autox-menu-label">
854
+ {t.label}
855
+ {t.planned ? <span className="autox-soon">Coming soon</span> : null}
856
+ {!t.planned && t.ready === false ? (
857
+ <span className="autox-soon">Needs setting up</span>
858
+ ) : null}
859
+ </span>
860
+ </span>
861
+ </button>
862
+ ))}
863
+ </div>
864
+ ) : null}
865
+ </>
866
+ );
867
+ }
868
+
869
+ /** One action's subtitle β€” what it will DO, composed from its own config. */
870
+ function actionSub(a: Action, tables: UserTable[]): string {
871
+ const cfg = a.config || {};
872
+ if (a.kind === "update_record") {
873
+ const keys = Object.keys((cfg as { values?: Record<string, unknown> }).values || {})
874
+ .filter(Boolean);
875
+ return keys.length ? `Sets ${keys.join(", ")}` : "No values yet";
876
+ }
877
+ if (a.kind === "create_record") {
878
+ const t = String((cfg as { table?: string }).table || "");
879
+ return t ? `Into ${tableLabel(tables, t)}` : "No database yet";
880
+ }
881
+ if (a.kind === "find_records") {
882
+ const t = String((cfg as { table?: string }).table || "");
883
+ return t ? `In ${tableLabel(tables, t)}` : "No database yet";
884
+ }
885
+ if (a.kind === "review") {
886
+ const by = String((cfg as { decidedBy?: string }).decidedBy || "user");
887
+ return by === "ai" ? "Decided by AI" : "Decided by a person";
888
+ }
889
+ return "";
890
+ }
891
+
892
+ function findAction(list: Action[], id: string): Action | null {
893
+ for (const a of list) {
894
+ if (a.id === id) return a;
895
+ const kids = (a.config as { actions?: Action[] })?.actions;
896
+ if (a.kind === "group" && Array.isArray(kids)) {
897
+ const hit = findAction(kids, id);
898
+ if (hit) return hit;
899
+ }
900
+ }
901
+ return null;
902
+ }
903
+
904
+ function nodePanel(
905
+ nodes: GraphNode[],
906
+ id: string,
907
+ render: (node: GraphNode) => ReactNode
908
+ ): ReactNode {
909
+ const node = nodes.find((n) => n.id === id);
910
+ if (!node) return <p className="auto-note">That step is no longer part of this automation.</p>;
911
+ return (
912
+ <>
913
+ <h3>{node.title}</h3>
914
+ {node.detail ? <p className="auto-hint">{node.detail}</p> : null}
915
+ {render(node)}
916
+ </>
917
+ );
918
+ }
919
+
920
+ // ── the Properties panel's two faces ─────────────────────────────────────────────────────────
921
+
922
+ function TriggerProps({
923
+ automation,
924
+ triggerKey,
925
+ options,
926
+ picked,
927
+ table,
928
+ tables,
929
+ ops,
930
+ nullaryOps,
931
+ vocab,
932
+ oauth,
933
+ busy,
934
+ onAskChange,
935
+ onPatchTrigger,
936
+ onPickTrigger,
937
+ onRunNow,
938
+ scheduleFace,
939
+ }: {
940
+ automation: Automation;
941
+ triggerKey: string;
942
+ options: TriggerOption[];
943
+ picked: TriggerOption | null;
944
+ table: UserTable | null;
945
+ tables: UserTable[];
946
+ ops: string[];
947
+ nullaryOps: string[];
948
+ vocab?: FlowVocab;
949
+ oauth: OAuthStatus | null;
950
+ busy: boolean;
951
+ /** Ask before a trigger change that can invalidate the config under it (image 6). */
952
+ onAskChange: (key: string) => void;
953
+ onPatchTrigger: (patch: Record<string, unknown>) => void;
954
+ onPickTrigger: (key: string) => void;
955
+ onRunNow: () => void;
956
+ scheduleFace: ReactNode;
957
+ }) {
958
+ const trigger = automation.trigger || null;
959
+ const provider = picked?.connect?.provider || "";
960
+ const connected = !!(provider && oauth?.[provider]?.connected);
961
+ const startUrl = picked?.connect?.startUrl || "";
962
+ const needsTable = !!trigger && "table" in trigger;
963
+ const last = automation.runs?.[0];
964
+
965
+ return (
966
+ <>
967
+ <h3>Trigger details</h3>
968
+ <div className="auto-field">
969
+ <label htmlFor="autox-tkind">Trigger type</label>
970
+ <select
971
+ id="autox-tkind"
972
+ className="auto-input"
973
+ value={triggerKey}
974
+ disabled={busy}
975
+ onChange={(e) => {
976
+ const next = e.target.value;
977
+ if (next === triggerKey) return;
978
+ // A change that can invalidate the configuration under it ASKS FIRST (image 6);
979
+ // one that cannot β€” there is nothing configured yet β€” just happens.
980
+ if (trigger && trigger.configured !== false) onAskChange(next);
981
+ else onPickTrigger(next);
982
+ }}
983
+ >
984
+ {triggerKey && !options.some((t) => t.key === triggerKey) ? (
985
+ <option value={triggerKey}>{triggerKey} (not offered here)</option>
986
+ ) : null}
987
+ {options.map((t) => (
988
+ <option key={t.key} value={t.key} disabled={!!t.planned}>
989
+ {t.planned
990
+ ? `${t.label} β€” coming soon`
991
+ : t.ready === false
992
+ ? `${t.label} β€” needs setting up`
993
+ : t.label}
994
+ </option>
995
+ ))}
996
+ </select>
997
+ </div>
998
+ {/*
999
+ β›” NO DESCRIPTION PARAGRAPH HERE, and its absence is deliberate. Airtable prints two
1000
+ sentences under this select (image 3). Ours would be a CLIENT paraphrase of a server
1001
+ vocabulary β€” the thing DESIGN.md 4 calls a hand copy of a registry that goes stale in
1002
+ silence the day the engine changes what a trigger does. A one-line `detail` on the
1003
+ server's own `TriggerOption` is asked for in the wave mailbox; when it rides, it renders
1004
+ here and cannot disagree with the engine.
1005
+ */}
1006
+ {picked && picked.ready === false && !picked.planned ? (
1007
+ <div className="autob-needs">
1008
+ <span className="autob-needs-text">
1009
+ {connected
1010
+ ? "Connected β€” this trigger is still being switched on for this deployment."
1011
+ : "This trigger is not set up yet."}
1012
+ </span>
1013
+ {!connected && startUrl ? (
1014
+ <a className="auto-btn is-primary autob-connect" href={startUrl}>
1015
+ Connect
1016
+ </a>
1017
+ ) : null}
1018
+ </div>
1019
+ ) : null}
1020
+
1021
+ <h3>Configuration</h3>
1022
+ {needsTable ? (
1023
+ <div className="auto-field">
1024
+ <label htmlFor="autox-ttable">
1025
+ <span className="autox-req">*</span> Database
1026
+ </label>
1027
+ <select
1028
+ id="autox-ttable"
1029
+ className="auto-input"
1030
+ value={trigger?.table || ""}
1031
+ disabled={busy}
1032
+ onChange={(e) => onPatchTrigger({ table: e.target.value })}
1033
+ >
1034
+ <option value="">Select a database…</option>
1035
+ {trigger?.table && !tables.some((t) => t.key === trigger.table) ? (
1036
+ <option value={trigger.table}>{trigger.table} (not visible to you)</option>
1037
+ ) : null}
1038
+ {tables.map((t) => (
1039
+ <option key={t.key} value={t.key}>
1040
+ {t.label}
1041
+ </option>
1042
+ ))}
1043
+ </select>
1044
+ </div>
1045
+ ) : null}
1046
+
1047
+ {triggerKey === "event_field" ? (
1048
+ <>
1049
+ <div className="auto-field">
1050
+ <label htmlFor="autox-tfield">Watched column</label>
1051
+ <select
1052
+ id="autox-tfield"
1053
+ className="auto-input"
1054
+ value={trigger?.field || ""}
1055
+ disabled={busy}
1056
+ onChange={(e) => onPatchTrigger({ field: e.target.value })}
1057
+ >
1058
+ {/* EMPTY IS A REAL CHOICE, not a missing one: unnamed means "any column", which is
1059
+ Airtable's plain condition trigger and the engine's own default. */}
1060
+ <option value="">Any column</option>
1061
+ {(table?.fields || []).map((f) => (
1062
+ <option key={f.key} value={f.key}>
1063
+ {f.label}
1064
+ </option>
1065
+ ))}
1066
+ </select>
1067
+ </div>
1068
+ <p className="auto-field-label">
1069
+ <span className="autox-req">*</span> Conditions
1070
+ </p>
1071
+ <CondBuilder
1072
+ cond={trigger?.when || null}
1073
+ onChange={(next) => onPatchTrigger({ when: next })}
1074
+ fields={table?.fields || []}
1075
+ ops={ops}
1076
+ nullaryOps={nullaryOps}
1077
+ maxDepth={vocab?.maxCondDepth ?? 3}
1078
+ maxChildren={vocab?.maxCondChildren ?? 12}
1079
+ disabled={busy}
1080
+ />
1081
+ </>
1082
+ ) : null}
1083
+
1084
+ {triggerKey === "record_updated" ? (
1085
+ <div className="auto-field">
1086
+ <label htmlFor="autox-twatch">Watched columns</label>
1087
+ <select
1088
+ id="autox-twatch"
1089
+ className="auto-input"
1090
+ multiple
1091
+ size={5}
1092
+ value={trigger?.fields || []}
1093
+ disabled={busy}
1094
+ onChange={(e) =>
1095
+ onPatchTrigger({
1096
+ fields: Array.from(e.target.selectedOptions).map((o) => o.value),
1097
+ })
1098
+ }
1099
+ >
1100
+ {(table?.fields || []).map((f) => (
1101
+ <option key={f.key} value={f.key}>
1102
+ {f.label}
1103
+ </option>
1104
+ ))}
1105
+ </select>
1106
+ <p className="auto-hint">Select none to fire on any column.</p>
1107
+ </div>
1108
+ ) : null}
1109
+
1110
+ {triggerKey === "enters_view" ? (
1111
+ <div className="auto-field">
1112
+ <label htmlFor="autox-tview">
1113
+ <span className="autox-req">*</span> View
1114
+ </label>
1115
+ {table?.views?.length ? (
1116
+ <select
1117
+ id="autox-tview"
1118
+ className="auto-input"
1119
+ value={trigger?.viewId || ""}
1120
+ disabled={busy}
1121
+ onChange={(e) => onPatchTrigger({ viewId: e.target.value })}
1122
+ >
1123
+ <option value="">Select a view…</option>
1124
+ {table.views.map((v) => (
1125
+ <option key={v.id} value={v.id}>
1126
+ {v.label}
1127
+ </option>
1128
+ ))}
1129
+ </select>
1130
+ ) : (
1131
+ /*
1132
+ ⚠ ABSENT IS A STATE, NOT AN EMPTY PICKER. `GET /automations/tables` does not carry
1133
+ views yet (asked of the engine session in the wave mailbox). An empty dropdown here
1134
+ would read as "this database has no views", which is a claim nobody measured β€” so
1135
+ the surface says what is actually true and the control lights up the day the list
1136
+ rides, with no change on this side.
1137
+ */
1138
+ <p className="auto-note">
1139
+ {table
1140
+ ? "This server did not offer a view list for that database yet."
1141
+ : "Choose a database first."}
1142
+ </p>
1143
+ )}
1144
+ </div>
1145
+ ) : null}
1146
+
1147
+ {triggerKey === "email" ? (
1148
+ <div className="auto-field">
1149
+ <label htmlFor="autox-tquery">Gmail search</label>
1150
+ <input
1151
+ id="autox-tquery"
1152
+ className="auto-input"
1153
+ defaultValue={trigger?.query || ""}
1154
+ disabled={busy}
1155
+ placeholder="from:orders@example.com"
1156
+ // FREE TEXT COMMITS ON BLUR β€” per-keystroke would PATCH a half-typed query.
1157
+ onBlur={(e) => onPatchTrigger({ query: e.target.value })}
1158
+ />
1159
+ </div>
1160
+ ) : null}
1161
+
1162
+ {triggerKey === "webhook" && trigger?.token ? (
1163
+ <div className="auto-field">
1164
+ <label htmlFor="autox-thook">Hook URL</label>
1165
+ <input
1166
+ id="autox-thook"
1167
+ className="auto-input is-mono"
1168
+ readOnly
1169
+ value={`/api/v1/automations/hook/${trigger.token}`}
1170
+ />
1171
+ <p className="auto-hint">Minted once. It survives every other change to this trigger.</p>
1172
+ </div>
1173
+ ) : null}
1174
+
1175
+ {/* THE SCHEDULE, rendered by the one component that owns the cron round-trip. */}
1176
+ {triggerKey === "schedule" ? scheduleFace : null}
1177
+ {triggerKey === "manual" ? (
1178
+ <p className="auto-hint">
1179
+ It runs when you press Run once now, and nothing else starts it.
1180
+ </p>
1181
+ ) : null}
1182
+
1183
+ <h3>Test step</h3>
1184
+ {/*
1185
+ β›” HONEST TO OUR SEMANTICS, not to Airtable's. Airtable's "Test step" replays one step
1186
+ against a chosen record. Ours has no step-replay: the engine runs a whole automation. So
1187
+ the control says what it actually does β€” RUN IT β€” and the results below are the last
1188
+ real run's, from the run log, rather than a rehearsal nobody performed.
1189
+ */}
1190
+ <button type="button" className="auto-btn" disabled={busy || !!automation.running}
1191
+ onClick={onRunNow}>
1192
+ {automation.running ? "Running…" : "Run once now"}
1193
+ </button>
1194
+ {last ? (
1195
+ <>
1196
+ <p className={"autox-result is-" + (last.ok ? "ok" : "bad")}>
1197
+ {last.ok ? "Last run succeeded" : "Last run failed"}
1198
+ </p>
1199
+ <p className="auto-hint">
1200
+ {last.ts.replace("T", " ").slice(0, 16)} β€” {last.summary}
1201
+ </p>
1202
+ </>
1203
+ ) : (
1204
+ <p className="auto-hint">It has not run yet.</p>
1205
+ )}
1206
+ </>
1207
+ );
1208
+ }
1209
+
1210
+ function ActionProps({
1211
+ action,
1212
+ catalog,
1213
+ tables,
1214
+ walkFields,
1215
+ ops,
1216
+ nullaryOps,
1217
+ vocab,
1218
+ busy,
1219
+ onEdit,
1220
+ }: {
1221
+ action: Action | null;
1222
+ catalog: ActionCatalogRow[];
1223
+ tables: UserTable[];
1224
+ /**
1225
+ * β›” THE WALKING RECORD'S COLUMNS β€” the trigger's database, resolved by the builder.
1226
+ *
1227
+ * These three surfaces were built with `fields={[]}` and it made R3's headline feature
1228
+ * unauthorable: a conditional group's condition, an action's `when`, and `update_record`'s
1229
+ * column picker all offered "Choose a field…" and nothing else. An empty list is not a
1230
+ * neutral default here β€” it is a picker with no options, which reads as "this database has
1231
+ * no columns".
1232
+ */
1233
+ walkFields: { key: string; label: string; type: string }[];
1234
+ ops: string[];
1235
+ nullaryOps: string[];
1236
+ vocab?: FlowVocab;
1237
+ busy: boolean;
1238
+ onEdit: (change: (a: Action) => Action) => void;
1239
+ }) {
1240
+ if (!action) return <p className="auto-note">That action is no longer part of this flow.</p>;
1241
+ const row = catalog.find((c) => c.kind === action.kind);
1242
+ const cfg = action.config || {};
1243
+ const setCfg = (patch: Record<string, unknown>) =>
1244
+ onEdit((a) => ({ ...a, config: { ...a.config, ...patch } }));
1245
+ const values = (cfg as { values?: Record<string, string | number> }).values || {};
1246
+ const target = tables.find((t) => t.key === String((cfg as { table?: string }).table || ""));
1247
+ const valueFields = action.kind === "create_record" ? target?.fields || [] : walkFields;
1248
+
1249
+ return (
1250
+ <>
1251
+ <h3>{row?.label || action.kind}</h3>
1252
+ {row?.detail ? <p className="auto-hint">{row.detail}</p> : null}
1253
+
1254
+ <h3>Configuration</h3>
1255
+
1256
+ {action.kind === "group" ? (
1257
+ <>
1258
+ <p className="auto-field-label">Run these actions if…</p>
1259
+ <CondBuilder
1260
+ cond={(cfg as { cond?: Cond | null }).cond || null}
1261
+ onChange={(next) => setCfg({ cond: next })}
1262
+ fields={walkFields}
1263
+ ops={ops}
1264
+ nullaryOps={nullaryOps}
1265
+ maxDepth={vocab?.maxCondDepth ?? 3}
1266
+ maxChildren={vocab?.maxCondChildren ?? 12}
1267
+ lead=""
1268
+ disabled={busy}
1269
+ />
1270
+ {/* ⚠ THE FIELD LIST IS THE WALKING RECORD'S, and this panel does not know which
1271
+ database that is β€” the trigger decides it and a group can sit under any of them.
1272
+ So the field picker offers the stored key rather than a list it would have to
1273
+ guess at; a wrong list is worse than a typed key, because it looks authoritative.
1274
+ Booked for the wave that gives an action its own resolved table. */}
1275
+ </>
1276
+ ) : null}
1277
+
1278
+ {action.kind === "create_record" ? (
1279
+ <div className="auto-field">
1280
+ <label htmlFor="autox-atable">
1281
+ <span className="autox-req">*</span> Database
1282
+ </label>
1283
+ <select
1284
+ id="autox-atable"
1285
+ className="auto-input"
1286
+ value={String((cfg as { table?: string }).table || "")}
1287
+ disabled={busy}
1288
+ onChange={(e) => setCfg({ table: e.target.value })}
1289
+ >
1290
+ <option value="">Select a database…</option>
1291
+ {tables.map((t) => (
1292
+ <option key={t.key} value={t.key}>
1293
+ {t.label}
1294
+ </option>
1295
+ ))}
1296
+ </select>
1297
+ </div>
1298
+ ) : null}
1299
+
1300
+ {action.kind === "update_record" || action.kind === "create_record" ? (
1301
+ <>
1302
+ <p className="auto-field-label">
1303
+ <span className="autox-req">*</span> Values
1304
+ </p>
1305
+ {Object.entries(values).map(([key, val], i) => (
1306
+ <div className="autoc-row" key={i}>
1307
+ <button
1308
+ type="button"
1309
+ className="autoc-drop"
1310
+ disabled={busy}
1311
+ aria-label="Remove this value"
1312
+ onClick={() => {
1313
+ const next = { ...values };
1314
+ delete next[key];
1315
+ setCfg({ values: next });
1316
+ }}
1317
+ >
1318
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
1319
+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
1320
+ strokeLinecap="round" />
1321
+ </svg>
1322
+ </button>
1323
+ <select
1324
+ className="auto-input is-tiny"
1325
+ value={key}
1326
+ disabled={busy}
1327
+ aria-label="Column"
1328
+ onChange={(e) => {
1329
+ const next: Record<string, string | number> = {};
1330
+ for (const [k, v] of Object.entries(values))
1331
+ next[k === key ? e.target.value : k] = v;
1332
+ setCfg({ values: next });
1333
+ }}
1334
+ >
1335
+ <option value="">Choose a column…</option>
1336
+ {/* ⚠ WHICH RECORD'S COLUMNS depends on the action: `create_record` writes into
1337
+ the database it NAMES, `update_record` writes onto the record walking the
1338
+ flow β€” the trigger's. Offering the target's columns for both would list the
1339
+ wrong database's columns for every update action. */}
1340
+ {key && !valueFields.some((f) => f.key === key) ? (
1341
+ <option value={key}>{key}</option>
1342
+ ) : null}
1343
+ {valueFields.map((f) => (
1344
+ <option key={f.key} value={f.key}>
1345
+ {f.label}
1346
+ </option>
1347
+ ))}
1348
+ </select>
1349
+ <input
1350
+ className="auto-input is-tiny autoc-value"
1351
+ defaultValue={String(val ?? "")}
1352
+ disabled={busy}
1353
+ aria-label="Value"
1354
+ placeholder="Value or {{column}}"
1355
+ onBlur={(e) => setCfg({ values: { ...values, [key]: e.target.value } })}
1356
+ />
1357
+ </div>
1358
+ ))}
1359
+ <button
1360
+ type="button"
1361
+ className="autoc-add"
1362
+ disabled={busy}
1363
+ onClick={() => setCfg({ values: { ...values, "": "" } })}
1364
+ >
1365
+ + Add a value
1366
+ </button>
1367
+ <p className="auto-hint">
1368
+ <code>{"{{column_key}}"}</code> reads that column off the record walking the flow.
1369
+ </p>
1370
+ </>
1371
+ ) : null}
1372
+
1373
+ {action.kind === "find_records" ? (
1374
+ <>
1375
+ <div className="auto-field">
1376
+ <label htmlFor="autox-ftable">
1377
+ <span className="autox-req">*</span> Database
1378
+ </label>
1379
+ <select
1380
+ id="autox-ftable"
1381
+ className="auto-input"
1382
+ value={String((cfg as { table?: string }).table || "")}
1383
+ disabled={busy}
1384
+ onChange={(e) => setCfg({ table: e.target.value })}
1385
+ >
1386
+ <option value="">Select a database…</option>
1387
+ {tables.map((t) => (
1388
+ <option key={t.key} value={t.key}>
1389
+ {t.label}
1390
+ </option>
1391
+ ))}
1392
+ </select>
1393
+ </div>
1394
+ <p className="auto-field-label">Conditions</p>
1395
+ <CondBuilder
1396
+ cond={(cfg as { cond?: Cond | null }).cond || null}
1397
+ onChange={(next) => setCfg({ cond: next })}
1398
+ fields={target?.fields || []}
1399
+ ops={ops}
1400
+ nullaryOps={nullaryOps}
1401
+ maxDepth={vocab?.maxCondDepth ?? 3}
1402
+ maxChildren={vocab?.maxCondChildren ?? 12}
1403
+ disabled={busy}
1404
+ />
1405
+ <div className="auto-field">
1406
+ <label htmlFor="autox-flimit">How many at most</label>
1407
+ <input
1408
+ id="autox-flimit"
1409
+ className="auto-input"
1410
+ type="number"
1411
+ min={1}
1412
+ defaultValue={Number((cfg as { limit?: number }).limit || 25)}
1413
+ disabled={busy}
1414
+ onBlur={(e) => setCfg({ limit: Number(e.target.value) || 25 })}
1415
+ />
1416
+ </div>
1417
+ <p className="auto-hint">
1418
+ What it found opens from the run log. Piping the rows into a later step is not built
1419
+ yet.
1420
+ </p>
1421
+ </>
1422
+ ) : null}
1423
+
1424
+ {action.kind === "review" ? (
1425
+ <ReviewProps
1426
+ cfg={cfg as Record<string, unknown>}
1427
+ vocab={vocab}
1428
+ busy={busy}
1429
+ setCfg={setCfg}
1430
+ />
1431
+ ) : null}
1432
+
1433
+ {/* EVERY action can be conditional, not just a group β€” `Action.when` is the engine's own
1434
+ field and a card with no way to set it would be a stored value the UI cannot reach. */}
1435
+ <h3>Run this only when</h3>
1436
+ <CondBuilder
1437
+ cond={action.when || null}
1438
+ onChange={(next) => onEdit((a) => ({ ...a, when: next }))}
1439
+ fields={walkFields}
1440
+ ops={ops}
1441
+ nullaryOps={nullaryOps}
1442
+ maxDepth={vocab?.maxCondDepth ?? 3}
1443
+ maxChildren={vocab?.maxCondChildren ?? 12}
1444
+ disabled={busy}
1445
+ />
1446
+ {!condComplete(action.when || null, nullaryOps) ? null : (
1447
+ <p className="auto-hint">Leave empty to run it every time.</p>
1448
+ )}
1449
+ </>
1450
+ );
1451
+ }
1452
+
1453
+ function ReviewProps({
1454
+ cfg,
1455
+ vocab,
1456
+ busy,
1457
+ setCfg,
1458
+ }: {
1459
+ cfg: Record<string, unknown>;
1460
+ vocab?: FlowVocab;
1461
+ busy: boolean;
1462
+ setCfg: (patch: Record<string, unknown>) => void;
1463
+ }) {
1464
+ const by = String(cfg.decidedBy || "user");
1465
+ const next = Array.isArray(cfg.next) ? (cfg.next as string[]) : [];
1466
+ const aiReady = vocab?.aiReady !== false;
1467
+ return (
1468
+ <>
1469
+ <div className="auto-field">
1470
+ <label htmlFor="autox-rby">Decided by</label>
1471
+ <select
1472
+ id="autox-rby"
1473
+ className="auto-input"
1474
+ value={by}
1475
+ disabled={busy}
1476
+ onChange={(e) => setCfg({ decidedBy: e.target.value })}
1477
+ >
1478
+ {(vocab?.reviewDeciders || ["user"]).map((d) => (
1479
+ <option key={d} value={d}>
1480
+ {d === "ai" ? "AI" : "A person"}
1481
+ </option>
1482
+ ))}
1483
+ </select>
1484
+ </div>
1485
+ {/*
1486
+ ⚠ `aiReady` IS A MEASUREMENT OF THIS DEPLOYMENT, and it is stated rather than styled
1487
+ around. With no LLM key the engine holds every card for a human β€” so an AI review that
1488
+ looked configured would promise a decision nothing will make (C6's fail-closed path).
1489
+ */}
1490
+ {by === "ai" && !aiReady ? (
1491
+ <p className="auto-note">
1492
+ No AI provider is configured here, so these cards wait for a person.
1493
+ </p>
1494
+ ) : null}
1495
+ {by === "ai" ? (
1496
+ <div className="auto-field">
1497
+ <label htmlFor="autox-rprompt">What should it decide?</label>
1498
+ <textarea
1499
+ id="autox-rprompt"
1500
+ className="auto-input"
1501
+ rows={3}
1502
+ defaultValue={String(cfg.prompt || "")}
1503
+ disabled={busy}
1504
+ placeholder="Approve suppliers with a UK address and more than 20 reviews."
1505
+ onBlur={(e) => setCfg({ prompt: e.target.value })}
1506
+ />
1507
+ </div>
1508
+ ) : null}
1509
+ <div className="auto-field">
1510
+ <label htmlFor="autox-rlabel">Stage name</label>
1511
+ <input
1512
+ id="autox-rlabel"
1513
+ className="auto-input"
1514
+ defaultValue={String(cfg.label || "Review")}
1515
+ disabled={busy}
1516
+ onBlur={(e) => setCfg({ label: e.target.value })}
1517
+ />
1518
+ </div>
1519
+ <p className="auto-field-label">Where it can go next</p>
1520
+ {next.map((n, i) => (
1521
+ <div className="autoc-row" key={i}>
1522
+ <button
1523
+ type="button"
1524
+ className="autoc-drop"
1525
+ disabled={busy}
1526
+ aria-label={`Remove ${n}`}
1527
+ onClick={() => setCfg({ next: next.filter((_x, j) => j !== i) })}
1528
+ >
1529
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
1530
+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
1531
+ strokeLinecap="round" />
1532
+ </svg>
1533
+ </button>
1534
+ <input
1535
+ className="auto-input is-tiny"
1536
+ defaultValue={n}
1537
+ disabled={busy}
1538
+ aria-label="Stage this review can send a record to"
1539
+ onBlur={(e) =>
1540
+ setCfg({ next: next.map((x, j) => (j === i ? e.target.value : x)) })
1541
+ }
1542
+ />
1543
+ </div>
1544
+ ))}
1545
+ <button
1546
+ type="button"
1547
+ className="autoc-add"
1548
+ disabled={busy}
1549
+ onClick={() => setCfg({ next: [...next, ""] })}
1550
+ >
1551
+ + Add an exit
1552
+ </button>
1553
+ <p className="auto-hint">
1554
+ Each exit is a lane on the board. A card waits here until it is moved to one of them.
1555
+ </p>
1556
+ </>
1557
+ );
1558
+ }
web/src/automation/AutomationCreate.tsx CHANGED
@@ -34,7 +34,35 @@ interface ColumnPlan {
34
  key: string;
35
  }
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  export default function AutomationCreate({ kinds, onCreated, onCancel }: Props) {
 
 
 
 
 
 
 
 
 
 
 
38
  const [name, setName] = useState("");
39
  const [kind, setKind] = useState<AutomationKind>("scrape_db");
40
  const [problem, setProblem] = useState("");
@@ -100,6 +128,16 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
100
  const res = await createAutomation({
101
  name,
102
  kind,
 
 
 
 
 
 
 
 
 
 
103
  schedule: { cron: "0 6 * * *", enabled: false },
104
  config:
105
  kind === "scrape_db"
@@ -133,19 +171,95 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
133
 
134
  const canCreate =
135
  !!name.trim() &&
 
 
 
 
136
  (kind === "scrape_db"
137
  ? !!url.trim() && plan.some((c) => c.include && c.key === keyField)
138
  : kind === "discover_instagram"
139
  ? true
140
  : !!targetTable && !!fieldKey);
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  return (
143
  <div className="auto-create-pane">
144
  <div className="auto-create-card">
145
  <h1>New automation</h1>
146
- <p className="auto-hint">
147
- Pick what it does. Once it exists you configure it one step at a time.
148
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
  {problem ? (
151
  <div className="auto-banner is-error" role="alert">
@@ -340,6 +454,11 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
340
  ) : (
341
  <>
342
  <div className="auto-field-row">
 
 
 
 
 
343
  <div className="auto-field">
344
  <label htmlFor="auto-new-table">Database</label>
345
  <select
@@ -360,6 +479,7 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
360
  ))}
361
  </select>
362
  </div>
 
363
  <div className="auto-field">
364
  <label htmlFor="auto-new-fieldkey">Automation column</label>
365
  <select
 
34
  key: string;
35
  }
36
 
37
+ /**
38
+ * C2's THREE CARDS β€” the wizard's first question, asked before the kind.
39
+ *
40
+ * β›” ONE LINE EACH (R13). The temptation here is to explain what a "machine database" is; the
41
+ * person reading has already decided to make an automation and every extra word is spent on
42
+ * someone who is leaving. What each mode DOES is visible one screen later, where the choice
43
+ * has consequences.
44
+ */
45
+ const TARGET_MODES: { mode: string; label: string; line: string }[] = [
46
+ { mode: "existing", label: "Use an existing database",
47
+ line: "It works on rows that are already there." },
48
+ { mode: "new", label: "Create a new database",
49
+ line: "An empty one, made now, named by you." },
50
+ { mode: "automated", label: "Create an automated database",
51
+ line: "The automation makes it on its first run." },
52
+ ];
53
+
54
  export default function AutomationCreate({ kinds, onCreated, onCancel }: Props) {
55
+ /**
56
+ * ⚠ TWO STEPS, NOT A WIZARD WITH A BACK STACK. The first answers "what does it work on", the
57
+ * second "what does it do" β€” and the second is the form this file has always been, because
58
+ * the engine still requires one of its three machine KINDS (`clean_definition` refuses an
59
+ * automation without one). C2 anticipates trigger-and-actions-only automations for the
60
+ * `existing`/`new` modes; there is no server kind for that yet, so the kind question stays
61
+ * and the wave mailbox carries the ask. When a plain kind exists, step 2 collapses for those
62
+ * two modes and nothing else here changes.
63
+ */
64
+ const [mode, setMode] = useState("");
65
+ const [newLabel, setNewLabel] = useState("");
66
  const [name, setName] = useState("");
67
  const [kind, setKind] = useState<AutomationKind>("scrape_db");
68
  const [problem, setProblem] = useState("");
 
128
  const res = await createAutomation({
129
  name,
130
  kind,
131
+ // C2's target, resolved SERVER-SIDE in the same call that creates the automation
132
+ // (`resolve_target` runs before validation), so a `new` database is never a
133
+ // create-then-patch this client has to sequence β€” and an automation is never saved
134
+ // pointing at a table that does not exist yet.
135
+ target:
136
+ mode === "existing"
137
+ ? { mode, table: targetTable }
138
+ : mode === "new"
139
+ ? { mode, label: newLabel }
140
+ : { mode: "automated", label: targetLabel },
141
  schedule: { cron: "0 6 * * *", enabled: false },
142
  config:
143
  kind === "scrape_db"
 
171
 
172
  const canCreate =
173
  !!name.trim() &&
174
+ // The MODE's own question has to be answered too β€” `resolve_target` refuses a blank one
175
+ // with its own sentence ("choose the database this automation works on"), and offering a
176
+ // Create button that can only produce that refusal is a control that lies.
177
+ (mode === "existing" ? !!targetTable : mode === "new" ? !!newLabel.trim() : true) &&
178
  (kind === "scrape_db"
179
  ? !!url.trim() && plan.some((c) => c.include && c.key === keyField)
180
  : kind === "discover_instagram"
181
  ? true
182
  : !!targetTable && !!fieldKey);
183
 
184
+ /* ── STEP 1: what does it work on? (C2, images 1-3's front door) ───────────────────────── */
185
+ if (!mode) {
186
+ return (
187
+ <div className="auto-create-pane">
188
+ <div className="auto-create-card">
189
+ <h1>New automation</h1>
190
+ <div className="autox-modes">
191
+ {TARGET_MODES.map((m) => (
192
+ <button
193
+ key={m.mode}
194
+ type="button"
195
+ className="autox-mode"
196
+ onClick={() => setMode(m.mode)}
197
+ >
198
+ <span className="autox-mode-label">{m.label}</span>
199
+ <span className="autox-mode-line">{m.line}</span>
200
+ </button>
201
+ ))}
202
+ </div>
203
+ <div className="auto-head-actions">
204
+ <button type="button" className="auto-btn" onClick={onCancel}>
205
+ Cancel
206
+ </button>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ );
211
+ }
212
+
213
  return (
214
  <div className="auto-create-pane">
215
  <div className="auto-create-card">
216
  <h1>New automation</h1>
217
+
218
+ {/* STEP 2's answer, and a way back to change it. A chosen mode that cannot be unchosen
219
+ is a dead end one click deep. */}
220
+ <div className="autox-modeback">
221
+ <span className="autox-mode-label">
222
+ {TARGET_MODES.find((m) => m.mode === mode)?.label || mode}
223
+ </span>
224
+ <button type="button" className="autoc-add" onClick={() => setMode("")}>
225
+ Change
226
+ </button>
227
+ </div>
228
+
229
+ {mode === "existing" ? (
230
+ <div className="auto-field">
231
+ <label htmlFor="auto-new-target">Database</label>
232
+ <select
233
+ id="auto-new-target"
234
+ className="auto-input"
235
+ value={targetTable}
236
+ onChange={(e) => {
237
+ setTargetTable(e.target.value);
238
+ setFieldKey("");
239
+ setUrlField("");
240
+ }}
241
+ >
242
+ <option value="">Choose a database…</option>
243
+ {tables.map((t) => (
244
+ <option key={t.key} value={t.key}>
245
+ {t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"})
246
+ </option>
247
+ ))}
248
+ </select>
249
+ </div>
250
+ ) : mode === "new" ? (
251
+ <div className="auto-field">
252
+ <label htmlFor="auto-new-dblabel">Name the new database</label>
253
+ <input
254
+ id="auto-new-dblabel"
255
+ className="auto-input"
256
+ value={newLabel}
257
+ placeholder="Supplier leads"
258
+ onChange={(e) => setNewLabel(e.target.value)}
259
+ />
260
+ <p className="auto-hint">It is created empty, with this automation.</p>
261
+ </div>
262
+ ) : null}
263
 
264
  {problem ? (
265
  <div className="auto-banner is-error" role="alert">
 
454
  ) : (
455
  <>
456
  <div className="auto-field-row">
457
+ {/* ⚠ ONE CONTROL PER FACT. Step 2 already asked which database for the
458
+ `existing`/`new` modes and binds the SAME state β€” two selects for one value
459
+ is how a form starts disagreeing with itself. Only the `automated` mode,
460
+ which asks nothing up front, needs this one. */}
461
+ {mode === "automated" ? (
462
  <div className="auto-field">
463
  <label htmlFor="auto-new-table">Database</label>
464
  <select
 
479
  ))}
480
  </select>
481
  </div>
482
+ ) : null}
483
  <div className="auto-field">
484
  <label htmlFor="auto-new-fieldkey">Automation column</label>
485
  <select
web/src/automation/AutomationDetail.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/automation/AutomationSurface.tsx CHANGED
@@ -16,6 +16,8 @@
16
  import type { KeyboardEvent as ReactKeyboardEvent } from "react";
17
  import { useCallback, useEffect, useRef, useState } from "react";
18
 
 
 
19
  import AutomationCreate from "./AutomationCreate";
20
  import AutomationDetail from "./AutomationDetail";
21
  import type { Automation, AutomationList } from "./automationApi";
@@ -55,6 +57,39 @@ function railSubtitle(a: Automation): string {
55
  return last ? `Last run ${last}` : "Manual only";
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  export default function AutomationSurface() {
59
  const [data, setData] = useState<AutomationList | null>(null);
60
  const [error, setError] = useState("");
@@ -62,21 +97,66 @@ export default function AutomationSurface() {
62
  const [creating, setCreating] = useState(false);
63
  const [railShut, setRailShut] = useState(false);
64
  const [busy, setBusy] = useState("");
 
 
 
 
65
  const listRef = useRef<HTMLDivElement | null>(null);
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  const load = useCallback(async (abort?: AbortSignal) => {
 
68
  try {
69
  const next = await listAutomations(abort);
70
- setData(next);
71
- setError("");
 
 
72
  return next;
73
  } catch (e) {
74
  if ((e as Error)?.name === "AbortError") return null;
75
- setError(
76
- e instanceof AutomationError
77
- ? e.message
78
- : "The automation service did not answer."
79
- );
 
80
  return null;
81
  }
82
  }, []);
@@ -91,13 +171,57 @@ export default function AutomationSurface() {
91
  // finished. Polling only while something is actually running keeps an idle surface
92
  // silent β€” a fixed interval would be a request every few seconds forever, for a page
93
  // whose contents change a handful of times a day.
 
 
 
 
 
 
94
  const anyRunning = (data?.automations || []).some((a) => a.running);
95
  useEffect(() => {
96
  if (!anyRunning) return undefined;
97
- const t = window.setInterval(() => void load(), 2500);
98
- return () => window.clearInterval(t);
 
 
 
 
99
  }, [anyRunning, load]);
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  const items = data?.automations || [];
102
  const active = items.find((a) => a.id === activeId) || null;
103
 
@@ -162,7 +286,7 @@ export default function AutomationSurface() {
162
  className="auto-create-btn"
163
  onClick={() => {
164
  setCreating(true);
165
- setActiveId("");
166
  }}
167
  >
168
  <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -189,7 +313,7 @@ export default function AutomationSurface() {
189
  type="button"
190
  className="auto-row-main"
191
  onClick={() => {
192
- setActiveId(a.id);
193
  setCreating(false);
194
  }}
195
  >
@@ -248,8 +372,11 @@ export default function AutomationSurface() {
248
  kinds={data?.kinds || []}
249
  onCreated={async (id) => {
250
  setCreating(false);
 
 
 
251
  const next = await load();
252
- if (id && next) setActiveId(id);
253
  }}
254
  onCancel={() => setCreating(false)}
255
  />
@@ -265,16 +392,42 @@ export default function AutomationSurface() {
265
  // trigger face can tell "the server offered nothing" from "the server offered
266
  // an empty list" rather than collapsing both into a picker with no options.
267
  triggers={data?.triggers}
 
 
 
 
 
268
  // `?? null` and never `|| {enabled:false}`: an absent tick bit is "the server did
269
  // not say", which Step 1 prints as its own sentence. Defaulting it here would
270
  // turn a missing field into a claim about production (C6 amendment #1).
271
  tick={data?.tick ?? null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  onSaved={async (id) => {
273
  const next = await load();
274
- if (id && next) setActiveId(id);
275
  }}
276
  onDeleted={async () => {
277
- setActiveId("");
278
  await load();
279
  }}
280
  />
@@ -296,7 +449,16 @@ export default function AutomationSurface() {
296
  * paragraph, spent differently.
297
  */
298
  <div className="autob-empty">
299
- <h1 className="autob-empty-title">Automations</h1>
 
 
 
 
 
 
 
 
 
300
  <p className="autob-empty-line">
301
  A job this workspace runs for you, on demand or on a schedule.
302
  </p>
@@ -305,7 +467,7 @@ export default function AutomationSurface() {
305
  className="auto-btn is-primary"
306
  onClick={() => {
307
  setCreating(true);
308
- setActiveId("");
309
  }}
310
  >
311
  New automation
 
16
  import type { KeyboardEvent as ReactKeyboardEvent } from "react";
17
  import { useCallback, useEffect, useRef, useState } from "react";
18
 
19
+ import type { AutomationOpenDetail } from "../apiContract";
20
+ import { AUTOMATION_OPEN_EVENT } from "../apiContract";
21
  import AutomationCreate from "./AutomationCreate";
22
  import AutomationDetail from "./AutomationDetail";
23
  import type { Automation, AutomationList } from "./automationApi";
 
57
  return last ? `Last run ${last}` : "Manual only";
58
  }
59
 
60
+ /*
61
+ * β›” W23-W5's LISTENER IS AT MODULE SCOPE, AND THAT IS THE POINT β€” not a stylistic choice.
62
+ *
63
+ * The frame's click-through sets `window.location.hash = "#/automation"` and signals on the
64
+ * VERY NEXT LINE (Shell.tsx:1647-1650, and its comment is right that the order matters). But a
65
+ * hash write does not mount anything synchronously: `hashchange` is delivered as a task, the
66
+ * router state then updates, React renders, and only THEN does a component effect subscribe.
67
+ * A listener registered in `useEffect` therefore misses every click that arrives from another
68
+ * page β€” which is every click, since a reader looking at Alerts is by definition not already
69
+ * on this surface. The event would dispatch into nothing and every gate would stay green:
70
+ * exactly the wave-20 item-25 failure the contract's own note describes, reproduced one layer
71
+ * down.
72
+ *
73
+ * This module is imported statically by the shell, so this listener exists from app start. It
74
+ * LATCHES the request; the component consumes the latch when it mounts and hears live events
75
+ * while it is mounted. A request nobody claims is dropped on the next one β€” the latch is a
76
+ * one-slot mailbox, never a queue.
77
+ */
78
+ let pendingOpen: AutomationOpenDetail | null = null;
79
+ const openSubscribers = new Set<(detail: AutomationOpenDetail) => void>();
80
+
81
+ if (typeof window !== "undefined") {
82
+ window.addEventListener(AUTOMATION_OPEN_EVENT, (event) => {
83
+ const detail = (event as CustomEvent<AutomationOpenDetail>).detail;
84
+ if (!detail?.autoId) return;
85
+ if (openSubscribers.size) {
86
+ for (const notify of openSubscribers) notify(detail);
87
+ return;
88
+ }
89
+ pendingOpen = detail;
90
+ });
91
+ }
92
+
93
  export default function AutomationSurface() {
94
  const [data, setData] = useState<AutomationList | null>(null);
95
  const [error, setError] = useState("");
 
97
  const [creating, setCreating] = useState(false);
98
  const [railShut, setRailShut] = useState(false);
99
  const [busy, setBusy] = useState("");
100
+ /** W23-W5's advisory half: which stage's panel the click-through asked for, if any. */
101
+ const [openStage, setOpenStage] = useState("");
102
+ /** An open request waiting for the list to arrive (see the resolver below). */
103
+ const [openRequest, setOpenRequest] = useState<AutomationOpenDetail | null>(null);
104
  const listRef = useRef<HTMLDivElement | null>(null);
105
 
106
+ /**
107
+ * β›” C14 LEG 2 β€” THE ID THE USER LAST ASKED FOR, and it is a ref because it has to be
108
+ * written SYNCHRONOUSLY, inside the click, before any promise that was already in flight
109
+ * can resolve. State would not do: a resolution racing React's commit would read the
110
+ * previous value, which is the exact window this guard exists to close.
111
+ */
112
+ const wantedId = useRef("");
113
+
114
+ /**
115
+ * ⭐ THE ONLY PLACE THE RAIL'S SELECTION MOVES ON PURPOSE. Every deliberate change of
116
+ * automation β€” a rail click, a create, a delete β€” goes through here, so "what the user
117
+ * asked for" and "what is on screen" are written in one statement and cannot drift apart.
118
+ * A resolution that wants to steer the rail compares itself against `wantedId` instead.
119
+ */
120
+ const select = useCallback((id: string, stage = "") => {
121
+ wantedId.current = id;
122
+ setActiveId(id);
123
+ // The stage hint belongs to THIS selection and no other. Every ordinary caller passes
124
+ // nothing, which clears it β€” so a review notification cannot leave a stage open on the
125
+ // next automation the reader clicks by hand.
126
+ setOpenStage(stage);
127
+ }, []);
128
+
129
+ /**
130
+ * β›” C14 LEG 3 (the paint half) β€” A STALE READ MAY NOT REPAINT.
131
+ *
132
+ * Every call takes the next generation; only the newest one is allowed to `setData`. The
133
+ * defect this closes is not cosmetic: a poll issued BEFORE a delete resolves AFTER it, and
134
+ * a list that still contains the deleted automation puts a row back in the rail that the
135
+ * user has just watched disappear. Same for a toggle, and same for a create.
136
+ *
137
+ * ⚠ THE RETURN VALUE IS NOT GUARDED, deliberately. The caller asked a question and gets
138
+ * its own answer β€” coupling the two would mean a create whose `load()` was overtaken by a
139
+ * poll silently failed to select the automation it had just made.
140
+ */
141
+ const gen = useRef(0);
142
+
143
  const load = useCallback(async (abort?: AbortSignal) => {
144
+ const mine = ++gen.current;
145
  try {
146
  const next = await listAutomations(abort);
147
+ if (mine === gen.current) {
148
+ setData(next);
149
+ setError("");
150
+ }
151
  return next;
152
  } catch (e) {
153
  if ((e as Error)?.name === "AbortError") return null;
154
+ if (mine === gen.current)
155
+ setError(
156
+ e instanceof AutomationError
157
+ ? e.message
158
+ : "The automation service did not answer."
159
+ );
160
  return null;
161
  }
162
  }, []);
 
171
  // finished. Polling only while something is actually running keeps an idle surface
172
  // silent β€” a fixed interval would be a request every few seconds forever, for a page
173
  // whose contents change a handful of times a day.
174
+ //
175
+ // β›” C14 LEG 3 (the abort half). It used to call `load()` bare β€” no signal, nothing to
176
+ // cancel β€” so a request the interval had already issued kept going after the effect that
177
+ // owned it was gone. One controller per effect run, aborted with the interval, means a poll
178
+ // cannot outlive the condition that justified it. The generation guard inside `load` covers
179
+ // the rest: a response that survives the abort still cannot repaint over a newer one.
180
  const anyRunning = (data?.automations || []).some((a) => a.running);
181
  useEffect(() => {
182
  if (!anyRunning) return undefined;
183
+ const ac = new AbortController();
184
+ const t = window.setInterval(() => void load(ac.signal), 2500);
185
+ return () => {
186
+ window.clearInterval(t);
187
+ ac.abort();
188
+ };
189
  }, [anyRunning, load]);
190
 
191
+ // ── W23-W5, the surface's half: hear the request, then answer it when we CAN ──────────
192
+ useEffect(() => {
193
+ const notify = (detail: AutomationOpenDetail) => setOpenRequest(detail);
194
+ openSubscribers.add(notify);
195
+ if (pendingOpen) {
196
+ const latched = pendingOpen;
197
+ pendingOpen = null;
198
+ setOpenRequest(latched);
199
+ }
200
+ return () => {
201
+ openSubscribers.delete(notify);
202
+ };
203
+ }, []);
204
+
205
+ /**
206
+ * ⚠ THE REQUEST OUTLIVES THE FETCH, and it has to. The reader clicks a notification from
207
+ * another page, so this surface is mounting WITH AN EMPTY LIST β€” "select it if it is in the
208
+ * list" would drop every real click and keep only the one case where the reader was already
209
+ * here. So the request is held until `data` exists, and only then answered.
210
+ *
211
+ * An automation the reader can no longer open does NOTHING (the contract's own words): the
212
+ * request is cleared either way, so a stale id cannot sit here re-firing against every
213
+ * subsequent list.
214
+ */
215
+ useEffect(() => {
216
+ if (!openRequest || !data) return;
217
+ const found = (data.automations || []).some((a) => a.id === openRequest.autoId);
218
+ if (found) {
219
+ setCreating(false);
220
+ select(openRequest.autoId, openRequest.stageId || "");
221
+ }
222
+ setOpenRequest(null);
223
+ }, [openRequest, data, select]);
224
+
225
  const items = data?.automations || [];
226
  const active = items.find((a) => a.id === activeId) || null;
227
 
 
286
  className="auto-create-btn"
287
  onClick={() => {
288
  setCreating(true);
289
+ select("");
290
  }}
291
  >
292
  <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
 
313
  type="button"
314
  className="auto-row-main"
315
  onClick={() => {
316
+ select(a.id);
317
  setCreating(false);
318
  }}
319
  >
 
372
  kinds={data?.kinds || []}
373
  onCreated={async (id) => {
374
  setCreating(false);
375
+ // THE CREATE FLOW IS THE ONE CALLER ALLOWED TO NAME A DIFFERENT ID (C14 leg 2),
376
+ // and it is a different PROP for exactly that reason β€” the guard below does not
377
+ // have to make an exception it could get wrong.
378
  const next = await load();
379
+ if (id && next) select(id);
380
  }}
381
  onCancel={() => setCreating(false)}
382
  />
 
392
  // trigger face can tell "the server offered nothing" from "the server offered
393
  // an empty list" rather than collapsing both into a picker with no options.
394
  triggers={data?.triggers}
395
+ // C4's action menu + the builder's ceilings. Forwarded as-is for the same reason
396
+ // `triggers` is: absent must stay absent, so the builder can tell "the server
397
+ // offered nothing" from "the server offered an empty list".
398
+ catalog={data?.actionsCatalog}
399
+ vocab={data?.flow}
400
  // `?? null` and never `|| {enabled:false}`: an absent tick bit is "the server did
401
  // not say", which Step 1 prints as its own sentence. Defaulting it here would
402
  // turn a missing field into a claim about production (C6 amendment #1).
403
  tick={data?.tick ?? null}
404
+ // W23-W5's advisory half. A review notification names the stage its cards are
405
+ // waiting at, and landing on the automation with that stage's panel already open
406
+ // is the difference between "here is the thing" and "here is the thing you were
407
+ // told about". Absent for every other way of arriving.
408
+ initialStage={openStage}
409
+ /*
410
+ * β›” C14 LEG 2 β€” THE STALE-ID WRITE-BACK, and this is the ghost's second cause.
411
+ *
412
+ * The detail calls `onSaved(automation.id)` from six places (save, the trigger
413
+ * picker, a schedule change, a node switch, a card move, a run). Each closure
414
+ * captures the automation it was mounted for, so a save that resolves AFTER the
415
+ * user has clicked a different row used to call `setActiveId(the OLD id)` β€” the
416
+ * rail jumped back, the detail remounted, and what the user saw was the previous
417
+ * automation reappearing over the one they had just opened. It looked like a
418
+ * rendering bug; it was a resolution steering the selection.
419
+ *
420
+ * A resolution may no longer steer anything. It reloads the list β€” that part was
421
+ * always right β€” and it re-asserts the selection ONLY when its id is still the one
422
+ * the user asked for, which makes the write a no-op in the good case and nothing
423
+ * at all in the bad one.
424
+ */
425
  onSaved={async (id) => {
426
  const next = await load();
427
+ if (id && next && id === wantedId.current) select(id);
428
  }}
429
  onDeleted={async () => {
430
+ select("");
431
  await load();
432
  }}
433
  />
 
449
  * paragraph, spent differently.
450
  */
451
  <div className="autob-empty">
452
+ {/*
453
+ β›” NO TITLE HERE ANY MORE (C13, owner item 2). This pane carried an `h1`
454
+ reading "Automations" at 20px/600 β€” a THIRD title treatment on a page that
455
+ also had the header's editable 16px/700 input, against every database page's
456
+ single 16px/650 `shell-db-name`. The shell now wraps this branch in the same
457
+ `shell-db-frame` + `DbHead` a database gets (wiring W23-W1), so the page's name
458
+ is drawn once, by the one component that draws every other page's name. A
459
+ stand-in restyled to match would have been a second copy of the same fact,
460
+ free to drift the day the header moves.
461
+ */}
462
  <p className="autob-empty-line">
463
  A job this workspace runs for you, on demand or on a schedule.
464
  </p>
 
467
  className="auto-btn is-primary"
468
  onClick={() => {
469
  setCreating(true);
470
+ select("");
471
  }}
472
  >
473
  New automation
web/src/automation/AutomationTrigger.tsx CHANGED
@@ -24,7 +24,6 @@
24
  // nothing and gets asked again.
25
  // ---------------------------------------------------------------------------
26
  import type { OAuthStatus, TickState, TriggerOption } from "./automationApi";
27
- import { oauthProviderFor, oauthStartUrlFor } from "./automationApi";
28
  import { WEEKDAYS, readCron, writeCron } from "./steps";
29
 
30
  interface Props {
@@ -54,20 +53,27 @@ interface Props {
54
  /** True when the raw cron face is open β€” a VIEW choice, not a schedule change. */
55
  showCron: boolean;
56
  onShowCron: (v: boolean) => void;
 
 
 
 
 
 
 
 
57
  }
58
 
59
- /**
60
- * ⚠ THE FALLBACK, AND IT IS TEMPORARY BY CONSTRUCTION. Until `triggers` rides,
61
- * these are the only two shapes the stored definition can express β€” the engine
62
- * has exactly one trigger node and `schedule.enabled` decides it β€” so this is a
63
- * description of what the server can do today, not a second vocabulary. It is
64
- * NEVER consulted once the list arrives; when C3 lands, deleting this constant
65
- * changes nothing on screen.
 
 
 
66
  */
67
- const FALLBACK_TRIGGERS: TriggerOption[] = [
68
- { key: "manual", label: "Manual only", ready: true },
69
- { key: "schedule", label: "On a schedule", ready: true },
70
- ];
71
 
72
  /** The one-line next step a not-ready trigger carries. Server words where there are any. */
73
  function needsLabel(needs: string): string {
@@ -77,6 +83,16 @@ function needsLabel(needs: string): string {
77
  return needs.replace(/_/g, " ");
78
  }
79
 
 
 
 
 
 
 
 
 
 
 
80
  /**
81
  * What the deployment will actually do with a schedule.
82
  *
@@ -128,20 +144,23 @@ export default function AutomationTrigger({
128
  oauth,
129
  showCron,
130
  onShowCron,
 
131
  }: Props) {
132
- const options = triggers && triggers.length ? triggers : FALLBACK_TRIGGERS;
 
 
133
  const picked = options.find((t) => t.key === current) || null;
134
  const isSchedule = current === "schedule";
135
  const shape = readCron(cron);
136
  const note = tickNote(tick);
137
  const every = showCron ? "custom" : shape.every;
138
- // The connector this trigger is waiting on, resolved from the SERVER's `needs`
139
- // token. Empty provider = we cannot name a route for it, which is a state the
140
- // block below states in words instead of drawing a button that goes nowhere.
141
- const waiting = !!picked && picked.ready === false && !!picked.needs;
142
- const provider = waiting ? oauthProviderFor(picked.needs as string) : "";
143
  const connected = !!(provider && oauth?.[provider]?.connected);
144
- const startUrl = waiting ? oauthStartUrlFor(picked.needs as string) : "";
145
 
146
  const pickEvery = (next: string) => {
147
  onShowCron(next === "custom");
@@ -152,6 +171,10 @@ export default function AutomationTrigger({
152
  return (
153
  <>
154
  <div className="auto-field-row">
 
 
 
 
155
  <div className="auto-field">
156
  <label htmlFor="auto-trigger-kind">When it runs</label>
157
  <select
@@ -171,14 +194,22 @@ export default function AutomationTrigger({
171
  <option value={current}>{current} (not offered here)</option>
172
  ) : null}
173
  {options.map((t) => (
174
- <option key={t.key} value={t.key}>
175
- {/* Not-ready is stated ON the option rather than by disabling it: the
176
- option is pickable, and picking it shows what it is waiting for. */}
177
- {t.ready === false ? `${t.label} β€” needs setting up` : t.label}
 
 
 
 
 
 
 
178
  </option>
179
  ))}
180
  </select>
181
  </div>
 
182
 
183
  {isSchedule ? (
184
  <>
@@ -269,6 +300,16 @@ export default function AutomationTrigger({
269
  ) : null}
270
  </div>
271
 
 
 
 
 
 
 
 
 
 
 
272
  {/* NOT CONFIGURED IS A STATE WITH A NEXT STEP, never a dead control (C3). */}
273
  {waiting ? (
274
  <div className="autob-needs">
 
24
  // nothing and gets asked again.
25
  // ---------------------------------------------------------------------------
26
  import type { OAuthStatus, TickState, TriggerOption } from "./automationApi";
 
27
  import { WEEKDAYS, readCron, writeCron } from "./steps";
28
 
29
  interface Props {
 
53
  /** True when the raw cron face is open β€” a VIEW choice, not a schedule change. */
54
  showCron: boolean;
55
  onShowCron: (v: boolean) => void;
56
+ /**
57
+ * WAVE 23 β€” the BUILDER supplies its own "Trigger type" select (Properties β†’ Trigger details),
58
+ * so this face drops its picker and contributes only the schedule half. It is a prop rather
59
+ * than a second component because the thing worth having exactly once is what sits BELOW the
60
+ * picker: the cron round-trip, the preset list and the tick-honesty note, three things that go
61
+ * wrong invisibly. Two pickers for one fact is the defect this file's header forbids.
62
+ */
63
+ hidePicker?: boolean;
64
  }
65
 
66
+ /*
67
+ * β›” `FALLBACK_TRIGGERS` IS GONE (D-43, wave 23). It described the only two shapes a stored
68
+ * definition could express before C3 shipped (manual / on a schedule), and its own note set
69
+ * the deletion condition: "NEVER consulted once the list arrives; when C3 lands, deleting this
70
+ * constant changes nothing on screen." C3 has been live for two releases and the vocabulary is
71
+ * now nine keys plus two planned, so the fallback could only ever have been WRONG β€” a server
72
+ * hiccup would have painted a two-option picker that looks authoritative.
73
+ *
74
+ * The absent case is a STATE now, not a substitute list: no `triggers` means the surface says
75
+ * so in one line (below) rather than quietly offering a vocabulary of its own.
76
  */
 
 
 
 
77
 
78
  /** The one-line next step a not-ready trigger carries. Server words where there are any. */
79
  function needsLabel(needs: string): string {
 
83
  return needs.replace(/_/g, " ");
84
  }
85
 
86
+ /**
87
+ * What an option says about itself. THREE states, and they are not the same fact:
88
+ * ready (nothing), waiting on a connection the USER can make ("needs setting up"), and
89
+ * declared-but-not-built ("coming soon", R2) which is waiting on us.
90
+ */
91
+ function optionLabel(t: TriggerOption): string {
92
+ if (t.planned) return `${t.label} β€” coming soon`;
93
+ return t.ready === false ? `${t.label} β€” needs setting up` : t.label;
94
+ }
95
+
96
  /**
97
  * What the deployment will actually do with a schedule.
98
  *
 
144
  oauth,
145
  showCron,
146
  onShowCron,
147
+ hidePicker,
148
  }: Props) {
149
+ // β›” THE SERVER'S LIST OR NOTHING (D-43). An empty array is not a fallback trigger to
150
+ // invent β€” it is the one honest thing to say, and it is said below.
151
+ const options = triggers || [];
152
  const picked = options.find((t) => t.key === current) || null;
153
  const isSchedule = current === "schedule";
154
  const shape = readCron(cron);
155
  const note = tickNote(tick);
156
  const every = showCron ? "custom" : shape.every;
157
+ // The connector this trigger is waiting on, and the route to it, BOTH COMPOSED BY THE
158
+ // SERVER (`TriggerOption.connect`). No `connect` = we cannot name a route, which is a state
159
+ // the block below states in words instead of drawing a button that goes nowhere.
160
+ const waiting = !!picked && picked.ready === false && !picked.planned && !!picked.needs;
161
+ const provider = picked?.connect?.provider || "";
162
  const connected = !!(provider && oauth?.[provider]?.connected);
163
+ const startUrl = picked?.connect?.startUrl || "";
164
 
165
  const pickEvery = (next: string) => {
166
  onShowCron(next === "custom");
 
171
  return (
172
  <>
173
  <div className="auto-field-row">
174
+ {/* ⚠ CONDITIONALLY RENDERED, never `hidden` β€” `.auto-field` sets `display: flex`, which
175
+ beats the `hidden` attribute's UA `display: none`, so the attribute would have left
176
+ a second trigger picker on screen looking exactly as if it had been asked for. */}
177
+ {hidePicker ? null : (
178
  <div className="auto-field">
179
  <label htmlFor="auto-trigger-kind">When it runs</label>
180
  <select
 
194
  <option value={current}>{current} (not offered here)</option>
195
  ) : null}
196
  {options.map((t) => (
197
+ /*
198
+ Not-ready is stated ON the option rather than by disabling it: the option is
199
+ pickable, and picking it shows what it is waiting for.
200
+ ⚠ PLANNED IS THE ONE EXCEPTION, and it is not the same fact (R2). The server
201
+ REFUSES a planned key at the door, so leaving it pickable would offer a
202
+ control whose only possible outcome is a refusal. It still renders β€” the
203
+ question "can it run when a button is clicked" has an answer β€” it just is not
204
+ an offer.
205
+ */
206
+ <option key={t.key} value={t.key} disabled={!!t.planned}>
207
+ {optionLabel(t)}
208
  </option>
209
  ))}
210
  </select>
211
  </div>
212
+ )}
213
 
214
  {isSchedule ? (
215
  <>
 
300
  ) : null}
301
  </div>
302
 
303
+ {/*
304
+ THE ABSENT LIST, SAID OUT LOUD (D-43). With the fallback deleted there is no
305
+ client-side vocabulary left to fall back TO, and that is the point: a picker that
306
+ quietly offers two options because a payload was short is a surface stating something
307
+ nobody measured. One line, no paragraph (R13).
308
+ */}
309
+ {!options.length && !hidePicker ? (
310
+ <p className="auto-note">This server did not offer a trigger list.</p>
311
+ ) : null}
312
+
313
  {/* NOT CONFIGURED IS A STATE WITH A NEXT STEP, never a dead control (C3). */}
314
  {waiting ? (
315
  <div className="autob-needs">
web/src/automation/CondBuilder.tsx ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // automation/CondBuilder.tsx β€” the condition TREE editor (contract C4).
3
+ //
4
+ // ONE editor, three callers: the trigger's "when a record matches conditions"
5
+ // (image 4), a conditional group's "Run actions in this group if…" (image 9),
6
+ // and a Find records step. They are the same shape (`Cond`) evaluated by the
7
+ // same engine function, so a second implementation would be a second set of
8
+ // bugs β€” and the one that matters is silent: a tree the editor can BUILD but
9
+ // the server refuses reads to the user as "it just doesn't save".
10
+ //
11
+ // β›” THE VOCABULARY IS THE SERVER'S. The operators are `flow.condOps`, the
12
+ // value-free ones are `flow.nullaryCondOps`, and the depth/breadth ceilings are
13
+ // `flow.maxCondDepth`/`maxCondChildren` β€” all off `GET /automations`. This file
14
+ // holds NO list of operators. It holds a WORDING for the ones it recognises and
15
+ // falls through to the raw key for anything it does not, which is visible and
16
+ // therefore reportable; a `switch` with no arm for a new op would render the row
17
+ // as nothing at all (the wave-9 silent-drop class).
18
+ //
19
+ // β›” IT NEVER PREDICTS A REFUSAL. `clean_cond` refuses an empty group, an
20
+ // unknown op, a valueless compare and an over-deep tree, each with a sentence.
21
+ // This editor SAYS a condition is incomplete β€” the red line Airtable shows β€” and
22
+ // still lets the save happen, so the authority on legality stays in one place.
23
+ // A client that blocked Save on its own arithmetic can block a tree the server
24
+ // would have taken, and the user cannot tell which of the two is wrong
25
+ // (`DiscoverGuard`'s note in automationApi states the same rule for the corpus
26
+ // filter, and it is the same rule).
27
+ // ---------------------------------------------------------------------------
28
+ import type { Cond } from "./automationApi";
29
+ import { groupParts, isCondGroup } from "./automationApi";
30
+
31
+ interface Field {
32
+ key: string;
33
+ label: string;
34
+ type?: string;
35
+ }
36
+
37
+ interface Props {
38
+ /** The stored tree. `null` = no conditions yet, which is a legal state, not an empty one. */
39
+ cond: Cond | null;
40
+ onChange: (next: Cond | null) => void;
41
+ fields: Field[];
42
+ /** `flow.condOps` β€” the comparisons the engine can answer. */
43
+ ops: string[];
44
+ /** `flow.nullaryCondOps` β€” the ones that take no value. */
45
+ nullaryOps: string[];
46
+ maxDepth: number;
47
+ maxChildren: number;
48
+ /** Prefix on the first row. Airtable says "When" at a trigger and nothing inside a group. */
49
+ lead?: string;
50
+ disabled?: boolean;
51
+ }
52
+
53
+ /**
54
+ * The WORDING of an operator, never the LIST of them.
55
+ *
56
+ * ⚠ The keys are the engine's `LANE_OPS` and the fallback is the key itself: an operator this
57
+ * table has not heard of renders as `>=` rather than as a blank cell. Ugly beats invisible β€”
58
+ * a blank is indistinguishable from a bug and nobody reports the row they cannot see.
59
+ * (Booked in the B mailbox as a small ask: `condOpLabels` on the wire would delete this map.)
60
+ */
61
+ const OP_WORDS: Record<string, string> = {
62
+ "=": "is",
63
+ "!=": "is not",
64
+ ">": "is greater than",
65
+ ">=": "is at least",
66
+ "<": "is less than",
67
+ "<=": "is at most",
68
+ includes: "contains",
69
+ not_includes: "does not contain",
70
+ is_empty: "is empty",
71
+ is_not_empty: "is not empty",
72
+ };
73
+
74
+ export function opWord(op: string): string {
75
+ return OP_WORDS[op] || op;
76
+ }
77
+
78
+ /** A fresh leaf, using the FIRST operator the server offered rather than a hard-coded "=". */
79
+ function newLeaf(ops: string[]): Cond {
80
+ return { field: "", op: ops[0] || "", value: "" };
81
+ }
82
+
83
+ /**
84
+ * ⭐ ONE CHILD IS STORED AS A BARE LEAF, not as a group of one.
85
+ *
86
+ * Both are legal (`cond_match` dispatches on shape), and the engine's own note says a stored
87
+ * leaf is never rewritten at rest. Emitting the leaf keeps a one-condition trigger byte-identical
88
+ * to what wave 22 wrote, so opening an old automation in this editor and saving it unchanged
89
+ * does not produce a diff β€” a save that silently reshapes stored data is how "I only looked at
90
+ * it" turns into a migration nobody reviewed.
91
+ */
92
+ function pack(join: "all" | "any", children: Cond[]): Cond | null {
93
+ if (!children.length) return null;
94
+ if (children.length === 1 && !isCondGroup(children[0])) return children[0];
95
+ return join === "all" ? { all: children } : { any: children };
96
+ }
97
+
98
+ /** Is every leaf in this tree answerable? Drives the red line, never the Save button. */
99
+ export function condComplete(cond: Cond | null | undefined, nullaryOps: string[]): boolean {
100
+ if (!cond) return true;
101
+ if (isCondGroup(cond)) {
102
+ const { children } = groupParts(cond);
103
+ return children.length > 0 && children.every((c) => condComplete(c, nullaryOps));
104
+ }
105
+ const leaf = cond as { field: string; op: string; value?: string | number };
106
+ if (!leaf.field || !leaf.op) return false;
107
+ if (nullaryOps.includes(leaf.op)) return true;
108
+ return leaf.value !== undefined && String(leaf.value).trim() !== "";
109
+ }
110
+
111
+ export default function CondBuilder({
112
+ cond,
113
+ onChange,
114
+ fields,
115
+ ops,
116
+ nullaryOps,
117
+ maxDepth,
118
+ maxChildren,
119
+ lead = "When",
120
+ disabled,
121
+ }: Props) {
122
+ // The editor always works on a GROUP even when one leaf is stored β€” a list of one is still a
123
+ // list, and `pack` puts it back the way it was found.
124
+ const { join, children } = isCondGroup(cond)
125
+ ? groupParts(cond)
126
+ : { join: "all" as const, children: cond ? [cond] : [] };
127
+
128
+ const emit = (nextJoin: "all" | "any", next: Cond[]) => onChange(pack(nextJoin, next));
129
+ const replace = (i: number, next: Cond) =>
130
+ emit(join, children.map((c, j) => (j === i ? next : c)));
131
+
132
+ return (
133
+ <div className="autoc-tree">
134
+ {children.map((child, i) => (
135
+ /*
136
+ ⚠ THE KEY CARRIES THE ROW COUNT, and that is what makes the uncontrolled value input
137
+ above safe. Keyed by index alone, removing row 0 would REUSE row 0's DOM node for what
138
+ used to be row 1 β€” and an uncontrolled input keeps its own DOM value, so the reader
139
+ would be looking at the deleted row's text under the surviving row's field. Changing
140
+ the count remounts the whole list, which discards every stale DOM value at exactly the
141
+ moment the structure changes.
142
+ */
143
+ <div className="autoc-row" key={`${children.length}:${i}`}>
144
+ <button
145
+ type="button"
146
+ className="autoc-drop"
147
+ disabled={disabled}
148
+ aria-label="Remove this condition"
149
+ title="Remove this condition"
150
+ onClick={() => emit(join, children.filter((_c, j) => j !== i))}
151
+ >
152
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
153
+ <path
154
+ d="M4 4l8 8M12 4l-8 8"
155
+ stroke="currentColor"
156
+ strokeWidth="1.5"
157
+ strokeLinecap="round"
158
+ />
159
+ </svg>
160
+ </button>
161
+
162
+ {/*
163
+ THE JOIN IS THE GROUP'S, AND IT IS EDITED IN EXACTLY ONE PLACE. Row 2 carries the
164
+ dropdown; every later row prints the word it chose. Giving each row its own
165
+ and/or select would offer a mixed tree the shape cannot express β€” `{all: […]}` has
166
+ one join β€” and the user would discover that only when their third row silently
167
+ behaved like the second's.
168
+ */}
169
+ <span className="autoc-join">
170
+ {i === 0 ? (
171
+ lead
172
+ ) : i === 1 ? (
173
+ <select
174
+ className="auto-input is-tiny"
175
+ value={join}
176
+ disabled={disabled}
177
+ aria-label="Match all or any of these conditions"
178
+ onChange={(e) => emit(e.target.value === "any" ? "any" : "all", children)}
179
+ >
180
+ <option value="all">and</option>
181
+ <option value="any">or</option>
182
+ </select>
183
+ ) : (
184
+ <span className="autoc-join-word">{join === "all" ? "and" : "or"}</span>
185
+ )}
186
+ </span>
187
+
188
+ {isCondGroup(child) ? (
189
+ <div className="autoc-nest">
190
+ <CondBuilder
191
+ cond={child}
192
+ onChange={(next) =>
193
+ next
194
+ ? replace(i, next)
195
+ : emit(join, children.filter((_c, j) => j !== i))
196
+ }
197
+ fields={fields}
198
+ ops={ops}
199
+ nullaryOps={nullaryOps}
200
+ maxDepth={maxDepth - 1}
201
+ maxChildren={maxChildren}
202
+ lead=""
203
+ {...(disabled ? { disabled: true } : {})}
204
+ />
205
+ </div>
206
+ ) : (
207
+ <Leaf
208
+ leaf={child as { field: string; op: string; value?: string | number }}
209
+ fields={fields}
210
+ ops={ops}
211
+ nullaryOps={nullaryOps}
212
+ {...(disabled ? { disabled: true } : {})}
213
+ onChange={(next) => replace(i, next)}
214
+ />
215
+ )}
216
+ </div>
217
+ ))}
218
+
219
+ <div className="autoc-adds">
220
+ <button
221
+ type="button"
222
+ className="autoc-add"
223
+ disabled={disabled || children.length >= maxChildren}
224
+ title={
225
+ children.length >= maxChildren
226
+ ? `This group holds at most ${maxChildren} conditions.`
227
+ : undefined
228
+ }
229
+ onClick={() => emit(join, [...children, newLeaf(ops)])}
230
+ >
231
+ + Add condition
232
+ </button>
233
+ {/* Nesting is offered only while the SERVER's depth allows it β€” the ceiling rides the
234
+ payload, so this control disappears at the same depth `clean_cond` refuses. */}
235
+ {maxDepth > 1 ? (
236
+ <button
237
+ type="button"
238
+ className="autoc-add"
239
+ disabled={disabled || children.length >= maxChildren}
240
+ onClick={() => emit(join, [...children, { all: [newLeaf(ops)] }])}
241
+ >
242
+ + Add condition group
243
+ </button>
244
+ ) : null}
245
+ </div>
246
+
247
+ {/* THE RED LINE (image 4). A statement about the tree, not a wall in front of Save. */}
248
+ {!condComplete(pack(join, children), nullaryOps) ? (
249
+ <p className="autoc-bad">A condition is incomplete or invalid.</p>
250
+ ) : null}
251
+ </div>
252
+ );
253
+ }
254
+
255
+ function Leaf({
256
+ leaf,
257
+ fields,
258
+ ops,
259
+ nullaryOps,
260
+ disabled,
261
+ onChange,
262
+ }: {
263
+ leaf: { field: string; op: string; value?: string | number };
264
+ fields: Field[];
265
+ ops: string[];
266
+ nullaryOps: string[];
267
+ disabled?: boolean;
268
+ onChange: (next: Cond) => void;
269
+ }) {
270
+ const nullary = nullaryOps.includes(leaf.op);
271
+ return (
272
+ <>
273
+ <select
274
+ className="auto-input is-tiny"
275
+ value={leaf.field}
276
+ disabled={disabled}
277
+ aria-label="Field"
278
+ onChange={(e) => onChange({ ...leaf, field: e.target.value })}
279
+ >
280
+ <option value="">Choose a field…</option>
281
+ {/*
282
+ ⚠ THE STORED VALUE IS ALWAYS AN OPTION. A <select> whose `value` matches no <option>
283
+ renders the FIRST one, so a condition on a column this list has not caught up with
284
+ would LOOK like a condition on a different column β€” and the next Save would write that
285
+ different column without anybody choosing it. This repo has paid for that twice
286
+ ([[cg-condition-builder-items]]).
287
+ */}
288
+ {leaf.field && !fields.some((f) => f.key === leaf.field) ? (
289
+ <option value={leaf.field}>{leaf.field} (not in this database)</option>
290
+ ) : null}
291
+ {fields.map((f) => (
292
+ <option key={f.key} value={f.key}>
293
+ {f.label}
294
+ </option>
295
+ ))}
296
+ </select>
297
+
298
+ <select
299
+ className="auto-input is-tiny"
300
+ value={leaf.op}
301
+ disabled={disabled}
302
+ aria-label="Comparison"
303
+ onChange={(e) => {
304
+ const op = e.target.value;
305
+ // Moving to a value-free comparison DROPS the value rather than keeping it out of
306
+ // sight: a stored `value` under `is_empty` is a fact the sentence does not show and
307
+ // the next operator change would silently resurrect.
308
+ onChange(nullaryOps.includes(op) ? { field: leaf.field, op } : { ...leaf, op });
309
+ }}
310
+ >
311
+ {leaf.op && !ops.includes(leaf.op) ? (
312
+ <option value={leaf.op}>{opWord(leaf.op)} (not offered here)</option>
313
+ ) : null}
314
+ {ops.map((op) => (
315
+ <option key={op} value={op}>
316
+ {opWord(op)}
317
+ </option>
318
+ ))}
319
+ </select>
320
+
321
+ {nullary ? null : (
322
+ /*
323
+ β›” FREE TEXT COMMITS ON BLUR, like every other free-text field in this builder β€” and
324
+ the controlled version this replaced was worse than merely chatty. `onChange` reached
325
+ `patchTrigger` β†’ PATCH, so typing "New" fired THREE requests; the displayed value
326
+ could not advance until each round-trip landed; and `disabled={busy}` disabled the
327
+ input under the typist's fingers. The two selects beside it stay controlled because
328
+ each is ONE discrete decision, which is exactly the distinction the cron field made
329
+ (AutomationTrigger's note) and the reason this input is not one.
330
+ */
331
+ <input
332
+ className="auto-input is-tiny autoc-value"
333
+ defaultValue={leaf.value === undefined ? "" : String(leaf.value)}
334
+ disabled={disabled}
335
+ aria-label="Value"
336
+ placeholder="Value"
337
+ onBlur={(e) => {
338
+ if (e.target.value !== String(leaf.value ?? "")) onChange({ ...leaf, value: e.target.value });
339
+ }}
340
+ />
341
+ )}
342
+ </>
343
+ );
344
+ }
web/src/automation/automationApi.ts CHANGED
@@ -100,7 +100,31 @@ export interface Automation {
100
  * definition can express are `schedule.enabled` true/false, and the picker
101
  * derives those rather than inventing a third answer.
102
  */
103
- trigger?: { key: string; ready?: boolean; needs?: string };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  /**
105
  * The review gate's decisions, newest first (<=100). A promotion is a person's
106
  * judgement written into a record, so it says WHO β€” the same reason every count in
@@ -158,6 +182,13 @@ export interface AutomationList {
158
  * controls that already exist rather than rendering an empty dropdown.
159
  */
160
  triggers?: TriggerOption[];
 
 
 
 
 
 
 
161
  storeAvailable: boolean;
162
  }
163
 
@@ -173,6 +204,111 @@ export interface TriggerOption {
173
  ready: boolean;
174
  /** What it is waiting for, e.g. `"connect_gmail"`. Rendered as a next step, never as an error. */
175
  needs?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  }
177
 
178
  export interface OAuthConnection {
@@ -190,28 +326,19 @@ export interface OAuthConnection {
190
  */
191
  export type OAuthStatus = Record<string, OAuthConnection | undefined>;
192
 
193
- /**
194
- * A trigger's `needs` token β†’ the connector it is waiting on.
195
  *
196
- * ⚠ THE ONE PLACE A SERVER TOKEN BECOMES A ROUTE, and it is deliberately tiny and
197
- * deliberately FAIL-QUIET-BUT-VISIBLE: an unknown token resolves to "", the connect
198
- * BUTTON is not drawn, and the not-configured state is still stated in words. A
199
- * button that navigates nowhere is worse than no button β€” it reads as a broken
200
- * product rather than an unfinished one.
 
201
  *
202
- * DEBT: this table disappears the day a `TriggerOption` carries its own provider or
203
- * start URL. Booked in the wave-22 mailbox as a question for the engine session.
204
  */
205
- const CONNECT_PROVIDERS: Record<string, string> = { connect_gmail: "google" };
206
-
207
- export function oauthProviderFor(needs: string): string {
208
- return CONNECT_PROVIDERS[String(needs || "")] || "";
209
- }
210
-
211
- export function oauthStartUrlFor(needs: string): string {
212
- const provider = oauthProviderFor(needs);
213
- return provider ? `${API_V1}/oauth/${provider}/start` : "";
214
- }
215
 
216
  export interface TickState {
217
  enabled: boolean;
@@ -312,6 +439,13 @@ export interface UserTable {
312
  source: string;
313
  rowCount: number;
314
  fields: { key: string; label: string; type: string; automation?: unknown }[];
 
 
 
 
 
 
 
315
  }
316
 
317
  export interface SourcePreview {
@@ -422,6 +556,16 @@ export interface BoardCard {
422
  /** The SERVER's answer to "may this move", not a permission the client derives. */
423
  canAdvance: boolean;
424
  next: string[];
 
 
 
 
 
 
 
 
 
 
425
  }
426
 
427
  export interface Board {
@@ -444,13 +588,25 @@ export interface Board {
444
  truncated?: string[];
445
  }
446
 
447
- /** One human decision at a review gate β€” R7's approval posture, audited (A2(5)). */
 
 
 
 
 
 
448
  export interface ReviewEntry {
449
  ts: string;
450
  user: string;
451
  rowId: string;
452
  from: string;
453
  to: string;
 
 
 
 
 
 
454
  }
455
 
456
  export class AutomationError extends Error {
 
100
  * definition can express are `schedule.enabled` true/false, and the picker
101
  * derives those rather than inventing a third answer.
102
  */
103
+ trigger?: {
104
+ key: string;
105
+ ready?: boolean;
106
+ needs?: string;
107
+ /** ⚠ STORED INERT, NOT REFUSED (A3): the picker writes `{key}` first and the table/field
108
+ * after, so an unfinished trigger is a STATE the builder finishes β€” never a snap-back to
109
+ * Manual. It cannot fire while incomplete, which is the fail-closed direction. */
110
+ configured?: boolean;
111
+ paused?: boolean;
112
+ enabled?: boolean;
113
+ table?: string;
114
+ field?: string;
115
+ fields?: string[];
116
+ viewId?: string;
117
+ query?: string;
118
+ token?: string;
119
+ formToken?: string;
120
+ when?: Cond | null;
121
+ };
122
+ /**
123
+ * WAVE 23 / C4 β€” the ACTIONS this automation runs, and how it ends (C5). Defaulted
124
+ * server-side to `{actions: [], ending: {mode: "terminal"}}`, so an automation written before
125
+ * this wave reads as "no actions, stops at the end" rather than as a missing key.
126
+ */
127
+ flow?: Flow;
128
  /**
129
  * The review gate's decisions, newest first (<=100). A promotion is a person's
130
  * judgement written into a record, so it says WHO β€” the same reason every count in
 
182
  * controls that already exist rather than rendering an empty dropdown.
183
  */
184
  triggers?: TriggerOption[];
185
+ /**
186
+ * WAVE 23 / C4 β€” the action menu and the builder's ceilings. OPTIONAL for the reason every
187
+ * other server vocabulary here is: absent is a third state (a server older than this client),
188
+ * and the builder prints what it has rather than inventing a menu.
189
+ */
190
+ actionsCatalog?: ActionCatalogRow[];
191
+ flow?: FlowVocab;
192
  storeAvailable: boolean;
193
  }
194
 
 
204
  ready: boolean;
205
  /** What it is waiting for, e.g. `"connect_gmail"`. Rendered as a next step, never as an error. */
206
  needs?: string;
207
+ /**
208
+ * WAVE 23 / R2 β€” DECLARED BUT NOT BUILT (`button_clicked`, `comment_added`). Distinct from
209
+ * `ready:false`, and the difference is what the user can DO about it: a not-ready trigger is
210
+ * waiting on a connection THEY can make, a planned one is waiting on us. Both render; only
211
+ * the first offers an action. The server refuses a planned key at the door
212
+ * (`clean_trigger`), so the faded state is enforced rather than merely displayed.
213
+ */
214
+ planned?: boolean;
215
+ /**
216
+ * ⭐ THE CONNECT AFFORDANCE, SERVER-COMPOSED (A3(3), wave 23). This replaced a client table
217
+ * that mapped a `needs` token to a provider and hand-built the start URL β€” two facts the
218
+ * OAuth registry already owns, kept in a second place that could disagree with it. `null`
219
+ * (or absent) means there is no route to offer, which the trigger face states in words
220
+ * instead of drawing a button that goes nowhere.
221
+ */
222
+ connect?: { provider: string; startUrl: string } | null;
223
+ }
224
+
225
+ // ── WAVE 23 / contracts C4 + C5: CONDITION TREES, ACTIONS, ENDINGS ───────────────────────────
226
+ //
227
+ // β›” EVERY VOCABULARY BELOW IS `string`, for the reason C1 and C3 already give: a client union
228
+ // over a server enumeration is a list that can go stale, and it goes stale SILENTLY. The engine
229
+ // ships `flow.condOps`, `flow.endingModes`, `flow.reviewDeciders` and `actionsCatalog` on
230
+ // `GET /automations`; the builder renders whatever rides and never a list of its own.
231
+
232
+ /**
233
+ * ONE condition, as a TREE (C4). A leaf is wave 22's lane condition unchanged β€” which is why
234
+ * nothing migrated: a stored leaf IS a valid tree, and the engine dispatches on shape.
235
+ * Depth and breadth are the SERVER's numbers (`flow.maxCondDepth` / `maxCondChildren`), read
236
+ * from the payload rather than repeated here.
237
+ */
238
+ export type Cond =
239
+ | { field: string; op: string; value?: string | number }
240
+ | { all: Cond[] }
241
+ | { any: Cond[] };
242
+
243
+ export function isCondGroup(c: Cond | null | undefined): c is { all: Cond[] } | { any: Cond[] } {
244
+ return !!c && (Array.isArray((c as { all?: Cond[] }).all)
245
+ || Array.isArray((c as { any?: Cond[] }).any));
246
+ }
247
+
248
+ /** A group's join word and its children, without the caller re-testing both keys every time. */
249
+ export function groupParts(c: Cond): { join: "all" | "any"; children: Cond[] } {
250
+ const all = (c as { all?: Cond[] }).all;
251
+ return Array.isArray(all) ? { join: "all", children: all }
252
+ : { join: "any", children: (c as { any: Cond[] }).any || [] };
253
+ }
254
+
255
+ /**
256
+ * One step of a flow (C4). `config` is per-kind and deliberately loose here β€” the engine
257
+ * validates it and REFUSES rather than coercing, so a client-side shape would be a second
258
+ * validator free to disagree with the one that decides.
259
+ */
260
+ export interface Action {
261
+ id: string;
262
+ kind: string;
263
+ enabled: boolean;
264
+ /** Run this action only when the walking record matches. `null` = always. */
265
+ when?: Cond | null;
266
+ config: Record<string, unknown>;
267
+ }
268
+
269
+ /** `flow.ending` (C5) β€” what happens to a record that reaches the end. */
270
+ export interface FlowEnding {
271
+ mode: string;
272
+ hours?: number;
273
+ }
274
+
275
+ export interface Flow {
276
+ actions: Action[];
277
+ ending: FlowEnding;
278
+ }
279
+
280
+ /**
281
+ * A row of the ACTION MENU (image 7/8's grouped list). `group` is the section heading and
282
+ * `ready:false` paints the card faded WITH the server's own reason β€” the owner asked for the
283
+ * full menu, and a shorter one would imply the missing actions do not exist. `clean_actions`
284
+ * refuses an unready kind, so faded is a wall rather than a styling choice.
285
+ */
286
+ export interface ActionCatalogRow {
287
+ kind: string;
288
+ label: string;
289
+ group: string;
290
+ ready: boolean;
291
+ detail: string;
292
+ }
293
+
294
+ /** The builder's own ceilings, server-declared so the client never hard-codes the same numbers. */
295
+ export interface FlowVocab {
296
+ condOps: string[];
297
+ nullaryCondOps: string[];
298
+ maxCondDepth: number;
299
+ maxCondChildren: number;
300
+ maxGroupDepth: number;
301
+ maxActions: number;
302
+ endingModes: string[];
303
+ maxResetHours: number;
304
+ reviewDeciders: string[];
305
+ /**
306
+ * ⚠ PER-DEPLOYMENT, AND HONEST RATHER THAN DECORATIVE. With no LLM key configured the engine
307
+ * holds every card for a human β€” so an enabled-looking "decided by AI" control would promise
308
+ * a decision nothing will make. False renders it as not-configured (C6's fail-closed path,
309
+ * made visible).
310
+ */
311
+ aiReady: boolean;
312
  }
313
 
314
  export interface OAuthConnection {
 
326
  */
327
  export type OAuthStatus = Record<string, OAuthConnection | undefined>;
328
 
329
+ /*
330
+ * β›” `CONNECT_PROVIDERS` / `oauthProviderFor` / `oauthStartUrlFor` ARE GONE (D-43, wave 23).
331
  *
332
+ * They were a client table mapping a server `needs` token ("connect_gmail") to a provider
333
+ * ("google") and composing `/oauth/google/start` by hand β€” a second copy of the OAuth
334
+ * registry, booked as debt on the day it shipped with the resolution written into it: "this
335
+ * table disappears the day a `TriggerOption` carries its own provider or start URL." It does
336
+ * now (`TriggerOption.connect`, composed by `routes_automation._triggers_vocab`), so the copy
337
+ * is deleted rather than left to drift the first time a second provider lands.
338
  *
339
+ * The FAIL-QUIET-BUT-VISIBLE behaviour it was built for is unchanged and now belongs to the
340
+ * server: no `connect` means no button, and the not-configured state is still said in words.
341
  */
 
 
 
 
 
 
 
 
 
 
342
 
343
  export interface TickState {
344
  enabled: boolean;
 
439
  source: string;
440
  rowCount: number;
441
  fields: { key: string; label: string; type: string; automation?: unknown }[];
442
+ /**
443
+ * The table's saved views β€” what `enters_view` names (C3). ABSENT until the server sends it,
444
+ * and absent is a third state, not an empty list: the trigger's view control says the list was
445
+ * not offered rather than rendering an empty picker that looks like "this table has no views".
446
+ * (Asked of session A in the wave mailbox; the control reads this key the day it rides.)
447
+ */
448
+ views?: { id: string; label: string }[];
449
  }
450
 
451
  export interface SourcePreview {
 
556
  /** The SERVER's answer to "may this move", not a permission the client derives. */
557
  canAdvance: boolean;
558
  next: string[];
559
+ /**
560
+ * WAVE 23 / C5 β€” how many times this record has been round the flow. The count lives in the
561
+ * machine cell `stage_<autoId>_cycles` and the engine increments it on every reset.
562
+ *
563
+ * ⚠ NOT ON THE CARD WIRE YET (asked of session A in the wave mailbox). Absent means "not
564
+ * reported", NOT "once": the badge only draws from 2 upwards, so a missing key renders
565
+ * nothing at all rather than a claim that every record is on its first pass β€” which is
566
+ * exactly the measurement nobody made. It lights up the day the key rides.
567
+ */
568
+ cycles?: number;
569
  }
570
 
571
  export interface Board {
 
588
  truncated?: string[];
589
  }
590
 
591
+ /**
592
+ * One decision at a review gate β€” R7's approval posture, audited (A2(5)).
593
+ *
594
+ * ⭐ WAVE 23 / C6 (R4/R14): a decision may now be made by a MODEL, and the audit says so. One
595
+ * log, not two β€” a reader asking "who decided this" gets one answer whether it was a person or
596
+ * a provider, which is the only version that can be trusted at all.
597
+ */
598
  export interface ReviewEntry {
599
  ts: string;
600
  user: string;
601
  rowId: string;
602
  from: string;
603
  to: string;
604
+ /** `"user"` | `"ai"`. Absent on entries written before this wave β€” render as a person. */
605
+ by?: string;
606
+ /** The one-line reason. On an AI decision it is the model's own; never invented here. */
607
+ note?: string;
608
+ /** Which model decided. Present only on AI entries, and shown so the reason has provenance. */
609
+ model?: string;
610
  }
611
 
612
  export class AutomationError extends Error {
web/src/connectors/ConnectorsPage.tsx ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // connectors/ConnectorsPage.tsx β€” WAVE 23 item 10 (ruling R8, contract C11).
3
+ //
4
+ // The Claude-style directory: a search box and a card grid, laid out from
5
+ // `reference/Claude Connectors.png` and painted in OUR tokens. Every card is a
6
+ // row of `GET /api/v1/connectors/directory` (SESSION A's route) β€” this page
7
+ // invents nothing, which is what lets `#/connectors` be a CHROME route without
8
+ // touching the shell's "an undeclared surface is denied" law (nav.ts'
9
+ // `CHROME_ROUTES` note carries the full argument).
10
+ //
11
+ // β›” NO THIRD-PARTY LOGOS AND NO EMOJIS (R8 + DESIGN.md Β§4). A connector's mark is
12
+ // a two-letter tile in the chip family every database already wears, so the
13
+ // directory reads as part of this product rather than as a wall of other
14
+ // companies' branding β€” and nobody has to ship, license or update an icon set.
15
+ //
16
+ // ⚠ NO CLIENT UNION OVER `state` OR `kind`. They are the server's vocabulary and
17
+ // arrive as strings (the wave-9 law): a client `type State = "connected" | …`
18
+ // turns "the server added a state" into "the client drops the card".
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { useEffect, useState } from "react";
22
+ import { API_V1, CREDENTIALS } from "../apiContract";
23
+
24
+ /** One row of the directory. Everything optional but `key` + `label` is genuinely optional on
25
+ * the wire β€” a builtin has no provider, a planned connector has no `connectedAs`. */
26
+ export interface ConnectorRow {
27
+ key: string;
28
+ label: string;
29
+ desc: string;
30
+ /** `connected` | `available` | `planned` β€” read as a STRING, never a union. */
31
+ state: string;
32
+ /** `builtin` | `oauth` | `token`. */
33
+ kind: string;
34
+ provider?: string;
35
+ /** What is missing before it can connect β€” shown on the card, never invented here. */
36
+ needs?: string;
37
+ /** The account the connection is made as, when the server chooses to say. */
38
+ connectedAs?: string;
39
+ /**
40
+ * β›” A DESTINATION WORD, NOT A URL β€” `"keychain" | "oauth" | "automation" | ""`.
41
+ *
42
+ * This page was first written against C11's prose, which reads as though `manage` were a link,
43
+ * and it rendered `<a href={row.manage}>` β€” i.e. `href="keychain"`, a relative link to a page
44
+ * that does not exist, on every Odoo and token card. It compiled, it looked right, and it went
45
+ * nowhere. Corrected against the real payload (`routes_connectors.directory`): the server
46
+ * names WHERE the action lives and this page decides how to open it, which is the right split β€”
47
+ * the door for a keychain entry is a modal the shell owns and no URL could express it.
48
+ *
49
+ * Empty for a NON-ADMIN, deliberately (the route's own note): the card renders without an
50
+ * action rather than offering a door that would 403.
51
+ */
52
+ manage?: string;
53
+ /** OAuth only: the URL that starts the flow. This IS a link, and it is a different field. */
54
+ startUrl?: string;
55
+ /** A token connector's honest one-liner: the key is stored, the data arrives later (R13). */
56
+ note?: string;
57
+ /** What the admin will be asked for β€” shown where the decision is made, not in a tour. */
58
+ hint?: string;
59
+ }
60
+
61
+ type Load =
62
+ | { phase: "loading" }
63
+ | { phase: "ready"; rows: ConnectorRow[] }
64
+ | { phase: "error"; message: string };
65
+
66
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
67
+
68
+ export function parseDirectory(body: unknown): ConnectorRow[] {
69
+ const raw = (body as { connectors?: unknown } | null)?.connectors;
70
+ if (!Array.isArray(raw)) return [];
71
+ const out: ConnectorRow[] = [];
72
+ for (const item of raw) {
73
+ if (!item || typeof item !== "object") continue;
74
+ const c = item as Record<string, unknown>;
75
+ const key = str(c.key).trim();
76
+ const label = str(c.label).trim();
77
+ // A card with no name is a tile with no sign on it β€” dropped rather than rendered from its
78
+ // key, which would put an internal identifier on screen (`parsePages`' own rule).
79
+ if (!key || !label) continue;
80
+ out.push({
81
+ key,
82
+ label,
83
+ desc: str(c.desc),
84
+ state: str(c.state) || "available",
85
+ kind: str(c.kind),
86
+ ...(str(c.provider) ? { provider: str(c.provider) } : {}),
87
+ ...(str(c.needs) ? { needs: str(c.needs) } : {}),
88
+ ...(str(c.connectedAs) ? { connectedAs: str(c.connectedAs) } : {}),
89
+ ...(str(c.manage) ? { manage: str(c.manage) } : {}),
90
+ ...(str(c.startUrl) ? { startUrl: str(c.startUrl) } : {}),
91
+ ...(str(c.note) ? { note: str(c.note) } : {}),
92
+ ...(str(c.hint) ? { hint: str(c.hint) } : {}),
93
+ });
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /** The card's mark: two letters from the connector's own name, in the database chip family.
99
+ * Unicode-aware for the same reason the account monogram is (`Shell.tsx:299`). */
100
+ export function tileText(label: string): string {
101
+ const chars = String(label ?? "").match(/[\p{L}\p{N}]/gu) ?? [];
102
+ return chars.slice(0, 2).join("").toUpperCase();
103
+ }
104
+
105
+ /** Search over the two things a person actually reads. Case-folded substring: this list is a
106
+ * couple of dozen rows, and a ranking function over a set you can see all of is noise. */
107
+ export function matches(row: ConnectorRow, query: string): boolean {
108
+ const q = query.trim().toLowerCase();
109
+ if (!q) return true;
110
+ return (
111
+ row.label.toLowerCase().includes(q) ||
112
+ row.desc.toLowerCase().includes(q) ||
113
+ row.key.toLowerCase().includes(q)
114
+ );
115
+ }
116
+
117
+ /**
118
+ * The chip's tone for a state.
119
+ *
120
+ * ⚠ FIVE STATES ARRIVE, NOT THREE. C11's prose names `connected | available | planned`; the real
121
+ * payload also sends `reconnect` (the token expired) and `unconfigured` (nobody registered the
122
+ * client on this deployment) β€” a distinction the route's own comment calls the point of the
123
+ * change. Read as strings and mapped here, so a SIXTH state renders in the neutral tone rather
124
+ * than dropping the card (the wave-9 law: no client union over a server vocabulary).
125
+ */
126
+ export function stateTone(state: string): string {
127
+ if (state === "connected") return " is-on";
128
+ if (state === "available" || state === "reconnect") return " is-open";
129
+ return ""; // planned, unconfigured, and anything new
130
+ }
131
+
132
+ export default function ConnectorsPage({
133
+ onKeychain,
134
+ }: {
135
+ /**
136
+ * `manage: "keychain"` β€” open Settings on the Keychains tab.
137
+ *
138
+ * β›” A CALLBACK, NOT A LINK, because the destination is a MODAL the shell owns and no URL can
139
+ * express it. REQUIRED rather than optional: an optional handler the frame forgot to pass
140
+ * degrades to "the Connect button does nothing", which is indistinguishable from a feature
141
+ * that was never built and goes red in no gate (this repo's most expensive lesson).
142
+ */
143
+ onKeychain: () => void;
144
+ }) {
145
+ const [load, setLoad] = useState<Load>({ phase: "loading" });
146
+ const [query, setQuery] = useState("");
147
+
148
+ useEffect(() => {
149
+ let dead = false;
150
+ void (async () => {
151
+ try {
152
+ const res = await fetch(`${API_V1}/connectors/directory`, {
153
+ credentials: CREDENTIALS,
154
+ });
155
+ const body = (await res.json().catch(() => null)) as
156
+ | { error?: { message?: string } }
157
+ | null;
158
+ if (dead) return;
159
+ if (!res.ok) {
160
+ setLoad({
161
+ phase: "error",
162
+ message: body?.error?.message || `The server answered ${res.status}.`,
163
+ });
164
+ return;
165
+ }
166
+ setLoad({ phase: "ready", rows: parseDirectory(body) });
167
+ } catch {
168
+ if (!dead) setLoad({ phase: "error", message: "Cannot reach the server." });
169
+ }
170
+ })();
171
+ return () => {
172
+ dead = true;
173
+ };
174
+ }, []);
175
+
176
+ const rows = load.phase === "ready" ? load.rows.filter((r) => matches(r, query)) : [];
177
+
178
+ return (
179
+ <div className="shell-conn">
180
+ <h1 className="conn-title">Connectors</h1>
181
+
182
+ <input
183
+ className="conn-search"
184
+ placeholder="Search connectors"
185
+ value={query}
186
+ maxLength={60}
187
+ onChange={(e) => setQuery(e.target.value)}
188
+ />
189
+
190
+ {load.phase === "loading" ? (
191
+ <div className="shell-loading">
192
+ <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
193
+ </div>
194
+ ) : null}
195
+ {load.phase === "error" ? (
196
+ // Honest, and it names the retry. Never a hard-coded list of connectors as a fallback:
197
+ // that would put sources on screen this workspace may not have (the nav's own rule,
198
+ // applied to a second server-composed payload).
199
+ <p className="conn-note">{load.message}</p>
200
+ ) : null}
201
+ {load.phase === "ready" && rows.length === 0 ? (
202
+ <p className="conn-note">
203
+ {query ? "No connector matches that." : "No connectors are available yet."}
204
+ </p>
205
+ ) : null}
206
+
207
+ <div className="conn-grid">
208
+ {rows.map((row) => {
209
+ const planned = row.state === "planned";
210
+ const connected = row.state === "connected";
211
+ return (
212
+ <div
213
+ key={row.key}
214
+ className={"conn-card" + (planned ? " is-planned" : "")}
215
+ // β›” `aria-disabled`, NOT `inert`/`pointer-events` alone. A planned card is
216
+ // non-interactive by R8, and a card that merely LOOKS faded while still taking a
217
+ // click is the painted-but-unclickable failure in reverse
218
+ // ([[ui-invisible-to-assertions]]). It carries no controls at all, so there is
219
+ // nothing to disable β€” the attribute is what says so to a screen reader.
220
+ {...(planned ? { "aria-disabled": true } : {})}
221
+ >
222
+ <div className="conn-card-head">
223
+ <span className="shell-db-chip conn-tile" aria-hidden="true">
224
+ {tileText(row.label)}
225
+ </span>
226
+ <span className="conn-card-name">{row.label}</span>
227
+ <span className={"conn-state" + stateTone(row.state)}>
228
+ {/* The server's own word, sentence-cased by CSS never by `toUpperCase` β€” R6
229
+ bans caps in app chrome, and a chip is chrome. */}
230
+ {row.state}
231
+ </span>
232
+ </div>
233
+ <p className="conn-card-desc">{row.desc}</p>
234
+ {connected && row.connectedAs ? (
235
+ <p className="conn-card-meta">Connected as {row.connectedAs}</p>
236
+ ) : null}
237
+ {/* ⚠ `needs` IS SHOWN WHATEVER THE STATE, and that is a correction: gating it on
238
+ "not connected" hid the one line that explains an `unconfigured` row, which is
239
+ the state it exists for ("the owner registers the client"). Only `planned`
240
+ suppresses it, because "not built yet" beside a card already marked planned is
241
+ the same sentence twice. */}
242
+ {!planned && row.needs ? (
243
+ <p className="conn-card-meta">{row.needs}</p>
244
+ ) : null}
245
+ {/* R13's honesty line for a token connector: the key is stored, the data arrives
246
+ later. Shown ON the card where the decision is, never in a paragraph. */}
247
+ {!planned && row.note ? <p className="conn-card-meta">{row.note}</p> : null}
248
+ {/* ── the ACTION. `manage` names WHERE it lives; this decides how to open it.
249
+ Every door here already existed β€” an OAuth start URL, the keychain dialog,
250
+ the automation surface β€” which is exactly why this page can be chrome: it
251
+ mints no new credential path (C11). An empty `manage` (a non-admin) renders
252
+ NO control rather than one that would 403. */}
253
+ {!planned && row.manage === "oauth" && row.startUrl ? (
254
+ <a className="conn-card-go" href={row.startUrl}>
255
+ {row.state === "reconnect" ? "Reconnect" : "Connect"}
256
+ </a>
257
+ ) : null}
258
+ {!planned && row.manage === "keychain" ? (
259
+ <button type="button" className="conn-card-go" onClick={onKeychain}>
260
+ {connected ? "Manage keys" : "Add keys"}
261
+ </button>
262
+ ) : null}
263
+ {!planned && row.manage === "automation" ? (
264
+ <a className="conn-card-go" href="#/automation">
265
+ Open automations
266
+ </a>
267
+ ) : null}
268
+ </div>
269
+ );
270
+ })}
271
+ </div>
272
+ </div>
273
+ );
274
+ }
web/src/customer-grid/ColumnMenu.tsx CHANGED
@@ -3,8 +3,8 @@ import { AnchoredOverlay } from "./OverlaySurface";
3
  import type { AnchorRect } from "./OverlaySurface";
4
  import { FieldTypeIcon, MenuLabel } from "./icons";
5
  import { FieldSelectButton } from "./FieldSelect";
6
- import { CREATABLE_TYPES, choiceOptions, choiceRenames, directionLabel, parseOptions, ratingMax }
7
- from "./types";
8
  import type { Field, FieldFormat, FieldScope, FieldType, Measure, Viewer } from "./types";
9
  import type { WindowSpec } from "./windows";
10
  import { normalizeWindow, windowLabel } from "./windows";
@@ -1235,7 +1235,16 @@ export default function ColumnMenu({
1235
  // Item 8c β€” the period draft is valid when it normalizes and differs from what the field
1236
  // already has. Compared through normalizeWindow so `{kind:'ltm'}` and `{kind:'ltm', n:undefined}`
1237
  // read as the same window.
1238
- const typeLineBase = field.measure
 
 
 
 
 
 
 
 
 
1239
  ? "Metric, read-only"
1240
  : field.type === "formula"
1241
  ? "Formula field, computed"
 
3
  import type { AnchorRect } from "./OverlaySurface";
4
  import { FieldTypeIcon, MenuLabel } from "./icons";
5
  import { FieldSelectButton } from "./FieldSelect";
6
+ import { CREATABLE_TYPES, choiceOptions, choiceRenames, directionLabel, isMachineOwned,
7
+ parseOptions, ratingMax } from "./types";
8
  import type { Field, FieldFormat, FieldScope, FieldType, Measure, Viewer } from "./types";
9
  import type { WindowSpec } from "./windows";
10
  import { normalizeWindow, windowLabel } from "./windows";
 
1235
  // Item 8c β€” the period draft is valid when it normalizes and differs from what the field
1236
  // already has. Compared through normalizeWindow so `{kind:'ltm'}` and `{kind:'ltm', n:undefined}`
1237
  // read as the same window.
1238
+ // ⭐ Wave-23 C8 (owner item 4 / R9) β€” FIRST in the chain, because it is the most specific true
1239
+ // thing about the column and the branches below it were saying something false. An
1240
+ // `automation` or `metric` column, and every column an automation SPAWNED (the `automation`
1241
+ // tag), is overlay-sourced and non-derived, so it fell through to "Editable overlay field" β€”
1242
+ // which is what the wash now visibly contradicts. ONE line, no second sentence about what an
1243
+ // automation is (R13 / DESIGN.md Β§4): the person reading a column menu is deciding whether to
1244
+ // type in it, and "filled by automation" is the whole answer.
1245
+ const typeLineBase = isMachineOwned(field)
1246
+ ? "Filled by automation"
1247
+ : field.measure
1248
  ? "Metric, read-only"
1249
  : field.type === "formula"
1250
  ? "Formula field, computed"
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -33,6 +33,7 @@ import type { MeasureSets } from "./useVisibleRows";
33
  import { useGetCellContent } from "./useGetCellContent";
34
  import { useGridSelection } from "./useGridSelection";
35
  import Toolbar from "./Toolbar";
 
36
  import RecordDetail from "./RecordDetail";
37
  import ViewSidebar from "./ViewSidebar";
38
  import ColumnMenu from "./ColumnMenu";
@@ -537,6 +538,14 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
537
  setPicker(null);
538
  requestAnimationFrame(() => gridRef.current?.focus());
539
  }, []);
 
 
 
 
 
 
 
 
540
  /** WAVE 21 item 11 (R10) β€” is "Select records from a list" open? Opened from the view
541
  * rail's "…" and closed by the dialog; the SELECTION it produces outlives it. */
542
  const [selectFromFile, setSelectFromFile] = useState(false);
@@ -1910,6 +1919,18 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
1910
  });
1911
  return;
1912
  }
 
 
 
 
 
 
 
 
 
 
 
 
1913
  // Wave-5 item 11 β€” a URL cell opens its link on click (scheme-guarded: http/https only,
1914
  // a bare domain gets https://). Editing stays with glide's overlay (Enter/double-click).
1915
  if (row.kind === "data" && field?.type === "url") {
@@ -3640,6 +3661,17 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3640
  onCalendarMetrics={setCalendarMetrics}
3641
  />
3642
  );
 
 
 
 
 
 
 
 
 
 
 
3643
  const pickerField = picker ? fieldByKey.get(picker.fieldKey) : undefined;
3644
  // A `user` field's choices come from the HOST's real user list, a `select`'s from its own
3645
  // definition β€” so an assignee is always someone who can log in, and a status is always one of
@@ -4691,6 +4723,21 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
4691
  </AnchoredOverlay>
4692
  )}
4693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4694
  {pickerField && picker && (
4695
  <AnchoredOverlay
4696
  anchor={picker.anchor}
 
33
  import { useGetCellContent } from "./useGetCellContent";
34
  import { useGridSelection } from "./useGridSelection";
35
  import Toolbar from "./Toolbar";
36
+ import JsonViewer from "./JsonViewer";
37
  import RecordDetail from "./RecordDetail";
38
  import ViewSidebar from "./ViewSidebar";
39
  import ColumnMenu from "./ColumnMenu";
 
538
  setPicker(null);
539
  requestAnimationFrame(() => gridRef.current?.focus());
540
  }, []);
541
+ /** ⭐ Wave-23 C7 β€” which `json` cell the big viewer is open on. NOT anchored like the picker
542
+ * above: a document is not a choice list, so it opens as a centred modal (the record drawer's
543
+ * surface) rather than a popover the size of the cell it came from. */
544
+ const [jsonAt, setJsonAt] = useState<{ pid: number; fieldKey: string } | null>(null);
545
+ const closeJson = useCallback(() => {
546
+ setJsonAt(null);
547
+ requestAnimationFrame(() => gridRef.current?.focus());
548
+ }, []);
549
  /** WAVE 21 item 11 (R10) β€” is "Select records from a list" open? Opened from the view
550
  * rail's "…" and closed by the dialog; the SELECTION it produces outlives it. */
551
  const [selectFromFile, setSelectFromFile] = useState(false);
 
1919
  });
1920
  return;
1921
  }
1922
+ // ⭐ Wave-23 C7 β€” a JSON cell opens the big viewer. It is the ONLY door: the cell carries
1923
+ // `allowOverlay:false`, because glide's overlay is a one-line box and one keystroke in the
1924
+ // wrong place inside a 32 KB document turns a well-formed payload into an unparseable one,
1925
+ // saved. Opened for EVERY reader (a document you may not edit is still one you must be
1926
+ // able to read) β€” the viewer takes `onSave` only when the permission verdict allows it,
1927
+ // and the host's write wall is the real one either way.
1928
+ if (row.kind === "data" && field?.type === "json") {
1929
+ event.preventDefault();
1930
+ setActiveCell(cell[0], cell[1]);
1931
+ setJsonAt({ pid: row.record.pid, fieldKey: field.key });
1932
+ return;
1933
+ }
1934
  // Wave-5 item 11 β€” a URL cell opens its link on click (scheme-guarded: http/https only,
1935
  // a bare domain gets https://). Editing stays with glide's overlay (Enter/double-click).
1936
  if (row.kind === "data" && field?.type === "url") {
 
3661
  onCalendarMetrics={setCalendarMetrics}
3662
  />
3663
  );
3664
+ // ⭐ Wave-23 C7 β€” the open document, resolved exactly like the picker's value below it:
3665
+ // the OPTIMISTIC edit wins over the raw record, so a save the server has not echoed yet is
3666
+ // what re-opening the cell shows (the echo-suppression law β€” reading the raw row here would
3667
+ // make a just-saved document appear to revert).
3668
+ const jsonField = jsonAt ? fieldByKey.get(jsonAt.fieldKey) : undefined;
3669
+ const jsonRow = jsonAt ? rawRows.find((r) => r.pid === jsonAt.pid) : undefined;
3670
+ const jsonValue = String(
3671
+ (jsonAt && overlayEdits[jsonAt.pid]?.[jsonAt.fieldKey])
3672
+ ?? (jsonField && jsonRow ? jsonRow[jsonField.key] : "")
3673
+ ?? ""
3674
+ );
3675
  const pickerField = picker ? fieldByKey.get(picker.fieldKey) : undefined;
3676
  // A `user` field's choices come from the HOST's real user list, a `select`'s from its own
3677
  // definition β€” so an assignee is always someone who can log in, and a status is always one of
 
4723
  </AnchoredOverlay>
4724
  )}
4725
 
4726
+ {/* ⭐ Wave-23 C7 β€” the document viewer. `onSave` is OMITTED, not disabled, when the reader
4727
+ may not edit: an absent handler is what makes the raw tab a `<pre>` instead of a
4728
+ textarea, so there is no editor to be refused by. */}
4729
+ {jsonField && jsonAt && (
4730
+ <JsonViewer
4731
+ label={jsonField.label}
4732
+ value={jsonValue}
4733
+ onSave={
4734
+ canEditField(jsonField)
4735
+ ? (next) => patchAndRecord(jsonAt.pid, { [jsonField.key]: next }, "a document")
4736
+ : undefined
4737
+ }
4738
+ onClose={closeJson}
4739
+ />
4740
+ )}
4741
  {pickerField && picker && (
4742
  <AnchoredOverlay
4743
  anchor={picker.anchor}
web/src/customer-grid/JsonViewer.tsx ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / JsonViewer.tsx β€” ⭐ wave-23 C7 (owner item 5 / ruling R5).
3
+ //
4
+ // THE BIG VIEWER a json cell opens on click. One component, reached from two
5
+ // places (the grid cell's click path and the record drawer's value slot), for
6
+ // the reason `assetUrl` is one resolver: two readers of the same document that
7
+ // disagree about how it folds is a bug nobody can see in either one alone.
8
+ //
9
+ // The document is a STRING at rest (the scalar Row contract β€” see the `json`
10
+ // note in types.ts), so everything here is text in and text out. Nothing in
11
+ // this file ever writes a parsed OBJECT back.
12
+ //
13
+ // THREE decisions worth stating, because each has a plausible alternative:
14
+ //
15
+ // 1. THE RAW TAB IS THE EDITOR, and there is no field-by-field one. A form
16
+ // over a document whose shape nobody declared would have to invent that
17
+ // shape from the current value β€” so adding a key would mean editing the
18
+ // form's idea of the schema, and the first machine write with a different
19
+ // shape would silently drop what it did not recognise.
20
+ // 2. PARSE ON SAVE, REFUSE WITH A SENTENCE. Never repair, never re-indent on
21
+ // the way in: `{a:1}` is not JSON and quietly "fixing" it to `{"a":1}`
22
+ // teaches a person their typo was fine, until the day a value cannot be
23
+ // guessed. The host applies the same rule at the write wall (C7), so a
24
+ // refusal here says the same thing the server would.
25
+ // 3. THE TREE IS READ-ONLY and collapses by DEPTH, not by hand-kept state per
26
+ // node beyond what the reader opens. A 200-key payload that arrives fully
27
+ // expanded is a scroll bar, not a document.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
31
+ import type { ReactNode } from "react";
32
+ import { BodyPortal, useOverlayLayer } from "./OverlaySurface";
33
+ import { jsonParse, jsonPretty, MAX_JSON_BYTES } from "./display";
34
+
35
+ /** Bytes, not characters β€” the host's cap is on the stored UTF-8, and a
36
+ * document of 20,000 emoji is 80 KB. Counting `length` would let a value
37
+ * through here that the server then refuses, which is the one refusal a user
38
+ * cannot act on (nothing on screen said it was too big). */
39
+ export function jsonByteLength(text: string): number {
40
+ return typeof TextEncoder === "undefined"
41
+ ? text.length
42
+ : new TextEncoder().encode(text).length;
43
+ }
44
+
45
+ /** The one-line summary in the modal's header: what this document IS. */
46
+ export function jsonSummary(text: string): string {
47
+ const parsed = jsonParse(text);
48
+ const bytes = jsonByteLength(text);
49
+ const size = bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} KB`;
50
+ if (String(text ?? "").trim() === "") return "Empty";
51
+ if (!parsed.ok) return `Not valid JSON Β· ${size}`;
52
+ const v = parsed.value;
53
+ if (Array.isArray(v)) return `${v.length} item${v.length === 1 ? "" : "s"} Β· ${size}`;
54
+ if (v !== null && typeof v === "object") {
55
+ const n = Object.keys(v as object).length;
56
+ return `${n} key${n === 1 ? "" : "s"} Β· ${size}`;
57
+ }
58
+ return `${v === null ? "null" : typeof v} Β· ${size}`;
59
+ }
60
+
61
+ // --- the tree ---------------------------------------------------------------
62
+
63
+ /** How deep a node is open when the viewer mounts. Two levels shows the shape
64
+ * of every payload this product actually receives (an object of objects) and
65
+ * stops before the leaf spam of a scraped array. */
66
+ const OPEN_TO_DEPTH = 2;
67
+
68
+ function isBranch(v: unknown): v is Record<string, unknown> | unknown[] {
69
+ return v !== null && typeof v === "object";
70
+ }
71
+
72
+ /** The value's own text, in its own ink class. Strings keep their quotes: a
73
+ * bare `12` and a `"12"` are different documents and the tree is where that
74
+ * difference has to be visible. */
75
+ function Leaf({ v }: { v: unknown }): ReactNode {
76
+ if (v === null) return <span className="cg-json-null">null</span>;
77
+ if (typeof v === "string") return <span className="cg-json-str">&quot;{v}&quot;</span>;
78
+ if (typeof v === "number") return <span className="cg-json-num">{String(v)}</span>;
79
+ if (typeof v === "boolean") return <span className="cg-json-bool">{String(v)}</span>;
80
+ return <span className="cg-json-str">{String(v)}</span>;
81
+ }
82
+
83
+ function Node({
84
+ name,
85
+ value,
86
+ depth,
87
+ last,
88
+ }: {
89
+ name: string | null;
90
+ value: unknown;
91
+ depth: number;
92
+ last: boolean;
93
+ }) {
94
+ const [open, setOpen] = useState(depth < OPEN_TO_DEPTH);
95
+ if (!isBranch(value))
96
+ return (
97
+ <div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>
98
+ {name !== null && <span className="cg-json-key">{name}</span>}
99
+ {name !== null && <span className="cg-json-punct">: </span>}
100
+ <Leaf v={value} />
101
+ {!last && <span className="cg-json-punct">,</span>}
102
+ </div>
103
+ );
104
+
105
+ const array = Array.isArray(value);
106
+ const entries: [string, unknown][] = array
107
+ ? (value as unknown[]).map((v, i) => [String(i), v])
108
+ : Object.entries(value as Record<string, unknown>);
109
+ const openMark = array ? "[" : "{";
110
+ const closeMark = array ? "]" : "}";
111
+
112
+ return (
113
+ <>
114
+ <div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>
115
+ <button
116
+ type="button"
117
+ className={"cg-json-twist" + (open ? " is-open" : "")}
118
+ onClick={() => setOpen((o) => !o)}
119
+ aria-expanded={open}
120
+ aria-label={`${open ? "Collapse" : "Expand"} ${name ?? "the document"}`}
121
+ >
122
+ <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden>
123
+ <path
124
+ d="M6 4l4 4-4 4"
125
+ fill="none"
126
+ stroke="currentColor"
127
+ strokeWidth="1.6"
128
+ strokeLinecap="round"
129
+ strokeLinejoin="round"
130
+ />
131
+ </svg>
132
+ </button>
133
+ {name !== null && <span className="cg-json-key">{name}</span>}
134
+ {name !== null && <span className="cg-json-punct">: </span>}
135
+ <span className="cg-json-punct">{openMark}</span>
136
+ {/* Collapsed, the count IS the content β€” a bare `{…}` tells a reader
137
+ nothing about whether opening it is worth the click. */}
138
+ {!open && (
139
+ <span className="cg-json-count">
140
+ {entries.length} {array ? (entries.length === 1 ? "item" : "items")
141
+ : entries.length === 1 ? "key" : "keys"}
142
+ </span>
143
+ )}
144
+ {!open && <span className="cg-json-punct">{closeMark}{last ? "" : ","}</span>}
145
+ </div>
146
+ {open &&
147
+ entries.map(([k, v], i) => (
148
+ <Node
149
+ key={k}
150
+ name={array ? null : k}
151
+ value={v}
152
+ depth={depth + 1}
153
+ last={i === entries.length - 1}
154
+ />
155
+ ))}
156
+ {open && (
157
+ <div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>
158
+ <span className="cg-json-punct">
159
+ {closeMark}
160
+ {last ? "" : ","}
161
+ </span>
162
+ </div>
163
+ )}
164
+ </>
165
+ );
166
+ }
167
+
168
+ // --- the modal --------------------------------------------------------------
169
+
170
+ export interface JsonViewerProps {
171
+ /** The column's own label β€” the modal's title. */
172
+ label: string;
173
+ /** The stored document, as a string. */
174
+ value: string;
175
+ /** Absent = read-only (the field is machine-owned, Odoo-sourced, or this
176
+ * viewer permits the reader nothing). Present = the raw tab is an editor. */
177
+ onSave?: (next: string) => void;
178
+ onClose: () => void;
179
+ }
180
+
181
+ export default function JsonViewer({ label, value, onSave, onClose }: JsonViewerProps) {
182
+ const panelRef = useRef<HTMLDivElement>(null);
183
+ const titleId = useId();
184
+ const tabsId = useId();
185
+ const [tab, setTab] = useState<"tree" | "raw">("tree");
186
+ const [draft, setDraft] = useState(value);
187
+ const [error, setError] = useState("");
188
+ const [copied, setCopied] = useState(false);
189
+
190
+ // ⚠ The draft re-seeds when the CELL changes underneath (a machine write
191
+ // landing while the viewer is open), and only then β€” keying on `value` alone
192
+ // would also stamp on every keystroke's re-render if a parent echoed it back.
193
+ useEffect(() => {
194
+ setDraft(value);
195
+ setError("");
196
+ }, [value]);
197
+
198
+ useOverlayLayer({
199
+ panelRef,
200
+ onDismiss: onClose,
201
+ dismissOnOutside: true,
202
+ initialFocus: "[data-overlay-autofocus]",
203
+ trapFocus: true,
204
+ });
205
+
206
+ const parsed = useMemo(() => jsonParse(draft), [draft]);
207
+ const dirty = draft !== value;
208
+
209
+ const save = () => {
210
+ const text = draft.trim();
211
+ // Blank is a legal edit: it clears the cell, which is how a document is
212
+ // removed. Everything else must parse before it is allowed to leave here.
213
+ if (text !== "") {
214
+ if (!jsonParse(text).ok) {
215
+ setError("That is not valid JSON β€” nothing was saved. Check for a trailing comma, a "
216
+ + "single quote, or an unquoted key.");
217
+ return;
218
+ }
219
+ const bytes = jsonByteLength(text);
220
+ if (bytes > MAX_JSON_BYTES) {
221
+ setError(`That document is ${(bytes / 1024).toFixed(1)} KB and the limit is 32 KB β€” `
222
+ + "nothing was saved.");
223
+ return;
224
+ }
225
+ }
226
+ setError("");
227
+ onSave?.(text);
228
+ onClose();
229
+ };
230
+
231
+ const copy = () => {
232
+ void navigator.clipboard?.writeText(draft).then(
233
+ () => {
234
+ setCopied(true);
235
+ window.setTimeout(() => setCopied(false), 1400);
236
+ },
237
+ () => setError("The browser refused clipboard access β€” select the raw text and copy it.")
238
+ );
239
+ };
240
+
241
+ return (
242
+ <BodyPortal>
243
+ <div className="cg-record-backdrop">
244
+ <div
245
+ className="cg-json-modal"
246
+ ref={panelRef}
247
+ role="dialog"
248
+ aria-modal="true"
249
+ aria-labelledby={titleId}
250
+ data-overlay-kind="json-viewer"
251
+ tabIndex={-1}
252
+ >
253
+ <div className="cg-json-head">
254
+ <div>
255
+ <div className="cg-json-title" id={titleId}>{label}</div>
256
+ {/* The summary is the ONE explanatory line (DESIGN.md Β§4): what
257
+ this document is, not what a JSON field is for. */}
258
+ <div className="cg-json-sub">{jsonSummary(draft)}</div>
259
+ </div>
260
+ <div className="cg-json-headacts">
261
+ <button type="button" className="cg-json-btn" onClick={copy}>
262
+ {copied ? "Copied" : "Copy"}
263
+ </button>
264
+ <button
265
+ type="button"
266
+ className="cg-icon-btn"
267
+ onClick={onClose}
268
+ aria-label="Close viewer"
269
+ >
270
+ Γ—
271
+ </button>
272
+ </div>
273
+ </div>
274
+
275
+ <div className="cg-record-tabs" role="tablist" aria-label="Document view">
276
+ {(["tree", "raw"] as const).map((k) => (
277
+ <button
278
+ key={k}
279
+ type="button"
280
+ role="tab"
281
+ id={`${tabsId}-${k}`}
282
+ aria-selected={tab === k}
283
+ aria-controls={`${tabsId}-panel`}
284
+ className={"cg-record-tab" + (tab === k ? " is-on" : "")}
285
+ onClick={() => setTab(k)}
286
+ data-overlay-autofocus={k === "tree" ? true : undefined}
287
+ >
288
+ {k === "tree" ? "Tree" : "Raw"}
289
+ </button>
290
+ ))}
291
+ </div>
292
+
293
+ <div
294
+ className="cg-json-body"
295
+ id={`${tabsId}-panel`}
296
+ role="tabpanel"
297
+ aria-labelledby={`${tabsId}-${tab}`}
298
+ >
299
+ {tab === "tree" ? (
300
+ parsed.ok ? (
301
+ <div className="cg-json-tree">
302
+ <Node name={null} value={parsed.value} depth={0} last />
303
+ </div>
304
+ ) : (
305
+ // β›” NOT an empty tree and NOT a repaired one. A document the app
306
+ // cannot open is a fact about the value, and the raw tab is where
307
+ // it gets read β€” so the empty state POINTS THERE rather than
308
+ // describing what JSON is.
309
+ <p className="cg-json-empty">
310
+ {draft.trim() === ""
311
+ ? "Nothing here yet."
312
+ : "This value is not valid JSON β€” read it on the Raw tab."}
313
+ </p>
314
+ )
315
+ ) : onSave ? (
316
+ <textarea
317
+ className="cg-json-raw"
318
+ value={draft}
319
+ spellCheck={false}
320
+ onChange={(e) => {
321
+ setDraft(e.target.value);
322
+ if (error) setError("");
323
+ }}
324
+ aria-label={`${label} raw JSON`}
325
+ />
326
+ ) : (
327
+ <pre className="cg-json-raw cg-json-raw--ro">{jsonPretty(draft)}</pre>
328
+ )}
329
+ </div>
330
+
331
+ {(error || (onSave && dirty)) && (
332
+ <div className="cg-json-foot">
333
+ {error ? (
334
+ <span className="cg-json-err">{error}</span>
335
+ ) : (
336
+ <span className="cg-json-hint">Unsaved changes</span>
337
+ )}
338
+ {onSave && (
339
+ <span className="cg-json-footacts">
340
+ <button
341
+ type="button"
342
+ className="cg-json-btn"
343
+ onClick={() => {
344
+ setDraft(value);
345
+ setError("");
346
+ }}
347
+ >
348
+ Revert
349
+ </button>
350
+ <button type="button" className="cg-json-btn cg-json-btn--primary" onClick={save}>
351
+ Save
352
+ </button>
353
+ </span>
354
+ )}
355
+ </div>
356
+ )}
357
+ </div>
358
+ </div>
359
+ </BodyPortal>
360
+ );
361
+ }
web/src/customer-grid/RecordDetail.css CHANGED
@@ -146,6 +146,25 @@
146
  line-height: 1.45;
147
  }
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  /* A rating's marks are 18px inline SVG; on the text baseline they hang their
150
  descender space below the line box and grow the slot past every neighbour. */
151
  .cg-record-main .cg-detail-value .cg-stars {
@@ -451,6 +470,33 @@
451
  font-size: var(--lp-fs-2xs);
452
  }
453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  /* Rating: the stars ARE the picker. Clicking the lit star clears the value β€”
455
  without it a rating could be changed here but never removed. */
456
  .cg-record-main .cg-detail-stars {
 
146
  line-height: 1.45;
147
  }
148
 
149
+ /* ⭐ Wave-23 C8 (owner item 4 / ruling R9) β€” a MACHINE-OWNED field's value slot.
150
+
151
+ `--lp-machine-tint` is EXACTLY what the canvas wash (`theme.MACHINE_CELL`,
152
+ `washOf(LP_MACHINE_DEEP, 0.12)`) composites to over white, so the drawer slot
153
+ and the grid cell are one grey rather than two that were each picked to look
154
+ right on their own β€” the sibling-panel rule (DESIGN.md Β§2) applied across two
155
+ rendering technologies instead of two panes.
156
+
157
+ THE VALUE KEEPS ITS INK, deliberately, on both surfaces. The wash is the whole
158
+ signal; dimming the number as well would say the figure matters less, and an
159
+ automation-written follower count is the entire reason its column exists.
160
+
161
+ Background only β€” no border change, no second marker. The column menu already
162
+ says "Filled by automation" once, and DESIGN.md Β§4 is that the app does not
163
+ narrate itself twice. */
164
+ .cg-record-main .cg-detail-field.is-machine .cg-detail-value {
165
+ background: var(--lp-machine-tint);
166
+ }
167
+
168
  /* A rating's marks are 18px inline SVG; on the text baseline they hang their
169
  descender space below the line box and grow the slot past every neighbour. */
170
  .cg-record-main .cg-detail-value .cg-stars {
 
470
  font-size: var(--lp-fs-2xs);
471
  }
472
 
473
+ /* ⭐ Wave-23 C7 (owner item 5) β€” a `json` field's row: the same compact preview the grid cell
474
+ paints, plus the door to the viewer. Reuses `.cg-detail-imagebtn` rather than minting a
475
+ second quiet-button shape (DESIGN.md Β§4) β€” the two rows are doing the same job, showing a
476
+ value too big for a slot and offering the surface that owns it. */
477
+ .cg-record-main .cg-detail-jsonslot {
478
+ display: flex;
479
+ align-items: center;
480
+ justify-content: space-between;
481
+ gap: 10px;
482
+ }
483
+
484
+ /* ⚠ `min-width: 0` is what lets the ellipsis happen at all: a flex item's default
485
+ `min-width: auto` refuses to shrink below its content, so the preview would push the Open
486
+ button out of the panel instead of truncating. Monospace because the preview is a fragment
487
+ of a document β€” `{handle: "royalimports"}` reads as code, and setting it in Inter beside the
488
+ viewer's monospace tree would make one value look like two different things. */
489
+ .cg-record-main .cg-detail-jsonpreview {
490
+ flex: 1 1 auto;
491
+ min-width: 0;
492
+ overflow: hidden;
493
+ text-overflow: ellipsis;
494
+ white-space: nowrap;
495
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
496
+ font-size: var(--lp-fs-2xs);
497
+ color: var(--lp-ink);
498
+ }
499
+
500
  /* Rating: the stars ARE the picker. Clicking the lit star clears the value β€”
501
  without it a rating could be changed here but never removed. */
502
  .cg-record-main .cg-detail-stars {
web/src/customer-grid/RecordDetail.tsx CHANGED
@@ -7,9 +7,11 @@ import {
7
  automationStateLabel,
8
  checkboxOn,
9
  formatDisplay,
 
10
  } from "./cells";
 
11
  import { StarRow, StarIcon } from "./Stars";
12
- import { isPickType, mayEditField, ratingMax } from "./types";
13
  import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
14
  // Item 9 (wave 14) β€” the pure value round-trip. Everything that could silently
15
  // LOSE a stored value moving it into an editor and back lives there, gated by
@@ -269,6 +271,10 @@ export default function RecordDetail({
269
  // reachable second case.
270
  const [imageBusy, setImageBusy] = useState("");
271
  const [imageError, setImageError] = useState("");
 
 
 
 
272
  const overlayFields = fields.filter((field) => field.source === "overlay");
273
  // ⚠ These five buckets are the DEFAULT ORDER of the interleaved list (C-LAYOUT) and NOTHING
274
  // ELSE. They used to double as the editability split β€” picked/rating fields rendered
@@ -475,12 +481,24 @@ export default function RecordDetail({
475
  // somebody to type `rec:` by hand at a picture that does not exist. Its slot is a
476
  // thumbnail plus an upload control, so it joins the constrained-control family.
477
  field.type !== "image" &&
 
 
 
 
 
 
478
  field.type !== "pct";
479
  const isControl = editable && !isEdit;
480
  const cls =
481
  "cg-detail-field" +
482
  (isEdit ? " cg-detail-field--edit" : "") +
483
  (isControl ? " cg-detail-field--control" : "") +
 
 
 
 
 
 
484
  (dropKey === field.key ? " is-dropzone" : "") +
485
  (dragKey === field.key ? " is-dragging" : "");
486
 
@@ -802,6 +820,28 @@ export default function RecordDetail({
802
  <StarRow value={ratingValue(field, record[field.key])} max={ratingMax(field)} />
803
  </span>
804
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
  } else if (field.type === "automation") {
806
  // C5-AUTOFIELD (wave 18). It has to be its OWN branch and not the read-only fallback
807
  // below, because that branch explains read-only-ness as a PERMISSION ("You do not have
@@ -1042,6 +1082,25 @@ export default function RecordDetail({
1042
  </div>
1043
  </div>
1044
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1045
  </BodyPortal>
1046
  );
1047
  }
 
7
  automationStateLabel,
8
  checkboxOn,
9
  formatDisplay,
10
+ jsonPreview,
11
  } from "./cells";
12
+ import JsonViewer from "./JsonViewer";
13
  import { StarRow, StarIcon } from "./Stars";
14
+ import { isMachineOwned, isPickType, mayEditField, ratingMax } from "./types";
15
  import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
16
  // Item 9 (wave 14) β€” the pure value round-trip. Everything that could silently
17
  // LOSE a stored value moving it into an editor and back lives there, gated by
 
271
  // reachable second case.
272
  const [imageBusy, setImageBusy] = useState("");
273
  const [imageError, setImageError] = useState("");
274
+ /** ⭐ Wave-23 C7 β€” which json field's viewer is open, by KEY. One at a time for the same
275
+ * reason `imageBusy` is one pair: a record has a handful of document columns and only one
276
+ * modal can be on screen. */
277
+ const [jsonKey, setJsonKey] = useState<string | null>(null);
278
  const overlayFields = fields.filter((field) => field.source === "overlay");
279
  // ⚠ These five buckets are the DEFAULT ORDER of the interleaved list (C-LAYOUT) and NOTHING
280
  // ELSE. They used to double as the editability split β€” picked/rating fields rendered
 
481
  // somebody to type `rec:` by hand at a picture that does not exist. Its slot is a
482
  // thumbnail plus an upload control, so it joins the constrained-control family.
483
  field.type !== "image" &&
484
+ // ⭐ Wave-23 C7 β€” a `json` value is NEVER edited through a free textarea, here or in the
485
+ // grid. `AutoTextarea` commits on blur straight through `onNotesCommit`, so this branch
486
+ // would be a door that saves an unparseable document with no check at all β€” the exact
487
+ // thing the viewer's parse-on-save exists to prevent, sitting one panel away from it.
488
+ // It joins the constrained-control family: preview + "Open", and the viewer owns the edit.
489
+ field.type !== "json" &&
490
  field.type !== "pct";
491
  const isControl = editable && !isEdit;
492
  const cls =
493
  "cg-detail-field" +
494
  (isEdit ? " cg-detail-field--edit" : "") +
495
  (isControl ? " cg-detail-field--control" : "") +
496
+ // ⭐ Wave-23 C8 (R9) β€” the SAME claim the grid cell makes, on the DOM side. A machine-owned
497
+ // column is grey in the table; opening the record must not present the identical value in
498
+ // an ordinary editable-looking slot, because the drawer is where somebody would try to type
499
+ // in it. `RecordDetail.css` paints the pair (`--lp-machine-tint` background, the measured
500
+ // `--lp-machine-deep` ink) at the value slot, not the row, so the label keeps its rhythm.
501
+ (isMachineOwned(field) ? " is-machine" : "") +
502
  (dropKey === field.key ? " is-dropzone" : "") +
503
  (dragKey === field.key ? " is-dragging" : "");
504
 
 
820
  <StarRow value={ratingValue(field, record[field.key])} max={ratingMax(field)} />
821
  </span>
822
  );
823
+ } else if (field.type === "json") {
824
+ // ⭐ Wave-23 C7 β€” the drawer shows the same compact preview the grid cell does and hands
825
+ // the document to the SAME viewer, because two readers of one document that fold it
826
+ // differently is a disagreement nobody can see in either one alone. The button is the
827
+ // whole affordance: a `<pre>` of 32 KB inside a field row would push every column below
828
+ // it off the panel.
829
+ const raw = rawText(record[field.key]);
830
+ slot = (
831
+ <span className="cg-detail-value cg-detail-jsonslot">
832
+ <span className="cg-detail-jsonpreview">{jsonPreview(raw) || "β€”"}</span>
833
+ <button
834
+ type="button"
835
+ className="cg-detail-imagebtn"
836
+ onClick={() => setJsonKey(field.key)}
837
+ >
838
+ {/* "Open" for a reader, "Open" for an editor β€” the viewer decides what it offers
839
+ once it is open, and a button that says "Edit" to somebody who then cannot would
840
+ be the painted-but-inert control the standing rule is about. */}
841
+ Open
842
+ </button>
843
+ </span>
844
+ );
845
  } else if (field.type === "automation") {
846
  // C5-AUTOFIELD (wave 18). It has to be its OWN branch and not the read-only fallback
847
  // below, because that branch explains read-only-ness as a PERMISSION ("You do not have
 
1082
  </div>
1083
  </div>
1084
  </div>
1085
+ {/* ⭐ Wave-23 C7 β€” the document viewer, INSIDE this portal and therefore stacked above
1086
+ this modal. `useOverlayLayer`'s stack is what makes that work: it registers on top, so
1087
+ Escape closes the viewer and leaves the record open rather than dismissing both. */}
1088
+ {jsonKey && (() => {
1089
+ const field = fields.find((f) => f.key === jsonKey);
1090
+ if (!field) return null;
1091
+ return (
1092
+ <JsonViewer
1093
+ label={field.label}
1094
+ value={rawText(record[field.key])}
1095
+ onSave={
1096
+ mayEditField(field, viewer)
1097
+ ? (next) => onNotesCommit(field.key, next)
1098
+ : undefined
1099
+ }
1100
+ onClose={() => setJsonKey(null)}
1101
+ />
1102
+ );
1103
+ })()}
1104
  </BodyPortal>
1105
  );
1106
  }
web/src/customer-grid/cells.ts CHANGED
@@ -34,7 +34,7 @@ import type {
34
  } from "@glideapps/glide-data-grid";
35
  import { assetUrl } from "./catalogData";
36
  import type { Field } from "./types";
37
- import { ratingMax } from "./types";
38
  import {
39
  LP_BLUE_TEXT,
40
  LP_BLUE_TINT,
@@ -44,6 +44,7 @@ import {
44
  LP_RED_TINT,
45
  LP_YELLOW_DEEP,
46
  LP_YELLOW_TINT,
 
47
  STATUS_BUBBLE,
48
  } from "./theme";
49
  // ⚠ ALIASED: this module has its own `pickTint` (the bubble table above) and choiceColors has
@@ -51,7 +52,7 @@ import {
51
  // distinguishable at the call site rather than one silently shadowing the other.
52
  import { optionTint, pickTint as choicePickTint } from "./choiceColors";
53
  import { automationState, avatarInitials, avatarSize, checkboxOn, dateTimeText, formulaIsBlank,
54
- formulaIsText, num, numberText, userCellPayload } from "./display";
55
  import type { AutomationState, CellValue, UserCellData } from "./display";
56
 
57
  // Wave-7 (item W2): the pure display-string half moved to display.ts so the
@@ -66,6 +67,9 @@ export type { UserCellData } from "./display";
66
  // gate can hold it); only the canvas tint table below needs this module.
67
  export { automationDetail, automationState, automationStateLabel } from "./display";
68
  export type { AutomationState } from "./display";
 
 
 
69
 
70
  /**
71
  * Wave-18 C5-AUTOFIELD β€” the cell tint per automation state.
@@ -393,6 +397,37 @@ export function makeCell(
393
  /** C-AVATAR β€” username β†’ data URL, straight off `GridWorkspace.userAvatars`. Absent, or a
394
  * username absent from it, paints the initials fallback. */
395
  userAvatars?: Record<string, string>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  ): GridCell {
397
  const ro = { allowOverlay: editable, readonly: !editable };
398
  const blank = v == null || v === "";
@@ -508,6 +543,33 @@ export function makeCell(
508
  allowOverlay: false,
509
  };
510
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  case "rating": {
512
  const max = ratingMax(field);
513
  const n = Math.max(0, Math.min(max, Math.round(num(v))));
 
34
  } from "@glideapps/glide-data-grid";
35
  import { assetUrl } from "./catalogData";
36
  import type { Field } from "./types";
37
+ import { isMachineOwned, ratingMax } from "./types";
38
  import {
39
  LP_BLUE_TEXT,
40
  LP_BLUE_TINT,
 
44
  LP_RED_TINT,
45
  LP_YELLOW_DEEP,
46
  LP_YELLOW_TINT,
47
+ machineCellTheme,
48
  STATUS_BUBBLE,
49
  } from "./theme";
50
  // ⚠ ALIASED: this module has its own `pickTint` (the bubble table above) and choiceColors has
 
52
  // distinguishable at the call site rather than one silently shadowing the other.
53
  import { optionTint, pickTint as choicePickTint } from "./choiceColors";
54
  import { automationState, avatarInitials, avatarSize, checkboxOn, dateTimeText, formulaIsBlank,
55
+ formulaIsText, jsonPreview, num, numberText, userCellPayload } from "./display";
56
  import type { AutomationState, CellValue, UserCellData } from "./display";
57
 
58
  // Wave-7 (item W2): the pure display-string half moved to display.ts so the
 
67
  // gate can hold it); only the canvas tint table below needs this module.
68
  export { automationDetail, automationState, automationStateLabel } from "./display";
69
  export type { AutomationState } from "./display";
70
+ // Wave-23 C7 β€” same arrangement again: the parse, the preview and the pretty-printer are pure
71
+ // (`verify_grid_ux` drives them under node); only the canvas cell below needs this module.
72
+ export { jsonParse, jsonPretty, jsonPreview, MAX_JSON_BYTES } from "./display";
73
 
74
  /**
75
  * Wave-18 C5-AUTOFIELD β€” the cell tint per automation state.
 
397
  /** C-AVATAR β€” username β†’ data URL, straight off `GridWorkspace.userAvatars`. Absent, or a
398
  * username absent from it, paints the initials fallback. */
399
  userAvatars?: Record<string, string>
400
+ ): GridCell {
401
+ const cell = baseCell(field, v, editable, userAvatars);
402
+ return isMachineOwned(field) ? withMachineWash(cell) : cell;
403
+ }
404
+
405
+ /**
406
+ * ⭐ Wave-23 C8 (owner item 4 / R9) β€” the grey wash on a MACHINE-OWNED cell.
407
+ *
408
+ * A per-CELL override rather than a per-column one (`useGridColumns.columnTheme`), following the
409
+ * `AUTOMATION_TINT` precedent directly below: the column path returns one of three SHARED
410
+ * `COLUMN_TONE_THEME` references, so forking it per column would both break that sharing and put
411
+ * the wash on the HEADER, which R9 does not ask for β€” an involved column's filter/sort/group tone
412
+ * keeps winning its header.
413
+ *
414
+ * The PRECEDENCE (state tint over wash, bubble tint alongside it) is `theme.machineCellTheme` β€”
415
+ * it lives there because this module imports glide and therefore cannot be driven by a gate,
416
+ * and precedence is the half of this that can be silently wrong. Same split as `display.ts`.
417
+ *
418
+ * ⚠ Re-built rather than mutated: glide declares every cell key `readonly`, and `baseCell`
419
+ * returns a fresh literal on every branch anyway. The `as GridCell` is the union-spread widening,
420
+ * not a claim about the shape β€” the object is byte-identical to the branch's own plus one key.
421
+ */
422
+ function withMachineWash(cell: GridCell): GridCell {
423
+ return { ...cell, themeOverride: machineCellTheme(cell.themeOverride) } as GridCell;
424
+ }
425
+
426
+ function baseCell(
427
+ field: Field,
428
+ v: CellValue,
429
+ editable: boolean,
430
+ userAvatars?: Record<string, string>
431
  ): GridCell {
432
  const ro = { allowOverlay: editable, readonly: !editable };
433
  const blank = v == null || v === "";
 
543
  allowOverlay: false,
544
  };
545
  }
546
+ case "json": {
547
+ // ⭐ Wave-23 C7 (owner item 5) β€” the compact preview; the DOCUMENT lives in the viewer.
548
+ //
549
+ // `allowOverlay: false` for the same reason `select` and `image` carry it: glide's text
550
+ // overlay is a one-line box, and a one-line box over a 32 KB document is an editor that
551
+ // can only damage the value β€” one keystroke in the wrong place and a well-formed payload
552
+ // becomes unparseable, saved. CustomerGrid opens the viewer on click instead (the same
553
+ // `onCellClicked` path the pickers use), and THAT is where the raw text is editable, with
554
+ // parse-on-save.
555
+ //
556
+ // ⚠ `readonly` is NOT set, and the difference matters: `allowOverlay:false` means "no
557
+ // inline editor", while `readonly` would tell glide the CELL cannot change β€” which would
558
+ // also block the paste path that legitimately writes a whole document into it.
559
+ //
560
+ // ⚠ `copyData` carries the RAW document, never the preview (the lesson the image cell
561
+ // booked in wave 19). Copy a json column and you get the payload; copy the preview and
562
+ // you get the sentence "{…} 5 keys", which is not data and cannot be pasted back.
563
+ const raw = String(v ?? "");
564
+ const text = jsonPreview(raw);
565
+ return {
566
+ kind: GridCellKind.Text,
567
+ data: text,
568
+ displayData: text,
569
+ copyData: raw,
570
+ allowOverlay: false,
571
+ };
572
+ }
573
  case "rating": {
574
  const max = ratingMax(field);
575
  const n = Math.max(0, Math.min(max, Math.round(num(v))));
web/src/customer-grid/display.ts CHANGED
@@ -228,6 +228,92 @@ export function automationStateLabel(v: CellValue): string {
228
  return s === "none" ? "Not run yet" : s[0].toUpperCase() + s.slice(1);
229
  }
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  export function formatDisplay(field: Field, v: CellValue): string {
232
  switch (field.type) {
233
  case "currency":
@@ -261,6 +347,12 @@ export function formatDisplay(field: Field, v: CellValue): string {
261
  const n = num(v);
262
  return n >= 1 ? `${Math.round(n)} of ${ratingMax(field)}` : "";
263
  }
 
 
 
 
 
 
264
  case "automation":
265
  // The machine-written line, verbatim. Explicit rather than left to the `default` branch
266
  // below: `formula` fell through a default once and printed every text result as `0` for
 
228
  return s === "none" ? "Not run yet" : s[0].toUpperCase() + s.slice(1);
229
  }
230
 
231
+ /**
232
+ * ⭐ Wave-23 C7 (owner item 5) β€” THE JSON PREVIEW: what a 200px cell says about a document.
233
+ *
234
+ * `MAX_JSON_BYTES` is the contract's ceiling, mirrored from the host's write validation so the
235
+ * viewer can refuse a paste with the same number the server would (a client that lets you type
236
+ * 40 KB and then shows you a server refusal has wasted the edit).
237
+ *
238
+ * The four cases, and each one is a decision rather than a formatting preference:
239
+ * Β· **a single-pair object shows THE PAIR.** `{handle: "royalimports"}` is more useful than
240
+ * "1 key" and it is the shape most machine writes actually have. Past one pair the pairs
241
+ * stop fitting and the honest answer is the count.
242
+ * Β· **many keys / many items β†’ `{…} N keys` / `[…] N items`.** Showing the FIRST pair of a
243
+ * twelve-key object would let a reader take one arbitrary value β€” whichever key the writer's
244
+ * serializer happened to emit first β€” for the cell's content.
245
+ * Β· **a bare scalar renders as itself.** `12`, `"ok"`, `true` and `null` are all valid JSON
246
+ * documents, and wrapping them in braces would describe a shape they do not have.
247
+ * Β· β›” **text that does not parse renders AS ITSELF, never as a shape.** The host validates on
248
+ * write, so this only happens to a value that predates the validation or arrived another
249
+ * way β€” and the one thing the preview must never do is claim a document is well-formed. The
250
+ * viewer's raw tab is where such a value gets read and repaired.
251
+ *
252
+ * Blank stays blank: an empty json cell is a document nobody has written, and `{}` is a document
253
+ * somebody wrote that is empty. Two different facts, two different cells.
254
+ */
255
+ export const MAX_JSON_BYTES = 32 * 1024;
256
+
257
+ /** Does this text parse as JSON? The ONE test, shared by the preview, the cell and the viewer's
258
+ * save β€” three copies of a try/catch is how they end up disagreeing about `""` or `NaN`. */
259
+ export function jsonParse(v: CellValue): { ok: boolean; value?: unknown } {
260
+ const s = String(v ?? "").trim();
261
+ if (s === "") return { ok: false };
262
+ try {
263
+ return { ok: true, value: JSON.parse(s) as unknown };
264
+ } catch {
265
+ return { ok: false };
266
+ }
267
+ }
268
+
269
+ /** One compact line for a json cell. See the note above for why each case reads as it does. */
270
+ export function jsonPreview(v: CellValue): string {
271
+ const raw = String(v ?? "").trim();
272
+ if (raw === "") return "";
273
+ const parsed = jsonParse(raw);
274
+ // ⚠ First line only, and clipped: an unparseable value is often a whole pasted response, and
275
+ // a cell is not where a 4 KB blob gets read. It is shown rather than hidden because the
276
+ // reader has to be able to see that the column holds something the app could not open.
277
+ if (!parsed.ok) return clip(raw.split("\n")[0], 60);
278
+ const value = parsed.value;
279
+ if (Array.isArray(value))
280
+ return value.length === 0 ? "[]" : `[…] ${value.length} item${value.length === 1 ? "" : "s"}`;
281
+ if (value !== null && typeof value === "object") {
282
+ const keys = Object.keys(value as Record<string, unknown>);
283
+ if (keys.length === 0) return "{}";
284
+ if (keys.length === 1)
285
+ return clip(`{${keys[0]}: ${scalarText((value as Record<string, unknown>)[keys[0]])}}`, 60);
286
+ return `{…} ${keys.length} keys`;
287
+ }
288
+ return clip(scalarText(value), 60);
289
+ }
290
+
291
+ /** A nested value, small enough to sit inside a one-pair preview. Objects and arrays collapse
292
+ * to their own marks rather than recursing β€” a preview that unfolds is not a preview. */
293
+ function scalarText(v: unknown): string {
294
+ if (v === null) return "null";
295
+ if (Array.isArray(v)) return `[…] ${v.length}`;
296
+ if (typeof v === "object") return `{…} ${Object.keys(v as object).length}`;
297
+ return typeof v === "string" ? v : String(v);
298
+ }
299
+
300
+ /** ⚠ An ellipsis CHARACTER, not three dots: the grid's canvas measures text and three periods
301
+ * are three glyphs wide. Same mark the group-bar fitter uses. */
302
+ function clip(s: string, n: number): string {
303
+ return s.length <= n ? s : s.slice(0, n - 1) + "…";
304
+ }
305
+
306
+ /**
307
+ * The document, indented for the viewer's pretty tab. Returns the RAW TEXT UNCHANGED when it
308
+ * does not parse β€” re-indenting is not repair, and handing a reader a "prettified" version of
309
+ * something the app could not read would hide the only thing they need to see.
310
+ */
311
+ export function jsonPretty(v: CellValue): string {
312
+ const raw = String(v ?? "");
313
+ const parsed = jsonParse(raw);
314
+ return parsed.ok ? JSON.stringify(parsed.value, null, 2) : raw;
315
+ }
316
+
317
  export function formatDisplay(field: Field, v: CellValue): string {
318
  switch (field.type) {
319
  case "currency":
 
347
  const n = num(v);
348
  return n >= 1 ? `${Math.round(n)} of ${ratingMax(field)}` : "";
349
  }
350
+ case "json":
351
+ // Wave-23 C7 β€” the SAME compact line the canvas cell paints. Explicit here rather than
352
+ // left to `default` for the reason the `formula` note above records: this function feeds
353
+ // ListView, KanbanView, the calendar, the record panel and all four EXPORT formats, and
354
+ // falling through would dump a whole 32 KB document into a CSV cell.
355
+ return jsonPreview(v);
356
  case "automation":
357
  // The machine-written line, verbatim. Explicit rather than left to the `default` branch
358
  // below: `formula` fell through a default once and printed every text result as `0` for
web/src/customer-grid/iconShapes.ts CHANGED
@@ -1,489 +1,524 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / iconShapes.ts
3
- // Wave-8 items I18 + I20 β€” ONE geometry source for the grid's icon vocabulary,
4
- // rendered by TWO very different painters:
5
- //
6
- // - React <FieldTypeIcon> / <ModeIcon> β€” DOM svg in panels, popovers, menus
7
- // - glide headerIcons sprites β€” canvas, drawn from an SVG *string*
8
- //
9
- // Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
10
- // never be a React component. Keeping the paths as DATA (IconShape[]) and giving
11
- // each painter its own thin renderer is what stops the two from drifting β€” the
12
- // alternative (hand-copying every path into a template literal) guarantees the
13
- // header and the panel eventually disagree about what a "date" looks like.
14
- //
15
- // Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
16
- // currentColor. Vector paths only β€” NEVER emoji (owner constant).
17
- // ---------------------------------------------------------------------------
18
-
19
- import type { DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
20
- import {
21
- LP_BLUE,
22
- LP_BLUE_DEEP,
23
- LP_GREEN,
24
- LP_GREEN_DEEP,
25
- LP_LINE,
26
- LP_MUTED,
27
- LP_RED,
28
- LP_RED_DEEP,
29
- LP_YELLOW,
30
- LP_YELLOW_DEEP,
31
- } from "./theme";
32
-
33
- /** One drawing primitive. `fill: true` fills the path instead of stroking it
34
- * (the rating star is the only shape that reads better solid). */
35
- export type IconShape = { d: string; fill?: boolean };
36
-
37
- /** A circle as a path β€” two half-arcs. Sprites are SVG *source*, so every shape
38
- * has to survive being serialized into a string; paths do, <circle> elements
39
- * would need a second serializer branch for no benefit. */
40
- const circle = (cx: number, cy: number, r: number): string =>
41
- `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
42
-
43
- const CALENDAR: IconShape[] = [
44
- { d: "M3.2 4.6h9.6v8.2H3.2z" },
45
- { d: "M3.2 7.2h9.6" },
46
- { d: "M5.8 3v3.2" },
47
- { d: "M10.2 3v3.2" },
48
- ];
49
-
50
- /**
51
- * Field type β†’ icon geometry. A TOTAL record on purpose: adding a FieldType
52
- * without an icon is a compile error, not a silently blank header.
53
- */
54
- export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
55
- text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
56
- status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
57
- currency: [
58
- { d: "M8 2.8v10.4" },
59
- { d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
60
- ],
61
- int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
62
- date: CALENDAR,
63
- pct: [
64
- { d: circle(4.6, 4.6, 1.6) },
65
- { d: circle(11.4, 11.4, 1.6) },
66
- { d: "M12.2 3.9L3.8 12.3" },
67
- ],
68
- select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
69
- user: [
70
- { d: circle(8, 6, 2.4) },
71
- { d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
72
- ],
73
- multiselect: [
74
- { d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
75
- { d: "M7.2 5.7h6M7.2 10.3h6" },
76
- ],
77
- checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
78
- phone: [
79
- { d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
80
- ],
81
- email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
82
- url: [
83
- { d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
84
- { d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
85
- { d: "M6.2 9.8l3.6-3.6" },
86
- ],
87
- rating: [
88
- {
89
- d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
90
- fill: true,
91
- },
92
- ],
93
- created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
94
- formula: [
95
- { d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
96
- { d: "M4.2 7.6h4.6" },
97
- { d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
98
- ],
99
- // Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
100
- // with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
101
- // Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
102
- // (`created_time` owns the closed rim + hands).
103
- automation: [
104
- { d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
105
- { d: "M4.2 6L5.3 4.1 3.1 4.5" },
106
- { d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
107
- ],
108
- // Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
109
- // series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
110
- // the formula fx (that computes over the ROW) and not a bare number (int owns ##).
111
- metric: [
112
- { d: "M3.2 3.2v9.6h9.6" },
113
- { d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
114
- ],
115
- // Wave-19 R7 β€” a framed picture: the mount, a sun, and the hill line every photo glyph
116
- // resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
117
- image: [
118
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
119
- { d: circle(6, 6.3, 1.1) },
120
- { d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
121
- ],
122
- };
123
-
124
- /**
125
- * Display mode β†’ icon geometry (I18). Also total: the wave-8 Dashboard mode
126
- * cannot land in DISPLAY_MODES without the compiler demanding its icon here.
127
- */
128
- export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
129
- grid: [
130
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
131
- { d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
132
- ],
133
- list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
134
- // I19c β€” a framed set of bars: "several charts", not "one chart".
135
- chart: [
136
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
137
- { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
138
- ],
139
- // I10 (C2) β€” the LEGACY key. Kept so this record stays total over DisplayMode, which is
140
- // what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
141
- // promise. Same drawing: a stored 'dashboard' IS a chart.
142
- dashboard: [
143
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
144
- { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
145
- ],
146
- calendar: CALENDAR,
147
- kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
148
- map: [
149
- { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
150
- { d: circle(8, 6.1, 1.3) },
151
- ],
152
- // 2026-08-02 item 7 β€” the time-series view. Drawn as a framed grid with a trend running
153
- // through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
154
- // a per-row line toggle. Deliberately not the plain `line` chart mark β€” a chart view and a
155
- // time-series table must not be the same picture in the same rail.
156
- timeseries: [
157
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
158
- { d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
159
- { d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
160
- ],
161
- // Wave-18 C6-CATALOG β€” an OPEN BOOK: two facing leaves with a spine between them, and a
162
- // product block sitting on the left one. Every other mode in this table draws a way of
163
- // arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
164
- // with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
165
- // not a framed grid (`grid`/`chart`/`timeseries` share the frame).
166
- catalog: [
167
- { d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
168
- { d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
169
- { d: "M3.9 6.4h2.3v2.8H3.9z" },
170
- ],
171
- };
172
-
173
- /**
174
- * Wave-9 I16 β€” one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
175
- * vocabulary cannot ship without a drawing.
176
- *
177
- * ⚠ The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
178
- * an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
179
- * picker is NOT a native `<select>` β€” it is a radio-row list like the mode switcher, which
180
- * is the only shape that can carry a real mark.
181
- *
182
- * The key type is a string union declared here rather than imported from chartData.ts: this
183
- * module is a leaf (types + theme only) and chartData imports IT, not the reverse.
184
- */
185
- export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
186
- export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
187
- bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
188
- line: [
189
- { d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
190
- { d: circle(5.8, 7.8, 0.9) },
191
- { d: circle(8.4, 9.8, 0.9) },
192
- ],
193
- area: [
194
- { d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
195
- { d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
196
- ],
197
- donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
198
- // A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
199
- // reads as "one number" beside four marks that all read as "a distribution".
200
- kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
201
- // Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
202
- // values, not a distribution), with a header band and a column rule.
203
- table: [
204
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
205
- { d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
206
- ],
207
- };
208
-
209
- export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
210
- bar: "Bar",
211
- line: "Line",
212
- area: "Area",
213
- donut: "Donut",
214
- kpi: "Single value",
215
- table: "Table",
216
- };
217
-
218
- /** I16 β€” the pastel each chart kind wears, same family rule as MODE_TONE. */
219
- export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
220
- bar: "blue",
221
- line: "green",
222
- area: "green",
223
- donut: "yellow",
224
- kpi: "neutral",
225
- table: "neutral",
226
- };
227
-
228
- /**
229
- * Wave-9 contract C5 (I15) β€” folder icon geometry. TOTAL over `FolderShape`, so a shape key
230
- * added to the wire contract in types.ts cannot ship without a drawing.
231
- *
232
- * Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
233
- * the same rail and read as one family. `folder` is first because it is the default every
234
- * pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
235
- */
236
- export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
237
- folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
238
- star: [
239
- { d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
240
- ],
241
- flag: [
242
- { d: "M4.2 13.4V2.9" },
243
- { d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
244
- ],
245
- tag: [
246
- { d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
247
- { d: circle(5.6, 5.6, 1) },
248
- ],
249
- bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
250
- // Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
251
- // Reusing the geometry rather than drawing a second "chart" is the whole point of the
252
- // one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
253
- grid: MODE_SHAPES.grid,
254
- chart: MODE_SHAPES.dashboard,
255
- map: MODE_SHAPES.map,
256
- users: TYPE_SHAPES.user,
257
- clock: TYPE_SHAPES.created_time,
258
- heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
259
- bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
260
- };
261
-
262
- /**
263
- * Tone key β†’ the pastel it FILLS with, and the -d weight it STROKES with.
264
- *
265
- * Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
266
- * that a base pastel at that size smudges β€” LP_BLUE measures 1.88:1 on white. So the pastel
267
- * is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
268
- * outline (an outline that reads at all).
269
- *
270
- * The default tone is `neutral` β€” HOST's C5 key, not "grey". The whitelist is SHARED between
271
- * the two ends, so the name matters more than the word: a tone the host does not recognise
272
- * degrades to the default and the user's choice silently disappears on reload.
273
- */
274
- export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
275
- neutral: { fill: LP_LINE, stroke: LP_MUTED },
276
- blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
277
- green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
278
- yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
279
- red: { fill: LP_RED, stroke: LP_RED_DEEP },
280
- };
281
-
282
- export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
283
- neutral: "Neutral",
284
- blue: "Blue",
285
- green: "Green",
286
- yellow: "Yellow",
287
- red: "Red",
288
- };
289
-
290
- export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
291
- folder: "Folder",
292
- star: "Star",
293
- flag: "Flag",
294
- tag: "Tag",
295
- bookmark: "Bookmark",
296
- grid: "Table",
297
- chart: "Chart",
298
- map: "Map",
299
- users: "People",
300
- clock: "Clock",
301
- heart: "Heart",
302
- bolt: "Priority",
303
- };
304
-
305
- /**
306
- * Wave-9 I14 β€” the tone each CREATABLE view type wears in the "+ Create new…" flyout.
307
- *
308
- * The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
309
- * is a list you read rather than scan. Assigned by family, not by rotation: the two
310
- * record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
311
- * share yellow, chart is green because it is the analytical one, map is red because it is
312
- * the geographic one. Folder is grey β€” it is not a view, and the flyout's last row should
313
- * not compete with the six above it.
314
- */
315
- /**
316
- * Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
317
- * sits beside the geometry, the way TYPE_LABELS does β€” the mode switcher, the create flyout
318
- * and the create prompt now read ONE table instead of three. C2's "Dashboard" β†’ "Chart"
319
- * rename is a single line here as a direct result.
320
- */
321
- export const MODE_LABELS: Record<DisplayMode, string> = {
322
- grid: "Grid",
323
- chart: "Chart",
324
- // Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
325
- // read before normalisation must never render a blank switcher chip.
326
- dashboard: "Chart",
327
- list: "List",
328
- calendar: "Calendar",
329
- kanban: "Kanban",
330
- map: "Map",
331
- timeseries: "Time series",
332
- catalog: "Catalog",
333
- };
334
-
335
- /**
336
- * I14 β€” the view types the "+ Create new…" flyout OFFERS, in the order it lists them.
337
- *
338
- * Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
339
- * C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
340
- * rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β€” one list cannot
341
- * express both. Living in this pure data module means the gate can assert the offered set
342
- * without importing a React component, and I10 becomes a one-line edit in one file.
343
- */
344
- // 2026-08-02 item 7 β€” `timeseries` was deliberately held OUT of this list until the host
345
- // accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
346
- // wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
347
- // not know, so offering it early would let a user create a view that silently reverts to a
348
- // grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
349
- // `DISPLAY_MODES += timeseries`, so it is offerable now.
350
- //
351
- // wave17 GRID, owner item 10 β€” THE ORDER BELOW IS THE OWNER'S, stated verbatim:
352
- // Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
353
- //
354
- // ⚠ It supersedes the two orderings this list has carried before it, and the reasoning that
355
- // produced them is now WRONG rather than merely outranked, so it is not left here to be
356
- // re-applied: `timeseries` was "listed last… it belongs beside Chart", and `list` sat second
357
- // as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
358
- // a product decision, so it is asserted in `verify_icons` rather than left to a comment β€”
359
- // nothing else on screen would go red if a future edit re-sorted it "sensibly".
360
- // Wave-18 C6-CATALOG β€” `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
361
- // accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
362
- // "C6 HOST MIRROR APPLIED β€” you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
363
- // It lands LAST by contract: the owner's seven-mode order above is a product decision and the
364
- // new mode joins the end of it rather than being sorted into it.
365
- export const CREATABLE_MODES: DisplayMode[] = [
366
- "grid",
367
- "chart",
368
- "calendar",
369
- "kanban",
370
- "timeseries",
371
- "map",
372
- "list",
373
- "catalog",
374
- ];
375
-
376
- export const MODE_TONE: Record<DisplayMode, FolderTone> = {
377
- grid: "blue",
378
- list: "blue",
379
- chart: "green",
380
- dashboard: "green",
381
- calendar: "yellow",
382
- kanban: "yellow",
383
- map: "red",
384
- // Green with `chart`: it is the other analytical mode, and the two belong to one family.
385
- timeseries: "green",
386
- // Wave-18 C6-CATALOG β€” NEUTRAL, and it is the honest pick rather than the leftover one. The
387
- // four colour tones each name a family of ways to arrange records (blue = tabular, green =
388
- // analytical, yellow = board/date, red = spatial); a catalog arranges nothing β€” it is a
389
- // published artifact. Giving it a colour would file it under a family it is not in.
390
- catalog: "neutral",
391
- };
392
-
393
- /**
394
- * Human labels for every field type. Lives here beside the icons so the two
395
- * halves of "how a field type presents itself" stay in one file (ColumnMenu
396
- * imports it rather than keeping a second copy).
397
- */
398
- export const TYPE_LABELS: Record<FieldType, string> = {
399
- text: "Single line text",
400
- select: "Single select",
401
- multiselect: "Multi select",
402
- user: "Assignee",
403
- int: "Number",
404
- currency: "Currency",
405
- pct: "Percent",
406
- date: "Date",
407
- checkbox: "Checkbox",
408
- phone: "Phone number",
409
- email: "Email",
410
- url: "URL",
411
- rating: "Rating",
412
- created_time: "Created time",
413
- formula: "Formula",
414
- // Wave-18 C5-AUTOFIELD (D's spec, applied by C).
415
- automation: "Automation",
416
- // Wave-22 C7 β€” spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
417
- // on headers and the field gear, not the create menu.
418
- metric: "Metric",
419
- // Wave-19 R7 β€” the picture column.
420
- image: "Image",
421
- status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
422
- };
423
-
424
-
425
- // ------------------------------------------------------------ glide sprites
426
-
427
- /** Serialize one shape to SVG source in an explicit colour (canvas sprites get
428
- * no `currentColor` β€” glide hands the painter the theme colours directly). */
429
- function shapeSource(s: IconShape, color: string): string {
430
- return s.fill
431
- ? `<path d="${s.d}" fill="${color}"/>`
432
- : `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
433
- `stroke-linecap="round" stroke-linejoin="round"/>`;
434
- }
435
-
436
- function sprite(shapes: IconShape[]) {
437
- return ({ fgColor }: { fgColor: string }) =>
438
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
439
- shapes.map((s) => shapeSource(s, fgColor)).join("") +
440
- `</svg>`;
441
- }
442
-
443
- /** Glide header-icon NAME for a field type β€” the `icon` a GridColumn asks for. */
444
- export function typeIconName(type: FieldType): string {
445
- return `t_${type}`;
446
- }
447
-
448
- /**
449
- * The sprite map handed to <DataEditor headerIcons>. One entry per field type
450
- * (I20 draws the type mark in every column header), built from the same shapes
451
- * the React icons use.
452
- *
453
- * Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
454
- * why theme.ts must set it β€” the library default is #FFFFFF, i.e. invisible on
455
- * our header (that was I21's actual bug, not a too-pale hex of ours).
456
- */
457
- export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
458
- Object.fromEntries(
459
- (Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
460
- );
461
-
462
- /**
463
- * The header sprite map handed to <DataEditor headerIcons>. Two families:
464
- *
465
- * t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
466
- * from the same shapes the React icons use. Painted by glide in
467
- * `theme.fgIconHeader`.
468
- * aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i).
469
- * OUTLINE ONLY: a dark-grey ring with a transparent interior, per
470
- * the owner. It still deliberately IGNORES the colours glide hands
471
- * it, for the reason wave-8 recorded - glide's "special" variant is
472
- * accentColor behind bgHeader, which under the C1 pastels is a pale
473
- * glyph on a pale disc, i.e. I21 in a new costume.
474
- * ⚠ It is NO LONGER a column `overlayIcon`. Glide draws an overlay
475
- * at a hard-coded offset from the TYPE mark on the far LEFT of the
476
- * header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
477
- * aligned. It is now painted by CustomerGrid's `drawHeader`
478
- * callback at `infoMarkRect()` - see overlayPlacement.ts.
479
- */
480
- export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = {
481
- ...TYPE_SPRITES,
482
- aiosInfo: () =>
483
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
484
- `<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` +
485
- `<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` +
486
- `stroke-linecap="round"/>` +
487
- `<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` +
488
- `</svg>`,
489
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / iconShapes.ts
3
+ // Wave-8 items I18 + I20 β€” ONE geometry source for the grid's icon vocabulary,
4
+ // rendered by TWO very different painters:
5
+ //
6
+ // - React <FieldTypeIcon> / <ModeIcon> β€” DOM svg in panels, popovers, menus
7
+ // - glide headerIcons sprites β€” canvas, drawn from an SVG *string*
8
+ //
9
+ // Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
10
+ // never be a React component. Keeping the paths as DATA (IconShape[]) and giving
11
+ // each painter its own thin renderer is what stops the two from drifting β€” the
12
+ // alternative (hand-copying every path into a template literal) guarantees the
13
+ // header and the panel eventually disagree about what a "date" looks like.
14
+ //
15
+ // Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
16
+ // currentColor. Vector paths only β€” NEVER emoji (owner constant).
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import type { DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
20
+ import {
21
+ LP_BLUE,
22
+ LP_BLUE_DEEP,
23
+ LP_GREEN,
24
+ LP_GREEN_DEEP,
25
+ LP_LINE,
26
+ LP_MUTED,
27
+ LP_RED,
28
+ LP_RED_DEEP,
29
+ LP_YELLOW,
30
+ LP_YELLOW_DEEP,
31
+ } from "./theme";
32
+
33
+ /** One drawing primitive. `fill: true` fills the path instead of stroking it
34
+ * (the rating star is the only shape that reads better solid). */
35
+ export type IconShape = { d: string; fill?: boolean };
36
+
37
+ /** A circle as a path β€” two half-arcs. Sprites are SVG *source*, so every shape
38
+ * has to survive being serialized into a string; paths do, <circle> elements
39
+ * would need a second serializer branch for no benefit. */
40
+ const circle = (cx: number, cy: number, r: number): string =>
41
+ `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
42
+
43
+ const CALENDAR: IconShape[] = [
44
+ { d: "M3.2 4.6h9.6v8.2H3.2z" },
45
+ { d: "M3.2 7.2h9.6" },
46
+ { d: "M5.8 3v3.2" },
47
+ { d: "M10.2 3v3.2" },
48
+ ];
49
+
50
+ /**
51
+ * Field type β†’ icon geometry. A TOTAL record on purpose: adding a FieldType
52
+ * without an icon is a compile error, not a silently blank header.
53
+ */
54
+ export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
55
+ text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
56
+ status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
57
+ currency: [
58
+ { d: "M8 2.8v10.4" },
59
+ { d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
60
+ ],
61
+ int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
62
+ date: CALENDAR,
63
+ pct: [
64
+ { d: circle(4.6, 4.6, 1.6) },
65
+ { d: circle(11.4, 11.4, 1.6) },
66
+ { d: "M12.2 3.9L3.8 12.3" },
67
+ ],
68
+ select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
69
+ user: [
70
+ { d: circle(8, 6, 2.4) },
71
+ { d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
72
+ ],
73
+ multiselect: [
74
+ { d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
75
+ { d: "M7.2 5.7h6M7.2 10.3h6" },
76
+ ],
77
+ checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
78
+ phone: [
79
+ { d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
80
+ ],
81
+ email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
82
+ url: [
83
+ { d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
84
+ { d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
85
+ { d: "M6.2 9.8l3.6-3.6" },
86
+ ],
87
+ rating: [
88
+ {
89
+ d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
90
+ fill: true,
91
+ },
92
+ ],
93
+ created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
94
+ formula: [
95
+ { d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
96
+ { d: "M4.2 7.6h4.6" },
97
+ { d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
98
+ ],
99
+ // Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
100
+ // with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
101
+ // Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
102
+ // (`created_time` owns the closed rim + hands).
103
+ automation: [
104
+ { d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
105
+ { d: "M4.2 6L5.3 4.1 3.1 4.5" },
106
+ { d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
107
+ ],
108
+ // Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
109
+ // series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
110
+ // the formula fx (that computes over the ROW) and not a bare number (int owns ##).
111
+ metric: [
112
+ { d: "M3.2 3.2v9.6h9.6" },
113
+ { d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
114
+ ],
115
+ // Wave-23 C7 β€” TWO BRACES facing each other with a dot between them: the universal mark for
116
+ // "a structured document", and the one glyph in this table that draws its own SYNTAX rather
117
+ // than a picture of what the value means. Deliberately not a document page (nothing here owns
118
+ // that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree
119
+ // of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from
120
+ // reading as parentheses at small sizes.
121
+ json: [
122
+ { d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" },
123
+ { d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" },
124
+ { d: circle(8, 8, 0.85), fill: true },
125
+ ],
126
+ // Wave-19 R7 β€” a framed picture: the mount, a sun, and the hill line every photo glyph
127
+ // resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
128
+ image: [
129
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
130
+ { d: circle(6, 6.3, 1.1) },
131
+ { d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
132
+ ],
133
+ };
134
+
135
+ /**
136
+ * Display mode β†’ icon geometry (I18). Also total: the wave-8 Dashboard mode
137
+ * cannot land in DISPLAY_MODES without the compiler demanding its icon here.
138
+ */
139
+ export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
140
+ grid: [
141
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
142
+ { d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
143
+ ],
144
+ list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
145
+ // I19c β€” a framed set of bars: "several charts", not "one chart".
146
+ chart: [
147
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
148
+ { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
149
+ ],
150
+ // I10 (C2) β€” the LEGACY key. Kept so this record stays total over DisplayMode, which is
151
+ // what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
152
+ // promise. Same drawing: a stored 'dashboard' IS a chart.
153
+ dashboard: [
154
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
155
+ { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
156
+ ],
157
+ calendar: CALENDAR,
158
+ kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
159
+ map: [
160
+ { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
161
+ { d: circle(8, 6.1, 1.3) },
162
+ ],
163
+ // 2026-08-02 item 7 β€” the time-series view. Drawn as a framed grid with a trend running
164
+ // through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
165
+ // a per-row line toggle. Deliberately not the plain `line` chart mark β€” a chart view and a
166
+ // time-series table must not be the same picture in the same rail.
167
+ timeseries: [
168
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
169
+ { d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
170
+ { d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
171
+ ],
172
+ // Wave-18 C6-CATALOG β€” an OPEN BOOK: two facing leaves with a spine between them, and a
173
+ // product block sitting on the left one. Every other mode in this table draws a way of
174
+ // arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
175
+ // with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
176
+ // not a framed grid (`grid`/`chart`/`timeseries` share the frame).
177
+ catalog: [
178
+ { d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
179
+ { d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
180
+ { d: "M3.9 6.4h2.3v2.8H3.9z" },
181
+ ],
182
+ // Wave-23 C9 β€” a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath
183
+ // them. Every other mode here draws a way of ARRANGING records that already exist; this one
184
+ // has to read as a record being MADE, so it is the only mark whose bottom line is open.
185
+ // Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil
186
+ // (an edit affordance means something else app-wide).
187
+ form: [
188
+ { d: "M3.4 2.8h9.2v10.4H3.4z" },
189
+ { d: "M5.6 5.6h4.8" },
190
+ { d: "M5.6 8h4.8" },
191
+ { d: "M5.6 10.6h2.6" },
192
+ ],
193
+ };
194
+
195
+ /**
196
+ * Wave-9 I16 β€” one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
197
+ * vocabulary cannot ship without a drawing.
198
+ *
199
+ * ⚠ The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
200
+ * an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
201
+ * picker is NOT a native `<select>` β€” it is a radio-row list like the mode switcher, which
202
+ * is the only shape that can carry a real mark.
203
+ *
204
+ * The key type is a string union declared here rather than imported from chartData.ts: this
205
+ * module is a leaf (types + theme only) and chartData imports IT, not the reverse.
206
+ */
207
+ export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
208
+ export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
209
+ bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
210
+ line: [
211
+ { d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
212
+ { d: circle(5.8, 7.8, 0.9) },
213
+ { d: circle(8.4, 9.8, 0.9) },
214
+ ],
215
+ area: [
216
+ { d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
217
+ { d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
218
+ ],
219
+ donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
220
+ // A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
221
+ // reads as "one number" beside four marks that all read as "a distribution".
222
+ kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
223
+ // Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
224
+ // values, not a distribution), with a header band and a column rule.
225
+ table: [
226
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
227
+ { d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
228
+ ],
229
+ };
230
+
231
+ export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
232
+ bar: "Bar",
233
+ line: "Line",
234
+ area: "Area",
235
+ donut: "Donut",
236
+ kpi: "Single value",
237
+ table: "Table",
238
+ };
239
+
240
+ /** I16 β€” the pastel each chart kind wears, same family rule as MODE_TONE. */
241
+ export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
242
+ bar: "blue",
243
+ line: "green",
244
+ area: "green",
245
+ donut: "yellow",
246
+ kpi: "neutral",
247
+ table: "neutral",
248
+ };
249
+
250
+ /**
251
+ * Wave-9 contract C5 (I15) β€” folder icon geometry. TOTAL over `FolderShape`, so a shape key
252
+ * added to the wire contract in types.ts cannot ship without a drawing.
253
+ *
254
+ * Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
255
+ * the same rail and read as one family. `folder` is first because it is the default every
256
+ * pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
257
+ */
258
+ export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
259
+ folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
260
+ star: [
261
+ { d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
262
+ ],
263
+ flag: [
264
+ { d: "M4.2 13.4V2.9" },
265
+ { d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
266
+ ],
267
+ tag: [
268
+ { d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
269
+ { d: circle(5.6, 5.6, 1) },
270
+ ],
271
+ bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
272
+ // Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
273
+ // Reusing the geometry rather than drawing a second "chart" is the whole point of the
274
+ // one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
275
+ grid: MODE_SHAPES.grid,
276
+ chart: MODE_SHAPES.dashboard,
277
+ map: MODE_SHAPES.map,
278
+ users: TYPE_SHAPES.user,
279
+ clock: TYPE_SHAPES.created_time,
280
+ heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
281
+ bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
282
+ };
283
+
284
+ /**
285
+ * Tone key β†’ the pastel it FILLS with, and the -d weight it STROKES with.
286
+ *
287
+ * Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
288
+ * that a base pastel at that size smudges β€” LP_BLUE measures 1.88:1 on white. So the pastel
289
+ * is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
290
+ * outline (an outline that reads at all).
291
+ *
292
+ * The default tone is `neutral` β€” HOST's C5 key, not "grey". The whitelist is SHARED between
293
+ * the two ends, so the name matters more than the word: a tone the host does not recognise
294
+ * degrades to the default and the user's choice silently disappears on reload.
295
+ */
296
+ export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
297
+ neutral: { fill: LP_LINE, stroke: LP_MUTED },
298
+ blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
299
+ green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
300
+ yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
301
+ red: { fill: LP_RED, stroke: LP_RED_DEEP },
302
+ };
303
+
304
+ export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
305
+ neutral: "Neutral",
306
+ blue: "Blue",
307
+ green: "Green",
308
+ yellow: "Yellow",
309
+ red: "Red",
310
+ };
311
+
312
+ export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
313
+ folder: "Folder",
314
+ star: "Star",
315
+ flag: "Flag",
316
+ tag: "Tag",
317
+ bookmark: "Bookmark",
318
+ grid: "Table",
319
+ chart: "Chart",
320
+ map: "Map",
321
+ users: "People",
322
+ clock: "Clock",
323
+ heart: "Heart",
324
+ bolt: "Priority",
325
+ };
326
+
327
+ /**
328
+ * Wave-9 I14 β€” the tone each CREATABLE view type wears in the "+ Create new…" flyout.
329
+ *
330
+ * The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
331
+ * is a list you read rather than scan. Assigned by family, not by rotation: the two
332
+ * record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
333
+ * share yellow, chart is green because it is the analytical one, map is red because it is
334
+ * the geographic one. Folder is grey β€” it is not a view, and the flyout's last row should
335
+ * not compete with the six above it.
336
+ */
337
+ /**
338
+ * Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
339
+ * sits beside the geometry, the way TYPE_LABELS does β€” the mode switcher, the create flyout
340
+ * and the create prompt now read ONE table instead of three. C2's "Dashboard" β†’ "Chart"
341
+ * rename is a single line here as a direct result.
342
+ */
343
+ export const MODE_LABELS: Record<DisplayMode, string> = {
344
+ grid: "Grid",
345
+ chart: "Chart",
346
+ // Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
347
+ // read before normalisation must never render a blank switcher chip.
348
+ dashboard: "Chart",
349
+ list: "List",
350
+ calendar: "Calendar",
351
+ kanban: "Kanban",
352
+ map: "Map",
353
+ timeseries: "Time series",
354
+ catalog: "Catalog",
355
+ // Wave-23 C9 β€” the mode that COLLECTS records. "Form", the word the whole product uses for
356
+ // it (the public page, the share panel, the `form_submitted` trigger); a synonym here would
357
+ // be the one surface calling it something else.
358
+ form: "Form",
359
+ };
360
+
361
+ /**
362
+ * I14 β€” the view types the "+ Create new…" flyout OFFERS, in the order it lists them.
363
+ *
364
+ * Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
365
+ * C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
366
+ * rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β€” one list cannot
367
+ * express both. Living in this pure data module means the gate can assert the offered set
368
+ * without importing a React component, and I10 becomes a one-line edit in one file.
369
+ */
370
+ // 2026-08-02 item 7 β€” `timeseries` was deliberately held OUT of this list until the host
371
+ // accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
372
+ // wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
373
+ // not know, so offering it early would let a user create a view that silently reverts to a
374
+ // grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
375
+ // `DISPLAY_MODES += timeseries`, so it is offerable now.
376
+ //
377
+ // wave17 GRID, owner item 10 β€” THE ORDER BELOW IS THE OWNER'S, stated verbatim:
378
+ // Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
379
+ //
380
+ // ⚠ It supersedes the two orderings this list has carried before it, and the reasoning that
381
+ // produced them is now WRONG rather than merely outranked, so it is not left here to be
382
+ // re-applied: `timeseries` was "listed last… it belongs beside Chart", and `list` sat second
383
+ // as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
384
+ // a product decision, so it is asserted in `verify_icons` rather than left to a comment β€”
385
+ // nothing else on screen would go red if a future edit re-sorted it "sensibly".
386
+ // Wave-18 C6-CATALOG β€” `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
387
+ // accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
388
+ // "C6 HOST MIRROR APPLIED β€” you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
389
+ // It lands LAST by contract: the owner's seven-mode order above is a product decision and the
390
+ // new mode joins the end of it rather than being sorted into it.
391
+ export const CREATABLE_MODES: DisplayMode[] = [
392
+ "grid",
393
+ "chart",
394
+ "calendar",
395
+ "kanban",
396
+ "timeseries",
397
+ "map",
398
+ "list",
399
+ "catalog",
400
+ ];
401
+
402
+ export const MODE_TONE: Record<DisplayMode, FolderTone> = {
403
+ grid: "blue",
404
+ list: "blue",
405
+ chart: "green",
406
+ dashboard: "green",
407
+ calendar: "yellow",
408
+ kanban: "yellow",
409
+ map: "red",
410
+ // Green with `chart`: it is the other analytical mode, and the two belong to one family.
411
+ timeseries: "green",
412
+ // Wave-18 C6-CATALOG β€” NEUTRAL, and it is the honest pick rather than the leftover one. The
413
+ // four colour tones each name a family of ways to arrange records (blue = tabular, green =
414
+ // analytical, yellow = board/date, red = spatial); a catalog arranges nothing β€” it is a
415
+ // published artifact. Giving it a colour would file it under a family it is not in.
416
+ catalog: "neutral",
417
+ // Wave-23 C9 β€” NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones
418
+ // name families of ways to ARRANGE records (blue tabular, green analytical, yellow
419
+ // board/date, red spatial). A form arranges nothing β€” it is a door records come in through β€”
420
+ // so giving it a colour would file it under a family it is not in.
421
+ form: "neutral",
422
+ };
423
+
424
+ /**
425
+ * Human labels for every field type. Lives here beside the icons so the two
426
+ * halves of "how a field type presents itself" stay in one file (ColumnMenu
427
+ * imports it rather than keeping a second copy).
428
+ */
429
+ export const TYPE_LABELS: Record<FieldType, string> = {
430
+ text: "Single line text",
431
+ select: "Single select",
432
+ multiselect: "Multi select",
433
+ user: "Assignee",
434
+ int: "Number",
435
+ currency: "Currency",
436
+ pct: "Percent",
437
+ date: "Date",
438
+ checkbox: "Checkbox",
439
+ phone: "Phone number",
440
+ email: "Email",
441
+ url: "URL",
442
+ rating: "Rating",
443
+ created_time: "Created time",
444
+ formula: "Formula",
445
+ // Wave-18 C5-AUTOFIELD (D's spec, applied by C).
446
+ automation: "Automation",
447
+ // Wave-22 C7 β€” spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
448
+ // on headers and the field gear, not the create menu.
449
+ metric: "Metric",
450
+ // Wave-19 R7 β€” the picture column.
451
+ image: "Image",
452
+ // Wave-23 C7 β€” the structured-document column. "JSON" rather than "Structured data": it is
453
+ // the word on the wire, in the viewer's raw tab and in every error the server can return, and
454
+ // a friendlier synonym would be the only place in the product using a different one.
455
+ json: "JSON",
456
+ status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
457
+ };
458
+
459
+
460
+ // ------------------------------------------------------------ glide sprites
461
+
462
+ /** Serialize one shape to SVG source in an explicit colour (canvas sprites get
463
+ * no `currentColor` β€” glide hands the painter the theme colours directly). */
464
+ function shapeSource(s: IconShape, color: string): string {
465
+ return s.fill
466
+ ? `<path d="${s.d}" fill="${color}"/>`
467
+ : `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
468
+ `stroke-linecap="round" stroke-linejoin="round"/>`;
469
+ }
470
+
471
+ function sprite(shapes: IconShape[]) {
472
+ return ({ fgColor }: { fgColor: string }) =>
473
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
474
+ shapes.map((s) => shapeSource(s, fgColor)).join("") +
475
+ `</svg>`;
476
+ }
477
+
478
+ /** Glide header-icon NAME for a field type β€” the `icon` a GridColumn asks for. */
479
+ export function typeIconName(type: FieldType): string {
480
+ return `t_${type}`;
481
+ }
482
+
483
+ /**
484
+ * The sprite map handed to <DataEditor headerIcons>. One entry per field type
485
+ * (I20 draws the type mark in every column header), built from the same shapes
486
+ * the React icons use.
487
+ *
488
+ * Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
489
+ * why theme.ts must set it β€” the library default is #FFFFFF, i.e. invisible on
490
+ * our header (that was I21's actual bug, not a too-pale hex of ours).
491
+ */
492
+ export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
493
+ Object.fromEntries(
494
+ (Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
495
+ );
496
+
497
+ /**
498
+ * The header sprite map handed to <DataEditor headerIcons>. Two families:
499
+ *
500
+ * t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
501
+ * from the same shapes the React icons use. Painted by glide in
502
+ * `theme.fgIconHeader`.
503
+ * aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i).
504
+ * OUTLINE ONLY: a dark-grey ring with a transparent interior, per
505
+ * the owner. It still deliberately IGNORES the colours glide hands
506
+ * it, for the reason wave-8 recorded - glide's "special" variant is
507
+ * accentColor behind bgHeader, which under the C1 pastels is a pale
508
+ * glyph on a pale disc, i.e. I21 in a new costume.
509
+ * ⚠ It is NO LONGER a column `overlayIcon`. Glide draws an overlay
510
+ * at a hard-coded offset from the TYPE mark on the far LEFT of the
511
+ * header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
512
+ * aligned. It is now painted by CustomerGrid's `drawHeader`
513
+ * callback at `infoMarkRect()` - see overlayPlacement.ts.
514
+ */
515
+ export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = {
516
+ ...TYPE_SPRITES,
517
+ aiosInfo: () =>
518
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
519
+ `<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` +
520
+ `<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` +
521
+ `stroke-linecap="round"/>` +
522
+ `<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` +
523
+ `</svg>`,
524
+ };
web/src/customer-grid/theme.ts CHANGED
@@ -73,6 +73,30 @@ export const LP_PURPLE_DEEP = "#6B57A8"; // 5.17:1 on its own tint
73
  */
74
  export const LP_VOID = "#F6F8FC";
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  export const LP_BLUE_DEEP = "#768FB6"; // 3.29:1 on white β€” rings, control borders
77
  /* THE PRODUCT PRIMARY β€” the same #5B8FD9 the host's `.streamlit/config.toml`
78
  paints on every primary button, so embed and standalone stop disagreeing
@@ -294,6 +318,57 @@ export const COLUMN_TONE_THEME: Record<ControlTone, Partial<Theme>> = {
294
  },
295
  };
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  /* wave17 GRID β€” item 2 / owner R5: **THE ALTERNATING ROW BANDS ARE DEAD.**
298
  *
299
  * `CONTROL_BAND_THEME` stood here: a faint wash on every other data row while any control was
 
73
  */
74
  export const LP_VOID = "#F6F8FC";
75
 
76
+ /**
77
+ * ⭐ Wave-23 C8 (owner item 4 / ruling R9) β€” **the MACHINE-OWNED column pair.**
78
+ *
79
+ * A sixth family, and neutral rather than a hue on purpose: the four C1 pastels each mean a
80
+ * STATE (green positive, red negative, yellow attention, blue interactive) and purple means
81
+ * GROUP. "A robot fills this column" is none of those β€” it is a property of the column's
82
+ * OWNERSHIP, so it takes the grey the palette had no name for. Deliberately NOT `LP_MUTED`
83
+ * (#6B7280): that is chrome ink, it is warmer, and re-using it would make "muted text" and
84
+ * "machine-owned" one token that two unrelated rules could then drag apart.
85
+ *
86
+ * `LP_MACHINE_TINT` the flat wash the DOM surfaces paint (`RecordDetail.css`'s
87
+ * `.cg-detail-field.is-machine .cg-detail-value`). It is exactly what the
88
+ * canvas wash below COMPOSITES TO over white, so the drawer's value slot
89
+ * and the grid cell are the same grey rather than two greys that were each
90
+ * picked to look right alone.
91
+ * `LP_MACHINE_DEEP` its ink β€” 5.35:1 on the tint, 6.32:1 on white (measured, not picked) β€”
92
+ * and the hue `MACHINE_CELL` derives the canvas wash from.
93
+ *
94
+ * Mirrors `--lp-machine-tint` / `--lp-machine-deep` in index.css; `verify_icons`' I7c parity
95
+ * diffs the two files, which is why both are plain hex strings here and neither is computed.
96
+ */
97
+ export const LP_MACHINE_TINT = "#EBECEE";
98
+ export const LP_MACHINE_DEEP = "#59606E";
99
+
100
  export const LP_BLUE_DEEP = "#768FB6"; // 3.29:1 on white β€” rings, control borders
101
  /* THE PRODUCT PRIMARY β€” the same #5B8FD9 the host's `.streamlit/config.toml`
102
  paints on every primary button, so embed and standalone stop disagreeing
 
318
  },
319
  };
320
 
321
+ /**
322
+ * ⭐ Wave-23 C8 β€” the wash a MACHINE-OWNED cell paints (`isMachineOwned`, types.ts).
323
+ *
324
+ * **ONE SHARED REFERENCE, and that is not a micro-optimisation.** glide has no theme-merge
325
+ * cache, so a fresh `{bgCell}` per cell costs a ~35-key spread on every cell of every frame β€”
326
+ * the same reasoning `STATUS_ROW_THEME` and `COLUMN_TONE_THEME` state, and it bites harder here
327
+ * because this override is returned for a whole COLUMN of cells rather than for the few rows
328
+ * that carry a status.
329
+ *
330
+ * **`rgba`, NOT a hex, and it is load-bearing** (the wave-15 R5 lesson, one surface down):
331
+ * `mergeAndRealizeTheme` alpha-BLENDS `bgCell` and hard-replaces every other key, so an opaque
332
+ * grey here would ERASE whatever it landed on β€” a colour-by row's status wash and the
333
+ * hover/active row wash both, leaving a machine column as a permanent gap through every tinted
334
+ * row. Blending means a hovered machine cell is darker than either alone, which is exactly the
335
+ * reading a person expects.
336
+ *
337
+ * ⚠ **0.12, chosen against a COLLISION rather than by eye.** `ROW_HIGHLIGHT_NEUTRAL` (the
338
+ * hover/active row) composites to β‰ˆ#F2F2F3 β€” a neutral grey wash already means something on
339
+ * this canvas. At 0.12 this composites to `LP_MACHINE_TINT` (#EBECEE), which is visibly the
340
+ * stronger of the two, so "a whole column is grey" and "the row under my pointer is grey" stay
341
+ * two different readings. Raising it is the one knob if a machine column reads as nothing; do
342
+ * not switch it to a hue (R9 is explicit that this is not a state).
343
+ */
344
+ export const MACHINE_CELL: Partial<Theme> = { bgCell: washOf(LP_MACHINE_DEEP, 0.12) };
345
+
346
+ /**
347
+ * ⭐ Wave-23 C8 β€” a machine-owned cell's FINAL theme override, given whatever the cell already
348
+ * carried. **THE PRECEDENCE IS THE WHOLE FUNCTION**, so it lives here (node-reachable, under
349
+ * `verify_grid_ux`'s mutation harness) rather than inline in `cells.ts`, which imports glide and
350
+ * therefore cannot be driven by a gate at all.
351
+ *
352
+ * MERGED, with the CELL's own keys landing last β€” not "keep whichever exists", which was the
353
+ * first shape and is wrong in both directions:
354
+ * Β· an `automation` cell that HAS run carries `AUTOMATION_TINT[state]` β€” a green/red/yellow
355
+ * `bgCell` plus its ink. That is a STATE and it says something the wash does not, so it must
356
+ * win. (A cell that has never run carries no override at all β€” `AUTOMATION_TINT.none` is
357
+ * deliberately `undefined` β€” and takes the wash, which is the honest reading: machine-owned,
358
+ * nothing written yet.)
359
+ * Β· a machine-TAGGED `status`/`select`/`multiselect` cell β€” an automation-spawned column is an
360
+ * ordinary field TYPE wearing the tag β€” carries a BUBBLE tint (`bgBubble`/`textBubble`),
361
+ * which does not touch `bgCell` at all. "Keep whichever exists" would have silently cost
362
+ * every one of those columns its wash, and a column of pills is exactly where a reader is
363
+ * least able to tell who typed the value.
364
+ *
365
+ * The no-override case returns the SHARED `MACHINE_CELL` reference (see its note above); only
366
+ * the two merge cases allocate, and both already allocated a fresh override upstream.
367
+ */
368
+ export function machineCellTheme(own: Partial<Theme> | undefined): Partial<Theme> {
369
+ return own ? { ...MACHINE_CELL, ...own } : MACHINE_CELL;
370
+ }
371
+
372
  /* wave17 GRID β€” item 2 / owner R5: **THE ALTERNATING ROW BANDS ARE DEAD.**
373
  *
374
  * `CONTROL_BAND_THEME` stood here: a faint wash on every other data row while any control was
web/src/customer-grid/types.ts CHANGED
@@ -44,6 +44,14 @@ export type FieldType =
44
  // menu (deliberately absent from CREATABLE_TYPES: the menu has no measure/window face yet β€”
45
  // offering the type without its config would mint permanently blank columns).
46
  | "metric"
 
 
 
 
 
 
 
 
47
  // ⭐ Wave-19 R7 / C5 β€” `image` is a PICTURE on a record. The cell holds a STRING REFERENCE,
48
  // never bytes (see `imageRef`): a SKU code, an `ed:` editorial slug, or a `rec:` upload. That
49
  // is what lets Royal's 1,142 existing SKU masters appear with nothing uploaded, and it keeps
@@ -56,10 +64,26 @@ export type FieldSource = "odoo" | "overlay";
56
  /** The types a user may create from the column menu. Odoo-sourced fields are not in this list β€”
57
  * their type comes from the model, and `status` in particular is Odoo's lifecycle, not a
58
  * user-defined set. */
 
 
 
 
 
 
 
 
59
  export const CREATABLE_TYPES: readonly FieldType[] = [
60
  "text", "select", "multiselect", "user", "int", "currency", "pct", "date",
61
  "checkbox", "phone", "email", "url", "rating", "created_time", "formula", "automation",
62
  "image",
 
 
 
 
 
 
 
 
63
  ];
64
 
65
  /**
@@ -237,8 +261,16 @@ export type FieldScope = "cohort" | "global";
237
  // held OUT of CREATABLE_MODES until `aios_grid.DISPLAY_MODES` accepts the name, because
238
  // `_clean_display` drops an unknown mode and offering it first would let a user build a whole
239
  // catalogue that silently reverts to a grid on the next read with nothing going red.
 
 
 
 
 
 
 
240
  export const DISPLAY_MODES = [
241
  "grid", "chart", "dashboard", "list", "calendar", "kanban", "map", "timeseries", "catalog",
 
242
  ] as const;
243
  export type DisplayMode = (typeof DISPLAY_MODES)[number];
244
 
@@ -946,6 +978,35 @@ export function mayEditField(field: Field, viewer: Viewer | undefined): boolean
946
  return edit === "creator" && field.createdBy === viewer.name;
947
  }
948
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
949
  /**
950
  * The NUMERIC and DATE families, for UI dispatch (operator lists, input kinds, sort-direction
951
  * wording). ONE definition here; the ENGINE (useVisibleRows) keeps its own deliberately β€”
@@ -1081,16 +1142,32 @@ export interface Field {
1081
  * Wave-18 C5-AUTOFIELD β€” what this column's automation DOES. Absent = unconfigured (the cells
1082
  * render "Not configured" and no run ever targets the column). Written through the field's
1083
  * gear; validated host-side (`aios_grid._clean_automation`) exactly as `options`/`formula` are.
 
 
 
 
 
 
 
 
 
 
1084
  */
1085
  automation?: {
1086
- /** v1 vocabulary: 'instagram_profile'. Host-whitelisted; an unknown kind is dropped. */
1087
- kind: string;
1088
- /** where the run gets its subject. 'record_url_field' reads `urlField` off the row. */
1089
- source: "record_url_field" | "self";
 
 
1090
  /** the row field holding the profile URL when source = 'record_url_field'. */
1091
  urlField?: string;
1092
  /** kind-specific knobs. Scalars only β€” the host clamps keys and value lengths. */
1093
  settings?: Record<string, string | number | boolean>;
 
 
 
 
1094
  };
1095
  /**
1096
  * Wave-22 C7 β€” a METRIC field's config bag, mirrored from `user_tables._clean_metric` (the
 
44
  // menu (deliberately absent from CREATABLE_TYPES: the menu has no measure/window face yet β€”
45
  // offering the type without its config would mint permanently blank columns).
46
  | "metric"
47
+ // ⭐ Wave-23 C7 (owner item 5 / ruling R5) β€” `json` is a STRUCTURED DOCUMENT on a record: an
48
+ // API response, a webhook body, a scraped payload. The cell holds a STRING that must parse as
49
+ // JSON, ≀ 32 KB, validated host-side on write (refuse with a sentence, never truncate) β€” so
50
+ // the scalar `Row` contract (types.ts's Row, and `grid_events.py:1619`'s non-scalar refusal)
51
+ // is untouched. The grid shows a compact preview and the big viewer modal owns the document.
52
+ // It is NOT in `READONLY_CELL_TYPES`: the value is editable, just never through a 200px cell
53
+ // β€” the same "picked, not typed" reasoning `select` and `image` carry.
54
+ | "json"
55
  // ⭐ Wave-19 R7 / C5 β€” `image` is a PICTURE on a record. The cell holds a STRING REFERENCE,
56
  // never bytes (see `imageRef`): a SKU code, an `ed:` editorial slug, or a `rec:` upload. That
57
  // is what lets Royal's 1,142 existing SKU masters appear with nothing uploaded, and it keeps
 
64
  /** The types a user may create from the column menu. Odoo-sourced fields are not in this list β€”
65
  * their type comes from the model, and `status` in particular is Odoo's lifecycle, not a
66
  * user-defined set. */
67
+ // ⚠ Wave-23 C7 β€” `json` is deliberately ABSENT from this list until SESSION A posts that
68
+ // `aios_grid.CUSTOM_FIELD_TYPES` and `core.user_tables.UT_FIELD_TYPES` accept the name. It is
69
+ // the field-kind twin of the CREATABLE_MODES hold (iconShapes.ts:344-364) and the failure is
70
+ // worse here, because it is not a downgrade: `user_tables._clean_field` (:389) RETURNS NONE for
71
+ // an unknown type, so a column created before the server knows the word simply does not exist
72
+ // on the next read β€” created, named, configured, gone, with nothing anywhere going red. Every
73
+ // OTHER json table below lands now, because reading a json field the server already sends is a
74
+ // different question from offering to create one.
75
  export const CREATABLE_TYPES: readonly FieldType[] = [
76
  "text", "select", "multiselect", "user", "int", "currency", "pct", "date",
77
  "checkbox", "phone", "email", "url", "rating", "created_time", "formula", "automation",
78
  "image",
79
+ // ⭐ Wave-23 C7 (owner item 5) β€” ADDED AT THE CLOSE-OUT AUDIT, and its absence is the exact
80
+ // half-landing the fields contract exists to catch. `json` reached the `FieldType` union, the
81
+ // shape/label tables and the server's `CUSTOM_FIELD_TYPES`, but not this list β€” so the server
82
+ // would have accepted a json field that the client offered no way to create. The type would
83
+ // have shipped real, documented and unreachable.
84
+ // `verify_fields_contract` caught it by diffing the two lists; nothing else could have, because
85
+ // every individual file was internally consistent.
86
+ "json",
87
  ];
88
 
89
  /**
 
261
  // held OUT of CREATABLE_MODES until `aios_grid.DISPLAY_MODES` accepts the name, because
262
  // `_clean_display` drops an unknown mode and offering it first would let a user build a whole
263
  // catalogue that silently reverts to a grid on the next read with nothing going red.
264
+ // ⭐ Wave-23 C9 (owner item 8): `form` joins the union β€” a view that COLLECTS records instead of
265
+ // arranging them, published at a public `#/form/<token>` link. Landed here (so the type, the
266
+ // icon table, the label table and the tone table are total over it, and a stored `form` view
267
+ // renders rather than blanking) and held OUT of `CREATABLE_MODES` until `aios_grid.DISPLAY_MODES`
268
+ // accepts the name β€” the identical hold `timeseries` and `catalog` served, for the identical
269
+ // reason: `_clean_display` DROPS an unknown mode, so offering it early would let somebody build
270
+ // a whole form, share its link, and have the view silently revert to a grid on the next read.
271
  export const DISPLAY_MODES = [
272
  "grid", "chart", "dashboard", "list", "calendar", "kanban", "map", "timeseries", "catalog",
273
+ "form",
274
  ] as const;
275
  export type DisplayMode = (typeof DISPLAY_MODES)[number];
276
 
 
978
  return edit === "creator" && field.createdBy === viewer.name;
979
  }
980
 
981
+ /**
982
+ * ⭐ Wave-23 C8 (owner item 4 / ruling R9) β€” **is this column filled by a MACHINE?**
983
+ *
984
+ * Two clauses, and R9 is what makes it exactly two:
985
+ * Β· `field.automation` present β€” the TAG the engine stamps on everything it spawns or writes:
986
+ * the IG preset columns (`{flowId}`, automation_engine.py:757), the `stage_<autoId>` board
987
+ * column and its cycles counter (`{flowId, stageField}`, :3600), and a user-configured
988
+ * `automation` column's own gear bag. Ordinary field TYPES, machine ownership, one marker.
989
+ * Β· `type ∈ {automation, metric}` β€” the two kinds that are machine-written BY DEFINITION even
990
+ * when no tag reached the client (an `automation` column created before the flow that fills
991
+ * it; a `metric`, which is computed server-side from the master series and never typed).
992
+ *
993
+ * β›” R9 DRAWS THE LINE HERE ON PURPOSE, and the two exclusions are the whole ruling: an
994
+ * ODOO-sourced column and a `formula`/`created_time` column **keep their current look**. All
995
+ * four are read-only, so "read-only" is NOT the predicate β€” a formula is written by the reader's
996
+ * own arithmetic and an Odoo column by the business, and greying either would say a robot owns
997
+ * a number the reader owns. This is why it is a separate function from `mayEditField` and not a
998
+ * branch of it.
999
+ *
1000
+ * ⚠ **PAINT, NEVER PERMISSION.** `mayEditField` above stays the one edit wall (client courtesy;
1001
+ * `grid_events.py:1608-1650` is the real one). A column can be machine-owned and editable
1002
+ * (nothing is, today) or editable and machine-owned tomorrow, and this predicate must not be
1003
+ * the reason a cell locks β€” two functions that agree today are exactly how one silently becomes
1004
+ * the other's enforcement.
1005
+ */
1006
+ export function isMachineOwned(field: Field): boolean {
1007
+ return !!field.automation || field.type === "automation" || field.type === "metric";
1008
+ }
1009
+
1010
  /**
1011
  * The NUMERIC and DATE families, for UI dispatch (operator lists, input kinds, sort-direction
1012
  * wording). ONE definition here; the ENGINE (useVisibleRows) keeps its own deliberately β€”
 
1142
  * Wave-18 C5-AUTOFIELD β€” what this column's automation DOES. Absent = unconfigured (the cells
1143
  * render "Not configured" and no run ever targets the column). Written through the field's
1144
  * gear; validated host-side (`aios_grid._clean_automation`) exactly as `options`/`formula` are.
1145
+ *
1146
+ * ⭐ WIDENED wave-23 C8, because this interface described only ONE of the three writers and
1147
+ * the other two have been shipping for a wave. `kind`/`source` are what the field GEAR writes
1148
+ * (`_clean_automation`); the engine stamps two more bags that never carry either of them:
1149
+ * Β· a SPAWNED preset column β€” `{flowId}` alone (`automation_engine.py:757`);
1150
+ * Β· a STAGE column β€” `{flowId, stageField: true, …}` (`automation_engine.py:3600`).
1151
+ * Nothing went red, because the wire arrives untyped: `field.automation.kind` on a stage
1152
+ * column is a runtime `undefined` under a green `tsc`. C8 makes the TAG ITSELF load-bearing
1153
+ * ("present β‡’ machine-owned", `isMachineOwned` above), so the shape it actually has is
1154
+ * declared here rather than left to the one writer that happened to be typed first.
1155
  */
1156
  automation?: {
1157
+ /** v1 vocabulary: 'instagram_profile'. Host-whitelisted; an unknown kind is dropped.
1158
+ * Absent on engine-stamped bags (spawned presets, stage columns). */
1159
+ kind?: string;
1160
+ /** where the run gets its subject. 'record_url_field' reads `urlField` off the row.
1161
+ * Absent on engine-stamped bags. */
1162
+ source?: "record_url_field" | "self";
1163
  /** the row field holding the profile URL when source = 'record_url_field'. */
1164
  urlField?: string;
1165
  /** kind-specific knobs. Scalars only β€” the host clamps keys and value lengths. */
1166
  settings?: Record<string, string | number | boolean>;
1167
+ /** the automation definition that owns this column (engine-stamped; the machine marker). */
1168
+ flowId?: string;
1169
+ /** true on the `stage_<autoId>` column an automation board walks its records through. */
1170
+ stageField?: boolean;
1171
  };
1172
  /**
1173
  * Wave-22 C7 β€” a METRIC field's config bag, mirrored from `user_tables._clean_metric` (the
web/src/filter-kit/ops.ts CHANGED
@@ -83,6 +83,20 @@ export function opsForType(t: FieldType): FilterOp[] {
83
  // by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
84
  if (t === "multiselect")
85
  return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  // Wave-18 C5-AUTOFIELD β€” `automation` lands HERE, on the text ops, and that is the whole
87
  // v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
88
  // `contains ok` and `contains error` are the two questions anyone actually asks of the
 
83
  // by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
84
  if (t === "multiselect")
85
  return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
86
+ // ⭐ Wave-23 C7 β€” `json` gets EXACTLY the two value-free ops, and the omissions are the
87
+ // decision. `contains` over a serialized document is a substring test against punctuation and
88
+ // key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
89
+ // fragment of 5120, and every one of those reads as a working filter. `eq` is worse β€” two
90
+ // documents that mean the same thing differ by key order and whitespace, so "is" would answer
91
+ // false for a record that plainly matches. "Has anything been captured here yet" is the
92
+ // question a json column can answer soundly, and it is the one people actually ask.
93
+ //
94
+ // ⚠ NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
95
+ // `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
96
+ // are TYPE-INDEPENDENT β€” they test blankness before any type dispatch β€” so the lock-step is
97
+ // untouched and nothing on the server had to move for this line. (The contract's prose spells
98
+ // them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
99
+ if (t === "json") return ["isNotEmpty", "isEmpty"];
100
  // Wave-18 C5-AUTOFIELD β€” `automation` lands HERE, on the text ops, and that is the whole
101
  // v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
102
  // `contains ok` and `contains error` are the two questions anyone actually asks of the
web/src/forms/FormPublic.tsx ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // forms / FormPublic.tsx β€” ⭐ wave-23 item 8 (contract W23-C9).
3
+ //
4
+ // The page an ANONYMOUS person lands on at `#/form/<token>`. E mounts it in the
5
+ // Shell BEFORE the auth wall (wiring W23-W2); everything below assumes there is
6
+ // no session, no workspace, no nav and no viewer.
7
+ //
8
+ // β›” IT IMPORTS NOTHING FROM `customer-grid/**`, AND THAT IS A REQUIREMENT, NOT
9
+ // A PREFERENCE. The grid bundle drags glide-data-grid β€” hundreds of kilobytes
10
+ // of canvas table β€” into whatever chunk touches it, and this page is the one
11
+ // surface in the product that a stranger loads cold, once, probably on a phone,
12
+ // probably on mobile data, to type four answers. One convenience import from
13
+ // `types.ts` would put the whole spreadsheet engine on that wire. So the field
14
+ // vocabulary below is DECLARED HERE as plain strings rather than imported: the
15
+ // server sends `type` as a string and this page renders a control per string,
16
+ // which is the same "no client union over a server vocabulary" rule the rest of
17
+ // the wave follows, arriving here for a second reason.
18
+ //
19
+ // ⚠ Styling reuses `.lpf-*` rules in the D REGION of index.css β€” the shell's
20
+ // stylesheet is one file and already loaded; a second stylesheet for one page
21
+ // would be a second copy of the tokens.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ import { useEffect, useState } from "react";
25
+
26
+ /** One field, exactly as `routes_forms._public_form` builds it. Nothing here is
27
+ * optional-because-maybe: every key below is one the server always sends, and
28
+ * the three that are conditional say so. */
29
+ interface FormField {
30
+ key: string;
31
+ label: string;
32
+ /** The server's own word. Deliberately `string`, not a union β€” an unknown
33
+ * type lands on the text input, which is the safe direction: a person can
34
+ * always type an answer, and a control this page has never heard of would
35
+ * otherwise render as nothing at all. */
36
+ type: string;
37
+ required: boolean;
38
+ options?: string[];
39
+ max?: number;
40
+ }
41
+
42
+ interface FormSpec {
43
+ title: string;
44
+ desc: string;
45
+ submitLabel: string;
46
+ fields: FormField[];
47
+ /** The honeypot's field name β€” planted, never shown. Server-named so the two
48
+ * halves cannot drift into a trap nobody checks. */
49
+ honeypot: string;
50
+ }
51
+
52
+ type Phase = "loading" | "ready" | "sent" | "gone";
53
+
54
+ const LONG_TEXT_MIN = 0; // every text field gets a textarea when it is the only one
55
+
56
+ export default function FormPublic({ token }: { token: string }) {
57
+ const [spec, setSpec] = useState<FormSpec | null>(null);
58
+ const [phase, setPhase] = useState<Phase>("loading");
59
+ const [values, setValues] = useState<Record<string, string>>({});
60
+ const [error, setError] = useState("");
61
+ const [busy, setBusy] = useState(false);
62
+
63
+ useEffect(() => {
64
+ let live = true;
65
+ setPhase("loading");
66
+ fetch(`/api/v1/forms/${encodeURIComponent(token)}`)
67
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
68
+ .then((data: FormSpec) => {
69
+ if (!live) return;
70
+ setSpec(data);
71
+ setPhase("ready");
72
+ })
73
+ // ⚠ ONE dead-end state for every failure, mirroring the server's uniform 403. Telling a
74
+ // visitor "this form is disabled" versus "no such form" would hand an enumerator the
75
+ // distinction the whole server-side refusal exists to withhold.
76
+ .catch(() => live && setPhase("gone"));
77
+ return () => {
78
+ live = false;
79
+ };
80
+ }, [token]);
81
+
82
+ const set = (key: string, v: string) =>
83
+ setValues((prev) => ({ ...prev, [key]: v }));
84
+
85
+ const submit = (event: React.FormEvent) => {
86
+ event.preventDefault();
87
+ if (busy) return;
88
+ setBusy(true);
89
+ setError("");
90
+ fetch(`/api/v1/forms/${encodeURIComponent(token)}`, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({ values }),
94
+ })
95
+ .then(async (r) => {
96
+ if (r.ok) {
97
+ setPhase("sent");
98
+ return;
99
+ }
100
+ // The server's sentence, verbatim β€” it names the field by the label THIS PAGE showed,
101
+ // which is the only name the person has. A generic "something went wrong" here would
102
+ // discard the one useful thing the refusal carried.
103
+ const body = await r.json().catch(() => null);
104
+ setError(
105
+ String(body?.error?.message || "That could not be sent β€” check your answers and try again.")
106
+ );
107
+ })
108
+ .catch(() =>
109
+ setError("That could not be sent β€” check your connection and try again.")
110
+ )
111
+ .finally(() => setBusy(false));
112
+ };
113
+
114
+ if (phase === "loading")
115
+ return (
116
+ <div className="lpf-page">
117
+ <div className="lpf-card lpf-card--quiet" />
118
+ </div>
119
+ );
120
+
121
+ if (phase === "gone")
122
+ return (
123
+ <div className="lpf-page">
124
+ <div className="lpf-card">
125
+ <h1 className="lpf-title">This form is not available</h1>
126
+ {/* ONE line (DESIGN.md Β§4). There is nothing this person can do about it and no
127
+ detail we may safely give, so the page does not pretend otherwise. */}
128
+ <p className="lpf-desc">The link may have expired or been turned off.</p>
129
+ </div>
130
+ </div>
131
+ );
132
+
133
+ if (phase === "sent")
134
+ return (
135
+ <div className="lpf-page">
136
+ <div className="lpf-card">
137
+ <h1 className="lpf-title">Thank you</h1>
138
+ <p className="lpf-desc">Your response has been recorded.</p>
139
+ <button
140
+ type="button"
141
+ className="lpf-btn"
142
+ onClick={() => {
143
+ setValues({});
144
+ setPhase("ready");
145
+ }}
146
+ >
147
+ Submit another
148
+ </button>
149
+ </div>
150
+ </div>
151
+ );
152
+
153
+ if (!spec) return null;
154
+
155
+ return (
156
+ <div className="lpf-page">
157
+ <form className="lpf-card" onSubmit={submit}>
158
+ <h1 className="lpf-title">{spec.title || "Form"}</h1>
159
+ {spec.desc && <p className="lpf-desc">{spec.desc}</p>}
160
+
161
+ {spec.fields.map((f) => (
162
+ <label key={f.key} className="lpf-field">
163
+ <span className="lpf-label">
164
+ {f.label}
165
+ {f.required && <span className="lpf-req" aria-hidden> *</span>}
166
+ </span>
167
+ <FieldControl field={f} value={values[f.key] ?? ""} onChange={set} />
168
+ </label>
169
+ ))}
170
+
171
+ {/* β›” THE HONEYPOT. Off-screen rather than `display:none` β€” several bot frameworks skip
172
+ hidden inputs on purpose, and a trap they know to skip is not a trap. `tabIndex={-1}`
173
+ and `aria-hidden` keep it away from a keyboard user and a screen reader alike, and
174
+ `autoComplete="off"` stops a browser filling it for a real person, which would
175
+ silently drop their submission. */}
176
+ <input
177
+ className="lpf-hp"
178
+ type="text"
179
+ name={spec.honeypot}
180
+ value={values[spec.honeypot] ?? ""}
181
+ onChange={(e) => set(spec.honeypot, e.target.value)}
182
+ tabIndex={-1}
183
+ autoComplete="off"
184
+ aria-hidden
185
+ />
186
+
187
+ {error && <p className="lpf-err">{error}</p>}
188
+ <button type="submit" className="lpf-btn lpf-btn--primary" disabled={busy}>
189
+ {busy ? "Sending…" : spec.submitLabel || "Submit"}
190
+ </button>
191
+ <p className="lpf-brand">Loopable</p>
192
+ </form>
193
+ </div>
194
+ );
195
+ }
196
+
197
+ /** One control per server type string. An unrecognised type falls through to text β€” see the
198
+ * `FormField.type` note: a stranger can always type an answer, and a blank where a control
199
+ * should be is the one failure they cannot work around. */
200
+ function FieldControl({
201
+ field,
202
+ value,
203
+ onChange,
204
+ }: {
205
+ field: FormField;
206
+ value: string;
207
+ onChange: (key: string, v: string) => void;
208
+ }) {
209
+ const common = {
210
+ className: "lpf-input",
211
+ value,
212
+ required: field.required,
213
+ onChange: (e: { target: { value: string } }) => onChange(field.key, e.target.value),
214
+ };
215
+ switch (field.type) {
216
+ case "checkbox":
217
+ return (
218
+ <input
219
+ type="checkbox"
220
+ className="lpf-check"
221
+ checked={value === "1"}
222
+ onChange={(e) => onChange(field.key, e.target.checked ? "1" : "")}
223
+ />
224
+ );
225
+ case "select":
226
+ case "status":
227
+ return (
228
+ <select {...common} className="lpf-input lpf-select">
229
+ {/* An explicit empty option, always. A `<select>` with no blank member has its first
230
+ choice pre-selected, so an untouched optional field silently submits a value the
231
+ person never picked. */}
232
+ <option value="">{field.required ? "Choose…" : "β€”"}</option>
233
+ {(field.options ?? []).map((o) => (
234
+ <option key={o} value={o}>{o}</option>
235
+ ))}
236
+ </select>
237
+ );
238
+ case "int":
239
+ case "currency":
240
+ case "pct":
241
+ case "rating":
242
+ return <input {...common} type="number" inputMode="decimal" />;
243
+ case "date":
244
+ // ⚠ `type="date"` submits ISO (`YYYY-MM-DD`) whatever the browser DISPLAYS, which is
245
+ // exactly what the server's refuse-never-coerce date check demands. A text input here
246
+ // would send whatever the locale suggested and be refused on half the planet.
247
+ return <input {...common} type="date" />;
248
+ case "email":
249
+ return <input {...common} type="email" inputMode="email" />;
250
+ case "phone":
251
+ return <input {...common} type="tel" inputMode="tel" />;
252
+ case "url":
253
+ return <input {...common} type="url" inputMode="url" />;
254
+ case "text":
255
+ return (
256
+ <textarea
257
+ {...common}
258
+ className="lpf-input lpf-textarea"
259
+ rows={value.length > 60 ? 4 : 2}
260
+ minLength={LONG_TEXT_MIN}
261
+ />
262
+ );
263
+ default:
264
+ return <input {...common} type="text" />;
265
+ }
266
+ }
web/src/home/HomePage.tsx ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // home/HomePage.tsx β€” WAVE 23 item 9 (ruling R7, contract C10): the landing.
3
+ //
4
+ // The layout is `reference/Airtable Home.png`, in OUR tokens: an `h1`, a row of
5
+ // quick-start cards, then the recently-opened databases under Today / Past 7
6
+ // days / Older, with a list/grid toggle. Nothing is copied but the ANATOMY β€”
7
+ // every colour, size and weight is the Loopable palette (R1's rule for the
8
+ // builder, applied here for the same reason).
9
+ //
10
+ // β›” THIS PAGE IS CHROME, NOT A GRANTED SURFACE, and the distinction is the one
11
+ // the shell's oldest law turns on (`Shell.tsx` :1039 β€” "the nav is
12
+ // server-filtered and an undeclared surface is denied"). Home does not violate
13
+ // that law because it cannot: every database it names is resolved from the
14
+ // `entries` the server returned (`homeModel.groupRecents` drops a recent whose
15
+ // key is not in them), and the four cards open doors the rail already offered.
16
+ // There is no payload here that `GET /nav` did not send.
17
+ //
18
+ // It holds NO state of its own beyond the layout toggle. The recents come down
19
+ // with the nav; the four actions are the frame's, because the frame owns the
20
+ // dialogs and the hash.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import { useEffect, useState } from "react";
24
+ import { FolderMark } from "../customer-grid/icons";
25
+ import { dbChipClass } from "../shell/nav";
26
+ import type { NavEntry, Recent } from "../shell/nav";
27
+ import {
28
+ HOME_LAYOUT_KEY,
29
+ groupRecents,
30
+ parseLayout,
31
+ } from "./homeModel";
32
+ import type { HomeLayout } from "./homeModel";
33
+
34
+ // ── the four quick-start marks ───────────────────────────────────────────────────────────────
35
+ //
36
+ // Drawn here, in the same 16x16 stroke vocabulary as the rail's `DbIcon`/`AutoIcon`/`BellIcon`
37
+ // β€” the established pattern in this tree (the shell and the alerts pane each draw their own
38
+ // bell rather than sharing an icon module). DESIGN.md's rule is "extend the set, don't import a
39
+ // new icon language", and these are the same language: 16x16, `currentColor` strokes, no fills,
40
+ // no emoji.
41
+
42
+ /** Templates β€” stacked sheets, the thing a template gives you. */
43
+ function TemplateIcon() {
44
+ return (
45
+ <svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
46
+ <rect x="2.2" y="4.4" width="11.6" height="9.2" rx="1.4" />
47
+ <path d="M4.4 4.4V2.6h9.4v9.2h-1.8M2.2 7.6h11.6" />
48
+ </svg>
49
+ );
50
+ }
51
+
52
+ /** A blank database β€” the rail's cylinder with the plus that makes one. */
53
+ function NewDbIcon() {
54
+ return (
55
+ <svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
56
+ <ellipse cx="6.6" cy="3.7" rx="4.4" ry="1.8" />
57
+ <path d="M2.2 3.7v6.6c0 1 2 1.8 4.4 1.8" />
58
+ <path d="M11 3.7v3.1" />
59
+ <path d="M11.6 10.4v4M9.6 12.4h4" />
60
+ </svg>
61
+ );
62
+ }
63
+
64
+ /** An automated database β€” two nodes and the edge between them, the rail's `AutoIcon` family. */
65
+ function AutomatedIcon() {
66
+ return (
67
+ <svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
68
+ <rect x="1.9" y="5.4" width="4.6" height="5.2" rx="1.1" />
69
+ <rect x="9.5" y="5.4" width="4.6" height="5.2" rx="1.1" />
70
+ <path d="M6.5 8h3" />
71
+ </svg>
72
+ );
73
+ }
74
+
75
+ /** Connect a source β€” two links of a chain, which is what a connector is. */
76
+ function ConnectIcon() {
77
+ return (
78
+ <svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
79
+ <path d="M6.7 9.3 9.3 6.7" />
80
+ <path d="M7.6 4.6 9.1 3.1a2.7 2.7 0 0 1 3.8 3.8l-1.5 1.5" />
81
+ <path d="M8.4 11.4l-1.5 1.5a2.7 2.7 0 0 1-3.8-3.8l1.5-1.5" />
82
+ </svg>
83
+ );
84
+ }
85
+
86
+ /** The two layout marks: rows, and a 2x2 of tiles. The reference pair, in our stroke weight. */
87
+ function ListIcon() {
88
+ return (
89
+ <svg viewBox="0 0 16 16" aria-hidden="true" className="home-layout-icon">
90
+ <path d="M2.6 4.4h10.8M2.6 8h10.8M2.6 11.6h10.8" />
91
+ </svg>
92
+ );
93
+ }
94
+
95
+ function GridIcon() {
96
+ return (
97
+ <svg viewBox="0 0 16 16" aria-hidden="true" className="home-layout-icon">
98
+ <rect x="2.5" y="2.5" width="4.6" height="4.6" rx="1" />
99
+ <rect x="8.9" y="2.5" width="4.6" height="4.6" rx="1" />
100
+ <rect x="2.5" y="8.9" width="4.6" height="4.6" rx="1" />
101
+ <rect x="8.9" y="8.9" width="4.6" height="4.6" rx="1" />
102
+ </svg>
103
+ );
104
+ }
105
+
106
+ /**
107
+ * One quick-start card. A `<button>` β€” every one of these opens a dialog or a wizard rather
108
+ * than navigating, and a link that does not link is a lie the browser's status bar tells.
109
+ *
110
+ * ⚠ `type="button"` and the UA reset ride the CLASS (DESIGN.md 4's button rule: a styled
111
+ * `<button>` sets `border/background/font/color` in its base rule, or it leaks the user agent's
112
+ * chrome β€” the wave-19 item-3 "mystery outer lines and tinted fill").
113
+ */
114
+ function QuickCard({
115
+ icon,
116
+ title,
117
+ detail,
118
+ onClick,
119
+ }: {
120
+ icon: React.ReactNode;
121
+ title: string;
122
+ /** ONE line. R13: the app does not narrate itself, and this is a passing-through surface. */
123
+ detail: string;
124
+ onClick: () => void;
125
+ }) {
126
+ return (
127
+ <button type="button" className="home-card" onClick={onClick}>
128
+ <span className="home-card-head">
129
+ {icon}
130
+ <span className="home-card-title">{title}</span>
131
+ </span>
132
+ <span className="home-card-detail">{detail}</span>
133
+ </button>
134
+ );
135
+ }
136
+
137
+ export default function HomePage({
138
+ entries,
139
+ recents,
140
+ onTemplates,
141
+ onNewDatabase,
142
+ onAutomated,
143
+ onConnectors,
144
+ }: {
145
+ /** The SHAPED, server-filtered nav. The only thing a tile may be drawn from. */
146
+ entries: NavEntry[];
147
+ recents: Recent[];
148
+ onTemplates: () => void;
149
+ onNewDatabase: () => void;
150
+ onAutomated: () => void;
151
+ onConnectors: () => void;
152
+ }) {
153
+ const [layout, setLayout] = useState<HomeLayout>(() => {
154
+ try {
155
+ return parseLayout(localStorage.getItem(HOME_LAYOUT_KEY));
156
+ } catch {
157
+ return "grid";
158
+ }
159
+ });
160
+ useEffect(() => {
161
+ try {
162
+ localStorage.setItem(HOME_LAYOUT_KEY, layout);
163
+ } catch {
164
+ // storage can be blocked; the toggle still works for this session
165
+ }
166
+ }, [layout]);
167
+
168
+ // ⚠ THE CLOCK IS READ ONCE, HERE, AND HANDED IN AS NUMBERS. `homeModel` is pure so a gate can
169
+ // assert the boundaries (23:59 vs 00:01, exactly seven days) rather than whatever today is.
170
+ // `todayStart` is the reader's LOCAL midnight β€” "Today" means the reader's today, which is the
171
+ // one clock this feature is allowed to consult (see the model's header note).
172
+ const now = Math.floor(Date.now() / 1000);
173
+ const midnight = new Date();
174
+ midnight.setHours(0, 0, 0, 0);
175
+ const sections = groupRecents(recents, entries, now, Math.floor(midnight.getTime() / 1000));
176
+
177
+ return (
178
+ <div className="shell-home">
179
+ <h1 className="home-title">Home</h1>
180
+
181
+ <div className="home-cards">
182
+ {/* ⚠ THE DETAIL LINES ARE BYTE-IDENTICAL TO THE DIALOG'S ROWS, and that is deliberate
183
+ rather than lazy. Each of these cards opens the same door as a row in the
184
+ New-database dialog, and the first draft described the same two things in four
185
+ slightly different sentences ("fills and keeps up to date" vs "creates and keeps up
186
+ to date"; "ready-made views" vs "a curated set of views"). Two words for one thing is
187
+ how a reader concludes they are two things β€” DESIGN.md 1's "variety is a defect".
188
+ The TITLES do differ, and that is a register difference the reference makes too: a
189
+ card is an invitation ("Start with templates"), a menu row is a choice ("From a
190
+ template"). */}
191
+ <QuickCard
192
+ icon={<TemplateIcon />}
193
+ title="Start with templates"
194
+ detail="Add a curated set of views to a database you already have."
195
+ onClick={onTemplates}
196
+ />
197
+ <QuickCard
198
+ icon={<NewDbIcon />}
199
+ title="New database"
200
+ detail="A blank database in this workspace."
201
+ onClick={onNewDatabase}
202
+ />
203
+ <QuickCard
204
+ icon={<AutomatedIcon />}
205
+ title="Automated database"
206
+ detail="A database an automation creates and keeps up to date."
207
+ onClick={onAutomated}
208
+ />
209
+ <QuickCard
210
+ icon={<ConnectIcon />}
211
+ title="Connect a source"
212
+ detail="Browse the connectors this workspace can use."
213
+ onClick={onConnectors}
214
+ />
215
+ </div>
216
+
217
+ <div className="home-recents-bar">
218
+ {/* The reference puts an "Opened anytime" filter here. We do not have one and do not
219
+ pretend to: a control that filters nothing is worse than no control. The toggle is
220
+ the real affordance, so it sits alone. */}
221
+ <div className="home-layout-toggle" role="group" aria-label="Layout">
222
+ <button
223
+ type="button"
224
+ className={"home-layout-btn" + (layout === "list" ? " is-on" : "")}
225
+ aria-pressed={layout === "list"}
226
+ aria-label="List"
227
+ title="List"
228
+ onClick={() => setLayout("list")}
229
+ >
230
+ <ListIcon />
231
+ </button>
232
+ <button
233
+ type="button"
234
+ className={"home-layout-btn" + (layout === "grid" ? " is-on" : "")}
235
+ aria-pressed={layout === "grid"}
236
+ aria-label="Grid"
237
+ title="Grid"
238
+ onClick={() => setLayout("grid")}
239
+ >
240
+ <GridIcon />
241
+ </button>
242
+ </div>
243
+ </div>
244
+
245
+ {sections.length === 0 ? (
246
+ // β›” ONE LINE, AND IT NAMES THE DOOR THAT FILLS IT (DESIGN.md 4 / R13). This is also
247
+ // where wave 18's tenant hero went: a workspace with no databases lands here, and the
248
+ // cards above ARE the "create your first database" affordance the hero carried. Two
249
+ // welcome messages on one screen is the bug Shell.tsx:1321-1326 already documents.
250
+ <p className="home-empty">
251
+ {entries.length === 0
252
+ ? "This workspace has no databases yet β€” create one above, or connect a source."
253
+ : "Nothing opened yet. The databases you open appear here."}
254
+ </p>
255
+ ) : (
256
+ sections.map((section) => (
257
+ <section key={section.id} className="home-section">
258
+ <h2 className="home-section-title">{section.title}</h2>
259
+ <div className={"home-tiles is-" + layout}>
260
+ {section.tiles.map((tile) => (
261
+ <a
262
+ key={tile.key}
263
+ className="home-tile"
264
+ href={tile.href}
265
+ {...(tile.external ? { target: "_blank", rel: "noreferrer" } : {})}
266
+ >
267
+ {/* The SAME chip family as the database header and the rail row
268
+ (`dbChipClass` β†’ `.shell-db-chip` + its tone), so a database looks like
269
+ itself everywhere. DESIGN.md 4: new elements join the existing rhythm,
270
+ never a new control shape. The chosen mark wins when there is one; two
271
+ letters otherwise, which is what the reference tiles show.
272
+
273
+ β›” AND THOSE TWO BRANCHES CARRY A CONTRAST RULE β€” DO NOT "SIMPLIFY" THEM
274
+ APART. `dbChipClass` returns a TONE class only when there IS an icon, and
275
+ an icon is exactly when we draw a GLYPH. That matters because the tones are
276
+ measured for a graphical object, not for text: white on `--lp-blue-deep` is
277
+ 3.29:1 (the stylesheet publishes every pairing beside its declaration) β€”
278
+ over the 3:1 bar a 16px glyph must clear, UNDER the 4.5:1 bar 12px letters
279
+ must. The fallback branch has no icon, so it takes the BASE chip
280
+ (`--lp-primary`, 7.71:1) and that is where the letters go. Forcing a tone
281
+ onto the initials, or letters onto a toned chip, would put 12px text at
282
+ 3.29:1 on screen. The two branches agree by construction; keep them
283
+ agreeing. */}
284
+ <span className={dbChipClass(tile.icon) + " home-tile-chip"} aria-hidden="true">
285
+ {tile.icon ? <FolderMark icon={tile.icon} size={16} /> : tile.initials}
286
+ </span>
287
+ <span className="home-tile-text">
288
+ <span className="home-tile-name">{tile.label}</span>
289
+ <span className="home-tile-ago">{tile.ago}</span>
290
+ </span>
291
+ </a>
292
+ ))}
293
+ </div>
294
+ </section>
295
+ ))
296
+ )}
297
+ </div>
298
+ );
299
+ }
web/src/home/TemplatePicker.tsx ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // home/TemplatePicker.tsx β€” WAVE 23 item 7 (R10, contract C12, wiring W23-W6).
3
+ //
4
+ // A template is a bundle of SAVED VIEWS applied to a database you already have β€”
5
+ // it does not create a table. So the picker asks two questions in the order a
6
+ // person actually answers them: WHICH DATABASE, then WHICH TEMPLATE. The second
7
+ // list is a function of the first, because the server filters templates by the
8
+ // target's real columns (`view_templates.missing_columns`), and offering one the
9
+ // apply door would refuse is a control that lies.
10
+ //
11
+ // It renders inside the New-database dialog's "From a template" mode rather than
12
+ // as a third modal: one dialog, several doors (DESIGN.md Β§4 β€” new elements join
13
+ // the existing rhythm).
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import { useEffect, useState } from "react";
17
+ import { API_V1, CREDENTIALS } from "../apiContract";
18
+ import type { NavEntry } from "../shell/nav";
19
+
20
+ /** One row of `GET /templates?table=` β€” the server's vocabulary, read as strings (the wave-9
21
+ * law: no client union over a server list). */
22
+ interface TemplateRow {
23
+ key: string;
24
+ label: string;
25
+ desc: string;
26
+ source: string;
27
+ views: number;
28
+ alert: boolean;
29
+ }
30
+
31
+ type Load =
32
+ | { phase: "idle" }
33
+ | { phase: "loading" }
34
+ | { phase: "ready"; rows: TemplateRow[] }
35
+ | { phase: "error"; message: string };
36
+
37
+ function parseRows(body: unknown): TemplateRow[] {
38
+ const raw = (body as { templates?: unknown } | null)?.templates;
39
+ if (!Array.isArray(raw)) return [];
40
+ const out: TemplateRow[] = [];
41
+ for (const item of raw) {
42
+ if (!item || typeof item !== "object") continue;
43
+ const t = item as Record<string, unknown>;
44
+ const key = typeof t.key === "string" ? t.key : "";
45
+ const label = typeof t.label === "string" ? t.label : "";
46
+ if (!key || !label) continue;
47
+ out.push({
48
+ key,
49
+ label,
50
+ desc: typeof t.desc === "string" ? t.desc : "",
51
+ source: typeof t.source === "string" ? t.source : "",
52
+ views: typeof t.views === "number" ? t.views : 0,
53
+ alert: t.alert === true,
54
+ });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export default function TemplatePicker({
60
+ entries,
61
+ onToast,
62
+ onDone,
63
+ }: {
64
+ /** The databases this session may open β€” the shaped, server-filtered nav, minus Automation. */
65
+ entries: NavEntry[];
66
+ onToast: (message: string) => void;
67
+ /** Applied: close the dialog and open the database, where the new views now are. */
68
+ onDone: (tableKey: string) => void;
69
+ }) {
70
+ // Only real destinations: a group head is a folder label with no table behind it.
71
+ const tables = entries.filter((e) => e.kind === "native");
72
+ const [table, setTable] = useState(tables[0]?.key ?? "");
73
+ const [load, setLoad] = useState<Load>({ phase: "idle" });
74
+ const [busy, setBusy] = useState("");
75
+
76
+ useEffect(() => {
77
+ if (!table) {
78
+ setLoad({ phase: "ready", rows: [] });
79
+ return;
80
+ }
81
+ let dead = false;
82
+ setLoad({ phase: "loading" });
83
+ void (async () => {
84
+ try {
85
+ const res = await fetch(
86
+ `${API_V1}/templates?table=${encodeURIComponent(table)}`,
87
+ { credentials: CREDENTIALS }
88
+ );
89
+ const body = (await res.json().catch(() => null)) as
90
+ | { error?: { message?: string } }
91
+ | null;
92
+ if (dead) return;
93
+ if (!res.ok) {
94
+ setLoad({
95
+ phase: "error",
96
+ message: body?.error?.message || `The server answered ${res.status}.`,
97
+ });
98
+ return;
99
+ }
100
+ setLoad({ phase: "ready", rows: parseRows(body) });
101
+ } catch {
102
+ if (!dead) setLoad({ phase: "error", message: "Cannot reach the server." });
103
+ }
104
+ })();
105
+ return () => {
106
+ dead = true;
107
+ };
108
+ }, [table]);
109
+
110
+ const apply = (key: string) => {
111
+ setBusy(key);
112
+ void (async () => {
113
+ try {
114
+ const res = await fetch(
115
+ `${API_V1}/templates/${encodeURIComponent(key)}/apply`,
116
+ {
117
+ method: "POST",
118
+ credentials: CREDENTIALS,
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({ table }),
121
+ }
122
+ );
123
+ const body = (await res.json().catch(() => null)) as
124
+ | { views?: { name?: string }[]; alerted?: boolean; error?: { message?: string } }
125
+ | null;
126
+ setBusy("");
127
+ if (!res.ok) {
128
+ // ⚠ THE SERVER'S SENTENCE, VERBATIM. A refusal here names the COLUMNS the target is
129
+ // missing, which is the only thing that tells the reader they picked the wrong
130
+ // database β€” replacing it with "could not apply" would throw that away.
131
+ onToast(body?.error?.message || `The server answered ${res.status}.`);
132
+ return;
133
+ }
134
+ const n = (body?.views ?? []).length;
135
+ onToast(
136
+ `${n} view${n === 1 ? "" : "s"} added.` +
137
+ (body?.alerted ? " An alert is now watching the first one." : "")
138
+ );
139
+ onDone(table);
140
+ } catch {
141
+ setBusy("");
142
+ onToast("Cannot reach the server.");
143
+ }
144
+ })();
145
+ };
146
+
147
+ // β›” A WORKSPACE WITH NO DATABASES IS NOT "NO TEMPLATE FITS". Without this branch the select
148
+ // renders empty, `table` is "", the fetch is skipped, and the reader is told
149
+ // "No template fits this database's columns." about a database they do not have β€” a false
150
+ // sentence, on the FIRST thing a brand-new workspace can click (Home's "Start with templates"
151
+ // card sits on exactly the empty state the tenant hero used to own). The distinction is the
152
+ // same one the Database flyout already makes between a search that matched nothing and a
153
+ // workspace that holds nothing; this branch is the third place it has to be made.
154
+ if (tables.length === 0) {
155
+ return (
156
+ <p className="home-tpl-note">
157
+ A template adds views to a database. Create one first β€” "Blank" above.
158
+ </p>
159
+ );
160
+ }
161
+
162
+ return (
163
+ <div className="home-tpl">
164
+ <label className="home-tpl-target">
165
+ <span className="home-tpl-label">Apply to</span>
166
+ {/* ⚠ A `<select>` MUST CARRY ITS VALUE. A select rendered without one shows its FIRST
167
+ option while the state says something else β€” the wave-22 scar ([[cg-condition-builder-items]]);
168
+ the control then reads as "customer_data" and applies to whatever `table` happens to
169
+ hold. Bound, not defaulted. */}
170
+ <select
171
+ className="home-tpl-select"
172
+ value={table}
173
+ onChange={(e) => setTable(e.target.value)}
174
+ >
175
+ {tables.map((t) => (
176
+ <option key={t.key} value={t.key}>
177
+ {t.label}
178
+ </option>
179
+ ))}
180
+ </select>
181
+ </label>
182
+
183
+ {load.phase === "loading" ? (
184
+ <div className="home-tpl-note">
185
+ <span className="lp-spin" role="status" aria-label="Loading" />
186
+ </div>
187
+ ) : null}
188
+ {load.phase === "error" ? <p className="shell-newdb-err">{load.message}</p> : null}
189
+ {load.phase === "ready" && load.rows.length === 0 ? (
190
+ // ONE line (R13). And it says the RIGHT thing: nothing fits THIS database, which is not
191
+ // the same as "there are no templates".
192
+ <p className="home-tpl-note">No template fits this database's columns.</p>
193
+ ) : null}
194
+
195
+ {load.phase === "ready" && load.rows.length > 0 ? (
196
+ <ul className="home-tpl-list">
197
+ {load.rows.map((row) => (
198
+ <li key={row.key} className="home-tpl-row">
199
+ <span className="home-tpl-text">
200
+ <span className="home-tpl-name">{row.label}</span>
201
+ <span className="home-tpl-desc">{row.desc}</span>
202
+ </span>
203
+ <button
204
+ type="button"
205
+ className="login-submit home-tpl-apply"
206
+ disabled={!!busy}
207
+ onClick={() => apply(row.key)}
208
+ >
209
+ {busy === row.key ? "Adding…" : `Add ${row.views}`}
210
+ </button>
211
+ </li>
212
+ ))}
213
+ </ul>
214
+ ) : null}
215
+ </div>
216
+ );
217
+ }
web/src/home/homeModel.ts ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // home/homeModel.ts β€” WAVE 23 item 9 (ruling R7, contract C10): the Home
3
+ // landing's PURE half. React-free and fetch-free, so `verify_home.py` can run it
4
+ // under node the way `verify_alerts.py` runs the inbox model.
5
+ //
6
+ // Everything here is a function of values that were passed in β€” including the
7
+ // clock. `todayStart` and `now` are PARAMETERS, never `Date.now()` calls, for two
8
+ // reasons that pull the same way:
9
+ //
10
+ // Β· a gate can then assert the boundary cases (23:59 vs 00:01, exactly seven
11
+ // days) instead of asserting whatever today happens to be;
12
+ // Β· [[date-window-vocabulary]]'s standing rule β€” `today` is a parameter β€” was
13
+ // written for the server's window vocabulary and holds just as well here.
14
+ //
15
+ // ⚠ THE ONE CLOCK THIS FEATURE MAY READ IS THE READER'S OWN, and that is not a
16
+ // contradiction of `alertsModel.stampText`'s "never touch a clock". Two different
17
+ // statements: a notification re-states WHEN A SERVER EVENT HAPPENED (re-deriving
18
+ // it in the browser is how a tenant a day ahead gets told an event happened
19
+ // tomorrow), while "Opened 30 minutes ago" states HOW LONG AGO THE READER
20
+ // THEMSELVES DID SOMETHING, and the Today bucket means the reader's today. The
21
+ // component reads the clock once and hands the numbers in here.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ import type { NavEntry, Recent } from "../shell/nav";
25
+ import type { FolderIcon } from "../customer-grid/types";
26
+
27
+ /** One rendered recents tile: the RESOLVED database, never the raw stamp. */
28
+ export interface RecentTile {
29
+ key: string;
30
+ label: string;
31
+ icon?: FolderIcon;
32
+ /** Epoch seconds, as stored. Kept so a tile can be re-sorted without re-fetching. */
33
+ at: number;
34
+ /** "Opened 30 minutes ago" β€” already assembled, so the component holds no clock logic. */
35
+ ago: string;
36
+ /** The chip's two letters. */
37
+ initials: string;
38
+ /**
39
+ * The entry's OWN href, carried through rather than rebuilt as `#/${key}`.
40
+ *
41
+ * β›” A TILE THAT REBUILDS ITS OWN LINK IS A SECOND ROUTER. `shapeNav` already decided what a
42
+ * key resolves to β€” `#/key` for a native surface, the current application's deep link for a
43
+ * hand-off β€” and a `#/${key}` assembled here would send a hand-off row to a hash this shell
44
+ * cannot render. Today only native keys are ever stamped (the frame stamps `active.kind ===
45
+ * "native"` alone), so this is defence rather than a fix; it costs one field and removes the
46
+ * class.
47
+ */
48
+ href: string;
49
+ /** A hand-off opens the current application in a new tab, and the tile says so. */
50
+ external: boolean;
51
+ }
52
+
53
+ /** One section of the recents list. `title` is the heading; an EMPTY section is never emitted. */
54
+ export interface RecentSection {
55
+ id: "today" | "week" | "older";
56
+ title: string;
57
+ tiles: RecentTile[];
58
+ }
59
+
60
+ const SECOND = 1;
61
+ const MINUTE = 60 * SECOND;
62
+ const HOUR = 60 * MINUTE;
63
+ export const DAY = 24 * HOUR;
64
+
65
+ /** "Past 7 days" means the seven days BEFORE today β€” today has its own section above it. */
66
+ export const WEEK_DAYS = 7;
67
+
68
+ /**
69
+ * The chip's two letters.
70
+ *
71
+ * ⚠ `\p{L}\p{N}` AND NOT `[A-Za-z0-9]`, and this shell has already paid for that once: the
72
+ * account monogram at `Shell.tsx:299` carries the same note, because the Streamlit host's
73
+ * `_account_css` uses Python's `str.isalnum()` and an ASCII-only class here would give a
74
+ * non-Latin name an initial in one shell and a blank circle in the other. A database called
75
+ * "ΠšΠ»ΠΈΠ΅Π½Ρ‚Ρ‹" gets "ΠšΠ›", not an empty tile.
76
+ *
77
+ * Two letters, not one: the reference tiles read "Un" / "Aa", and a single letter over a 26px
78
+ * chip is a bullet point rather than a name.
79
+ */
80
+ export function initials(label: string): string {
81
+ const chars = String(label ?? "").match(/[\p{L}\p{N}]/gu) ?? [];
82
+ return chars.slice(0, 2).join("").toUpperCase();
83
+ }
84
+
85
+ /**
86
+ * "Opened N ago", in the largest unit that is still true.
87
+ *
88
+ * Rounds DOWN throughout (`Math.floor`), so a tile never claims more time has passed than has:
89
+ * at 119 minutes this says "1 hour ago", never "2 hours ago". A stamp in the FUTURE β€” a clock
90
+ * skew between the browser and the host β€” clamps to "just now" rather than rendering a negative
91
+ * count, which is the only honest thing a relative label can say about it.
92
+ */
93
+ export function agoText(at: number, now: number): string {
94
+ const d = Math.max(0, Math.floor(now) - Math.floor(at));
95
+ if (d < MINUTE) return "Opened just now";
96
+ const unit = (n: number, one: string) => `Opened ${n} ${one}${n === 1 ? "" : "s"} ago`;
97
+ if (d < HOUR) return unit(Math.floor(d / MINUTE), "minute");
98
+ if (d < DAY) return unit(Math.floor(d / HOUR), "hour");
99
+ return unit(Math.floor(d / DAY), "day");
100
+ }
101
+
102
+ /**
103
+ * Which section a stamp belongs to.
104
+ *
105
+ * `todayStart` is the epoch second of the reader's LOCAL midnight β€” computed once by the
106
+ * component, so the boundary is the reader's calendar day rather than UTC's. Anything at or
107
+ * after it is Today; the seven days before that are the week; everything else is Older.
108
+ */
109
+ export function bucketOf(at: number, todayStart: number): RecentSection["id"] {
110
+ if (at >= todayStart) return "today";
111
+ if (at >= todayStart - WEEK_DAYS * DAY) return "week";
112
+ return "older";
113
+ }
114
+
115
+ const TITLES: Record<RecentSection["id"], string> = {
116
+ today: "Today",
117
+ week: "Past 7 days",
118
+ older: "Older",
119
+ };
120
+
121
+ /**
122
+ * ⭐ THE C10 LAW, AS CODE: Home renders ONLY what `/nav` already granted.
123
+ *
124
+ * A recent is a KEY and a TIME. Everything a tile shows β€” its name, its chosen mark, whether it
125
+ * exists at all β€” is resolved from `entries`, which is the shaped, server-filtered nav payload.
126
+ * So a database that was revoked, deleted, or never belonged to this account cannot appear on
127
+ * Home even though its stamp is still in the reader's own recents bucket: there is nothing here
128
+ * to draw it FROM.
129
+ *
130
+ * That is what makes `#/home` chrome rather than a surface. The server prunes on read as well
131
+ * (`routes_nav._read_recents`), so this is the second of two walls rather than the only one β€”
132
+ * but it is the one that would still hold if the payload ever widened.
133
+ *
134
+ * β›” GROUP HEADS ARE EXCLUDED. A `kind: "group"` entry is a folder label with no destination;
135
+ * a tile for one would be a card you cannot open. `defaultRoute` makes the same distinction.
136
+ */
137
+ export function groupRecents(
138
+ recents: Recent[],
139
+ entries: NavEntry[],
140
+ now: number,
141
+ todayStart: number
142
+ ): RecentSection[] {
143
+ const byKey = new Map(entries.filter((e) => e.kind !== "group").map((e) => [e.key, e]));
144
+ const buckets: Record<RecentSection["id"], RecentTile[]> = { today: [], week: [], older: [] };
145
+ for (const r of [...recents].sort((a, b) => b.at - a.at)) {
146
+ const entry = byKey.get(r.key);
147
+ // No entry, or an entry with nowhere to go: not a tile. `href` is absent only on a group
148
+ // head, which the map above already excluded β€” this is the type-level restatement of that,
149
+ // and it means a tile always has a destination rather than being a card that does nothing.
150
+ if (!entry || !entry.href) continue;
151
+ buckets[bucketOf(r.at, todayStart)].push({
152
+ key: entry.key,
153
+ label: entry.label,
154
+ ...(entry.icon ? { icon: entry.icon } : {}),
155
+ at: r.at,
156
+ ago: agoText(r.at, now),
157
+ initials: initials(entry.label),
158
+ href: entry.href,
159
+ external: entry.kind === "handoff",
160
+ });
161
+ }
162
+ const out: RecentSection[] = [];
163
+ for (const id of ["today", "week", "older"] as const) {
164
+ // An empty section is not rendered. A heading with nothing under it says "there should be
165
+ // something here", which is a different (and false) statement from "you have not opened
166
+ // anything today".
167
+ if (buckets[id].length) out.push({ id, title: TITLES[id], tiles: buckets[id] });
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /**
173
+ * The server's recents, plus the stamps THIS SESSION made since the nav was fetched.
174
+ *
175
+ * β›” WITHOUT THIS, HOME IS ALWAYS ONE NAVIGATION STALE β€” and it is the wave's headline feature
176
+ * that would be stale. `recents` rides the `GET /nav` payload (one round trip per sign-in, per
177
+ * `navEpoch` bump), while the stamp is a fire-and-forget POST that changes nothing the client
178
+ * holds. So: open a database, go Home, and the database you just opened is not in Today. The
179
+ * alternative β€” refetching the whole nav on every route change to pick up one integer β€” is a
180
+ * request per navigation for a decoration.
181
+ *
182
+ * LOCAL WINS ON A KEY, always: it is strictly newer by construction (it happened after the
183
+ * fetch). And it is deliberately NOT persisted anywhere β€” a reload re-asks the server, which is
184
+ * the only end that actually knows.
185
+ */
186
+ export function mergeRecents(server: Recent[], local: Record<string, number>): Recent[] {
187
+ const byKey = new Map<string, number>();
188
+ for (const r of server) byKey.set(r.key, r.at);
189
+ for (const [key, at] of Object.entries(local)) {
190
+ const cur = byKey.get(key);
191
+ if (cur === undefined || at > cur) byKey.set(key, at);
192
+ }
193
+ return [...byKey.entries()]
194
+ .map(([key, at]) => ({ key, at }))
195
+ .sort((a, b) => b.at - a.at);
196
+ }
197
+
198
+ // ── the list/grid toggle ────────────────────────────────────────────────────────────────────
199
+
200
+ export type HomeLayout = "grid" | "list";
201
+
202
+ /** Per-browser, like the nav's collapsed state β€” a display preference, not account state. */
203
+ export const HOME_LAYOUT_KEY = "aios-home-layout";
204
+
205
+ /** Anything unrecognised reads as `grid`, which is the reference layout and the denser one. */
206
+ export function parseLayout(raw: unknown): HomeLayout {
207
+ return raw === "list" ? "list" : "grid";
208
+ }
web/src/index.css CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/settings/permsModel.ts CHANGED
@@ -152,6 +152,10 @@ const FIELD_TYPE_TABLE: Record<FieldType, true> = {
152
  // names is a listing here. Nowhere near `SettingsSection` / the rail, which is B's half of
153
  // this file.
154
  image: true,
 
 
 
 
155
  };
156
 
157
  export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE));
 
152
  // names is a listing here. Nowhere near `SettingsSection` / the rail, which is B's half of
153
  // this file.
154
  image: true,
155
+ // Wave-23 C7 (added by session D β€” `settings/**` is frozen this wave and this ONE key is the
156
+ // exception the freeze cannot cover: the alarm four lines up is a COMPILE error, so the union
157
+ // and this listing cannot land in two different changes. Posted in D's mailbox for C.)
158
+ json: true,
159
  };
160
 
161
  export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE));
web/src/shell/Shell.tsx CHANGED
@@ -31,21 +31,30 @@
31
  // ---------------------------------------------------------------------------
32
 
33
  import { useCallback, useEffect, useRef, useState } from "react";
34
- import type { MouseEvent as ReactMouseEvent } from "react";
 
35
  import AutomationSurface from "../automation/AutomationSurface";
36
  import CustomerGrid from "../customer-grid/CustomerGrid";
37
  import { clearCustomersCache } from "../customer-grid/apiBridge";
38
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
39
  // ROWS_STALE_EVENT left with `addRecord` (wave 20 item 4): the shell no longer writes rows,
40
  // so it no longer has to tell the grid that it did.
41
- import { API_V1, CREDENTIALS, DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT } from "../apiContract";
42
  import { PageSurface } from "../pages/PageSurface";
43
  import { SettingsModal } from "../settings/SettingsModal";
44
  import type { SettingsSection } from "../settings/SettingsModal";
45
  import { Brand } from "./Brand";
46
  import LoginPage from "./LoginPage";
47
- import { EMPTY_NAV_PREFS, ENVELOPE_KEYS, MAX_NAV_FOLDERS, appLink, dbChipClass, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
48
- import type { NavEntry, NavMetaPatch, NavPage, NavPrefs } from "./nav";
 
 
 
 
 
 
 
 
49
  import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras";
50
  import AlertsPane from "../alerts/AlertsPane";
51
  import { createAlert, fetchInbox } from "../alerts/alertsApi";
@@ -83,6 +92,71 @@ const NAV_DRAG_TYPE = "application/x-loopable-nav";
83
  /** A stable identity for "no nav yet" β€” see its use below. */
84
  const NO_ENTRIES: NavEntry[] = [];
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  type Session =
87
  | { phase: "checking" }
88
  | { phase: "anon" }
@@ -91,10 +165,19 @@ type Session =
91
  type Nav =
92
  | { phase: "idle" }
93
  | { phase: "loading" }
94
- | { phase: "ready"; entries: NavEntry[]; utility: NavPage[]; empty?: string }
 
 
 
 
 
 
 
95
  | { phase: "error" };
96
 
97
  const NO_UTILITY: NavPage[] = [];
 
 
98
 
99
  function useHashRoute(): string {
100
  const read = () => window.location.hash.replace(/^#\/?/, "");
@@ -173,7 +256,7 @@ function ExtIcon() {
173
  * The chip is decorative and says so: the name beside it is real text, so a
174
  * second announcement of the same fact is noise to a screen reader.
175
  */
176
- function DbHead({ label, icon }: { label: string; icon?: FolderIcon }) {
177
  return (
178
  <div className="shell-db-head">
179
  <span className={dbChipClass(icon)} aria-hidden="true">
@@ -181,8 +264,15 @@ function DbHead({ label, icon }: { label: string; icon?: FolderIcon }) {
181
  the same pair the rail row draws, so the header and the nav agree. Both
182
  paint in the chip's ink (the stylesheet's two overrides): `FolderMark`
183
  would otherwise stroke its pastel `-deep`, which is measured against the
184
- WHITE rail and disappears on its own tone. */}
185
- {icon ? <FolderMark icon={icon} size={16} /> : <DbIcon />}
 
 
 
 
 
 
 
186
  </span>
187
  {/* An `h1`, not a styled span: this is the first time the work surface has NAMED itself,
188
  and the name of the thing you are looking at is what a heading is for. Every other
@@ -242,6 +332,50 @@ function BellIcon() {
242
  );
243
  }
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  /** Owner item 11 β€” the rail toggle: three horizontal bars. One glyph for both rails (the
246
  * views rail draws the same geometry), so "minimize a panel" reads as one idea. */
247
  function RailToggleIcon() {
@@ -435,6 +569,10 @@ export default function Shell() {
435
  const [alertsOpen, setAlertsOpen] = useState(false);
436
  const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX);
437
  const route = useHashRoute();
 
 
 
 
438
  // Owner items 10/11 β€” the navigation folds to a slim strip: by the toggle in the rail head,
439
  // or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
440
  // grid). Remembered per browser; expanding is always one click on the same toggle.
@@ -490,6 +628,75 @@ export default function Shell() {
490
  setNavCollapsed(false);
491
  }, []);
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  // The window signals the data layer raises (apiContract.ts). It talks to the
494
  // frame this way because `customer-grid/**` is host-neutral β€” the same tree
495
  // the Streamlit embed ships β€” and must not import a shell.
@@ -589,6 +796,7 @@ export default function Shell() {
589
  // the host does (`core.perms.nav_pages`' own contract).
590
  const { main, utility } = splitChrome(r.pages);
591
  setNav({ phase: "ready", entries: shapeNav(main, APP_BASE), utility,
 
592
  ...(r.empty ? { empty: r.empty } : {}) });
593
  }
594
  // 401 is not "the nav is broken", it is "the session died under us" β€”
@@ -687,6 +895,10 @@ export default function Shell() {
687
  // scar); `dropTarget` is a folder id or "__root__" (drag out = drop on the list itself).
688
  const [dragKey, setDragKey] = useState<string | null>(null);
689
  const [dropTarget, setDropTarget] = useState<string | null>(null);
 
 
 
 
690
  const createFolder = useCallback(
691
  (name: string) => {
692
  const cur = navPrefsRef.current;
@@ -696,6 +908,14 @@ export default function Shell() {
696
  }
697
  const id = `nf_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
698
  commitPrefs({ folders: [...cur.folders, { id, name }], placement: cur.placement });
 
 
 
 
 
 
 
 
699
  },
700
  [commitPrefs]
701
  );
@@ -809,12 +1029,59 @@ export default function Shell() {
809
 
810
  // Keep the URL honest in both directions: an anonymous shell sits on
811
  // `#/login`, and a signed-in one never does.
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  useEffect(() => {
 
 
 
 
 
 
813
  if (session.phase === "anon" && route !== LOGIN_ROUTE) {
814
  window.location.hash = `#/${LOGIN_ROUTE}`;
815
- } else if (session.phase === "authed" && route === LOGIN_ROUTE && entries.length) {
816
  window.location.hash = `#/${defaultRoute(entries)}`;
817
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
818
  }, [session.phase, route, entries]);
819
 
820
  const signOut = useCallback(() => {
@@ -849,26 +1116,55 @@ export default function Shell() {
849
  return () => window.removeEventListener("keydown", onKey);
850
  });
851
  // (C3-UT): "+ New database" + the user-table Add-record bar.
852
- const [newDb, setNewDb] = useState<null | { name: string; busy: boolean; err: string }>(null);
 
 
 
 
 
 
 
 
 
853
  const newDbOpenRef = useRef(false);
854
  newDbOpenRef.current = newDb !== null;
 
 
 
 
 
 
 
 
 
 
 
 
 
855
  const createDb = useCallback(async () => {
856
  setNewDb((cur) => {
857
  if (!cur || cur.busy || !cur.name.trim()) return cur;
858
  const name = cur.name;
859
  void (async () => {
860
  try {
 
 
 
 
 
 
 
861
  const res = await fetch(`${API_V1}/tables`, {
862
  method: "POST",
863
  credentials: CREDENTIALS,
864
  headers: { "Content-Type": "application/json" },
865
- body: JSON.stringify({ label: name.trim() }),
866
  });
867
  const body = (await res.json().catch(() => null)) as
868
  | { key?: string; error?: { message?: string } }
869
  | null;
870
  if (!res.ok || !body?.key) {
871
- setNewDb({ name, busy: false,
872
  err: body?.error?.message || `The server answered ${res.status}.` });
873
  return;
874
  }
@@ -876,7 +1172,7 @@ export default function Shell() {
876
  setNavEpoch((e) => e + 1);
877
  window.location.hash = `#/${body.key}`;
878
  } catch {
879
- setNewDb({ name, busy: false, err: "Cannot reach the server." });
880
  }
881
  })();
882
  return { ...cur, busy: true, err: "" };
@@ -889,6 +1185,34 @@ export default function Shell() {
889
  // trailing "+" row (S3's half), where the row it adds is the row you are looking at, so
890
  // the shell stops being a second writer of table data.
891
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
892
  if (session.phase === "checking") {
893
  // Deliberately wordless. A "Loading…" line here would flash for one round
894
  // trip on every load, and the mark says everything the moment needs.
@@ -935,6 +1259,20 @@ export default function Shell() {
935
  // only granted surface is Automation that it has no surfaces at all.
936
  const automation = entries.find((e) => e.key === "automation" && e.kind === "native");
937
  const dbEntries = automation ? entries.filter((e) => e.key !== "automation") : entries;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
938
 
939
  return (
940
  <div className="shell-root">
@@ -977,6 +1315,20 @@ export default function Shell() {
977
  </div>
978
 
979
  <nav className="shell-nav">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  {/* The Analyst slot, above the database list β€” the host's own IA
981
  (app.py:8166 pins "AI assistant" over the nav tree, same label,
982
  same sparkle). Wave 18 (owner item 3): the hand-off into Streamlit
@@ -1065,7 +1417,97 @@ export default function Shell() {
1065
  ) : null}
1066
  </button>
1067
 
1068
- <div
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1069
  className={
1070
  "shell-nav-list" + (dropTarget === "__root__" ? " is-drop-root" : "")
1071
  }
@@ -1088,10 +1530,13 @@ export default function Shell() {
1088
  movePage(key, null);
1089
  }}
1090
  >
1091
- {/* C-SCHEMA: the list folds under the user's folders. The collapsed
1092
- rail renders FLAT (folder chrome has no room at 44px) β€” members
1093
- stay reachable, the folders wait for the rail to open. */}
1094
- {foldNav(dbEntries, navCollapsed ? EMPTY_NAV_PREFS : navPrefs, closedFolders).map(
 
 
 
1095
  (row) => {
1096
  if (row.kind === "folder") {
1097
  return (
@@ -1100,7 +1545,9 @@ export default function Shell() {
1100
  folder={row.folder}
1101
  count={row.count}
1102
  open={row.open}
1103
- collapsed={navCollapsed}
 
 
1104
  onToggle={() => toggleFolder(row.folder.id)}
1105
  onRename={(name) => renameFolder(row.folder.id, name)}
1106
  onDelete={() => deleteFolder(row.folder.id)}
@@ -1171,16 +1618,18 @@ export default function Shell() {
1171
  </div>
1172
  );
1173
  }
1174
- // Owner item 3 β€” collapsed, the icon names its database on hover.
1175
- const tip = navCollapsed
1176
- ? {
1177
- onMouseEnter: tipEnter(item.label),
1178
- onMouseLeave: tipLeave,
1179
- }
1180
- : {};
 
 
1181
  const link =
1182
  item.kind === "native" ? (
1183
- <a className={cls} href={item.href} {...tip}>
1184
  {inner}
1185
  </a>
1186
  ) : (
@@ -1189,7 +1638,7 @@ export default function Shell() {
1189
  href={item.href}
1190
  target="_blank"
1191
  rel="noreferrer"
1192
- {...tip}
1193
  >
1194
  {inner}
1195
  </a>
@@ -1206,7 +1655,13 @@ export default function Shell() {
1206
  (row.folderId ? " is-foldered" : "") +
1207
  (dragKey === item.key ? " is-dragging" : "")
1208
  }
1209
- draggable={!navCollapsed && item.depth === 0}
 
 
 
 
 
 
1210
  onDragStart={(e) => {
1211
  e.dataTransfer.setData(NAV_DRAG_TYPE, item.key);
1212
  e.dataTransfer.effectAllowed = "move";
@@ -1218,7 +1673,7 @@ export default function Shell() {
1218
  }}
1219
  >
1220
  {link}
1221
- {!navCollapsed && item.depth === 0 && (
1222
  <RowMenu
1223
  entryLabel={item.label}
1224
  canSchema
@@ -1287,24 +1742,60 @@ export default function Shell() {
1287
  );
1288
  }
1289
  )}
1290
- {/* Wave 14 C-NAVFOLD β€” the folder creator moved here from the row menu (R10).
1291
- WAVE 19 R11 β€” and "+ New database" moved INTO it: one "+ Create new…" row
1292
- offering Folder | Database, replacing two affordances that sat in different
1293
- places and looked like different kinds of thing.
1294
- ⚠ The `entries.length > 0` gate that used to guard this row is now the
1295
- `canFolder` PROP, not a reason to hide the row: a tenant with no databases
1296
- has nothing to file in a folder but is exactly the tenant who needs to make
1297
- a database. It renders whenever the nav has answered. */}
1298
- {nav.phase === "ready" ? (
1299
- <CreateNewRow
1300
- collapsed={navCollapsed}
1301
- onExpand={() => setNavCollapsed(false)}
1302
- onCreateFolder={createFolder}
1303
- onNewDatabase={() => setNewDb({ name: "", busy: false, err: "" })}
1304
- canFolder={entries.length > 0}
1305
- />
1306
- ) : null}
1307
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1308
  {/* Wave 17 item 3 (R6) β€” the mark, never the word. "Loading…" under an
1309
  empty rail told the reader what they could already see, and it read
1310
  as a nav ITEM for the beat before it vanished. */}
@@ -1320,22 +1811,13 @@ export default function Shell() {
1320
  ) : null}
1321
  {/* ⚠ Wave 18: this line is for a MISCONFIGURED ACCOUNT β€” one whose grants give it
1322
  nothing β€” and it must not fire for a freshly provisioned TENANT, which has no
1323
- modules by design and is being welcomed by the hero in the main pane. Both at
1324
- once said "something is wrong here" and "welcome, start here" in one screen
1325
- (caught in the close-out visual pass, not by any gate). The rail's own "New
1326
- database" button below is the honest affordance in the tenant case. */}
1327
  {nav.phase === "ready" && entries.length === 0 && !tenantEmptyState ? (
1328
  <div className="shell-nav-note">No surfaces are available to this account.</div>
1329
  ) : null}
1330
- {/* β›” THE STANDALONE "+ New database" BUTTON WAS HERE, AND WAVE 19 R11 FOLDED
1331
- IT INTO "+ Create new…" at the bottom of the database list above. It was the
1332
- louder half of the owner's complaint about this rail: a `<button>` wearing the
1333
- user agent's border and grey fill (this class never reset either β€” see the
1334
- index.css note), sitting BELOW the list's own quiet "+ New folder" and offering
1335
- a sibling action in a completely different visual register. One row, one place,
1336
- two things to create. The collapsed-rail behaviour it carried survives inside
1337
- `CreateNewRow`: from the 56px strip the "+" opens the rail rather than a
1338
- popover that would read as coming from nowhere. */}
1339
  </nav>
1340
 
1341
  <div
@@ -1397,7 +1879,39 @@ export default function Shell() {
1397
  ) : active?.kind === "native" && active.key === "automation" ? (
1398
  // Wave 18 (C-AUTONAV): the Automation surface β€” its OWN secondary rail + editor,
1399
  // props-free by contract (it fetches /api/v1/automations itself). SESSION D's tree.
1400
- <AutomationSurface />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1401
  ) : active?.kind === "native" ? (
1402
  // The native grid surfaces β€” ONE component, topic decided by the route. Wave 16:
1403
  // the `#/cohort` route left with the cohort registry row (cohorts are LOCKED VIEWS
@@ -1432,42 +1946,54 @@ export default function Shell() {
1432
  </div>
1433
  ) : active ? (
1434
  <StranglerPage entry={active} />
1435
- ) : nav.phase === "ready" || nav.phase === "error" ? (
1436
- // ⚠ WAVE 17 ITEM 4 (R6) β€” THIS BRANCH IS ONLY REACHED HONESTLY ONCE
1437
- // THE NAV HAS ANSWERED. `active` is resolved from `entries`, which is
1438
- // empty while the nav is in flight, so every first login used to land
1439
- // here and be told "This account has access to no surfaces yet" β€”
1440
- // stating, as fact, the single worst thing this frame could say to a
1441
- // new user, for as long as the round trip took. The sentence is
1442
- // RIGHT for a nav that came back empty and WRONG for one that has not
1443
- // come back, and those two were indistinguishable. They are not now.
1444
- nav.phase === "ready" ? (
1445
- // Wave 18 (C1-TENANT, R4): a READY nav with zero entries is a freshly provisioned
1446
- // tenant (the server says so explicitly β€” a misconfigured account 403s and lands
1447
- // on the error branch instead). The honest next step is the empty state's whole
1448
- // content: create the first database.
1449
- <div className="shell-placeholder shell-tenant-hero">
1450
- <h1>Welcome</h1>
1451
- <p>
1452
- {/* WAVE 19 R12 β€” the pointer follows the rename. A sentence naming a
1453
- door by its old label is a wrong instruction, not a stale comment. */}
1454
- This workspace has no databases yet. Create your first one below, or store a
1455
- data source's keys under Settings β†’ Keychains to connect one later.
1456
- </p>
1457
- <button
1458
- className="login-submit shell-hero-create"
1459
- type="button"
1460
- onClick={() => setNewDb({ name: "", busy: false, err: "" })}
1461
- >
1462
- Create your first database
1463
- </button>
1464
- </div>
1465
- ) : (
1466
- <div className="shell-placeholder">
1467
- <h1>Nothing to show</h1>
1468
- <p>The navigation service did not answer. Reload to retry.</p>
1469
- </div>
1470
- )
 
 
 
 
 
 
 
 
 
 
 
 
1471
  ) : (
1472
  // Still asking (`loading`, or the `idle` beat before the effect runs).
1473
  <div className="shell-loading">
@@ -1519,41 +2045,104 @@ export default function Shell() {
1519
  onClick={(e) => e.stopPropagation()}
1520
  >
1521
  <h2>New database</h2>
1522
- <p className="shell-newdb-sub">
1523
- A blank database in this workspace. Add fields and records once it opens β€”
1524
- nothing here connects to a source.
1525
- </p>
1526
- <input
1527
- autoFocus
1528
- className="shell-newdb-input"
1529
- placeholder="Database name"
1530
- value={newDb.name}
1531
- maxLength={60}
1532
- disabled={newDb.busy}
1533
- onChange={(e) => setNewDb({ ...newDb, name: e.target.value })}
1534
- onKeyDown={(e) => {
1535
- if (e.key === "Enter") void createDb();
1536
- if (e.key === "Escape" && !newDb.busy) setNewDb(null);
1537
- }}
1538
- />
1539
- {newDb.err ? <p className="shell-newdb-err">{newDb.err}</p> : null}
1540
- <div className="shell-newdb-actions">
1541
  <button
1542
  type="button"
1543
- onClick={() => setNewDb(null)}
 
 
1544
  disabled={newDb.busy}
 
1545
  >
1546
- Cancel
 
 
 
1547
  </button>
1548
  <button
1549
  type="button"
1550
- className="login-submit"
1551
- onClick={() => void createDb()}
1552
- disabled={newDb.busy || !newDb.name.trim()}
 
 
1553
  >
1554
- {newDb.busy ? "Creating…" : "Create database"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1555
  </button>
1556
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1557
  </div>
1558
  </div>
1559
  ) : null}
@@ -1594,6 +2183,21 @@ export default function Shell() {
1594
  new CustomEvent("aios:view-open", { detail: { topic, viewId } })
1595
  );
1596
  }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1597
  />
1598
  ) : null}
1599
 
 
31
  // ---------------------------------------------------------------------------
32
 
33
  import { useCallback, useEffect, useRef, useState } from "react";
34
+ import { createPortal } from "react-dom";
35
+ import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
36
  import AutomationSurface from "../automation/AutomationSurface";
37
  import CustomerGrid from "../customer-grid/CustomerGrid";
38
  import { clearCustomersCache } from "../customer-grid/apiBridge";
39
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
40
  // ROWS_STALE_EVENT left with `addRecord` (wave 20 item 4): the shell no longer writes rows,
41
  // so it no longer has to tell the grid that it did.
42
+ import { API_V1, AUTOMATION_CREATE_EVENT, AUTOMATION_OPEN_EVENT, CREDENTIALS, DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT, signal } from "../apiContract";
43
  import { PageSurface } from "../pages/PageSurface";
44
  import { SettingsModal } from "../settings/SettingsModal";
45
  import type { SettingsSection } from "../settings/SettingsModal";
46
  import { Brand } from "./Brand";
47
  import LoginPage from "./LoginPage";
48
+ import { CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, MAX_NAV_FOLDERS, appLink, dbChipClass, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, postOpened, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
49
+ import type { NavEntry, NavMetaPatch, NavPage, NavPrefs, Recent } from "./nav";
50
+ import HomePage from "../home/HomePage";
51
+ import TemplatePicker from "../home/TemplatePicker";
52
+ import ConnectorsPage from "../connectors/ConnectorsPage";
53
+ // WAVE 23 C9 (W23-W2) β€” SESSION D's public page. A STATIC import, not a lazy one: the wave's own
54
+ // rule is that a required import fails the build when the file is not there, which is the whole
55
+ // difference between a mount and an intention (verify_wiring's header).
56
+ import FormPublic from "../forms/FormPublic";
57
+ import { mergeRecents } from "../home/homeModel";
58
  import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras";
59
  import AlertsPane from "../alerts/AlertsPane";
60
  import { createAlert, fetchInbox } from "../alerts/alertsApi";
 
92
  /** A stable identity for "no nav yet" β€” see its use below. */
93
  const NO_ENTRIES: NavEntry[] = [];
94
 
95
+ /**
96
+ * WAVE 23 C10 β€” the Database flyout's anchor, CLAMPED to the viewport.
97
+ *
98
+ * β›” `NavExtras.MenuShell` clamps both axes and this panel first did not, which is the same
99
+ * omission with a bigger blast radius: the Database button sits ~200px down the rail and the
100
+ * panel is up to 560px tall, so on a short laptop viewport its create footer β€” the three rows
101
+ * that are the whole point of the redesign β€” lands below the fold with no way to scroll to it
102
+ * (the panel's own scroll is INSIDE the list, deliberately, so the footer stays put).
103
+ *
104
+ * The height here is the CSS `max-height: min(70vh, 560px)`, restated. Two copies of one number
105
+ * is a real cost; the alternative is measuring the panel after it mounts, which means rendering
106
+ * it in the wrong place for a frame. Named and kept beside the rule it mirrors.
107
+ */
108
+ const DBFLY_MAX_H = 560;
109
+
110
+ export function flyoutAt(rect: { right: number; top: number }, vw: number, vh: number) {
111
+ const h = Math.min(Math.round(vh * 0.7), DBFLY_MAX_H);
112
+ return {
113
+ x: Math.min(Math.round(rect.right + 6), Math.max(8, vw - 268 - 8)),
114
+ y: Math.max(8, Math.min(Math.round(rect.top), vh - h - 8)),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * ⭐ THE ONE PLACE the Database flyout's position is measured off its button (wave-23 close-out).
120
+ *
121
+ * Two open-paths grew this wave β€” the button's own click, and "+ Create new…" opening the panel so
122
+ * a just-named folder is visible β€” and each measured the element itself. That is the shape D-20
123
+ * exists to prevent, one pattern over: **the null case and the measurement should live in exactly
124
+ * one place**, or the second caller is one refactor away from forgetting the guard.
125
+ *
126
+ * ⚠ Honest note on the gate, because the fix should not be mistaken for a rename. `verify_overlay`
127
+ * greps for a LITERAL token β€” a variable named `anchor`, followed by a measure call β€” so the
128
+ * click-path (which reads `e.currentTarget`) always passed while the ref-path failed. The needle
129
+ * is a proxy for "a call site measured its own anchor", and a proxy can be satisfied by renaming a
130
+ * variable. This change satisfies it STRUCTURALLY instead: there is now one measurement, one null
131
+ * guard, and no second call site to keep honest. Booked as DEBT (D-49) so the gate's real subject
132
+ * (anchor-rect logic outside the overlay layer) can be tightened deliberately rather than by grep.
133
+ *
134
+ * β›” AND DO NOT SPELL THE TOKEN OUT IN THIS COMMENT. The first draft of this note quoted the exact
135
+ * string it was describing, and the gate β€” which greps raw file text β€” matched the explanation and
136
+ * went red on a file with no defect in it. Prose about a pattern IS the pattern to a text scan;
137
+ * the same trap as a marker comment placed mid-selector (verify_catalog, wave 21).
138
+ */
139
+ export function flyoutFrom(el: HTMLElement | null) {
140
+ if (!el) return null;
141
+ return flyoutAt(el.getBoundingClientRect(), window.innerWidth, window.innerHeight);
142
+ }
143
+
144
+ /**
145
+ * WAVE 23 C9 (wiring W23-W2) β€” the PUBLIC form route: `#/form/<token>`.
146
+ *
147
+ * β›” THE TOKEN IS WHITELISTED, NOT SLICED. `secrets.token_urlsafe` mints `[A-Za-z0-9_-]`, so
148
+ * anything else in that position is not a token this product issued β€” and passing it through
149
+ * would let a crafted hash decide what `FormPublic` puts in a URL. Refusing here means the frame
150
+ * falls through to its normal routing (and the visitor meets the login page), which is the
151
+ * correct answer for a link that is not one of ours.
152
+ *
153
+ * `null` for every other route, so the branch that reads it is a single truthiness test.
154
+ */
155
+ export function formTokenOf(route: string): string | null {
156
+ const m = /^form\/([A-Za-z0-9_-]{8,128})$/.exec(route);
157
+ return m ? m[1] : null;
158
+ }
159
+
160
  type Session =
161
  | { phase: "checking" }
162
  | { phase: "anon" }
 
165
  type Nav =
166
  | { phase: "idle" }
167
  | { phase: "loading" }
168
+ | {
169
+ phase: "ready";
170
+ entries: NavEntry[];
171
+ utility: NavPage[];
172
+ /** WAVE 23 C10 β€” the Home landing's recently-opened list, per user, off the nav payload. */
173
+ recents: Recent[];
174
+ empty?: string;
175
+ }
176
  | { phase: "error" };
177
 
178
  const NO_UTILITY: NavPage[] = [];
179
+ /** Stable identity, like `NO_ENTRIES` β€” a fresh `[]` per render would re-run Home's memo. */
180
+ const NO_RECENTS: Recent[] = [];
181
 
182
  function useHashRoute(): string {
183
  const read = () => window.location.hash.replace(/^#\/?/, "");
 
256
  * The chip is decorative and says so: the name beside it is real text, so a
257
  * second announcement of the same fact is noise to a screen reader.
258
  */
259
+ function DbHead({ label, icon, glyph }: { label: string; icon?: FolderIcon; glyph?: ReactNode }) {
260
  return (
261
  <div className="shell-db-head">
262
  <span className={dbChipClass(icon)} aria-hidden="true">
 
264
  the same pair the rail row draws, so the header and the nav agree. Both
265
  paint in the chip's ink (the stylesheet's two overrides): `FolderMark`
266
  would otherwise stroke its pastel `-deep`, which is measured against the
267
+ WHITE rail and disappears on its own tone.
268
+
269
+ WAVE 23 C13 β€” `glyph` overrides only the FALLBACK, never a chosen mark, and
270
+ that ordering is the contract. Automation is not a database and must not wear
271
+ the cylinder; but it CAN wear a `nav_meta` icon (the rail already draws one,
272
+ Shell:1026), and a header that ignored it would be the one surface where the
273
+ rail and the frame disagreed about the same row. So: chosen icon > caller's
274
+ glyph > the cylinder. */}
275
+ {icon ? <FolderMark icon={icon} size={16} /> : (glyph ?? <DbIcon />)}
276
  </span>
277
  {/* An `h1`, not a styled span: this is the first time the work surface has NAMED itself,
278
  and the name of the thing you are looking at is what a heading is for. Every other
 
332
  );
333
  }
334
 
335
+ /** WAVE 23 R7 β€” Home's mark. A house, in the same 16x16 stroke vocabulary as its neighbours;
336
+ * the reference rail uses one and there is no more literal glyph for "the landing". */
337
+ function HomeIcon() {
338
+ return (
339
+ <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
340
+ <path d="M2.4 7.2 8 2.6l5.6 4.6" />
341
+ <path d="M3.9 8.2v5.2h8.2V8.2" />
342
+ </svg>
343
+ );
344
+ }
345
+
346
+ /** WAVE 23 R8 β€” Connectors: two links of a chain, which is exactly what a connector is. The
347
+ * same glyph the Home card draws, so the nav row and the card that leads to it agree. */
348
+ function PlugIcon() {
349
+ return (
350
+ <svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
351
+ <path d="M6.7 9.3 9.3 6.7" />
352
+ <path d="M7.6 4.6 9.1 3.1a2.7 2.7 0 0 1 3.8 3.8l-1.5 1.5" />
353
+ <path d="M8.4 11.4l-1.5 1.5a2.7 2.7 0 0 1-3.8-3.8l1.5-1.5" />
354
+ </svg>
355
+ );
356
+ }
357
+
358
+ /** WAVE 23 C10 β€” the flyout footer's plus. Same geometry as `NavExtras`' own `PlusIcon` (which
359
+ * is private to that file); drawn rather than imported, the way this tree already draws two
360
+ * bells. */
361
+ function PlusMark() {
362
+ return (
363
+ <svg className="shell-dbfly-plus" viewBox="0 0 16 16" aria-hidden="true">
364
+ <path d="M8 3.4v9.2M3.4 8h9.2" />
365
+ </svg>
366
+ );
367
+ }
368
+
369
+ /** WAVE 23 R7 β€” the disclosure caret on the "Database" row: this opens a panel, and the mark
370
+ * says so before the click does (the same job `ExtIcon` does for a hand-off). */
371
+ function CaretIcon() {
372
+ return (
373
+ <svg className="shell-nav-open shell-nav-caret" viewBox="0 0 12 12" aria-hidden="true">
374
+ <path d="M4.4 2.6 8 6l-3.6 3.4" />
375
+ </svg>
376
+ );
377
+ }
378
+
379
  /** Owner item 11 β€” the rail toggle: three horizontal bars. One glyph for both rails (the
380
  * views rail draws the same geometry), so "minimize a panel" reads as one idea. */
381
  function RailToggleIcon() {
 
569
  const [alertsOpen, setAlertsOpen] = useState(false);
570
  const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX);
571
  const route = useHashRoute();
572
+ // WAVE 23 C9 β€” non-null on `#/form/<token>` only. Computed here rather than inside the render
573
+ // branch because the hash normaliser below has to read it too, and two copies of "is this the
574
+ // public form?" is how one of them ends up answering differently.
575
+ const formToken = formTokenOf(route);
576
  // Owner items 10/11 β€” the navigation folds to a slim strip: by the toggle in the rail head,
577
  // or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
578
  // grid). Remembered per browser; expanding is always one click on the same toggle.
 
628
  setNavCollapsed(false);
629
  }, []);
630
 
631
+ // ── WAVE 23 items 9/10 (R7/R8, contract C10): the DATABASE FLYOUT ──────────────────────────
632
+ //
633
+ // R7 takes the databases OUT of the rail: the always-on `.shell-nav-list` band becomes a panel
634
+ // behind a "Database" button, so the rail is a fixed set of destinations instead of a list
635
+ // whose height is a function of how many tables the tenant made.
636
+ //
637
+ // `dbAt` is the anchor rect (null = shut), `dbQuery` the search. The panel renders OUTSIDE the
638
+ // `<aside>` and `position: fixed`, for the same reason `shell-nav-tip` does: the rail scrolls
639
+ // and clips, so anything that must escape it cannot be its descendant.
640
+ const [dbAt, setDbAt] = useState<{ x: number; y: number } | null>(null);
641
+ const [dbQuery, setDbQuery] = useState("");
642
+ const dbPanel = useRef<HTMLDivElement | null>(null);
643
+ const dbButton = useRef<HTMLButtonElement | null>(null);
644
+ const closeDbFly = useCallback(() => {
645
+ setDbAt(null);
646
+ setDbQuery("");
647
+ }, []);
648
+ // ⚠ Declared here and READ inside the close effect below, because that effect must not
649
+ // re-subscribe on every drag state change β€” and because the guard it needs is "a drag is in
650
+ // flight", which is state this component already holds (`dragKey`, declared further down with
651
+ // the other prefs state). A ref keeps the effect's dependency list honest.
652
+ const draggingRef = useRef(false);
653
+
654
+ /**
655
+ * β›” THREE WAYS OUT, AND ONE THING THAT MUST NOT CLOSE IT.
656
+ *
657
+ * Out: an outside mousedown, Escape, or a database link (handled at the link β€” a click on the
658
+ * row's β‹― must NOT close the panel the menu is anchored inside).
659
+ *
660
+ * NOT out: a DRAG. This panel carries the folder drag-and-drop the rail used to (`NAV_DRAG_TYPE`,
661
+ * the `__root__` drop target, `draggable` rows). A pointer that leaves the panel mid-drag would
662
+ * otherwise trip the outside-click rule and unmount the drop target under the cursor β€” the
663
+ * folder move silently fails and the panel vanishes, which reads as a crash. The wirings this
664
+ * wave must keep green (W-3/W-5 live on these rows) would still pass, because a gate cannot
665
+ * see a drop that never lands.
666
+ *
667
+ * And NOT the trigger button: without that exemption the mousedown closes the panel and the
668
+ * button's own click immediately reopens it, so the control could never be used to shut the
669
+ * thing it opened. (`useMenu` in NavExtras has this quirk; a rail button is used far more often
670
+ * than a row's β‹―, so it is worth the ref here.)
671
+ */
672
+ useEffect(() => {
673
+ if (!dbAt) return;
674
+ const onDown = (e: MouseEvent) => {
675
+ if (draggingRef.current) return;
676
+ const t = e.target as Node | null;
677
+ if (dbPanel.current?.contains(t as Node)) return;
678
+ if (dbButton.current?.contains(t as Node)) return;
679
+ closeDbFly();
680
+ };
681
+ const onKey = (e: KeyboardEvent) => {
682
+ if (e.key === "Escape") closeDbFly();
683
+ };
684
+ document.addEventListener("mousedown", onDown, true);
685
+ document.addEventListener("keydown", onKey, true);
686
+ return () => {
687
+ document.removeEventListener("mousedown", onDown, true);
688
+ document.removeEventListener("keydown", onKey, true);
689
+ };
690
+ }, [dbAt, closeDbFly]);
691
+
692
+ // ── WAVE 23 C10: the recents stamp ────────────────────────────────────────────────────────
693
+ //
694
+ // `opened` mirrors the stamps this session made, so Home shows the database you opened one
695
+ // click ago rather than the state of the world at sign-in (`mergeRecents`' own note). It is
696
+ // NOT persisted: a reload re-asks the server, which is the only end that knows.
697
+ const [opened, setOpened] = useState<Record<string, number>>({});
698
+ const lastStamped = useRef("");
699
+
700
  // The window signals the data layer raises (apiContract.ts). It talks to the
701
  // frame this way because `customer-grid/**` is host-neutral β€” the same tree
702
  // the Streamlit embed ships β€” and must not import a shell.
 
796
  // the host does (`core.perms.nav_pages`' own contract).
797
  const { main, utility } = splitChrome(r.pages);
798
  setNav({ phase: "ready", entries: shapeNav(main, APP_BASE), utility,
799
+ recents: r.recents,
800
  ...(r.empty ? { empty: r.empty } : {}) });
801
  }
802
  // 401 is not "the nav is broken", it is "the session died under us" β€”
 
895
  // scar); `dropTarget` is a folder id or "__root__" (drag out = drop on the list itself).
896
  const [dragKey, setDragKey] = useState<string | null>(null);
897
  const [dropTarget, setDropTarget] = useState<string | null>(null);
898
+ // WAVE 23 C10 β€” read by the flyout's outside-click rule, which must NOT fire mid-drag (see
899
+ // its note). Assigned during render like `navPrefsRef` above: the effect keeps a stable
900
+ // dependency list and still sees the current answer.
901
+ draggingRef.current = dragKey !== null;
902
  const createFolder = useCallback(
903
  (name: string) => {
904
  const cur = navPrefsRef.current;
 
908
  }
909
  const id = `nf_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
910
  commitPrefs({ folders: [...cur.folders, { id, name }], placement: cur.placement });
911
+ // ⭐ WAVE 23 C10 β€” SHOW THE THING THAT WAS JUST MADE. "+ Create new…" stayed in the RAIL
912
+ // while the folders it creates moved into the FLYOUT, so naming a folder produced no
913
+ // visible result at all: the only surface that draws one was a closed panel. The
914
+ // affordance and its effect ended up in different places, which is the class of defect
915
+ // this rework was supposed to remove rather than introduce. Opening the panel is the
916
+ // smaller of the two fixes and keeps C10's rail order exactly as ruled.
917
+ const at = flyoutFrom(dbButton.current);
918
+ if (at) setDbAt(at);
919
  },
920
  [commitPrefs]
921
  );
 
1029
 
1030
  // Keep the URL honest in both directions: an anonymous shell sits on
1031
  // `#/login`, and a signed-in one never does.
1032
+ //
1033
+ // ⭐ WAVE 23 C10 β€” THE LANDING MOVED, AND ALL THREE SITES MOVED TOGETHER. `LANDING_PREFERENCE`
1034
+ // and `defaultRoute` are the other two (nav.ts); this is the redirect a fresh sign-in takes.
1035
+ // C10's own warning is that a half-move strands logins on a dead hash, so:
1036
+ //
1037
+ // Β· the `entries.length` gate is GONE. It existed because `defaultRoute([])` was `""` β€” a
1038
+ // redirect to `#/` β€” so the frame had to wait for the nav before it could name a landing.
1039
+ // `home` is chrome and needs no entry, so there is nothing left to wait for, and the
1040
+ // tenant whose nav is legitimately empty (a fresh workspace, wave 18) now lands somewhere
1041
+ // real instead of sitting on the login hash until a database exists.
1042
+ // Β· an EMPTY hash normalises to the landing too. `removeDatabase` sets `hash = ""` after a
1043
+ // delete and relies on this; before, an empty hash silently rendered `customer_data` while
1044
+ // the URL said nothing.
1045
  useEffect(() => {
1046
+ // β›” WAVE 23 C9 β€” THE PUBLIC FORM IS EXEMPT FROM BOTH DIRECTIONS. Without the first
1047
+ // exemption an anonymous visitor at `#/form/<token>` is bounced to `#/login` before the
1048
+ // render branch is ever reached, and the public door is public only to people who already
1049
+ // have an account. Without the second, a SIGNED-IN person testing their own form link is
1050
+ // yanked to Home mid-read.
1051
+ if (formToken) return;
1052
  if (session.phase === "anon" && route !== LOGIN_ROUTE) {
1053
  window.location.hash = `#/${LOGIN_ROUTE}`;
1054
+ } else if (session.phase === "authed" && (route === LOGIN_ROUTE || route === "")) {
1055
  window.location.hash = `#/${defaultRoute(entries)}`;
1056
  }
1057
+ }, [session.phase, route, entries, formToken]);
1058
+
1059
+ // WAVE 23 C10 β€” stamp the route as opened, for Home's recents.
1060
+ //
1061
+ // ⚠ ABOVE THE EARLY RETURNS with every other hook (the React #310 scar at the block below),
1062
+ // so it re-resolves the route itself rather than reading the `active` computed after them.
1063
+ //
1064
+ // THREE THINGS IT DELIBERATELY DOES NOT STAMP: a chrome route (Home and Connectors resolve to
1065
+ // no entry, so `hit` is undefined); a route the nav has not answered for yet (same reason β€”
1066
+ // `entries` is empty in flight, and stamping an unresolved key would record a page that may
1067
+ // not exist); and a hand-off, which opens the current application in another tab and is not a
1068
+ // surface this shell can put a recents tile back into.
1069
+ //
1070
+ // `lastStamped` suppresses the REPEAT write a `navEpoch` bump would otherwise cause (new
1071
+ // `entries` identity, same route) β€” and clears whenever the route resolves to nothing, so
1072
+ // leaving a table and coming back to it does re-stamp, which is what makes the ordering on
1073
+ // Home mean "most recently opened".
1074
+ useEffect(() => {
1075
+ if (session.phase !== "authed") return;
1076
+ const hit = resolveRoute(entries, route);
1077
+ if (!hit || hit.kind !== "native") {
1078
+ lastStamped.current = "";
1079
+ return;
1080
+ }
1081
+ if (lastStamped.current === hit.key) return;
1082
+ lastStamped.current = hit.key;
1083
+ postOpened(hit.key);
1084
+ setOpened((cur) => ({ ...cur, [hit.key]: Math.floor(Date.now() / 1000) }));
1085
  }, [session.phase, route, entries]);
1086
 
1087
  const signOut = useCallback(() => {
 
1116
  return () => window.removeEventListener("keydown", onKey);
1117
  });
1118
  // (C3-UT): "+ New database" + the user-table Add-record bar.
1119
+ //
1120
+ // WAVE 23 C10 β€” the dialog grew a `mode`: the three-way choice R7 asks for (blank / from a
1121
+ // template / automated). `automated` never persists in this state β€” picking it closes the
1122
+ // dialog and hands off β€” so it exists in the union only because the CHOOSER offers it.
1123
+ const [newDb, setNewDb] = useState<null | {
1124
+ mode: "blank" | "template";
1125
+ name: string;
1126
+ busy: boolean;
1127
+ err: string;
1128
+ }>(null);
1129
  const newDbOpenRef = useRef(false);
1130
  newDbOpenRef.current = newDb !== null;
1131
+ const openNewDb = useCallback((mode: "blank" | "template") => {
1132
+ setNewDb({ mode, name: "", busy: false, err: "" });
1133
+ }, []);
1134
+ /**
1135
+ * WAVE 23 C2/C10 β€” the "automated database" door. Route, then ASK, exactly as the review
1136
+ * click-through does: the create wizard is `AutomationSurface`'s own state and this frame
1137
+ * neither holds it nor should learn to.
1138
+ */
1139
+ const openAutomated = useCallback(() => {
1140
+ setNewDb(null);
1141
+ window.location.hash = "#/automation";
1142
+ signal(AUTOMATION_CREATE_EVENT);
1143
+ }, []);
1144
  const createDb = useCallback(async () => {
1145
  setNewDb((cur) => {
1146
  if (!cur || cur.busy || !cur.name.trim()) return cur;
1147
  const name = cur.name;
1148
  void (async () => {
1149
  try {
1150
+ // WAVE 23 C10 β€” `source` rides the body the route ALREADY forwards
1151
+ // (`routes_tables.py:192` passes it to `user_tables.create`). It is the literal
1152
+ // `"Blank"` because that is the enum member the server accepts; ⚠ an unknown source is
1153
+ // SILENTLY DOWNGRADED to Blank there (user_tables.py:165), so a third literal invented
1154
+ // here would create a table that quietly disagrees with the word the dialog used.
1155
+ // "Automated" is not one of these values by design β€” that path is the automation
1156
+ // wizard's (`openAutomated`), and the engine stamps the source itself.
1157
  const res = await fetch(`${API_V1}/tables`, {
1158
  method: "POST",
1159
  credentials: CREDENTIALS,
1160
  headers: { "Content-Type": "application/json" },
1161
+ body: JSON.stringify({ label: name.trim(), source: "Blank" }),
1162
  });
1163
  const body = (await res.json().catch(() => null)) as
1164
  | { key?: string; error?: { message?: string } }
1165
  | null;
1166
  if (!res.ok || !body?.key) {
1167
+ setNewDb({ mode: "blank", name, busy: false,
1168
  err: body?.error?.message || `The server answered ${res.status}.` });
1169
  return;
1170
  }
 
1172
  setNavEpoch((e) => e + 1);
1173
  window.location.hash = `#/${body.key}`;
1174
  } catch {
1175
+ setNewDb({ mode: "blank", name, busy: false, err: "Cannot reach the server." });
1176
  }
1177
  })();
1178
  return { ...cur, busy: true, err: "" };
 
1185
  // trailing "+" row (S3's half), where the row it adds is the row you are looking at, so
1186
  // the shell stops being a second writer of table data.
1187
 
1188
+ // ⭐ WAVE 23 C9 (SESSION D's page, E's mount β€” wiring W23-W2) β€” THE PUBLIC FORM, BEFORE THE
1189
+ // AUTH WALL.
1190
+ //
1191
+ // β›” THE POSITION IS THE ENTIRE CONTRACT. It sits above BOTH early returns: above `anon`,
1192
+ // obviously, but also above `checking` β€” a person filling in a public form should not watch a
1193
+ // brand mark while `me()` resolves a session they do not have. It is below every hook, which
1194
+ // is the other half of the rule (the React #310 scar the block at :828 documents): the hook
1195
+ // count may not change between renders, and this branch would be the shortest possible path
1196
+ // to breaking that.
1197
+ //
1198
+ // ⚠ THE SMALLEST POSSIBLE BRANCH, deliberately (C9's own words). It reads a token out of the
1199
+ // hash and renders D's page; it does not fetch, does not touch the session, does not import
1200
+ // anything of the grid's. Nothing else in this frame is reachable from it.
1201
+ //
1202
+ // β›” AND THE HASH NORMALISER HAS TO KNOW. The effect at :812 pins an anonymous shell to
1203
+ // `#/login` β€” which would have bounced every anonymous form visitor to a login screen before
1204
+ // this branch ever rendered. `isPublicForm` is read there too; a mount without that exemption
1205
+ // is a public door that only signed-in people can reach.
1206
+ //
1207
+ // ⚠ The SERVER side of this is genuinely public (D's `routes_forms.py`, the automation-hook
1208
+ // pattern β€” uniform 403, constant-time compare). This branch adds no new public SURFACE: the
1209
+ // SPA bundle is already served unauthenticated (main.py:257-295, `_DEV_FIXTURES` guard), so
1210
+ // `#/form/<token>` has always loaded for free. What it adds is a client route that renders
1211
+ // something there instead of the login page.
1212
+ if (formToken) {
1213
+ return <FormPublic token={formToken} />;
1214
+ }
1215
+
1216
  if (session.phase === "checking") {
1217
  // Deliberately wordless. A "Loading…" line here would flash for one round
1218
  // trip on every load, and the mark says everything the moment needs.
 
1259
  // only granted surface is Automation that it has no surfaces at all.
1260
  const automation = entries.find((e) => e.key === "automation" && e.kind === "native");
1261
  const dbEntries = automation ? entries.filter((e) => e.key !== "automation") : entries;
1262
+ // WAVE 23 C10 β€” the flyout's search. A plain label substring, case-folded: this list is at
1263
+ // most a few dozen rows, so anything cleverer (fuzzy, ranked) would be a scoring function
1264
+ // nobody can predict over a set small enough to read.
1265
+ //
1266
+ // ⚠ SEARCHING RENDERS FLAT. `foldNav` is called with EMPTY prefs while a query is live,
1267
+ // because a folder head whose members were filtered out would report a count it is not
1268
+ // showing β€” the same "count derived from one list, rows from another" defect the R10
1269
+ // relocation note two blocks up already documents.
1270
+ const dbQ = dbQuery.trim().toLowerCase();
1271
+ const shownEntries = dbQ
1272
+ ? dbEntries.filter((e) => e.label.toLowerCase().includes(dbQ))
1273
+ : dbEntries;
1274
+ // WAVE 23 C10 β€” what Home draws: the server's recents plus this session's own stamps.
1275
+ const recents = mergeRecents(nav.phase === "ready" ? nav.recents : NO_RECENTS, opened);
1276
 
1277
  return (
1278
  <div className="shell-root">
 
1315
  </div>
1316
 
1317
  <nav className="shell-nav">
1318
+ {/* ⭐ WAVE 23 item 9 (R7, contract C10) β€” HOME, the new landing, at the top of the rail.
1319
+ An `<a>` to a CHROME route: it needs no grant because it renders nothing the server
1320
+ did not already send (nav.ts' `CHROME_ROUTES` note carries the full argument, and
1321
+ the :1039 law below is unchanged β€” an undeclared SURFACE is still denied). */}
1322
+ <a
1323
+ className={"shell-nav-item shell-nav-home" + (route === HOME_ROUTE ? " is-active" : "")}
1324
+ href={`#/${HOME_ROUTE}`}
1325
+ onMouseEnter={navCollapsed ? tipEnter("Home") : undefined}
1326
+ onMouseLeave={navCollapsed ? tipLeave : undefined}
1327
+ >
1328
+ <HomeIcon />
1329
+ <span className="shell-nav-label">Home</span>
1330
+ </a>
1331
+
1332
  {/* The Analyst slot, above the database list β€” the host's own IA
1333
  (app.py:8166 pins "AI assistant" over the nav tree, same label,
1334
  same sparkle). Wave 18 (owner item 3): the hand-off into Streamlit
 
1417
  ) : null}
1418
  </button>
1419
 
1420
+ {/* ⭐ WAVE 23 item 10 (R8, contract C11, wiring W23-W3) β€” CONNECTORS, directly below
1421
+ Alerts, exactly where R8 puts it. A chrome route like Home: the DIRECTORY it renders
1422
+ is composed by the server and session-gated, so this row can no more invent a
1423
+ connector than Home can invent a database. */}
1424
+ <a
1425
+ className={
1426
+ "shell-nav-item shell-nav-connectors" +
1427
+ (route === CONNECTORS_ROUTE ? " is-active" : "")
1428
+ }
1429
+ href={`#/${CONNECTORS_ROUTE}`}
1430
+ onMouseEnter={navCollapsed ? tipEnter("Connectors") : undefined}
1431
+ onMouseLeave={navCollapsed ? tipLeave : undefined}
1432
+ >
1433
+ <PlugIcon />
1434
+ <span className="shell-nav-label">Connectors</span>
1435
+ </a>
1436
+
1437
+ {/* ⭐ WAVE 23 item 9 (R7) β€” THE DATABASE BUTTON, and the end of the always-on band.
1438
+ R7: "databases LEAVE the always-on rail; a 'Database' nav button opens the flyout".
1439
+ The rail is now a fixed set of destinations whose height does not depend on how many
1440
+ tables the tenant made β€” which is the actual complaint behind the ruling.
1441
+ β›” A BUTTON, NOT A ROUTE, and for the same reason Alerts is one (:1039): there is no
1442
+ `#/databases` surface and inventing one would be the hard-coded page this frame
1443
+ refuses to have. It opens a panel over the list the server already sent. */}
1444
+ <button
1445
+ type="button"
1446
+ ref={dbButton}
1447
+ className={"shell-nav-item shell-nav-db" + (dbAt ? " is-active" : "")}
1448
+ aria-haspopup="dialog"
1449
+ aria-expanded={!!dbAt}
1450
+ onMouseEnter={navCollapsed ? tipEnter("Database") : undefined}
1451
+ onMouseLeave={navCollapsed ? tipLeave : undefined}
1452
+ onClick={(e) => {
1453
+ if (dbAt) {
1454
+ closeDbFly();
1455
+ return;
1456
+ }
1457
+ const at = flyoutFrom(e.currentTarget);
1458
+ if (at) setDbAt(at);
1459
+ }}
1460
+ >
1461
+ <DbIcon />
1462
+ <span className="shell-nav-label">Database</span>
1463
+ <CaretIcon />
1464
+ </button>
1465
+
1466
+ {/* Wave 14 C-NAVFOLD / WAVE 19 R11 β€” "+ Create new…", now a RAIL row rather than the
1467
+ foot of a list that no longer lives here. C10 pins it between Database and the
1468
+ account corner, which is where R11 always meant it to read: the last thing in the
1469
+ rail is the thing that adds to it. */}
1470
+ {nav.phase === "ready" ? (
1471
+ <CreateNewRow
1472
+ collapsed={navCollapsed}
1473
+ onExpand={() => setNavCollapsed(false)}
1474
+ onCreateFolder={createFolder}
1475
+ onNewDatabase={() => openNewDb("blank")}
1476
+ canFolder={entries.length > 0}
1477
+ />
1478
+ ) : null}
1479
+
1480
+ {/* ── THE DATABASE FLYOUT (C10) ────────────────────────────────────────────────────
1481
+ β›” PORTALLED TO `<body>`, and it has to be. Left inside the `<aside>` it would be a
1482
+ descendant of `.shell-side.is-collapsed`, whose rules hide `.shell-nav-label`,
1483
+ `.shell-nav-badge` and `.shell-nav-open` β€” so with the rail folded the panel would
1484
+ render a column of unlabelled icons, which is the one state this control exists to
1485
+ rescue the user from. `createPortal` is already this tree's answer for exactly this
1486
+ (OverlaySurface, CatalogView); the panel is not part of the rail, it only points at
1487
+ it. */}
1488
+ {dbAt
1489
+ ? createPortal(
1490
+ <div
1491
+ className="shell-dbfly"
1492
+ ref={dbPanel}
1493
+ role="dialog"
1494
+ aria-label="Databases"
1495
+ style={{ left: dbAt.x, top: dbAt.y }}
1496
+ >
1497
+ <div className="shell-dbfly-head">
1498
+ <input
1499
+ autoFocus
1500
+ className="shell-dbfly-search"
1501
+ placeholder="Search databases"
1502
+ value={dbQuery}
1503
+ maxLength={60}
1504
+ onChange={(e) => setDbQuery(e.target.value)}
1505
+ onKeyDown={(e) => {
1506
+ if (e.key === "Escape") closeDbFly();
1507
+ }}
1508
+ />
1509
+ </div>
1510
+ <div
1511
  className={
1512
  "shell-nav-list" + (dropTarget === "__root__" ? " is-drop-root" : "")
1513
  }
 
1530
  movePage(key, null);
1531
  }}
1532
  >
1533
+ {/* C-SCHEMA: the list folds under the user's folders.
1534
+ ⭐ WAVE 23 C10 β€” the `navCollapsed ? EMPTY_NAV_PREFS :` gate is GONE with the band
1535
+ it belonged to. It existed because the 44px strip had no room for folder chrome;
1536
+ this panel is 268px wide whatever the rail is doing, so the folders always render
1537
+ and the reason for the gate no longer exists. A SEARCH still flattens (see
1538
+ `shownEntries`) β€” that is a different fact about a different state. */}
1539
+ {foldNav(shownEntries, dbQ ? EMPTY_NAV_PREFS : navPrefs, closedFolders).map(
1540
  (row) => {
1541
  if (row.kind === "folder") {
1542
  return (
 
1545
  folder={row.folder}
1546
  count={row.count}
1547
  open={row.open}
1548
+ // WAVE 23 C10 β€” always false in the flyout: the panel has full width in
1549
+ // either rail state, so folder chrome is never the thing being squeezed.
1550
+ collapsed={false}
1551
  onToggle={() => toggleFolder(row.folder.id)}
1552
  onRename={(name) => renameFolder(row.folder.id, name)}
1553
  onDelete={() => deleteFolder(row.folder.id)}
 
1618
  </div>
1619
  );
1620
  }
1621
+ // ⭐ WAVE 23 C10 β€” the collapsed-rail hover tip is GONE from these rows, with
1622
+ // the band it belonged to. It named a database whose LABEL was hidden at 44px;
1623
+ // inside the flyout the label is always on screen, so the tip would have been a
1624
+ // second copy of the word beside itself. `tipEnter`/`tipLeave` still serve the
1625
+ // rail's own rows above.
1626
+ //
1627
+ // Clicking a database CLOSES the panel: you asked for it, you got it. The
1628
+ // handler is on the LINK and not on the list, deliberately β€” a click on the
1629
+ // row's β‹― must leave the panel open, because that menu is anchored inside it.
1630
  const link =
1631
  item.kind === "native" ? (
1632
+ <a className={cls} href={item.href} onClick={closeDbFly}>
1633
  {inner}
1634
  </a>
1635
  ) : (
 
1638
  href={item.href}
1639
  target="_blank"
1640
  rel="noreferrer"
1641
+ onClick={closeDbFly}
1642
  >
1643
  {inner}
1644
  </a>
 
1655
  (row.folderId ? " is-foldered" : "") +
1656
  (dragKey === item.key ? " is-dragging" : "")
1657
  }
1658
+ // WAVE 23 C10 β€” `!navCollapsed &&` dropped from both gates below: the panel
1659
+ // is the same width in either rail state, so the two things that gate
1660
+ // referred to (no horizontal room for a β‹―, no room to drag) are no longer
1661
+ // true. The folder-reorder drag and the row menu therefore keep working with
1662
+ // the rail folded, which is the state a user who opened this panel is most
1663
+ // likely to be in.
1664
+ draggable={item.depth === 0}
1665
  onDragStart={(e) => {
1666
  e.dataTransfer.setData(NAV_DRAG_TYPE, item.key);
1667
  e.dataTransfer.effectAllowed = "move";
 
1673
  }}
1674
  >
1675
  {link}
1676
+ {item.depth === 0 && (
1677
  <RowMenu
1678
  entryLabel={item.label}
1679
  canSchema
 
1742
  );
1743
  }
1744
  )}
1745
+ {/* The panel's own empty state: ONE line, and it is different from "this
1746
+ workspace has none" β€” a search that matched nothing is not a workspace
1747
+ with nothing in it, and saying the second when the first is true is how a
1748
+ reader concludes their data is gone. */}
1749
+ {shownEntries.length === 0 ? (
1750
+ <p className="shell-dbfly-empty">
1751
+ {dbQuery ? "No database matches that." : "No databases yet."}
1752
+ </p>
1753
+ ) : null}
1754
+ </div>
1755
+ {/* ── the create footer (C10) β€” three doors, ONE dialog ──────────────────
1756
+ Blank and template open the same New-database dialog with the choice
1757
+ pre-selected; automated hands off to the automation wizard, because an
1758
+ automated database is CREATED BY the automation that fills it (C2's
1759
+ `target.mode`), not by a name box that then needs one. */}
1760
+ <div className="shell-dbfly-foot">
1761
+ <button
1762
+ type="button"
1763
+ className="shell-dbfly-make"
1764
+ onClick={() => {
1765
+ closeDbFly();
1766
+ openNewDb("blank");
1767
+ }}
1768
+ >
1769
+ <PlusMark />
1770
+ Blank database
1771
+ </button>
1772
+ <button
1773
+ type="button"
1774
+ className="shell-dbfly-make"
1775
+ onClick={() => {
1776
+ closeDbFly();
1777
+ openNewDb("template");
1778
+ }}
1779
+ >
1780
+ <PlusMark />
1781
+ From a template
1782
+ </button>
1783
+ <button
1784
+ type="button"
1785
+ className="shell-dbfly-make"
1786
+ onClick={() => {
1787
+ closeDbFly();
1788
+ openAutomated();
1789
+ }}
1790
+ >
1791
+ <PlusMark />
1792
+ Automated database
1793
+ </button>
1794
+ </div>
1795
+ </div>,
1796
+ document.body
1797
+ )
1798
+ : null}
1799
  {/* Wave 17 item 3 (R6) β€” the mark, never the word. "Loading…" under an
1800
  empty rail told the reader what they could already see, and it read
1801
  as a nav ITEM for the beat before it vanished. */}
 
1811
  ) : null}
1812
  {/* ⚠ Wave 18: this line is for a MISCONFIGURED ACCOUNT β€” one whose grants give it
1813
  nothing β€” and it must not fire for a freshly provisioned TENANT, which has no
1814
+ modules by design and is being welcomed on Home. Both at once said "something is
1815
+ wrong here" and "welcome, start here" in one screen (caught in the close-out
1816
+ visual pass, not by any gate). "+ Create new…" above is the honest affordance in
1817
+ the tenant case. */}
1818
  {nav.phase === "ready" && entries.length === 0 && !tenantEmptyState ? (
1819
  <div className="shell-nav-note">No surfaces are available to this account.</div>
1820
  ) : null}
 
 
 
 
 
 
 
 
 
1821
  </nav>
1822
 
1823
  <div
 
1879
  ) : active?.kind === "native" && active.key === "automation" ? (
1880
  // Wave 18 (C-AUTONAV): the Automation surface β€” its OWN secondary rail + editor,
1881
  // props-free by contract (it fetches /api/v1/automations itself). SESSION D's tree.
1882
+ //
1883
+ // ⭐ WAVE 23 C13 (owner item 2), wiring W23-W1 β€” THE SAME FRAME AS EVERY DATABASE.
1884
+ //
1885
+ // This branch used to mount the surface BARE: no `shell-db-frame`, no `DbHead`. So
1886
+ // the one page in the product that is not a database was also the one page whose
1887
+ // name was drawn by its own component, at its own size and weight β€” three title
1888
+ // treatments (the surface's 16px/700 editable input, its 20px/600 empty-state h1,
1889
+ // and the frame's 16px/650 `shell-db-name`) against the grid's one. The fix is not
1890
+ // to restyle the stand-in but to delete the reason it exists: one title system,
1891
+ // owned by the shell, mounted here.
1892
+ //
1893
+ // ⚠ THE LABEL COMES OFF THE PAYLOAD, exactly as the rail's does (:1006). C13 words
1894
+ // it "label 'Automation'" and that IS `active.label` β€” the registry's literal, or a
1895
+ // tenant's `nav_meta` override of it. Hard-coding the word here would put the same
1896
+ // surface's name in two places and let them drift on the day someone renames it.
1897
+ //
1898
+ // β›” THE HEIGHT CHAIN. `.auto-surface` is `height: 100%`, so dropping it straight
1899
+ // into the frame's flex column would size it against the WHOLE frame and push its
1900
+ // rail a header's height below the fold. `.shell-auto-host` is the same `flex: 1 1
1901
+ // auto; min-height: 0` link `.shell-grid-host` is for glide β€” and it is the shell's
1902
+ // OWN class rather than a `.shell-db-frame > .auto-surface` rule, because
1903
+ // `.auto-*` is SESSION B's CSS region this wave and a frame has no business
1904
+ // reaching into its child's namespace to make itself fit.
1905
+ <div className="shell-db-frame">
1906
+ <DbHead
1907
+ label={active.label}
1908
+ glyph={<AutoIcon />}
1909
+ {...(active.icon ? { icon: active.icon } : {})}
1910
+ />
1911
+ <div className="shell-auto-host">
1912
+ <AutomationSurface />
1913
+ </div>
1914
+ </div>
1915
  ) : active?.kind === "native" ? (
1916
  // The native grid surfaces β€” ONE component, topic decided by the route. Wave 16:
1917
  // the `#/cohort` route left with the cohort registry row (cohorts are LOCKED VIEWS
 
1946
  </div>
1947
  ) : active ? (
1948
  <StranglerPage entry={active} />
1949
+ ) : route === CONNECTORS_ROUTE ? (
1950
+ // ⭐ WAVE 23 item 10 (R8, C11, wiring W23-W3) β€” the connectors directory.
1951
+ //
1952
+ // The one action the page cannot perform itself: a `manage: "keychain"` row opens
1953
+ // Settings on the Keychains tab, and the Settings MODAL is the frame's. Same division
1954
+ // as every other panel here β€” the page knows what it wants, the frame owns the door.
1955
+ <ConnectorsPage onKeychain={() => setSettings("keychains")} />
1956
+ ) : nav.phase === "ready" ? (
1957
+ // ⭐ WAVE 23 item 9 (R7, C10) β€” HOME, AND IT IS THE FALLBACK RATHER THAN A ROUTE MATCH.
1958
+ //
1959
+ // β›” WHY `!active` AND NOT `route === HOME_ROUTE`. Three states have to land here and
1960
+ // only one of them is the literal hash: `#/home`, an EMPTY hash, and a route that no
1961
+ // longer resolves (a deleted table, a stale bookmark, a hand-typed key). `resolveRoute`
1962
+ // already funnels all three to "no entry" now that `defaultRoute` returns a chrome
1963
+ // route, so testing the literal would have left the other two on the "Nothing to show"
1964
+ // branch β€” a shell that looks broken as the reward for deleting a database, which is
1965
+ // the exact failure `removeDatabase`'s own note warns about from the other side.
1966
+ //
1967
+ // ⚠ AND THIS BRANCH ABSORBED WAVE 18's TENANT HERO (R4), which used to live below.
1968
+ // That hero said "Welcome β€” this workspace has no databases yet" plus one create
1969
+ // button, on precisely the state Home now owns: a ready nav with zero entries. Leaving
1970
+ // both reachable would put two welcome messages on one screen, which is the defect the
1971
+ // rail's own note at the empty-nav line already documents. Home's four cards ARE the
1972
+ // hero's create door, and its empty line carries the hero's sentence (HomePage's
1973
+ // `home-empty`, which branches on `entries.length === 0` for exactly this reason).
1974
+ // Booked as a dated amendment in the wave doc, since it moves an R4 surface.
1975
+ <HomePage
1976
+ entries={entries}
1977
+ recents={recents}
1978
+ onTemplates={() => openNewDb("template")}
1979
+ onNewDatabase={() => openNewDb("blank")}
1980
+ onAutomated={openAutomated}
1981
+ onConnectors={() => {
1982
+ window.location.hash = `#/${CONNECTORS_ROUTE}`;
1983
+ }}
1984
+ />
1985
+ ) : nav.phase === "error" ? (
1986
+ // ⚠ WAVE 17 ITEM 4 (R6) β€” THE ERROR AND THE EMPTY ARE DIFFERENT SENTENCES, and this
1987
+ // branch is what keeps them apart. `active` resolves from `entries`, which is empty
1988
+ // while the nav is IN FLIGHT, so every first login used to land on a pane stating the
1989
+ // single worst thing this frame could say to a new user for as long as a round trip
1990
+ // took. WAVE 23 keeps that separation and simplifies its shape: `ready` now goes to
1991
+ // Home above (which welcomes an empty workspace properly), so this is only ever the
1992
+ // nav that FAILED, and the nested ternary that used to sort the two is gone.
1993
+ <div className="shell-placeholder">
1994
+ <h1>Nothing to show</h1>
1995
+ <p>The navigation service did not answer. Reload to retry.</p>
1996
+ </div>
1997
  ) : (
1998
  // Still asking (`loading`, or the `idle` beat before the effect runs).
1999
  <div className="shell-loading">
 
2045
  onClick={(e) => e.stopPropagation()}
2046
  >
2047
  <h2>New database</h2>
2048
+ {/* ⭐ WAVE 23 C10 β€” THE THREE-WAY CHOICE. Three one-line rows, not three paragraphs:
2049
+ the explanation lives where the DECISION is made and stops there (R13). The
2050
+ dialog's old sub-line ("a blank database… nothing here connects to a source")
2051
+ said one of these three things as if it were the only one, so it moved onto the
2052
+ row it actually describes. */}
2053
+ <div className="shell-newdb-kinds" role="radiogroup" aria-label="What to create">
 
 
 
 
 
 
 
 
 
 
 
 
 
2054
  <button
2055
  type="button"
2056
+ role="radio"
2057
+ aria-checked={newDb.mode === "blank"}
2058
+ className={"shell-newdb-kind" + (newDb.mode === "blank" ? " is-on" : "")}
2059
  disabled={newDb.busy}
2060
+ onClick={() => setNewDb({ ...newDb, mode: "blank", err: "" })}
2061
  >
2062
+ <span className="shell-newdb-kind-name">Blank</span>
2063
+ <span className="shell-newdb-kind-detail">
2064
+ An empty database β€” add fields and records once it opens.
2065
+ </span>
2066
  </button>
2067
  <button
2068
  type="button"
2069
+ role="radio"
2070
+ aria-checked={newDb.mode === "template"}
2071
+ className={"shell-newdb-kind" + (newDb.mode === "template" ? " is-on" : "")}
2072
+ disabled={newDb.busy}
2073
+ onClick={() => setNewDb({ ...newDb, mode: "template", err: "" })}
2074
  >
2075
+ <span className="shell-newdb-kind-name">From a template</span>
2076
+ <span className="shell-newdb-kind-detail">
2077
+ Add a curated set of views to a database you already have.
2078
+ </span>
2079
+ </button>
2080
+ {/* ⚠ NOT A RADIO β€” it LEAVES. Picking it hands off to the automation wizard
2081
+ rather than selecting a state in this dialog, so it must not sit in the
2082
+ radiogroup pretending to be a third selectable option (`role="radio"` with
2083
+ `aria-checked` it can never wear is a control that lies to a screen reader). */}
2084
+ <button
2085
+ type="button"
2086
+ className="shell-newdb-kind"
2087
+ disabled={newDb.busy}
2088
+ onClick={openAutomated}
2089
+ >
2090
+ <span className="shell-newdb-kind-name">
2091
+ Automated
2092
+ <ExtIcon />
2093
+ </span>
2094
+ <span className="shell-newdb-kind-detail">
2095
+ A database an automation creates and keeps up to date.
2096
+ </span>
2097
  </button>
2098
  </div>
2099
+ {newDb.mode === "template" ? (
2100
+ // WAVE 23 C12 (wiring W23-W6) β€” the picker. It applies views to an EXISTING
2101
+ // database rather than making one, which is what a platform-curated template is:
2102
+ // a set of saved views, not a table (R10).
2103
+ <TemplatePicker
2104
+ entries={dbEntries}
2105
+ onToast={setToast}
2106
+ onDone={(tableKey) => {
2107
+ setNewDb(null);
2108
+ window.location.hash = `#/${tableKey}`;
2109
+ }}
2110
+ />
2111
+ ) : (
2112
+ <>
2113
+ <input
2114
+ autoFocus
2115
+ className="shell-newdb-input"
2116
+ placeholder="Database name"
2117
+ value={newDb.name}
2118
+ maxLength={60}
2119
+ disabled={newDb.busy}
2120
+ onChange={(e) => setNewDb({ ...newDb, name: e.target.value })}
2121
+ onKeyDown={(e) => {
2122
+ if (e.key === "Enter") void createDb();
2123
+ if (e.key === "Escape" && !newDb.busy) setNewDb(null);
2124
+ }}
2125
+ />
2126
+ {newDb.err ? <p className="shell-newdb-err">{newDb.err}</p> : null}
2127
+ <div className="shell-newdb-actions">
2128
+ <button
2129
+ type="button"
2130
+ onClick={() => setNewDb(null)}
2131
+ disabled={newDb.busy}
2132
+ >
2133
+ Cancel
2134
+ </button>
2135
+ <button
2136
+ type="button"
2137
+ className="login-submit"
2138
+ onClick={() => void createDb()}
2139
+ disabled={newDb.busy || !newDb.name.trim()}
2140
+ >
2141
+ {newDb.busy ? "Creating…" : "Create database"}
2142
+ </button>
2143
+ </div>
2144
+ </>
2145
+ )}
2146
  </div>
2147
  </div>
2148
  ) : null}
 
2183
  new CustomEvent("aios:view-open", { detail: { topic, viewId } })
2184
  );
2185
  }}
2186
+ // ⭐ WAVE 23 C6 (wiring W23-W5) β€” the review notification's click-through.
2187
+ //
2188
+ // THE SAME TWO-PART MOVE the view click-through makes, and for the same reason: the
2189
+ // frame owns the hash, the automation SURFACE owns which automation is selected
2190
+ // (`activeId` is its own state, seeded from its own list β€” this frame cannot see it
2191
+ // and must not learn to). So: route, then ASK.
2192
+ //
2193
+ // ⚠ THE ORDER IS LOAD-BEARING. The hash is set FIRST so the surface is mounting (or
2194
+ // already mounted) when the event arrives; dispatching first would fire into a route
2195
+ // that does not exist yet. `signal` is `apiContract`'s helper β€” the same channel the
2196
+ // grid's own listeners use, rather than a second hand-rolled `dispatchEvent` here.
2197
+ onOpenAutomation={(autoId, stageId) => {
2198
+ window.location.hash = "#/automation";
2199
+ signal(AUTOMATION_OPEN_EVENT, { autoId, ...(stageId ? { stageId } : {}) });
2200
+ }}
2201
  />
2202
  ) : null}
2203
 
web/src/shell/nav.ts CHANGED
@@ -188,15 +188,70 @@ export function splitChrome(pages: NavPage[]): { main: NavPage[]; utility: NavPa
188
  return { main, utility };
189
  }
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  /** `empty` (wave 18): the SERVER's reason for an empty page list. `"no_databases"` means a
192
  * freshly provisioned tenant with no modules and no databases YET β€” a legitimate starting
193
  * state, not a broken account. Absent means "no reason given", which the shell reads as the
194
  * misconfigured-account case it always did. Distinguishing them is what stops the rail saying
195
  * "no surfaces are available" beside a hero saying "welcome, create your first database". */
196
  export type NavResult =
197
- | { ok: true; pages: NavPage[]; empty?: string }
198
  | { ok: false; status: number };
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  /**
201
  * X2 `GET /api/v1/nav`. `status` rides the failure so the caller can tell the
202
  * two apart that must not be conflated: **401 means the session is gone** (drop
@@ -210,7 +265,12 @@ export async function fetchNav(): Promise<NavResult> {
210
  if (!res.ok) return { ok: false, status: res.status };
211
  const body = await res.json();
212
  const empty = typeof body?.empty === "string" ? body.empty : undefined;
213
- return { ok: true, pages: parsePages(body), ...(empty ? { empty } : {}) };
 
 
 
 
 
214
  } catch {
215
  return { ok: false, status: 0 };
216
  }
@@ -702,10 +762,62 @@ export function shapeNav(pages: NavPage[], appBase: string): NavEntry[] {
702
  * can read, not in whatever the registry happens to order first.
703
  */
704
  // (`cohort` left the list with its NATIVE_KEYS exit, wave 16 β€” a landing must be drawable.)
705
- export const LANDING_PREFERENCE: readonly string[] = ["customer_data"];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
 
 
 
 
 
 
 
 
 
 
 
 
 
707
  export function defaultRoute(entries: NavEntry[]): string {
708
- for (const key of LANDING_PREFERENCE) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  const hit = entries.find((e) => e.key === key && e.kind === "native");
710
  if (hit) return hit.key;
711
  }
@@ -725,6 +837,13 @@ export function defaultRoute(entries: NavEntry[]): string {
725
  * member"). An unknown route falls to the default. Both fall back rather than
726
  * erroring, because a URL is something a person can type or a bookmark can
727
  * outlive.
 
 
 
 
 
 
 
728
  */
729
  export function resolveRoute(entries: NavEntry[], route: string): NavEntry | undefined {
730
  const i = entries.findIndex((e) => e.key === route);
 
188
  return { main, utility };
189
  }
190
 
191
+ /**
192
+ * WAVE 23 (C10 / R7) β€” one entry of the Home landing's recents.
193
+ *
194
+ * `at` is EPOCH SECONDS, and the type says so because the alternative bit this product once:
195
+ * a formatted naive-local stamp read by a browser in another zone (D-18). An integer instant
196
+ * has one reading everywhere, and the two things Home renders from it β€” "Opened N minutes ago"
197
+ * and the Today / Past 7 days / Older bucket β€” are both statements about THE READER'S OWN
198
+ * CLOCK, which is the one case where using it is correct rather than forbidden (contrast
199
+ * `alertsModel.stampText`, which must NOT touch a clock because it re-states a server event).
200
+ */
201
+ export interface Recent {
202
+ key: string;
203
+ at: number;
204
+ }
205
+
206
  /** `empty` (wave 18): the SERVER's reason for an empty page list. `"no_databases"` means a
207
  * freshly provisioned tenant with no modules and no databases YET β€” a legitimate starting
208
  * state, not a broken account. Absent means "no reason given", which the shell reads as the
209
  * misconfigured-account case it always did. Distinguishing them is what stops the rail saying
210
  * "no surfaces are available" beside a hero saying "welcome, create your first database". */
211
  export type NavResult =
212
+ | { ok: true; pages: NavPage[]; recents: Recent[]; empty?: string }
213
  | { ok: false; status: number };
214
 
215
+ /**
216
+ * `{recents:[…]}` β†’ the list, fail-closed. An entry with no key or an unreadable stamp is
217
+ * DROPPED rather than rendered at the epoch, which would file it under "Older" for ever and
218
+ * put a tile on Home for something nobody opened.
219
+ */
220
+ export function parseRecents(body: unknown): Recent[] {
221
+ const raw = (body as { recents?: unknown } | null)?.recents;
222
+ if (!Array.isArray(raw)) return [];
223
+ const out: Recent[] = [];
224
+ for (const item of raw) {
225
+ if (!item || typeof item !== "object") continue;
226
+ const r = item as Record<string, unknown>;
227
+ const key = typeof r.key === "string" ? r.key.trim() : "";
228
+ const at = typeof r.at === "number" && isFinite(r.at) ? Math.floor(r.at) : 0;
229
+ if (!key || at <= 0) continue;
230
+ out.push({ key, at });
231
+ }
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * WAVE 23 (C10) β€” stamp a page as opened. FIRE AND FORGET, by design.
237
+ *
238
+ * β›” IT RETURNS `void` AND SWALLOWS EVERYTHING. A recents stamp is the least important write in
239
+ * the product: it decorates a landing page. Nothing the user is doing may wait on it, and no
240
+ * failure of it may reach a screen β€” a toast saying "we could not record that you opened this
241
+ * table" is noise about a feature nobody asked for, printed over the table they successfully
242
+ * opened. The server answers 503 honestly when the store is down; this end simply does not
243
+ * care, which is the only correct posture for telemetry-shaped state.
244
+ */
245
+ export function postOpened(key: string): void {
246
+ if (!key) return;
247
+ void fetch(`${API_V1}/nav/opened`, {
248
+ method: "POST",
249
+ credentials: CREDENTIALS,
250
+ headers: { "Content-Type": "application/json" },
251
+ body: JSON.stringify({ key }),
252
+ }).catch(() => {});
253
+ }
254
+
255
  /**
256
  * X2 `GET /api/v1/nav`. `status` rides the failure so the caller can tell the
257
  * two apart that must not be conflated: **401 means the session is gone** (drop
 
265
  if (!res.ok) return { ok: false, status: res.status };
266
  const body = await res.json();
267
  const empty = typeof body?.empty === "string" ? body.empty : undefined;
268
+ return {
269
+ ok: true,
270
+ pages: parsePages(body),
271
+ recents: parseRecents(body),
272
+ ...(empty ? { empty } : {}),
273
+ };
274
  } catch {
275
  return { ok: false, status: 0 };
276
  }
 
762
  * can read, not in whatever the registry happens to order first.
763
  */
764
  // (`cohort` left the list with its NATIVE_KEYS exit, wave 16 β€” a landing must be drawable.)
765
+ //
766
+ // ⭐ WAVE 23 (contract C10, ruling R7) β€” THE LANDING IS NOW `#/home`.
767
+ //
768
+ // β›” CHROME ROUTES ARE NOT GRANTED PAGES, AND THE :1039 LAW SURVIVES INTACT. This shell's
769
+ // oldest rule is that the nav is server-filtered and an undeclared surface is denied β€” which is
770
+ // why Alerts is a PANEL and not a route. Home and Connectors are routes, and they do not
771
+ // violate that rule for one reason that has to be true of every future member of this set:
772
+ //
773
+ // A CHROME ROUTE RENDERS NOTHING THE SERVER DID NOT ALREADY GRANT.
774
+ //
775
+ // Home shows the entries `/nav` returned (its recents are RESOLVED against those entries and a
776
+ // key absent from them is dropped, `homeModel.groupRecents`) plus create affordances that were
777
+ // already in the rail. Connectors renders a SERVER-COMPOSED directory (`GET /connectors/
778
+ // directory`, session-gated) β€” it is a window onto a payload, not a surface with its own data.
779
+ // Neither can show a database, a module or a connector this session may not see; adding a route
780
+ // that could would be the hard-coded surface the frame refuses to have.
781
+ export const HOME_ROUTE = "home";
782
+ export const CONNECTORS_ROUTE = "connectors";
783
+ export const CHROME_ROUTES: ReadonlySet<string> = new Set([HOME_ROUTE, CONNECTORS_ROUTE]);
784
+
785
+ export const LANDING_PREFERENCE: readonly string[] = [HOME_ROUTE];
786
 
787
+ /**
788
+ * The surface the shell lands on.
789
+ *
790
+ * ⚠ THE THREE LANDING SITES MOVE TOGETHER OR LOGINS STRAND (C10's own warning): this function,
791
+ * `LANDING_PREFERENCE` above, and the post-login redirect in `Shell.tsx`. Half a move leaves a
792
+ * signed-in user on a hash nothing renders.
793
+ *
794
+ * A chrome route needs no entry to be reachable β€” that is what makes it chrome β€” so the loop
795
+ * below returns it without consulting `entries`, and the old preference chain survives beneath
796
+ * it for the day a granted page becomes the landing again. `entries` stays a parameter for the
797
+ * same reason: the fallback is still a real answer about a real payload.
798
+ */
799
  export function defaultRoute(entries: NavEntry[]): string {
800
+ return firstDestination(entries, LANDING_PREFERENCE);
801
+ }
802
+
803
+ /**
804
+ * The landing rule itself, over an arbitrary preference list.
805
+ *
806
+ * ⚠ EXTRACTED (wave 23) SO THE FALLBACK CHAIN STAYS UNDER TEST. Before C10 the chain WAS
807
+ * `defaultRoute`, so `shell.test.ts` asserted it by calling that. With `home` at the head of the
808
+ * preference the function now returns on its first line, and the whole "preferred native entry β†’
809
+ * any native entry β†’ any destination" ladder became unreachable from the outside β€” three checks
810
+ * would have had to be deleted rather than retargeted, which is how a gate quietly stops
811
+ * asserting the thing it was written for ([[gate-can-report-green-on-nothing]]). It is one
812
+ * function called two ways instead.
813
+ */
814
+ export function firstDestination(
815
+ entries: NavEntry[],
816
+ preference: readonly string[]
817
+ ): string {
818
+ for (const key of preference) {
819
+ // A chrome route needs no entry to be reachable β€” that is what makes it chrome.
820
+ if (CHROME_ROUTES.has(key)) return key;
821
  const hit = entries.find((e) => e.key === key && e.kind === "native");
822
  if (hit) return hit.key;
823
  }
 
837
  * member"). An unknown route falls to the default. Both fall back rather than
838
  * erroring, because a URL is something a person can type or a bookmark can
839
  * outlive.
840
+ *
841
+ * ⭐ WAVE 23 (C10): the fallback is now `#/home`, which is CHROME and therefore matches no
842
+ * entry β€” so this returns `undefined` for an unknown route, and the frame reads "no entry" as
843
+ * "render Home". One behaviour change, stated plainly: `#/deleted_table` used to open the
844
+ * Customer grid and now opens Home. That is the better answer (the reader asked for something
845
+ * that is gone; the landing is where you choose again) and it is the SAME answer an empty hash
846
+ * gets β€” which is what "Home is the default" has to mean in order to be true.
847
  */
848
  export function resolveRoute(entries: NavEntry[], route: string): NavEntry | undefined {
849
  const i = entries.findIndex((e) => e.key === route);
web/src/viz/seriesData.ts CHANGED
@@ -40,6 +40,11 @@ export const VIZ_FIELD_TYPES: ReadonlySet<string> = new Set([
40
  // report the column as `text`, and the one thing a viz field type decides is what the surface
41
  // is allowed to claim about the column.
42
  "image",
 
 
 
 
 
43
  ]);
44
 
45
  /**
 
40
  // report the column as `text`, and the one thing a viz field type decides is what the surface
41
  // is allowed to claim about the column.
42
  "image",
43
+ // Wave 23 C7 β€” `json`. THE BATCH LINE the union's own comment names: a `Set` is not a
44
+ // `Record`, so the compiler stays green when this goes stale and `asFields` silently reports
45
+ // a json column as `text`. Same reasoning as `image` β€” the type is categorical either way,
46
+ // and what listing it buys is that the surface stops mis-naming the column.
47
+ "json",
48
  ]);
49
 
50
  /**
web/src/viz/types.ts CHANGED
@@ -47,7 +47,15 @@ export type FieldType =
47
  // picture (it is a reference string, so `isNumericFieldType` leaves it categorical and the
48
  // shared formatter prints the ref) β€” the vocabulary has to know the word regardless, or the
49
  // grid cannot hand its own fields to `<DashboardView>` at all.
50
- | "image";
 
 
 
 
 
 
 
 
51
 
52
  export type FieldSource = "odoo" | "overlay";
53
 
 
47
  // picture (it is a reference string, so `isNumericFieldType` leaves it categorical and the
48
  // shared formatter prints the ref) β€” the vocabulary has to know the word regardless, or the
49
  // grid cannot hand its own fields to `<DashboardView>` at all.
50
+ | "image"
51
+ // Wave-23 C7 β€” `json`, and it is COERCED TO TEXT for charting by construction rather than by
52
+ // a branch: `isNumericFieldType` does not name it, so the engine groups it as a category and
53
+ // the shared formatter prints the stored string. Grouping a table by a whole document is a
54
+ // question nobody asks, but the vocabulary has to know the word or the grid cannot hand its
55
+ // own fields to DashboardView at all β€” which is the alarm two rules below going off. KEEP
56
+ // THIS COMMENT FREE OF THE STATEMENT-TERMINATOR CHARACTER, exactly as the metric note above
57
+ // says: verify_ui parses this union up to the first one it meets
58
+ | "json";
59
 
60
  export type FieldSource = "odoo" | "overlay";
61