fsanyoto commited on
Commit
e1b3e71
Β·
verified Β·
1 Parent(s): 91de770

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
Files changed (46) hide show
  1. RELEASES.json +1 -1
  2. VERSION +1 -1
  3. api/automation_engine.py +0 -0
  4. api/connectors_bd.py +745 -0
  5. api/connectors_ig.py +289 -636
  6. api/connectors_tt.py +237 -10
  7. api/main.py +9 -0
  8. api/odoo_relational.py +335 -21
  9. api/providers.py +7 -2
  10. api/routes_automation.py +147 -5
  11. api/routes_connectors.py +38 -8
  12. api/routes_customers.py +20 -1
  13. api/routes_grid.py +19 -4
  14. api/routes_odoo_tables.py +804 -6
  15. api/routes_products.py +66 -3
  16. api/routes_tables.py +175 -0
  17. platform/aios_grid.py +20 -2
  18. platform/aios_grid_fields.json +464 -423
  19. platform/core/grid_events.py +58 -0
  20. platform/core/user_tables.py +0 -0
  21. platform/harness/datastore.py +135 -2
  22. platform/modules/customer_data.py +140 -4
  23. platform/modules/customers.py +16 -4
  24. platform/modules/product_data.py +220 -4
  25. platform/modules/products.py +172 -0
  26. web/public/sample_customers.json +545 -529
  27. web/src/automation/AutomationBuilder.tsx +0 -0
  28. web/src/automation/AutomationDetail.tsx +62 -7
  29. web/src/automation/AutomationSurface.tsx +35 -3
  30. web/src/automation/TriggerPicker.tsx +52 -5
  31. web/src/automation/automationApi.ts +58 -1
  32. web/src/connectors/ConnectorsPage.tsx +127 -18
  33. web/src/connectors/brandMarks.tsx +294 -0
  34. web/src/customer-grid/CustomerGrid.tsx +172 -1
  35. web/src/customer-grid/ImportDialog.tsx +4 -4
  36. web/src/customer-grid/apiBridge.ts +0 -0
  37. web/src/customer-grid/counts.ts +193 -1
  38. web/src/customer-grid/icons.tsx +29 -5
  39. web/src/customer-grid/types.ts +44 -0
  40. web/src/customer-grid/useCustomerData.ts +109 -8
  41. web/src/customer-grid/useGridColumns.ts +28 -1
  42. web/src/filter-kit/FieldsHidePanel.tsx +17 -7
  43. web/src/index.css +91 -0
  44. web/src/shell/ErrorBoundary.tsx +136 -0
  45. web/src/shell/Shell.tsx +48 -8
  46. web/src/shell/errorBoundary.css +32 -0
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "e8f1e2f",
3
  "releases": [
4
  {
5
  "version": "v24",
 
1
  {
2
+ "current": "4258a93",
3
  "releases": [
4
  {
5
  "version": "v24",
VERSION CHANGED
@@ -1 +1 @@
1
- e8f1e2f
 
1
+ 4258a93
api/automation_engine.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/connectors_bd.py ADDED
@@ -0,0 +1,745 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """connectors_bd.py -- THE BRIGHT DATA TRANSPORT. One vendor's wire, and nothing else.
2
+
3
+ ⭐⭐ WAVE 30 · T09 (DEBT D-128). Every function below was CORRECT CODE UNDER A WRONG NAME: it lived
4
+ in `connectors_ig.py` and took the dataset id as a PARAMETER, which is the definition of not being
5
+ Instagram's. `connectors_tt.py` therefore had to import thirteen names from the Instagram connector
6
+ to reach a wire neither platform owns -- and the module header there said so in as many words
7
+ (*"THE TRANSPORT IS BORROWED FROM `connectors_ig`, ON PURPOSE ... the name is wrong and the code is
8
+ right"*). This file is that sentence resolved.
9
+
10
+ β›”β›” **THIS MODULE IS THE ONE PLACE A SOCKET IS OPENED TO BRIGHT DATA, AND THAT IS A TESTABILITY
11
+ PROPERTY BEFORE IT IS AN ARCHITECTURAL ONE.** `bd_call` is the single door: `bd_scrape`,
12
+ `bd_snapshot_progress` and the three corpus functions all reach the network THROUGH it, resolving it
13
+ from THIS module's globals. So one monkeypatch on `connectors_bd.bd_call` seals every paid path in
14
+ the product, for both platforms, whatever the caller. Before the split there was no such point --
15
+ `connectors_ig.bd_call` sealed Instagram's rungs and TikTok's, but only because TikTok's connector
16
+ had borrowed Instagram's function object, which is a coincidence of an import rather than a
17
+ guarantee. [[seal-the-transport-not-the-rung]] is the standing rule; this module is where it becomes
18
+ structural. ⚠ `verify_automation.py:_seal_bd` patches here AND sweeps `sys.modules` for any other
19
+ binding of the same name, because a re-export elsewhere is a second door that reads exactly as green.
20
+
21
+ β›” **WHAT IS DELIBERATELY *NOT* HERE: ANY PLATFORM'S VOCABULARY.** No `BD_DS_*` dataset id (those are
22
+ per-platform and stay with their connector), no field map, no profile/post/comment mapper. The test
23
+ for whether something belongs here is not the `bd_` prefix -- it is whether the function could serve
24
+ a THIRD platform tomorrow without an edit. ⚠ One Instagram assumption was found INSIDE the moved code
25
+ and is now a parameter rather than a literal: `bd_filter_start`'s engine-added exclusion rule named
26
+ the column `account`, which B-20 MEASURED as `account_id` in Bright Data's TikTok Profiles dataset.
27
+ It rides on every discovery run, is invisible to the condition builder, and would have reached the
28
+ vendor on the first TikTok search that had anything to exclude. See `handle_field`.
29
+
30
+ ⚠ **THE ROW PRIMITIVES IN SECTION 3 ARE HERE FOR A REASON AND IT IS NOT TIDINESS.** They are the
31
+ readers every Bright Data mapper needs (a vendor row is JSON with two or three names for one fact),
32
+ both connectors used them, and leaving them in `connectors_ig.py` would have kept the import line
33
+ this ticket exists to delete. They know nothing about Instagram. ⚠ `_ig_int` KEEPS ITS NAME on
34
+ purpose: it is referenced by name in the engine and in exact-match gate fixtures, and a rename buys
35
+ nothing but a diff.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import os
41
+ import time
42
+
43
+ import requests
44
+
45
+
46
+ # =============================================================================================
47
+ # 1. THE WIRE -- auth, one HTTP door, the scraper endpoints
48
+ # =============================================================================================
49
+ BD_BASE_DEFAULT = "https://api.brightdata.com"
50
+
51
+
52
+ BD_PATH_SCRAPE = "/datasets/v3/scrape" # SYNC: rows come back inline. param: dataset_id
53
+
54
+
55
+ BD_PATH_TRIGGER = "/datasets/v3/trigger" # ASYNC: -> {"snapshot_id": "sd_…"}
56
+
57
+
58
+ BD_PATH_SNAPSHOT = "/datasets/v3/snapshot" # /<sd_id>?format=json -> the rows
59
+
60
+
61
+ #: ⭐⭐ 2026-08-09 β€” THE SNAPSHOT'S OWN STATUS DOCUMENT, and asking it is the difference between
62
+ #: "the vendor is still working" and "the vendor finished and collected nothing". MEASURED on
63
+ #: `sd_msl80v7l1ti14rhcd6` (nurilab's `roxyfoxypinky`): `{"status": "ready", "records": 0,
64
+ #: "errors": 1, "error_codes": {"crawl_error": 1}, "collection_duration": 320513}`. The rows
65
+ #: endpoint answers `[]` for that snapshot forever, which `bd_scrape`'s old `if rows:` poll could
66
+ #: not distinguish from a snapshot mid-build β€” so it burned its whole budget and then reported
67
+ #: *"the records are collected, not lost"* about a batch that had collected nothing at all.
68
+ #: β›” SCRAPER NAMESPACE ONLY (`sd_…`). The corpus has no twin here; its status is
69
+ #: `BD_PATH_FILTER_SNAPSHOT/<snap_id>`, and crossing the two 404s (Β§2c).
70
+ BD_PATH_PROGRESS = "/datasets/v3/progress" # /<sd_id> -> {status, records, errors, error_codes}
71
+
72
+
73
+ BD_PATH_FILTER = "/datasets/filter" # ⚠ NO /v3/ β€” the CORPUS query (discovery)
74
+
75
+
76
+ BD_PATH_FILTER_SNAPSHOT = "/datasets/snapshot" # /<snap_id> -> status
77
+
78
+
79
+ #: A sync scrape MEASURED at 15–23 s for one URL and ~40 s for two, so the timeout is generous;
80
+ #: it is a ceiling against a hung socket, not a latency budget.
81
+ BD_TIMEOUT = 300.0
82
+
83
+
84
+ #: The READ ceiling for one vendor answer, in KB. `fetch` truncates SILENTLY at this size, so it
85
+ #: is load-bearing arithmetic rather than a round number: MEASURED `file_size` was 175,617 bytes
86
+ #: for 5 corpus rows β‡’ **~35 KB per profile row**, and `BD_MAX_RECORDS` is set from it with
87
+ #: headroom (500 Γ— 35 KB β‰ˆ 17 MB against 32 MB). A truncated body is REPORTED as truncated in
88
+ #: `bd_call` β€” never allowed to surface as "that is not JSON".
89
+ BD_MAX_KB = 32768
90
+
91
+
92
+ #: How long a run waits on a scrape batch the vendor DEFERRED (see `_bd_deferral`). Much shorter
93
+ #: than the corpus filter's budget because a scrape snapshot is minutes, not tens of minutes β€”
94
+ #: and unlike the corpus path there is no handoff to a later run, so this is the whole wait.
95
+ BD_SCRAPE_WAIT = float(os.environ.get("AIOS_BD_SCRAPE_WAIT") or 180)
96
+
97
+
98
+ BD_SCRAPE_POLL = float(os.environ.get("AIOS_BD_SCRAPE_POLL") or 10)
99
+
100
+
101
+ # A Profile response is the enrichment's identity half, so it retains the normal bounded
102
+ # synchronous wait. Post/Reel engagement can create several snapshots per profile; action
103
+ # runners hand those off immediately and the scheduler collects the already-paid work later.
104
+ BD_METRIC_SCRAPE_WAIT = float(os.environ.get("AIOS_BD_METRIC_SCRAPE_WAIT") or 0)
105
+
106
+
107
+ #: ⚠ SPEC, NOT MEASURED β€” off the pricing page, never returned by the API (the funds gate fires
108
+ #: BEFORE a price is quoted, and `price: 0` means "not priced", not "free"). Every surface that
109
+ #: shows a number derived from this MUST say it is an estimate. `/customer/balance` answers 403
110
+ #: for our token, so there is no way to check a balance or a spend from here either.
111
+ BD_RECORD_PRICE_SPEC = 0.0025
112
+
113
+
114
+ def bd_key():
115
+ """The key, or ''. Read fresh every call β€” see the module note above on why."""
116
+ return (os.environ.get("AIOS_BRIGHTDATA_KEY") or "").strip()
117
+
118
+
119
+ def bd_base():
120
+ return (os.environ.get("AIOS_BRIGHTDATA_BASE") or BD_BASE_DEFAULT).strip().rstrip("/")
121
+
122
+
123
+ def bd_ready():
124
+ """Is the paid rung configured at all? The UI reads this to say so honestly."""
125
+ return bool(bd_key())
126
+
127
+
128
+ def _bd_why(raw, key=""):
129
+ """The vendor's OWN words for a refusal, as `" β€” …"`, or `""` when it gave none.
130
+
131
+ β›” THIS WAS THROWN AWAY, AND THE COST WAS MEASURED RATHER THAN IMAGINED. nurilab's live
132
+ discovery automation failed four runs across two days with `the profile source answered 400`
133
+ while the body it discarded said, in full:
134
+
135
+ {"validation_errors":["Filter logical groups can have a maximum of 4 rules."]}
136
+
137
+ β€” the exact instruction needed to fix it, in our hands, deleted one line before the customer.
138
+ A bare status code sends somebody to read a vendor's schema for a fault we were already
139
+ holding the answer to; it is the same defect as a green dot over nothing, pointed the other
140
+ way. Every non-2xx says WHY now, and the named cases above (402/401/403/429) keep their own
141
+ plainer sentences because those are ours to explain, not the vendor's.
142
+ """
143
+ from automation_engine import _s # lazy β€” see the module header
144
+ try:
145
+ text = bytes(raw or b"").decode("utf-8", "replace").strip()[:600]
146
+ except Exception: # noqa: BLE001
147
+ return ""
148
+ if not text:
149
+ return ""
150
+ why = ""
151
+ try:
152
+ d = json.loads(text)
153
+ except Exception: # noqa: BLE001
154
+ d = None
155
+ if isinstance(d, dict):
156
+ errs = d.get("validation_errors") or d.get("errors")
157
+ if isinstance(errs, list) and errs:
158
+ why = "; ".join(str(e) for e in errs)
159
+ else:
160
+ why = str(d.get("error") or d.get("message") or d.get("detail") or "")
161
+ why = why or text
162
+ # ⚠ A BODY WE DID NOT WRITE GETS THE SAME TREATMENT AS AN EXCEPTION'S str(). `bd_call` already
163
+ # refuses to put the latter on the wire in case it carries the key; promising that a vendor
164
+ # never echoes an Authorization header back is not a guarantee this module can make.
165
+ if key and key in why:
166
+ why = why.replace(key, "***")
167
+ return f" β€” {_s(why, 300)}"
168
+
169
+
170
+ def bd_call(path, params=None, body=None):
171
+ """One authenticated vendor call. GET when `body` is None, POST otherwise.
172
+
173
+ Returns `(payload, note)`; a non-empty `note` means it did NOT answer, and the note is safe to
174
+ show a user and safe to write to a log.
175
+
176
+ Never raises. Every refusal shape β€” no key, a guarded base, a 402, a 429, a transport error,
177
+ unparseable JSON β€” comes back as a note so the caller can record the attempt and drop to the
178
+ next rung.
179
+ """
180
+ from automation_engine import Refused, fetch, fetch_json # lazy β€” see the module header
181
+ key = bd_key()
182
+ if not key:
183
+ # β›” THE FAIL-CLOSED PATH (C4). Named precisely, because "blocked" with no reason sends
184
+ # somebody to read Instagram's status page instead of setting an env var.
185
+ return None, "AIOS_BRIGHTDATA_KEY is not configured β€” the paid rung is closed"
186
+ qs = "&".join(f"{k}={requests.utils.quote(str(v))}"
187
+ for k, v in (params or {}).items() if v not in (None, ""))
188
+ url = f"{bd_base()}{path}" + (f"?{qs}" if qs else "")
189
+ hdrs = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
190
+ try:
191
+ if body is None:
192
+ status, _final, raw = fetch(url, timeout=BD_TIMEOUT, max_kb=BD_MAX_KB, headers=hdrs)
193
+ else:
194
+ status, raw = fetch_json(url, body, timeout=BD_TIMEOUT, max_kb=BD_MAX_KB,
195
+ headers=hdrs)
196
+ except Refused as e:
197
+ # The BASE was refused by the SSRF rail β€” a configuration fault, not a vendor outage.
198
+ return None, f"the profile source's address was refused by the URL rail: {e}"
199
+ except Exception as e: # noqa: BLE001
200
+ # ⚠ `type(e).__name__` only. A requests exception's str() can carry the full URL, and the
201
+ # Authorization header is one refactor away from being a query param; this line must not
202
+ # be what leaks it.
203
+ return None, f"the profile source did not answer ({type(e).__name__})"
204
+ if status == 402:
205
+ return None, "the profile search is out of credit"
206
+ if status in (401, 403):
207
+ return None, f"the profile source refused our key ({status})"
208
+ if status == 429:
209
+ # ⚠ MEASURED during the discovery probe: `too_many_parallel_jobs`. The filter API has a
210
+ # concurrency cap and the probe's most important test died on it β€” hence every caller
211
+ # below is SERIAL by construction.
212
+ return None, "the profile source is busy with another search; they run one at a time"
213
+ if not (200 <= status < 300):
214
+ return None, f"the profile source answered {status}{_bd_why(raw, key)}"
215
+ # β›” A BODY THAT REACHED THE READ CEILING IS A TRUNCATION, AND IT MUST SAY SO. `fetch` reads
216
+ # at most `max_kb` and returns what it got β€” silently. A truncated JSON document then fails
217
+ # `json.loads`, falls through to the NDJSON branch, and comes back as "answered with
218
+ # something that is not JSON": a sentence that sends somebody to read the vendor's schema
219
+ # looking for a fault that is OUR byte ceiling β€” after a result set that has already been
220
+ # paid for. Naming it here is the same no-silent-caps rule the row and table ceilings follow.
221
+ if len(raw) >= BD_MAX_KB * 1024:
222
+ return None, (f"the profile source's answer exceeded this client's {BD_MAX_KB // 1024} MB read "
223
+ f"ceiling and was TRUNCATED β€” ask for fewer records; nothing was parsed "
224
+ f"from a partial document")
225
+ text = raw.decode("utf-8", "replace").strip()
226
+ if not text:
227
+ return None, f"the profile source answered {status} with an empty body"
228
+ try:
229
+ return json.loads(text), ""
230
+ except Exception: # noqa: BLE001
231
+ pass
232
+ # NDJSON is the other shape this API uses for row sets. Tried SECOND, so a genuine JSON error
233
+ # is not silently reinterpreted as a one-line NDJSON document.
234
+ rows = []
235
+ for line in text.splitlines():
236
+ line = line.strip()
237
+ if not line:
238
+ continue
239
+ try:
240
+ rows.append(json.loads(line))
241
+ except Exception: # noqa: BLE001
242
+ return None, "the profile source answered with something we could not read"
243
+ return (rows, "") if rows else (None, "the profile source answered with something we could not read")
244
+
245
+
246
+ def _bd_rows(payload):
247
+ """A vendor answer β†’ the list of row dicts inside it, whatever envelope it arrived in.
248
+
249
+ MEASURED shapes: a bare list (`/v3/scrape`), NDJSON (already listified by `bd_call`), and
250
+ `{"data": [...]}` / `{"results": [...]}` on the snapshot reads. An unreadable envelope yields
251
+ NOTHING rather than a guess.
252
+ """
253
+ if isinstance(payload, list):
254
+ return [x for x in payload if isinstance(x, dict)]
255
+ if isinstance(payload, dict):
256
+ for k in ("data", "results", "records", "items"):
257
+ v = payload.get(k)
258
+ if isinstance(v, list):
259
+ return [x for x in v if isinstance(x, dict)]
260
+ return [payload] if payload else []
261
+ return []
262
+
263
+
264
+ def _bd_deferral(payload):
265
+ """The snapshot id when the 'sync' endpoint DEFERRED, else ''.
266
+
267
+ β›” MEASURED 2026-08-05, AND IT IS THE TRAP OF THIS WHOLE RUNG. `/v3/scrape` is documented and
268
+ named as the synchronous call, and for a small batch it is β€” two profile URLs came back
269
+ inline. Give it more work and it answers **200** with no rows and a note instead:
270
+
271
+ {"snapshot_id": "sd_…", "message": "Your request is still in progress and cannot be
272
+ retrieved in this call. Use the provided Snapshot ID to track progress…"}
273
+
274
+ Read naively that is a 200 with a body, so a parser looking for "did it error?" sails past
275
+ it, `_bd_rows` wraps the envelope as ONE unusable row, and the caller reports that the vendor
276
+ returned nothing β€” **while a snapshot we have already been billed for sits on the account,
277
+ finishing, and is never collected.** That is the D-26 failure shape (paid work abandoned)
278
+ arriving through the success path.
279
+ ⚠ It is a TIME threshold, not a row count: 2 URLs answered inline, 6 and 10 deferred.
280
+ """
281
+ if isinstance(payload, dict) and payload.get("snapshot_id") and not payload.get("account"):
282
+ return str(payload["snapshot_id"])
283
+ return ""
284
+
285
+
286
+ def bd_snapshot_progress(sid):
287
+ """One scraper snapshot's status. `(state, records, note)`.
288
+
289
+ state ∈ `running` | `done` | `failed` | `unknown`:
290
+ Β· `running` β€” the vendor is still collecting; poll again.
291
+ Β· `done` β€” finished. `records` says whether it produced anything.
292
+ Β· `failed` β€” finished, produced NOTHING, and the vendor blamed the TARGET (an
293
+ `error_codes` entry such as `crawl_error`). On a profile request that is the
294
+ vendor saying it could not reach that account at all, which is a different
295
+ action from "we found no matches" β€” so it is a distinct state rather than a
296
+ phrase inside the note. A caller that has to grep an English sentence to
297
+ decide what happened is a caller whose behaviour changes when someone
298
+ improves the wording.
299
+ Β· `unknown` β€” the status call itself did not answer; fall back to probing the rows.
300
+
301
+ ⭐⭐ 2026-08-09 β€” THE QUESTION `bd_scrape` COULD NOT ASK. Its poll loop advanced only on
302
+ `if rows:`, so a snapshot the vendor had FINISHED with zero records looked byte-for-byte like
303
+ one still building: the rows endpoint answers `[]` in both cases. MEASURED on nurilab's one
304
+ pending handle β€” `status: ready, records: 0, errors: 1, crawl_error: 1` after 320 s β€” while
305
+ the enrich run reported *"deferred … the records are collected, not lost"*, which was false
306
+ about that batch and had been re-reported on three separate runs.
307
+
308
+ β›” `unknown` IS NOT `done`, and the distinction is the whole reason this returns three states
309
+ rather than a boolean. When the status call itself fails we must fall back to the old
310
+ row-probing behaviour, not conclude the snapshot is empty β€” an unreachable status endpoint
311
+ would otherwise turn every deferral into a confident "the vendor found nothing".
312
+
313
+ ⚠ THE NOTE IS DELIBERATELY VENDOR-NEUTRAL about WHAT was being read. This serves profile,
314
+ post, reel and comment snapshots alike; a sentence naming "the account" would be wrong on
315
+ three of the four, and the caller knows which it asked for.
316
+ """
317
+ payload, err = bd_call(f"{BD_PATH_PROGRESS}/{sid}", None, None)
318
+ if err or not isinstance(payload, dict):
319
+ return "unknown", 0, ""
320
+ status = str(payload.get("status") or "").strip().lower()
321
+
322
+ def _n(key):
323
+ try:
324
+ return int(payload.get(key) or 0)
325
+ except (TypeError, ValueError):
326
+ return 0
327
+
328
+ records, errors = _n("records"), _n("errors")
329
+ if status not in ("ready", "done", "failed", "error"):
330
+ return "running", records, ""
331
+ if records > 0:
332
+ return "done", records, ""
333
+ codes = payload.get("error_codes")
334
+ named = (", ".join(f"{str(k).replace('_', ' ')} x{v}"
335
+ for k, v in sorted(codes.items()))
336
+ if isinstance(codes, dict) and codes else "")
337
+ if errors or named or status in ("failed", "error"):
338
+ return "failed", 0, ("the source finished this request and collected nothing"
339
+ + (f" β€” it reported {named}" if named else "")
340
+ + "; the target is unreachable, private, or no longer exists")
341
+ return "done", 0, "the source finished this request and found no records for it"
342
+
343
+
344
+ def bd_scrape(dataset_id, urls, wait=None, deferred=None):
345
+ """Scrape a batch. `(rows, note)`; a note means it did not answer.
346
+
347
+ Sends the SYNC call and, when the vendor defers it (see `_bd_deferral`), **collects the
348
+ snapshot it handed back** rather than discarding it. One call carries many URLs, which is
349
+ what keeps the per-profile pacing floor from turning a 20-profile run into an hour.
350
+ """
351
+ requested_urls = [str(u) for u in (urls or []) if u]
352
+ payload, note = bd_call(BD_PATH_SCRAPE, {"dataset_id": dataset_id},
353
+ body=[{"url": u} for u in requested_urls])
354
+ if note:
355
+ return [], note
356
+ sid = _bd_deferral(payload)
357
+ if not sid:
358
+ return _bd_rows(payload), ""
359
+
360
+ # --- THE DEFERRED PATH. Poll the scraper namespace (`sd_…` at `/datasets/v3/…` β€” crossing it
361
+ # with the corpus namespace is a flat 404 about a snapshot that is alive).
362
+ budget = BD_SCRAPE_WAIT if wait is None else float(wait)
363
+ waited = 0.0
364
+ while True:
365
+ # ⭐ THE STATUS DOCUMENT IS ASKED FIRST, because it is the only surface that can say
366
+ # "finished, and there was nothing". Fetching rows while the vendor is still collecting
367
+ # is also a wasted call on every single poll.
368
+ state, records, empty_note = bd_snapshot_progress(sid)
369
+ if state in ("done", "failed") and not records:
370
+ return [], empty_note # a definitive EMPTY, never our timeout sentence
371
+ if state != "running":
372
+ got, gnote = bd_call(f"{BD_PATH_SNAPSHOT}/{sid}", {"format": "json"})
373
+ if not gnote:
374
+ rows = _bd_rows(got)
375
+ # The status document is itself JSON, so "did it deliver?" is decided by whether
376
+ # what came back looks like ROWS β€” never by the HTTP code.
377
+ if rows and not _bd_deferral(got) and not (
378
+ len(rows) == 1 and str(rows[0].get("status") or "") in
379
+ ("running", "building", "collecting")):
380
+ return rows, ""
381
+ if waited >= budget:
382
+ break
383
+ time.sleep(BD_SCRAPE_POLL)
384
+ waited += BD_SCRAPE_POLL
385
+ # β›” NAME THE SNAPSHOT. It has been paid for and it is still finishing; a note that said only
386
+ # "no rows" would throw away both the data and the way to get it.
387
+ if isinstance(deferred, list):
388
+ deferred.append({"snapshotId": sid, "datasetId": str(dataset_id),
389
+ "urls": requested_urls})
390
+ return [], (f"the profile source deferred this batch to {sid} and it was not ready within "
391
+ f"{int(budget)}s β€” the records are collected, not lost; ask for a smaller batch "
392
+ f"or collect {sid} from the control panel")
393
+
394
+
395
+ # =============================================================================================
396
+ # 2. THE CORPUS FILTER -- `POST /datasets/filter`, a DIFFERENT namespace from the scraper
397
+ # =============================================================================================
398
+ # ⚠ Snapshots minted here are `snap_…` and are read at `/datasets/snapshot/…`; the scraper's are
399
+ # `sd_…` at `/datasets/v3/snapshot/…`, and the two 404 each other. Both halves live in this module
400
+ # so that pairing is visible in one place instead of being rediscovered per platform.
401
+ #: β›” THE VENDOR'S HARD CAP ON ONE LOGICAL GROUP β€” MEASURED 2026-08-06 against the live filter API
402
+ #: with nurilab's own seven-condition search, which had failed every run for two days:
403
+ #:
404
+ #: 400 {"validation_errors":["Filter logical groups can have a maximum of 4 rules."]}
405
+ #:
406
+ #: ⚠ EVERY ONE OF THOSE SEVEN CONDITIONS IS ACCEPTED ALONE (all seven probed individually, all
407
+ #: 200). The refusal is about the SHAPE of the group and nothing else β€” so no field, operator or
408
+ #: value in the builder is at fault, and no amount of editing the conditions would have found it.
409
+ #: What it means without `bd_group` below: the FIFTH condition anybody adds turns a working
410
+ #: discovery automation into one that fails forever, and the surface blames the search.
411
+ BD_GROUP_MAX = 4
412
+
413
+
414
+ def bd_group(nodes, operator):
415
+ """`nodes` β†’ an equivalent list in which NO logical group exceeds `BD_GROUP_MAX` rules.
416
+
417
+ ⭐ SAFE BECAUSE AND AND OR ARE ASSOCIATIVE. Folding a run of same-operator rules into a nested
418
+ group of the SAME operator cannot change what the filter matches β€” `a∧b∧c∧d∧e` and
419
+ `(a∧b∧c∧d)∧e` are one predicate written two ways. That is why this is a re-SHAPING of the
420
+ owner's search rather than a reinterpretation of it, and why it needs no ruling: a condition
421
+ list means the same thing before and after. Nesting itself is already proven on this API
422
+ (`expand_predicates`' or-groups, `snap_msh14ix81n8hlpo3nr`).
423
+
424
+ β›” A ONE-RULE CHUNK IS NEVER WRAPPED. `expand_predicates` already carries that law for the
425
+ one-value case β€” a one-element group is a shape nothing has ever been billed against β€” and a
426
+ balanced split reaches it whenever a level divides with a remainder of one.
427
+ """
428
+ nodes = list(nodes or [])
429
+ if len(nodes) <= BD_GROUP_MAX:
430
+ return nodes
431
+ # ⚠ BALANCED, NOT "take three and nest the rest". The tail-recursive shape costs one level of
432
+ # DEPTH per extra rule (13 conditions β‡’ 5 deep) against a vendor whose nesting limit we have
433
+ # not measured and would discover the same way we discovered this one: in production, on
434
+ # somebody's automation. Chunking makes the depth logβ‚„(n) β€” 16 conditions fit in two levels.
435
+ out = []
436
+ for i in range(0, len(nodes), BD_GROUP_MAX):
437
+ chunk = nodes[i:i + BD_GROUP_MAX]
438
+ out.append(chunk[0] if len(chunk) == 1 else {"operator": operator, "filters": chunk})
439
+ return bd_group(out, operator)
440
+
441
+
442
+ #: β›” DEBT D-68 β€” THE VENDOR'S NESTING CEILING, MEASURED IN PRODUCTION 2026-08-06:
443
+ #: 400 β€” "filter" failed custom validation because logical operators cannot be more than
444
+ #: 3 levels deep
445
+ #: ⭐ AND IT IS NOT AN INDEPENDENT BUDGET FROM `BD_GROUP_MAX`. A >4-rule group is fixed by
446
+ #: NESTING, so the 4-rule cap is what MANUFACTURES depth: five top-level conditions carrying
447
+ #: multi-value lists goes `AND`(split) β†’ `OR`(values) β†’ `OR`(split) and is refused. The real
448
+ #: envelope is about 4 top-level conditions x <=4 values each, which is why
449
+ #: `MAX_PREDICATE_VALUES = 12` was never reachable at a realistic condition count.
450
+ BD_MAX_DEPTH = 3
451
+
452
+
453
+ def filter_depth(node):
454
+ """How many logical levels a vendor `filters` structure nests to. A bare rule is depth 1."""
455
+ if isinstance(node, list):
456
+ return max((filter_depth(n) for n in node), default=0)
457
+ if isinstance(node, dict) and isinstance(node.get("filters"), list):
458
+ return 1 + filter_depth(node["filters"])
459
+ return 1
460
+
461
+
462
+ def depth_refusal(predicates, operator="and"):
463
+ """⭐ D-68 β€” refuse a search whose SHAPE the vendor will reject, before it is sent.
464
+
465
+ Returns a sentence or `""`.
466
+
467
+ β›” WHY THIS CANNOT BE A RULE-COUNT CHECK. The failure is in the emitted STRUCTURE, not in the
468
+ conditions: the same five conditions pass or fail depending on how many VALUES each carries,
469
+ because every multi-value list becomes its own OR level and every group over `BD_GROUP_MAX`
470
+ adds a split level. So the only honest test is to BUILD the shape and measure it β€” which is
471
+ cheap, because we build it anyway.
472
+ ⚠ Free to get wrong at the vendor and expensive to diagnose there: a rejected filter is never
473
+ billed, but the 400 says nothing about which condition to drop, so a person edits keywords for
474
+ an afternoon while the shape stays identical. That is what this sentence exists to prevent.
475
+ """
476
+ try:
477
+ depth = filter_depth(bd_group(expand_predicates(predicates), operator or "and"))
478
+ except Exception: # noqa: BLE001
479
+ return "" # never block a save on this check's own bug
480
+ if depth <= BD_MAX_DEPTH:
481
+ return ""
482
+ multi = [p for p in (predicates or [])
483
+ if isinstance((p or {}).get("value"), (list, tuple)) and len(p["value"]) > 1]
484
+ hint = (f"drop one of the {len(multi)} multi-value conditions, or shorten its list"
485
+ if multi else "drop one condition")
486
+ return (f"this search nests {depth} levels deep and the provider allows "
487
+ f"{BD_MAX_DEPTH} β€” {hint}. Every extra condition beyond {BD_GROUP_MAX}, and every "
488
+ f"list of values, adds a level")
489
+
490
+
491
+ def expand_predicates(predicates):
492
+ """Stored predicates β†’ the vendor's `filters` list, expanding a value LIST into a nested OR.
493
+
494
+ ⭐ MEASURED 2026-08-06: the filter API ACCEPTS a nested `{operator, filters:[…]}` inside
495
+ `filters` (`snap_msh14ix81n8hlpo3nr`), which is what makes "Bio contains any of floral,
496
+ flower, beauty" expressible WITHOUT setting the whole search to "match any". The alternative
497
+ β€” the global Match dropdown β€” would drag every other condition into the same union, so
498
+ "…and at least 10,000 followers" would silently become "…or at least 10,000 followers".
499
+
500
+ β›” A ONE-VALUE CONDITION IS STILL SENT FLAT. Wrapping it in a one-element group would be a
501
+ shape nothing has ever been billed against, adopted for tidiness, on the path that spends
502
+ money. Only the several-values case takes the new shape.
503
+ """
504
+ out = []
505
+ for p in predicates or []:
506
+ v = (p or {}).get("value")
507
+ if isinstance(v, list) and len(v) > 1:
508
+ # ⚠ THE OR-GROUP IS SUBJECT TO THE SAME 4-RULE CAP as the outer one, and this is the
509
+ # leg that hits it first in practice: "Bio contains any of floral, flower, beauty,
510
+ # bouquet, wedding" is five keywords in ONE condition β€” a single row in the builder,
511
+ # and a group the vendor refuses. `bd_group` is applied to the union too.
512
+ out.append({"operator": "or",
513
+ "filters": bd_group(
514
+ [{"name": p["name"], "operator": p["operator"], "value": one}
515
+ for one in v], "or")})
516
+ elif isinstance(v, list):
517
+ out.append({**p, "value": v[0]} if v else
518
+ {k: val for k, val in p.items() if k != "value"})
519
+ else:
520
+ out.append(dict(p))
521
+ return out
522
+
523
+
524
+ #: β›” HOW MANY HANDLES ONE `not_in` MAY CARRY. W25/R1b MEASURED that the vendor accepts a flat
525
+ #: `account not_in [...]` list to this size β€” *"the 12-value ceiling was OURS, not theirs"*. It is
526
+ #: also why the rule below is built BY HAND instead of going through `expand_predicates`: that
527
+ #: function turns any multi-value predicate into a nested OR of one-value rules, which for 5,000
528
+ #: handles would be a 5,000-rule tree the vendor refuses on both its caps at once.
529
+ BD_EXCLUDE_MAX = 5000
530
+
531
+
532
+ def bd_filter_start(predicates, operator="and", records_limit=5, dataset_id="",
533
+ exclude_handles=(), applied=None, handle_field="account"):
534
+ """Start ONE corpus query. Returns `(snapshot_id, note)`.
535
+
536
+ β›”β›” WAVE 30 Β· T09 β€” TWO INSTAGRAM ASSUMPTIONS CAME OUT OF THIS FUNCTION WHEN IT MOVED, AND THE
537
+ SECOND ONE WAS SPENDING MONEY.
538
+ 1. `dataset_id` defaulted to `BD_DS_PROFILES`. In a module that serves both platforms a
539
+ default corpus is a wrong answer waiting for a caller who forgot; it now REFUSES with a
540
+ sentence rather than searching Instagram on TikTok's behalf.
541
+ 2. ⭐ `handle_field`. The engine-added `not_in` exclusion below named the column **`account`**
542
+ β€” and B-20 MEASURED that Bright Data's TikTok Profiles dataset calls it **`account_id`**
543
+ (5 of our 21 filter names exist there at all; five more are renamed). This rule is
544
+ invisible to the condition builder by design, so no amount of restricting what a PERSON
545
+ may type keeps it off the wire: it rides on every discovery run that has anything to
546
+ exclude β€” i.e. every run after the first. ⚠ **What Bright Data DOES with an unknown filter
547
+ field is UNMEASURED** (refuse, or bill for an unfiltered corpus) and cannot be measured for
548
+ $0, since every `POST /datasets/filter` that answers 200 mints a billable snapshot. Naming
549
+ the column per platform is the half that can be fixed for nothing, so it is fixed here.
550
+
551
+ ⚠ ALWAYS THE COMPOUND `{operator, filters:[…]}` SHAPE, even for a single condition. A bare
552
+ `{name, operator, value}` is a plausible-looking second shape that has never been sent from
553
+ here, and a one-element `filters` list is a trivial extension of the three-element body that
554
+ HAS been measured end-to-end. One shape, and it is the tested one.
555
+
556
+ ⭐⭐ WAVE 27 ITEM 7 β€” `exclude_handles` IS THE ENGINE'S OWN REFINEMENT AND IS NEVER A CONDITION
557
+ ROW. Until now discovery paid for every re-found profile: a nightly search over the same
558
+ keywords returns the same accounts, the upsert notices they are `seen_again`, and the vendor
559
+ has already billed for all of them. This narrows the search at the VENDOR instead of after it.
560
+ It is invisible to the condition builder deliberately (W25/R1b: `not_in` is an engine-added
561
+ refinement, and `BD_OPS_BY_KIND` omits it so nobody can type one) β€” a person did not write it,
562
+ should not have to maintain it, and would be confused by a condition that changes every night.
563
+
564
+ β›” AND IT IS DROPPED RATHER THAN RISKED. Two ways a free saving could break a search that
565
+ works, and both are checked before the body is sent:
566
+ 1. **DEPTH.** The vendor allows 3 logical levels and the 4-rule group cap MANUFACTURES
567
+ depth, so a search already near the ceiling gains a level from this rule and starts
568
+ answering 400 forever ([[bright-data-group-cap]]).
569
+ 2. **`or`.** Under a top-level OR, adding an exclusion as another branch would WIDEN the
570
+ search to "…or any account not in this list" β€” i.e. the whole corpus. Under OR the
571
+ existing filter is wrapped in an AND instead, which costs the level checked in (1).
572
+ A dropped exclusion is not an error: the search runs exactly as it did before and we pay for
573
+ the duplicates, which is the status quo, not a regression.
574
+ """
575
+ if not str(dataset_id or "").strip():
576
+ # Fail-closed, and it names what is missing. A transport that guesses a corpus is a
577
+ # transport that bills the wrong one.
578
+ return "", "the search was not started β€” no corpus was named for this platform"
579
+ nodes = bd_group(expand_predicates(predicates), operator)
580
+ filt = {"operator": operator, "filters": nodes}
581
+ handles = [str(h).strip().lstrip("@").lower() for h in (exclude_handles or [])]
582
+ handles = [h for h in dict.fromkeys(handles) if h][:BD_EXCLUDE_MAX]
583
+ if isinstance(applied, dict):
584
+ applied["available"] = len(handles)
585
+ applied["excluded"] = 0
586
+ if handles:
587
+ rule = {"name": handle_field, "operator": "not_in", "value": handles}
588
+ cand = (bd_group([*nodes, rule], "and") if operator == "and"
589
+ else [filt, rule])
590
+ if filter_depth(cand) <= BD_MAX_DEPTH:
591
+ filt = {"operator": "and", "filters": cand}
592
+ if isinstance(applied, dict):
593
+ applied["excluded"] = len(handles)
594
+ elif isinstance(applied, dict):
595
+ applied["dropped"] = ("the search is already at the provider's nesting ceiling, so "
596
+ "the already-found list was left off rather than risk it")
597
+ body = {"dataset_id": dataset_id, "records_limit": int(records_limit),
598
+ # ⭐ `bd_group` β€” the top-level group obeys the vendor's 4-rule cap (see its note).
599
+ # Applied HERE rather than inside `expand_predicates` so the expansion stays a pure
600
+ # stored-shape β†’ wire-shape translation and the rebalance is one readable step on the
601
+ # one path that actually posts a filter.
602
+ "filter": filt}
603
+ payload, note = bd_call(BD_PATH_FILTER, body=body)
604
+ if note:
605
+ return "", note
606
+ sid = str((payload or {}).get("snapshot_id") or "") if isinstance(payload, dict) else ""
607
+ if not sid:
608
+ return "", "the search was accepted but came back with nothing to collect it from"
609
+ return sid, ""
610
+
611
+
612
+ def bd_filter_status(snapshot_id):
613
+ """`(status, size, note)` for a corpus snapshot. `status` ∈ building | ready | failed | ''."""
614
+ payload, note = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snapshot_id}")
615
+ if note:
616
+ return "", 0, note
617
+ d = payload if isinstance(payload, dict) else {}
618
+ status = str(d.get("status") or "")
619
+ err = str(d.get("error") or "")
620
+ if status == "failed" and "did not match any records" in err.lower():
621
+ # ⭐ MEASURED 2026-08-06 (`snap_msh14ix81n8hlpo3nr`): a filter that matches NOTHING comes
622
+ # back `failed` β€” "Provided filter did not match any records" β€” not `ready` with zero
623
+ # rows. Passed through, that reached the customer as "the search failed", which is the
624
+ # single most likely outcome of typing a keyword that is too specific and the one most
625
+ # certain to be read as "the product is broken". It is an ANSWER, so it gets its own
626
+ # status and no error note.
627
+ return "empty", 0, ""
628
+ if status == "failed" and err:
629
+ # NOT_ENOUGH_FUNDS is the one everybody will hit; say what it means, not just its name.
630
+ extra = (" β€” the matched set is too large for this account's balance; narrow the "
631
+ "conditions or lower the limit" if "FUNDS" in err.upper() else "")
632
+ return status, 0, f"the search failed: {err}{extra}"
633
+ return status, int(d.get("dataset_size") or 0), ""
634
+
635
+
636
+ def bd_filter_rows(snapshot_id):
637
+ """The rows of a READY corpus snapshot. `(rows, note)`.
638
+
639
+ ⚠ `/download` answered *"Snapshot is building. Try again in a few minutes"* while the status
640
+ endpoint ALREADY said `ready` β€” delivery lags readiness by minutes. So a download that does
641
+ not parse as rows is reported as not-yet-collectable rather than as an error.
642
+ """
643
+ payload, note = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snapshot_id}/download",
644
+ {"format": "json"})
645
+ if note:
646
+ return [], note
647
+ rows = _bd_rows(payload)
648
+ if not rows:
649
+ return [], "the search is ready but its rows are not downloadable yet β€” the vendor's " \
650
+ "delivery lags its own `ready` status by minutes"
651
+ return rows, ""
652
+
653
+
654
+ # =============================================================================================
655
+ # 3. ROW PRIMITIVES -- reading one vendor row, for any platform's mapper
656
+ # =============================================================================================
657
+ #: `bd_call` refuses a partial response at this same transport ceiling. A single source document
658
+ #: therefore fits a JSON cell; Source data is never shortened by `_s`, which would corrupt JSON
659
+ #: and silently lose fields.
660
+ BD_SOURCE_PAYLOAD_MAX = BD_MAX_KB * 1024
661
+
662
+
663
+ def _ig_int(v):
664
+ try:
665
+ return int(v)
666
+ except Exception:
667
+ return None
668
+
669
+
670
+ def _first(node, *names, default=None):
671
+ """The first of `names` present on `node` with a real value.
672
+
673
+ ⚠ THE TOLERANCE IS THE POINT, not laziness. The vendor's `components/schemas` was not readable
674
+ on 2026-08-04 (recorded as RECALLED in the digest), so every media field name below is a
675
+ hypothesis. Reading through a candidate list means a rename costs one more candidate instead
676
+ of a rung that returns nothing and cannot say why. **A field that matches nothing returns
677
+ `default` (None), never 0** β€” an invented zero is a measurement, and this module's whole
678
+ honest-status contract exists to stop those.
679
+ """
680
+ if not isinstance(node, dict):
681
+ return default
682
+ for n in names:
683
+ v = node.get(n)
684
+ if v not in (None, ""):
685
+ return v
686
+ return default
687
+
688
+
689
+ def _bd_first_url(v):
690
+ """A link-in-bio value β†’ one string.
691
+
692
+ ⚠ MEASURED 2026-08-05: `external_url` is an **ARRAY** on the real row
693
+ (`["http://linktr.ee/utopianevents/"]`), not the string the previous vendor returned. A dict
694
+ (`external_url_title`) is also in the family. Read through the shape rather than assuming one.
695
+ """
696
+ if isinstance(v, list):
697
+ v = next((x for x in v if x), "")
698
+ if isinstance(v, dict):
699
+ v = _first(v, "url", "link", default="")
700
+ return str(v or "")
701
+
702
+
703
+ def _bd_flag(node, *names):
704
+ """A vendor boolean β†’ `'1'` / `''` / None. **None when the key is ABSENT or null**, which is
705
+ the distinction that matters: `is_business_account: false` is a measurement and `''` records
706
+ it; a missing key is not, and must not arrive as "false"."""
707
+ for n in names:
708
+ v = node.get(n) if isinstance(node, dict) else None
709
+ if v is None:
710
+ continue
711
+ return "1" if v is True or str(v).strip().lower() in ("true", "1", "yes") else ""
712
+ return None
713
+
714
+
715
+ def _bd_list(node, *names):
716
+ """An array field β†’ a comma-joined string, or None when it is absent/empty."""
717
+ for n in names:
718
+ v = node.get(n) if isinstance(node, dict) else None
719
+ if isinstance(v, list) and v:
720
+ return ", ".join(str(x) for x in v if x)[:400]
721
+ return None
722
+
723
+
724
+ def _bd_source_payload(row):
725
+ """One complete Bright Data response row as valid JSON, without field filtering.
726
+
727
+ Source data is the lossless provider record, not a second relationship cache. A Profile's
728
+ embedded ``posts`` array stays here *and* each parseable post becomes a linked Post record;
729
+ keeping both preserves data while Links remain the interactive relational view. `bd_call`
730
+ rejects partial bodies at ``BD_SOURCE_PAYLOAD_MAX``, and the table-layer JSON ceiling matches
731
+ it, so this function never truncates a paid source response.
732
+ """
733
+ if not isinstance(row, dict):
734
+ return None
735
+ try:
736
+ packed = json.dumps({str(k): v for k, v in row.items()}, ensure_ascii=False,
737
+ sort_keys=True, separators=(",", ":"))
738
+ except (TypeError, ValueError):
739
+ return None
740
+ # A source row is bounded by the complete raw HTTP response. Leave this defensive assertion
741
+ # explicit: should the transport ceiling and JSON-cell ceiling ever diverge, fail visibly
742
+ # rather than shortening a document and pretending every paid field was retained.
743
+ if len(packed.encode("utf-8")) > BD_SOURCE_PAYLOAD_MAX:
744
+ raise ValueError("a Bright Data source row exceeded the guarded response ceiling")
745
+ return packed
api/connectors_ig.py CHANGED
@@ -35,7 +35,8 @@ probe doing `import connectors_ig` would have got a half-built engine and an Att
35
  inside a vendor call. **An intermittent-by-import-order failure is the worst shape available**, so
36
  every reach-back is a `from automation_engine import …` INSIDE the function that needs it, where
37
  the module is always fully built. There are exactly seven β€” `_bd_why`, `bd_call`,
38
- `apify_posts`, `apify_profile`, `_tag_metric_deferrals`, `pull_profile_bd`, `pull_profile` β€” each
 
39
  marked `# lazy β€” see the module header`, and they reach for four kinds of thing:
40
 
41
  fetch / fetch_json / Refused the SSRF-guarded HTTP rail (the scrape runner shares it)
@@ -65,6 +66,43 @@ import requests
65
 
66
  import providers
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  # ---------------------------------------------------------------------------------------------
70
  # HANDLE + COUNT READERS (no vendor, no key β€” pure parsing)
@@ -94,11 +132,6 @@ def ig_handle(url):
94
  return re.sub(r"[^A-Za-z0-9._]", "", first)[:40]
95
 
96
 
97
- def _ig_int(v):
98
- try:
99
- return int(v)
100
- except Exception:
101
- return None
102
 
103
 
104
  def _ig_zero_is_blank(v):
@@ -116,200 +149,22 @@ def _ig_zero_is_blank(v):
116
  return None if n == 0 else n
117
 
118
 
119
- BD_BASE_DEFAULT = "https://api.brightdata.com"
120
  BD_DS_PROFILES = "gd_l1vikfch901nx3by4" # Instagram – Profiles. 36 fields, 620M records
121
  BD_DS_POSTS = "gd_lk5ns7kz21pck8jpis" # Instagram – Posts. 43 fields
122
  BD_DS_REELS = "gd_lyclm20il4r5helnj" # Instagram Reels: views and play counts
123
  BD_DS_COMMENTS = "gd_ltppn085pokosxh13" # Instagram Comments: opt-in full comment engagement
124
- BD_PATH_SCRAPE = "/datasets/v3/scrape" # SYNC: rows come back inline. param: dataset_id
125
- BD_PATH_TRIGGER = "/datasets/v3/trigger" # ASYNC: -> {"snapshot_id": "sd_…"}
126
- BD_PATH_SNAPSHOT = "/datasets/v3/snapshot" # /<sd_id>?format=json -> the rows
127
- #: ⭐⭐ 2026-08-09 β€” THE SNAPSHOT'S OWN STATUS DOCUMENT, and asking it is the difference between
128
- #: "the vendor is still working" and "the vendor finished and collected nothing". MEASURED on
129
- #: `sd_msl80v7l1ti14rhcd6` (nurilab's `roxyfoxypinky`): `{"status": "ready", "records": 0,
130
- #: "errors": 1, "error_codes": {"crawl_error": 1}, "collection_duration": 320513}`. The rows
131
- #: endpoint answers `[]` for that snapshot forever, which `bd_scrape`'s old `if rows:` poll could
132
- #: not distinguish from a snapshot mid-build β€” so it burned its whole budget and then reported
133
- #: *"the records are collected, not lost"* about a batch that had collected nothing at all.
134
- #: β›” SCRAPER NAMESPACE ONLY (`sd_…`). The corpus has no twin here; its status is
135
- #: `BD_PATH_FILTER_SNAPSHOT/<snap_id>`, and crossing the two 404s (Β§2c).
136
- BD_PATH_PROGRESS = "/datasets/v3/progress" # /<sd_id> -> {status, records, errors, error_codes}
137
- BD_PATH_FILTER = "/datasets/filter" # ⚠ NO /v3/ β€” the CORPUS query (discovery)
138
- BD_PATH_FILTER_SNAPSHOT = "/datasets/snapshot" # /<snap_id> -> status
139
- #: A sync scrape MEASURED at 15–23 s for one URL and ~40 s for two, so the timeout is generous;
140
- #: it is a ceiling against a hung socket, not a latency budget.
141
- BD_TIMEOUT = 300.0
142
- #: The READ ceiling for one vendor answer, in KB. `fetch` truncates SILENTLY at this size, so it
143
- #: is load-bearing arithmetic rather than a round number: MEASURED `file_size` was 175,617 bytes
144
- #: for 5 corpus rows β‡’ **~35 KB per profile row**, and `BD_MAX_RECORDS` is set from it with
145
- #: headroom (500 Γ— 35 KB β‰ˆ 17 MB against 32 MB). A truncated body is REPORTED as truncated in
146
- #: `bd_call` β€” never allowed to surface as "that is not JSON".
147
- BD_MAX_KB = 32768
148
- #: How long a run waits on a scrape batch the vendor DEFERRED (see `_bd_deferral`). Much shorter
149
- #: than the corpus filter's budget because a scrape snapshot is minutes, not tens of minutes β€”
150
- #: and unlike the corpus path there is no handoff to a later run, so this is the whole wait.
151
- BD_SCRAPE_WAIT = float(os.environ.get("AIOS_BD_SCRAPE_WAIT") or 180)
152
- BD_SCRAPE_POLL = float(os.environ.get("AIOS_BD_SCRAPE_POLL") or 10)
153
- # A Profile response is the enrichment's identity half, so it retains the normal bounded
154
- # synchronous wait. Post/Reel engagement can create several snapshots per profile; action
155
- # runners hand those off immediately and the scheduler collects the already-paid work later.
156
- BD_METRIC_SCRAPE_WAIT = float(os.environ.get("AIOS_BD_METRIC_SCRAPE_WAIT") or 0)
157
- #: ⚠ SPEC, NOT MEASURED β€” off the pricing page, never returned by the API (the funds gate fires
158
- #: BEFORE a price is quoted, and `price: 0` means "not priced", not "free"). Every surface that
159
- #: shows a number derived from this MUST say it is an estimate. `/customer/balance` answers 403
160
- #: for our token, so there is no way to check a balance or a spend from here either.
161
- BD_RECORD_PRICE_SPEC = 0.0025
162
-
163
-
164
- def bd_key():
165
- """The key, or ''. Read fresh every call β€” see the module note above on why."""
166
- return (os.environ.get("AIOS_BRIGHTDATA_KEY") or "").strip()
167
-
168
-
169
- def bd_base():
170
- return (os.environ.get("AIOS_BRIGHTDATA_BASE") or BD_BASE_DEFAULT).strip().rstrip("/")
171
-
172
-
173
- def bd_ready():
174
- """Is the paid rung configured at all? The UI reads this to say so honestly."""
175
- return bool(bd_key())
176
-
177
-
178
- def _bd_why(raw, key=""):
179
- """The vendor's OWN words for a refusal, as `" β€” …"`, or `""` when it gave none.
180
-
181
- β›” THIS WAS THROWN AWAY, AND THE COST WAS MEASURED RATHER THAN IMAGINED. nurilab's live
182
- discovery automation failed four runs across two days with `the profile source answered 400`
183
- while the body it discarded said, in full:
184
-
185
- {"validation_errors":["Filter logical groups can have a maximum of 4 rules."]}
186
-
187
- β€” the exact instruction needed to fix it, in our hands, deleted one line before the customer.
188
- A bare status code sends somebody to read a vendor's schema for a fault we were already
189
- holding the answer to; it is the same defect as a green dot over nothing, pointed the other
190
- way. Every non-2xx says WHY now, and the named cases above (402/401/403/429) keep their own
191
- plainer sentences because those are ours to explain, not the vendor's.
192
- """
193
- from automation_engine import _s # lazy β€” see the module header
194
- try:
195
- text = bytes(raw or b"").decode("utf-8", "replace").strip()[:600]
196
- except Exception: # noqa: BLE001
197
- return ""
198
- if not text:
199
- return ""
200
- why = ""
201
- try:
202
- d = json.loads(text)
203
- except Exception: # noqa: BLE001
204
- d = None
205
- if isinstance(d, dict):
206
- errs = d.get("validation_errors") or d.get("errors")
207
- if isinstance(errs, list) and errs:
208
- why = "; ".join(str(e) for e in errs)
209
- else:
210
- why = str(d.get("error") or d.get("message") or d.get("detail") or "")
211
- why = why or text
212
- # ⚠ A BODY WE DID NOT WRITE GETS THE SAME TREATMENT AS AN EXCEPTION'S str(). `bd_call` already
213
- # refuses to put the latter on the wire in case it carries the key; promising that a vendor
214
- # never echoes an Authorization header back is not a guarantee this module can make.
215
- if key and key in why:
216
- why = why.replace(key, "***")
217
- return f" β€” {_s(why, 300)}"
218
 
219
 
220
- def bd_call(path, params=None, body=None):
221
- """One authenticated vendor call. GET when `body` is None, POST otherwise.
222
 
223
- Returns `(payload, note)`; a non-empty `note` means it did NOT answer, and the note is safe to
224
- show a user and safe to write to a log.
225
 
226
- Never raises. Every refusal shape β€” no key, a guarded base, a 402, a 429, a transport error,
227
- unparseable JSON β€” comes back as a note so the caller can record the attempt and drop to the
228
- next rung.
229
- """
230
- from automation_engine import Refused, fetch, fetch_json # lazy β€” see the module header
231
- key = bd_key()
232
- if not key:
233
- # β›” THE FAIL-CLOSED PATH (C4). Named precisely, because "blocked" with no reason sends
234
- # somebody to read Instagram's status page instead of setting an env var.
235
- return None, "AIOS_BRIGHTDATA_KEY is not configured β€” the paid rung is closed"
236
- qs = "&".join(f"{k}={requests.utils.quote(str(v))}"
237
- for k, v in (params or {}).items() if v not in (None, ""))
238
- url = f"{bd_base()}{path}" + (f"?{qs}" if qs else "")
239
- hdrs = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
240
- try:
241
- if body is None:
242
- status, _final, raw = fetch(url, timeout=BD_TIMEOUT, max_kb=BD_MAX_KB, headers=hdrs)
243
- else:
244
- status, raw = fetch_json(url, body, timeout=BD_TIMEOUT, max_kb=BD_MAX_KB,
245
- headers=hdrs)
246
- except Refused as e:
247
- # The BASE was refused by the SSRF rail β€” a configuration fault, not a vendor outage.
248
- return None, f"the profile source's address was refused by the URL rail: {e}"
249
- except Exception as e: # noqa: BLE001
250
- # ⚠ `type(e).__name__` only. A requests exception's str() can carry the full URL, and the
251
- # Authorization header is one refactor away from being a query param; this line must not
252
- # be what leaks it.
253
- return None, f"the profile source did not answer ({type(e).__name__})"
254
- if status == 402:
255
- return None, "the profile search is out of credit"
256
- if status in (401, 403):
257
- return None, f"the profile source refused our key ({status})"
258
- if status == 429:
259
- # ⚠ MEASURED during the discovery probe: `too_many_parallel_jobs`. The filter API has a
260
- # concurrency cap and the probe's most important test died on it β€” hence every caller
261
- # below is SERIAL by construction.
262
- return None, "the profile source is busy with another search; they run one at a time"
263
- if not (200 <= status < 300):
264
- return None, f"the profile source answered {status}{_bd_why(raw, key)}"
265
- # β›” A BODY THAT REACHED THE READ CEILING IS A TRUNCATION, AND IT MUST SAY SO. `fetch` reads
266
- # at most `max_kb` and returns what it got β€” silently. A truncated JSON document then fails
267
- # `json.loads`, falls through to the NDJSON branch, and comes back as "answered with
268
- # something that is not JSON": a sentence that sends somebody to read the vendor's schema
269
- # looking for a fault that is OUR byte ceiling β€” after a result set that has already been
270
- # paid for. Naming it here is the same no-silent-caps rule the row and table ceilings follow.
271
- if len(raw) >= BD_MAX_KB * 1024:
272
- return None, (f"the profile source's answer exceeded this client's {BD_MAX_KB // 1024} MB read "
273
- f"ceiling and was TRUNCATED β€” ask for fewer records; nothing was parsed "
274
- f"from a partial document")
275
- text = raw.decode("utf-8", "replace").strip()
276
- if not text:
277
- return None, f"the profile source answered {status} with an empty body"
278
- try:
279
- return json.loads(text), ""
280
- except Exception: # noqa: BLE001
281
- pass
282
- # NDJSON is the other shape this API uses for row sets. Tried SECOND, so a genuine JSON error
283
- # is not silently reinterpreted as a one-line NDJSON document.
284
- rows = []
285
- for line in text.splitlines():
286
- line = line.strip()
287
- if not line:
288
- continue
289
- try:
290
- rows.append(json.loads(line))
291
- except Exception: # noqa: BLE001
292
- return None, "the profile source answered with something we could not read"
293
- return (rows, "") if rows else (None, "the profile source answered with something we could not read")
294
 
295
 
296
- def _first(node, *names, default=None):
297
- """The first of `names` present on `node` with a real value.
298
 
299
- ⚠ THE TOLERANCE IS THE POINT, not laziness. The vendor's `components/schemas` was not readable
300
- on 2026-08-04 (recorded as RECALLED in the digest), so every media field name below is a
301
- hypothesis. Reading through a candidate list means a rename costs one more candidate instead
302
- of a rung that returns nothing and cannot say why. **A field that matches nothing returns
303
- `default` (None), never 0** β€” an invented zero is a measurement, and this module's whole
304
- honest-status contract exists to stop those.
305
- """
306
- if not isinstance(node, dict):
307
- return default
308
- for n in names:
309
- v = node.get(n)
310
- if v not in (None, ""):
311
- return v
312
- return default
313
 
314
 
315
  #: Source-data retention is intentionally unfiltered: if Bright Data returned it in an already
@@ -317,10 +172,6 @@ def _first(node, *names, default=None):
317
  #: The one cost boundary is the *request*: the full Comments dataset is only called when an owner
318
  #: enables `commentMetrics`.
319
 
320
- #: `bd_call` refuses a partial response at this same transport ceiling. A single source document
321
- #: therefore fits a JSON cell; Source data is never shortened by `_s`, which would corrupt JSON
322
- #: and silently lose fields.
323
- BD_SOURCE_PAYLOAD_MAX = BD_MAX_KB * 1024
324
 
325
  #: The vendor's content vocabulary β†’ ours. MEASURED values: profiles' `posts[].content_type` is
326
  #: `Image`/`Reel`/`Carousel`; the Posts dataset adds `product_type` `clips`/`carousel_container`.
@@ -355,18 +206,6 @@ def ig_shortcode(url):
355
  return ""
356
 
357
 
358
- def _bd_first_url(v):
359
- """A link-in-bio value β†’ one string.
360
-
361
- ⚠ MEASURED 2026-08-05: `external_url` is an **ARRAY** on the real row
362
- (`["http://linktr.ee/utopianevents/"]`), not the string the previous vendor returned. A dict
363
- (`external_url_title`) is also in the family. Read through the shape rather than assuming one.
364
- """
365
- if isinstance(v, list):
366
- v = next((x for x in v if x), "")
367
- if isinstance(v, dict):
368
- v = _first(v, "url", "link", default="")
369
- return str(v or "")
370
 
371
 
372
  def _bd_posts_count(node):
@@ -389,25 +228,8 @@ def _bd_posts_count(node):
389
  return None if v == 0 else v
390
 
391
 
392
- def _bd_flag(node, *names):
393
- """A vendor boolean β†’ `'1'` / `''` / None. **None when the key is ABSENT or null**, which is
394
- the distinction that matters: `is_business_account: false` is a measurement and `''` records
395
- it; a missing key is not, and must not arrive as "false"."""
396
- for n in names:
397
- v = node.get(n) if isinstance(node, dict) else None
398
- if v is None:
399
- continue
400
- return "1" if v is True or str(v).strip().lower() in ("true", "1", "yes") else ""
401
- return None
402
 
403
 
404
- def _bd_list(node, *names):
405
- """An array field β†’ a comma-joined string, or None when it is absent/empty."""
406
- for n in names:
407
- v = node.get(n) if isinstance(node, dict) else None
408
- if isinstance(v, list) and v:
409
- return ", ".join(str(x) for x in v if x)[:400]
410
- return None
411
 
412
 
413
  def _bd_tagged_location(node):
@@ -432,28 +254,6 @@ def _bd_tagged_location(node):
432
  return ", ".join(parts)[:400] or None
433
 
434
 
435
- def _bd_source_payload(row):
436
- """One complete Bright Data response row as valid JSON, without field filtering.
437
-
438
- Source data is the lossless provider record, not a second relationship cache. A Profile's
439
- embedded ``posts`` array stays here *and* each parseable post becomes a linked Post record;
440
- keeping both preserves data while Links remain the interactive relational view. `bd_call`
441
- rejects partial bodies at ``BD_SOURCE_PAYLOAD_MAX``, and the table-layer JSON ceiling matches
442
- it, so this function never truncates a paid source response.
443
- """
444
- if not isinstance(row, dict):
445
- return None
446
- try:
447
- packed = json.dumps({str(k): v for k, v in row.items()}, ensure_ascii=False,
448
- sort_keys=True, separators=(",", ":"))
449
- except (TypeError, ValueError):
450
- return None
451
- # A source row is bounded by the complete raw HTTP response. Leave this defensive assertion
452
- # explicit: should the transport ceiling and JSON-cell ceiling ever diverge, fail visibly
453
- # rather than shortening a document and pretending every paid field was retained.
454
- if len(packed.encode("utf-8")) > BD_SOURCE_PAYLOAD_MAX:
455
- raise ValueError("a Bright Data source row exceeded the guarded response ceiling")
456
- return packed
457
 
458
 
459
  def _bd_comment(row, influencer_key="", shortcode="", fallback_key=""):
@@ -717,153 +517,12 @@ def bd_true_posts_count(rows):
717
  return None
718
 
719
 
720
- def _bd_rows(payload):
721
- """A vendor answer β†’ the list of row dicts inside it, whatever envelope it arrived in.
722
 
723
- MEASURED shapes: a bare list (`/v3/scrape`), NDJSON (already listified by `bd_call`), and
724
- `{"data": [...]}` / `{"results": [...]}` on the snapshot reads. An unreadable envelope yields
725
- NOTHING rather than a guess.
726
- """
727
- if isinstance(payload, list):
728
- return [x for x in payload if isinstance(x, dict)]
729
- if isinstance(payload, dict):
730
- for k in ("data", "results", "records", "items"):
731
- v = payload.get(k)
732
- if isinstance(v, list):
733
- return [x for x in v if isinstance(x, dict)]
734
- return [payload] if payload else []
735
- return []
736
-
737
-
738
- def _bd_deferral(payload):
739
- """The snapshot id when the 'sync' endpoint DEFERRED, else ''.
740
-
741
- β›” MEASURED 2026-08-05, AND IT IS THE TRAP OF THIS WHOLE RUNG. `/v3/scrape` is documented and
742
- named as the synchronous call, and for a small batch it is β€” two profile URLs came back
743
- inline. Give it more work and it answers **200** with no rows and a note instead:
744
-
745
- {"snapshot_id": "sd_…", "message": "Your request is still in progress and cannot be
746
- retrieved in this call. Use the provided Snapshot ID to track progress…"}
747
-
748
- Read naively that is a 200 with a body, so a parser looking for "did it error?" sails past
749
- it, `_bd_rows` wraps the envelope as ONE unusable row, and the caller reports that the vendor
750
- returned nothing β€” **while a snapshot we have already been billed for sits on the account,
751
- finishing, and is never collected.** That is the D-26 failure shape (paid work abandoned)
752
- arriving through the success path.
753
- ⚠ It is a TIME threshold, not a row count: 2 URLs answered inline, 6 and 10 deferred.
754
- """
755
- if isinstance(payload, dict) and payload.get("snapshot_id") and not payload.get("account"):
756
- return str(payload["snapshot_id"])
757
- return ""
758
 
759
 
760
- def bd_snapshot_progress(sid):
761
- """One scraper snapshot's status. `(state, records, note)`.
762
-
763
- state ∈ `running` | `done` | `failed` | `unknown`:
764
- Β· `running` β€” the vendor is still collecting; poll again.
765
- Β· `done` β€” finished. `records` says whether it produced anything.
766
- Β· `failed` β€” finished, produced NOTHING, and the vendor blamed the TARGET (an
767
- `error_codes` entry such as `crawl_error`). On a profile request that is the
768
- vendor saying it could not reach that account at all, which is a different
769
- action from "we found no matches" β€” so it is a distinct state rather than a
770
- phrase inside the note. A caller that has to grep an English sentence to
771
- decide what happened is a caller whose behaviour changes when someone
772
- improves the wording.
773
- Β· `unknown` β€” the status call itself did not answer; fall back to probing the rows.
774
-
775
- ⭐⭐ 2026-08-09 β€” THE QUESTION `bd_scrape` COULD NOT ASK. Its poll loop advanced only on
776
- `if rows:`, so a snapshot the vendor had FINISHED with zero records looked byte-for-byte like
777
- one still building: the rows endpoint answers `[]` in both cases. MEASURED on nurilab's one
778
- pending handle β€” `status: ready, records: 0, errors: 1, crawl_error: 1` after 320 s β€” while
779
- the enrich run reported *"deferred … the records are collected, not lost"*, which was false
780
- about that batch and had been re-reported on three separate runs.
781
-
782
- β›” `unknown` IS NOT `done`, and the distinction is the whole reason this returns three states
783
- rather than a boolean. When the status call itself fails we must fall back to the old
784
- row-probing behaviour, not conclude the snapshot is empty β€” an unreachable status endpoint
785
- would otherwise turn every deferral into a confident "the vendor found nothing".
786
-
787
- ⚠ THE NOTE IS DELIBERATELY VENDOR-NEUTRAL about WHAT was being read. This serves profile,
788
- post, reel and comment snapshots alike; a sentence naming "the account" would be wrong on
789
- three of the four, and the caller knows which it asked for.
790
- """
791
- payload, err = bd_call(f"{BD_PATH_PROGRESS}/{sid}", None, None)
792
- if err or not isinstance(payload, dict):
793
- return "unknown", 0, ""
794
- status = str(payload.get("status") or "").strip().lower()
795
 
796
- def _n(key):
797
- try:
798
- return int(payload.get(key) or 0)
799
- except (TypeError, ValueError):
800
- return 0
801
-
802
- records, errors = _n("records"), _n("errors")
803
- if status not in ("ready", "done", "failed", "error"):
804
- return "running", records, ""
805
- if records > 0:
806
- return "done", records, ""
807
- codes = payload.get("error_codes")
808
- named = (", ".join(f"{str(k).replace('_', ' ')} x{v}"
809
- for k, v in sorted(codes.items()))
810
- if isinstance(codes, dict) and codes else "")
811
- if errors or named or status in ("failed", "error"):
812
- return "failed", 0, ("the source finished this request and collected nothing"
813
- + (f" β€” it reported {named}" if named else "")
814
- + "; the target is unreachable, private, or no longer exists")
815
- return "done", 0, "the source finished this request and found no records for it"
816
-
817
-
818
- def bd_scrape(dataset_id, urls, wait=None, deferred=None):
819
- """Scrape a batch. `(rows, note)`; a note means it did not answer.
820
-
821
- Sends the SYNC call and, when the vendor defers it (see `_bd_deferral`), **collects the
822
- snapshot it handed back** rather than discarding it. One call carries many URLs, which is
823
- what keeps the per-profile pacing floor from turning a 20-profile run into an hour.
824
- """
825
- requested_urls = [str(u) for u in (urls or []) if u]
826
- payload, note = bd_call(BD_PATH_SCRAPE, {"dataset_id": dataset_id},
827
- body=[{"url": u} for u in requested_urls])
828
- if note:
829
- return [], note
830
- sid = _bd_deferral(payload)
831
- if not sid:
832
- return _bd_rows(payload), ""
833
 
834
- # --- THE DEFERRED PATH. Poll the scraper namespace (`sd_…` at `/datasets/v3/…` β€” crossing it
835
- # with the corpus namespace is a flat 404 about a snapshot that is alive).
836
- budget = BD_SCRAPE_WAIT if wait is None else float(wait)
837
- waited = 0.0
838
- while True:
839
- # ⭐ THE STATUS DOCUMENT IS ASKED FIRST, because it is the only surface that can say
840
- # "finished, and there was nothing". Fetching rows while the vendor is still collecting
841
- # is also a wasted call on every single poll.
842
- state, records, empty_note = bd_snapshot_progress(sid)
843
- if state in ("done", "failed") and not records:
844
- return [], empty_note # a definitive EMPTY, never our timeout sentence
845
- if state != "running":
846
- got, gnote = bd_call(f"{BD_PATH_SNAPSHOT}/{sid}", {"format": "json"})
847
- if not gnote:
848
- rows = _bd_rows(got)
849
- # The status document is itself JSON, so "did it deliver?" is decided by whether
850
- # what came back looks like ROWS β€” never by the HTTP code.
851
- if rows and not _bd_deferral(got) and not (
852
- len(rows) == 1 and str(rows[0].get("status") or "") in
853
- ("running", "building", "collecting")):
854
- return rows, ""
855
- if waited >= budget:
856
- break
857
- time.sleep(BD_SCRAPE_POLL)
858
- waited += BD_SCRAPE_POLL
859
- # β›” NAME THE SNAPSHOT. It has been paid for and it is still finishing; a note that said only
860
- # "no rows" would throw away both the data and the way to get it.
861
- if isinstance(deferred, list):
862
- deferred.append({"snapshotId": sid, "datasetId": str(dataset_id),
863
- "urls": requested_urls})
864
- return [], (f"the profile source deferred this batch to {sid} and it was not ready within "
865
- f"{int(budget)}s β€” the records are collected, not lost; ask for a smaller batch "
866
- f"or collect {sid} from the control panel")
867
 
868
 
869
  #: How many profile URLs ride in ONE `/datasets/v3/scrape` call.
@@ -890,8 +549,49 @@ PROFILE_BATCH_URLS = max(1, int(os.environ.get("AIOS_BD_PROFILE_BATCH") or 5))
890
  MAX_POST_WINDOW = int(os.environ.get("AIOS_IG_MAX_POST_WINDOW") or 60)
891
 
892
 
893
- def bd_profile_posts(handle, count, deferred=None, wait=None):
894
- """A profile's LAST `count` posts via discover-by-url. `(rows, note)`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895
 
896
  β›” A DIFFERENT ROUTE FROM `pull_profile`, AND THAT IS THE WHOLE POINT. The profile record
897
  embeds at most 12 posts however many you ask for β€” measured, not assumed. This is the
@@ -906,16 +606,28 @@ def bd_profile_posts(handle, count, deferred=None, wait=None):
906
  n = max(1, min(int(count), MAX_POST_WINDOW))
907
  except (TypeError, ValueError):
908
  n = 12
 
 
 
 
 
 
909
  payload, note = bd_call(BD_PATH_TRIGGER,
910
  {"dataset_id": BD_DS_POSTS, "type": "discover_new",
911
  "discover_by": "url"},
912
- body=[{"url": f"https://www.instagram.com/{ig_handle(handle) or handle}/",
913
- "num_of_posts": n}])
914
  if note:
915
  return [], note
916
- sid = _bd_deferral(payload) or str((payload or {}).get("snapshot_id") or "")
 
 
 
 
 
 
 
917
  if not sid:
918
- return _bd_rows(payload), ""
919
  budget = BD_SCRAPE_WAIT if wait is None else float(wait)
920
  waited = 0.0
921
  while True:
@@ -929,7 +641,7 @@ def bd_profile_posts(handle, count, deferred=None, wait=None):
929
  if rows and not _bd_deferral(got) and not (
930
  len(rows) == 1 and str(rows[0].get("status") or "") in
931
  ("running", "building", "collecting")):
932
- return rows, ""
933
  if waited >= budget:
934
  break
935
  time.sleep(BD_SCRAPE_POLL)
@@ -941,17 +653,12 @@ def bd_profile_posts(handle, count, deferred=None, wait=None):
941
  f"{int(budget)}s β€” the records are collected, not lost")
942
 
943
 
944
- def select_post_groups(posts, groups):
945
- """Keep the newest `limit` posts of each named TYPE. Returns the kept posts, newest first.
946
-
947
- ⭐ 2026-08-09 (owner: *"not just last 12 but by group also"*). `groups` is
948
- `[{"type": "video", "limit": 12}, {"type": "image", "limit": 6}]`. A type nobody named is
949
- DROPPED, which is the point of a filter β€” asking for reels and getting carousels back is the
950
- behaviour this replaces.
951
 
952
- ⚠ NEWEST-FIRST BY `posted_at`, and a post with no date sorts LAST rather than first. A blank
953
- date is not "just now"; ranking it as though it were would silently prefer exactly the rows we
954
- know least about ([[sentinel-in-a-sort-key]] β€” partition, never rank a sentinel).
955
  """
956
  wanted = {}
957
  for g in (groups or []):
@@ -964,6 +671,45 @@ def select_post_groups(posts, groups):
964
  continue
965
  if t and lim > 0:
966
  wanted[t] = max(wanted.get(t, 0), lim)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
967
  if not wanted:
968
  return list(posts or [])
969
  dated, undated = [], []
@@ -1116,6 +862,65 @@ def apify_posts(urls):
1116
  return [r for r in rows if r], ""
1117
 
1118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1119
  def apify_profile(handle):
1120
  """One handle -> (canonical profile | None, note). A note means it did NOT answer.
1121
 
@@ -1408,7 +1213,7 @@ DEFERRED_MARK = "__aios_deferred_snapshot__"
1408
 
1409
  def pull_profile_bd(url, max_posts=None, post_metrics=False,
1410
  comment_metrics=False, log=print, pending_metrics=None,
1411
- pending_profile=None, prefetch=None):
1412
  """The paid rung: one profile, EXACT counts, plus the top posts the anonymous surface hides.
1413
 
1414
  Same return contract as `pull_profile` (`{state, profile, posts, via, note}`) so the runner
@@ -1517,7 +1322,57 @@ def pull_profile_bd(url, max_posts=None, post_metrics=False,
1517
  if got:
1518
  posts.append(got)
1519
 
1520
- if post_metrics and posts:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1521
  time.sleep(min(PACE_SECONDS, 1.0)) # the vendor is paid, but it is still someone's API
1522
  # Posts and Reels are different Bright Data endpoints. Sending every permalink to the
1523
  # Posts endpoint was why Inayma's five video rows got likes/comments but no views: the
@@ -1525,7 +1380,7 @@ def pull_profile_bd(url, max_posts=None, post_metrics=False,
1525
  # without duplicating any record, so enabling post metrics still buys one row per post.
1526
  ordinary = [p for p in posts if p.get("type") != "video"]
1527
  videos = [p for p in posts if p.get("type") == "video"]
1528
- rows2, metric_notes, ordinary_rows = [], [], []
1529
  # `None` keeps direct callers backwards-compatible: they wait for their answer. An
1530
  # automation passes one run-level list, which makes a deferred metric batch durable and
1531
  # lets this profile finish rather than parking the whole enrichment on it.
@@ -1589,7 +1444,18 @@ def pull_profile_bd(url, max_posts=None, post_metrics=False,
1589
  elif metric_notes:
1590
  note = f"some post metrics were unavailable: {'; '.join(metric_notes)}"
1591
 
 
 
 
 
 
 
 
 
 
1592
  note = "; ".join(x for x in (note, top_up_views(posts, log=log)) if x)
 
 
1593
 
1594
  # ⭐⭐ DEBT D-82 (owner ruling R12: *"price, then wire the Posts-dataset call so posts_count
1595
  # fills with the true value"*). The Profiles dataset's `posts_count: 0` is discarded as the
@@ -1665,243 +1531,29 @@ def pull_profile_bd(url, max_posts=None, post_metrics=False,
1665
  "via": via,
1666
  "note": note or ("" if posts else "profile read; no posts were returned")}
1667
 
1668
- #: β›” THE VENDOR'S HARD CAP ON ONE LOGICAL GROUP β€” MEASURED 2026-08-06 against the live filter API
1669
- #: with nurilab's own seven-condition search, which had failed every run for two days:
1670
- #:
1671
- #: 400 {"validation_errors":["Filter logical groups can have a maximum of 4 rules."]}
1672
- #:
1673
- #: ⚠ EVERY ONE OF THOSE SEVEN CONDITIONS IS ACCEPTED ALONE (all seven probed individually, all
1674
- #: 200). The refusal is about the SHAPE of the group and nothing else β€” so no field, operator or
1675
- #: value in the builder is at fault, and no amount of editing the conditions would have found it.
1676
- #: What it means without `bd_group` below: the FIFTH condition anybody adds turns a working
1677
- #: discovery automation into one that fails forever, and the surface blames the search.
1678
- BD_GROUP_MAX = 4
1679
-
1680
-
1681
- def bd_group(nodes, operator):
1682
- """`nodes` β†’ an equivalent list in which NO logical group exceeds `BD_GROUP_MAX` rules.
1683
-
1684
- ⭐ SAFE BECAUSE AND AND OR ARE ASSOCIATIVE. Folding a run of same-operator rules into a nested
1685
- group of the SAME operator cannot change what the filter matches β€” `a∧b∧c∧d∧e` and
1686
- `(a∧b∧c∧d)∧e` are one predicate written two ways. That is why this is a re-SHAPING of the
1687
- owner's search rather than a reinterpretation of it, and why it needs no ruling: a condition
1688
- list means the same thing before and after. Nesting itself is already proven on this API
1689
- (`expand_predicates`' or-groups, `snap_msh14ix81n8hlpo3nr`).
1690
-
1691
- β›” A ONE-RULE CHUNK IS NEVER WRAPPED. `expand_predicates` already carries that law for the
1692
- one-value case β€” a one-element group is a shape nothing has ever been billed against β€” and a
1693
- balanced split reaches it whenever a level divides with a remainder of one.
1694
- """
1695
- nodes = list(nodes or [])
1696
- if len(nodes) <= BD_GROUP_MAX:
1697
- return nodes
1698
- # ⚠ BALANCED, NOT "take three and nest the rest". The tail-recursive shape costs one level of
1699
- # DEPTH per extra rule (13 conditions β‡’ 5 deep) against a vendor whose nesting limit we have
1700
- # not measured and would discover the same way we discovered this one: in production, on
1701
- # somebody's automation. Chunking makes the depth logβ‚„(n) β€” 16 conditions fit in two levels.
1702
- out = []
1703
- for i in range(0, len(nodes), BD_GROUP_MAX):
1704
- chunk = nodes[i:i + BD_GROUP_MAX]
1705
- out.append(chunk[0] if len(chunk) == 1 else {"operator": operator, "filters": chunk})
1706
- return bd_group(out, operator)
1707
-
1708
-
1709
- #: β›” DEBT D-68 β€” THE VENDOR'S NESTING CEILING, MEASURED IN PRODUCTION 2026-08-06:
1710
- #: 400 β€” "filter" failed custom validation because logical operators cannot be more than
1711
- #: 3 levels deep
1712
- #: ⭐ AND IT IS NOT AN INDEPENDENT BUDGET FROM `BD_GROUP_MAX`. A >4-rule group is fixed by
1713
- #: NESTING, so the 4-rule cap is what MANUFACTURES depth: five top-level conditions carrying
1714
- #: multi-value lists goes `AND`(split) β†’ `OR`(values) β†’ `OR`(split) and is refused. The real
1715
- #: envelope is about 4 top-level conditions x <=4 values each, which is why
1716
- #: `MAX_PREDICATE_VALUES = 12` was never reachable at a realistic condition count.
1717
- BD_MAX_DEPTH = 3
1718
-
1719
-
1720
- def filter_depth(node):
1721
- """How many logical levels a vendor `filters` structure nests to. A bare rule is depth 1."""
1722
- if isinstance(node, list):
1723
- return max((filter_depth(n) for n in node), default=0)
1724
- if isinstance(node, dict) and isinstance(node.get("filters"), list):
1725
- return 1 + filter_depth(node["filters"])
1726
- return 1
1727
-
1728
-
1729
- def depth_refusal(predicates, operator="and"):
1730
- """⭐ D-68 β€” refuse a search whose SHAPE the vendor will reject, before it is sent.
1731
-
1732
- Returns a sentence or `""`.
1733
-
1734
- β›” WHY THIS CANNOT BE A RULE-COUNT CHECK. The failure is in the emitted STRUCTURE, not in the
1735
- conditions: the same five conditions pass or fail depending on how many VALUES each carries,
1736
- because every multi-value list becomes its own OR level and every group over `BD_GROUP_MAX`
1737
- adds a split level. So the only honest test is to BUILD the shape and measure it β€” which is
1738
- cheap, because we build it anyway.
1739
- ⚠ Free to get wrong at the vendor and expensive to diagnose there: a rejected filter is never
1740
- billed, but the 400 says nothing about which condition to drop, so a person edits keywords for
1741
- an afternoon while the shape stays identical. That is what this sentence exists to prevent.
1742
- """
1743
- try:
1744
- depth = filter_depth(bd_group(expand_predicates(predicates), operator or "and"))
1745
- except Exception: # noqa: BLE001
1746
- return "" # never block a save on this check's own bug
1747
- if depth <= BD_MAX_DEPTH:
1748
- return ""
1749
- multi = [p for p in (predicates or [])
1750
- if isinstance((p or {}).get("value"), (list, tuple)) and len(p["value"]) > 1]
1751
- hint = (f"drop one of the {len(multi)} multi-value conditions, or shorten its list"
1752
- if multi else "drop one condition")
1753
- return (f"this search nests {depth} levels deep and the provider allows "
1754
- f"{BD_MAX_DEPTH} β€” {hint}. Every extra condition beyond {BD_GROUP_MAX}, and every "
1755
- f"list of values, adds a level")
1756
-
1757
-
1758
- def expand_predicates(predicates):
1759
- """Stored predicates β†’ the vendor's `filters` list, expanding a value LIST into a nested OR.
1760
-
1761
- ⭐ MEASURED 2026-08-06: the filter API ACCEPTS a nested `{operator, filters:[…]}` inside
1762
- `filters` (`snap_msh14ix81n8hlpo3nr`), which is what makes "Bio contains any of floral,
1763
- flower, beauty" expressible WITHOUT setting the whole search to "match any". The alternative
1764
- β€” the global Match dropdown β€” would drag every other condition into the same union, so
1765
- "…and at least 10,000 followers" would silently become "…or at least 10,000 followers".
1766
-
1767
- β›” A ONE-VALUE CONDITION IS STILL SENT FLAT. Wrapping it in a one-element group would be a
1768
- shape nothing has ever been billed against, adopted for tidiness, on the path that spends
1769
- money. Only the several-values case takes the new shape.
1770
- """
1771
- out = []
1772
- for p in predicates or []:
1773
- v = (p or {}).get("value")
1774
- if isinstance(v, list) and len(v) > 1:
1775
- # ⚠ THE OR-GROUP IS SUBJECT TO THE SAME 4-RULE CAP as the outer one, and this is the
1776
- # leg that hits it first in practice: "Bio contains any of floral, flower, beauty,
1777
- # bouquet, wedding" is five keywords in ONE condition β€” a single row in the builder,
1778
- # and a group the vendor refuses. `bd_group` is applied to the union too.
1779
- out.append({"operator": "or",
1780
- "filters": bd_group(
1781
- [{"name": p["name"], "operator": p["operator"], "value": one}
1782
- for one in v], "or")})
1783
- elif isinstance(v, list):
1784
- out.append({**p, "value": v[0]} if v else
1785
- {k: val for k, val in p.items() if k != "value"})
1786
- else:
1787
- out.append(dict(p))
1788
- return out
1789
 
1790
 
1791
- #: β›” HOW MANY HANDLES ONE `not_in` MAY CARRY. W25/R1b MEASURED that the vendor accepts a flat
1792
- #: `account not_in [...]` list to this size β€” *"the 12-value ceiling was OURS, not theirs"*. It is
1793
- #: also why the rule below is built BY HAND instead of going through `expand_predicates`: that
1794
- #: function turns any multi-value predicate into a nested OR of one-value rules, which for 5,000
1795
- #: handles would be a 5,000-rule tree the vendor refuses on both its caps at once.
1796
- BD_EXCLUDE_MAX = 5000
1797
-
1798
-
1799
- def bd_filter_start(predicates, operator="and", records_limit=5, dataset_id=BD_DS_PROFILES,
1800
- exclude_handles=(), applied=None):
1801
- """Start ONE corpus query. Returns `(snapshot_id, note)`.
1802
-
1803
- ⚠ ALWAYS THE COMPOUND `{operator, filters:[…]}` SHAPE, even for a single condition. A bare
1804
- `{name, operator, value}` is a plausible-looking second shape that has never been sent from
1805
- here, and a one-element `filters` list is a trivial extension of the three-element body that
1806
- HAS been measured end-to-end. One shape, and it is the tested one.
1807
-
1808
- ⭐⭐ WAVE 27 ITEM 7 β€” `exclude_handles` IS THE ENGINE'S OWN REFINEMENT AND IS NEVER A CONDITION
1809
- ROW. Until now discovery paid for every re-found profile: a nightly search over the same
1810
- keywords returns the same accounts, the upsert notices they are `seen_again`, and the vendor
1811
- has already billed for all of them. This narrows the search at the VENDOR instead of after it.
1812
- It is invisible to the condition builder deliberately (W25/R1b: `not_in` is an engine-added
1813
- refinement, and `BD_OPS_BY_KIND` omits it so nobody can type one) β€” a person did not write it,
1814
- should not have to maintain it, and would be confused by a condition that changes every night.
1815
-
1816
- β›” AND IT IS DROPPED RATHER THAN RISKED. Two ways a free saving could break a search that
1817
- works, and both are checked before the body is sent:
1818
- 1. **DEPTH.** The vendor allows 3 logical levels and the 4-rule group cap MANUFACTURES
1819
- depth, so a search already near the ceiling gains a level from this rule and starts
1820
- answering 400 forever ([[bright-data-group-cap]]).
1821
- 2. **`or`.** Under a top-level OR, adding an exclusion as another branch would WIDEN the
1822
- search to "…or any account not in this list" β€” i.e. the whole corpus. Under OR the
1823
- existing filter is wrapped in an AND instead, which costs the level checked in (1).
1824
- A dropped exclusion is not an error: the search runs exactly as it did before and we pay for
1825
- the duplicates, which is the status quo, not a regression.
1826
- """
1827
- nodes = bd_group(expand_predicates(predicates), operator)
1828
- filt = {"operator": operator, "filters": nodes}
1829
- handles = [str(h).strip().lstrip("@").lower() for h in (exclude_handles or [])]
1830
- handles = [h for h in dict.fromkeys(handles) if h][:BD_EXCLUDE_MAX]
1831
- if isinstance(applied, dict):
1832
- applied["available"] = len(handles)
1833
- applied["excluded"] = 0
1834
- if handles:
1835
- rule = {"name": "account", "operator": "not_in", "value": handles}
1836
- cand = (bd_group([*nodes, rule], "and") if operator == "and"
1837
- else [filt, rule])
1838
- if filter_depth(cand) <= BD_MAX_DEPTH:
1839
- filt = {"operator": "and", "filters": cand}
1840
- if isinstance(applied, dict):
1841
- applied["excluded"] = len(handles)
1842
- elif isinstance(applied, dict):
1843
- applied["dropped"] = ("the search is already at the provider's nesting ceiling, so "
1844
- "the already-found list was left off rather than risk it")
1845
- body = {"dataset_id": dataset_id, "records_limit": int(records_limit),
1846
- # ⭐ `bd_group` β€” the top-level group obeys the vendor's 4-rule cap (see its note).
1847
- # Applied HERE rather than inside `expand_predicates` so the expansion stays a pure
1848
- # stored-shape β†’ wire-shape translation and the rebalance is one readable step on the
1849
- # one path that actually posts a filter.
1850
- "filter": filt}
1851
- payload, note = bd_call(BD_PATH_FILTER, body=body)
1852
- if note:
1853
- return "", note
1854
- sid = str((payload or {}).get("snapshot_id") or "") if isinstance(payload, dict) else ""
1855
- if not sid:
1856
- return "", "the search was accepted but came back with nothing to collect it from"
1857
- return sid, ""
1858
 
1859
 
1860
- def bd_filter_status(snapshot_id):
1861
- """`(status, size, note)` for a corpus snapshot. `status` ∈ building | ready | failed | ''."""
1862
- payload, note = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snapshot_id}")
1863
- if note:
1864
- return "", 0, note
1865
- d = payload if isinstance(payload, dict) else {}
1866
- status = str(d.get("status") or "")
1867
- err = str(d.get("error") or "")
1868
- if status == "failed" and "did not match any records" in err.lower():
1869
- # ⭐ MEASURED 2026-08-06 (`snap_msh14ix81n8hlpo3nr`): a filter that matches NOTHING comes
1870
- # back `failed` β€” "Provided filter did not match any records" β€” not `ready` with zero
1871
- # rows. Passed through, that reached the customer as "the search failed", which is the
1872
- # single most likely outcome of typing a keyword that is too specific and the one most
1873
- # certain to be read as "the product is broken". It is an ANSWER, so it gets its own
1874
- # status and no error note.
1875
- return "empty", 0, ""
1876
- if status == "failed" and err:
1877
- # NOT_ENOUGH_FUNDS is the one everybody will hit; say what it means, not just its name.
1878
- extra = (" β€” the matched set is too large for this account's balance; narrow the "
1879
- "conditions or lower the limit" if "FUNDS" in err.upper() else "")
1880
- return status, 0, f"the search failed: {err}{extra}"
1881
- return status, int(d.get("dataset_size") or 0), ""
1882
-
1883
-
1884
- def bd_filter_rows(snapshot_id):
1885
- """The rows of a READY corpus snapshot. `(rows, note)`.
1886
-
1887
- ⚠ `/download` answered *"Snapshot is building. Try again in a few minutes"* while the status
1888
- endpoint ALREADY said `ready` β€” delivery lags readiness by minutes. So a download that does
1889
- not parse as rows is reported as not-yet-collectable rather than as an error.
1890
- """
1891
- payload, note = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snapshot_id}/download",
1892
- {"format": "json"})
1893
- if note:
1894
- return [], note
1895
- rows = _bd_rows(payload)
1896
- if not rows:
1897
- return [], "the search is ready but its rows are not downloadable yet β€” the vendor's " \
1898
- "delivery lags its own `ready` status by minutes"
1899
- return rows, ""
1900
 
1901
 
1902
  def pull_profile(url, max_posts=None, log=print,
1903
  post_metrics=False, comment_metrics=False, pending_metrics=None,
1904
- pending_profile=None, prefetch=None):
1905
  """Everything readable about one public profile β€” EXACT NUMBERS OR NOTHING.
1906
 
1907
  Returns `{state, profile, posts, via, note}` with state ∈ ok | partial | blocked | error.
@@ -1943,7 +1595,8 @@ def pull_profile(url, max_posts=None, log=print,
1943
  paid = pull_profile_bd(url, max_posts=max_posts, post_metrics=post_metrics,
1944
  comment_metrics=comment_metrics, log=log,
1945
  pending_metrics=pending_metrics,
1946
- pending_profile=pending_profile, prefetch=prefetch)
 
1947
  if paid["state"] in ("ok", "partial"):
1948
  return paid
1949
  # ⚠ 200, NOT 90. This string is the ONLY account of what the vendor said, and at 90 the
 
35
  inside a vendor call. **An intermittent-by-import-order failure is the worst shape available**, so
36
  every reach-back is a `from automation_engine import …` INSIDE the function that needs it, where
37
  the module is always fully built. There are exactly seven β€” `_bd_why`, `bd_call`,
38
+ `apify_posts`, `apify_reels`, `apify_profile`, `_tag_metric_deferrals`, `pull_profile_bd`,
39
+ `pull_profile` β€” each
40
  marked `# lazy β€” see the module header`, and they reach for four kinds of thing:
41
 
42
  fetch / fetch_json / Refused the SSRF-guarded HTTP rail (the scrape runner shares it)
 
66
 
67
  import providers
68
 
69
+ # ⭐⭐ WAVE 30 Β· T09 (DEBT D-128) β€” THE VENDOR WIRE LIVES IN `connectors_bd.py` NOW, AND THIS IS AN
70
+ # IMPORT, NOT A RE-EXPORT-FOR-EVERYONE. Only the names THIS file's own code still calls are listed:
71
+ # `verify_automation.section_split` derives the list both ways from the AST, so an import that
72
+ # outlived its last call site and a use with no import are each a red. Everything else that used to
73
+ # live here β€” `bd_ready`, `bd_key`, the whole corpus filter, `bd_group`, `expand_predicates` β€” is
74
+ # reached by its callers directly from `connectors_bd`.
75
+ #
76
+ # β›” **WHY THE NAMES ARE IMPORTED RATHER THAN CALLED AS `bd.<name>`, deliberately:** the functions
77
+ # below that still call `bd_call` and `bd_scrape` resolve them from THIS module's globals, which is
78
+ # what makes `connectors_ig.bd_call = <stub>` continue to seal them exactly as it did before the
79
+ # split. Changing the call style would have silently retired six working patch sites in the gate.
80
+ # ⚠ **AND THE HALF THAT DOES NOT SURVIVE, because it is the money one:** a function that MOVED
81
+ # (`bd_scrape`, `bd_snapshot_progress`, the corpus trio) now resolves `bd_call` from
82
+ # `connectors_bd`'s globals, so patching it HERE no longer seals it. `verify_automation._seal_bd`
83
+ # patches every module that holds a binding and then sweeps `sys.modules` to prove none was missed.
84
+ from connectors_bd import ( # noqa: F401
85
+ BD_MAX_KB,
86
+ BD_METRIC_SCRAPE_WAIT,
87
+ BD_PATH_FILTER,
88
+ BD_PATH_FILTER_SNAPSHOT,
89
+ BD_PATH_SNAPSHOT,
90
+ BD_PATH_TRIGGER,
91
+ BD_SCRAPE_POLL,
92
+ BD_SCRAPE_WAIT,
93
+ _bd_deferral,
94
+ _bd_first_url,
95
+ _bd_flag,
96
+ _bd_list,
97
+ _bd_rows,
98
+ _bd_source_payload,
99
+ _first,
100
+ _ig_int,
101
+ bd_call,
102
+ bd_scrape,
103
+ bd_snapshot_progress
104
+ )
105
+
106
 
107
  # ---------------------------------------------------------------------------------------------
108
  # HANDLE + COUNT READERS (no vendor, no key β€” pure parsing)
 
132
  return re.sub(r"[^A-Za-z0-9._]", "", first)[:40]
133
 
134
 
 
 
 
 
 
135
 
136
 
137
  def _ig_zero_is_blank(v):
 
149
  return None if n == 0 else n
150
 
151
 
 
152
  BD_DS_PROFILES = "gd_l1vikfch901nx3by4" # Instagram – Profiles. 36 fields, 620M records
153
  BD_DS_POSTS = "gd_lk5ns7kz21pck8jpis" # Instagram – Posts. 43 fields
154
  BD_DS_REELS = "gd_lyclm20il4r5helnj" # Instagram Reels: views and play counts
155
  BD_DS_COMMENTS = "gd_ltppn085pokosxh13" # Instagram Comments: opt-in full comment engagement
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
 
 
 
158
 
 
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
 
 
 
162
 
163
+
164
+
165
+
166
+
167
+
 
 
 
 
 
 
 
 
 
168
 
169
 
170
  #: Source-data retention is intentionally unfiltered: if Bright Data returned it in an already
 
172
  #: The one cost boundary is the *request*: the full Comments dataset is only called when an owner
173
  #: enables `commentMetrics`.
174
 
 
 
 
 
175
 
176
  #: The vendor's content vocabulary β†’ ours. MEASURED values: profiles' `posts[].content_type` is
177
  #: `Image`/`Reel`/`Carousel`; the Posts dataset adds `product_type` `clips`/`carousel_container`.
 
206
  return ""
207
 
208
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
 
211
  def _bd_posts_count(node):
 
228
  return None if v == 0 else v
229
 
230
 
 
 
 
 
 
 
 
 
 
 
231
 
232
 
 
 
 
 
 
 
 
233
 
234
 
235
  def _bd_tagged_location(node):
 
254
  return ", ".join(parts)[:400] or None
255
 
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
 
259
  def _bd_comment(row, influencer_key="", shortcode="", fallback_key=""):
 
517
  return None
518
 
519
 
 
 
520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
 
522
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
 
525
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
 
527
 
528
  #: How many profile URLs ride in ONE `/datasets/v3/scrape` call.
 
549
  MAX_POST_WINDOW = int(os.environ.get("AIOS_IG_MAX_POST_WINDOW") or 60)
550
 
551
 
552
+ def _bd_mapped_posts(rows, raw_out=None):
553
+ """Posts-dataset rows -> our canonical post rows, drops what will not map.
554
+
555
+ ⚠ `raw_out` IS NOT A CONVENIENCE. `bd_true_posts_count` reads the vendor's profile-context
556
+ echo, which lives on the RAW row and is destroyed by mapping β€” so a caller that only kept the
557
+ mapped rows would lose the FREE `posts_count` read and fall through to the branch that BUYS a
558
+ record for it. The sink keeps a window route exactly as cheap as the per-permalink one it
559
+ replaces. Same shape as `deferred=` and `applied=` elsewhere in this module.
560
+
561
+ ⚠ `_bd_post_metrics`, the SAME mapper `pull_profile_bd` feeds its per-permalink reads through.
562
+ A window route with its own mapper would be a second answer to "which vendor key is `views`",
563
+ and this repo has already paid for one of those ([[one-question-two-normalizers]]).
564
+ """
565
+ out = []
566
+ for r in (rows or []):
567
+ if isinstance(raw_out, list) and isinstance(r, dict):
568
+ raw_out.append(r)
569
+ got = _bd_post_metrics(r) if isinstance(r, dict) else None
570
+ if got:
571
+ out.append(got)
572
+ return out
573
+
574
+
575
+ def bd_profile_posts(handle, count, deferred=None, wait=None, post_type="", raw_out=None):
576
+ """A profile's LAST `count` posts via discover-by-url. `(rows, note)` β€” rows already MAPPED.
577
+
578
+ ⭐⭐ WAVE 30 Β· T16 (carried W29-T10) β€” `post_type` IS THE WHOLE FIX AND IT IS ONE KEY.
579
+ MEASURED 2026-08-10 on `@theresalearns`, same window, same hour, so no probability argument is
580
+ needed: this route WITHOUT the key returned **8 Reels + 3 Carousels** for a 12-post ask; WITH
581
+ `post_type="reel"` it returned **12/12**. Asking for "the last 12 reels" and being handed eight
582
+ is not a filter that under-delivers, it is a filter applied to the wrong set β€” the shortfall
583
+ scales with a creator's carousel share and nothing in the run says so.
584
+ ⚠ It is HONOURED, not silently ignored (the failure mode that looks like success): the returned
585
+ rows carry `content_type: "Reel"`. Same dataset, same route, same mapper, same price.
586
+ β›” **AND IT DOES NOT REVIVE VIEWS ON BRIGHT DATA.** This measured `product_type`, never `views`;
587
+ the Reels dataset stays type-capable and views-INCAPABLE ([[bd-reels-views-account-level]]).
588
+ Views still come from Apify. Conflating the two questions about one dataset cost a day once.
589
+
590
+ ⭐ ROWS ARE MAPPED HERE NOW, and that is the second half of why this had no caller. It returned
591
+ raw `_bd_rows()` β€” a vendor-shaped list every caller would have had to normalise itself, which
592
+ is a second mapper waiting to disagree with `_bd_post_metrics`. It is the boundary's job.
593
+
594
+ β›” A DIFFERENT ROUTE FROM `pull_profile`, AND THAT IS THE WHOLE POINT.
595
 
596
  β›” A DIFFERENT ROUTE FROM `pull_profile`, AND THAT IS THE WHOLE POINT. The profile record
597
  embeds at most 12 posts however many you ask for β€” measured, not assumed. This is the
 
606
  n = max(1, min(int(count), MAX_POST_WINDOW))
607
  except (TypeError, ValueError):
608
  n = 12
609
+ _row = {"url": f"https://www.instagram.com/{ig_handle(handle) or handle}/",
610
+ "num_of_posts": n}
611
+ if str(post_type or "").strip():
612
+ # ⚠ SENT ONLY WHEN ASKED FOR. An unconditional `post_type: "post"` would change what every
613
+ # future caller buys, and the vendor's documented values are exactly `post` and `reel`.
614
+ _row["post_type"] = str(post_type).strip()
615
  payload, note = bd_call(BD_PATH_TRIGGER,
616
  {"dataset_id": BD_DS_POSTS, "type": "discover_new",
617
  "discover_by": "url"},
618
+ body=[_row])
 
619
  if note:
620
  return [], note
621
+ # β›” `isinstance`, NOT `(payload or {}).get(...)` β€” WAVE 30 Β· T16, and this is a crash that
622
+ # shipped because the function had no caller. The very next line handles the case where the
623
+ # vendor answered with ROWS INLINE instead of a snapshot id, so a list payload is an EXPECTED
624
+ # shape here; `.get` on it raises `AttributeError` before that line can run. Whole, correct-
625
+ # looking, unreachable code is not tested code β€” the first real caller found this in one run
626
+ # ([[artifact-with-no-importer]], the failure mode rather than the waste).
627
+ sid = _bd_deferral(payload) or (str(payload.get("snapshot_id") or "")
628
+ if isinstance(payload, dict) else "")
629
  if not sid:
630
+ return _bd_mapped_posts(_bd_rows(payload), raw_out), ""
631
  budget = BD_SCRAPE_WAIT if wait is None else float(wait)
632
  waited = 0.0
633
  while True:
 
641
  if rows and not _bd_deferral(got) and not (
642
  len(rows) == 1 and str(rows[0].get("status") or "") in
643
  ("running", "building", "collecting")):
644
+ return _bd_mapped_posts(rows, raw_out), ""
645
  if waited >= budget:
646
  break
647
  time.sleep(BD_SCRAPE_POLL)
 
653
  f"{int(budget)}s β€” the records are collected, not lost")
654
 
655
 
656
+ def group_types(groups):
657
+ """`{type: limit}` for a stored `postGroups` value, ignoring anything malformed.
 
 
 
 
 
658
 
659
+ ⚠ ONE READER FOR THE STORED SHAPE. `select_post_groups` built this dict inline and the two
660
+ routing helpers below need the same answer; three copies of "what did the person ask for" is
661
+ how a filter and the read that feeds it end up disagreeing about the ask.
662
  """
663
  wanted = {}
664
  for g in (groups or []):
 
671
  continue
672
  if t and lim > 0:
673
  wanted[t] = max(wanted.get(t, 0), lim)
674
+ return wanted
675
+
676
+
677
+ def reels_only_limit(groups):
678
+ """The reel limit when the config asks for REELS AND NOTHING ELSE, else 0.
679
+
680
+ β›” THE TEST IS EXCLUSIVITY, NOT PRESENCE. "12 reels AND 6 images" cannot be served by a
681
+ single-type route β€” one request cannot express it β€” so a `video in wanted` test would send a
682
+ mixed ask down the reels-only branch and silently drop the images. That is the specialisation
683
+ trap: the narrow route is right only when the ask is narrow.
684
+ """
685
+ wanted = group_types(groups)
686
+ return wanted.get("video", 0) if set(wanted) == {"video"} else 0
687
+
688
+
689
+ def group_window(groups):
690
+ """How many posts ONE wide read must return for every group to be fillable.
691
+
692
+ ⚠ THE SUM, then bounded by `MAX_POST_WINDOW`. A mixed ask is only satisfiable if the window is
693
+ at least as large as everything asked for, and the vendor's window is what actually limits it β€”
694
+ which is why the caller REPORTS a bound it could not meet rather than quietly returning fewer
695
+ (a short window reads downstream as "this creator posts less", which is a claim about them).
696
+ """
697
+ return min(sum(group_types(groups).values()), MAX_POST_WINDOW)
698
+
699
+
700
+ def select_post_groups(posts, groups):
701
+ """Keep the newest `limit` posts of each named TYPE. Returns the kept posts, newest first.
702
+
703
+ ⭐ 2026-08-09 (owner: *"not just last 12 but by group also"*). `groups` is
704
+ `[{"type": "video", "limit": 12}, {"type": "image", "limit": 6}]`. A type nobody named is
705
+ DROPPED, which is the point of a filter β€” asking for reels and getting carousels back is the
706
+ behaviour this replaces.
707
+
708
+ ⚠ NEWEST-FIRST BY `posted_at`, and a post with no date sorts LAST rather than first. A blank
709
+ date is not "just now"; ranking it as though it were would silently prefer exactly the rows we
710
+ know least about ([[sentinel-in-a-sort-key]] β€” partition, never rank a sentinel).
711
+ """
712
+ wanted = group_types(groups)
713
  if not wanted:
714
  return list(posts or [])
715
  dated, undated = [], []
 
862
  return [r for r in rows if r], ""
863
 
864
 
865
+ def apify_reels(handle, limit):
866
+ """A profile's LAST `limit` REELS, with view counts INLINE. `(rows, note)`.
867
+
868
+ ⭐⭐ WAVE 30 Β· T16 (carried W29-T11) β€” ONE CALL FOR WHAT CURRENTLY TAKES THREE.
869
+ MEASURED 2026-08-10 (`@theresalearns`): `resultsType: "reels"` + `resultsLimit: 12` returned
870
+ **12/12 `productType: clips`, every one carrying a distinct `videoPlayCount`** (2,113 β†’
871
+ 418,232). The shipped path for the same answer is a Bright Data profile read, a client-side
872
+ type filter, and a SEPARATE per-permalink Apify `top_up_views` β€” three steps, two vendors, and
873
+ one request's worth of data.
874
+
875
+ β›” A NEW BRANCH, NEVER A RE-POINTING OF THE DEFAULT CHAIN. `ig_post_metrics` does not move and
876
+ Bright Data stays first for it; this is the specialisation for a single-type ask, and it is
877
+ reached only when the config's groups name reels and nothing else.
878
+ ⚠ ITS CALLER MUST NOT THEN RUN `top_up_views`. The views are already here; asking Apify again
879
+ per permalink would buy the same numbers twice, which is the shape a "top-up" is meant to
880
+ avoid rather than cause.
881
+ β›” THE ACTOR PAGE CONTRADICTS ITS OWN INPUT SCHEMA β€” the page says `resultsType` has no
882
+ `"reels"` value. It does; the response is the evidence ([[measure-the-real-call]]).
883
+ """
884
+ from automation_engine import Refused, fetch_json # lazy β€” see the module header
885
+ h = ig_handle(handle) or str(handle or "").strip().lstrip("@")
886
+ if not h:
887
+ return [], "no handle to read reels for"
888
+ key = apify_key()
889
+ if not key:
890
+ return [], "AIOS_APIFY_KEY is not configured β€” the Apify rung is closed"
891
+ try:
892
+ n = max(1, min(int(limit), MAX_POST_WINDOW))
893
+ except (TypeError, ValueError):
894
+ n = 12
895
+ url = (f"{APIFY_BASE}/acts/{APIFY_ACTOR_POSTS}/run-sync-get-dataset-items"
896
+ f"?token={requests.utils.quote(key)}&timeout={int(APIFY_WAIT)}")
897
+ body = {"directUrls": [f"https://www.instagram.com/{h}/"], "resultsType": "reels",
898
+ "resultsLimit": n, "addParentData": False}
899
+ try:
900
+ status, raw = fetch_json(url, body, timeout=APIFY_WAIT + 60, max_kb=BD_MAX_KB)
901
+ except Refused as e:
902
+ return [], f"Apify's address was refused by the URL rail: {e}"
903
+ except Exception as e: # noqa: BLE001
904
+ # ⚠ TYPE NAME ONLY β€” the token is in the query string of `url`.
905
+ return [], f"Apify did not answer ({type(e).__name__})"
906
+ if status == 402:
907
+ return [], "the Apify account is out of credit"
908
+ if status in (401, 403):
909
+ return [], f"Apify refused our key ({status})"
910
+ if status >= 400:
911
+ return [], f"Apify answered {status}"
912
+ try:
913
+ items = json.loads(raw.decode("utf-8", "replace"))
914
+ except Exception: # noqa: BLE001
915
+ return [], "Apify's answer was not readable JSON"
916
+ if not isinstance(items, list):
917
+ return [], "Apify answered with something other than a list of items"
918
+ rows = [r for r in (providers.normalize_post_apify(x) for x in items) if r]
919
+ if not rows:
920
+ return [], "Apify answered with no reels for this profile"
921
+ return rows, ""
922
+
923
+
924
  def apify_profile(handle):
925
  """One handle -> (canonical profile | None, note). A note means it did NOT answer.
926
 
 
1213
 
1214
  def pull_profile_bd(url, max_posts=None, post_metrics=False,
1215
  comment_metrics=False, log=print, pending_metrics=None,
1216
+ pending_profile=None, prefetch=None, post_groups=None):
1217
  """The paid rung: one profile, EXACT counts, plus the top posts the anonymous surface hides.
1218
 
1219
  Same return contract as `pull_profile` (`{state, profile, posts, via, note}`) so the runner
 
1322
  if got:
1323
  posts.append(got)
1324
 
1325
+ # ⭐⭐ WAVE 30 Β· T16 β€” THE WINDOW COMES BEFORE THE PRICE, WHICH IS WHY THIS SITS HERE AND NOT
1326
+ # IN THE ENGINE. `select_post_groups` filters what we already bought, and what we already
1327
+ # bought is the PROFILE's embed β€” at most 12 posts, MIXED. So "the last 12 reels" has been
1328
+ # landing eight on a creator who posts carousels, forever, with a green run and a summary that
1329
+ # says twelve. The engine's own comment named this and deferred it: *"widening belongs ahead of
1330
+ # that step, in the paid path, and that is its own change"*. This is that change.
1331
+ #
1332
+ # β›” GATED ON `post_metrics`, DELIBERATELY. Both routes below BUY records. `post_metrics` is
1333
+ # the switch that already means "I am paying per post"; widening without it would spend money
1334
+ # on a run whose config says not to. ⚠ And when a group config cannot be honoured for that
1335
+ # reason the run SAYS SO rather than filtering a window it knows is too narrow β€” a short answer
1336
+ # that reads as a fact about the creator is the failure this note exists to prevent.
1337
+ # ⚠ `rows2` IS INITIALISED HERE, not inside the metric block, and that is a defect this
1338
+ # ticket introduced and caught in one run: the block that defines it is now SKIPPED when a
1339
+ # window was bought, so `bd_true_posts_count(rows2 ...)` below raised `UnboundLocalError` on
1340
+ # the new path. A name defined in a branch, read outside it, is a crash waiting for whoever
1341
+ # adds the next branch.
1342
+ rows2 = []
1343
+ views_inline, window_bought, window_note = False, False, ""
1344
+ if post_groups and not post_metrics:
1345
+ window_note = ("post groups were configured but post data is switched off, so they filtered "
1346
+ "the profile's own preview (at most 12 mixed posts) rather than a window "
1347
+ "bought for them")
1348
+ elif post_groups:
1349
+ _reels_n = reels_only_limit(post_groups)
1350
+ if _reels_n and apify_ready():
1351
+ # ⭐ ONE CALL: last N reels WITH `videoPlayCount` inline. `views_inline` is what stops
1352
+ # `top_up_views` buying the same numbers a second time, per permalink.
1353
+ wide, wnote = apify_reels(handle, _reels_n)
1354
+ if wide:
1355
+ posts, views_inline, window_bought = wide, True, True
1356
+ else:
1357
+ window_note = f"the reels route did not answer ({_s(wnote, 90)})"
1358
+ else:
1359
+ # ⚠ THE FALLBACK IS NOT A DEGRADED COPY β€” for a reels-only ask it sends Bright Data's
1360
+ # own `post_type: "reel"`, MEASURED at 12/12 where the unfiltered route gives 8. For a
1361
+ # MIXED ask no single-type route exists, so it buys the SUM and lets
1362
+ # `select_post_groups` keep N of each β€” which is the one path that clause still owns.
1363
+ wide, wnote = bd_profile_posts(handle, group_window(post_groups),
1364
+ post_type=("reel" if _reels_n else ""),
1365
+ deferred=pending_metrics, raw_out=rows2)
1366
+ if wide:
1367
+ posts, window_bought = wide, True
1368
+ else:
1369
+ window_note = f"the post window was not readable ({_s(wnote, 90)})"
1370
+
1371
+ # β›” `not window_bought` IS THE DOUBLE-SPEND GUARD, and it is the whole reason the routes
1372
+ # above return MAPPED rows. Both of them buy records that already carry likes, comments,
1373
+ # captions and (on the Apify branch) views; re-reading the same permalinks below would pay
1374
+ # twice for numbers already in hand β€” the exact thing a "top-up" exists to avoid causing.
1375
+ if post_metrics and posts and not window_bought:
1376
  time.sleep(min(PACE_SECONDS, 1.0)) # the vendor is paid, but it is still someone's API
1377
  # Posts and Reels are different Bright Data endpoints. Sending every permalink to the
1378
  # Posts endpoint was why Inayma's five video rows got likes/comments but no views: the
 
1380
  # without duplicating any record, so enabling post metrics still buys one row per post.
1381
  ordinary = [p for p in posts if p.get("type") != "video"]
1382
  videos = [p for p in posts if p.get("type") == "video"]
1383
+ metric_notes, ordinary_rows = [], []
1384
  # `None` keeps direct callers backwards-compatible: they wait for their answer. An
1385
  # automation passes one run-level list, which makes a deferred metric batch durable and
1386
  # lets this profile finish rather than parking the whole enrichment on it.
 
1444
  elif metric_notes:
1445
  note = f"some post metrics were unavailable: {'; '.join(metric_notes)}"
1446
 
1447
+ # ⭐⭐ T16 β€” THE ONE PATH THAT LEGITIMATELY SKIPS THE VIEWS TOP-UP, and it is skipped because
1448
+ # the numbers are already here rather than because nobody wants them. `apify_reels` returns
1449
+ # `videoPlayCount` INLINE on every row; `top_up_views` would then buy the same figure again,
1450
+ # per permalink, from the same vendor. Every other route still tops up β€” Bright Data's Reels
1451
+ # dataset is MEASURED views-incapable, so a BD window is exactly as short of views as the
1452
+ # profile embed was. ⚠ Lifted OUT of the per-permalink block on purpose: leaving it there
1453
+ # would have silently dropped the top-up for a Bright Data WINDOW too, which is the quiet
1454
+ # half-fix this comment exists to refuse.
1455
+ if post_metrics and posts and not views_inline:
1456
  note = "; ".join(x for x in (note, top_up_views(posts, log=log)) if x)
1457
+ if window_note:
1458
+ note = "; ".join(x for x in (note, window_note) if x)
1459
 
1460
  # ⭐⭐ DEBT D-82 (owner ruling R12: *"price, then wire the Posts-dataset call so posts_count
1461
  # fills with the true value"*). The Profiles dataset's `posts_count: 0` is discarded as the
 
1531
  "via": via,
1532
  "note": note or ("" if posts else "profile read; no posts were returned")}
1533
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1534
 
1535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1536
 
1537
 
1538
+
1539
+
1540
+
1541
+
1542
+
1543
+
1544
+
1545
+
1546
+
1547
+
1548
+
1549
+
1550
+
1551
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1552
 
1553
 
1554
  def pull_profile(url, max_posts=None, log=print,
1555
  post_metrics=False, comment_metrics=False, pending_metrics=None,
1556
+ pending_profile=None, prefetch=None, post_groups=None):
1557
  """Everything readable about one public profile β€” EXACT NUMBERS OR NOTHING.
1558
 
1559
  Returns `{state, profile, posts, via, note}` with state ∈ ok | partial | blocked | error.
 
1595
  paid = pull_profile_bd(url, max_posts=max_posts, post_metrics=post_metrics,
1596
  comment_metrics=comment_metrics, log=log,
1597
  pending_metrics=pending_metrics,
1598
+ pending_profile=pending_profile, prefetch=prefetch,
1599
+ post_groups=post_groups)
1600
  if paid["state"] in ("ok", "partial"):
1601
  return paid
1602
  # ⚠ 200, NOT 90. This string is the ONLY account of what the vendor said, and at 90 the
api/connectors_tt.py CHANGED
@@ -16,13 +16,15 @@ name below reads through a candidate list it is because the vendor has two names
16
  public data through a supplier, exactly the rail `connectors_ig.py` states for Instagram. The
17
  vendor key is a key to a SUPPLIER.
18
 
19
- ⚠ **THE TRANSPORT IS BORROWED FROM `connectors_ig`, ON PURPOSE.** `bd_call`, `bd_scrape` and
 
20
  `bd_filter_start` take the dataset id as a PARAMETER β€” they are Bright Data's wire, not
21
- Instagram's β€” so re-implementing them here would be a second copy of the deferral handling, the
22
  truncation guard, the SSRF rail and the snapshot-progress reader, i.e. five places for one bug.
23
- The name is wrong and the code is right; extracting a `connectors_bd` transport module is booked
24
- as a PENDING line in B's mailbox rather than done mid-wave, because moving those functions would
25
- land in the middle of three other tickets' diffs.
 
26
 
27
  ⚠ **WHAT $0 COULD NOT BUY, so nobody reads this file as more measured than it is:**
28
  1. the real ROW shape β€” `/metadata` describes a DATASET, and Instagram's rows carry undeclared
@@ -34,17 +36,18 @@ land in the middle of three other tickets' diffs.
34
  from __future__ import annotations
35
 
36
  import automation_engine as engine
37
- from connectors_ig import ( # noqa: F401 (bd_call/bd_scrape are re-exported for the runners)
 
 
 
 
 
38
  _bd_first_url,
39
  _bd_flag,
40
  _bd_list,
41
  _bd_source_payload,
42
  _first,
43
  _ig_int,
44
- bd_call,
45
- bd_filter_start,
46
- bd_key,
47
- bd_ready,
48
  bd_scrape,
49
  )
50
 
@@ -291,3 +294,227 @@ TT_NORMALIZERS = {
291
  "tt_post_metrics": normalize_post,
292
  "tt_comments": normalize_comment,
293
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  public data through a supplier, exactly the rail `connectors_ig.py` states for Instagram. The
17
  vendor key is a key to a SUPPLIER.
18
 
19
+ ⭐ **THE TRANSPORT IS `connectors_bd.py` β€” SHARED, VENDOR-NAMED, AND NO LONGER BORROWED FROM THE
20
+ OTHER PLATFORM'S CONNECTOR** (WAVE 30 Β· T09, DEBT D-128). `bd_call`, `bd_scrape` and
21
  `bd_filter_start` take the dataset id as a PARAMETER β€” they are Bright Data's wire, not
22
+ Instagram's β€” and re-implementing them here would be a second copy of the deferral handling, the
23
  truncation guard, the SSRF rail and the snapshot-progress reader, i.e. five places for one bug.
24
+ Until wave 30 the code was right and the NAME was wrong: this file imported thirteen symbols from
25
+ `connectors_ig`, which read as a dependency on Instagram and was really a dependency on a supplier.
26
+ ⚠ **This file now imports ZERO names from `connectors_ig`, and a gate check asserts that**, because
27
+ the sentence above is the kind that quietly stops being true.
28
 
29
  ⚠ **WHAT $0 COULD NOT BUY, so nobody reads this file as more measured than it is:**
30
  1. the real ROW shape β€” `/metadata` describes a DATASET, and Instagram's rows carry undeclared
 
36
  from __future__ import annotations
37
 
38
  import automation_engine as engine
39
+ # ⚠ T09 β€” FOUR NAMES CAME OFF THIS LIST AND NOTHING BROKE, which is the point of deriving an
40
+ # import block from the AST both ways. `bd_call`, `bd_filter_start`, `bd_key` and `bd_ready` were
41
+ # imported here under a comment claiming they were *"re-exported for the runners"*; no runner ever
42
+ # read them off this module (the engine imports them from the transport itself), so they were four
43
+ # lines of dependency nobody was paying for. `[[artifact-with-no-importer]]` in its smallest form.
44
+ from connectors_bd import (
45
  _bd_first_url,
46
  _bd_flag,
47
  _bd_list,
48
  _bd_source_payload,
49
  _first,
50
  _ig_int,
 
 
 
 
51
  bd_scrape,
52
  )
53
 
 
294
  "tt_post_metrics": normalize_post,
295
  "tt_comments": normalize_comment,
296
  }
297
+
298
+
299
+ # ---------------------------------------------------------------------------------------------
300
+ # THE FETCH β€” wave 30 Β· W30-T08 (carrying wave-29's dropped T05)
301
+ # ---------------------------------------------------------------------------------------------
302
+
303
+ def tt_handle(url):
304
+ """A TikTok profile URL **or** a bare handle β†’ the handle. `''` when it is neither.
305
+
306
+ Deliberately permissive about the input and strict about the output, because the two callers
307
+ hand it different things: an automation stores whatever a person typed in the profile column
308
+ (`@nurilab`, `nurilab`, or the full URL), while the discovery runner already holds a clean
309
+ `account_id`. One normaliser, so a row found by discovery and a row typed by hand cannot
310
+ resolve to two different handles.
311
+ """
312
+ s = str(url or "").strip()
313
+ if not s:
314
+ return ""
315
+ if "tiktok.com" in s.lower():
316
+ # Everything after the first `@`, up to the next path segment or query.
317
+ tail = s.split("@", 1)[1] if "@" in s else ""
318
+ s = tail.split("/")[0].split("?")[0].split("#")[0]
319
+ s = s.strip().lstrip("@").strip()
320
+ # A handle is the vendor's `account_id` shape: alphanumerics, dots and underscores.
321
+ return s if s and all(c.isalnum() or c in "._" for c in s) else ""
322
+
323
+
324
+ def tt_post_urls(node, limit=0):
325
+ """⭐ WAVE 30 Β· T10 β€” the profile row's own post permalinks, newest-first as the vendor sends.
326
+
327
+ β›” THIS IS WHY TIKTOK POST CAPTURE COSTS NO EXTRA DISCOVERY. The probe measured `top_videos` as
328
+ *"an array of video permalinks, NO empties"* on the PROFILE row we have already bought β€” so the
329
+ posts read is a scrape of URLs we hold, never a search for them. The two TikTok DISCOVERY
330
+ datasets (posts-by-profile, posts-by-keyword) are **404 for our key**, so a design that reached
331
+ for either would not merely be dearer, it would not work.
332
+
333
+ ⚠ `top_posts_data` is deliberately NOT read: the probe calls it *"a thin dup of `top_videos`"*,
334
+ and preferring whichever happened to be longer is how one creator's window silently differs
335
+ from another's.
336
+ ⚠ `limit <= 0` means "everything the row carried". The CAP IS THE CALLER'S β€” `config.maxPosts`,
337
+ validated 1..12 β€” and it is applied here rather than after the scrape so an unwanted post is
338
+ never bought. [[a-constant-two-features-share]]: the 12 is the vendor's measured profile window,
339
+ not a number this function may invent.
340
+ """
341
+ raw = (node or {}).get("top_videos")
342
+ out = []
343
+ for item in raw if isinstance(raw, list) else []:
344
+ # The array is documented as permalinks; a vendor that starts sending objects instead must
345
+ # degrade to "no posts", never to a URL built out of a guess.
346
+ u = str(item.get("url") or "").strip() if isinstance(item, dict) else str(item or "").strip()
347
+ if u.lower().startswith("http") and u not in out:
348
+ out.append(u)
349
+ return out[:limit] if limit and limit > 0 else out
350
+
351
+
352
+ def pull_posts_tt(post_urls, log=print, deferred=None):
353
+ """The TikTok Posts dataset for a list of permalinks β†’ `(rows, note)`, already normalised.
354
+
355
+ ⚠ ONE CALL FOR THE WHOLE WINDOW. `bd_scrape` has always taken a list, and the Instagram side
356
+ measured what happens when a caller forgets: 25 records, one billed snapshot each, a walk still
357
+ running at 67 minutes. Nothing here loops per URL.
358
+ """
359
+ urls = [str(u) for u in (post_urls or []) if str(u or "").strip()]
360
+ if not urls:
361
+ return [], ""
362
+ rows, note = bd_scrape(TT_DS_POSTS, urls, deferred=deferred)
363
+ if note:
364
+ log(f"[aios-tt] posts: {note}")
365
+ return [], note
366
+ out = [r for r in (normalize_post(n) for n in rows) if r]
367
+ return out, ""
368
+
369
+
370
+ def pull_comments_tt(post_urls, log=print, deferred=None):
371
+ """The TikTok Comments dataset for a list of POST permalinks β†’ `(rows, note)`, normalised.
372
+
373
+ β›” THE MOST EXPENSIVE THING THIS PRODUCT BUYS, and the reason `commentMetrics` defaults OFF on
374
+ both networks: a comments scrape ingests identifiable third parties who never entered anybody's
375
+ list (D-24). The mapper already keeps every commenter field in `source_payload` and promotes
376
+ none of them to a column; this function adds no new exposure, it just has to be asked for.
377
+ """
378
+ urls = [str(u) for u in (post_urls or []) if str(u or "").strip()]
379
+ if not urls:
380
+ return [], ""
381
+ rows, note = bd_scrape(TT_DS_COMMENTS, urls, deferred=deferred)
382
+ if note:
383
+ log(f"[aios-tt] comments: {note}")
384
+ return [], note
385
+ out = [r for r in (normalize_comment(n) for n in rows) if r]
386
+ return out, ""
387
+
388
+
389
+ #: ⭐⭐ WAVE 30 Β· D-156 β€” THE MEDIA DATASETS, AS A SET, SO THE HAND-OFF CAN FILTER ON IDENTITY.
390
+ #: `_media_deferrals` uses this to lift ONLY posts/comments snapshots out of the local deferral
391
+ #: list. That is what makes it structurally impossible to file a PROFILE snapshot in the engine's
392
+ #: metric queue β€” the defect a draft of T10 shipped and A-39 booked as "the wrong fix is worse
393
+ #: than the gap". A membership test cannot be got wrong by a later edit the way `if` order can.
394
+ TT_MEDIA_DATASETS = (TT_DS_POSTS, TT_DS_COMMENTS)
395
+
396
+
397
+ def _media_deferrals(deferred):
398
+ """The POSTS/COMMENTS entries of a `bd_scrape` deferral list β€” never the profile's.
399
+
400
+ ⚠ The engine, not this module, decides what a deferral MEANS: it stamps `kind` and the
401
+ handle and files it. TikTok needs no `_tag_metric_deferrals` twin because one dataset is one
402
+ kind here, so the id already carries everything a mapper choice depends on β€” and importing
403
+ Instagram's tagger is not available anyway (W30-T09 gates ZERO `from connectors_ig` lines).
404
+ """
405
+ out = []
406
+ for d in deferred or []:
407
+ if isinstance(d, dict) and str(d.get("datasetId") or "") in TT_MEDIA_DATASETS:
408
+ out.append(dict(d))
409
+ return out
410
+
411
+
412
+ def pull_profile_tt(url, log=print, pending_profile=None, prefetch=None,
413
+ max_posts=0, post_metrics=False, comment_metrics=False):
414
+ """ONE TikTok profile from the vendor. Same return contract as `pull_profile`.
415
+
416
+ `{state, profile, posts, comments, via, note}` with `state ∈ ok | partial | blocked | error`,
417
+ so the engine's enrich branch treats every network identically and no caller learns a new
418
+ shape.
419
+
420
+ ⭐ WAVE 30 Β· T10 β€” POSTS AND COMMENTS ARE REAL NOW, AND BOTH DEFAULT OFF, exactly as Instagram's
421
+ do. `post_metrics` scrapes the profile row's own `top_videos` permalinks (see `tt_post_urls` β€”
422
+ no discovery call, because both TikTok discovery datasets 404 for our key); `comment_metrics`
423
+ then scrapes the comments of the posts that came back. ⚠ COMMENTS REQUIRE POSTS by construction
424
+ rather than by a rule: their input IS a post permalink, so asking for comments with post capture
425
+ off is a request with no subject, and it returns none instead of quietly buying posts nobody
426
+ asked for.
427
+
428
+ β›” `partial` IS THE SUCCESS STATE WHENEVER NO MEDIA WAS READ, and that is deliberate rather than
429
+ pessimistic. The Instagram contract reads `ok` only when identity AND media both landed
430
+ (`pull_profile_bd`: *"identity without media is still partial ... a run that wrote a follower
431
+ count and no posts must not paint green over a posts table that did not grow"*). So: posts not
432
+ ASKED for β†’ `partial`, saying so; posts asked for and landed β†’ `ok`; asked for and none came β†’
433
+ `partial` with the vendor's reason. The state answers "did this pull deliver what it went for",
434
+ never "did the function finish".
435
+
436
+ ⚠ **NO FREE RUNG, AND NO FALLBACK CHAIN.** Instagram's `pull_profile` drops to Apify when the
437
+ paid rung refuses; `providers.DEFAULT_CHAINS["tt_profile"]` is deliberately single-provider,
438
+ with its own note explaining that a multi-provider chain is a promise something walks it and
439
+ that nothing walks Instagram's second name today either. So a refusal here is final, and it
440
+ says so instead of implying a retry somewhere.
441
+ """
442
+ handle = tt_handle(url)
443
+ if not handle:
444
+ return {"state": "error", "profile": {}, "posts": [], "comments": [], "via": "",
445
+ "note": f"{url!r} is not a TikTok profile URL or handle"}
446
+
447
+ # ⭐ THE BATCH FAST PATH, same shape as the Instagram side: `prefetch` is `{handle: node}` from
448
+ # one multi-URL scrape covering a whole selection. A hit is a vendor round trip that does not
449
+ # happen; a miss falls through to the single-URL call below.
450
+ cached = prefetch.get(handle) if isinstance(prefetch, dict) else None
451
+ _deferred = []
452
+ if isinstance(cached, dict) and cached:
453
+ rows, note = [cached], ""
454
+ else:
455
+ rows, note = bd_scrape(TT_DS_PROFILES, [tt_profile_url(handle)], deferred=_deferred)
456
+
457
+ node = rows[0] if rows else {}
458
+ profile = normalize_profile(node, handle) if node else {}
459
+ # β›” THE READABILITY TEST IS `followers`/`following`, NOT "did we get a dict". `normalize_profile`
460
+ # drops blanks, so an unreadable row still returns `{"platform": …, "handle": …}` β€” truthy, and
461
+ # carrying nothing anybody asked for. The Instagram rung tests exactly this pair for exactly
462
+ # this reason, and answering "0 followers" instead is the failure it exists to prevent.
463
+ unreadable = profile.get("followers") is None and profile.get("following") is None
464
+ if note or unreadable:
465
+ # ⭐ THE DEFERRAL IS HANDED OVER RATHER THAN DISCARDED. A snapshot the vendor is still
466
+ # building HAS ALREADY BEEN PAID FOR; dropping its id bills again on the next run for the
467
+ # same record. That was live on the Instagram profile path until 2026-08-09 β€” measured on
468
+ # nurilab as two runs, two fresh snapshots, both abandoned β€” and it is not being
469
+ # reintroduced here by omission.
470
+ if isinstance(pending_profile, list):
471
+ for d in _deferred:
472
+ pending_profile.append({**d, "kind": "profile", "influencer": handle})
473
+ why = note or ("the scrape answered, but no follower/following counts were readable in it "
474
+ "(the field names may have moved - see tiktok-capture.md)")
475
+ return {"state": "blocked", "profile": {}, "posts": [], "comments": [], "via": "brightdata",
476
+ "deferredProfile": [d.get("snapshotId") for d in _deferred],
477
+ "note": why}
478
+
479
+ # --- W30-T10: THE MEDIA, ONLY WHEN IT WAS ASKED FOR. ------------------------------------
480
+ if not post_metrics:
481
+ return {"state": "partial", "profile": profile, "posts": [], "comments": [],
482
+ "via": "brightdata",
483
+ "note": note or "profile read; post capture is off for this step"}
484
+ urls = tt_post_urls(node, limit=max_posts)
485
+ if not urls:
486
+ # ⚠ NOT AN ERROR AND NOT A RETRY. A creator with no `top_videos` has nothing to buy, and
487
+ # saying so is what stops the next run paying to be told the same thing.
488
+ return {"state": "partial", "profile": profile, "posts": [], "comments": [],
489
+ "via": "brightdata",
490
+ "note": note or "profile read; this account's row carried no post links"}
491
+ posts, p_note = pull_posts_tt(urls, log=log, deferred=_deferred)
492
+ comments, c_note = ([], "")
493
+ if comment_metrics and posts:
494
+ # The comments dataset is keyed on a POST permalink, so it reads the posts we just bought β€”
495
+ # `url` from the mapper, never the profile's raw array, so a post the posts scrape refused
496
+ # is not silently asked about again one rung later.
497
+ comments, c_note = pull_comments_tt([p.get("url") for p in posts if p.get("url")],
498
+ log=log, deferred=_deferred)
499
+ # ⭐⭐ WAVE 30 Β· D-156 β€” THE MEDIA DEFERRALS ARE HANDED BACK, and the shape of the hand-off is
500
+ # the whole lesson. An earlier draft of T10 appended every `_deferred` entry to
501
+ # `pending_profile` tagged `kind: "profile"`. By the time control reaches here a PROFILE
502
+ # deferral is impossible β€” the profile branch above returns `blocked` on any note β€” so **every
503
+ # id fanned out that way was a POSTS or COMMENTS snapshot in the PROFILE queue**, whose
504
+ # collector writes preset profile cells onto somebody's record from post rows. The engine keeps
505
+ # the two queues apart deliberately (`_pending_profile_tasks` vs `_pending_metric_tasks`).
506
+ # β‡’ So this returns them under their OWN key, filtered by dataset identity
507
+ # (`_media_deferrals`), and the engine files them in the metric queue with the handle it
508
+ # already holds. Returning rather than appending also keeps the queue's vocabulary out of a
509
+ # connector: this module knows which CORPUS deferred, never what the engine calls it.
510
+ # ⚠ `deferredMedia` rides BOTH returns on purpose. The empty-posts case is the one that
511
+ # matters most β€” that is exactly the run where the vendor took too long, so a caller reading
512
+ # the ids only from the success path would lose every batch it actually paid for.
513
+ deferred_media = _media_deferrals(_deferred)
514
+ if not posts:
515
+ return {"state": "partial", "profile": profile, "posts": [], "comments": [],
516
+ "via": "brightdata", "deferredMedia": deferred_media,
517
+ "note": p_note or note or "profile read; the post source returned nothing"}
518
+ return {"state": "ok", "profile": profile, "posts": posts, "comments": comments,
519
+ "via": "brightdata", "deferredMedia": deferred_media,
520
+ "note": c_note or note or ""}
api/main.py CHANGED
@@ -534,6 +534,15 @@ def _prewarm():
534
  routes_customers.warm_default(rt)
535
  import pages as _pages
536
  _pages.warm_default(rt)
 
 
 
 
 
 
 
 
 
537
  print(f"[aios-api] prewarm done in {_t.time() - t0:.1f}s")
538
  except Exception as e: # noqa: BLE001 β€” boot must not die on a warm-up
539
  print(f"[aios-api] prewarm skipped: {e}")
 
534
  routes_customers.warm_default(rt)
535
  import pages as _pages
536
  _pages.warm_default(rt)
537
+ # ⭐⭐ WAVE 30 · T12, THE COLD PATH (owner items 4/5). Automation was the ONE module absent
538
+ # from this list, so its memo was always filled by a visitor rather than by boot: call 1 of
539
+ # `GET /automations` after every deploy downloaded the whole `user_tables` document (35.8 MB
540
+ # ceiling, under the store lock) onto whoever clicked first. Memoising the WARM path β€” two
541
+ # waves of it β€” could not touch that, because the cold call is the one that fills the memo.
542
+ # ⚠ It elects a STRING and keeps no document; see `warm_default`'s own note on why caching
543
+ # the bucket would trade a latency for memory this tier does not have.
544
+ import routes_automation as _rauto
545
+ _rauto.warm_default(rt)
546
  print(f"[aios-api] prewarm done in {_t.time() - t0:.1f}s")
547
  except Exception as e: # noqa: BLE001 β€” boot must not die on a warm-up
548
  print(f"[aios-api] prewarm skipped: {e}")
api/odoo_relational.py CHANGED
@@ -57,15 +57,33 @@ PRODUCTS_KEY = "ut_odoo_products"
57
  #: ⭐ WAVE 28 (owner R1): *"ALL of Unique ID in Odoo is a database e.g. Customers/Products/Agents,
58
  #: etc. Including expenses and GL codes."* Four more DOCUMENT/REGISTRY grains, each measured to
59
  #: fit far inside `MAX_ROWS` (19 / 192 / 6,538 / 393 against 60,000).
60
- #: β›” THE LINE GRAINS ARE DELIBERATELY NOT HERE and this is R2, not an omission: 256,810 order
61
- #: lines, 154,917 expense GL lines and 311,140 commission lines cannot live in a `ut_*` document
62
- #: at any cap (63.9 MB / 2.57 s per copy for the order lines alone). "Expenses" as a BROWSABLE
63
- #: LEDGER is a read-through mirror grid; "expenses" as a NUMBER is a rollup on the GL account row.
64
  AGENTS_KEY = "ut_odoo_agents"
65
  ACCOUNTS_KEY = "ut_odoo_accounts"
66
  BILLS_KEY = "ut_odoo_bills"
67
  VENDORS_KEY = "ut_odoo_vendors"
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  #: The join column the partner-grain tables carry. Derived links resolve through it (`on`/`from`).
70
  JOIN_KEY = "partner_id"
71
  #: The product-grain equivalent.
@@ -352,6 +370,150 @@ def order_fields():
352
  )]
353
 
354
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  def product_fields():
356
  """One row per `product.product`, keyed on its id β€” EVERY product, archived ones included.
357
 
@@ -967,8 +1129,69 @@ TABLES = (
967
  ("accounts", ACCOUNTS_KEY, "Odoo GL accounts", account_fields),
968
  ("vendors", VENDORS_KEY, "Odoo vendors", vendor_fields),
969
  ("bills", BILLS_KEY, "Odoo vendor bills", bill_fields),
 
 
 
 
 
 
 
 
970
  )
971
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972
 
973
  def plan(cur, rt=None):
974
  """The rows that WOULD be written, plus the refusals that apply β€” no store WRITE at all.
@@ -976,6 +1199,11 @@ def plan(cur, rt=None):
976
  Separated from `apply_plan` so a route, a gate and a dry run all measure the same thing, and
977
  so **every cap is checked before anything is committed**.
978
 
 
 
 
 
 
979
  β›” `rt` IS WHAT MAKES THE TABLE-COUNT CHECK HONEST, and leaving it out was a real half-spawn
980
  bug. This spawn writes FOUR tables in four updater passes; a tenant near `MAX_TABLES` would
981
  create some and refuse the rest β€” leaving a locked invoices database with no rollup host,
@@ -984,25 +1212,78 @@ def plan(cur, rt=None):
984
  write. `rt` also carries the stored row counts the shrink guard compares against.
985
  """
986
  ut = _ut()
 
 
 
 
 
 
 
 
 
987
  excluded = excluded_ids(cur)
988
- built = {
989
- "customers": read_customers(cur, excluded=excluded),
990
- "products": read_products(cur),
991
- "invoices": read_invoices(cur, excluded=excluded),
992
- "orders": read_orders(cur, excluded=excluded),
993
- "agents": read_agents(cur),
994
- "accounts": read_accounts(cur),
995
- "vendors": read_vendors(cur),
996
- "bills": read_bills(cur),
997
- }
998
- problems = []
999
-
 
 
 
 
 
 
 
 
1000
  existing = {}
1001
  if rt is not None:
1002
  try:
1003
  existing = dict(rt.get(ut.STORE_KEY) or {})
1004
  except Exception: # noqa: BLE001
1005
  existing = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1006
  needed = [k for _b, k, _l, _f in TABLES if k not in existing]
1007
  if needed and len(existing) + len(needed) > ut.MAX_TABLES:
1008
  problems.append(
@@ -1012,11 +1293,32 @@ def plan(cur, rt=None):
1012
 
1013
  for bucket, key, _label, _fields in TABLES:
1014
  rows = built[bucket]
1015
- # β›” REFUSE, NEVER TRUNCATE. A capped table understates every total it feeds while looking
1016
- # exactly like a complete one β€” the failure mode this whole module is arranged against.
1017
- if len(rows) > ut.MAX_ROWS:
1018
- problems.append(f"{key}: {len(rows)} rows exceeds MAX_ROWS={ut.MAX_ROWS}; refusing "
1019
- f"(a truncated table understates every rollup it feeds)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1020
  stored = len(((existing.get(key) or {}).get("rows")) or {})
1021
  if stored and len(rows) < stored * MAX_SHRINK:
1022
  problems.append(
@@ -1024,6 +1326,11 @@ def plan(cur, rt=None):
1024
  f"more than {int((1 - MAX_SHRINK) * 100)}% is a bad read, not Odoo history "
1025
  f"shrinking; refusing rather than deleting rows that still exist")
1026
  built["problems"] = problems
 
 
 
 
 
1027
  return built
1028
 
1029
 
@@ -1107,6 +1414,13 @@ def _ensure_table_inplace(cur, key, label, fields, rows, username, stamp):
1107
  "recordMode": ut.AUTOMATION_RECORD_MODE,
1108
  }
1109
  table.setdefault("recordMode", ut.AUTOMATION_RECORD_MODE)
 
 
 
 
 
 
 
1110
  have = {str(f.get("key")): f for f in (table.get("fields") or [])}
1111
  for field in fields:
1112
  fkey = str(field.get("key"))
 
57
  #: ⭐ WAVE 28 (owner R1): *"ALL of Unique ID in Odoo is a database e.g. Customers/Products/Agents,
58
  #: etc. Including expenses and GL codes."* Four more DOCUMENT/REGISTRY grains, each measured to
59
  #: fit far inside `MAX_ROWS` (19 / 192 / 6,538 / 393 against 60,000).
 
 
 
 
60
  AGENTS_KEY = "ut_odoo_agents"
61
  ACCOUNTS_KEY = "ut_odoo_accounts"
62
  BILLS_KEY = "ut_odoo_bills"
63
  VENDORS_KEY = "ut_odoo_vendors"
64
 
65
+ #: ⭐⭐ WAVE 30 / R7 / W30-T35 β€” THE TWO LINE GRAINS, AND THEY ARRIVE THE ONLY WAY THEY EVER COULD.
66
+ #:
67
+ #: ⚠ THE PARAGRAPH THAT STOOD HERE SAID THESE WERE "DELIBERATELY NOT HERE … at any cap", and it
68
+ #: was RIGHT ABOUT THE CAP AND WRONG ABOUT THE CONCLUSION β€” which is exactly why it is replaced
69
+ #: rather than left standing beside its own contradiction. The obstacle was never the number of
70
+ #: rows; it was that every row had to be COPIED into the shared `user_tables` document. MEASURED
71
+ #: on this box's mirror 2026-08-12: 254,189 order lines in the confirmed scope (256,810 unscoped)
72
+ #: and 963,783 GL lines β€” 4.2x and 16x `MAX_ROWS`, 63.9 MB and ~240 MB as JSON. Owner ruling R6
73
+ #: settles what that means: *"there is no cap in how many data from the API source … can be pulled
74
+ #: into the app"*, so the answer is a different residency, never a bigger ceiling.
75
+ #:
76
+ #: β›” THESE TWO TABLES STORE NO ROWS HERE AND NEVER WILL. `routes_odoo_tables` binds them to the
77
+ #: DuckDB mirror (`GRID_SOURCES`) and `core.user_tables.row_limit` answers **0** for them β€” "this
78
+ #: database stores no rows HERE", which is a different statement from `None` ("connected and
79
+ #: uncapped") and from `MAX_ROWS` ("the editable substrate"). `plan()` below reads that evaluator
80
+ #: and builds no python row for either grain: 963,783 dicts in one process is the dangerous work
81
+ #: the answer exists to prevent. What DOES get written is the DEFINITION β€” a locked database with
82
+ #: fields, a label, grants and a nav entry, and zero rows. A definition with no rows is a working
83
+ #: grid; that is the whole shape of the conversion.
84
+ ORDER_LINES_KEY = "ut_odoo_order_lines"
85
+ GL_LINES_KEY = "ut_odoo_gl_lines"
86
+
87
  #: The join column the partner-grain tables carry. Derived links resolve through it (`on`/`from`).
88
  JOIN_KEY = "partner_id"
89
  #: The product-grain equivalent.
 
370
  )]
371
 
372
 
373
+ # ═════════════════════════════════════════════════════════════════════════════════════════════
374
+ # THE TWO READ-THROUGH GRAINS (W30-T35). Their rows are SERVED FROM THE MIRROR, never stored.
375
+ # ═════════════════════════════════════════════════════════════════════════════════════════════
376
+ # β›”β›” THE KEY SET IS HALF OF A CONTRACT AND `routes_odoo_tables.GRID_SOURCES[…]["cols"]` IS THE
377
+ # OTHER HALF. It binds a key to SQL; the label, type and order are declared HERE, once. The two
378
+ # lists must name the SAME columns, and both failure directions are silent:
379
+ # * a bound key with no declaration -> a silent NO-CELL (the row projection is strict);
380
+ # * a declared key with no binding -> an INACTIVE filter leaf, which WIDENS the result set.
381
+ # `verify_scopes.section_line_grids` compares the two sets, which is why neither side may "just
382
+ # add a column".
383
+ #
384
+ # β›” NO LINK COLUMN AND NO `refreshed` STAMP ON EITHER TABLE, and both absences are findings
385
+ # rather than omissions:
386
+ # * a LINK folds the rows the TARGET table STORES (`compute_relation_cells` reads the raw
387
+ # document, not the mirror), so a link at a read-through grain resolves against nothing and
388
+ # renders a permanently blank column β€” the trap `account_fields` names from the other end.
389
+ # The join ids ride as ordinary filterable columns instead, so the relationships are all
390
+ # still reachable by a person and by SQL.
391
+ # * `refreshed` means "when this row was last reconciled against Odoo", and it is stamped by
392
+ # `_ensure_table_inplace` onto rows it WRITES. Nothing here is ever written, so the column
393
+ # would be blank for every row forever. `section_line_grids` tolerates the key; the honest
394
+ # thing is not to declare it.
395
+
396
+
397
+ def order_line_fields():
398
+ """One row per `sale.order.line` on a CONFIRMED order β€” the same `state in (sale, done)`
399
+ scope every wholesale metric in this product uses.
400
+
401
+ MEASURED on the mirror 2026-08-12: **254,189 lines in scope** of 256,810 (the 2,621 excluded
402
+ sit on draft/sent/cancelled orders). Every line in scope is on a `sale` order β€” zero `done` β€”
403
+ but `done` stays in the option list because it is in the SCOPE, and a filter offering only
404
+ what happens to be stored today goes stale the first time an order is marked done.
405
+
406
+ β›” THE SCOPE, THE ORDER DATE AND THE ORDER NAME ALL LIVE ACROSS A JOIN. `sale_order_line`
407
+ carries no `state` at all (12 columns, measured), so the binding is a join to `sale_order` β€”
408
+ which is also what makes `order_no` a readable primary cell instead of a line id.
409
+
410
+ ⚠ `qty` IS `int`, NOT `currency`, AND THAT IS A MEASUREMENT. 3,888 of 256,810 lines carry a
411
+ FRACTIONAL quantity (0.2, 0.4, 0.5, 1.66 …) and the minimum is -1.0, so the question "does the
412
+ type truncate?" had to be answered rather than assumed: it does not. `int` and `currency` both
413
+ render through the client's `numberText`, which rounds nothing without a `format.decimals`
414
+ bag β€” the only difference is the `$` a `currency` column prepends. A quantity is not money, so
415
+ it takes the type that does not paint one.
416
+ """
417
+ return [_preset(f) for f in (
418
+ {"key": "order_no", "label": "Order", "type": "text", "source": "overlay",
419
+ "default": True, "pinned": True,
420
+ "description": "The sale order this line belongs to. Zero orders have a blank name, "
421
+ "which is why it is the primary cell rather than the line id."},
422
+ {"key": "odoo_id", "label": "Odoo ID", "type": "int", "source": "overlay",
423
+ "default": False, "description": "The `sale.order.line` id. Also this row's id."},
424
+ {"key": "order_id", "label": "Odoo order id", "type": "int", "source": "overlay",
425
+ "default": False,
426
+ "description": "The `sale.order` id β€” the key `ut_odoo_orders` is keyed on."},
427
+ {"key": "customer", "label": "Customer", "type": "text", "source": "overlay",
428
+ "default": True},
429
+ {"key": JOIN_KEY, "label": "Odoo partner id", "type": "int", "source": "overlay",
430
+ "default": False},
431
+ {"key": "product", "label": "Product", "type": "text", "source": "overlay",
432
+ "default": True,
433
+ "description": "Blank on the 101 section and note lines, which carry no product."},
434
+ {"key": PRODUCT_JOIN_KEY, "label": "Odoo product id", "type": "int", "source": "overlay",
435
+ "default": False},
436
+ {"key": "qty", "label": "Qty", "type": "int", "source": "overlay", "default": True,
437
+ "agg": "sum",
438
+ "description": "Ordered quantity. 3,888 lines carry a fraction and some are negative "
439
+ "(returns), so nothing here is rounded."},
440
+ {"key": "price_subtotal", "label": "Line $", "type": "currency", "source": "overlay",
441
+ "default": True, "agg": "sum"},
442
+ {"key": "margin", "label": "Margin $", "type": "currency", "source": "overlay",
443
+ "default": False, "agg": "sum",
444
+ "description": "Odoo's own line margin. Populated on every line."},
445
+ {"key": "purchase_price", "label": "Unit cost", "type": "currency", "source": "overlay",
446
+ "default": False,
447
+ "description": "The cost Odoo priced this line's margin against, per unit."},
448
+ {"key": "order_date", "label": "Order date", "type": "date", "source": "overlay",
449
+ "default": True},
450
+ {"key": "state", "label": "State", "type": "select", "source": "overlay",
451
+ "default": False, "options": ["sale", "done"]},
452
+ _scope_field(),
453
+ )]
454
+
455
+
456
+ def gl_line_fields():
457
+ """One row per `account.move.line` β€” the general ledger, and the owner's "expenses" at the
458
+ grain a person can actually browse.
459
+
460
+ MEASURED 2026-08-12: **963,783 lines**, of which 944,846 posted, 18,885 cancelled and 52
461
+ draft. β›” UNSCOPED ON PURPOSE β€” a general ledger whose draft and cancelled entries are
462
+ invisible is a ledger that cannot be reconciled, so `parent_state` rides as a COLUMN and the
463
+ reader chooses. That is the same decision the binding states from the SQL side.
464
+
465
+ ⚠ TWO COLUMNS ARE LEGITIMATELY BLANK ON REAL ROWS, named here so neither reads as a defect:
466
+ **61,911 lines carry no partner** (journal entries that are not about a customer), and
467
+ **1,727 carry no account** at all, which is also why `account_code` β€” the key
468
+ `ut_odoo_accounts` is keyed on β€” is blank on exactly those 1,727 and the join that supplies
469
+ it is a LEFT one.
470
+
471
+ ⚠ `move_type` IS `text`, NOT `select`, and it is the `product_fields.category` argument:
472
+ five values exist today (`out_invoice` 515,634 Β· `entry` 414,992 Β· `in_invoice` 18,901 Β·
473
+ `out_refund` 14,061 Β· `in_refund` 195) and Odoo's enum is longer than what we happen to hold.
474
+ A select whose options go stale answers a filter with a list that cannot match a stored value
475
+ (wave-26 item 24). `line_type` and `parent_state` ARE selects because their option lists were
476
+ measured COMPLETE against the whole table.
477
+ """
478
+ return [_preset(f) for f in (
479
+ {"key": "entry", "label": "Entry", "type": "text", "source": "overlay",
480
+ "default": True, "pinned": True,
481
+ "description": "The journal entry this line belongs to. Never blank."},
482
+ {"key": "odoo_id", "label": "Odoo ID", "type": "int", "source": "overlay",
483
+ "default": False, "description": "The `account.move.line` id. Also this row's id."},
484
+ {"key": "move_id", "label": "Odoo entry id", "type": "int", "source": "overlay",
485
+ "default": False},
486
+ {"key": "account", "label": "Account", "type": "text", "source": "overlay",
487
+ "default": True},
488
+ {"key": ACCOUNT_JOIN_KEY, "label": "Account code", "type": "text", "source": "overlay",
489
+ "default": True,
490
+ "description": "The GL code, from the joined chart of accounts β€” the key "
491
+ "`ut_odoo_accounts` is keyed on. Blank on the 1,727 lines with no "
492
+ "account."},
493
+ {"key": "customer", "label": "Partner", "type": "text", "source": "overlay",
494
+ "default": True,
495
+ "description": "Blank on the 61,911 lines that are not about a partner."},
496
+ {"key": JOIN_KEY, "label": "Odoo partner id", "type": "int", "source": "overlay",
497
+ "default": False},
498
+ {"key": "date", "label": "Date", "type": "date", "source": "overlay", "default": True},
499
+ {"key": "debit", "label": "Debit", "type": "currency", "source": "overlay",
500
+ "default": True, "agg": "sum"},
501
+ {"key": "credit", "label": "Credit", "type": "currency", "source": "overlay",
502
+ "default": True, "agg": "sum"},
503
+ {"key": "balance", "label": "Balance", "type": "currency", "source": "overlay",
504
+ "default": True, "agg": "sum",
505
+ "description": "Debit minus credit, as Odoo stores it. Sums to zero over a whole entry."},
506
+ {"key": "line_type", "label": "Line type", "type": "select", "source": "overlay",
507
+ "default": False,
508
+ "options": ["product", "cogs", "payment_term", "line_note", "line_section"]},
509
+ {"key": "move_type", "label": "Document type", "type": "text", "source": "overlay",
510
+ "default": False},
511
+ {"key": "parent_state", "label": "Entry state", "type": "select", "source": "overlay",
512
+ "default": True, "options": ["draft", "posted", "cancel"]},
513
+ _scope_field(),
514
+ )]
515
+
516
+
517
  def product_fields():
518
  """One row per `product.product`, keyed on its id β€” EVERY product, archived ones included.
519
 
 
1129
  ("accounts", ACCOUNTS_KEY, "Odoo GL accounts", account_fields),
1130
  ("vendors", VENDORS_KEY, "Odoo vendors", vendor_fields),
1131
  ("bills", BILLS_KEY, "Odoo vendor bills", bill_fields),
1132
+ # ⭐⭐ W30-T35 / R7 β€” the two READ-THROUGH grains. They are spec rows like any other, and that
1133
+ # is the point: `apply_plan` creates their DEFINITION (label, fields, lock, nav entry, grants)
1134
+ # exactly as it does for the eight above, and `plan` hands them ZERO rows. Leaving them out of
1135
+ # this tuple was the alternative and it is the wrong one β€” the route 404s on a key `TABLES`
1136
+ # does not name, so the grids would be bound to the mirror and unreachable, which is this
1137
+ # wave's own [[reachable-is-not-the-same-as-built]] shape.
1138
+ ("order_lines", ORDER_LINES_KEY, "Odoo order lines", order_line_fields),
1139
+ ("gl_lines", GL_LINES_KEY, "Odoo GL lines", gl_line_fields),
1140
  )
1141
 
1142
+ #: The bucket -> reader map. β›” ITS ABSENCES ARE LOAD-BEARING: a bucket with no reader has no
1143
+ #: python row builder ANYWHERE, which is what makes "never materialised" structural rather than a
1144
+ #: policy `plan()` could forget. The two line grains are absent for that reason and no other.
1145
+ _READERS = {
1146
+ "customers": lambda cur, excluded: read_customers(cur, excluded=excluded),
1147
+ "products": lambda cur, excluded: read_products(cur),
1148
+ "invoices": lambda cur, excluded: read_invoices(cur, excluded=excluded),
1149
+ "orders": lambda cur, excluded: read_orders(cur, excluded=excluded),
1150
+ "agents": lambda cur, excluded: read_agents(cur),
1151
+ "accounts": lambda cur, excluded: read_accounts(cur),
1152
+ "vendors": lambda cur, excluded: read_vendors(cur),
1153
+ "bills": lambda cur, excluded: read_bills(cur),
1154
+ }
1155
+
1156
+ #: The table keys this module can never materialise β€” DERIVED from the absence of a reader, never
1157
+ #: typed out, so it cannot drift from the fact it describes.
1158
+ #:
1159
+ #: β›”β›” IT IS STAMPED ONTO THE DEFINITION AT SPAWN, AND THAT IS NOT BELT-AND-BRACES β€” IT IS THE
1160
+ #: ONLY WAY THESE TWO TABLES EVER GET THE DURABLE FLAG. `core.user_tables.materialises` reads a
1161
+ #: process-global registry first and falls back to a stored `readThrough` stamp, "which is what a
1162
+ #: cold process reads" β€” but the only writer of that stamp is `strip_materialised`, and it stamps
1163
+ #: exclusively tables it found rows on (`if isinstance(t, dict) and t.get('rows')`, after an early
1164
+ #: return when nothing is fat). A table that was BORN read-through has no rows to strip, so it is
1165
+ #: never stamped, so a process that cannot reach the mirror reads `rows: {}` and calls that the
1166
+ #: answer β€” an EMPTY GRID with nothing going red, which is the exact failure that docstring names.
1167
+ #: The conversion writes the stamp; a table that needs no conversion still needs the statement.
1168
+ READ_THROUGH_KEYS = frozenset(key for bucket, key, _l, _f in TABLES if bucket not in _READERS)
1169
+
1170
+
1171
+ class _LentDoc:
1172
+ """A store handle that serves the ONE `user_tables` document `plan()` has ALREADY read.
1173
+
1174
+ β›”β›” THIS IS NOT A MICRO-OPTIMISATION AND IT IS NOT OPTIONAL. `core.user_tables.row_limit`
1175
+ resolves through `materialises` β†’ `get` β†’ `all_tables(st)`, and every one of those is a WHOLE
1176
+ 20 MB document read, deep-copied under `Store._lock`. `plan()` asks the evaluator once per
1177
+ table per loop, so passing the live handle would have added ~16 full document copies to a
1178
+ function that already reads it exactly once β€” and with `st=None` (the gate's fixture posture,
1179
+ and any dry run) those reads resolve to the MODULE-GLOBAL store, i.e. a Hugging Face dataset
1180
+ fetch per table, on a path that has no business touching the network at all.
1181
+ `materialises`' own docstring asks callers to lend the definition they are holding; `row_limit`
1182
+ takes `st` rather than `defn`, so the lending happens one level up, here.
1183
+
1184
+ ⚠ It answers ONLY the user-tables document and `None` for anything else, deliberately: a shim
1185
+ that quietly proxied other keys would be a second store with a partial view, which is worse
1186
+ than one that says what it knows.
1187
+ """
1188
+
1189
+ def __init__(self, doc, key):
1190
+ self._doc, self._key = doc if isinstance(doc, dict) else {}, key
1191
+
1192
+ def get(self, name):
1193
+ return self._doc if name == self._key else None
1194
+
1195
 
1196
  def plan(cur, rt=None):
1197
  """The rows that WOULD be written, plus the refusals that apply β€” no store WRITE at all.
 
1199
  Separated from `apply_plan` so a route, a gate and a dry run all measure the same thing, and
1200
  so **every cap is checked before anything is committed**.
1201
 
1202
+ Returns one key per bucket plus two that are not buckets: `problems` (refusals β€” a non-empty
1203
+ list makes `apply_plan` raise before it writes anything) and, since W30-T35, **`limits`** β€”
1204
+ `{table_key: limit_report}` for every table this plan did not fully materialise, which is R6's
1205
+ second sentence carried as data rather than left for a reader to infer from an empty list.
1206
+
1207
  β›” `rt` IS WHAT MAKES THE TABLE-COUNT CHECK HONEST, and leaving it out was a real half-spawn
1208
  bug. This spawn writes FOUR tables in four updater passes; a tenant near `MAX_TABLES` would
1209
  create some and refuse the rest β€” leaving a locked invoices database with no rollup host,
 
1212
  write. `rt` also carries the stored row counts the shrink guard compares against.
1213
  """
1214
  ut = _ut()
1215
+ # ⭐ R6, AND WITHOUT THIS LINE THE RULE IS ONLY ACCIDENTALLY TRUE. `is_connected` answers from
1216
+ # three places in falling authority: the registry, a stored `connected: True`, then the
1217
+ # `ut_odoo_` naming convention β€” and that last leg needs the table to ALREADY EXIST. So on a
1218
+ # FIRST spawn, in a process that has not yet built `routes_odoo_tables.GRID_SOURCES`, every one
1219
+ # of these tables reads as unconnected and earns `MAX_ROWS`: R6's cap removal would silently
1220
+ # not apply on exactly the run that creates the databases. This module DECLARES these keys, so
1221
+ # it is the honest place to say what they are. Idempotent (a set add), and it fills the
1222
+ # evaluator's input rather than becoming a second evaluator.
1223
+ ut.register_connected(*[key for _b, key, _l, _f in TABLES])
1224
  excluded = excluded_ids(cur)
1225
+ # ⭐⭐ W30-T35 / R6 / R7 β€” WHICH BUCKETS ARE BUILT AT ALL IS NOW ASKED, NOT ASSUMED, and it is
1226
+ # `core.user_tables.row_limit` that answers: 0 = "stores no rows HERE" (read-through), None =
1227
+ # "connected and uncapped", MAX_ROWS = "the editable substrate". Its own docstring names this
1228
+ # function as the caller that reads it, which is the seam working as designed β€” one evaluator,
1229
+ # so the spawn, the write doors and the wire cannot disagree about whether a table is capped
1230
+ # ([[one-evaluator-per-question]]).
1231
+ #
1232
+ # β›” TWO DIFFERENT REASONS NOT TO BUILD, AND THEY ARE KEPT SEPARATE ON PURPOSE:
1233
+ # * no reader at all β€” structural, permanent, and the case that must not depend on a store
1234
+ # read succeeding (a cold process with no mirror still must not try to build 963,783 rows);
1235
+ # * a reader exists but the table has already been converted to read-through β€” the
1236
+ # `ut_odoo_accounts` case. Building 192 rows and letting `strip_materialised` delete them
1237
+ # again on the next pass "works", and it is exactly the wasted, dangerous work `row_limit`
1238
+ # was built to prevent. It also stops a refresh from silently RE-MATERIALISING a table
1239
+ # D-87's conversion had already emptied.
1240
+ #
1241
+ # ⚠ THE DOCUMENT IS READ **ONCE**, HERE, AND LENT TO THE EVALUATOR β€” see `_LentDoc`. It used
1242
+ # to be read after the build loop; it moved up because `row_limit` needs it and reading it per
1243
+ # table per loop is ~16 more whole-document deep copies (or, with `st=None`, a Hugging Face
1244
+ # fetch per table on a path that must never touch the network).
1245
  existing = {}
1246
  if rt is not None:
1247
  try:
1248
  existing = dict(rt.get(ut.STORE_KEY) or {})
1249
  except Exception: # noqa: BLE001
1250
  existing = {}
1251
+ # β›” THE LENT DOCUMENT CARRIES THE `readThrough` STAMP THIS MODULE IS RESPONSIBLE FOR, and
1252
+ # without it R6's report is silently absent on the run that matters most β€” the FIRST spawn.
1253
+ # MEASURED: with an empty store, `row_limit` finds no registry entry and no stored stamp, so
1254
+ # it answers `None` ("connected and uncapped") for a grain that stores nothing at all, and
1255
+ # `limit_report` answers None with it β€” so `plan()["limits"]` came back EMPTY and the grids
1256
+ # were skipped with no stated reason. The structural `reader is None` guard still did its job;
1257
+ # what went missing was the half of R6 that has to SAY WHY.
1258
+ #
1259
+ # ⚠ THE OBVIOUS FIX IS THE ONE I DID NOT TAKE: `ut.register_read_through(*READ_THROUGH_KEYS)`
1260
+ # would work in one line, and `core.user_tables` explicitly reserves that registrar β€”
1261
+ # *"IT IS ALSO THE ONLY PLACE THAT MAY CALL `register_read_through`"* β€” for
1262
+ # `routes_odoo_tables.sync_read_through`, because ELIGIBILITY needs the mirror and the fold
1263
+ # matrix. That reasoning does not apply to a grain with no reader (there is nothing it could
1264
+ # be eligible FOR), but the law is written without an exception, so this lends the evaluator
1265
+ # the definition instead of taking one. `_ensure_table_inplace` writes exactly this stamp, so
1266
+ # what is lent is the document as it stands the moment this plan applies.
1267
+ lent = _LentDoc({**existing,
1268
+ **{k: {**(existing.get(k) or {}), "readThrough": True}
1269
+ for k in READ_THROUGH_KEYS}},
1270
+ ut.STORE_KEY)
1271
+
1272
+ built, limits = {}, {}
1273
+ caps = {}
1274
+ for bucket, key, _label, _fields in TABLES:
1275
+ reader = _READERS.get(bucket)
1276
+ caps[key] = cap = ut.row_limit(key, st=lent)
1277
+ if reader is None or cap == 0:
1278
+ built[bucket] = []
1279
+ report = ut.limit_report(key, st=lent)
1280
+ if report:
1281
+ limits[key] = report
1282
+ continue
1283
+ built[bucket] = reader(cur, excluded)
1284
+ problems = []
1285
+
1286
+ if rt is not None:
1287
  needed = [k for _b, k, _l, _f in TABLES if k not in existing]
1288
  if needed and len(existing) + len(needed) > ut.MAX_TABLES:
1289
  problems.append(
 
1293
 
1294
  for bucket, key, _label, _fields in TABLES:
1295
  rows = built[bucket]
1296
+ cap = caps[key] # asked ONCE per table, above β€” never re-read per loop
1297
+ # ⭐⭐ R6: THE `MAX_ROWS` REFUSAL IS GONE FOR A CONNECTED SOURCE, AND THE SENTENCE IT USED
1298
+ # TO PRINT IS NOW `limit_report`'s STRUCTURED ANSWER. Owner, verbatim: *"there is no cap in
1299
+ # how many data from the API source (as long as its from a connected source like Odoo) that
1300
+ # can be pulled into the app… Now if there is lag or it can't be done, you need to
1301
+ # explicitly tell me why and recommend a fix."* Both halves are here: a connected table
1302
+ # answers `None` and is never refused for its size, and anything that IS still bounded is
1303
+ # reported with its cause and its recommendation instead of a hand-typed line.
1304
+ #
1305
+ # β›” THE `cap and` GUARD IS THE WHOLE CHANGE AND ITS TWO FALSY CASES MEAN OPPOSITE THINGS:
1306
+ # `None` = connected, uncapped, build every row Odoo has; `0` = stores no rows here, and
1307
+ # the loop above already handed it an empty list. Neither may reach the refusal. The
1308
+ # editable substrate still gets `MAX_ROWS` and is still REFUSED, never truncated β€” a capped
1309
+ # table understates every total it feeds while looking exactly like a complete one.
1310
+ if cap and len(rows) > cap:
1311
+ report = ut.limit_report(key, st=lent) or {}
1312
+ limits[key] = report
1313
+ problems.append(
1314
+ f"{key}: {len(rows):,} rows exceeds the {cap:,}-row ceiling; refusing "
1315
+ f"({report.get('cause', 'a truncated table understates every rollup it feeds')}). "
1316
+ f"{report.get('recommendation', '')}".strip())
1317
+ # ⚠ THE SHRINK GUARD SKIPS A READ-THROUGH GRAIN, and without this it would refuse every
1318
+ # spawn after the first conversion: zero rows against a stored population is the INTENDED
1319
+ # end state there, not the partial mirror read this guard exists to catch.
1320
+ if cap == 0:
1321
+ continue
1322
  stored = len(((existing.get(key) or {}).get("rows")) or {})
1323
  if stored and len(rows) < stored * MAX_SHRINK:
1324
  problems.append(
 
1326
  f"more than {int((1 - MAX_SHRINK) * 100)}% is a bad read, not Odoo history "
1327
  f"shrinking; refusing rather than deleting rows that still exist")
1328
  built["problems"] = problems
1329
+ # R6's second sentence as DATA rather than prose: every table whose rows this plan did not
1330
+ # (or may not) materialise, with the cause and the recommendation `core.user_tables` derives.
1331
+ # ⚠ NOT a bucket β€” `apply_plan` iterates `TABLES` and asks `if bucket in built`, so a key that
1332
+ # is not a bucket name is inert there, exactly as `problems` has always been.
1333
+ built["limits"] = limits
1334
  return built
1335
 
1336
 
 
1414
  "recordMode": ut.AUTOMATION_RECORD_MODE,
1415
  }
1416
  table.setdefault("recordMode", ut.AUTOMATION_RECORD_MODE)
1417
+ # W30-T35 β€” the durable "my rows are not in this document" statement, on the tables no
1418
+ # conversion will ever stamp (see `READ_THROUGH_KEYS`). Written on every pass, not
1419
+ # `setdefault`: it is derived from the code's own structure, so the code is what it must agree
1420
+ # with, and a definition that somehow lost the flag should regain it rather than keep serving
1421
+ # an empty grid.
1422
+ if key in READ_THROUGH_KEYS:
1423
+ table["readThrough"] = True
1424
  have = {str(f.get("key")): f for f in (table.get("fields") or [])}
1425
  for field in fields:
1426
  fkey = str(field.get("key"))
api/providers.py CHANGED
@@ -44,9 +44,14 @@ from typing import Callable
44
  #: vendor's own dashboard is. Wrong by 2x still ranks correctly; wrong by 100x does not, which is
45
  #: why they are named and dated rather than guessed silently.
46
  #: Bright Data Instagram datasets ~ $0.0015/record (2026-08 list). Apify instagram-scraper
47
- #: ~ $0.0023/result (2026-08 list). Both re-checked when a provider is added.
 
 
 
 
 
48
  _COST_BRIGHTDATA = float(os.environ.get("AIOS_COST_BRIGHTDATA") or 0.0015)
49
- _COST_APIFY = float(os.environ.get("AIOS_COST_APIFY") or 0.0023)
50
 
51
 
52
  @dataclass(frozen=True)
 
44
  #: vendor's own dashboard is. Wrong by 2x still ranks correctly; wrong by 100x does not, which is
45
  #: why they are named and dated rather than guessed silently.
46
  #: Bright Data Instagram datasets ~ $0.0015/record (2026-08 list). Apify instagram-scraper
47
+ #: ~ $0.0027/result (2026-08 list). Both re-checked when a provider is added.
48
+ #: ⚠ WAVE 30 Β· T16 β€” APIFY CORRECTED 0.0023 -> 0.0027, and the direction matters: the old number
49
+ #: made the fallback look CHEAPER than it is, and `estimate()` is what a person is shown before
50
+ #: they authorise a run. An under-stated price is the one rounding error a cost guard cannot catch.
51
+ #: ⚠ Both are LIST rates used to rank and to estimate. They are not invoices, and nothing here
52
+ #: reads a live price β€” a modelled figure that says so is honest; one that pretends is not.
53
  _COST_BRIGHTDATA = float(os.environ.get("AIOS_COST_BRIGHTDATA") or 0.0015)
54
+ _COST_APIFY = float(os.environ.get("AIOS_COST_APIFY") or 0.0027)
55
 
56
 
57
  @dataclass(frozen=True)
api/routes_automation.py CHANGED
@@ -19,6 +19,7 @@ from fastapi import APIRouter, Body, Depends, Header, Request
19
  import automation_engine as engine
20
  import oauth_connect
21
  import routes_oauth
 
22
  from deps import Session, err, module_gate, require_session
23
 
24
  router = APIRouter(prefix="/api/v1")
@@ -225,6 +226,127 @@ def _tick_state():
225
  #: or delete β€” a new database cannot contain the legacy stage cells this retires.
226
  _BOARD_RETIRED = set()
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
  @router.get("/automations")
230
  def list_automations(session: Session = Depends(_GATE)):
@@ -250,8 +372,17 @@ def list_automations(session: Session = Depends(_GATE)):
250
  # `_without_retired_board`), so the persist step is hygiene, not correctness. No client can
251
  # ever be shown state this skip left behind. Nothing writes new `stage_auto_` cells either β€”
252
  # wave 26's R6 deleted the board that made them.
253
- tables = engine.ut_all(session.runtime) if session.runtime.available() else None
254
- if tables is not None and session.tenant not in _BOARD_RETIRED:
 
 
 
 
 
 
 
 
 
255
  _BOARD_RETIRED.add(session.tenant)
256
  # Idempotent Board retirement removes only engine-marked stage fields and legacy Board
257
  # state. User-created Status/Stage columns remain intact.
@@ -319,7 +450,11 @@ def list_automations(session: Session = Depends(_GATE)):
319
  # save would have written somewhere else β€” a default that disagrees with
320
  # itself across two panels, which is the shape wave 25's R2 fixed for
321
  # `targetTable` vs the action's `table`.
322
- "table": (engine.discover_default_table(session.runtime, tables=tables)
 
 
 
 
323
  or engine.DISCOVER_TABLE)},
324
  "storeAvailable": bool(session.runtime.available()),
325
  # Wave 22 C3 β€” the trigger vocabulary, session-scoped because email readiness is a
@@ -449,9 +584,16 @@ def automation_tables(session: Session = Depends(_GATE)):
449
  pass
450
 
451
  out = []
452
- for key, t in sorted(engine.ut_all(session.runtime).items(),
 
 
 
 
 
 
 
453
  key=lambda kv: (kv[1].get("label") or "").lower()):
454
- if not user_tables.may_open(key, session.uname, session.admin, st=session.runtime):
455
  continue
456
  row = {"key": key, "label": t.get("label") or key,
457
  "source": t.get("source") or "Blank",
 
19
  import automation_engine as engine
20
  import oauth_connect
21
  import routes_oauth
22
+ import scope_cache
23
  from deps import Session, err, module_gate, require_session
24
 
25
  router = APIRouter(prefix="/api/v1")
 
226
  #: or delete β€” a new database cannot contain the legacy stage cells this retires.
227
  _BOARD_RETIRED = set()
228
 
229
+ #: ⭐⭐ WAVE 30 Β· T12 β€” THE PICKER DEFAULT, MEMOISED. `{tenant: (stamp, table_key)}`, the shape
230
+ #: `scope_cache` stores.
231
+ #:
232
+ #: β›” WHY THE DERIVED STRING AND NOT THE DOCUMENT. The obvious cache here is the `user_tables`
233
+ #: bucket itself, and it is the wrong one: that bucket is up to 35.8 MB per tenant and this box is
234
+ #: the HF free tier, so caching it would trade a latency problem for a memory one. What the warm
235
+ #: path actually needs is `discover_default_table`'s ANSWER β€” one short string.
236
+ #:
237
+ #: β›” AND WHY NOT A PLAIN `_BOARD_RETIRED`-STYLE ONCE-PER-PROCESS SET, which would have been less
238
+ #: code: the election reads which profile databases exist and which hold rows, and BOTH change
239
+ #: while the process lives (somebody creates a database, an automation writes the first row). A
240
+ #: once-per-process memo would pin the picker's default to whatever was true at boot and never
241
+ #: correct itself β€” a default that disagrees with the save door, which is exactly the wave-25 R2
242
+ #: defect the `"table"` line's own comment records. A TTL bounds the staleness instead.
243
+ #:
244
+ #: ⚠ `scope_cache` rather than a hand-rolled dict, because it is the house pattern for precisely
245
+ #: this (`routes_customers`, `routes_products`, `pages` all use it) and it is stale-while-refresh:
246
+ #: once a copy exists NO request blocks on a rebuild. Automation was the one module importing it
247
+ #: nowhere, which the wave-30 scout named as the reason every other surface feels fast.
248
+ _DISCOVER_DEFAULT = {}
249
+ #: 5 minutes β€” the same order as `apiBridge.ts:CUSTOMERS_FRESH_MS` on the client. Overridable so a
250
+ #: gate can pin it rather than sleep.
251
+ _DISCOVER_DEFAULT_TTL = float(os.environ.get("AIOS_AUTOMATION_DEFAULT_TTL") or 300)
252
+
253
+
254
+ #: The one store key `_LentTables` intercepts. DERIVED from the engine's own constant rather than
255
+ #: written out here: `engine.ut_all` reads the bucket through it, so if that key ever moves, the
256
+ #: lend moves with it instead of silently becoming a pass-through that still looks correct.
257
+ _UT_STORE_KEY = engine.UT_STORE_KEY
258
+
259
+
260
+ class _LentTables:
261
+ """⭐⭐ WAVE 30 Β· T13 β€” a read-only `st` that serves ONE already-read `user_tables` document
262
+ and passes every other key straight through to the real runtime.
263
+
264
+ β›” WHY THIS EXISTS RATHER THAN A SECOND COPY OF THE WALL. `automation_tables` calls
265
+ `user_tables.may_open(key, …)` per table; `may_open` β†’ `user_tables.get` β†’ `all_tables(st)` β†’
266
+ `st.get('user_tables')`, and `core/store.py:Store.get` deep-copies the WHOLE tenant document on
267
+ every call, hit or miss, under the store lock. So listing N databases cost **1 + N** full
268
+ deep copies of a document whose documented ceiling is 35.8 MB β€” and this is the route that runs
269
+ the instant somebody clicks an automation row, i.e. the *"and in the MODULE itself"* half of the
270
+ owner's item 4.
271
+ β›” THE OBVIOUS FIX IS THE FORBIDDEN ONE. Inlining the creator-or-admin test here would remove
272
+ the N reads and re-create the exact defect wave 20 fixed: this route USED to carry its own
273
+ wider rule (`createdBy in (uname, 'automation', 'scheduler')`), so a non-admin saw a database
274
+ in the picker and was refused the moment they opened it. This function's own docstring calls
275
+ that out β€” *"a duplicate READER is fine; a duplicate WALL is not"*. `may_open` stays THE one
276
+ resolver, unmodified and still called per table; it is simply no longer charged for a document
277
+ the caller is already holding.
278
+ ⚠ AND IT IS A CALLER-SIDE FIX ON PURPOSE: adding a `tables=` parameter to `may_open` would mean
279
+ editing `platform/core/user_tables.py`, which belongs to another lane this wave.
280
+
281
+ ⚠ READ-ONLY LENDING, and it is safe by inspection rather than by hope: `all_tables` does
282
+ `dict(st.get(STORE_KEY) or {})`, i.e. it shallow-copies the top level before anybody touches
283
+ it, and `may_open` only ever READS `t.get('createdBy')`. Nothing on this path mutates the
284
+ lent document. Any other store key β€” the shares registry `role_for` consults, for instance β€”
285
+ is not intercepted and reaches the real runtime unchanged.
286
+ """
287
+
288
+ def __init__(self, runtime, tables):
289
+ self._runtime, self._tables = runtime, tables
290
+
291
+ def get(self, name, fresh=False):
292
+ if name == _UT_STORE_KEY and not fresh:
293
+ return self._tables
294
+ return self._runtime.get(name, fresh)
295
+
296
+ def __getattr__(self, name):
297
+ return getattr(self._runtime, name)
298
+
299
+
300
+ def _discover_default(session, tables):
301
+ """The discovery picker's default table for this tenant β€” WITHOUT a bucket read on a warm call.
302
+
303
+ ⚠ `tables` is the document the caller ALREADY holds on a cold call (the retirement pass reads
304
+ one). Passing it through means the cold path elects from the copy it has rather than taking a
305
+ second one, so this is never an extra read β€” only ever a saved one.
306
+ """
307
+ if not session.runtime.available():
308
+ return ""
309
+ if tables is not None:
310
+ # Cold call: the document is in hand. Elect from it and prime the memo in the same pass.
311
+ value = engine.discover_default_table(session.runtime, tables=tables)
312
+ _DISCOVER_DEFAULT[session.tenant] = (time.time(), value)
313
+ return value
314
+ return scope_cache.get(_DISCOVER_DEFAULT, session.tenant, _DISCOVER_DEFAULT_TTL,
315
+ lambda: engine.discover_default_table(session.runtime))
316
+
317
+
318
+ def warm_default(rt):
319
+ """⭐⭐ WAVE 30 · T12, THE COLD HALF. Elect the discovery picker's default at BOOT.
320
+
321
+ β›” THE HALF THE MEMO CANNOT FIX, and it is why this exists rather than being a nicety. The memo
322
+ above makes calls 2..N free; **call 1 is still a full `user_tables` download**, and it lands on
323
+ whoever clicks Automation first after a deploy β€” the one visitor with no cache anywhere,
324
+ waiting on a document whose documented ceiling is 35.8 MB / ~1.4 s, taken under the store's
325
+ single lock. That is the owner's *"only automation has a loading screen"* on a cold Space, and
326
+ two waves of memoising the warm path could never touch it. Automation was the only module
327
+ importing `scope_cache` nowhere AND the only one absent from `_prewarm`.
328
+
329
+ ⭐ IT ELECTS, IT DOES NOT CACHE THE DOCUMENT β€” deliberately, and this is the whole design.
330
+ `discover_default_table` reads the bucket once here, in the prewarm daemon thread where nobody
331
+ is waiting, and what survives is a DERIVED STRING. Holding the 35.8 MB document resident would
332
+ trade a latency the tenant notices for memory the HF free tier does not have, which is the
333
+ argument `_DISCOVER_DEFAULT`'s own note makes against caching it.
334
+
335
+ ⚠ SAME TTL AS THE REQUEST PATH, not a permanent set: the election reads which databases exist
336
+ and which hold rows, and both change while the process lives. Priming by assignment is exactly
337
+ what the cold request path already does one function up, so there is one way this memo is
338
+ filled, not two.
339
+
340
+ Returns the elected key (`""` when the store is unavailable), so a caller can log it rather
341
+ than guess whether the warm-up did anything. Called from `main.py:_prewarm` only.
342
+ """
343
+ tenant = str(getattr(rt, "key", "") or "")
344
+ if not tenant or not rt.available():
345
+ return ""
346
+ value = engine.discover_default_table(rt)
347
+ _DISCOVER_DEFAULT[tenant] = (time.time(), value)
348
+ return value
349
+
350
 
351
  @router.get("/automations")
352
  def list_automations(session: Session = Depends(_GATE)):
 
372
  # `_without_retired_board`), so the persist step is hygiene, not correctness. No client can
373
  # ever be shown state this skip left behind. Nothing writes new `stage_auto_` cells either β€”
374
  # wave 26's R6 deleted the board that made them.
375
+ # ⭐⭐ WAVE 30 · T12 (owner items 4/5, the complaint that has now survived TWO waves).
376
+ # β›” W29-T01 GUARDED THE SCAN AND NOT THE READ, and that is the whole of what was left. The
377
+ # line below used to be unconditional β€” every single request deep-copied the tenant's entire
378
+ # `user_tables` document (documented ceiling 35.8 MB / ~1.4 s, and `core/store.py:Store.get`
379
+ # re-serializes on EVERY `.get()`, hit or miss, UNDER THE STORE LOCK so the copies also queue
380
+ # behind each other) β€” while on calls 2..N the result was DISCARDED: `tables` had exactly two
381
+ # consumers, the `_BOARD_RETIRED` scan (skipped after call 1) and a picker DEFAULT STRING.
382
+ # A whole-tenant document, per request, to render a rail showing a name and a toggle.
383
+ tables = None
384
+ if session.runtime.available() and session.tenant not in _BOARD_RETIRED:
385
+ tables = engine.ut_all(session.runtime)
386
  _BOARD_RETIRED.add(session.tenant)
387
  # Idempotent Board retirement removes only engine-marked stage fields and legacy Board
388
  # state. User-created Status/Stage columns remain intact.
 
450
  # save would have written somewhere else β€” a default that disagrees with
451
  # itself across two panels, which is the shape wave 25's R2 fixed for
452
  # `targetTable` vs the action's `table`.
453
+ # ⭐ WAVE 30 Β· T12 β€” through the per-tenant memo. The ELECTION rule and
454
+ # everything the comment above says about it are unchanged; what changed
455
+ # is that a warm request no longer re-reads a 35.8 MB document to
456
+ # recompute a string that did not move.
457
+ "table": (_discover_default(session, tables)
458
  or engine.DISCOVER_TABLE)},
459
  "storeAvailable": bool(session.runtime.available()),
460
  # Wave 22 C3 β€” the trigger vocabulary, session-scoped because email readiness is a
 
584
  pass
585
 
586
  out = []
587
+ # ⭐⭐ WAVE 30 Β· T13 β€” ONE read of the tenant document, LENT to the wall for every table.
588
+ # It was `1 + N` full deep copies (this `ut_all`, then `may_open` β†’ `get` β†’ `all_tables` per
589
+ # table), each of a document with a 35.8 MB / ~370 ms ceiling, all of them queued behind
590
+ # `Store._lock`. `may_open` is unchanged and still asked about every table β€” see `_LentTables`
591
+ # on why re-implementing the wall here is the one fix that is NOT available.
592
+ _tables_doc = engine.ut_all(session.runtime)
593
+ _lent = _LentTables(session.runtime, _tables_doc)
594
+ for key, t in sorted(_tables_doc.items(),
595
  key=lambda kv: (kv[1].get("label") or "").lower()):
596
+ if not user_tables.may_open(key, session.uname, session.admin, st=_lent):
597
  continue
598
  row = {"key": key, "label": t.get("label") or key,
599
  "source": t.get("source") or "Blank",
api/routes_connectors.py CHANGED
@@ -60,13 +60,36 @@ PLANNED = [
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
- # ⚠ The vendor's name came out of this line in wave 25 (C2's sweep). What a customer needs to
66
- # know is that Instagram data already arrives another way β€” not which supplier carries it.
67
- {"key": "meta", "label": "WhatsApp and Instagram (Meta)", "kind": "oauth",
68
- "desc": "Blocked on Meta business verification; Instagram data arrives through the "
69
- "Scraper connector instead."},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  ]
71
 
72
  #: Token connectors an admin can wire up TODAY by pasting a key β€” no OAuth client, no app review.
@@ -156,10 +179,17 @@ def directory(rt, uname, is_admin=False):
156
  # `needs` string still names the env var, deliberately: it is read by whoever has to set it.
157
  rows.append({
158
  "key": "scraper", "label": "Scraper", "kind": "builtin",
159
- "desc": "Instagram profile and post capture for the discovery automations.",
 
 
 
160
  "state": "connected" if _eng.bd_ready() else "unconfigured",
161
  "needs": "" if _eng.bd_ready() else "AIOS_BRIGHTDATA_KEY is not set on this deployment",
162
  "manage": "",
 
 
 
 
163
  })
164
  rows.append({
165
  "key": "webhooks", "label": "Webhooks", "kind": "builtin",
 
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
+ # ⭐⭐ WAVE 30 Β· R4 β€” TWO ROWS WERE DELETED HERE, AND THEY WERE MISINFORMATION, not clutter.
64
+ #
65
+ # {"key": "tiktok", "label": "TikTok", ... "The second social source beside Instagram."}
66
+ # {"key": "meta", "label": "WhatsApp and Instagram (Meta)", ... "Blocked on Meta business
67
+ # verification; Instagram data arrives through the Scraper connector"}
68
+ #
69
+ # β›” THE TIKTOK ROW SAID "not built yet" ABOUT SOMETHING THAT SHIPPED IN WAVE 29 β€” which is
70
+ # precisely the rot this module's header warns about: *"a provider gets wired up somewhere and
71
+ # the directory keeps calling it planned for a wave or two, which is exactly the
72
+ # misinformation this surface exists to prevent."* TikTok is a PLATFORM of the Scraper
73
+ # connector now (R3/R4), and it appears in the Scraper card's own drill below.
74
+ # ⚠ THE META ROW IS A DIFFERENT CASE and is deleted for a different reason: what it described
75
+ # (Instagram arriving another way) is now visible ON the Scraper card, so keeping it would put
76
+ # Instagram on this page twice, once as working and once as blocked. The Meta business
77
+ # verification blocker itself is not lost β€” it is D-86's subject and lives in
78
+ # `.claude/wiki/research/ads-connectors-owner-actions.md`, where an owner action belongs.
79
+ ]
80
+
81
+ #: ⭐ WAVE 30 Β· R4 β€” THE SCRAPER'S PLATFORMS, and the whole shape of the ruling in one list.
82
+ #: The owner: *"the damn Tiktok and Instagram belongs in the same bucket … Only when I click
83
+ #: 'Scraper' … would I see the option to choose either Instagram OR TikTok."* So the directory
84
+ #: mirrors the automation menus exactly: ONE card for the capability, and the platforms are behind
85
+ #: it rather than beside it.
86
+ #: ⚠ These are not connectors and must never become rows: they have no state, no credential and
87
+ #: no manage door of their own β€” the Scraper's key is what is configured, and it configures both.
88
+ SCRAPER_PLATFORMS = [
89
+ {"key": "instagram", "label": "Instagram",
90
+ "desc": "Profiles, posts and comments, by search or by handle."},
91
+ {"key": "tiktok", "label": "TikTok",
92
+ "desc": "The same three, on TikTok's own schema."},
93
  ]
94
 
95
  #: Token connectors an admin can wire up TODAY by pasting a key β€” no OAuth client, no app review.
 
179
  # `needs` string still names the env var, deliberately: it is read by whoever has to set it.
180
  rows.append({
181
  "key": "scraper", "label": "Scraper", "kind": "builtin",
182
+ # ⭐ WAVE 30 Β· R4 β€” the sentence names the CAPABILITY; the platforms are the drill below.
183
+ # It used to say "Instagram profile and post capture", which stopped being true the day
184
+ # TikTok discovery shipped and would have to be re-edited for every platform after it.
185
+ "desc": "Profile, post and comment capture for the discovery automations.",
186
  "state": "connected" if _eng.bd_ready() else "unconfigured",
187
  "needs": "" if _eng.bd_ready() else "AIOS_BRIGHTDATA_KEY is not set on this deployment",
188
  "manage": "",
189
+ # β›” ONE CREDENTIAL, TWO PLATFORMS β€” which is why this is a field on the row and not two
190
+ # rows. `bd_ready()` above answers for both; a per-platform state would be a second
191
+ # answer to a question that has one.
192
+ "platforms": [dict(p) for p in SCRAPER_PLATFORMS],
193
  })
194
  rows.append({
195
  "key": "webhooks", "label": "Webhooks", "kind": "builtin",
api/routes_customers.py CHANGED
@@ -221,10 +221,14 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
221
 
222
 
223
  def _payload(session: Session):
224
- """`{fields, rows, today, pulled_at}` β€” X2's shape, which `verify_fields_contract.py`
225
  referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by
226
  construction); see `grid_assembly` for what that fixed.
227
 
 
 
 
 
228
  ⚠ `rows_src` is the SHARED cached list β€” `rows_from_pool` reads it and builds NEW dicts,
229
  never mutating a cached row (the same-scope-second-user leak rule).
230
  """
@@ -233,11 +237,26 @@ def _payload(session: Session):
233
  g = grid_assembly(session)
234
  rows = aios_grid.rows_from_pool(
235
  g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  return {"fields": g["fields"], "rows": rows,
237
  # `today` rides the payload because every relative date condition must resolve against
238
  # the TENANT's day, never the browser's β€” a client that falls back to its own clock
239
  # disagrees with the server for everyone west of it.
240
  "today": g["today"],
 
 
241
  "pulled_at": time.strftime("%Y-%m-%d %H:%M")}
242
 
243
 
 
221
 
222
 
223
  def _payload(session: Session):
224
+ """`{fields, rows, today, docs, pulled_at}` β€” X2's shape, which `verify_fields_contract.py`
225
  referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by
226
  construction); see `grid_assembly` for what that fixed.
227
 
228
+ ⭐ `docs` joined the shape in wave 30 (W30-T37 / contract C4). Named here rather than left to
229
+ the reader because a docstring that still lists the OLD shape is a stale comment on correct
230
+ code β€” this repo's D-73 β€” and it is the first thing anyone greps to learn the payload.
231
+
232
  ⚠ `rows_src` is the SHARED cached list β€” `rows_from_pool` reads it and builds NEW dicts,
233
  never mutating a cached row (the same-scope-second-user leak rule).
234
  """
 
237
  g = grid_assembly(session)
238
  rows = aios_grid.rows_from_pool(
239
  g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
240
+ # ⭐ C4 / D-138 (W30-T37) β€” THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door
241
+ # (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is
242
+ # complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key.
243
+ # All six `onDoc*` handlers in `CustomerGrid.tsx` read `payload?.docs ? … : undefined`, so an
244
+ # ABSENT key β€” not a broken one β€” is what has been switching the whole feature off.
245
+ #
246
+ # β›” IMPORTED, NEVER RE-SERIALISED. `core.grid_events.docs_for` is the ONE serialiser and
247
+ # `routes_tables` (the `ut_*` scope) calls the SAME function with the same argument order.
248
+ # A matching pair here is precisely how the wave-29 close-out reintroduced its own defect in
249
+ # the opposite direction inside a single commit ([[one-question-two-normalizers]]).
250
+ # ⚠ `g["pids"]` is the row set this session is ALREADY scoped to β€” `docs_for` has no
251
+ # "every document in the tenant" mode to reach for, deliberately.
252
+ from core import grid_events as _ge
253
  return {"fields": g["fields"], "rows": rows,
254
  # `today` rides the payload because every relative date condition must resolve against
255
  # the TENANT's day, never the browser's β€” a client that falls back to its own clock
256
  # disagrees with the server for everyone west of it.
257
  "today": g["today"],
258
+ "docs": _ge.docs_for(g["pids"], scope_key="customer", uname=session.uname,
259
+ admin=session.admin, st=session.runtime),
260
  "pulled_at": time.strftime("%Y-%m-%d %H:%M")}
261
 
262
 
api/routes_grid.py CHANGED
@@ -757,9 +757,17 @@ def grid_events_route(body: dict = Body(default=None),
757
  elif scope.startswith("ut_"):
758
  # Wave 18 C3-UT β€” the user-table wall (creator/admin) is inside the assembly; the
759
  # measure/cohort context is honestly EMPTY (customer-grain machinery, no meaning here).
760
- from routes_tables import ut_assembly
761
-
762
- g = ut_assembly(session, scope, consume_corrections=False)
 
 
 
 
 
 
 
 
763
  else:
764
  g = grid_assembly(session, scope=scope, consume_corrections=False)
765
  # ⚠ THE MEASURE CONTEXT IS NOT OPTIONAL (2026-07-31). Without `measure_offer`,
@@ -789,7 +797,14 @@ def grid_events_route(body: dict = Body(default=None),
789
 
790
  out = {"results": results, "rerender": any(r["rerender"] for r in results)}
791
  if ctx.out.doc is not None:
792
- out["doc"] = ctx.out.doc
 
 
 
 
 
 
 
793
  if ctx.out.toast is not None:
794
  out["toast"] = ctx.out.toast
795
 
 
757
  elif scope.startswith("ut_"):
758
  # Wave 18 C3-UT β€” the user-table wall (creator/admin) is inside the assembly; the
759
  # measure/cohort context is honestly EMPTY (customer-grain machinery, no meaning here).
760
+ # ⭐⭐ WAVE 30 / W30-T30 (owner item 2: *"when I click hide fields it crash… no matter the
761
+ # size"*). This used to be `ut_assembly(...)`, which builds the whole table to validate a
762
+ # write it then throws away: `scoped_pool` allocates a dict per row and sorts them, so ONE
763
+ # hide-fields checkbox rebuilt ~33k order rows before the event was even dispatched.
764
+ # β›” NOTHING IS VALIDATED LESS. The comment above still holds β€” this assembly is the
765
+ # permission wall and the admission context β€” and `ut_write_ctx` returns the SAME six keys
766
+ # this route reads, with a pid set derived from exactly the row ids `scoped_pool` would
767
+ # have kept. What it does not do is materialise the rows nobody here looks at.
768
+ from routes_tables import ut_write_ctx
769
+
770
+ g = ut_write_ctx(session, scope)
771
  else:
772
  g = grid_assembly(session, scope=scope, consume_corrections=False)
773
  # ⚠ THE MEASURE CONTEXT IS NOT OPTIONAL (2026-07-31). Without `measure_offer`,
 
797
 
798
  out = {"results": results, "rerender": any(r["rerender"] for r in results)}
799
  if ctx.out.doc is not None:
800
+ # ⭐ C4 / W30-T27 β€” `docPayload` IS THE NAME THE CLIENT ALREADY DECLARES. `types.ts` has
801
+ # carried `docPayload?: {pid, docId, name, mime, data_b64}` since C5, and `Documents.tsx`
802
+ # matches it against the fetch it is waiting on β€” while this route has been answering
803
+ # `doc`, which `apiBridge.ts` deliberately drops. One object, emitted under the name the
804
+ # consumer looks for, so F's wiring needs no translation step to get wrong.
805
+ # ⚠ `doc` stays for one wave: nothing in the client reads it, but a gate might, and
806
+ # removing a key to save six bytes is not worth a red nobody predicted.
807
+ out["doc"] = out["docPayload"] = ctx.out.doc
808
  if ctx.out.toast is not None:
809
  out["toast"] = ctx.out.toast
810
 
api/routes_odoo_tables.py CHANGED
@@ -14,9 +14,14 @@ it can. `verify_api` now does both.
14
 
15
  ADMIN-GATED, and not for tidiness: a refresh REWRITES four locked databases for the whole tenant
16
  and deletes the rows that left the population. That is an operator action.
 
 
 
17
  """
 
 
18
  from fastapi import Depends
19
- from fastapi import APIRouter
20
 
21
  from deps import Session, err, require_session
22
 
@@ -63,8 +68,19 @@ def refresh_odoo_tables(session: Session = Depends(require_session)):
63
  raise err(400, "not_applicable",
64
  "These databases are built from the Royal Imports Odoo mirror (R1).")
65
  try:
66
- return {"ok": True, **rel.refresh(session.runtime, session.tenant,
67
- username=session.user)}
 
 
 
 
 
 
 
 
 
 
 
68
  except rel.Refused as e:
69
  # A refusal is the ANSWER, not a crash: the caller must see WHY nothing was written.
70
  raise err(409, "refused", str(e))
@@ -93,6 +109,16 @@ def odoo_tables_status(session: Session = Depends(require_session)):
93
  """
94
  rel, out = _rel(), {}
95
  import core.user_tables as user_tables
 
 
 
 
 
 
 
 
 
 
96
  # ⚠ ITERATES `rel.TABLES`, NEVER A LITERAL PAIR. It read `(INVOICES_KEY, CUSTOMERS_KEY)`
97
  # while those were the only two; the 2026-08-09 widening added products and orders, and a
98
  # hard-coded list here would have reported "everything is fine" over two databases it had
@@ -101,10 +127,782 @@ def odoo_tables_status(session: Session = Depends(require_session)):
101
  table = user_tables.get(key, st=session.runtime) or {}
102
  rows = (table.get("rows") or {})
103
  stamps = [str((r or {}).get("refreshed") or "") for r in rows.values()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  out[key] = {
105
  "exists": bool(table),
106
- "rows": len(rows),
107
- "refreshed": max([s for s in stamps if s], default=""),
 
 
 
 
 
108
  "locked": table.get("recordMode") == user_tables.AUTOMATION_RECORD_MODE,
 
 
 
 
109
  }
110
- return {"ok": True, "applicable": rel.is_royal(session.tenant), "tables": out}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  ADMIN-GATED, and not for tidiness: a refresh REWRITES four locked databases for the whole tenant
16
  and deletes the rows that left the population. That is an operator action.
17
+ ⭐ WAVE 30 (R6/R7, contract C2) ADDS A THIRD ENDPOINT AND IT IS A DIFFERENT KIND OF THING: the
18
+ READ-THROUGH WINDOW. The two above operate on the materialised copy; `/{table_key}/rows` does not
19
+ read the copy at all. See the block above `GRID_SOURCES` for why that had to change.
20
  """
21
+ import json
22
+
23
  from fastapi import Depends
24
+ from fastapi import APIRouter, Query
25
 
26
  from deps import Session, err, require_session
27
 
 
68
  raise err(400, "not_applicable",
69
  "These databases are built from the Royal Imports Odoo mirror (R1).")
70
  try:
71
+ out = rel.refresh(session.runtime, session.tenant, username=session.user)
72
+ # ⭐⭐ W30-T31 β€” AND THEN THE ROWS THAT SHOULD NOT BE HERE LEAVE AGAIN.
73
+ #
74
+ # The spawn writes every bucket's rows straight into the tenant document by mutating it
75
+ # inside its own updater, so no guard in `core.user_tables` is on that path (measured, not
76
+ # assumed: `_ensure_table_inplace` never calls a function there). Stripping AFTER the write
77
+ # is what makes "a read-through grid stores no rows" true rather than intended β€” and the
78
+ # refresh is the only moment a stripped table can come back.
79
+ # ⚠ Reported in the response, because a silent 7 MB moving in or out of a tenant's
80
+ # document is exactly the kind of thing an operator should be able to see happening.
81
+ sync_read_through()
82
+ moved = _ut().strip_materialised(st=session.runtime)
83
+ return {"ok": True, **out, "unmaterialised": moved}
84
  except rel.Refused as e:
85
  # A refusal is the ANSWER, not a crash: the caller must see WHY nothing was written.
86
  raise err(409, "refused", str(e))
 
109
  """
110
  rel, out = _rel(), {}
111
  import core.user_tables as user_tables
112
+ # ⭐ W30-T31 β€” THE ELIGIBILITY PASS RUNS HERE TOO, and the mirror cursor is taken best-effort:
113
+ # the freshness surface must answer on a box with no mirror (that is what it is FOR), so a
114
+ # store that is not ready costs the size half of the question, never the whole endpoint.
115
+ cur = None
116
+ try:
117
+ from harness import datastore
118
+ cur = datastore.ro_con()
119
+ except Exception: # noqa: BLE001
120
+ pass
121
+ eligible, why_not = sync_read_through(cur)
122
  # ⚠ ITERATES `rel.TABLES`, NEVER A LITERAL PAIR. It read `(INVOICES_KEY, CUSTOMERS_KEY)`
123
  # while those were the only two; the 2026-08-09 widening added products and orders, and a
124
  # hard-coded list here would have reported "everything is fine" over two databases it had
 
127
  table = user_tables.get(key, st=session.runtime) or {}
128
  rows = (table.get("rows") or {})
129
  stamps = [str((r or {}).get("refreshed") or "") for r in rows.values()]
130
+ # β›”β›” A FRESHNESS SURFACE THAT LIES IS WORSE THAN NO FRESHNESS SURFACE, and this endpoint
131
+ # was one step from becoming one. Both numbers below are derived from `table["rows"]`, so
132
+ # the moment a grid stops materialising they would read `rows: 0, refreshed: ""` β€” an
133
+ # operator would see a database that looks EMPTY and STALE on a route whose own docstring
134
+ # says a worklist that quietly stopped updating must be legible without a refresh. So a
135
+ # read-through table is counted from the MIRROR and says where its count came from.
136
+ materialised = user_tables.materialises(key, st=session.runtime)
137
+ if not materialised:
138
+ try:
139
+ rows = {}
140
+ stamps = []
141
+ spec = _source_for(cur, key) if cur is not None else None
142
+ if spec is not None:
143
+ from harness import datastore as _ds
144
+ frm = ({"from_sql": spec["from_sql"]} if spec.get("from_sql")
145
+ else {"table": spec["table"]})
146
+ n = _ds.window(select="1", where=spec.get("where") or "",
147
+ order_by=spec["id"], limit=1, cur=cur, **frm)["total"]
148
+ else:
149
+ n = -1 # unknown, and never reported as zero
150
+ except Exception: # noqa: BLE001
151
+ n = -1
152
  out[key] = {
153
  "exists": bool(table),
154
+ "rows": (len(rows) if materialised else n),
155
+ "rowsFrom": ("document" if materialised else "mirror"),
156
+ "materialised": materialised,
157
+ # why this grid still keeps its rows in the tenant document, when it does not have to
158
+ "materialisedBecause": (why_not.get(key, "") if materialised else ""),
159
+ "refreshed": (max([s for s in stamps if s], default="") if materialised
160
+ else "live β€” read through the mirror"),
161
  "locked": table.get("recordMode") == user_tables.AUTOMATION_RECORD_MODE,
162
+ # W30/R7: whether this database is served THROUGH the mirror rather than from the copy
163
+ # above. Reported per table, because they convert one at a time and an operator
164
+ # reading `rows: 0` needs to know whether that means "empty" or "not stored here".
165
+ "readThrough": key in _sources(),
166
  }
167
+ # ⭐ W30-T32 / R6's SECOND SENTENCE, APPLIED TO OUR OWN HALF-BUILT STATE. A read-through
168
+ # binding whose FIELD DECLARATION has not landed yet answers 404 on the rows route, and an
169
+ # operator would read that as "the grid does not exist" rather than as "half of it shipped".
170
+ # These are the two line grains: bound here, declared in `odoo_relational` by W30-T35.
171
+ # ⚠ It reports the KEYS, never a field list β€” inventing a contract here is exactly the second
172
+ # source of truth the ticket forbids.
173
+ pending = {k: {"readThrough": True, "declared": False,
174
+ "cause": "this connected grid has a read-through binding but no field "
175
+ "declaration in odoo_relational yet, so it cannot be opened",
176
+ "recommendation": "declare its fields + a TABLES row (W30-T35); the binding "
177
+ "and the window are already live"}
178
+ for k in _sources() if k not in out}
179
+ return {"ok": True, "applicable": rel.is_royal(session.tenant), "tables": out,
180
+ "bound_not_declared": pending}
181
+
182
+
183
+ # ═════════════════════════════════════════════════════════════════════════════════════════════
184
+ # ⭐⭐ THE READ-THROUGH WINDOW β€” owner ruling R6 ("no cap on connected-source data") via R7.
185
+ # ═════════════════════════════════════════════════════════════════════════════════════════════
186
+ #
187
+ # THE PROBLEM IT REPLACES, in the owner's numbers. `core.user_tables.MAX_ROWS = 60_000` bounds a
188
+ # `ut_*` table because ALL of a tenant's tables live in ONE JSON document that `Store.get`
189
+ # deep-copies per request (D-87: four Odoo databases = 20.7 MB, ~370 ms a copy). Orders at
190
+ # **32,826** is already 55% of that ceiling; order lines (**255,286**) are 4.3Γ— over it and GL
191
+ # lines (**971,034**) 16Γ— over, so those two could never be grids at all β€” `odoo_relational.plan`
192
+ # refuses them rather than truncating, which is the correct refusal of the wrong architecture.
193
+ #
194
+ # ⭐ "NO CAP" IS NOT A BIGGER NUMBER. Raising `MAX_ROWS` would make every request slower for every
195
+ # tenant, including the ones who never open an Odoo grid. The mirror ALREADY holds all of it
196
+ # uncapped β€” measured on this box: 963,783 GL lines counted in ~24 ms β€” so the fix is to stop
197
+ # copying and serve a WINDOW: the requested slice, plus a `SELECT count(*)` that tells the truth
198
+ # about the whole.
199
+ #
200
+ # β›”β›” `total` IS NEVER `len(rows)`. It comes from `datastore.window`'s own count statement over
201
+ # the SAME predicate. A window whose count is its own length is a fabricated aggregate wearing an
202
+ # authoritative face ([[no-unverifiable-aggregates]]).
203
+ #
204
+ # β›” PREDICATES PUSH DOWN, and this is the half a client cannot be trusted with. A filter chip
205
+ # evaluated over the 200 rows that happen to be in memory would report "40 matches" out of
206
+ # 971,034 β€” [[one-question-two-normalizers]] at scale. `harness/filter_sql.py` compiles OUR filter
207
+ # vocabulary to SQL and is the same evaluator the TS engine is held in step with, so the fold and
208
+ # the display answer one question.
209
+ #
210
+ # ⭐ THE SPEC BELOW IS A SECOND STATEMENT OF SOMETHING `odoo_relational`'s READER ALREADY SAYS,
211
+ # AND THAT IS THE REAL RISK HERE β€” not SQL injection. It is gated rather than trusted:
212
+ # `verify_scopes.section_read_through` runs the REAL reader over the REAL mirror as an ORACLE and
213
+ # asserts the windowed path agrees ROW FOR ROW and TOTAL FOR TOTAL. A binding that drifts from its
214
+ # reader goes red; a bucket with no binding is REPORTED, never silently served from the copy as
215
+ # though it were read-through ([[one-evaluator-per-question]], [[gate-answers-the-wrong-question]]).
216
+ #
217
+ # ⚠ ONE BUCKET IS BOUND HERE (orders). The other seven still serve from the materialised copy and
218
+ # say so on the wire (`readThrough: false`), because R6's second sentence β€” *"if there is lag or
219
+ # it can't be done, you need to explicitly tell me why and recommend a fix"* β€” makes an unconverted
220
+ # grid something to REPORT, not something to leave looking converted. Adding one is a spec row plus
221
+ # a green oracle check.
222
+
223
+ #: `ut_*` key -> how to read that grid straight out of the mirror.
224
+ #:
225
+ #: `where`/`select` are SQL WE author (never a request value); every request value is bound through
226
+ #: `params` by `filter_sql`. `cols` maps a FIELD KEY (what `odoo_relational.<x>_fields()` declares,
227
+ #: and what a saved view's filters name) to `(sql expression, coercion)`. The coercion reproduces
228
+ #: the reader's own python cast, by CALLING the reader's helpers where one exists β€” `_as_date` and
229
+ #: `_in_scope` are imported, not re-implemented.
230
+ #: Keys a reader does not produce (link columns, `refreshed`) are absent here on purpose: they are
231
+ #: filled by the grid at render time exactly as they are on the materialised path.
232
+ GRID_SOURCES = {}
233
+
234
+
235
+ def _sources():
236
+ """Build `GRID_SOURCES` lazily β€” it names `odoo_relational` constants, and importing that
237
+ module at file-import time would drag the Odoo layer into every process that mounts a router.
238
+ """
239
+ if GRID_SOURCES:
240
+ return GRID_SOURCES
241
+ rel = _rel()
242
+ # ⭐ R6 / W30-T29 β€” TELL THE STORE LAYER WHICH DATABASES ARE CONNECTED, so `MAX_ROWS` stops
243
+ # being a fact about them. `core` never imports up, so the declaration goes this way round.
244
+ # ⚠ ALL EIGHT, not just the read-through-bound one: every row in these tables comes from Odoo,
245
+ # which is what R6 is about β€” being served from the stored copy today is our conversion state,
246
+ # not a property of the data.
247
+ _ut().register_connected(*[key for _b, key, _l, _f in rel.TABLES])
248
+ _s, _i, _n = ((lambda v: str(v or "")), (lambda v: int(v or 0)),
249
+ (lambda v: float(v or 0.0)))
250
+ GRID_SOURCES[rel.ORDERS_KEY] = {
251
+ "table": "sale_order",
252
+ # β›” IMPORTED, NOT RETYPED. `_CONFIRMED` is the fixed wholesale scope; if it ever changes,
253
+ # this window changes with it and the oracle check proves it did.
254
+ "where": f"{rel._CONFIRMED} AND partner_id IS NOT NULL",
255
+ "id": "id",
256
+ "needs_excluded": True, # `wholesale_scope` is resolved against the excluded set
257
+ "cols": {
258
+ "order_no": ("name", _s),
259
+ "odoo_id": ("id", _i),
260
+ "customer": ("partner_name", _s),
261
+ rel.JOIN_KEY: ("partner_id", _i),
262
+ "order_date": ("date_order", rel._as_date),
263
+ "amount_untaxed": ("amount_untaxed", _n),
264
+ "team": ("team_name", _s),
265
+ "state": ("state", _s),
266
+ "invoice_status": ("invoice_status", _s),
267
+ # ⭐ THE SCOPE COLUMN BECOMES REAL SQL, which is the point. On the materialised path it
268
+ # is `_in_scope(pid, excluded)` β€” a python set test, and a filter on it therefore could
269
+ # not push down. Inlining the ids (ints, from our own query) makes it a column the
270
+ # mirror can filter and sort on, so the R6 "limit" it would otherwise have earned does
271
+ # not exist. `{excluded}` is substituted by `_source_for` below.
272
+ "wholesale_scope": ("CASE WHEN partner_id IN ({excluded}) THEN '' ELSE '1' END", _s),
273
+ },
274
+ }
275
+
276
+ # ═════════════════════════════════════════════════════════════════════════════════════════
277
+ # ⭐⭐ W30-T32 β€” THE TWO LINE GRAINS. These are the grids R6 exists for: they have never had a
278
+ # `ut_*` table and never can, at any cap. MEASURED on this box's mirror, warm:
279
+ # sale_order_line 254,189 in the confirmed scope (256,810 unscoped) β€” 63.9 MB as JSON
280
+ # account_move_line 963,783 β€” ~240 MB as JSON
281
+ # Against `MAX_ROWS = 60_000` that is 4.2x and 16x, and against the 32 MB per-table document
282
+ # budget it is 2x and 7.5x. Read THROUGH, both serve a page in 134–166 ms.
283
+ #
284
+ # β›” THE FIELD KEYS BELOW ARE HALF OF A CONTRACT AND `odoo_relational` OWNS THE OTHER HALF
285
+ # (W30-T35, session E). `cols` binds a field key to SQL; the field's label, type and order are
286
+ # DECLARED THERE, once. Until that declaration lands the route answers 404 for these two keys
287
+ # (`rel.TABLES` has no entry), and `odoo_tables_status` REPORTS them as bound-not-declared
288
+ # rather than leaving them invisible β€” R6's second sentence applied to our own conversion.
289
+ # ⚠ A key here with no declaration there is a silent NO-CELL (`rows_from_pool` projects
290
+ # strictly); a key there with no binding here is an INACTIVE filter leaf, which WIDENS. The
291
+ # two lists are checked against each other by `verify_scopes.section_line_grids`.
292
+ ol_key = getattr(rel, "ORDER_LINES_KEY", "ut_odoo_order_lines")
293
+ gl_key = getattr(rel, "GL_LINES_KEY", "ut_odoo_gl_lines")
294
+ _ut().register_connected(ol_key, gl_key)
295
+ # β›” THE SCOPE IS THE ORDER'S, AND THE LINE TABLE CANNOT ANSWER IT ALONE: `sale_order_line`
296
+ # carries no `state` (12 columns, measured), so the confirmed-order scope β€” and `order_date`,
297
+ # and the order NAME a person reads the grid by β€” only exist across the join. MEASURED, warm,
298
+ # best of two: the JOIN beats `order_id IN (SELECT id FROM sale_order WHERE …)` at both depths
299
+ # (166 / 483 ms against 237 / 565 ms at offset 0 / 200,000), so the shape is chosen on a
300
+ # number rather than on taste.
301
+ # ⚠ `_CONFIRMED` is IMPORTED and QUALIFIED, never retyped β€” it opens with the bare column
302
+ # `state`, which `sale_order_line` does not have, so the prefix is what keeps it unambiguous
303
+ # if that table ever gains one. `section_line_grids` counts the same population a second way
304
+ # (a subquery, not a join) and the two must agree, which is what catches a mis-qualification.
305
+ GRID_SOURCES[ol_key] = {
306
+ "from_sql": "(sale_order_line sol JOIN sale_order so ON so.id = sol.order_id)",
307
+ "tables": {"sol": "sale_order_line", "so": "sale_order"},
308
+ "where": f"so.{rel._CONFIRMED}",
309
+ "id": "sol.id",
310
+ "needs_excluded": True,
311
+ "cols": {
312
+ "odoo_id": ("sol.id", _i),
313
+ "order_no": ("so.name", _s),
314
+ "order_id": ("sol.order_id", _i),
315
+ "customer": ("sol.order_partner_name", _s),
316
+ rel.JOIN_KEY: ("sol.order_partner_id", _i),
317
+ "product": ("sol.product_name", _s),
318
+ rel.PRODUCT_JOIN_KEY: ("sol.product_id", _i),
319
+ "qty": ("sol.product_uom_qty", _n),
320
+ "price_subtotal": ("sol.price_subtotal", _n),
321
+ "margin": ("sol.margin", _n),
322
+ "purchase_price": ("sol.purchase_price", _n),
323
+ "order_date": ("so.date_order", rel._as_date),
324
+ "state": ("so.state", _s),
325
+ "wholesale_scope": (
326
+ "CASE WHEN sol.order_partner_id IN ({excluded}) THEN '' ELSE '1' END", _s),
327
+ },
328
+ }
329
+ # ⚠ UNSCOPED ON PURPOSE, and it is a decision rather than an omission: every other grid here
330
+ # carries a fixed scope, but a GENERAL LEDGER whose draft and cancelled entries are invisible
331
+ # is a ledger that cannot be reconciled. `parent_state` rides as a column so a person filters
332
+ # in SQL over all 963,783 rows instead of us choosing for them. (Posted-only is 944,846.)
333
+ # The join to `account_account` is what makes `account_code` β€” the key `ut_odoo_accounts` is
334
+ # linked on β€” available at all; the mirror flattens `account_id`/`account_name` onto the line
335
+ # but not the CODE, and 192 accounts hash-join for free.
336
+ GRID_SOURCES[gl_key] = {
337
+ "from_sql": ("(account_move_line aml LEFT JOIN account_account aa "
338
+ "ON aa.id = aml.account_id)"),
339
+ "tables": {"aml": "account_move_line", "aa": "account_account"},
340
+ "where": "",
341
+ "id": "aml.id",
342
+ "needs_excluded": True,
343
+ "cols": {
344
+ "odoo_id": ("aml.id", _i),
345
+ "entry": ("aml.move_name", _s),
346
+ "move_id": ("aml.move_id", _i),
347
+ "account": ("aml.account_name", _s),
348
+ rel.ACCOUNT_JOIN_KEY: ("aa.code", _s),
349
+ "customer": ("aml.partner_name", _s),
350
+ rel.JOIN_KEY: ("aml.partner_id", _i),
351
+ "date": ("aml.date", rel._as_date),
352
+ "debit": ("aml.debit", _n),
353
+ "credit": ("aml.credit", _n),
354
+ "balance": ("aml.balance", _n),
355
+ "line_type": ("aml.display_type", _s),
356
+ "move_type": ("aml.move_type", _s),
357
+ "parent_state": ("aml.parent_state", _s),
358
+ "wholesale_scope": (
359
+ "CASE WHEN aml.partner_id IN ({excluded}) THEN '' ELSE '1' END", _s),
360
+ },
361
+ }
362
+ # ⭐ W30-T31 β€” THE GL ACCOUNT REGISTRY, BOUND BECAUSE IT IS THE ONE GRID THAT CAN ACTUALLY
363
+ # STOP MATERIALISING TODAY. 192 rows, nothing folds it, nothing links at it, and it fits
364
+ # inside one window β€” the three conditions `_unmaterialisable` checks. It is small, and that
365
+ # is the point: it is the first shipped database whose rows are NOT in the tenant document,
366
+ # so the stratum is proven on a real table instead of on a mechanism with no subject.
367
+ # ⚠ `account_fields()` declares six columns and `read_accounts` is one `cur.execute` over
368
+ # `account_account`; `section_read_through`'s differential oracle holds this binding to it.
369
+ # ⚠ `is_expense` REPRODUCES `read_accounts`' predicate IN SQL rather than inventing one, and
370
+ # that predicate is itself a copy of the semantic layer's `gl_lines` scope. Three statements of
371
+ # one rule is two too many, but the reader's own comment explains why it is copied rather than
372
+ # imported, and `section_read_through`'s differential oracle is what keeps this one honest.
373
+ GRID_SOURCES[rel.ACCOUNTS_KEY] = {
374
+ "table": "account_account",
375
+ "where": "",
376
+ "id": "id",
377
+ "cols": {
378
+ rel.ACCOUNT_JOIN_KEY: ("code", _s),
379
+ "account_name": ("name", _s),
380
+ "odoo_id": ("id", _i),
381
+ "account_type": ("account_type", _s),
382
+ "is_expense": ("CASE WHEN account_type IN ('expense','expense_depreciation') "
383
+ "THEN '1' ELSE '' END", _s),
384
+ },
385
+ }
386
+ return GRID_SOURCES
387
+
388
+
389
+ def sync_read_through(cur=None):
390
+ """Register every grid that may stop storing rows, and REPORT why the rest may not.
391
+
392
+ Returns `({key: eligible}, {key: reason})`. Idempotent, cheap, and safe to call from any door:
393
+ registration is additive and `strip_materialised` is a no-op once a table is empty.
394
+
395
+ β›” IT IS ALSO THE ONLY PLACE THAT MAY CALL `register_read_through`, because the eligibility
396
+ question needs BOTH halves β€” `odoo_relational`'s field declarations (for the fold matrix) and
397
+ the mirror (for the size) β€” and `core` may import neither.
398
+ """
399
+ eligible, reasons = _unmaterialisable(cur)
400
+ keys = [k for k, ok in eligible.items() if ok]
401
+ if keys:
402
+ _ut().register_read_through(*keys)
403
+ return eligible, reasons
404
+
405
+
406
+ #: ⚠ A mirror can be `ready()` and still lack a column (`ready()` reads entity PHASES; column
407
+ #: backfills checkpoint separately) β€” the gap that already cost a live 500. Every projected
408
+ #: expression is checked against the real column list and degraded to a literal, exactly as
409
+ #: `odoo_relational._col` does for the reader, so a fresh Space serves a blank cell rather than a
410
+ #: DuckDB Binder error.
411
+ #:
412
+ #: β›” W30-T32 β€” IT TAKES AN ALIAS MAP NOW, AND WITHOUT THAT THE GUARD WAS ABOUT TO GO BLIND. The
413
+ #: line-grain grids project `sol.price_subtotal` / `aml.parent_state`, and a dotted string is not
414
+ #: `isalnum()`, so the old single-table version returned EVERY qualified expression unchecked β€”
415
+ #: the same "expression, nothing to check" branch that correctly skips a CASE. Both of those
416
+ #: columns are 2026-07-28 backfills that a mirror can genuinely be missing, so the blind spot
417
+ #: would have surfaced as a bare DuckDB Binder error on a fresh Space, which is precisely the
418
+ #: failure this helper exists to prevent ([[gate-answers-the-wrong-question]]).
419
+ # ═════════════════════════════════════════════════════════════════════════════════════════════
420
+ # ⭐⭐ W30-T31 / D-87 β€” WHICH CONNECTED GRIDS MAY STOP MATERIALISING, AND WHY MOST MAY NOT YET.
421
+ # ═════════════════════════════════════════════════════════════════════════════════════════════
422
+ #
423
+ # The prize is real: all of a tenant's `ut_*` tables live in ONE document that `Store.get`
424
+ # deep-copies on EVERY call, hit or miss, and the four original Odoo grids are 20.7 MB / ~370 ms
425
+ # of it. Every permission check in the app pays that.
426
+ #
427
+ # β›” AND YOU CANNOT SIMPLY DELETE THE ROWS, WHICH IS THE FINDING THIS FUNCTION ENCODES. Measured
428
+ # mechanically across the eight field declarations, not by eye:
429
+ # * `ut_odoo_invoices` and `ut_odoo_orders` β€” 16.2 of those 20.7 MB β€” are folded by SIX link
430
+ # rollups on `ut_odoo_customers` (`ar_outstanding`, `open_invoices`, `oldest_due`,
431
+ # `invoiced_all_time`, `order_count`, `last_order`). `automation_engine.compute_relation_cells`
432
+ # answers those by reading the RAW document, so with the rows gone it writes zeros β€” silently.
433
+ # * every table with a `link` pointing AT it (agents, vendors, bills, customers) has its link
434
+ # CELLS materialised the same way, from the target's stored rows.
435
+ # * `ut_odoo_customers` and `ut_odoo_products` carry SOURCE rollups, and `rollup_sql.compute`
436
+ # writes those cells INTO their own stored rows: no rows, no cells.
437
+ # * and while the grid client still asks for a whole table (F's W30-T42 is what changes this),
438
+ # a population larger than one window could only be served by TRUNCATING it β€” which R6
439
+ # forbids more strongly than it forbids a cap.
440
+ #
441
+ # ⭐ SO THE PREDICATE IS DERIVED FROM THE DECLARATIONS RATHER THAN LISTED. The day a lane converts
442
+ # those six link rollups to SOURCE rollups (`orders_ytd` on that same table is the precedent), or
443
+ # the day the client pages, the affected grids become eligible here with NO code change β€” and
444
+ # until then each one's reason is reported per table on the status door, which is R6's second
445
+ # sentence applied to our own conversion state.
446
+ def _fold_reasons(rel):
447
+ """`{table_key: "why its stored rows are still read by something else"}`.
448
+
449
+ Read out of the FIELD DECLARATIONS themselves β€” one pass over `rel.TABLES`. A table absent
450
+ from this map is folded by nothing.
451
+ """
452
+ reasons = {}
453
+
454
+ def _add(key, why):
455
+ reasons.setdefault(str(key), []).append(why)
456
+
457
+ for _bucket, key, _label, mk in rel.TABLES:
458
+ try:
459
+ fields = mk()
460
+ except Exception: # noqa: BLE001
461
+ continue
462
+ by_key = {f.get("key"): f for f in fields if isinstance(f, dict)}
463
+ short = str(key).replace("ut_odoo_", "")
464
+ for f in fields:
465
+ if not isinstance(f, dict):
466
+ continue
467
+ if f.get("type") == "link" and (f.get("link") or {}).get("table"):
468
+ _add(f["link"]["table"], f"{short}.{f['key']} is a link whose cells are built "
469
+ f"from these rows")
470
+ if f.get("type") != "rollup":
471
+ continue
472
+ bag = f.get("rollup") or {}
473
+ if isinstance(bag.get("source"), dict):
474
+ _add(key, f"{short}.{f['key']} is a source rollup and its cells are written "
475
+ f"into these rows")
476
+ continue
477
+ tgt = ((by_key.get(str(bag.get("link") or "")) or {}).get("link") or {}).get("table")
478
+ if tgt:
479
+ _add(tgt, f"{short}.{f['key']} folds these rows")
480
+ return {k: "; ".join(v) for k, v in reasons.items()}
481
+
482
+
483
+ def _unmaterialisable(cur=None):
484
+ """`({key: eligible}, {key: reason})` β€” who may stop storing rows, and why the rest may not.
485
+
486
+ β›” SIZE FORCES READ-THROUGH; IT NEVER BLOCKS IT β€” and getting that backwards was a real bug in
487
+ the first cut of this function. A table too big for the tenant document has NO materialised
488
+ option at all, so making it ineligible would have handed `odoo_relational.plan` a `row_limit`
489
+ of None and invited it to build 963,783 python dicts. The window ceiling is a different, much
490
+ softer thing: it only limits what the whole-table CLIENT door can serve today.
491
+
492
+ rows > MAX_ROWS -> read-through REQUIRED (the document cannot hold it)
493
+ else if something folds it -> stays materialised (a fold over no rows writes ZEROS)
494
+ else if rows > WINDOW_MAX -> stays materialised until the client pages (W30-T42)
495
+ else -> eligible
496
+
497
+ ⚠ Without a mirror cursor the size questions cannot be asked, so only a grid that was never
498
+ part of the materialised spawn is eligible β€” a table is never freed by a question we skipped.
499
+ """
500
+ rel = _rel()
501
+ folds, eligible, reasons = _fold_reasons(rel), {}, {}
502
+ # β›” "WAS THIS PART OF THE MATERIALISED SPAWN?", and being in `TABLES` STOPPED ANSWERING IT.
503
+ # W30-T35 declared the two line grains, which have no python row builder anywhere β€” their
504
+ # absence from `_READERS` IS that statement β€” so a table the spawn could never materialise
505
+ # started reading as spawned, and on a mirror-less deployment fell through to "its size could
506
+ # not be read" and came back INELIGIBLE. That inverts this function's own first law (size
507
+ # forces read-through; it never blocks it). Ask the question through the reader map, which is
508
+ # what actually decides whether a row could ever have been built.
509
+ spawned = {key for _b, key, _l, _f in rel.TABLES if _b in getattr(rel, "_READERS", {})}
510
+ from harness import datastore
511
+ ut = _ut()
512
+ for key, spec in _sources().items():
513
+ total = None
514
+ if cur is not None:
515
+ try:
516
+ frm = ({"from_sql": spec["from_sql"]} if spec.get("from_sql")
517
+ else {"table": spec["table"]})
518
+ total = datastore.window(select="1", where=spec.get("where") or "",
519
+ order_by=spec["id"], limit=1, cur=cur, **frm)["total"]
520
+ except Exception: # noqa: BLE001
521
+ total = None
522
+ if total is not None and total > ut.MAX_ROWS:
523
+ eligible[key] = True # no other home exists; this is not a choice
524
+ continue
525
+ if total is None and key not in spawned:
526
+ eligible[key] = True # never materialised, so the mirror is its home
527
+ continue
528
+ why = []
529
+ if total is None:
530
+ why.append("its size could not be read from the mirror on this deployment")
531
+ if key in folds:
532
+ why.append(folds[key])
533
+ if total is not None and total > datastore.WINDOW_MAX:
534
+ why.append(f"its {total:,} rows exceed the {datastore.WINDOW_MAX:,}-row window and "
535
+ f"the grid client still asks for whole tables, so un-materialising it "
536
+ f"today could only truncate")
537
+ eligible[key] = not why
538
+ if why:
539
+ reasons[key] = "; ".join(why)
540
+ return eligible, reasons
541
+
542
+
543
+ def _degrade(expr, have_by_alias, default_alias=""):
544
+ bare = expr.strip()
545
+ alias, _, col = bare.partition(".")
546
+ if col and alias.replace("_", "").isalnum() and col.replace("_", "").isalnum():
547
+ have = have_by_alias.get(alias)
548
+ if have is None:
549
+ return expr # an alias we do not own β€” leave it to the caller's SQL
550
+ return expr if col.lower() in have else "NULL"
551
+ if not bare.replace("_", "").isalnum():
552
+ return expr # an expression, not a bare column β€” nothing to check
553
+ # ⚠ A BARE COLUMN IN A JOINED SPEC RESOLVES THE WAY SQL RESOLVES IT β€” against every table in
554
+ # the FROM, not against nothing. Checking it against `have_by_alias[""]`, which a joined spec
555
+ # does not have, would degrade every such column to NULL: a silent blank cell, which is the
556
+ # failure this helper exists to avoid rather than to cause.
557
+ have = have_by_alias.get(default_alias)
558
+ if have is None:
559
+ have = set().union(*have_by_alias.values()) if have_by_alias else set()
560
+ return expr if bare.lower() in have else "NULL"
561
+
562
+
563
+ def _source_for(cur, table_key):
564
+ """The resolved spec for one connected grid, or None when this grid is not read-through yet."""
565
+ spec = _sources().get(str(table_key or ""))
566
+ if not spec:
567
+ return None
568
+ from harness import datastore
569
+ # ⚠ `tables` maps the SQL alias a projection uses to the mirror table behind it. A single-table
570
+ # spec declares none and its columns are bare, so it degrades against `table` as before.
571
+ aliases = dict(spec.get("tables") or {})
572
+ if spec.get("table"):
573
+ aliases.setdefault("", spec["table"])
574
+ have_by_alias = {}
575
+ for alias, tname in aliases.items():
576
+ cols = datastore.columns_of(tname, cur=cur)
577
+ if not cols:
578
+ return None # the mirror has no such table on this deployment
579
+ have_by_alias[alias] = cols
580
+ excluded = ""
581
+ if spec.get("needs_excluded"):
582
+ ids = sorted(int(p) for p in _rel().excluded_ids(cur))
583
+ # `-1` keeps the IN-list non-empty and matches no Odoo id, so the SQL shape is constant
584
+ # whether or not this tenant excludes a channel.
585
+ excluded = ", ".join(str(i) for i in ids) or "-1"
586
+ cols = {}
587
+ for key, (expr, cast) in spec["cols"].items():
588
+ cols[key] = (_degrade(expr.format(excluded=excluded) if "{excluded}" in expr else expr,
589
+ have_by_alias), cast)
590
+ return {**spec, "cols": cols}
591
+
592
+
593
+ #: How deep a page has to be before the walk is worth a sentence. Derived from the measurement in
594
+ #: the route below, not chosen: 100,000 is still 71 ms, 200,000 is 483 ms, and the second half of
595
+ #: the GL table is where it passes a second. Reporting from 100,000 puts the sentence in front of
596
+ #: the person BEFORE the wait rather than after it.
597
+ _DEEP_PAGE = 100_000
598
+
599
+
600
+ #: `filter_sql` speaks the CLIENT's type vocabulary (`types.ts isNumericType`), and two of our
601
+ #: field kinds are spelled differently there. Mapped in one place so the pushdown and the grid
602
+ #: cannot disagree about whether a column is text or a number.
603
+ _FILTER_TYPE = {"select": "status", "checkbox": "text"}
604
+
605
+
606
+ def _filter_columns(spec, fields):
607
+ """`{colId: {sql, type, aggregate}}` β€” what `filter_sql` needs to compile a predicate.
608
+
609
+ Only columns with a real mirror expression are offered. An omitted column is UNKNOWN to the
610
+ compiler, which skips it β€” and that is the one behaviour that must be REPORTED rather than
611
+ accepted, because an ignored condition WIDENS (the tri-state engine's inactive-leaf rule).
612
+ `_unpushable` below turns every such skip into an R6 sentence.
613
+ """
614
+ by_key = {f["key"]: f for f in fields}
615
+ out = {}
616
+ for key, (expr, _cast) in spec["cols"].items():
617
+ ftype = str((by_key.get(key) or {}).get("type") or "text")
618
+ out[key] = {"sql": expr, "type": _FILTER_TYPE.get(ftype, ftype), "aggregate": False}
619
+ return out
620
+
621
+
622
+ def _leaf_cols(nodes):
623
+ """Every `colId` a filter tree names, at any depth."""
624
+ seen = set()
625
+ for n in (nodes or []):
626
+ if not isinstance(n, dict):
627
+ continue
628
+ if n.get("children") is not None:
629
+ seen |= _leaf_cols(n.get("children"))
630
+ elif n.get("colId"):
631
+ seen.add(str(n["colId"]))
632
+ rhs = n.get("rhs")
633
+ if isinstance(rhs, dict) and rhs.get("colId"):
634
+ seen.add(str(rhs["colId"]))
635
+ return seen
636
+
637
+
638
+ def _json_arg(raw, what):
639
+ """Decode a JSON query argument, or refuse. β›” NEVER degrade to "no filter": a filter that
640
+ silently fails to parse WIDENS the answer, and the caller sees a plausible bigger number."""
641
+ if raw in (None, ""):
642
+ return None
643
+ try:
644
+ val = json.loads(raw)
645
+ except Exception: # noqa: BLE001
646
+ raise err(400, "bad_argument", f"{what} must be JSON")
647
+ return val
648
+
649
+
650
+ @router.get("/odoo-tables/{table_key}/rows")
651
+ def odoo_table_rows(table_key: str,
652
+ offset: int = Query(default=0, ge=0),
653
+ limit: int = Query(default=0),
654
+ filters: str = Query(default=None),
655
+ filterConj: str = Query(default="and"),
656
+ sorts: str = Query(default=None),
657
+ search: str = Query(default=None),
658
+ session: Session = Depends(require_session)):
659
+ """ONE WINDOW over a connected grid, read straight from the mirror (contract C2).
660
+
661
+ `{fields, rows, total, totalUnfiltered, offset, limit, limits, identity, recordsMutable}` β€”
662
+ `rows` is the requested slice and `total` is the count of everything the CURRENT PREDICATE
663
+ matches, from its own `SELECT count(*)`. `rows.length < total` is the normal case.
664
+
665
+ β›” `total` is never `len(rows)`; `totalUnfiltered` is the population with the predicate
666
+ dropped, so a client can render "N of M" without inventing either number.
667
+ β›” The filter, the sort and the search all resolve in SQL against the whole table. Anything
668
+ that CANNOT (a column with no mirror expression, an op the compiler refuses) is reported in
669
+ `limits` with its cause and a recommendation β€” R6's second sentence β€” and never silently
670
+ dropped, because an ignored condition widens.
671
+ """
672
+ import aios_grid
673
+ from harness import datastore
674
+ from routes_tables import _defn_or_refuse
675
+
676
+ rel = _rel()
677
+ # THE WALL FIRST, and it is the SAME one the materialised path uses β€” 404 for a key that does
678
+ # not exist, 403 for one this session may not open. Reused rather than re-stated: a second
679
+ # idea of "may this session open this database" is a permission bug waiting to happen.
680
+ defn = _defn_or_refuse(session, table_key)
681
+ fields = None
682
+ for _bucket, key, _label, mk_fields in rel.TABLES:
683
+ if key == table_key:
684
+ fields = mk_fields()
685
+ break
686
+ if fields is None:
687
+ raise err(404, "not_connected", "that database is not a connected Odoo grid")
688
+
689
+ try:
690
+ cur = datastore.ro_con()
691
+ except RuntimeError as e:
692
+ # The mirror is mid-first-sync. Relaying it beats serving a window over a partial table.
693
+ raise err(503, "store_not_ready", str(e))
694
+
695
+ spec = _source_for(cur, table_key)
696
+ if spec is None:
697
+ raise err(409, "not_read_through",
698
+ "this connected database is still served from its stored copy; it has no "
699
+ "read-through binding on this deployment yet")
700
+
701
+ cols = _filter_columns(spec, fields)
702
+ limits, tree = [], _json_arg(filters, "filters")
703
+ sort_spec = _json_arg(sorts, "sorts") or []
704
+
705
+ # ── the predicate, pushed down ────────────────────────────────────────────────────────────
706
+ named = _leaf_cols(tree)
707
+ missing = sorted(named - set(cols))
708
+ if missing:
709
+ limits.append({
710
+ "subject": ", ".join(missing), "effect": "filter_ignored",
711
+ "cause": "these columns have no expression in the mirror (a link, a rollup, or a "
712
+ "column this deployment's mirror has not backfilled), so a condition on "
713
+ "them cannot be answered in SQL",
714
+ "recommendation": "filter on the id column the link is built from, or open the "
715
+ "linked database directly"})
716
+ # ⭐ R6, MEASURED, AND IT IS THE KIND OF LIMIT THE RULING EXISTS FOR β€” a difference in the
717
+ # ANSWER, not in the speed.
718
+ #
719
+ # `filter_sql._value_sql` compiles every numeric comparison as `round_even(x, 0)`, and says
720
+ # why: *"reproduces `aios_grid._round` … The grid displays rounded values; filters must agree
721
+ # with what is on screen."* That is true of `source: "odoo"` columns, which `rows_from_pool`
722
+ # rounds. It is FALSE here β€” these columns are `source: "overlay"` (a storage choice, not a
723
+ # display one) so `rows_from_pool` passes the exact value through, and the TS engine
724
+ # (`useVisibleRows.toNum`) does not round either. So the pushdown compares at whole units while
725
+ # the cell beside it carries cents.
726
+ #
727
+ # MEASURED on the live orders mirror (32,700 rows, 47.9% with a non-integer amount):
728
+ # > 1000 python 4,476 vs SQL 4,473 (-3)
729
+ # > 173.6 python 24,711 vs SQL 24,726 (+15)
730
+ # > 500.25 python 11,028 vs SQL 11,026 (-2)
731
+ # Small, and NOT nothing. Rounding the wire to match would have been the other fix and it was
732
+ # rejected on measurement: it changes 96 of every 200 money cells (173.55 -> 174) to remove a
733
+ # 0.05% counting difference β€” a visible product regression traded for an invisible one.
734
+ # β›” SO IT IS REPORTED INSTEAD. Booked for the owner of `harness/filter_sql.py`, which is not
735
+ # this fence; see mailbox/D.md.
736
+ numeric = sorted(k for k in (named & set(cols))
737
+ if cols[k]["type"] in ("currency", "int", "pct"))
738
+ if numeric:
739
+ limits.append({
740
+ "subject": ", ".join(numeric), "effect": "precision",
741
+ "cause": "a number condition is evaluated in SQL at whole-unit precision "
742
+ "(`filter_sql` rounds to match the grids whose values the server rounds), "
743
+ "while these cells carry their exact value β€” so a row within half a unit of "
744
+ "the threshold can fall on the other side of it",
745
+ "recommendation": "compare against a whole number, or use a range that does not sit "
746
+ "on a fractional boundary"})
747
+
748
+ where, params = spec["where"], []
749
+ try:
750
+ pred = _fs().compile_filter_tree(
751
+ tree, conj=(filterConj if filterConj in ("and", "or") else "and"), columns=cols,
752
+ today=_today())
753
+ except ValueError as e:
754
+ # β›” A REFUSAL IS AN ANSWER, NOT A CRASH β€” and it must not become "no filter". Ranking ops
755
+ # ("top 10") have no SQL form in this compiler; saying so beats returning every row.
756
+ raise err(400, "filter_unsupported", str(e))
757
+ if pred is not None:
758
+ if pred.uses_aggregate:
759
+ raise err(400, "filter_unsupported",
760
+ "a condition on an aggregate column belongs in HAVING, and that path is "
761
+ "deliberately not built for windowed grids")
762
+ where = f"({where}) AND {pred.sql}" if where else pred.sql
763
+ params.extend(pred.params)
764
+ if str(search or "").strip():
765
+ got = _fs().compile_search(search.strip(), cols)
766
+ if got is not None:
767
+ where = f"({where}) AND {got.sql}" if where else got.sql
768
+ params.extend(got.params)
769
+
770
+ # ── the order, made TOTAL ─────────────────────────────────────────────────────────────────
771
+ # β›” `tiebreak_sql` is not optional here: without a total order, LIMIT/OFFSET may return one
772
+ # row on two pages and drop another entirely β€” a duplicate the user sees with no error
773
+ # anywhere. `compile_order_by`'s own docstring says so.
774
+ order = _fs().compile_order_by(sort_spec, cols, tiebreak_sql=spec["id"]) or spec["id"]
775
+
776
+ select = ", ".join(f'{expr} AS "{key}"' for key, (expr, _c) in spec["cols"].items())
777
+ # ⚠ EXACTLY ONE of `table`/`from_sql` β€” `window` raises if both or neither arrive, so the
778
+ # spec's own shape decides and a malformed spec fails loudly instead of serving a wrong FROM.
779
+ frm = {"from_sql": spec["from_sql"]} if spec.get("from_sql") else {"table": spec["table"]}
780
+ win = datastore.window(select=f'{spec["id"]} AS "_pid", {select}',
781
+ where=where, params=tuple(params), order_by=order,
782
+ offset=offset, limit=(limit or None), cur=cur, **frm)
783
+ if win["clamped"]:
784
+ limits.append({
785
+ "subject": "limit", "effect": "window_clamped",
786
+ "cause": f"one response carries at most {datastore.WINDOW_MAX} rows so a single "
787
+ f"request cannot exhaust memory for every other tenant on the process",
788
+ "recommendation": "page with `offset`; `total` already reports the whole population, "
789
+ "and no row is unreachable"})
790
+ # ⭐ R6's SECOND SENTENCE, ON THE ONE LIMIT THAT SURVIVES THE CONVERSION. Removing the row cap
791
+ # does not make every row equally cheap: `LIMIT/OFFSET` WALKS the offset, so the deeper the
792
+ # page the longer the scan. MEASURED warm on 963,783 GL lines β€” offset 0: 134 ms Β· 100,000:
793
+ # 71 ms Β· 900,000: **1,450 ms**; sorted by date rather than by id, offset 500,000: 1,645 ms.
794
+ # It is a real cost, it is nobody's mistake, and the owner asked to be told rather than to
795
+ # discover it: say so with the fix, which is a CURSOR the client has to send.
796
+ if offset >= _DEEP_PAGE:
797
+ limits.append({
798
+ "subject": "offset", "effect": "slow",
799
+ "cause": f"a page {offset:,} rows deep is reached by walking every row before it "
800
+ f"(SQL OFFSET has no other meaning), which costs about a second past "
801
+ f"half a million rows",
802
+ "recommendation": "jump with a filter or a sort instead of scrolling, or ask for "
803
+ "keyset paging (`id > <last row>`), which is O(page) at any depth "
804
+ "and needs the client to send the last row it holds"})
805
+
806
+ # ── the wire rows, through the SAME serialiser the materialised path uses ──────────────────
807
+ rows_src = _pool_from_window(spec, win)
808
+ overlays = {str(r["pid"]): {k: v for k, v in r.items() if k != "pid"} for r in rows_src}
809
+
810
+ # ⭐⭐ W30-T28 β€” THE TENANT-WIDE OVERLAY, ON A READ-THROUGH GRID.
811
+ #
812
+ # A user-added column on a connected grid has nowhere per-user to live: there is no `ut_*` row
813
+ # to hang it on any more, and `table_store`'s per-user strata would make a SHARED view name a
814
+ # column other accounts do not have β€” an unknown column is an INACTIVE condition in the
815
+ # tri-state engine, which WIDENS. `core/shared_overlay.py` was built for exactly this and has
816
+ # had no product door since it shipped (W29-T62).
817
+ #
818
+ # β›” `cells(table_key, pids)` TAKES THE PIDS AND THERE IS NO "EVERYTHING" CALL β€” and here that
819
+ # is an asset rather than a chore: **the window IS the scoped row set**, already narrowed by
820
+ # the wall and the predicate, so the argument it demands is the list we just fetched.
821
+ # ⚠ It is NOT a permission wall (its header says so twice); `_defn_or_refuse` above already
822
+ # answered "may this session open this surface".
823
+ shared_defs = _so().fields(table_key, st=session.runtime)
824
+ if shared_defs:
825
+ fields = list(fields) + [dict(f, source="overlay") for f in shared_defs.values()]
826
+ for pid, cells in _so().cells(table_key, [r["pid"] for r in rows_src],
827
+ st=session.runtime).items():
828
+ overlays.setdefault(pid, {}).update(cells)
829
+ rows = aios_grid.rows_from_pool(rows_src, fields, overlays)
830
+
831
+ unfiltered = win["total"] if where == spec["where"] else datastore.window(
832
+ select="1", where=spec["where"], order_by=spec["id"], limit=1, cur=cur, **frm)["total"]
833
+
834
+ return {"ok": True, "fields": fields, "rows": rows,
835
+ # β›” FROM THE COUNT STATEMENT. Never `len(rows)`.
836
+ "total": win["total"], "totalUnfiltered": unfiltered,
837
+ "offset": win["offset"], "limit": win["limit"], "limits": limits,
838
+ "today": _today(), "identity": {"pid": "pid"},
839
+ "scope": {"table": table_key, "readThrough": True},
840
+ "recordsMutable": bool(defn.get("recordMode") != _ut().AUTOMATION_RECORD_MODE)}
841
+
842
+
843
+ def _pool_from_window(spec, win):
844
+ """`[{pid, **cells}]` β€” THE one place a mirror window becomes product rows.
845
+
846
+ β›” ONE BUILDER, TWO CALLERS, and that is deliberate rather than tidy: the windowed route and
847
+ the whole-table read-through below would otherwise each cast the same columns their own way,
848
+ and a cell that renders differently depending on which door served it is this repo's recorded
849
+ defect class ([[one-question-two-normalizers]]).
850
+ """
851
+ keys = list(spec["cols"])
852
+ return [{"pid": int(r[0]),
853
+ **{k: spec["cols"][k][1](v) for k, v in zip(keys, r[1:])}} for r in win["rows"]]
854
+
855
+
856
+ class TooBigToMaterialise(Exception):
857
+ """A read-through table asked for WHOLE exceeds one window β€” refuse, never truncate."""
858
+
859
+
860
+ def whole_pool(table_key, cur=None):
861
+ """Every row of a read-through grid, in `scoped_pool`'s shape β€” or a refusal.
862
+
863
+ ⚠ THIS EXISTS FOR THE CLIENT WE HAVE, NOT THE ONE WE WANT. The grid still fetches whole
864
+ tables (`GET /tables/{key}/rows`); paging is F's W30-T42. So a database whose rows have left
865
+ this tenant's document has to be servable to that client somehow, and the honest answer for a
866
+ small one is "read all of it from the mirror". `_unmaterialisable` only ever registers a table
867
+ that fits, so the refusal below is a guard against the population GROWING past the window
868
+ later β€” at which point R6 requires a sentence, not a quietly shorter grid.
869
+ """
870
+ from harness import datastore
871
+ cur = cur if cur is not None else datastore.ro_con()
872
+ spec = _source_for(cur, table_key)
873
+ if spec is None:
874
+ raise TooBigToMaterialise(
875
+ f"{table_key}: this database is served through the mirror but has no read-through "
876
+ f"binding on this deployment, so its rows cannot be read at all")
877
+ frm = {"from_sql": spec["from_sql"]} if spec.get("from_sql") else {"table": spec["table"]}
878
+ select = ", ".join(f'{expr} AS "{key}"' for key, (expr, _c) in spec["cols"].items())
879
+ win = datastore.window(select=f'{spec["id"]} AS "_pid", {select}', where=spec.get("where"),
880
+ order_by=spec["id"], limit=datastore.WINDOW_MAX, cur=cur, **frm)
881
+ if win["total"] > len(win["rows"]):
882
+ # β›” REFUSE, NEVER TRIM. A short grid that says nothing is exactly the silent truncation
883
+ # R6's second sentence is about β€” and the caller turns this into a message with a cause.
884
+ raise TooBigToMaterialise(
885
+ f"{table_key}: {win['total']:,} rows is more than one {datastore.WINDOW_MAX:,}-row "
886
+ f"window, and this database no longer stores rows in the tenant document; it can "
887
+ f"only be read a page at a time (`/odoo-tables/{table_key}/rows`)")
888
+ return _pool_from_window(spec, win)
889
+
890
+
891
+ def _fs():
892
+ from harness import filter_sql
893
+ return filter_sql
894
+
895
+
896
+ def _so():
897
+ import core.shared_overlay as shared_overlay
898
+ return shared_overlay
899
+
900
+
901
+ def _ut():
902
+ import core.user_tables as user_tables
903
+ return user_tables
904
+
905
+
906
+ def _today():
907
+ import time
908
+ return time.strftime("%Y-%m-%d")
api/routes_products.py CHANGED
@@ -147,6 +147,22 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
147
  scope_key="product", table=pd.TABLE_OPS)
148
  ws = grid_events.table_workspace(ctx, allowed_pids=pids,
149
  consume_corrections=consume_corrections)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  workspace, fields, views, lists = aios_grid.workspace_wire(
151
  ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
152
  fields_base=fields_base)
@@ -178,9 +194,9 @@ def products(session: Session = Depends(module_gate(MODULE))):
178
  import aios_grid
179
 
180
  g = product_assembly(session)
181
- rows = _seed_image(aios_grid.rows_from_pool(
182
- g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"]),
183
- g["fields"])
184
  return {"fields": g["fields"], "rows": rows,
185
  "today": g["today"], "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
186
  "identity": {"pid": "pid", "businessKey": "code"},
@@ -222,8 +238,14 @@ def patch_product(pid: int, body: dict = Body(default=None),
222
 
223
  # What actually landed, read back from the PRODUCT bucket rather than echoed from the
224
  # request: a refused key or a truncated value must not be reported as accepted.
 
 
 
 
 
225
  stored = (grid_events.table_workspace(ctx, allowed_pids=None)
226
  .get("overlays") or {}).get(str(pid)) or {}
 
227
  accepted = {k: stored.get(k) for k in updates if k in stored}
228
  refused = sorted(k for k in updates
229
  if k not in accepted or stored.get(k) != str(updates[k]))
@@ -242,6 +264,47 @@ def patch_product(pid: int, body: dict = Body(default=None),
242
  PRODUCT_IMAGE_KEY = "image"
243
 
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  def _seed_image(rows, fields):
246
  """R7's "auto-seeded from SKU `code`", as a per-render DEFAULT rather than stored data.
247
 
 
147
  scope_key="product", table=pd.TABLE_OPS)
148
  ws = grid_events.table_workspace(ctx, allowed_pids=pids,
149
  consume_corrections=consume_corrections)
150
+ # ⭐⭐ W30-T36 β€” THE TENANT-WIDE STRATUM, MERGED OVER THE PER-USER ONE, IN THE ASSEMBLY SO
151
+ # EVERY CONSUMER SEES ONE TRUTH. `routes_grid`'s /workspace route serves
152
+ # `workspace["overlays"] = g["ws"].get("overlays")` verbatim, so doing it here means that door
153
+ # carries shared values with no change in a file this lane does not own.
154
+ #
155
+ # ⚠ SAFE TO MUTATE, and that was checked rather than assumed: `table_workspace` reads through
156
+ # `store.get`, which deep-copies, so `ws` is a detached copy and nothing writes it back. A
157
+ # shared value can therefore never leak INTO the per-user bucket by way of this merge.
158
+ # ⚠ SHARED WINS per key β€” these are contract columns, so the tenant-wide value is the answer
159
+ # and any per-user leftover under the same key is stale by construction.
160
+ _shared = pd.shared_cells(pids)
161
+ if _shared:
162
+ _ov = dict(ws.get("overlays") or {})
163
+ for _pid, _cells in _shared.items():
164
+ _ov[_pid] = {**(_ov.get(_pid) or {}), **_cells}
165
+ ws["overlays"] = _ov
166
  workspace, fields, views, lists = aios_grid.workspace_wire(
167
  ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
168
  fields_base=fields_base)
 
194
  import aios_grid
195
 
196
  g = product_assembly(session)
197
+ rows = aios_grid.rows_from_pool(
198
+ g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
199
+ rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"])
200
  return {"fields": g["fields"], "rows": rows,
201
  "today": g["today"], "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
202
  "identity": {"pid": "pid", "businessKey": "code"},
 
238
 
239
  # What actually landed, read back from the PRODUCT bucket rather than echoed from the
240
  # request: a refused key or a truncated value must not be reported as accepted.
241
+ # ⚠ W30-T36 β€” AND FROM THE SHARED BUCKET TOO. The four shared columns are written to
242
+ # `<TABLE_KEY>__shared` by `_ProductTableStore.patch_overlay`, so a read-back that consulted
243
+ # only the per-user stratum would find nothing and report every accepted shared write as
244
+ # `refused` β€” a correct write reported as a failure, which is the read-path-cannot-witness-
245
+ # the-write-path shape ([[read-path-cannot-witness-write-path]]).
246
  stored = (grid_events.table_workspace(ctx, allowed_pids=None)
247
  .get("overlays") or {}).get(str(pid)) or {}
248
+ stored = {**stored, **((pd.shared_cells([pid]).get(str(pid))) or {})}
249
  accepted = {k: stored.get(k) for k in updates if k in stored}
250
  refused = sorted(k for k in updates
251
  if k not in accepted or stored.get(k) != str(updates[k]))
 
264
  PRODUCT_IMAGE_KEY = "image"
265
 
266
 
267
+ def _seed_shared(rows, rows_src, fields):
268
+ """W30-T36 β€” the supplier master as a per-render DEFAULT for the four SHARED columns.
269
+
270
+ Exactly `_seed_image`'s shape and for the same reasons: no migration, and nothing frozen. An
271
+ untouched grid renders byte-identically to the read-only version it replaces β€” including
272
+ `supplier: "(none)"`, which the pool has always stamped β€” and **empty means "no override"**,
273
+ so clearing a cell restores the mastersheet value rather than blanking the column.
274
+
275
+ β›”β›” GATED PER KEY ON THE SERVED FIELD LIST, AND HERE THAT IS A PERMISSION WALL RATHER THAN
276
+ TIDINESS. `_seed_image`'s docstring predicted this failure in writing β€” *"An unconditional seed
277
+ would put the key straight back onto every row AFTER that strip… the value here is the visible
278
+ `code`, so nothing new escapes today; the contract is the point, and a future non-code default
279
+ would escape."* **`first_cost` IS that future non-code default**: a money number, and
280
+ `product_assembly` strips a hidden field from BOTH wires precisely so a restricted reader
281
+ cannot read it off the row payload. An ungated seed would re-attach it after the strip.
282
+ (Belt and braces, deliberately: `perm_scope.strip_row` has already removed the key from
283
+ `rows_src` too, so the pool row cannot supply it either.)
284
+
285
+ ⚠ JOINED ON `pid`, never on position: `rows_from_pool` iterates the pool, but nothing in the
286
+ contract promises the two lists stay index-aligned, and an off-by-one here would put one SKU's
287
+ supplier on another SKU's row β€” a wrong answer that looks entirely plausible.
288
+ """
289
+ import modules.product_data as pd
290
+
291
+ served = {f.get("key") for f in (fields or ())}
292
+ keys = [k for k in pd.SHARED_KEYS() if k in served]
293
+ if not keys:
294
+ return rows
295
+ by_pid = {r.get("pid"): r for r in (rows_src or ())}
296
+ for row in rows:
297
+ src = by_pid.get(row.get("pid")) or {}
298
+ for key in keys:
299
+ # A non-empty stored value WINS; only an empty cell is seeded. Blank is therefore
300
+ # "no override" rather than "blank", which is the documented meaning `_seed_image`
301
+ # already gives this column family and the only one that keeps the master reachable
302
+ # after a mistaken edit.
303
+ if row.get(key) in (None, ""):
304
+ row[key] = src.get(key)
305
+ return rows
306
+
307
+
308
  def _seed_image(rows, fields):
309
  """R7's "auto-seeded from SKU `code`", as a per-render DEFAULT rather than stored data.
310
 
api/routes_tables.py CHANGED
@@ -166,6 +166,27 @@ def scoped_pool(session: Session, table_key: str):
166
  defn = _defn_or_refuse(session, table_key)
167
  fields_base = [dict(f) for f in (defn.get("fields") or [])]
168
  field_keys = {f["key"] for f in fields_base}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  rows_src = []
170
  for rid, row in (defn.get("rows") or {}).items():
171
  if not str(rid).isdigit():
@@ -180,6 +201,144 @@ def scoped_pool(session: Session, table_key: str):
180
  return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn
181
 
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  def ut_assembly(session: Session, table_key: str, storage_key: str = "",
184
  consume_corrections: bool = True):
185
  """The user-table mirror of `grid_assembly` / `product_assembly` β€” SAME g-dict keys, so
@@ -612,10 +771,26 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
612
  # so a tenant using `json` for a short config sees no change at all.
613
  rows = aios_grid.rows_from_pool(
614
  g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  return {"fields": g["fields"], "rows": rows, "today": g["today"],
 
 
616
  "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
617
  "identity": {"pid": "pid"},
618
  "scope": {"table": table_key, "rowCount": len(rows)},
 
619
  "recordsMutable": _ut().records_mutable(table_key, st=session.runtime)}
620
 
621
 
 
166
  defn = _defn_or_refuse(session, table_key)
167
  fields_base = [dict(f) for f in (defn.get("fields") or [])]
168
  field_keys = {f["key"] for f in fields_base}
169
+ # ⭐⭐ W30-T31 / D-87 β€” A READ-THROUGH DATABASE HAS NO ROWS HERE, SO THEY COME FROM THE MIRROR.
170
+ #
171
+ # This is the one function that turns "what is stored" into "what this session may see", which
172
+ # is exactly why the read-through arm belongs HERE and nowhere else: the rows route, the events
173
+ # route, the comments wall and the assembly all reach rows through it, so they all convert
174
+ # together or not at all. β›” Reading `defn["rows"]` for such a table would find `{}` and serve
175
+ # an EMPTY GRID β€” correct-looking, wrong, and silent.
176
+ # ⚠ The wall above has already run. This adds no scope of its own and takes none away.
177
+ if not _ut().materialises(table_key, st=session.runtime, defn=defn):
178
+ import routes_odoo_tables
179
+ try:
180
+ rows_src = [r for r in routes_odoo_tables.whole_pool(table_key)]
181
+ except routes_odoo_tables.TooBigToMaterialise as e:
182
+ # R6's second sentence: a limit that cannot be met is a SENTENCE, never a short grid.
183
+ raise err(409, "window_required", str(e))
184
+ except RuntimeError as e:
185
+ raise err(503, "store_not_ready", str(e))
186
+ rows_src = [{k: v for k, v in r.items() if k in field_keys or k == "pid"}
187
+ for r in rows_src]
188
+ rows_src.sort(key=lambda r: r["pid"])
189
+ return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn
190
  rows_src = []
191
  for rid, row in (defn.get("rows") or {}).items():
192
  if not str(rid).isdigit():
 
201
  return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn
202
 
203
 
204
+ @router.patch("/tables/{table_key}/shared/{pid}")
205
+ def patch_shared_cell(table_key: str, pid: int, body: dict = Body(default=None),
206
+ session: Session = Depends(require_session)):
207
+ """Write a cell into the TENANT-WIDE overlay β€” the product door `core/shared_overlay.py` has
208
+ been waiting for since it shipped (W29-T62, wave 30 T28).
209
+
210
+ ⭐ WHY A SEPARATE STRATUM AT ALL, restated because it is the whole feature and it is not
211
+ "sharing would be nice": a user-created column and its values live PER USER, so a shared view
212
+ filtering on one names a column other accounts do not have β€” and an unknown column is an
213
+ INACTIVE condition in the tri-state engine, which IGNORES it and therefore WIDENS. The buy
214
+ list would silently show the whole catalogue to everyone but its author. A column whose value
215
+ is the same for every reader is the precondition for editing it at all.
216
+
217
+ β›” THE WALL IS `_defn_or_refuse`, AND THE STRATUM IS NOT ONE. `shared_overlay` refuses no
218
+ reader and no writer by design; "may this session open this surface" is answered HERE, where
219
+ the session is. Do not push the question down there.
220
+ ⚠ A tenant-wide write is not a private one: every account that may open this database sees it.
221
+ That is the point, and it is why this door declares the column too β€” a value with no
222
+ definition is a cell nobody can find.
223
+ """
224
+ body = body if isinstance(body, dict) else {}
225
+ key = str(body.get("field") or "").strip()
226
+ if not key:
227
+ raise err(400, "bad_request", "a field key is required")
228
+ _defn_or_refuse(session, table_key)
229
+ from core import shared_overlay
230
+ if not shared_overlay.is_shared(table_key, key, st=session.runtime):
231
+ shared_overlay.put_field(table_key, key, {
232
+ "key": key, "label": str(body.get("label") or key), "source": "overlay",
233
+ "type": str(body.get("type") or "text"), "shared": True,
234
+ "createdBy": session.uname}, st=session.runtime)
235
+ try:
236
+ # ⚠ `put_cell`, not `put_cells` β€” this door writes exactly ONE cell, and the singular is
237
+ # the API that says so. It delegates to the plural, so both stay reachable through the one
238
+ # caller; before this, the singular had no caller at all and `verify_reachability` LENS 2
239
+ # named it (the same lens that found `drop_field` had no door either).
240
+ stored = {key: shared_overlay.put_cell(table_key, pid, key, body.get("value"),
241
+ st=session.runtime)}
242
+ except ValueError as e:
243
+ # A non-scalar RAISES in the stratum rather than being dropped; relay it as the answer.
244
+ raise err(400, "bad_value", str(e))
245
+ return {"ok": True, "pid": pid, "cells": stored,
246
+ "fields": list(shared_overlay.fields(table_key, st=session.runtime))}
247
+
248
+
249
+ @router.delete("/tables/{table_key}/shared/fields/{field_key}")
250
+ def delete_shared_field(table_key: str, field_key: str,
251
+ session: Session = Depends(require_session)):
252
+ """Remove a TENANT-WIDE column and every value in it.
253
+
254
+ β›” WHY THIS EXISTS AT ALL, said plainly: W30-T28 shipped the door that CREATES a shared column
255
+ and none that removes one, so a column anybody added was permanent for the whole tenant. The
256
+ reachability gate found it from the other end β€” `shared_overlay.drop_field` was complete,
257
+ correct, gated, and callable by nothing but its own gate ([[reachable-is-not-the-same-as-built]]).
258
+
259
+ β›” AND THIS ONE IS CREATOR-OR-ADMIN, WHICH THE WRITE DOOR IS NOT. Writing a cell changes a
260
+ value; dropping the column deletes that value for EVERY account at once, so it is the
261
+ destructive-op wall this repo already uses for a database delete β€” not `editRole`, which
262
+ governs renaming and is not a value wall ([[schema-role-is-not-a-value-wall]]).
263
+ ⚠ `createdBy` is stamped by the write door above; a column stored before that stamp existed
264
+ is admin-only, which is the safe direction.
265
+ """
266
+ _defn_or_refuse(session, table_key)
267
+ from core import shared_overlay
268
+ defn = (shared_overlay.fields(table_key, st=session.runtime) or {}).get(str(field_key))
269
+ if not defn:
270
+ raise err(404, "unknown_field", "that column is not a shared column on this database")
271
+ owner = str(defn.get("createdBy") or "")
272
+ if not session.admin and owner != session.uname:
273
+ raise err(403, "forbidden",
274
+ f"a tenant-wide column can be removed by its creator or an admin β€” this one "
275
+ f"was added by {owner or 'somebody else'}, and dropping it would delete the "
276
+ f"value for every account")
277
+ dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
278
+ return {"ok": True, "dropped": bool(dropped),
279
+ "fields": list(shared_overlay.fields(table_key, st=session.runtime))}
280
+
281
+
282
+ def scoped_pids(session: Session, table_key: str):
283
+ """`(pids, fields_base, defn)` β€” the SAME wall and the SAME row set as `scoped_pool`, without
284
+ building a row.
285
+
286
+ ⭐⭐ WAVE 30 / W30-T30 β€” THIS IS WHY ONE HIDE-FIELDS CHECKBOX WAS EXPENSIVE. A view write
287
+ (`view_upsert`) reaches `grid_events_route`, which built a FULL assembly purely to validate
288
+ it: `scoped_pool` allocates a fresh dict per row and then sorts them β€” ~33k order rows, on
289
+ every toggle β€” and the six keys the events route actually reads from that assembly
290
+ (`fields`, `pids`, `measures`, `measure_sets`, `lists`, `views`) contain no row at all.
291
+ `rows_src` was computed and discarded.
292
+
293
+ β›” THE PID SET IS IDENTICAL, NOT MERELY EQUIVALENT, and that is the whole safety argument:
294
+ `scoped_pool` derives its pids as `frozenset(r["pid"] for r in rows_src)` over exactly the
295
+ row ids that pass `str(rid).isdigit()`, which is this comprehension with a dict build in the
296
+ middle. The row WALL is unchanged β€” a narrower or wider set here would be a permission
297
+ change, and this is a performance change.
298
+
299
+ ⚠ It does NOT make the write cheap on its own: `_defn_or_refuse` still costs two whole-document
300
+ deep copies (`ut.get` then `may_open`), which is D-87 and W30-T31. This removes the row pass.
301
+ """
302
+ defn = _defn_or_refuse(session, table_key)
303
+ fields_base = [dict(f) for f in (defn.get("fields") or [])]
304
+ # ⚠ W30-T31: on a read-through database the stored `rows` is `{}` by construction, so the
305
+ # comprehension below would answer an EMPTY pid set β€” and the promise this function makes is
306
+ # that its set is IDENTICAL to `scoped_pool`'s, not merely cheaper. It defers to that function
307
+ # rather than growing a second idea of the row set; the saving this ticket bought stays on
308
+ # every materialised table, which is all of the big ones.
309
+ if not _ut().materialises(table_key, st=session.runtime, defn=defn):
310
+ pids, _rows, fields_base, defn = scoped_pool(session, table_key)
311
+ return pids, fields_base, defn
312
+ pids = frozenset(int(rid) for rid in (defn.get("rows") or {}) if str(rid).isdigit())
313
+ return pids, fields_base, defn
314
+
315
+
316
+ def ut_write_ctx(session: Session, table_key: str):
317
+ """The g-dict a WRITE needs β€” same keys as `ut_assembly`, no rows.
318
+
319
+ Returns the six keys `routes_grid.grid_events_route` reads, so the events route consumes this
320
+ or a full assembly interchangeably. `rows_src` is `[]` on purpose rather than absent: a caller
321
+ that starts needing rows should fail on an empty list it can see, not on a KeyError.
322
+ """
323
+ import aios_grid
324
+ from core import grid_events
325
+
326
+ pids, fields_base, defn = scoped_pids(session, table_key)
327
+ ctx = grid_events.EventCtx(
328
+ uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=frozenset(),
329
+ admin=session.admin, fallback_ws=None, seen_ids={}, st=session.runtime,
330
+ scope_key=table_key, table=_ops(session, table_key))
331
+ ws = grid_events.table_workspace(ctx, allowed_pids=pids, consume_corrections=False)
332
+ workspace, fields, views, lists = aios_grid.workspace_wire(
333
+ ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
334
+ fields_base=fields_base)
335
+ return {"rows_src": [], "pids": pids, "ws": ws, "workspace": workspace,
336
+ "fields": fields, "views": views, "lists": lists,
337
+ "derived": aios_grid.cohort_cells(lists),
338
+ "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
339
+ "defn": defn}
340
+
341
+
342
  def ut_assembly(session: Session, table_key: str, storage_key: str = "",
343
  consume_corrections: bool = True):
344
  """The user-table mirror of `grid_assembly` / `product_assembly` β€” SAME g-dict keys, so
 
771
  # so a tenant using `json` for a short config sees no change at all.
772
  rows = aios_grid.rows_from_pool(
773
  g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
774
+ # ⭐ R6's SECOND SENTENCE, ON THE WIRE (W30-T29). *"if there is lag or it can't be done, you
775
+ # need to explicitly tell me why and recommend a fix."* A ceiling that still applies to this
776
+ # database says so here, with its cause and the recommendation, rather than waiting to be
777
+ # discovered as a refused paste. `None` for a connected source, and an EMPTY LIST is the
778
+ # honest answer for a table nothing limits β€” never an absent key, which a client cannot tell
779
+ # apart from an older server.
780
+ _report = _ut().limit_report(table_key, st=session.runtime)
781
+ # ⭐ C4 / D-138 β€” THE DOCUMENTS PRODUCER. Absent since EXIT-6 deleted `app.py`, which was the
782
+ # only thing that ever set this key; the write door never stopped working and every client
783
+ # half is complete, but all six `onDoc*` handlers read `payload?.docs ? … : undefined`, so a
784
+ # missing key has been silently switching the feature off. β›” ONE shared serialiser with the
785
+ # customer scope β€” `grid_events.docs_for` β€” never a twin here.
786
+ from core import grid_events as _ge
787
  return {"fields": g["fields"], "rows": rows, "today": g["today"],
788
+ "docs": _ge.docs_for(g["pids"], scope_key=table_key, uname=session.uname,
789
+ admin=session.admin, st=session.runtime),
790
  "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
791
  "identity": {"pid": "pid"},
792
  "scope": {"table": table_key, "rowCount": len(rows)},
793
+ "limits": [_report] if _report else [],
794
  "recordsMutable": _ut().records_mutable(table_key, st=session.runtime)}
795
 
796
 
platform/aios_grid.py CHANGED
@@ -1769,8 +1769,26 @@ def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
1769
 
1770
 
1771
  def _default_view_config(fields):
1772
- shown = [field["key"] for field in fields
1773
- if field.get("default") is not False or field["source"] == "overlay"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1774
  hidden = [field["key"] for field in fields if field["key"] not in shown]
1775
  return {
1776
  # `filters` is the ROOT of the filter tree: leaf conditions and/or nested
 
1769
 
1770
 
1771
  def _default_view_config(fields):
1772
+ # ⭐⭐ W30-T41's SERVER HALF (F's ask F-1, answered by D β€” this file is D's fence).
1773
+ #
1774
+ # β›” THE SECOND ARM USED TO BE `or field["source"] == "overlay"`, AND IT SWALLOWED THE FIRST
1775
+ # ONE FOR EVERY CONNECTED COLUMN. `user_tables._clean_field` stamps `source: "overlay"` on
1776
+ # every `ut_` field, so on an Odoo grid the arm was true for ALL of them and `default: False`
1777
+ # meant nothing: `odoo_id`, `state`, `customer_link` and `partner_id` opened SHOWN however
1778
+ # they were declared. The exception had become the rule ([[fallback-that-became-the-rule]]),
1779
+ # and it is the same predicate `useGridColumns.isDefaultVisible` carried on the client.
1780
+ #
1781
+ # ⚠ AND THE TWO HALVES MUST MOVE TOGETHER, which is why this is not cosmetic. `CustomerGrid`
1782
+ # compares the stored view against its own `defaultViewConfig` by JSON equality; with the
1783
+ # client fixed (T41) and this left alone, the system view would differ from the client's
1784
+ # default on every render β€” a view that looks permanently dirty and autosaves forever, which
1785
+ # is the failure `verify_filter_engine`'s key-ORDER check exists to prevent, one level down.
1786
+ #
1787
+ # ⚠ A USER-CREATED COLUMN IS UNAFFECTED, and that is why the fix is a DELETION rather than a
1788
+ # carve-out for the four Odoo keys: it carries no `default` key at all, so `is not False`
1789
+ # keeps it visible. On the main Customer grid exactly one field moves β€” `notes`, which asks
1790
+ # to be hidden in its own declaration and was being shown against it.
1791
+ shown = [field["key"] for field in fields if field.get("default") is not False]
1792
  hidden = [field["key"] for field in fields if field["key"] not in shown]
1793
  return {
1794
  # `filters` is the ROOT of the filter tree: leaf conditions and/or nested
platform/aios_grid_fields.json CHANGED
@@ -1,423 +1,464 @@
1
- {
2
- "_comment": "CANONICAL field contract for the AIOS Airtable-style grid \u2014 the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description \u2014 every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else \u2014 no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` \u2014 not `is empty` \u2014 finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED \u2014 LTM's replacement is a creatable Sales measure column (the demo column IS Sales \u00b7 the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED \u2014 every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
3
- "fields": [
4
- {
5
- "key": "customer",
6
- "label": "Customer",
7
- "type": "text",
8
- "source": "odoo",
9
- "pinned": true,
10
- "default": true,
11
- "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
12
- },
13
- {
14
- "key": "odoo_status",
15
- "label": "Odoo record",
16
- "type": "status",
17
- "source": "odoo",
18
- "default": false,
19
- "description": "Whether this customer still exists in Odoo. Archived means deleted there."
20
- },
21
- {
22
- "key": "agent",
23
- "label": "Agent",
24
- "type": "text",
25
- "source": "odoo",
26
- "default": true,
27
- "description": "The sales agent who owns this account."
28
- },
29
- {
30
- "key": "dba",
31
- "label": "DBA",
32
- "type": "select",
33
- "source": "odoo",
34
- "default": false,
35
- "options": [
36
- "Fisch",
37
- "Royal",
38
- "Both"
39
- ],
40
- "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
41
- },
42
- {
43
- "key": "salesperson",
44
- "label": "Salesperson",
45
- "type": "text",
46
- "source": "odoo",
47
- "default": false,
48
- "description": "Who keyed in most of this customer's orders \u2014 not the Agent, who owns the account."
49
- },
50
- {
51
- "key": "city",
52
- "label": "City",
53
- "type": "text",
54
- "source": "odoo",
55
- "default": true,
56
- "description": "City on the customer's Odoo address."
57
- },
58
- {
59
- "key": "state",
60
- "label": "State",
61
- "type": "text",
62
- "source": "odoo",
63
- "default": true,
64
- "description": "State or province on the customer's Odoo address."
65
- },
66
- {
67
- "key": "country",
68
- "label": "Country",
69
- "type": "text",
70
- "source": "odoo",
71
- "default": false,
72
- "description": "Country on the customer's Odoo address."
73
- },
74
- {
75
- "key": "zip",
76
- "label": "ZIP",
77
- "type": "text",
78
- "source": "odoo",
79
- "default": false,
80
- "description": "Postal code on the customer's Odoo address."
81
- },
82
- {
83
- "key": "customer_since",
84
- "label": "Customer since",
85
- "type": "date",
86
- "source": "odoo",
87
- "default": false,
88
- "description": "When this customer was first set up in Odoo."
89
- },
90
- {
91
- "key": "tags",
92
- "label": "Tags",
93
- "type": "text",
94
- "source": "odoo",
95
- "default": false,
96
- "description": "Odoo labels on this customer, comma-separated."
97
- },
98
- {
99
- "key": "pricelist",
100
- "label": "Price list",
101
- "type": "text",
102
- "source": "odoo",
103
- "default": false,
104
- "description": "The price list this customer buys on."
105
- },
106
- {
107
- "key": "payment_terms",
108
- "label": "Payment terms",
109
- "type": "text",
110
- "source": "odoo",
111
- "default": false,
112
- "description": "Payment terms on this customer's account \u2014 Net 30, for example."
113
- },
114
- {
115
- "key": "last_order",
116
- "label": "Last order",
117
- "type": "date",
118
- "source": "odoo",
119
- "default": true,
120
- "description": "Date of the most recent confirmed order."
121
- },
122
- {
123
- "key": "overdue_days",
124
- "label": "Overdue days",
125
- "type": "int",
126
- "source": "odoo",
127
- "default": true,
128
- "description": "How many days late this customer is running against their own usual ordering rhythm."
129
- },
130
- {
131
- "_note": "filterable:false \u2014 DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule \u2014 see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
132
- "key": "est_missed",
133
- "label": "Est. missed $",
134
- "type": "currency",
135
- "source": "odoo",
136
- "default": true,
137
- "agg": "sum",
138
- "filterable": false,
139
- "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
140
- },
141
- {
142
- "_note": "wave 21 R1 \u2014 KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary \u2014 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
143
- "key": "ar_open",
144
- "label": "AR current $",
145
- "type": "currency",
146
- "source": "odoo",
147
- "default": false,
148
- "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
149
- },
150
- {
151
- "key": "ar_overdue",
152
- "label": "AR overdue $",
153
- "type": "currency",
154
- "source": "odoo",
155
- "default": false,
156
- "description": "Invoiced money past due \u2014 same basis as the Collections page."
157
- },
158
- {
159
- "_note": "wave 21 R1 \u2014 the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie \u2014 no second oracle.",
160
- "key": "ar_outstanding",
161
- "label": "AR outstanding $",
162
- "type": "currency",
163
- "source": "odoo",
164
- "default": false,
165
- "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
166
- },
167
- {
168
- "key": "ar_exposure",
169
- "label": "Credit exposure $",
170
- "type": "currency",
171
- "source": "odoo",
172
- "default": false,
173
- "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
174
- },
175
- {
176
- "key": "ar_aged_1_30",
177
- "label": "1-30 days $",
178
- "type": "currency",
179
- "source": "odoo",
180
- "default": false,
181
- "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
182
- },
183
- {
184
- "key": "ar_aged_31_60",
185
- "label": "31-60 days $",
186
- "type": "currency",
187
- "source": "odoo",
188
- "default": false,
189
- "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
190
- },
191
- {
192
- "key": "ar_aged_61_90",
193
- "label": "61-90 days $",
194
- "type": "currency",
195
- "source": "odoo",
196
- "default": false,
197
- "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
198
- },
199
- {
200
- "key": "ar_aged_90_plus",
201
- "label": "90+ days $",
202
- "type": "currency",
203
- "source": "odoo",
204
- "default": false,
205
- "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
206
- },
207
- {
208
- "key": "days_to_pay",
209
- "label": "Days to pay",
210
- "type": "int",
211
- "source": "odoo",
212
- "default": false,
213
- "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
214
- },
215
- {
216
- "key": "top_category",
217
- "label": "Top category",
218
- "type": "text",
219
- "source": "odoo",
220
- "default": false,
221
- "description": "The category this customer spent the most on in the last 12 months."
222
- },
223
- {
224
- "key": "top_category_pct",
225
- "label": "Top category %",
226
- "type": "pct",
227
- "source": "odoo",
228
- "default": false,
229
- "description": "Share of last-12-months spend that went to the top category."
230
- },
231
- {
232
- "key": "sku_count",
233
- "label": "SKUs bought",
234
- "type": "int",
235
- "source": "odoo",
236
- "default": false,
237
- "description": "Distinct products bought in the last 12 months."
238
- },
239
- {
240
- "key": "top_sku",
241
- "label": "Top SKU",
242
- "type": "text",
243
- "source": "odoo",
244
- "default": false,
245
- "description": "The product this customer spent the most on in the last 12 months."
246
- },
247
- {
248
- "key": "days_since",
249
- "label": "Days since order",
250
- "type": "int",
251
- "source": "odoo",
252
- "default": false,
253
- "description": "Days since the last confirmed order."
254
- },
255
- {
256
- "key": "typical_gap_days",
257
- "label": "Typical gap days",
258
- "type": "int",
259
- "source": "odoo",
260
- "default": false,
261
- "description": "Days this customer usually goes between orders, from their own history."
262
- },
263
- {
264
- "key": "notes",
265
- "label": "Notes",
266
- "type": "text",
267
- "source": "overlay",
268
- "default": false,
269
- "description": "Your notes on this customer. Saved in this app only, visible only to you."
270
- }
271
- ],
272
- "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
273
- "_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
274
- "product_data": {
275
- "identity": "pid",
276
- "business_key": "code",
277
- "fields": [
278
- {
279
- "key": "code",
280
- "label": "SKU",
281
- "type": "text",
282
- "source": "odoo",
283
- "pinned": true,
284
- "default": true,
285
- "description": "The SKU code \u2014 the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
286
- },
287
- {
288
- "key": "product",
289
- "label": "Product",
290
- "type": "text",
291
- "source": "odoo",
292
- "default": true,
293
- "description": "Product name as it appears in Odoo."
294
- },
295
- {
296
- "key": "category",
297
- "label": "Category",
298
- "type": "select",
299
- "source": "odoo",
300
- "default": true,
301
- "description": "Product category; '(uncategorized)' when Odoo carries none."
302
- },
303
- {
304
- "key": "supplier",
305
- "label": "Supplier",
306
- "type": "text",
307
- "source": "odoo",
308
- "default": true,
309
- "description": "Who makes it, from the inventory mastersheet. Not an Odoo field."
310
- },
311
- {
312
- "key": "origin_country",
313
- "label": "Country",
314
- "type": "text",
315
- "source": "odoo",
316
- "default": false,
317
- "description": "Country of origin, from the inventory mastersheet."
318
- },
319
- {
320
- "key": "lead_days",
321
- "label": "Lead time (days)",
322
- "type": "int",
323
- "source": "odoo",
324
- "default": true,
325
- "description": "Order-to-arrival days for this supplier. Drives the buy signal."
326
- },
327
- {
328
- "key": "first_cost",
329
- "label": "First cost",
330
- "type": "currency",
331
- "source": "odoo",
332
- "default": false,
333
- "description": "Quoted unit cost at origin, before freight and duty."
334
- },
335
- {
336
- "key": "rev_ytd",
337
- "label": "Revenue YTD",
338
- "type": "currency",
339
- "source": "odoo",
340
- "default": true,
341
- "description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
342
- },
343
- {
344
- "key": "rev_ly",
345
- "label": "Revenue LY",
346
- "type": "currency",
347
- "source": "odoo",
348
- "description": "Same period last year \u2014 seasonal wholesale compares like for like."
349
- },
350
- {
351
- "key": "yoy_pct",
352
- "label": "YoY %",
353
- "type": "pct",
354
- "source": "odoo",
355
- "description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
356
- },
357
- {
358
- "key": "qty_ytd",
359
- "label": "Units YTD",
360
- "type": "int",
361
- "source": "odoo",
362
- "description": "Units sold year to date."
363
- },
364
- {
365
- "key": "orders_ytd",
366
- "label": "Orders YTD",
367
- "type": "int",
368
- "source": "odoo",
369
- "description": "Distinct orders containing this SKU, year to date."
370
- },
371
- {
372
- "key": "on_hand",
373
- "label": "On hand",
374
- "type": "int",
375
- "source": "odoo",
376
- "description": "Units in stock. CONSOLIDATED \u2014 one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
377
- },
378
- {
379
- "key": "unit_cost",
380
- "label": "Unit cost",
381
- "type": "currency",
382
- "source": "odoo",
383
- "description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
384
- },
385
- {
386
- "key": "inv_value",
387
- "label": "Stock value",
388
- "type": "currency",
389
- "source": "odoo",
390
- "description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
391
- },
392
- {
393
- "key": "qty_ltm",
394
- "label": "Units LTM",
395
- "type": "int",
396
- "source": "odoo",
397
- "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
398
- },
399
- {
400
- "key": "dos",
401
- "label": "Days of supply",
402
- "type": "int",
403
- "source": "odoo",
404
- "description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
405
- },
406
- {
407
- "key": "cover_gap_d",
408
- "label": "Cover gap (days)",
409
- "type": "int",
410
- "source": "odoo",
411
- "default": false,
412
- "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
413
- },
414
- {
415
- "key": "stock_bucket",
416
- "label": "Stock status",
417
- "type": "select",
418
- "source": "odoo",
419
- "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
420
- }
421
- ]
422
- }
423
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "CANONICAL field contract for the AIOS Airtable-style grid β€” the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description β€” every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β€” no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` β€” not `is empty` β€” finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED β€” LTM's replacement is a creatable Sales measure column (the demo column IS Sales Β· the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED β€” every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
3
+ "fields": [
4
+ {
5
+ "key": "customer",
6
+ "label": "Customer",
7
+ "type": "text",
8
+ "source": "odoo",
9
+ "pinned": true,
10
+ "default": true,
11
+ "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
12
+ },
13
+ {
14
+ "key": "odoo_status",
15
+ "label": "Odoo record",
16
+ "type": "status",
17
+ "source": "odoo",
18
+ "default": false,
19
+ "description": "Whether this customer still exists in Odoo. Archived means deleted there."
20
+ },
21
+ {
22
+ "key": "agent",
23
+ "label": "Agent",
24
+ "type": "text",
25
+ "source": "odoo",
26
+ "default": true,
27
+ "description": "The sales agent who owns this account."
28
+ },
29
+ {
30
+ "key": "dba",
31
+ "label": "DBA",
32
+ "type": "select",
33
+ "source": "odoo",
34
+ "default": false,
35
+ "options": [
36
+ "Fisch",
37
+ "Royal",
38
+ "Both"
39
+ ],
40
+ "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
41
+ },
42
+ {
43
+ "key": "salesperson",
44
+ "label": "Salesperson",
45
+ "type": "text",
46
+ "source": "odoo",
47
+ "default": false,
48
+ "description": "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
49
+ },
50
+ {
51
+ "key": "street",
52
+ "label": "Street",
53
+ "type": "text",
54
+ "source": "odoo",
55
+ "default": false,
56
+ "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
57
+ },
58
+ {
59
+ "key": "street2",
60
+ "label": "Street 2",
61
+ "type": "text",
62
+ "source": "odoo",
63
+ "default": false,
64
+ "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
65
+ },
66
+ {
67
+ "key": "city",
68
+ "label": "City",
69
+ "type": "text",
70
+ "source": "odoo",
71
+ "default": true,
72
+ "description": "City on the customer's Odoo address."
73
+ },
74
+ {
75
+ "key": "state",
76
+ "label": "State",
77
+ "type": "text",
78
+ "source": "odoo",
79
+ "default": true,
80
+ "description": "State or province on the customer's Odoo address."
81
+ },
82
+ {
83
+ "key": "country",
84
+ "label": "Country",
85
+ "type": "text",
86
+ "source": "odoo",
87
+ "default": false,
88
+ "description": "Country on the customer's Odoo address."
89
+ },
90
+ {
91
+ "key": "zip",
92
+ "label": "ZIP",
93
+ "type": "text",
94
+ "source": "odoo",
95
+ "default": false,
96
+ "description": "Postal code on the customer's Odoo address."
97
+ },
98
+ {
99
+ "key": "customer_since",
100
+ "label": "Customer since",
101
+ "type": "date",
102
+ "source": "odoo",
103
+ "default": false,
104
+ "description": "When this customer was first set up in Odoo."
105
+ },
106
+ {
107
+ "key": "tags",
108
+ "label": "Tags",
109
+ "type": "text",
110
+ "source": "odoo",
111
+ "default": false,
112
+ "description": "Odoo labels on this customer, comma-separated."
113
+ },
114
+ {
115
+ "key": "pricelist",
116
+ "label": "Price list",
117
+ "type": "text",
118
+ "source": "odoo",
119
+ "default": false,
120
+ "description": "The price list this customer buys on."
121
+ },
122
+ {
123
+ "key": "payment_terms",
124
+ "label": "Payment terms",
125
+ "type": "text",
126
+ "source": "odoo",
127
+ "default": false,
128
+ "description": "Payment terms on this customer's account β€” Net 30, for example."
129
+ },
130
+ {
131
+ "key": "last_order",
132
+ "label": "Last order",
133
+ "type": "date",
134
+ "source": "odoo",
135
+ "default": true,
136
+ "description": "Date of the most recent confirmed order."
137
+ },
138
+ {
139
+ "key": "overdue_days",
140
+ "label": "Overdue days",
141
+ "type": "int",
142
+ "source": "odoo",
143
+ "default": true,
144
+ "description": "How many days late this customer is running against their own usual ordering rhythm."
145
+ },
146
+ {
147
+ "_note": "filterable:false β€” DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β€” see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
148
+ "key": "est_missed",
149
+ "label": "Est. missed $",
150
+ "type": "currency",
151
+ "source": "odoo",
152
+ "default": true,
153
+ "agg": "sum",
154
+ "filterable": false,
155
+ "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
156
+ },
157
+ {
158
+ "_note": "wave 21 R1 β€” KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β€” 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
159
+ "key": "ar_open",
160
+ "label": "AR current $",
161
+ "type": "currency",
162
+ "source": "odoo",
163
+ "default": false,
164
+ "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
165
+ },
166
+ {
167
+ "key": "ar_overdue",
168
+ "label": "AR overdue $",
169
+ "type": "currency",
170
+ "source": "odoo",
171
+ "default": false,
172
+ "description": "Invoiced money past due β€” same basis as the Collections page."
173
+ },
174
+ {
175
+ "_note": "wave 21 R1 β€” the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β€” no second oracle.",
176
+ "key": "ar_outstanding",
177
+ "label": "AR outstanding $",
178
+ "type": "currency",
179
+ "source": "odoo",
180
+ "default": false,
181
+ "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
182
+ },
183
+ {
184
+ "key": "ar_exposure",
185
+ "label": "Credit exposure $",
186
+ "type": "currency",
187
+ "source": "odoo",
188
+ "default": false,
189
+ "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
190
+ },
191
+ {
192
+ "key": "ar_aged_1_30",
193
+ "label": "1-30 days $",
194
+ "type": "currency",
195
+ "source": "odoo",
196
+ "default": false,
197
+ "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
198
+ },
199
+ {
200
+ "key": "ar_aged_31_60",
201
+ "label": "31-60 days $",
202
+ "type": "currency",
203
+ "source": "odoo",
204
+ "default": false,
205
+ "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
206
+ },
207
+ {
208
+ "key": "ar_aged_61_90",
209
+ "label": "61-90 days $",
210
+ "type": "currency",
211
+ "source": "odoo",
212
+ "default": false,
213
+ "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
214
+ },
215
+ {
216
+ "key": "ar_aged_90_plus",
217
+ "label": "90+ days $",
218
+ "type": "currency",
219
+ "source": "odoo",
220
+ "default": false,
221
+ "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
222
+ },
223
+ {
224
+ "key": "days_to_pay",
225
+ "label": "Days to pay",
226
+ "type": "int",
227
+ "source": "odoo",
228
+ "default": false,
229
+ "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
230
+ },
231
+ {
232
+ "key": "top_category",
233
+ "label": "Top category",
234
+ "type": "text",
235
+ "source": "odoo",
236
+ "default": false,
237
+ "description": "The category this customer spent the most on in the last 12 months."
238
+ },
239
+ {
240
+ "key": "top_category_pct",
241
+ "label": "Top category %",
242
+ "type": "pct",
243
+ "source": "odoo",
244
+ "default": false,
245
+ "description": "Share of last-12-months spend that went to the top category."
246
+ },
247
+ {
248
+ "key": "sku_count",
249
+ "label": "SKUs bought",
250
+ "type": "int",
251
+ "source": "odoo",
252
+ "default": false,
253
+ "description": "Distinct products bought in the last 12 months."
254
+ },
255
+ {
256
+ "key": "top_sku",
257
+ "label": "Top SKU",
258
+ "type": "text",
259
+ "source": "odoo",
260
+ "default": false,
261
+ "description": "The product this customer spent the most on in the last 12 months."
262
+ },
263
+ {
264
+ "key": "days_since",
265
+ "label": "Days since order",
266
+ "type": "int",
267
+ "source": "odoo",
268
+ "default": false,
269
+ "description": "Days since the last confirmed order."
270
+ },
271
+ {
272
+ "key": "typical_gap_days",
273
+ "label": "Typical gap days",
274
+ "type": "int",
275
+ "source": "odoo",
276
+ "default": false,
277
+ "description": "Days this customer usually goes between orders, from their own history."
278
+ },
279
+ {
280
+ "key": "notes",
281
+ "label": "Notes",
282
+ "type": "text",
283
+ "source": "overlay",
284
+ "default": false,
285
+ "description": "Your notes on this customer. Saved in this app only, visible only to you."
286
+ }
287
+ ],
288
+ "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
289
+ "_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
290
+ "product_data": {
291
+ "identity": "pid",
292
+ "business_key": "code",
293
+ "fields": [
294
+ {
295
+ "key": "code",
296
+ "label": "SKU",
297
+ "type": "text",
298
+ "source": "odoo",
299
+ "pinned": true,
300
+ "default": true,
301
+ "description": "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
302
+ },
303
+ {
304
+ "key": "product",
305
+ "label": "Product",
306
+ "type": "text",
307
+ "source": "odoo",
308
+ "default": true,
309
+ "description": "Product name as it appears in Odoo."
310
+ },
311
+ {
312
+ "key": "category",
313
+ "label": "Category",
314
+ "type": "select",
315
+ "source": "odoo",
316
+ "default": true,
317
+ "description": "Product category; '(uncategorized)' when Odoo carries none."
318
+ },
319
+ {
320
+ "key": "supplier",
321
+ "label": "Supplier",
322
+ "type": "text",
323
+ "source": "overlay",
324
+ "default": true,
325
+ "description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
326
+ "shared": true
327
+ },
328
+ {
329
+ "key": "origin_country",
330
+ "label": "Country",
331
+ "type": "text",
332
+ "source": "overlay",
333
+ "default": false,
334
+ "description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
335
+ "shared": true
336
+ },
337
+ {
338
+ "key": "lead_days",
339
+ "label": "Lead time (days)",
340
+ "type": "int",
341
+ "source": "overlay",
342
+ "default": true,
343
+ "description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
344
+ "shared": true
345
+ },
346
+ {
347
+ "key": "first_cost",
348
+ "label": "First cost",
349
+ "type": "currency",
350
+ "source": "overlay",
351
+ "default": false,
352
+ "description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
353
+ "shared": true
354
+ },
355
+ {
356
+ "key": "price_fisch",
357
+ "label": "Fisch price",
358
+ "type": "currency",
359
+ "source": "odoo",
360
+ "description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
361
+ },
362
+ {
363
+ "key": "price_royal_1",
364
+ "label": "Royal 1 price",
365
+ "type": "currency",
366
+ "source": "odoo",
367
+ "description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
368
+ },
369
+ {
370
+ "key": "price_royal_2",
371
+ "label": "Royal 2 price",
372
+ "type": "currency",
373
+ "source": "odoo",
374
+ "description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
375
+ },
376
+ {
377
+ "key": "rev_ytd",
378
+ "label": "Revenue YTD",
379
+ "type": "currency",
380
+ "source": "odoo",
381
+ "default": true,
382
+ "description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
383
+ },
384
+ {
385
+ "key": "rev_ly",
386
+ "label": "Revenue LY",
387
+ "type": "currency",
388
+ "source": "odoo",
389
+ "description": "Same period last year β€” seasonal wholesale compares like for like."
390
+ },
391
+ {
392
+ "key": "yoy_pct",
393
+ "label": "YoY %",
394
+ "type": "pct",
395
+ "source": "odoo",
396
+ "description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
397
+ },
398
+ {
399
+ "key": "qty_ytd",
400
+ "label": "Units YTD",
401
+ "type": "int",
402
+ "source": "odoo",
403
+ "description": "Units sold year to date."
404
+ },
405
+ {
406
+ "key": "orders_ytd",
407
+ "label": "Orders YTD",
408
+ "type": "int",
409
+ "source": "odoo",
410
+ "description": "Distinct orders containing this SKU, year to date."
411
+ },
412
+ {
413
+ "key": "on_hand",
414
+ "label": "On hand",
415
+ "type": "int",
416
+ "source": "odoo",
417
+ "description": "Units in stock. CONSOLIDATED β€” one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
418
+ },
419
+ {
420
+ "key": "unit_cost",
421
+ "label": "Unit cost",
422
+ "type": "currency",
423
+ "source": "odoo",
424
+ "description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
425
+ },
426
+ {
427
+ "key": "inv_value",
428
+ "label": "Stock value",
429
+ "type": "currency",
430
+ "source": "odoo",
431
+ "description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
432
+ },
433
+ {
434
+ "key": "qty_ltm",
435
+ "label": "Units LTM",
436
+ "type": "int",
437
+ "source": "odoo",
438
+ "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
439
+ },
440
+ {
441
+ "key": "dos",
442
+ "label": "Days of supply",
443
+ "type": "int",
444
+ "source": "odoo",
445
+ "description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
446
+ },
447
+ {
448
+ "key": "cover_gap_d",
449
+ "label": "Cover gap (days)",
450
+ "type": "int",
451
+ "source": "odoo",
452
+ "default": false,
453
+ "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
454
+ },
455
+ {
456
+ "key": "stock_bucket",
457
+ "label": "Stock status",
458
+ "type": "select",
459
+ "source": "odoo",
460
+ "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
461
+ }
462
+ ]
463
+ }
464
+ }
platform/core/grid_events.py CHANGED
@@ -255,6 +255,64 @@ def _cohorts(ctx):
255
  return cohort_mod.scoped(ctx.scope_key)
256
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  def _docs_key(ctx):
259
  """The DOCUMENTS bucket this ctx operates on β€” `_cohorts`'s sibling (wave 19, item 12's
260
  silent third case).
 
255
  return cohort_mod.scoped(ctx.scope_key)
256
 
257
 
258
+ #: The keys a stored document entry may put on the wire. β›” `path` IS NOT ONE OF THEM β€” it is the
259
+ #: byte address inside the tenant's repo, of no use to a browser and a needless disclosure of the
260
+ #: store's layout. `canDelete` is not stored either: it is a fact about the READER, computed per
261
+ #: request below, so one person's answer can never be cached into another's.
262
+ _DOC_WIRE_KEYS = ('id', 'name', 'mime', 'size', 'by', 'ts')
263
+
264
+
265
+ def docs_for(pids, scope_key='customer', uname='', admin=False, st=None):
266
+ """`{"<pid>": [CustomerDoc, ...]}` for a SCOPED row set β€” the producer `payload.docs` lost.
267
+
268
+ ⭐⭐ WAVE 30 / CONTRACT C4 (D-138). The write door has been live and UNREACHABLE since EXIT-6:
269
+ `doc_add`/`doc_fetch`/`doc_delete` all still work, and the comment inside that handler says
270
+ exactly why nobody noticed β€” *"the React shell cannot reach it yet (`payload.docs` is set only
271
+ by app.py)"*. `app.py` was deleted, and the producer went with it. Every client half survived:
272
+ `RecordDetail`, `SwipeView` and `Documents` are complete, and all six `onDoc*` handlers in
273
+ `CustomerGrid.tsx` are written as `payload?.docs ? … : undefined`, so an ABSENT `docs` key is
274
+ what has been switching the whole feature off. An empty dict is truthy and turns it on with an
275
+ honest empty state.
276
+ β›” THIS IS THE SIXTH INSTANCE OF THE REACHABILITY CLASS and the one `verify_reachability.py`
277
+ cannot see β€” a payload key assembled through helpers and dict merges is not a text-matchable
278
+ literal ([[reachable-is-not-the-same-as-built]], [[artifact-with-no-importer]]).
279
+
280
+ β›” ONE READER, NOT A MATCHING PAIR. `routes_tables` (ut_*) and `routes_customers` (the customer
281
+ scope) both call THIS β€” they do not each serialise. Two surfaces answering one question
282
+ separately is how the same defect got reintroduced in the opposite direction inside a single
283
+ commit ([[one-question-two-normalizers]]).
284
+
285
+ ⚠ `pids` IS REQUIRED and is the row set the CALLER has already scoped β€” `shared_overlay.cells`'
286
+ argument, for the same reason: there is no "every document in the tenant" call to reach for.
287
+ A record with no documents gets `[]`, never a missing key, so a client can tell "none" from
288
+ "not served".
289
+ """
290
+ wanted = {str(int(p)) for p in (pids or [])}
291
+ if not wanted:
292
+ return {}
293
+ scope = str(scope_key or 'customer').strip().lower()
294
+ key = _DOCS_KEY if scope in cohort_mod.LEGACY_SCOPES else f'{scope}_docs'
295
+ try:
296
+ stored = (st if st is not None else store).get(key) or {}
297
+ except Exception: # noqa: BLE001
298
+ stored = {} # a display read degrades; it never breaks the grid
299
+ out = {}
300
+ for pid in wanted:
301
+ row = stored.get(pid)
302
+ docs = []
303
+ for d in (row if isinstance(row, list) else []):
304
+ if not isinstance(d, dict) or not d.get('id'):
305
+ continue
306
+ wire = {k: d.get(k) for k in _DOC_WIRE_KEYS}
307
+ # The delete wall, mirrored onto the wire so the button is absent rather than
308
+ # offered-and-refused. `doc_delete` enforces the same test server-side; this is the
309
+ # DISPLAY of that rule, never a substitute for it.
310
+ wire['canDelete'] = bool(admin or (d.get('by') and d.get('by') == uname))
311
+ docs.append(wire)
312
+ out[pid] = docs
313
+ return out
314
+
315
+
316
  def _docs_key(ctx):
317
  """The DOCUMENTS bucket this ctx operates on β€” `_cohorts`'s sibling (wave 19, item 12's
318
  silent third case).
platform/core/user_tables.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/harness/datastore.py CHANGED
@@ -19,6 +19,7 @@ Design rules:
19
  import datetime as dt
20
  import json
21
  import os
 
22
  import sys
23
  import time
24
  from pathlib import Path
@@ -178,12 +179,23 @@ ENTITIES = {
178
  # β›” It is synced HERE rather than read from live Odoo because `read_customers` also runs
179
  # at BOOT off a hydrated snapshot, where Odoo may be unreachable β€” sourcing it live would
180
  # make the population "whatever source answered this time", changing size with the network.
 
 
 
 
 
 
 
 
 
181
  "fields": ["name", "team_id", "city", "state_id", "country_id", "active",
182
- "agent", "salesman_as_agent", "customer_rank", "write_date"],
 
183
  # `backfill_fields` carries it too, so mirrors that already exist pick it up on the next
184
  # sync_all() instead of needing a reseed.
185
  "backfill_fields": ["agent", "salesman_as_agent", # added 2026-07-28
186
- "customer_rank"], # added 2026-08-11 (W29-T54)
 
187
  # m2m fields land as <base>_id (the FIRST linked id β€” the Customers-module convention:
188
  # a customer's assigned agent is agent_ids[0], ~one per customer, MECE) + <field> as a
189
  # JSON list of ALL ids. Declared explicitly: a 2-element m2m read would otherwise be
@@ -387,6 +399,127 @@ def ro_con():
387
  return cur
388
 
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  SEED_DATASET = os.environ.get("STORE_SEED_DATASET", "royal-imports/cfo-os-data")
391
 
392
 
 
19
  import datetime as dt
20
  import json
21
  import os
22
+ import re
23
  import sys
24
  import time
25
  from pathlib import Path
 
179
  # β›” It is synced HERE rather than read from live Odoo because `read_customers` also runs
180
  # at BOOT off a hydrated snapshot, where Odoo may be unreachable β€” sourcing it live would
181
  # make the population "whatever source answered this time", changing size with the network.
182
+ # ⭐ WAVE 30 (session E's ask, W30-T33). `street`/`street2`/`zip` are the POSTAL half of
183
+ # an address the mirror has never carried: it holds `city`, `state_id` and `country_id`
184
+ # and stops there, so `odoo_relational.read_customers` β€” which reads only the mirror β€”
185
+ # cannot serve a full address at all, however the column is declared.
186
+ # β›” DECLARING THEM DOES NOT FILL THEM, and the difference is a whole sync: an existing
187
+ # mirror ALTERs the columns in on the next `sync_all()` and leaves them NULL until the
188
+ # backfill below has walked every partner (a partner's `write_date` did not move because
189
+ # WE changed the schema). Every projection degrades a missing/NULL column to blank rather
190
+ # than raising, so declaring early is safe β€” it is just not yet true.
191
  "fields": ["name", "team_id", "city", "state_id", "country_id", "active",
192
+ "agent", "salesman_as_agent", "customer_rank",
193
+ "street", "street2", "zip", "write_date"],
194
  # `backfill_fields` carries it too, so mirrors that already exist pick it up on the next
195
  # sync_all() instead of needing a reseed.
196
  "backfill_fields": ["agent", "salesman_as_agent", # added 2026-07-28
197
+ "customer_rank", # added 2026-08-11 (W29-T54)
198
+ "street", "street2", "zip"], # added 2026-08-12 (W30-T33)
199
  # m2m fields land as <base>_id (the FIRST linked id β€” the Customers-module convention:
200
  # a customer's assigned agent is agent_ids[0], ~one per customer, MECE) + <field> as a
201
  # JSON list of ALL ids. Declared explicitly: a 2-element m2m read would otherwise be
 
399
  return cur
400
 
401
 
402
+ # ─────────────────────────────────────────────────────────────────────────────────────────────
403
+ # ⭐ WAVE 30 / OWNER RULING R6 + R7 β€” THE WINDOW. Read this before touching `window()`.
404
+ #
405
+ # R6, verbatim: *"there is no cap in how many data from the API source (as long as its from a
406
+ # connected source like Odoo) that can be pulled into the app."* R7 says how: a connected grid
407
+ # reads THROUGH this mirror instead of copying rows into the one `user_tables` document, which
408
+ # `MAX_ROWS = 60_000` bounds. The mirror is already uncapped β€” 971,034 GL lines live in this file
409
+ # today β€” so "no cap" is not a bigger number, it is a different mechanism: serve a SLICE and count
410
+ # the whole.
411
+ #
412
+ # β›”β›” `total` IS A `SELECT count(*)` OVER THE SAME PREDICATE, NEVER `len(rows)`. A window whose
413
+ # count is its own length is a fabricated aggregate that reads as authoritative β€” the class this
414
+ # repo has paid for twice ([[no-unverifiable-aggregates]], [[one-question-two-normalizers]]: a
415
+ # display predicate and a fold predicate answering one question differently). The two statements
416
+ # below are built from ONE `where` + ONE `params` tuple for exactly that reason; they cannot drift
417
+ # apart without deleting a line.
418
+ #
419
+ # β›” AND THE PREDICATE PUSHES DOWN. `where` is compiled by `harness/filter_sql.py` β€” the same
420
+ # compiler the client's filter engine is held in step with β€” so a filter matching rows outside the
421
+ # loaded window still COUNTS them. A caller that filters the returned list instead has silently
422
+ # asked "how many of the 200 rows in memory match" about a 971,034-row table.
423
+ #
424
+ # ⚠ `order_by` IS REQUIRED IN PRACTICE AND DEFAULTED HERE. Two pages of an UNORDERED window are
425
+ # not guaranteed to partition the table: DuckDB may legally return a row on page 1 and again on
426
+ # page 2, and the user sees a duplicate with no error anywhere. The default is the physical
427
+ # rowid-ish `1` only when a caller genuinely has no key; every real caller passes one.
428
+ #
429
+ # ⚠ TRUST BOUNDARY. `table`, `select`, `where` and `order_by` are SQL we author (a spec row, or
430
+ # `filter_sql`'s output over a whitelisted column map). Every VALUE is bound through `params`.
431
+ # `table` is additionally shape-checked below β€” not because a caller is hostile, but because a
432
+ # typo'd identifier interpolated into two statements is worth one cheap assertion.
433
+
434
+ #: The most rows ONE request may carry back. Not a cap on the data β€” every row is reachable by
435
+ #: paging and `total` always tells the truth about how many there are β€” but a bound on the memory
436
+ #: a single response can cost. R6's second sentence applies: a caller that asks for more is CLAMPED
437
+ #: and the clamp is REPORTED (`routes_odoo_tables` turns it into a `limits` entry), never silent.
438
+ WINDOW_MAX = 5_000
439
+
440
+ #: What a caller gets when it names no window at all. Matches `semantic.store_rows`' own default so
441
+ #: the two windowed readers in this codebase do not disagree about what "a page" means.
442
+ WINDOW_DEFAULT = 200
443
+
444
+ _IDENT_OK = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
445
+
446
+
447
+ def window(table=None, select=None, where=None, params=(), order_by=None,
448
+ offset=0, limit=WINDOW_DEFAULT, cur=None, from_sql=None):
449
+ """One page of a mirror table PLUS the true count of everything the same predicate matches.
450
+
451
+ Returns `{"rows": [tuple, ...], "columns": [name, ...], "total": int, "offset": int,
452
+ "limit": int, "clamped": bool}` β€” `rows` is the slice, `total` is the population.
453
+
454
+ `select` is the projection expression list ("id, name, amount_untaxed"); `where` is a WHERE
455
+ body with `?` placeholders; `params` binds them, and is used by BOTH statements.
456
+
457
+ ⚠ `table` and `from_sql` are the SAME argument wearing two trust levels, and they are separate
458
+ names on purpose. `table` is an identifier and is SHAPE-CHECKED; `from_sql` is a whole FROM
459
+ body β€” a parenthesised subquery with an alias β€” which cannot be checked at all. Two of the
460
+ eight connected grids need one (`read_customers` and `read_agents` are single statements whose
461
+ population is a SQL `UNION`), so the capability has to exist; naming it distinctly means a
462
+ reviewer can grep for every caller that hands over raw FROM SQL instead of inferring it.
463
+ """
464
+ if bool(table) == bool(from_sql):
465
+ raise ValueError("datastore.window: pass exactly one of `table` or `from_sql`")
466
+ if from_sql:
467
+ name = str(from_sql).strip()
468
+ else:
469
+ name = str(table or "").strip()
470
+ if not _IDENT_OK.match(name):
471
+ raise ValueError(f"datastore.window: {table!r} is not a table identifier")
472
+ proj = str(select or "").strip()
473
+ if not proj:
474
+ raise ValueError("datastore.window: a projection is required β€” there is no implicit *")
475
+ try:
476
+ offset = max(0, int(offset or 0))
477
+ except (TypeError, ValueError):
478
+ offset = 0
479
+ try:
480
+ limit = int(limit if limit is not None else WINDOW_DEFAULT)
481
+ except (TypeError, ValueError):
482
+ limit = WINDOW_DEFAULT
483
+ clamped = limit > WINDOW_MAX or limit < 1
484
+ limit = min(max(limit, 1), WINDOW_MAX)
485
+
486
+ params = tuple(params or ())
487
+ pred = str(where or "").strip()
488
+ tail = f" WHERE {pred}" if pred else ""
489
+ order = str(order_by or "").strip() or "1"
490
+ cur = cur if cur is not None else ro_con()
491
+
492
+ # β›” THE COUNT RUNS FIRST AND OVER THE SAME `tail` + `params`. Ordering it first is deliberate:
493
+ # if the projection is ever wrong, the caller fails LOUDLY on the rows query rather than
494
+ # quietly returning a good count beside a broken page.
495
+ total = int(cur.execute(f"SELECT count(*) FROM {name}{tail}", params).fetchone()[0] or 0)
496
+ got = cur.execute(
497
+ f"SELECT {proj} FROM {name}{tail} ORDER BY {order} LIMIT {int(limit)} OFFSET {int(offset)}",
498
+ params).fetchall()
499
+ cols = [d[0] for d in (cur.description or [])]
500
+ return {"rows": [tuple(r) for r in got], "columns": cols,
501
+ "total": total, "offset": offset, "limit": limit, "clamped": clamped}
502
+
503
+
504
+ def columns_of(table, cur=None):
505
+ """The column names a mirror table actually has, lowercased.
506
+
507
+ ⚠ A mirror can be `ready()` and still be MISSING COLUMNS: `ready()` reads entity PHASES, while
508
+ `_ensure_columns`/`_backfill_columns` checkpoint separately. Asking before projecting is what
509
+ stops a DuckDB `BinderException: Referenced column … not found` reaching a user as a bare 500
510
+ (it already cost one live, which is why `odoo_relational.columns` exists on the app side).
511
+ """
512
+ name = str(table or "").strip()
513
+ if not _IDENT_OK.match(name):
514
+ raise ValueError(f"datastore.columns_of: {table!r} is not a table identifier")
515
+ cur = cur if cur is not None else ro_con()
516
+ try:
517
+ rows = cur.execute(f"PRAGMA table_info('{name}')").fetchall()
518
+ except Exception: # noqa: BLE001
519
+ return set()
520
+ return {str(r[1]).lower() for r in rows}
521
+
522
+
523
  SEED_DATASET = os.environ.get("STORE_SEED_DATASET", "royal-imports/cfo-os-data")
524
 
525
 
platform/modules/customer_data.py CHANGED
@@ -8,8 +8,11 @@ The page is a customer-list WORKBENCH in two parts:
8
  metric columns) plus hand-picked members. 'Call list' and 'Win-back' ship as TEMPLATES β€”
9
  the exact formulas the old My Day queues used β€” and the user can change or reset them.
10
 
11
- Composes already-validated customers-module functions; no separate validate() β€” the page's
12
- counts derive FROM the lists it renders (rule 8b). Persistence: HF store key 'customer_lists'
 
 
 
13
  ({username: {list_name: {rules, sort, members, note}}}); templates are virtual until edited.
14
 
15
  The user↔agent link (users.py 'agent' field) scopes the pool to that agent's book; users with
@@ -247,6 +250,130 @@ def pool_first(agent_name=None, team_id=None, limit=100):
247
  return _pool_build(agent_name, team_id, limit=limit, fast=True)
248
 
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  def _dba_attrs(pids, t):
251
  """{pid: 'Fisch' | 'Royal' | 'Both'} β€” which brand(s) a customer's confirmed orders carry
252
  over the pool's own 24-month universe (C-DBA, wave 2026-08-02).
@@ -433,7 +560,10 @@ def _pool_build(agent_name, team_id, limit, fast=False):
433
  'customer': tv.get('name') or lv.get('name')
434
  or (ltm.get(pid) or {}).get('name') or extra_names.get(pid) or '?',
435
  'status': status,
436
- 'agent': a.get('agent', '(none)'), 'city': a.get('city', '(none)'),
 
 
 
437
  # C-DBA: blank (not '(none)') β€” a select's blank is its own honest empty state.
438
  'dba': dba_a.get(pid, ''),
439
  'state': a.get('state', '(none)'),
@@ -541,7 +671,13 @@ def _reconcile_ledger(rows, total, agent_name, team_id):
541
  # Everything else is genuinely UNKNOWN now β€” the record is gone. Blank is the
542
  # honest rendering; carrying the last-known numbers forward would state figures
543
  # as current that nothing can reconcile ([[no-unverifiable-aggregates]]).
544
- 'agent': '(none)', 'city': '(none)', 'state': '(none)', 'country': '(none)',
 
 
 
 
 
 
545
  'zip': '(none)', 'payment_terms': '(none)', 'customer_since': '',
546
  '_created': '', 'tags': '(none)', 'pricelist': '(none)',
547
  'ar_open': 0.0, 'ar_overdue': 0.0, 'ar_outstanding': 0.0, 'ar_exposure': 0.0,
 
8
  metric columns) plus hand-picked members. 'Call list' and 'Win-back' ship as TEMPLATES β€”
9
  the exact formulas the old My Day queues used β€” and the user can change or reset them.
10
 
11
+ Composes already-validated customers-module functions, so the page's counts derive FROM the
12
+ lists it renders (rule 8b). ⚠ **This header used to end "no separate validate()" and W30-T33
13
+ made that false** β€” see `validate()` below. The claim held only while every column composed an
14
+ already-reconciled customers-module function; the ADDRESS family does not, so it needed its own
15
+ tie. Persistence: HF store key 'customer_lists'
16
  ({username: {list_name: {rules, sort, members, note}}}); templates are virtual until edited.
17
 
18
  The user↔agent link (users.py 'agent' field) scopes the pool to that agent's book; users with
 
250
  return _pool_build(agent_name, team_id, limit=limit, fast=True)
251
 
252
 
253
+ # ------------------------------------------------------------------ the address reconciliation
254
+ #: {our pool key: the `res.partner` field an Odoo domain filters on}. The two m2o columns are
255
+ #: stored here as RESOLVED NAMES (`O.m2o_name`), so the oracle asks Odoo whether the id is SET
256
+ #: while we count whether the name is non-blank β€” a set id resolving to an empty name is the one
257
+ #: place those two questions could come apart, and the per-key leg is what would report it.
258
+ ADDRESS_FIELDS = {
259
+ 'street': 'street', 'street2': 'street2', 'city': 'city',
260
+ 'state': 'state_id', 'country': 'country_id', 'zip': 'zip',
261
+ }
262
+
263
+ #: OUR blank. `_partner_attrs` collapses every partner attribute to '(none)' when blank (MECE, so
264
+ #: a group-by has no null bucket), a pool row for a partner with NO attrs takes the same sentinel
265
+ #: from the row template, and `_reconcile_ledger`'s revived rows are written '(none)' outright.
266
+ _ADDRESS_BLANK = ('(none)', '', None)
267
+
268
+
269
+ def address_blank(value):
270
+ """OUR blank predicate, in ONE place β€” the fold and the oracle must ask ONE question.
271
+
272
+ ⚠ `.strip()` is part of it, and that is the half that diverges from Odoo. A domain
273
+ `('street', '!=', False)` counts a whitespace-only street as PRESENT while this counts it as
274
+ blank, so the two normalizers would disagree by exactly the whitespace-only population.
275
+ MEASURED 2026-08-12 across `street`/`street2`/`city`/`zip` over the whole pool: **0**. The
276
+ leg that keeps it 0 is asserted in `validate()` rather than assumed
277
+ ([[one-question-two-normalizers]]).
278
+ """
279
+ return value in _ADDRESS_BLANK or (isinstance(value, str) and not value.strip())
280
+
281
+
282
+ def validate(team_id=None):
283
+ """Reconcile the ADDRESS family the Customer grid ships to an independent Odoo aggregate.
284
+
285
+ β›” THE POINT OF THIS FUNCTION IS THAT THE NUMBER SURVIVES THE SESSION THAT TOOK IT. W30-T33
286
+ shipped `street`/`street2` after measuring their coverage with a throwaway shell one-liner β€”
287
+ a measurement, never a `validate()` β€” so nothing in the repo re-checked it and the figure
288
+ died with that session. The house law is *every metric gets a `validate()` reconciling to an
289
+ INDEPENDENT Odoo aggregate*, and an oracle nobody re-runs rots exactly like a mode nobody
290
+ runs ([[rules-need-gates]]). `aios-web/api/verify_odoo_relational.py` is what re-runs it.
291
+
292
+ ⚠ THE TRAP THAT REDS A CORRECT LEG, and it is why every domain below is scoped to the pool's
293
+ OWN pid set: **the customer pool is the WHOLE BOOK**, not `customer_rank > 0 AND active`
294
+ ([[customer-grid-pool-vs-odoo-count]]). The 3,617/3,478 pair quoted in the wave-30 mailbox is
295
+ the rank>0-active population and is NOT this one; measured here the same day, the pool holds
296
+ 3,629 rows of which 3,487 carry a street. Comparing our count against a differently-scoped
297
+ Odoo count is a true measurement of the wrong subject [[measure-the-real-call]].
298
+
299
+ β›” IT BUILDS THE FULL `pool()`, NOT `pool_first`/`fast=True`, AND THE COST IS REAL β€” **444 s
300
+ measured 2026-08-12** (the address itself is ~14 s of that; AR, product mix, salesperson,
301
+ coords and DBA are the rest). Reported rather than optimised away, per R6's second sentence.
302
+ `fast=True` is not the shipping basis: it skips `_reconcile_ledger`, so the RETAINED customers
303
+ (I13) never enter and the population is a different one. The recommended fix if this ever has
304
+ to get cheap is a `families=` argument on `_pool_build` so a reconciliation can ask for the
305
+ families it reconciles β€” NOT a second cheaper pool builder, which would be two evaluators for
306
+ one question [[one-evaluator-per-question]].
307
+ """
308
+ rows = pool(team_id=team_id)
309
+ pids = [int(r['pid']) for r in rows if r.get('pid')]
310
+ odoo = O.get_odoo()
311
+ checks = []
312
+
313
+ # ── the population, and the three numbers that keep the address legs honest ───────────────
314
+ # An Odoo `search` excludes archived records by default, so `n_visible` counts the partners
315
+ # that are BOTH live and ours. Three populations therefore ride the pool and only one of them
316
+ # can carry an address: rows Odoo can see, rows `_reconcile_ledger` REVIVED (I13 β€” Odoo
317
+ # dropped them; every attribute is written '(none)'), and rows that are neither, i.e. archived
318
+ # in Odoo but never retained. The third is the one that grows silently, so it is REPORTED as
319
+ # a number rather than absorbed into a tolerance.
320
+ revived = [r for r in rows if r.get('odoo_status') != 'Active']
321
+ rpids = [int(r['pid']) for r in revived if r.get('pid')]
322
+ n_visible = odoo.search_count('res.partner', [('id', 'in', pids)])
323
+ # β›” ASSERTED, NOT ASSUMED. "The retained rows are the ones Odoo lost" is trivially true when
324
+ # checked against the ledger that did the retaining β€” that is the self-sealing shape. So it is
325
+ # asked of ODOO: a retained pid Odoo can still see means `_reconcile_ledger` buried a LIVE
326
+ # customer's address behind six '(none)' cells.
327
+ still_live = odoo.search_count('res.partner', [('id', 'in', rpids)]) if rpids else 0
328
+ checks.append({
329
+ 'check': "every RETAINED customer (I13) is one Odoo genuinely cannot see β€” asked of "
330
+ "Odoo, never of the ledger that did the retaining",
331
+ 'ours': still_live, 'theirs': 0, 'ok': still_live == 0,
332
+ 'detail': {'pool_rows': len(rows), 'odoo_visible': n_visible, 'retained': len(revived),
333
+ 'archived_not_retained': len(pids) - n_visible - len(revived)},
334
+ })
335
+
336
+ # ── the six address columns, each against a domain Odoo evaluates server-side ─────────────
337
+ # β›” `search_count` WITH A DOMAIN, never a re-read-and-recount: it makes ODOO do the filtering,
338
+ # so the oracle cannot inherit a bug from `_partner_attrs`, which is the code under test.
339
+ for key, field in ADDRESS_FIELDS.items():
340
+ ours = sum(1 for r in rows if not address_blank(r.get(key)))
341
+ theirs = odoo.search_count('res.partner', [('id', 'in', pids), (field, '!=', False)])
342
+ checks.append({
343
+ 'check': f"the grid's `{key}` column is non-blank on exactly the pool customers Odoo "
344
+ f"says carry `{field}` β€” an INDEPENDENT search_count over the pool's own "
345
+ f"pid set, never a count of the rows we just built",
346
+ 'ours': ours, 'theirs': theirs, 'ok': ours == theirs,
347
+ # `column` is what the GATE matches on. Substring-matching the prose is how a leg
348
+ # silently stops being checked when somebody rewords it, and this file's own twin
349
+ # (`verify_product_pool`) matches on prose precisely because it had no key to use.
350
+ 'detail': {'column': key, 'odoo_field': field, 'blank_here': len(rows) - ours},
351
+ })
352
+
353
+ # ── the two normalizers are ONE question, and this is the leg that keeps them one ─────────
354
+ # Everything above compares OUR `.strip() or '(none)'` against ODOO'S `!= False`. Those agree
355
+ # on every value except a whitespace-only one, which Odoo reports present and this module
356
+ # renders blank. Measured 0 today; if it ever stops being 0 the per-key legs above go red for
357
+ # a reason that has nothing to do with the address being wrong, so the cause is named HERE.
358
+ text_keys = [k for k, f in ADDRESS_FIELDS.items() if f == k]
359
+ ws = {}
360
+ if pids:
361
+ # `O.search_read`, the same door `_partner_attrs` reads through β€” the client's own method
362
+ # takes `domain=`/`fields=` as keywords and this wrapper is the one with a fixed shape.
363
+ for got in O.search_read('res.partner', [('id', 'in', pids)], text_keys):
364
+ for k in text_keys:
365
+ v = got.get(k)
366
+ if isinstance(v, str) and v and not v.strip():
367
+ ws[k] = ws.get(k, 0) + 1
368
+ checks.append({
369
+ 'check': "no pool customer carries a WHITESPACE-ONLY address value β€” the one input on "
370
+ "which our blank predicate and Odoo's `!= False` provably disagree",
371
+ 'ours': sum(ws.values()), 'theirs': 0, 'ok': not ws,
372
+ 'detail': {'per_field': ws, 'fields_checked': text_keys},
373
+ })
374
+ return checks
375
+
376
+
377
  def _dba_attrs(pids, t):
378
  """{pid: 'Fisch' | 'Royal' | 'Both'} β€” which brand(s) a customer's confirmed orders carry
379
  over the pool's own 24-month universe (C-DBA, wave 2026-08-02).
 
560
  'customer': tv.get('name') or lv.get('name')
561
  or (ltm.get(pid) or {}).get('name') or extra_names.get(pid) or '?',
562
  'status': status,
563
+ 'agent': a.get('agent', '(none)'),
564
+ # ⭐ W30-T33 (carried W29-T51) β€” the street lines beside the city they belong to.
565
+ 'street': a.get('street', '(none)'), 'street2': a.get('street2', '(none)'),
566
+ 'city': a.get('city', '(none)'),
567
  # C-DBA: blank (not '(none)') β€” a select's blank is its own honest empty state.
568
  'dba': dba_a.get(pid, ''),
569
  'state': a.get('state', '(none)'),
 
671
  # Everything else is genuinely UNKNOWN now β€” the record is gone. Blank is the
672
  # honest rendering; carrying the last-known numbers forward would state figures
673
  # as current that nothing can reconcile ([[no-unverifiable-aggregates]]).
674
+ # ⚠ W30-T33: the address keys land HERE TOO, and this is the site a field addition
675
+ # silently skips. `rows_from_pool` projects with `r.get(k)`, so a revived row
676
+ # missing a key renders BLANK rather than raising β€” archived customers would show
677
+ # an empty address column while live ones showed a full one, and nothing anywhere
678
+ # would report it. Two row templates, one field contract.
679
+ 'agent': '(none)', 'street': '(none)', 'street2': '(none)',
680
+ 'city': '(none)', 'state': '(none)', 'country': '(none)',
681
  'zip': '(none)', 'payment_terms': '(none)', 'customer_since': '',
682
  '_created': '', 'tags': '(none)', 'pricelist': '(none)',
683
  'ar_open': 0.0, 'ar_overdue': 0.0, 'ar_outstanding': 0.0, 'ar_exposure': 0.0,
platform/modules/customers.py CHANGED
@@ -573,8 +573,9 @@ DIMENSIONS = {
573
 
574
 
575
  def _partner_attrs(pids):
576
- """{pid: {city, state, country, agent, zip, payment_terms, customer_since, tags, pricelist}}
577
- β€” the customer attributes the sum-level rollups slice by and the Customer table displays.
 
578
 
579
  Agent is res.partner.agent_ids (the assigned sales agent; ~one per customer), NOT Odoo's
580
  user_id 'salesperson' β€” MEASURED 2026-07-27 at 26 of 1,548 (2%), which is why agent_ids has
@@ -589,8 +590,8 @@ def _partner_attrs(pids):
589
  if not pids:
590
  return {}
591
  rows = O.search_read('res.partner', [('id', 'in', pids)],
592
- ['city', 'state_id', 'country_id', 'agent_ids', 'zip',
593
- 'property_payment_term_id', 'create_date', 'category_id',
594
  'property_product_pricelist'])
595
  aids = {a for r in rows for a in (r.get('agent_ids') or [])}
596
  anames = ({p['id']: p['name'] for p in O.search_read('res.partner', [('id', 'in', list(aids))], ['name'])}
@@ -610,6 +611,17 @@ def _partner_attrs(pids):
610
  # compares dates ISO-LEXICALLY, so a trailing time would sort and filter as text noise.
611
  since = (r.get('create_date') or '')
612
  out[r['id']] = {
 
 
 
 
 
 
 
 
 
 
 
613
  'city': city.title() if city else '(none)',
614
  'state': O.m2o_name(r.get('state_id')) or '(none)',
615
  'country': O.m2o_name(r.get('country_id')) or '(none)',
 
573
 
574
 
575
  def _partner_attrs(pids):
576
+ """{pid: {street, street2, city, state, country, agent, zip, payment_terms, customer_since,
577
+ tags, pricelist}} β€” the customer attributes the sum-level rollups slice by and the Customer
578
+ table displays.
579
 
580
  Agent is res.partner.agent_ids (the assigned sales agent; ~one per customer), NOT Odoo's
581
  user_id 'salesperson' β€” MEASURED 2026-07-27 at 26 of 1,548 (2%), which is why agent_ids has
 
590
  if not pids:
591
  return {}
592
  rows = O.search_read('res.partner', [('id', 'in', pids)],
593
+ ['street', 'street2', 'city', 'state_id', 'country_id', 'agent_ids',
594
+ 'zip', 'property_payment_term_id', 'create_date', 'category_id',
595
  'property_product_pricelist'])
596
  aids = {a for r in rows for a in (r.get('agent_ids') or [])}
597
  anames = ({p['id']: p['name'] for p in O.search_read('res.partner', [('id', 'in', list(aids))], ['name'])}
 
611
  # compares dates ISO-LEXICALLY, so a trailing time would sort and filter as text noise.
612
  since = (r.get('create_date') or '')
613
  out[r['id']] = {
614
+ # ⭐ W30-T33 (carried W29-T51) β€” the STREET half of the address. `city`/`state`/`zip`/
615
+ # `country` were already here; the two street lines were read nowhere on the customer
616
+ # path, only by `modules/map.py` for geocoding, and a geocoder's input never reached
617
+ # the grid. ⚠ NOT ROUTED THROUGH `map.py`: these come off `res.partner` directly, so a
618
+ # customer the geocoder could not place still shows its address.
619
+ # ⚠ `.strip()` WITHOUT `.title()`, unlike `city`. A street line carries unit numbers,
620
+ # directionals and abbreviations ("123 NW 4TH ST APT 2B"), and title-casing turns
621
+ # those into "123 Nw 4Th St Apt 2B" β€” a mangling `city` does not risk because a city
622
+ # is one word-set of ordinary nouns.
623
+ 'street': (r.get('street') or '').strip() or '(none)',
624
+ 'street2': (r.get('street2') or '').strip() or '(none)',
625
  'city': city.title() if city else '(none)',
626
  'state': O.m2o_name(r.get('state_id')) or '(none)',
627
  'country': O.m2o_name(r.get('country_id')) or '(none)',
platform/modules/product_data.py CHANGED
@@ -57,6 +57,7 @@ from pathlib import Path
57
 
58
  import core.odoo as O
59
  import core.periods as P
 
60
  import core.table_store as table_store
61
  import modules.products as products
62
 
@@ -117,9 +118,16 @@ UNSHIPPED_ROW_KEYS = ("buy_now",)
117
  #: exist β€” and an unknown column is an INACTIVE condition in the tri-state engine, which IGNORES
118
  #: it and therefore WIDENS. The buy list would silently show the whole catalogue to everyone but
119
  #: its author. Contract columns are identical for every reader, so the view means one thing.
120
- #: β­’ The owner asked for these to be EDITABLE ("turn this Excel sheet into a User created Field
121
- #: that we can edit"). That needs a SHARED field/overlay stratum, which the platform does not
122
- #: have yet β€” booked as the named gap in the wave-17 doc rather than faked per-user.
 
 
 
 
 
 
 
123
  _SUPPLIER_MAP_PATH = Path(__file__).resolve().parent.parent / "procurement_suppliers.json"
124
  _SUPPLIER_CACHE = {}
125
 
@@ -156,7 +164,70 @@ def supplier_master():
156
  #: the whole reason the table-page factory exists ("a new table object gets its own
157
  #: table_store.make('<its>_table_workspace')").
158
  TABLE_KEY = 'product_table_workspace'
159
- TABLE_OPS = table_store.make(TABLE_KEY)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
 
162
  def sku_pid(code):
@@ -286,6 +357,20 @@ def _catalogue_by_code():
286
  return products.catalogue()
287
 
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  def pool(team_id=None, t=None):
290
  """One row per ACTIVE product β€” the PRODUCT analogue of `customer_data.pool`.
291
 
@@ -305,6 +390,7 @@ def pool(team_id=None, t=None):
305
  inv = _inventory_by_code(t)
306
  bu_share = {} if consolidated else _bu_ltm_share(t, team_id)
307
  sup = supplier_master()
 
308
  cat = _catalogue_by_code()
309
  # The LEFT side of the join, indexed by the same code key. β›” A code here that the catalogue
310
  # does not carry belongs to a product no ACTIVE record claims β€” archived, and R12 keeps those
@@ -332,6 +418,18 @@ def pool(team_id=None, t=None):
332
  "qty_ytd": r.get("qty_ytd", 0.0) if r else None,
333
  "orders_ytd": r.get("orders_ytd", 0) if r else None,
334
  }
 
 
 
 
 
 
 
 
 
 
 
 
335
  # Wave 17 R3 β€” the supplier master, on every pull (it is catalogue data, not stock).
336
  s = sup.get(code) or {}
337
  row.update({
@@ -504,6 +602,124 @@ def validate(team_id=None, t=None):
504
  "ours": still_active, "theirs": 0, "ok": still_active == 0,
505
  "detail": {"codes": coded_outside[:10], "n_codes": len(outside)},
506
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  # WAVE 17 R3 β€” the BUY SIGNAL must be a total, exact partition of the catalogue, and every
508
  # member of it must be re-derivable from the two columns beside it. A signal somebody buys
509
  # stock on cannot be "mostly right": the failure that matters is a row that says OK because
 
57
 
58
  import core.odoo as O
59
  import core.periods as P
60
+ import core.shared_overlay as shared_overlay
61
  import core.table_store as table_store
62
  import modules.products as products
63
 
 
118
  #: exist β€” and an unknown column is an INACTIVE condition in the tri-state engine, which IGNORES
119
  #: it and therefore WIDENS. The buy list would silently show the whole catalogue to everyone but
120
  #: its author. Contract columns are identical for every reader, so the view means one thing.
121
+ #:
122
+ #: ⭐⭐ 2026-08-12 (W30-T36) β€” THE OWNER'S ASK IS NOW DELIVERED, AND THE PARAGRAPH ABOVE IS WHY IT
123
+ #: TOOK THREE WAVES. Owner: *"turn this Excel sheet into a User created Field that we can edit."*
124
+ #: It was parked because a per-user column silently WIDENS a shared view β€” not because editing was
125
+ #: hard. Wave 29's `core/shared_overlay.py` (whose header quotes this very comment) is the stratum
126
+ #: that removes the objection: **one value per (row, column) for the whole tenant**, so the column
127
+ #: still means ONE thing to every reader and a shared view still filters honestly.
128
+ #: β‡’ The four columns below are now `source: "overlay"` + `shared: true` in the canonical
129
+ #: contract, their values live in `<TABLE_KEY>__shared`, and the master map is what SEEDS an
130
+ #: unedited cell rather than what freezes it. See `SHARED_KEYS` and `_ProductTableStore`.
131
  _SUPPLIER_MAP_PATH = Path(__file__).resolve().parent.parent / "procurement_suppliers.json"
132
  _SUPPLIER_CACHE = {}
133
 
 
164
  #: the whole reason the table-page factory exists ("a new table object gets its own
165
  #: table_store.make('<its>_table_workspace')").
166
  TABLE_KEY = 'product_table_workspace'
167
+
168
+ _GRID_FIELDS_PATH = Path(__file__).resolve().parent.parent / 'aios_grid_fields.json'
169
+ _SHARED_KEYS = None
170
+
171
+
172
+ def SHARED_KEYS():
173
+ """The product columns whose values are TENANT-WIDE β€” derived from the canonical contract's
174
+ own `shared: true`, never typed out a second time.
175
+
176
+ β›” IT DELIBERATELY DOES NOT SWALLOW A READ FAILURE. Degrading to `()` would send a shared
177
+ write into the per-user stratum with nothing going wrong anywhere β€” the widening defect
178
+ reappearing silently, which is the one outcome this whole mechanism exists to prevent. If the
179
+ canonical contract is unreadable the product grid cannot render at all (`pd_fields` parses the
180
+ same file with no guard), so a raise here costs nothing that was still working.
181
+ """
182
+ global _SHARED_KEYS
183
+ if _SHARED_KEYS is None:
184
+ doc = json.loads(_GRID_FIELDS_PATH.read_text(encoding='utf-8'))
185
+ _SHARED_KEYS = tuple(f['key'] for f in (doc.get('product_data') or {}).get('fields') or []
186
+ if f.get('shared'))
187
+ return _SHARED_KEYS
188
+
189
+
190
+ class _ProductTableStore(table_store.TableStore):
191
+ """The product workspace, with the SHARED columns routed to the tenant-wide stratum.
192
+
193
+ ⭐⭐ THIS SUBCLASS IS THE WHOLE OF W30-T36's WRITE PATH, AND THE REASON IT LIVES HERE RATHER
194
+ THAN AT A ROUTE IS THAT **THE BROWSER NEVER CALLS `PATCH /products/{pid}`** β€” measured, zero
195
+ call sites in `aios-web/web/src`. A cell edit travels `POST /grid/events` β†’ `grid_events.
196
+ handle_one` β†’ `_tops(ctx).patch_overlay(...)`, and `_tops` returns `ctx.table`, which
197
+ `routes_grid._ctx` sets to `TABLE_OPS` for the product scope. So this object IS the seam both
198
+ doors pass through; intercepting at either route would have left the other one writing a
199
+ per-user value that only its author could see.
200
+
201
+ ⚠ `st=self.st`, NEVER the module default. The shared stratum must resolve to the SAME store
202
+ handle as the per-user one it sits beside β€” `_tops`' own comment explains that a split, where
203
+ one side is tenant-scoped and the other is not, is worse than a stated residency error because
204
+ a user's value would vanish the moment they saved it. Reading `self.st` means both strata move
205
+ together the day that singleton gains a tenant handle.
206
+ """
207
+
208
+ def patch_overlay(self, username, pid, updates):
209
+ clean = dict(updates or {})
210
+ if not clean:
211
+ return
212
+ keys = set(SHARED_KEYS())
213
+ shared = {k: v for k, v in clean.items() if k in keys}
214
+ personal = {k: v for k, v in clean.items() if k not in keys}
215
+ if shared:
216
+ shared_overlay.put_cells(TABLE_KEY, pid, shared, st=self.st)
217
+ if personal:
218
+ super().patch_overlay(username, pid, personal)
219
+
220
+
221
+ TABLE_OPS = _ProductTableStore(TABLE_KEY)
222
+
223
+
224
+ def shared_cells(pids, st=None):
225
+ """`{"<pid>": {key: value}}` for the SHARED columns of the rows named by `pids`.
226
+
227
+ ⚠ `pids` is required and positional all the way down β€” `shared_overlay.cells` refuses to serve
228
+ "everything" by design, and the caller here always holds an already-scoped pool.
229
+ """
230
+ return shared_overlay.cells(TABLE_KEY, pids, st=st if st is not None else TABLE_OPS.st)
231
 
232
 
233
  def sku_pid(code):
 
357
  return products.catalogue()
358
 
359
 
360
+ def _pricelist_by_code():
361
+ """`({code: {price_*: price}}, report)` from `products.pricelist_by_code`, or `({}, …)`.
362
+
363
+ A SEAM for the same two reasons `_inventory_by_code` is one: it degrades rather than raises
364
+ (these are columns, not the row set), and `verify_perm_scope`'s section H stubs it to build a
365
+ pool without Odoo. β›” Unstubbed there, section H would reach live Odoo through the back door
366
+ and the whole file would stop being runnable offline.
367
+ """
368
+ try:
369
+ return products.pricelist_by_code()
370
+ except Exception as e:
371
+ return {}, {"error": f"{type(e).__name__}: {str(e)[:200]}"}
372
+
373
+
374
  def pool(team_id=None, t=None):
375
  """One row per ACTIVE product β€” the PRODUCT analogue of `customer_data.pool`.
376
 
 
390
  inv = _inventory_by_code(t)
391
  bu_share = {} if consolidated else _bu_ltm_share(t, team_id)
392
  sup = supplier_master()
393
+ prices, _price_report = _pricelist_by_code()
394
  cat = _catalogue_by_code()
395
  # The LEFT side of the join, indexed by the same code key. β›” A code here that the catalogue
396
  # does not carry belongs to a product no ACTIVE record claims β€” archived, and R12 keeps those
 
418
  "qty_ytd": r.get("qty_ytd", 0.0) if r else None,
419
  "orders_ytd": r.get("orders_ytd", 0) if r else None,
420
  }
421
+ # ⭐ WAVE 30 W30-T34 β€” the PRICELIST stratum, one column per declared list. Catalogue
422
+ # data like the supplier block, so it rides every pull, scoped or not: a price book is
423
+ # not a thing a business unit owns a slice of, and a Fisch reader who cannot see the
424
+ # Fisch price is exactly who this column is for.
425
+ #
426
+ # β›” `None`, NEVER 0 AND NEVER A FALLBACK, for the 1,883 SKUs no list prices. That is
427
+ # W29-T52's own negative control: the single `3_global` rule computes over `list_price`,
428
+ # which is 1.00 on 5,817 of 5,871 products, so the fallback is not a cheaper answer β€”
429
+ # it is a wrong one wearing a currency sign.
430
+ p = prices.get(code) or {}
431
+ for _col, _name in products.PRICELIST_COLUMNS:
432
+ row[_col] = p.get(_col)
433
  # Wave 17 R3 β€” the supplier master, on every pull (it is catalogue data, not stock).
434
  s = sup.get(code) or {}
435
  row.update({
 
602
  "ours": still_active, "theirs": 0, "ok": still_active == 0,
603
  "detail": {"codes": coded_outside[:10], "n_codes": len(outside)},
604
  })
605
+ # ── ⭐⭐ WAVE 30 W30-T34 β€” THE PRICELIST COLUMNS, AGAINST ORACLES THAT CANNOT SEE OUR JOIN ──
606
+ #
607
+ # Three legs, because the column can fail in three different ways and only one of them is a
608
+ # count. `products.pricelist_by_code` builds a `{code: {col: price}}` map by expanding each
609
+ # rule over the products it names, IN PYTHON β€” that expansion is the fragile part, so every
610
+ # leg below re-asks ODOO instead of re-reading the map.
611
+ price_report = _pricelist_by_code()[1]
612
+ _pl_rows = O.search_read('product.pricelist', [], ['id', 'name'])
613
+ _pl_id = {}
614
+ for _p in _pl_rows:
615
+ _pl_id.setdefault(str(_p.get('name') or '').strip(), _p['id'])
616
+ _today = P.today().isoformat()
617
+
618
+ _targets_memo = {}
619
+
620
+ def _rule_targets(list_name):
621
+ """(variant_ids, template_ids) a pricelist prices today β€” read FRESH from Odoo.
622
+
623
+ Memoised for the LIFE OF THIS CALL only: legs 1 and 2 both need all three lists, and
624
+ without this `validate()` makes six identical round trips instead of three (measured:
625
+ ~48s of the run). Deliberately NOT an `lru_cache` β€” an oracle that survives the process
626
+ is an oracle reading yesterday's Odoo.
627
+ """
628
+ if list_name in _targets_memo:
629
+ return _targets_memo[list_name]
630
+ var, tmpl = set(), set()
631
+ plid = _pl_id.get(list_name)
632
+ if plid is None:
633
+ return var, tmpl
634
+ for r in O.search_read(
635
+ 'product.pricelist.item',
636
+ [('pricelist_id', '=', plid), ('compute_price', '=', 'fixed'),
637
+ ('applied_on', 'in', ['0_product_variant', '1_product'])],
638
+ ['product_id', 'product_tmpl_id', 'applied_on', 'fixed_price',
639
+ 'date_start', 'date_end']):
640
+ ds, de = str(r.get('date_start') or '')[:10], str(r.get('date_end') or '')[:10]
641
+ if (ds and ds > _today) or (de and de < _today):
642
+ continue
643
+ fp = r.get('fixed_price')
644
+ if not isinstance(fp, (int, float)) or fp <= 0:
645
+ continue
646
+ if r.get('applied_on') == '0_product_variant' and r.get('product_id'):
647
+ var.add(O.m2o_id(r['product_id']))
648
+ elif r.get('product_tmpl_id'):
649
+ tmpl.add(O.m2o_id(r['product_tmpl_id']))
650
+ _targets_memo[list_name] = (var, tmpl)
651
+ return var, tmpl
652
+
653
+ # LEG 1 β€” COVERAGE PER LIST. Ours: cells we filled. Theirs: a bare Odoo `search_count` of
654
+ # ACTIVE products a fresh read of that list's rules reaches. The rule set is shared (it IS
655
+ # the data) but the EXPANSION is not, and the expansion is what breaks.
656
+ for _col, _name in products.PRICELIST_COLUMNS:
657
+ _var, _tmpl = _rule_targets(_name)
658
+ _dom = [('active', '=', True), '|', ('id', 'in', sorted(_var)),
659
+ ('product_tmpl_id', 'in', sorted(_tmpl))]
660
+ theirs = O.get_odoo().search_count('product.product', _dom) if (_var or _tmpl) else 0
661
+ ours = sum(1 for r in rows if isinstance(r.get(_col), (int, float)))
662
+ checks.append({
663
+ "check": f"{_name} pricelist: SKUs priced in the grid == an INDEPENDENT Odoo count "
664
+ f"of ACTIVE products its date-valid fixed rules reach",
665
+ "ours": ours, "theirs": theirs, "ok": ours == theirs,
666
+ "detail": {"column": _col, "variant_rules": len(_var), "template_rules": len(_tmpl)},
667
+ })
668
+
669
+ # LEG 2 β€” β›” THE TICKET'S OWN NEGATIVE CONTROL, AS A LEG. A SKU with no specific item must
670
+ # render BLANK. Asked of ODOO, never of the map that produced the blank β€” "everything I left
671
+ # empty was genuinely unpriced" is trivially true when checked against the dict that emptied
672
+ # it, which is the self-sealing shape the population leg above exists to end.
673
+ _unpriced = [r for r in rows
674
+ if not any(isinstance(r.get(c), (int, float))
675
+ for c, _n in products.PRICELIST_COLUMNS)]
676
+ _codes = {r["code"] for r in _unpriced}
677
+ _stray = 0
678
+ if _codes:
679
+ # Resolve those codes back to Odoo ids and ask whether ANY declared list prices them.
680
+ _ids, _tmpls = set(), set()
681
+ for _p in O.search_read('product.product',
682
+ [('active', '=', True),
683
+ ('default_code', 'in', sorted(c for c in _codes
684
+ if not c.startswith("pid:")))],
685
+ ['id', 'product_tmpl_id']):
686
+ _ids.add(_p['id'])
687
+ _tmpls.add(O.m2o_id(_p.get('product_tmpl_id')))
688
+ for _col, _name in products.PRICELIST_COLUMNS:
689
+ _v, _t = _rule_targets(_name)
690
+ _stray += len((_v & _ids) | ({t for t in _tmpls if t in _t}))
691
+ checks.append({
692
+ "check": "every SKU rendering a BLANK price is genuinely unpriced on all three declared "
693
+ "lists (W29-T52's NC: no fallback dressed as a price) β€” asked of Odoo",
694
+ "ours": _stray, "theirs": 0, "ok": _stray == 0,
695
+ "detail": {"blank_skus": len(_unpriced), "priced_skus": len(rows) - len(_unpriced)},
696
+ })
697
+
698
+ # LEG 3 β€” ⭐ R6's SECOND SENTENCE, AS A NUMBER. *"If there is lag or it can't be done, you
699
+ # need to explicitly tell me why and recommend a fix."* Everything the reader cannot see is
700
+ # counted here rather than dropped. The ASSERTION is the one thing that must never be true β€”
701
+ # a price cell that is present and not a positive number, i.e. a "free" SKU β€” while the
702
+ # rest rides `detail` so it is reported without reddening an honest day's data.
703
+ _bad = [r["code"] for r in rows
704
+ for c, _n in products.PRICELIST_COLUMNS
705
+ if r.get(c) is not None and not (isinstance(r.get(c), (int, float)) and r[c] > 0)]
706
+ # β›” MEASURED 2026-08-12 AND NOT FIXABLE FROM THIS FENCE: `aios_grid.rows_from_pool` sends
707
+ # every non-text field through `_round(v)` = `round(v)` with no ndigits, i.e. to a whole
708
+ # dollar. 52.1% of fixed prices carry cents and the median relative error is 5.26%, so this
709
+ # column ties to the cent HERE and ships rounded. Reported, never silently enforced.
710
+ _cents = sum(1 for r in rows for c, _n in products.PRICELIST_COLUMNS
711
+ if isinstance(r.get(c), (int, float)) and abs(r[c] - round(r[c])) > 1e-9)
712
+ _to_zero = sum(1 for r in rows for c, _n in products.PRICELIST_COLUMNS
713
+ if isinstance(r.get(c), (int, float)) and round(r[c]) == 0)
714
+ checks.append({
715
+ "check": "no price cell is present-but-not-a-positive-number (a 0 would read as FREE; "
716
+ "an unset rule must be blank), and what the reader cannot see is COUNTED",
717
+ "ours": len(_bad), "theirs": 0, "ok": not _bad,
718
+ "detail": {"bad_cells": _bad[:10], "reader_report": price_report,
719
+ "wire_rounding_loses_cents_on": _cents,
720
+ "wire_rounding_to_zero_dollars": _to_zero},
721
+ })
722
+
723
  # WAVE 17 R3 β€” the BUY SIGNAL must be a total, exact partition of the catalogue, and every
724
  # member of it must be re-derivable from the two columns beside it. A signal somebody buys
725
  # stock on cannot be "mostly right": the failure that matters is a row that says OK because
platform/modules/products.py CHANGED
@@ -212,6 +212,178 @@ def catalogue():
212
  return out
213
 
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  def catalogue_count():
216
  """The INDEPENDENT population oracle: Odoo's own count of active products.
217
 
 
212
  return out
213
 
214
 
215
+ #: ⭐⭐ WAVE 30 / W30-T34 (the carried W29-T52) β€” THE PRICELIST STRATUM, PER LIST.
216
+ #:
217
+ #: MEASURED LIVE 2026-08-12, and every one of these numbers shaped the design rather than
218
+ #: decorating it:
219
+ #:
220
+ #: Β· **Five pricelists, three of them material.** Date-valid fixed rules: Fisch 5,139,
221
+ #: Royal 1 2,702, Royal 2 2,605, Public Pricelist 17, Giftware Deals 1. At SKU level that
222
+ #: is Fisch 3,971 Β· Royal 1 1,920 Β· Royal 2 1,857 Β· Public 6 Β· Giftware 0.
223
+ #: Β· **3,988 of 5,871 active products carry a usable fixed rule; 1,883 carry NONE.**
224
+ #: Β· **1,853 SKUs are priced on all three lists AND THE LISTS DISAGREE** β€” median relative
225
+ #: spread 16%, max 82%. That is why this is three columns rather than one "the price" or a
226
+ #: low/high pair: neither can tell a Fisch rep what Fisch sells the SKU for, which is the
227
+ #: only question the column exists to answer.
228
+ #: Β· β›” **`list_price` is 1.00 on 5,817 of the 5,871** β€” so the single `3_global` rule
229
+ #: computed over it is meaningless, exactly as W29-T52 said. **This reader never falls
230
+ #: through to it**, and that is the ticket's own negative control: a SKU with no specific
231
+ #: item renders BLANK, never a fallback dressed as a price.
232
+ #:
233
+ #: ⚠ RESOLVED BY NAME, NOT BY ID. A pricelist renamed or deleted in Odoo makes its column go
234
+ #: blank and makes `report["lists_missing"]` name it β€” a hard-coded id would keep pointing at
235
+ #: whatever inherited it and mislabel every cell silently.
236
+ PRICELIST_COLUMNS = (
237
+ ("price_fisch", "Fisch"),
238
+ ("price_royal_1", "Royal 1"),
239
+ ("price_royal_2", "Royal 2"),
240
+ )
241
+
242
+
243
+ def pricelist_by_code():
244
+ """`({code: {column_key: price}}, report)` β€” the date-valid FIXED base-tier price for each
245
+ declared pricelist, keyed EXACTLY as `catalogue()` keys its rows.
246
+
247
+ β›” THE KEYING IS LOAD-BEARING, not a detail. `catalogue()` keys on `default_code` with a
248
+ `pid:{id}` fallback for the ~33 active records that carry none. Keying this map any other
249
+ way would leave those SKUs permanently blank while an oracle counting active products
250
+ counted them β€” a red gate on a working build, or worse, a silent hole nobody counts.
251
+
252
+ **The base tier, deliberately.** `pricecomp._tier_for` picks the highest `min_quantity` at
253
+ or below an order's quantity, because it is pricing a LINE that has one. A catalogue column
254
+ has no quantity in hand, so it takes the LOWEST `min_quantity` β€” the price at qty 1. Rules
255
+ above that break are a bulk price, and `report["qty_break_only"]` counts the SKUs whose only
256
+ rule sits on one (MEASURED: 10 rules of 10,464 carry a break at all).
257
+
258
+ **Variant rules beat template rules**, matching `pricecomp._tier_for`: a `0_product_variant`
259
+ rule is the more specific statement about this exact SKU.
260
+
261
+ ⚠ DEGRADES TO `({}, report)` on a read failure, matching `_inventory_by_code` rather than
262
+ `catalogue()`: these are COLUMNS, and a product grid that will not render because pricing is
263
+ momentarily unreachable is a worse failure than one with blank price columns. The blanks are
264
+ not silent β€” `product_data.validate()`'s coverage leg reconciles each column against a fresh
265
+ Odoo count and goes red at zero.
266
+
267
+ ⭐ **The report exists because R6's second sentence is law** (*"if it can't be done, you need
268
+ to explicitly tell me why and recommend a fix"*). Everything this reader CANNOT see is
269
+ counted rather than dropped: rules on pricelists the contract does not declare, non-fixed
270
+ (`formula`/`percent`) rules, the `3_global` fallback, and prices that only exist above a
271
+ quantity break.
272
+ """
273
+ # ⚠ THE COUNTERS ARE DISJOINT AND ORDER-DEPENDENT, and saying so is the difference between a
274
+ # report and a misleading one. A rule is classified ONCE, by the first reason it is skipped:
275
+ # undeclared list β†’ out of date β†’ not fixed β†’ global β†’ zero price. So `rules_not_fixed: 0`
276
+ # means "no formula rule on a list we declare", NOT "this Odoo has no formula rules"
277
+ # (MEASURED 2026-08-12: it has exactly one, and it sits on Public Pricelist, which the
278
+ # contract does not declare β€” so it lands in `rules_undeclared_list`).
279
+ report = {"lists_missing": [], "rules_total": 0, "rules_undeclared_list": 0,
280
+ "rules_not_fixed": 0, "rules_global": 0, "rules_out_of_date": 0,
281
+ "rules_zero_price": 0, "qty_break_only": 0}
282
+ try:
283
+ pl_rows = O.search_read('product.pricelist', [], ['id', 'name'])
284
+ by_name = {}
285
+ for p in pl_rows:
286
+ by_name.setdefault(str(p.get('name') or '').strip(), p['id'])
287
+ wanted = {}
288
+ for col, name in PRICELIST_COLUMNS:
289
+ pid = by_name.get(name)
290
+ if pid is None:
291
+ report["lists_missing"].append(name)
292
+ else:
293
+ wanted[pid] = col
294
+
295
+ # ⭐⭐ FILTERED SERVER-SIDE, AND THE REASON IS A MEASUREMENT, NOT A STYLE PREFERENCE.
296
+ # Reading all 10,464 rules with 9 fields and sorting them in Python costs **30.1s** on
297
+ # this connection; the same rules under a server-side domain with 6 fields cost
298
+ # **13.6s** (measured 2026-08-12, back to back). `pool()` runs this on a scope's
299
+ # first-ever build, so that 16.5s is 16.5s of somebody's page load.
300
+ #
301
+ # ⚠ The domain reproduces the Python predicate EXACTLY, and the null legs are the part
302
+ # that is easy to get wrong: an absent `date_start` is `False`, not a past date, so
303
+ # `('date_start','<=',today)` ALONE would drop every open-ended rule β€” which is almost
304
+ # all of them.
305
+ today = P.today().isoformat()
306
+ _declared = sorted(wanted)
307
+ _kinds = ['0_product_variant', '1_product']
308
+ _live = [('pricelist_id', 'in', _declared), ('compute_price', '=', 'fixed'),
309
+ ('applied_on', 'in', _kinds),
310
+ '|', ('date_start', '=', False), ('date_start', '<=', today),
311
+ '|', ('date_end', '=', False), ('date_end', '>=', today),
312
+ ('fixed_price', '>', 0)]
313
+ rules = O.search_read(
314
+ 'product.pricelist.item', _live,
315
+ ['pricelist_id', 'product_id', 'product_tmpl_id', 'applied_on', 'fixed_price',
316
+ 'min_quantity']) if _declared else []
317
+
318
+ # ⭐ R6's SECOND SENTENCE, PAID FOR WITH `search_count` RATHER THAN A WIDER READ. Six
319
+ # counts cost ~1.2s together; the rules they count would cost 16s to read. What this
320
+ # reader cannot see is still REPORTED β€” it is just no longer transferred.
321
+ def _n(extra):
322
+ try:
323
+ return O.get_odoo().search_count('product.pricelist.item', extra)
324
+ except Exception:
325
+ return -1 # -1 reads as "not measured", never as zero
326
+ _dated = ['|', ('date_start', '=', False), ('date_start', '<=', today),
327
+ '|', ('date_end', '=', False), ('date_end', '>=', today)]
328
+ report["rules_total"] = _n([])
329
+ report["rules_undeclared_list"] = _n([('pricelist_id', 'not in', _declared)]) \
330
+ if _declared else report["rules_total"]
331
+ if _declared:
332
+ _on = [('pricelist_id', 'in', _declared)]
333
+ # `percent_price` and formula rules are read NOWHERE in this repo. A rule we cannot
334
+ # price is one the operator is TOLD about, never one that quietly becomes a blank.
335
+ report["rules_not_fixed"] = _n(_on + [('compute_price', '!=', 'fixed')])
336
+ report["rules_global"] = _n(_on + [('compute_price', '=', 'fixed'),
337
+ ('applied_on', 'not in', _kinds)])
338
+ report["rules_out_of_date"] = (
339
+ _n(_on + [('compute_price', '=', 'fixed'), ('applied_on', 'in', _kinds)])
340
+ - _n(_on + [('compute_price', '=', 'fixed'),
341
+ ('applied_on', 'in', _kinds)] + _dated))
342
+ # A zero price is not "free" β€” it is an unset rule. Blank says so; 0 does not.
343
+ report["rules_zero_price"] = _n(
344
+ _on + [('compute_price', '=', 'fixed'), ('applied_on', 'in', _kinds)]
345
+ + _dated + [('fixed_price', '<=', 0)])
346
+
347
+ by_var, by_tmpl = {}, {}
348
+ for r in rules:
349
+ col = wanted[O.m2o_id(r.get('pricelist_id'))]
350
+ if r.get('applied_on') == '0_product_variant' and r.get('product_id'):
351
+ by_var.setdefault((col, O.m2o_id(r['product_id'])), []).append(r)
352
+ elif r.get('product_tmpl_id'):
353
+ by_tmpl.setdefault((col, O.m2o_id(r['product_tmpl_id'])), []).append(r)
354
+
355
+ dom = [('active', '=', True)]
356
+ prods = O.search_read('product.product', dom,
357
+ ['id', 'default_code', 'product_tmpl_id'], limit=50000)
358
+ n = O.get_odoo().search_count('product.product', dom)
359
+ if len(prods) != n:
360
+ # Same guard, same reason as `catalogue()`: a short pull renders as a plausible
361
+ # smaller set of priced SKUs with nothing reporting it.
362
+ raise ValueError(
363
+ f"products.pricelist_by_code: the product pull is TRUNCATED β€” read {len(prods)} "
364
+ f"rows against a search_count of {n}.")
365
+ except Exception as e:
366
+ report["error"] = f"{type(e).__name__}: {str(e)[:200]}"
367
+ return {}, report
368
+
369
+ out = {}
370
+ for p in prods:
371
+ code = (str(p['default_code']).strip() if p.get('default_code') else f"pid:{p['id']}")
372
+ tmpl = O.m2o_id(p.get('product_tmpl_id'))
373
+ cells = {}
374
+ for col, _name in PRICELIST_COLUMNS:
375
+ cands = by_var.get((col, p['id'])) or by_tmpl.get((col, tmpl))
376
+ if not cands:
377
+ continue
378
+ base = min(cands, key=lambda r: r.get('min_quantity') or 0.0)
379
+ if (base.get('min_quantity') or 0.0) > 1.0:
380
+ report["qty_break_only"] += 1
381
+ cells[col] = base.get('fixed_price')
382
+ if cells:
383
+ out.setdefault(code, {}).update(cells)
384
+ return out, report
385
+
386
+
387
  def catalogue_count():
388
  """The INDEPENDENT population oracle: Odoo's own count of active products.
389
 
web/public/sample_customers.json CHANGED
@@ -1,529 +1,545 @@
1
- {
2
- "fields": [
3
- {
4
- "key": "customer",
5
- "label": "Customer",
6
- "type": "text",
7
- "source": "odoo",
8
- "pinned": true,
9
- "default": true,
10
- "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
11
- },
12
- {
13
- "key": "odoo_status",
14
- "label": "Odoo record",
15
- "type": "status",
16
- "source": "odoo",
17
- "default": false,
18
- "description": "Whether this customer still exists in Odoo. Archived means deleted there."
19
- },
20
- {
21
- "key": "agent",
22
- "label": "Agent",
23
- "type": "text",
24
- "source": "odoo",
25
- "default": true,
26
- "description": "The sales agent who owns this account."
27
- },
28
- {
29
- "key": "dba",
30
- "label": "DBA",
31
- "type": "select",
32
- "source": "odoo",
33
- "default": false,
34
- "options": [
35
- "Fisch",
36
- "Royal",
37
- "Both"
38
- ],
39
- "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
40
- },
41
- {
42
- "key": "salesperson",
43
- "label": "Salesperson",
44
- "type": "text",
45
- "source": "odoo",
46
- "default": false,
47
- "description": "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
48
- },
49
- {
50
- "key": "city",
51
- "label": "City",
52
- "type": "text",
53
- "source": "odoo",
54
- "default": true,
55
- "description": "City on the customer's Odoo address."
56
- },
57
- {
58
- "key": "state",
59
- "label": "State",
60
- "type": "text",
61
- "source": "odoo",
62
- "default": true,
63
- "description": "State or province on the customer's Odoo address."
64
- },
65
- {
66
- "key": "country",
67
- "label": "Country",
68
- "type": "text",
69
- "source": "odoo",
70
- "default": false,
71
- "description": "Country on the customer's Odoo address."
72
- },
73
- {
74
- "key": "zip",
75
- "label": "ZIP",
76
- "type": "text",
77
- "source": "odoo",
78
- "default": false,
79
- "description": "Postal code on the customer's Odoo address."
80
- },
81
- {
82
- "key": "customer_since",
83
- "label": "Customer since",
84
- "type": "date",
85
- "source": "odoo",
86
- "default": false,
87
- "description": "When this customer was first set up in Odoo."
88
- },
89
- {
90
- "key": "tags",
91
- "label": "Tags",
92
- "type": "text",
93
- "source": "odoo",
94
- "default": false,
95
- "description": "Odoo labels on this customer, comma-separated."
96
- },
97
- {
98
- "key": "pricelist",
99
- "label": "Price list",
100
- "type": "text",
101
- "source": "odoo",
102
- "default": false,
103
- "description": "The price list this customer buys on."
104
- },
105
- {
106
- "key": "payment_terms",
107
- "label": "Payment terms",
108
- "type": "text",
109
- "source": "odoo",
110
- "default": false,
111
- "description": "Payment terms on this customer's account β€” Net 30, for example."
112
- },
113
- {
114
- "key": "last_order",
115
- "label": "Last order",
116
- "type": "date",
117
- "source": "odoo",
118
- "default": true,
119
- "description": "Date of the most recent confirmed order."
120
- },
121
- {
122
- "key": "overdue_days",
123
- "label": "Overdue days",
124
- "type": "int",
125
- "source": "odoo",
126
- "default": true,
127
- "description": "How many days late this customer is running against their own usual ordering rhythm."
128
- },
129
- {
130
- "_note": "filterable:false β€” DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β€” see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
131
- "key": "est_missed",
132
- "label": "Est. missed $",
133
- "type": "currency",
134
- "source": "odoo",
135
- "default": true,
136
- "agg": "sum",
137
- "filterable": false,
138
- "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
139
- },
140
- {
141
- "_note": "wave 21 R1 β€” KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β€” 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
142
- "key": "ar_open",
143
- "label": "AR current $",
144
- "type": "currency",
145
- "source": "odoo",
146
- "default": false,
147
- "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
148
- },
149
- {
150
- "key": "ar_overdue",
151
- "label": "AR overdue $",
152
- "type": "currency",
153
- "source": "odoo",
154
- "default": false,
155
- "description": "Invoiced money past due β€” same basis as the Collections page."
156
- },
157
- {
158
- "_note": "wave 21 R1 β€” the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β€” no second oracle.",
159
- "key": "ar_outstanding",
160
- "label": "AR outstanding $",
161
- "type": "currency",
162
- "source": "odoo",
163
- "default": false,
164
- "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
165
- },
166
- {
167
- "key": "ar_exposure",
168
- "label": "Credit exposure $",
169
- "type": "currency",
170
- "source": "odoo",
171
- "default": false,
172
- "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
173
- },
174
- {
175
- "key": "ar_aged_1_30",
176
- "label": "1-30 days $",
177
- "type": "currency",
178
- "source": "odoo",
179
- "default": false,
180
- "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
181
- },
182
- {
183
- "key": "ar_aged_31_60",
184
- "label": "31-60 days $",
185
- "type": "currency",
186
- "source": "odoo",
187
- "default": false,
188
- "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
189
- },
190
- {
191
- "key": "ar_aged_61_90",
192
- "label": "61-90 days $",
193
- "type": "currency",
194
- "source": "odoo",
195
- "default": false,
196
- "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
197
- },
198
- {
199
- "key": "ar_aged_90_plus",
200
- "label": "90+ days $",
201
- "type": "currency",
202
- "source": "odoo",
203
- "default": false,
204
- "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
205
- },
206
- {
207
- "key": "days_to_pay",
208
- "label": "Days to pay",
209
- "type": "int",
210
- "source": "odoo",
211
- "default": false,
212
- "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
213
- },
214
- {
215
- "key": "top_category",
216
- "label": "Top category",
217
- "type": "text",
218
- "source": "odoo",
219
- "default": false,
220
- "description": "The category this customer spent the most on in the last 12 months."
221
- },
222
- {
223
- "key": "top_category_pct",
224
- "label": "Top category %",
225
- "type": "pct",
226
- "source": "odoo",
227
- "default": false,
228
- "description": "Share of last-12-months spend that went to the top category."
229
- },
230
- {
231
- "key": "sku_count",
232
- "label": "SKUs bought",
233
- "type": "int",
234
- "source": "odoo",
235
- "default": false,
236
- "description": "Distinct products bought in the last 12 months."
237
- },
238
- {
239
- "key": "top_sku",
240
- "label": "Top SKU",
241
- "type": "text",
242
- "source": "odoo",
243
- "default": false,
244
- "description": "The product this customer spent the most on in the last 12 months."
245
- },
246
- {
247
- "key": "days_since",
248
- "label": "Days since order",
249
- "type": "int",
250
- "source": "odoo",
251
- "default": false,
252
- "description": "Days since the last confirmed order."
253
- },
254
- {
255
- "key": "typical_gap_days",
256
- "label": "Typical gap days",
257
- "type": "int",
258
- "source": "odoo",
259
- "default": false,
260
- "description": "Days this customer usually goes between orders, from their own history."
261
- },
262
- {
263
- "key": "notes",
264
- "label": "Notes",
265
- "type": "text",
266
- "source": "overlay",
267
- "default": false,
268
- "description": "Your notes on this customer. Saved in this app only, visible only to you."
269
- }
270
- ],
271
- "rows": [
272
- {
273
- "pid": 101,
274
- "customer": "Poppy Flowers",
275
- "status": "New",
276
- "agent": "Naomi Linnell Rivera",
277
- "city": "Charlottesville",
278
- "state": "Virginia (US)",
279
- "last_order": "2026-07-21",
280
- "est_missed": 0,
281
- "notes": "",
282
- "country": "United States",
283
- "zip": "02720",
284
- "payment_terms": "30 Days",
285
- "pricelist": "Fisch 1 (USD)",
286
- "tags": "Royal",
287
- "customer_since": "2023-01-10",
288
- "salesperson": "Jessica",
289
- "ar_open": 0,
290
- "ar_overdue": 0,
291
- "ar_exposure": 0,
292
- "top_category": "Styrofoam",
293
- "top_category_pct": 0.47,
294
- "sku_count": 93,
295
- "top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
296
- "days_to_pay": 34,
297
- "_created": "2023-01-15 09:10:00",
298
- "lat": 25.7617,
299
- "lon": -80.1918,
300
- "odoo_status": "Archived",
301
- "dba": "Royal",
302
- "ar_outstanding": 0
303
- },
304
- {
305
- "pid": 102,
306
- "customer": "Meadow & Vine Wholesale",
307
- "status": "Growing",
308
- "agent": "Carla Jimenez",
309
- "city": "Portland",
310
- "state": "Oregon (US)",
311
- "last_order": "2026-07-19",
312
- "est_missed": 0,
313
- "notes": "Expanding to a second storefront.",
314
- "country": "United States",
315
- "zip": "77041",
316
- "payment_terms": "Immediate Payment",
317
- "pricelist": "Royal 1 (USD)",
318
- "tags": "Royal, Key account",
319
- "customer_since": "2024-02-11",
320
- "salesperson": "Naomi",
321
- "ar_open": 1240.5,
322
- "ar_overdue": 0,
323
- "ar_exposure": 1740.5,
324
- "top_category": "Ribbon",
325
- "top_category_pct": 0.95,
326
- "sku_count": 4,
327
- "top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
328
- "days_to_pay": null,
329
- "_created": "2023-02-15 09:11:00",
330
- "lat": 27.9506,
331
- "lon": -82.4572,
332
- "odoo_status": "Active",
333
- "dba": "Fisch",
334
- "ar_outstanding": 1240.5
335
- },
336
- {
337
- "pid": 103,
338
- "customer": "Bluestem Floral Supply",
339
- "status": "Growing",
340
- "agent": "Naomi Linnell Rivera",
341
- "city": "Kansas City",
342
- "state": "Missouri (US)",
343
- "last_order": "2026-07-17",
344
- "est_missed": 0,
345
- "notes": "",
346
- "country": "United States",
347
- "zip": "11219",
348
- "payment_terms": "60 Days",
349
- "pricelist": "Royal 1 (USD)",
350
- "tags": "Fisch",
351
- "customer_since": "2025-03-12",
352
- "salesperson": "Karen",
353
- "ar_open": 0,
354
- "ar_overdue": 5120.25,
355
- "ar_exposure": 5120.25,
356
- "top_category": "Foams & Finishes",
357
- "top_category_pct": 0.31,
358
- "sku_count": 27,
359
- "top_sku": "SATIN RIBBON 2IN",
360
- "days_to_pay": 61,
361
- "_created": "2023-03-15 09:12:00",
362
- "lat": 28.5384,
363
- "lon": -81.3789,
364
- "odoo_status": "Active",
365
- "dba": "Both",
366
- "ar_outstanding": 5120.25
367
- },
368
- {
369
- "pid": 104,
370
- "customer": "Camellia Row Florist",
371
- "status": "Declining",
372
- "agent": "Devon Marsh",
373
- "city": "Savannah",
374
- "state": "Georgia (US)",
375
- "last_order": "2026-05-30",
376
- "est_missed": 41200,
377
- "notes": "Switched some volume to a local grower.",
378
- "country": "United States",
379
- "zip": "07649",
380
- "payment_terms": "30 Days",
381
- "pricelist": "Fisch 1 (USD)",
382
- "tags": "(none)",
383
- "customer_since": "2026-04-13",
384
- "salesperson": "(none)",
385
- "ar_open": 8300,
386
- "ar_overdue": 940,
387
- "ar_exposure": 11440,
388
- "top_category": "All",
389
- "top_category_pct": 1.0,
390
- "sku_count": 1,
391
- "top_sku": "(none)",
392
- "days_to_pay": 12,
393
- "_created": "2023-04-15 09:13:00",
394
- "lat": 30.3322,
395
- "lon": -81.6557,
396
- "odoo_status": "Active",
397
- "dba": "Royal",
398
- "ar_outstanding": 9240
399
- },
400
- {
401
- "pid": 105,
402
- "customer": "Harborlight Wholesale Blooms",
403
- "status": "Growing",
404
- "agent": "Carla Jimenez",
405
- "city": "Seattle",
406
- "state": "Washington (US)",
407
- "last_order": "2026-07-22",
408
- "est_missed": 0,
409
- "notes": "Top-10 account.",
410
- "country": "United States",
411
- "zip": "33125",
412
- "payment_terms": "Immediate Payment",
413
- "pricelist": "Royal 1 (USD)",
414
- "tags": "Royal",
415
- "customer_since": "2023-05-14",
416
- "salesperson": "Jessica",
417
- "ar_open": 0,
418
- "ar_overdue": 0,
419
- "ar_exposure": 0,
420
- "top_category": "Styrofoam",
421
- "top_category_pct": 0.47,
422
- "sku_count": 93,
423
- "top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
424
- "days_to_pay": 34,
425
- "_created": "2023-05-15 09:14:00",
426
- "lat": 26.1224,
427
- "lon": -80.1373,
428
- "odoo_status": "Active",
429
- "dba": "",
430
- "ar_outstanding": 0
431
- },
432
- {
433
- "pid": 106,
434
- "customer": "Dogwood & Fern Co.",
435
- "status": "Dormant",
436
- "agent": "Devon Marsh",
437
- "city": "Asheville",
438
- "state": "North Carolina (US)",
439
- "last_order": "2026-02-11",
440
- "est_missed": 52400,
441
- "notes": "No spring order this year.",
442
- "country": "United States",
443
- "zip": "90210",
444
- "payment_terms": "60 Days",
445
- "pricelist": "Royal 1 (USD)",
446
- "tags": "Royal, Key account",
447
- "customer_since": "2024-06-15",
448
- "salesperson": "Naomi",
449
- "ar_open": 1240.5,
450
- "ar_overdue": 0,
451
- "ar_exposure": 1740.5,
452
- "top_category": "Ribbon",
453
- "top_category_pct": 0.95,
454
- "sku_count": 4,
455
- "top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
456
- "days_to_pay": null,
457
- "_created": "2023-06-15 09:15:00",
458
- "lat": 27.3364,
459
- "lon": -82.5307,
460
- "odoo_status": "Active",
461
- "dba": "Fisch",
462
- "ar_outstanding": 1240.5
463
- },
464
- {
465
- "pid": 107,
466
- "customer": "Verbena Market Florals",
467
- "status": "Lost",
468
- "agent": "Naomi Linnell Rivera",
469
- "city": "Austin",
470
- "state": "Texas (US)",
471
- "last_order": "2025-11-04",
472
- "est_missed": 78300,
473
- "notes": "Went with a competitor on freight terms.",
474
- "country": "United States",
475
- "zip": "08701",
476
- "payment_terms": "30 Days",
477
- "pricelist": "Fisch 1 (USD)",
478
- "tags": "Fisch",
479
- "customer_since": "2025-07-16",
480
- "salesperson": "Karen",
481
- "ar_open": 0,
482
- "ar_overdue": 5120.25,
483
- "ar_exposure": 5120.25,
484
- "top_category": "Foams & Finishes",
485
- "top_category_pct": 0.31,
486
- "sku_count": 27,
487
- "top_sku": "SATIN RIBBON 2IN",
488
- "days_to_pay": 61,
489
- "_created": "2023-07-15 09:16:00",
490
- "lat": null,
491
- "lon": null,
492
- "odoo_status": "Active",
493
- "dba": "Both",
494
- "ar_outstanding": 5120.25
495
- },
496
- {
497
- "pid": 108,
498
- "customer": "Larkspur Lane Supply",
499
- "status": "New",
500
- "agent": "Carla Jimenez",
501
- "city": "Denver",
502
- "state": "Colorado (US)",
503
- "last_order": "2026-07-14",
504
- "est_missed": 0,
505
- "notes": "First order in April.",
506
- "country": "United States",
507
- "zip": "60614",
508
- "payment_terms": "Immediate Payment",
509
- "pricelist": "Royal 1 (USD)",
510
- "tags": "(none)",
511
- "customer_since": "2026-08-17",
512
- "salesperson": "(none)",
513
- "ar_open": 8300,
514
- "ar_overdue": 940,
515
- "ar_exposure": 11440,
516
- "top_category": "All",
517
- "top_category_pct": 1.0,
518
- "sku_count": 1,
519
- "top_sku": "(none)",
520
- "days_to_pay": 12,
521
- "_created": "2023-08-15 09:17:00",
522
- "lat": 33.749,
523
- "lon": -84.388,
524
- "odoo_status": "Active",
525
- "dba": "Royal",
526
- "ar_outstanding": 9240
527
- }
528
- ]
529
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fields": [
3
+ {
4
+ "key": "customer",
5
+ "label": "Customer",
6
+ "type": "text",
7
+ "source": "odoo",
8
+ "pinned": true,
9
+ "default": true,
10
+ "description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
11
+ },
12
+ {
13
+ "key": "odoo_status",
14
+ "label": "Odoo record",
15
+ "type": "status",
16
+ "source": "odoo",
17
+ "default": false,
18
+ "description": "Whether this customer still exists in Odoo. Archived means deleted there."
19
+ },
20
+ {
21
+ "key": "agent",
22
+ "label": "Agent",
23
+ "type": "text",
24
+ "source": "odoo",
25
+ "default": true,
26
+ "description": "The sales agent who owns this account."
27
+ },
28
+ {
29
+ "key": "dba",
30
+ "label": "DBA",
31
+ "type": "select",
32
+ "source": "odoo",
33
+ "default": false,
34
+ "options": [
35
+ "Fisch",
36
+ "Royal",
37
+ "Both"
38
+ ],
39
+ "description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
40
+ },
41
+ {
42
+ "key": "salesperson",
43
+ "label": "Salesperson",
44
+ "type": "text",
45
+ "source": "odoo",
46
+ "default": false,
47
+ "description": "Who keyed in most of this customer's orders β€” not the Agent, who owns the account."
48
+ },
49
+ {
50
+ "key": "street",
51
+ "label": "Street",
52
+ "type": "text",
53
+ "source": "odoo",
54
+ "default": false,
55
+ "description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
56
+ },
57
+ {
58
+ "key": "street2",
59
+ "label": "Street 2",
60
+ "type": "text",
61
+ "source": "odoo",
62
+ "default": false,
63
+ "description": "Second address line (suite, unit, floor) on the customer's Odoo address."
64
+ },
65
+ {
66
+ "key": "city",
67
+ "label": "City",
68
+ "type": "text",
69
+ "source": "odoo",
70
+ "default": true,
71
+ "description": "City on the customer's Odoo address."
72
+ },
73
+ {
74
+ "key": "state",
75
+ "label": "State",
76
+ "type": "text",
77
+ "source": "odoo",
78
+ "default": true,
79
+ "description": "State or province on the customer's Odoo address."
80
+ },
81
+ {
82
+ "key": "country",
83
+ "label": "Country",
84
+ "type": "text",
85
+ "source": "odoo",
86
+ "default": false,
87
+ "description": "Country on the customer's Odoo address."
88
+ },
89
+ {
90
+ "key": "zip",
91
+ "label": "ZIP",
92
+ "type": "text",
93
+ "source": "odoo",
94
+ "default": false,
95
+ "description": "Postal code on the customer's Odoo address."
96
+ },
97
+ {
98
+ "key": "customer_since",
99
+ "label": "Customer since",
100
+ "type": "date",
101
+ "source": "odoo",
102
+ "default": false,
103
+ "description": "When this customer was first set up in Odoo."
104
+ },
105
+ {
106
+ "key": "tags",
107
+ "label": "Tags",
108
+ "type": "text",
109
+ "source": "odoo",
110
+ "default": false,
111
+ "description": "Odoo labels on this customer, comma-separated."
112
+ },
113
+ {
114
+ "key": "pricelist",
115
+ "label": "Price list",
116
+ "type": "text",
117
+ "source": "odoo",
118
+ "default": false,
119
+ "description": "The price list this customer buys on."
120
+ },
121
+ {
122
+ "key": "payment_terms",
123
+ "label": "Payment terms",
124
+ "type": "text",
125
+ "source": "odoo",
126
+ "default": false,
127
+ "description": "Payment terms on this customer's account β€” Net 30, for example."
128
+ },
129
+ {
130
+ "key": "last_order",
131
+ "label": "Last order",
132
+ "type": "date",
133
+ "source": "odoo",
134
+ "default": true,
135
+ "description": "Date of the most recent confirmed order."
136
+ },
137
+ {
138
+ "key": "overdue_days",
139
+ "label": "Overdue days",
140
+ "type": "int",
141
+ "source": "odoo",
142
+ "default": true,
143
+ "description": "How many days late this customer is running against their own usual ordering rhythm."
144
+ },
145
+ {
146
+ "_note": "filterable:false β€” DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β€” see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
147
+ "key": "est_missed",
148
+ "label": "Est. missed $",
149
+ "type": "currency",
150
+ "source": "odoo",
151
+ "default": true,
152
+ "agg": "sum",
153
+ "filterable": false,
154
+ "description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
155
+ },
156
+ {
157
+ "_note": "wave 21 R1 β€” KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β€” 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
158
+ "key": "ar_open",
159
+ "label": "AR current $",
160
+ "type": "currency",
161
+ "source": "odoo",
162
+ "default": false,
163
+ "description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
164
+ },
165
+ {
166
+ "key": "ar_overdue",
167
+ "label": "AR overdue $",
168
+ "type": "currency",
169
+ "source": "odoo",
170
+ "default": false,
171
+ "description": "Invoiced money past due β€” same basis as the Collections page."
172
+ },
173
+ {
174
+ "_note": "wave 21 R1 β€” the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β€” no second oracle.",
175
+ "key": "ar_outstanding",
176
+ "label": "AR outstanding $",
177
+ "type": "currency",
178
+ "source": "odoo",
179
+ "default": false,
180
+ "description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
181
+ },
182
+ {
183
+ "key": "ar_exposure",
184
+ "label": "Credit exposure $",
185
+ "type": "currency",
186
+ "source": "odoo",
187
+ "default": false,
188
+ "description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
189
+ },
190
+ {
191
+ "key": "ar_aged_1_30",
192
+ "label": "1-30 days $",
193
+ "type": "currency",
194
+ "source": "odoo",
195
+ "default": false,
196
+ "description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
197
+ },
198
+ {
199
+ "key": "ar_aged_31_60",
200
+ "label": "31-60 days $",
201
+ "type": "currency",
202
+ "source": "odoo",
203
+ "default": false,
204
+ "description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
205
+ },
206
+ {
207
+ "key": "ar_aged_61_90",
208
+ "label": "61-90 days $",
209
+ "type": "currency",
210
+ "source": "odoo",
211
+ "default": false,
212
+ "description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
213
+ },
214
+ {
215
+ "key": "ar_aged_90_plus",
216
+ "label": "90+ days $",
217
+ "type": "currency",
218
+ "source": "odoo",
219
+ "default": false,
220
+ "description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
221
+ },
222
+ {
223
+ "key": "days_to_pay",
224
+ "label": "Days to pay",
225
+ "type": "int",
226
+ "source": "odoo",
227
+ "default": false,
228
+ "description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
229
+ },
230
+ {
231
+ "key": "top_category",
232
+ "label": "Top category",
233
+ "type": "text",
234
+ "source": "odoo",
235
+ "default": false,
236
+ "description": "The category this customer spent the most on in the last 12 months."
237
+ },
238
+ {
239
+ "key": "top_category_pct",
240
+ "label": "Top category %",
241
+ "type": "pct",
242
+ "source": "odoo",
243
+ "default": false,
244
+ "description": "Share of last-12-months spend that went to the top category."
245
+ },
246
+ {
247
+ "key": "sku_count",
248
+ "label": "SKUs bought",
249
+ "type": "int",
250
+ "source": "odoo",
251
+ "default": false,
252
+ "description": "Distinct products bought in the last 12 months."
253
+ },
254
+ {
255
+ "key": "top_sku",
256
+ "label": "Top SKU",
257
+ "type": "text",
258
+ "source": "odoo",
259
+ "default": false,
260
+ "description": "The product this customer spent the most on in the last 12 months."
261
+ },
262
+ {
263
+ "key": "days_since",
264
+ "label": "Days since order",
265
+ "type": "int",
266
+ "source": "odoo",
267
+ "default": false,
268
+ "description": "Days since the last confirmed order."
269
+ },
270
+ {
271
+ "key": "typical_gap_days",
272
+ "label": "Typical gap days",
273
+ "type": "int",
274
+ "source": "odoo",
275
+ "default": false,
276
+ "description": "Days this customer usually goes between orders, from their own history."
277
+ },
278
+ {
279
+ "key": "notes",
280
+ "label": "Notes",
281
+ "type": "text",
282
+ "source": "overlay",
283
+ "default": false,
284
+ "description": "Your notes on this customer. Saved in this app only, visible only to you."
285
+ }
286
+ ],
287
+ "rows": [
288
+ {
289
+ "pid": 101,
290
+ "customer": "Poppy Flowers",
291
+ "status": "New",
292
+ "agent": "Naomi Linnell Rivera",
293
+ "city": "Charlottesville",
294
+ "state": "Virginia (US)",
295
+ "last_order": "2026-07-21",
296
+ "est_missed": 0,
297
+ "notes": "",
298
+ "country": "United States",
299
+ "zip": "02720",
300
+ "payment_terms": "30 Days",
301
+ "pricelist": "Fisch 1 (USD)",
302
+ "tags": "Royal",
303
+ "customer_since": "2023-01-10",
304
+ "salesperson": "Jessica",
305
+ "ar_open": 0,
306
+ "ar_overdue": 0,
307
+ "ar_exposure": 0,
308
+ "top_category": "Styrofoam",
309
+ "top_category_pct": 0.47,
310
+ "sku_count": 93,
311
+ "top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
312
+ "days_to_pay": 34,
313
+ "_created": "2023-01-15 09:10:00",
314
+ "lat": 25.7617,
315
+ "lon": -80.1918,
316
+ "odoo_status": "Archived",
317
+ "dba": "Royal",
318
+ "ar_outstanding": 0
319
+ },
320
+ {
321
+ "pid": 102,
322
+ "customer": "Meadow & Vine Wholesale",
323
+ "status": "Growing",
324
+ "agent": "Carla Jimenez",
325
+ "city": "Portland",
326
+ "state": "Oregon (US)",
327
+ "last_order": "2026-07-19",
328
+ "est_missed": 0,
329
+ "notes": "Expanding to a second storefront.",
330
+ "country": "United States",
331
+ "zip": "77041",
332
+ "payment_terms": "Immediate Payment",
333
+ "pricelist": "Royal 1 (USD)",
334
+ "tags": "Royal, Key account",
335
+ "customer_since": "2024-02-11",
336
+ "salesperson": "Naomi",
337
+ "ar_open": 1240.5,
338
+ "ar_overdue": 0,
339
+ "ar_exposure": 1740.5,
340
+ "top_category": "Ribbon",
341
+ "top_category_pct": 0.95,
342
+ "sku_count": 4,
343
+ "top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
344
+ "days_to_pay": null,
345
+ "_created": "2023-02-15 09:11:00",
346
+ "lat": 27.9506,
347
+ "lon": -82.4572,
348
+ "odoo_status": "Active",
349
+ "dba": "Fisch",
350
+ "ar_outstanding": 1240.5
351
+ },
352
+ {
353
+ "pid": 103,
354
+ "customer": "Bluestem Floral Supply",
355
+ "status": "Growing",
356
+ "agent": "Naomi Linnell Rivera",
357
+ "city": "Kansas City",
358
+ "state": "Missouri (US)",
359
+ "last_order": "2026-07-17",
360
+ "est_missed": 0,
361
+ "notes": "",
362
+ "country": "United States",
363
+ "zip": "11219",
364
+ "payment_terms": "60 Days",
365
+ "pricelist": "Royal 1 (USD)",
366
+ "tags": "Fisch",
367
+ "customer_since": "2025-03-12",
368
+ "salesperson": "Karen",
369
+ "ar_open": 0,
370
+ "ar_overdue": 5120.25,
371
+ "ar_exposure": 5120.25,
372
+ "top_category": "Foams & Finishes",
373
+ "top_category_pct": 0.31,
374
+ "sku_count": 27,
375
+ "top_sku": "SATIN RIBBON 2IN",
376
+ "days_to_pay": 61,
377
+ "_created": "2023-03-15 09:12:00",
378
+ "lat": 28.5384,
379
+ "lon": -81.3789,
380
+ "odoo_status": "Active",
381
+ "dba": "Both",
382
+ "ar_outstanding": 5120.25
383
+ },
384
+ {
385
+ "pid": 104,
386
+ "customer": "Camellia Row Florist",
387
+ "status": "Declining",
388
+ "agent": "Devon Marsh",
389
+ "city": "Savannah",
390
+ "state": "Georgia (US)",
391
+ "last_order": "2026-05-30",
392
+ "est_missed": 41200,
393
+ "notes": "Switched some volume to a local grower.",
394
+ "country": "United States",
395
+ "zip": "07649",
396
+ "payment_terms": "30 Days",
397
+ "pricelist": "Fisch 1 (USD)",
398
+ "tags": "(none)",
399
+ "customer_since": "2026-04-13",
400
+ "salesperson": "(none)",
401
+ "ar_open": 8300,
402
+ "ar_overdue": 940,
403
+ "ar_exposure": 11440,
404
+ "top_category": "All",
405
+ "top_category_pct": 1.0,
406
+ "sku_count": 1,
407
+ "top_sku": "(none)",
408
+ "days_to_pay": 12,
409
+ "_created": "2023-04-15 09:13:00",
410
+ "lat": 30.3322,
411
+ "lon": -81.6557,
412
+ "odoo_status": "Active",
413
+ "dba": "Royal",
414
+ "ar_outstanding": 9240
415
+ },
416
+ {
417
+ "pid": 105,
418
+ "customer": "Harborlight Wholesale Blooms",
419
+ "status": "Growing",
420
+ "agent": "Carla Jimenez",
421
+ "city": "Seattle",
422
+ "state": "Washington (US)",
423
+ "last_order": "2026-07-22",
424
+ "est_missed": 0,
425
+ "notes": "Top-10 account.",
426
+ "country": "United States",
427
+ "zip": "33125",
428
+ "payment_terms": "Immediate Payment",
429
+ "pricelist": "Royal 1 (USD)",
430
+ "tags": "Royal",
431
+ "customer_since": "2023-05-14",
432
+ "salesperson": "Jessica",
433
+ "ar_open": 0,
434
+ "ar_overdue": 0,
435
+ "ar_exposure": 0,
436
+ "top_category": "Styrofoam",
437
+ "top_category_pct": 0.47,
438
+ "sku_count": 93,
439
+ "top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
440
+ "days_to_pay": 34,
441
+ "_created": "2023-05-15 09:14:00",
442
+ "lat": 26.1224,
443
+ "lon": -80.1373,
444
+ "odoo_status": "Active",
445
+ "dba": "",
446
+ "ar_outstanding": 0
447
+ },
448
+ {
449
+ "pid": 106,
450
+ "customer": "Dogwood & Fern Co.",
451
+ "status": "Dormant",
452
+ "agent": "Devon Marsh",
453
+ "city": "Asheville",
454
+ "state": "North Carolina (US)",
455
+ "last_order": "2026-02-11",
456
+ "est_missed": 52400,
457
+ "notes": "No spring order this year.",
458
+ "country": "United States",
459
+ "zip": "90210",
460
+ "payment_terms": "60 Days",
461
+ "pricelist": "Royal 1 (USD)",
462
+ "tags": "Royal, Key account",
463
+ "customer_since": "2024-06-15",
464
+ "salesperson": "Naomi",
465
+ "ar_open": 1240.5,
466
+ "ar_overdue": 0,
467
+ "ar_exposure": 1740.5,
468
+ "top_category": "Ribbon",
469
+ "top_category_pct": 0.95,
470
+ "sku_count": 4,
471
+ "top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
472
+ "days_to_pay": null,
473
+ "_created": "2023-06-15 09:15:00",
474
+ "lat": 27.3364,
475
+ "lon": -82.5307,
476
+ "odoo_status": "Active",
477
+ "dba": "Fisch",
478
+ "ar_outstanding": 1240.5
479
+ },
480
+ {
481
+ "pid": 107,
482
+ "customer": "Verbena Market Florals",
483
+ "status": "Lost",
484
+ "agent": "Naomi Linnell Rivera",
485
+ "city": "Austin",
486
+ "state": "Texas (US)",
487
+ "last_order": "2025-11-04",
488
+ "est_missed": 78300,
489
+ "notes": "Went with a competitor on freight terms.",
490
+ "country": "United States",
491
+ "zip": "08701",
492
+ "payment_terms": "30 Days",
493
+ "pricelist": "Fisch 1 (USD)",
494
+ "tags": "Fisch",
495
+ "customer_since": "2025-07-16",
496
+ "salesperson": "Karen",
497
+ "ar_open": 0,
498
+ "ar_overdue": 5120.25,
499
+ "ar_exposure": 5120.25,
500
+ "top_category": "Foams & Finishes",
501
+ "top_category_pct": 0.31,
502
+ "sku_count": 27,
503
+ "top_sku": "SATIN RIBBON 2IN",
504
+ "days_to_pay": 61,
505
+ "_created": "2023-07-15 09:16:00",
506
+ "lat": null,
507
+ "lon": null,
508
+ "odoo_status": "Active",
509
+ "dba": "Both",
510
+ "ar_outstanding": 5120.25
511
+ },
512
+ {
513
+ "pid": 108,
514
+ "customer": "Larkspur Lane Supply",
515
+ "status": "New",
516
+ "agent": "Carla Jimenez",
517
+ "city": "Denver",
518
+ "state": "Colorado (US)",
519
+ "last_order": "2026-07-14",
520
+ "est_missed": 0,
521
+ "notes": "First order in April.",
522
+ "country": "United States",
523
+ "zip": "60614",
524
+ "payment_terms": "Immediate Payment",
525
+ "pricelist": "Royal 1 (USD)",
526
+ "tags": "(none)",
527
+ "customer_since": "2026-08-17",
528
+ "salesperson": "(none)",
529
+ "ar_open": 8300,
530
+ "ar_overdue": 940,
531
+ "ar_exposure": 11440,
532
+ "top_category": "All",
533
+ "top_category_pct": 1.0,
534
+ "sku_count": 1,
535
+ "top_sku": "(none)",
536
+ "days_to_pay": 12,
537
+ "_created": "2023-08-15 09:17:00",
538
+ "lat": 33.749,
539
+ "lon": -84.388,
540
+ "odoo_status": "Active",
541
+ "dba": "Royal",
542
+ "ar_outstanding": 9240
543
+ }
544
+ ]
545
+ }
web/src/automation/AutomationBuilder.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/automation/AutomationDetail.tsx CHANGED
@@ -171,6 +171,32 @@ export default function AutomationDetail({
171
  const cfg = (automation.config || {}) as Record<string, string | number | boolean>;
172
  const kind = automation.kind;
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  const [name, setName] = useState(automation.name || "");
175
  const [cron, setCron] = useState(automation.schedule?.cron || "0 6 * * *");
176
  const [message, setMessage] = useState("");
@@ -227,7 +253,10 @@ export default function AutomationDetail({
227
  Math.min(Math.max(1, Number(cfg.maxPosts) || 10), 12)
228
  );
229
 
230
- // --- discover_instagram half (R7)
 
 
 
231
  const [recordsLimit, setRecordsLimit] = useState(Number(cfg.recordsLimit || 25));
232
  const [joinOp, setJoinOp] = useState(String(cfg.operator || "and"));
233
  /**
@@ -348,10 +377,22 @@ export default function AutomationDetail({
348
  * ⚠ The trigger's OWN pickers keep using the trigger's own table; these are two different
349
  * questions and answering both from one variable is what produced the bug.
350
  */
351
- const walkTable =
352
- kind === "discover_instagram"
353
- ? String(cfg.targetTable || "") || String(discover?.table || "")
354
- : String(cfg.targetTable || "") || String(automation.trigger?.table || "");
 
 
 
 
 
 
 
 
 
 
 
 
355
 
356
  /**
357
  * WHAT STARTS THIS ONE, as a key. The server's stored trigger when it ships one;
@@ -474,8 +515,11 @@ export default function AutomationDetail({
474
  * a second reader of a string that exists to be shown to a person. One arithmetic, two
475
  * wordings, and neither wording is parsed by anything.
476
  */
 
 
 
477
  const heldCount = (): number =>
478
- kind === "discover_instagram" ? preds.length - completePreds().length : 0;
479
 
480
  const heldNote = (): string => {
481
  const held = heldCount();
@@ -498,7 +542,18 @@ export default function AutomationDetail({
498
  targetLabel,
499
  };
500
  }
501
- if (kind === "discover_instagram") {
 
 
 
 
 
 
 
 
 
 
 
502
  return {
503
  recordsLimit,
504
  operator: joinOp,
 
171
  const cfg = (automation.config || {}) as Record<string, string | number | boolean>;
172
  const kind = automation.kind;
173
 
174
+ /**
175
+ * ⭐⭐ WAVE 30 Β· W30-T17 β€” IS THIS A CORPUS SEARCH? ONE PREDICATE, AND THAT IS THE POINT.
176
+ *
177
+ * β›” THE DEFECT THIS CLOSES, AND WHY IT WAS A 400 RATHER THAN A MISSING FEATURE.
178
+ * `automation_engine.py:clean_config`'s discovery branch is already SHARED across both kinds
179
+ * (`if kind in ("discover_instagram","discover_tiktok")`) and it reads `recordsLimit` with NO
180
+ * fallback to the stored value β€” so a config that omits the key is read as `limit = 0` and
181
+ * refused with the owner's exact sentence about an unbounded discovery query. Three arms in
182
+ * this file tested `kind === "discover_instagram"` as a literal, so a `discover_tiktok`
183
+ * automation fell through `buildConfig` to `{ targetTable }` and every save after the first
184
+ * 400'd β€” on a panel that was showing the person a records limit the whole time.
185
+ *
186
+ * β›” A SHARED PREDICATE, NOT A SECOND STRING TEST, AND THE REASON IS MEASURED RATHER THAN
187
+ * STYLISTIC: TikTok's kind shipped in wave 29 and was missed in FOUR places here and THREE in
188
+ * the engine, because each site was an independent literal and none of them was wrong on its
189
+ * own. B is making the same change server-side in the same wave for the same reason. The next
190
+ * platform gets missed once β€” here β€” instead of seven times.
191
+ *
192
+ * ⚠ NOT the same question as "does this automation have a TikTok trigger". The kind is what
193
+ * `clean_config` branches on and what `RUNNERS` dispatches on; the trigger is what a person
194
+ * picked. `clean_definition`'s law 1 derives one from the other, server-side, and this client
195
+ * reads the RESULT rather than re-deriving it β€” two derivations of one fact is how the two
196
+ * halves disagree.
197
+ */
198
+ const isDiscovery = kind === "discover_instagram" || kind === "discover_tiktok";
199
+
200
  const [name, setName] = useState(automation.name || "");
201
  const [cron, setCron] = useState(automation.schedule?.cron || "0 6 * * *");
202
  const [message, setMessage] = useState("");
 
253
  Math.min(Math.max(1, Number(cfg.maxPosts) || 10), 12)
254
  );
255
 
256
+ // --- the DISCOVERY half (R7) β€” `discover_instagram` and, since W30-T17, `discover_tiktok`.
257
+ // ⚠ The 25 is not this panel's invention: it mirrors `automation_engine.py`'s own
258
+ // `DISCOVER_SEED_RECORDS`, which is what the server writes when a discovery trigger is
259
+ // first picked. Two numbers here would be two answers to "how many by default".
260
  const [recordsLimit, setRecordsLimit] = useState(Number(cfg.recordsLimit || 25));
261
  const [joinOp, setJoinOp] = useState(String(cfg.operator || "and"));
262
  /**
 
377
  * ⚠ The trigger's OWN pickers keep using the trigger's own table; these are two different
378
  * questions and answering both from one variable is what produced the bug.
379
  */
380
+ /*
381
+ ⭐ W30-T17 β€” BOTH DISCOVERY KINDS TAKE THIS BRANCH NOW, AND THE FALLBACK DELIBERATELY DOES NOT.
382
+ β›” `discover.table` IS THE INSTAGRAM VOCABULARY'S OWN TABLE, not "the discovery table".
383
+ MEASURED (scout, 2026-08-12): the wire carries exactly ONE `discover` object β€” built by
384
+ `routes_automation.py:list_automations` from `automation_engine.py:BD_FILTER_FIELDS` and
385
+ friends, with no platform argument anywhere on the route β€” and its `table` is Instagram's.
386
+ Widening this fallback to every discovery kind would therefore have offered a TikTok
387
+ automation the columns of an Instagram database: a list that looks authoritative and names
388
+ the wrong database, which is the exact failure the comment above says is worse than no list.
389
+ So TikTok gets `cfg.targetTable` (which `clean_config` sets to its own `ut_tt_profile`) or
390
+ NOTHING, and nothing renders as "this automation has no table for it to read" β€” true.
391
+ */
392
+ const walkTable = isDiscovery
393
+ ? String(cfg.targetTable || "") ||
394
+ (kind === "discover_instagram" ? String(discover?.table || "") : "")
395
+ : String(cfg.targetTable || "") || String(automation.trigger?.table || "");
396
 
397
  /**
398
  * WHAT STARTS THIS ONE, as a key. The server's stored trigger when it ships one;
 
515
  * a second reader of a string that exists to be shown to a person. One arithmetic, two
516
  * wordings, and neither wording is parsed by anything.
517
  */
518
+ /* W30-T17: both discovery kinds hold half-written rows back, because `completePreds` is what
519
+ `buildConfig` sends for both and a held row that is never announced is the silent-drop class
520
+ this module refuses everywhere else. A TikTok automation used to report 0 held, always. */
521
  const heldCount = (): number =>
522
+ isDiscovery ? preds.length - completePreds().length : 0;
523
 
524
  const heldNote = (): string => {
525
  const held = heldCount();
 
542
  targetLabel,
543
  };
544
  }
545
+ /*
546
+ β›” BOTH DISCOVERY KINDS, ONE SHAPE (W30-T17). This arm was `kind === "discover_instagram"`,
547
+ so a TikTok discovery automation fell all the way through to `{ targetTable }` and dropped
548
+ `recordsLimit` on EVERY save. The server's discovery branch reads that key with no
549
+ prev-fallback, so the drop is not "the old value survives" β€” it is `limit = 0`, which the
550
+ save door refuses outright. Instagram never showed it because this arm existed.
551
+ ⚠ The FIRST save is a different question and it is not fixed here: `pickTrigger` calls this
552
+ while `kind` is still `plain` (the server flips it after reading the trigger), so the
553
+ opening PATCH of a brand-new discovery automation carries no limit by construction. That
554
+ one is the engine's seed to set β€” W30-T04 β€” and it is why the two tickets are separate.
555
+ */
556
+ if (isDiscovery) {
557
  return {
558
  recordsLimit,
559
  operator: joinOp,
web/src/automation/AutomationSurface.tsx CHANGED
@@ -14,7 +14,7 @@
14
  // untouched.
15
  // ---------------------------------------------------------------------------
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";
@@ -26,16 +26,39 @@ import { AUTOMATION_OPEN_EVENT } from "../apiContract";
26
  // WAVE'S BRAND while a comment asserted parity. So this imports the component that POINTS at the
27
  // generated artifact. [[loopable-nav-logo-toggle]] β€” one element paints the mark.
28
  import { Mark } from "../shell/Brand";
29
- import AutomationDetail from "./AutomationDetail";
30
  import type { Automation, AutomationList } from "./automationApi";
31
  import {
32
  AutomationError,
 
33
  createAutomation,
34
  listAutomations,
35
  liveStepOf,
36
  patchAutomation,
37
  } from "./automationApi";
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  /**
40
  * The run poll's cadence (item 6). Named constants because they are a MEASURED trade-off, not a
41
  * taste: `POLL_MAX_MS` is the longest a finished run can still look live, and it is the only
@@ -185,7 +208,12 @@ function nextAutomationName(existing: Automation[]): string {
185
  }
186
 
187
  export default function AutomationSurface() {
188
- const [data, setData] = useState<AutomationList | null>(null);
 
 
 
 
 
189
  const [error, setError] = useState("");
190
  const [activeId, setActiveId] = useState<string>("");
191
  const [railShut, setRailShut] = useState(false);
@@ -574,7 +602,10 @@ export default function AutomationSurface() {
574
  The import went with it β€” it is what would break `npx tsc -b` for the WHOLE client the
575
  moment session B deletes the file, which is why this deletion is sequenced first.
576
  */}
 
 
577
  {active ? (
 
578
  <AutomationDetail
579
  key={active.id}
580
  automation={active}
@@ -631,6 +662,7 @@ export default function AutomationSurface() {
631
  await load();
632
  }}
633
  />
 
634
  ) : (
635
  /*
636
  * THE EMPTY STATE IS ONE LINE AND A BUTTON (owner ruling R13 β€” "never
 
14
  // untouched.
15
  // ---------------------------------------------------------------------------
16
  import type { KeyboardEvent as ReactKeyboardEvent } from "react";
17
+ import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
18
 
19
  import type { AutomationOpenDetail } from "../apiContract";
20
  import { AUTOMATION_OPEN_EVENT } from "../apiContract";
 
26
  // WAVE'S BRAND while a comment asserted parity. So this imports the component that POINTS at the
27
  // generated artifact. [[loopable-nav-logo-toggle]] β€” one element paints the mark.
28
  import { Mark } from "../shell/Brand";
 
29
  import type { Automation, AutomationList } from "./automationApi";
30
  import {
31
  AutomationError,
32
+ cachedAutomations,
33
  createAutomation,
34
  listAutomations,
35
  liveStepOf,
36
  patchAutomation,
37
  } from "./automationApi";
38
 
39
+ /**
40
+ * ⭐⭐ WAVE 30 Β· T21 (owner items 4 + 5) β€” THE EDITOR STOPS SHIPPING INSIDE THE LIST.
41
+ *
42
+ * β›” THIS `lazy()` IS THE TICKET. `AutomationDetail` was a STATIC import, and it statically pulls
43
+ * `AutomationBuilder` (which in turn pulls `CondBuilder`, `PresetPlan` and `TriggerPicker`),
44
+ * `AutomationFind` and `AutomationTrigger` β€” so the whole editor was linked into the one chunk a
45
+ * person must download before the RAIL can paint a list of names. MEASURED at 88,771 B, against
46
+ * `HomePage` at 4,814 and `ConnectorsPage` at 3,551. That is the "Automation takes forever to
47
+ * load" complaint, and it had survived two waves because nothing asserted a number.
48
+ *
49
+ * β›” AND IT NEEDS ITS OWN `<Suspense>`, NOT `Shell.tsx`'s. `Lazily` already wraps this whole
50
+ * surface, but a Suspense boundary catches ANY suspending descendant β€” so without an inner
51
+ * boundary, opening an automation would suspend the boundary that owns the RAIL and blank the
52
+ * list you just clicked in. The inner one keeps the rail painted while the editor streams in.
53
+ * `fallback={null}` is deliberate and is `Lazily`'s own choice, not a shortcut: the work area is
54
+ * simply not there for the moment the chunk is in flight, which is what every other route in this
55
+ * product already does. ⚠ A skeleton here would be a NEW loading screen in the same wave that
56
+ * exists to remove one (T22).
57
+ * ⚠ Default export, checked: `Shell.tsx:53` records that `lazy()` over a NAMED export fails at
58
+ * runtime, on click, and only for that one surface β€” `AutomationDetail` exports default.
59
+ */
60
+ const AutomationDetail = lazy(() => import("./AutomationDetail"));
61
+
62
  /**
63
  * The run poll's cadence (item 6). Named constants because they are a MEASURED trade-off, not a
64
  * taste: `POLL_MAX_MS` is the longest a finished run can still look live, and it is the only
 
208
  }
209
 
210
  export default function AutomationSurface() {
211
+ /* ⭐ T22 β€” SEEDED FROM THE CLIENT MEMO, so a revisit inside the freshness window never passes
212
+ through `data === null` and therefore never paints the skeleton. The lazy initialiser runs
213
+ once, on mount, BEFORE the first paint β€” `useState(cachedAutomations())` would call it on
214
+ every render instead, which is the same value at needless cost. A cold start still returns
215
+ null and still gets the skeleton: it is made rare, not removed (see `automationApi.ts`). */
216
+ const [data, setData] = useState<AutomationList | null>(() => cachedAutomations());
217
  const [error, setError] = useState("");
218
  const [activeId, setActiveId] = useState<string>("");
219
  const [railShut, setRailShut] = useState(false);
 
602
  The import went with it β€” it is what would break `npx tsc -b` for the WHOLE client the
603
  moment session B deletes the file, which is why this deletion is sequenced first.
604
  */}
605
+ {/* T21: the editor's own Suspense boundary β€” see the `lazy()` above for why it cannot be
606
+ `Shell.tsx`'s. `fallback={null}`, matching `Lazily`. */}
607
  {active ? (
608
+ <Suspense fallback={null}>
609
  <AutomationDetail
610
  key={active.id}
611
  automation={active}
 
662
  await load();
663
  }}
664
  />
665
+ </Suspense>
666
  ) : (
667
  /*
668
  * THE EMPTY STATE IS ONE LINE AND A BUTTON (owner ruling R13 β€” "never
web/src/automation/TriggerPicker.tsx CHANGED
@@ -27,6 +27,8 @@
27
  // ---------------------------------------------------------------------------
28
  import { useEffect, useRef, useState } from "react";
29
 
 
 
30
  import type { TriggerOption } from "./automationApi";
31
  import { groupTriggers, selectedTrigger } from "./steps";
32
 
@@ -46,6 +48,18 @@ import { groupTriggers, selectedTrigger } from "./steps";
46
  * icons).
47
  */
48
  export function TriggerMark({ kind = "" }: { kind?: string }) {
 
 
 
 
 
 
 
 
 
 
 
 
49
  const common = {
50
  width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
51
  strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
@@ -234,7 +248,7 @@ function TriggerList({
234
  title={t.detail || ""}
235
  onClick={() => onPick(t.key)}
236
  >
237
- <span className="autox-card-mark">
238
  <TriggerMark kind={t.key} />
239
  </span>
240
  <span className="autox-menu-text">
@@ -271,11 +285,42 @@ function TriggerList({
271
  reading "connector" in lower case. */}
272
  {g.label ? <p className="autox-menu-head">{g.label}</p> : null}
273
  {g.rows.map(row)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  {g.sub.map((s) => (
275
- <div className="autox-menu-sub" key={s.key}>
276
- <p className="autox-menu-subhead">{s.label}</p>
 
 
 
 
 
 
 
 
 
 
277
  {s.rows.map(row)}
278
- </div>
279
  ))}
280
  </div>
281
  ))}
@@ -367,7 +412,9 @@ export default function TriggerPicker({
367
  The `<select>` this replaces carried the same guard, and without it a trigger pointed
368
  at something withheld from this session would LOOK unconfigured and the next patch
369
  would write the blank over it. */}
370
- <span className="autox-card-mark">
 
 
371
  <TriggerMark kind={picked?.key || ""} />
372
  </span>
373
  <span className="autox-trigbtn-label">
 
27
  // ---------------------------------------------------------------------------
28
  import { useEffect, useRef, useState } from "react";
29
 
30
+ import { brandForConnector, brandForKind, hasBrandKind } from "../connectors/brandMarks";
31
+
32
  import type { TriggerOption } from "./automationApi";
33
  import { groupTriggers, selectedTrigger } from "./steps";
34
 
 
48
  * icons).
49
  */
50
  export function TriggerMark({ kind = "" }: { kind?: string }) {
51
+ /*
52
+ ⭐⭐ WAVE 30 Β· ITEM 7 / R3 + R4 β€” A BRAND WINS OVER THE HOUSE GLYPH, and it is resolved HERE
53
+ rather than at the four call sites. `TriggerMark` is drawn in the flyout, in the compact
54
+ Properties control, and by `AutomationBuilder` on the trigger card; a per-call-site check is
55
+ three chances to give the same trigger two identities on one screen.
56
+ β›” The geometric glyphs BELOW ARE NOT DELETED. They are the fallback for every unbranded
57
+ trigger and, for the two branded ones, the record of what this picker drew before the owner
58
+ asked for logos β€” a magnifier with a person in it, and the same frame with a note. Deleting
59
+ them would also delete the only description of why those two shapes differed.
60
+ */
61
+ const brand = brandForKind(kind);
62
+ if (brand) return brand;
63
  const common = {
64
  width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
65
  strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
 
248
  title={t.detail || ""}
249
  onClick={() => onPick(t.key)}
250
  >
251
+ <span className={"autox-card-mark" + (hasBrandKind(t.key) ? " is-brand" : "")}>
252
  <TriggerMark kind={t.key} />
253
  </span>
254
  <span className="autox-menu-text">
 
285
  reading "connector" in lower case. */}
286
  {g.label ? <p className="autox-menu-head">{g.label}</p> : null}
287
  {g.rows.map(row)}
288
+ {/*
289
+ ⭐⭐ WAVE 30 Β· ITEM 7 / R3 β€” THE CONNECTOR NEST IS A COLLAPSED DISCLOSURE NOW.
290
+ The owner, verbatim and for the third wave: *"Only when I click 'Scraper' under each
291
+ automation trigger and actions would I see the option to choose either Instagram OR
292
+ TikTok. That's it."*
293
+
294
+ β›” WHAT WAS HERE WAS THE COMPLAINT ITSELF, and it was an ASYMMETRY rather than a
295
+ missing feature: this rendered `<div className="autox-menu-sub">` with a `<p>` heading
296
+ β€” always open, merely indented β€” while `AutomationBuilder`'s action menu four inches
297
+ away already used exactly the markup below. One menu asked you to click; the other
298
+ showed you everything. So this is not a new pattern, it is the OTHER menu's pattern,
299
+ copied verbatim so the two cannot drift apart again.
300
+
301
+ ⚠ `<details>` and not a hand-built disclosure: it opens on Enter and Space with no
302
+ keydown handler, it needs no open/closed state of its own to get wrong, and it is
303
+ CLOSED by default, which is the whole ruling.
304
+ ⚠ The summary wears the same 26px `autox-card-mark` the rows beneath it wear (T19's
305
+ marks). A smaller slot here was the first version and it made the heading look like a
306
+ different KIND of thing from its own children β€” which is the asymmetry again, one
307
+ level down.
308
+ */}
309
  {g.sub.map((s) => (
310
+ <details className="autox-menu-nest" key={s.key}>
311
+ <summary className="autox-menu-row autox-menu-sum">
312
+ {brandForConnector(s.key) ? (
313
+ <span className="autox-card-mark is-brand">{brandForConnector(s.key)}</span>
314
+ ) : null}
315
+ <span className="autox-menu-text">
316
+ <span className="autox-menu-label">{s.label}</span>
317
+ <span className="autox-menu-detail">
318
+ {s.rows.length} trigger{s.rows.length === 1 ? "" : "s"}
319
+ </span>
320
+ </span>
321
+ </summary>
322
  {s.rows.map(row)}
323
+ </details>
324
  ))}
325
  </div>
326
  ))}
 
412
  The `<select>` this replaces carried the same guard, and without it a trigger pointed
413
  at something withheld from this session would LOOK unconfigured and the next patch
414
  would write the blank over it. */}
415
+ <span
416
+ className={"autox-card-mark" + (hasBrandKind(picked?.key || "") ? " is-brand" : "")}
417
+ >
418
  <TriggerMark kind={picked?.key || ""} />
419
  </span>
420
  <span className="autox-trigbtn-label">
web/src/automation/automationApi.ts CHANGED
@@ -24,11 +24,24 @@ import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, checkTenant, signal } from "..
24
  * `scrape_db` and `field_instagram` survive on the two automations that already use them and
25
  * no new one can be created (R6); `discover_instagram` is now reached by PICKING the
26
  * `ig_profile_match` trigger rather than by a wizard.
 
 
 
 
 
 
 
 
 
 
 
 
27
  */
28
  export type AutomationKind =
29
  | "scrape_db"
30
  | "field_instagram"
31
  | "discover_instagram"
 
32
  | "plain";
33
  export type RunState = "idle" | "running" | "ok" | "error" | "partial";
34
  /*
@@ -809,8 +822,52 @@ function send<T>(path: string, init?: RequestInit): Promise<T> {
809
  }).then((r) => result<T>(r));
810
  }
811
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  export function listAutomations(abort?: AbortSignal): Promise<AutomationList> {
813
- return send<AutomationList>("/automations", { signal: abort });
 
 
 
 
 
814
  }
815
 
816
  export function listTables(abort?: AbortSignal): Promise<{ tables: UserTable[] }> {
 
24
  * `scrape_db` and `field_instagram` survive on the two automations that already use them and
25
  * no new one can be created (R6); `discover_instagram` is now reached by PICKING the
26
  * `ig_profile_match` trigger rather than by a wizard.
27
+ *
28
+ * ⭐⭐ WAVE 30 Β· W30-T17 β€” `discover_tiktok` JOINS, AND IT WAS MISSING FROM THIS UNION SINCE THE
29
+ * DAY THE KIND SHIPPED. `automation_engine.py:KINDS` has carried it since wave 29, and
30
+ * `clean_definition`'s law-1 arm flips a `tiktok_profile_match` trigger to it UNCONDITIONALLY β€”
31
+ * so the server has been storing a kind this client's own type system said could not exist.
32
+ * β›” THE COST WAS NOT COSMETIC. A union that cannot express the value makes `kind ===
33
+ * "discover_tiktok"` a COMPILE ERROR, so every arm that had to learn TikTok
34
+ * (`buildConfig`, `heldCount`, `walkTable`) could not be written even by someone who noticed β€”
35
+ * the type was quietly enforcing the bug. This is the client half of [[wire-takes-a-list-caller-passes-one]]
36
+ * turned inside out: the wire carried a value the caller could not name.
37
+ * ⚠ It is a DISCOVERY kind, so the rule to reach for is the shared predicate, never a fourth
38
+ * string test: `AutomationDetail.tsx:isDiscovery` is the one place that decides.
39
  */
40
  export type AutomationKind =
41
  | "scrape_db"
42
  | "field_instagram"
43
  | "discover_instagram"
44
+ | "discover_tiktok"
45
  | "plain";
46
  export type RunState = "idle" | "running" | "ok" | "error" | "partial";
47
  /*
 
822
  }).then((r) => result<T>(r));
823
  }
824
 
825
+ /**
826
+ * ⭐⭐ WAVE 30 Β· T22 (owner item 4) β€” THE CLIENT MEMO THAT MAKES THE LOADING SCREEN RARE.
827
+ *
828
+ * β›” THE SKELETON IS NOT THE DEFECT AND MUST NOT BE DELETED. `AutomationSurface` renders
829
+ * `.auto-rail-loading` while `data === null`, and W29-T01 added it for a real reason: before it,
830
+ * a person who owns several automations was told "No automations yet." on every single visit,
831
+ * for the whole first round trip. Deleting it restores that false-empty. **The fix is to make
832
+ * `data === null` UNREACHABLE on a revisit, not to hide what it renders.**
833
+ *
834
+ * ⭐ WHY THE MEMO LIVES HERE AND NOT IN THE COMPONENT, which is the whole win: `Shell.tsx:1155`
835
+ * ALREADY calls `listAutomations()` on Home mount for its automation tiles. Memoising at this
836
+ * door β€” the one function both callers share β€” means Home β†’ Automation is paid for before the
837
+ * click, with **no edit to `shell/`** (another session's fence). A cache inside the component
838
+ * could never see Shell's call.
839
+ *
840
+ * ⚠ STALE-WHILE-REVALIDATE, STATED: the seed paints last-known rows and the mount effect still
841
+ * fetches, so a revisit shows real names instantly and corrects itself. Every mutation path in
842
+ * the surface already calls `load()`, which refreshes this memo, so nothing has to remember to
843
+ * invalidate it. An error leaves the memo intact ON PURPOSE β€” last-known rows beside an error
844
+ * banner beat an empty rail that blames the reader.
845
+ * ⚠ Module scope, so it dies with the tab. It is a paint accelerator, never storage β€” nothing
846
+ * here may outlive a session, because the account decides the tenant [[aios-multi-tenancy]].
847
+ */
848
+ const AUTOMATIONS_FRESH_MS = 5 * 60 * 1000;
849
+
850
+ let listMemo: { at: number; data: AutomationList } | null = null;
851
+
852
+ /** The last list, if it is still fresh β€” otherwise `null`, which is a COLD start and says so. */
853
+ export function cachedAutomations(): AutomationList | null {
854
+ if (!listMemo) return null;
855
+ if (Date.now() - listMemo.at > AUTOMATIONS_FRESH_MS) return null;
856
+ return listMemo.data;
857
+ }
858
+
859
+ /** Drop the memo. For a tenant/identity change β€” a paint accelerator must never cross accounts. */
860
+ export function forgetAutomations(): void {
861
+ listMemo = null;
862
+ }
863
+
864
  export function listAutomations(abort?: AbortSignal): Promise<AutomationList> {
865
+ return send<AutomationList>("/automations", { signal: abort }).then((data) => {
866
+ // ⚠ ONLY ON SUCCESS. `send` throws on a non-2xx, so a failed poll cannot overwrite a good
867
+ // list with nothing β€” which would turn a transient 500 into the false-empty above.
868
+ listMemo = { at: Date.now(), data };
869
+ return data;
870
+ });
871
  }
872
 
873
  export function listTables(abort?: AbortSignal): Promise<{ tables: UserTable[] }> {
web/src/connectors/ConnectorsPage.tsx CHANGED
@@ -8,10 +8,19 @@
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" | …`
@@ -20,6 +29,7 @@
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`. */
@@ -56,6 +66,22 @@ export interface ConnectorRow {
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 =
@@ -65,6 +91,25 @@ type Load =
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 [];
@@ -90,13 +135,20 @@ export function parseDirectory(body: unknown): ConnectorRow[] {
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();
@@ -205,12 +257,35 @@ export default function ConnectorsPage({
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
@@ -220,9 +295,20 @@ export default function ConnectorsPage({
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
@@ -231,6 +317,33 @@ export default function ConnectorsPage({
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}
@@ -266,9 +379,5 @@ export default function ConnectorsPage({
266
  </a>
267
  ) : null}
268
  </div>
269
- );
270
- })}
271
- </div>
272
- </div>
273
  );
274
  }
 
8
  // touching the shell's "an undeclared surface is denied" law (nav.ts'
9
  // `CHROME_ROUTES` note carries the full argument).
10
  //
11
+ // ⭐⭐ WAVE 30 · R4 SUPERSEDES WAVE-23 R8 FOR CONNECTOR IDENTITY. A connector card
12
+ // wears its company's REAL mark, drawn as inline SVG by `./brandMarks`. The owner
13
+ // asked for this three times; what stood here was the rule that refused it:
14
+ //
15
+ // "β›” NO THIRD-PARTY LOGOS AND NO EMOJIS (R8 + DESIGN.md Β§4). A connector's mark
16
+ // is a two-letter tile in the chip family every database already wears…"
17
+ //
18
+ // ⚠ THE REST OF THAT RULE IS UNCHANGED and is not weakened by R4: still no emojis
19
+ // anywhere, still nothing shipped, licensed or hotlinked (every mark is a React
20
+ // component in this directory), and `tileText` SURVIVES as the fallback for any
21
+ // connector we have no logo for β€” a two-letter tile is the honest answer to "we
22
+ // do not have that company's mark", and deleting it would have left every planned
23
+ // row with an empty square.
24
  //
25
  // ⚠ NO CLIENT UNION OVER `state` OR `kind`. They are the server's vocabulary and
26
  // arrive as strings (the wave-9 law): a client `type State = "connected" | …`
 
29
 
30
  import { useEffect, useState } from "react";
31
  import { API_V1, CREDENTIALS } from "../apiContract";
32
+ import { brandForConnector } from "./brandMarks";
33
 
34
  /** One row of the directory. Everything optional but `key` + `label` is genuinely optional on
35
  * the wire β€” a builtin has no provider, a planned connector has no `connectedAs`. */
 
66
  note?: string;
67
  /** What the admin will be asked for β€” shown where the decision is made, not in a tour. */
68
  hint?: string;
69
+ /**
70
+ * ⭐ WAVE 30 Β· R4 β€” the PLATFORMS behind one connector, today only the Scraper's.
71
+ *
72
+ * β›” NOT ROWS OF THEIR OWN, and the ruling is precisely about that: *"Only when I click
73
+ * 'Scraper' … would I see the option to choose either Instagram OR TikTok."* They carry no
74
+ * `state`, no credential and no manage door, because one key configures both β€” a per-platform
75
+ * state would be a second answer to a question that already has one.
76
+ */
77
+ platforms?: ConnectorPlatform[];
78
+ }
79
+
80
+ /** One platform inside a connector's drill. Name, sentence, and the key its mark is drawn from. */
81
+ export interface ConnectorPlatform {
82
+ key: string;
83
+ label: string;
84
+ desc: string;
85
  }
86
 
87
  type Load =
 
91
 
92
  const str = (v: unknown): string => (typeof v === "string" ? v : "");
93
 
94
+ /**
95
+ * The platform list, read the way `parseDirectory` reads a row: a platform with no name is
96
+ * DROPPED rather than rendered from its key, and a payload with no `platforms` at all yields
97
+ * `[]` β€” an older server simply shows a card with no drill, which is the honest degradation.
98
+ */
99
+ export function parsePlatforms(raw: unknown): ConnectorPlatform[] {
100
+ if (!Array.isArray(raw)) return [];
101
+ const out: ConnectorPlatform[] = [];
102
+ for (const item of raw) {
103
+ if (!item || typeof item !== "object") continue;
104
+ const p = item as Record<string, unknown>;
105
+ const key = str(p.key).trim();
106
+ const label = str(p.label).trim();
107
+ if (!key || !label) continue;
108
+ out.push({ key, label, desc: str(p.desc) });
109
+ }
110
+ return out;
111
+ }
112
+
113
  export function parseDirectory(body: unknown): ConnectorRow[] {
114
  const raw = (body as { connectors?: unknown } | null)?.connectors;
115
  if (!Array.isArray(raw)) return [];
 
135
  ...(str(c.startUrl) ? { startUrl: str(c.startUrl) } : {}),
136
  ...(str(c.note) ? { note: str(c.note) } : {}),
137
  ...(str(c.hint) ? { hint: str(c.hint) } : {}),
138
+ ...(parsePlatforms(c.platforms).length
139
+ ? { platforms: parsePlatforms(c.platforms) }
140
+ : {}),
141
  });
142
  }
143
  return out;
144
  }
145
 
146
+ /** The card's FALLBACK mark: two letters from the connector's own name, in the database chip
147
+ * family. Unicode-aware for the same reason the account monogram is (`Shell.tsx:299`).
148
+ * ⚠ SINCE WAVE 30 IT IS THE FALLBACK, NOT THE RULE (R4) β€” a connector we have a logo for wears
149
+ * the logo, and everything else still wears these two letters. Deleting this on the way to
150
+ * "real logos everywhere" would have emptied every card the mark module has no entry for,
151
+ * which is most of the planned list. */
152
  export function tileText(label: string): string {
153
  const chars = String(label ?? "").match(/[\p{L}\p{N}]/gu) ?? [];
154
  return chars.slice(0, 2).join("").toUpperCase();
 
257
  ) : null}
258
 
259
  <div className="conn-grid">
260
+ {rows.map((row) => (
261
+ <ConnectorCard key={row.key} row={row} onKeychain={onKeychain} />
262
+ ))}
263
+ </div>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ /**
269
+ * ONE CARD, EXPORTED β€” which is what lets `_conn_shot.tsx` paint the real thing.
270
+ *
271
+ * β›” EXTRACTED IN WAVE 30 SO THE DIRECTORY CAN BE LOOKED AT, and that is not a cosmetic reason.
272
+ * The page fetches on mount, and `renderToStaticMarkup` does not run effects β€” so a static render
273
+ * of `ConnectorsPage` shows a spinner and nothing else, forever. The owner's item 7 is a VISUAL
274
+ * ruling ("their logo to be fucking displayed"), and a ticket whose done-when is visual has to be
275
+ * provable by a picture of the shipping component rather than by a picture of a copy of it.
276
+ * ⚠ Behaviour is byte-identical: this is the same JSX, lifted whole out of the map.
277
+ */
278
+ export function ConnectorCard({
279
+ row,
280
+ onKeychain,
281
+ }: {
282
+ row: ConnectorRow;
283
+ onKeychain: () => void;
284
+ }) {
285
+ const planned = row.state === "planned";
286
+ const connected = row.state === "connected";
287
+ return (
288
  <div
 
289
  className={"conn-card" + (planned ? " is-planned" : "")}
290
  // β›” `aria-disabled`, NOT `inert`/`pointer-events` alone. A planned card is
291
  // non-interactive by R8, and a card that merely LOOKS faded while still taking a
 
295
  {...(planned ? { "aria-disabled": true } : {})}
296
  >
297
  <div className="conn-card-head">
298
+ {/* ⭐ WAVE 30 Β· R4 β€” THE LOGO, OR THE LETTERS. Two mutually exclusive marks, and
299
+ they take DIFFERENT chips deliberately: letters ride the base chip because
300
+ 12px text needs 4.5:1 and only `--lp-primary` clears it, while a logo brings
301
+ its own colour and wants neutral ground under it. Writing one span that
302
+ switched only its contents would have put a brand mark on a blue chip. */}
303
+ {brandForConnector(row.key, 22) ? (
304
+ <span className="conn-tile is-brand" aria-hidden="true">
305
+ {brandForConnector(row.key, 22)}
306
+ </span>
307
+ ) : (
308
+ <span className="shell-db-chip conn-tile" aria-hidden="true">
309
+ {tileText(row.label)}
310
+ </span>
311
+ )}
312
  <span className="conn-card-name">{row.label}</span>
313
  <span className={"conn-state" + stateTone(row.state)}>
314
  {/* The server's own word, sentence-cased by CSS never by `toUpperCase` β€” R6
 
317
  </span>
318
  </div>
319
  <p className="conn-card-desc">{row.desc}</p>
320
+ {/* ⭐⭐ WAVE 30 Β· R4 β€” THE DRILL, AND IT IS COLLAPSED FOR THE SAME REASON THE
321
+ TRIGGER MENU'S IS. The owner's words are a sequence, not a layout: *"Only when
322
+ I click 'Scraper' … would I see the option to choose either Instagram OR
323
+ TikTok."* So the platforms are behind a disclosure, not listed beside the
324
+ card's own sentence β€” and it is a real `<details>`, which opens on Enter and
325
+ Space with no keydown handler and carries no open/closed state to get wrong.
326
+ ⚠ These rows are INFORMATIONAL. A platform has no credential of its own, so it
327
+ offers no Connect button β€” the card's own action configures both, and a second
328
+ door here would be one that has nothing to open. */}
329
+ {row.platforms && row.platforms.length ? (
330
+ <details className="conn-plats">
331
+ <summary className="conn-plats-sum">
332
+ {row.platforms.length} platform{row.platforms.length === 1 ? "" : "s"}
333
+ </summary>
334
+ {row.platforms.map((p) => (
335
+ <div className="conn-plat" key={p.key}>
336
+ <span className="conn-plat-mark" aria-hidden="true">
337
+ {brandForConnector(p.key, 16)}
338
+ </span>
339
+ <span className="conn-plat-text">
340
+ <span className="conn-plat-name">{p.label}</span>
341
+ {p.desc ? <span className="conn-plat-desc">{p.desc}</span> : null}
342
+ </span>
343
+ </div>
344
+ ))}
345
+ </details>
346
+ ) : null}
347
  {connected && row.connectedAs ? (
348
  <p className="conn-card-meta">Connected as {row.connectedAs}</p>
349
  ) : null}
 
379
  </a>
380
  ) : null}
381
  </div>
 
 
 
 
382
  );
383
  }
web/src/connectors/brandMarks.tsx ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // connectors/brandMarks.tsx β€” WAVE 30, owner item 7 (rulings R3 + R4):
3
+ // THE ONE PLACE A CONNECTOR'S IDENTITY IS DRAWN.
4
+ //
5
+ // The owner, verbatim and for the third time: *"their Instagram and Tiktok logo
6
+ // to be fucking displayed, same with all logos under the Connectors module."*
7
+ //
8
+ // β›” THIS SUPERSEDES A RULE THAT WAS STILL SHIPPING. Wave-23 R8 said connector
9
+ // identity is a two-letter monogram in the chip family, and it was enforced β€”
10
+ // `verify_home.py:section_connectors` asserted a real mark could not appear.
11
+ // R4 replaces it FOR CONNECTOR IDENTITY ONLY. Everything else the design
12
+ // constitution says is untouched: no emojis anywhere, and no decorative
13
+ // third-party imagery outside this file's purpose, which is telling a person
14
+ // which company a row is about.
15
+ //
16
+ // β›” INLINE SVG, NEVER A FILE AND NEVER A REMOTE URL. There is no `assets/` or
17
+ // `icons/` directory in this app and there is not going to be one: every icon
18
+ // in the product is a React component, `index.css` inlines even its font as a
19
+ // `data:` URI, and `customer-grid/types.ts` carries the standing sentence
20
+ // *"Never an external URL: those are neither same-origin nor CSP-safe here."*
21
+ // A remote logo would work today (no CSP header is set) and would be the one
22
+ // thing on the page that can be taken away by somebody else's outage.
23
+ //
24
+ // β›” ONE MODULE FOR THREE SURFACES, and that is the whole design. The trigger
25
+ // picker, the action menu and the Connectors directory all answer "which
26
+ // company is this?" β€” three copies of a logo set is three chances to add a
27
+ // platform in two places and forget the third, which is precisely the defect
28
+ // wave 30 exists to fix on the server side of the same question.
29
+ //
30
+ // ⚠ WHAT THESE ARE, HONESTLY: recognisable SIMPLIFICATIONS drawn from each
31
+ // brand's published silhouette and brand colour, at 16px, not the trademarked
32
+ // artwork. That is the right call at this size β€” the real Google `G` is a
33
+ // 500-byte path that renders as mush in a 15px box β€” and it is stated here so
34
+ // nobody "upgrades" them by pasting a vendor's asset file into a repo that has
35
+ // no place to put one.
36
+ //
37
+ // ⚠ THEME. Marks are brand-coloured and must survive BOTH themes, so no mark
38
+ // relies on pure black or pure white for its silhouette: TikTok's note is
39
+ // `currentColor` with its two brand offsets behind it, which is also how the
40
+ // real mark is constructed.
41
+ // ---------------------------------------------------------------------------
42
+ import type { ReactElement } from "react";
43
+ import { useId } from "react";
44
+
45
+ /**
46
+ * The canonical brand names this module can draw. Deliberately NOT the same
47
+ * strings as the connector keys or the trigger kinds: three vocabularies point
48
+ * AT this one, and folding them together would make a rename in any of the
49
+ * three a silent loss of an icon.
50
+ */
51
+ export type BrandName =
52
+ | "instagram"
53
+ | "tiktok"
54
+ | "odoo"
55
+ | "google"
56
+ | "gmail"
57
+ | "stripe"
58
+ | "shopify"
59
+ | "webhooks"
60
+ | "scraper";
61
+
62
+ /**
63
+ * Trigger and action KIND -> brand.
64
+ *
65
+ * β›” KEYED ON THE KIND AND NOT ON THE CONNECTOR, and the reason is R3 itself:
66
+ * after this wave Instagram and TikTok share `connector.key === "scraper"`, so
67
+ * the connector can no longer tell them apart. The kind is the only thing that
68
+ * still distinguishes the two products, which is exactly why the picker's own
69
+ * `TriggerMark` has always been keyed this way.
70
+ *
71
+ * ⚠ An unmapped kind returns null and the caller draws its existing glyph β€” a
72
+ * trigger the engine adds tomorrow renders plainly rather than not at all.
73
+ */
74
+ export const BRAND_OF_KIND: Record<string, BrandName> = {
75
+ ig_profile_match: "instagram",
76
+ tiktok_profile_match: "tiktok",
77
+ enrich_instagram: "instagram",
78
+ enrich_tiktok: "tiktok",
79
+ field_instagram: "instagram",
80
+ };
81
+
82
+ /**
83
+ * Connector/provider KEY -> brand, for the Connectors directory and for the
84
+ * collapsed connector nests in both automation menus.
85
+ *
86
+ * ⚠ `scraper` and `webhooks` are OURS, not brands β€” they get house glyphs in
87
+ * `currentColor` so they sit in the chip family the way they always have. They
88
+ * are in this map rather than special-cased at the call site so that "what mark
89
+ * does this row wear" has ONE answer, whoever is asking.
90
+ */
91
+ export const BRAND_OF_CONNECTOR: Record<string, BrandName> = {
92
+ odoo: "odoo",
93
+ google: "google",
94
+ gmail: "gmail",
95
+ stripe: "stripe",
96
+ shopify: "shopify",
97
+ scraper: "scraper",
98
+ webhooks: "webhooks",
99
+ instagram: "instagram",
100
+ tiktok: "tiktok",
101
+ };
102
+
103
+ /** The house frame every mark is drawn in. 16Γ—16 user units, sized by the caller. */
104
+ function frame(size: number) {
105
+ return { width: size, height: size, viewBox: "0 0 16 16", "aria-hidden": true } as const;
106
+ }
107
+
108
+ /**
109
+ * ⚠ TIKTOK'S NOTE, DRAWN THREE TIMES. The real mark is one glyph offset in
110
+ * cyan and magenta behind a solid copy; drawing only the solid copy loses the
111
+ * brand entirely at this size, and drawing the offsets in black loses it in
112
+ * dark mode. The solid copy is `currentColor` so it inherits the row.
113
+ */
114
+ const TT_NOTE =
115
+ "M9.05 1.9h2.02c.13 1.2.87 2.13 2.03 2.3v2.03c-.78 0-1.53-.2-2.18-.58v3.98"
116
+ + "a3.62 3.62 0 1 1-3.62-3.62c.16 0 .32.01.48.03v2.06a1.6 1.6 0 1 0 1.27 1.56z";
117
+
118
+ export function BrandMark({
119
+ name,
120
+ size = 15,
121
+ }: {
122
+ name: BrandName;
123
+ size?: number;
124
+ }): ReactElement | null {
125
+ const f = frame(size);
126
+ // Per-instance, for the reason spelled out at the `<defs>` below. Hooks run for every
127
+ // name, not only Instagram, because a hook cannot sit behind a conditional return.
128
+ const gradId = `aios-brand-ig-${useId()}`;
129
+ if (name === "instagram")
130
+ return (
131
+ <svg {...f} fill="none">
132
+ {/*
133
+ β›”β›” THE GRADIENT ID IS PER-INSTANCE, AND THE FIXED ONE WAS A REAL BUG β€” FOUND BY A
134
+ PICTURE, NOT BY A GATE. It shipped for an hour as the constant `aios-brand-ig`, on the
135
+ reasoning that duplicate ids resolve to the FIRST definition and every definition is
136
+ byte-identical, so it cannot matter. MEASURED in the connectors shot: the Scraper card
137
+ holds an Instagram mark inside a COLLAPSED `<details>`, a second card below holds a
138
+ visible one β€” and the visible one painted NOTHING. A closed `<details>` is
139
+ `content-visibility: hidden`, its subtree is not rendered, and a paint server defined
140
+ inside a skipped subtree does not resolve for a user outside it. "The first definition
141
+ wins" IS the failure: whichever copy happens to be hidden decides for all of them.
142
+ ⚠ Nothing about `<details>` is special β€” `display:none` does the same. Any page holding
143
+ two of this mark with the first one hidden loses the second.
144
+ ⚠ `useId` rather than a module counter, so the markup stays DETERMINISTIC for a given
145
+ tree β€” the render gates could not assert against a random id β€” and stable across a
146
+ server render and its hydration.
147
+ ⚠ Every check that looks for this mark matches the PREFIX `url(#aios-brand-ig`.
148
+ */}
149
+ <defs>
150
+ {/* ⚠ `userSpaceOnUse` IS LOAD-BEARING AND ITS ABSENCE LOOKED FINE. The default is
151
+ `objectBoundingBox`, where the coordinates are FRACTIONS of the shape's box β€” so
152
+ `1,15 β†’ 15,1` put the whole gradient vector far outside every path and each one
153
+ painted a single clamped stop. The mark still rendered, still read as Instagram,
154
+ and was simply flat pink: a defect only a picture catches, which is why one was
155
+ taken ([[ui-invisible-to-assertions]]). In user space the numbers mean viewBox
156
+ units, which is what they were written as. */}
157
+ <linearGradient
158
+ id={gradId} gradientUnits="userSpaceOnUse"
159
+ x1="2" y1="14" x2="14" y2="2"
160
+ >
161
+ <stop offset="0" stopColor="#FEDA75" />
162
+ <stop offset="0.32" stopColor="#FA7E1E" />
163
+ <stop offset="0.62" stopColor="#D62976" />
164
+ <stop offset="1" stopColor="#962FBF" />
165
+ </linearGradient>
166
+ </defs>
167
+ <rect
168
+ x="1.7" y="1.7" width="12.6" height="12.6" rx="3.7"
169
+ stroke={`url(#${gradId})`} strokeWidth="1.5"
170
+ />
171
+ <circle cx="8" cy="8" r="3" stroke={`url(#${gradId})`} strokeWidth="1.5" />
172
+ <circle cx="11.6" cy="4.4" r="0.95" fill={`url(#${gradId})`} />
173
+ </svg>
174
+ );
175
+ if (name === "tiktok")
176
+ return (
177
+ <svg {...f}>
178
+ <path d={TT_NOTE} fill="#25F4EE" transform="translate(-0.9 -0.5)" />
179
+ <path d={TT_NOTE} fill="#FE2C55" transform="translate(0.9 0.5)" />
180
+ <path d={TT_NOTE} fill="currentColor" />
181
+ </svg>
182
+ );
183
+ if (name === "odoo")
184
+ return (
185
+ <svg {...f}>
186
+ {/* Odoo's brand purple, and its wordmark's twin `o`s β€” the part of that
187
+ wordmark that survives being shrunk to 15px. */}
188
+ <rect x="1" y="1" width="14" height="14" rx="3.4" fill="#714B67" />
189
+ <circle cx="5.7" cy="8.2" r="2.1" fill="none" stroke="#FFFFFF" strokeWidth="1.5" />
190
+ <circle cx="10.4" cy="8.2" r="2.1" fill="none" stroke="#FFFFFF" strokeWidth="1.5" />
191
+ </svg>
192
+ );
193
+ if (name === "google")
194
+ return (
195
+ <svg {...f} fill="none" strokeWidth="2.3">
196
+ {/* The four-arc G. Each arc is one brand colour, and the bar into the
197
+ centre is what makes it a G rather than a ring. */}
198
+ <path d="M4.46 4.46A5 5 0 0 1 11.54 4.46" stroke="#EA4335" strokeLinecap="round" />
199
+ <path d="M4.46 4.46A5 5 0 0 0 4.46 11.54" stroke="#FBBC05" strokeLinecap="round" />
200
+ <path d="M4.46 11.54A5 5 0 0 0 11.54 11.54" stroke="#34A853" strokeLinecap="round" />
201
+ <path d="M11.54 11.54A5 5 0 0 0 13 8" stroke="#4285F4" strokeLinecap="round" />
202
+ <path d="M13 8H8.4" stroke="#4285F4" />
203
+ </svg>
204
+ );
205
+ if (name === "gmail")
206
+ return (
207
+ <svg {...f} fill="none" stroke="#EA4335" strokeWidth="1.5" strokeLinejoin="round">
208
+ <rect x="1.6" y="3.6" width="12.8" height="9" rx="1.6" />
209
+ <path d="m2 4.4 6 4.6 6-4.6" />
210
+ </svg>
211
+ );
212
+ if (name === "stripe")
213
+ return (
214
+ <svg {...f}>
215
+ <rect x="1" y="1" width="14" height="14" rx="3.4" fill="#635BFF" />
216
+ <path
217
+ d="M10.5 5.6a2.3 2.3 0 0 0-3.6 1.2c0 2.1 4.1 1.5 4.1 3.5a2.4 2.4 0 0 1-3.7 1.3"
218
+ fill="none" stroke="#FFFFFF" strokeWidth="1.5" strokeLinecap="round"
219
+ />
220
+ </svg>
221
+ );
222
+ if (name === "shopify")
223
+ return (
224
+ <svg {...f}>
225
+ {/* The bag, in Shopify's two greens: the darker one is the handle, which
226
+ is the part that reads as a bag rather than as a box. */}
227
+ <path d="M3.5 5.2h9l-.75 8.4a.9.9 0 0 1-.9.8H5.15a.9.9 0 0 1-.9-.8z" fill="#95BF47" />
228
+ <path
229
+ d="M6.1 6.4V4.5a1.9 1.9 0 0 1 3.8 0v1.9"
230
+ fill="none" stroke="#5E8E3E" strokeWidth="1.4" strokeLinecap="round"
231
+ />
232
+ </svg>
233
+ );
234
+ /*
235
+ ⚠ THE LAST TWO ARE OURS, NOT BRANDS β€” `currentColor`, house stroke weight,
236
+ so they sit in the chip family beside the coloured logos rather than
237
+ pretending to be a company. Keeping them here is what lets every caller ask
238
+ one question instead of two.
239
+ */
240
+ if (name === "webhooks")
241
+ return (
242
+ <svg {...f} fill="none" stroke="currentColor" strokeWidth="1.4"
243
+ strokeLinecap="round" strokeLinejoin="round">
244
+ <path d="M6.6 9.4 4.9 11a2.6 2.6 0 1 1-1.5-4.4" />
245
+ <path d="M9.4 6.6 11.1 5a2.6 2.6 0 1 1 1.5 4.4" />
246
+ <path d="M6.2 8h3.6" />
247
+ </svg>
248
+ );
249
+ if (name === "scraper")
250
+ return (
251
+ <svg {...f} fill="none" stroke="currentColor" strokeWidth="1.4"
252
+ strokeLinecap="round" strokeLinejoin="round">
253
+ {/* A magnifier over a grid of rows: it reads a catalogue of profiles and
254
+ makes records out of what matches β€” which is what the bucket does,
255
+ and it is deliberately NOT either platform's mark, because the whole
256
+ point of R3 is that the bucket is neither one of them. */}
257
+ <path d="M2.6 3.4h10.8M2.6 6.2h5.2M2.6 9h3.4" />
258
+ <circle cx="10.4" cy="10.2" r="3" />
259
+ <path d="m12.6 12.4 1.6 1.6" />
260
+ </svg>
261
+ );
262
+ return null;
263
+ }
264
+
265
+ /**
266
+ * Does this kind wear a LOGO rather than a house glyph?
267
+ *
268
+ * β›” IT EXISTS BECAUSE THE TILE HAS TO CHANGE TOO, and forgetting that would have shipped the
269
+ * defect quietly: `.autox-card-mark` paints a blue tint and sets `color: var(--lp-blue-deep)`,
270
+ * so TikTok's note β€” which is `currentColor` by design, so it survives dark mode β€” would have
271
+ * rendered BLUE, and Instagram's gradient would have sat on a coloured chip. The caller adds
272
+ * `is-brand`, which neutralises both. One predicate, so the mark and its tile cannot disagree
273
+ * about whether a row is branded.
274
+ */
275
+ export function hasBrandKind(kind: string): boolean {
276
+ return Boolean(BRAND_OF_KIND[kind]);
277
+ }
278
+
279
+ /** The connector-key twin of `hasBrandKind`, for the directory's tiles. */
280
+ export function hasBrandConnector(key: string): boolean {
281
+ return Boolean(BRAND_OF_CONNECTOR[key]);
282
+ }
283
+
284
+ /** The mark for a trigger or action KIND, or null when it is not a branded one. */
285
+ export function brandForKind(kind: string, size = 15): ReactElement | null {
286
+ const name = BRAND_OF_KIND[kind];
287
+ return name ? <BrandMark name={name} size={size} /> : null;
288
+ }
289
+
290
+ /** The mark for a connector/provider KEY, or null when we have no logo for it. */
291
+ export function brandForConnector(key: string, size = 15): ReactElement | null {
292
+ const name = BRAND_OF_CONNECTOR[key];
293
+ return name ? <BrandMark name={name} size={size} /> : null;
294
+ }
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -25,6 +25,14 @@ import "@glideapps/glide-data-grid/dist/index.css";
25
 
26
  import { useCustomerData } from "./useCustomerData";
27
  import type { SurfaceScope } from "./apiBridge";
 
 
 
 
 
 
 
 
28
  import { defaultViewConfig, useGridColumns } from "./useGridColumns";
29
  import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
30
  unresolvedConditions, useVisibleRows } from "./useVisibleRows";
@@ -666,6 +674,7 @@ function CustomerGrid({
666
  overlayEdits,
667
  setOverlayEdits,
668
  patchOverlay,
 
669
  } = useCustomerData(scope, {
670
  bindSurface: !embedded,
671
  includeWorkspace: !embedded,
@@ -1402,6 +1411,77 @@ function CustomerGrid({
1402
  const displaySpec = cleanDisplay(config.display);
1403
  const displayMode: DisplayMode = serverWindowed ? "grid" : displaySpec?.mode ?? "grid";
1404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1405
  // CG-8 β€” measure conditions, answered by the host as `{ruleId: pid[]}`. Converted to Sets
1406
  // once here rather than per row: the pipeline runs this over every row of the book.
1407
  //
@@ -1576,7 +1656,20 @@ function CustomerGrid({
1576
  // pidToIndex is rebuilt over the slice because every index consumer (selection, detail
1577
  // navigation, hover) is positional against what is actually on screen.
1578
  const [displayCap, setDisplayCap] = useState(DISPLAY_PAGE);
1579
- const capped = visibleRows.length > displayCap;
 
 
 
 
 
 
 
 
 
 
 
 
 
1580
  /**
1581
  * ⭐⭐ WAVE-29 T33 (owner item 17) β€” THE TOTALS ROW, and the cap fix it forced.
1582
  *
@@ -1598,10 +1691,57 @@ function CustomerGrid({
1598
  ),
1599
  [visibleRows, fields]
1600
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1601
  const showTotals = useMemo(
1602
  () => !config.groupBy && Object.keys(totalsAggs).length > 0,
1603
  [config.groupBy, totalsAggs]
1604
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1605
  const displayRows = useMemo(() => {
1606
  const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows;
1607
  // ⚠ APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself β€” and it is the
@@ -5236,6 +5376,17 @@ function CustomerGrid({
5236
  // pixel of the marker column; it only makes its width a number we own.
5237
  // ═══ end W18-B VOID ═══
5238
  rowMarkerWidth={rowMarkerPx(displayRows.length)}
 
 
 
 
 
 
 
 
 
 
 
5239
  gridSelection={gridSelection}
5240
  onGridSelectionChange={onGridSelectionChange}
5241
  getRowThemeOverride={getRowThemeOverride}
@@ -5769,6 +5920,26 @@ function CustomerGrid({
5769
  count above still states the full matched count; this bar says what is painted.
5770
  Grid + list only: calendar/kanban project the FULL set with their own stated
5771
  bounds (a month, a stack's Show-all). */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5772
  {capped && (displayMode === "grid" || displayMode === "list") && (
5773
  <div className="cg-more">
5774
  <span className="cg-more-note">
 
25
 
26
  import { useCustomerData } from "./useCustomerData";
27
  import type { SurfaceScope } from "./apiBridge";
28
+ // ⭐ WAVE 30 Β· W30-T42 (contract C2) β€” the WINDOWED grid's arithmetic, pure and node-run in
29
+ // `verify_grid_ux.py`, because every one of these decisions is taken inside a callback where
30
+ // only its own source text could otherwise be checked.
31
+ import { WINDOW_ROWS } from "./apiBridge";
32
+ import {
33
+ EMPTY_WINDOW_PREDICATE, limitSummary, nextWindowOffset, windowedCapabilityNote,
34
+ windowedFoldNote, windowPredicateKey,
35
+ } from "./counts";
36
  import { defaultViewConfig, useGridColumns } from "./useGridColumns";
37
  import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
38
  unresolvedConditions, useVisibleRows } from "./useVisibleRows";
 
674
  overlayEdits,
675
  setOverlayEdits,
676
  patchOverlay,
677
+ requestWindow,
678
  } = useCustomerData(scope, {
679
  bindSurface: !embedded,
680
  includeWorkspace: !embedded,
 
1411
  const displaySpec = cleanDisplay(config.display);
1412
  const displayMode: DisplayMode = serverWindowed ? "grid" : displaySpec?.mode ?? "grid";
1413
 
1414
+ // ═══════════════════════════════════════════════════════════════════════════════════════
1415
+ // ⭐⭐ WAVE 30 Β· W30-T42 (contract C2) β€” PAGING A READ-THROUGH GRID.
1416
+ // ═══════════════════════════════════════════════════════════════════════════════════════
1417
+ //
1418
+ // The client engine is already refused in this mode (`useVisibleRows` passes rows straight
1419
+ // through), which is C2's "F may render no client-side predicate over a windowed grid". That
1420
+ // clause on its own would leave a filter chip INERT: the user narrows 32,826 orders, nothing
1421
+ // filters, and the count keeps reading 32,826 β€” honest about the scope and silent about the
1422
+ // question. So the predicate has to reach the evaluator that CAN answer it, which is SQL.
1423
+ //
1424
+ // β›” THE SAVED VIEW'S OWN OBJECTS GO ON THE WIRE, UNTRANSLATED (see `windowRowsPath`).
1425
+ const windowPredicate = useMemo(
1426
+ () => windowPredicateKey(config.filters, config.filterConj, config.sorts, search),
1427
+ [config.filters, config.filterConj, config.sorts, search]
1428
+ );
1429
+ const windowRequest = useCallback(
1430
+ (offset: number) => ({
1431
+ offset,
1432
+ limit: WINDOW_ROWS,
1433
+ filters: config.filters,
1434
+ filterConj: config.filterConj,
1435
+ sorts: config.sorts,
1436
+ search,
1437
+ }),
1438
+ [config.filters, config.filterConj, config.sorts, search]
1439
+ );
1440
+ const sentPredicate = useRef<string | null>(null);
1441
+ useEffect(() => {
1442
+ if (!serverWindowed) {
1443
+ sentPredicate.current = null;
1444
+ return;
1445
+ }
1446
+ if (sentPredicate.current === windowPredicate) return;
1447
+ // ⚠ THE FIRST WINDOW WENT OUT WITH NO QUERY ARGS AT ALL β€” `config` is this component's own
1448
+ // state, seeded from a workspace call that had not landed when the rows request left. So a
1449
+ // view carrying no filter, no sort and no search has ALREADY been answered, and firing here
1450
+ // would spend a round trip to receive the bytes on screen. Any other predicate is a real
1451
+ // question and goes out. One function owns both the key and the empty case, so "no
1452
+ // predicate" cannot be spelled two ways.
1453
+ if (sentPredicate.current === null && windowPredicate === EMPTY_WINDOW_PREDICATE) {
1454
+ sentPredicate.current = windowPredicate;
1455
+ return;
1456
+ }
1457
+ sentPredicate.current = windowPredicate;
1458
+ requestWindow(windowRequest(0));
1459
+ }, [serverWindowed, windowPredicate, windowRequest, requestWindow]);
1460
+
1461
+ /**
1462
+ * The scroll β†’ next window. glide reports the visible RECTANGLE; `nextWindowOffset` decides.
1463
+ *
1464
+ * ⚠ `counts.shown` IS THE LOADED COUNT, not `rawRows.length`. They are the same number today
1465
+ * and would stop being one the moment anything layers a row in that the server did not send
1466
+ * (`pendingRows` does exactly that on editable tables) β€” and the offset this produces is a
1467
+ * promise to the server about where our contiguous run ends. It has to come from the merge
1468
+ * that built the run.
1469
+ */
1470
+ const onVisibleRegionChanged = useCallback(
1471
+ (range: Rectangle) => {
1472
+ if (!serverWindowed) return;
1473
+ const offset = nextWindowOffset({
1474
+ lastVisibleRow: range.y + range.height,
1475
+ loaded: payload?.counts?.shown ?? 0,
1476
+ matched: payload?.counts?.matched ?? 0,
1477
+ limit: WINDOW_ROWS,
1478
+ });
1479
+ if (offset !== null) requestWindow(windowRequest(offset));
1480
+ },
1481
+ [serverWindowed, payload?.counts?.shown, payload?.counts?.matched, requestWindow,
1482
+ windowRequest]
1483
+ );
1484
+
1485
  // CG-8 β€” measure conditions, answered by the host as `{ruleId: pid[]}`. Converted to Sets
1486
  // once here rather than per row: the pipeline runs this over every row of the book.
1487
  //
 
1656
  // pidToIndex is rebuilt over the slice because every index consumer (selection, detail
1657
  // navigation, hover) is positional against what is actually on screen.
1658
  const [displayCap, setDisplayCap] = useState(DISPLAY_PAGE);
1659
+ /**
1660
+ * β›” W30-T42 β€” NO CLIENT CAP ON A WINDOWED TABLE, AND THE TWO CAPS WOULD HAVE FOUGHT.
1661
+ *
1662
+ * The display cap exists because the whole-book path holds every row and glide should not be
1663
+ * asked to lay out 33,000 of them at once. In `server-windowed` mode the WINDOW is already
1664
+ * that bound β€” one page of `WINDOW_ROWS` β€” so a second cap on top would stop the grid 50 rows
1665
+ * in, under a "Showing first 50 of 200" bar, while the toolbar says "showing 200 of 32,826".
1666
+ * Two truncation stories about one table, neither of them wrong, together a lie.
1667
+ *
1668
+ * β›” AND IT WOULD HAVE BROKEN THE PAGING OUTRIGHT: the scroll decision reads how far down the
1669
+ * LOADED rows the viewport reaches, and a capped grid can never scroll past the cap, so the
1670
+ * end of the window would be unreachable and the next window never requested.
1671
+ */
1672
+ const capped = !serverWindowed && visibleRows.length > displayCap;
1673
  /**
1674
  * ⭐⭐ WAVE-29 T33 (owner item 17) β€” THE TOTALS ROW, and the cap fix it forced.
1675
  *
 
1691
  ),
1692
  [visibleRows, fields]
1693
  );
1694
+ /**
1695
+ * ⭐⭐ WAVE 30 Β· W30-T42 (contract C2 / the ticket's own named trap) β€” THE TOTALS ROW OVER A
1696
+ * WINDOW IS DISCLOSED, NOT DELETED.
1697
+ *
1698
+ * β›” THE TRAP. `totalsAggs` folds `visibleRows`, which in `server-windowed` mode is exactly the
1699
+ * rows this browser has loaded. Painted in the table's footer with no denominator, "the sum of
1700
+ * whatever happens to be in memory" wears the sum of 32,826 orders β€” a fabricated aggregate,
1701
+ * the thing [[no-unverifiable-aggregates]] exists to forbid. Grouping is refused in this mode
1702
+ * for the same reason (a group header over a window subtotals the PAGE while claiming to
1703
+ * describe the group), and that refusal stays: a group is a claim about a SET, and the client
1704
+ * cannot see the set.
1705
+ *
1706
+ * β›” BUT ABSENT IS NOT ONE OF THE TWO HONEST STATES. T42's done-when offers exactly two β€”
1707
+ * "reads from the server" or "says plainly that it covers the loaded window" β€” and the wire
1708
+ * carries no aggregates (`rows/total/totalUnfiltered/offset/limit/limits`), so the second one
1709
+ * is the one available. The fold is CORRECT for a question nobody asked; naming the question
1710
+ * is what makes it honest, and the loaded rows are on screen and scrollable, so the number
1711
+ * still drills to rows. The disclosure is `windowedFoldNote`, and it is bound to `showTotals`
1712
+ * by ONE memo below rather than by two conditions that could drift apart β€” a totals row over a
1713
+ * window with its sentence missing is the whole defect back again.
1714
+ */
1715
  const showTotals = useMemo(
1716
  () => !config.groupBy && Object.keys(totalsAggs).length > 0,
1717
  [config.groupBy, totalsAggs]
1718
  );
1719
+ /** The denominator sentence β€” non-null EXACTLY when a totals row is painted over a window that
1720
+ * does not hold the whole matched set. Null on a whole-book table (the total is total) and on
1721
+ * a window that happens to hold everything (`windowedFoldNote` refuses to invent a
1722
+ * truncation that is not there). */
1723
+ const foldNote = useMemo(
1724
+ () =>
1725
+ serverWindowed && showTotals
1726
+ ? windowedFoldNote(payload?.counts?.shown ?? 0, payload?.counts?.matched ?? 0)
1727
+ : null,
1728
+ [serverWindowed, showTotals, payload?.counts?.shown, payload?.counts?.matched]
1729
+ );
1730
+ /** R6's SECOND SENTENCE, on the client side of the wire: the limits D's route DECLARED on this
1731
+ * response. Received and painted nothing = the limit is silent again, one layer further out. */
1732
+ const limitNote = useMemo(() => limitSummary(payload?.limits), [payload?.limits]);
1733
+ /**
1734
+ * β›” AND THE LIMIT NOBODY DECLARED, WHICH IS THE ONE A PERSON ACTUALLY NOTICES. Nine controls
1735
+ * this component gates on `serverWindowed` vanish the moment a grid becomes a window β€” export,
1736
+ * cohorts, folders, grouping, the four alternative views, select-from-file, alert badges β€” all
1737
+ * of which were working on this grid the day before, because the whole table used to be in the
1738
+ * browser. Every refusal is right on its own; a screen where eight buttons quietly disappear
1739
+ * is not. See `windowedCapabilityNote` for the list and why it is not a guess.
1740
+ */
1741
+ const capabilityNote = useMemo(
1742
+ () => (serverWindowed ? windowedCapabilityNote(payload?.counts?.matched ?? 0) : null),
1743
+ [serverWindowed, payload?.counts?.matched]
1744
+ );
1745
  const displayRows = useMemo(() => {
1746
  const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows;
1747
  // ⚠ APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself β€” and it is the
 
5376
  // pixel of the marker column; it only makes its width a number we own.
5377
  // ═══ end W18-B VOID ═══
5378
  rowMarkerWidth={rowMarkerPx(displayRows.length)}
5379
+ /**
5380
+ * ⭐⭐ W30-T42 (contract C2) β€” THE SCROLL THAT ASKS FOR THE NEXT WINDOW.
5381
+ *
5382
+ * ⚠ The W18-B VOID note above says there is deliberately no such handler "anywhere
5383
+ * near it", and that stays true of the void, which is arithmetic over the box and
5384
+ * must not become scroll-tracking. This one is a different subject: how far down
5385
+ * the LOADED rows the viewport has reached, which is the only question a windowed
5386
+ * grid can answer paging with. It is a no-op on every whole-book table (the
5387
+ * callback returns on `!serverWindowed` before reading anything).
5388
+ */
5389
+ onVisibleRegionChanged={onVisibleRegionChanged}
5390
  gridSelection={gridSelection}
5391
  onGridSelectionChange={onGridSelectionChange}
5392
  getRowThemeOverride={getRowThemeOverride}
 
5920
  count above still states the full matched count; this bar says what is painted.
5921
  Grid + list only: calendar/kanban project the FULL set with their own stated
5922
  bounds (a month, a stack's Show-all). */}
5923
+ {/* ⭐ W30-T42 β€” the WINDOWED grid's honest footnotes, in the strip that already exists
5924
+ for exactly this job. Reuses `.cg-more` / `.cg-more-note` rather than adding a
5925
+ class: `index.css` is another lane's fence this wave, and this needs no new pixel.
5926
+ `foldNote` is null unless a totals row is folded over a partial window; `limitNote`
5927
+ is null unless the SERVER declared a limit on this response (R6's second sentence β€”
5928
+ short on screen, the full cause and recommendation one hover away, the same
5929
+ short-form/`title` split `countLabelCompact` uses for the same strip). */}
5930
+ {!capped && (foldNote || limitNote || capabilityNote) && (
5931
+ <div className="cg-more">
5932
+ {foldNote && <span className="cg-more-note">{foldNote}</span>}
5933
+ {limitNote && (
5934
+ <span className="cg-more-note" title={limitNote.full}>{limitNote.short}</span>
5935
+ )}
5936
+ {capabilityNote && (
5937
+ <span className="cg-more-note" title={capabilityNote.full}>
5938
+ {capabilityNote.short}
5939
+ </span>
5940
+ )}
5941
+ </div>
5942
+ )}
5943
  {capped && (displayMode === "grid" || displayMode === "list") && (
5944
  <div className="cg-more">
5945
  <span className="cg-more-note">
web/src/customer-grid/ImportDialog.tsx CHANGED
@@ -92,11 +92,11 @@ export default function ImportDialog({
92
  body: form,
93
  });
94
  const body = (await res.json().catch(() => null)) as
95
- | (Parsed & { detail?: { error?: { message?: string } } })
96
  | null;
97
  if (!res.ok || !body) {
98
  setError(
99
- body?.detail?.error?.message ??
100
  `That file could not be read (the server answered ${res.status}).`
101
  );
102
  return;
@@ -145,11 +145,11 @@ export default function ImportDialog({
145
  }
146
  );
147
  const body = (await res.json().catch(() => null)) as
148
- | { imported?: number; detail?: { error?: { message?: string } } }
149
  | null;
150
  if (!res.ok) {
151
  // The server's sentence beats anything invented here: it names the column, or the cap.
152
- setError(body?.detail?.error?.message ?? `The server answered ${res.status}.`);
153
  return;
154
  }
155
  onImported(body?.imported ?? plan.rows.length);
 
92
  body: form,
93
  });
94
  const body = (await res.json().catch(() => null)) as
95
+ | (Parsed & { error?: { message?: string } })
96
  | null;
97
  if (!res.ok || !body) {
98
  setError(
99
+ body?.error?.message ??
100
  `That file could not be read (the server answered ${res.status}).`
101
  );
102
  return;
 
145
  }
146
  );
147
  const body = (await res.json().catch(() => null)) as
148
+ | { imported?: number; error?: { message?: string } }
149
  | null;
150
  if (!res.ok) {
151
  // The server's sentence beats anything invented here: it names the column, or the cap.
152
+ setError(body?.error?.message ?? `The server answered ${res.status}.`);
153
  return;
154
  }
155
  onImported(body?.imported ?? plan.rows.length);
web/src/customer-grid/apiBridge.ts CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/counts.ts CHANGED
@@ -9,7 +9,7 @@
9
  // asserted by aios-web/verify_filter_engine.py alongside the filter engine.
10
  // ---------------------------------------------------------------------------
11
 
12
- import type { ScopeCounts } from "./types";
13
 
14
  /**
15
  * A whole-book table holds every row, so its own count IS the truth.
@@ -89,3 +89,195 @@ export function countLabelCompact(recordCount: number, scope?: ScopeCounts): str
89
  }
90
  return `showing ${n(scope.shown)} of ${n(scope.matched)}`;
91
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  // asserted by aios-web/verify_filter_engine.py alongside the filter engine.
10
  // ---------------------------------------------------------------------------
11
 
12
+ import type { FilterNode, GridLimit, ScopeCounts, SortSpec } from "./types";
13
 
14
  /**
15
  * A whole-book table holds every row, so its own count IS the truth.
 
89
  }
90
  return `showing ${n(scope.shown)} of ${n(scope.matched)}`;
91
  }
92
+
93
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
94
+ // ⭐⭐ WAVE 30 Β· W30-T42 (contract C2) β€” THE THREE DECISIONS A WINDOWED GRID MAKES,
95
+ // EXTRACTED AS PURE FUNCTIONS SO THEY CAN BE RUN RATHER THAN GREPPED.
96
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
97
+ //
98
+ // Everything below is decided per scroll, per keystroke and per filter chip inside
99
+ // `CustomerGrid`, i.e. at CLICK TIME β€” the one place this lane has repeatedly found defects a
100
+ // source scan cannot see (W30-T40's fields panel built its body through a render prop; the
101
+ // crash was only reachable by opening the panel). A decision that lives in a callback can only
102
+ // be asserted by its own text; a decision that lives in an exported function can be FED the
103
+ // numbers and checked against its answer. So the callbacks up there hold wiring, and the
104
+ // arithmetic lives here, where `gridUx.test.ts` runs it under node.
105
+
106
+ /** The page size one window request asks for. See `apiBridge.WINDOW_ROWS` β€” this module holds
107
+ * no network knowledge, so the caller passes it in. */
108
+ export interface WindowScroll {
109
+ /**
110
+ * How far down the loaded rows the viewport reaches β€” the EXCLUSIVE end of glide's visible
111
+ * rectangle (`range.y + range.height`), i.e. the first row index below the fold. Named for
112
+ * what the caller passes rather than for a row it points at: it is one PAST the last visible
113
+ * row, and a doc that called it "the last row" would be off by one in a reader's head.
114
+ */
115
+ lastVisibleRow: number;
116
+ /** how many rows this browser holds right now (`ScopeCounts.shown`) */
117
+ loaded: number;
118
+ /** rows matching the predicate across the WHOLE scope (`ScopeCounts.matched`) */
119
+ matched: number;
120
+ /** the page size the next request would ask for */
121
+ limit: number;
122
+ /** how many rows from the end to start fetching. Default 25. */
123
+ lead?: number;
124
+ }
125
+
126
+ const DEFAULT_LEAD = 25;
127
+
128
+ /**
129
+ * "Should I fetch another window, and from which offset?" β€” `null` means no.
130
+ *
131
+ * β›” THE OFFSET IS THE LOADED COUNT, AND THAT IS THE WHOLE CONTRACT WITH THE SERVER. Windows
132
+ * are contiguous from 0 under a TOTAL order (`compile_order_by` appends the id as a tiebreak
133
+ * precisely so `LIMIT/OFFSET` cannot return one row on two pages), so "how many rows do I
134
+ * hold" and "where does the next page start" are the same number. Deriving it from a page
135
+ * counter instead would drift the moment one response comes back short.
136
+ *
137
+ * β›” AND IT REFUSES ON `matched <= loaded`. When the whole match set is already here there is
138
+ * nothing to page, and asking anyway would spend a request per scroll event forever against a
139
+ * server that answers with an empty window β€” a poll wearing an infinite scroll.
140
+ *
141
+ * ⚠ `loaded < 1` also refuses: the FIRST window belongs to the load effect, not to a scroll.
142
+ * A scroll event that fires before any payload has landed must not race it.
143
+ */
144
+ export function nextWindowOffset(s: WindowScroll): number | null {
145
+ const { lastVisibleRow, loaded, matched, limit } = s;
146
+ if (!Number.isFinite(limit) || limit < 1) return null;
147
+ if (!Number.isFinite(loaded) || loaded < 1) return null;
148
+ if (!Number.isFinite(matched) || matched <= loaded) return null;
149
+ const wanted = Number.isFinite(s.lead as number) ? (s.lead as number) : DEFAULT_LEAD;
150
+ const lead = Math.max(1, Math.min(wanted, limit));
151
+ if (!Number.isFinite(lastVisibleRow) || lastVisibleRow < loaded - lead) return null;
152
+ return loaded;
153
+ }
154
+
155
+ /**
156
+ * The sentence under a totals row that was folded over a WINDOW β€” `null` when there is
157
+ * nothing to disclose.
158
+ *
159
+ * β›” THE TICKET'S OWN TRAP, AND WHY THIS IS A DISCLOSURE RATHER THAN A DELETION. `totalsAggs`
160
+ * folds `visibleRows`, which in `server-windowed` mode is exactly the rows this browser has
161
+ * loaded. The number is therefore CORRECT for a question nobody asked ("the sum of the first
162
+ * 200 orders") and wrong for the one the position implies ("the sum of 32,826 orders"). This
163
+ * repo's law is that every number drills to rows ([[no-unverifiable-aggregates]]) β€” and the
164
+ * loaded rows ARE rows, on screen, scrollable. So the honest fix is to name the denominator,
165
+ * not to hide the fold: W30-T42's done-when offers exactly two acceptable states, "reads from
166
+ * the server" or "says plainly that it covers the loaded window", and absent is neither.
167
+ *
168
+ * ⚠ `null` WHEN `loaded >= matched`, and that is not an optimisation. There the loaded rows
169
+ * ARE every matching row, so the totals row is a true total and a caveat under it would invent
170
+ * a truncation that is not there β€” the same mistake `countLabel` avoids one function up.
171
+ */
172
+ export function windowedFoldNote(loaded: number, matched: number): string | null {
173
+ if (!Number.isFinite(loaded) || !Number.isFinite(matched)) return null;
174
+ if (loaded >= matched) return null;
175
+ const n = (v: number) => Math.max(0, Math.floor(v)).toLocaleString();
176
+ return `Column totals cover the ${n(loaded)} rows loaded so far, of ${n(matched)} matching.`;
177
+ }
178
+
179
+ /**
180
+ * The footer's report of the limits the SERVER declared β€” `{short, full}` or `null`.
181
+ *
182
+ * β›” R6's SECOND SENTENCE, ON THE CLIENT SIDE OF THE WIRE, AND IT IS THE HALF THAT GETS
183
+ * DROPPED. D's route already refuses to truncate silently: it names every limit that binds a
184
+ * response with its cause and its recommendation. A client that receives that array and paints
185
+ * nothing has re-created the exact violation the ruling forbids β€” the limit is now silent
186
+ * again, one layer further out, with a green gate on both sides of the wire.
187
+ *
188
+ * ⚠ SHORT ON SCREEN, FULL IN THE `title`. The causes are whole sentences (they have to be β€”
189
+ * "a number condition is evaluated in SQL at whole-unit precision…" is not compressible into a
190
+ * chip) and the strip they land in is 30px tall. `countLabelCompact` already established this
191
+ * pattern for the same strip and the same reason: the honest short form on screen, the complete
192
+ * statement one hover away. Nothing is dropped; the long half moves.
193
+ */
194
+ export function limitSummary(
195
+ limits: GridLimit[] | undefined
196
+ ): { short: string; full: string } | null {
197
+ const binding = (limits ?? []).filter(
198
+ (l) => l && typeof l.subject === "string" && (l.effect ?? "none") !== "none"
199
+ );
200
+ if (binding.length === 0) return null;
201
+ const subjects = binding.map((l) => l.subject).join(", ");
202
+ const full = binding
203
+ .map((l) => `${l.subject}: ${l.cause ?? ""}${l.recommendation ? ` β€” ${l.recommendation}` : ""}`)
204
+ .join("\n\n");
205
+ return { short: `Limits apply to ${subjects}`, full };
206
+ }
207
+
208
+ /**
209
+ * ⭐⭐ WAVE 30 Β· W30-T42 / R6's SECOND SENTENCE β€” WHAT A WINDOWED GRID CANNOT DO, SAID OUT LOUD.
210
+ *
211
+ * β›” THE THING THIS EXISTS TO PREVENT, AND IT IS A CLIFF NOBODY HAD WRITTEN DOWN. Turning
212
+ * `counts.windowed` on for the orders grid does not only change how rows arrive β€” nine controls
213
+ * `CustomerGrid` already gates on `serverWindowed` DISAPPEAR the same instant, every one of them
214
+ * with no explanation on screen. They were all working on that grid the day before, because the
215
+ * whole table was in the browser. Each refusal is individually correct β€” a cohort built from the
216
+ * 200 rows that happen to be in memory is a wrong cohort, an export of one page is a wrong
217
+ * export, a group header over a window subtotals the PAGE while claiming to describe the group β€”
218
+ * and a person who just watched eight buttons vanish is owed the reason.
219
+ *
220
+ * That is R6's second sentence exactly: *"if there is lag or it can't be done, you need to
221
+ * explicitly tell me why and recommend a fix."* A limit that cannot be removed must be REPORTED
222
+ * with its cause and a recommendation, never silently enforced. Silently enforcing this one would
223
+ * have been the violation, not the limit.
224
+ *
225
+ * ⚠ THE LIST IS THE CODE'S, NOT A GUESS: every entry below is a control `CustomerGrid.tsx` turns
226
+ * off on `serverWindowed`, and `verify_grid_ux.py` counts the gates so an item cannot be added
227
+ * there without a line here.
228
+ */
229
+ export const WINDOWED_TOOLS_OFF = [
230
+ "grouping and its subtotals",
231
+ "the kanban, calendar, list, map and catalog views",
232
+ "export",
233
+ "add to cohort, and remove from cohort",
234
+ "add to folder",
235
+ "select from file",
236
+ "view alert badges",
237
+ ];
238
+
239
+ export function windowedCapabilityNote(matched: number): { short: string; full: string } {
240
+ const n = Math.max(0, Math.floor(Number.isFinite(matched) ? matched : 0)).toLocaleString();
241
+ return {
242
+ short: "Some tools are off on this grid",
243
+ full:
244
+ `This database is read one page at a time out of ${n} records, so the tools that need `
245
+ + `every matching row in the browser are off here: ${WINDOWED_TOOLS_OFF.join(", ")}. `
246
+ + `Filtering, searching and sorting are NOT affected β€” they run on the server across all `
247
+ + `${n}. Restoring the rest needs the server to do those folds: an export, a cohort `
248
+ + `membership and a group summary computed over the whole match set rather than over the `
249
+ + `page in memory.`,
250
+ };
251
+ }
252
+
253
+ /**
254
+ * The predicate this browser has asked the SERVER to evaluate, as one comparable string.
255
+ *
256
+ * β›” WHY A KEY AND NOT A DEEP COMPARE AT THE CALL SITE. In `server-windowed` mode every
257
+ * predicate change is a REQUEST, so the effect that fires it must be able to say "this is the
258
+ * same question I already asked" β€” and `config.filters` is a fresh array on every render of a
259
+ * component that re-renders on hover. Keyed on the VALUE, the effect fires once per real
260
+ * change; keyed on identity it would fire per repaint, which is a paid round trip per mouse
261
+ * move on a 963,783-row grid.
262
+ *
263
+ * ⚠ ONE PLACE BUILDS BOTH THE KEY AND THE EMPTINESS TEST (`EMPTY_WINDOW_PREDICATE` below is
264
+ * this function's own answer for a blank view). Two spellings of "no predicate" is how the
265
+ * first window gets re-requested on every mount for nothing.
266
+ */
267
+ export function windowPredicateKey(
268
+ filters: FilterNode[] | undefined,
269
+ filterConj: string | undefined,
270
+ sorts: SortSpec | undefined,
271
+ search: string | undefined
272
+ ): string {
273
+ return JSON.stringify([
274
+ filters ?? [],
275
+ filterConj === "or" ? "or" : "and",
276
+ sorts ?? [],
277
+ (search ?? "").trim(),
278
+ ]);
279
+ }
280
+
281
+ /** What `windowPredicateKey` answers for a view with no filter, no sort and no search β€” i.e.
282
+ * exactly the question the FIRST window (offset 0, no query args) already answered. */
283
+ export const EMPTY_WINDOW_PREDICATE = windowPredicateKey([], "and", [], "");
web/src/customer-grid/icons.tsx CHANGED
@@ -27,10 +27,29 @@ import {
27
  TYPE_SHAPES,
28
  } from "./iconShapes";
29
 
30
- function Shapes({ shapes }: { shapes: IconShape[] }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  return (
32
  <>
33
- {shapes.map((s, i) =>
34
  s.fill ? (
35
  <path key={i} d={s.d} fill="currentColor" />
36
  ) : (
@@ -120,7 +139,10 @@ export function ToneModeIcon({
120
  viewBox="0 0 16 16"
121
  aria-hidden
122
  >
123
- {MODE_SHAPES[mode].map((s, i) =>
 
 
 
124
  s.fill ? (
125
  <path key={i} d={s.d} fill={paint.stroke} />
126
  ) : (
@@ -165,7 +187,8 @@ export function ChartKindIcon({
165
  viewBox="0 0 16 16"
166
  aria-hidden
167
  >
168
- {CHART_KIND_SHAPES[kind].map((s, i) => (
 
169
  <path
170
  key={i}
171
  d={s.d}
@@ -469,7 +492,8 @@ export function FolderMark({
469
  viewBox="0 0 16 16"
470
  aria-hidden
471
  >
472
- {FOLDER_SHAPE_PATHS[shape].map((s, i) => (
 
473
  <path
474
  key={i}
475
  d={s.d}
 
27
  TYPE_SHAPES,
28
  } from "./iconShapes";
29
 
30
+ /**
31
+ * ⭐ WAVE 30 item 2 (R5) β€” `shapes` IS OPTIONAL, and the `?? []` is the whole point.
32
+ *
33
+ * Every caller reaches this through a TOTAL `Record<K, IconShape[]>`, so TypeScript promises the
34
+ * lookup always hits. That promise is only as good as the key: `type` arrives over the WIRE, and
35
+ * a field whose type is not in the union β€” a kind added server-side, a stored definition from
36
+ * before a rename β€” makes `TYPE_SHAPES[type]` `undefined` at runtime with tsc none the wiser.
37
+ * `undefined.map` is a TypeError, and until this wave a TypeError anywhere unmounted the entire
38
+ * React tree: a blank white page, which is exactly what a user calls "it crashes".
39
+ *
40
+ * ⚠ RANKED SECOND AND SHIPPED ANYWAY (R5). Every field in `odoo_relational.order_fields` carries
41
+ * a type present in `TYPE_SHAPES`, and the server's `UT_FIELD_TYPES` filters unknown types off
42
+ * the wire β€” so this is LATENT, not the proven cause of owner item 2. The reporter was a user and
43
+ * cannot be re-interviewed, so both candidate causes are fixed rather than one guessed at.
44
+ *
45
+ * An absent lookup now paints NO GLYPH, which is the honest degradation: the row still renders,
46
+ * still carries its label, and the missing mark says "this type has no icon" rather than taking
47
+ * the product down to say it.
48
+ */
49
+ function Shapes({ shapes }: { shapes?: IconShape[] }) {
50
  return (
51
  <>
52
+ {(shapes ?? []).map((s, i) =>
53
  s.fill ? (
54
  <path key={i} d={s.d} fill="currentColor" />
55
  ) : (
 
139
  viewBox="0 0 16 16"
140
  aria-hidden
141
  >
142
+ {/* R5: `?? []` for the same reason as `Shapes` β€” `mode` is a STORED display mode off
143
+ the wire, so a view saved under a kind this build no longer knows makes the lookup
144
+ undefined and `.map` a TypeError. */}
145
+ {(MODE_SHAPES[mode] ?? []).map((s, i) =>
146
  s.fill ? (
147
  <path key={i} d={s.d} fill={paint.stroke} />
148
  ) : (
 
187
  viewBox="0 0 16 16"
188
  aria-hidden
189
  >
190
+ {/* R5: same guard, same reason β€” `kind` comes from a saved chart definition. */}
191
+ {(CHART_KIND_SHAPES[kind] ?? []).map((s, i) => (
192
  <path
193
  key={i}
194
  d={s.d}
 
492
  viewBox="0 0 16 16"
493
  aria-hidden
494
  >
495
+ {/* R5: same guard β€” a folder's shape is stored in nav prefs. */}
496
+ {(FOLDER_SHAPE_PATHS[shape] ?? []).map((s, i) => (
497
  <path
498
  key={i}
499
  d={s.d}
web/src/customer-grid/types.ts CHANGED
@@ -1283,6 +1283,29 @@ export function isPresetField(field: Field): boolean {
1283
  return (field.source === "odoo" && !field.custom) || isMachineOwned(field);
1284
  }
1285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1286
  /**
1287
  * ⭐ 2026-08-06, owner item 4 β€” **the cells a person must not type into.**
1288
  *
@@ -3268,6 +3291,25 @@ export interface ScopeCounts {
3268
  windowed: boolean;
3269
  }
3270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3271
  export interface CustomersPayload {
3272
  fields: Field[];
3273
  rows: Row[];
@@ -3276,6 +3318,8 @@ export interface CustomersPayload {
3276
  workspace?: GridWorkspace;
3277
  /** Present only for server-windowed tables. See ScopeCounts. */
3278
  counts?: ScopeCounts;
 
 
3279
  /** Assignable people for `user` fields β€” supplied by the host from the tenant's real user
3280
  * list, never invented client-side, so an assignee is always someone who can log in. */
3281
  userOptions?: string[];
 
1283
  return (field.source === "odoo" && !field.custom) || isMachineOwned(field);
1284
  }
1285
 
1286
+ /**
1287
+ * ⭐ WAVE 30 item 2 (R5) β€” THE ONE PLACE A FIELD'S NAME IS RESOLVED, and why it is a function.
1288
+ *
1289
+ * `Field.label` is declared `string` and arrives over the WIRE, where nothing enforces that. The
1290
+ * fields panel did `f.label.toLowerCase()` unguarded, so a single label-less definition made a
1291
+ * TypeError the moment a search query was typed β€” and before this wave a TypeError anywhere
1292
+ * unmounted the whole React tree, which is the blank page a user calls "it crashes".
1293
+ *
1294
+ * ⚠ IT LIVES HERE, BESIDE THE OTHER FIELD PREDICATES, RATHER THAN INSIDE THE PANEL, for two
1295
+ * reasons that are worth keeping. A panel-local helper is invisible to every other surface that
1296
+ * will make the same read; and a panel-local helper cannot be RUN under this repo's node harness,
1297
+ * so the search path β€” the one that only fires once somebody types β€” would have had no test at all.
1298
+ *
1299
+ * ⚠ FALLS BACK TO THE KEY, never to "". A column with no label is still findable and still
1300
+ * nameable by the thing it actually is; an empty string would produce a row a user can see, cannot
1301
+ * search for, and cannot name in a report.
1302
+ */
1303
+ export function fieldLabel(field: Field): string {
1304
+ return typeof field.label === "string" && field.label
1305
+ ? field.label
1306
+ : String(field.key ?? "");
1307
+ }
1308
+
1309
  /**
1310
  * ⭐ 2026-08-06, owner item 4 β€” **the cells a person must not type into.**
1311
  *
 
3291
  windowed: boolean;
3292
  }
3293
 
3294
+ /**
3295
+ * ⭐ WAVE 30 (contract C2 / R6's SECOND SENTENCE) β€” one limit the server could not remove.
3296
+ *
3297
+ * Authored by `routes_odoo_tables.odoo_table_rows`, which refuses to truncate silently: a
3298
+ * predicate it cannot push down, a window it had to clamp, a page deep enough to be slow, each
3299
+ * arrives named, with the cause and the recommendation the owner's ruling demands. The client's
3300
+ * only job is to not swallow it β€” see `counts.limitSummary`.
3301
+ *
3302
+ * ⚠ `effect: "none"` is a limit that did NOT bind this response; `"truncated"` is never emitted
3303
+ * by design (the route raises instead), and the value exists so that a future change cannot
3304
+ * start trimming without saying so.
3305
+ */
3306
+ export interface GridLimit {
3307
+ subject: string;
3308
+ cause: string;
3309
+ recommendation: string;
3310
+ effect?: string;
3311
+ }
3312
+
3313
  export interface CustomersPayload {
3314
  fields: Field[];
3315
  rows: Row[];
 
3318
  workspace?: GridWorkspace;
3319
  /** Present only for server-windowed tables. See ScopeCounts. */
3320
  counts?: ScopeCounts;
3321
+ /** C2 β€” limits the SERVER declared on this response. Empty/absent = nothing binds. */
3322
+ limits?: GridLimit[];
3323
  /** Assignable people for `user` fields β€” supplied by the host from the tenant's real user
3324
  * list, never invented client-side, so an assignee is always someone who can log in. */
3325
  userOptions?: string[];
web/src/customer-grid/useCustomerData.ts CHANGED
@@ -28,12 +28,18 @@ import { topicForScope } from "./types";
28
  import type { CustomersPayload, Field, GridWorkspace, Row } from "./types";
29
  import {
30
  clearTopicRowsCache,
 
 
31
  fetchTopicRows,
32
  fetchWorkspace,
 
33
  patchTopicRow,
 
34
  setSurfaceScope,
35
  subscribeChanges,
 
36
  } from "./apiBridge";
 
37
  import { DERIVED_CELLS_EVENT, ROWS_STALE_EVENT, WORKSPACE_STALE_EVENT } from "../apiContract";
38
  import { planChangeReaction, rowsRefetchDelay } from "./liveWorkspace";
39
  import { signal } from "../apiContract";
@@ -165,6 +171,14 @@ export interface CustomerData {
165
  setOverlayEdits: Dispatch<SetStateAction<OverlayEdits>>;
166
  /** Optimistic write + PATCH /api/customers/{pid}; rolls back on failure. */
167
  patchOverlay: (pid: number, updates: Partial<Row>) => void;
 
 
 
 
 
 
 
 
168
  }
169
 
170
  interface CustomerDataOptions {
@@ -210,6 +224,26 @@ export function useCustomerData(
210
  // something OUTSIDE the grid's own write path changed rows (shell Add-record, an automation
211
  // run). Senders clear the rows cache first, so the refetch cannot be served from the memo.
212
  const [rowsBump, setRowsBump] = useState(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  /** Item 3 (3c) β€” the LAST scope whose first paint has already been attempted. See the
214
  * `firstPaint` note in the load effect: this is what keeps a workspace failure loud on
215
  * arrival and silent on every refetch afterwards. */
@@ -252,6 +286,12 @@ export function useCustomerData(
252
  // bump without this is served the copy that predates the change we just detected β€” a
253
  // refetch that costs a request, succeeds, and shows the user nothing.
254
  clearTopicRowsCache(topic.rowsPath);
 
 
 
 
 
 
255
  lastRowsRead = Date.now();
256
  setRowsBump((b) => b + 1);
257
  };
@@ -346,17 +386,48 @@ export function useCustomerData(
346
  const firstPaint = loudScope.current !== scope;
347
  loudScope.current = scope;
348
 
 
 
349
  async function load() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  const [payload, workspace] = await Promise.all([
351
- fetchTopicRows(topic),
352
- // A workspace that cannot be read at first paint is not "a table with no saved
353
- // views" β€” it is a table whose schema we do not have, and the grid used to fill
354
- // that hole from a localStorage bucket shared with every other surface.
355
- includeWorkspace
356
- ? fetchWorkspace(scope, { announceFailure: firstPaint })
357
- : Promise.resolve(null),
 
358
  ]);
359
- if (cancelled || !payload) return;
360
  setData(workspace ? withWorkspace(payload, workspace) : payload);
361
  }
362
 
@@ -499,6 +570,35 @@ export function useCustomerData(
499
  });
500
  }, [topic, writable]);
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  return {
503
  fields: data?.fields ?? [],
504
  rawRows: data?.rows ?? [],
@@ -507,5 +607,6 @@ export function useCustomerData(
507
  overlayEdits,
508
  setOverlayEdits,
509
  patchOverlay,
 
510
  };
511
  }
 
28
  import type { CustomersPayload, Field, GridWorkspace, Row } from "./types";
29
  import {
30
  clearTopicRowsCache,
31
+ dropTableRowsCache,
32
+ fetchTableWindow,
33
  fetchTopicRows,
34
  fetchWorkspace,
35
+ mergeWindow,
36
  patchTopicRow,
37
+ readThroughTables,
38
  setSurfaceScope,
39
  subscribeChanges,
40
+ WINDOW_ROWS,
41
  } from "./apiBridge";
42
+ import type { RowWindowRequest } from "./apiBridge";
43
  import { DERIVED_CELLS_EVENT, ROWS_STALE_EVENT, WORKSPACE_STALE_EVENT } from "../apiContract";
44
  import { planChangeReaction, rowsRefetchDelay } from "./liveWorkspace";
45
  import { signal } from "../apiContract";
 
171
  setOverlayEdits: Dispatch<SetStateAction<OverlayEdits>>;
172
  /** Optimistic write + PATCH /api/customers/{pid}; rolls back on failure. */
173
  patchOverlay: (pid: number, updates: Partial<Row>) => void;
174
+ /**
175
+ * ⭐ WAVE 30 Β· W30-T42 (contract C2) β€” ASK THE SERVER FOR A WINDOW OF A READ-THROUGH GRID.
176
+ *
177
+ * `offset: 0` replaces (a new predicate); `offset > 0` appends (a scroll). A no-op on a
178
+ * whole-book table, so the caller never has to ask which mode it is in twice β€” `serverWindowed`
179
+ * already answers that from `payload.counts`, and a second test here could disagree with it.
180
+ */
181
+ requestWindow: (req: RowWindowRequest) => void;
182
  }
183
 
184
  interface CustomerDataOptions {
 
224
  // something OUTSIDE the grid's own write path changed rows (shell Add-record, an automation
225
  // run). Senders clear the rows cache first, so the refetch cannot be served from the memo.
226
  const [rowsBump, setRowsBump] = useState(0);
227
+ /**
228
+ * ⭐ WAVE 30 Β· W30-T42 (contract C2) β€” the three refs a WINDOWED grid needs, and why each one
229
+ * is a ref rather than state: none of them may re-render the grid on its own.
230
+ *
231
+ * `readThroughRef` does THIS table read through the mirror? Answered once per open from
232
+ * `odoo-tables/status`, never from a key list here. Reset to false the
233
+ * moment the scope changes, so a switch cannot carry the previous table's
234
+ * answer into the new table's first scroll.
235
+ * `windowSeq` ONE ordering token shared by the load effect and `requestWindow`. Two
236
+ * paths can `setData` for the same surface (a rowsBump re-load while a
237
+ * scroll is in flight); the newest request wins and every older response
238
+ * is dropped rather than merged, because a merge of two orders is rows in
239
+ * the wrong places with nothing erroring.
240
+ * `inflightOffset` glide fires `onVisibleRegionChanged` on EVERY frame of a drag. Without
241
+ * this the same page is requested dozens of times before the first answer
242
+ * lands β€” a paid round trip per animation frame.
243
+ */
244
+ const readThroughRef = useRef(false);
245
+ const windowSeq = useRef(0);
246
+ const inflightOffset = useRef<number | null>(null);
247
  /** Item 3 (3c) β€” the LAST scope whose first paint has already been attempted. See the
248
  * `firstPaint` note in the load effect: this is what keeps a workspace failure loud on
249
  * arrival and silent on every refetch afterwards. */
 
286
  // bump without this is served the copy that predates the change we just detected β€” a
287
  // refetch that costs a request, succeeds, and shows the user nothing.
288
  clearTopicRowsCache(topic.rowsPath);
289
+ // ⚠ AND THE WINDOWED FAMILY, which `topic.rowsPath` cannot name: a read-through grid is
290
+ // served from `odoo-tables/<key>/rows?…` under one key per offset Γ— predicate. Without
291
+ // this the poller notices a change, re-runs the load effect, and the load effect is
292
+ // served the pre-change window straight out of the memo β€” a refetch that costs a request,
293
+ // succeeds, and shows the user nothing (the exact defect the line above exists to fix).
294
+ dropTableRowsCache(scope);
295
  lastRowsRead = Date.now();
296
  setRowsBump((b) => b + 1);
297
  };
 
386
  const firstPaint = loudScope.current !== scope;
387
  loudScope.current = scope;
388
 
389
+ readThroughRef.current = false;
390
+
391
  async function load() {
392
+ const seq = ++windowSeq.current;
393
+ // A workspace that cannot be read at first paint is not "a table with no saved
394
+ // views" β€” it is a table whose schema we do not have, and the grid used to fill
395
+ // that hole from a localStorage bucket shared with every other surface.
396
+ //
397
+ // ⚠ STARTED BEFORE THE READ-THROUGH QUESTION IS ASKED, deliberately. The roster below is
398
+ // memoised per session but its FIRST call is a real round trip, and awaiting it before
399
+ // this line would serialise the workspace behind it on the first grid a person opens.
400
+ // Only the ROWS request has to wait, because only it needs the answer.
401
+ const wsPromise = includeWorkspace
402
+ ? fetchWorkspace(scope, { announceFailure: firstPaint })
403
+ : Promise.resolve(null);
404
+ /**
405
+ * ⭐⭐ W30-T42 (R7 / contract C2) β€” WHOLE BOOK, OR ONE WINDOW?
406
+ *
407
+ * β›” THE SERVER DECIDES, NOT A LIST HERE. `odoo-tables/status` reports `readThrough` per
408
+ * table because the bindings convert one bucket at a time; a key list in the client goes
409
+ * stale on the next spec row and fails in the dangerous direction (asking the window
410
+ * route for a table it cannot serve, which answers 409).
411
+ *
412
+ * β›” AND IT FAILS SOFT TO TODAY'S BEHAVIOUR. An unreachable roster yields the empty set,
413
+ * so the table loads whole exactly as it always has. The opposite default β€” assume
414
+ * windowed, discover otherwise β€” would paint an empty grid over a working table.
415
+ */
416
+ const tableKey = scope.startsWith("ut_") ? scope : null;
417
+ const readThrough = tableKey !== null && (await readThroughTables()).has(tableKey);
418
+ if (cancelled || seq !== windowSeq.current) return;
419
+ readThroughRef.current = readThrough;
420
  const [payload, workspace] = await Promise.all([
421
+ readThrough && tableKey !== null
422
+ // The FIRST window carries no predicate: `config` is CustomerGrid's own state, seeded
423
+ // from the workspace that is still in flight beside this call, so it does not exist
424
+ // yet. The grid re-asks with the saved view's predicate the moment it does β€” see
425
+ // `windowPredicateKey` and the effect that consumes it.
426
+ ? fetchTableWindow(tableKey, { offset: 0, limit: WINDOW_ROWS })
427
+ : fetchTopicRows(topic),
428
+ wsPromise,
429
  ]);
430
+ if (cancelled || seq !== windowSeq.current || !payload) return;
431
  setData(workspace ? withWorkspace(payload, workspace) : payload);
432
  }
433
 
 
570
  });
571
  }, [topic, writable]);
572
 
573
+ /**
574
+ * ⭐⭐ WAVE 30 Β· W30-T42 β€” THE ONE DOOR THE GRID USES TO PAGE A READ-THROUGH TABLE.
575
+ *
576
+ * Two callers, one function: a PREDICATE change (offset 0, replace) and a SCROLL (offset >
577
+ * 0, append). Keeping them on one door is what makes the two impossible to get out of step β€”
578
+ * `mergeWindow` reads the offset and decides, so there is no second place where "does this
579
+ * response replace or extend what I hold" is answered.
580
+ *
581
+ * β›” A no-op on a whole-book table, and silently so. `serverWindowed` (from `payload.counts`)
582
+ * is the client's ONE answer to "is this table windowed"; a second test that could disagree
583
+ * with it is how the grid ends up requesting pages of a table it already holds entirely.
584
+ */
585
+ const requestWindow = useCallback((req: RowWindowRequest) => {
586
+ if (!readThroughRef.current || STREAMLIT_COMPONENT || EMBEDDED_HTML) return;
587
+ const offset = Math.max(0, Math.floor(req.offset || 0));
588
+ // β›” ONE REQUEST PER PAGE IN FLIGHT. glide fires its visible-region callback on every frame
589
+ // of a scroll; without this the same offset is asked for on each one. Offset 0 is exempt on
590
+ // purpose β€” that is a PREDICATE change, and the newest predicate must always be able to
591
+ // overtake an older one rather than be suppressed by it.
592
+ if (offset > 0 && inflightOffset.current === offset) return;
593
+ const seq = ++windowSeq.current;
594
+ if (offset > 0) inflightOffset.current = offset;
595
+ void fetchTableWindow(scope, { ...req, offset }).then((next) => {
596
+ if (offset > 0 && inflightOffset.current === offset) inflightOffset.current = null;
597
+ if (seq !== windowSeq.current || !next) return;
598
+ setData((prev) => mergeWindow(prev, next, offset));
599
+ });
600
+ }, [scope]);
601
+
602
  return {
603
  fields: data?.fields ?? [],
604
  rawRows: data?.rows ?? [],
 
607
  overlayEdits,
608
  setOverlayEdits,
609
  patchOverlay,
610
+ requestWindow,
611
  };
612
  }
web/src/customer-grid/useGridColumns.ts CHANGED
@@ -140,8 +140,35 @@ export function columnTones(
140
  return out;
141
  }
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  function isDefaultVisible(field: Field): boolean {
144
- return field.default !== false || field.source === "overlay";
145
  }
146
 
147
  export function defaultViewConfig(fields: Field[]): ViewConfig {
 
140
  return out;
141
  }
142
 
143
+ /**
144
+ * ⭐ WAVE 30 (owner item 2 scouting) β€” THE OVERLAY ARM IS GONE, and the arithmetic is the reason.
145
+ *
146
+ * This read `field.default !== false || field.source === "overlay"`. The second arm was written
147
+ * when `overlay` meant "a column a user added here", and those columns have no `default` key at
148
+ * all β€” so the arm was a belt-and-braces no-op for them. Then `user_tables._clean_field` started
149
+ * stamping `source: "overlay"` on EVERY `ut_*` field, and every connected database became a `ut_*`
150
+ * one: the exception quietly became the rule and swallowed the first arm entirely.
151
+ *
152
+ * β›” WHAT THAT COST, measured on the live orders grid: `odoo_relational.order_fields` declares
153
+ * `default: False` on `odoo_id`, `state`, `customer_link` and `partner_id`, and every one of them
154
+ * opened SHOWN. The grid arrives wide, and "Hide fields" reads inactive because
155
+ * `shownCount === fields.length` β€” so the control that says "some columns are hidden" says the
156
+ * opposite of what its own table declares. A second instance of [[fallback-that-became-the-rule]].
157
+ *
158
+ * ⚠ DROPPING THE ARM DOES NOT HIDE USER COLUMNS, and this is the check that matters before
159
+ * believing the fix. A column somebody created carries no `default` key, `undefined !== false` is
160
+ * true, and it stays visible. `_clean_field` also keeps `default` ONLY when it is `True`, so a
161
+ * `ut_*` column can never arrive carrying `default: false` by accident β€” only a shipped field
162
+ * CONTRACT (which is written straight into the store, bypassing that validator) can declare it.
163
+ *
164
+ * ⚠ THIS PREDICATE HAS A SERVER TWIN and the twin is the one that decides on first open:
165
+ * `platform/aios_grid.py:_default_view_config` carries the same expression and builds the "All
166
+ * records" system view the client pins as its landing default. Fixing one side alone changes
167
+ * nothing a user sees β€” see `mailbox/F.md` F-1 ([[one-question-two-normalizers]] across two
168
+ * languages).
169
+ */
170
  function isDefaultVisible(field: Field): boolean {
171
+ return field.default !== false;
172
  }
173
 
174
  export function defaultViewConfig(fields: Field[]): ViewConfig {
web/src/filter-kit/FieldsHidePanel.tsx CHANGED
@@ -15,7 +15,17 @@
15
 
16
  import { useState } from "react";
17
  import type { Field } from "../customer-grid/types";
18
- import { isPresetField } from "../customer-grid/types";
 
 
 
 
 
 
 
 
 
 
19
  import { FieldTypeIcon } from "../customer-grid/icons";
20
  import { TYPE_LABELS } from "../customer-grid/iconShapes";
21
 
@@ -74,7 +84,7 @@ export function FieldsHidePanel({
74
  const [dragKey, setDragKey] = useState<string | null>(null);
75
  const [overKey, setOverKey] = useState<string | null>(null);
76
  const q = query.trim().toLowerCase();
77
- const shown = q ? fields.filter((f) => f.label.toLowerCase().includes(q)) : fields;
78
  /**
79
  * Item 23 β€” reorder is OFF while a search is narrowing the list. Dropping "City" above
80
  * "State" in a list that is hiding the eight fields between them means nothing the user can
@@ -188,10 +198,10 @@ export function FieldsHidePanel({
188
  draggable={canReorder && !locked}
189
  aria-label={
190
  locked
191
- ? `${f.label} is always first`
192
  : canReorder
193
- ? `Reorder ${f.label} β€” drag, or use the arrow keys`
194
- : `Reorder ${f.label} (clear the search first)`
195
  }
196
  title={
197
  canReorder && !locked
@@ -241,7 +251,7 @@ export function FieldsHidePanel({
241
  draws (icons.tsx). Titled, because this list is where the icon
242
  vocabulary is learned. */}
243
  <FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
244
- <span className="cg-check-label">{f.label}</span>
245
  {/* ⭐ OWNER ITEM 3 (2026-08-06): *"it doesn't show pre-set when I hide fields just
246
  like how Odoo fields are. This is always the standard label."* Right β€” the chip
247
  asked `source === "odoo"`, so a column an AUTOMATION fills (source `overlay`)
@@ -262,7 +272,7 @@ export function FieldsHidePanel({
262
  type="button"
263
  className={"cg-check-del" + (armed ? " is-armed" : "")}
264
  aria-label={
265
- armed ? `Permanently delete ${f.label}?` : `Delete ${f.label}`
266
  }
267
  title={armed ? undefined : "Removes this field and its values on every row."}
268
  onClick={(e) => {
 
15
 
16
  import { useState } from "react";
17
  import type { Field } from "../customer-grid/types";
18
+ // ⭐ WAVE 30 item 2 (R5) β€” `fieldLabel` is the ONE resolver for a field's NAME, and every read
19
+ // of `f.label` in this file goes through it. `Field.label` is declared `string` and arrives
20
+ // over the WIRE, where nothing enforces that; the search filter below did
21
+ // `f.label.toLowerCase()` unguarded, so one label-less definition threw the moment a query
22
+ // was typed β€” and before this wave a throw anywhere unmounted the whole React tree.
23
+ // ⚠ ALL SIX SITES, not just the filter. A guard on the filter alone leaves the visible row
24
+ // and its four aria strings reading `undefined` β€” [[one-question-two-normalizers]] in
25
+ // miniature, where what a user searches and what they read answer the same question
26
+ // differently. It lives in `types.ts` so the node harness can run it: this panel is JSX and
27
+ // its search path only fires once somebody types, which no static render can do.
28
+ import { fieldLabel, isPresetField } from "../customer-grid/types";
29
  import { FieldTypeIcon } from "../customer-grid/icons";
30
  import { TYPE_LABELS } from "../customer-grid/iconShapes";
31
 
 
84
  const [dragKey, setDragKey] = useState<string | null>(null);
85
  const [overKey, setOverKey] = useState<string | null>(null);
86
  const q = query.trim().toLowerCase();
87
+ const shown = q ? fields.filter((f) => fieldLabel(f).toLowerCase().includes(q)) : fields;
88
  /**
89
  * Item 23 β€” reorder is OFF while a search is narrowing the list. Dropping "City" above
90
  * "State" in a list that is hiding the eight fields between them means nothing the user can
 
198
  draggable={canReorder && !locked}
199
  aria-label={
200
  locked
201
+ ? `${fieldLabel(f)} is always first`
202
  : canReorder
203
+ ? `Reorder ${fieldLabel(f)} β€” drag, or use the arrow keys`
204
+ : `Reorder ${fieldLabel(f)} (clear the search first)`
205
  }
206
  title={
207
  canReorder && !locked
 
251
  draws (icons.tsx). Titled, because this list is where the icon
252
  vocabulary is learned. */}
253
  <FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
254
+ <span className="cg-check-label">{fieldLabel(f)}</span>
255
  {/* ⭐ OWNER ITEM 3 (2026-08-06): *"it doesn't show pre-set when I hide fields just
256
  like how Odoo fields are. This is always the standard label."* Right β€” the chip
257
  asked `source === "odoo"`, so a column an AUTOMATION fills (source `overlay`)
 
272
  type="button"
273
  className={"cg-check-del" + (armed ? " is-armed" : "")}
274
  aria-label={
275
+ armed ? `Permanently delete ${fieldLabel(f)}?` : `Delete ${fieldLabel(f)}`
276
  }
277
  title={armed ? undefined : "Removes this field and its values on every row."}
278
  onClick={(e) => {
web/src/index.css CHANGED
@@ -8778,6 +8778,39 @@ a.cg-map-ctl-b { text-decoration: none; }
8778
  background: var(--lp-surface-2);
8779
  color: var(--lp-muted);
8780
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8781
  .autox-card-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
8782
  .autox-card-title {
8783
  overflow: hidden;
@@ -11152,6 +11185,64 @@ a.cg-map-ctl-b { text-decoration: none; }
11152
  font-weight: 600;
11153
  letter-spacing: 0.02em;
11154
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11155
  .conn-card-name {
11156
  flex: 1 1 auto;
11157
  min-width: 0;
 
8778
  background: var(--lp-surface-2);
8779
  color: var(--lp-muted);
8780
  }
8781
+ /* ⭐⭐ WAVE 30 Β· ITEM 7 / R4 β€” THE TILE A REAL LOGO SITS IN.
8782
+ β›” IT IS NOT A STYLE PREFERENCE, IT IS A CORRECTNESS FIX. The base tile paints
8783
+ `--lp-blue-tint` and sets `color: var(--lp-blue-deep)`, and both break a brand mark in a
8784
+ different way: Instagram's gradient would sit on a coloured chip that is not its colour, and
8785
+ TikTok's note β€” drawn in `currentColor` ON PURPOSE, so the mark survives dark mode β€” would
8786
+ render BLUE. Neutral ground and inherited text put both back where the brand put them.
8787
+ ⚠ SPECIFICITY, STATED: this is (0,2,0), so the two `.autox-card.is-X .autox-card-mark` rules
8788
+ above (0,3,0) still win inside a group or machine card. That is correct today because neither
8789
+ of those card kinds is ever branded β€” and if one becomes so, this comment is where to look.
8790
+ β›”β›” `color: inherit` IS THE RULE, AND IT IS NOT A TIDY-UP β€” DO NOT REPLACE IT WITH A TOKEN.
8791
+ This shipped as `var(--lp-text)`, a token that is DEFINED NOWHERE (the ink token is `--lp-ink`).
8792
+ It worked by accident and `web_icons` refused it: an unresolvable `var()` is invalid at
8793
+ computed-value time, so `color` β€” an inherited property β€” fell back to `unset`, which for an
8794
+ inherited property IS `inherit`. So this line is a byte-identical statement of what the four
8795
+ brand tiles were already doing, and the paragraph above is where the intent was written down.
8796
+ β›” Pinning a real ink here (`--lp-ink`) would be the one change that BREAKS them: two of the
8797
+ nine marks are drawn in `currentColor` on purpose so they survive a dark ground, and an ink
8798
+ would nail them to one. And minting a `--lp-text` token would mint a second name for
8799
+ `--lp-ink`, which the 23-token parity leg (`index.css` vs `theme.ts`) would carry forever. */
8800
+ .autox-card-mark.is-brand {
8801
+ background: transparent;
8802
+ color: inherit;
8803
+ }
8804
+ /* The connector heading's own small slot: a nest caption is 4xs text, so it takes a 13px mark
8805
+ rather than the 26px row tile β€” the tile beside a caption reads as a row that lost its label. */
8806
+ .autox-sub-mark {
8807
+ display: inline-flex;
8808
+ align-items: center;
8809
+ justify-content: center;
8810
+ vertical-align: -2px;
8811
+ margin-right: 5px;
8812
+ color: inherit; /* see `.autox-card-mark.is-brand` above β€” inherited on purpose, never a token */
8813
+ }
8814
  .autox-card-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
8815
  .autox-card-title {
8816
  overflow: hidden;
 
11185
  font-weight: 600;
11186
  letter-spacing: 0.02em;
11187
  }
11188
+ /* ⭐⭐ WAVE 30 Β· R4 β€” THE LOGO TILE. It carries its own box because it does NOT wear
11189
+ `.shell-db-chip`: that class paints `--lp-primary` and white text, which is exactly right for
11190
+ two LETTERS (12px type needs 4.5:1 and only the base chip clears it) and exactly wrong under a
11191
+ brand mark, which brings its own colour. Same 26px square in the same radius, so the grid does
11192
+ not shift between a card with a logo and a card with letters. */
11193
+ .conn-tile.is-brand {
11194
+ display: inline-flex;
11195
+ align-items: center;
11196
+ justify-content: center;
11197
+ flex: 0 0 auto;
11198
+ width: 26px;
11199
+ height: 26px;
11200
+ border-radius: var(--lp-r-md);
11201
+ background: transparent;
11202
+ color: inherit; /* see `.autox-card-mark.is-brand` β€” inherited on purpose, never a token */
11203
+ }
11204
+ /* The platform drill (R4): collapsed by default, so a card costs one line until somebody asks. */
11205
+ .conn-plats { margin: 6px 0 0; }
11206
+ .conn-plats-sum {
11207
+ display: flex;
11208
+ align-items: center;
11209
+ gap: 5px;
11210
+ cursor: pointer;
11211
+ list-style: none;
11212
+ font-size: var(--lp-fs-3xs);
11213
+ color: var(--lp-muted);
11214
+ }
11215
+ .conn-plats-sum::-webkit-details-marker { display: none; }
11216
+ /* The chevron, drawn the way the automation menu's nest draws it, so the two disclosures in this
11217
+ product are one gesture rather than two that happen to both open. */
11218
+ .conn-plats-sum::before {
11219
+ content: "";
11220
+ flex: 0 0 auto;
11221
+ width: 7px;
11222
+ height: 7px;
11223
+ border-right: 1.4px solid var(--lp-muted);
11224
+ border-bottom: 1.4px solid var(--lp-muted);
11225
+ transform: rotate(-45deg);
11226
+ transition: transform 0.12s ease;
11227
+ }
11228
+ .conn-plats[open] > .conn-plats-sum::before { transform: rotate(45deg); }
11229
+ .conn-plat {
11230
+ display: flex;
11231
+ align-items: flex-start;
11232
+ gap: 8px;
11233
+ padding: 6px 0 0 14px;
11234
+ }
11235
+ .conn-plat-mark {
11236
+ flex: 0 0 auto;
11237
+ display: inline-flex;
11238
+ align-items: center;
11239
+ justify-content: center;
11240
+ margin-top: 1px;
11241
+ color: inherit; /* see `.autox-card-mark.is-brand` β€” inherited on purpose, never a token */
11242
+ }
11243
+ .conn-plat-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
11244
+ .conn-plat-name { font-size: var(--lp-fs-3xs); font-weight: 600; color: var(--lp-ink); }
11245
+ .conn-plat-desc { font-size: var(--lp-fs-4xs); color: var(--lp-muted); }
11246
  .conn-card-name {
11247
  flex: 1 1 auto;
11248
  min-width: 0;
web/src/shell/ErrorBoundary.tsx ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // shell / ErrorBoundary.tsx β€” WAVE 30, owner item 2 (ruling R5).
3
+ //
4
+ // β›” WHY THIS FILE EXISTS, AND THE MEASUREMENT BEHIND IT. Until today,
5
+ // `ErrorBoundary|componentDidCatch|getDerivedStateFromError` had ZERO hits in the
6
+ // whole of `web/src`. React's contract when a render throws and nothing catches it
7
+ // is to unmount the ENTIRE tree β€” so one unguarded property access anywhere in the
8
+ // product produced a blank white page. That is exactly what a user calls "it
9
+ // crashes", and it is why owner item 2 arrived as a report with no diagnosis: there
10
+ // was nothing left on screen to diagnose.
11
+ //
12
+ // A boundary is therefore not a workaround for the hide-fields panel. It is the
13
+ // difference between "one surface failed and said so" and "the product vanished".
14
+ //
15
+ // ⚠ WHAT NO GATE IN THIS REPO CAN PROVE, measured 2026-08-12 rather than assumed β€”
16
+ // written here so nobody reads the gate as stronger than it is:
17
+ // Β· there is NO DOM. `jsdom`, `happy-dom`, `linkedom`, `domino` and
18
+ // `react-test-renderer` are all absent from `web/node_modules` (160 packages),
19
+ // and there is no vitest. So the React RECONCILER cannot be run under node.
20
+ // Β· React 18.3.1's server renderers do NOT invoke error boundaries. Both
21
+ // `renderToString` and `renderToStaticMarkup` were fed one throwing child under
22
+ // a boundary declaring `getDerivedStateFromError`; both PROPAGATED the throw and
23
+ // `componentDidCatch` never fired.
24
+ // β‡’ the fallback is a SEPARATE exported component precisely so it can be rendered
25
+ // for real, and the class's protocol is driven by hand in `verify_grid_ux.py`
26
+ // (throw β†’ catch β†’ `getDerivedStateFromError` β†’ `render()`), which is React's own
27
+ // documented sequence. The reconciler's guarantee is React's, not ours; what IS
28
+ // ours β€” that every surface is actually wrapped β€” is a source scan, and that is
29
+ // the check with teeth ([[reachable-is-not-the-same-as-built]]: a boundary that
30
+ // exists and wraps nothing is this repo's sixth whole-correct-unreachable feature).
31
+ // ---------------------------------------------------------------------------
32
+
33
+ import { Component } from "react";
34
+ import type { ErrorInfo, ReactNode } from "react";
35
+ import "./errorBoundary.css";
36
+
37
+ export interface FailurePanelProps {
38
+ /** The surface that failed, named the way the user got to it ("Odoo orders",
39
+ * "Automation", "Loopable"). A panel that will not say WHAT broke sends the
40
+ * reader back to us with the same sentence item 2 arrived with. */
41
+ surface: string;
42
+ /** The thrown message. Shown, not hidden: a user who can paste one line saves a
43
+ * scouting session. ⚠ It is the message ONLY β€” never a stack, and never a value
44
+ * from a row, because this panel is on screen wherever the failure happened. */
45
+ message?: string;
46
+ onReload: () => void;
47
+ }
48
+
49
+ /**
50
+ * The contained failure card.
51
+ *
52
+ * It borrows `.shell-placeholder` and `.shell-retry` on purpose β€” the shell already
53
+ * has two failure panels (the data-error card and the nav-failed card) and a third
54
+ * visual language for the same idea would read as a different kind of problem. The
55
+ * only new paint is the tone strip in `errorBoundary.css`, which is what separates
56
+ * "this did not load" from "this broke".
57
+ *
58
+ * Exported as its own component so it can be rendered for real under node; see the
59
+ * measurement in this file's header for why the class cannot be.
60
+ */
61
+ export function FailurePanel({ surface, message, onReload }: FailurePanelProps) {
62
+ return (
63
+ <div className="shell-placeholder shell-crash" role="alert">
64
+ <h1>{surface} could not be shown</h1>
65
+ <p>
66
+ Something here failed while drawing. The rest of the app still works β€” pick
67
+ another database from the sidebar, or reload to try this one again.
68
+ </p>
69
+ {message ? <p className="shell-crash-detail">{message}</p> : null}
70
+ <button
71
+ className="login-submit shell-retry"
72
+ type="button"
73
+ onClick={onReload}
74
+ >
75
+ Reload
76
+ </button>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ export interface ErrorBoundaryProps {
82
+ surface: string;
83
+ children: ReactNode;
84
+ }
85
+
86
+ export interface ErrorBoundaryState {
87
+ error: Error | null;
88
+ }
89
+
90
+ /**
91
+ * Catch a render throw and paint `FailurePanel` in its place, leaving every sibling
92
+ * mounted.
93
+ *
94
+ * ⚠ A BOUNDARY LATCHES, AND THAT IS THE TRAP TO KNOW ABOUT. Once it holds an error
95
+ * it renders the panel until it is REMOUNTED β€” there is no "it will sort itself out
96
+ * on the next render". So every mount below passes a `key` that changes when the
97
+ * user moves (the route, the surface), which turns "navigate away and back" into the
98
+ * recovery path a person will actually try. `Reload` is the second door, and it is
99
+ * the same control the shell's two existing failure cards offer.
100
+ */
101
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
102
+ constructor(props: ErrorBoundaryProps) {
103
+ super(props);
104
+ this.state = { error: null };
105
+ }
106
+
107
+ static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
108
+ // Anything can be thrown in JavaScript, including a string or `undefined`.
109
+ // Normalising here means `render` has one shape to reason about and the panel
110
+ // can never itself throw reading `.message` off a non-Error.
111
+ return { error: error instanceof Error ? error : new Error(String(error)) };
112
+ }
113
+
114
+ componentDidCatch(error: unknown, info: ErrorInfo): void {
115
+ // The console is the only sink this client has today. It is enough to turn the
116
+ // next report from "it crashes" into a stack β€” which was the whole cost of item
117
+ // 2 arriving undiagnosed. Forwarding this to the server is booked as pending
118
+ // work rather than smuggled in here: it needs a route, and it must be proven to
119
+ // carry no row values before it ships.
120
+ console.error(`[${this.props.surface}] render failed`, error, info.componentStack);
121
+ }
122
+
123
+ render(): ReactNode {
124
+ const { error } = this.state;
125
+ if (error) {
126
+ return (
127
+ <FailurePanel
128
+ surface={this.props.surface}
129
+ message={error.message}
130
+ onReload={() => window.location.reload()}
131
+ />
132
+ );
133
+ }
134
+ return this.props.children;
135
+ }
136
+ }
web/src/shell/Shell.tsx CHANGED
@@ -64,6 +64,7 @@ const SettingsModal = lazy(() =>
64
  import("../settings/SettingsModal").then((m) => ({ default: m.SettingsModal })));
65
  import type { SettingsSection } from "../settings/SettingsModal";
66
  import { Brand } from "./Brand";
 
67
  import LoginPage from "./LoginPage";
68
  import { CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, MAX_NAV_FOLDERS, appLink, databaseEntries, dbChipClass, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, postOpened, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
69
  import type { NavEntry, NavMetaPatch, NavPage, NavPrefs, Recent } from "./nav";
@@ -95,9 +96,20 @@ const AlertsPane = lazy(() => import("../alerts/AlertsPane"));
95
  * These chunks are tens of kilobytes off the same origin that just served the shell, so the
96
  * honest choice between a flash of a spinner and a frame of nothing is nothing β€” a spinner that
97
  * appears and vanishes within one frame reads as a glitch, not as progress.
 
 
 
 
 
 
 
98
  */
99
- function Lazily({ children }: { children: ReactNode }) {
100
- return <Suspense fallback={null}>{children}</Suspense>;
 
 
 
 
101
  }
102
 
103
  import { createAlert, fetchInbox } from "../alerts/alertsApi";
@@ -578,7 +590,26 @@ function AccountMenu({
578
  );
579
  }
580
 
 
 
 
 
 
 
 
 
 
 
 
581
  export default function Shell() {
 
 
 
 
 
 
 
 
582
  const [session, setSession] = useState<Session>({ phase: "checking" });
583
  const [nav, setNav] = useState<Nav>({ phase: "idle" });
584
  // Wave 18 (C3-UT): bumped after "+ New database" so the rail refetches and the new ut_ key
@@ -2047,6 +2078,14 @@ export default function Shell() {
2047
  ) : null}
2048
 
2049
  <main className="shell-main">
 
 
 
 
 
 
 
 
2050
  {/* ⚠ An honest failure, never a fallback to the bundled sample. Behind
2051
  a login, plausible-looking sample revenue is fabricated data on a
2052
  screen the user has every reason to trust. */}
@@ -2096,7 +2135,7 @@ export default function Shell() {
2096
  {...(active.icon ? { icon: active.icon } : {})}
2097
  />
2098
  <div className="shell-auto-host">
2099
- <Lazily><AutomationSurface /></Lazily>
2100
  </div>
2101
  </div>
2102
  ) : active?.kind === "native" ? (
@@ -2139,7 +2178,7 @@ export default function Shell() {
2139
  // The one action the page cannot perform itself: a `manage: "keychain"` row opens
2140
  // Settings on the Keychains tab, and the Settings MODAL is the frame's. Same division
2141
  // as every other panel here β€” the page knows what it wants, the frame owns the door.
2142
- <Lazily><ConnectorsPage onKeychain={() => setSettings("keychains")} /></Lazily>
2143
  ) : nav.phase === "ready" ? (
2144
  // ⭐ WAVE 23 item 9 (R7, C10) β€” HOME, AND IT IS THE FALLBACK RATHER THAN A ROUTE MATCH.
2145
  //
@@ -2159,7 +2198,7 @@ export default function Shell() {
2159
  // hero's create door, and its empty line carries the hero's sentence (HomePage's
2160
  // `home-empty`, which branches on `entries.length === 0` for exactly this reason).
2161
  // Booked as a dated amendment in the wave doc, since it moves an R4 surface.
2162
- <Lazily>
2163
  <HomePage
2164
  // ⭐ `dbEntries`, NOT `entries` β€” found by LOOKING at it (wave 24 close-out visual
2165
  // pass), which is the only way it could have been found. The Automation SURFACE is a
@@ -2208,6 +2247,7 @@ export default function Shell() {
2208
  <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
2209
  </div>
2210
  )}
 
2211
  </main>
2212
 
2213
  {/* The server's own confirmation of a write (X2's `toast`). In standalone
@@ -2306,7 +2346,7 @@ export default function Shell() {
2306
  // WAVE 23 C12 (wiring W23-W6) β€” the picker. It applies views to an EXISTING
2307
  // database rather than making one, which is what a platform-curated template is:
2308
  // a set of saved views, not a table (R10).
2309
- <Lazily>
2310
  <TemplatePicker
2311
  entries={dbEntries}
2312
  onToast={setToast}
@@ -2356,7 +2396,7 @@ export default function Shell() {
2356
  ) : null}
2357
 
2358
  {settings ? (
2359
- <Lazily>
2360
  <SettingsModal
2361
  user={session.user}
2362
  section={settings}
@@ -2375,7 +2415,7 @@ export default function Shell() {
2375
 
2376
  {/* C-ALERT (item 25): the inbox. */}
2377
  {alertsOpen ? (
2378
- <Lazily>
2379
  <AlertsPane
2380
  onClose={() => setAlertsOpen(false)}
2381
  onInbox={setInbox}
 
64
  import("../settings/SettingsModal").then((m) => ({ default: m.SettingsModal })));
65
  import type { SettingsSection } from "../settings/SettingsModal";
66
  import { Brand } from "./Brand";
67
+ import { ErrorBoundary } from "./ErrorBoundary";
68
  import LoginPage from "./LoginPage";
69
  import { CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, MAX_NAV_FOLDERS, appLink, databaseEntries, dbChipClass, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, postOpened, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
70
  import type { NavEntry, NavMetaPatch, NavPage, NavPrefs, Recent } from "./nav";
 
96
  * These chunks are tens of kilobytes off the same origin that just served the shell, so the
97
  * honest choice between a flash of a spinner and a frame of nothing is nothing β€” a spinner that
98
  * appears and vanishes within one frame reads as a glitch, not as progress.
99
+ *
100
+ * ⭐ WAVE 30 (R5) β€” AND AN ERROR BOUNDARY, INSIDE THE HELPER RATHER THAN AT SIX CALL SITES.
101
+ * Three of these six (`TemplatePicker`, `SettingsModal`, `AlertsPane`) are siblings of `<main>`,
102
+ * so the content-area boundary below cannot cover them; putting it here covers all six with one
103
+ * edit. It also catches what `Suspense` alone cannot: a REJECTED chunk fetch. `lazy()` throws its
104
+ * rejection at render, which before today unmounted the whole product because one deploy-time
105
+ * cache miss made a modal's JavaScript unreachable.
106
  */
107
+ function Lazily({ children, surface }: { children: ReactNode; surface: string }) {
108
+ return (
109
+ <ErrorBoundary surface={surface}>
110
+ <Suspense fallback={null}>{children}</Suspense>
111
+ </ErrorBoundary>
112
+ );
113
  }
114
 
115
  import { createAlert, fetchInbox } from "../alerts/alertsApi";
 
590
  );
591
  }
592
 
593
+ /**
594
+ * ⭐ WAVE 30 (R5) β€” THE OUTERMOST BOUNDARY, and the ONLY one that can cover the three
595
+ * EARLY RETURNS below (`FormPublic`, the boot card, `LoginPage`) plus this component's own
596
+ * body: its route resolution, its nav shaping and its derivations all run BEFORE any JSX
597
+ * exists to wrap. A throw in any of them used to take the document with it, and a person
598
+ * who cannot get past the login screen has no navigation left to escape with.
599
+ *
600
+ * ⚠ Split rather than nested inline for one reason: a boundary cannot catch a throw from
601
+ * its OWN render. `ErrorBoundary` has to be the parent of the component that fails, so the
602
+ * frame becomes a child and the default export becomes the wrapper.
603
+ */
604
  export default function Shell() {
605
+ return (
606
+ <ErrorBoundary surface="Loopable">
607
+ <ShellFrame />
608
+ </ErrorBoundary>
609
+ );
610
+ }
611
+
612
+ function ShellFrame() {
613
  const [session, setSession] = useState<Session>({ phase: "checking" });
614
  const [nav, setNav] = useState<Nav>({ phase: "idle" });
615
  // Wave 18 (C3-UT): bumped after "+ New database" so the rail refetches and the new ut_ key
 
2078
  ) : null}
2079
 
2080
  <main className="shell-main">
2081
+ {/* ⭐ WAVE 30 (R5) β€” THE CONTENT-AREA BOUNDARY, and the reason it is HERE and not around
2082
+ the whole shell: the `<aside>` rail is its SIBLING, so a surface that throws leaves the
2083
+ navigation mounted and the user can walk away from the broken page instead of reloading
2084
+ a blank document. That is the difference the ticket's done-when is naming.
2085
+ ⚠ `key={route}` is load-bearing. A boundary LATCHES β€” once it holds an error it renders
2086
+ the panel until it is remounted β€” so without the key, navigating away from a failing
2087
+ database would carry its failure card onto every page afterwards. */}
2088
+ <ErrorBoundary key={route} surface={active?.label ?? "This page"}>
2089
  {/* ⚠ An honest failure, never a fallback to the bundled sample. Behind
2090
  a login, plausible-looking sample revenue is fabricated data on a
2091
  screen the user has every reason to trust. */}
 
2135
  {...(active.icon ? { icon: active.icon } : {})}
2136
  />
2137
  <div className="shell-auto-host">
2138
+ <Lazily surface="Automation"><AutomationSurface /></Lazily>
2139
  </div>
2140
  </div>
2141
  ) : active?.kind === "native" ? (
 
2178
  // The one action the page cannot perform itself: a `manage: "keychain"` row opens
2179
  // Settings on the Keychains tab, and the Settings MODAL is the frame's. Same division
2180
  // as every other panel here β€” the page knows what it wants, the frame owns the door.
2181
+ <Lazily surface="Connectors"><ConnectorsPage onKeychain={() => setSettings("keychains")} /></Lazily>
2182
  ) : nav.phase === "ready" ? (
2183
  // ⭐ WAVE 23 item 9 (R7, C10) β€” HOME, AND IT IS THE FALLBACK RATHER THAN A ROUTE MATCH.
2184
  //
 
2198
  // hero's create door, and its empty line carries the hero's sentence (HomePage's
2199
  // `home-empty`, which branches on `entries.length === 0` for exactly this reason).
2200
  // Booked as a dated amendment in the wave doc, since it moves an R4 surface.
2201
+ <Lazily surface="Home">
2202
  <HomePage
2203
  // ⭐ `dbEntries`, NOT `entries` β€” found by LOOKING at it (wave 24 close-out visual
2204
  // pass), which is the only way it could have been found. The Automation SURFACE is a
 
2247
  <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
2248
  </div>
2249
  )}
2250
+ </ErrorBoundary>
2251
  </main>
2252
 
2253
  {/* The server's own confirmation of a write (X2's `toast`). In standalone
 
2346
  // WAVE 23 C12 (wiring W23-W6) β€” the picker. It applies views to an EXISTING
2347
  // database rather than making one, which is what a platform-curated template is:
2348
  // a set of saved views, not a table (R10).
2349
+ <Lazily surface="Templates">
2350
  <TemplatePicker
2351
  entries={dbEntries}
2352
  onToast={setToast}
 
2396
  ) : null}
2397
 
2398
  {settings ? (
2399
+ <Lazily surface="Settings">
2400
  <SettingsModal
2401
  user={session.user}
2402
  section={settings}
 
2415
 
2416
  {/* C-ALERT (item 25): the inbox. */}
2417
  {alertsOpen ? (
2418
+ <Lazily surface="Alerts">
2419
  <AlertsPane
2420
  onClose={() => setAlertsOpen(false)}
2421
  onInbox={setInbox}
web/src/shell/errorBoundary.css ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* shell/errorBoundary.css β€” WAVE 30 item 2 (R5): the contained-failure card.
2
+ New classes only, co-located with the component that imports them, following
3
+ `shell/navExtras.css`'s precedent β€” the shell's own rail and placeholder classes
4
+ stay in index.css untouched (that file is another session's fence this wave).
5
+
6
+ ⚠ Font sizes are tokens, never literals: `verify_ui.py`'s R5 sweep reads EVERY
7
+ stylesheet under `src/`, not just index.css. */
8
+
9
+ /* The tone strip is the whole point of this rule. `.shell-placeholder` already
10
+ centres a heading, a sentence and a retry button β€” that is what the nav-failed
11
+ and data-error cards look like, and both of those mean "it did not load". This
12
+ one means "it broke", and a reader who cannot tell those apart reports the wrong
13
+ thing. The strip takes the `-deep` weight because it reads as a stroke
14
+ (DESIGN.md: strokes and text take `-deep`, flat surfaces take the pastel base). */
15
+ .shell-crash {
16
+ border-left: 3px solid var(--lp-red-deep);
17
+ background: var(--lp-red-tint);
18
+ border-radius: var(--lp-r-lg);
19
+ /* Contained, so a long message cannot push the sidebar off-screen β€” the failure
20
+ panel must never be the second way this surface breaks the app. */
21
+ max-width: 640px;
22
+ margin: 24px;
23
+ }
24
+
25
+ /* The thrown message, verbatim. Monospace because it is a machine's words, not
26
+ ours, and a reader pasting it back to us should be able to see where it ends. */
27
+ .shell-crash-detail {
28
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
29
+ font-size: var(--lp-fs-2xs);
30
+ color: var(--lp-red-deep);
31
+ overflow-wrap: anywhere;
32
+ }