fsanyoto commited on
Commit
2183dbe
Β·
verified Β·
1 Parent(s): 0b66db6

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. RELEASES.json +1 -1
  2. VERSION +1 -1
  3. api/ai_review.py +591 -591
  4. api/automation_engine.py +0 -0
  5. api/connectors_tt.py +568 -568
  6. api/main.py +0 -0
  7. api/odoo_relational.py +0 -0
  8. api/providers.py +0 -0
  9. api/routes_admin.py +257 -1
  10. api/routes_agent_harness.py +455 -455
  11. api/routes_alerts.py +668 -668
  12. api/routes_customers.py +0 -0
  13. api/routes_grid.py +0 -0
  14. api/routes_keychain.py +0 -0
  15. api/routes_nav.py +0 -0
  16. api/routes_oauth.py +114 -114
  17. api/routes_products.py +20 -0
  18. api/routes_publish.py +0 -0
  19. api/routes_records.py +211 -211
  20. api/routes_script_views.py +439 -439
  21. api/routes_shares.py +0 -0
  22. api/routes_slack.py +789 -789
  23. api/routes_statements.py +298 -298
  24. api/routes_tables.py +0 -0
  25. api/routes_web_agent.py +84 -84
  26. platform/aios_grid.py +44 -2
  27. platform/aios_grid_fields.json +142 -0
  28. platform/core/field_permissions.py +580 -5
  29. platform/core/grid_events.py +514 -22
  30. platform/core/perm_scope.py +0 -0
  31. platform/core/script_sandbox.py +519 -519
  32. platform/core/shared_overlay.py +521 -327
  33. platform/core/shares.py +584 -560
  34. platform/core/store.py +0 -0
  35. platform/core/table_store.py +0 -0
  36. platform/core/user_tables.py +0 -0
  37. platform/harness/connectors/odoo.py +17 -1
  38. platform/harness/datastore.py +0 -0
  39. platform/harness/meta_store.py +521 -521
  40. platform/model/metrics/sales.yml +149 -149
  41. platform/model/topics/odoo_accounts.yml +60 -60
  42. platform/model/topics/odoo_agents.yml +91 -91
  43. platform/model/topics/odoo_bills.yml +86 -86
  44. platform/model/topics/odoo_invoices.yml +98 -98
  45. platform/model/topics/odoo_orders.yml +87 -87
  46. platform/model/topics/odoo_products.yml +16 -1
  47. platform/model/topics/odoo_vendors.yml +103 -103
  48. platform/modules/agent.py +385 -385
  49. platform/modules/collections_send.py +267 -267
  50. platform/modules/product_data.py +82 -0
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "v53 (89ed2cb)",
3
  "releases": [
4
  {
5
  "version": "v53",
 
1
  {
2
+ "current": "a8583e6",
3
  "releases": [
4
  {
5
  "version": "v53",
VERSION CHANGED
@@ -1 +1 @@
1
- v53 (89ed2cb)
 
1
+ a8583e6
api/ai_review.py CHANGED
@@ -1,591 +1,591 @@
1
- """AI REVIEW β€” let a model decide which stage a card moves to (wave 23, owner ruling R4/R14).
2
-
3
- ⭐ WHAT THIS IS. A review stage holds a record until somebody decides where it goes next. R4 made
4
- that somebody optionally a MODEL: the engine hands over the record's own values, the review's
5
- prompt, and the list of stages the review is allowed to send a card to, and gets back ONE of
6
- those stage labels plus a one-line reason. Every decision is written to the same `reviews` audit
7
- log a human click writes to, tagged `by: "ai"` with the provider and model that made it.
8
-
9
- β›” FAIL-CLOSED IN EVERY DIRECTION, and this is the whole safety story. No key configured, a
10
- network failure, a slow answer, a malformed answer, or an answer naming a stage the review does
11
- not offer β€” all return `("", {...})`, and the caller leaves the card exactly where a human would
12
- have found it. The feature can be absent, broken or wrong and the worst outcome is a person doing
13
- the work. Nothing here can move a card somewhere the review does not already permit.
14
-
15
- ⭐ CHEAP FIRST (owner R14, verbatim: *"Claude is a bit too expensive"*). The ladder is
16
- `groq β†’ cerebras β†’ openrouter β†’ anthropic`; the first CONFIGURED provider wins, and Anthropic is
17
- last rather than absent β€” it is the quality backstop, not the default. `AIOS_AI_REVIEW_PROVIDER`
18
- pins one; `AIOS_AI_MODEL` overrides the model.
19
-
20
- ⭐⭐ THE ANTHROPIC LEG IS ON THE OFFICIAL SDK NOW (D-346, W37-T39, 2026-08-19). β›” THIS PARAGRAPH
21
- USED TO ARGUE THE OPPOSITE and is rewritten rather than deleted, because a comment left asserting
22
- the reverse of its own code is [[two-gates-can-assert-opposite-things]] with no gate to catch it.
23
- The retired text said raw HTTP was *"a deliberate deviation … booked as a DEBT line so the
24
- integrator can overturn it deliberately rather than by drift"*. This is that overturn.
25
-
26
- Its two reasons were real and are both answered rather than waved away:
27
- 1. *"one SDK leg beside three hand-rolled ones is two implementations of the same call."* It is
28
- not, because the leg does not live here: `providers.anthropic_send` owns the wire, prefers
29
- the SDK, and normalises BOTH transports to one `(status, body)` pair. This file gained no
30
- Anthropic knowledge β€” it lost some.
31
- 2. *"adding a pinned dependency rebuilds the container, a real deploy risk."* Still true, so the
32
- import is LAZY and its absence is a FALLBACK, not a crash: with no `anthropic` package the
33
- same raw POST runs and `LAST_ANTHROPIC_TRANSPORT` reports `'http'`. The pin is owed in BOTH
34
- manifests ([[pin-deps-space-rebuilds]]) and is outside every worker fence this wave.
35
- ⚠ The other three legs stay OpenAI-chat-shaped over `requests` β€” they have no shared SDK, and that
36
- half of the original reasoning never expired.
37
-
38
- The Messages shape below is the current one: `x-api-key` + `anthropic-version: 2023-06-01`, and
39
- `stop_reason: "refusal"` is checked BEFORE reading `content` β€” a refusal answers HTTP 200 with an
40
- empty content list, so code that indexes `content[0]` unconditionally breaks on it.
41
- """
42
- from __future__ import annotations
43
-
44
- import json
45
- import os
46
- import re
47
-
48
- import requests
49
-
50
- #: The ladder. Order IS the policy (R14) β€” cheapest capable first, Anthropic last.
51
- PROVIDERS = [
52
- # β›”β›” D-345, FIXED W37-T39 (2026-08-19). This read `llama-3.3-70b-versatile` and Groq RETIRED
53
- # it: a real POST with the live key answers HTTP 404 `model_not_found`, and the id is absent
54
- # from `GET /openai/v1/models`. ⚠ THE `openai/` PREFIX IS PART OF GROQ'S ID and is NOT a typo
55
- # for the cerebras rung below, which serves the same family bare as `gpt-oss-120b`.
56
- {"name": "groq", "env": "GROQ_API_KEY", "shape": "openai",
57
- "url": "https://api.groq.com/openai/v1/chat/completions",
58
- "model": "openai/gpt-oss-120b"},
59
- {"name": "cerebras", "env": "CEREBRAS_API_KEY", "shape": "openai",
60
- "url": "https://api.cerebras.ai/v1/chat/completions",
61
- "model": "gpt-oss-120b"},
62
- {"name": "openrouter", "env": "OPENROUTER_API_KEY", "shape": "openai",
63
- "url": "https://openrouter.ai/api/v1/chat/completions",
64
- "model": "openai/gpt-4o-mini"},
65
- # ⚠ haiku-class DELIBERATELY, not the default Opus tier: R14 put Anthropic on this ladder as
66
- # the backstop for a one-line classification, and this is the cheapest current Claude that
67
- # does it well. A bigger model here would be spending the owner's money to pick between two
68
- # labels it already has in front of it.
69
- {"name": "anthropic", "env": "ANTHROPIC_API_KEY", "shape": "anthropic",
70
- "url": "https://api.anthropic.com/v1/messages",
71
- # ⚠ AN ENV LEVER, NOT A NEW DEFAULT (W36-T35): haiku stays the choice for the reasons above,
72
- # and a deployment whose side rungs are out of credit can raise the tier without a release.
73
- "model": os.environ.get("AIOS_AI_REVIEW_ANTHROPIC_MODEL") or "claude-haiku-4-5"},
74
- ]
75
- ANTHROPIC_VERSION = "2023-06-01"
76
- TIMEOUT_SECONDS = float(os.environ.get("AIOS_AI_REVIEW_TIMEOUT") or 20)
77
- MAX_FIELD_CHARS = 200 # per value handed to the model
78
- MAX_FIELDS = 30 # columns handed to the model
79
- MAX_REASON = 200
80
-
81
-
82
- def ladder():
83
- """The providers that are actually usable here, in order. Empty = the feature is off."""
84
- pin = (os.environ.get("AIOS_AI_REVIEW_PROVIDER") or "").strip().lower()
85
- live = [p for p in PROVIDERS if (os.environ.get(p["env"]) or "").strip()]
86
- if pin:
87
- live = [p for p in live if p["name"] == pin]
88
- return live
89
-
90
-
91
- def configured():
92
- return bool(ladder())
93
-
94
-
95
- def _record_text(row, fields):
96
- """The record, as the model sees it. Values are truncated and the column set is bounded β€”
97
- an automation table can carry a 32 KB JSON blob per row (C7) and a review decision does not
98
- need it. Machine bookkeeping columns are dropped: a stage cell naming the stage the card is
99
- sitting at would be the model reading its own question back."""
100
- keys = [k for k in (fields or list((row or {}).keys()))
101
- if not str(k).startswith("stage_")][:MAX_FIELDS]
102
- lines = []
103
- for k in keys:
104
- v = (row or {}).get(k)
105
- if v is None or str(v).strip() == "":
106
- continue
107
- lines.append(f"{k}: {str(v)[:MAX_FIELD_CHARS]}")
108
- return "\n".join(lines) or "(this record has no filled-in values)"
109
-
110
-
111
- def _instruction(prompt, options, label):
112
- return (
113
- f"You are deciding what happens to one record waiting at a review step called "
114
- f"{label!r} in a workflow.\n\n"
115
- f"The person who built this workflow told you: {prompt}\n\n"
116
- f"Choose EXACTLY ONE of these next steps, by its exact name:\n"
117
- + "\n".join(f"- {o}" for o in options)
118
- + "\n\nAnswer with one line of JSON and nothing else:\n"
119
- '{"choice": "<one name from the list above>", "reason": "<one short sentence>"}\n'
120
- "If the record does not give you enough to decide, answer "
121
- '{"choice": "", "reason": "why not"} and a person will decide instead.'
122
- )
123
-
124
-
125
- def _parse(text, options):
126
- """The model's line β†’ `(choice, reason)`. A choice that is not one of the offered stages is
127
- DISCARDED, not fuzzy-matched: the offered list is a permission boundary, and a near-miss
128
- resolved by string distance is how a card ends up somewhere nobody authorised."""
129
- raw = str(text or "").strip()
130
- obj = None
131
- m = re.search(r"\{.*\}", raw, re.S)
132
- if m:
133
- try:
134
- obj = json.loads(m.group(0))
135
- except (ValueError, TypeError):
136
- obj = None
137
- if not isinstance(obj, dict):
138
- return "", ""
139
- choice = str(obj.get("choice") or "").strip()
140
- reason = str(obj.get("reason") or "").strip()[:MAX_REASON]
141
- for opt in options:
142
- if choice.lower() == str(opt).lower():
143
- return str(opt), reason # the OFFERED spelling wins, never the model's
144
- return "", reason
145
-
146
-
147
- # ⭐⭐ WAVE 35 Β· W35-T41 / C7 β€” THE TWO TRANSPORTS RETURN THE RESPONSE BODY'S `usage`.
148
- #
149
- # β›” THIS FILE'S OWN HEADER USED TO BE THE PRODUCT'S CONFESSION THAT NOTHING COUNTED: `ai_enrich`'s
150
- # docstring says *"`ai_review.decide` -- the product's only other LLM entry -- has no token
151
- # accounting of ANY kind"*. R9 asks for ONE meter for every AI surface, so the counting has to reach
152
- # this transport rather than be bolted onto its caller β€” the caller never sees the body.
153
- #
154
- # ⚠ A THIRD RETURN VALUE, NOT A MUTATED ARGUMENT, and both call sites are in `decide` below. The
155
- # tuple grew from `(text, err)` to `(text, err, usage)`; `usage` is the raw `usage` OBJECT (or None),
156
- # because reading it is `usage_ledger`'s job and a second reader here would be the two-normalizers
157
- # shape this repo keeps paying for.
158
- def _call_openai(p, model, system, user, timeout):
159
- r = requests.post(p["url"], timeout=timeout,
160
- headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
161
- "Content-Type": "application/json"},
162
- json={"model": model, "max_tokens": 300, "temperature": 0,
163
- "messages": [{"role": "system", "content": system},
164
- {"role": "user", "content": user}]})
165
- if r.status_code >= 400:
166
- # ⚠ NO BODY ON A 4xx/5xx: an error envelope carries no usage, and a provider that refused
167
- # before running the model has nothing to bill. The call is still COUNTED by `decide`.
168
- return "", f"{p['name']} answered {r.status_code}", None
169
- body = r.json()
170
- choices = body.get("choices") or []
171
- if not choices:
172
- return "", f"{p['name']} returned no choices", body
173
- return str(((choices[0] or {}).get("message") or {}).get("content") or ""), "", body
174
-
175
-
176
- #: What transport the last Anthropic call actually used: `'sdk'`, `'http'`, or `''` before any.
177
- #: ⚠ PROCESS-LOCAL AND FOR REPORTING ONLY. It exists so `GET /meta` and `verify_web_agent` can say
178
- #: WHICH path ran rather than inferring it from a requirements file nobody in this lane can edit.
179
- LAST_ANTHROPIC_TRANSPORT = ""
180
-
181
-
182
- def _call_anthropic(p, model, system, user, timeout):
183
- """One classification turn on the Messages API, through `providers.anthropic_send`.
184
-
185
- ⭐ D-346 (W37-T39): this used to hand-roll the POST. It now goes through the shared wire, which
186
- prefers the OFFICIAL `anthropic` SDK and falls back to the same raw POST when the package is
187
- absent. The shape of what comes back is unchanged, deliberately: `anthropic_send` normalises
188
- both transports to `(status, body)`, so every line below this call is untouched.
189
- """
190
- global LAST_ANTHROPIC_TRANSPORT
191
- import providers as _prov # noqa: PLC0415
192
- # ⚠ NO TOOLS ON THIS PATH. The builder omits `tool_choice` for exactly that case, so there is
193
- # nothing to strip here β€” the rule lives in `anthropic_request`, beside the thing it constrains.
194
- req = _prov.anthropic_request(
195
- model=model, key=os.environ[p["env"]].strip(), system=system,
196
- messages=[{"role": "user", "content": user}], tools=[], max_tokens=300)
197
- status, body, transport = _prov.anthropic_send(req, timeout=timeout)
198
- LAST_ANTHROPIC_TRANSPORT = transport
199
- if status >= 400 or status == 0:
200
- # ⭐ THE SENTENCE COMES FROM THE LADDER, NOT FROM HERE. `refusal_sentence` already names the
201
- # vendor and the action for every status R4 enumerated, so a bare "anthropic answered 402"
202
- # (which is what this line used to say, and what the owner quoted back at us) cannot recur.
203
- return "", _prov.refusal_sentence("anthropic", status, json.dumps(body)[:400]), None
204
- body = body or {}
205
- # β›” stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
206
- # reading content[0] before this check turns a refusal into an IndexError inside a run.
207
- # ⚠ A refusal IS billed and its body carries `usage`, so the body rides back on this branch too.
208
- if body.get("stop_reason") == "refusal":
209
- return "", "anthropic declined to answer this record", body
210
- parts = [b.get("text") or "" for b in (body.get("content") or [])
211
- if isinstance(b, dict) and b.get("type") == "text"]
212
- if not parts:
213
- return "", "anthropic returned no text", body
214
- return "".join(parts), "", body
215
-
216
-
217
- def decide(*, prompt, options, row, fields=(), label="Review", timeout=None, st=None, user=""):
218
- """Pick this record's next stage. Returns `(choice, meta)`.
219
-
220
- `choice` is "" whenever a person should decide β€” which is every failure mode there is.
221
- `meta` carries `provider`, `model`, `reason` on success, and `problem` on refusal to answer.
222
-
223
- ⭐ W35-T41 / C7 β€” `st` and `user` are the USAGE LEDGER's target. Both default to absent because
224
- this function's caller is `automation_engine.ai_decide(rt, ...)`, in another lane's fence: it
225
- HAS the runtime and does not pass it yet, so until it does, a review's tokens are counted as
226
- unattributed and REPORTED by `GET /usage` rather than dropped. See `usage_ledger`'s header for
227
- why they cannot be resolved implicitly (measured: a contextvar does not survive a FastAPI
228
- dependency).
229
- """
230
- import usage_ledger # noqa: PLC0415
231
- opts = [str(o) for o in (options or []) if str(o).strip()]
232
- if not opts:
233
- return "", {"problem": "the review offers no next stages"}
234
- if not str(prompt or "").strip():
235
- return "", {"problem": "the review has no prompt for the model to follow"}
236
- live = ladder()
237
- if not live:
238
- return "", {"problem": "no AI provider is configured on this deployment"}
239
- system = _instruction(prompt, opts, label)
240
- user = "Here is the record:\n\n" + _record_text(row, fields)
241
- tmo = float(timeout or TIMEOUT_SECONDS)
242
- override = (os.environ.get("AIOS_AI_MODEL") or "").strip()
243
- problems = []
244
- for p in live:
245
- model = override or p["model"]
246
- body = None
247
- try:
248
- text, err, body = (_call_anthropic if p["shape"] == "anthropic" else _call_openai)(
249
- p, model, system, user, tmo)
250
- except Exception as e: # noqa: BLE001
251
- text, err = "", f"{p['name']} failed: {type(e).__name__}"
252
- # ⭐⭐ C7 β€” THE LEDGER LINE, BEFORE ANY BRANCH BELOW READS THE ANSWER.
253
- # β›” IT IS WRITTEN ON EVERY OUTCOME THAT REACHED A PROVIDER, including a refusal and an
254
- # unusable answer. A meter that books only successes reports a cheap week for a run that
255
- # spent its budget being declined β€” and a declined call is billed. The only path that does
256
- # NOT record is a transport that never reached the vendor (`body is None`), which spent
257
- # nothing.
258
- if body is not None:
259
- ins, outs = usage_ledger.tokens_from(body)
260
- usage_ledger.record("ai_review", p["name"], model, ins, outs,
261
- total=usage_ledger.total_from(body), st=st, user=user)
262
- if err:
263
- problems.append(err)
264
- continue # ladder: a dead provider degrades to the next one
265
- choice, reason = _parse(text, opts)
266
- if not choice:
267
- # The provider ANSWERED and declined (or answered unusably). That is a decision about
268
- # this record, not a fault in the provider, so it does NOT fall through to a more
269
- # expensive one β€” the card goes to a human, which is what the model just asked for.
270
- return "", {"provider": p["name"], "model": model,
271
- "problem": reason or "the model did not choose one of the stages"}
272
- return choice, {"provider": p["name"], "model": model, "reason": reason}
273
- return "", {"problem": "; ".join(problems)[:300] or "no provider answered"}
274
-
275
-
276
- # ══════════════════════════════════════════════════════════════════ the FLOW WRITER
277
- # ⭐⭐ WAVE 33 Β· W33-T54/T55 (owner item 7, ruling R3) β€” A PROMPT BECOMES A DRAFT AUTOMATION.
278
- #
279
- # Owner, verbatim: *"lay out the foundation with custom tools we provide"* β€” and the tools are the
280
- # action catalog, which has been machine-readable since wave 23. So this writes no new vocabulary:
281
- # it hands the model the SAME `ACTION_CATALOG` the menu paints, the SAME `ACTION_REQUIRED` the
282
- # runner blocks on, and the SAME trigger keys `clean_trigger` accepts, and asks for one JSON object
283
- # in that vocabulary.
284
- #
285
- # β›” WHY IT LIVES IN `ai_review.py` RATHER THAN A NEW FILE. Two reasons, both about ownership. This
286
- # is the one module in the API package that already holds an LLM ladder and its credentials, so a
287
- # second one would be a second place a rotated key has to be noticed; and a new `routes_*.py` needs
288
- # a `main.py` line from another lane (contract C2) to be reachable at all, which is how wave 23
289
- # shipped three finished routers 404-dead behind green gates. The DOOR is a route on
290
- # `routes_automation.py`, which is already mounted.
291
- #
292
- # β›”β›” CONTRACT C7 IS THE DESIGN, NOT A CHECK AT THE END: *"the flow writer may emit only kinds
293
- # present in `ACTION_CATALOG`, and its output must pass `clean_actions` unchanged."* Both halves are
294
- # enforced mechanically rather than asked for politely β€” the kind list is an `enum` in the schema
295
- # the model fills, and the caller runs `clean_actions` and DIFFS the result. That diff matters
296
- # because `clean_actions` has no disclosure channel (D-75): it drops a key it does not recognise
297
- # and answers 200, so a draft accepted without the diff would show a person a flow that is not the
298
- # flow the model described, with nothing anywhere saying so.
299
- #
300
- # ⚠ `chat` IS INJECTABLE, exactly as `routes_query._call_model`'s is, and it is the reason this door
301
- # can be proven end to end with no API key and no spend. A gate that can only run where a
302
- # credential exists is a gate that never runs.
303
-
304
- #: Cerebras first, NOT the cheap-first order `ladder()` uses. Same reason `routes_query` inverts it:
305
- #: this contract includes a REFUSAL, and refusing honestly ("I cannot build that from the steps you
306
- #: have") is a model property. A cheaper rung answers an impossible request with a plausible flow,
307
- #: which is worse than no flow. Override with `AIOS_FLOW_PROVIDER`.
308
- FLOW_PROVIDER_ORDER = ("cerebras", "groq", "openrouter", "anthropic")
309
- MAX_DRAFT_ACTIONS = 12 # a draft a person reads in one screen; the engine's own cap is 25
310
- MAX_PROMPT_CHARS = 2000
311
-
312
-
313
- def flow_providers(pin=None):
314
- """The rungs usable here, in THIS module's refusal-first order. Empty = the feature is off.
315
-
316
- ⭐ `pin` IS ASK D-18 (2026-08-18): the Agent chat's model toggle must configure something, and
317
- the draft door used to read `prompt` off the body and nothing else β€” so the key the client sent
318
- was accepted and dropped, and the picker was a control over nothing.
319
- ⚠ AN UNKNOWN OR UNCONFIGURED PIN FALLS BACK TO THE LADDER rather than refusing. A model the
320
- ladder stopped offering must not turn every later draft into an error; the caller is told which
321
- rung actually answered, which is the honest half.
322
- """
323
- by_name = {p["name"]: p for p in PROVIDERS}
324
- live = [n for n in FLOW_PROVIDER_ORDER
325
- if n in by_name and (os.environ.get(by_name[n]["env"]) or "").strip()]
326
- wanted = str(pin or os.environ.get("AIOS_FLOW_PROVIDER") or "").strip().lower()
327
- if wanted and wanted in live:
328
- return [by_name[wanted]]
329
- return [by_name[n] for n in live]
330
-
331
-
332
- def flow_schema(kinds, trigger_keys, table_keys):
333
- """The tool schema the model fills β€” the catalog's OWN key lists as enums.
334
-
335
- β›” `kind` AND `trigger` ARE ENUMS, not free strings, and that is C7's first half enforced by the
336
- transport rather than by a check afterwards. A model asked for "any action name" invents
337
- `send_slack_message` on a deployment that has no such kind, and the failure then surfaces as a
338
- 400 from `clean_actions` carrying a sentence about a word the person never typed.
339
- ⚠ `required` IS `["kind"]` ALONE, deliberately: a REFUSAL carries no name and no actions, and a
340
- schema demanding them turns an honest refusal into a provider-side 400 that reads exactly like a
341
- transport failure (`routes_query._spec_schema` carries the same note, for the same measured
342
- reason).
343
- """
344
- return {
345
- "type": "object",
346
- "properties": {
347
- "kind": {"type": "string", "enum": ["flow", "refused"],
348
- "description": "refused = this cannot be built from the steps available"},
349
- "refusal": {"type": "string",
350
- "description": "when kind=refused: ONE plain sentence naming what is "
351
- "missing, in the words a non-technical person would use"},
352
- "name": {"type": "string", "description": "a short title for the automation"},
353
- "trigger": {"type": "string", "enum": sorted(trigger_keys),
354
- "description": "what starts this automation"},
355
- "table": {"type": "string", "enum": sorted(table_keys),
356
- "description": "the database whose records this flow walks, if any"},
357
- "actions": {
358
- "type": "array",
359
- "maxItems": MAX_DRAFT_ACTIONS,
360
- "items": {
361
- "type": "object",
362
- "properties": {
363
- "kind": {"type": "string", "enum": sorted(kinds)},
364
- "why": {"type": "string",
365
- "description": "one short sentence: why this step is here"},
366
- "config": {"type": "object", "additionalProperties": True,
367
- "description": "the step's settings, using ONLY the keys named "
368
- "for that kind in the system message"},
369
- },
370
- "required": ["kind"],
371
- },
372
- },
373
- },
374
- "required": ["kind"],
375
- }
376
-
377
-
378
- def flow_system_prompt(catalog, required, triggers, tables):
379
- """What the model is told it may build with β€” DERIVED, never written down twice.
380
-
381
- Every list here is the server's own: the catalog rows the menu paints, the required-key table
382
- the runner blocks on, the trigger keys `clean_trigger` accepts, and this tenant's real databases
383
- with their real columns. Nothing about the vocabulary is restated by hand, so a kind added to
384
- the catalog is offered here on the same deploy and a kind removed stops being offered.
385
- """
386
- lines = ["You build small automations for a business tool. You are given the EXACT set of "
387
- "steps this tool can perform. You may use nothing else.",
388
- "", "THE STEPS YOU MAY USE:"]
389
- # ⚠ BOTH HALVES, `key (phrase)`. The KEY is what the model must write into `config` and the
390
- # PHRASE is the only human wording of that requirement anywhere β€” the one the runner's own
391
- # refusal sentence is built from. Keys alone leave the model guessing what `field` means on a
392
- # `web_read` (it is a column to write into, not a form field); phrases alone leave it guessing
393
- # what to call the setting. Handing it one and hoping is how a draft comes back configured
394
- # against a key the validator drops.
395
- req = {str(k): [f"{key} ({phrase})" for phrase, key in v] for k, v in (required or {}).items()}
396
- for row in catalog or []:
397
- if not row.get("ready"):
398
- continue
399
- kind = str(row.get("kind") or "")
400
- need = req.get(kind) or []
401
- lines.append(f"- {kind}: {row.get('label')} - {row.get('detail') or ''}"
402
- + (f" REQUIRED settings: {', '.join(need)}" if need else ""))
403
- lines += ["", "WHAT CAN START AN AUTOMATION:"]
404
- for t in triggers or []:
405
- if t.get("planned") or not t.get("ready", True):
406
- continue
407
- lines.append(f"- {t.get('key')}: {t.get('label')}")
408
- lines += ["", "THE DATABASES THIS PERSON HAS:"]
409
- for t in (tables or [])[:40]:
410
- cols = ", ".join(str(f.get("key")) for f in (t.get("fields") or [])[:25])
411
- lines.append(f"- {t.get('key')} ({t.get('label')}): {cols or 'no columns yet'}")
412
- if not tables:
413
- lines.append("- (none - do not name a database)")
414
- lines += [
415
- "",
416
- "RULES:",
417
- "1. Use ONLY the step kinds listed above. If what is asked needs a step that is not there, "
418
- "answer kind=refused and say plainly which capability is missing.",
419
- "2. Fill in every REQUIRED setting you can from what the person told you. Leave one blank "
420
- "rather than inventing a web address, a CSS selector or a column name.",
421
- "3. A web address must start with http:// or https://.",
422
- "4. To use a value from the record the flow is walking, write it as {{Column name}}.",
423
- "5. Name a database only from the list above, by its key.",
424
- "6. Keep it short. Fewer steps that work beat more steps that guess.",
425
- ]
426
- return "\n".join(lines)
427
-
428
-
429
- def _flow_from_tool_call(obj):
430
- """The model's tool arguments -> `(draft, refusal)`. A shape error is a refusal, never a crash."""
431
- if not isinstance(obj, dict):
432
- return None, "the assistant's answer could not be read"
433
- if str(obj.get("kind") or "") == "refused":
434
- return None, (str(obj.get("refusal") or "").strip()
435
- or "the assistant could not build this from the steps available")
436
- acts = obj.get("actions")
437
- if not isinstance(acts, list) or not acts:
438
- return None, ("the assistant did not produce any steps - try describing what should "
439
- "happen, one action at a time")
440
- out = []
441
- for a in acts[:MAX_DRAFT_ACTIONS]:
442
- if not isinstance(a, dict) or not str(a.get("kind") or "").strip():
443
- continue
444
- cfg = a.get("config")
445
- out.append({"kind": str(a["kind"]).strip(),
446
- "config": cfg if isinstance(cfg, dict) else {},
447
- "why": str(a.get("why") or "").strip()[:200]})
448
- if not out:
449
- return None, "the assistant's steps could not be read"
450
- return {"name": str(obj.get("name") or "").strip()[:80] or "New automation",
451
- "trigger": str(obj.get("trigger") or "").strip(),
452
- "table": str(obj.get("table") or "").strip(),
453
- # β›”β›” THE TRUNCATION IS REPORTED, and it is a STANDING RULE that it must be (owner,
454
- # 2026-08-12, W30/R6 second sentence): *"if there is lag or it can't be done, you need
455
- # to explicitly tell me why and recommend a fix"* β€” **a silent truncation IS the
456
- # violation, not the limit.** `acts[:MAX_DRAFT_ACTIONS]` above dropped everything past
457
- # the twelfth and said nothing, so a model that answered with a twenty-step journey had
458
- # eight steps deleted between the answer and the screen, with no key anywhere in the
459
- # response naming them. `MAX_ACTIONS` is 20, so those steps were STORABLE β€” this
460
- # ceiling is the draft door's own, which makes reporting it the whole obligation.
461
- # ⚠ Found by the verifier that checked this ticket, not by a gate.
462
- "asked": len([a for a in acts if isinstance(a, dict)]),
463
- "actions": out}, ""
464
-
465
-
466
- def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeout=None,
467
- st=None, user="", model=None):
468
- """A sentence -> `(draft, refusal_sentence, provider)`. β›” NOTHING IS SAVED HERE.
469
-
470
- Exactly one of `draft` and `refusal_sentence` is truthy β€” the same contract
471
- `web_agent.run_step` keeps, so a caller has no third case to get wrong.
472
-
473
- ⭐ W35-T41 / C7 β€” `st`/`user` are the usage ledger's target, exactly as on `decide` above and for
474
- the same reason: both of this function's callers (`automation_engine._ai_agent_plan` and
475
- `routes_automation`'s draft door) are in lane D's fence. C7 says E adds the ledger line here and
476
- D asserts it; the two keywords are what D has to pass for the line to be attributable.
477
- """
478
- # ⚠ DECLARED AT THE TOP OF THE FUNCTION, not beside the assignment inside the provider loop.
479
- # It parses either way; a reader scanning for the declaration does not look inside a `for`.
480
- global LAST_ANTHROPIC_TRANSPORT
481
- import usage_ledger # noqa: PLC0415
482
- text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
483
- if not text:
484
- return None, "type what you want the automation to do", None
485
- kinds = sorted({str(r.get("kind")) for r in (catalog or []) if r.get("ready")})
486
- if not kinds:
487
- return None, "this deployment offers no automation steps to build with", None
488
- trigger_keys = sorted({str(t.get("key")) for t in (triggers or [])
489
- if t.get("key") and not t.get("planned")}) or ["manual"]
490
- table_keys = sorted({str(t.get("key")) for t in (tables or []) if t.get("key")}) or [""]
491
- tools = [{"type": "function", "function": {
492
- "name": "build_automation",
493
- "description": "Emit the automation, or refuse.",
494
- "parameters": flow_schema(kinds, trigger_keys, table_keys)}}]
495
- messages = [{"role": "system",
496
- "content": flow_system_prompt(catalog, required, triggers, tables)},
497
- {"role": "user", "content": text}]
498
-
499
- if chat is not None:
500
- # ⚠ THE INJECTED PATH IS THE PROVEN PATH. It runs the SAME parse and the SAME refusal
501
- # branches as a live call; only the transport is replaced.
502
- draft, refusal = _flow_from_tool_call(chat(messages, tools))
503
- return draft, refusal, "injected"
504
-
505
- provs = flow_providers(model)
506
- if not provs:
507
- # β›” SAY SO. An AI feature that silently does nothing is indistinguishable from one that was
508
- # never built [[flag-shipped-without-its-writer]].
509
- return None, ("the assistant is not configured on this deployment, so an automation cannot "
510
- "be drafted from a description yet"), None
511
- tmo = float(timeout or TIMEOUT_SECONDS)
512
- problems = []
513
- import providers as _prov
514
- for p in provs:
515
- # ⭐⭐ W36-T35 / R4 β€” THE OWNER QUOTED THIS LINE BACK AT US. It used to read
516
- # `problems.append(f"{p['name']}: tool calls are not wired for this shape")` and `continue`,
517
- # so the sentence *"anthropic: tool calls are not wired for this shape"* appeared under the
518
- # Agent module verbatim. R4: *"Anthropic becomes the tool-calling path that always works."*
519
- # It is wired now, through the ONE wire in `providers`, and it matters more than it looks:
520
- # every side rung on this account is refusing today (cerebras 402, groq 404, openrouter
521
- # 402), so without this branch the drafter cannot answer at all.
522
- # ⚠ NO `effort` ON THIS DOOR β€” `output_config.effort` errors on Haiku 4.5, the tier this
523
- # ladder runs. The parameter is the caller's to send, which is why the wire takes it.
524
- # ⭐ D-346 (W37-T39): BOTH shapes now yield the SAME `(status, body)` pair, so every branch
525
- # below reads one thing. The anthropic side gets there through `anthropic_send` (official
526
- # SDK when installed, the identical raw POST when not); the openai side still posts here
527
- # because there is no shared SDK across three different vendors on that wire.
528
- global LAST_ANTHROPIC_TRANSPORT
529
- try:
530
- if p["shape"] == "anthropic":
531
- req = _prov.anthropic_request(
532
- model=p["model"], key=os.environ[p["env"]].strip(), system=None,
533
- messages=messages, tools=tools, max_tokens=1500, tool_choice="required")
534
- status, body, transport = _prov.anthropic_send(req, timeout=tmo)
535
- LAST_ANTHROPIC_TRANSPORT = transport
536
- else:
537
- r = requests.post(p["url"], timeout=tmo,
538
- headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
539
- "Content-Type": "application/json"},
540
- json={"model": p["model"], "messages": messages, "tools": tools,
541
- "tool_choice": "required", "temperature": 0.1,
542
- "max_tokens": 1500})
543
- status = r.status_code
544
- body = r.json() if (r.content and r.status_code == 200) else {"_text": r.text}
545
- except Exception as e: # noqa: BLE001
546
- problems.append(f"{p['name']}: {type(e).__name__}")
547
- continue
548
- if status != 200:
549
- # β›” A SENTENCE, NOT `HTTP 402` (R4's third clause). The owner read the status codes off
550
- # this very door. `refusal_sentence` also decides whether this was a CREDIT failure, and
551
- # the memo makes the next turn SKIP the empty account instead of paying for it again.
552
- # ⚠ The body is re-serialised for the substring test because the SDK path never had a
553
- # `.text` β€” that is the one thing this normalisation costs, and it costs nothing else.
554
- detail = json.dumps(body)[:600] if isinstance(body, dict) else str(body)[:600]
555
- if _prov.is_credit_failure(status, detail):
556
- _prov.mark_no_credit(p["name"])
557
- problems.append(_prov.refusal_sentence(p["name"], status, detail))
558
- continue
559
- try:
560
- body = body or {}
561
- if p["shape"] == "anthropic":
562
- _text, args, _refused = _prov.anthropic_read(body)
563
- if _refused:
564
- problems.append(f"{p['name']}: {_refused}")
565
- continue
566
- else:
567
- calls = (((body.get("choices") or [{}])[0].get("message") or {})
568
- .get("tool_calls") or [])
569
- args = json.loads(calls[0]["function"]["arguments"]) if calls else None
570
- except Exception as e: # noqa: BLE001
571
- problems.append(f"{p['name']}: unreadable answer ({type(e).__name__})")
572
- continue
573
- # ⭐⭐ C7 β€” THE LEDGER LINE. Placed after the parse rather than before it so an UNREADABLE
574
- # answer is not double-counted by the `continue` above... ⚠ which means an unreadable answer
575
- # is NOT counted at all, and that is a deliberate, disclosed loss: a body this code cannot
576
- # parse is a body whose `usage` it also cannot trust, and `body` is out of scope in that
577
- # branch by construction. The 200-with-junk case is rare and named here rather than
578
- # silently rounded to zero.
579
- ins, outs = usage_ledger.tokens_from(body)
580
- usage_ledger.record("automation_draft", p["name"], p["model"], ins, outs,
581
- total=usage_ledger.total_from(body), st=st, user=user)
582
- draft, refusal = _flow_from_tool_call(args)
583
- if draft is None and isinstance(args, dict) and str(args.get("kind")) == "refused":
584
- # β›” A REFUSAL IS AN ANSWER, NOT A FAULT, so it does NOT fall through to a more
585
- # expensive rung β€” the model has just said this cannot be built. `decide()` takes the
586
- # same posture for the same reason.
587
- return None, refusal, p["name"]
588
- if draft is not None:
589
- return draft, "", p["name"]
590
- problems.append(f"{p['name']}: {refusal}")
591
- return None, ("; ".join(problems)[:300] or "no provider answered"), None
 
1
+ """AI REVIEW β€” let a model decide which stage a card moves to (wave 23, owner ruling R4/R14).
2
+
3
+ ⭐ WHAT THIS IS. A review stage holds a record until somebody decides where it goes next. R4 made
4
+ that somebody optionally a MODEL: the engine hands over the record's own values, the review's
5
+ prompt, and the list of stages the review is allowed to send a card to, and gets back ONE of
6
+ those stage labels plus a one-line reason. Every decision is written to the same `reviews` audit
7
+ log a human click writes to, tagged `by: "ai"` with the provider and model that made it.
8
+
9
+ β›” FAIL-CLOSED IN EVERY DIRECTION, and this is the whole safety story. No key configured, a
10
+ network failure, a slow answer, a malformed answer, or an answer naming a stage the review does
11
+ not offer β€” all return `("", {...})`, and the caller leaves the card exactly where a human would
12
+ have found it. The feature can be absent, broken or wrong and the worst outcome is a person doing
13
+ the work. Nothing here can move a card somewhere the review does not already permit.
14
+
15
+ ⭐ CHEAP FIRST (owner R14, verbatim: *"Claude is a bit too expensive"*). The ladder is
16
+ `groq β†’ cerebras β†’ openrouter β†’ anthropic`; the first CONFIGURED provider wins, and Anthropic is
17
+ last rather than absent β€” it is the quality backstop, not the default. `AIOS_AI_REVIEW_PROVIDER`
18
+ pins one; `AIOS_AI_MODEL` overrides the model.
19
+
20
+ ⭐⭐ THE ANTHROPIC LEG IS ON THE OFFICIAL SDK NOW (D-346, W37-T39, 2026-08-19). β›” THIS PARAGRAPH
21
+ USED TO ARGUE THE OPPOSITE and is rewritten rather than deleted, because a comment left asserting
22
+ the reverse of its own code is [[two-gates-can-assert-opposite-things]] with no gate to catch it.
23
+ The retired text said raw HTTP was *"a deliberate deviation … booked as a DEBT line so the
24
+ integrator can overturn it deliberately rather than by drift"*. This is that overturn.
25
+
26
+ Its two reasons were real and are both answered rather than waved away:
27
+ 1. *"one SDK leg beside three hand-rolled ones is two implementations of the same call."* It is
28
+ not, because the leg does not live here: `providers.anthropic_send` owns the wire, prefers
29
+ the SDK, and normalises BOTH transports to one `(status, body)` pair. This file gained no
30
+ Anthropic knowledge β€” it lost some.
31
+ 2. *"adding a pinned dependency rebuilds the container, a real deploy risk."* Still true, so the
32
+ import is LAZY and its absence is a FALLBACK, not a crash: with no `anthropic` package the
33
+ same raw POST runs and `LAST_ANTHROPIC_TRANSPORT` reports `'http'`. The pin is owed in BOTH
34
+ manifests ([[pin-deps-space-rebuilds]]) and is outside every worker fence this wave.
35
+ ⚠ The other three legs stay OpenAI-chat-shaped over `requests` β€” they have no shared SDK, and that
36
+ half of the original reasoning never expired.
37
+
38
+ The Messages shape below is the current one: `x-api-key` + `anthropic-version: 2023-06-01`, and
39
+ `stop_reason: "refusal"` is checked BEFORE reading `content` β€” a refusal answers HTTP 200 with an
40
+ empty content list, so code that indexes `content[0]` unconditionally breaks on it.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import os
46
+ import re
47
+
48
+ import requests
49
+
50
+ #: The ladder. Order IS the policy (R14) β€” cheapest capable first, Anthropic last.
51
+ PROVIDERS = [
52
+ # β›”β›” D-345, FIXED W37-T39 (2026-08-19). This read `llama-3.3-70b-versatile` and Groq RETIRED
53
+ # it: a real POST with the live key answers HTTP 404 `model_not_found`, and the id is absent
54
+ # from `GET /openai/v1/models`. ⚠ THE `openai/` PREFIX IS PART OF GROQ'S ID and is NOT a typo
55
+ # for the cerebras rung below, which serves the same family bare as `gpt-oss-120b`.
56
+ {"name": "groq", "env": "GROQ_API_KEY", "shape": "openai",
57
+ "url": "https://api.groq.com/openai/v1/chat/completions",
58
+ "model": "openai/gpt-oss-120b"},
59
+ {"name": "cerebras", "env": "CEREBRAS_API_KEY", "shape": "openai",
60
+ "url": "https://api.cerebras.ai/v1/chat/completions",
61
+ "model": "gpt-oss-120b"},
62
+ {"name": "openrouter", "env": "OPENROUTER_API_KEY", "shape": "openai",
63
+ "url": "https://openrouter.ai/api/v1/chat/completions",
64
+ "model": "openai/gpt-4o-mini"},
65
+ # ⚠ haiku-class DELIBERATELY, not the default Opus tier: R14 put Anthropic on this ladder as
66
+ # the backstop for a one-line classification, and this is the cheapest current Claude that
67
+ # does it well. A bigger model here would be spending the owner's money to pick between two
68
+ # labels it already has in front of it.
69
+ {"name": "anthropic", "env": "ANTHROPIC_API_KEY", "shape": "anthropic",
70
+ "url": "https://api.anthropic.com/v1/messages",
71
+ # ⚠ AN ENV LEVER, NOT A NEW DEFAULT (W36-T35): haiku stays the choice for the reasons above,
72
+ # and a deployment whose side rungs are out of credit can raise the tier without a release.
73
+ "model": os.environ.get("AIOS_AI_REVIEW_ANTHROPIC_MODEL") or "claude-haiku-4-5"},
74
+ ]
75
+ ANTHROPIC_VERSION = "2023-06-01"
76
+ TIMEOUT_SECONDS = float(os.environ.get("AIOS_AI_REVIEW_TIMEOUT") or 20)
77
+ MAX_FIELD_CHARS = 200 # per value handed to the model
78
+ MAX_FIELDS = 30 # columns handed to the model
79
+ MAX_REASON = 200
80
+
81
+
82
+ def ladder():
83
+ """The providers that are actually usable here, in order. Empty = the feature is off."""
84
+ pin = (os.environ.get("AIOS_AI_REVIEW_PROVIDER") or "").strip().lower()
85
+ live = [p for p in PROVIDERS if (os.environ.get(p["env"]) or "").strip()]
86
+ if pin:
87
+ live = [p for p in live if p["name"] == pin]
88
+ return live
89
+
90
+
91
+ def configured():
92
+ return bool(ladder())
93
+
94
+
95
+ def _record_text(row, fields):
96
+ """The record, as the model sees it. Values are truncated and the column set is bounded β€”
97
+ an automation table can carry a 32 KB JSON blob per row (C7) and a review decision does not
98
+ need it. Machine bookkeeping columns are dropped: a stage cell naming the stage the card is
99
+ sitting at would be the model reading its own question back."""
100
+ keys = [k for k in (fields or list((row or {}).keys()))
101
+ if not str(k).startswith("stage_")][:MAX_FIELDS]
102
+ lines = []
103
+ for k in keys:
104
+ v = (row or {}).get(k)
105
+ if v is None or str(v).strip() == "":
106
+ continue
107
+ lines.append(f"{k}: {str(v)[:MAX_FIELD_CHARS]}")
108
+ return "\n".join(lines) or "(this record has no filled-in values)"
109
+
110
+
111
+ def _instruction(prompt, options, label):
112
+ return (
113
+ f"You are deciding what happens to one record waiting at a review step called "
114
+ f"{label!r} in a workflow.\n\n"
115
+ f"The person who built this workflow told you: {prompt}\n\n"
116
+ f"Choose EXACTLY ONE of these next steps, by its exact name:\n"
117
+ + "\n".join(f"- {o}" for o in options)
118
+ + "\n\nAnswer with one line of JSON and nothing else:\n"
119
+ '{"choice": "<one name from the list above>", "reason": "<one short sentence>"}\n'
120
+ "If the record does not give you enough to decide, answer "
121
+ '{"choice": "", "reason": "why not"} and a person will decide instead.'
122
+ )
123
+
124
+
125
+ def _parse(text, options):
126
+ """The model's line β†’ `(choice, reason)`. A choice that is not one of the offered stages is
127
+ DISCARDED, not fuzzy-matched: the offered list is a permission boundary, and a near-miss
128
+ resolved by string distance is how a card ends up somewhere nobody authorised."""
129
+ raw = str(text or "").strip()
130
+ obj = None
131
+ m = re.search(r"\{.*\}", raw, re.S)
132
+ if m:
133
+ try:
134
+ obj = json.loads(m.group(0))
135
+ except (ValueError, TypeError):
136
+ obj = None
137
+ if not isinstance(obj, dict):
138
+ return "", ""
139
+ choice = str(obj.get("choice") or "").strip()
140
+ reason = str(obj.get("reason") or "").strip()[:MAX_REASON]
141
+ for opt in options:
142
+ if choice.lower() == str(opt).lower():
143
+ return str(opt), reason # the OFFERED spelling wins, never the model's
144
+ return "", reason
145
+
146
+
147
+ # ⭐⭐ WAVE 35 Β· W35-T41 / C7 β€” THE TWO TRANSPORTS RETURN THE RESPONSE BODY'S `usage`.
148
+ #
149
+ # β›” THIS FILE'S OWN HEADER USED TO BE THE PRODUCT'S CONFESSION THAT NOTHING COUNTED: `ai_enrich`'s
150
+ # docstring says *"`ai_review.decide` -- the product's only other LLM entry -- has no token
151
+ # accounting of ANY kind"*. R9 asks for ONE meter for every AI surface, so the counting has to reach
152
+ # this transport rather than be bolted onto its caller β€” the caller never sees the body.
153
+ #
154
+ # ⚠ A THIRD RETURN VALUE, NOT A MUTATED ARGUMENT, and both call sites are in `decide` below. The
155
+ # tuple grew from `(text, err)` to `(text, err, usage)`; `usage` is the raw `usage` OBJECT (or None),
156
+ # because reading it is `usage_ledger`'s job and a second reader here would be the two-normalizers
157
+ # shape this repo keeps paying for.
158
+ def _call_openai(p, model, system, user, timeout):
159
+ r = requests.post(p["url"], timeout=timeout,
160
+ headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
161
+ "Content-Type": "application/json"},
162
+ json={"model": model, "max_tokens": 300, "temperature": 0,
163
+ "messages": [{"role": "system", "content": system},
164
+ {"role": "user", "content": user}]})
165
+ if r.status_code >= 400:
166
+ # ⚠ NO BODY ON A 4xx/5xx: an error envelope carries no usage, and a provider that refused
167
+ # before running the model has nothing to bill. The call is still COUNTED by `decide`.
168
+ return "", f"{p['name']} answered {r.status_code}", None
169
+ body = r.json()
170
+ choices = body.get("choices") or []
171
+ if not choices:
172
+ return "", f"{p['name']} returned no choices", body
173
+ return str(((choices[0] or {}).get("message") or {}).get("content") or ""), "", body
174
+
175
+
176
+ #: What transport the last Anthropic call actually used: `'sdk'`, `'http'`, or `''` before any.
177
+ #: ⚠ PROCESS-LOCAL AND FOR REPORTING ONLY. It exists so `GET /meta` and `verify_web_agent` can say
178
+ #: WHICH path ran rather than inferring it from a requirements file nobody in this lane can edit.
179
+ LAST_ANTHROPIC_TRANSPORT = ""
180
+
181
+
182
+ def _call_anthropic(p, model, system, user, timeout):
183
+ """One classification turn on the Messages API, through `providers.anthropic_send`.
184
+
185
+ ⭐ D-346 (W37-T39): this used to hand-roll the POST. It now goes through the shared wire, which
186
+ prefers the OFFICIAL `anthropic` SDK and falls back to the same raw POST when the package is
187
+ absent. The shape of what comes back is unchanged, deliberately: `anthropic_send` normalises
188
+ both transports to `(status, body)`, so every line below this call is untouched.
189
+ """
190
+ global LAST_ANTHROPIC_TRANSPORT
191
+ import providers as _prov # noqa: PLC0415
192
+ # ⚠ NO TOOLS ON THIS PATH. The builder omits `tool_choice` for exactly that case, so there is
193
+ # nothing to strip here β€” the rule lives in `anthropic_request`, beside the thing it constrains.
194
+ req = _prov.anthropic_request(
195
+ model=model, key=os.environ[p["env"]].strip(), system=system,
196
+ messages=[{"role": "user", "content": user}], tools=[], max_tokens=300)
197
+ status, body, transport = _prov.anthropic_send(req, timeout=timeout)
198
+ LAST_ANTHROPIC_TRANSPORT = transport
199
+ if status >= 400 or status == 0:
200
+ # ⭐ THE SENTENCE COMES FROM THE LADDER, NOT FROM HERE. `refusal_sentence` already names the
201
+ # vendor and the action for every status R4 enumerated, so a bare "anthropic answered 402"
202
+ # (which is what this line used to say, and what the owner quoted back at us) cannot recur.
203
+ return "", _prov.refusal_sentence("anthropic", status, json.dumps(body)[:400]), None
204
+ body = body or {}
205
+ # β›” stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
206
+ # reading content[0] before this check turns a refusal into an IndexError inside a run.
207
+ # ⚠ A refusal IS billed and its body carries `usage`, so the body rides back on this branch too.
208
+ if body.get("stop_reason") == "refusal":
209
+ return "", "anthropic declined to answer this record", body
210
+ parts = [b.get("text") or "" for b in (body.get("content") or [])
211
+ if isinstance(b, dict) and b.get("type") == "text"]
212
+ if not parts:
213
+ return "", "anthropic returned no text", body
214
+ return "".join(parts), "", body
215
+
216
+
217
+ def decide(*, prompt, options, row, fields=(), label="Review", timeout=None, st=None, user=""):
218
+ """Pick this record's next stage. Returns `(choice, meta)`.
219
+
220
+ `choice` is "" whenever a person should decide β€” which is every failure mode there is.
221
+ `meta` carries `provider`, `model`, `reason` on success, and `problem` on refusal to answer.
222
+
223
+ ⭐ W35-T41 / C7 β€” `st` and `user` are the USAGE LEDGER's target. Both default to absent because
224
+ this function's caller is `automation_engine.ai_decide(rt, ...)`, in another lane's fence: it
225
+ HAS the runtime and does not pass it yet, so until it does, a review's tokens are counted as
226
+ unattributed and REPORTED by `GET /usage` rather than dropped. See `usage_ledger`'s header for
227
+ why they cannot be resolved implicitly (measured: a contextvar does not survive a FastAPI
228
+ dependency).
229
+ """
230
+ import usage_ledger # noqa: PLC0415
231
+ opts = [str(o) for o in (options or []) if str(o).strip()]
232
+ if not opts:
233
+ return "", {"problem": "the review offers no next stages"}
234
+ if not str(prompt or "").strip():
235
+ return "", {"problem": "the review has no prompt for the model to follow"}
236
+ live = ladder()
237
+ if not live:
238
+ return "", {"problem": "no AI provider is configured on this deployment"}
239
+ system = _instruction(prompt, opts, label)
240
+ user = "Here is the record:\n\n" + _record_text(row, fields)
241
+ tmo = float(timeout or TIMEOUT_SECONDS)
242
+ override = (os.environ.get("AIOS_AI_MODEL") or "").strip()
243
+ problems = []
244
+ for p in live:
245
+ model = override or p["model"]
246
+ body = None
247
+ try:
248
+ text, err, body = (_call_anthropic if p["shape"] == "anthropic" else _call_openai)(
249
+ p, model, system, user, tmo)
250
+ except Exception as e: # noqa: BLE001
251
+ text, err = "", f"{p['name']} failed: {type(e).__name__}"
252
+ # ⭐⭐ C7 β€” THE LEDGER LINE, BEFORE ANY BRANCH BELOW READS THE ANSWER.
253
+ # β›” IT IS WRITTEN ON EVERY OUTCOME THAT REACHED A PROVIDER, including a refusal and an
254
+ # unusable answer. A meter that books only successes reports a cheap week for a run that
255
+ # spent its budget being declined β€” and a declined call is billed. The only path that does
256
+ # NOT record is a transport that never reached the vendor (`body is None`), which spent
257
+ # nothing.
258
+ if body is not None:
259
+ ins, outs = usage_ledger.tokens_from(body)
260
+ usage_ledger.record("ai_review", p["name"], model, ins, outs,
261
+ total=usage_ledger.total_from(body), st=st, user=user)
262
+ if err:
263
+ problems.append(err)
264
+ continue # ladder: a dead provider degrades to the next one
265
+ choice, reason = _parse(text, opts)
266
+ if not choice:
267
+ # The provider ANSWERED and declined (or answered unusably). That is a decision about
268
+ # this record, not a fault in the provider, so it does NOT fall through to a more
269
+ # expensive one β€” the card goes to a human, which is what the model just asked for.
270
+ return "", {"provider": p["name"], "model": model,
271
+ "problem": reason or "the model did not choose one of the stages"}
272
+ return choice, {"provider": p["name"], "model": model, "reason": reason}
273
+ return "", {"problem": "; ".join(problems)[:300] or "no provider answered"}
274
+
275
+
276
+ # ══════════════════════════════════════════════════════════════════ the FLOW WRITER
277
+ # ⭐⭐ WAVE 33 Β· W33-T54/T55 (owner item 7, ruling R3) β€” A PROMPT BECOMES A DRAFT AUTOMATION.
278
+ #
279
+ # Owner, verbatim: *"lay out the foundation with custom tools we provide"* β€” and the tools are the
280
+ # action catalog, which has been machine-readable since wave 23. So this writes no new vocabulary:
281
+ # it hands the model the SAME `ACTION_CATALOG` the menu paints, the SAME `ACTION_REQUIRED` the
282
+ # runner blocks on, and the SAME trigger keys `clean_trigger` accepts, and asks for one JSON object
283
+ # in that vocabulary.
284
+ #
285
+ # β›” WHY IT LIVES IN `ai_review.py` RATHER THAN A NEW FILE. Two reasons, both about ownership. This
286
+ # is the one module in the API package that already holds an LLM ladder and its credentials, so a
287
+ # second one would be a second place a rotated key has to be noticed; and a new `routes_*.py` needs
288
+ # a `main.py` line from another lane (contract C2) to be reachable at all, which is how wave 23
289
+ # shipped three finished routers 404-dead behind green gates. The DOOR is a route on
290
+ # `routes_automation.py`, which is already mounted.
291
+ #
292
+ # β›”β›” CONTRACT C7 IS THE DESIGN, NOT A CHECK AT THE END: *"the flow writer may emit only kinds
293
+ # present in `ACTION_CATALOG`, and its output must pass `clean_actions` unchanged."* Both halves are
294
+ # enforced mechanically rather than asked for politely β€” the kind list is an `enum` in the schema
295
+ # the model fills, and the caller runs `clean_actions` and DIFFS the result. That diff matters
296
+ # because `clean_actions` has no disclosure channel (D-75): it drops a key it does not recognise
297
+ # and answers 200, so a draft accepted without the diff would show a person a flow that is not the
298
+ # flow the model described, with nothing anywhere saying so.
299
+ #
300
+ # ⚠ `chat` IS INJECTABLE, exactly as `routes_query._call_model`'s is, and it is the reason this door
301
+ # can be proven end to end with no API key and no spend. A gate that can only run where a
302
+ # credential exists is a gate that never runs.
303
+
304
+ #: Cerebras first, NOT the cheap-first order `ladder()` uses. Same reason `routes_query` inverts it:
305
+ #: this contract includes a REFUSAL, and refusing honestly ("I cannot build that from the steps you
306
+ #: have") is a model property. A cheaper rung answers an impossible request with a plausible flow,
307
+ #: which is worse than no flow. Override with `AIOS_FLOW_PROVIDER`.
308
+ FLOW_PROVIDER_ORDER = ("cerebras", "groq", "openrouter", "anthropic")
309
+ MAX_DRAFT_ACTIONS = 12 # a draft a person reads in one screen; the engine's own cap is 25
310
+ MAX_PROMPT_CHARS = 2000
311
+
312
+
313
+ def flow_providers(pin=None):
314
+ """The rungs usable here, in THIS module's refusal-first order. Empty = the feature is off.
315
+
316
+ ⭐ `pin` IS ASK D-18 (2026-08-18): the Agent chat's model toggle must configure something, and
317
+ the draft door used to read `prompt` off the body and nothing else β€” so the key the client sent
318
+ was accepted and dropped, and the picker was a control over nothing.
319
+ ⚠ AN UNKNOWN OR UNCONFIGURED PIN FALLS BACK TO THE LADDER rather than refusing. A model the
320
+ ladder stopped offering must not turn every later draft into an error; the caller is told which
321
+ rung actually answered, which is the honest half.
322
+ """
323
+ by_name = {p["name"]: p for p in PROVIDERS}
324
+ live = [n for n in FLOW_PROVIDER_ORDER
325
+ if n in by_name and (os.environ.get(by_name[n]["env"]) or "").strip()]
326
+ wanted = str(pin or os.environ.get("AIOS_FLOW_PROVIDER") or "").strip().lower()
327
+ if wanted and wanted in live:
328
+ return [by_name[wanted]]
329
+ return [by_name[n] for n in live]
330
+
331
+
332
+ def flow_schema(kinds, trigger_keys, table_keys):
333
+ """The tool schema the model fills β€” the catalog's OWN key lists as enums.
334
+
335
+ β›” `kind` AND `trigger` ARE ENUMS, not free strings, and that is C7's first half enforced by the
336
+ transport rather than by a check afterwards. A model asked for "any action name" invents
337
+ `send_slack_message` on a deployment that has no such kind, and the failure then surfaces as a
338
+ 400 from `clean_actions` carrying a sentence about a word the person never typed.
339
+ ⚠ `required` IS `["kind"]` ALONE, deliberately: a REFUSAL carries no name and no actions, and a
340
+ schema demanding them turns an honest refusal into a provider-side 400 that reads exactly like a
341
+ transport failure (`routes_query._spec_schema` carries the same note, for the same measured
342
+ reason).
343
+ """
344
+ return {
345
+ "type": "object",
346
+ "properties": {
347
+ "kind": {"type": "string", "enum": ["flow", "refused"],
348
+ "description": "refused = this cannot be built from the steps available"},
349
+ "refusal": {"type": "string",
350
+ "description": "when kind=refused: ONE plain sentence naming what is "
351
+ "missing, in the words a non-technical person would use"},
352
+ "name": {"type": "string", "description": "a short title for the automation"},
353
+ "trigger": {"type": "string", "enum": sorted(trigger_keys),
354
+ "description": "what starts this automation"},
355
+ "table": {"type": "string", "enum": sorted(table_keys),
356
+ "description": "the database whose records this flow walks, if any"},
357
+ "actions": {
358
+ "type": "array",
359
+ "maxItems": MAX_DRAFT_ACTIONS,
360
+ "items": {
361
+ "type": "object",
362
+ "properties": {
363
+ "kind": {"type": "string", "enum": sorted(kinds)},
364
+ "why": {"type": "string",
365
+ "description": "one short sentence: why this step is here"},
366
+ "config": {"type": "object", "additionalProperties": True,
367
+ "description": "the step's settings, using ONLY the keys named "
368
+ "for that kind in the system message"},
369
+ },
370
+ "required": ["kind"],
371
+ },
372
+ },
373
+ },
374
+ "required": ["kind"],
375
+ }
376
+
377
+
378
+ def flow_system_prompt(catalog, required, triggers, tables):
379
+ """What the model is told it may build with β€” DERIVED, never written down twice.
380
+
381
+ Every list here is the server's own: the catalog rows the menu paints, the required-key table
382
+ the runner blocks on, the trigger keys `clean_trigger` accepts, and this tenant's real databases
383
+ with their real columns. Nothing about the vocabulary is restated by hand, so a kind added to
384
+ the catalog is offered here on the same deploy and a kind removed stops being offered.
385
+ """
386
+ lines = ["You build small automations for a business tool. You are given the EXACT set of "
387
+ "steps this tool can perform. You may use nothing else.",
388
+ "", "THE STEPS YOU MAY USE:"]
389
+ # ⚠ BOTH HALVES, `key (phrase)`. The KEY is what the model must write into `config` and the
390
+ # PHRASE is the only human wording of that requirement anywhere β€” the one the runner's own
391
+ # refusal sentence is built from. Keys alone leave the model guessing what `field` means on a
392
+ # `web_read` (it is a column to write into, not a form field); phrases alone leave it guessing
393
+ # what to call the setting. Handing it one and hoping is how a draft comes back configured
394
+ # against a key the validator drops.
395
+ req = {str(k): [f"{key} ({phrase})" for phrase, key in v] for k, v in (required or {}).items()}
396
+ for row in catalog or []:
397
+ if not row.get("ready"):
398
+ continue
399
+ kind = str(row.get("kind") or "")
400
+ need = req.get(kind) or []
401
+ lines.append(f"- {kind}: {row.get('label')} - {row.get('detail') or ''}"
402
+ + (f" REQUIRED settings: {', '.join(need)}" if need else ""))
403
+ lines += ["", "WHAT CAN START AN AUTOMATION:"]
404
+ for t in triggers or []:
405
+ if t.get("planned") or not t.get("ready", True):
406
+ continue
407
+ lines.append(f"- {t.get('key')}: {t.get('label')}")
408
+ lines += ["", "THE DATABASES THIS PERSON HAS:"]
409
+ for t in (tables or [])[:40]:
410
+ cols = ", ".join(str(f.get("key")) for f in (t.get("fields") or [])[:25])
411
+ lines.append(f"- {t.get('key')} ({t.get('label')}): {cols or 'no columns yet'}")
412
+ if not tables:
413
+ lines.append("- (none - do not name a database)")
414
+ lines += [
415
+ "",
416
+ "RULES:",
417
+ "1. Use ONLY the step kinds listed above. If what is asked needs a step that is not there, "
418
+ "answer kind=refused and say plainly which capability is missing.",
419
+ "2. Fill in every REQUIRED setting you can from what the person told you. Leave one blank "
420
+ "rather than inventing a web address, a CSS selector or a column name.",
421
+ "3. A web address must start with http:// or https://.",
422
+ "4. To use a value from the record the flow is walking, write it as {{Column name}}.",
423
+ "5. Name a database only from the list above, by its key.",
424
+ "6. Keep it short. Fewer steps that work beat more steps that guess.",
425
+ ]
426
+ return "\n".join(lines)
427
+
428
+
429
+ def _flow_from_tool_call(obj):
430
+ """The model's tool arguments -> `(draft, refusal)`. A shape error is a refusal, never a crash."""
431
+ if not isinstance(obj, dict):
432
+ return None, "the assistant's answer could not be read"
433
+ if str(obj.get("kind") or "") == "refused":
434
+ return None, (str(obj.get("refusal") or "").strip()
435
+ or "the assistant could not build this from the steps available")
436
+ acts = obj.get("actions")
437
+ if not isinstance(acts, list) or not acts:
438
+ return None, ("the assistant did not produce any steps - try describing what should "
439
+ "happen, one action at a time")
440
+ out = []
441
+ for a in acts[:MAX_DRAFT_ACTIONS]:
442
+ if not isinstance(a, dict) or not str(a.get("kind") or "").strip():
443
+ continue
444
+ cfg = a.get("config")
445
+ out.append({"kind": str(a["kind"]).strip(),
446
+ "config": cfg if isinstance(cfg, dict) else {},
447
+ "why": str(a.get("why") or "").strip()[:200]})
448
+ if not out:
449
+ return None, "the assistant's steps could not be read"
450
+ return {"name": str(obj.get("name") or "").strip()[:80] or "New automation",
451
+ "trigger": str(obj.get("trigger") or "").strip(),
452
+ "table": str(obj.get("table") or "").strip(),
453
+ # β›”β›” THE TRUNCATION IS REPORTED, and it is a STANDING RULE that it must be (owner,
454
+ # 2026-08-12, W30/R6 second sentence): *"if there is lag or it can't be done, you need
455
+ # to explicitly tell me why and recommend a fix"* β€” **a silent truncation IS the
456
+ # violation, not the limit.** `acts[:MAX_DRAFT_ACTIONS]` above dropped everything past
457
+ # the twelfth and said nothing, so a model that answered with a twenty-step journey had
458
+ # eight steps deleted between the answer and the screen, with no key anywhere in the
459
+ # response naming them. `MAX_ACTIONS` is 20, so those steps were STORABLE β€” this
460
+ # ceiling is the draft door's own, which makes reporting it the whole obligation.
461
+ # ⚠ Found by the verifier that checked this ticket, not by a gate.
462
+ "asked": len([a for a in acts if isinstance(a, dict)]),
463
+ "actions": out}, ""
464
+
465
+
466
+ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeout=None,
467
+ st=None, user="", model=None):
468
+ """A sentence -> `(draft, refusal_sentence, provider)`. β›” NOTHING IS SAVED HERE.
469
+
470
+ Exactly one of `draft` and `refusal_sentence` is truthy β€” the same contract
471
+ `web_agent.run_step` keeps, so a caller has no third case to get wrong.
472
+
473
+ ⭐ W35-T41 / C7 β€” `st`/`user` are the usage ledger's target, exactly as on `decide` above and for
474
+ the same reason: both of this function's callers (`automation_engine._ai_agent_plan` and
475
+ `routes_automation`'s draft door) are in lane D's fence. C7 says E adds the ledger line here and
476
+ D asserts it; the two keywords are what D has to pass for the line to be attributable.
477
+ """
478
+ # ⚠ DECLARED AT THE TOP OF THE FUNCTION, not beside the assignment inside the provider loop.
479
+ # It parses either way; a reader scanning for the declaration does not look inside a `for`.
480
+ global LAST_ANTHROPIC_TRANSPORT
481
+ import usage_ledger # noqa: PLC0415
482
+ text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
483
+ if not text:
484
+ return None, "type what you want the automation to do", None
485
+ kinds = sorted({str(r.get("kind")) for r in (catalog or []) if r.get("ready")})
486
+ if not kinds:
487
+ return None, "this deployment offers no automation steps to build with", None
488
+ trigger_keys = sorted({str(t.get("key")) for t in (triggers or [])
489
+ if t.get("key") and not t.get("planned")}) or ["manual"]
490
+ table_keys = sorted({str(t.get("key")) for t in (tables or []) if t.get("key")}) or [""]
491
+ tools = [{"type": "function", "function": {
492
+ "name": "build_automation",
493
+ "description": "Emit the automation, or refuse.",
494
+ "parameters": flow_schema(kinds, trigger_keys, table_keys)}}]
495
+ messages = [{"role": "system",
496
+ "content": flow_system_prompt(catalog, required, triggers, tables)},
497
+ {"role": "user", "content": text}]
498
+
499
+ if chat is not None:
500
+ # ⚠ THE INJECTED PATH IS THE PROVEN PATH. It runs the SAME parse and the SAME refusal
501
+ # branches as a live call; only the transport is replaced.
502
+ draft, refusal = _flow_from_tool_call(chat(messages, tools))
503
+ return draft, refusal, "injected"
504
+
505
+ provs = flow_providers(model)
506
+ if not provs:
507
+ # β›” SAY SO. An AI feature that silently does nothing is indistinguishable from one that was
508
+ # never built [[flag-shipped-without-its-writer]].
509
+ return None, ("the assistant is not configured on this deployment, so an automation cannot "
510
+ "be drafted from a description yet"), None
511
+ tmo = float(timeout or TIMEOUT_SECONDS)
512
+ problems = []
513
+ import providers as _prov
514
+ for p in provs:
515
+ # ⭐⭐ W36-T35 / R4 β€” THE OWNER QUOTED THIS LINE BACK AT US. It used to read
516
+ # `problems.append(f"{p['name']}: tool calls are not wired for this shape")` and `continue`,
517
+ # so the sentence *"anthropic: tool calls are not wired for this shape"* appeared under the
518
+ # Agent module verbatim. R4: *"Anthropic becomes the tool-calling path that always works."*
519
+ # It is wired now, through the ONE wire in `providers`, and it matters more than it looks:
520
+ # every side rung on this account is refusing today (cerebras 402, groq 404, openrouter
521
+ # 402), so without this branch the drafter cannot answer at all.
522
+ # ⚠ NO `effort` ON THIS DOOR β€” `output_config.effort` errors on Haiku 4.5, the tier this
523
+ # ladder runs. The parameter is the caller's to send, which is why the wire takes it.
524
+ # ⭐ D-346 (W37-T39): BOTH shapes now yield the SAME `(status, body)` pair, so every branch
525
+ # below reads one thing. The anthropic side gets there through `anthropic_send` (official
526
+ # SDK when installed, the identical raw POST when not); the openai side still posts here
527
+ # because there is no shared SDK across three different vendors on that wire.
528
+ global LAST_ANTHROPIC_TRANSPORT
529
+ try:
530
+ if p["shape"] == "anthropic":
531
+ req = _prov.anthropic_request(
532
+ model=p["model"], key=os.environ[p["env"]].strip(), system=None,
533
+ messages=messages, tools=tools, max_tokens=1500, tool_choice="required")
534
+ status, body, transport = _prov.anthropic_send(req, timeout=tmo)
535
+ LAST_ANTHROPIC_TRANSPORT = transport
536
+ else:
537
+ r = requests.post(p["url"], timeout=tmo,
538
+ headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
539
+ "Content-Type": "application/json"},
540
+ json={"model": p["model"], "messages": messages, "tools": tools,
541
+ "tool_choice": "required", "temperature": 0.1,
542
+ "max_tokens": 1500})
543
+ status = r.status_code
544
+ body = r.json() if (r.content and r.status_code == 200) else {"_text": r.text}
545
+ except Exception as e: # noqa: BLE001
546
+ problems.append(f"{p['name']}: {type(e).__name__}")
547
+ continue
548
+ if status != 200:
549
+ # β›” A SENTENCE, NOT `HTTP 402` (R4's third clause). The owner read the status codes off
550
+ # this very door. `refusal_sentence` also decides whether this was a CREDIT failure, and
551
+ # the memo makes the next turn SKIP the empty account instead of paying for it again.
552
+ # ⚠ The body is re-serialised for the substring test because the SDK path never had a
553
+ # `.text` β€” that is the one thing this normalisation costs, and it costs nothing else.
554
+ detail = json.dumps(body)[:600] if isinstance(body, dict) else str(body)[:600]
555
+ if _prov.is_credit_failure(status, detail):
556
+ _prov.mark_no_credit(p["name"])
557
+ problems.append(_prov.refusal_sentence(p["name"], status, detail))
558
+ continue
559
+ try:
560
+ body = body or {}
561
+ if p["shape"] == "anthropic":
562
+ _text, args, _refused = _prov.anthropic_read(body)
563
+ if _refused:
564
+ problems.append(f"{p['name']}: {_refused}")
565
+ continue
566
+ else:
567
+ calls = (((body.get("choices") or [{}])[0].get("message") or {})
568
+ .get("tool_calls") or [])
569
+ args = json.loads(calls[0]["function"]["arguments"]) if calls else None
570
+ except Exception as e: # noqa: BLE001
571
+ problems.append(f"{p['name']}: unreadable answer ({type(e).__name__})")
572
+ continue
573
+ # ⭐⭐ C7 β€” THE LEDGER LINE. Placed after the parse rather than before it so an UNREADABLE
574
+ # answer is not double-counted by the `continue` above... ⚠ which means an unreadable answer
575
+ # is NOT counted at all, and that is a deliberate, disclosed loss: a body this code cannot
576
+ # parse is a body whose `usage` it also cannot trust, and `body` is out of scope in that
577
+ # branch by construction. The 200-with-junk case is rare and named here rather than
578
+ # silently rounded to zero.
579
+ ins, outs = usage_ledger.tokens_from(body)
580
+ usage_ledger.record("automation_draft", p["name"], p["model"], ins, outs,
581
+ total=usage_ledger.total_from(body), st=st, user=user)
582
+ draft, refusal = _flow_from_tool_call(args)
583
+ if draft is None and isinstance(args, dict) and str(args.get("kind")) == "refused":
584
+ # β›” A REFUSAL IS AN ANSWER, NOT A FAULT, so it does NOT fall through to a more
585
+ # expensive rung β€” the model has just said this cannot be built. `decide()` takes the
586
+ # same posture for the same reason.
587
+ return None, refusal, p["name"]
588
+ if draft is not None:
589
+ return draft, "", p["name"]
590
+ problems.append(f"{p['name']}: {refusal}")
591
+ return None, ("; ".join(problems)[:300] or "no provider answered"), None
api/automation_engine.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/connectors_tt.py CHANGED
@@ -1,568 +1,568 @@
1
- """connectors_tt.py β€” the TIKTOK connector (wave 29 Β· item 7 Β· DEBT D-9 Β· rulings R1 + R2).
2
-
3
- Everything in this file knows what a VENDOR's TikTok row looks like. Nothing in it knows what an
4
- automation is. That split is `connectors_ig.py`'s (wave 27 item 23) and it is the reason this file
5
- exists at all rather than another thousand lines inside the engine.
6
-
7
- β›” **EVERY VENDOR FIELD NAME HERE WAS PROBED, NOT GUESSED.** The whole schema β€” 40 profile / 43
8
- post / 17 comment fields, each with the vendor's own type, description and `pii` flag β€” was read
9
- live from `GET /datasets/{id}/metadata` for **$0.00** and written down in
10
- `.claude/wiki/waves/wave29/proto/tiktok-schema.md` (promoted to `tiktok-capture.md` at
11
- close-out). That document is the AUTHORITY: do not re-probe it, and do not invent a key. Where a
12
- name below reads through a candidate list it is because the vendor has two names for one fact
13
- (`biography`/`signature`, `region`/`country`), never because the name is uncertain.
14
-
15
- β›” **NOTHING HERE EVER AUTHENTICATES TO TIKTOK.** No login, no cookie, no account to get banned β€”
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
31
- envelope keys (`timestamp`, `input`) that no metadata call mentions;
32
- 2. that a declared field POPULATES β€” Bright Data's Instagram Reels *declares* `views: number`
33
- and delivers an account-grain wrong number (Β§4e). **Declared is not delivered**, and the one
34
- TikTok claim that matters most (`play_count`) is exactly a declaration.
35
- """
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
-
54
- # ---------------------------------------------------------------------------------------------
55
- # THE DATASETS
56
- # ---------------------------------------------------------------------------------------------
57
- # ⚠ CATALOGUE PRESENCE IS NOT ENTITLEMENT β€” the same finding Instagram produced. `GET
58
- # /datasets/list` returned 1,735 rows of which 12 are TikTok; the three below answer 200 with a
59
- # full field list, and the two DISCOVERY halves answer **404 for our key**:
60
- # `gd_lj71gn6l68bz7y9hc` (posts by profile) and `gd_lilwhto81z415d9mdl` (posts by keyword).
61
- # β‡’ TikTok discovery routes through the PROFILES dataset's corpus filter, exactly as Instagram's
62
- # does. A ticket that reaches for a by-keyword endpoint is reaching for a 404.
63
- TT_DS_PROFILES = "gd_l1villgoiiidt09ci" # TikTok - Profiles. 40 fields, 152,000,000 records
64
- TT_DS_POSTS = "gd_lu702nij2f790tmv9h" # TikTok - Posts. 43 fields
65
- TT_DS_COMMENTS = "gd_lkf2st302ap89utw5k" # TikTok - Comments. 17 fields
66
-
67
- #: The vendor's two post-type tokens, verbatim from the dataset's own `ai_description`
68
- #: (*"strictly these two"*, video = 99.5% of rows). Ours are `image`/`video`/`carousel`.
69
- TT_POST_TYPE_VIDEO = "video"
70
- TT_POST_TYPE_CONTENT = "content"
71
-
72
- #: What a TikTok profile URL looks like, for the runner that has a handle and needs a URL. Kept
73
- #: beside the dataset ids because it is the same class of vendor fact.
74
- #: ⚠ ONE SPELLING. `platform/core/user_tables.profile_url(handle, 'tiktok')` builds the identical
75
- #: string from `_PROFILE_RULES` (contract C2, E's half) β€” this exists for the connector's own
76
- #: batch calls, and the gate asserts the two agree rather than trusting that they do.
77
- TT_PROFILE_URL = "https://www.tiktok.com/@{handle}"
78
-
79
-
80
- def tt_profile_url(handle):
81
- """`nurilab` β†’ `https://www.tiktok.com/@nurilab`. `''` for a blank handle, never a bare `@`."""
82
- h = str(handle or "").strip().lstrip("@")
83
- return TT_PROFILE_URL.format(handle=h) if h else ""
84
-
85
-
86
- # ---------------------------------------------------------------------------------------------
87
- # THE FIELD MAPS
88
- # ---------------------------------------------------------------------------------------------
89
- # Each function turns ONE vendor row into the cell dict for one of the `ut_tt_*` schemas declared
90
- # in `automation_engine`. The rules they all obey, stated once:
91
- #
92
- # * **BLANK MEANS NOT READ, NEVER "THEY HAVE NONE".** A key the vendor did not send is OMITTED,
93
- # so a later, richer pull fills it instead of being overwritten by this one's silence. `_first`
94
- # returns `None` (never 0) when nothing matches, which is what makes that possible.
95
- # * **A zero from the vendor is a MEASUREMENT and survives.** (Instagram's `_ig_zero_is_blank`
96
- # rule is scoped to a paid rung whose zeros were proven fictional; nothing here has earned it.)
97
- # * **Every unpromoted vendor key stays whole in `source_payload`.** A schema addition on the
98
- # vendor's side is preserved rather than silently discarded while our column model catches up.
99
- # * **Nothing here writes a session token.** `tt_chain_token`, `secu_id` (~85% null), `short_id`
100
- # (100% null in the sample), `ftc` (100% null) and `relation` are deliberately unmapped; they
101
- # ride in `source_payload` where they make no claim.
102
-
103
-
104
- def _tt_str(node, *names):
105
- """The first non-empty string among `names`, or None when the vendor sent nothing.
106
-
107
- β›” `None`, NOT `""`. The callers below drop `None` keys, which is what keeps a blank honest β€”
108
- an empty string written into a cell claims "we looked and it is empty".
109
- """
110
- v = _first(node, *names)
111
- if v is None:
112
- return None
113
- s = str(v).strip()
114
- return s or None
115
-
116
-
117
- def _tt_pct(node, *names):
118
- """A vendor 0–1 engagement fraction β†’ our stored 0–100 percentage, or None.
119
-
120
- β›” THE Γ—100 IS NOT COSMETIC (wave 26, amendment C1-a). Our `pct` renderer appends the sign to
121
- the STORED number, so writing the vendor's raw 0.0656 would print a 6.6% creator as `0.0%` β€”
122
- measured on the Instagram side, and TikTok sends the same shape on all three of its rates.
123
- """
124
- v = _first(node, *names)
125
- if v is None:
126
- return None
127
- out = engine._pct100(v)
128
- return out or None
129
-
130
-
131
- def _tt_day(node, *names):
132
- """A vendor stamp β†’ `YYYY-MM-DD`, or None. Our `date` columns store a day."""
133
- v = _first(node, *names)
134
- if v is None:
135
- return None
136
- return engine._day(v) or None
137
-
138
-
139
- def _drop_blanks(row):
140
- """The one place a mapped row loses its `None`s β€” see the BLANK MEANS NOT READ rule above."""
141
- return {k: v for k, v in row.items() if v is not None and v != ""}
142
-
143
-
144
- def normalize_profile(node, handle=""):
145
- """A TikTok Profiles row β†’ the `ut_tt_profile` / `ut_tt_snapshots` cell shape.
146
-
147
- ⚠ TWO FIELDS READ THROUGH A CANDIDATE PAIR, and both pairs are the vendor's, not a guess:
148
- * `biography` is PRIMARY and `signature` the FALLBACK β€” the probe measured `signature`
149
- populated on 85% of rows and they carry the same text;
150
- * `region` is PRIMARY and `country` the FALLBACK β€” `region` is the one with a documented
151
- two-letter-ISO description, `country` has no description at all.
152
- ⚠ `videos_count` is mapped to `posts_count` with a caveat recorded rather than hidden: its
153
- `ai_description` ranges 1-89, so it may be a WINDOW rather than a lifetime total. It is the
154
- only count of its kind the dataset offers.
155
- """
156
- node = node if isinstance(node, dict) else {}
157
- account = _tt_str(node, "account_id") or str(handle or "").strip().lstrip("@")
158
- return _drop_blanks({
159
- "platform": engine.PLATFORM_TIKTOK,
160
- "handle": account,
161
- "full_name": _tt_str(node, "nickname"),
162
- "tt_id": _tt_str(node, "id"),
163
- "profile_url": _tt_str(node, "url") or (tt_profile_url(account) or None),
164
- "bio": _tt_str(node, "biography", "signature"),
165
- "external_url": _bd_first_url(_first(node, "bio_link")),
166
- "verified": _bd_flag(node, "is_verified"),
167
- "is_private": _bd_flag(node, "is_private"),
168
- # ⚠ APPROXIMATE, and the word is the vendor's: `is_commerce_user` has *"many null values"*
169
- # on their own description. It is the closest thing TikTok has to Instagram's
170
- # `is_business_account`, and `_bd_flag` writes nothing at all when the key is absent β€” so
171
- # the approximation only ever fills a cell the vendor actually answered.
172
- "is_business": _bd_flag(node, "is_commerce_user"),
173
- "followers": _ig_int(_first(node, "followers")),
174
- "following": _ig_int(_first(node, "following")),
175
- "posts_count": _ig_int(_first(node, "videos_count")),
176
- # ⚠ `likes` on a PROFILE row is likes RECEIVED across the account's videos (18-110,200, no
177
- # nulls). It is not a post-level number and it is not our `likes` column, which is why it
178
- # is stored under a different name.
179
- "likes_received": _ig_int(_first(node, "likes")),
180
- "avg_engagement": _tt_pct(node, "awg_engagement_rate"),
181
- "like_engagement": _tt_pct(node, "like_engagement_rate"),
182
- "comment_engagement": _tt_pct(node, "comment_engagement_rate"),
183
- "country_code": _tt_str(node, "region", "country"),
184
- "region": _tt_str(node, "region"),
185
- "predicted_lang": _tt_str(node, "predicted_lang"),
186
- # ⚠ ACCOUNT AGE, NOT A MEASUREMENT STAMP β€” `create_time` on a profile is when the ACCOUNT
187
- # was made. TikTok stamps nothing with "when this number was true", exactly like Instagram,
188
- # which is why the append law dates a snapshot by when WE read it.
189
- "account_created_at": _tt_day(node, "create_time"),
190
- "source_payload": _bd_source_payload(node),
191
- })
192
-
193
-
194
- def tt_post_type(node):
195
- """A TikTok post row β†’ one of OUR three type options, or None.
196
-
197
- β›” THE FIRST OF THE PROBE DOC'S TWO NAMED BLOCKERS. The vendor's vocabulary is `"video"` /
198
- `"content"`; ours is `image` / `video` / `carousel` and has no `"content"`. Writing the
199
- vendor's token would fail `_clean_field`'s option check on the way in and would put an
200
- untranslated API word in front of a user on the way out.
201
-
202
- So: `video` is `video`, and `content` β€” TikTok's photo-mode post β€” is `image`, EXCEPT when the
203
- row carries more than one `carousel_images` entry, which is what a carousel IS on either
204
- network. ⚠ The multi-image branch is decided from the IMAGES, never from the type token: the
205
- token cannot express it, so inferring `carousel` from the word would be inventing a fact.
206
- ⚠ An UNKNOWN token returns None rather than defaulting to `video` (99.5% of rows are video, and
207
- that is exactly what would make the wrong default invisible).
208
- """
209
- raw = str((node or {}).get("post_type") or "").strip().lower()
210
- if raw == TT_POST_TYPE_VIDEO:
211
- return "video"
212
- if raw != TT_POST_TYPE_CONTENT:
213
- return None
214
- images = (node or {}).get("carousel_images")
215
- return "carousel" if isinstance(images, list) and len(images) > 1 else "image"
216
-
217
-
218
- def normalize_post(node):
219
- """A TikTok Posts row β†’ the `ut_tt_posts` cell shape. None when it carries no identity.
220
-
221
- β›” THE SECOND NAMED BLOCKER, RESOLVED HERE AND NOWHERE ELSE: `play_count` is ONE number and
222
- Instagram's schema has TWO columns for it (`plays` and `views`). On TikTok they are the same
223
- fact β€” `play_count` IS the count TikTok displays under a video β€” so it maps to `views` and
224
- `ut_tt_posts` HAS NO `plays` COLUMN. Copying one vendor number into two of our columns would
225
- manufacture a second measurement that a rollup could average or double-count, which is a worse
226
- outcome than the missing column it would paper over.
227
-
228
- ⚠ `shortcode` reads `shortcode` then `post_id`: both are 19-digit numerics on this dataset and
229
- the probe measured them as the same shape. That equality is what lets the comments→posts link
230
- join with NO normaliser, which the Instagram side never had.
231
- ⚠ `num_share_count` (a number) is preferred over `share_count` (typed TEXT by the vendor).
232
- ⚠ `commerce_info` is a business/commerce LOCATION per its own description β€” cities and
233
- countries. It is NOT a paid-partnership flag, and nothing in this dataset is: TikTok declares
234
- no equivalent, so `paid_partnership`/`partner` have no column on this family at all.
235
- """
236
- node = node if isinstance(node, dict) else {}
237
- shortcode = _tt_str(node, "shortcode", "post_id")
238
- if not shortcode:
239
- return None
240
- return _drop_blanks({
241
- "platform": engine.PLATFORM_TIKTOK,
242
- "shortcode": shortcode,
243
- # β›”β›” `account_id`, NOT `profile_username` β€” MEASURED on a real Posts row 2026-08-12.
244
- # The vendor's `profile_username` is the DISPLAY NAME (`"Dina"`), while `account_id` is the
245
- # @handle (`"d1na_th"`) β€” the same field `normalize_profile` already reads for `handle`, so
246
- # one name means one thing across both corpora. Reading the display name silently broke the
247
- # only join this table has: `ut_tt_posts.influencer_key` -> `ut_tt_profile.handle` matched
248
- # NOTHING, so a person could not filter posts by creator and a rollup would count zero.
249
- # ⚠ NO FALLBACK TO `profile_username`, deliberately. It is not a degraded handle, it is a
250
- # different fact, and filling a join key with it is worse than leaving it blank β€” a blank
251
- # is visibly missing, a display name looks like an answer [[one-question-two-normalizers]].
252
- # The URL is the honest second source: it carries the handle by construction.
253
- "influencer_key": (_tt_str(node, "account_id")
254
- or tt_handle(_tt_str(node, "profile_url", "url") or "") or None),
255
- "posted_at": _tt_day(node, "create_time"),
256
- "type": tt_post_type(node),
257
- "caption": _tt_str(node, "description"),
258
- "url": _tt_str(node, "url"),
259
- "hashtags": _bd_list(node, "hashtags"),
260
- "tagged_location": _tt_str(node, "commerce_info"),
261
- "views": _ig_int(_first(node, "play_count")),
262
- "likes": _ig_int(_first(node, "digg_count")),
263
- "comments": _ig_int(_first(node, "comment_count")),
264
- "shares": _ig_int(_first(node, "num_share_count")),
265
- "saves": _ig_int(_first(node, "collect_count")),
266
- "video_duration": _ig_int(_first(node, "video_duration")),
267
- "source_payload": _bd_source_payload(node),
268
- })
269
-
270
-
271
- def normalize_comment(node):
272
- """A TikTok Comments row β†’ the `ut_tt_comments` cell shape. None without a comment id.
273
-
274
- ⚠ THE NAME COLLISION, RESOLVED: TikTok's `replies` is an ARRAY of reply objects and OUR
275
- `replies` column is an INT count. The count comes from `num_replies`; the array stays whole in
276
- `source_payload`. Reading the array's length instead would be a second, disagreeing answer to
277
- a question the vendor already answers β€” and it would disagree, because a page of replies is not
278
- all of them.
279
- ⚠ `date_created` is typed `date` by this vendor, unlike Instagram's `comment_date` which is
280
- text and needs defensive parsing. It still goes through `_tt_day` β€” one date path, so a vendor
281
- that changes its mind cannot change ours.
282
- ⚠ The comment TEXT and every identifiable commenter field (`commenter_user_name` is flagged
283
- PII) stay in `source_payload` and are promoted to no column, which is the same posture the
284
- Instagram comment schema takes for the same D-24 reason.
285
- """
286
- node = node if isinstance(node, dict) else {}
287
- comment_key = _tt_str(node, "comment_id")
288
- if not comment_key:
289
- return None
290
- return _drop_blanks({
291
- "platform": engine.PLATFORM_TIKTOK,
292
- "comment_key": comment_key,
293
- "shortcode": _tt_str(node, "post_id"),
294
- # ⭐⭐ OWNER RULING 2026-08-12: the comment's CONTENT gets a column. `comment_text` is the
295
- # vendor's own key and `comment_text_only` its stripped variant (the probe recorded both);
296
- # primary first, so a row carrying the rich form is not silently served the plain one.
297
- # β›” The commenter's identity is deliberately NOT promoted β€” see `TT_COMMENT_FIELDS`.
298
- "text": _tt_str(node, "comment_text", "comment_text_only"),
299
- "commented_at": _tt_day(node, "date_created"),
300
- "likes": _ig_int(_first(node, "num_likes")),
301
- "replies": _ig_int(_first(node, "num_replies")),
302
- "source_payload": _bd_source_payload(node),
303
- })
304
-
305
-
306
- #: ⭐ The three maps, addressable by name β€” so a gate (and the runners in T04-T06) can walk them
307
- #: rather than naming three functions, and so adding a fourth dataset is one entry.
308
- TT_NORMALIZERS = {
309
- "tt_profile": normalize_profile,
310
- "tt_post_metrics": normalize_post,
311
- "tt_comments": normalize_comment,
312
- }
313
-
314
-
315
- # ---------------------------------------------------------------------------------------------
316
- # THE FETCH β€” wave 30 Β· W30-T08 (carrying wave-29's dropped T05)
317
- # ---------------------------------------------------------------------------------------------
318
-
319
- def tt_handle(url):
320
- """A TikTok profile URL **or** a bare handle β†’ the handle. `''` when it is neither.
321
-
322
- Deliberately permissive about the input and strict about the output, because the two callers
323
- hand it different things: an automation stores whatever a person typed in the profile column
324
- (`@nurilab`, `nurilab`, or the full URL), while the discovery runner already holds a clean
325
- `account_id`. One normaliser, so a row found by discovery and a row typed by hand cannot
326
- resolve to two different handles.
327
- """
328
- s = str(url or "").strip()
329
- if not s:
330
- return ""
331
- if "tiktok.com" in s.lower():
332
- # Everything after the first `@`, up to the next path segment or query.
333
- tail = s.split("@", 1)[1] if "@" in s else ""
334
- s = tail.split("/")[0].split("?")[0].split("#")[0]
335
- s = s.strip().lstrip("@").strip()
336
- # A handle is the vendor's `account_id` shape: alphanumerics, dots and underscores.
337
- return s if s and all(c.isalnum() or c in "._" for c in s) else ""
338
-
339
-
340
- def tt_post_urls(node, limit=0):
341
- """⭐ WAVE 30 Β· T10 β€” the profile row's own post permalinks, newest-first as the vendor sends.
342
-
343
- β›” THIS IS WHY TIKTOK POST CAPTURE COSTS NO EXTRA DISCOVERY. `top_videos` rides the PROFILE row
344
- we have already bought, so the posts read is a scrape of links we hold, never a search for them.
345
- The two TikTok DISCOVERY datasets (posts-by-profile, posts-by-keyword) are **404 for our key**,
346
- so a design that reached for either would not merely be dearer, it would not work.
347
-
348
- β›”β›” CORRECTED 2026-08-12 β€” THIS DOCSTRING USED TO SAY *"the probe MEASURED `top_videos` as an
349
- array of video permalinks, NO empties"*, AND THAT SENTENCE IS WHAT SHIPPED THE BUG. The probe
350
- read `/datasets/{id}/metadata` β€” a DATASET description β€” and the phrase quoted was the field's
351
- `ai_description`, not an observation of a row. The real row sends **dicts keyed `video_url`**
352
- (measured below), so the reader built against the quoted sentence found nothing, forever, in
353
- silence. ⭐ The transferable half: *"the probe measured X"* and *"the probe read a declaration
354
- of X"* are different claims, and prose cannot be told apart by a reader downstream β€” which is
355
- why the correction names the method, not just the value.
356
-
357
- ⚠ `top_posts_data` is deliberately NOT read: the probe calls it *"a thin dup of `top_videos`"*,
358
- and preferring whichever happened to be longer is how one creator's window silently differs
359
- from another's.
360
- ⚠ `limit <= 0` means "everything the row carried". The CAP IS THE CALLER'S β€” `config.maxPosts`,
361
- validated 1..12 β€” and it is applied here rather than after the scrape so an unwanted post is
362
- never bought. [[a-constant-two-features-share]]: the 12 is the vendor's measured profile window,
363
- not a number this function may invent.
364
- """
365
- raw = (node or {}).get("top_videos")
366
- out = []
367
- for item in raw if isinstance(raw, list) else []:
368
- # β›”β›” MEASURED ON A REAL ROW 2026-08-12, AND IT IS NOT WHAT THE SCHEMA SAID.
369
- # `top_videos` is NOT an array of permalink strings. One paid TikTok Profiles scrape of a
370
- # live handle returns **19 DICTS**, keyed
371
- # `video_url Β· video_id Β· playcount Β· diggcount Β· commentcount Β· share_count Β·
372
- # favorites_count Β· create_date Β· cover_image`.
373
- # The docstring above cites the $0 probe as having "measured" permalinks β€” it had not, and
374
- # could not: `/datasets/{id}/metadata` describes a DATASET, and the probe's own verdict says
375
- # so in terms (*"what $0 cannot buy … the real ROW shape … that a declared field
376
- # POPULATES"*, `wave29/proto/tiktok-schema.md`). This is the SECOND time this vendor's
377
- # declaration has diverged from its delivery on this exact axis; BD's IG Reels `views` was
378
- # the first. [[reachable-is-not-the-same-as-built]]
379
- # β‡’ The consequence, live: `TT_DS_POSTS` was never reached, because this returned an EMPTY
380
- # list on every real profile β€” post capture could not have worked for anybody, and the
381
- # T10 gate stayed green because its canned fixture encoded the DECLARED shape. A fixture
382
- # written from a schema tests the schema.
383
- # ⚠ `video_url` is FIRST because it is the key the vendor actually sends; `url` is kept
384
- # because it costs nothing and is what a future corpus revision would most likely use. The
385
- # bare-string branch stays for the same reason β€” this widens what is ACCEPTED and invents
386
- # nothing: a shape that yields no `http…` value still degrades to "no posts", exactly as
387
- # before, rather than to a URL built out of a guess.
388
- # ⚠ `top_posts_data` is STILL not read (it carries `post_url` and would work): preferring
389
- # whichever array happened to be longer is how one creator's window silently differs from
390
- # another's, and that reasoning is unchanged by this correction.
391
- if isinstance(item, dict):
392
- u = str(item.get("video_url") or item.get("url") or "").strip()
393
- else:
394
- u = str(item or "").strip()
395
- if u.lower().startswith("http") and u not in out:
396
- out.append(u)
397
- return out[:limit] if limit and limit > 0 else out
398
-
399
-
400
- def pull_posts_tt(post_urls, log=print, deferred=None):
401
- """The TikTok Posts dataset for a list of permalinks β†’ `(rows, note)`, already normalised.
402
-
403
- ⚠ ONE CALL FOR THE WHOLE WINDOW. `bd_scrape` has always taken a list, and the Instagram side
404
- measured what happens when a caller forgets: 25 records, one billed snapshot each, a walk still
405
- running at 67 minutes. Nothing here loops per URL.
406
- """
407
- urls = [str(u) for u in (post_urls or []) if str(u or "").strip()]
408
- if not urls:
409
- return [], ""
410
- rows, note = bd_scrape(TT_DS_POSTS, urls, deferred=deferred)
411
- if note:
412
- log(f"[aios-tt] posts: {note}")
413
- return [], note
414
- out = [r for r in (normalize_post(n) for n in rows) if r]
415
- return out, ""
416
-
417
-
418
- def pull_comments_tt(post_urls, log=print, deferred=None):
419
- """The TikTok Comments dataset for a list of POST permalinks β†’ `(rows, note)`, normalised.
420
-
421
- β›” THE MOST EXPENSIVE THING THIS PRODUCT BUYS, and the reason `commentMetrics` defaults OFF on
422
- both networks: a comments scrape ingests identifiable third parties who never entered anybody's
423
- list (D-24). The mapper already keeps every commenter field in `source_payload` and promotes
424
- none of them to a column; this function adds no new exposure, it just has to be asked for.
425
- """
426
- urls = [str(u) for u in (post_urls or []) if str(u or "").strip()]
427
- if not urls:
428
- return [], ""
429
- rows, note = bd_scrape(TT_DS_COMMENTS, urls, deferred=deferred)
430
- if note:
431
- log(f"[aios-tt] comments: {note}")
432
- return [], note
433
- out = [r for r in (normalize_comment(n) for n in rows) if r]
434
- return out, ""
435
-
436
-
437
- #: ⭐⭐ WAVE 30 Β· D-156 β€” THE MEDIA DATASETS, AS A SET, SO THE HAND-OFF CAN FILTER ON IDENTITY.
438
- #: `_media_deferrals` uses this to lift ONLY posts/comments snapshots out of the local deferral
439
- #: list. That is what makes it structurally impossible to file a PROFILE snapshot in the engine's
440
- #: metric queue β€” the defect a draft of T10 shipped and A-39 booked as "the wrong fix is worse
441
- #: than the gap". A membership test cannot be got wrong by a later edit the way `if` order can.
442
- TT_MEDIA_DATASETS = (TT_DS_POSTS, TT_DS_COMMENTS)
443
-
444
-
445
- def _media_deferrals(deferred):
446
- """The POSTS/COMMENTS entries of a `bd_scrape` deferral list β€” never the profile's.
447
-
448
- ⚠ The engine, not this module, decides what a deferral MEANS: it stamps `kind` and the
449
- handle and files it. TikTok needs no `_tag_metric_deferrals` twin because one dataset is one
450
- kind here, so the id already carries everything a mapper choice depends on β€” and importing
451
- Instagram's tagger is not available anyway (W30-T09 gates ZERO `from connectors_ig` lines).
452
- """
453
- out = []
454
- for d in deferred or []:
455
- if isinstance(d, dict) and str(d.get("datasetId") or "") in TT_MEDIA_DATASETS:
456
- out.append(dict(d))
457
- return out
458
-
459
-
460
- def pull_profile_tt(url, log=print, pending_profile=None, prefetch=None,
461
- max_posts=0, post_metrics=False, comment_metrics=False):
462
- """ONE TikTok profile from the vendor. Same return contract as `pull_profile`.
463
-
464
- `{state, profile, posts, comments, via, note}` with `state ∈ ok | partial | blocked | error`,
465
- so the engine's enrich branch treats every network identically and no caller learns a new
466
- shape.
467
-
468
- ⭐ WAVE 30 Β· T10 β€” POSTS AND COMMENTS ARE REAL NOW, AND BOTH DEFAULT OFF, exactly as Instagram's
469
- do. `post_metrics` scrapes the profile row's own `top_videos` permalinks (see `tt_post_urls` β€”
470
- no discovery call, because both TikTok discovery datasets 404 for our key); `comment_metrics`
471
- then scrapes the comments of the posts that came back. ⚠ COMMENTS REQUIRE POSTS by construction
472
- rather than by a rule: their input IS a post permalink, so asking for comments with post capture
473
- off is a request with no subject, and it returns none instead of quietly buying posts nobody
474
- asked for.
475
-
476
- β›” `partial` IS THE SUCCESS STATE WHENEVER NO MEDIA WAS READ, and that is deliberate rather than
477
- pessimistic. The Instagram contract reads `ok` only when identity AND media both landed
478
- (`pull_profile_bd`: *"identity without media is still partial ... a run that wrote a follower
479
- count and no posts must not paint green over a posts table that did not grow"*). So: posts not
480
- ASKED for β†’ `partial`, saying so; posts asked for and landed β†’ `ok`; asked for and none came β†’
481
- `partial` with the vendor's reason. The state answers "did this pull deliver what it went for",
482
- never "did the function finish".
483
-
484
- ⚠ **NO FREE RUNG, AND NO FALLBACK CHAIN.** Instagram's `pull_profile` drops to Apify when the
485
- paid rung refuses; `providers.DEFAULT_CHAINS["tt_profile"]` is deliberately single-provider,
486
- with its own note explaining that a multi-provider chain is a promise something walks it and
487
- that nothing walks Instagram's second name today either. So a refusal here is final, and it
488
- says so instead of implying a retry somewhere.
489
- """
490
- handle = tt_handle(url)
491
- if not handle:
492
- return {"state": "error", "profile": {}, "posts": [], "comments": [], "via": "",
493
- "note": f"{url!r} is not a TikTok profile URL or handle"}
494
-
495
- # ⭐ THE BATCH FAST PATH, same shape as the Instagram side: `prefetch` is `{handle: node}` from
496
- # one multi-URL scrape covering a whole selection. A hit is a vendor round trip that does not
497
- # happen; a miss falls through to the single-URL call below.
498
- cached = prefetch.get(handle) if isinstance(prefetch, dict) else None
499
- _deferred = []
500
- if isinstance(cached, dict) and cached:
501
- rows, note = [cached], ""
502
- else:
503
- rows, note = bd_scrape(TT_DS_PROFILES, [tt_profile_url(handle)], deferred=_deferred)
504
-
505
- node = rows[0] if rows else {}
506
- profile = normalize_profile(node, handle) if node else {}
507
- # β›” THE READABILITY TEST IS `followers`/`following`, NOT "did we get a dict". `normalize_profile`
508
- # drops blanks, so an unreadable row still returns `{"platform": …, "handle": …}` β€” truthy, and
509
- # carrying nothing anybody asked for. The Instagram rung tests exactly this pair for exactly
510
- # this reason, and answering "0 followers" instead is the failure it exists to prevent.
511
- unreadable = profile.get("followers") is None and profile.get("following") is None
512
- if note or unreadable:
513
- # ⭐ THE DEFERRAL IS HANDED OVER RATHER THAN DISCARDED. A snapshot the vendor is still
514
- # building HAS ALREADY BEEN PAID FOR; dropping its id bills again on the next run for the
515
- # same record. That was live on the Instagram profile path until 2026-08-09 β€” measured on
516
- # nurilab as two runs, two fresh snapshots, both abandoned β€” and it is not being
517
- # reintroduced here by omission.
518
- if isinstance(pending_profile, list):
519
- for d in _deferred:
520
- pending_profile.append({**d, "kind": "profile", "influencer": handle})
521
- why = note or ("the scrape answered, but no follower/following counts were readable in it "
522
- "(the field names may have moved - see tiktok-capture.md)")
523
- return {"state": "blocked", "profile": {}, "posts": [], "comments": [], "via": "brightdata",
524
- "deferredProfile": [d.get("snapshotId") for d in _deferred],
525
- "note": why}
526
-
527
- # --- W30-T10: THE MEDIA, ONLY WHEN IT WAS ASKED FOR. ------------------------------------
528
- if not post_metrics:
529
- return {"state": "partial", "profile": profile, "posts": [], "comments": [],
530
- "via": "brightdata",
531
- "note": note or "profile read; post capture is off for this step"}
532
- urls = tt_post_urls(node, limit=max_posts)
533
- if not urls:
534
- # ⚠ NOT AN ERROR AND NOT A RETRY. A creator with no `top_videos` has nothing to buy, and
535
- # saying so is what stops the next run paying to be told the same thing.
536
- return {"state": "partial", "profile": profile, "posts": [], "comments": [],
537
- "via": "brightdata",
538
- "note": note or "profile read; this account's row carried no post links"}
539
- posts, p_note = pull_posts_tt(urls, log=log, deferred=_deferred)
540
- comments, c_note = ([], "")
541
- if comment_metrics and posts:
542
- # The comments dataset is keyed on a POST permalink, so it reads the posts we just bought β€”
543
- # `url` from the mapper, never the profile's raw array, so a post the posts scrape refused
544
- # is not silently asked about again one rung later.
545
- comments, c_note = pull_comments_tt([p.get("url") for p in posts if p.get("url")],
546
- log=log, deferred=_deferred)
547
- # ⭐⭐ WAVE 30 Β· D-156 β€” THE MEDIA DEFERRALS ARE HANDED BACK, and the shape of the hand-off is
548
- # the whole lesson. An earlier draft of T10 appended every `_deferred` entry to
549
- # `pending_profile` tagged `kind: "profile"`. By the time control reaches here a PROFILE
550
- # deferral is impossible β€” the profile branch above returns `blocked` on any note β€” so **every
551
- # id fanned out that way was a POSTS or COMMENTS snapshot in the PROFILE queue**, whose
552
- # collector writes preset profile cells onto somebody's record from post rows. The engine keeps
553
- # the two queues apart deliberately (`_pending_profile_tasks` vs `_pending_metric_tasks`).
554
- # β‡’ So this returns them under their OWN key, filtered by dataset identity
555
- # (`_media_deferrals`), and the engine files them in the metric queue with the handle it
556
- # already holds. Returning rather than appending also keeps the queue's vocabulary out of a
557
- # connector: this module knows which CORPUS deferred, never what the engine calls it.
558
- # ⚠ `deferredMedia` rides BOTH returns on purpose. The empty-posts case is the one that
559
- # matters most β€” that is exactly the run where the vendor took too long, so a caller reading
560
- # the ids only from the success path would lose every batch it actually paid for.
561
- deferred_media = _media_deferrals(_deferred)
562
- if not posts:
563
- return {"state": "partial", "profile": profile, "posts": [], "comments": [],
564
- "via": "brightdata", "deferredMedia": deferred_media,
565
- "note": p_note or note or "profile read; the post source returned nothing"}
566
- return {"state": "ok", "profile": profile, "posts": posts, "comments": comments,
567
- "via": "brightdata", "deferredMedia": deferred_media,
568
- "note": c_note or note or ""}
 
1
+ """connectors_tt.py β€” the TIKTOK connector (wave 29 Β· item 7 Β· DEBT D-9 Β· rulings R1 + R2).
2
+
3
+ Everything in this file knows what a VENDOR's TikTok row looks like. Nothing in it knows what an
4
+ automation is. That split is `connectors_ig.py`'s (wave 27 item 23) and it is the reason this file
5
+ exists at all rather than another thousand lines inside the engine.
6
+
7
+ β›” **EVERY VENDOR FIELD NAME HERE WAS PROBED, NOT GUESSED.** The whole schema β€” 40 profile / 43
8
+ post / 17 comment fields, each with the vendor's own type, description and `pii` flag β€” was read
9
+ live from `GET /datasets/{id}/metadata` for **$0.00** and written down in
10
+ `.claude/wiki/waves/wave29/proto/tiktok-schema.md` (promoted to `tiktok-capture.md` at
11
+ close-out). That document is the AUTHORITY: do not re-probe it, and do not invent a key. Where a
12
+ name below reads through a candidate list it is because the vendor has two names for one fact
13
+ (`biography`/`signature`, `region`/`country`), never because the name is uncertain.
14
+
15
+ β›” **NOTHING HERE EVER AUTHENTICATES TO TIKTOK.** No login, no cookie, no account to get banned β€”
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
31
+ envelope keys (`timestamp`, `input`) that no metadata call mentions;
32
+ 2. that a declared field POPULATES β€” Bright Data's Instagram Reels *declares* `views: number`
33
+ and delivers an account-grain wrong number (Β§4e). **Declared is not delivered**, and the one
34
+ TikTok claim that matters most (`play_count`) is exactly a declaration.
35
+ """
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
+
54
+ # ---------------------------------------------------------------------------------------------
55
+ # THE DATASETS
56
+ # ---------------------------------------------------------------------------------------------
57
+ # ⚠ CATALOGUE PRESENCE IS NOT ENTITLEMENT β€” the same finding Instagram produced. `GET
58
+ # /datasets/list` returned 1,735 rows of which 12 are TikTok; the three below answer 200 with a
59
+ # full field list, and the two DISCOVERY halves answer **404 for our key**:
60
+ # `gd_lj71gn6l68bz7y9hc` (posts by profile) and `gd_lilwhto81z415d9mdl` (posts by keyword).
61
+ # β‡’ TikTok discovery routes through the PROFILES dataset's corpus filter, exactly as Instagram's
62
+ # does. A ticket that reaches for a by-keyword endpoint is reaching for a 404.
63
+ TT_DS_PROFILES = "gd_l1villgoiiidt09ci" # TikTok - Profiles. 40 fields, 152,000,000 records
64
+ TT_DS_POSTS = "gd_lu702nij2f790tmv9h" # TikTok - Posts. 43 fields
65
+ TT_DS_COMMENTS = "gd_lkf2st302ap89utw5k" # TikTok - Comments. 17 fields
66
+
67
+ #: The vendor's two post-type tokens, verbatim from the dataset's own `ai_description`
68
+ #: (*"strictly these two"*, video = 99.5% of rows). Ours are `image`/`video`/`carousel`.
69
+ TT_POST_TYPE_VIDEO = "video"
70
+ TT_POST_TYPE_CONTENT = "content"
71
+
72
+ #: What a TikTok profile URL looks like, for the runner that has a handle and needs a URL. Kept
73
+ #: beside the dataset ids because it is the same class of vendor fact.
74
+ #: ⚠ ONE SPELLING. `platform/core/user_tables.profile_url(handle, 'tiktok')` builds the identical
75
+ #: string from `_PROFILE_RULES` (contract C2, E's half) β€” this exists for the connector's own
76
+ #: batch calls, and the gate asserts the two agree rather than trusting that they do.
77
+ TT_PROFILE_URL = "https://www.tiktok.com/@{handle}"
78
+
79
+
80
+ def tt_profile_url(handle):
81
+ """`nurilab` β†’ `https://www.tiktok.com/@nurilab`. `''` for a blank handle, never a bare `@`."""
82
+ h = str(handle or "").strip().lstrip("@")
83
+ return TT_PROFILE_URL.format(handle=h) if h else ""
84
+
85
+
86
+ # ---------------------------------------------------------------------------------------------
87
+ # THE FIELD MAPS
88
+ # ---------------------------------------------------------------------------------------------
89
+ # Each function turns ONE vendor row into the cell dict for one of the `ut_tt_*` schemas declared
90
+ # in `automation_engine`. The rules they all obey, stated once:
91
+ #
92
+ # * **BLANK MEANS NOT READ, NEVER "THEY HAVE NONE".** A key the vendor did not send is OMITTED,
93
+ # so a later, richer pull fills it instead of being overwritten by this one's silence. `_first`
94
+ # returns `None` (never 0) when nothing matches, which is what makes that possible.
95
+ # * **A zero from the vendor is a MEASUREMENT and survives.** (Instagram's `_ig_zero_is_blank`
96
+ # rule is scoped to a paid rung whose zeros were proven fictional; nothing here has earned it.)
97
+ # * **Every unpromoted vendor key stays whole in `source_payload`.** A schema addition on the
98
+ # vendor's side is preserved rather than silently discarded while our column model catches up.
99
+ # * **Nothing here writes a session token.** `tt_chain_token`, `secu_id` (~85% null), `short_id`
100
+ # (100% null in the sample), `ftc` (100% null) and `relation` are deliberately unmapped; they
101
+ # ride in `source_payload` where they make no claim.
102
+
103
+
104
+ def _tt_str(node, *names):
105
+ """The first non-empty string among `names`, or None when the vendor sent nothing.
106
+
107
+ β›” `None`, NOT `""`. The callers below drop `None` keys, which is what keeps a blank honest β€”
108
+ an empty string written into a cell claims "we looked and it is empty".
109
+ """
110
+ v = _first(node, *names)
111
+ if v is None:
112
+ return None
113
+ s = str(v).strip()
114
+ return s or None
115
+
116
+
117
+ def _tt_pct(node, *names):
118
+ """A vendor 0–1 engagement fraction β†’ our stored 0–100 percentage, or None.
119
+
120
+ β›” THE Γ—100 IS NOT COSMETIC (wave 26, amendment C1-a). Our `pct` renderer appends the sign to
121
+ the STORED number, so writing the vendor's raw 0.0656 would print a 6.6% creator as `0.0%` β€”
122
+ measured on the Instagram side, and TikTok sends the same shape on all three of its rates.
123
+ """
124
+ v = _first(node, *names)
125
+ if v is None:
126
+ return None
127
+ out = engine._pct100(v)
128
+ return out or None
129
+
130
+
131
+ def _tt_day(node, *names):
132
+ """A vendor stamp β†’ `YYYY-MM-DD`, or None. Our `date` columns store a day."""
133
+ v = _first(node, *names)
134
+ if v is None:
135
+ return None
136
+ return engine._day(v) or None
137
+
138
+
139
+ def _drop_blanks(row):
140
+ """The one place a mapped row loses its `None`s β€” see the BLANK MEANS NOT READ rule above."""
141
+ return {k: v for k, v in row.items() if v is not None and v != ""}
142
+
143
+
144
+ def normalize_profile(node, handle=""):
145
+ """A TikTok Profiles row β†’ the `ut_tt_profile` / `ut_tt_snapshots` cell shape.
146
+
147
+ ⚠ TWO FIELDS READ THROUGH A CANDIDATE PAIR, and both pairs are the vendor's, not a guess:
148
+ * `biography` is PRIMARY and `signature` the FALLBACK β€” the probe measured `signature`
149
+ populated on 85% of rows and they carry the same text;
150
+ * `region` is PRIMARY and `country` the FALLBACK β€” `region` is the one with a documented
151
+ two-letter-ISO description, `country` has no description at all.
152
+ ⚠ `videos_count` is mapped to `posts_count` with a caveat recorded rather than hidden: its
153
+ `ai_description` ranges 1-89, so it may be a WINDOW rather than a lifetime total. It is the
154
+ only count of its kind the dataset offers.
155
+ """
156
+ node = node if isinstance(node, dict) else {}
157
+ account = _tt_str(node, "account_id") or str(handle or "").strip().lstrip("@")
158
+ return _drop_blanks({
159
+ "platform": engine.PLATFORM_TIKTOK,
160
+ "handle": account,
161
+ "full_name": _tt_str(node, "nickname"),
162
+ "tt_id": _tt_str(node, "id"),
163
+ "profile_url": _tt_str(node, "url") or (tt_profile_url(account) or None),
164
+ "bio": _tt_str(node, "biography", "signature"),
165
+ "external_url": _bd_first_url(_first(node, "bio_link")),
166
+ "verified": _bd_flag(node, "is_verified"),
167
+ "is_private": _bd_flag(node, "is_private"),
168
+ # ⚠ APPROXIMATE, and the word is the vendor's: `is_commerce_user` has *"many null values"*
169
+ # on their own description. It is the closest thing TikTok has to Instagram's
170
+ # `is_business_account`, and `_bd_flag` writes nothing at all when the key is absent β€” so
171
+ # the approximation only ever fills a cell the vendor actually answered.
172
+ "is_business": _bd_flag(node, "is_commerce_user"),
173
+ "followers": _ig_int(_first(node, "followers")),
174
+ "following": _ig_int(_first(node, "following")),
175
+ "posts_count": _ig_int(_first(node, "videos_count")),
176
+ # ⚠ `likes` on a PROFILE row is likes RECEIVED across the account's videos (18-110,200, no
177
+ # nulls). It is not a post-level number and it is not our `likes` column, which is why it
178
+ # is stored under a different name.
179
+ "likes_received": _ig_int(_first(node, "likes")),
180
+ "avg_engagement": _tt_pct(node, "awg_engagement_rate"),
181
+ "like_engagement": _tt_pct(node, "like_engagement_rate"),
182
+ "comment_engagement": _tt_pct(node, "comment_engagement_rate"),
183
+ "country_code": _tt_str(node, "region", "country"),
184
+ "region": _tt_str(node, "region"),
185
+ "predicted_lang": _tt_str(node, "predicted_lang"),
186
+ # ⚠ ACCOUNT AGE, NOT A MEASUREMENT STAMP β€” `create_time` on a profile is when the ACCOUNT
187
+ # was made. TikTok stamps nothing with "when this number was true", exactly like Instagram,
188
+ # which is why the append law dates a snapshot by when WE read it.
189
+ "account_created_at": _tt_day(node, "create_time"),
190
+ "source_payload": _bd_source_payload(node),
191
+ })
192
+
193
+
194
+ def tt_post_type(node):
195
+ """A TikTok post row β†’ one of OUR three type options, or None.
196
+
197
+ β›” THE FIRST OF THE PROBE DOC'S TWO NAMED BLOCKERS. The vendor's vocabulary is `"video"` /
198
+ `"content"`; ours is `image` / `video` / `carousel` and has no `"content"`. Writing the
199
+ vendor's token would fail `_clean_field`'s option check on the way in and would put an
200
+ untranslated API word in front of a user on the way out.
201
+
202
+ So: `video` is `video`, and `content` β€” TikTok's photo-mode post β€” is `image`, EXCEPT when the
203
+ row carries more than one `carousel_images` entry, which is what a carousel IS on either
204
+ network. ⚠ The multi-image branch is decided from the IMAGES, never from the type token: the
205
+ token cannot express it, so inferring `carousel` from the word would be inventing a fact.
206
+ ⚠ An UNKNOWN token returns None rather than defaulting to `video` (99.5% of rows are video, and
207
+ that is exactly what would make the wrong default invisible).
208
+ """
209
+ raw = str((node or {}).get("post_type") or "").strip().lower()
210
+ if raw == TT_POST_TYPE_VIDEO:
211
+ return "video"
212
+ if raw != TT_POST_TYPE_CONTENT:
213
+ return None
214
+ images = (node or {}).get("carousel_images")
215
+ return "carousel" if isinstance(images, list) and len(images) > 1 else "image"
216
+
217
+
218
+ def normalize_post(node):
219
+ """A TikTok Posts row β†’ the `ut_tt_posts` cell shape. None when it carries no identity.
220
+
221
+ β›” THE SECOND NAMED BLOCKER, RESOLVED HERE AND NOWHERE ELSE: `play_count` is ONE number and
222
+ Instagram's schema has TWO columns for it (`plays` and `views`). On TikTok they are the same
223
+ fact β€” `play_count` IS the count TikTok displays under a video β€” so it maps to `views` and
224
+ `ut_tt_posts` HAS NO `plays` COLUMN. Copying one vendor number into two of our columns would
225
+ manufacture a second measurement that a rollup could average or double-count, which is a worse
226
+ outcome than the missing column it would paper over.
227
+
228
+ ⚠ `shortcode` reads `shortcode` then `post_id`: both are 19-digit numerics on this dataset and
229
+ the probe measured them as the same shape. That equality is what lets the comments→posts link
230
+ join with NO normaliser, which the Instagram side never had.
231
+ ⚠ `num_share_count` (a number) is preferred over `share_count` (typed TEXT by the vendor).
232
+ ⚠ `commerce_info` is a business/commerce LOCATION per its own description β€” cities and
233
+ countries. It is NOT a paid-partnership flag, and nothing in this dataset is: TikTok declares
234
+ no equivalent, so `paid_partnership`/`partner` have no column on this family at all.
235
+ """
236
+ node = node if isinstance(node, dict) else {}
237
+ shortcode = _tt_str(node, "shortcode", "post_id")
238
+ if not shortcode:
239
+ return None
240
+ return _drop_blanks({
241
+ "platform": engine.PLATFORM_TIKTOK,
242
+ "shortcode": shortcode,
243
+ # β›”β›” `account_id`, NOT `profile_username` β€” MEASURED on a real Posts row 2026-08-12.
244
+ # The vendor's `profile_username` is the DISPLAY NAME (`"Dina"`), while `account_id` is the
245
+ # @handle (`"d1na_th"`) β€” the same field `normalize_profile` already reads for `handle`, so
246
+ # one name means one thing across both corpora. Reading the display name silently broke the
247
+ # only join this table has: `ut_tt_posts.influencer_key` -> `ut_tt_profile.handle` matched
248
+ # NOTHING, so a person could not filter posts by creator and a rollup would count zero.
249
+ # ⚠ NO FALLBACK TO `profile_username`, deliberately. It is not a degraded handle, it is a
250
+ # different fact, and filling a join key with it is worse than leaving it blank β€” a blank
251
+ # is visibly missing, a display name looks like an answer [[one-question-two-normalizers]].
252
+ # The URL is the honest second source: it carries the handle by construction.
253
+ "influencer_key": (_tt_str(node, "account_id")
254
+ or tt_handle(_tt_str(node, "profile_url", "url") or "") or None),
255
+ "posted_at": _tt_day(node, "create_time"),
256
+ "type": tt_post_type(node),
257
+ "caption": _tt_str(node, "description"),
258
+ "url": _tt_str(node, "url"),
259
+ "hashtags": _bd_list(node, "hashtags"),
260
+ "tagged_location": _tt_str(node, "commerce_info"),
261
+ "views": _ig_int(_first(node, "play_count")),
262
+ "likes": _ig_int(_first(node, "digg_count")),
263
+ "comments": _ig_int(_first(node, "comment_count")),
264
+ "shares": _ig_int(_first(node, "num_share_count")),
265
+ "saves": _ig_int(_first(node, "collect_count")),
266
+ "video_duration": _ig_int(_first(node, "video_duration")),
267
+ "source_payload": _bd_source_payload(node),
268
+ })
269
+
270
+
271
+ def normalize_comment(node):
272
+ """A TikTok Comments row β†’ the `ut_tt_comments` cell shape. None without a comment id.
273
+
274
+ ⚠ THE NAME COLLISION, RESOLVED: TikTok's `replies` is an ARRAY of reply objects and OUR
275
+ `replies` column is an INT count. The count comes from `num_replies`; the array stays whole in
276
+ `source_payload`. Reading the array's length instead would be a second, disagreeing answer to
277
+ a question the vendor already answers β€” and it would disagree, because a page of replies is not
278
+ all of them.
279
+ ⚠ `date_created` is typed `date` by this vendor, unlike Instagram's `comment_date` which is
280
+ text and needs defensive parsing. It still goes through `_tt_day` β€” one date path, so a vendor
281
+ that changes its mind cannot change ours.
282
+ ⚠ The comment TEXT and every identifiable commenter field (`commenter_user_name` is flagged
283
+ PII) stay in `source_payload` and are promoted to no column, which is the same posture the
284
+ Instagram comment schema takes for the same D-24 reason.
285
+ """
286
+ node = node if isinstance(node, dict) else {}
287
+ comment_key = _tt_str(node, "comment_id")
288
+ if not comment_key:
289
+ return None
290
+ return _drop_blanks({
291
+ "platform": engine.PLATFORM_TIKTOK,
292
+ "comment_key": comment_key,
293
+ "shortcode": _tt_str(node, "post_id"),
294
+ # ⭐⭐ OWNER RULING 2026-08-12: the comment's CONTENT gets a column. `comment_text` is the
295
+ # vendor's own key and `comment_text_only` its stripped variant (the probe recorded both);
296
+ # primary first, so a row carrying the rich form is not silently served the plain one.
297
+ # β›” The commenter's identity is deliberately NOT promoted β€” see `TT_COMMENT_FIELDS`.
298
+ "text": _tt_str(node, "comment_text", "comment_text_only"),
299
+ "commented_at": _tt_day(node, "date_created"),
300
+ "likes": _ig_int(_first(node, "num_likes")),
301
+ "replies": _ig_int(_first(node, "num_replies")),
302
+ "source_payload": _bd_source_payload(node),
303
+ })
304
+
305
+
306
+ #: ⭐ The three maps, addressable by name β€” so a gate (and the runners in T04-T06) can walk them
307
+ #: rather than naming three functions, and so adding a fourth dataset is one entry.
308
+ TT_NORMALIZERS = {
309
+ "tt_profile": normalize_profile,
310
+ "tt_post_metrics": normalize_post,
311
+ "tt_comments": normalize_comment,
312
+ }
313
+
314
+
315
+ # ---------------------------------------------------------------------------------------------
316
+ # THE FETCH β€” wave 30 Β· W30-T08 (carrying wave-29's dropped T05)
317
+ # ---------------------------------------------------------------------------------------------
318
+
319
+ def tt_handle(url):
320
+ """A TikTok profile URL **or** a bare handle β†’ the handle. `''` when it is neither.
321
+
322
+ Deliberately permissive about the input and strict about the output, because the two callers
323
+ hand it different things: an automation stores whatever a person typed in the profile column
324
+ (`@nurilab`, `nurilab`, or the full URL), while the discovery runner already holds a clean
325
+ `account_id`. One normaliser, so a row found by discovery and a row typed by hand cannot
326
+ resolve to two different handles.
327
+ """
328
+ s = str(url or "").strip()
329
+ if not s:
330
+ return ""
331
+ if "tiktok.com" in s.lower():
332
+ # Everything after the first `@`, up to the next path segment or query.
333
+ tail = s.split("@", 1)[1] if "@" in s else ""
334
+ s = tail.split("/")[0].split("?")[0].split("#")[0]
335
+ s = s.strip().lstrip("@").strip()
336
+ # A handle is the vendor's `account_id` shape: alphanumerics, dots and underscores.
337
+ return s if s and all(c.isalnum() or c in "._" for c in s) else ""
338
+
339
+
340
+ def tt_post_urls(node, limit=0):
341
+ """⭐ WAVE 30 Β· T10 β€” the profile row's own post permalinks, newest-first as the vendor sends.
342
+
343
+ β›” THIS IS WHY TIKTOK POST CAPTURE COSTS NO EXTRA DISCOVERY. `top_videos` rides the PROFILE row
344
+ we have already bought, so the posts read is a scrape of links we hold, never a search for them.
345
+ The two TikTok DISCOVERY datasets (posts-by-profile, posts-by-keyword) are **404 for our key**,
346
+ so a design that reached for either would not merely be dearer, it would not work.
347
+
348
+ β›”β›” CORRECTED 2026-08-12 β€” THIS DOCSTRING USED TO SAY *"the probe MEASURED `top_videos` as an
349
+ array of video permalinks, NO empties"*, AND THAT SENTENCE IS WHAT SHIPPED THE BUG. The probe
350
+ read `/datasets/{id}/metadata` β€” a DATASET description β€” and the phrase quoted was the field's
351
+ `ai_description`, not an observation of a row. The real row sends **dicts keyed `video_url`**
352
+ (measured below), so the reader built against the quoted sentence found nothing, forever, in
353
+ silence. ⭐ The transferable half: *"the probe measured X"* and *"the probe read a declaration
354
+ of X"* are different claims, and prose cannot be told apart by a reader downstream β€” which is
355
+ why the correction names the method, not just the value.
356
+
357
+ ⚠ `top_posts_data` is deliberately NOT read: the probe calls it *"a thin dup of `top_videos`"*,
358
+ and preferring whichever happened to be longer is how one creator's window silently differs
359
+ from another's.
360
+ ⚠ `limit <= 0` means "everything the row carried". The CAP IS THE CALLER'S β€” `config.maxPosts`,
361
+ validated 1..12 β€” and it is applied here rather than after the scrape so an unwanted post is
362
+ never bought. [[a-constant-two-features-share]]: the 12 is the vendor's measured profile window,
363
+ not a number this function may invent.
364
+ """
365
+ raw = (node or {}).get("top_videos")
366
+ out = []
367
+ for item in raw if isinstance(raw, list) else []:
368
+ # β›”β›” MEASURED ON A REAL ROW 2026-08-12, AND IT IS NOT WHAT THE SCHEMA SAID.
369
+ # `top_videos` is NOT an array of permalink strings. One paid TikTok Profiles scrape of a
370
+ # live handle returns **19 DICTS**, keyed
371
+ # `video_url Β· video_id Β· playcount Β· diggcount Β· commentcount Β· share_count Β·
372
+ # favorites_count Β· create_date Β· cover_image`.
373
+ # The docstring above cites the $0 probe as having "measured" permalinks β€” it had not, and
374
+ # could not: `/datasets/{id}/metadata` describes a DATASET, and the probe's own verdict says
375
+ # so in terms (*"what $0 cannot buy … the real ROW shape … that a declared field
376
+ # POPULATES"*, `wave29/proto/tiktok-schema.md`). This is the SECOND time this vendor's
377
+ # declaration has diverged from its delivery on this exact axis; BD's IG Reels `views` was
378
+ # the first. [[reachable-is-not-the-same-as-built]]
379
+ # β‡’ The consequence, live: `TT_DS_POSTS` was never reached, because this returned an EMPTY
380
+ # list on every real profile β€” post capture could not have worked for anybody, and the
381
+ # T10 gate stayed green because its canned fixture encoded the DECLARED shape. A fixture
382
+ # written from a schema tests the schema.
383
+ # ⚠ `video_url` is FIRST because it is the key the vendor actually sends; `url` is kept
384
+ # because it costs nothing and is what a future corpus revision would most likely use. The
385
+ # bare-string branch stays for the same reason β€” this widens what is ACCEPTED and invents
386
+ # nothing: a shape that yields no `http…` value still degrades to "no posts", exactly as
387
+ # before, rather than to a URL built out of a guess.
388
+ # ⚠ `top_posts_data` is STILL not read (it carries `post_url` and would work): preferring
389
+ # whichever array happened to be longer is how one creator's window silently differs from
390
+ # another's, and that reasoning is unchanged by this correction.
391
+ if isinstance(item, dict):
392
+ u = str(item.get("video_url") or item.get("url") or "").strip()
393
+ else:
394
+ u = str(item or "").strip()
395
+ if u.lower().startswith("http") and u not in out:
396
+ out.append(u)
397
+ return out[:limit] if limit and limit > 0 else out
398
+
399
+
400
+ def pull_posts_tt(post_urls, log=print, deferred=None):
401
+ """The TikTok Posts dataset for a list of permalinks β†’ `(rows, note)`, already normalised.
402
+
403
+ ⚠ ONE CALL FOR THE WHOLE WINDOW. `bd_scrape` has always taken a list, and the Instagram side
404
+ measured what happens when a caller forgets: 25 records, one billed snapshot each, a walk still
405
+ running at 67 minutes. Nothing here loops per URL.
406
+ """
407
+ urls = [str(u) for u in (post_urls or []) if str(u or "").strip()]
408
+ if not urls:
409
+ return [], ""
410
+ rows, note = bd_scrape(TT_DS_POSTS, urls, deferred=deferred)
411
+ if note:
412
+ log(f"[aios-tt] posts: {note}")
413
+ return [], note
414
+ out = [r for r in (normalize_post(n) for n in rows) if r]
415
+ return out, ""
416
+
417
+
418
+ def pull_comments_tt(post_urls, log=print, deferred=None):
419
+ """The TikTok Comments dataset for a list of POST permalinks β†’ `(rows, note)`, normalised.
420
+
421
+ β›” THE MOST EXPENSIVE THING THIS PRODUCT BUYS, and the reason `commentMetrics` defaults OFF on
422
+ both networks: a comments scrape ingests identifiable third parties who never entered anybody's
423
+ list (D-24). The mapper already keeps every commenter field in `source_payload` and promotes
424
+ none of them to a column; this function adds no new exposure, it just has to be asked for.
425
+ """
426
+ urls = [str(u) for u in (post_urls or []) if str(u or "").strip()]
427
+ if not urls:
428
+ return [], ""
429
+ rows, note = bd_scrape(TT_DS_COMMENTS, urls, deferred=deferred)
430
+ if note:
431
+ log(f"[aios-tt] comments: {note}")
432
+ return [], note
433
+ out = [r for r in (normalize_comment(n) for n in rows) if r]
434
+ return out, ""
435
+
436
+
437
+ #: ⭐⭐ WAVE 30 Β· D-156 β€” THE MEDIA DATASETS, AS A SET, SO THE HAND-OFF CAN FILTER ON IDENTITY.
438
+ #: `_media_deferrals` uses this to lift ONLY posts/comments snapshots out of the local deferral
439
+ #: list. That is what makes it structurally impossible to file a PROFILE snapshot in the engine's
440
+ #: metric queue β€” the defect a draft of T10 shipped and A-39 booked as "the wrong fix is worse
441
+ #: than the gap". A membership test cannot be got wrong by a later edit the way `if` order can.
442
+ TT_MEDIA_DATASETS = (TT_DS_POSTS, TT_DS_COMMENTS)
443
+
444
+
445
+ def _media_deferrals(deferred):
446
+ """The POSTS/COMMENTS entries of a `bd_scrape` deferral list β€” never the profile's.
447
+
448
+ ⚠ The engine, not this module, decides what a deferral MEANS: it stamps `kind` and the
449
+ handle and files it. TikTok needs no `_tag_metric_deferrals` twin because one dataset is one
450
+ kind here, so the id already carries everything a mapper choice depends on β€” and importing
451
+ Instagram's tagger is not available anyway (W30-T09 gates ZERO `from connectors_ig` lines).
452
+ """
453
+ out = []
454
+ for d in deferred or []:
455
+ if isinstance(d, dict) and str(d.get("datasetId") or "") in TT_MEDIA_DATASETS:
456
+ out.append(dict(d))
457
+ return out
458
+
459
+
460
+ def pull_profile_tt(url, log=print, pending_profile=None, prefetch=None,
461
+ max_posts=0, post_metrics=False, comment_metrics=False):
462
+ """ONE TikTok profile from the vendor. Same return contract as `pull_profile`.
463
+
464
+ `{state, profile, posts, comments, via, note}` with `state ∈ ok | partial | blocked | error`,
465
+ so the engine's enrich branch treats every network identically and no caller learns a new
466
+ shape.
467
+
468
+ ⭐ WAVE 30 Β· T10 β€” POSTS AND COMMENTS ARE REAL NOW, AND BOTH DEFAULT OFF, exactly as Instagram's
469
+ do. `post_metrics` scrapes the profile row's own `top_videos` permalinks (see `tt_post_urls` β€”
470
+ no discovery call, because both TikTok discovery datasets 404 for our key); `comment_metrics`
471
+ then scrapes the comments of the posts that came back. ⚠ COMMENTS REQUIRE POSTS by construction
472
+ rather than by a rule: their input IS a post permalink, so asking for comments with post capture
473
+ off is a request with no subject, and it returns none instead of quietly buying posts nobody
474
+ asked for.
475
+
476
+ β›” `partial` IS THE SUCCESS STATE WHENEVER NO MEDIA WAS READ, and that is deliberate rather than
477
+ pessimistic. The Instagram contract reads `ok` only when identity AND media both landed
478
+ (`pull_profile_bd`: *"identity without media is still partial ... a run that wrote a follower
479
+ count and no posts must not paint green over a posts table that did not grow"*). So: posts not
480
+ ASKED for β†’ `partial`, saying so; posts asked for and landed β†’ `ok`; asked for and none came β†’
481
+ `partial` with the vendor's reason. The state answers "did this pull deliver what it went for",
482
+ never "did the function finish".
483
+
484
+ ⚠ **NO FREE RUNG, AND NO FALLBACK CHAIN.** Instagram's `pull_profile` drops to Apify when the
485
+ paid rung refuses; `providers.DEFAULT_CHAINS["tt_profile"]` is deliberately single-provider,
486
+ with its own note explaining that a multi-provider chain is a promise something walks it and
487
+ that nothing walks Instagram's second name today either. So a refusal here is final, and it
488
+ says so instead of implying a retry somewhere.
489
+ """
490
+ handle = tt_handle(url)
491
+ if not handle:
492
+ return {"state": "error", "profile": {}, "posts": [], "comments": [], "via": "",
493
+ "note": f"{url!r} is not a TikTok profile URL or handle"}
494
+
495
+ # ⭐ THE BATCH FAST PATH, same shape as the Instagram side: `prefetch` is `{handle: node}` from
496
+ # one multi-URL scrape covering a whole selection. A hit is a vendor round trip that does not
497
+ # happen; a miss falls through to the single-URL call below.
498
+ cached = prefetch.get(handle) if isinstance(prefetch, dict) else None
499
+ _deferred = []
500
+ if isinstance(cached, dict) and cached:
501
+ rows, note = [cached], ""
502
+ else:
503
+ rows, note = bd_scrape(TT_DS_PROFILES, [tt_profile_url(handle)], deferred=_deferred)
504
+
505
+ node = rows[0] if rows else {}
506
+ profile = normalize_profile(node, handle) if node else {}
507
+ # β›” THE READABILITY TEST IS `followers`/`following`, NOT "did we get a dict". `normalize_profile`
508
+ # drops blanks, so an unreadable row still returns `{"platform": …, "handle": …}` β€” truthy, and
509
+ # carrying nothing anybody asked for. The Instagram rung tests exactly this pair for exactly
510
+ # this reason, and answering "0 followers" instead is the failure it exists to prevent.
511
+ unreadable = profile.get("followers") is None and profile.get("following") is None
512
+ if note or unreadable:
513
+ # ⭐ THE DEFERRAL IS HANDED OVER RATHER THAN DISCARDED. A snapshot the vendor is still
514
+ # building HAS ALREADY BEEN PAID FOR; dropping its id bills again on the next run for the
515
+ # same record. That was live on the Instagram profile path until 2026-08-09 β€” measured on
516
+ # nurilab as two runs, two fresh snapshots, both abandoned β€” and it is not being
517
+ # reintroduced here by omission.
518
+ if isinstance(pending_profile, list):
519
+ for d in _deferred:
520
+ pending_profile.append({**d, "kind": "profile", "influencer": handle})
521
+ why = note or ("the scrape answered, but no follower/following counts were readable in it "
522
+ "(the field names may have moved - see tiktok-capture.md)")
523
+ return {"state": "blocked", "profile": {}, "posts": [], "comments": [], "via": "brightdata",
524
+ "deferredProfile": [d.get("snapshotId") for d in _deferred],
525
+ "note": why}
526
+
527
+ # --- W30-T10: THE MEDIA, ONLY WHEN IT WAS ASKED FOR. ------------------------------------
528
+ if not post_metrics:
529
+ return {"state": "partial", "profile": profile, "posts": [], "comments": [],
530
+ "via": "brightdata",
531
+ "note": note or "profile read; post capture is off for this step"}
532
+ urls = tt_post_urls(node, limit=max_posts)
533
+ if not urls:
534
+ # ⚠ NOT AN ERROR AND NOT A RETRY. A creator with no `top_videos` has nothing to buy, and
535
+ # saying so is what stops the next run paying to be told the same thing.
536
+ return {"state": "partial", "profile": profile, "posts": [], "comments": [],
537
+ "via": "brightdata",
538
+ "note": note or "profile read; this account's row carried no post links"}
539
+ posts, p_note = pull_posts_tt(urls, log=log, deferred=_deferred)
540
+ comments, c_note = ([], "")
541
+ if comment_metrics and posts:
542
+ # The comments dataset is keyed on a POST permalink, so it reads the posts we just bought β€”
543
+ # `url` from the mapper, never the profile's raw array, so a post the posts scrape refused
544
+ # is not silently asked about again one rung later.
545
+ comments, c_note = pull_comments_tt([p.get("url") for p in posts if p.get("url")],
546
+ log=log, deferred=_deferred)
547
+ # ⭐⭐ WAVE 30 Β· D-156 β€” THE MEDIA DEFERRALS ARE HANDED BACK, and the shape of the hand-off is
548
+ # the whole lesson. An earlier draft of T10 appended every `_deferred` entry to
549
+ # `pending_profile` tagged `kind: "profile"`. By the time control reaches here a PROFILE
550
+ # deferral is impossible β€” the profile branch above returns `blocked` on any note β€” so **every
551
+ # id fanned out that way was a POSTS or COMMENTS snapshot in the PROFILE queue**, whose
552
+ # collector writes preset profile cells onto somebody's record from post rows. The engine keeps
553
+ # the two queues apart deliberately (`_pending_profile_tasks` vs `_pending_metric_tasks`).
554
+ # β‡’ So this returns them under their OWN key, filtered by dataset identity
555
+ # (`_media_deferrals`), and the engine files them in the metric queue with the handle it
556
+ # already holds. Returning rather than appending also keeps the queue's vocabulary out of a
557
+ # connector: this module knows which CORPUS deferred, never what the engine calls it.
558
+ # ⚠ `deferredMedia` rides BOTH returns on purpose. The empty-posts case is the one that
559
+ # matters most β€” that is exactly the run where the vendor took too long, so a caller reading
560
+ # the ids only from the success path would lose every batch it actually paid for.
561
+ deferred_media = _media_deferrals(_deferred)
562
+ if not posts:
563
+ return {"state": "partial", "profile": profile, "posts": [], "comments": [],
564
+ "via": "brightdata", "deferredMedia": deferred_media,
565
+ "note": p_note or note or "profile read; the post source returned nothing"}
566
+ return {"state": "ok", "profile": profile, "posts": posts, "comments": comments,
567
+ "via": "brightdata", "deferredMedia": deferred_media,
568
+ "note": c_note or note or ""}
api/main.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/odoo_relational.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/providers.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_admin.py CHANGED
@@ -546,6 +546,203 @@ def _perm_modules(session, surfaces=False):
546
  #: editor renders, so "shown" and "storable" cannot drift apart again.
547
 
548
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  def _module_fields(key, session=None):
550
  """The field schema the editor builds its pickers from β€” the SAME vocabulary the grid uses,
551
  so an admin filters on exactly what the user sees.
@@ -679,7 +876,31 @@ def _module_fields(key, session=None):
679
  # skipped on a key collision so a declared column always wins its own key.
680
  taken = {f["key"] for f in out}
681
  out.extend(m for m in _metric_fields(key) if m["key"] not in taken)
682
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
 
684
 
685
  def _metric_fields(key):
@@ -1218,6 +1439,19 @@ def _clean_perms(v, governed_keys=None, session=None):
1218
  # per-user private one cannot, and those keep denying every leaf they are named in.
1219
  row_wall_blind = _row_wall_blind_keys(key, session)
1220
  filter_keys -= row_wall_blind
 
 
 
 
 
 
 
 
 
 
 
 
 
1221
  hidden = raw.get("hiddenFields") or []
1222
  if not isinstance(hidden, list):
1223
  raise err(400, "bad_perms", f"{key}: hiddenFields must be a list")
@@ -1302,6 +1536,28 @@ def _clean_perms(v, governed_keys=None, session=None):
1302
  f"would hide EVERY row with nothing on screen saying why. Hide the "
1303
  f"column instead, or filter on one of the database's own columns "
1304
  f"or on a column shared with the whole workspace.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1305
  cleaned = aios_grid.clean_filter_tree(nodes, filter_keys, cohort_ids=None)
1306
  if _leaf_count(cleaned) != _leaf_count(nodes):
1307
  raise err(400, "bad_filter",
 
546
  #: editor renders, so "shown" and "storable" cannot drift apart again.
547
 
548
 
549
+ #: What a picker row says about provenance when nothing could be established: no author on the
550
+ #: definition, no owner in the registry, and no answer to "does that owner hold admin".
551
+ #: β›”β›” `ownerIsAdmin: None` IS NOT `False`, AND THE DIFFERENCE IS THE WHOLE POINT OF W41-T08.
552
+ #: `False` asserts "a known account that is not an administrator owns this column"; `None` says
553
+ #: "unresolved". R8 arms an ADMIN-CREATED-ONLY rule on top of this, and a consumer that read the
554
+ #: unresolved answer as `False` would wall a column on a store outage β€” silently, and in the
555
+ #: direction that breaks a live permission rule. Every consumer must treat `None` as "do not act".
556
+ #: ⚠ `origin` FALLS TO `"preset"` for a row with no definition behind it (the `measure_`
557
+ #: pseudo-fields), which is the truth rather than a default: the platform's metric catalogue made
558
+ #: those, not a person. `field_origin`'s own docstring argues the same polarity.
559
+ _PROVENANCE_UNKNOWN = {"createdBy": None, "owner": None, "ownerIsAdmin": None,
560
+ "origin": "preset"}
561
+
562
+ #: ⭐⭐ W41-T09 / RULING R8 / OWNER INSTRUCTION 15 β€” THE KEY A PICKER ROW CARRIES WHEN R8 IS WHAT
563
+ #: TOOK IT OUT OF THE PERMISSION FILTER BUILDER. A MODULE CONSTANT rather than a literal, because
564
+ #: the producer (`_module_fields`) and the consumer (`_clean_perms`' named refusal) sit 500 lines
565
+ #: apart: two spellings of one key is wave 40's own scar, where a TITLE instruction shipped as a
566
+ #: no-op with both lanes correct and every gate green because the consumer never read the
567
+ #: producer's new name [[two-lanes-one-contract-dead-feature]].
568
+ #: ⚠ PRESENT ONLY WHEN R8 IS THE CAUSE. A `measure_` pseudo-field and `est_missed` are
569
+ #: `filterable: False` for reasons that predate this rule and carry no reason key, so the set of
570
+ #: rows holding one IS exactly the set R8 refuses β€” which is what lets the validator derive its
571
+ #: refusal from the very rows the picker was built from instead of recomputing the predicate.
572
+ _FILTER_REASON_KEY = "filterableReason"
573
+
574
+ #: β›” USER-FACING COPY (CONTRACT C7): no em dash, no en dash, no ALL-CAPS chrome. One sentence, and
575
+ #: it NAMES THE OWNER because that is the repair rather than a diagnosis with no next step β€”
576
+ #: W41-T03 shipped owner reassignment this wave, so "who owns it" is the actionable half.
577
+ _ADMIN_ONLY_FILTER_REASON = ("Only a column owned by an administrator can be used in a permission "
578
+ "filter, and {owner} owns this one.")
579
+
580
+
581
+ def _apply_admin_only_filter_rule(row):
582
+ """RULING R8, ARMED: a column whose CURRENT OWNER does not hold admin leaves the permission
583
+ filter builder, and says why.
584
+
585
+ Owner instruction 15: *"only admin-created fields may be used for permissioning filtration."*
586
+ ⚠ THE PREMISE HAD NO SUBJECT UNTIL W41-T08 β€” a picker row carried `custom` and nothing else β€”
587
+ so R8 defines "admin-created" as THE FIELD'S CURRENT OWNER HOLDS THE ADMIN ROLE, repairable
588
+ through the ownership transfer W41-T03 shipped. On live today the two readings coincide:
589
+ `object_shares` holds no `field` namespace at all for this tenant, so every owner resolves
590
+ through C1's documented `createdBy` fallback (W41-T08's census, measured 2026-08-24).
591
+
592
+ β›”β›” `None` ADMITS, AND THAT DECISION IS THE WHOLE SAFETY PROPERTY OF THIS FUNCTION.
593
+ `ownerIsAdmin` is `False` only for a KNOWN account known not to hold admin; it is `None` for
594
+ "unresolved" β€” no owner recorded, the roster unreadable, or no session to read either with.
595
+ `store.get` answers `{}` on a transient failure, so a rule that refused on `None` would revoke
596
+ EVERY live permission filter in the tenant at once, off one bad read, in the direction that
597
+ hands an account the whole book. Admitting on `None` costs the opposite and it is bounded: a
598
+ degraded read leaves the offer exactly as wide as it was the day before R8 shipped.
599
+ ⚠ AND THE CHOICE IS MEASURED, NOT ONLY ARGUED. `verify_api`'s W40-T18 fixture
600
+ `custom_region_qa` carries no `createdBy` and no grant record, so it resolves `None`; refusing
601
+ on `None` turns that shipped ACCEPTANCE leg red. The safe direction and the measured one agree.
602
+
603
+ β›” NARROW THREE WAYS, and each one names a live column this must not touch:
604
+
605
+ * `origin == "user"` ONLY, and the badge comes from CONTRACT C1 (`field_origin`), never from
606
+ a second derivation in this file. A PLATFORM column is not "admin-created" either on a
607
+ literal reading, and walling `dba` or `revenue` because somebody was handed their
608
+ ownership would be R8 eating the database's own contract.
609
+ * A row that is ALREADY unfilterable is left alone, reason key and all. A `measure_`
610
+ pseudo-field and `est_missed` are out of the builder for causes that predate R8, and
611
+ stamping this sentence on them would explain their absence with the wrong reason.
612
+ * The FILTER only. οΏ½οΏ½ R8 ARMS FILTRATION, NOT HIDING: `_clean_perms` validates
613
+ `hiddenFields` against EVERY key and the permanent filter against `filterable` alone, so
614
+ flipping this one flag reaches the builder and leaves the hide list whole. The nine
615
+ declared `shared` product columns and all five of these stay hideable.
616
+
617
+ ⚠ A KNOWN GAP, NAMED RATHER THAN PAPERED OVER: on a `ut_*` database `user_tables._clean_field`
618
+ is a strict allowlist that keeps neither `custom` nor `createdBy`, so its columns answer
619
+ `origin: "preset"` and R8 cannot fire there until a transfer writes a real grant record.
620
+ `field_origin`'s own docstring books that allowlist as the defect; this inherits the gap rather
621
+ than routing around it with the second badge C1 exists to forbid.
622
+
623
+ β›” THE STORED RECORD IS NOT TOUCHED, and that is deliberate. `get_perms`' I16 cascade prunes a
624
+ leaf naming a column the database no longer HAS; a column that still exists and merely changed
625
+ who may name it is not stale, and pruning it would DELETE a live permission rule silently, in
626
+ the WIDENING direction. An existing wall keeps working (`apply_row_scope` is unchanged) and a
627
+ re-save of it is refused LOUDLY by `_clean_perms`, with the column named.
628
+
629
+ ⚠ `owner` IS GUARDED STRUCTURALLY, not defensively: `_field_provenance` cannot answer `False`
630
+ without one, and the sentence is only well formed with a name in it.
631
+ """
632
+ owner = row.get("owner")
633
+ if (row.get("origin") != "user" or not row.get("filterable")
634
+ or row.get("ownerIsAdmin") is not False or not owner):
635
+ return row
636
+ return dict(row, filterable=False,
637
+ **{_FILTER_REASON_KEY: _ADMIN_ONLY_FILTER_REASON.format(owner=owner)})
638
+
639
+
640
+ def _admin_usernames():
641
+ """Every account holding the `admin` role, lowercased β€” or `None` when that set could not be
642
+ established.
643
+
644
+ β›”β›” `None` IS NOT `set()`, for the reason `perm_scope.user_generated_fields` states one door
645
+ over: `set()` means "resolved: nobody here is an administrator" and `None` means "the roster
646
+ could not be read". `core.users.registry` is a LENIENT display read (`store.get`), so a store
647
+ outage hands back `{}` β€” indistinguishable from a tenant with no accounts, and reading that as
648
+ "no admins" would make every `ownerIsAdmin` answer `False` at exactly the moment nothing can be
649
+ verified. An EMPTY registry therefore answers `None`; a populated one with no admin in it is a
650
+ real (and reportable) `set()`.
651
+
652
+ ⚠ THE REGISTRY IS A GLOBAL CONTROL-PLANE BUCKET AND THAT IS WHY THIS IS NOT TENANT-SCOPED.
653
+ `list_users` says it in full: one bucket holds every tenant's accounts, keyed by username, so a
654
+ username resolves to exactly one record and exactly one role however the caller arrived. The
655
+ tenant filter there protects a ROSTER LISTING; this asks a per-username question that has one
656
+ answer.
657
+ """
658
+ try:
659
+ reg = users.registry() or {}
660
+ except Exception: # noqa: BLE001
661
+ return None
662
+ if not isinstance(reg, dict) or not reg:
663
+ return None
664
+ return {str(u).strip().lower() for u, rec in reg.items()
665
+ if isinstance(rec, dict)
666
+ and str(rec.get("role") or "user").strip().lower() == "admin"}
667
+
668
+
669
+ def _field_provenance(src, key, session):
670
+ """`{field_key: {createdBy, owner, ownerIsAdmin, origin}}` for the definitions in `src`.
671
+
672
+ ⭐⭐ W41-T08 / RULING R8 / CONTRACT C1 β€” WHO MADE THIS COLUMN, WHO OWNS IT NOW, AND WHETHER
673
+ THAT OWNER HOLDS ADMIN. R8 wants permission filters restricted to admin-created columns, and
674
+ the premise could not even be CHECKED before this: a picker row carried `custom` and nothing
675
+ else, so "admin-created" had no subject on the wire. β›” THIS FUNCTION MEASURES AND REPORTS.
676
+ It arms nothing, filters nothing and drops nothing β€” W41-T09 is the ticket that decides what a
677
+ wall does with the answer, and it is blocked on the census this produces.
678
+
679
+ β›” THE OWNER COMES FROM CONTRACT C1 AND FROM NOWHERE ELSE. `field_permissions.field_classes` is
680
+ the ONE producer of a field's badges (`{origin, audience, sharedBy, values, owner}`) and the
681
+ permission-rule picker is one of its four named consumers. Re-reading `object_shares` here, or
682
+ treating `createdBy` as the owner, would be the second derivation C1 exists to forbid β€” and the
683
+ two would disagree the first time a column changed hands, which is precisely the question R8
684
+ asks. `createdBy` still travels, but as the RAW author stamp beside the owner, never as it.
685
+
686
+ ⚠ THE BATCH, NEVER `field_class` IN A LOOP. `field_classes` opens the grant registry ONCE for
687
+ the whole list; the per-field door deep-copies `object_shares` per column, which is the D-214
688
+ shape its own docstring spent a ticket removing. This runs on every admin GET.
689
+
690
+ ⚠ `values_shared=()` IS DELIBERATE AND IT IS WHY ONLY TWO MEMBERS OF THE BAG ARE READ HERE.
691
+ The `values` and `audience` badges are the two that depend on which columns hold a tenant-wide
692
+ stratum, and resolving that costs another document read on a hot path. `origin` and `owner` do
693
+ not depend on it at all. β›” SO NEITHER `values` NOR `audience` MAY BE EMITTED FROM THIS CALL:
694
+ lending an empty key set makes both of them answer for a world in which nothing is shared. A
695
+ consumer that needs those two badges asks `field_classes` with the real set, the way
696
+ `routes_customers`' grid assembly does.
697
+
698
+ ⚠ NO SESSION MEANS NO PROVENANCE, AND THAT IS HONEST DEGRADATION RATHER THAN A GAP. Two callers
699
+ arrive without one (`_fold_legacy_scope` on the live PUT path, `verify_api`'s W30a probe), and
700
+ the only reads that could answer them are TENANT-BLIND module-level ones: `shares.grants` with
701
+ no `st` opens the default repo's registry, which on any tenant but #0 is a different workspace's
702
+ grants attributed to this one. An unresolved answer costs those two callers nothing β€” neither
703
+ reads provenance β€” and a wrong one would be a tenant leak. `origin` is still answered, because
704
+ `field_origin` is a pure read of the definition in hand and touches no store.
705
+ """
706
+ try:
707
+ from core import field_permissions as _fp
708
+ except Exception: # noqa: BLE001
709
+ return {}
710
+ defs = [f for f in src if isinstance(f, dict) and f.get("key")]
711
+ classes = {}
712
+ admins = None
713
+ if session is not None:
714
+ admins = _admin_usernames()
715
+ try:
716
+ # β›” `grant_topic` IS THE MODULE KEY, NOT THE STORE BUCKET. `shares.field_oid` namespaces
717
+ # a grant by the topic the write doors already use β€” `"customer_data"` /
718
+ # `"product_data"` (`routes_customers`/`routes_products`) and the table key itself for a
719
+ # `ut_*` database (`routes_tables`) β€” which is `key` in every one of those three cases.
720
+ # Handing it `customer_table_workspace` finds no record for any column and every owner
721
+ # silently reads as absent, which is the `grant_records` docstring's own warning.
722
+ classes = _fp.field_classes(defs, getattr(session, "uname", ""),
723
+ grant_topic=key, values_shared=(),
724
+ st=getattr(session, "runtime", None))
725
+ except Exception: # noqa: BLE001
726
+ # A registry that will not open costs the OWNER, not the picker. The rows still ship,
727
+ # still carry every membership boolean, and say `None` about what they could not read.
728
+ classes = {}
729
+ out = {}
730
+ for f in defs:
731
+ fkey = f["key"]
732
+ bag = classes.get(fkey) or {}
733
+ owner = bag.get("owner") or None
734
+ try:
735
+ origin = _fp.field_origin(f)
736
+ except Exception: # noqa: BLE001
737
+ origin = "preset"
738
+ out[fkey] = {"createdBy": (str(f.get("createdBy") or "").strip().lower() or None),
739
+ "owner": owner,
740
+ "ownerIsAdmin": (None if not owner or admins is None
741
+ else owner in admins),
742
+ "origin": origin}
743
+ return out
744
+
745
+
746
  def _module_fields(key, session=None):
747
  """The field schema the editor builds its pickers from β€” the SAME vocabulary the grid uses,
748
  so an admin filters on exactly what the user sees.
 
876
  # skipped on a key collision so a declared column always wins its own key.
877
  taken = {f["key"] for f in out}
878
  out.extend(m for m in _metric_fields(key) if m["key"] not in taken)
879
+ # ⭐⭐ W41-T08 / R8 / C1 β€” PROVENANCE, STAMPED AS A LAST PASS OVER THE FINISHED LIST.
880
+ # β›” A PASS, NOT A FILTER, AND THE POSITION IS THE PROOF. Every row that reached `out` above
881
+ # leaves this function, in the same order, with the same key/label/type/options/pinned and the
882
+ # same four membership booleans; the only difference is four ADDED keys. T09 arms R8's
883
+ # admin-only rule and it is blocked on the census this feeds β€” narrowing the offer here would
884
+ # build T09 by accident and break `Manual allocation of agent` the day after D-470 made it work.
885
+ # ⚠ A NEW DICT PER ROW, NEVER AN IN-PLACE STAMP β€” the rule `routes_customers`' grid assembly
886
+ # states over its own `class` bag, for the same reason. `owner` and `ownerIsAdmin` are THIS
887
+ # request's answer about a roster that changes, so stamping a row this function did not build
888
+ # itself (every `_metric_fields` row) would publish that answer to whatever else holds it.
889
+ # Those rows are freshly built today; the copy makes the aliasing impossible to reintroduce
890
+ # rather than merely untrue now.
891
+ # ⚠ A ROW WITH NO DEFINITION BEHIND IT (every `measure_` pseudo-field) takes
892
+ # `_PROVENANCE_UNKNOWN`, whose `origin` is `"preset"` β€” see the note there.
893
+ # ⭐⭐ W41-T09 / R8 β€” AND THE RULE IS APPLIED IN THE SAME PASS, ON THE STAMPED ROW.
894
+ # β›” THE ORDER IS THE POINT: the provenance has to be ON the row before anything can read
895
+ # `ownerIsAdmin` off it, and the rule reads nothing else β€” no second store call, no second
896
+ # derivation of who owns what. The list is still KEY-SET PRESERVING and still in the same
897
+ # order; a refused column keeps its row, its label and its four membership booleans, and
898
+ # loses exactly one flag while gaining one sentence. That is what keeps `hiddenFields`
899
+ # whole while the FILTER narrows, and it is why the picker and the validator below can
900
+ # still be the one vocabulary this function's docstring promises.
901
+ prov = _field_provenance(src, key, session)
902
+ return [_apply_admin_only_filter_rule(dict(r, **prov.get(r["key"], _PROVENANCE_UNKNOWN)))
903
+ for r in out]
904
 
905
 
906
  def _metric_fields(key):
 
1439
  # per-user private one cannot, and those keep denying every leaf they are named in.
1440
  row_wall_blind = _row_wall_blind_keys(key, session)
1441
  filter_keys -= row_wall_blind
1442
+ # ⭐⭐ W41-T09 / R8 β€” THE COLUMNS THE ADMIN-OWNER RULE TOOK OUT OF THE BUILDER, READ OFF
1443
+ # THE ROWS THE PICKER WAS BUILT FROM rather than recomputed here.
1444
+ # β›” THIS IS THE ONE-SOURCE INVARIANT DOING ITS JOB, not a convenience. Re-deriving "does
1445
+ # this owner hold admin" in the validator would be a SECOND registry read at a SECOND
1446
+ # moment, and the two would disagree the first time a role changed between the GET and
1447
+ # the PUT: the admin would be refused a column the screen in front of them was still
1448
+ # offering, or offered one the wall then rejected. `_apply_admin_only_filter_rule` already
1449
+ # decided, on this very call, and the decision travels on the row.
1450
+ # ⚠ `filter_keys` ALREADY EXCLUDES THESE (the rule cleared `filterable`), so this set is
1451
+ # not what refuses them β€” the generic leaf-count check below would do that on its own,
1452
+ # with the "unknown field" wording. It exists so the refusal can NAME THE COLUMN and give
1453
+ # the cause, which is the half of done-when the generic message cannot carry.
1454
+ admin_only_blocked = {f["key"] for f in fields_here if f.get(_FILTER_REASON_KEY)}
1455
  hidden = raw.get("hiddenFields") or []
1456
  if not isinstance(hidden, list):
1457
  raise err(400, "bad_perms", f"{key}: hiddenFields must be a list")
 
1536
  f"would hide EVERY row with nothing on screen saying why. Hide the "
1537
  f"column instead, or filter on one of the database's own columns "
1538
  f"or on a column shared with the whole workspace.")
1539
+ # ⭐⭐ W41-T09 / R8 / OWNER INSTRUCTION 15 β€” THE WRITE DOOR, AND IT NAMES THE
1540
+ # COLUMN. Second rather than first on purpose: a column that is BOTH unwallable
1541
+ # and non-admin-owned gets the message above, because reassigning its owner would
1542
+ # not make it filterable and this one would send the admin to do exactly that.
1543
+ # β›” AND IT IS NOT DECORATION OVER THE GENERIC CHECK BELOW. Without it the same
1544
+ # submission still 400s, worded *"an unknown field"* about a column the picker
1545
+ # listed one request ago and the grid renders every day. That wording sends an
1546
+ # administrator to look for a typo, and they will simply try again; owner
1547
+ # instruction 15 is a RULE, and a rule that cannot say its own name is
1548
+ # indistinguishable from a bug [[a-declared-gate-is-an-unchecked-claim]].
1549
+ # ⚠ BOTH DOORS ARE COVERED BY THIS ONE BRANCH: `put_perms` and
1550
+ # `routes_slack.put_channel_agent_perms` call THIS validator, both threading a
1551
+ # session, so a channel agent cannot be walled on a column a person cannot be.
1552
+ denied = sorted(_leaf_col_ids(nodes) & admin_only_blocked)
1553
+ if denied:
1554
+ raise err(400, "field_owner_not_admin",
1555
+ f"{key}: a permission filter cannot use {denied}. A permanent rule "
1556
+ f"may only name a column that an administrator owns, because "
1557
+ f"deleting a column also deletes the permission filter naming it, "
1558
+ f"and these columns belong to accounts without the admin role. "
1559
+ f"Reassign the column to an administrator, or write the rule on one "
1560
+ f"of the database's own columns.")
1561
  cleaned = aios_grid.clean_filter_tree(nodes, filter_keys, cohort_ids=None)
1562
  if _leaf_count(cleaned) != _leaf_count(nodes):
1563
  raise err(400, "bad_filter",
api/routes_agent_harness.py CHANGED
@@ -1,455 +1,455 @@
1
- """routes_agent_harness.py β€” CONTRACT C4: the agent's HARNESS, kept as versioned files.
2
-
3
- Owner item 3, verbatim (2026-08-18): *"This new module under agent is supposed to host any file
4
- pertaining to the agent skills, router etc. So we build a user can build a custom harness for each
5
- agent through the chat interface."*
6
-
7
- GET /api/v1/agents/{id}/harness the file LIST (no bodies)
8
- GET /api/v1/agents/{id}/harness?path=… one file: current body + its versions
9
- GET /api/v1/agents/{id}/harness?path=…&version=N one older body, verbatim
10
- PUT /api/v1/agents/{id}/harness write a NEW version of one file
11
- DELETE /api/v1/agents/{id}/harness?path=… drop a file and its history
12
-
13
- ⭐⭐ **R8 IS THE WHOLE SHAPE: BOTH PRINCIPALS WRITE, AND NOTHING IS EVER OVERWRITTEN.** A write
14
- appends a version; a roll-back is a write of an older body (`restoredFrom`), never a delete. So the
15
- history is a record of what happened rather than of what somebody last wanted it to look like.
16
-
17
- β›” **R8 IS DELIBERATELY NOT R9.** An agent-authored ACTION's configuration is agent-only (item 8,
18
- `routes_automation`); a harness file is not. Do not copy this file's posture over there or that
19
- one's over here β€” the two rulings differ on purpose and they sit one screen apart in the product.
20
-
21
- ⚠ **`author` IS THE SESSION, `authorKind` IS THE PROVENANCE, AND THEY ARE DIFFERENT FACTS.** Every
22
- write through this router is made BY a signed-in administrator, so `author` is stamped from the
23
- session and can never be supplied by the caller. `authorKind` says whether the BODY was drafted by
24
- a person or by the agent in the chat β€” a claim the client is entitled to make, because both are
25
- permitted (R8) and so nothing is bought by forging it. `record_version()` below is the server-side
26
- door the automation engine uses when the agent writes with no session at all; that one stamps the
27
- agent's own id as the author, which is the only case where `author` is not a username.
28
-
29
- β›” **THE VERSION LIST IS CAPPED AND THE CAP IS REPORTED, NEVER SILENT.** `MAX_VERSIONS` versions of
30
- one file are kept; past that the OLDEST are dropped and the count of what was dropped rides in
31
- `trimmed` on every payload that mentions the file, so a reader can see that the history is partial
32
- rather than infer that the file was only ever saved twice. This is the tenant document, which is
33
- already 28.6 MB on tenant #0 and is deep-copied on every read: an unbounded per-agent history is
34
- a store-sized leak with a UI in front of it.
35
- """
36
- from datetime import datetime, timezone
37
-
38
- from fastapi import APIRouter, Body, Depends
39
-
40
- from deps import Session, err, require_session
41
-
42
- router = APIRouter(prefix="/api/v1")
43
-
44
- #: The tenant's harness files: `{agent_id: {path: file_record}}`. A per-tenant bucket, so it rides
45
- #: `runtime.store_key`'s prefix and never lands in tenant #0's namespace β€” the same rule
46
- #: `routes_slack.AGENTS_KEY` follows for the agent records these hang off.
47
- HARNESS_KEY = "agent_harness"
48
-
49
- #: One body. Generous for a skill or a router file and far under the point where a single write
50
- #: would move the tenant document measurably. A larger body is a REFUSAL at the door, not a
51
- #: truncation: a silently truncated skill file is a harness that does not do what its text says.
52
- MAX_BODY_BYTES = 128 * 1024
53
-
54
- #: Files per agent. A refusal, not a trim β€” creating the 65th file is a different act from saving
55
- #: the 101st version of one, and only the second can be a routine consequence of ordinary editing.
56
- MAX_FILES = 64
57
-
58
- #: Versions kept per file. Past this the oldest go and `trimmed` counts them (see the header).
59
- MAX_VERSIONS = 100
60
-
61
- MAX_PATH = 200
62
-
63
- #: What a path may contain. β›” THIS IS NOT A FILESYSTEM PATH AND NOTHING HERE EVER TOUCHES A DISK β€”
64
- #: the "files" are keys in a store bucket. The character rule exists so the key is displayable, is
65
- #: safe to put in a URL, and cannot carry a traversal sequence that would look meaningful to a
66
- #: future reader who assumes it IS a filesystem path. Fail-closed on the character set, not on a
67
- #: list of forbidden sequences: an allow-list cannot be walked around by a spelling.
68
- _PATH_OK = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
69
-
70
- AUTHOR_KINDS = ("user", "agent")
71
-
72
-
73
- def _now():
74
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
75
-
76
-
77
- def _all(rt):
78
- """`{agent_id: {path: record}}` for one tenant. `{}` on any failure β€” an unreadable bucket must
79
- degrade to "this agent has no harness files", never to a 500 on the pane that lists them."""
80
- try:
81
- found = rt.get(HARNESS_KEY) or {}
82
- except Exception: # noqa: BLE001
83
- return {}
84
- return found if isinstance(found, dict) else {}
85
-
86
-
87
- def _files(rt, agent_id):
88
- found = _all(rt).get(str(agent_id))
89
- return found if isinstance(found, dict) else {}
90
-
91
-
92
- #: What kind of principal an id names. `None` = this tenant has no agent with that id.
93
- #:
94
- #: β›”β›” THERE ARE TWO AGENT REGISTRIES IN THIS PRODUCT AND THE FIRST DRAFT OF THIS FILE KNEW ONLY
95
- #: ONE (ASK D-5, 2026-08-18). `routes_slack._agents` is the per-Slack-channel permission wall under
96
- #: Manage users. The **Agents module** the owner's item 3 is about is the AUTOMATION surface β€”
97
- #: `Shell.tsx` mounts `<Lazily surface="Agents"><AutomationSurface /></Lazily>`, and
98
- #: `AutomationDetail`'s `panelTabs` is literally the `Properties | Run history` strip R7 adds
99
- #: "Harness" to. Keyed on the Slack bucket alone, every `GET/PUT /agents/{id}/harness` from that
100
- #: tab would have answered **404 no_agent**: whole, gate-green and dead on arrival
101
- #: [[reachable-is-not-the-same-as-built]]. Verified independently before acting, not taken on
102
- #: report: `surface="Agents"` mounts `AutomationSurface`, and `ManageAgentPane` contains ZERO
103
- #: occurrences of Canvas, Properties, Run history or panelTabs.
104
- #:
105
- #: ⭐ ONE STORE, BOTH PRINCIPALS β€” never a second bucket keyed by surface. The owner's "agent
106
- #: skills, router" files in two places is the parallel code path item 13 exists to refuse.
107
- AGENT_SLACK = "slack"
108
- AGENT_AUTOMATION = "automation"
109
-
110
-
111
- def agent_kind(rt, agent_id):
112
- """Which registry holds this id β€” `AGENT_SLACK`, `AGENT_AUTOMATION`, or `None`.
113
-
114
- ⚠ A SYNTHETIC ROW IS NOT AN AGENT FOR THIS PURPOSE. `field:` and `system:` ids are DERIVED at
115
- read time from a column definition or a connector schedule; they have no stored home, so a
116
- harness file hung off one is an orphan the moment the column changes. `patch_automation`
117
- already refuses those ids for the neighbouring reason, and this refuses them by simply not
118
- finding them β€” `all_definitions` holds stored automations only.
119
- """
120
- import routes_slack
121
- aid = str(agent_id or "")
122
- if isinstance(routes_slack._agents(rt).get(aid), dict):
123
- return AGENT_SLACK
124
- import automation_engine as engine
125
- try:
126
- known = engine.all_definitions(rt) or {}
127
- except Exception: # noqa: BLE001
128
- return None
129
- return AGENT_AUTOMATION if aid in known else None
130
-
131
-
132
- def _known_agent(rt, agent_id):
133
- """Does this tenant have an agent with this id, in EITHER registry?"""
134
- return agent_kind(rt, agent_id) is not None
135
-
136
-
137
- def agent_wall(session, agent_id):
138
- """404 for an unknown id, else apply the wall THAT PRINCIPAL'S OWN SURFACE applies.
139
-
140
- β›”β›” ONE DOOR, TWO WALLS, AND THAT IS NOT A SECOND CODE PATH β€” it is the refusal to invent a
141
- THIRD wall. A Slack channel agent is administered under Manage users and every `/agents/*` door
142
- in `routes_slack` is `admin_gate`; an automation lives in the Agents module and every door in
143
- `routes_automation` is `module_gate("automation")`. A harness file is configuration OF the
144
- agent it hangs off, so it is reached by whoever may already configure that agent. Picking one
145
- of the two walls for both would either lock the Agents module's own users out of a tab the
146
- owner asked for, or hand the Slack permission wall to anyone with an automation grant.
147
- """
148
- kind = agent_kind(session.runtime, agent_id)
149
- if kind is None:
150
- raise err(404, "no_agent", "there is no agent with that id in this workspace")
151
- if kind == AGENT_SLACK:
152
- import core.perms as perms
153
- if not perms.is_admin(session.user):
154
- raise err(403, "forbidden", "administrators only")
155
- else:
156
- session.require("automation")
157
- return kind
158
-
159
-
160
- def normalize_path(raw):
161
- """THE path rule, in ONE place. Returns the cleaned path, or `None` if it is not acceptable.
162
-
163
- β›”β›” ONE RULE, TWO DOORS, AND THAT IS WHY THIS IS A FUNCTION RATHER THAN TWO IF-BLOCKS. There
164
- are two ways into this store β€” the HTTP route (which must answer 400) and `record_version()`
165
- (which must raise `ValueError`, having no response to put a status into). Written twice, the
166
- two copies are [[one-question-two-normalizers]] waiting to happen: the first weakening of one
167
- copy is invisible because the other still refuses, so nothing goes red and the wall is now
168
- half there. Written once, a change to the rule is felt at both doors and by the gate.
169
- """
170
- path = str(raw or "").strip().strip("/")
171
- if not path or len(path) > MAX_PATH:
172
- return None
173
- if set(path) - _PATH_OK or ".." in path or "//" in path:
174
- return None
175
- return path
176
-
177
-
178
- def _clean_path(raw):
179
- """`normalize_path` at the HTTP door, where a refusal is a 400 with a reason."""
180
- path = normalize_path(raw)
181
- if path is None:
182
- if not str(raw or "").strip().strip("/"):
183
- raise err(400, "no_path", "a harness file needs a path, for example skills/router.md")
184
- if len(str(raw)) > MAX_PATH:
185
- raise err(400, "path_too_long", f"a harness path is at most {MAX_PATH} characters")
186
- raise err(400, "bad_path",
187
- "a harness path may use letters, digits, dot, dash, underscore and / only")
188
- return path
189
-
190
-
191
- def _blank(path, author, author_kind):
192
- return {"path": path, "versions": [], "trimmed": 0,
193
- "created": _now(), "createdBy": author, "createdKind": author_kind}
194
-
195
-
196
- def _append(record, body, author, author_kind, restored_from=None):
197
- """Append ONE version to a file record, in place, and report what was trimmed.
198
-
199
- The version NUMBER is monotonic and survives trimming β€” it counts writes, not stored entries.
200
- A version list whose numbers restart at 1 after a trim would make two different bodies share a
201
- name, and `restoredFrom` would then point at whichever one happened to be in the window.
202
- """
203
- # ⚠ A NEW LIST, NEVER `versions.append(...)` ON THE STORED ONE. `update()` hands the callback
204
- # the live document and may run it more than once; appending in place would then stack two
205
- # copies of the same version into the history on a retry.
206
- prior = record.get("versions") if isinstance(record.get("versions"), list) else []
207
- last = max((int(v.get("version") or 0) for v in prior if isinstance(v, dict)), default=0)
208
- entry = {"version": last + 1, "body": body, "author": author, "authorKind": author_kind,
209
- "created": _now(), "bytes": len(body.encode("utf-8"))}
210
- if restored_from:
211
- entry["restoredFrom"] = int(restored_from)
212
- versions = [*prior, entry]
213
- dropped = max(0, len(versions) - MAX_VERSIONS)
214
- if dropped:
215
- versions = versions[dropped:]
216
- record["versions"] = versions
217
- record["trimmed"] = int(record.get("trimmed") or 0) + dropped
218
- return entry
219
-
220
-
221
- def _head(record):
222
- """The newest version of a file record, or `None` for a record with no versions at all."""
223
- versions = record.get("versions") if isinstance(record.get("versions"), list) else []
224
- return versions[-1] if versions else None
225
-
226
-
227
- def _row(record):
228
- """One file, as the LIST door reports it: everything except the bodies.
229
-
230
- ⚠ NO BODY, AND THAT IS THE POINT. A list door that carried every version of every file would
231
- ship the whole harness on every pane render; the Harness tab lists first and opens one file
232
- second, which is exactly the shape this answers.
233
- """
234
- head = _head(record) or {}
235
- versions = record.get("versions") if isinstance(record.get("versions"), list) else []
236
- return {"path": record.get("path") or "",
237
- "version": int(head.get("version") or 0),
238
- "bytes": int(head.get("bytes") or 0),
239
- "author": head.get("author") or record.get("createdBy") or "",
240
- "authorKind": head.get("authorKind") or record.get("createdKind") or "user",
241
- "updated": head.get("created") or record.get("created") or "",
242
- "created": record.get("created") or "",
243
- "versions": len(versions),
244
- "trimmed": int(record.get("trimmed") or 0)}
245
-
246
-
247
- def _version_rows(record):
248
- """The history of one file, newest first, WITHOUT the bodies.
249
-
250
- A body per version is what makes a diff possible, and it is also what makes this payload big:
251
- 100 versions of a 128 KB file is 12 MB. The client asks for the two bodies it is diffing
252
- (`?path=…&version=N`), which is two round trips for a diff and none for a history list.
253
- """
254
- versions = record.get("versions") if isinstance(record.get("versions"), list) else []
255
- out = []
256
- for entry in reversed(versions):
257
- if not isinstance(entry, dict):
258
- continue
259
- row = {"version": int(entry.get("version") or 0),
260
- "author": entry.get("author") or "",
261
- "authorKind": entry.get("authorKind") or "user",
262
- "created": entry.get("created") or "",
263
- "bytes": int(entry.get("bytes") or 0)}
264
- if entry.get("restoredFrom"):
265
- row["restoredFrom"] = int(entry["restoredFrom"])
266
- out.append(row)
267
- return out
268
-
269
-
270
- def _limits():
271
- """The caps, IN the payload, so a client can say "this file is full" before a write fails.
272
-
273
- ⚠ A limit the client cannot see is a limit the user meets as an error. `trimmed` reports the
274
- one cap that acts without refusing; these report the three that refuse.
275
- """
276
- return {"maxBodyBytes": MAX_BODY_BYTES, "maxFiles": MAX_FILES,
277
- "maxVersions": MAX_VERSIONS, "maxPath": MAX_PATH}
278
-
279
-
280
- # ── the write door the SERVER uses (no session) ────────────────────────────────────────────────
281
- def record_version(runtime, agent_id, path, body, author="", author_kind="agent",
282
- restored_from=None):
283
- """Write one version from INSIDE the server β€” the agent's own half of R8.
284
-
285
- ⭐ THIS IS THE FUNCTION, NOT THE ROUTE, THAT MAKES "editable by BOTH the user and the agent"
286
- true. An agent acting inside an automation run holds no session and no cookie; if its only way
287
- to write were the HTTP door it would have to borrow a person's identity, and the authorship
288
- column would then be a record of who was logged in rather than of what wrote the file.
289
-
290
- Returns the appended entry. Raises nothing the caller cannot handle: an unknown agent is a
291
- `ValueError`, because a server-side caller has no HTTP response to put a 404 into.
292
- """
293
- agent_id = str(agent_id or "")
294
- if not _known_agent(runtime, agent_id):
295
- raise ValueError(f"no agent {agent_id!r} in this workspace")
296
- path = normalize_path(path)
297
- if path is None:
298
- raise ValueError("that is not an acceptable harness path")
299
- body = str(body or "")
300
- if len(body.encode("utf-8")) > MAX_BODY_BYTES:
301
- raise ValueError("harness body is over the size limit")
302
- kind = author_kind if author_kind in AUTHOR_KINDS else "agent"
303
- author = str(author or agent_id)
304
- # β›” THE FILE-COUNT CEILING IS CHECKED HERE, NOT INSIDE `_set`. An exception raised inside a
305
- # store `update` callback propagates out of a half-run read-modify-write, and the one thing a
306
- # refusal must never do is leave the caller unsure whether the write happened.
307
- existing = _files(runtime, agent_id)
308
- if path not in existing and len(existing) >= MAX_FILES:
309
- raise ValueError(f"this agent already has {MAX_FILES} harness files")
310
- appended = {}
311
-
312
- def _set(cur):
313
- cur = dict(cur or {})
314
- files = dict(cur.get(agent_id) or {}) if isinstance(cur.get(agent_id), dict) else {}
315
- record = dict(files[path]) if isinstance(files.get(path), dict) else _blank(path, author, kind)
316
- appended.clear()
317
- appended.update(_append(record, body, author, kind, restored_from))
318
- files[path] = record
319
- cur[agent_id] = files
320
- return cur
321
-
322
- runtime.update(HARNESS_KEY, _set, flush="sync")
323
- return appended
324
-
325
-
326
- # ── the routes ────────────────────────────────────────────────────────────────────────────────
327
- @router.get("/agents/{agent_id}/harness")
328
- def get_harness(agent_id: str, path: str = "", version: int = 0,
329
- session: Session = Depends(require_session)):
330
- """The list, one file, or one older body β€” decided by the query string (C4).
331
-
332
- ⚠ ADMIN-GATED LIKE EVERY OTHER AGENT DOOR (`routes_slack`), and for a stronger reason than
333
- consistency: a harness file is what the agent is INSTRUCTED to do, so writing one is closer to
334
- editing a permission than to editing a document.
335
- """
336
- agent_id = str(agent_id or "")
337
- agent_wall(session, agent_id)
338
- files = _files(session.runtime, agent_id)
339
- if not path:
340
- rows = [_row(rec) for _p, rec in sorted(files.items()) if isinstance(rec, dict)]
341
- return {"agent": agent_id, "files": rows, "limits": _limits()}
342
-
343
- wanted = _clean_path(path)
344
- record = files.get(wanted)
345
- if not isinstance(record, dict):
346
- raise err(404, "no_file", f"this agent has no harness file at {wanted}")
347
- versions = record.get("versions") if isinstance(record.get("versions"), list) else []
348
- if version:
349
- for entry in versions:
350
- if isinstance(entry, dict) and int(entry.get("version") or 0) == int(version):
351
- return {"agent": agent_id, **_row(record), "body": entry.get("body") or "",
352
- "atVersion": int(version), "history": _version_rows(record),
353
- "limits": _limits()}
354
- # β›” A TRIMMED VERSION IS A NAMED REFUSAL, NOT A 404 SHAPED LIKE A TYPO. The client asked
355
- # for something that existed and no longer does, and telling it apart from a bad number is
356
- # the difference between "roll back to v3" failing loudly and failing as if v3 never was.
357
- trimmed = int(record.get("trimmed") or 0)
358
- if trimmed and int(version) <= trimmed:
359
- raise err(410, "version_trimmed",
360
- f"version {int(version)} is older than the {MAX_VERSIONS} versions kept "
361
- f"for this file, and its body is gone")
362
- raise err(404, "no_version", f"this file has no version {int(version)}")
363
- head = _head(record) or {}
364
- return {"agent": agent_id, **_row(record), "body": head.get("body") or "",
365
- "atVersion": int(head.get("version") or 0), "history": _version_rows(record),
366
- "limits": _limits()}
367
-
368
-
369
- @router.put("/agents/{agent_id}/harness")
370
- def put_harness(agent_id: str, body: dict = Body(default=None),
371
- session: Session = Depends(require_session)):
372
- """Write a NEW version of one harness file. `{path, body, authorKind?, restoredFrom?}` (C4).
373
-
374
- β›” THERE IS NO OVERWRITE HERE AND THERE IS NO EDIT-IN-PLACE. R8's "every version kept" is not a
375
- UI affordance; it is this function refusing to have a code path that replaces a body. A
376
- roll-back arrives as an ordinary write carrying `restoredFrom`, so the history records that
377
- somebody went back rather than pretending the intervening versions never happened.
378
- """
379
- agent_id = str(agent_id or "")
380
- agent_wall(session, agent_id)
381
- body = body if isinstance(body, dict) else {}
382
- path = _clean_path(body.get("path"))
383
- text = body.get("body")
384
- if not isinstance(text, str):
385
- raise err(400, "no_body", "a harness file needs a body, even an empty one")
386
- if len(text.encode("utf-8")) > MAX_BODY_BYTES:
387
- raise err(413, "body_too_long",
388
- f"a harness file is at most {MAX_BODY_BYTES // 1024} KB; this one is larger")
389
-
390
- # ⚠ THE CALLER DECLARES THE PROVENANCE AND THE SERVER STAMPS THE IDENTITY. `authorKind` is a
391
- # claim about who WROTE the text (the person, or the agent in the chat panel); `author` is the
392
- # session and is never read off the request. Nothing is bought by forging the first β€” both
393
- # principals may write (R8) β€” and everything would be bought by forging the second.
394
- kind = str(body.get("authorKind") or "user").strip().lower()
395
- if kind not in AUTHOR_KINDS:
396
- raise err(400, "bad_author_kind", "authorKind is either user or agent")
397
- restored = body.get("restoredFrom")
398
- try:
399
- restored = int(restored) if restored else None
400
- except (TypeError, ValueError):
401
- raise err(400, "bad_version", "restoredFrom must be a version number")
402
-
403
- files = _files(session.runtime, agent_id)
404
- if path not in files and len(files) >= MAX_FILES:
405
- raise err(409, "too_many_files",
406
- f"this agent already has {MAX_FILES} harness files; delete one to add another")
407
- try:
408
- record_version(session.runtime, agent_id, path, text,
409
- author=session.uname, author_kind=kind, restored_from=restored)
410
- except ValueError as exc:
411
- raise err(400, "refused", str(exc))
412
-
413
- fresh = _files(session.runtime, agent_id).get(path)
414
- if not isinstance(fresh, dict) or not _head(fresh):
415
- # The store took the write and did not record it. A 200 here would tell an administrator
416
- # their skill file was saved when it was not β€” the shape `routes_slack` refuses too.
417
- raise err(503, "store_unavailable", "the harness file was NOT saved")
418
- head = _head(fresh)
419
- return {"agent": agent_id, **_row(fresh), "body": head.get("body") or "",
420
- "atVersion": int(head.get("version") or 0), "history": _version_rows(fresh),
421
- "limits": _limits()}
422
-
423
-
424
- @router.delete("/agents/{agent_id}/harness")
425
- def delete_harness(agent_id: str, path: str = "", session: Session = Depends(require_session)):
426
- """Drop one harness file AND its history.
427
-
428
- ⚠ THIS IS NOT THE THING R8 FORBIDS. R8 forbids a ROLL-BACK implemented as a delete β€” losing
429
- versions as a side effect of an edit. Deleting a file is a person deciding the file should not
430
- exist, which is a different act with a different button, and a store with no way to remove a
431
- file is one where a typo'd path is permanent.
432
- """
433
- agent_id = str(agent_id or "")
434
- agent_wall(session, agent_id)
435
- wanted = _clean_path(path)
436
- if wanted not in _files(session.runtime, agent_id):
437
- raise err(404, "no_file", f"this agent has no harness file at {wanted}")
438
-
439
- def _set(cur):
440
- cur = dict(cur or {})
441
- files = dict(cur.get(agent_id) or {}) if isinstance(cur.get(agent_id), dict) else {}
442
- files.pop(wanted, None)
443
- # An agent with no harness files leaves NO key behind. An empty dict per agent id is how a
444
- # bucket accumulates a row for every agent anybody ever opened the tab on.
445
- if files:
446
- cur[agent_id] = files
447
- else:
448
- cur.pop(agent_id, None)
449
- return cur
450
-
451
- session.runtime.update(HARNESS_KEY, _set, flush="sync")
452
- return {"agent": agent_id, "deleted": wanted,
453
- "files": [_row(rec) for _p, rec in sorted(_files(session.runtime, agent_id).items())
454
- if isinstance(rec, dict)],
455
- "limits": _limits()}
 
1
+ """routes_agent_harness.py β€” CONTRACT C4: the agent's HARNESS, kept as versioned files.
2
+
3
+ Owner item 3, verbatim (2026-08-18): *"This new module under agent is supposed to host any file
4
+ pertaining to the agent skills, router etc. So we build a user can build a custom harness for each
5
+ agent through the chat interface."*
6
+
7
+ GET /api/v1/agents/{id}/harness the file LIST (no bodies)
8
+ GET /api/v1/agents/{id}/harness?path=… one file: current body + its versions
9
+ GET /api/v1/agents/{id}/harness?path=…&version=N one older body, verbatim
10
+ PUT /api/v1/agents/{id}/harness write a NEW version of one file
11
+ DELETE /api/v1/agents/{id}/harness?path=… drop a file and its history
12
+
13
+ ⭐⭐ **R8 IS THE WHOLE SHAPE: BOTH PRINCIPALS WRITE, AND NOTHING IS EVER OVERWRITTEN.** A write
14
+ appends a version; a roll-back is a write of an older body (`restoredFrom`), never a delete. So the
15
+ history is a record of what happened rather than of what somebody last wanted it to look like.
16
+
17
+ β›” **R8 IS DELIBERATELY NOT R9.** An agent-authored ACTION's configuration is agent-only (item 8,
18
+ `routes_automation`); a harness file is not. Do not copy this file's posture over there or that
19
+ one's over here β€” the two rulings differ on purpose and they sit one screen apart in the product.
20
+
21
+ ⚠ **`author` IS THE SESSION, `authorKind` IS THE PROVENANCE, AND THEY ARE DIFFERENT FACTS.** Every
22
+ write through this router is made BY a signed-in administrator, so `author` is stamped from the
23
+ session and can never be supplied by the caller. `authorKind` says whether the BODY was drafted by
24
+ a person or by the agent in the chat β€” a claim the client is entitled to make, because both are
25
+ permitted (R8) and so nothing is bought by forging it. `record_version()` below is the server-side
26
+ door the automation engine uses when the agent writes with no session at all; that one stamps the
27
+ agent's own id as the author, which is the only case where `author` is not a username.
28
+
29
+ β›” **THE VERSION LIST IS CAPPED AND THE CAP IS REPORTED, NEVER SILENT.** `MAX_VERSIONS` versions of
30
+ one file are kept; past that the OLDEST are dropped and the count of what was dropped rides in
31
+ `trimmed` on every payload that mentions the file, so a reader can see that the history is partial
32
+ rather than infer that the file was only ever saved twice. This is the tenant document, which is
33
+ already 28.6 MB on tenant #0 and is deep-copied on every read: an unbounded per-agent history is
34
+ a store-sized leak with a UI in front of it.
35
+ """
36
+ from datetime import datetime, timezone
37
+
38
+ from fastapi import APIRouter, Body, Depends
39
+
40
+ from deps import Session, err, require_session
41
+
42
+ router = APIRouter(prefix="/api/v1")
43
+
44
+ #: The tenant's harness files: `{agent_id: {path: file_record}}`. A per-tenant bucket, so it rides
45
+ #: `runtime.store_key`'s prefix and never lands in tenant #0's namespace β€” the same rule
46
+ #: `routes_slack.AGENTS_KEY` follows for the agent records these hang off.
47
+ HARNESS_KEY = "agent_harness"
48
+
49
+ #: One body. Generous for a skill or a router file and far under the point where a single write
50
+ #: would move the tenant document measurably. A larger body is a REFUSAL at the door, not a
51
+ #: truncation: a silently truncated skill file is a harness that does not do what its text says.
52
+ MAX_BODY_BYTES = 128 * 1024
53
+
54
+ #: Files per agent. A refusal, not a trim β€” creating the 65th file is a different act from saving
55
+ #: the 101st version of one, and only the second can be a routine consequence of ordinary editing.
56
+ MAX_FILES = 64
57
+
58
+ #: Versions kept per file. Past this the oldest go and `trimmed` counts them (see the header).
59
+ MAX_VERSIONS = 100
60
+
61
+ MAX_PATH = 200
62
+
63
+ #: What a path may contain. β›” THIS IS NOT A FILESYSTEM PATH AND NOTHING HERE EVER TOUCHES A DISK β€”
64
+ #: the "files" are keys in a store bucket. The character rule exists so the key is displayable, is
65
+ #: safe to put in a URL, and cannot carry a traversal sequence that would look meaningful to a
66
+ #: future reader who assumes it IS a filesystem path. Fail-closed on the character set, not on a
67
+ #: list of forbidden sequences: an allow-list cannot be walked around by a spelling.
68
+ _PATH_OK = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
69
+
70
+ AUTHOR_KINDS = ("user", "agent")
71
+
72
+
73
+ def _now():
74
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
75
+
76
+
77
+ def _all(rt):
78
+ """`{agent_id: {path: record}}` for one tenant. `{}` on any failure β€” an unreadable bucket must
79
+ degrade to "this agent has no harness files", never to a 500 on the pane that lists them."""
80
+ try:
81
+ found = rt.get(HARNESS_KEY) or {}
82
+ except Exception: # noqa: BLE001
83
+ return {}
84
+ return found if isinstance(found, dict) else {}
85
+
86
+
87
+ def _files(rt, agent_id):
88
+ found = _all(rt).get(str(agent_id))
89
+ return found if isinstance(found, dict) else {}
90
+
91
+
92
+ #: What kind of principal an id names. `None` = this tenant has no agent with that id.
93
+ #:
94
+ #: β›”β›” THERE ARE TWO AGENT REGISTRIES IN THIS PRODUCT AND THE FIRST DRAFT OF THIS FILE KNEW ONLY
95
+ #: ONE (ASK D-5, 2026-08-18). `routes_slack._agents` is the per-Slack-channel permission wall under
96
+ #: Manage users. The **Agents module** the owner's item 3 is about is the AUTOMATION surface β€”
97
+ #: `Shell.tsx` mounts `<Lazily surface="Agents"><AutomationSurface /></Lazily>`, and
98
+ #: `AutomationDetail`'s `panelTabs` is literally the `Properties | Run history` strip R7 adds
99
+ #: "Harness" to. Keyed on the Slack bucket alone, every `GET/PUT /agents/{id}/harness` from that
100
+ #: tab would have answered **404 no_agent**: whole, gate-green and dead on arrival
101
+ #: [[reachable-is-not-the-same-as-built]]. Verified independently before acting, not taken on
102
+ #: report: `surface="Agents"` mounts `AutomationSurface`, and `ManageAgentPane` contains ZERO
103
+ #: occurrences of Canvas, Properties, Run history or panelTabs.
104
+ #:
105
+ #: ⭐ ONE STORE, BOTH PRINCIPALS β€” never a second bucket keyed by surface. The owner's "agent
106
+ #: skills, router" files in two places is the parallel code path item 13 exists to refuse.
107
+ AGENT_SLACK = "slack"
108
+ AGENT_AUTOMATION = "automation"
109
+
110
+
111
+ def agent_kind(rt, agent_id):
112
+ """Which registry holds this id β€” `AGENT_SLACK`, `AGENT_AUTOMATION`, or `None`.
113
+
114
+ ⚠ A SYNTHETIC ROW IS NOT AN AGENT FOR THIS PURPOSE. `field:` and `system:` ids are DERIVED at
115
+ read time from a column definition or a connector schedule; they have no stored home, so a
116
+ harness file hung off one is an orphan the moment the column changes. `patch_automation`
117
+ already refuses those ids for the neighbouring reason, and this refuses them by simply not
118
+ finding them β€” `all_definitions` holds stored automations only.
119
+ """
120
+ import routes_slack
121
+ aid = str(agent_id or "")
122
+ if isinstance(routes_slack._agents(rt).get(aid), dict):
123
+ return AGENT_SLACK
124
+ import automation_engine as engine
125
+ try:
126
+ known = engine.all_definitions(rt) or {}
127
+ except Exception: # noqa: BLE001
128
+ return None
129
+ return AGENT_AUTOMATION if aid in known else None
130
+
131
+
132
+ def _known_agent(rt, agent_id):
133
+ """Does this tenant have an agent with this id, in EITHER registry?"""
134
+ return agent_kind(rt, agent_id) is not None
135
+
136
+
137
+ def agent_wall(session, agent_id):
138
+ """404 for an unknown id, else apply the wall THAT PRINCIPAL'S OWN SURFACE applies.
139
+
140
+ β›”β›” ONE DOOR, TWO WALLS, AND THAT IS NOT A SECOND CODE PATH β€” it is the refusal to invent a
141
+ THIRD wall. A Slack channel agent is administered under Manage users and every `/agents/*` door
142
+ in `routes_slack` is `admin_gate`; an automation lives in the Agents module and every door in
143
+ `routes_automation` is `module_gate("automation")`. A harness file is configuration OF the
144
+ agent it hangs off, so it is reached by whoever may already configure that agent. Picking one
145
+ of the two walls for both would either lock the Agents module's own users out of a tab the
146
+ owner asked for, or hand the Slack permission wall to anyone with an automation grant.
147
+ """
148
+ kind = agent_kind(session.runtime, agent_id)
149
+ if kind is None:
150
+ raise err(404, "no_agent", "there is no agent with that id in this workspace")
151
+ if kind == AGENT_SLACK:
152
+ import core.perms as perms
153
+ if not perms.is_admin(session.user):
154
+ raise err(403, "forbidden", "administrators only")
155
+ else:
156
+ session.require("automation")
157
+ return kind
158
+
159
+
160
+ def normalize_path(raw):
161
+ """THE path rule, in ONE place. Returns the cleaned path, or `None` if it is not acceptable.
162
+
163
+ β›”β›” ONE RULE, TWO DOORS, AND THAT IS WHY THIS IS A FUNCTION RATHER THAN TWO IF-BLOCKS. There
164
+ are two ways into this store β€” the HTTP route (which must answer 400) and `record_version()`
165
+ (which must raise `ValueError`, having no response to put a status into). Written twice, the
166
+ two copies are [[one-question-two-normalizers]] waiting to happen: the first weakening of one
167
+ copy is invisible because the other still refuses, so nothing goes red and the wall is now
168
+ half there. Written once, a change to the rule is felt at both doors and by the gate.
169
+ """
170
+ path = str(raw or "").strip().strip("/")
171
+ if not path or len(path) > MAX_PATH:
172
+ return None
173
+ if set(path) - _PATH_OK or ".." in path or "//" in path:
174
+ return None
175
+ return path
176
+
177
+
178
+ def _clean_path(raw):
179
+ """`normalize_path` at the HTTP door, where a refusal is a 400 with a reason."""
180
+ path = normalize_path(raw)
181
+ if path is None:
182
+ if not str(raw or "").strip().strip("/"):
183
+ raise err(400, "no_path", "a harness file needs a path, for example skills/router.md")
184
+ if len(str(raw)) > MAX_PATH:
185
+ raise err(400, "path_too_long", f"a harness path is at most {MAX_PATH} characters")
186
+ raise err(400, "bad_path",
187
+ "a harness path may use letters, digits, dot, dash, underscore and / only")
188
+ return path
189
+
190
+
191
+ def _blank(path, author, author_kind):
192
+ return {"path": path, "versions": [], "trimmed": 0,
193
+ "created": _now(), "createdBy": author, "createdKind": author_kind}
194
+
195
+
196
+ def _append(record, body, author, author_kind, restored_from=None):
197
+ """Append ONE version to a file record, in place, and report what was trimmed.
198
+
199
+ The version NUMBER is monotonic and survives trimming β€” it counts writes, not stored entries.
200
+ A version list whose numbers restart at 1 after a trim would make two different bodies share a
201
+ name, and `restoredFrom` would then point at whichever one happened to be in the window.
202
+ """
203
+ # ⚠ A NEW LIST, NEVER `versions.append(...)` ON THE STORED ONE. `update()` hands the callback
204
+ # the live document and may run it more than once; appending in place would then stack two
205
+ # copies of the same version into the history on a retry.
206
+ prior = record.get("versions") if isinstance(record.get("versions"), list) else []
207
+ last = max((int(v.get("version") or 0) for v in prior if isinstance(v, dict)), default=0)
208
+ entry = {"version": last + 1, "body": body, "author": author, "authorKind": author_kind,
209
+ "created": _now(), "bytes": len(body.encode("utf-8"))}
210
+ if restored_from:
211
+ entry["restoredFrom"] = int(restored_from)
212
+ versions = [*prior, entry]
213
+ dropped = max(0, len(versions) - MAX_VERSIONS)
214
+ if dropped:
215
+ versions = versions[dropped:]
216
+ record["versions"] = versions
217
+ record["trimmed"] = int(record.get("trimmed") or 0) + dropped
218
+ return entry
219
+
220
+
221
+ def _head(record):
222
+ """The newest version of a file record, or `None` for a record with no versions at all."""
223
+ versions = record.get("versions") if isinstance(record.get("versions"), list) else []
224
+ return versions[-1] if versions else None
225
+
226
+
227
+ def _row(record):
228
+ """One file, as the LIST door reports it: everything except the bodies.
229
+
230
+ ⚠ NO BODY, AND THAT IS THE POINT. A list door that carried every version of every file would
231
+ ship the whole harness on every pane render; the Harness tab lists first and opens one file
232
+ second, which is exactly the shape this answers.
233
+ """
234
+ head = _head(record) or {}
235
+ versions = record.get("versions") if isinstance(record.get("versions"), list) else []
236
+ return {"path": record.get("path") or "",
237
+ "version": int(head.get("version") or 0),
238
+ "bytes": int(head.get("bytes") or 0),
239
+ "author": head.get("author") or record.get("createdBy") or "",
240
+ "authorKind": head.get("authorKind") or record.get("createdKind") or "user",
241
+ "updated": head.get("created") or record.get("created") or "",
242
+ "created": record.get("created") or "",
243
+ "versions": len(versions),
244
+ "trimmed": int(record.get("trimmed") or 0)}
245
+
246
+
247
+ def _version_rows(record):
248
+ """The history of one file, newest first, WITHOUT the bodies.
249
+
250
+ A body per version is what makes a diff possible, and it is also what makes this payload big:
251
+ 100 versions of a 128 KB file is 12 MB. The client asks for the two bodies it is diffing
252
+ (`?path=…&version=N`), which is two round trips for a diff and none for a history list.
253
+ """
254
+ versions = record.get("versions") if isinstance(record.get("versions"), list) else []
255
+ out = []
256
+ for entry in reversed(versions):
257
+ if not isinstance(entry, dict):
258
+ continue
259
+ row = {"version": int(entry.get("version") or 0),
260
+ "author": entry.get("author") or "",
261
+ "authorKind": entry.get("authorKind") or "user",
262
+ "created": entry.get("created") or "",
263
+ "bytes": int(entry.get("bytes") or 0)}
264
+ if entry.get("restoredFrom"):
265
+ row["restoredFrom"] = int(entry["restoredFrom"])
266
+ out.append(row)
267
+ return out
268
+
269
+
270
+ def _limits():
271
+ """The caps, IN the payload, so a client can say "this file is full" before a write fails.
272
+
273
+ ⚠ A limit the client cannot see is a limit the user meets as an error. `trimmed` reports the
274
+ one cap that acts without refusing; these report the three that refuse.
275
+ """
276
+ return {"maxBodyBytes": MAX_BODY_BYTES, "maxFiles": MAX_FILES,
277
+ "maxVersions": MAX_VERSIONS, "maxPath": MAX_PATH}
278
+
279
+
280
+ # ── the write door the SERVER uses (no session) ────────────────────────────────────────────────
281
+ def record_version(runtime, agent_id, path, body, author="", author_kind="agent",
282
+ restored_from=None):
283
+ """Write one version from INSIDE the server β€” the agent's own half of R8.
284
+
285
+ ⭐ THIS IS THE FUNCTION, NOT THE ROUTE, THAT MAKES "editable by BOTH the user and the agent"
286
+ true. An agent acting inside an automation run holds no session and no cookie; if its only way
287
+ to write were the HTTP door it would have to borrow a person's identity, and the authorship
288
+ column would then be a record of who was logged in rather than of what wrote the file.
289
+
290
+ Returns the appended entry. Raises nothing the caller cannot handle: an unknown agent is a
291
+ `ValueError`, because a server-side caller has no HTTP response to put a 404 into.
292
+ """
293
+ agent_id = str(agent_id or "")
294
+ if not _known_agent(runtime, agent_id):
295
+ raise ValueError(f"no agent {agent_id!r} in this workspace")
296
+ path = normalize_path(path)
297
+ if path is None:
298
+ raise ValueError("that is not an acceptable harness path")
299
+ body = str(body or "")
300
+ if len(body.encode("utf-8")) > MAX_BODY_BYTES:
301
+ raise ValueError("harness body is over the size limit")
302
+ kind = author_kind if author_kind in AUTHOR_KINDS else "agent"
303
+ author = str(author or agent_id)
304
+ # β›” THE FILE-COUNT CEILING IS CHECKED HERE, NOT INSIDE `_set`. An exception raised inside a
305
+ # store `update` callback propagates out of a half-run read-modify-write, and the one thing a
306
+ # refusal must never do is leave the caller unsure whether the write happened.
307
+ existing = _files(runtime, agent_id)
308
+ if path not in existing and len(existing) >= MAX_FILES:
309
+ raise ValueError(f"this agent already has {MAX_FILES} harness files")
310
+ appended = {}
311
+
312
+ def _set(cur):
313
+ cur = dict(cur or {})
314
+ files = dict(cur.get(agent_id) or {}) if isinstance(cur.get(agent_id), dict) else {}
315
+ record = dict(files[path]) if isinstance(files.get(path), dict) else _blank(path, author, kind)
316
+ appended.clear()
317
+ appended.update(_append(record, body, author, kind, restored_from))
318
+ files[path] = record
319
+ cur[agent_id] = files
320
+ return cur
321
+
322
+ runtime.update(HARNESS_KEY, _set, flush="sync")
323
+ return appended
324
+
325
+
326
+ # ── the routes ────────────────────────────────────────────────────────────────────────────────
327
+ @router.get("/agents/{agent_id}/harness")
328
+ def get_harness(agent_id: str, path: str = "", version: int = 0,
329
+ session: Session = Depends(require_session)):
330
+ """The list, one file, or one older body β€” decided by the query string (C4).
331
+
332
+ ⚠ ADMIN-GATED LIKE EVERY OTHER AGENT DOOR (`routes_slack`), and for a stronger reason than
333
+ consistency: a harness file is what the agent is INSTRUCTED to do, so writing one is closer to
334
+ editing a permission than to editing a document.
335
+ """
336
+ agent_id = str(agent_id or "")
337
+ agent_wall(session, agent_id)
338
+ files = _files(session.runtime, agent_id)
339
+ if not path:
340
+ rows = [_row(rec) for _p, rec in sorted(files.items()) if isinstance(rec, dict)]
341
+ return {"agent": agent_id, "files": rows, "limits": _limits()}
342
+
343
+ wanted = _clean_path(path)
344
+ record = files.get(wanted)
345
+ if not isinstance(record, dict):
346
+ raise err(404, "no_file", f"this agent has no harness file at {wanted}")
347
+ versions = record.get("versions") if isinstance(record.get("versions"), list) else []
348
+ if version:
349
+ for entry in versions:
350
+ if isinstance(entry, dict) and int(entry.get("version") or 0) == int(version):
351
+ return {"agent": agent_id, **_row(record), "body": entry.get("body") or "",
352
+ "atVersion": int(version), "history": _version_rows(record),
353
+ "limits": _limits()}
354
+ # β›” A TRIMMED VERSION IS A NAMED REFUSAL, NOT A 404 SHAPED LIKE A TYPO. The client asked
355
+ # for something that existed and no longer does, and telling it apart from a bad number is
356
+ # the difference between "roll back to v3" failing loudly and failing as if v3 never was.
357
+ trimmed = int(record.get("trimmed") or 0)
358
+ if trimmed and int(version) <= trimmed:
359
+ raise err(410, "version_trimmed",
360
+ f"version {int(version)} is older than the {MAX_VERSIONS} versions kept "
361
+ f"for this file, and its body is gone")
362
+ raise err(404, "no_version", f"this file has no version {int(version)}")
363
+ head = _head(record) or {}
364
+ return {"agent": agent_id, **_row(record), "body": head.get("body") or "",
365
+ "atVersion": int(head.get("version") or 0), "history": _version_rows(record),
366
+ "limits": _limits()}
367
+
368
+
369
+ @router.put("/agents/{agent_id}/harness")
370
+ def put_harness(agent_id: str, body: dict = Body(default=None),
371
+ session: Session = Depends(require_session)):
372
+ """Write a NEW version of one harness file. `{path, body, authorKind?, restoredFrom?}` (C4).
373
+
374
+ β›” THERE IS NO OVERWRITE HERE AND THERE IS NO EDIT-IN-PLACE. R8's "every version kept" is not a
375
+ UI affordance; it is this function refusing to have a code path that replaces a body. A
376
+ roll-back arrives as an ordinary write carrying `restoredFrom`, so the history records that
377
+ somebody went back rather than pretending the intervening versions never happened.
378
+ """
379
+ agent_id = str(agent_id or "")
380
+ agent_wall(session, agent_id)
381
+ body = body if isinstance(body, dict) else {}
382
+ path = _clean_path(body.get("path"))
383
+ text = body.get("body")
384
+ if not isinstance(text, str):
385
+ raise err(400, "no_body", "a harness file needs a body, even an empty one")
386
+ if len(text.encode("utf-8")) > MAX_BODY_BYTES:
387
+ raise err(413, "body_too_long",
388
+ f"a harness file is at most {MAX_BODY_BYTES // 1024} KB; this one is larger")
389
+
390
+ # ⚠ THE CALLER DECLARES THE PROVENANCE AND THE SERVER STAMPS THE IDENTITY. `authorKind` is a
391
+ # claim about who WROTE the text (the person, or the agent in the chat panel); `author` is the
392
+ # session and is never read off the request. Nothing is bought by forging the first β€” both
393
+ # principals may write (R8) β€” and everything would be bought by forging the second.
394
+ kind = str(body.get("authorKind") or "user").strip().lower()
395
+ if kind not in AUTHOR_KINDS:
396
+ raise err(400, "bad_author_kind", "authorKind is either user or agent")
397
+ restored = body.get("restoredFrom")
398
+ try:
399
+ restored = int(restored) if restored else None
400
+ except (TypeError, ValueError):
401
+ raise err(400, "bad_version", "restoredFrom must be a version number")
402
+
403
+ files = _files(session.runtime, agent_id)
404
+ if path not in files and len(files) >= MAX_FILES:
405
+ raise err(409, "too_many_files",
406
+ f"this agent already has {MAX_FILES} harness files; delete one to add another")
407
+ try:
408
+ record_version(session.runtime, agent_id, path, text,
409
+ author=session.uname, author_kind=kind, restored_from=restored)
410
+ except ValueError as exc:
411
+ raise err(400, "refused", str(exc))
412
+
413
+ fresh = _files(session.runtime, agent_id).get(path)
414
+ if not isinstance(fresh, dict) or not _head(fresh):
415
+ # The store took the write and did not record it. A 200 here would tell an administrator
416
+ # their skill file was saved when it was not β€” the shape `routes_slack` refuses too.
417
+ raise err(503, "store_unavailable", "the harness file was NOT saved")
418
+ head = _head(fresh)
419
+ return {"agent": agent_id, **_row(fresh), "body": head.get("body") or "",
420
+ "atVersion": int(head.get("version") or 0), "history": _version_rows(fresh),
421
+ "limits": _limits()}
422
+
423
+
424
+ @router.delete("/agents/{agent_id}/harness")
425
+ def delete_harness(agent_id: str, path: str = "", session: Session = Depends(require_session)):
426
+ """Drop one harness file AND its history.
427
+
428
+ ⚠ THIS IS NOT THE THING R8 FORBIDS. R8 forbids a ROLL-BACK implemented as a delete β€” losing
429
+ versions as a side effect of an edit. Deleting a file is a person deciding the file should not
430
+ exist, which is a different act with a different button, and a store with no way to remove a
431
+ file is one where a typo'd path is permanent.
432
+ """
433
+ agent_id = str(agent_id or "")
434
+ agent_wall(session, agent_id)
435
+ wanted = _clean_path(path)
436
+ if wanted not in _files(session.runtime, agent_id):
437
+ raise err(404, "no_file", f"this agent has no harness file at {wanted}")
438
+
439
+ def _set(cur):
440
+ cur = dict(cur or {})
441
+ files = dict(cur.get(agent_id) or {}) if isinstance(cur.get(agent_id), dict) else {}
442
+ files.pop(wanted, None)
443
+ # An agent with no harness files leaves NO key behind. An empty dict per agent id is how a
444
+ # bucket accumulates a row for every agent anybody ever opened the tab on.
445
+ if files:
446
+ cur[agent_id] = files
447
+ else:
448
+ cur.pop(agent_id, None)
449
+ return cur
450
+
451
+ session.runtime.update(HARNESS_KEY, _set, flush="sync")
452
+ return {"agent": agent_id, "deleted": wanted,
453
+ "files": [_row(rec) for _p, rec in sorted(_files(session.runtime, agent_id).items())
454
+ if isinstance(rec, dict)],
455
+ "limits": _limits()}
api/routes_alerts.py CHANGED
@@ -1,668 +1,668 @@
1
- """routes_alerts.py β€” the Alerts module (wave 20, owner item 25, contract C-ALERT).
2
-
3
- GET /api/v1/alerts -> {alerts:[...]}
4
- POST /api/v1/alerts <- {viewId, topic, label?}
5
- DELETE /api/v1/alerts/{alert_id}
6
- POST /api/v1/alerts/{alert_id}/run -> evaluate now (the pane's manual refresh)
7
- GET /api/v1/notifications -> {unread, items:[...]}
8
- POST /api/v1/notifications/read <- {ids:[...]|null, read?:bool}
9
-
10
- The semantics β€” an alert is a view plus a remembered matched set, a notification is a NEW
11
- ENTRANT, and the first evaluation seeds silently β€” live in `core.alerts` with the reasoning.
12
- This file owns the two things a route must: WHO may do it, and HOW the view gets evaluated.
13
-
14
- ⭐ **THE EVALUATION RUNS AS THE ALERT'S OWNER, NOT AS THE CALLER.** `_run_alert` builds the pool
15
- for `rec['owner']`, never for whoever tripped the write hook. Any other choice leaks: a
16
- full-access admin editing a cell would otherwise evaluate a BU-scoped user's alert over the whole
17
- book, and the notification would name customers that user may not see β€” a permission leak wearing
18
- a notification's clothes. The owner's own scope is the only correct basis for their alert.
19
-
20
- ⚠ **AN ALERT IS NOT A SECOND READ PATH.** It resolves rows through the same
21
- `routes_customers.grid_assembly` / `routes_tables.ut_assembly` the grid uses, so a row that an
22
- alert can see is by construction a row its owner could open. Re-implementing the filter here
23
- would be a second definition of "matches", and those two would drift.
24
- """
25
- import re
26
-
27
- from fastapi import APIRouter, Body, Depends
28
-
29
- import core.alerts as alerts
30
- from deps import Session, err, require_session
31
-
32
- router = APIRouter(prefix="/api/v1")
33
-
34
- #: The alert-bearing surfaces. `ut_` tables are admitted by prefix, like everywhere else.
35
- _TOPICS = ("customer", "product")
36
-
37
- # ── ⭐⭐ WAVE 32 Β· T20 Β· CONTRACT C3 β€” THE INBOX SHAPE, DERIVED ON READ ────────────────────────
38
- #
39
- # `GET /notifications` gains `subject`, `kind` and `target` per item (`read` was always there).
40
- #
41
- # β›” DERIVED, NEVER STORED, AND THAT IS THE WHOLE OF WHY THIS WAVE EXISTS. Stamping the three
42
- # keys onto the record at write time would give them to notifications minted AFTER the deploy and
43
- # to nothing else β€” every notification already sitting in every tenant's inbox would open nothing,
44
- # and the feature would be correct in the source and absent from the product
45
- # ([[a-migration-that-runs-on-the-next-write]], D-201). A read-side derivation reaches a
46
- # notification queued last month. It also keeps the store shape out of `core/alerts.py`, which is
47
- # another lane's file this wave β€” but that is the convenience, not the reason.
48
- #
49
- # ⚠ TWO PRODUCERS WRITE TWO SHAPES into one inbox, and the vocabulary below is what tells them
50
- # apart. `_queue` (a record ENTERED a watched view) sets `topic`+`viewId`. `notify()` sets
51
- # `topic='automation'` and puts the producer's key in `alertId`, leaving `viewId` empty. Deciding
52
- # here means the client branches on ONE field instead of re-deriving the same split.
53
- #
54
- # β›” **D-101 IS CLOSED HERE, BY SUBTRACTION.** There was a THIRD shape β€” `kind='automation_review'`
55
- # + `autoId`, a card arriving at a review stage β€” and its producer `notify_review` was deleted by
56
- # W27/R3 with the review lanes. `automation_engine.py`'s own tombstone (search `notify_review`)
57
- # records the 2026-08-12 sweep: **no `.py` file anywhere produces one**, while the client branch,
58
- # its route and three gate legs stayed fully alive. D-101's exit condition is *"the client review
59
- # branch is deleted in the same change as any remaining residue, OR `notify_review` gains its real
60
- # caller"* β€” the residue is zero, so the branch goes. It is not carried into the Inbox: a stored
61
- # review notification (if any survives in a tenant from the wave-23 era) derives as an ordinary
62
- # `alert` with no target, i.e. an honest unclickable row, which is correct β€” the board it pointed
63
- # at was deleted two waves ago.
64
-
65
- #: C3's `kind` vocabulary. Plain strings on the wire β€” the client must never union over them
66
- #: (alertsModel's wave-9 law: a client union turns "the server grew a kind" into a dropped row).
67
- NOTIF_KIND_ALERT = "alert"
68
- NOTIF_KIND_AUTOMATION = "automation"
69
- NOTIF_KIND_SHARE = "share"
70
-
71
- #: C3's `target.module` vocabulary, and the automation sub-selection.
72
- TARGET_MODULE_DATABASE = "database"
73
- TARGET_MODULE_AUTOMATION = "automation"
74
- TARGET_TAB_RUNS = "runs"
75
-
76
- #: The topic `notify()` carries for a SHARE (W32-T28 writes it; nothing does yet, and a kind with
77
- #: no producer is a string that reads as a feature β€” the reason this constant is named here and
78
- #: cited from `routes_shares` rather than typed twice).
79
- SHARE_TOPIC = "share"
80
-
81
- #: `core.alerts.notify`'s default topic for a run outcome. Mirrors `inboxModel.AUTOMATION_TOPIC`.
82
- AUTOMATION_TOPIC = "automation"
83
-
84
- _UT_TOPIC = re.compile(r"ut_[A-Za-z0-9_]+\Z")
85
-
86
-
87
- def route_for_topic(topic):
88
- """A grid SCOPE key -> the registry route that renders it, or None.
89
-
90
- β›” THE SAME TABLE AS `alertsModel.routeForTopic`, and the parity is GATED
91
- (`verify_alerts.py`'s vocabulary scan) rather than trusted. The two built-ins are the only
92
- pair that differ β€” the registry names the surface (`customer_data`) while the grid names the
93
- scope (`customer`) β€” so a topic passed through as a route sends every click to a page that
94
- does not exist. `None` for anything else: a target this product cannot resolve must be ABSENT
95
- rather than plausible, because an absent target renders as a row that does not pretend to be
96
- clickable, and a wrong one renders as a click that silently goes nowhere.
97
- """
98
- t = str(topic or "").strip()
99
- if t == "customer":
100
- return "customer_data"
101
- if t == "product":
102
- return "product_data"
103
- if _UT_TOPIC.match(t):
104
- return t
105
- return None
106
-
107
-
108
- def _refusal_code(exc):
109
- """The `error.code` an `HTTPException` raised by `deps.err()` carries, or `""`.
110
-
111
- ⭐ W32-T22. Four refusals travel up the assembly chain β€” `unknown_table` (404), `forbidden`
112
- (403), `window_required` (409) and `store_not_ready` (503) β€” and each already names its own
113
- cause. Anything that reduces all four to one word is throwing away the only information the
114
- reader could have acted on. Returns `""` for a plain exception, so a caller can tell
115
- "refused, and here is why" apart from "broke, and we do not know why".
116
- """
117
- detail = getattr(exc, "detail", None)
118
- if isinstance(detail, dict):
119
- inner = detail.get("error")
120
- if isinstance(inner, dict):
121
- return str(inner.get("code") or "")
122
- return ""
123
-
124
-
125
- def notification_view(item):
126
- """One STORED notification -> the shape the Inbox renders. PURE, and total.
127
-
128
- Never raises and never drops a row: an item it cannot classify comes back as an `alert` with
129
- no `target`, which the client renders as an unclickable row rather than hiding. An inbox that
130
- silently omits what it does not understand is the one failure a reader cannot detect.
131
- """
132
- if not isinstance(item, dict):
133
- return item
134
- topic = str(item.get("topic") or "").strip()
135
- alert_id = str(item.get("alertId") or "").strip()
136
-
137
- # β›” THE ID TEST IS HALF OF EVERY BRANCH, and it is the load-bearing half. A row whose topic
138
- # says `automation` but whose producer key never arrived (a truncated payload, a server
139
- # mid-deploy) would otherwise be handed a target naming NOTHING β€” a click that appears to work
140
- # and silently does not, which is this repo's most-repeated failure shape. Failing the test
141
- # drops it to the `alert` branch, where `route_for_topic` refuses out loud by answering None.
142
- if topic == AUTOMATION_TOPIC and alert_id:
143
- kind = NOTIF_KIND_AUTOMATION
144
- target = {"module": TARGET_MODULE_AUTOMATION, "id": alert_id, "tab": TARGET_TAB_RUNS}
145
- elif topic == SHARE_TOPIC and alert_id:
146
- # ⭐ W32-T28: the sharer writes `key=<the ROUTE to open>` and, for a shared VIEW,
147
- # `row_id=<the view to select>`.
148
- #
149
- # β›” `key` IS ALREADY A ROUTE, NOT A RAW OBJECT ID, and the first version of this got it
150
- # wrong in a way worth recording: a shared VIEW put the VIEW's id in `alertId`, so the
151
- # target read `{module: "database", id: "view_42"}` β€” an instruction to open a database
152
- # called `view_42`. It looked right in the payload and would have opened nothing. The
153
- # producer resolves the object to its topic and hands over the route; this branch only
154
- # shapes what it is given.
155
- kind = NOTIF_KIND_SHARE
156
- row_id = str(item.get("rowId") or "").strip()
157
- target = {"module": TARGET_MODULE_DATABASE, "id": alert_id,
158
- **({"tab": row_id} if row_id else {})}
159
- else:
160
- kind = NOTIF_KIND_ALERT
161
- route = route_for_topic(topic)
162
- view_id = str(item.get("viewId") or "").strip()
163
- target = None if route is None else (
164
- {"module": TARGET_MODULE_DATABASE, "id": route,
165
- **({"tab": view_id} if view_id else {})})
166
-
167
- # The email split: `subject` is the HEADER (what this is about β€” the alert, the automation,
168
- # the database), `label` stays the BODY (what happened β€” the record that entered, the run
169
- # summary). They were one field, which is why a notification read as a sentence with no
170
- # sender and the pane could not be laid out like mail.
171
- subject = str(item.get("alertLabel") or "").strip() or str(item.get("label") or "").strip()
172
- # ⚠ `kind` is OVERWRITTEN, not merged. There was one stored value (`automation_review`) and it
173
- # is D-101's dead one; leaving it through would give the client two vocabularies for one
174
- # question, which is the defect this wave's item 6 is about in a different file.
175
- # ⭐⭐ W33-T28 (owner: "the Inbox reads like email") β€” THE SENDER, WHICH DID NOT EXIST.
176
- #
177
- # β›” A `verifier` reading the finished wave-32 surface found that the row's sender POSITION was
178
- # occupied by `kindLabel(n.kind)` β€” the literals "Alert" / "Automation" / "Shared with you" β€”
179
- # i.e. a CATEGORY standing where a who belongs, and no sender field anywhere on the wire, in
180
- # the model or in the markup. Mail has a from. This is it.
181
- #
182
- # ⚠ IT IS DERIVED HERE, NOT STORED, FOR EVERY KIND BUT ONE β€” and the exception is the point.
183
- # An alert firing and an automation landing rows have no person behind them; their honest
184
- # sender is the machine that did it, named as the thing the reader recognises. A SHARE has a
185
- # real person, and only the producer knows who: `routes_shares.py` writes it as `actor` and
186
- # this reads it back. β›” It is NOT parsed out of the body prose ("<name> shared this with
187
- # you") β€” a sender recovered by regexing a sentence breaks the first time the sentence is
188
- # reworded, and it would break silently, in the header.
189
- #
190
- # ⚠ FALLS BACK, NEVER BLANK. A share queued BEFORE `actor` existed has none, and a row with an
191
- # empty from column reads as a broken inbox rather than as an old notification.
192
- actor = str(item.get("actor") or "").strip()
193
- if kind == NOTIF_KIND_SHARE:
194
- sender = actor or "A teammate"
195
- elif kind == NOTIF_KIND_AUTOMATION:
196
- # β›” "Agents", not "Automation" (W34-T40, corrected at QA 2026-08-17). The client's
197
- # `senderOf` already falls back to `AGENTS_MODULE_LABEL` β€” but `if (sent) return sent`
198
- # runs FIRST, so this server literal won and every actor-less automation notification
199
- # showed the retired module name in the inbox's From column.
200
- sender = actor or "Agents"
201
- else:
202
- sender = actor or "Alerts"
203
- out = {**item, "read": bool(item.get("read")), "kind": kind,
204
- "subject": subject or "Notification", "sender": sender}
205
- if target is not None:
206
- out["target"] = target
207
- return out
208
-
209
-
210
- def inbox_view(box):
211
- """`core.alerts.inbox()`'s answer, with every item put through {@link notification_view}.
212
-
213
- ⚠ `unread` IS NOT RECOUNTED. It is the ACCOUNT's number and `items` is one page of it; a
214
- recount here would make the badge a function of whatever this page happened to include, which
215
- is the exact defect `alertsModel.parseInbox`'s own header records from the other side.
216
- """
217
- if not isinstance(box, dict):
218
- return box
219
- items = box.get("items")
220
- if not isinstance(items, list):
221
- return box
222
- # ⭐⭐ W33-T28 / D-208 β€” THE SERVER'S CLOCK RIDES WITH THE PAGE, and it is what lets the client
223
- # render a mail-shaped stamp ("09:41" today, "Aug 12" beyond) instead of `2026-08-13 09:41`.
224
- #
225
- # β›” THE CLIENT MUST NOT READ ITS OWN CLOCK, which is D-208's exit condition word for word and
226
- # is why this key exists rather than a `new Date()` in the browser. `at` is sent as UTC WITH
227
- # its offset (D-18) precisely so every reader sees the same instant; deciding "is this today?"
228
- # against a browser clock would re-introduce the drift the offset exists to remove β€” a reader a
229
- # day ahead being told an event happened tomorrow [[date-window-vocabulary]]. Both operands
230
- # now come from the same machine.
231
- # ⚠ Same funnel as the enrichment, so the read door and the mark-read door cannot disagree β€”
232
- # the note two lines up records what happened last time only one of them was enriched.
233
- return {**box, "now": alerts._now_iso(),
234
- "items": [notification_view(n) for n in items]}
235
-
236
-
237
- def _view_by_id(g, view_id):
238
- """One saved view out of an assembly, by id. `None` when there is no such view.
239
-
240
- β›”β›” W33-T29 (owner: *"Alert me about new records"* answering "Something went wrong") β€” THIS
241
- FUNCTION EXISTS BECAUSE TWO CALL SITES BOTH WROTE `(g.get("views") or {}).get(view_id)`, AND
242
- `g["views"] IS A LIST`. `aios_grid.views_from_defs` returns `[{...}]`, `workspace_wire` passes
243
- it straight out and both `ut_assembly` and `grid_assembly` return it unchanged β€” so `.get` on
244
- it raises `AttributeError`, and `views_from_defs` always returns at least one element, so the
245
- `or {}` never fires. **It raised on EVERY call, on every topic, since wave 20.**
246
-
247
- β›” AND THE TWO SITES FAILED DIFFERENTLY, WHICH IS WHY ONLY ONE WAS EVER REPORTED. In
248
- `_require_filtered_view` the raise lands ABOVE the handler's own `try`, so it leaves as a bare
249
- FastAPI 500 and the client's `errorMessage` turns any 5xx into *"Something went wrong on our
250
- side"* β€” the exact sentence the owner reported (D-107's shape, again: an attribute error above
251
- the guard arrives as plain text rather than as our envelope). In `_evaluate` the identical
252
- line is swallowed by `/notifications`' `except Exception: continue`, so **every stored
253
- view-alert was silently dropped from the Inbox** and nobody had anything to report at all.
254
- One expression, one loud symptom and one silent one.
255
-
256
- ⚠ SO IT IS A FUNCTION, NOT TWO FIXED LINES. Two copies of "find the view" is what let one site
257
- be discussed for three waves while its twin went unnoticed [[one-question-two-normalizers]].
258
-
259
- ⚠ It accepts a dict too, and that is not defensive noise: `verify_alerts`' door fixture was
260
- keyed `{id: view}` β€” which is precisely why the gate was green while production raised on
261
- every call. The fixture is moving to the production shape in this same change, and tolerating
262
- both here means a caller that legitimately holds one cannot resurrect the bug.
263
- """
264
- want = str(view_id or "")
265
- if not want:
266
- return None
267
- views = (g or {}).get("views")
268
- if isinstance(views, dict):
269
- found = views.get(want)
270
- return found if isinstance(found, dict) else None
271
- if not isinstance(views, list):
272
- return None
273
- for v in views:
274
- if isinstance(v, dict) and str(v.get("id") or "") == want:
275
- return v
276
- return None
277
-
278
-
279
- def _topic_or_400(raw):
280
- topic = str(raw or "").strip().lower()
281
- if topic.startswith("ut_") or topic in _TOPICS:
282
- return topic
283
- raise err(400, "bad_topic", f"topic must be one of {', '.join(_TOPICS)} or a ut_ table")
284
-
285
-
286
- def _owner_session(session: Session, owner: str):
287
- """A `Session` for the alert's OWNER (see the module note on why the owner, not the caller).
288
-
289
- ⚠ `Session` exposes `uname`/`admin` as PROPERTIES derived from `user`, not as fields β€” so an
290
- owner session is built by swapping the `user` RECORD and letting both derive themselves. An
291
- earlier version passed `uname=`/`admin=` to the constructor, which would have raised on the
292
- first write hook of the wave; the properties are the single definition of who a session is,
293
- and going around them is how a session with an admin flag and a non-admin record exists.
294
-
295
- Returns None when the owner is gone or deactivated β€” their alerts then stop evaluating rather
296
- than evaluating as somebody else, which is the fail-closed direction.
297
- """
298
- import core.users as users
299
-
300
- if str(owner) == str(session.uname):
301
- return session
302
- rec = (users.registry() or {}).get(str(owner))
303
- if not isinstance(rec, dict) or not rec.get("active", True):
304
- return None
305
- # `_public` is THE definition of what a session may know about its own account (never a hash
306
- # or a salt) β€” the same one `routes_auth` uses. Building the dict by hand here would be a
307
- # second definition, and the one that leaks is always the copy.
308
- return Session(tenant=session.tenant, user=users._public(str(owner), rec),
309
- claims=session.claims, runtime=session.runtime)
310
-
311
-
312
- def _evaluate(session: Session, rec: dict, assemblies=None):
313
- """Resolve `rec`'s view over its topic AS THE ALERT'S OWNER, then fold the result in.
314
-
315
- ⭐⭐ W31-T24 β€” `assemblies` IS A PER-REQUEST MEMO, KEYED `(topic, owner)`, and it is the whole
316
- of this ticket's server half. `/notifications` re-evaluates EVERY alert inline on read and each
317
- one built a FULL assembly β€” the pool, the workspace, `rows_from_pool` over every row. Two
318
- alerts on one view built that table twice; ten built it ten times. Nothing dedupes them,
319
- because each `_evaluate` was a closed call.
320
- ⚠ `(topic, owner)` and not `topic`: the assembly is built as the alert's OWNER (see the module
321
- note β€” evaluating a BU-scoped user's alert on a full-access admin's pool is a permission leak
322
- wearing a notification's clothes), so two owners on one topic are two DIFFERENT tables and
323
- must never share an entry. Getting that key wrong is the one way this optimisation could leak.
324
- ⚠ Passing nothing keeps the old behaviour exactly, which is what the create/run doors want:
325
- they evaluate ONE alert and a memo for a single call is pure overhead.
326
- """
327
- import aios_grid
328
- from harness import filter_eval
329
-
330
- owner_sess = _owner_session(session, rec.get("owner"))
331
- if owner_sess is None:
332
- return {"skipped": "owner_unavailable"}
333
- topic = str(rec.get("topic") or "")
334
- memo_key = (topic, str(owner_sess.uname))
335
- g = assemblies.get(memo_key) if isinstance(assemblies, dict) else None
336
- if g is None:
337
- try:
338
- if topic.startswith("ut_"):
339
- from routes_tables import ut_assembly
340
- # β›” `consume_corrections=False`, and the default was a REAL BUG, not a tidy-up.
341
- # `ut_assembly` defaults it True, so every `/notifications` read CONSUMED the
342
- # one-shot field-name correction acks for every `ut_` topic that has an alert β€”
343
- # taking them from the `/workspace` refresh that exists to show them to the person
344
- # who made the edit. The customer branch below has always passed False; this one
345
- # inherited a default nobody re-read. An inbox poll must never consume a one-shot.
346
- g = ut_assembly(owner_sess, topic,
347
- storage_key=f"{owner_sess.tenant}:{topic}:{owner_sess.uname}",
348
- consume_corrections=False)
349
- else:
350
- from routes_customers import grid_assembly
351
- g = grid_assembly(owner_sess, scope=topic, consume_corrections=False)
352
- except Exception as e: # noqa: BLE001
353
- # ⭐ W32-T22 β€” SKIPPING IS FINE HERE; SKIPPING ANONYMOUSLY IS NOT. This one must not
354
- # raise (one bad alert cannot empty an inbox), so unlike `_require_filtered_view` it
355
- # keeps a blanket catch β€” but it now reports the refusal's OWN code where there is
356
- # one. `type(e).__name__` said `HTTPException` for four different causes, and
357
- # `lastError` is the only place a user ever learns why an alert stopped firing.
358
- #
359
- # ⚠ `with_rows=True` STAYS on this path, deliberately: unlike the create door, an
360
- # evaluation genuinely needs the rows to run the filter over. So an alert on a
361
- # read-through grid is created (T22) and then skips at evaluation with
362
- # `window_required` naming why β€” which is D-184's remaining half, and it is a
363
- # SENTENCE now rather than silence.
364
- return {"skipped": _refusal_code(e) or "unavailable", "detail": type(e).__name__}
365
- if isinstance(assemblies, dict):
366
- assemblies[memo_key] = g
367
-
368
- view = _view_by_id(g, rec.get("viewId"))
369
- if not isinstance(view, dict):
370
- # Deleted, or un-shared out from under the alert. Say so on the RECORD rather than
371
- # deleting the alert: an alert that silently vanishes is indistinguishable from one that
372
- # never fires, and the user cannot debug what is not there.
373
- return {"skipped": "view_missing"}
374
-
375
- # The SAME row build the grid and `/customers` use β€” `rows_from_pool` is what puts derived
376
- # and overlay values on a row. Evaluating a filter against raw pool dicts would silently
377
- # never match any condition on a user-created or measure column.
378
- #
379
- # β›”β›” AND "THE SAME ROW BUILD" WAS NOT TRUE, WHICH MADE EVERY ALERT ON A `ut_*` DATABASE BLIND
380
- # TO IMPORTED DATA. Found by a verifier driving one real assembly through both paths.
381
- #
382
- # `routes_tables.table_rows` β€” the grid the person is looking at β€” merges the DEFINITION rows
383
- # underneath the overlay ("base first, overlay wins"; that merge is itself the fix for owner
384
- # item 3, *"it all got reseted"*). `_evaluate` is a second copy of that read and never got it:
385
- # it handed `ws['overlays']` to `rows_from_pool` raw, so for a `ut_*` table every base cell
386
- # evaluated as BLANK. Measured on one assembly, same view, same rows:
387
- # rows_src state='unpaid' / 'paid'
388
- # _evaluate saw state='' / '' ⇐ every base cell blank
389
- # the GRID saw state='unpaid' / 'paid'
390
- # so `state eq unpaid` matched NOTHING while the view showed one row, and `state isEmpty`
391
- # matched EVERYTHING while the view showed none. **The alert did not merely miss rows β€” it
392
- # inverted.** End to end: a row whose value arrived by import, automation, paste or the create
393
- # door never fired; only a value typed as a hand EDIT did.
394
- #
395
- # ⚠ Scope, so nobody widens the fix past its cause: materialised `ut_*` tables are hit;
396
- # `customer`/`product` are not (their fields are `source: "odoo"` and read off `rows_src`);
397
- # `ut_odoo_*` never reaches here (`with_rows=True` refuses first and returns
398
- # `skipped: window_required`).
399
- # β›” ORDER IS LOAD-BEARING AND IS THE GRID'S: base underneath, overlay ON TOP. Inverting it
400
- # would let a stale definition value shadow an edit the user has just made β€” the same defect
401
- # `table_rows`' own note records, arriving from the other side.
402
- _ov = (g.get("ws") or {}).get("overlays") or {}
403
- _merged = {}
404
- for _r in g["rows_src"]:
405
- _pid = str(_r.get("pid"))
406
- _cells = {k: v for k, v in _r.items() if k != "pid"}
407
- _o = _ov.get(_pid)
408
- if isinstance(_o, dict):
409
- _cells.update(_o)
410
- _merged[_pid] = _cells
411
- rows = aios_grid.rows_from_pool(g["rows_src"], g["fields"], _merged,
412
- derived=g.get("derived"))
413
- config = view.get("config") or view
414
- ctx = filter_eval.EvalCtx(
415
- cohort_sets={str(k): {str(p) for p in (v.get("memberPids") or ())}
416
- for k, v in (g.get("lists") or {}).items() if isinstance(v, dict)},
417
- measure_sets=g.get("measure_sets") or {},
418
- today=g.get("today"))
419
- pids = filter_eval.visible_pids(config.get("filters") or [], rows, g["fields"], ctx,
420
- member_pids=config.get("memberPids"))
421
- labels = {str(r.get("pid")): str(r.get("name") or r.get("pid")) for r in rows}
422
- return alerts.evaluate(rec.get("id"), [str(p) for p in pids],
423
- labels=labels, partial=False, st=session.runtime)
424
-
425
-
426
- @router.get("/alerts")
427
- def list_alerts(session: Session = Depends(require_session)):
428
- return {"alerts": alerts.list_alerts(user=session.uname, is_admin=session.admin,
429
- st=session.runtime)}
430
-
431
-
432
- @router.post("/alerts")
433
- def create_alert(body: dict = Body(default=None), session: Session = Depends(require_session)):
434
- body = body or {}
435
- view_id = str(body.get("viewId") or "").strip()
436
- if not view_id:
437
- raise err(400, "bad_view", "an alert needs the id of the view it watches")
438
- topic = _topic_or_400(body.get("topic"))
439
- _require_filtered_view(session, topic, view_id)
440
- import uuid
441
- aid = f"al_{uuid.uuid4().hex[:12]}"
442
- rec = alerts.create(aid, view_id=view_id, topic=topic, owner=session.uname,
443
- label=body.get("label") or "", st=session.runtime)
444
- # SEED IMMEDIATELY, so the alert starts from "everything currently matching is old news".
445
- # Deferring this to the first write hook would mean the next edit announces the whole view.
446
- outcome = _evaluate(session, rec)
447
- return {"alert": {**rec, "seeded": True}, "first": outcome}
448
-
449
-
450
- def _require_filtered_view(session: Session, topic: str, view_id: str):
451
- """400 unless `view_id` exists on `topic` AND actually narrows something.
452
-
453
- β›” AN ALERT ON AN UNFILTERED VIEW IS SILENTLY INCAPABLE OF ALERTING, which is worse than one
454
- that is refused. `filter_eval` treats an inactive tree as "no narrowing, every row shows"
455
- (`visible_pids`'s own rule), so such an alert seeds with the entire table and can never see an
456
- entrant again β€” there is nothing left to enter. The owner's words are *"when a Record gets
457
- into that Filter's criteria"*: no criteria, no alert, and said at creation rather than
458
- discovered by never being notified.
459
-
460
- `is_rule_active` is the SAME activeness predicate the engine and the column tints use β€” a
461
- half-typed rule is not a filter, and this must agree with what actually narrows or it would
462
- accept a view whose one rule the engine then ignores.
463
-
464
- ⭐⭐ WAVE 32 Β· T22 (owner item 17) β€” THIS FUNCTION WAS THE ERROR. Two defects, stacked, and
465
- the second one hid the first.
466
-
467
- (1) **IT ASKED FOR EVERY ROW OF A TABLE IT NEVER LOOKS AT.** The only thing read below is
468
- `g["views"]`. `ut_assembly` defaults `with_rows=True`, so creating an alert on a
469
- read-through grid built the whole pool β€” and `scoped_pool` refuses that with
470
- `409 window_required` over 963,783 rows, exactly as it is supposed to. `with_rows=False`
471
- (W31-T20's flag, built for precisely this) answers the same question with `scoped_pids`,
472
- runs the SAME `_defn_or_refuse` wall, and does not refuse. **That is D-184's create half,
473
- closed** β€” an alert on a read-through grid can now be made at all.
474
- (2) **A BLANKET `except Exception` TURNED EVERY NAMED REFUSAL INTO A 503.** `HTTPException`
475
- is an `Exception`, so `404 unknown_table`, `403 forbidden`, `409 window_required` and
476
- `503 store_not_ready` β€” four refusals that each say what is wrong β€” were all replaced by
477
- *"the table is unavailable β€” try again in a moment"*. β›” AND THAT SENTENCE NEVER REACHED
478
- A USER EITHER: `alertsApi.errorMessage` discards the text of any status β‰₯ 500 by design
479
- (a 5xx body is the server's internals), substituting *"Something went wrong on our
480
- side."* β€” which is the owner's screenshot, word for word. A knowable cause returned as a
481
- 5xx is invisible by construction, so re-wording the 503 could never have fixed this.
482
- ⚠ The except is narrowed, not deleted: an UNEXPECTED failure is still a 503, because that is
483
- honest. What it may no longer do is catch a refusal that already knows its own name.
484
- """
485
- from fastapi import HTTPException
486
-
487
- from harness import filter_eval
488
-
489
- try:
490
- if topic.startswith("ut_"):
491
- from routes_tables import ut_assembly
492
- # ⚠ `consume_corrections=False` β€” the customer branch has always passed it and this
493
- # one inherited a default nobody re-read. Creating an alert must not eat the one-shot
494
- # field-name correction acks belonging to the `/workspace` refresh that exists to show
495
- # them to the person who made the edit. Same defect `_evaluate`'s header records.
496
- g = ut_assembly(session, topic,
497
- storage_key=f"{session.tenant}:{topic}:{session.uname}",
498
- consume_corrections=False, with_rows=False)
499
- else:
500
- from routes_customers import grid_assembly
501
- g = grid_assembly(session, scope=topic, consume_corrections=False)
502
- except HTTPException:
503
- raise # it already names its own cause
504
- except Exception as e: # noqa: BLE001
505
- # Genuinely unexpected. Still a 503, and now it carries the exception TYPE β€” without it,
506
- # the one path that reaches this branch is also the one path with nothing to debug from.
507
- raise err(503, "unavailable",
508
- f"the table could not be read ({type(e).__name__}) β€” try again in a moment")
509
- view = _view_by_id(g, view_id)
510
- if not isinstance(view, dict):
511
- raise err(404, "no_view", "that view does not exist on this table")
512
- nodes, _conj = filter_eval.tree_parts((view.get("config") or view).get("filters") or [])
513
-
514
- # β›”β›” WAVE 33 Β· T29 β€” **CORRECTION: THE BLOCK BELOW IS TRUE ABOUT THE CODE AND FALSE ABOUT
515
- # PRODUCTION, AND IT MUST BE READ SECOND.** It claims the missing-argument `TypeError` "IS
516
- # owner item 17" β€” the owner's *"Something went wrong"*. It was not, and it could not have
517
- # been: at `cbcf005`, the build the owner was using, the dict-read on `views` sat ~10 lines
518
- # ABOVE this call and raised `AttributeError` on EVERY request, so the walk never reached the
519
- # leaf and the arity bug was unreachable. `_view_by_id`'s own header records that fix.
520
- #
521
- # ⚠ WHY THE STALE PARAGRAPH STAYS RATHER THAN GETTING DELETED: the arity bug was real, the
522
- # fix was right, and the three reasons it hid are the most transferable thing in this file.
523
- # What was wrong is only its CLAIM TO BE THE CAUSE. Two comment blocks in one function each
524
- # naming themselves as the origin of the same screenshot are mutually exclusive, and the next
525
- # reader believes whichever they meet first β€” which is why this correction sits above rather
526
- # than below. Caught by a verifier that read the SHIPPED file at the deployed commit instead
527
- # of the working tree. [[grep-output-is-not-source]]
528
- #
529
- # β›”β›” WAVE 32 Β· T22 β€” **THE CALL BELOW WAS MISSING AN ARGUMENT** (and wave 32 believed, wrongly,
530
- # that this was owner item 17 β€” see the correction directly above).
531
- #
532
- # `is_rule_active(rule, columns)` takes TWO parameters (`harness/filter_sql.py`; every other
533
- # caller in the repo passes both). This one passed ONE, so the moment the walk reached a LEAF
534
- # rule it raised `TypeError: is_rule_active() missing 1 required positional argument`.
535
- #
536
- # ⚠ READ WHAT THAT MEANS BEFORE FIXING ANYTHING ELSE: the walk only reaches a leaf when the
537
- # view HAS a condition β€” and a view with a condition is the only kind an alert is allowed on.
538
- # A view with no filters yields an empty `nodes`, so `_any_active` returns False without ever
539
- # calling this, and the reader gets the honest 400 `no_filter`. **So the only path that
540
- # worked was the refusal path: "Alert me about new records" had never once created an alert
541
- # on a filtered view.** β›” And the raise lands OUTSIDE the `try` above, so it was not even the
542
- # 503 β€” it was a bare FastAPI 500, which `alertsApi.errorMessage` renders as *"Something went
543
- # wrong on our side. Try again in a moment."*, the owner's screenshot word for word.
544
- #
545
- # ⚠ THREE THINGS HID IT, and they are worth more than the fix. (1) Python does not check
546
- # arity until the line RUNS, and this line runs only on the success path of a feature whose
547
- # every test exercised its refusals. (2) The `no_filter` 400 above it is a real, correct,
548
- # well-tested refusal, so the door looked alive. (3) `verify_alerts.py` asserts the refusal
549
- # (`no_filter` reaches the user) and the transport β€” never a creation. A gate can be green,
550
- # thorough and honest about everything except the one path the feature exists for.
551
- #
552
- # `_columns_map` is the DEFINITION of fields -> the membership set `is_rule_active` looks a
553
- # column up in; building a second dict here would be a second answer to one question, which
554
- # is this wave's other headline defect in a different file. Its leading underscore is a real
555
- # smell and is BOOKED (PENDING, mailbox/C.md) rather than worked around.
556
- columns = filter_eval._columns_map(g.get("fields") or [])
557
-
558
- def _any_active(ns):
559
- for n in ns or ():
560
- if isinstance(n, dict) and isinstance(n.get("children"), list):
561
- if _any_active(n["children"]):
562
- return True
563
- elif filter_eval.is_rule_active(n, columns):
564
- return True
565
- return False
566
-
567
- if not _any_active(nodes):
568
- raise err(400, "no_filter",
569
- "this view has no active filter, so no record can ever ENTER it β€” add a "
570
- "condition to the view first, then create the alert")
571
-
572
-
573
- @router.delete("/alerts/{alert_id}")
574
- def delete_alert(alert_id: str, session: Session = Depends(require_session)):
575
- rec = next((r for r in alerts.list_alerts(st=session.runtime)
576
- if str(r.get("id")) == str(alert_id)), None)
577
- if rec is None:
578
- raise err(404, "no_alert", "that alert does not exist")
579
- if str(rec.get("owner")) != str(session.uname) and not session.admin:
580
- raise err(403, "not_yours", "only the alert's owner (or an administrator) can delete it")
581
- alerts.delete(alert_id, st=session.runtime)
582
- return {"ok": True}
583
-
584
-
585
- @router.post("/alerts/{alert_id}/run")
586
- def run_alert(alert_id: str, session: Session = Depends(require_session)):
587
- rec = next((r for r in alerts.list_alerts(user=session.uname, is_admin=session.admin,
588
- st=session.runtime)
589
- if str(r.get("id")) == str(alert_id)), None)
590
- if rec is None:
591
- raise err(404, "no_alert", "that alert does not exist")
592
- return _evaluate(session, rec)
593
-
594
-
595
- @router.get("/notifications")
596
- def notifications(session: Session = Depends(require_session)):
597
- """The inbox β€” RE-EVALUATED on read, which is a deliberate design choice.
598
-
599
- ⭐ A-S1-2 RESOLVED THE OTHER WAY, and the reason is structural rather than a shortcut. The
600
- plan was a push hook: the automation engine calls `after_write` when it lands rows. But
601
- `run_async` runs on a BACKGROUND THREAD with no `Session` in scope, and an alert must be
602
- evaluated as its OWNER (see `_evaluate`) β€” so a push hook would have to mint a session inside
603
- a worker thread from a tenant runtime, which is exactly the kind of ad-hoc identity
604
- construction that leaks scope.
605
-
606
- Pulling on read has none of that: the caller IS a session, the assemblies are already
607
- scope-cached, and the user cannot observe the difference β€” an inbox is only ever read by
608
- someone opening it. The cost is that a notification is minted when you LOOK rather than when
609
- the row landed, so the `at` stamp is detection time, not arrival time.
610
-
611
- `after_write` stays exported for the day the engine can hand over a real identity.
612
-
613
- ⭐⭐ W31-T24 β€” ONE ASSEMBLY PER (TOPIC, OWNER), NOT ONE PER ALERT.
614
- β›” MEASURED FIRST, AND THE MEASUREMENT CORRECTS AN EARLIER READING OF IT. This route is
615
- **20 ms in-process and 3,280 ms live** on tenant #0 β€” but tenant #0 has **ZERO alerts**
616
- (censused 2026-08-12), so the 20 ms is an EMPTY LOOP and says nothing at all about what the
617
- re-evaluation costs. The live 3,280 ms is the two store reads either side of that loop. So the
618
- body below is not slow today; it is UNEXERCISED, and every alert a tenant creates adds a whole
619
- grid assembly to an inbox poll. The memo turns O(alerts) into O(distinct topic Γ— owner), which
620
- is the difference between "fine" and "three seconds per alert" the day somebody uses the
621
- feature. ⚠ Making the read cheap by evaluating LESS is the obvious wrong fix and is not what
622
- this does: every alert is still evaluated, against the same rows, in the same order.
623
- """
624
- assemblies = {}
625
- for rec in alerts.list_alerts(user=session.uname, is_admin=False, st=session.runtime):
626
- try:
627
- _evaluate(session, rec, assemblies=assemblies)
628
- except Exception: # noqa: BLE001
629
- continue # one bad alert must not empty the pane
630
- # ⭐ W32-T20 (C3): every item leaves through `inbox_view`, so a notification queued before
631
- # this wave carries a `target` too. See `notification_view`'s header for why it is derived.
632
- return inbox_view(alerts.inbox(session.uname, st=session.runtime))
633
-
634
-
635
- @router.post("/notifications/read")
636
- def read_notifications(body: dict = Body(default=None),
637
- session: Session = Depends(require_session)):
638
- body = body or {}
639
- ids = body.get("ids")
640
- if ids is not None and not isinstance(ids, list):
641
- raise err(400, "bad_ids", "ids must be a list, or null to mark every notification")
642
- # ⚠ THE SAME ENRICHMENT ON BOTH DOORS. `mark_read` returns a fresh inbox, and the Inbox
643
- # module re-renders from it β€” an un-enriched answer here would strip `target` off every row
644
- # the moment somebody marked one read, i.e. the feature would work until first use.
645
- return inbox_view(alerts.mark_read(session.uname, ids, read=bool(body.get("read", True)),
646
- st=session.runtime))
647
-
648
-
649
- def after_write(session: Session, topic_key: str):
650
- """THE WRITE HOOK β€” call after a write that could change what a view matches.
651
-
652
- Exported as a plain function (not a route) so `core.grid_events`' callers and S2's automation
653
- upserts reach it the same way. It never raises: an alert evaluation failing must not fail the
654
- edit that triggered it.
655
-
656
- ⭐ W31-T24 β€” it shares `/notifications`' memo shape for the same reason: a write that changes
657
- one view can trip several alerts on the SAME topic, and each would otherwise rebuild the table.
658
- ⚠ STILL ZERO PRODUCTION CALLERS (W31-T24 confirmed it; the route docstring above says why the
659
- push hook was resolved the other way). Booked rather than wired: minting a session inside the
660
- engine's worker thread is the ad-hoc identity construction this file exists to avoid.
661
- """
662
- try:
663
- assemblies = {}
664
- return alerts.after_write(topic_key, st=session.runtime,
665
- runner=lambda rec: _evaluate(session, rec,
666
- assemblies=assemblies))
667
- except Exception: # noqa: BLE001
668
- return {"evaluated": 0}
 
1
+ """routes_alerts.py β€” the Alerts module (wave 20, owner item 25, contract C-ALERT).
2
+
3
+ GET /api/v1/alerts -> {alerts:[...]}
4
+ POST /api/v1/alerts <- {viewId, topic, label?}
5
+ DELETE /api/v1/alerts/{alert_id}
6
+ POST /api/v1/alerts/{alert_id}/run -> evaluate now (the pane's manual refresh)
7
+ GET /api/v1/notifications -> {unread, items:[...]}
8
+ POST /api/v1/notifications/read <- {ids:[...]|null, read?:bool}
9
+
10
+ The semantics β€” an alert is a view plus a remembered matched set, a notification is a NEW
11
+ ENTRANT, and the first evaluation seeds silently β€” live in `core.alerts` with the reasoning.
12
+ This file owns the two things a route must: WHO may do it, and HOW the view gets evaluated.
13
+
14
+ ⭐ **THE EVALUATION RUNS AS THE ALERT'S OWNER, NOT AS THE CALLER.** `_run_alert` builds the pool
15
+ for `rec['owner']`, never for whoever tripped the write hook. Any other choice leaks: a
16
+ full-access admin editing a cell would otherwise evaluate a BU-scoped user's alert over the whole
17
+ book, and the notification would name customers that user may not see β€” a permission leak wearing
18
+ a notification's clothes. The owner's own scope is the only correct basis for their alert.
19
+
20
+ ⚠ **AN ALERT IS NOT A SECOND READ PATH.** It resolves rows through the same
21
+ `routes_customers.grid_assembly` / `routes_tables.ut_assembly` the grid uses, so a row that an
22
+ alert can see is by construction a row its owner could open. Re-implementing the filter here
23
+ would be a second definition of "matches", and those two would drift.
24
+ """
25
+ import re
26
+
27
+ from fastapi import APIRouter, Body, Depends
28
+
29
+ import core.alerts as alerts
30
+ from deps import Session, err, require_session
31
+
32
+ router = APIRouter(prefix="/api/v1")
33
+
34
+ #: The alert-bearing surfaces. `ut_` tables are admitted by prefix, like everywhere else.
35
+ _TOPICS = ("customer", "product")
36
+
37
+ # ── ⭐⭐ WAVE 32 Β· T20 Β· CONTRACT C3 β€” THE INBOX SHAPE, DERIVED ON READ ────────────────────────
38
+ #
39
+ # `GET /notifications` gains `subject`, `kind` and `target` per item (`read` was always there).
40
+ #
41
+ # β›” DERIVED, NEVER STORED, AND THAT IS THE WHOLE OF WHY THIS WAVE EXISTS. Stamping the three
42
+ # keys onto the record at write time would give them to notifications minted AFTER the deploy and
43
+ # to nothing else β€” every notification already sitting in every tenant's inbox would open nothing,
44
+ # and the feature would be correct in the source and absent from the product
45
+ # ([[a-migration-that-runs-on-the-next-write]], D-201). A read-side derivation reaches a
46
+ # notification queued last month. It also keeps the store shape out of `core/alerts.py`, which is
47
+ # another lane's file this wave β€” but that is the convenience, not the reason.
48
+ #
49
+ # ⚠ TWO PRODUCERS WRITE TWO SHAPES into one inbox, and the vocabulary below is what tells them
50
+ # apart. `_queue` (a record ENTERED a watched view) sets `topic`+`viewId`. `notify()` sets
51
+ # `topic='automation'` and puts the producer's key in `alertId`, leaving `viewId` empty. Deciding
52
+ # here means the client branches on ONE field instead of re-deriving the same split.
53
+ #
54
+ # β›” **D-101 IS CLOSED HERE, BY SUBTRACTION.** There was a THIRD shape β€” `kind='automation_review'`
55
+ # + `autoId`, a card arriving at a review stage β€” and its producer `notify_review` was deleted by
56
+ # W27/R3 with the review lanes. `automation_engine.py`'s own tombstone (search `notify_review`)
57
+ # records the 2026-08-12 sweep: **no `.py` file anywhere produces one**, while the client branch,
58
+ # its route and three gate legs stayed fully alive. D-101's exit condition is *"the client review
59
+ # branch is deleted in the same change as any remaining residue, OR `notify_review` gains its real
60
+ # caller"* β€” the residue is zero, so the branch goes. It is not carried into the Inbox: a stored
61
+ # review notification (if any survives in a tenant from the wave-23 era) derives as an ordinary
62
+ # `alert` with no target, i.e. an honest unclickable row, which is correct β€” the board it pointed
63
+ # at was deleted two waves ago.
64
+
65
+ #: C3's `kind` vocabulary. Plain strings on the wire β€” the client must never union over them
66
+ #: (alertsModel's wave-9 law: a client union turns "the server grew a kind" into a dropped row).
67
+ NOTIF_KIND_ALERT = "alert"
68
+ NOTIF_KIND_AUTOMATION = "automation"
69
+ NOTIF_KIND_SHARE = "share"
70
+
71
+ #: C3's `target.module` vocabulary, and the automation sub-selection.
72
+ TARGET_MODULE_DATABASE = "database"
73
+ TARGET_MODULE_AUTOMATION = "automation"
74
+ TARGET_TAB_RUNS = "runs"
75
+
76
+ #: The topic `notify()` carries for a SHARE (W32-T28 writes it; nothing does yet, and a kind with
77
+ #: no producer is a string that reads as a feature β€” the reason this constant is named here and
78
+ #: cited from `routes_shares` rather than typed twice).
79
+ SHARE_TOPIC = "share"
80
+
81
+ #: `core.alerts.notify`'s default topic for a run outcome. Mirrors `inboxModel.AUTOMATION_TOPIC`.
82
+ AUTOMATION_TOPIC = "automation"
83
+
84
+ _UT_TOPIC = re.compile(r"ut_[A-Za-z0-9_]+\Z")
85
+
86
+
87
+ def route_for_topic(topic):
88
+ """A grid SCOPE key -> the registry route that renders it, or None.
89
+
90
+ β›” THE SAME TABLE AS `alertsModel.routeForTopic`, and the parity is GATED
91
+ (`verify_alerts.py`'s vocabulary scan) rather than trusted. The two built-ins are the only
92
+ pair that differ β€” the registry names the surface (`customer_data`) while the grid names the
93
+ scope (`customer`) β€” so a topic passed through as a route sends every click to a page that
94
+ does not exist. `None` for anything else: a target this product cannot resolve must be ABSENT
95
+ rather than plausible, because an absent target renders as a row that does not pretend to be
96
+ clickable, and a wrong one renders as a click that silently goes nowhere.
97
+ """
98
+ t = str(topic or "").strip()
99
+ if t == "customer":
100
+ return "customer_data"
101
+ if t == "product":
102
+ return "product_data"
103
+ if _UT_TOPIC.match(t):
104
+ return t
105
+ return None
106
+
107
+
108
+ def _refusal_code(exc):
109
+ """The `error.code` an `HTTPException` raised by `deps.err()` carries, or `""`.
110
+
111
+ ⭐ W32-T22. Four refusals travel up the assembly chain β€” `unknown_table` (404), `forbidden`
112
+ (403), `window_required` (409) and `store_not_ready` (503) β€” and each already names its own
113
+ cause. Anything that reduces all four to one word is throwing away the only information the
114
+ reader could have acted on. Returns `""` for a plain exception, so a caller can tell
115
+ "refused, and here is why" apart from "broke, and we do not know why".
116
+ """
117
+ detail = getattr(exc, "detail", None)
118
+ if isinstance(detail, dict):
119
+ inner = detail.get("error")
120
+ if isinstance(inner, dict):
121
+ return str(inner.get("code") or "")
122
+ return ""
123
+
124
+
125
+ def notification_view(item):
126
+ """One STORED notification -> the shape the Inbox renders. PURE, and total.
127
+
128
+ Never raises and never drops a row: an item it cannot classify comes back as an `alert` with
129
+ no `target`, which the client renders as an unclickable row rather than hiding. An inbox that
130
+ silently omits what it does not understand is the one failure a reader cannot detect.
131
+ """
132
+ if not isinstance(item, dict):
133
+ return item
134
+ topic = str(item.get("topic") or "").strip()
135
+ alert_id = str(item.get("alertId") or "").strip()
136
+
137
+ # β›” THE ID TEST IS HALF OF EVERY BRANCH, and it is the load-bearing half. A row whose topic
138
+ # says `automation` but whose producer key never arrived (a truncated payload, a server
139
+ # mid-deploy) would otherwise be handed a target naming NOTHING β€” a click that appears to work
140
+ # and silently does not, which is this repo's most-repeated failure shape. Failing the test
141
+ # drops it to the `alert` branch, where `route_for_topic` refuses out loud by answering None.
142
+ if topic == AUTOMATION_TOPIC and alert_id:
143
+ kind = NOTIF_KIND_AUTOMATION
144
+ target = {"module": TARGET_MODULE_AUTOMATION, "id": alert_id, "tab": TARGET_TAB_RUNS}
145
+ elif topic == SHARE_TOPIC and alert_id:
146
+ # ⭐ W32-T28: the sharer writes `key=<the ROUTE to open>` and, for a shared VIEW,
147
+ # `row_id=<the view to select>`.
148
+ #
149
+ # β›” `key` IS ALREADY A ROUTE, NOT A RAW OBJECT ID, and the first version of this got it
150
+ # wrong in a way worth recording: a shared VIEW put the VIEW's id in `alertId`, so the
151
+ # target read `{module: "database", id: "view_42"}` β€” an instruction to open a database
152
+ # called `view_42`. It looked right in the payload and would have opened nothing. The
153
+ # producer resolves the object to its topic and hands over the route; this branch only
154
+ # shapes what it is given.
155
+ kind = NOTIF_KIND_SHARE
156
+ row_id = str(item.get("rowId") or "").strip()
157
+ target = {"module": TARGET_MODULE_DATABASE, "id": alert_id,
158
+ **({"tab": row_id} if row_id else {})}
159
+ else:
160
+ kind = NOTIF_KIND_ALERT
161
+ route = route_for_topic(topic)
162
+ view_id = str(item.get("viewId") or "").strip()
163
+ target = None if route is None else (
164
+ {"module": TARGET_MODULE_DATABASE, "id": route,
165
+ **({"tab": view_id} if view_id else {})})
166
+
167
+ # The email split: `subject` is the HEADER (what this is about β€” the alert, the automation,
168
+ # the database), `label` stays the BODY (what happened β€” the record that entered, the run
169
+ # summary). They were one field, which is why a notification read as a sentence with no
170
+ # sender and the pane could not be laid out like mail.
171
+ subject = str(item.get("alertLabel") or "").strip() or str(item.get("label") or "").strip()
172
+ # ⚠ `kind` is OVERWRITTEN, not merged. There was one stored value (`automation_review`) and it
173
+ # is D-101's dead one; leaving it through would give the client two vocabularies for one
174
+ # question, which is the defect this wave's item 6 is about in a different file.
175
+ # ⭐⭐ W33-T28 (owner: "the Inbox reads like email") β€” THE SENDER, WHICH DID NOT EXIST.
176
+ #
177
+ # β›” A `verifier` reading the finished wave-32 surface found that the row's sender POSITION was
178
+ # occupied by `kindLabel(n.kind)` β€” the literals "Alert" / "Automation" / "Shared with you" β€”
179
+ # i.e. a CATEGORY standing where a who belongs, and no sender field anywhere on the wire, in
180
+ # the model or in the markup. Mail has a from. This is it.
181
+ #
182
+ # ⚠ IT IS DERIVED HERE, NOT STORED, FOR EVERY KIND BUT ONE β€” and the exception is the point.
183
+ # An alert firing and an automation landing rows have no person behind them; their honest
184
+ # sender is the machine that did it, named as the thing the reader recognises. A SHARE has a
185
+ # real person, and only the producer knows who: `routes_shares.py` writes it as `actor` and
186
+ # this reads it back. β›” It is NOT parsed out of the body prose ("<name> shared this with
187
+ # you") β€” a sender recovered by regexing a sentence breaks the first time the sentence is
188
+ # reworded, and it would break silently, in the header.
189
+ #
190
+ # ⚠ FALLS BACK, NEVER BLANK. A share queued BEFORE `actor` existed has none, and a row with an
191
+ # empty from column reads as a broken inbox rather than as an old notification.
192
+ actor = str(item.get("actor") or "").strip()
193
+ if kind == NOTIF_KIND_SHARE:
194
+ sender = actor or "A teammate"
195
+ elif kind == NOTIF_KIND_AUTOMATION:
196
+ # β›” "Agents", not "Automation" (W34-T40, corrected at QA 2026-08-17). The client's
197
+ # `senderOf` already falls back to `AGENTS_MODULE_LABEL` β€” but `if (sent) return sent`
198
+ # runs FIRST, so this server literal won and every actor-less automation notification
199
+ # showed the retired module name in the inbox's From column.
200
+ sender = actor or "Agents"
201
+ else:
202
+ sender = actor or "Alerts"
203
+ out = {**item, "read": bool(item.get("read")), "kind": kind,
204
+ "subject": subject or "Notification", "sender": sender}
205
+ if target is not None:
206
+ out["target"] = target
207
+ return out
208
+
209
+
210
+ def inbox_view(box):
211
+ """`core.alerts.inbox()`'s answer, with every item put through {@link notification_view}.
212
+
213
+ ⚠ `unread` IS NOT RECOUNTED. It is the ACCOUNT's number and `items` is one page of it; a
214
+ recount here would make the badge a function of whatever this page happened to include, which
215
+ is the exact defect `alertsModel.parseInbox`'s own header records from the other side.
216
+ """
217
+ if not isinstance(box, dict):
218
+ return box
219
+ items = box.get("items")
220
+ if not isinstance(items, list):
221
+ return box
222
+ # ⭐⭐ W33-T28 / D-208 β€” THE SERVER'S CLOCK RIDES WITH THE PAGE, and it is what lets the client
223
+ # render a mail-shaped stamp ("09:41" today, "Aug 12" beyond) instead of `2026-08-13 09:41`.
224
+ #
225
+ # β›” THE CLIENT MUST NOT READ ITS OWN CLOCK, which is D-208's exit condition word for word and
226
+ # is why this key exists rather than a `new Date()` in the browser. `at` is sent as UTC WITH
227
+ # its offset (D-18) precisely so every reader sees the same instant; deciding "is this today?"
228
+ # against a browser clock would re-introduce the drift the offset exists to remove β€” a reader a
229
+ # day ahead being told an event happened tomorrow [[date-window-vocabulary]]. Both operands
230
+ # now come from the same machine.
231
+ # ⚠ Same funnel as the enrichment, so the read door and the mark-read door cannot disagree β€”
232
+ # the note two lines up records what happened last time only one of them was enriched.
233
+ return {**box, "now": alerts._now_iso(),
234
+ "items": [notification_view(n) for n in items]}
235
+
236
+
237
+ def _view_by_id(g, view_id):
238
+ """One saved view out of an assembly, by id. `None` when there is no such view.
239
+
240
+ β›”β›” W33-T29 (owner: *"Alert me about new records"* answering "Something went wrong") β€” THIS
241
+ FUNCTION EXISTS BECAUSE TWO CALL SITES BOTH WROTE `(g.get("views") or {}).get(view_id)`, AND
242
+ `g["views"] IS A LIST`. `aios_grid.views_from_defs` returns `[{...}]`, `workspace_wire` passes
243
+ it straight out and both `ut_assembly` and `grid_assembly` return it unchanged β€” so `.get` on
244
+ it raises `AttributeError`, and `views_from_defs` always returns at least one element, so the
245
+ `or {}` never fires. **It raised on EVERY call, on every topic, since wave 20.**
246
+
247
+ β›” AND THE TWO SITES FAILED DIFFERENTLY, WHICH IS WHY ONLY ONE WAS EVER REPORTED. In
248
+ `_require_filtered_view` the raise lands ABOVE the handler's own `try`, so it leaves as a bare
249
+ FastAPI 500 and the client's `errorMessage` turns any 5xx into *"Something went wrong on our
250
+ side"* β€” the exact sentence the owner reported (D-107's shape, again: an attribute error above
251
+ the guard arrives as plain text rather than as our envelope). In `_evaluate` the identical
252
+ line is swallowed by `/notifications`' `except Exception: continue`, so **every stored
253
+ view-alert was silently dropped from the Inbox** and nobody had anything to report at all.
254
+ One expression, one loud symptom and one silent one.
255
+
256
+ ⚠ SO IT IS A FUNCTION, NOT TWO FIXED LINES. Two copies of "find the view" is what let one site
257
+ be discussed for three waves while its twin went unnoticed [[one-question-two-normalizers]].
258
+
259
+ ⚠ It accepts a dict too, and that is not defensive noise: `verify_alerts`' door fixture was
260
+ keyed `{id: view}` β€” which is precisely why the gate was green while production raised on
261
+ every call. The fixture is moving to the production shape in this same change, and tolerating
262
+ both here means a caller that legitimately holds one cannot resurrect the bug.
263
+ """
264
+ want = str(view_id or "")
265
+ if not want:
266
+ return None
267
+ views = (g or {}).get("views")
268
+ if isinstance(views, dict):
269
+ found = views.get(want)
270
+ return found if isinstance(found, dict) else None
271
+ if not isinstance(views, list):
272
+ return None
273
+ for v in views:
274
+ if isinstance(v, dict) and str(v.get("id") or "") == want:
275
+ return v
276
+ return None
277
+
278
+
279
+ def _topic_or_400(raw):
280
+ topic = str(raw or "").strip().lower()
281
+ if topic.startswith("ut_") or topic in _TOPICS:
282
+ return topic
283
+ raise err(400, "bad_topic", f"topic must be one of {', '.join(_TOPICS)} or a ut_ table")
284
+
285
+
286
+ def _owner_session(session: Session, owner: str):
287
+ """A `Session` for the alert's OWNER (see the module note on why the owner, not the caller).
288
+
289
+ ⚠ `Session` exposes `uname`/`admin` as PROPERTIES derived from `user`, not as fields β€” so an
290
+ owner session is built by swapping the `user` RECORD and letting both derive themselves. An
291
+ earlier version passed `uname=`/`admin=` to the constructor, which would have raised on the
292
+ first write hook of the wave; the properties are the single definition of who a session is,
293
+ and going around them is how a session with an admin flag and a non-admin record exists.
294
+
295
+ Returns None when the owner is gone or deactivated β€” their alerts then stop evaluating rather
296
+ than evaluating as somebody else, which is the fail-closed direction.
297
+ """
298
+ import core.users as users
299
+
300
+ if str(owner) == str(session.uname):
301
+ return session
302
+ rec = (users.registry() or {}).get(str(owner))
303
+ if not isinstance(rec, dict) or not rec.get("active", True):
304
+ return None
305
+ # `_public` is THE definition of what a session may know about its own account (never a hash
306
+ # or a salt) β€” the same one `routes_auth` uses. Building the dict by hand here would be a
307
+ # second definition, and the one that leaks is always the copy.
308
+ return Session(tenant=session.tenant, user=users._public(str(owner), rec),
309
+ claims=session.claims, runtime=session.runtime)
310
+
311
+
312
+ def _evaluate(session: Session, rec: dict, assemblies=None):
313
+ """Resolve `rec`'s view over its topic AS THE ALERT'S OWNER, then fold the result in.
314
+
315
+ ⭐⭐ W31-T24 β€” `assemblies` IS A PER-REQUEST MEMO, KEYED `(topic, owner)`, and it is the whole
316
+ of this ticket's server half. `/notifications` re-evaluates EVERY alert inline on read and each
317
+ one built a FULL assembly β€” the pool, the workspace, `rows_from_pool` over every row. Two
318
+ alerts on one view built that table twice; ten built it ten times. Nothing dedupes them,
319
+ because each `_evaluate` was a closed call.
320
+ ⚠ `(topic, owner)` and not `topic`: the assembly is built as the alert's OWNER (see the module
321
+ note β€” evaluating a BU-scoped user's alert on a full-access admin's pool is a permission leak
322
+ wearing a notification's clothes), so two owners on one topic are two DIFFERENT tables and
323
+ must never share an entry. Getting that key wrong is the one way this optimisation could leak.
324
+ ⚠ Passing nothing keeps the old behaviour exactly, which is what the create/run doors want:
325
+ they evaluate ONE alert and a memo for a single call is pure overhead.
326
+ """
327
+ import aios_grid
328
+ from harness import filter_eval
329
+
330
+ owner_sess = _owner_session(session, rec.get("owner"))
331
+ if owner_sess is None:
332
+ return {"skipped": "owner_unavailable"}
333
+ topic = str(rec.get("topic") or "")
334
+ memo_key = (topic, str(owner_sess.uname))
335
+ g = assemblies.get(memo_key) if isinstance(assemblies, dict) else None
336
+ if g is None:
337
+ try:
338
+ if topic.startswith("ut_"):
339
+ from routes_tables import ut_assembly
340
+ # β›” `consume_corrections=False`, and the default was a REAL BUG, not a tidy-up.
341
+ # `ut_assembly` defaults it True, so every `/notifications` read CONSUMED the
342
+ # one-shot field-name correction acks for every `ut_` topic that has an alert β€”
343
+ # taking them from the `/workspace` refresh that exists to show them to the person
344
+ # who made the edit. The customer branch below has always passed False; this one
345
+ # inherited a default nobody re-read. An inbox poll must never consume a one-shot.
346
+ g = ut_assembly(owner_sess, topic,
347
+ storage_key=f"{owner_sess.tenant}:{topic}:{owner_sess.uname}",
348
+ consume_corrections=False)
349
+ else:
350
+ from routes_customers import grid_assembly
351
+ g = grid_assembly(owner_sess, scope=topic, consume_corrections=False)
352
+ except Exception as e: # noqa: BLE001
353
+ # ⭐ W32-T22 β€” SKIPPING IS FINE HERE; SKIPPING ANONYMOUSLY IS NOT. This one must not
354
+ # raise (one bad alert cannot empty an inbox), so unlike `_require_filtered_view` it
355
+ # keeps a blanket catch β€” but it now reports the refusal's OWN code where there is
356
+ # one. `type(e).__name__` said `HTTPException` for four different causes, and
357
+ # `lastError` is the only place a user ever learns why an alert stopped firing.
358
+ #
359
+ # ⚠ `with_rows=True` STAYS on this path, deliberately: unlike the create door, an
360
+ # evaluation genuinely needs the rows to run the filter over. So an alert on a
361
+ # read-through grid is created (T22) and then skips at evaluation with
362
+ # `window_required` naming why β€” which is D-184's remaining half, and it is a
363
+ # SENTENCE now rather than silence.
364
+ return {"skipped": _refusal_code(e) or "unavailable", "detail": type(e).__name__}
365
+ if isinstance(assemblies, dict):
366
+ assemblies[memo_key] = g
367
+
368
+ view = _view_by_id(g, rec.get("viewId"))
369
+ if not isinstance(view, dict):
370
+ # Deleted, or un-shared out from under the alert. Say so on the RECORD rather than
371
+ # deleting the alert: an alert that silently vanishes is indistinguishable from one that
372
+ # never fires, and the user cannot debug what is not there.
373
+ return {"skipped": "view_missing"}
374
+
375
+ # The SAME row build the grid and `/customers` use β€” `rows_from_pool` is what puts derived
376
+ # and overlay values on a row. Evaluating a filter against raw pool dicts would silently
377
+ # never match any condition on a user-created or measure column.
378
+ #
379
+ # β›”β›” AND "THE SAME ROW BUILD" WAS NOT TRUE, WHICH MADE EVERY ALERT ON A `ut_*` DATABASE BLIND
380
+ # TO IMPORTED DATA. Found by a verifier driving one real assembly through both paths.
381
+ #
382
+ # `routes_tables.table_rows` β€” the grid the person is looking at β€” merges the DEFINITION rows
383
+ # underneath the overlay ("base first, overlay wins"; that merge is itself the fix for owner
384
+ # item 3, *"it all got reseted"*). `_evaluate` is a second copy of that read and never got it:
385
+ # it handed `ws['overlays']` to `rows_from_pool` raw, so for a `ut_*` table every base cell
386
+ # evaluated as BLANK. Measured on one assembly, same view, same rows:
387
+ # rows_src state='unpaid' / 'paid'
388
+ # _evaluate saw state='' / '' ⇐ every base cell blank
389
+ # the GRID saw state='unpaid' / 'paid'
390
+ # so `state eq unpaid` matched NOTHING while the view showed one row, and `state isEmpty`
391
+ # matched EVERYTHING while the view showed none. **The alert did not merely miss rows β€” it
392
+ # inverted.** End to end: a row whose value arrived by import, automation, paste or the create
393
+ # door never fired; only a value typed as a hand EDIT did.
394
+ #
395
+ # ⚠ Scope, so nobody widens the fix past its cause: materialised `ut_*` tables are hit;
396
+ # `customer`/`product` are not (their fields are `source: "odoo"` and read off `rows_src`);
397
+ # `ut_odoo_*` never reaches here (`with_rows=True` refuses first and returns
398
+ # `skipped: window_required`).
399
+ # β›” ORDER IS LOAD-BEARING AND IS THE GRID'S: base underneath, overlay ON TOP. Inverting it
400
+ # would let a stale definition value shadow an edit the user has just made β€” the same defect
401
+ # `table_rows`' own note records, arriving from the other side.
402
+ _ov = (g.get("ws") or {}).get("overlays") or {}
403
+ _merged = {}
404
+ for _r in g["rows_src"]:
405
+ _pid = str(_r.get("pid"))
406
+ _cells = {k: v for k, v in _r.items() if k != "pid"}
407
+ _o = _ov.get(_pid)
408
+ if isinstance(_o, dict):
409
+ _cells.update(_o)
410
+ _merged[_pid] = _cells
411
+ rows = aios_grid.rows_from_pool(g["rows_src"], g["fields"], _merged,
412
+ derived=g.get("derived"))
413
+ config = view.get("config") or view
414
+ ctx = filter_eval.EvalCtx(
415
+ cohort_sets={str(k): {str(p) for p in (v.get("memberPids") or ())}
416
+ for k, v in (g.get("lists") or {}).items() if isinstance(v, dict)},
417
+ measure_sets=g.get("measure_sets") or {},
418
+ today=g.get("today"))
419
+ pids = filter_eval.visible_pids(config.get("filters") or [], rows, g["fields"], ctx,
420
+ member_pids=config.get("memberPids"))
421
+ labels = {str(r.get("pid")): str(r.get("name") or r.get("pid")) for r in rows}
422
+ return alerts.evaluate(rec.get("id"), [str(p) for p in pids],
423
+ labels=labels, partial=False, st=session.runtime)
424
+
425
+
426
+ @router.get("/alerts")
427
+ def list_alerts(session: Session = Depends(require_session)):
428
+ return {"alerts": alerts.list_alerts(user=session.uname, is_admin=session.admin,
429
+ st=session.runtime)}
430
+
431
+
432
+ @router.post("/alerts")
433
+ def create_alert(body: dict = Body(default=None), session: Session = Depends(require_session)):
434
+ body = body or {}
435
+ view_id = str(body.get("viewId") or "").strip()
436
+ if not view_id:
437
+ raise err(400, "bad_view", "an alert needs the id of the view it watches")
438
+ topic = _topic_or_400(body.get("topic"))
439
+ _require_filtered_view(session, topic, view_id)
440
+ import uuid
441
+ aid = f"al_{uuid.uuid4().hex[:12]}"
442
+ rec = alerts.create(aid, view_id=view_id, topic=topic, owner=session.uname,
443
+ label=body.get("label") or "", st=session.runtime)
444
+ # SEED IMMEDIATELY, so the alert starts from "everything currently matching is old news".
445
+ # Deferring this to the first write hook would mean the next edit announces the whole view.
446
+ outcome = _evaluate(session, rec)
447
+ return {"alert": {**rec, "seeded": True}, "first": outcome}
448
+
449
+
450
+ def _require_filtered_view(session: Session, topic: str, view_id: str):
451
+ """400 unless `view_id` exists on `topic` AND actually narrows something.
452
+
453
+ β›” AN ALERT ON AN UNFILTERED VIEW IS SILENTLY INCAPABLE OF ALERTING, which is worse than one
454
+ that is refused. `filter_eval` treats an inactive tree as "no narrowing, every row shows"
455
+ (`visible_pids`'s own rule), so such an alert seeds with the entire table and can never see an
456
+ entrant again β€” there is nothing left to enter. The owner's words are *"when a Record gets
457
+ into that Filter's criteria"*: no criteria, no alert, and said at creation rather than
458
+ discovered by never being notified.
459
+
460
+ `is_rule_active` is the SAME activeness predicate the engine and the column tints use β€” a
461
+ half-typed rule is not a filter, and this must agree with what actually narrows or it would
462
+ accept a view whose one rule the engine then ignores.
463
+
464
+ ⭐⭐ WAVE 32 Β· T22 (owner item 17) β€” THIS FUNCTION WAS THE ERROR. Two defects, stacked, and
465
+ the second one hid the first.
466
+
467
+ (1) **IT ASKED FOR EVERY ROW OF A TABLE IT NEVER LOOKS AT.** The only thing read below is
468
+ `g["views"]`. `ut_assembly` defaults `with_rows=True`, so creating an alert on a
469
+ read-through grid built the whole pool β€” and `scoped_pool` refuses that with
470
+ `409 window_required` over 963,783 rows, exactly as it is supposed to. `with_rows=False`
471
+ (W31-T20's flag, built for precisely this) answers the same question with `scoped_pids`,
472
+ runs the SAME `_defn_or_refuse` wall, and does not refuse. **That is D-184's create half,
473
+ closed** β€” an alert on a read-through grid can now be made at all.
474
+ (2) **A BLANKET `except Exception` TURNED EVERY NAMED REFUSAL INTO A 503.** `HTTPException`
475
+ is an `Exception`, so `404 unknown_table`, `403 forbidden`, `409 window_required` and
476
+ `503 store_not_ready` β€” four refusals that each say what is wrong β€” were all replaced by
477
+ *"the table is unavailable β€” try again in a moment"*. β›” AND THAT SENTENCE NEVER REACHED
478
+ A USER EITHER: `alertsApi.errorMessage` discards the text of any status β‰₯ 500 by design
479
+ (a 5xx body is the server's internals), substituting *"Something went wrong on our
480
+ side."* β€” which is the owner's screenshot, word for word. A knowable cause returned as a
481
+ 5xx is invisible by construction, so re-wording the 503 could never have fixed this.
482
+ ⚠ The except is narrowed, not deleted: an UNEXPECTED failure is still a 503, because that is
483
+ honest. What it may no longer do is catch a refusal that already knows its own name.
484
+ """
485
+ from fastapi import HTTPException
486
+
487
+ from harness import filter_eval
488
+
489
+ try:
490
+ if topic.startswith("ut_"):
491
+ from routes_tables import ut_assembly
492
+ # ⚠ `consume_corrections=False` β€” the customer branch has always passed it and this
493
+ # one inherited a default nobody re-read. Creating an alert must not eat the one-shot
494
+ # field-name correction acks belonging to the `/workspace` refresh that exists to show
495
+ # them to the person who made the edit. Same defect `_evaluate`'s header records.
496
+ g = ut_assembly(session, topic,
497
+ storage_key=f"{session.tenant}:{topic}:{session.uname}",
498
+ consume_corrections=False, with_rows=False)
499
+ else:
500
+ from routes_customers import grid_assembly
501
+ g = grid_assembly(session, scope=topic, consume_corrections=False)
502
+ except HTTPException:
503
+ raise # it already names its own cause
504
+ except Exception as e: # noqa: BLE001
505
+ # Genuinely unexpected. Still a 503, and now it carries the exception TYPE β€” without it,
506
+ # the one path that reaches this branch is also the one path with nothing to debug from.
507
+ raise err(503, "unavailable",
508
+ f"the table could not be read ({type(e).__name__}) β€” try again in a moment")
509
+ view = _view_by_id(g, view_id)
510
+ if not isinstance(view, dict):
511
+ raise err(404, "no_view", "that view does not exist on this table")
512
+ nodes, _conj = filter_eval.tree_parts((view.get("config") or view).get("filters") or [])
513
+
514
+ # β›”β›” WAVE 33 Β· T29 β€” **CORRECTION: THE BLOCK BELOW IS TRUE ABOUT THE CODE AND FALSE ABOUT
515
+ # PRODUCTION, AND IT MUST BE READ SECOND.** It claims the missing-argument `TypeError` "IS
516
+ # owner item 17" β€” the owner's *"Something went wrong"*. It was not, and it could not have
517
+ # been: at `cbcf005`, the build the owner was using, the dict-read on `views` sat ~10 lines
518
+ # ABOVE this call and raised `AttributeError` on EVERY request, so the walk never reached the
519
+ # leaf and the arity bug was unreachable. `_view_by_id`'s own header records that fix.
520
+ #
521
+ # ⚠ WHY THE STALE PARAGRAPH STAYS RATHER THAN GETTING DELETED: the arity bug was real, the
522
+ # fix was right, and the three reasons it hid are the most transferable thing in this file.
523
+ # What was wrong is only its CLAIM TO BE THE CAUSE. Two comment blocks in one function each
524
+ # naming themselves as the origin of the same screenshot are mutually exclusive, and the next
525
+ # reader believes whichever they meet first β€” which is why this correction sits above rather
526
+ # than below. Caught by a verifier that read the SHIPPED file at the deployed commit instead
527
+ # of the working tree. [[grep-output-is-not-source]]
528
+ #
529
+ # β›”β›” WAVE 32 Β· T22 β€” **THE CALL BELOW WAS MISSING AN ARGUMENT** (and wave 32 believed, wrongly,
530
+ # that this was owner item 17 β€” see the correction directly above).
531
+ #
532
+ # `is_rule_active(rule, columns)` takes TWO parameters (`harness/filter_sql.py`; every other
533
+ # caller in the repo passes both). This one passed ONE, so the moment the walk reached a LEAF
534
+ # rule it raised `TypeError: is_rule_active() missing 1 required positional argument`.
535
+ #
536
+ # ⚠ READ WHAT THAT MEANS BEFORE FIXING ANYTHING ELSE: the walk only reaches a leaf when the
537
+ # view HAS a condition β€” and a view with a condition is the only kind an alert is allowed on.
538
+ # A view with no filters yields an empty `nodes`, so `_any_active` returns False without ever
539
+ # calling this, and the reader gets the honest 400 `no_filter`. **So the only path that
540
+ # worked was the refusal path: "Alert me about new records" had never once created an alert
541
+ # on a filtered view.** β›” And the raise lands OUTSIDE the `try` above, so it was not even the
542
+ # 503 β€” it was a bare FastAPI 500, which `alertsApi.errorMessage` renders as *"Something went
543
+ # wrong on our side. Try again in a moment."*, the owner's screenshot word for word.
544
+ #
545
+ # ⚠ THREE THINGS HID IT, and they are worth more than the fix. (1) Python does not check
546
+ # arity until the line RUNS, and this line runs only on the success path of a feature whose
547
+ # every test exercised its refusals. (2) The `no_filter` 400 above it is a real, correct,
548
+ # well-tested refusal, so the door looked alive. (3) `verify_alerts.py` asserts the refusal
549
+ # (`no_filter` reaches the user) and the transport β€” never a creation. A gate can be green,
550
+ # thorough and honest about everything except the one path the feature exists for.
551
+ #
552
+ # `_columns_map` is the DEFINITION of fields -> the membership set `is_rule_active` looks a
553
+ # column up in; building a second dict here would be a second answer to one question, which
554
+ # is this wave's other headline defect in a different file. Its leading underscore is a real
555
+ # smell and is BOOKED (PENDING, mailbox/C.md) rather than worked around.
556
+ columns = filter_eval._columns_map(g.get("fields") or [])
557
+
558
+ def _any_active(ns):
559
+ for n in ns or ():
560
+ if isinstance(n, dict) and isinstance(n.get("children"), list):
561
+ if _any_active(n["children"]):
562
+ return True
563
+ elif filter_eval.is_rule_active(n, columns):
564
+ return True
565
+ return False
566
+
567
+ if not _any_active(nodes):
568
+ raise err(400, "no_filter",
569
+ "this view has no active filter, so no record can ever ENTER it β€” add a "
570
+ "condition to the view first, then create the alert")
571
+
572
+
573
+ @router.delete("/alerts/{alert_id}")
574
+ def delete_alert(alert_id: str, session: Session = Depends(require_session)):
575
+ rec = next((r for r in alerts.list_alerts(st=session.runtime)
576
+ if str(r.get("id")) == str(alert_id)), None)
577
+ if rec is None:
578
+ raise err(404, "no_alert", "that alert does not exist")
579
+ if str(rec.get("owner")) != str(session.uname) and not session.admin:
580
+ raise err(403, "not_yours", "only the alert's owner (or an administrator) can delete it")
581
+ alerts.delete(alert_id, st=session.runtime)
582
+ return {"ok": True}
583
+
584
+
585
+ @router.post("/alerts/{alert_id}/run")
586
+ def run_alert(alert_id: str, session: Session = Depends(require_session)):
587
+ rec = next((r for r in alerts.list_alerts(user=session.uname, is_admin=session.admin,
588
+ st=session.runtime)
589
+ if str(r.get("id")) == str(alert_id)), None)
590
+ if rec is None:
591
+ raise err(404, "no_alert", "that alert does not exist")
592
+ return _evaluate(session, rec)
593
+
594
+
595
+ @router.get("/notifications")
596
+ def notifications(session: Session = Depends(require_session)):
597
+ """The inbox β€” RE-EVALUATED on read, which is a deliberate design choice.
598
+
599
+ ⭐ A-S1-2 RESOLVED THE OTHER WAY, and the reason is structural rather than a shortcut. The
600
+ plan was a push hook: the automation engine calls `after_write` when it lands rows. But
601
+ `run_async` runs on a BACKGROUND THREAD with no `Session` in scope, and an alert must be
602
+ evaluated as its OWNER (see `_evaluate`) β€” so a push hook would have to mint a session inside
603
+ a worker thread from a tenant runtime, which is exactly the kind of ad-hoc identity
604
+ construction that leaks scope.
605
+
606
+ Pulling on read has none of that: the caller IS a session, the assemblies are already
607
+ scope-cached, and the user cannot observe the difference β€” an inbox is only ever read by
608
+ someone opening it. The cost is that a notification is minted when you LOOK rather than when
609
+ the row landed, so the `at` stamp is detection time, not arrival time.
610
+
611
+ `after_write` stays exported for the day the engine can hand over a real identity.
612
+
613
+ ⭐⭐ W31-T24 β€” ONE ASSEMBLY PER (TOPIC, OWNER), NOT ONE PER ALERT.
614
+ β›” MEASURED FIRST, AND THE MEASUREMENT CORRECTS AN EARLIER READING OF IT. This route is
615
+ **20 ms in-process and 3,280 ms live** on tenant #0 β€” but tenant #0 has **ZERO alerts**
616
+ (censused 2026-08-12), so the 20 ms is an EMPTY LOOP and says nothing at all about what the
617
+ re-evaluation costs. The live 3,280 ms is the two store reads either side of that loop. So the
618
+ body below is not slow today; it is UNEXERCISED, and every alert a tenant creates adds a whole
619
+ grid assembly to an inbox poll. The memo turns O(alerts) into O(distinct topic Γ— owner), which
620
+ is the difference between "fine" and "three seconds per alert" the day somebody uses the
621
+ feature. ⚠ Making the read cheap by evaluating LESS is the obvious wrong fix and is not what
622
+ this does: every alert is still evaluated, against the same rows, in the same order.
623
+ """
624
+ assemblies = {}
625
+ for rec in alerts.list_alerts(user=session.uname, is_admin=False, st=session.runtime):
626
+ try:
627
+ _evaluate(session, rec, assemblies=assemblies)
628
+ except Exception: # noqa: BLE001
629
+ continue # one bad alert must not empty the pane
630
+ # ⭐ W32-T20 (C3): every item leaves through `inbox_view`, so a notification queued before
631
+ # this wave carries a `target` too. See `notification_view`'s header for why it is derived.
632
+ return inbox_view(alerts.inbox(session.uname, st=session.runtime))
633
+
634
+
635
+ @router.post("/notifications/read")
636
+ def read_notifications(body: dict = Body(default=None),
637
+ session: Session = Depends(require_session)):
638
+ body = body or {}
639
+ ids = body.get("ids")
640
+ if ids is not None and not isinstance(ids, list):
641
+ raise err(400, "bad_ids", "ids must be a list, or null to mark every notification")
642
+ # ⚠ THE SAME ENRICHMENT ON BOTH DOORS. `mark_read` returns a fresh inbox, and the Inbox
643
+ # module re-renders from it β€” an un-enriched answer here would strip `target` off every row
644
+ # the moment somebody marked one read, i.e. the feature would work until first use.
645
+ return inbox_view(alerts.mark_read(session.uname, ids, read=bool(body.get("read", True)),
646
+ st=session.runtime))
647
+
648
+
649
+ def after_write(session: Session, topic_key: str):
650
+ """THE WRITE HOOK β€” call after a write that could change what a view matches.
651
+
652
+ Exported as a plain function (not a route) so `core.grid_events`' callers and S2's automation
653
+ upserts reach it the same way. It never raises: an alert evaluation failing must not fail the
654
+ edit that triggered it.
655
+
656
+ ⭐ W31-T24 β€” it shares `/notifications`' memo shape for the same reason: a write that changes
657
+ one view can trip several alerts on the SAME topic, and each would otherwise rebuild the table.
658
+ ⚠ STILL ZERO PRODUCTION CALLERS (W31-T24 confirmed it; the route docstring above says why the
659
+ push hook was resolved the other way). Booked rather than wired: minting a session inside the
660
+ engine's worker thread is the ad-hoc identity construction this file exists to avoid.
661
+ """
662
+ try:
663
+ assemblies = {}
664
+ return alerts.after_write(topic_key, st=session.runtime,
665
+ runner=lambda rec: _evaluate(session, rec,
666
+ assemblies=assemblies))
667
+ except Exception: # noqa: BLE001
668
+ return {"evaluated": 0}
api/routes_customers.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_grid.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_keychain.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_nav.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_oauth.py CHANGED
@@ -1,114 +1,114 @@
1
- """routes_oauth.py β€” the OAuth connector surface (wave 22, contract C5 + A2/A3 / R12).
2
-
3
- Thin over `oauth_connect`, the way `routes_automation` is thin over the engine: sessions,
4
- shapes and status codes here; every decision that could be wrong lives in the module a gate
5
- can drive without a server. GENERIC over `{provider}` (C5-A2): the routes read the registry,
6
- so the day a second provider lands here is the day nothing in this file changes.
7
-
8
- MOUNTED FROM `routes_automation` (not `main.py`): this wave's ownership fence gives no session
9
- `main.py`, and `routes_automation` is already included there β€” so this router rides inside it
10
- (`/api/v1` + `/oauth/...`). Lifting the include into `main.py` later is a two-line change that
11
- alters no path.
12
-
13
- ⚠ THE TWO REDIRECT LAWS (A3): `/{provider}/start` answers **302 to the provider's consent
14
- screen** β€” it is a top-level navigation the client reaches by `<a href>`, never JSON. The
15
- callback 302s BACK to the return path the `state` carried (relative-only, sanitised by
16
- `oauth_connect.safe_next`), so the user lands where they left β€” connected or not, whatever
17
- went wrong rides in the query string; a dead-end error page where the app used to be reads as
18
- "the product broke", not "the connect failed".
19
- """
20
- import os
21
-
22
- from fastapi import APIRouter, Depends, Request
23
- from fastapi.responses import RedirectResponse
24
-
25
- import oauth_connect
26
- from deps import Session, err, require_session
27
-
28
- router = APIRouter(prefix="/oauth")
29
-
30
-
31
- def _redirect_uri(request: Request, provider: str) -> str:
32
- """The redirect URI this deployment registers at the provider β€” env-pinned when the
33
- container sits behind a proxy that rewrites the scheme (the HF Space), else derived from
34
- the request. MUST match a console-registered URI verbatim, so it is computed in exactly
35
- one place.
36
-
37
- ⭐ WAVE 29 (R4): `deploy_web.py` now PUSHES `AIOS_PUBLIC_BASE` on every deploy, defaulted to
38
- the same URL as `APP_BASE_URL`, so the pinned branch is the one that runs in production and
39
- the request-derived fallback below is effectively dev-only.
40
- β›” THAT MAKES THIS FUNCTION A CUSTOM-DOMAIN COUPLING, not merely a scheme fix. Whatever host
41
- this returns is where the provider sends the user BACK, and the session cookie is host-only
42
- (`aios_session.py:114-117`, no `domain=`) β€” so a callback base that disagrees with the host
43
- the user actually browsed plants the session on the wrong hostname and they return logged
44
- out. Moving the app to a new hostname means moving this value AND re-registering the
45
- resulting URI in the provider console; one without the other fails closed.
46
- Runbook: `.claude/wiki/research/loopable-domain-runbook.md`."""
47
- base = (os.environ.get("AIOS_PUBLIC_BASE") or "").strip().rstrip("/")
48
- if not base:
49
- base = f"{request.url.scheme}://{request.url.netloc}"
50
- return f"{base}/api/v1/oauth/{provider}/callback"
51
-
52
-
53
- @router.get("/status")
54
- def oauth_status(session: Session = Depends(require_session)):
55
- """C5's status shape for the SESSION user, one entry per registry provider:
56
- `{google: {connected, email, reconnect, configured}}` today. The bit the email trigger's
57
- `ready` reads through."""
58
- return oauth_connect.status(session.runtime, session.uname)
59
-
60
-
61
- def _offered_or_404(provider: str):
62
- """β›”β›” W32-T14 / OWNER ITEM 12 / R6 β€” A PROVIDER THE PRODUCT DOES NOT OFFER HAS NO DOOR.
63
-
64
- The owner pasted the failure this replaces: clicking Connect on the Google card answered
65
- `503 oauth_unavailable` as raw JSON. R6's fix is not a nicer error β€” it is that the flow is
66
- not offered at all, so the honest status is the one for a URL that does not exist. `404`
67
- rather than `503`, deliberately: a 503 says *"come back later"* about a door that is not
68
- coming back until somebody pays for CASA verification (D-45, ~$540–1,800/yr).
69
-
70
- ⚠ Every door in this router goes through it, START included, because the JSON the owner saw
71
- came from the start route and a guard on one leg is a guard on one leg.
72
- """
73
- if not oauth_connect.offered(provider):
74
- raise err(404, "unknown_provider", f"{provider!r} is not a connectable provider")
75
-
76
-
77
- @router.get("/{provider}/start")
78
- def oauth_start(provider: str, request: Request, next: str = "",
79
- session: Session = Depends(require_session)):
80
- """302 to the provider's consent screen (A3 β€” a navigation, never JSON). `?next=` is the
81
- RELATIVE path the callback returns the browser to; it rides inside the single-use state,
82
- sanitised, so the round trip cannot be steered off-origin."""
83
- _offered_or_404(provider)
84
- url, problem = oauth_connect.start(provider, session.uname,
85
- _redirect_uri(request, provider), next_path=next)
86
- if problem:
87
- raise err(503 if "not configured" in problem else 404, "oauth_unavailable", problem)
88
- return RedirectResponse(url, status_code=302)
89
-
90
-
91
- @router.get("/{provider}/callback")
92
- def oauth_callback(provider: str, request: Request,
93
- session: Session = Depends(require_session),
94
- state: str = "", code: str = "", error: str = ""):
95
- """The provider's redirect target. Exchanges the code, stores the per-user slot, and sends
96
- the browser back to the state's return path β€” connected or not (see module header)."""
97
- _offered_or_404(provider)
98
- if error:
99
- home = "/#/"
100
- return RedirectResponse(f"{home}?oauthError={error[:80]}", status_code=302)
101
- email, home, problem = oauth_connect.callback(session.runtime, session.uname, state, code)
102
- sep = "&" if "?" in home else "?"
103
- if problem:
104
- return RedirectResponse(f"{home}{sep}oauthError=connect_failed", status_code=302)
105
- return RedirectResponse(f"{home}{sep}connected={provider}", status_code=302)
106
-
107
-
108
- @router.post("/{provider}/disconnect")
109
- def oauth_disconnect(provider: str, session: Session = Depends(require_session)):
110
- _offered_or_404(provider)
111
- if oauth_connect.provider_def(provider) is None:
112
- raise err(404, "unknown_provider", f"{provider!r} is not a connectable provider")
113
- oauth_connect.disconnect(session.runtime, session.uname, provider)
114
- return {"disconnected": provider}
 
1
+ """routes_oauth.py β€” the OAuth connector surface (wave 22, contract C5 + A2/A3 / R12).
2
+
3
+ Thin over `oauth_connect`, the way `routes_automation` is thin over the engine: sessions,
4
+ shapes and status codes here; every decision that could be wrong lives in the module a gate
5
+ can drive without a server. GENERIC over `{provider}` (C5-A2): the routes read the registry,
6
+ so the day a second provider lands here is the day nothing in this file changes.
7
+
8
+ MOUNTED FROM `routes_automation` (not `main.py`): this wave's ownership fence gives no session
9
+ `main.py`, and `routes_automation` is already included there β€” so this router rides inside it
10
+ (`/api/v1` + `/oauth/...`). Lifting the include into `main.py` later is a two-line change that
11
+ alters no path.
12
+
13
+ ⚠ THE TWO REDIRECT LAWS (A3): `/{provider}/start` answers **302 to the provider's consent
14
+ screen** β€” it is a top-level navigation the client reaches by `<a href>`, never JSON. The
15
+ callback 302s BACK to the return path the `state` carried (relative-only, sanitised by
16
+ `oauth_connect.safe_next`), so the user lands where they left β€” connected or not, whatever
17
+ went wrong rides in the query string; a dead-end error page where the app used to be reads as
18
+ "the product broke", not "the connect failed".
19
+ """
20
+ import os
21
+
22
+ from fastapi import APIRouter, Depends, Request
23
+ from fastapi.responses import RedirectResponse
24
+
25
+ import oauth_connect
26
+ from deps import Session, err, require_session
27
+
28
+ router = APIRouter(prefix="/oauth")
29
+
30
+
31
+ def _redirect_uri(request: Request, provider: str) -> str:
32
+ """The redirect URI this deployment registers at the provider β€” env-pinned when the
33
+ container sits behind a proxy that rewrites the scheme (the HF Space), else derived from
34
+ the request. MUST match a console-registered URI verbatim, so it is computed in exactly
35
+ one place.
36
+
37
+ ⭐ WAVE 29 (R4): `deploy_web.py` now PUSHES `AIOS_PUBLIC_BASE` on every deploy, defaulted to
38
+ the same URL as `APP_BASE_URL`, so the pinned branch is the one that runs in production and
39
+ the request-derived fallback below is effectively dev-only.
40
+ β›” THAT MAKES THIS FUNCTION A CUSTOM-DOMAIN COUPLING, not merely a scheme fix. Whatever host
41
+ this returns is where the provider sends the user BACK, and the session cookie is host-only
42
+ (`aios_session.py:114-117`, no `domain=`) β€” so a callback base that disagrees with the host
43
+ the user actually browsed plants the session on the wrong hostname and they return logged
44
+ out. Moving the app to a new hostname means moving this value AND re-registering the
45
+ resulting URI in the provider console; one without the other fails closed.
46
+ Runbook: `.claude/wiki/research/loopable-domain-runbook.md`."""
47
+ base = (os.environ.get("AIOS_PUBLIC_BASE") or "").strip().rstrip("/")
48
+ if not base:
49
+ base = f"{request.url.scheme}://{request.url.netloc}"
50
+ return f"{base}/api/v1/oauth/{provider}/callback"
51
+
52
+
53
+ @router.get("/status")
54
+ def oauth_status(session: Session = Depends(require_session)):
55
+ """C5's status shape for the SESSION user, one entry per registry provider:
56
+ `{google: {connected, email, reconnect, configured}}` today. The bit the email trigger's
57
+ `ready` reads through."""
58
+ return oauth_connect.status(session.runtime, session.uname)
59
+
60
+
61
+ def _offered_or_404(provider: str):
62
+ """β›”β›” W32-T14 / OWNER ITEM 12 / R6 β€” A PROVIDER THE PRODUCT DOES NOT OFFER HAS NO DOOR.
63
+
64
+ The owner pasted the failure this replaces: clicking Connect on the Google card answered
65
+ `503 oauth_unavailable` as raw JSON. R6's fix is not a nicer error β€” it is that the flow is
66
+ not offered at all, so the honest status is the one for a URL that does not exist. `404`
67
+ rather than `503`, deliberately: a 503 says *"come back later"* about a door that is not
68
+ coming back until somebody pays for CASA verification (D-45, ~$540–1,800/yr).
69
+
70
+ ⚠ Every door in this router goes through it, START included, because the JSON the owner saw
71
+ came from the start route and a guard on one leg is a guard on one leg.
72
+ """
73
+ if not oauth_connect.offered(provider):
74
+ raise err(404, "unknown_provider", f"{provider!r} is not a connectable provider")
75
+
76
+
77
+ @router.get("/{provider}/start")
78
+ def oauth_start(provider: str, request: Request, next: str = "",
79
+ session: Session = Depends(require_session)):
80
+ """302 to the provider's consent screen (A3 β€” a navigation, never JSON). `?next=` is the
81
+ RELATIVE path the callback returns the browser to; it rides inside the single-use state,
82
+ sanitised, so the round trip cannot be steered off-origin."""
83
+ _offered_or_404(provider)
84
+ url, problem = oauth_connect.start(provider, session.uname,
85
+ _redirect_uri(request, provider), next_path=next)
86
+ if problem:
87
+ raise err(503 if "not configured" in problem else 404, "oauth_unavailable", problem)
88
+ return RedirectResponse(url, status_code=302)
89
+
90
+
91
+ @router.get("/{provider}/callback")
92
+ def oauth_callback(provider: str, request: Request,
93
+ session: Session = Depends(require_session),
94
+ state: str = "", code: str = "", error: str = ""):
95
+ """The provider's redirect target. Exchanges the code, stores the per-user slot, and sends
96
+ the browser back to the state's return path β€” connected or not (see module header)."""
97
+ _offered_or_404(provider)
98
+ if error:
99
+ home = "/#/"
100
+ return RedirectResponse(f"{home}?oauthError={error[:80]}", status_code=302)
101
+ email, home, problem = oauth_connect.callback(session.runtime, session.uname, state, code)
102
+ sep = "&" if "?" in home else "?"
103
+ if problem:
104
+ return RedirectResponse(f"{home}{sep}oauthError=connect_failed", status_code=302)
105
+ return RedirectResponse(f"{home}{sep}connected={provider}", status_code=302)
106
+
107
+
108
+ @router.post("/{provider}/disconnect")
109
+ def oauth_disconnect(provider: str, session: Session = Depends(require_session)):
110
+ _offered_or_404(provider)
111
+ if oauth_connect.provider_def(provider) is None:
112
+ raise err(404, "unknown_provider", f"{provider!r} is not a connectable provider")
113
+ oauth_connect.disconnect(session.runtime, session.uname, provider)
114
+ return {"disconnected": provider}
api/routes_products.py CHANGED
@@ -433,14 +433,34 @@ def products(session: Session = Depends(module_gate(MODULE))):
433
  enforced as a post-filter yields a correct row list carrying both units' numbers.
434
  """
435
  import aios_grid
 
436
 
437
  g = product_assembly(session)
438
  rows = aios_grid.rows_from_pool(
439
  g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
440
  rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  return {"fields": g["fields"], "rows": rows,
442
  "today": g["today"], "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
443
  "identity": {"pid": "pid", "businessKey": "code"},
 
444
  "scope": {"team_id": g["team_id"], "consolidated": g["team_id"] is None}}
445
 
446
 
 
433
  enforced as a post-filter yields a correct row list carrying both units' numbers.
434
  """
435
  import aios_grid
436
+ import modules.product_data as pd
437
 
438
  g = product_assembly(session)
439
  rows = aios_grid.rows_from_pool(
440
  g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
441
  rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"])
442
+ # ⭐⭐ W41-T17 (owner instruction 25) β€” THE IDENTITY REPORT RIDES THE ENVELOPE, because a
443
+ # report nothing reads is not a report. `product_data.identity_report` is standing rule 1's
444
+ # second sentence as data for this grid's two identity columns: `product_id` (the Odoo
445
+ # `product.product` id, newly declared in `aios_grid_fields.json`) and `code` (the business
446
+ # key, which 33 active products do not have and wear a `pid:<id>` fallback for instead).
447
+ #
448
+ # β›” BUILT OVER `rows_src`, THE WALLED ROWS, NOT OVER THE CATALOGUE. `scoped_pool` runs
449
+ # `perm_scope.apply_row_scope` before these pids are taken, so a reader with a permanent
450
+ # filter sees fewer products than Odoo has; counting the catalogue here would print 33 beside
451
+ # a grid that does not contain 33 such rows, and a number that disagrees with the screen is
452
+ # believed anyway. It is the same list `rows_from_pool` was just handed, so the two cannot
453
+ # drift.
454
+ #
455
+ # ⚠ A NEW TOP-LEVEL KEY, NOT A MEMBER OF `identity`. `verify_api`'s W16 section asserts
456
+ # `body["identity"] == {"pid": "pid", "businessKey": "code"}` by EQUALITY, so folding the
457
+ # report in there would red a gate outside this ticket's fence for a purely cosmetic nesting.
458
+ # The envelope check beside it filters to the four `/customers` keys, so an additive key is
459
+ # the shape this route already established with `identity` and `scope`.
460
  return {"fields": g["fields"], "rows": rows,
461
  "today": g["today"], "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
462
  "identity": {"pid": "pid", "businessKey": "code"},
463
+ "identity_report": pd.identity_report(g["rows_src"]),
464
  "scope": {"team_id": g["team_id"], "consolidated": g["team_id"] is None}}
465
 
466
 
api/routes_publish.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_records.py CHANGED
@@ -1,211 +1,211 @@
1
- """Record detail routes: durable comments, scoped to the caller's book β€” ON EVERY DATABASE.
2
-
3
- ⭐ WAVE 19 (owner item 12). This file used to be the CUSTOMER record's comment routes with a
4
- customer-shaped wall bolted to the module import line: `_in_book` asked
5
- `routes_customers.allowed_pids` whatever surface the browser was on. Opening a PRODUCT record and
6
- typing a comment therefore asked the customer book about a CRC32 hash of a SKU code, and the
7
- panel answered "that customer is not in your book" β€” the owner's report. The dangerous half is
8
- the one nobody sees: a hash that collides with a real partner id passes the wall, and the comment
9
- is filed against somebody's customer where the whole team can read it.
10
-
11
- THE SHAPE NOW: `?scope=` names the database (the same vocabulary `/workspace?scope=` and the
12
- events route's `scopeKey` already speak), and `_pool_or_refuse` resolves BOTH halves of the wall
13
- per scope β€” the GRANT and the ROW SET β€” by asking that topic's own route, never by re-deriving
14
- one here:
15
-
16
- customer / cohort `routes_customers.allowed_pids` behind the `customer_data` grant
17
- product `routes_products.scoped_pool` behind the `product_data` grant
18
- ut_<slug> `routes_tables.scoped_pids`, whose `_defn_or_refuse` IS the wall
19
- (404 unknown / 403 not yours β€” a user table has no module grant).
20
- ⭐ W33-T03/D-183: `scoped_pIDs`, not `scoped_pOOL` β€” the pool builds every
21
- ROW to derive a pid set this module discards, and RAISES 409 on a
22
- read-through grid past one window, which is why the record drawer painted
23
- an error page on `ut_odoo_gl_lines`.
24
-
25
- ⚠ THE PATH KEEPS ITS `/customers/` SEGMENT. It is the shipped URL and `verify_api.py`'s E1a
26
- section pins it; the scope now travels beside it explicitly. A nicer noun is not worth churning
27
- another session's gate mid-wave β€” the WALL is the query parameter, not the word.
28
-
29
- ⚠ NO DEFAULT BEYOND THE LEGACY ONE. An absent `scope` means `customer`, which is what every
30
- shipped client sent and what keeps the old callers byte-identical; an UNRECOGNISED scope is a
31
- 400, never a silent fallback to the customer book (`routes_grid._scope_or_400`'s rule, and for
32
- the same reason: a typo served as `customer` answers a question nobody asked).
33
- """
34
- from fastapi import APIRouter, Body, Depends, Query
35
-
36
- from deps import Session, err, require_session
37
-
38
- router = APIRouter(prefix="/api/v1")
39
-
40
- #: The customer topic's two names β€” one book, two surfaces (the Cohort page is the customer table
41
- #: over hand-curated sets). Mirrors `modules.cohort.LEGACY_SCOPES` / `core.record_comments`.
42
- _CUSTOMER_SCOPES = ("", "customer", "cohort")
43
-
44
-
45
- def _scope_or_400(raw):
46
- scope = str(raw or "customer").strip().lower()
47
- if scope in _CUSTOMER_SCOPES or scope == "product" or scope.startswith("ut_"):
48
- return "customer" if scope in _CUSTOMER_SCOPES else scope
49
- raise err(400, "bad_scope",
50
- "scope must be customer, cohort, product or a ut_ database β€” refusing to guess")
51
-
52
-
53
- def _pool_or_refuse(session: Session, scope: str):
54
- """The pids this session may attach comments to ON THIS DATABASE β€” grant wall included.
55
-
56
- Returns **`(pids, unbounded)`** β€” a 2-tuple on EVERY branch. Raises the topic's own 403/404/503,
57
- so a caller who may not open the surface never learns anything about the row they asked about.
58
-
59
- β›” `unbounded` is TRUE only when the row set could not be ENUMERATED (a read-through grid past
60
- one window), never when it is merely EMPTY. Those are opposite answers and `scoped_pids` returns
61
- `frozenset()` for both β€” see the branch below.
62
- ⚠ THE SHAPE IS A CONTRACT EVEN THOUGH THIS FUNCTION IS PRIVATE, and it has two consumers that
63
- do not travel together: `_in_book` here, and a NEGATIVE CONTROL in `aios-web/api/verify_scopes.py`
64
- that REPLACES this function with its own lambda. A gate's test double is a caller
65
- ([[test-double-patched-by-a-name-list]]); when this signature moved, that double kept returning a
66
- bare frozenset and the section died on `ValueError: too many values to unpack` β€” no tally, no
67
- failing name. Change the shape here and that double changes with it.
68
- """
69
- if scope == "product":
70
- from routes_products import MODULE as PRODUCT_MODULE, scoped_pool
71
-
72
- session.require(PRODUCT_MODULE)
73
- pids, _team, _rows, _fields = scoped_pool(session)
74
- # ⚠ `(pids, unbounded)` on EVERY branch. This one returned a bare frozenset for ten minutes
75
- # after the `ut_` branch grew its second element, and `_in_book`'s unpack would have raised
76
- # a `TypeError` β€” a 500 on every product comment β€” while both other branches worked. A
77
- # return shape is a contract even when the function is private.
78
- return (pids, False)
79
- if scope.startswith("ut_"):
80
- # No module grant exists for a user table β€” `_defn_or_refuse` inside `scoped_pids` IS
81
- # the wall (creator or admin, fail-closed), and it answers 404 before 403 exactly as the
82
- # rows routes do.
83
- #
84
- # ⭐⭐ W33-T03 / D-183 β€” `scoped_pids`, NOT `scoped_pool`, AND THAT ONE WORD IS THE BUG.
85
- # `scoped_pool` builds every ROW to derive a pid set this function then throws away, and on
86
- # a read-through grid larger than one window it RAISES `409 window_required`. So opening the
87
- # record drawer on `ut_odoo_gl_lines` (975,137 rows) painted an error page β€” for a panel
88
- # that renders comments about ONE row it already has. `scoped_pids` answers the identical
89
- # question (its docstring: *"the pid set is IDENTICAL, not merely equivalent"*) and takes
90
- # W31-T20's `limits` OUT-PARAMETER instead of raising, which is the same shape `/workspace`
91
- # used to become openable on those two grids.
92
- from routes_tables import scoped_pids
93
-
94
- limits = []
95
- pids, _fields, _defn = scoped_pids(session, scope, limits=limits)
96
- # β›” AN EMPTY PID SET AND AN UNRESOLVABLE ONE ARE OPPOSITE ANSWERS, and collapsing them is
97
- # how a fail-closed default becomes a lie. `scoped_pids` returns `frozenset()` BOTH for a
98
- # database with no rows and for a read-through grid too big to enumerate β€” it distinguishes
99
- # them by APPENDING R6's sentence to `limits`. Without this branch the drawer would move
100
- # from a 409 error page to a 403 "not in your book" on a row the user is looking at, which
101
- # is the same defect wearing a politer message ([[empty-answer-vs-unfinished-answer]]).
102
- # ⚠ ADMITTING HERE IS NOT A WIDENING, and the ruling is wave 27 / D-72: on a `ut_*` database
103
- # THE TENANT IS THE UNIT β€” `scoped_pool` itself carries "no per-row owner filter; the
104
- # table-level wall is the WHOLE wall". `_defn_or_refuse` has already run inside
105
- # `scoped_pids` and answered 404/403. The pid set was only ever an existence check.
106
- return (pids, bool(limits))
107
- from routes_customers import MODULE as CUSTOMER_MODULE, allowed_pids
108
-
109
- session.require(CUSTOMER_MODULE)
110
- return (frozenset(allowed_pids(session)), False)
111
-
112
-
113
- def _in_book(pid, session, scope):
114
- pids, unbounded = _pool_or_refuse(session, scope)
115
- if unbounded:
116
- # The table wall passed and the row set is larger than this process will enumerate. Said
117
- # out loud rather than silently admitting: R6's second sentence is that a limit which
118
- # cannot be removed gets REPORTED, and this is the one place the report has no envelope to
119
- # ride in.
120
- print(f"[records] {scope}: pid membership unresolved (read-through beyond one window) β€” "
121
- f"admitting on the table wall alone, per D-72")
122
- return
123
- if pid not in pids:
124
- # 403, not 404: the record may exist, but this session may not inspect it.
125
- raise err(403, "out_of_scope", "that record is not in your book")
126
-
127
-
128
- def _unavailable():
129
- return err(
130
- 503,
131
- "store_unavailable",
132
- "record comments are temporarily unavailable β€” no change was saved",
133
- )
134
-
135
-
136
- # ⭐ WAVE 21 (D-17): the CANONICAL path is /records/{pid}/comments β€” comments hang off a RECORD
137
- # in whatever topic `?scope=` names, and the customer-flavoured noun was wave-19 residue (the
138
- # wall was always the query param). The old path stays as an ALIAS because the shipped client
139
- # still calls it; verify_api pins the canonical path AND that the alias answers, so removing
140
- # the alias later is a decision, never an accident.
141
- @router.get("/records/{pid}/comments")
142
- @router.get("/customers/{pid}/comments")
143
- def comments(pid: int, scope: str = Query(default="customer"),
144
- session: Session = Depends(require_session)):
145
- from core import record_comments
146
-
147
- scope = _scope_or_400(scope)
148
- _in_book(pid, session, scope)
149
- try:
150
- rows = record_comments.list_comments(session.runtime, pid, scope=scope)
151
- except record_comments.CommentsUnavailable:
152
- raise _unavailable()
153
- return {"comments": rows}
154
-
155
-
156
- @router.post("/records/{pid}/comments", status_code=201)
157
- @router.post("/customers/{pid}/comments", status_code=201)
158
- def create_comment(
159
- pid: int,
160
- body: dict = Body(default=None),
161
- scope: str = Query(default="customer"),
162
- session: Session = Depends(require_session),
163
- ):
164
- from core import record_comments
165
-
166
- scope = _scope_or_400(scope)
167
- _in_book(pid, session, scope)
168
- try:
169
- comment = record_comments.add_comment(
170
- session.runtime,
171
- pid,
172
- (body or {}).get("body"),
173
- session.uname,
174
- session.user.get("name") or session.uname,
175
- scope=scope,
176
- )
177
- except ValueError as exc:
178
- raise err(400, "bad_comment", str(exc))
179
- except record_comments.CommentsUnavailable:
180
- raise _unavailable()
181
- return {"comment": comment}
182
-
183
-
184
- @router.delete("/records/{pid}/comments/{comment_id}")
185
- @router.delete("/customers/{pid}/comments/{comment_id}")
186
- def remove_comment(
187
- pid: int,
188
- comment_id: str,
189
- scope: str = Query(default="customer"),
190
- session: Session = Depends(require_session),
191
- ):
192
- from core import record_comments
193
-
194
- scope = _scope_or_400(scope)
195
- _in_book(pid, session, scope)
196
- try:
197
- deleted = record_comments.delete_comment(
198
- session.runtime,
199
- pid,
200
- comment_id,
201
- session.uname,
202
- admin=session.admin,
203
- scope=scope,
204
- )
205
- except record_comments.CommentForbidden:
206
- raise err(403, "comment_forbidden", "only the author may delete this comment")
207
- except record_comments.CommentsUnavailable:
208
- raise _unavailable()
209
- if not deleted:
210
- raise err(404, "comment_not_found", "that comment no longer exists")
211
- return {"ok": True, "id": comment_id}
 
1
+ """Record detail routes: durable comments, scoped to the caller's book β€” ON EVERY DATABASE.
2
+
3
+ ⭐ WAVE 19 (owner item 12). This file used to be the CUSTOMER record's comment routes with a
4
+ customer-shaped wall bolted to the module import line: `_in_book` asked
5
+ `routes_customers.allowed_pids` whatever surface the browser was on. Opening a PRODUCT record and
6
+ typing a comment therefore asked the customer book about a CRC32 hash of a SKU code, and the
7
+ panel answered "that customer is not in your book" β€” the owner's report. The dangerous half is
8
+ the one nobody sees: a hash that collides with a real partner id passes the wall, and the comment
9
+ is filed against somebody's customer where the whole team can read it.
10
+
11
+ THE SHAPE NOW: `?scope=` names the database (the same vocabulary `/workspace?scope=` and the
12
+ events route's `scopeKey` already speak), and `_pool_or_refuse` resolves BOTH halves of the wall
13
+ per scope β€” the GRANT and the ROW SET β€” by asking that topic's own route, never by re-deriving
14
+ one here:
15
+
16
+ customer / cohort `routes_customers.allowed_pids` behind the `customer_data` grant
17
+ product `routes_products.scoped_pool` behind the `product_data` grant
18
+ ut_<slug> `routes_tables.scoped_pids`, whose `_defn_or_refuse` IS the wall
19
+ (404 unknown / 403 not yours β€” a user table has no module grant).
20
+ ⭐ W33-T03/D-183: `scoped_pIDs`, not `scoped_pOOL` β€” the pool builds every
21
+ ROW to derive a pid set this module discards, and RAISES 409 on a
22
+ read-through grid past one window, which is why the record drawer painted
23
+ an error page on `ut_odoo_gl_lines`.
24
+
25
+ ⚠ THE PATH KEEPS ITS `/customers/` SEGMENT. It is the shipped URL and `verify_api.py`'s E1a
26
+ section pins it; the scope now travels beside it explicitly. A nicer noun is not worth churning
27
+ another session's gate mid-wave β€” the WALL is the query parameter, not the word.
28
+
29
+ ⚠ NO DEFAULT BEYOND THE LEGACY ONE. An absent `scope` means `customer`, which is what every
30
+ shipped client sent and what keeps the old callers byte-identical; an UNRECOGNISED scope is a
31
+ 400, never a silent fallback to the customer book (`routes_grid._scope_or_400`'s rule, and for
32
+ the same reason: a typo served as `customer` answers a question nobody asked).
33
+ """
34
+ from fastapi import APIRouter, Body, Depends, Query
35
+
36
+ from deps import Session, err, require_session
37
+
38
+ router = APIRouter(prefix="/api/v1")
39
+
40
+ #: The customer topic's two names β€” one book, two surfaces (the Cohort page is the customer table
41
+ #: over hand-curated sets). Mirrors `modules.cohort.LEGACY_SCOPES` / `core.record_comments`.
42
+ _CUSTOMER_SCOPES = ("", "customer", "cohort")
43
+
44
+
45
+ def _scope_or_400(raw):
46
+ scope = str(raw or "customer").strip().lower()
47
+ if scope in _CUSTOMER_SCOPES or scope == "product" or scope.startswith("ut_"):
48
+ return "customer" if scope in _CUSTOMER_SCOPES else scope
49
+ raise err(400, "bad_scope",
50
+ "scope must be customer, cohort, product or a ut_ database β€” refusing to guess")
51
+
52
+
53
+ def _pool_or_refuse(session: Session, scope: str):
54
+ """The pids this session may attach comments to ON THIS DATABASE β€” grant wall included.
55
+
56
+ Returns **`(pids, unbounded)`** β€” a 2-tuple on EVERY branch. Raises the topic's own 403/404/503,
57
+ so a caller who may not open the surface never learns anything about the row they asked about.
58
+
59
+ β›” `unbounded` is TRUE only when the row set could not be ENUMERATED (a read-through grid past
60
+ one window), never when it is merely EMPTY. Those are opposite answers and `scoped_pids` returns
61
+ `frozenset()` for both β€” see the branch below.
62
+ ⚠ THE SHAPE IS A CONTRACT EVEN THOUGH THIS FUNCTION IS PRIVATE, and it has two consumers that
63
+ do not travel together: `_in_book` here, and a NEGATIVE CONTROL in `aios-web/api/verify_scopes.py`
64
+ that REPLACES this function with its own lambda. A gate's test double is a caller
65
+ ([[test-double-patched-by-a-name-list]]); when this signature moved, that double kept returning a
66
+ bare frozenset and the section died on `ValueError: too many values to unpack` β€” no tally, no
67
+ failing name. Change the shape here and that double changes with it.
68
+ """
69
+ if scope == "product":
70
+ from routes_products import MODULE as PRODUCT_MODULE, scoped_pool
71
+
72
+ session.require(PRODUCT_MODULE)
73
+ pids, _team, _rows, _fields = scoped_pool(session)
74
+ # ⚠ `(pids, unbounded)` on EVERY branch. This one returned a bare frozenset for ten minutes
75
+ # after the `ut_` branch grew its second element, and `_in_book`'s unpack would have raised
76
+ # a `TypeError` β€” a 500 on every product comment β€” while both other branches worked. A
77
+ # return shape is a contract even when the function is private.
78
+ return (pids, False)
79
+ if scope.startswith("ut_"):
80
+ # No module grant exists for a user table β€” `_defn_or_refuse` inside `scoped_pids` IS
81
+ # the wall (creator or admin, fail-closed), and it answers 404 before 403 exactly as the
82
+ # rows routes do.
83
+ #
84
+ # ⭐⭐ W33-T03 / D-183 β€” `scoped_pids`, NOT `scoped_pool`, AND THAT ONE WORD IS THE BUG.
85
+ # `scoped_pool` builds every ROW to derive a pid set this function then throws away, and on
86
+ # a read-through grid larger than one window it RAISES `409 window_required`. So opening the
87
+ # record drawer on `ut_odoo_gl_lines` (975,137 rows) painted an error page β€” for a panel
88
+ # that renders comments about ONE row it already has. `scoped_pids` answers the identical
89
+ # question (its docstring: *"the pid set is IDENTICAL, not merely equivalent"*) and takes
90
+ # W31-T20's `limits` OUT-PARAMETER instead of raising, which is the same shape `/workspace`
91
+ # used to become openable on those two grids.
92
+ from routes_tables import scoped_pids
93
+
94
+ limits = []
95
+ pids, _fields, _defn = scoped_pids(session, scope, limits=limits)
96
+ # β›” AN EMPTY PID SET AND AN UNRESOLVABLE ONE ARE OPPOSITE ANSWERS, and collapsing them is
97
+ # how a fail-closed default becomes a lie. `scoped_pids` returns `frozenset()` BOTH for a
98
+ # database with no rows and for a read-through grid too big to enumerate β€” it distinguishes
99
+ # them by APPENDING R6's sentence to `limits`. Without this branch the drawer would move
100
+ # from a 409 error page to a 403 "not in your book" on a row the user is looking at, which
101
+ # is the same defect wearing a politer message ([[empty-answer-vs-unfinished-answer]]).
102
+ # ⚠ ADMITTING HERE IS NOT A WIDENING, and the ruling is wave 27 / D-72: on a `ut_*` database
103
+ # THE TENANT IS THE UNIT β€” `scoped_pool` itself carries "no per-row owner filter; the
104
+ # table-level wall is the WHOLE wall". `_defn_or_refuse` has already run inside
105
+ # `scoped_pids` and answered 404/403. The pid set was only ever an existence check.
106
+ return (pids, bool(limits))
107
+ from routes_customers import MODULE as CUSTOMER_MODULE, allowed_pids
108
+
109
+ session.require(CUSTOMER_MODULE)
110
+ return (frozenset(allowed_pids(session)), False)
111
+
112
+
113
+ def _in_book(pid, session, scope):
114
+ pids, unbounded = _pool_or_refuse(session, scope)
115
+ if unbounded:
116
+ # The table wall passed and the row set is larger than this process will enumerate. Said
117
+ # out loud rather than silently admitting: R6's second sentence is that a limit which
118
+ # cannot be removed gets REPORTED, and this is the one place the report has no envelope to
119
+ # ride in.
120
+ print(f"[records] {scope}: pid membership unresolved (read-through beyond one window) β€” "
121
+ f"admitting on the table wall alone, per D-72")
122
+ return
123
+ if pid not in pids:
124
+ # 403, not 404: the record may exist, but this session may not inspect it.
125
+ raise err(403, "out_of_scope", "that record is not in your book")
126
+
127
+
128
+ def _unavailable():
129
+ return err(
130
+ 503,
131
+ "store_unavailable",
132
+ "record comments are temporarily unavailable β€” no change was saved",
133
+ )
134
+
135
+
136
+ # ⭐ WAVE 21 (D-17): the CANONICAL path is /records/{pid}/comments β€” comments hang off a RECORD
137
+ # in whatever topic `?scope=` names, and the customer-flavoured noun was wave-19 residue (the
138
+ # wall was always the query param). The old path stays as an ALIAS because the shipped client
139
+ # still calls it; verify_api pins the canonical path AND that the alias answers, so removing
140
+ # the alias later is a decision, never an accident.
141
+ @router.get("/records/{pid}/comments")
142
+ @router.get("/customers/{pid}/comments")
143
+ def comments(pid: int, scope: str = Query(default="customer"),
144
+ session: Session = Depends(require_session)):
145
+ from core import record_comments
146
+
147
+ scope = _scope_or_400(scope)
148
+ _in_book(pid, session, scope)
149
+ try:
150
+ rows = record_comments.list_comments(session.runtime, pid, scope=scope)
151
+ except record_comments.CommentsUnavailable:
152
+ raise _unavailable()
153
+ return {"comments": rows}
154
+
155
+
156
+ @router.post("/records/{pid}/comments", status_code=201)
157
+ @router.post("/customers/{pid}/comments", status_code=201)
158
+ def create_comment(
159
+ pid: int,
160
+ body: dict = Body(default=None),
161
+ scope: str = Query(default="customer"),
162
+ session: Session = Depends(require_session),
163
+ ):
164
+ from core import record_comments
165
+
166
+ scope = _scope_or_400(scope)
167
+ _in_book(pid, session, scope)
168
+ try:
169
+ comment = record_comments.add_comment(
170
+ session.runtime,
171
+ pid,
172
+ (body or {}).get("body"),
173
+ session.uname,
174
+ session.user.get("name") or session.uname,
175
+ scope=scope,
176
+ )
177
+ except ValueError as exc:
178
+ raise err(400, "bad_comment", str(exc))
179
+ except record_comments.CommentsUnavailable:
180
+ raise _unavailable()
181
+ return {"comment": comment}
182
+
183
+
184
+ @router.delete("/records/{pid}/comments/{comment_id}")
185
+ @router.delete("/customers/{pid}/comments/{comment_id}")
186
+ def remove_comment(
187
+ pid: int,
188
+ comment_id: str,
189
+ scope: str = Query(default="customer"),
190
+ session: Session = Depends(require_session),
191
+ ):
192
+ from core import record_comments
193
+
194
+ scope = _scope_or_400(scope)
195
+ _in_book(pid, session, scope)
196
+ try:
197
+ deleted = record_comments.delete_comment(
198
+ session.runtime,
199
+ pid,
200
+ comment_id,
201
+ session.uname,
202
+ admin=session.admin,
203
+ scope=scope,
204
+ )
205
+ except record_comments.CommentForbidden:
206
+ raise err(403, "comment_forbidden", "only the author may delete this comment")
207
+ except record_comments.CommentsUnavailable:
208
+ raise _unavailable()
209
+ if not deleted:
210
+ raise err(404, "comment_not_found", "that comment no longer exists")
211
+ return {"ok": True, "id": comment_id}
api/routes_script_views.py CHANGED
@@ -1,439 +1,439 @@
1
- """routes_script_views.py β€” CONTRACT C3: a database View that is a PYTHON SCRIPT (R3 / R5 / R10).
2
-
3
- Owner item 6, verbatim (2026-08-18): *"Add code script as an interface (database View) so a user
4
- can build whatever they want through the Agent chat interface. be able to create any dashboard
5
- they want. User should have the ability to see the code AND the dashboard output of course… Limit
6
- the code script View per database… Any agent can add into more AI script, so we can see different
7
- versions or different things the AI code for us."*
8
-
9
- GET /api/v1/script-views?database=K the views bound to ONE database
10
- POST /api/v1/script-views create one {database, name?, source}
11
- GET /api/v1/script-views/{id} one view, its source and its history
12
- PUT /api/v1/script-views/{id} a NEW VERSION of the source
13
- DELETE /api/v1/script-views/{id} drop it
14
- POST /api/v1/script-views/{id}/run run it -> {ok, spec | error, stdout, ms}
15
- POST /api/v1/script-views/{id}/revert go back to an earlier version {version}
16
-
17
- ⭐⭐ **R3 IS "SCOPED, NOT CAPPED", AND THE TWO HALVES POINT OPPOSITE WAYS.** *Scoped*: a view may
18
- read ONLY the database it lives in, and a script that names another database is REFUSED with a
19
- message naming both. *Not capped*: there is **no limit on how many script views a database may
20
- carry**, because that is how an agent offers three attempts and the owner picks one. So nothing
21
- below counts views. What IS bounded is what makes them big β€” one source is capped, and one view's
22
- edit history is capped and REPORTS what it dropped.
23
-
24
- β›” **THE RUN IS THE CALLER'S, NEVER THE AUTHOR'S (R5).** `script_sandbox.run_view` is handed
25
- `session.user`, so a script written by an administrator and opened by a scoped analyst reads the
26
- ANALYST's rows. The author decides what the code does; the reader decides what it can see.
27
- ⚠ And the reverse case is safe rather than lucky: a narrowly-scoped author cannot write a script
28
- that exfiltrates anything, because the only thing a script can return is a render spec drawn on
29
- the screen of the person who ran it. There is no network, no file and no second reader.
30
-
31
- β›” **`run` IS `def`, NOT `async def`.** It waits on a subprocess for up to ten seconds; as a
32
- coroutine that would block the event loop for every other request in the container. FastAPI runs a
33
- plain `def` in the threadpool, which is what makes one slow script one slow REQUEST.
34
- """
35
- import threading
36
- from datetime import datetime, timezone
37
-
38
- from fastapi import APIRouter, Body, Depends
39
-
40
- from deps import Session, err, require_session
41
-
42
- router = APIRouter(prefix="/api/v1")
43
-
44
- #: The tenant's script views: `{id: record}`. Per tenant, so it rides `runtime.store_key`.
45
- VIEWS_KEY = "script_views"
46
-
47
- MAX_NAME = 80
48
- MAX_SOURCE_BYTES = 128 * 1024
49
-
50
- #: Edit history per view. ⚠ NOT a cap on the NUMBER of views (R3 forbids that) β€” a cap on how far
51
- #: back ONE view's source is kept. Past this the oldest go and `trimmed` counts them, so a reader
52
- #: can see the history is partial instead of concluding the view was only ever saved twice.
53
- MAX_HISTORY = 40
54
-
55
- #: β›” HOW MANY SCRIPTS MAY BE RUNNING IN THIS CONTAINER AT ONCE, and it is a REPORTED refusal
56
- #: rather than a queue. Each run is a real subprocess with a ten-second wall clock; without this,
57
- #: holding down refresh forks until the box gives up, and the tenant's ONE FastAPI process is what
58
- #: gives up. A 429 that says so is honest; an unbounded fork is not.
59
- MAX_CONCURRENT_RUNS = 4
60
- _RUN_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_RUNS)
61
-
62
-
63
- def _now():
64
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
65
-
66
-
67
- def _all(rt):
68
- """`{id: record}` for one tenant. `{}` on any failure β€” an unreadable bucket must degrade to
69
- "this database has no script views", never to a 500 on the view rail."""
70
- try:
71
- found = rt.get(VIEWS_KEY) or {}
72
- except Exception: # noqa: BLE001
73
- return {}
74
- return found if isinstance(found, dict) else {}
75
-
76
-
77
- def _database_ok(session, database):
78
- """Does this database EXIST, and may this caller read it? Answered by C1, never by a list.
79
-
80
- β›” `perm_scope.may_read` ALONE IS NOT ENOUGH and the reason is easy to miss: it answers True
81
- for an ADMIN on any key at all, including one no database answers to. So a create validated
82
- with `may_read` would let an administrator bind a view to a typo and leave an orphan nothing
83
- can ever run. `scoped_fields` is the cheap half of C1 (a `ut_*` definition, no rows) and it
84
- RAISES `UnknownTable`, which is exactly the question being asked.
85
- """
86
- import core.perm_scope as perm_scope
87
- try:
88
- perm_scope.scoped_fields(session.user, database, st=session.runtime)
89
- except perm_scope.UnknownTable:
90
- raise err(404, "no_database", f"there is no database '{database}' in this workspace")
91
- except perm_scope.Denied:
92
- raise err(403, "forbidden", f"your account may not read '{database}'")
93
- except perm_scope.Unresolvable as exc:
94
- # The database is real and cannot be served under this call's constraints. Standing rule
95
- # 1's second sentence: report the cause and the recommendation, never a bare refusal.
96
- raise err(409, "unresolvable", str(exc)) from None
97
-
98
-
99
- def _clean_source(raw, *, allow_empty=False):
100
- """The stored source, or a 400. `allow_empty` is CREATE's alone and the asymmetry is the point.
101
-
102
- ⭐⭐ W37-T41 β€” WHY CREATE MAY BE EMPTY AND SAVE MAY NOT.
103
- Picking "Custom View" in the mode picker mints the view immediately, before a line of code
104
- exists, so that `mode === 'script'` always implies a real id and no reader has to carry a
105
- "the id might be missing" branch (the create semantics handed to lane C in mailbox E-7).
106
- A view that has been created and not yet written is therefore a REAL, legible state: the editor
107
- shows its starter placeholder and the Run control is right there.
108
- A PUT is a different act. It appends a VERSION to a history capped at 40, and blanking a
109
- working script by saving nothing over it is not an edit anybody means to make. So the guard
110
- stays exactly where it was on that door, and a person who wants the view gone deletes it.
111
-
112
- β›” THIS WAS FOUND BY THE GATE, NOT BY READING. `core.script_sandbox.check_source("")` returns
113
- None, so "an empty script is storable" looked true and was written into a mailbox answer another
114
- lane was about to build on. The refusal was HERE, one layer above, in a function the sandbox
115
- knows nothing about. Two validators for one question, disagreeing
116
- [[one-question-two-normalizers]] β€” and the one that would have bitten a person is the one no
117
- unit of this feature was asserting.
118
- """
119
- source = str(raw or "")
120
- if not source.strip() and not allow_empty:
121
- raise err(400, "no_source", "a script view needs some code")
122
- if len(source.encode("utf-8", "replace")) > MAX_SOURCE_BYTES:
123
- raise err(413, "source_too_long",
124
- f"a script view is at most {MAX_SOURCE_BYTES // 1024} KB of code")
125
- return source
126
-
127
-
128
- def _row(rec, *, source=False):
129
- """One view as the list door reports it. ⚠ NO SOURCE unless asked: the rail lists names."""
130
- out = {"id": rec.get("id") or "", "database": rec.get("database") or "",
131
- "name": rec.get("name") or "", "author": rec.get("author") or "",
132
- "version": int(rec.get("version") or 1),
133
- "created": rec.get("created") or "", "updated": rec.get("updated") or "",
134
- "versions": len(rec.get("history") or []) + 1,
135
- "trimmed": int(rec.get("trimmed") or 0),
136
- # ⭐ W37-T46: which version this one was RESTORED from, when it was. Present on the
137
- # row (not only the history) because the editor's header is where a person reads "what
138
- # am I looking at", and "v5, restored from v2" is the sentence that makes a roll-back
139
- # legible as an event rather than as a coincidence of matching code.
140
- "restoredFrom": rec.get("restoredFrom")}
141
- if source:
142
- out["source"] = rec.get("source") or ""
143
- out["history"] = [{"version": int(h.get("version") or 0), "author": h.get("author") or "",
144
- "created": h.get("created") or "",
145
- "bytes": len(str(h.get("source") or "").encode("utf-8", "replace"))}
146
- for h in reversed(rec.get("history") or []) if isinstance(h, dict)]
147
- return out
148
-
149
-
150
- def _limits():
151
- return {"maxSourceBytes": MAX_SOURCE_BYTES, "maxName": MAX_NAME,
152
- "maxHistory": MAX_HISTORY, "maxConcurrentRuns": MAX_CONCURRENT_RUNS,
153
- # ⭐ SAID OUT LOUD, because R3's "not capped" half is the one a reader assumes wrong.
154
- "maxViewsPerDatabase": None}
155
-
156
-
157
- def _put(session, view_id, mutate):
158
- """Read-modify-write ONE view, synchronously β€” the client re-reads the rail immediately."""
159
- def _set(cur):
160
- cur = dict(cur or {})
161
- nxt = mutate(cur.get(view_id) if isinstance(cur.get(view_id), dict) else None)
162
- if nxt is None:
163
- cur.pop(view_id, None)
164
- else:
165
- cur[view_id] = nxt
166
- return cur
167
-
168
- session.runtime.update(VIEWS_KEY, _set, flush="sync")
169
-
170
-
171
- def _mine_or_admin(session, rec):
172
- """Who may EDIT or DELETE a view: its author, or an administrator.
173
-
174
- ⚠ RUNNING IS A DIFFERENT QUESTION and deliberately wider β€” anybody who may read the database
175
- may run any view on it, under their OWN scope. That is the whole of "so we can see different
176
- versions or different things the AI code for us": a colleague's attempt is worth nothing if
177
- only its author can open it.
178
- """
179
- if session.admin or str(rec.get("author") or "") == session.uname:
180
- return
181
- raise err(403, "forbidden", "only the author or an administrator can change this script view")
182
-
183
-
184
- # ── the routes ────────────────────────────────────────────────────────────────────────────────
185
- @router.get("/script-views")
186
- def list_script_views(database: str = "", session: Session = Depends(require_session)):
187
- """Every script view bound to ONE database, newest first. `database` is required."""
188
- key = str(database or "").strip()
189
- if not key:
190
- raise err(400, "no_database", "name the database whose script views you want")
191
- _database_ok(session, key)
192
- rows = [_row(rec) for rec in _all(session.runtime).values()
193
- if isinstance(rec, dict) and str(rec.get("database") or "") == key]
194
- rows.sort(key=lambda r: (r["created"], r["id"]), reverse=True)
195
- return {"database": key, "views": rows, "limits": _limits()}
196
-
197
-
198
- @router.post("/script-views")
199
- def create_script_view(body: dict = Body(default=None),
200
- session: Session = Depends(require_session)):
201
- """Create one. β›” THE SOURCE IS CHECKED BEFORE IT IS STORED, not first at run time.
202
-
203
- A script view that cannot be run is a broken feature the reader discovers by pressing a button,
204
- and the agent that wrote it is long gone by then. `check_source` is pure and costs no process,
205
- so the refusal arrives while the author still has the code in front of them.
206
- """
207
- import secrets # noqa: PLC0415
208
- import core.script_sandbox as sandbox # noqa: PLC0415
209
-
210
- body = body if isinstance(body, dict) else {}
211
- database = str(body.get("database") or "").strip()
212
- if not database:
213
- raise err(400, "no_database", "a script view is bound to one database")
214
- _database_ok(session, database)
215
- # ⭐ `allow_empty` is CREATE's alone - see `_clean_source`. The picker mints a view the
216
- # moment a person chooses the mode, so the empty source is the normal first state, not a slip.
217
- source = _clean_source(body.get("source"), allow_empty=True)
218
- refusal = sandbox.check_source(source)
219
- if refusal is not None:
220
- raise err(400, refusal.code, refusal.message)
221
-
222
- view_id = "sv_" + secrets.token_urlsafe(9)
223
- rec = {"id": view_id, "database": database,
224
- "name": " ".join(str(body.get("name") or "Script view").split())[:MAX_NAME],
225
- "source": source, "author": session.uname, "version": 1,
226
- "created": _now(), "updated": _now(), "history": [], "trimmed": 0}
227
- _put(session, view_id, lambda _prior: rec)
228
- fresh = _all(session.runtime).get(view_id)
229
- if not isinstance(fresh, dict):
230
- # The store took the write and did not record it. A 200 here would tell the author their
231
- # script was saved when it was not.
232
- raise err(503, "store_unavailable", "the script view was NOT created")
233
- return {"view": _row(fresh, source=True), "limits": _limits()}
234
-
235
-
236
- @router.get("/script-views/{view_id}")
237
- def get_script_view(view_id: str, session: Session = Depends(require_session)):
238
- """One view WITH its source and its edit history. Owner item 6's *"see the code"* half."""
239
- rec = _all(session.runtime).get(str(view_id))
240
- if not isinstance(rec, dict):
241
- raise err(404, "no_view", "there is no script view with that id")
242
- _database_ok(session, str(rec.get("database") or ""))
243
- return {"view": _row(rec, source=True), "limits": _limits()}
244
-
245
-
246
- @router.put("/script-views/{view_id}")
247
- def update_script_view(view_id: str, body: dict = Body(default=None),
248
- session: Session = Depends(require_session)):
249
- """A NEW VERSION of one view's source. The prior source is KEPT, never replaced in place."""
250
- import core.script_sandbox as sandbox # noqa: PLC0415
251
-
252
- view_id = str(view_id)
253
- rec = _all(session.runtime).get(view_id)
254
- if not isinstance(rec, dict):
255
- raise err(404, "no_view", "there is no script view with that id")
256
- _mine_or_admin(session, rec)
257
- body = body if isinstance(body, dict) else {}
258
- source = _clean_source(body.get("source"))
259
- refusal = sandbox.check_source(source)
260
- if refusal is not None:
261
- raise err(400, refusal.code, refusal.message)
262
-
263
- def _mutate(prior):
264
- prior = dict(prior or rec)
265
- history = list(prior.get("history") or [])
266
- history.append({"version": int(prior.get("version") or 1),
267
- "source": prior.get("source") or "",
268
- "author": prior.get("author") or "", "created": prior.get("updated") or ""})
269
- dropped = max(0, len(history) - MAX_HISTORY)
270
- prior["history"] = history[dropped:] if dropped else history
271
- prior["trimmed"] = int(prior.get("trimmed") or 0) + dropped
272
- prior["source"] = source
273
- prior["version"] = int(prior.get("version") or 1) + 1
274
- prior["updated"] = _now()
275
- if body.get("name"):
276
- prior["name"] = " ".join(str(body["name"]).split())[:MAX_NAME]
277
- return prior
278
-
279
- _put(session, view_id, _mutate)
280
- fresh = _all(session.runtime).get(view_id)
281
- if not isinstance(fresh, dict):
282
- raise err(503, "store_unavailable", "the new version was NOT saved")
283
- return {"view": _row(fresh, source=True), "limits": _limits()}
284
-
285
-
286
- @router.post("/script-views/{view_id}/revert")
287
- def revert_script_view(view_id: str, body: dict = Body(default=None),
288
- session: Session = Depends(require_session)):
289
- """Put one view back to an earlier version. D-338 / W37-T46.
290
-
291
- ⭐⭐ A ROLL-BACK IS A NEW VERSION, NEVER A DELETE OF THE ONES AFTER IT, and that is the whole
292
- point of the ticket rather than an implementation detail. The history is what lets a person
293
- trust an agent with their code: if reverting destroyed the versions it stepped over, one
294
- mistaken revert would cost exactly what the history existed to protect, and there would be no
295
- way back from the way back. So this appends, and `restoredFrom` records where the text came
296
- from β€” the same posture the agent-harness roll-back already takes, so a reader who has seen one
297
- is not surprised by the other.
298
-
299
- β›” IT LIVES ON THE SERVER BECAUSE THE HISTORY DOES. The list door deliberately serves history
300
- entries WITHOUT their source (`_row`: the rail lists names), so a client cannot assemble an old
301
- version's text to re-PUT it. Handing the source out just so the client could send it straight
302
- back would widen a payload for a round trip that does not need to exist, and would make the
303
- revert non-atomic: two calls, and a failure between them leaves the person looking at code that
304
- is not what is stored.
305
- ⚠ `_clean_source` is NOT re-run. The text being restored was already validated when it was
306
- first saved, and a source that a later, stricter rule would now reject is exactly the source a
307
- person is most likely to want back. `check_source` still runs, because the SANDBOX's refusal is
308
- about what the code would DO and that must never be bypassed by a route.
309
- """
310
- import core.script_sandbox as sandbox # noqa: PLC0415
311
-
312
- view_id = str(view_id)
313
- rec = _all(session.runtime).get(view_id)
314
- if not isinstance(rec, dict):
315
- raise err(404, "no_view", "there is no script view with that id")
316
- _mine_or_admin(session, rec)
317
- body = body if isinstance(body, dict) else {}
318
- try:
319
- want = int(body.get("version"))
320
- except (TypeError, ValueError):
321
- raise err(400, "no_version", "say which version to go back to") from None
322
-
323
- history = [h for h in (rec.get("history") or []) if isinstance(h, dict)]
324
- match = next((h for h in history if int(h.get("version") or 0) == want), None)
325
- if match is None:
326
- # β›” TWO REASONS A VERSION IS MISSING AND THEY ARE NOT THE SAME FACT. One was never
327
- # written; the other was TRIMMED off the 40-deep history and the record even counts how
328
- # many. Saying "that version is gone" for the first would be a lie a person could act on.
329
- trimmed = int(rec.get("trimmed") or 0)
330
- if trimmed and want <= trimmed:
331
- raise err(410, "version_trimmed",
332
- f"version {want} is older than this view's history keeps "
333
- f"({trimmed} earlier versions have been dropped)")
334
- raise err(404, "no_version", f"this view has no version {want}")
335
-
336
- source = str(match.get("source") or "")
337
- refusal = sandbox.check_source(source)
338
- if refusal is not None:
339
- # An older version that today's sandbox refuses. The person is told which version and why,
340
- # rather than being handed a 400 about code they did not just type.
341
- raise err(400, refusal.code, f"version {want} cannot be restored: {refusal.message}")
342
-
343
- def _mutate(prior):
344
- prior = dict(prior or rec)
345
- history_now = list(prior.get("history") or [])
346
- history_now.append({"version": int(prior.get("version") or 1),
347
- "source": prior.get("source") or "",
348
- "author": prior.get("author") or "",
349
- "created": prior.get("updated") or ""})
350
- dropped = max(0, len(history_now) - MAX_HISTORY)
351
- prior["history"] = history_now[dropped:] if dropped else history_now
352
- prior["trimmed"] = int(prior.get("trimmed") or 0) + dropped
353
- prior["source"] = source
354
- prior["version"] = int(prior.get("version") or 1) + 1
355
- prior["updated"] = _now()
356
- # ⚠ ON THE RECORD, so the history reads as WHAT HAPPENED rather than as a version that
357
- # mysteriously matches an older one. Without it a reader sees v5 and v2 with identical
358
- # code and no way to tell a revert from a coincidence.
359
- prior["restoredFrom"] = want
360
- return prior
361
-
362
- _put(session, view_id, _mutate)
363
- fresh = _all(session.runtime).get(view_id)
364
- if not isinstance(fresh, dict):
365
- raise err(503, "store_unavailable", "the roll-back was NOT saved")
366
- return {"view": _row(fresh, source=True), "restoredFrom": want, "limits": _limits()}
367
-
368
-
369
- @router.delete("/script-views/{view_id}")
370
- def delete_script_view(view_id: str, session: Session = Depends(require_session)):
371
- view_id = str(view_id)
372
- rec = _all(session.runtime).get(view_id)
373
- if not isinstance(rec, dict):
374
- raise err(404, "no_view", "there is no script view with that id")
375
- _mine_or_admin(session, rec)
376
- _put(session, view_id, lambda _prior: None)
377
- return {"deleted": view_id}
378
-
379
-
380
- @router.post("/script-views/{view_id}/run")
381
- def run_script_view(view_id: str, body: dict = Body(default=None),
382
- session: Session = Depends(require_session)):
383
- """CONTRACT C3's run door: `{ok, spec | error, stdout, ms}`.
384
-
385
- β›” `spec` IS A DESCRIPTION THE CLIENT DRAWS. It is never HTML and never text the browser
386
- executes β€” the sandbox refuses a spec carrying an `html`, `script`, `src`, `href` or `on*` key
387
- before this function ever sees it, so a renderer cannot be talked into running something by a
388
- script that was itself perfectly well behaved.
389
-
390
- β›” AND IT IS PLAIN `def`, NOT `async def` β€” see this module's header. A ten-second subprocess
391
- wait on the event loop is a ten-second outage for the whole container.
392
- """
393
- import core.script_sandbox as sandbox # noqa: PLC0415
394
-
395
- rec = _all(session.runtime).get(str(view_id))
396
- if not isinstance(rec, dict):
397
- raise err(404, "no_view", "there is no script view with that id")
398
- database = str(rec.get("database") or "")
399
- # A DRAFT run: the editor sends unsaved code so the author can try it before committing to it.
400
- # It is checked exactly as a stored one is, because "unsaved" is not a permission.
401
- draft = (body or {}).get("source") if isinstance(body, dict) else None
402
- source = _clean_source(draft) if draft else str(rec.get("source") or "")
403
- # ⚠ NO `_database_ok` CALL HERE. `run_view` asks C1 the identical question a line later, and
404
- # asking twice builds a registry topic's pool twice. The three refusals are translated below
405
- # instead, which is the same wall reached through the same door.
406
-
407
- if not _RUN_SLOTS.acquire(blocking=False):
408
- raise err(429, "busy",
409
- f"{MAX_CONCURRENT_RUNS} script views are already running on this server. "
410
- f"Try again in a moment")
411
- try:
412
- out = sandbox.run_view(session.user, database, source, st=session.runtime)
413
- finally:
414
- _RUN_SLOTS.release()
415
-
416
- # β›” C1'S THREE REFUSALS ARE HTTP STATUSES, NOT `ok:false`. "You may not read this database"
417
- # answered 200 would be a permission decision the client has to go looking for, and every
418
- # other door in this app answers 403 for it. Everything BELOW this line is a well-formed
419
- # request whose ANSWER is that the script did not produce a view β€” that is a 200 with
420
- # `ok:false`, the shape `routes_web_agent.test` already uses for the same reason.
421
- if out.get("code") == "unknown_table":
422
- raise err(404, "no_database", out.get("message") or f"there is no database '{database}'")
423
- if out.get("code") == "denied":
424
- raise err(403, "forbidden", out.get("message") or "your account may not read that database")
425
- if out.get("code") == "unresolvable":
426
- refusal = err(409, "unresolvable", out.get("message") or "these rows cannot be served")
427
- refusal.detail["error"]["limit"] = out.get("limit") or {}
428
- raise refusal
429
-
430
- answer = {"ok": bool(out.get("ok")), "spec": out.get("spec"),
431
- "stdout": out.get("stdout") or "", "truncated": bool(out.get("truncated")),
432
- "ms": int(out.get("ms") or 0), "code": out.get("code") or "",
433
- # ⭐ `caps` RIDES ON THE ANSWER (standing rule 1). On a POSIX host all three limits
434
- # were applied; on a Windows host the memory and CPU ones were not, and a screen
435
- # that claims an enforcement which did not happen is the failure the rule is about.
436
- "caps": out.get("caps") or {}}
437
- if not answer["ok"]:
438
- answer["error"] = out.get("message") or "the script view did not produce a view"
439
- return answer
 
1
+ """routes_script_views.py β€” CONTRACT C3: a database View that is a PYTHON SCRIPT (R3 / R5 / R10).
2
+
3
+ Owner item 6, verbatim (2026-08-18): *"Add code script as an interface (database View) so a user
4
+ can build whatever they want through the Agent chat interface. be able to create any dashboard
5
+ they want. User should have the ability to see the code AND the dashboard output of course… Limit
6
+ the code script View per database… Any agent can add into more AI script, so we can see different
7
+ versions or different things the AI code for us."*
8
+
9
+ GET /api/v1/script-views?database=K the views bound to ONE database
10
+ POST /api/v1/script-views create one {database, name?, source}
11
+ GET /api/v1/script-views/{id} one view, its source and its history
12
+ PUT /api/v1/script-views/{id} a NEW VERSION of the source
13
+ DELETE /api/v1/script-views/{id} drop it
14
+ POST /api/v1/script-views/{id}/run run it -> {ok, spec | error, stdout, ms}
15
+ POST /api/v1/script-views/{id}/revert go back to an earlier version {version}
16
+
17
+ ⭐⭐ **R3 IS "SCOPED, NOT CAPPED", AND THE TWO HALVES POINT OPPOSITE WAYS.** *Scoped*: a view may
18
+ read ONLY the database it lives in, and a script that names another database is REFUSED with a
19
+ message naming both. *Not capped*: there is **no limit on how many script views a database may
20
+ carry**, because that is how an agent offers three attempts and the owner picks one. So nothing
21
+ below counts views. What IS bounded is what makes them big β€” one source is capped, and one view's
22
+ edit history is capped and REPORTS what it dropped.
23
+
24
+ β›” **THE RUN IS THE CALLER'S, NEVER THE AUTHOR'S (R5).** `script_sandbox.run_view` is handed
25
+ `session.user`, so a script written by an administrator and opened by a scoped analyst reads the
26
+ ANALYST's rows. The author decides what the code does; the reader decides what it can see.
27
+ ⚠ And the reverse case is safe rather than lucky: a narrowly-scoped author cannot write a script
28
+ that exfiltrates anything, because the only thing a script can return is a render spec drawn on
29
+ the screen of the person who ran it. There is no network, no file and no second reader.
30
+
31
+ β›” **`run` IS `def`, NOT `async def`.** It waits on a subprocess for up to ten seconds; as a
32
+ coroutine that would block the event loop for every other request in the container. FastAPI runs a
33
+ plain `def` in the threadpool, which is what makes one slow script one slow REQUEST.
34
+ """
35
+ import threading
36
+ from datetime import datetime, timezone
37
+
38
+ from fastapi import APIRouter, Body, Depends
39
+
40
+ from deps import Session, err, require_session
41
+
42
+ router = APIRouter(prefix="/api/v1")
43
+
44
+ #: The tenant's script views: `{id: record}`. Per tenant, so it rides `runtime.store_key`.
45
+ VIEWS_KEY = "script_views"
46
+
47
+ MAX_NAME = 80
48
+ MAX_SOURCE_BYTES = 128 * 1024
49
+
50
+ #: Edit history per view. ⚠ NOT a cap on the NUMBER of views (R3 forbids that) β€” a cap on how far
51
+ #: back ONE view's source is kept. Past this the oldest go and `trimmed` counts them, so a reader
52
+ #: can see the history is partial instead of concluding the view was only ever saved twice.
53
+ MAX_HISTORY = 40
54
+
55
+ #: β›” HOW MANY SCRIPTS MAY BE RUNNING IN THIS CONTAINER AT ONCE, and it is a REPORTED refusal
56
+ #: rather than a queue. Each run is a real subprocess with a ten-second wall clock; without this,
57
+ #: holding down refresh forks until the box gives up, and the tenant's ONE FastAPI process is what
58
+ #: gives up. A 429 that says so is honest; an unbounded fork is not.
59
+ MAX_CONCURRENT_RUNS = 4
60
+ _RUN_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_RUNS)
61
+
62
+
63
+ def _now():
64
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
65
+
66
+
67
+ def _all(rt):
68
+ """`{id: record}` for one tenant. `{}` on any failure β€” an unreadable bucket must degrade to
69
+ "this database has no script views", never to a 500 on the view rail."""
70
+ try:
71
+ found = rt.get(VIEWS_KEY) or {}
72
+ except Exception: # noqa: BLE001
73
+ return {}
74
+ return found if isinstance(found, dict) else {}
75
+
76
+
77
+ def _database_ok(session, database):
78
+ """Does this database EXIST, and may this caller read it? Answered by C1, never by a list.
79
+
80
+ β›” `perm_scope.may_read` ALONE IS NOT ENOUGH and the reason is easy to miss: it answers True
81
+ for an ADMIN on any key at all, including one no database answers to. So a create validated
82
+ with `may_read` would let an administrator bind a view to a typo and leave an orphan nothing
83
+ can ever run. `scoped_fields` is the cheap half of C1 (a `ut_*` definition, no rows) and it
84
+ RAISES `UnknownTable`, which is exactly the question being asked.
85
+ """
86
+ import core.perm_scope as perm_scope
87
+ try:
88
+ perm_scope.scoped_fields(session.user, database, st=session.runtime)
89
+ except perm_scope.UnknownTable:
90
+ raise err(404, "no_database", f"there is no database '{database}' in this workspace")
91
+ except perm_scope.Denied:
92
+ raise err(403, "forbidden", f"your account may not read '{database}'")
93
+ except perm_scope.Unresolvable as exc:
94
+ # The database is real and cannot be served under this call's constraints. Standing rule
95
+ # 1's second sentence: report the cause and the recommendation, never a bare refusal.
96
+ raise err(409, "unresolvable", str(exc)) from None
97
+
98
+
99
+ def _clean_source(raw, *, allow_empty=False):
100
+ """The stored source, or a 400. `allow_empty` is CREATE's alone and the asymmetry is the point.
101
+
102
+ ⭐⭐ W37-T41 β€” WHY CREATE MAY BE EMPTY AND SAVE MAY NOT.
103
+ Picking "Custom View" in the mode picker mints the view immediately, before a line of code
104
+ exists, so that `mode === 'script'` always implies a real id and no reader has to carry a
105
+ "the id might be missing" branch (the create semantics handed to lane C in mailbox E-7).
106
+ A view that has been created and not yet written is therefore a REAL, legible state: the editor
107
+ shows its starter placeholder and the Run control is right there.
108
+ A PUT is a different act. It appends a VERSION to a history capped at 40, and blanking a
109
+ working script by saving nothing over it is not an edit anybody means to make. So the guard
110
+ stays exactly where it was on that door, and a person who wants the view gone deletes it.
111
+
112
+ β›” THIS WAS FOUND BY THE GATE, NOT BY READING. `core.script_sandbox.check_source("")` returns
113
+ None, so "an empty script is storable" looked true and was written into a mailbox answer another
114
+ lane was about to build on. The refusal was HERE, one layer above, in a function the sandbox
115
+ knows nothing about. Two validators for one question, disagreeing
116
+ [[one-question-two-normalizers]] β€” and the one that would have bitten a person is the one no
117
+ unit of this feature was asserting.
118
+ """
119
+ source = str(raw or "")
120
+ if not source.strip() and not allow_empty:
121
+ raise err(400, "no_source", "a script view needs some code")
122
+ if len(source.encode("utf-8", "replace")) > MAX_SOURCE_BYTES:
123
+ raise err(413, "source_too_long",
124
+ f"a script view is at most {MAX_SOURCE_BYTES // 1024} KB of code")
125
+ return source
126
+
127
+
128
+ def _row(rec, *, source=False):
129
+ """One view as the list door reports it. ⚠ NO SOURCE unless asked: the rail lists names."""
130
+ out = {"id": rec.get("id") or "", "database": rec.get("database") or "",
131
+ "name": rec.get("name") or "", "author": rec.get("author") or "",
132
+ "version": int(rec.get("version") or 1),
133
+ "created": rec.get("created") or "", "updated": rec.get("updated") or "",
134
+ "versions": len(rec.get("history") or []) + 1,
135
+ "trimmed": int(rec.get("trimmed") or 0),
136
+ # ⭐ W37-T46: which version this one was RESTORED from, when it was. Present on the
137
+ # row (not only the history) because the editor's header is where a person reads "what
138
+ # am I looking at", and "v5, restored from v2" is the sentence that makes a roll-back
139
+ # legible as an event rather than as a coincidence of matching code.
140
+ "restoredFrom": rec.get("restoredFrom")}
141
+ if source:
142
+ out["source"] = rec.get("source") or ""
143
+ out["history"] = [{"version": int(h.get("version") or 0), "author": h.get("author") or "",
144
+ "created": h.get("created") or "",
145
+ "bytes": len(str(h.get("source") or "").encode("utf-8", "replace"))}
146
+ for h in reversed(rec.get("history") or []) if isinstance(h, dict)]
147
+ return out
148
+
149
+
150
+ def _limits():
151
+ return {"maxSourceBytes": MAX_SOURCE_BYTES, "maxName": MAX_NAME,
152
+ "maxHistory": MAX_HISTORY, "maxConcurrentRuns": MAX_CONCURRENT_RUNS,
153
+ # ⭐ SAID OUT LOUD, because R3's "not capped" half is the one a reader assumes wrong.
154
+ "maxViewsPerDatabase": None}
155
+
156
+
157
+ def _put(session, view_id, mutate):
158
+ """Read-modify-write ONE view, synchronously β€” the client re-reads the rail immediately."""
159
+ def _set(cur):
160
+ cur = dict(cur or {})
161
+ nxt = mutate(cur.get(view_id) if isinstance(cur.get(view_id), dict) else None)
162
+ if nxt is None:
163
+ cur.pop(view_id, None)
164
+ else:
165
+ cur[view_id] = nxt
166
+ return cur
167
+
168
+ session.runtime.update(VIEWS_KEY, _set, flush="sync")
169
+
170
+
171
+ def _mine_or_admin(session, rec):
172
+ """Who may EDIT or DELETE a view: its author, or an administrator.
173
+
174
+ ⚠ RUNNING IS A DIFFERENT QUESTION and deliberately wider β€” anybody who may read the database
175
+ may run any view on it, under their OWN scope. That is the whole of "so we can see different
176
+ versions or different things the AI code for us": a colleague's attempt is worth nothing if
177
+ only its author can open it.
178
+ """
179
+ if session.admin or str(rec.get("author") or "") == session.uname:
180
+ return
181
+ raise err(403, "forbidden", "only the author or an administrator can change this script view")
182
+
183
+
184
+ # ── the routes ────────────────────────────────────────────────────────────────────────────────
185
+ @router.get("/script-views")
186
+ def list_script_views(database: str = "", session: Session = Depends(require_session)):
187
+ """Every script view bound to ONE database, newest first. `database` is required."""
188
+ key = str(database or "").strip()
189
+ if not key:
190
+ raise err(400, "no_database", "name the database whose script views you want")
191
+ _database_ok(session, key)
192
+ rows = [_row(rec) for rec in _all(session.runtime).values()
193
+ if isinstance(rec, dict) and str(rec.get("database") or "") == key]
194
+ rows.sort(key=lambda r: (r["created"], r["id"]), reverse=True)
195
+ return {"database": key, "views": rows, "limits": _limits()}
196
+
197
+
198
+ @router.post("/script-views")
199
+ def create_script_view(body: dict = Body(default=None),
200
+ session: Session = Depends(require_session)):
201
+ """Create one. β›” THE SOURCE IS CHECKED BEFORE IT IS STORED, not first at run time.
202
+
203
+ A script view that cannot be run is a broken feature the reader discovers by pressing a button,
204
+ and the agent that wrote it is long gone by then. `check_source` is pure and costs no process,
205
+ so the refusal arrives while the author still has the code in front of them.
206
+ """
207
+ import secrets # noqa: PLC0415
208
+ import core.script_sandbox as sandbox # noqa: PLC0415
209
+
210
+ body = body if isinstance(body, dict) else {}
211
+ database = str(body.get("database") or "").strip()
212
+ if not database:
213
+ raise err(400, "no_database", "a script view is bound to one database")
214
+ _database_ok(session, database)
215
+ # ⭐ `allow_empty` is CREATE's alone - see `_clean_source`. The picker mints a view the
216
+ # moment a person chooses the mode, so the empty source is the normal first state, not a slip.
217
+ source = _clean_source(body.get("source"), allow_empty=True)
218
+ refusal = sandbox.check_source(source)
219
+ if refusal is not None:
220
+ raise err(400, refusal.code, refusal.message)
221
+
222
+ view_id = "sv_" + secrets.token_urlsafe(9)
223
+ rec = {"id": view_id, "database": database,
224
+ "name": " ".join(str(body.get("name") or "Script view").split())[:MAX_NAME],
225
+ "source": source, "author": session.uname, "version": 1,
226
+ "created": _now(), "updated": _now(), "history": [], "trimmed": 0}
227
+ _put(session, view_id, lambda _prior: rec)
228
+ fresh = _all(session.runtime).get(view_id)
229
+ if not isinstance(fresh, dict):
230
+ # The store took the write and did not record it. A 200 here would tell the author their
231
+ # script was saved when it was not.
232
+ raise err(503, "store_unavailable", "the script view was NOT created")
233
+ return {"view": _row(fresh, source=True), "limits": _limits()}
234
+
235
+
236
+ @router.get("/script-views/{view_id}")
237
+ def get_script_view(view_id: str, session: Session = Depends(require_session)):
238
+ """One view WITH its source and its edit history. Owner item 6's *"see the code"* half."""
239
+ rec = _all(session.runtime).get(str(view_id))
240
+ if not isinstance(rec, dict):
241
+ raise err(404, "no_view", "there is no script view with that id")
242
+ _database_ok(session, str(rec.get("database") or ""))
243
+ return {"view": _row(rec, source=True), "limits": _limits()}
244
+
245
+
246
+ @router.put("/script-views/{view_id}")
247
+ def update_script_view(view_id: str, body: dict = Body(default=None),
248
+ session: Session = Depends(require_session)):
249
+ """A NEW VERSION of one view's source. The prior source is KEPT, never replaced in place."""
250
+ import core.script_sandbox as sandbox # noqa: PLC0415
251
+
252
+ view_id = str(view_id)
253
+ rec = _all(session.runtime).get(view_id)
254
+ if not isinstance(rec, dict):
255
+ raise err(404, "no_view", "there is no script view with that id")
256
+ _mine_or_admin(session, rec)
257
+ body = body if isinstance(body, dict) else {}
258
+ source = _clean_source(body.get("source"))
259
+ refusal = sandbox.check_source(source)
260
+ if refusal is not None:
261
+ raise err(400, refusal.code, refusal.message)
262
+
263
+ def _mutate(prior):
264
+ prior = dict(prior or rec)
265
+ history = list(prior.get("history") or [])
266
+ history.append({"version": int(prior.get("version") or 1),
267
+ "source": prior.get("source") or "",
268
+ "author": prior.get("author") or "", "created": prior.get("updated") or ""})
269
+ dropped = max(0, len(history) - MAX_HISTORY)
270
+ prior["history"] = history[dropped:] if dropped else history
271
+ prior["trimmed"] = int(prior.get("trimmed") or 0) + dropped
272
+ prior["source"] = source
273
+ prior["version"] = int(prior.get("version") or 1) + 1
274
+ prior["updated"] = _now()
275
+ if body.get("name"):
276
+ prior["name"] = " ".join(str(body["name"]).split())[:MAX_NAME]
277
+ return prior
278
+
279
+ _put(session, view_id, _mutate)
280
+ fresh = _all(session.runtime).get(view_id)
281
+ if not isinstance(fresh, dict):
282
+ raise err(503, "store_unavailable", "the new version was NOT saved")
283
+ return {"view": _row(fresh, source=True), "limits": _limits()}
284
+
285
+
286
+ @router.post("/script-views/{view_id}/revert")
287
+ def revert_script_view(view_id: str, body: dict = Body(default=None),
288
+ session: Session = Depends(require_session)):
289
+ """Put one view back to an earlier version. D-338 / W37-T46.
290
+
291
+ ⭐⭐ A ROLL-BACK IS A NEW VERSION, NEVER A DELETE OF THE ONES AFTER IT, and that is the whole
292
+ point of the ticket rather than an implementation detail. The history is what lets a person
293
+ trust an agent with their code: if reverting destroyed the versions it stepped over, one
294
+ mistaken revert would cost exactly what the history existed to protect, and there would be no
295
+ way back from the way back. So this appends, and `restoredFrom` records where the text came
296
+ from β€” the same posture the agent-harness roll-back already takes, so a reader who has seen one
297
+ is not surprised by the other.
298
+
299
+ β›” IT LIVES ON THE SERVER BECAUSE THE HISTORY DOES. The list door deliberately serves history
300
+ entries WITHOUT their source (`_row`: the rail lists names), so a client cannot assemble an old
301
+ version's text to re-PUT it. Handing the source out just so the client could send it straight
302
+ back would widen a payload for a round trip that does not need to exist, and would make the
303
+ revert non-atomic: two calls, and a failure between them leaves the person looking at code that
304
+ is not what is stored.
305
+ ⚠ `_clean_source` is NOT re-run. The text being restored was already validated when it was
306
+ first saved, and a source that a later, stricter rule would now reject is exactly the source a
307
+ person is most likely to want back. `check_source` still runs, because the SANDBOX's refusal is
308
+ about what the code would DO and that must never be bypassed by a route.
309
+ """
310
+ import core.script_sandbox as sandbox # noqa: PLC0415
311
+
312
+ view_id = str(view_id)
313
+ rec = _all(session.runtime).get(view_id)
314
+ if not isinstance(rec, dict):
315
+ raise err(404, "no_view", "there is no script view with that id")
316
+ _mine_or_admin(session, rec)
317
+ body = body if isinstance(body, dict) else {}
318
+ try:
319
+ want = int(body.get("version"))
320
+ except (TypeError, ValueError):
321
+ raise err(400, "no_version", "say which version to go back to") from None
322
+
323
+ history = [h for h in (rec.get("history") or []) if isinstance(h, dict)]
324
+ match = next((h for h in history if int(h.get("version") or 0) == want), None)
325
+ if match is None:
326
+ # β›” TWO REASONS A VERSION IS MISSING AND THEY ARE NOT THE SAME FACT. One was never
327
+ # written; the other was TRIMMED off the 40-deep history and the record even counts how
328
+ # many. Saying "that version is gone" for the first would be a lie a person could act on.
329
+ trimmed = int(rec.get("trimmed") or 0)
330
+ if trimmed and want <= trimmed:
331
+ raise err(410, "version_trimmed",
332
+ f"version {want} is older than this view's history keeps "
333
+ f"({trimmed} earlier versions have been dropped)")
334
+ raise err(404, "no_version", f"this view has no version {want}")
335
+
336
+ source = str(match.get("source") or "")
337
+ refusal = sandbox.check_source(source)
338
+ if refusal is not None:
339
+ # An older version that today's sandbox refuses. The person is told which version and why,
340
+ # rather than being handed a 400 about code they did not just type.
341
+ raise err(400, refusal.code, f"version {want} cannot be restored: {refusal.message}")
342
+
343
+ def _mutate(prior):
344
+ prior = dict(prior or rec)
345
+ history_now = list(prior.get("history") or [])
346
+ history_now.append({"version": int(prior.get("version") or 1),
347
+ "source": prior.get("source") or "",
348
+ "author": prior.get("author") or "",
349
+ "created": prior.get("updated") or ""})
350
+ dropped = max(0, len(history_now) - MAX_HISTORY)
351
+ prior["history"] = history_now[dropped:] if dropped else history_now
352
+ prior["trimmed"] = int(prior.get("trimmed") or 0) + dropped
353
+ prior["source"] = source
354
+ prior["version"] = int(prior.get("version") or 1) + 1
355
+ prior["updated"] = _now()
356
+ # ⚠ ON THE RECORD, so the history reads as WHAT HAPPENED rather than as a version that
357
+ # mysteriously matches an older one. Without it a reader sees v5 and v2 with identical
358
+ # code and no way to tell a revert from a coincidence.
359
+ prior["restoredFrom"] = want
360
+ return prior
361
+
362
+ _put(session, view_id, _mutate)
363
+ fresh = _all(session.runtime).get(view_id)
364
+ if not isinstance(fresh, dict):
365
+ raise err(503, "store_unavailable", "the roll-back was NOT saved")
366
+ return {"view": _row(fresh, source=True), "restoredFrom": want, "limits": _limits()}
367
+
368
+
369
+ @router.delete("/script-views/{view_id}")
370
+ def delete_script_view(view_id: str, session: Session = Depends(require_session)):
371
+ view_id = str(view_id)
372
+ rec = _all(session.runtime).get(view_id)
373
+ if not isinstance(rec, dict):
374
+ raise err(404, "no_view", "there is no script view with that id")
375
+ _mine_or_admin(session, rec)
376
+ _put(session, view_id, lambda _prior: None)
377
+ return {"deleted": view_id}
378
+
379
+
380
+ @router.post("/script-views/{view_id}/run")
381
+ def run_script_view(view_id: str, body: dict = Body(default=None),
382
+ session: Session = Depends(require_session)):
383
+ """CONTRACT C3's run door: `{ok, spec | error, stdout, ms}`.
384
+
385
+ β›” `spec` IS A DESCRIPTION THE CLIENT DRAWS. It is never HTML and never text the browser
386
+ executes β€” the sandbox refuses a spec carrying an `html`, `script`, `src`, `href` or `on*` key
387
+ before this function ever sees it, so a renderer cannot be talked into running something by a
388
+ script that was itself perfectly well behaved.
389
+
390
+ β›” AND IT IS PLAIN `def`, NOT `async def` β€” see this module's header. A ten-second subprocess
391
+ wait on the event loop is a ten-second outage for the whole container.
392
+ """
393
+ import core.script_sandbox as sandbox # noqa: PLC0415
394
+
395
+ rec = _all(session.runtime).get(str(view_id))
396
+ if not isinstance(rec, dict):
397
+ raise err(404, "no_view", "there is no script view with that id")
398
+ database = str(rec.get("database") or "")
399
+ # A DRAFT run: the editor sends unsaved code so the author can try it before committing to it.
400
+ # It is checked exactly as a stored one is, because "unsaved" is not a permission.
401
+ draft = (body or {}).get("source") if isinstance(body, dict) else None
402
+ source = _clean_source(draft) if draft else str(rec.get("source") or "")
403
+ # ⚠ NO `_database_ok` CALL HERE. `run_view` asks C1 the identical question a line later, and
404
+ # asking twice builds a registry topic's pool twice. The three refusals are translated below
405
+ # instead, which is the same wall reached through the same door.
406
+
407
+ if not _RUN_SLOTS.acquire(blocking=False):
408
+ raise err(429, "busy",
409
+ f"{MAX_CONCURRENT_RUNS} script views are already running on this server. "
410
+ f"Try again in a moment")
411
+ try:
412
+ out = sandbox.run_view(session.user, database, source, st=session.runtime)
413
+ finally:
414
+ _RUN_SLOTS.release()
415
+
416
+ # β›” C1'S THREE REFUSALS ARE HTTP STATUSES, NOT `ok:false`. "You may not read this database"
417
+ # answered 200 would be a permission decision the client has to go looking for, and every
418
+ # other door in this app answers 403 for it. Everything BELOW this line is a well-formed
419
+ # request whose ANSWER is that the script did not produce a view β€” that is a 200 with
420
+ # `ok:false`, the shape `routes_web_agent.test` already uses for the same reason.
421
+ if out.get("code") == "unknown_table":
422
+ raise err(404, "no_database", out.get("message") or f"there is no database '{database}'")
423
+ if out.get("code") == "denied":
424
+ raise err(403, "forbidden", out.get("message") or "your account may not read that database")
425
+ if out.get("code") == "unresolvable":
426
+ refusal = err(409, "unresolvable", out.get("message") or "these rows cannot be served")
427
+ refusal.detail["error"]["limit"] = out.get("limit") or {}
428
+ raise refusal
429
+
430
+ answer = {"ok": bool(out.get("ok")), "spec": out.get("spec"),
431
+ "stdout": out.get("stdout") or "", "truncated": bool(out.get("truncated")),
432
+ "ms": int(out.get("ms") or 0), "code": out.get("code") or "",
433
+ # ⭐ `caps` RIDES ON THE ANSWER (standing rule 1). On a POSIX host all three limits
434
+ # were applied; on a Windows host the memory and CPU ones were not, and a screen
435
+ # that claims an enforcement which did not happen is the failure the rule is about.
436
+ "caps": out.get("caps") or {}}
437
+ if not answer["ok"]:
438
+ answer["error"] = out.get("message") or "the script view did not produce a view"
439
+ return answer
api/routes_shares.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_slack.py CHANGED
@@ -1,789 +1,789 @@
1
- """routes_slack.py β€” MANAGE AGENT: a bot per Slack channel, walled by the engine that walls a
2
- person. (wave 33, owner item 10 Β· `W33-T38` / `W33-T39` / `W33-T67`.)
3
-
4
- Owner, verbatim (2026-08-14): *"Expand Slack we need to be able to configure an AI bot PER channel
5
- whose permissions we can toggle based on Fields etc of the databases, exactly like how we toggle
6
- permissioning per user."*
7
-
8
- ────────────────────────────────────────────────────────────────────────────────────────────────
9
- β›”β›” D-84 SAID THIS WAS "adapted from OpenTag" AND THAT PREMISE IS FALSE (PRD amendment A1).
10
-
11
- `PENDING.md` D-84 has named OpenTag as the model for per-Slack-channel permissioning since
12
- 2026-08-08. **OpenTag has no permission model to adapt.** `app/runtime-host.ts` is
13
- `identifyUser: () => OPENTAG_SERVICE_USER` β€” ONE constant identity for every request β€” and its own
14
- `sender-context.ts` says the Slack sender is *"informational only… not gating access."* Its
15
- authorization ceiling is whole-MCP-server, decided once at process boot from env vars. It is a
16
- live specimen of identity that LOOKS like a permission and is not.
17
-
18
- `core/perm_scope.py` is already strictly more expressive: per database, per row, per FIELD, per
19
- principal. So item 10 is OUR engine projected onto a CHANNEL principal β€” see `agent_principal`
20
- below, which is the whole projection and is nine lines. Study:
21
- `.claude/wiki/research/opentag-adoption.md` (licence gate: MIT, PASSED).
22
-
23
- What OpenTag genuinely contributes is the WRITE-APPROVAL interceptor (`W33-T67`, `approval_card`
24
- below) and nothing else. Its TRANSPORT is AVOID wholesale β€” hosted CopilotKit Intelligence, a
25
- second Node process, a persistent outbound socket and each tenant's Slack credentials held by a
26
- third party, against ONE process on the HF free tier. The door here is `routes_forms.py`'s proven
27
- unauthenticated-public shape plus Slack's signing-secret check.
28
-
29
- ────────────────────────────────────────────────────────────────────────────────────────────────
30
- ⚠ A CHANNEL IS A PRINCIPAL, NOT A PERSON. The wall binds to the Slack conversation id, so every
31
- human in that channel reads through the same rules. That is the honest reading of "a bot per
32
- channel" and the pane says it out loud, because the alternative is an admin assuming per-person
33
- scoping from a screen that looks exactly like the per-person one.
34
-
35
- ⚠ AND THE IDENTITY IS THE CHANNEL ID, NEVER ITS NAME. A channel can be renamed; its id cannot.
36
- A wall bound to `#sales` silently re-points the day somebody renames the channel.
37
- """
38
- import hmac
39
- import json
40
- import os
41
- import secrets
42
- import time
43
- from datetime import datetime, timezone
44
-
45
- from fastapi import APIRouter, Body, Depends, Request
46
-
47
- from deps import Session, err, require_session
48
- from routes_admin import admin_gate
49
-
50
- router = APIRouter(prefix="/api/v1")
51
-
52
- #: The tenant's channel agents: `{id: record}`. A per-tenant bucket, so it rides
53
- #: `runtime.store_key`'s prefix and never lands in tenant #0's namespace.
54
- AGENTS_KEY = "slack_agents"
55
-
56
- #: Pending mutations awaiting a human's click (`W33-T67`). Server-only, per tenant.
57
- PENDING_KEY = "slack_pending_writes"
58
-
59
- #: How long an approval card stays clickable. An approval is a statement about the world as it was
60
- #: when the card was rendered; an hour later the values it names may no longer be the values it
61
- #: would write. Expiring is the honest behaviour, and an expired card says so rather than 403ing.
62
- APPROVAL_TTL_S = 15 * 60
63
-
64
- #: Slack rejects a replayed request older than 5 minutes; so do we, before any signature work.
65
- SLACK_MAX_SKEW_S = 60 * 5
66
-
67
- MAX_BODY_BYTES = 64 * 1024
68
-
69
- #: β›” ROW-CAPPED BECAUSE AN APPROVER WHO CANNOT READ THE CARD CANNOT APPROVE IT. Copied
70
- #: deliberately from OpenTag's `agent/write_confirmation.py`, which caps for the same reason:
71
- #: past a certain length Slack collapses a message behind "Show more", and a human clicking
72
- #: Approve on a card whose tail they never saw is worse than no card at all.
73
- APPROVAL_MAX_ROWS = 12
74
-
75
-
76
- # ── the tenant's agent records ───────────────────────────────────────────────────────────────
77
- def _agents(rt):
78
- """`{id: record}` for one tenant. `{}` on any failure β€” an unreadable bucket must degrade to
79
- "this tenant has no agents", never to a 500 on the settings pane."""
80
- try:
81
- found = rt.get(AGENTS_KEY) or {}
82
- except Exception: # noqa: BLE001
83
- return {}
84
- return found if isinstance(found, dict) else {}
85
-
86
-
87
- def _clean_agent(raw, prior=None):
88
- """One stored record, built KEY BY KEY.
89
-
90
- β›” Never `dict(raw)` and never `**raw`. This record is the SUBJECT of a permission decision;
91
- a client-supplied key landing in it is a client-supplied input to `perm_scope`. The same rule
92
- `routes_forms._public_form` follows on the way out, applied here on the way in.
93
- """
94
- prior = prior if isinstance(prior, dict) else {}
95
- out = {
96
- "id": str(prior.get("id") or raw.get("id") or ""),
97
- "channel": str(raw.get("channel") or prior.get("channel") or "").strip()[:64],
98
- "channelName": str(raw.get("channelName") or prior.get("channelName") or "").strip()[:80],
99
- "label": " ".join(str(raw.get("label") or prior.get("label") or "").split())[:80],
100
- "active": bool(raw.get("active", prior.get("active", True))),
101
- # β›” DEFAULT TRUE, AND `is not False` RATHER THAN TRUTHINESS. A record written before this
102
- # key existed must read as "approval required": the one direction a default here is
103
- # allowed to be wrong in is the safe one, because an unattended write cannot be undone by
104
- # revoking the bot afterwards [[default-must-pass-its-own-guard]].
105
- "writeApproval": raw.get("writeApproval", prior.get("writeApproval", True)) is not False,
106
- # The permission block. Same shape, same validator, same engine as a user's.
107
- "perms": prior.get("perms") if isinstance(prior.get("perms"), dict) else {},
108
- "perms_v": int(prior.get("perms_v") or 0),
109
- "createdBy": str(prior.get("createdBy") or raw.get("createdBy") or ""),
110
- "createdAt": str(prior.get("createdAt") or raw.get("createdAt") or ""),
111
- }
112
- return out
113
-
114
-
115
- def _put_agent(session, agent_id, mutate):
116
- """Read-modify-write ONE agent under the tenant's bucket, synchronously.
117
-
118
- `flush="sync"` because the client re-reads the list immediately after every write here, and an
119
- async flush would let the refetch win the race and paint the pre-write state β€” the
120
- [[refetch-eats-its-own-write]] shape.
121
- """
122
- def _set(cur):
123
- cur = dict(cur or {})
124
- rec = cur.get(agent_id) if isinstance(cur.get(agent_id), dict) else None
125
- nxt = mutate(rec)
126
- if nxt is None:
127
- cur.pop(agent_id, None)
128
- else:
129
- cur[agent_id] = nxt
130
- return cur
131
-
132
- session.runtime.update(AGENTS_KEY, _set, flush="sync")
133
-
134
-
135
- # ── ⭐⭐ THE PROJECTION β€” THE WHOLE OF "the same engine that permissions a user" ───────────────
136
- def agent_principal(agent):
137
- """A channel agent, as a PRINCIPAL `core/perm_scope` already understands.
138
-
139
- ⭐⭐ THIS IS THE ENTIRE POINT OF THE TICKET AND IT IS NINE LINES. `perm_scope` takes a user
140
- RECORD β€” a plain dict with `perms`, `perms_v` and `role` β€” not a User object and not a session.
141
- So a channel agent carrying a `perms` block simply IS such a record, and `may_access`,
142
- `visible_fields`, `hidden_keys` and `apply_row_scope` work on it unmodified. There is no second
143
- engine, no parallel vocabulary, and nothing about a channel to keep in step with anything about
144
- a person. That is what the owner's *"exactly like how we toggle permissioning per user"* means
145
- when it is true in code rather than in appearance.
146
-
147
- β›” `role` IS HARDCODED NON-ADMIN AND MUST STAY SO. `perm_scope.may_access` returns True
148
- unconditionally for `role == 'admin'` β€” the break-glass clause that keeps the owner out of a
149
- locked store. A bot has no such emergency and no hands: an agent record that could carry
150
- `role: 'admin'` would be one stored key away from bypassing every wall on this page. The key is
151
- not copied from the record; it is written here, every time.
152
-
153
- β›” AND `perms_v` IS FORCED TO THE CURRENT VERSION, which makes an UNDECLARED database DENY
154
- (C-PERM amendment 4). Without it a fresh agent would fall through to `perms.may_open`'s legacy
155
- grant wall and read `modules: 'all'`-shaped defaults β€” i.e. a brand-new bot would start with
156
- access to everything. Fail-closed from the first byte.
157
- """
158
- import core.perm_scope as perm_scope
159
- return {
160
- "username": f"slack:{(agent or {}).get('channel') or '?'}",
161
- "role": "agent",
162
- "perms": (agent or {}).get("perms") or {},
163
- "perms_v": perm_scope.PERMS_VERSION,
164
- # No `bus`, no `agent`: a channel is not a business unit and not a salesperson. Absent
165
- # rather than 'all' β€” `perms.allowed_bu_labels` narrows on absence, which is the direction
166
- # this must fail in.
167
- }
168
-
169
-
170
- def agent_may_read(agent, module):
171
- """May this channel open `module` at all?"""
172
- import core.perm_scope as perm_scope
173
- if not (agent or {}).get("active", True):
174
- return False
175
- return bool(perm_scope.may_access(agent_principal(agent), module))
176
-
177
-
178
- def agent_visible_fields(agent, fields, module):
179
- """`fields` minus this channel's hidden closure β€” the SAME transitive closure a person gets,
180
- so a formula over a hidden column cannot leak it back through arithmetic."""
181
- import core.perm_scope as perm_scope
182
- return perm_scope.visible_fields(fields, agent_principal(agent), module)
183
-
184
-
185
- def agent_rows(agent, rows, module, fields, ctx=None, st=None):
186
- """The rows this channel may receive β€” `permits()`, so an unanswerable permanent filter DENIES
187
- rather than being ignored.
188
-
189
- ⭐⭐ `st` IS THE TENANT HANDLE AND IT IS HERE BECAUSE OF THE SENTENCE IN `agent_principal`:
190
- a channel agent IS a principal of the same wall, so a permanent filter naming a
191
- user-generated column has to mean the SAME thing here as it does on the grid (owner I16).
192
- A door that lends the handle and a door that does not are one stored rule with two meanings.
193
-
194
- ⚠ PASS THE TENANT'S OWN RUNTIME β€” every other store read in this file takes
195
- `session.runtime`, and a channel is not a second tenancy. It is a keyword with a `None`
196
- default for the same reason `perm_scope.apply_row_scope`'s is: without one this is
197
- byte-identical to the wall that shipped before the argument existed.
198
-
199
- ⚠ `hidden_keys` DELIBERATELY DOES NOT TAKE IT IN THIS CHANGE. Its field-grant leg
200
- under-hides without a handle (see `perm_scope.visible_overlays`), which is a real and
201
- separate gap on this surface β€” but it is the FIELD wall, it moves what a channel receives
202
- rather than what I16 asked for, and widening two walls in one ticket is how a permission
203
- change stops being reviewable.
204
- """
205
- import core.perm_scope as perm_scope
206
- p = agent_principal(agent)
207
- hide = perm_scope.hidden_keys(p, module, fields)
208
- kept = perm_scope.apply_row_scope(rows, p, module, fields, ctx, st=st)
209
- # Both wires, never one: the field list and the row payload are separate, and stripping only
210
- # the first leaves the value sitting in the second where anyone can read it.
211
- return [perm_scope.strip_row(r, hide) for r in kept]
212
-
213
-
214
- # ── the credential ───────────────────────────────────────────────────────────────────────────
215
- def slack_creds(rt):
216
- """This TENANT's Slack credentials from its own keychain, or None.
217
-
218
- β›” NO ENVIRONMENT FALLBACK, DELIBERATELY, AND THIS IS D-202's LESSON APPLIED BEFORE IT
219
- HAPPENS AGAIN. Meta Ads shipped loading end-to-end and was still not a connector, because
220
- `keychain.meta_creds` had no caller and the token came from the owner's `.env` β€” making it a
221
- tenant-#0 FACT indistinguishable from a screenshot. `keychain.meta_creds`' own docstring spells
222
- out the rule: handing the environment's credential to a tenant whose admin has not stored one
223
- is the leak the resolver exists to prevent. So a tenant with no entry gets None and the pane
224
- SAYS so.
225
-
226
- ⚠ Reads through `list_entries` + `read_fields` (both public) rather than `keychain._first_creds`
227
- (private, and in a file outside this lane's fence). Same deterministic rule: entries are
228
- id-sorted, so "first" is stable across reads rather than dict-order luck.
229
- """
230
- try:
231
- import core.keychain as keychain
232
- for row in keychain.list_entries(rt):
233
- if str(row.get("type") or "") != "slack":
234
- continue
235
- fields = keychain.read_fields(rt, row.get("id"))
236
- if isinstance(fields, dict) and fields.get("signing_secret"):
237
- return fields
238
- except Exception: # noqa: BLE001
239
- # A locked or unreadable keychain reads as "not configured" for the PANE, which is honest;
240
- # the signature check below fails closed regardless, so this cannot widen anything.
241
- return None
242
- return None
243
-
244
-
245
- # ── the authenticated doors (the Manage agent surface) ───────────────────────────────────────
246
- def _summary(agent, modules):
247
- """One sentence per agent for the list row β€” computed here, because the alternative is one
248
- round trip per row to fill one cell (the same reason `AdminUser.access` exists)."""
249
- # ⭐ W36-T22 / C2 β€” every listed database is governed now; the `enforced` flag it used to
250
- # filter on is DELETED, and `m.get("enforced")` would have gone silently falsy here and
251
- # summarised every agent as "" (no databases at all).
252
- governed = list(modules)
253
- perms = agent.get("perms") or {}
254
- open_n = sum(1 for m in governed if (perms.get(m["key"]) or {}).get("access"))
255
- if not governed:
256
- return ""
257
- if open_n == 0:
258
- return "No access"
259
- restricted = sum(1 for m in governed
260
- if (perms.get(m["key"]) or {}).get("access")
261
- and ((perms.get(m["key"]) or {}).get("filter")
262
- or (perms.get(m["key"]) or {}).get("hiddenFields")))
263
- head = (f"All {open_n} database{'' if open_n == 1 else 's'}" if open_n == len(governed)
264
- else f"{open_n} of {len(governed)} databases")
265
- return f"{head}, {restricted} restricted" if restricted else head
266
-
267
-
268
- @router.get("/agents")
269
- def list_channel_agents(session: Session = Depends(admin_gate)):
270
- """This tenant's channel agents, plus whether Slack is reachable at all."""
271
- import routes_admin
272
- modules = routes_admin._perm_modules(session)
273
- rows = []
274
- for aid, a in sorted(_agents(session.runtime).items()):
275
- if not isinstance(a, dict):
276
- continue
277
- rows.append({"id": aid, "channel": a.get("channel") or "",
278
- "channelName": a.get("channelName") or "",
279
- "label": a.get("label") or "", "active": a.get("active", True) is not False,
280
- "writeApproval": a.get("writeApproval", True) is not False,
281
- "summary": _summary(a, modules)})
282
- return {"agents": rows, "configured": slack_creds(session.runtime) is not None}
283
-
284
-
285
- @router.post("/agents")
286
- def create_channel_agent(body: dict = Body(default=None),
287
- session: Session = Depends(admin_gate)):
288
- body = body if isinstance(body, dict) else {}
289
- channel = str(body.get("channel") or "").strip()
290
- if not channel:
291
- raise err(400, "no_channel", "a Slack channel ID is required")
292
- existing = _agents(session.runtime)
293
- # ⚠ ONE AGENT PER CHANNEL. Two records for one conversation would mean two walls, and nothing
294
- # anywhere decides which one applies β€” the [[one-question-two-normalizers]] shape, in the one
295
- # place where the two answers are "may read" and "may not".
296
- for a in existing.values():
297
- if isinstance(a, dict) and str(a.get("channel") or "") == channel:
298
- raise err(409, "channel_taken",
299
- "that channel already has an agent β€” edit it instead of adding a second")
300
- aid = secrets.token_urlsafe(9)
301
- rec = _clean_agent(dict(body, id=aid, createdBy=session.uname,
302
- createdAt=datetime.now(timezone.utc).isoformat(timespec="seconds")))
303
- _put_agent(session, aid, lambda _prior: rec)
304
- fresh = _agents(session.runtime).get(aid)
305
- if not isinstance(fresh, dict):
306
- # The store took the write and did not record it. A 200 here would tell an administrator
307
- # the agent exists when it does not.
308
- raise err(503, "store_unavailable", "the agent was NOT created")
309
- return {"agent": {"id": aid, "channel": rec["channel"], "channelName": rec["channelName"],
310
- "label": rec["label"], "active": rec["active"],
311
- "writeApproval": rec["writeApproval"], "summary": "No access"}}
312
-
313
-
314
- @router.get("/agents/{agent_id}")
315
- def get_channel_agent(agent_id: str, session: Session = Depends(admin_gate)):
316
- """The agent PLUS the permission envelope β€” the SAME shape `get_perms` answers with.
317
-
318
- ⭐ ONE PAYLOAD SHAPE FOR TWO ROOMS. `permsModel.parsePermsPayload` parses this, and
319
- `settings/ModulePermsList` renders it, because they are literally the same client code. A
320
- second envelope here would mean a second parser and a second set of defaults within a wave.
321
- """
322
- import routes_admin
323
- a = _agents(session.runtime).get(str(agent_id))
324
- if not isinstance(a, dict):
325
- raise err(404, "no_such_agent", "no agent with that id")
326
- modules = routes_admin._perm_modules(session)
327
- # ⭐⭐ W36-T22 / C2 β€” EVERY listed database, `ut_*` included. β›” THIS IS WHY THE FLAG'S
328
- # DELETION IS NOT A ONE-FILE CHANGE: `m.get("enforced")` on a row that no longer carries the
329
- # key is None, so this list would have been EMPTY and the channel perms editor would have
330
- # governed ZERO databases while looking entirely correct. A Slack channel is a principal of
331
- # the SAME wall (`verify_perm_scope` section H) and gets the same catalogue.
332
- governed = [m["key"] for m in modules]
333
- stored = a.get("perms") or {}
334
- principal = agent_principal(a)
335
- import core.perm_scope as perm_scope
336
- perms_out = {}
337
- for k in governed:
338
- e = stored.get(k)
339
- # β›” W36-T22 β€” `may_read`, the same evaluator the read door uses, for the reason spelled
340
- # out at `routes_admin.get_perms`. ⚠ It answers DIFFERENTLY here and correctly so: a
341
- # channel agent is not a person with a share, so a `ut_*` key it has not been granted
342
- # defaults CLOSED β€” which is the fail-closed direction for a bot, and the same answer the
343
- # table routes would give it.
344
- perms_out[k] = e if isinstance(e, dict) else {
345
- "access": bool(perm_scope.may_read(principal, k, st=session.runtime)),
346
- "filter": None, "hiddenFields": []}
347
- return {"id": str(agent_id), "channel": a.get("channel") or "",
348
- "channelName": a.get("channelName") or "", "label": a.get("label") or "",
349
- "active": a.get("active", True) is not False,
350
- "writeApproval": a.get("writeApproval", True) is not False,
351
- "perms": perms_out,
352
- # β›” ALWAYS THE CURRENT VERSION, because `agent_principal` forces it: the editor must
353
- # render "undeclared = denied", not the legacy-grant reading it would show at 0.
354
- "perms_v": perm_scope.PERMS_VERSION,
355
- # A bot is NEVER an admin β€” see `agent_principal`. Sent so the editor never paints
356
- # the "Everything (admin)" state for a principal that cannot have it.
357
- "is_admin": False,
358
- "modules": modules,
359
- "fields_by_module": {k: routes_admin._module_fields(k, session=session)
360
- for k in governed}}
361
-
362
-
363
- @router.put("/agents/{agent_id}/perms")
364
- def put_channel_agent_perms(agent_id: str, body: dict = Body(default=None),
365
- session: Session = Depends(admin_gate)):
366
- """Replace this agent's permission block wholesale β€” the same validator a user's block gets."""
367
- import routes_admin
368
- body = body if isinstance(body, dict) else {}
369
- if not isinstance(_agents(session.runtime).get(str(agent_id)), dict):
370
- raise err(404, "no_such_agent", "no agent with that id")
371
- if "perms" not in body:
372
- raise err(400, "empty_patch", "no perms to save")
373
- mods = routes_admin._perm_modules(session)
374
- # ⭐ THE SAME `_clean_perms`, NOT A COPY OF IT. It refuses a filter this module cannot
375
- # evaluate, refuses a hiddenFields key that names nothing, and refuses a BU condition the
376
- # pushdown cannot read. Every one of those is exactly as true for a channel as for a person,
377
- # and a second validator here is a second place for one of them to go missing.
378
- # ⚠ CORRECTED W36-T22: this list used to end "refuses an unenforced `ut_*` key". That refusal
379
- # is DELETED β€” W36-T21 armed the wall over every database, so there is no unenforced key left
380
- # to refuse, and a comment naming a rule that no longer exists is how the next wave re-derives
381
- # it ([[two-gates-can-assert-opposite-things]]).
382
- cleaned = routes_admin._clean_perms(
383
- body.get("perms"),
384
- governed_keys={m["key"] for m in mods}, session=session) or {}
385
-
386
- import core.perm_scope as perm_scope
387
-
388
- def _mut(prior):
389
- if not isinstance(prior, dict):
390
- return None
391
- nxt = dict(prior)
392
- nxt["perms"] = cleaned
393
- nxt["perms_v"] = perm_scope.PERMS_VERSION
394
- return nxt
395
-
396
- _put_agent(session, str(agent_id), _mut)
397
- fresh = _agents(session.runtime).get(str(agent_id)) or {}
398
- if (fresh.get("perms") or {}) != cleaned:
399
- # The one direction this must never fail in: telling an administrator the wall is up when
400
- # it is not.
401
- raise err(503, "store_unavailable", "the permissions were not saved β€” the agent is UNCHANGED")
402
- return {"id": str(agent_id), "perms": fresh.get("perms") or {},
403
- "perms_v": int(fresh.get("perms_v") or 0)}
404
-
405
-
406
- @router.patch("/agents/{agent_id}")
407
- def patch_channel_agent(agent_id: str, body: dict = Body(default=None),
408
- session: Session = Depends(admin_gate)):
409
- body = body if isinstance(body, dict) else {}
410
- if not isinstance(_agents(session.runtime).get(str(agent_id)), dict):
411
- raise err(404, "no_such_agent", "no agent with that id")
412
- #: β›” `perms` IS NOT PATCHABLE HERE. It has its own door with its own validator; accepting it
413
- #: on the metadata route would be a second, unvalidated way to write the wall.
414
- allowed = {k: v for k, v in body.items()
415
- if k in ("label", "active", "writeApproval", "channelName")}
416
- if not allowed:
417
- raise err(400, "empty_patch", "nothing to change")
418
-
419
- def _mut(prior):
420
- return _clean_agent(allowed, prior=prior) if isinstance(prior, dict) else None
421
-
422
- _put_agent(session, str(agent_id), _mut)
423
- import routes_admin
424
- a = _agents(session.runtime).get(str(agent_id)) or {}
425
- return {"agent": {"id": str(agent_id), "channel": a.get("channel") or "",
426
- "channelName": a.get("channelName") or "", "label": a.get("label") or "",
427
- "active": a.get("active", True) is not False,
428
- "writeApproval": a.get("writeApproval", True) is not False,
429
- "summary": _summary(a, routes_admin._perm_modules(session))}}
430
-
431
-
432
- @router.delete("/agents/{agent_id}")
433
- def delete_channel_agent(agent_id: str, session: Session = Depends(admin_gate)):
434
- if not isinstance(_agents(session.runtime).get(str(agent_id)), dict):
435
- raise err(404, "no_such_agent", "no agent with that id")
436
- _put_agent(session, str(agent_id), lambda _prior: None)
437
- if isinstance(_agents(session.runtime).get(str(agent_id)), dict):
438
- raise err(503, "store_unavailable", "the agent was NOT removed")
439
- return {"ok": True}
440
-
441
-
442
- # ── ⭐ W33-T67: THE WRITE-APPROVAL GATE ───────────────────────────────────────────────────────
443
- def _is_empty(value):
444
- """Is this value ABSENT, as opposed to falsy?
445
-
446
- β›” COPIED EXACTLY FROM OpenTag's `agent/write_confirmation.py`, and the exactness is the point:
447
- `0` and `False` are VALUES a human must see on the card. Setting a price to 0 or a flag to
448
- False is precisely the kind of write somebody needs to approve, and a naive `if not value`
449
- drops both rows silently β€” the approver then reads a card that does not describe the write
450
- they are approving. The MIT licence gate for this borrowing is recorded in
451
- `.claude/wiki/research/opentag-adoption.md`.
452
- """
453
- return value is None or (isinstance(value, str) and value.strip() == "")
454
-
455
-
456
- def approval_card(action, values, agent=None):
457
- """The card a human reads before a bot writes. `{action, rows, truncated, note}`.
458
-
459
- β›” ROW-CAPPED, and the cap is a SAFETY property rather than a layout one: past a certain
460
- length Slack collapses a message behind "Show more", and **an approver who cannot read the
461
- card cannot approve it.** When rows are dropped the card SAYS how many β€” a truncation nobody
462
- was told about is the violation, not the truncation (wave-30 R6's second sentence).
463
- """
464
- rows = [{"field": str(k), "value": v}
465
- for k, v in (values or {}).items() if not _is_empty(v)]
466
- rows.sort(key=lambda r: r["field"])
467
- shown, dropped = rows[:APPROVAL_MAX_ROWS], max(0, len(rows) - APPROVAL_MAX_ROWS)
468
- return {
469
- "action": str(action or "")[:80],
470
- "rows": shown,
471
- "truncated": dropped,
472
- "note": (f"{dropped} more field(s) not shown β€” open the record to review them before "
473
- f"approving." if dropped else ""),
474
- "agent": (agent or {}).get("label") or (agent or {}).get("channel") or "",
475
- }
476
-
477
-
478
- def intercept_write(agent, action, values, stash):
479
- """THE INTERCEPTOR. Returns `("commit", None)` or `("await_approval", card)`.
480
-
481
- ⭐ The one thing worth taking from OpenTag: a MUTATING tool call is halted, rendered as a card
482
- naming the action and its values, and committed only on an explicit human accept. Everything
483
- else in that repo β€” the transport, the runtime, the identity model β€” is AVOID (amendment A1).
484
-
485
- β›” FAIL-CLOSED ON A MISSING FLAG: `is not False`, so a record written before `writeApproval`
486
- existed requires approval. The unsafe direction here is not recoverable β€” revoking a bot does
487
- not un-write what it wrote.
488
- """
489
- if (agent or {}).get("writeApproval", True) is not False:
490
- card = approval_card(action, values, agent)
491
- stash(card)
492
- return "await_approval", card
493
- return "commit", None
494
-
495
-
496
- def _pending(rt):
497
- try:
498
- found = rt.get(PENDING_KEY) or {}
499
- except Exception: # noqa: BLE001
500
- return {}
501
- return found if isinstance(found, dict) else {}
502
-
503
-
504
- def stash_pending(rt, agent, action, values, now=None):
505
- """Store one awaiting-approval mutation and return its token.
506
-
507
- The token is what rides the card's button, so it is `secrets.token_urlsafe` and not the
508
- action's id: a guessable value on an unauthenticated door is a way to approve somebody else's
509
- write. Server-only bucket, per tenant, exactly as `routes_forms.TOKENS_KEY` is.
510
- """
511
- token = secrets.token_urlsafe(18)
512
- row = {"at": float(now if now is not None else time.time()),
513
- "channel": str((agent or {}).get("channel") or ""),
514
- "action": str(action or "")[:80],
515
- "values": values if isinstance(values, dict) else {}}
516
- rt.update(PENDING_KEY, lambda cur: {**(cur or {}), token: row}, flush="sync")
517
- return token
518
-
519
-
520
- #: `{action kind: fn(rt, row) -> None}`. Registered by whatever can actually perform a mutation.
521
- #:
522
- #: β›”β›” EMPTY IN PRODUCTION TODAY, AND SAYING SO IS THE POINT. `commit_pending` below is the ONE
523
- #: place an approved write may be performed, so it is the place a negative control can bite β€” but
524
- #: a gate is only as honest as what it guards. Until something registers here, an approved card
525
- #: performs NOTHING and `commit_pending` returns `no_committer` rather than pretending. That is a
526
- #: declared absence, not a silent one [[flag-shipped-without-its-writer]].
527
- _COMMITTERS = {}
528
-
529
-
530
- def register_committer(kind, fn):
531
- """Declare who may perform an approved mutation of `kind`. Idempotent by key."""
532
- _COMMITTERS[str(kind)] = fn
533
-
534
-
535
- def commit_pending(rt, token, approved, now=None):
536
- """Resolve ONE approval card. Returns one of
537
- `expired` Β· `unknown` Β· `rejected` Β· `no_committer` Β· `committed` Β· `failed`.
538
-
539
- β›”β›” THIS IS THE WRITE GATE, AND IT IS A SEPARATE FUNCTION FROM THE ROUTE ON PURPOSE. T67's
540
- `done-when` asks for an NC proving a REJECTED card writes nothing. A control aimed at the
541
- route could not bite while the route had no write in it at all β€” it would stay green with the
542
- guard deleted, because there would be nothing for the guard to be protecting anything from
543
- ([[gate-can-report-green-on-nothing]], and a verifier caught exactly that on this ticket's
544
- first draft). Putting the commit behind ONE named seam gives the control a real subject: the
545
- gate registers a recorder, and `rejected` must leave it untouched while `approve` must call it
546
- exactly once.
547
-
548
- β›” THE TOKEN IS CONSUMED BEFORE EITHER BRANCH, and before any committer runs. A card that
549
- survives its own click is a replayable write, and "approve twice" must never mean "write
550
- twice". The delete is `flush="sync"` for the same reason.
551
- """
552
- now = float(now if now is not None else time.time())
553
- row = _pending(rt).get(str(token))
554
- if not isinstance(row, dict):
555
- return "unknown"
556
- # Consume FIRST β€” before the TTL verdict, before the approve/reject branch, before any write.
557
- rt.update(PENDING_KEY,
558
- lambda cur: {k: v for k, v in (cur or {}).items() if k != str(token)},
559
- flush="sync")
560
- if now - float(row.get("at") or 0) > APPROVAL_TTL_S:
561
- return "expired"
562
- if not approved:
563
- # β›” NOTHING BELOW THIS LINE RUNS FOR A REJECTED CARD. The whole gate is this return.
564
- return "rejected"
565
- fn = _COMMITTERS.get(str(row.get("action") or ""))
566
- if fn is None:
567
- return "no_committer"
568
- try:
569
- fn(rt, row)
570
- except Exception: # noqa: BLE001
571
- # A committer that raises must not read as a commit. The card is already consumed, so the
572
- # honest report is that it failed, and the human asks again.
573
- return "failed"
574
- return "committed"
575
-
576
-
577
- # ── the UNAUTHENTICATED Slack door ───────────────────────────────────────────────────────────
578
- #: Sliding window, keyed on the SLACK TEAM, never the client IP.
579
- #:
580
- #: β›” WHY NOT THE IP, WHICH IS WHAT `routes_forms` DOES. Every Slack event arrives from Slack's own
581
- #: infrastructure, so an IP window is one shared bucket for every workspace on the platform: one
582
- #: busy tenant would spend the allowance for all of them, and the symptom would be another
583
- #: tenant's bot going quiet. The team id is the closest thing to the actual noisy party.
584
- #: ⚠ It is attacker-CONTROLLED before the signature is checked, so the window is applied AFTER
585
- #: verification, never before β€” an unsigned request is refused on cost grounds anyway (a signature
586
- #: check is a single HMAC).
587
- RATE_WINDOW_S, RATE_PER_WINDOW = 60, 60
588
- _HITS: dict = {}
589
-
590
-
591
- def _rate_ok(key, now):
592
- seen = [t for t in _HITS.get(key, ()) if now - t < RATE_WINDOW_S]
593
- seen.append(now)
594
- _HITS[key] = seen
595
- if len(_HITS) > 4096:
596
- for k in [k for k, v in _HITS.items() if not v or now - v[-1] > RATE_WINDOW_S]:
597
- _HITS.pop(k, None)
598
- return len(seen) <= RATE_PER_WINDOW
599
-
600
-
601
- async def _raw_body(request):
602
- """The body as RAW BYTES, capped.
603
-
604
- β›”β›” RAW, NOT PARSED, AND THAT IS NOT A STYLE CHOICE. Slack signs the literal string
605
- `v0:{timestamp}:{body}` byte for byte. `routes_forms._bounded_body` β€” the function this is
606
- otherwise copied from β€” returns only the parsed dict and DISCARDS the bytes, so a verbatim
607
- copy of it could not verify a Slack signature at all: re-serialising the dict changes
608
- whitespace and key order and the HMAC no longer matches. The bug would look like "Slack
609
- signatures are always invalid", which is indistinguishable from a wrong secret.
610
-
611
- ⚠ And it streams rather than calling `request.body()`: FastAPI would otherwise have buffered
612
- the whole body before the handler's first line, so a content-length check inside the handler
613
- caps nothing (`routes_forms`' own scar, recorded in its docstring).
614
- """
615
- size, chunks = 0, []
616
- async for chunk in request.stream():
617
- size += len(chunk)
618
- if size > MAX_BODY_BYTES:
619
- raise err(413, "body_too_large", "that request is too large")
620
- chunks.append(chunk)
621
- return b"".join(chunks)
622
-
623
-
624
- def _refuse():
625
- """ONE refusal for every reason. No oracle.
626
-
627
- A wrong signature, a stale timestamp, an unknown team, an unconfigured tenant and a channel
628
- with no agent all answer identically β€” otherwise the door tells an unauthenticated caller
629
- which tenants exist and which channels are configured, which is the question it was built to
630
- not answer. `routes_forms._refuse` takes the same posture for the same reason.
631
- """
632
- return err(403, "bad_request", "that request could not be verified")
633
-
634
-
635
- def _verify(secret, timestamp, raw, signature, now):
636
- """Slack's v0 signature, with the replay window checked FIRST."""
637
- try:
638
- ts = int(str(timestamp or "0"))
639
- except ValueError:
640
- return False
641
- if abs(now - ts) > SLACK_MAX_SKEW_S:
642
- return False
643
- base = b"v0:" + str(ts).encode("ascii") + b":" + (raw or b"")
644
- import hashlib
645
- expected = "v0=" + hmac.new(str(secret).encode("utf-8"), base, hashlib.sha256).hexdigest()
646
- return hmac.compare_digest(expected, str(signature or ""))
647
-
648
-
649
- def _resolve_tenant(raw, timestamp, signature, now):
650
- """`(slug, runtime, creds)` for the tenant whose signing secret verifies this request.
651
-
652
- β›” THE TENANT IS DECIDED BY THE SIGNATURE, NEVER BY THE PAYLOAD. A body-supplied `team_id`
653
- would let an unauthenticated caller name the tenant it wants to be β€” the exact widening
654
- `deps._user_for`'s tenant-equality check closes on the authenticated side. So every configured
655
- tenant's secret is tried and the one that VERIFIES names the tenant.
656
-
657
- ⚠ NO EARLY `break` ON A FAILED CANDIDATE and no per-tenant error: the loop's cost must not
658
- depend on which tenant matched. `routes_forms._resolve` walks tenants the same way, for the
659
- same reason β€” a public door has no session to ask.
660
- """
661
- from harness import runtime as _rt
662
- hit = None
663
- try:
664
- slugs = list(_rt.known_tenants())
665
- except Exception: # noqa: BLE001
666
- return None, None, None
667
- for slug in slugs:
668
- try:
669
- rt = _rt.get_runtime(slug)
670
- except Exception: # noqa: BLE001
671
- continue
672
- creds = slack_creds(rt)
673
- if not creds:
674
- continue
675
- if _verify(creds.get("signing_secret"), timestamp, raw, signature, now) and hit is None:
676
- hit = (slug, rt, creds)
677
- return hit if hit else (None, None, None)
678
-
679
-
680
- @router.post("/slack/events")
681
- async def slack_events(request: Request):
682
- """Slack's Events API. UNAUTHENTICATED BY CONSTRUCTION β€” no `Depends(require_session)`.
683
-
684
- ⭐ Built like `routes_forms.py`'s public door and not like OpenTag's: one process, no vendor,
685
- no second runtime, no persistent outbound socket, and this tenant's Slack secret never leaves
686
- this deployment (amendment A1).
687
- """
688
- now = time.time()
689
- raw = await _raw_body(request)
690
- ts = request.headers.get("x-slack-request-timestamp")
691
- sig = request.headers.get("x-slack-signature")
692
- slug, rt, _creds = _resolve_tenant(raw, ts, sig, now)
693
- if not rt:
694
- raise _refuse()
695
-
696
- try:
697
- payload = json.loads(raw or b"{}")
698
- except ValueError:
699
- raise _refuse()
700
- if not isinstance(payload, dict):
701
- raise _refuse()
702
-
703
- # Slack's one-time endpoint handshake. Answered ONLY after the signature verified β€” an
704
- # unsigned challenge echo would confirm the endpoint exists to anyone who probes it.
705
- if payload.get("type") == "url_verification":
706
- return {"challenge": str(payload.get("challenge") or "")[:512]}
707
-
708
- team = str(payload.get("team_id") or slug)
709
- if not _rate_ok(f"{slug}:{team}", now):
710
- raise err(429, "too_many_requests", "too many requests β€” wait a moment and try again")
711
-
712
- event = payload.get("event") if isinstance(payload.get("event"), dict) else {}
713
- channel = str(event.get("channel") or "")
714
- agent = None
715
- for a in _agents(rt).values():
716
- if isinstance(a, dict) and str(a.get("channel") or "") == channel:
717
- agent = a
718
- break
719
- # β›” NO AGENT, OR AN INACTIVE ONE, IS SILENCE β€” a 200 with no action. Not a 403: Slack retries
720
- # a non-2xx up to three times, so refusing here would turn "this channel is not configured"
721
- # into three refusals per message, and the retry storm would be the only visible symptom.
722
- # ⚠ And it must not say WHICH β€” a distinguishable answer tells an unauthenticated caller which
723
- # channels this workspace has configured.
724
- if not isinstance(agent, dict) or agent.get("active", True) is False:
725
- return {"ok": True}
726
-
727
- # β›”β›” THE ANSWERING HALF IS NOT BUILT YET, AND SAYING SO IS THE POINT. What ships here is the
728
- # DOOR and the WALL: a verified request, resolved to a tenant by signature, matched to a
729
- # channel agent whose `perms` block is a `perm_scope` principal (`agent_principal`). Composing
730
- # a reply means an LLM call on the cheap-first ladder plus the read path, and a half-built
731
- # answerer that returns something plausible is worse than one that returns nothing.
732
- # Booked rather than faked [[flag-shipped-without-its-writer]].
733
- return {"ok": True}
734
-
735
-
736
- @router.post("/slack/interact")
737
- async def slack_interact(request: Request):
738
- """The approval card's button (`W33-T67`). UNAUTHENTICATED, signature-verified, same shape."""
739
- now = time.time()
740
- raw = await _raw_body(request)
741
- slug, rt, _creds = _resolve_tenant(raw, request.headers.get("x-slack-request-timestamp"),
742
- request.headers.get("x-slack-signature"), now)
743
- if not rt:
744
- raise _refuse()
745
- # Slack posts interactions as `application/x-www-form-urlencoded` with a `payload=` field.
746
- from urllib.parse import parse_qs
747
- try:
748
- form = parse_qs(raw.decode("utf-8"))
749
- payload = json.loads((form.get("payload") or ["{}"])[0])
750
- except Exception: # noqa: BLE001
751
- raise _refuse()
752
- if not isinstance(payload, dict):
753
- raise _refuse()
754
- if not _rate_ok(f"{slug}:interact", now):
755
- raise err(429, "too_many_requests", "too many requests β€” wait a moment and try again")
756
-
757
- actions = payload.get("actions") if isinstance(payload.get("actions"), list) else []
758
- choice = (actions[0] if actions and isinstance(actions[0], dict) else {})
759
- token = str(choice.get("value") or "")
760
- # ⭐ ANYTHING THAT IS NOT LITERALLY "approve" IS A REJECT. Fail-closed on a malformed,
761
- # truncated or unknown action id: the direction that can be wrong here without anyone
762
- # noticing is the one that writes.
763
- approved = str(choice.get("action_id") or "") == "approve"
764
- # β›” THE ROUTE DOES NOT DECIDE β€” `commit_pending` does, and it is the ONE seam a negative
765
- # control can aim at. Consumption, the TTL, the reject branch and the committer lookup all
766
- # live there; this handler only turns its verdict into a sentence.
767
- verdict = commit_pending(rt, token, approved, now=now)
768
- return {"text": {
769
- # An unknown, already-used or expired token is not an error: a person clicking a stale
770
- # card should be told it is stale, not shown a failure.
771
- "unknown": "That approval is no longer available. Ask the bot again.",
772
- "expired": "That approval expired before it was answered. Ask the bot again.",
773
- "rejected": "Rejected. Nothing was changed.",
774
- # ⚠ HONEST, not reassuring: the answering half that would register a committer is booked,
775
- # so an approved card today performs nothing and says exactly that rather than "Approved."
776
- "no_committer": "Approved β€” but this workspace has nothing configured to carry it out yet.",
777
- "failed": "That change could not be completed. Nothing was saved; ask the bot again.",
778
- "committed": "Approved.",
779
- }.get(verdict, "That approval is no longer available. Ask the bot again.")}
780
-
781
-
782
- @router.get("/slack/health")
783
- def slack_health(session: Session = Depends(require_session)):
784
- """Is Slack configured for THIS tenant? Session-gated, and it answers about one tenant only β€”
785
- enumerating the others would answer a question about our customer list."""
786
- creds = slack_creds(session.runtime)
787
- return {"configured": creds is not None,
788
- "agents": len(_agents(session.runtime)),
789
- "hasBotToken": bool((creds or {}).get("bot_token"))}
 
1
+ """routes_slack.py β€” MANAGE AGENT: a bot per Slack channel, walled by the engine that walls a
2
+ person. (wave 33, owner item 10 Β· `W33-T38` / `W33-T39` / `W33-T67`.)
3
+
4
+ Owner, verbatim (2026-08-14): *"Expand Slack we need to be able to configure an AI bot PER channel
5
+ whose permissions we can toggle based on Fields etc of the databases, exactly like how we toggle
6
+ permissioning per user."*
7
+
8
+ ────────────────────────────────────────────────────────────────────────────────────────────────
9
+ β›”β›” D-84 SAID THIS WAS "adapted from OpenTag" AND THAT PREMISE IS FALSE (PRD amendment A1).
10
+
11
+ `PENDING.md` D-84 has named OpenTag as the model for per-Slack-channel permissioning since
12
+ 2026-08-08. **OpenTag has no permission model to adapt.** `app/runtime-host.ts` is
13
+ `identifyUser: () => OPENTAG_SERVICE_USER` β€” ONE constant identity for every request β€” and its own
14
+ `sender-context.ts` says the Slack sender is *"informational only… not gating access."* Its
15
+ authorization ceiling is whole-MCP-server, decided once at process boot from env vars. It is a
16
+ live specimen of identity that LOOKS like a permission and is not.
17
+
18
+ `core/perm_scope.py` is already strictly more expressive: per database, per row, per FIELD, per
19
+ principal. So item 10 is OUR engine projected onto a CHANNEL principal β€” see `agent_principal`
20
+ below, which is the whole projection and is nine lines. Study:
21
+ `.claude/wiki/research/opentag-adoption.md` (licence gate: MIT, PASSED).
22
+
23
+ What OpenTag genuinely contributes is the WRITE-APPROVAL interceptor (`W33-T67`, `approval_card`
24
+ below) and nothing else. Its TRANSPORT is AVOID wholesale β€” hosted CopilotKit Intelligence, a
25
+ second Node process, a persistent outbound socket and each tenant's Slack credentials held by a
26
+ third party, against ONE process on the HF free tier. The door here is `routes_forms.py`'s proven
27
+ unauthenticated-public shape plus Slack's signing-secret check.
28
+
29
+ ────────────────────────────────────────────────────────────────────────────────────────────────
30
+ ⚠ A CHANNEL IS A PRINCIPAL, NOT A PERSON. The wall binds to the Slack conversation id, so every
31
+ human in that channel reads through the same rules. That is the honest reading of "a bot per
32
+ channel" and the pane says it out loud, because the alternative is an admin assuming per-person
33
+ scoping from a screen that looks exactly like the per-person one.
34
+
35
+ ⚠ AND THE IDENTITY IS THE CHANNEL ID, NEVER ITS NAME. A channel can be renamed; its id cannot.
36
+ A wall bound to `#sales` silently re-points the day somebody renames the channel.
37
+ """
38
+ import hmac
39
+ import json
40
+ import os
41
+ import secrets
42
+ import time
43
+ from datetime import datetime, timezone
44
+
45
+ from fastapi import APIRouter, Body, Depends, Request
46
+
47
+ from deps import Session, err, require_session
48
+ from routes_admin import admin_gate
49
+
50
+ router = APIRouter(prefix="/api/v1")
51
+
52
+ #: The tenant's channel agents: `{id: record}`. A per-tenant bucket, so it rides
53
+ #: `runtime.store_key`'s prefix and never lands in tenant #0's namespace.
54
+ AGENTS_KEY = "slack_agents"
55
+
56
+ #: Pending mutations awaiting a human's click (`W33-T67`). Server-only, per tenant.
57
+ PENDING_KEY = "slack_pending_writes"
58
+
59
+ #: How long an approval card stays clickable. An approval is a statement about the world as it was
60
+ #: when the card was rendered; an hour later the values it names may no longer be the values it
61
+ #: would write. Expiring is the honest behaviour, and an expired card says so rather than 403ing.
62
+ APPROVAL_TTL_S = 15 * 60
63
+
64
+ #: Slack rejects a replayed request older than 5 minutes; so do we, before any signature work.
65
+ SLACK_MAX_SKEW_S = 60 * 5
66
+
67
+ MAX_BODY_BYTES = 64 * 1024
68
+
69
+ #: β›” ROW-CAPPED BECAUSE AN APPROVER WHO CANNOT READ THE CARD CANNOT APPROVE IT. Copied
70
+ #: deliberately from OpenTag's `agent/write_confirmation.py`, which caps for the same reason:
71
+ #: past a certain length Slack collapses a message behind "Show more", and a human clicking
72
+ #: Approve on a card whose tail they never saw is worse than no card at all.
73
+ APPROVAL_MAX_ROWS = 12
74
+
75
+
76
+ # ── the tenant's agent records ───────────────────────────────────────────────────────────────
77
+ def _agents(rt):
78
+ """`{id: record}` for one tenant. `{}` on any failure β€” an unreadable bucket must degrade to
79
+ "this tenant has no agents", never to a 500 on the settings pane."""
80
+ try:
81
+ found = rt.get(AGENTS_KEY) or {}
82
+ except Exception: # noqa: BLE001
83
+ return {}
84
+ return found if isinstance(found, dict) else {}
85
+
86
+
87
+ def _clean_agent(raw, prior=None):
88
+ """One stored record, built KEY BY KEY.
89
+
90
+ β›” Never `dict(raw)` and never `**raw`. This record is the SUBJECT of a permission decision;
91
+ a client-supplied key landing in it is a client-supplied input to `perm_scope`. The same rule
92
+ `routes_forms._public_form` follows on the way out, applied here on the way in.
93
+ """
94
+ prior = prior if isinstance(prior, dict) else {}
95
+ out = {
96
+ "id": str(prior.get("id") or raw.get("id") or ""),
97
+ "channel": str(raw.get("channel") or prior.get("channel") or "").strip()[:64],
98
+ "channelName": str(raw.get("channelName") or prior.get("channelName") or "").strip()[:80],
99
+ "label": " ".join(str(raw.get("label") or prior.get("label") or "").split())[:80],
100
+ "active": bool(raw.get("active", prior.get("active", True))),
101
+ # β›” DEFAULT TRUE, AND `is not False` RATHER THAN TRUTHINESS. A record written before this
102
+ # key existed must read as "approval required": the one direction a default here is
103
+ # allowed to be wrong in is the safe one, because an unattended write cannot be undone by
104
+ # revoking the bot afterwards [[default-must-pass-its-own-guard]].
105
+ "writeApproval": raw.get("writeApproval", prior.get("writeApproval", True)) is not False,
106
+ # The permission block. Same shape, same validator, same engine as a user's.
107
+ "perms": prior.get("perms") if isinstance(prior.get("perms"), dict) else {},
108
+ "perms_v": int(prior.get("perms_v") or 0),
109
+ "createdBy": str(prior.get("createdBy") or raw.get("createdBy") or ""),
110
+ "createdAt": str(prior.get("createdAt") or raw.get("createdAt") or ""),
111
+ }
112
+ return out
113
+
114
+
115
+ def _put_agent(session, agent_id, mutate):
116
+ """Read-modify-write ONE agent under the tenant's bucket, synchronously.
117
+
118
+ `flush="sync"` because the client re-reads the list immediately after every write here, and an
119
+ async flush would let the refetch win the race and paint the pre-write state β€” the
120
+ [[refetch-eats-its-own-write]] shape.
121
+ """
122
+ def _set(cur):
123
+ cur = dict(cur or {})
124
+ rec = cur.get(agent_id) if isinstance(cur.get(agent_id), dict) else None
125
+ nxt = mutate(rec)
126
+ if nxt is None:
127
+ cur.pop(agent_id, None)
128
+ else:
129
+ cur[agent_id] = nxt
130
+ return cur
131
+
132
+ session.runtime.update(AGENTS_KEY, _set, flush="sync")
133
+
134
+
135
+ # ── ⭐⭐ THE PROJECTION β€” THE WHOLE OF "the same engine that permissions a user" ───────────────
136
+ def agent_principal(agent):
137
+ """A channel agent, as a PRINCIPAL `core/perm_scope` already understands.
138
+
139
+ ⭐⭐ THIS IS THE ENTIRE POINT OF THE TICKET AND IT IS NINE LINES. `perm_scope` takes a user
140
+ RECORD β€” a plain dict with `perms`, `perms_v` and `role` β€” not a User object and not a session.
141
+ So a channel agent carrying a `perms` block simply IS such a record, and `may_access`,
142
+ `visible_fields`, `hidden_keys` and `apply_row_scope` work on it unmodified. There is no second
143
+ engine, no parallel vocabulary, and nothing about a channel to keep in step with anything about
144
+ a person. That is what the owner's *"exactly like how we toggle permissioning per user"* means
145
+ when it is true in code rather than in appearance.
146
+
147
+ β›” `role` IS HARDCODED NON-ADMIN AND MUST STAY SO. `perm_scope.may_access` returns True
148
+ unconditionally for `role == 'admin'` β€” the break-glass clause that keeps the owner out of a
149
+ locked store. A bot has no such emergency and no hands: an agent record that could carry
150
+ `role: 'admin'` would be one stored key away from bypassing every wall on this page. The key is
151
+ not copied from the record; it is written here, every time.
152
+
153
+ β›” AND `perms_v` IS FORCED TO THE CURRENT VERSION, which makes an UNDECLARED database DENY
154
+ (C-PERM amendment 4). Without it a fresh agent would fall through to `perms.may_open`'s legacy
155
+ grant wall and read `modules: 'all'`-shaped defaults β€” i.e. a brand-new bot would start with
156
+ access to everything. Fail-closed from the first byte.
157
+ """
158
+ import core.perm_scope as perm_scope
159
+ return {
160
+ "username": f"slack:{(agent or {}).get('channel') or '?'}",
161
+ "role": "agent",
162
+ "perms": (agent or {}).get("perms") or {},
163
+ "perms_v": perm_scope.PERMS_VERSION,
164
+ # No `bus`, no `agent`: a channel is not a business unit and not a salesperson. Absent
165
+ # rather than 'all' β€” `perms.allowed_bu_labels` narrows on absence, which is the direction
166
+ # this must fail in.
167
+ }
168
+
169
+
170
+ def agent_may_read(agent, module):
171
+ """May this channel open `module` at all?"""
172
+ import core.perm_scope as perm_scope
173
+ if not (agent or {}).get("active", True):
174
+ return False
175
+ return bool(perm_scope.may_access(agent_principal(agent), module))
176
+
177
+
178
+ def agent_visible_fields(agent, fields, module):
179
+ """`fields` minus this channel's hidden closure β€” the SAME transitive closure a person gets,
180
+ so a formula over a hidden column cannot leak it back through arithmetic."""
181
+ import core.perm_scope as perm_scope
182
+ return perm_scope.visible_fields(fields, agent_principal(agent), module)
183
+
184
+
185
+ def agent_rows(agent, rows, module, fields, ctx=None, st=None):
186
+ """The rows this channel may receive β€” `permits()`, so an unanswerable permanent filter DENIES
187
+ rather than being ignored.
188
+
189
+ ⭐⭐ `st` IS THE TENANT HANDLE AND IT IS HERE BECAUSE OF THE SENTENCE IN `agent_principal`:
190
+ a channel agent IS a principal of the same wall, so a permanent filter naming a
191
+ user-generated column has to mean the SAME thing here as it does on the grid (owner I16).
192
+ A door that lends the handle and a door that does not are one stored rule with two meanings.
193
+
194
+ ⚠ PASS THE TENANT'S OWN RUNTIME β€” every other store read in this file takes
195
+ `session.runtime`, and a channel is not a second tenancy. It is a keyword with a `None`
196
+ default for the same reason `perm_scope.apply_row_scope`'s is: without one this is
197
+ byte-identical to the wall that shipped before the argument existed.
198
+
199
+ ⚠ `hidden_keys` DELIBERATELY DOES NOT TAKE IT IN THIS CHANGE. Its field-grant leg
200
+ under-hides without a handle (see `perm_scope.visible_overlays`), which is a real and
201
+ separate gap on this surface β€” but it is the FIELD wall, it moves what a channel receives
202
+ rather than what I16 asked for, and widening two walls in one ticket is how a permission
203
+ change stops being reviewable.
204
+ """
205
+ import core.perm_scope as perm_scope
206
+ p = agent_principal(agent)
207
+ hide = perm_scope.hidden_keys(p, module, fields)
208
+ kept = perm_scope.apply_row_scope(rows, p, module, fields, ctx, st=st)
209
+ # Both wires, never one: the field list and the row payload are separate, and stripping only
210
+ # the first leaves the value sitting in the second where anyone can read it.
211
+ return [perm_scope.strip_row(r, hide) for r in kept]
212
+
213
+
214
+ # ── the credential ───────────────────────────────────────────────────────────────────────────
215
+ def slack_creds(rt):
216
+ """This TENANT's Slack credentials from its own keychain, or None.
217
+
218
+ β›” NO ENVIRONMENT FALLBACK, DELIBERATELY, AND THIS IS D-202's LESSON APPLIED BEFORE IT
219
+ HAPPENS AGAIN. Meta Ads shipped loading end-to-end and was still not a connector, because
220
+ `keychain.meta_creds` had no caller and the token came from the owner's `.env` β€” making it a
221
+ tenant-#0 FACT indistinguishable from a screenshot. `keychain.meta_creds`' own docstring spells
222
+ out the rule: handing the environment's credential to a tenant whose admin has not stored one
223
+ is the leak the resolver exists to prevent. So a tenant with no entry gets None and the pane
224
+ SAYS so.
225
+
226
+ ⚠ Reads through `list_entries` + `read_fields` (both public) rather than `keychain._first_creds`
227
+ (private, and in a file outside this lane's fence). Same deterministic rule: entries are
228
+ id-sorted, so "first" is stable across reads rather than dict-order luck.
229
+ """
230
+ try:
231
+ import core.keychain as keychain
232
+ for row in keychain.list_entries(rt):
233
+ if str(row.get("type") or "") != "slack":
234
+ continue
235
+ fields = keychain.read_fields(rt, row.get("id"))
236
+ if isinstance(fields, dict) and fields.get("signing_secret"):
237
+ return fields
238
+ except Exception: # noqa: BLE001
239
+ # A locked or unreadable keychain reads as "not configured" for the PANE, which is honest;
240
+ # the signature check below fails closed regardless, so this cannot widen anything.
241
+ return None
242
+ return None
243
+
244
+
245
+ # ── the authenticated doors (the Manage agent surface) ───────────────────────────────────────
246
+ def _summary(agent, modules):
247
+ """One sentence per agent for the list row β€” computed here, because the alternative is one
248
+ round trip per row to fill one cell (the same reason `AdminUser.access` exists)."""
249
+ # ⭐ W36-T22 / C2 β€” every listed database is governed now; the `enforced` flag it used to
250
+ # filter on is DELETED, and `m.get("enforced")` would have gone silently falsy here and
251
+ # summarised every agent as "" (no databases at all).
252
+ governed = list(modules)
253
+ perms = agent.get("perms") or {}
254
+ open_n = sum(1 for m in governed if (perms.get(m["key"]) or {}).get("access"))
255
+ if not governed:
256
+ return ""
257
+ if open_n == 0:
258
+ return "No access"
259
+ restricted = sum(1 for m in governed
260
+ if (perms.get(m["key"]) or {}).get("access")
261
+ and ((perms.get(m["key"]) or {}).get("filter")
262
+ or (perms.get(m["key"]) or {}).get("hiddenFields")))
263
+ head = (f"All {open_n} database{'' if open_n == 1 else 's'}" if open_n == len(governed)
264
+ else f"{open_n} of {len(governed)} databases")
265
+ return f"{head}, {restricted} restricted" if restricted else head
266
+
267
+
268
+ @router.get("/agents")
269
+ def list_channel_agents(session: Session = Depends(admin_gate)):
270
+ """This tenant's channel agents, plus whether Slack is reachable at all."""
271
+ import routes_admin
272
+ modules = routes_admin._perm_modules(session)
273
+ rows = []
274
+ for aid, a in sorted(_agents(session.runtime).items()):
275
+ if not isinstance(a, dict):
276
+ continue
277
+ rows.append({"id": aid, "channel": a.get("channel") or "",
278
+ "channelName": a.get("channelName") or "",
279
+ "label": a.get("label") or "", "active": a.get("active", True) is not False,
280
+ "writeApproval": a.get("writeApproval", True) is not False,
281
+ "summary": _summary(a, modules)})
282
+ return {"agents": rows, "configured": slack_creds(session.runtime) is not None}
283
+
284
+
285
+ @router.post("/agents")
286
+ def create_channel_agent(body: dict = Body(default=None),
287
+ session: Session = Depends(admin_gate)):
288
+ body = body if isinstance(body, dict) else {}
289
+ channel = str(body.get("channel") or "").strip()
290
+ if not channel:
291
+ raise err(400, "no_channel", "a Slack channel ID is required")
292
+ existing = _agents(session.runtime)
293
+ # ⚠ ONE AGENT PER CHANNEL. Two records for one conversation would mean two walls, and nothing
294
+ # anywhere decides which one applies β€” the [[one-question-two-normalizers]] shape, in the one
295
+ # place where the two answers are "may read" and "may not".
296
+ for a in existing.values():
297
+ if isinstance(a, dict) and str(a.get("channel") or "") == channel:
298
+ raise err(409, "channel_taken",
299
+ "that channel already has an agent β€” edit it instead of adding a second")
300
+ aid = secrets.token_urlsafe(9)
301
+ rec = _clean_agent(dict(body, id=aid, createdBy=session.uname,
302
+ createdAt=datetime.now(timezone.utc).isoformat(timespec="seconds")))
303
+ _put_agent(session, aid, lambda _prior: rec)
304
+ fresh = _agents(session.runtime).get(aid)
305
+ if not isinstance(fresh, dict):
306
+ # The store took the write and did not record it. A 200 here would tell an administrator
307
+ # the agent exists when it does not.
308
+ raise err(503, "store_unavailable", "the agent was NOT created")
309
+ return {"agent": {"id": aid, "channel": rec["channel"], "channelName": rec["channelName"],
310
+ "label": rec["label"], "active": rec["active"],
311
+ "writeApproval": rec["writeApproval"], "summary": "No access"}}
312
+
313
+
314
+ @router.get("/agents/{agent_id}")
315
+ def get_channel_agent(agent_id: str, session: Session = Depends(admin_gate)):
316
+ """The agent PLUS the permission envelope β€” the SAME shape `get_perms` answers with.
317
+
318
+ ⭐ ONE PAYLOAD SHAPE FOR TWO ROOMS. `permsModel.parsePermsPayload` parses this, and
319
+ `settings/ModulePermsList` renders it, because they are literally the same client code. A
320
+ second envelope here would mean a second parser and a second set of defaults within a wave.
321
+ """
322
+ import routes_admin
323
+ a = _agents(session.runtime).get(str(agent_id))
324
+ if not isinstance(a, dict):
325
+ raise err(404, "no_such_agent", "no agent with that id")
326
+ modules = routes_admin._perm_modules(session)
327
+ # ⭐⭐ W36-T22 / C2 β€” EVERY listed database, `ut_*` included. β›” THIS IS WHY THE FLAG'S
328
+ # DELETION IS NOT A ONE-FILE CHANGE: `m.get("enforced")` on a row that no longer carries the
329
+ # key is None, so this list would have been EMPTY and the channel perms editor would have
330
+ # governed ZERO databases while looking entirely correct. A Slack channel is a principal of
331
+ # the SAME wall (`verify_perm_scope` section H) and gets the same catalogue.
332
+ governed = [m["key"] for m in modules]
333
+ stored = a.get("perms") or {}
334
+ principal = agent_principal(a)
335
+ import core.perm_scope as perm_scope
336
+ perms_out = {}
337
+ for k in governed:
338
+ e = stored.get(k)
339
+ # β›” W36-T22 β€” `may_read`, the same evaluator the read door uses, for the reason spelled
340
+ # out at `routes_admin.get_perms`. ⚠ It answers DIFFERENTLY here and correctly so: a
341
+ # channel agent is not a person with a share, so a `ut_*` key it has not been granted
342
+ # defaults CLOSED β€” which is the fail-closed direction for a bot, and the same answer the
343
+ # table routes would give it.
344
+ perms_out[k] = e if isinstance(e, dict) else {
345
+ "access": bool(perm_scope.may_read(principal, k, st=session.runtime)),
346
+ "filter": None, "hiddenFields": []}
347
+ return {"id": str(agent_id), "channel": a.get("channel") or "",
348
+ "channelName": a.get("channelName") or "", "label": a.get("label") or "",
349
+ "active": a.get("active", True) is not False,
350
+ "writeApproval": a.get("writeApproval", True) is not False,
351
+ "perms": perms_out,
352
+ # β›” ALWAYS THE CURRENT VERSION, because `agent_principal` forces it: the editor must
353
+ # render "undeclared = denied", not the legacy-grant reading it would show at 0.
354
+ "perms_v": perm_scope.PERMS_VERSION,
355
+ # A bot is NEVER an admin β€” see `agent_principal`. Sent so the editor never paints
356
+ # the "Everything (admin)" state for a principal that cannot have it.
357
+ "is_admin": False,
358
+ "modules": modules,
359
+ "fields_by_module": {k: routes_admin._module_fields(k, session=session)
360
+ for k in governed}}
361
+
362
+
363
+ @router.put("/agents/{agent_id}/perms")
364
+ def put_channel_agent_perms(agent_id: str, body: dict = Body(default=None),
365
+ session: Session = Depends(admin_gate)):
366
+ """Replace this agent's permission block wholesale β€” the same validator a user's block gets."""
367
+ import routes_admin
368
+ body = body if isinstance(body, dict) else {}
369
+ if not isinstance(_agents(session.runtime).get(str(agent_id)), dict):
370
+ raise err(404, "no_such_agent", "no agent with that id")
371
+ if "perms" not in body:
372
+ raise err(400, "empty_patch", "no perms to save")
373
+ mods = routes_admin._perm_modules(session)
374
+ # ⭐ THE SAME `_clean_perms`, NOT A COPY OF IT. It refuses a filter this module cannot
375
+ # evaluate, refuses a hiddenFields key that names nothing, and refuses a BU condition the
376
+ # pushdown cannot read. Every one of those is exactly as true for a channel as for a person,
377
+ # and a second validator here is a second place for one of them to go missing.
378
+ # ⚠ CORRECTED W36-T22: this list used to end "refuses an unenforced `ut_*` key". That refusal
379
+ # is DELETED β€” W36-T21 armed the wall over every database, so there is no unenforced key left
380
+ # to refuse, and a comment naming a rule that no longer exists is how the next wave re-derives
381
+ # it ([[two-gates-can-assert-opposite-things]]).
382
+ cleaned = routes_admin._clean_perms(
383
+ body.get("perms"),
384
+ governed_keys={m["key"] for m in mods}, session=session) or {}
385
+
386
+ import core.perm_scope as perm_scope
387
+
388
+ def _mut(prior):
389
+ if not isinstance(prior, dict):
390
+ return None
391
+ nxt = dict(prior)
392
+ nxt["perms"] = cleaned
393
+ nxt["perms_v"] = perm_scope.PERMS_VERSION
394
+ return nxt
395
+
396
+ _put_agent(session, str(agent_id), _mut)
397
+ fresh = _agents(session.runtime).get(str(agent_id)) or {}
398
+ if (fresh.get("perms") or {}) != cleaned:
399
+ # The one direction this must never fail in: telling an administrator the wall is up when
400
+ # it is not.
401
+ raise err(503, "store_unavailable", "the permissions were not saved β€” the agent is UNCHANGED")
402
+ return {"id": str(agent_id), "perms": fresh.get("perms") or {},
403
+ "perms_v": int(fresh.get("perms_v") or 0)}
404
+
405
+
406
+ @router.patch("/agents/{agent_id}")
407
+ def patch_channel_agent(agent_id: str, body: dict = Body(default=None),
408
+ session: Session = Depends(admin_gate)):
409
+ body = body if isinstance(body, dict) else {}
410
+ if not isinstance(_agents(session.runtime).get(str(agent_id)), dict):
411
+ raise err(404, "no_such_agent", "no agent with that id")
412
+ #: β›” `perms` IS NOT PATCHABLE HERE. It has its own door with its own validator; accepting it
413
+ #: on the metadata route would be a second, unvalidated way to write the wall.
414
+ allowed = {k: v for k, v in body.items()
415
+ if k in ("label", "active", "writeApproval", "channelName")}
416
+ if not allowed:
417
+ raise err(400, "empty_patch", "nothing to change")
418
+
419
+ def _mut(prior):
420
+ return _clean_agent(allowed, prior=prior) if isinstance(prior, dict) else None
421
+
422
+ _put_agent(session, str(agent_id), _mut)
423
+ import routes_admin
424
+ a = _agents(session.runtime).get(str(agent_id)) or {}
425
+ return {"agent": {"id": str(agent_id), "channel": a.get("channel") or "",
426
+ "channelName": a.get("channelName") or "", "label": a.get("label") or "",
427
+ "active": a.get("active", True) is not False,
428
+ "writeApproval": a.get("writeApproval", True) is not False,
429
+ "summary": _summary(a, routes_admin._perm_modules(session))}}
430
+
431
+
432
+ @router.delete("/agents/{agent_id}")
433
+ def delete_channel_agent(agent_id: str, session: Session = Depends(admin_gate)):
434
+ if not isinstance(_agents(session.runtime).get(str(agent_id)), dict):
435
+ raise err(404, "no_such_agent", "no agent with that id")
436
+ _put_agent(session, str(agent_id), lambda _prior: None)
437
+ if isinstance(_agents(session.runtime).get(str(agent_id)), dict):
438
+ raise err(503, "store_unavailable", "the agent was NOT removed")
439
+ return {"ok": True}
440
+
441
+
442
+ # ── ⭐ W33-T67: THE WRITE-APPROVAL GATE ───────────────────────────────────────────────────────
443
+ def _is_empty(value):
444
+ """Is this value ABSENT, as opposed to falsy?
445
+
446
+ β›” COPIED EXACTLY FROM OpenTag's `agent/write_confirmation.py`, and the exactness is the point:
447
+ `0` and `False` are VALUES a human must see on the card. Setting a price to 0 or a flag to
448
+ False is precisely the kind of write somebody needs to approve, and a naive `if not value`
449
+ drops both rows silently β€” the approver then reads a card that does not describe the write
450
+ they are approving. The MIT licence gate for this borrowing is recorded in
451
+ `.claude/wiki/research/opentag-adoption.md`.
452
+ """
453
+ return value is None or (isinstance(value, str) and value.strip() == "")
454
+
455
+
456
+ def approval_card(action, values, agent=None):
457
+ """The card a human reads before a bot writes. `{action, rows, truncated, note}`.
458
+
459
+ β›” ROW-CAPPED, and the cap is a SAFETY property rather than a layout one: past a certain
460
+ length Slack collapses a message behind "Show more", and **an approver who cannot read the
461
+ card cannot approve it.** When rows are dropped the card SAYS how many β€” a truncation nobody
462
+ was told about is the violation, not the truncation (wave-30 R6's second sentence).
463
+ """
464
+ rows = [{"field": str(k), "value": v}
465
+ for k, v in (values or {}).items() if not _is_empty(v)]
466
+ rows.sort(key=lambda r: r["field"])
467
+ shown, dropped = rows[:APPROVAL_MAX_ROWS], max(0, len(rows) - APPROVAL_MAX_ROWS)
468
+ return {
469
+ "action": str(action or "")[:80],
470
+ "rows": shown,
471
+ "truncated": dropped,
472
+ "note": (f"{dropped} more field(s) not shown β€” open the record to review them before "
473
+ f"approving." if dropped else ""),
474
+ "agent": (agent or {}).get("label") or (agent or {}).get("channel") or "",
475
+ }
476
+
477
+
478
+ def intercept_write(agent, action, values, stash):
479
+ """THE INTERCEPTOR. Returns `("commit", None)` or `("await_approval", card)`.
480
+
481
+ ⭐ The one thing worth taking from OpenTag: a MUTATING tool call is halted, rendered as a card
482
+ naming the action and its values, and committed only on an explicit human accept. Everything
483
+ else in that repo β€” the transport, the runtime, the identity model β€” is AVOID (amendment A1).
484
+
485
+ β›” FAIL-CLOSED ON A MISSING FLAG: `is not False`, so a record written before `writeApproval`
486
+ existed requires approval. The unsafe direction here is not recoverable β€” revoking a bot does
487
+ not un-write what it wrote.
488
+ """
489
+ if (agent or {}).get("writeApproval", True) is not False:
490
+ card = approval_card(action, values, agent)
491
+ stash(card)
492
+ return "await_approval", card
493
+ return "commit", None
494
+
495
+
496
+ def _pending(rt):
497
+ try:
498
+ found = rt.get(PENDING_KEY) or {}
499
+ except Exception: # noqa: BLE001
500
+ return {}
501
+ return found if isinstance(found, dict) else {}
502
+
503
+
504
+ def stash_pending(rt, agent, action, values, now=None):
505
+ """Store one awaiting-approval mutation and return its token.
506
+
507
+ The token is what rides the card's button, so it is `secrets.token_urlsafe` and not the
508
+ action's id: a guessable value on an unauthenticated door is a way to approve somebody else's
509
+ write. Server-only bucket, per tenant, exactly as `routes_forms.TOKENS_KEY` is.
510
+ """
511
+ token = secrets.token_urlsafe(18)
512
+ row = {"at": float(now if now is not None else time.time()),
513
+ "channel": str((agent or {}).get("channel") or ""),
514
+ "action": str(action or "")[:80],
515
+ "values": values if isinstance(values, dict) else {}}
516
+ rt.update(PENDING_KEY, lambda cur: {**(cur or {}), token: row}, flush="sync")
517
+ return token
518
+
519
+
520
+ #: `{action kind: fn(rt, row) -> None}`. Registered by whatever can actually perform a mutation.
521
+ #:
522
+ #: β›”β›” EMPTY IN PRODUCTION TODAY, AND SAYING SO IS THE POINT. `commit_pending` below is the ONE
523
+ #: place an approved write may be performed, so it is the place a negative control can bite β€” but
524
+ #: a gate is only as honest as what it guards. Until something registers here, an approved card
525
+ #: performs NOTHING and `commit_pending` returns `no_committer` rather than pretending. That is a
526
+ #: declared absence, not a silent one [[flag-shipped-without-its-writer]].
527
+ _COMMITTERS = {}
528
+
529
+
530
+ def register_committer(kind, fn):
531
+ """Declare who may perform an approved mutation of `kind`. Idempotent by key."""
532
+ _COMMITTERS[str(kind)] = fn
533
+
534
+
535
+ def commit_pending(rt, token, approved, now=None):
536
+ """Resolve ONE approval card. Returns one of
537
+ `expired` Β· `unknown` Β· `rejected` Β· `no_committer` Β· `committed` Β· `failed`.
538
+
539
+ β›”β›” THIS IS THE WRITE GATE, AND IT IS A SEPARATE FUNCTION FROM THE ROUTE ON PURPOSE. T67's
540
+ `done-when` asks for an NC proving a REJECTED card writes nothing. A control aimed at the
541
+ route could not bite while the route had no write in it at all β€” it would stay green with the
542
+ guard deleted, because there would be nothing for the guard to be protecting anything from
543
+ ([[gate-can-report-green-on-nothing]], and a verifier caught exactly that on this ticket's
544
+ first draft). Putting the commit behind ONE named seam gives the control a real subject: the
545
+ gate registers a recorder, and `rejected` must leave it untouched while `approve` must call it
546
+ exactly once.
547
+
548
+ β›” THE TOKEN IS CONSUMED BEFORE EITHER BRANCH, and before any committer runs. A card that
549
+ survives its own click is a replayable write, and "approve twice" must never mean "write
550
+ twice". The delete is `flush="sync"` for the same reason.
551
+ """
552
+ now = float(now if now is not None else time.time())
553
+ row = _pending(rt).get(str(token))
554
+ if not isinstance(row, dict):
555
+ return "unknown"
556
+ # Consume FIRST β€” before the TTL verdict, before the approve/reject branch, before any write.
557
+ rt.update(PENDING_KEY,
558
+ lambda cur: {k: v for k, v in (cur or {}).items() if k != str(token)},
559
+ flush="sync")
560
+ if now - float(row.get("at") or 0) > APPROVAL_TTL_S:
561
+ return "expired"
562
+ if not approved:
563
+ # β›” NOTHING BELOW THIS LINE RUNS FOR A REJECTED CARD. The whole gate is this return.
564
+ return "rejected"
565
+ fn = _COMMITTERS.get(str(row.get("action") or ""))
566
+ if fn is None:
567
+ return "no_committer"
568
+ try:
569
+ fn(rt, row)
570
+ except Exception: # noqa: BLE001
571
+ # A committer that raises must not read as a commit. The card is already consumed, so the
572
+ # honest report is that it failed, and the human asks again.
573
+ return "failed"
574
+ return "committed"
575
+
576
+
577
+ # ── the UNAUTHENTICATED Slack door ───────────────────────────────────────────────────────────
578
+ #: Sliding window, keyed on the SLACK TEAM, never the client IP.
579
+ #:
580
+ #: β›” WHY NOT THE IP, WHICH IS WHAT `routes_forms` DOES. Every Slack event arrives from Slack's own
581
+ #: infrastructure, so an IP window is one shared bucket for every workspace on the platform: one
582
+ #: busy tenant would spend the allowance for all of them, and the symptom would be another
583
+ #: tenant's bot going quiet. The team id is the closest thing to the actual noisy party.
584
+ #: ⚠ It is attacker-CONTROLLED before the signature is checked, so the window is applied AFTER
585
+ #: verification, never before β€” an unsigned request is refused on cost grounds anyway (a signature
586
+ #: check is a single HMAC).
587
+ RATE_WINDOW_S, RATE_PER_WINDOW = 60, 60
588
+ _HITS: dict = {}
589
+
590
+
591
+ def _rate_ok(key, now):
592
+ seen = [t for t in _HITS.get(key, ()) if now - t < RATE_WINDOW_S]
593
+ seen.append(now)
594
+ _HITS[key] = seen
595
+ if len(_HITS) > 4096:
596
+ for k in [k for k, v in _HITS.items() if not v or now - v[-1] > RATE_WINDOW_S]:
597
+ _HITS.pop(k, None)
598
+ return len(seen) <= RATE_PER_WINDOW
599
+
600
+
601
+ async def _raw_body(request):
602
+ """The body as RAW BYTES, capped.
603
+
604
+ β›”β›” RAW, NOT PARSED, AND THAT IS NOT A STYLE CHOICE. Slack signs the literal string
605
+ `v0:{timestamp}:{body}` byte for byte. `routes_forms._bounded_body` β€” the function this is
606
+ otherwise copied from β€” returns only the parsed dict and DISCARDS the bytes, so a verbatim
607
+ copy of it could not verify a Slack signature at all: re-serialising the dict changes
608
+ whitespace and key order and the HMAC no longer matches. The bug would look like "Slack
609
+ signatures are always invalid", which is indistinguishable from a wrong secret.
610
+
611
+ ⚠ And it streams rather than calling `request.body()`: FastAPI would otherwise have buffered
612
+ the whole body before the handler's first line, so a content-length check inside the handler
613
+ caps nothing (`routes_forms`' own scar, recorded in its docstring).
614
+ """
615
+ size, chunks = 0, []
616
+ async for chunk in request.stream():
617
+ size += len(chunk)
618
+ if size > MAX_BODY_BYTES:
619
+ raise err(413, "body_too_large", "that request is too large")
620
+ chunks.append(chunk)
621
+ return b"".join(chunks)
622
+
623
+
624
+ def _refuse():
625
+ """ONE refusal for every reason. No oracle.
626
+
627
+ A wrong signature, a stale timestamp, an unknown team, an unconfigured tenant and a channel
628
+ with no agent all answer identically β€” otherwise the door tells an unauthenticated caller
629
+ which tenants exist and which channels are configured, which is the question it was built to
630
+ not answer. `routes_forms._refuse` takes the same posture for the same reason.
631
+ """
632
+ return err(403, "bad_request", "that request could not be verified")
633
+
634
+
635
+ def _verify(secret, timestamp, raw, signature, now):
636
+ """Slack's v0 signature, with the replay window checked FIRST."""
637
+ try:
638
+ ts = int(str(timestamp or "0"))
639
+ except ValueError:
640
+ return False
641
+ if abs(now - ts) > SLACK_MAX_SKEW_S:
642
+ return False
643
+ base = b"v0:" + str(ts).encode("ascii") + b":" + (raw or b"")
644
+ import hashlib
645
+ expected = "v0=" + hmac.new(str(secret).encode("utf-8"), base, hashlib.sha256).hexdigest()
646
+ return hmac.compare_digest(expected, str(signature or ""))
647
+
648
+
649
+ def _resolve_tenant(raw, timestamp, signature, now):
650
+ """`(slug, runtime, creds)` for the tenant whose signing secret verifies this request.
651
+
652
+ β›” THE TENANT IS DECIDED BY THE SIGNATURE, NEVER BY THE PAYLOAD. A body-supplied `team_id`
653
+ would let an unauthenticated caller name the tenant it wants to be β€” the exact widening
654
+ `deps._user_for`'s tenant-equality check closes on the authenticated side. So every configured
655
+ tenant's secret is tried and the one that VERIFIES names the tenant.
656
+
657
+ ⚠ NO EARLY `break` ON A FAILED CANDIDATE and no per-tenant error: the loop's cost must not
658
+ depend on which tenant matched. `routes_forms._resolve` walks tenants the same way, for the
659
+ same reason β€” a public door has no session to ask.
660
+ """
661
+ from harness import runtime as _rt
662
+ hit = None
663
+ try:
664
+ slugs = list(_rt.known_tenants())
665
+ except Exception: # noqa: BLE001
666
+ return None, None, None
667
+ for slug in slugs:
668
+ try:
669
+ rt = _rt.get_runtime(slug)
670
+ except Exception: # noqa: BLE001
671
+ continue
672
+ creds = slack_creds(rt)
673
+ if not creds:
674
+ continue
675
+ if _verify(creds.get("signing_secret"), timestamp, raw, signature, now) and hit is None:
676
+ hit = (slug, rt, creds)
677
+ return hit if hit else (None, None, None)
678
+
679
+
680
+ @router.post("/slack/events")
681
+ async def slack_events(request: Request):
682
+ """Slack's Events API. UNAUTHENTICATED BY CONSTRUCTION β€” no `Depends(require_session)`.
683
+
684
+ ⭐ Built like `routes_forms.py`'s public door and not like OpenTag's: one process, no vendor,
685
+ no second runtime, no persistent outbound socket, and this tenant's Slack secret never leaves
686
+ this deployment (amendment A1).
687
+ """
688
+ now = time.time()
689
+ raw = await _raw_body(request)
690
+ ts = request.headers.get("x-slack-request-timestamp")
691
+ sig = request.headers.get("x-slack-signature")
692
+ slug, rt, _creds = _resolve_tenant(raw, ts, sig, now)
693
+ if not rt:
694
+ raise _refuse()
695
+
696
+ try:
697
+ payload = json.loads(raw or b"{}")
698
+ except ValueError:
699
+ raise _refuse()
700
+ if not isinstance(payload, dict):
701
+ raise _refuse()
702
+
703
+ # Slack's one-time endpoint handshake. Answered ONLY after the signature verified β€” an
704
+ # unsigned challenge echo would confirm the endpoint exists to anyone who probes it.
705
+ if payload.get("type") == "url_verification":
706
+ return {"challenge": str(payload.get("challenge") or "")[:512]}
707
+
708
+ team = str(payload.get("team_id") or slug)
709
+ if not _rate_ok(f"{slug}:{team}", now):
710
+ raise err(429, "too_many_requests", "too many requests β€” wait a moment and try again")
711
+
712
+ event = payload.get("event") if isinstance(payload.get("event"), dict) else {}
713
+ channel = str(event.get("channel") or "")
714
+ agent = None
715
+ for a in _agents(rt).values():
716
+ if isinstance(a, dict) and str(a.get("channel") or "") == channel:
717
+ agent = a
718
+ break
719
+ # β›” NO AGENT, OR AN INACTIVE ONE, IS SILENCE β€” a 200 with no action. Not a 403: Slack retries
720
+ # a non-2xx up to three times, so refusing here would turn "this channel is not configured"
721
+ # into three refusals per message, and the retry storm would be the only visible symptom.
722
+ # ⚠ And it must not say WHICH β€” a distinguishable answer tells an unauthenticated caller which
723
+ # channels this workspace has configured.
724
+ if not isinstance(agent, dict) or agent.get("active", True) is False:
725
+ return {"ok": True}
726
+
727
+ # β›”β›” THE ANSWERING HALF IS NOT BUILT YET, AND SAYING SO IS THE POINT. What ships here is the
728
+ # DOOR and the WALL: a verified request, resolved to a tenant by signature, matched to a
729
+ # channel agent whose `perms` block is a `perm_scope` principal (`agent_principal`). Composing
730
+ # a reply means an LLM call on the cheap-first ladder plus the read path, and a half-built
731
+ # answerer that returns something plausible is worse than one that returns nothing.
732
+ # Booked rather than faked [[flag-shipped-without-its-writer]].
733
+ return {"ok": True}
734
+
735
+
736
+ @router.post("/slack/interact")
737
+ async def slack_interact(request: Request):
738
+ """The approval card's button (`W33-T67`). UNAUTHENTICATED, signature-verified, same shape."""
739
+ now = time.time()
740
+ raw = await _raw_body(request)
741
+ slug, rt, _creds = _resolve_tenant(raw, request.headers.get("x-slack-request-timestamp"),
742
+ request.headers.get("x-slack-signature"), now)
743
+ if not rt:
744
+ raise _refuse()
745
+ # Slack posts interactions as `application/x-www-form-urlencoded` with a `payload=` field.
746
+ from urllib.parse import parse_qs
747
+ try:
748
+ form = parse_qs(raw.decode("utf-8"))
749
+ payload = json.loads((form.get("payload") or ["{}"])[0])
750
+ except Exception: # noqa: BLE001
751
+ raise _refuse()
752
+ if not isinstance(payload, dict):
753
+ raise _refuse()
754
+ if not _rate_ok(f"{slug}:interact", now):
755
+ raise err(429, "too_many_requests", "too many requests β€” wait a moment and try again")
756
+
757
+ actions = payload.get("actions") if isinstance(payload.get("actions"), list) else []
758
+ choice = (actions[0] if actions and isinstance(actions[0], dict) else {})
759
+ token = str(choice.get("value") or "")
760
+ # ⭐ ANYTHING THAT IS NOT LITERALLY "approve" IS A REJECT. Fail-closed on a malformed,
761
+ # truncated or unknown action id: the direction that can be wrong here without anyone
762
+ # noticing is the one that writes.
763
+ approved = str(choice.get("action_id") or "") == "approve"
764
+ # β›” THE ROUTE DOES NOT DECIDE β€” `commit_pending` does, and it is the ONE seam a negative
765
+ # control can aim at. Consumption, the TTL, the reject branch and the committer lookup all
766
+ # live there; this handler only turns its verdict into a sentence.
767
+ verdict = commit_pending(rt, token, approved, now=now)
768
+ return {"text": {
769
+ # An unknown, already-used or expired token is not an error: a person clicking a stale
770
+ # card should be told it is stale, not shown a failure.
771
+ "unknown": "That approval is no longer available. Ask the bot again.",
772
+ "expired": "That approval expired before it was answered. Ask the bot again.",
773
+ "rejected": "Rejected. Nothing was changed.",
774
+ # ⚠ HONEST, not reassuring: the answering half that would register a committer is booked,
775
+ # so an approved card today performs nothing and says exactly that rather than "Approved."
776
+ "no_committer": "Approved β€” but this workspace has nothing configured to carry it out yet.",
777
+ "failed": "That change could not be completed. Nothing was saved; ask the bot again.",
778
+ "committed": "Approved.",
779
+ }.get(verdict, "That approval is no longer available. Ask the bot again.")}
780
+
781
+
782
+ @router.get("/slack/health")
783
+ def slack_health(session: Session = Depends(require_session)):
784
+ """Is Slack configured for THIS tenant? Session-gated, and it answers about one tenant only β€”
785
+ enumerating the others would answer a question about our customer list."""
786
+ creds = slack_creds(session.runtime)
787
+ return {"configured": creds is not None,
788
+ "agents": len(_agents(session.runtime)),
789
+ "hasBotToken": bool((creds or {}).get("bot_token"))}
api/routes_statements.py CHANGED
@@ -1,298 +1,298 @@
1
- """routes_statements.py β€” the statement-of-account sender, ported off Streamlit (EXIT-6).
2
-
3
- β›” THIS IS THE ONE SANCTIONED ODOO WRITER IN THE ENTIRE SYSTEM. Everything else in AIOS is
4
- read-only on Odoo by hard block. `modules/collections_send.py` owns a narrow client that whitelists
5
- exactly `mail.mail create` (queueing an outbound email) and nothing else; these routes call it and
6
- add no write of their own.
7
-
8
- WHY IT EXISTS SEPARATELY FROM THE COLLECTIONS PAGE. Owner ruling wave-17 item 15 retired the
9
- Collections *dashboard* β€” the worklist is the shared "Collections" view on the Customer grid, from
10
- the same reconciled blocks. What that ruling explicitly left "untouched" is this send workflow, so
11
- when `app.py` is deleted it is the ONLY live Streamlit-only feature, and it moves here rather than
12
- dying with the host. Ported faithfully: same tiers, same filters, same template placeholders, same
13
- preview, same test-send, same two-step confirm.
14
-
15
- THE THREE GUARDRAILS, and where each is enforced:
16
- 1. SAFE_MODE (default ON) β€” in the DATA LAYER (`collections_send.queue_statement`), so no route,
17
- payload or UI can bypass it. These routes only REPORT it; they never re-implement the check.
18
- 2. Admin only β€” `admin_gate` (role, fail-closed), mirroring the Streamlit `if not is_admin()`.
19
- 3. ⭐ TENANT β€” NEW HERE, and it did not exist in Streamlit because it could not. `cs_mod.Odoo()`
20
- reads Odoo credentials from the ENVIRONMENT, which after the keychain cutover belongs to
21
- TENANT #0 ALONE. On the multi-tenant API an unguarded route would let a nurilab or gtmlab
22
- admin queue mail from Royal Imports' Odoo, as Royal Imports. `_royal_only` closes that; the
23
- single-tenant Streamlit host never had the exposure, so this is a port that must ADD a wall
24
- rather than copy one.
25
-
26
- NOTHING SENDS ON A GET. The send route requires an explicit customer list in the body; there is no
27
- "send all" parameter, deliberately β€” the confirm step is a product requirement, not a formality.
28
- """
29
- from fastapi import APIRouter, Body, Depends
30
-
31
- from deps import Session, err
32
- from routes_admin import admin_gate
33
-
34
- # W35-T35 (C8): the tenant predicate, held once. See `_royal_only` for why the import runs this
35
- # way round. `main.py` already imports both modules, so this adds no load.
36
- import automation_engine as engine
37
-
38
- router = APIRouter(prefix="/api/v1")
39
-
40
- #: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the
41
- #: list moves slowly (it is a dunning worklist, not a live feed) and the pull is a multi-model read.
42
- _TTL = 1800
43
- _cache = {"at": 0.0, "rows": None}
44
-
45
-
46
- def _cs():
47
- import modules.collections_send as cs
48
- return cs
49
-
50
-
51
- def _royal_only(session: Session) -> Session:
52
- """β›” See the module docstring, guardrail 3. The send client is env-credentialed, so it is
53
- tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company.
54
-
55
- Keyed on the runtime, never on a request field: a tenant is a property of the SESSION.
56
-
57
- ⭐⭐ WAVE 35 Β· T35 / CONTRACT C8 β€” THE TEST ITSELF NOW LIVES IN ONE PLACE. Statements became an
58
- agent step this wave, so the automation engine has to answer the same question at two more
59
- doors (which tenants see the action, which tenants may store it). C8's words are "the predicate
60
- is imported, never re-expressed", and this is the direction that import can run: the engine
61
- imports no route module and no FastAPI, so reaching `_royal_only` FROM it would drag `deps` and
62
- `routes_admin` into a route-free module. The engine holds the boolean; this holds the refusal.
63
- ⚠ WHAT STAYS HERE IS THE HTTP SHAPE, and that is deliberate β€” a 404 rather than a 403, so the
64
- surface is invisible rather than forbidden. The engine must not know about status codes."""
65
- if not engine.is_statement_tenant(session.runtime):
66
- raise err(404, "not_found", "statements are not configured for this workspace")
67
- return session
68
-
69
-
70
- def _gate(session: Session = Depends(admin_gate)) -> Session:
71
- return _royal_only(session)
72
-
73
-
74
- def _rows(force=False):
75
- import time
76
- cs = _cs()
77
- if force or _cache["rows"] is None or (time.time() - _cache["at"]) > _TTL:
78
- _cache["rows"] = cs.load_collection_list(cs.Odoo())
79
- _cache["at"] = time.time()
80
- return _cache["rows"], time.strftime("%Y-%m-%d %H:%M", time.localtime(_cache["at"]))
81
-
82
-
83
- def _public(row):
84
- """Strip the internals the Streamlit grid also hid (`_`-prefixed + partner_id is kept, because
85
- the client needs a stable row identity that is not the display name)."""
86
- return {k: v for k, v in row.items() if not str(k).startswith("_")}
87
-
88
-
89
- @router.get("/admin/statements")
90
- def statements(refresh: int = 0, session: Session = Depends(_gate)):
91
- """The worklist + everything the sender UI needs to render itself honestly."""
92
- cs = _cs()
93
- try:
94
- rows, loaded_at = _rows(force=bool(refresh))
95
- except Exception as e:
96
- raise err(502, "odoo_unavailable", f"could not load the collection list: {str(e)[:200]}")
97
- return {
98
- "rows": [_public(r) for r in rows],
99
- "loadedAt": loaded_at,
100
- # The guardrail is reported, never decided, here β€” the data layer owns it.
101
- "safeMode": bool(cs.SAFE_MODE),
102
- "safeRecipients": sorted(cs.SAFE_RECIPIENTS),
103
- "sender": {"name": cs.SENDER_NAME, "email": cs.SENDER_EMAIL,
104
- "replyTo": cs.REPLY_TO, "company": cs.COMPANY},
105
- "templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO,
106
- "footer": cs.DEFAULT_FOOTER},
107
- # W35-T35: ONE literal, read from the engine, so the sender's worklist and a statements
108
- # agent's tier filter cannot come to mean different things.
109
- "tiers": list(engine.STATEMENT_TIERS),
110
- }
111
-
112
-
113
- def _find(rows, customer):
114
- return next((r for r in rows if r.get("Customer") == customer), None)
115
-
116
-
117
- @router.post("/admin/statements/preview")
118
- def preview(body: dict = Body(default=None), session: Session = Depends(_gate)):
119
- """Render ONE customer's statement exactly as the send path would."""
120
- cs = _cs()
121
- body = body or {}
122
- rows, _ = _rows()
123
- row = _find(rows, body.get("customer"))
124
- if row is None:
125
- raise err(404, "not_found", "no such customer on the collection list")
126
- t = body.get("templates") or {}
127
- import datetime as dt
128
- month = dt.date.today().strftime("%B %Y")
129
- subject = (t.get("subject") or cs.DEFAULT_SUBJECT)
130
- try:
131
- subject = subject.format(customer=row["Customer"], company=cs.COMPANY, month=month)
132
- except Exception: # noqa: BLE001
133
- # An unknown placeholder is the user's typo, not a 500. Show the template verbatim so they
134
- # can see what they typed rather than getting an opaque error.
135
- # β›” W36-T42: THIS CAUGHT THREE OF THE WAYS `str.format` FAILS AND THERE ARE MORE.
136
- # `{customer` is a ValueError, `{customer.x}` an AttributeError, `{customer:%Y}` a
137
- # TypeError, and every one of them is the same user mistake this branch was written for.
138
- # Naming a subset of the exception types turns a typo in the OTHER half into a 500 on the
139
- # one screen somebody opens to avoid mailing 200 people the wrong thing.
140
- pass
141
- return {
142
- "html": cs.render_statement_html(row, t.get("intro") or cs.DEFAULT_INTRO,
143
- t.get("footer") or cs.DEFAULT_FOOTER),
144
- "to": row.get("Email") or "",
145
- "subject": subject,
146
- }
147
-
148
-
149
- @router.post("/admin/statements/send")
150
- def send(body: dict = Body(default=None), session: Session = Depends(_gate)):
151
- """Queue statements. Returns per-customer outcomes β€” NEVER a bare count.
152
-
153
- `overrideTo` is the test-send path: one customer, one address. SAFE_MODE still applies (the
154
- data layer refuses an address outside the allow-list), which is why the route does not check it.
155
- """
156
- cs = _cs()
157
- body = body or {}
158
- names = [str(n) for n in (body.get("customers") or []) if str(n).strip()]
159
- if not names:
160
- raise err(400, "bad_request", "name at least one customer")
161
- # β›”β›” W36-T42 / D-299 β€” A DECLARED TEST SEND WITH NO ADDRESS REFUSES. IT DOES NOT BECOME A
162
- # REAL ONE. This read `(body.get("overrideTo") or "").strip() or None`, so a caller who sent
163
- # `overrideTo: ""` (the field left blank, the state not yet typed into, a trimmed-away space)
164
- # got a REAL statement mailed to the debtor's own address, reported back as `test: false`. On
165
- # the one route in this product that is allowed to write to Odoo, the difference between a
166
- # rehearsal and mailing a live customer was one empty string.
167
- #
168
- # ⭐ THE DISCRIMINATOR IS THE CALLER'S DECLARATION, NOT THE VALUE. A body with no `overrideTo`
169
- # key and no `test` flag is the PRODUCTION send, and refusing that would delete the feature
170
- # D-299 exists to preserve. What can be refused is a caller who SAID this is a test: the key
171
- # being present, or `test: true`, is that statement, and an empty address beside it is the
172
- # mistake. So both spellings of "absent" are covered: the key present and blank, and `test`
173
- # asserted with no key at all.
174
- #
175
- # ⚠ THIS IS THE SECOND OF THREE WALLS AND THE ONLY ONE A PAYLOAD CANNOT ROUTE AROUND.
176
- # `automationApi.testSendStatement` refuses an empty address before the request is built, and
177
- # SAFE_MODE refuses an address outside the allow-list inside `queue_statement`. The client
178
- # wall is bypassable by construction (anything can POST); this one is not.
179
- declared_test = ("overrideTo" in body) or bool(body.get("test"))
180
- override = str(body.get("overrideTo") or "").strip()
181
- if declared_test and not override:
182
- raise err(400, "no_override_address",
183
- "a test send needs the address to send the test to. Without one this would "
184
- "mail the customer's own address, which is the opposite of a test")
185
- override = override or None
186
- if override and len(names) != 1:
187
- raise err(400, "bad_request", "a test send takes exactly one customer")
188
- t = body.get("templates") or {}
189
- rows, _ = _rows()
190
-
191
- sent, failed, skipped = [], [], []
192
- for name in names:
193
- row = _find(rows, name)
194
- if row is None:
195
- failed.append({"customer": name, "error": "not on the current collection list"})
196
- continue
197
- if not override and not row.get("Email"):
198
- # The Streamlit page warned and skipped these. Reporting them SEPARATELY from failures
199
- # keeps "we could not" distinct from "there was nowhere to send".
200
- skipped.append({"customer": name, "reason": "no email address on the customer record"})
201
- continue
202
- try:
203
- mid = cs.queue_statement(cs.Odoo(), row, t.get("subject") or cs.DEFAULT_SUBJECT,
204
- t.get("intro") or cs.DEFAULT_INTRO,
205
- t.get("footer") or cs.DEFAULT_FOOTER, override_to=override)
206
- sent.append({"customer": name, "to": override or row.get("Email"), "mailId": mid})
207
- except Exception as e:
208
- # SafeModeBlocked lands here too, and that is correct: to the caller a guardrail refusal
209
- # and an Odoo error are both "this one did not go", each with its own honest message.
210
- failed.append({"customer": name, "error": str(e)[:200]})
211
- return {"sent": sent, "failed": failed, "skipped": skipped,
212
- "safeMode": bool(cs.SAFE_MODE), "test": bool(override)}
213
-
214
-
215
- # ---------------------------------------------------------------------------------------------
216
- # WAVE 35 Β· T36 / CONTRACT C8 / OWNER RULING R10 β€” THE AGENT'S PARKED BATCH.
217
- #
218
- # β›”β›” THE AGENT ASSEMBLES; A PERSON CLICKS SEND. `automation_engine.run_statements` renders a batch
219
- # and parks it on the automation definition, and it imports no mail path at all. THIS is the only
220
- # door that releases one, and it is deliberately built out of the parts already here:
221
- # Β· `_gate` β€” `admin_gate` THEN `_royal_only`, byte-for-byte the dependency the other three
222
- # endpoints use. Not a copy of the checks: the same object.
223
- # Β· `cs.queue_statement` β€” the same call `send()` above makes, so SAFE_MODE's allow-list refusal
224
- # happens in the DATA LAYER, where no route, payload or UI can reach around it.
225
- # β‡’ Nothing about the guardrails is re-expressed here, which is what makes "unchanged" checkable.
226
- #
227
- # ⚠ WHY THE BATCH IS RE-RENDERED FROM THE LIVE WORKLIST RATHER THAN SENT AS STORED. The parked
228
- # `html` is what a person APPROVED and is what the review screen shows; but a balance can move
229
- # between the parking and the click, and mailing a figure we know to be stale is worse than mailing
230
- # a fresh one. So the parked batch decides WHO and WITH WHAT WORDS, and the live row decides the
231
- # NUMBERS β€” the same split `preview` already makes. A customer who has left the worklist entirely
232
- # (they paid) is reported as skipped rather than invoiced.
233
-
234
- @router.get("/admin/statements/agent/{auto_id}")
235
- def agent_batch(auto_id: str, session: Session = Depends(_gate)):
236
- """What is parked and waiting for a click, for ONE agent. `null` when nothing is."""
237
- defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {}
238
- pending = defn.get("pendingStatements")
239
- if not isinstance(pending, dict):
240
- return {"pending": None}
241
- # ⚠ The rendered `html` is NOT returned in the list β€” a 200-statement batch of rendered mail is
242
- # megabytes, and the review screen needs the count and the names to decide. `preview` already
243
- # renders ONE on demand.
244
- return {"pending": {
245
- "ts": pending.get("ts"), "count": int(pending.get("count") or 0),
246
- "notes": list(pending.get("notes") or []),
247
- "items": [{k: v for k, v in it.items() if k != "html"}
248
- for it in (pending.get("items") or [])],
249
- "safeMode": bool(_cs().SAFE_MODE)}}
250
-
251
-
252
- @router.post("/admin/statements/agent/{auto_id}/send")
253
- def agent_send(auto_id: str, body: dict = Body(default=None),
254
- session: Session = Depends(_gate)):
255
- """Release a parked batch. THE CLICK R10 REQUIRES β€” nothing else in the system calls this.
256
-
257
- β›” IT REFUSES AN EMPTY OR MISSING BATCH rather than answering 200 with nothing sent: a send
258
- that reports success and mails nobody is the `view_upsert` failure mode (200 OK, zero writes)
259
- arriving on the one route in this system that talks to a mail server.
260
- """
261
- cs = _cs()
262
- defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {}
263
- pending = defn.get("pendingStatements")
264
- items = list((pending or {}).get("items") or []) if isinstance(pending, dict) else []
265
- if not items:
266
- raise err(404, "no_batch", "there is nothing parked for this agent to send")
267
- # ⚠ An explicit subset is allowed (a person unticking a customer on the review screen) but it
268
- # may only NARROW the parked batch. A name that was never parked cannot be introduced by the
269
- # payload, or the review stops being what authorised the send.
270
- want = {str(n) for n in (body or {}).get("customers") or []}
271
- if want:
272
- items = [it for it in items if str(it.get("customer")) in want]
273
- if not items:
274
- raise err(400, "bad_request", "none of those customers are in the parked batch")
275
- rows, _ = _rows()
276
- sent, failed, skipped = [], [], []
277
- for it in items:
278
- name = str(it.get("customer") or "")
279
- row = _find(rows, name)
280
- if row is None:
281
- skipped.append({"customer": name,
282
- "reason": "no longer on the collection list, so nothing is owed"})
283
- continue
284
- try:
285
- # β›” THE SAME DATA-LAYER CALL `send()` MAKES. SafeModeBlocked is raised INSIDE
286
- # `queue_statement`, so the guardrail cannot be argued with from here.
287
- mid = cs.queue_statement(cs.Odoo(), row,
288
- str(it.get("subject") or "") or cs.DEFAULT_SUBJECT,
289
- cs.DEFAULT_INTRO, cs.DEFAULT_FOOTER)
290
- sent.append({"customer": name, "to": row.get("Email"), "mailId": mid})
291
- except Exception as e: # noqa: BLE001
292
- failed.append({"customer": name, "error": str(e)[:200]})
293
- # ⚠ CLEARED ONLY WHEN NOTHING IS LEFT TO RETRY. A batch dropped while some of it failed would
294
- # lose the list of who still needs a statement, and nobody would know to look.
295
- if sent and not failed:
296
- engine.clear_statements(session.runtime, auto_id)
297
- return {"sent": sent, "failed": failed, "skipped": skipped,
298
- "safeMode": bool(cs.SAFE_MODE), "cleared": bool(sent and not failed)}
 
1
+ """routes_statements.py β€” the statement-of-account sender, ported off Streamlit (EXIT-6).
2
+
3
+ β›” THIS IS THE ONE SANCTIONED ODOO WRITER IN THE ENTIRE SYSTEM. Everything else in AIOS is
4
+ read-only on Odoo by hard block. `modules/collections_send.py` owns a narrow client that whitelists
5
+ exactly `mail.mail create` (queueing an outbound email) and nothing else; these routes call it and
6
+ add no write of their own.
7
+
8
+ WHY IT EXISTS SEPARATELY FROM THE COLLECTIONS PAGE. Owner ruling wave-17 item 15 retired the
9
+ Collections *dashboard* β€” the worklist is the shared "Collections" view on the Customer grid, from
10
+ the same reconciled blocks. What that ruling explicitly left "untouched" is this send workflow, so
11
+ when `app.py` is deleted it is the ONLY live Streamlit-only feature, and it moves here rather than
12
+ dying with the host. Ported faithfully: same tiers, same filters, same template placeholders, same
13
+ preview, same test-send, same two-step confirm.
14
+
15
+ THE THREE GUARDRAILS, and where each is enforced:
16
+ 1. SAFE_MODE (default ON) β€” in the DATA LAYER (`collections_send.queue_statement`), so no route,
17
+ payload or UI can bypass it. These routes only REPORT it; they never re-implement the check.
18
+ 2. Admin only β€” `admin_gate` (role, fail-closed), mirroring the Streamlit `if not is_admin()`.
19
+ 3. ⭐ TENANT β€” NEW HERE, and it did not exist in Streamlit because it could not. `cs_mod.Odoo()`
20
+ reads Odoo credentials from the ENVIRONMENT, which after the keychain cutover belongs to
21
+ TENANT #0 ALONE. On the multi-tenant API an unguarded route would let a nurilab or gtmlab
22
+ admin queue mail from Royal Imports' Odoo, as Royal Imports. `_royal_only` closes that; the
23
+ single-tenant Streamlit host never had the exposure, so this is a port that must ADD a wall
24
+ rather than copy one.
25
+
26
+ NOTHING SENDS ON A GET. The send route requires an explicit customer list in the body; there is no
27
+ "send all" parameter, deliberately β€” the confirm step is a product requirement, not a formality.
28
+ """
29
+ from fastapi import APIRouter, Body, Depends
30
+
31
+ from deps import Session, err
32
+ from routes_admin import admin_gate
33
+
34
+ # W35-T35 (C8): the tenant predicate, held once. See `_royal_only` for why the import runs this
35
+ # way round. `main.py` already imports both modules, so this adds no load.
36
+ import automation_engine as engine
37
+
38
+ router = APIRouter(prefix="/api/v1")
39
+
40
+ #: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the
41
+ #: list moves slowly (it is a dunning worklist, not a live feed) and the pull is a multi-model read.
42
+ _TTL = 1800
43
+ _cache = {"at": 0.0, "rows": None}
44
+
45
+
46
+ def _cs():
47
+ import modules.collections_send as cs
48
+ return cs
49
+
50
+
51
+ def _royal_only(session: Session) -> Session:
52
+ """β›” See the module docstring, guardrail 3. The send client is env-credentialed, so it is
53
+ tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company.
54
+
55
+ Keyed on the runtime, never on a request field: a tenant is a property of the SESSION.
56
+
57
+ ⭐⭐ WAVE 35 Β· T35 / CONTRACT C8 β€” THE TEST ITSELF NOW LIVES IN ONE PLACE. Statements became an
58
+ agent step this wave, so the automation engine has to answer the same question at two more
59
+ doors (which tenants see the action, which tenants may store it). C8's words are "the predicate
60
+ is imported, never re-expressed", and this is the direction that import can run: the engine
61
+ imports no route module and no FastAPI, so reaching `_royal_only` FROM it would drag `deps` and
62
+ `routes_admin` into a route-free module. The engine holds the boolean; this holds the refusal.
63
+ ⚠ WHAT STAYS HERE IS THE HTTP SHAPE, and that is deliberate β€” a 404 rather than a 403, so the
64
+ surface is invisible rather than forbidden. The engine must not know about status codes."""
65
+ if not engine.is_statement_tenant(session.runtime):
66
+ raise err(404, "not_found", "statements are not configured for this workspace")
67
+ return session
68
+
69
+
70
+ def _gate(session: Session = Depends(admin_gate)) -> Session:
71
+ return _royal_only(session)
72
+
73
+
74
+ def _rows(force=False):
75
+ import time
76
+ cs = _cs()
77
+ if force or _cache["rows"] is None or (time.time() - _cache["at"]) > _TTL:
78
+ _cache["rows"] = cs.load_collection_list(cs.Odoo())
79
+ _cache["at"] = time.time()
80
+ return _cache["rows"], time.strftime("%Y-%m-%d %H:%M", time.localtime(_cache["at"]))
81
+
82
+
83
+ def _public(row):
84
+ """Strip the internals the Streamlit grid also hid (`_`-prefixed + partner_id is kept, because
85
+ the client needs a stable row identity that is not the display name)."""
86
+ return {k: v for k, v in row.items() if not str(k).startswith("_")}
87
+
88
+
89
+ @router.get("/admin/statements")
90
+ def statements(refresh: int = 0, session: Session = Depends(_gate)):
91
+ """The worklist + everything the sender UI needs to render itself honestly."""
92
+ cs = _cs()
93
+ try:
94
+ rows, loaded_at = _rows(force=bool(refresh))
95
+ except Exception as e:
96
+ raise err(502, "odoo_unavailable", f"could not load the collection list: {str(e)[:200]}")
97
+ return {
98
+ "rows": [_public(r) for r in rows],
99
+ "loadedAt": loaded_at,
100
+ # The guardrail is reported, never decided, here β€” the data layer owns it.
101
+ "safeMode": bool(cs.SAFE_MODE),
102
+ "safeRecipients": sorted(cs.SAFE_RECIPIENTS),
103
+ "sender": {"name": cs.SENDER_NAME, "email": cs.SENDER_EMAIL,
104
+ "replyTo": cs.REPLY_TO, "company": cs.COMPANY},
105
+ "templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO,
106
+ "footer": cs.DEFAULT_FOOTER},
107
+ # W35-T35: ONE literal, read from the engine, so the sender's worklist and a statements
108
+ # agent's tier filter cannot come to mean different things.
109
+ "tiers": list(engine.STATEMENT_TIERS),
110
+ }
111
+
112
+
113
+ def _find(rows, customer):
114
+ return next((r for r in rows if r.get("Customer") == customer), None)
115
+
116
+
117
+ @router.post("/admin/statements/preview")
118
+ def preview(body: dict = Body(default=None), session: Session = Depends(_gate)):
119
+ """Render ONE customer's statement exactly as the send path would."""
120
+ cs = _cs()
121
+ body = body or {}
122
+ rows, _ = _rows()
123
+ row = _find(rows, body.get("customer"))
124
+ if row is None:
125
+ raise err(404, "not_found", "no such customer on the collection list")
126
+ t = body.get("templates") or {}
127
+ import datetime as dt
128
+ month = dt.date.today().strftime("%B %Y")
129
+ subject = (t.get("subject") or cs.DEFAULT_SUBJECT)
130
+ try:
131
+ subject = subject.format(customer=row["Customer"], company=cs.COMPANY, month=month)
132
+ except Exception: # noqa: BLE001
133
+ # An unknown placeholder is the user's typo, not a 500. Show the template verbatim so they
134
+ # can see what they typed rather than getting an opaque error.
135
+ # β›” W36-T42: THIS CAUGHT THREE OF THE WAYS `str.format` FAILS AND THERE ARE MORE.
136
+ # `{customer` is a ValueError, `{customer.x}` an AttributeError, `{customer:%Y}` a
137
+ # TypeError, and every one of them is the same user mistake this branch was written for.
138
+ # Naming a subset of the exception types turns a typo in the OTHER half into a 500 on the
139
+ # one screen somebody opens to avoid mailing 200 people the wrong thing.
140
+ pass
141
+ return {
142
+ "html": cs.render_statement_html(row, t.get("intro") or cs.DEFAULT_INTRO,
143
+ t.get("footer") or cs.DEFAULT_FOOTER),
144
+ "to": row.get("Email") or "",
145
+ "subject": subject,
146
+ }
147
+
148
+
149
+ @router.post("/admin/statements/send")
150
+ def send(body: dict = Body(default=None), session: Session = Depends(_gate)):
151
+ """Queue statements. Returns per-customer outcomes β€” NEVER a bare count.
152
+
153
+ `overrideTo` is the test-send path: one customer, one address. SAFE_MODE still applies (the
154
+ data layer refuses an address outside the allow-list), which is why the route does not check it.
155
+ """
156
+ cs = _cs()
157
+ body = body or {}
158
+ names = [str(n) for n in (body.get("customers") or []) if str(n).strip()]
159
+ if not names:
160
+ raise err(400, "bad_request", "name at least one customer")
161
+ # β›”β›” W36-T42 / D-299 β€” A DECLARED TEST SEND WITH NO ADDRESS REFUSES. IT DOES NOT BECOME A
162
+ # REAL ONE. This read `(body.get("overrideTo") or "").strip() or None`, so a caller who sent
163
+ # `overrideTo: ""` (the field left blank, the state not yet typed into, a trimmed-away space)
164
+ # got a REAL statement mailed to the debtor's own address, reported back as `test: false`. On
165
+ # the one route in this product that is allowed to write to Odoo, the difference between a
166
+ # rehearsal and mailing a live customer was one empty string.
167
+ #
168
+ # ⭐ THE DISCRIMINATOR IS THE CALLER'S DECLARATION, NOT THE VALUE. A body with no `overrideTo`
169
+ # key and no `test` flag is the PRODUCTION send, and refusing that would delete the feature
170
+ # D-299 exists to preserve. What can be refused is a caller who SAID this is a test: the key
171
+ # being present, or `test: true`, is that statement, and an empty address beside it is the
172
+ # mistake. So both spellings of "absent" are covered: the key present and blank, and `test`
173
+ # asserted with no key at all.
174
+ #
175
+ # ⚠ THIS IS THE SECOND OF THREE WALLS AND THE ONLY ONE A PAYLOAD CANNOT ROUTE AROUND.
176
+ # `automationApi.testSendStatement` refuses an empty address before the request is built, and
177
+ # SAFE_MODE refuses an address outside the allow-list inside `queue_statement`. The client
178
+ # wall is bypassable by construction (anything can POST); this one is not.
179
+ declared_test = ("overrideTo" in body) or bool(body.get("test"))
180
+ override = str(body.get("overrideTo") or "").strip()
181
+ if declared_test and not override:
182
+ raise err(400, "no_override_address",
183
+ "a test send needs the address to send the test to. Without one this would "
184
+ "mail the customer's own address, which is the opposite of a test")
185
+ override = override or None
186
+ if override and len(names) != 1:
187
+ raise err(400, "bad_request", "a test send takes exactly one customer")
188
+ t = body.get("templates") or {}
189
+ rows, _ = _rows()
190
+
191
+ sent, failed, skipped = [], [], []
192
+ for name in names:
193
+ row = _find(rows, name)
194
+ if row is None:
195
+ failed.append({"customer": name, "error": "not on the current collection list"})
196
+ continue
197
+ if not override and not row.get("Email"):
198
+ # The Streamlit page warned and skipped these. Reporting them SEPARATELY from failures
199
+ # keeps "we could not" distinct from "there was nowhere to send".
200
+ skipped.append({"customer": name, "reason": "no email address on the customer record"})
201
+ continue
202
+ try:
203
+ mid = cs.queue_statement(cs.Odoo(), row, t.get("subject") or cs.DEFAULT_SUBJECT,
204
+ t.get("intro") or cs.DEFAULT_INTRO,
205
+ t.get("footer") or cs.DEFAULT_FOOTER, override_to=override)
206
+ sent.append({"customer": name, "to": override or row.get("Email"), "mailId": mid})
207
+ except Exception as e:
208
+ # SafeModeBlocked lands here too, and that is correct: to the caller a guardrail refusal
209
+ # and an Odoo error are both "this one did not go", each with its own honest message.
210
+ failed.append({"customer": name, "error": str(e)[:200]})
211
+ return {"sent": sent, "failed": failed, "skipped": skipped,
212
+ "safeMode": bool(cs.SAFE_MODE), "test": bool(override)}
213
+
214
+
215
+ # ---------------------------------------------------------------------------------------------
216
+ # WAVE 35 Β· T36 / CONTRACT C8 / OWNER RULING R10 β€” THE AGENT'S PARKED BATCH.
217
+ #
218
+ # β›”β›” THE AGENT ASSEMBLES; A PERSON CLICKS SEND. `automation_engine.run_statements` renders a batch
219
+ # and parks it on the automation definition, and it imports no mail path at all. THIS is the only
220
+ # door that releases one, and it is deliberately built out of the parts already here:
221
+ # Β· `_gate` β€” `admin_gate` THEN `_royal_only`, byte-for-byte the dependency the other three
222
+ # endpoints use. Not a copy of the checks: the same object.
223
+ # Β· `cs.queue_statement` β€” the same call `send()` above makes, so SAFE_MODE's allow-list refusal
224
+ # happens in the DATA LAYER, where no route, payload or UI can reach around it.
225
+ # β‡’ Nothing about the guardrails is re-expressed here, which is what makes "unchanged" checkable.
226
+ #
227
+ # ⚠ WHY THE BATCH IS RE-RENDERED FROM THE LIVE WORKLIST RATHER THAN SENT AS STORED. The parked
228
+ # `html` is what a person APPROVED and is what the review screen shows; but a balance can move
229
+ # between the parking and the click, and mailing a figure we know to be stale is worse than mailing
230
+ # a fresh one. So the parked batch decides WHO and WITH WHAT WORDS, and the live row decides the
231
+ # NUMBERS β€” the same split `preview` already makes. A customer who has left the worklist entirely
232
+ # (they paid) is reported as skipped rather than invoiced.
233
+
234
+ @router.get("/admin/statements/agent/{auto_id}")
235
+ def agent_batch(auto_id: str, session: Session = Depends(_gate)):
236
+ """What is parked and waiting for a click, for ONE agent. `null` when nothing is."""
237
+ defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {}
238
+ pending = defn.get("pendingStatements")
239
+ if not isinstance(pending, dict):
240
+ return {"pending": None}
241
+ # ⚠ The rendered `html` is NOT returned in the list β€” a 200-statement batch of rendered mail is
242
+ # megabytes, and the review screen needs the count and the names to decide. `preview` already
243
+ # renders ONE on demand.
244
+ return {"pending": {
245
+ "ts": pending.get("ts"), "count": int(pending.get("count") or 0),
246
+ "notes": list(pending.get("notes") or []),
247
+ "items": [{k: v for k, v in it.items() if k != "html"}
248
+ for it in (pending.get("items") or [])],
249
+ "safeMode": bool(_cs().SAFE_MODE)}}
250
+
251
+
252
+ @router.post("/admin/statements/agent/{auto_id}/send")
253
+ def agent_send(auto_id: str, body: dict = Body(default=None),
254
+ session: Session = Depends(_gate)):
255
+ """Release a parked batch. THE CLICK R10 REQUIRES β€” nothing else in the system calls this.
256
+
257
+ β›” IT REFUSES AN EMPTY OR MISSING BATCH rather than answering 200 with nothing sent: a send
258
+ that reports success and mails nobody is the `view_upsert` failure mode (200 OK, zero writes)
259
+ arriving on the one route in this system that talks to a mail server.
260
+ """
261
+ cs = _cs()
262
+ defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {}
263
+ pending = defn.get("pendingStatements")
264
+ items = list((pending or {}).get("items") or []) if isinstance(pending, dict) else []
265
+ if not items:
266
+ raise err(404, "no_batch", "there is nothing parked for this agent to send")
267
+ # ⚠ An explicit subset is allowed (a person unticking a customer on the review screen) but it
268
+ # may only NARROW the parked batch. A name that was never parked cannot be introduced by the
269
+ # payload, or the review stops being what authorised the send.
270
+ want = {str(n) for n in (body or {}).get("customers") or []}
271
+ if want:
272
+ items = [it for it in items if str(it.get("customer")) in want]
273
+ if not items:
274
+ raise err(400, "bad_request", "none of those customers are in the parked batch")
275
+ rows, _ = _rows()
276
+ sent, failed, skipped = [], [], []
277
+ for it in items:
278
+ name = str(it.get("customer") or "")
279
+ row = _find(rows, name)
280
+ if row is None:
281
+ skipped.append({"customer": name,
282
+ "reason": "no longer on the collection list, so nothing is owed"})
283
+ continue
284
+ try:
285
+ # β›” THE SAME DATA-LAYER CALL `send()` MAKES. SafeModeBlocked is raised INSIDE
286
+ # `queue_statement`, so the guardrail cannot be argued with from here.
287
+ mid = cs.queue_statement(cs.Odoo(), row,
288
+ str(it.get("subject") or "") or cs.DEFAULT_SUBJECT,
289
+ cs.DEFAULT_INTRO, cs.DEFAULT_FOOTER)
290
+ sent.append({"customer": name, "to": row.get("Email"), "mailId": mid})
291
+ except Exception as e: # noqa: BLE001
292
+ failed.append({"customer": name, "error": str(e)[:200]})
293
+ # ⚠ CLEARED ONLY WHEN NOTHING IS LEFT TO RETRY. A batch dropped while some of it failed would
294
+ # lose the list of who still needs a statement, and nobody would know to look.
295
+ if sent and not failed:
296
+ engine.clear_statements(session.runtime, auto_id)
297
+ return {"sent": sent, "failed": failed, "skipped": skipped,
298
+ "safeMode": bool(cs.SAFE_MODE), "cleared": bool(sent and not failed)}
api/routes_tables.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_web_agent.py CHANGED
@@ -1,84 +1,84 @@
1
- """routes_web_agent.py β€” the door that lets a person TEST the web agent (wave 31, R10 / D-51).
2
-
3
- The owner's words are the whole reason this file exists: *"we already laid the foundation of this
4
- but never test anything."* The capability is otherwise reachable only from inside an automation
5
- run, which means the first person to discover it is broken is a customer at 3am.
6
-
7
- GET /api/v1/web-agent/capability any session β€” can this deployment run a web step, and
8
- if not, the SENTENCE saying why
9
- POST /api/v1/web-agent/test ADMIN β€” run one real `web_read` and show what
10
- came back, or the sentence
11
-
12
- β›” NEITHER ROUTE IS THE SEAM. `automation_engine` calls `web_agent.run_step` directly (contract
13
- C5); these are an operator surface over the same function, so a green test here and a red run
14
- there cannot disagree about anything except the input.
15
-
16
- ⚠ `POST /test` BLOCKS FOR ~10-30 s and COSTS A FRACTION OF A CENT. It is `def`, not `async def`,
17
- so FastAPI runs it in the threadpool and one test cannot stall the event loop for everybody.
18
-
19
- ⚠ ON THE URL IT WILL FETCH: the fetch happens inside an ephemeral HF Job on Hugging Face's
20
- network, never from this server, so this is not a door into our own infrastructure. It is still
21
- admin-gated, because it spends money and because D-51 Β§5's authorisation posture ("only systems
22
- the tenant is authorised to use, at their instruction") is not something an ordinary member
23
- should be able to commit the tenant to.
24
-
25
- ⭐ MOUNTED, and the gate is what says so rather than this sentence: `main.py:80` imports it and
26
- `main.py:316` includes it, and `verify_web_agent.py` asserts the route answers rather than trusting
27
- either line. This paragraph read "NOT MOUNTED YET" for a whole wave after the mount landed, which
28
- is the same class of stale claim as a green gate on a router nobody wired: three finished routers
29
- once shipped 404-dead behind entirely green gates, and prose is not the control that stops it.
30
- """
31
- from fastapi import APIRouter, Body, Depends
32
-
33
- import web_agent
34
- from deps import Session, require_session, err
35
- from routes_admin import admin_gate
36
-
37
- router = APIRouter(prefix="/api/v1")
38
-
39
- MAX_URL = 2000
40
- MAX_SELECTOR = 400
41
-
42
-
43
- @router.get("/web-agent/capability")
44
- def web_agent_capability(session: Session = Depends(require_session)):
45
- """Can a web step run here at all? Configuration, not liveness β€” see `web_agent.capability`.
46
-
47
- Deliberately NARROW: it answers the question a UI needs ("may I offer this, and what do I say
48
- if not") and withholds the deployment detail (namespace, which token key, the image) that
49
- only an operator has any use for. Nothing here is ever a credential.
50
- """
51
- cap = web_agent.capability()
52
- return {"ready": bool(cap["ready"]), "reason": cap["reason"],
53
- "runnableKinds": cap["runnableKinds"], "profile": cap["profile"]}
54
-
55
-
56
- @router.post("/web-agent/test")
57
- def web_agent_test(session: Session = Depends(admin_gate), body: dict = Body(...)):
58
- """Run ONE real `web_read` and report exactly what the seam returned.
59
-
60
- The response mirrors the seam's own contract rather than flattening it: `ok` plus a `value`,
61
- or `ok:false` plus the SENTENCE. A test surface that turns a named failure into "something
62
- went wrong" would hide the one thing it exists to show.
63
- """
64
- url = str(body.get("url") or "").strip()
65
- selector = str(body.get("selector") or "").strip()
66
- if len(url) > MAX_URL or len(selector) > MAX_SELECTOR:
67
- raise err(400, "too_long", "the URL or selector is longer than this door accepts")
68
-
69
- step = {"kind": "web_read", "id": "test", "url": url, "selector": selector,
70
- "attr": (body.get("attr") or "text"), "all": bool(body.get("all")),
71
- "timeoutMs": int(body.get("timeoutMs") or 20000)}
72
- if body.get("waitFor"):
73
- step["waitFor"] = str(body["waitFor"])
74
-
75
- notes = []
76
- result, error = web_agent.run_step(
77
- step, {"tenant": session.tenant, "runId": f"test-{session.tenant}-{session.uname}",
78
- "log": notes.append})
79
- if error:
80
- # 200 with `ok:false`, not a 4xx: the request was well-formed and the ANSWER is that the
81
- # web step did not succeed. A 500 here would make an ordinary "the selector matched
82
- # nothing" look like a server fault.
83
- return {"ok": False, "error": error, "log": notes[-6:]}
84
- return {"ok": True, "result": result, "log": notes[-6:]}
 
1
+ """routes_web_agent.py β€” the door that lets a person TEST the web agent (wave 31, R10 / D-51).
2
+
3
+ The owner's words are the whole reason this file exists: *"we already laid the foundation of this
4
+ but never test anything."* The capability is otherwise reachable only from inside an automation
5
+ run, which means the first person to discover it is broken is a customer at 3am.
6
+
7
+ GET /api/v1/web-agent/capability any session β€” can this deployment run a web step, and
8
+ if not, the SENTENCE saying why
9
+ POST /api/v1/web-agent/test ADMIN β€” run one real `web_read` and show what
10
+ came back, or the sentence
11
+
12
+ β›” NEITHER ROUTE IS THE SEAM. `automation_engine` calls `web_agent.run_step` directly (contract
13
+ C5); these are an operator surface over the same function, so a green test here and a red run
14
+ there cannot disagree about anything except the input.
15
+
16
+ ⚠ `POST /test` BLOCKS FOR ~10-30 s and COSTS A FRACTION OF A CENT. It is `def`, not `async def`,
17
+ so FastAPI runs it in the threadpool and one test cannot stall the event loop for everybody.
18
+
19
+ ⚠ ON THE URL IT WILL FETCH: the fetch happens inside an ephemeral HF Job on Hugging Face's
20
+ network, never from this server, so this is not a door into our own infrastructure. It is still
21
+ admin-gated, because it spends money and because D-51 Β§5's authorisation posture ("only systems
22
+ the tenant is authorised to use, at their instruction") is not something an ordinary member
23
+ should be able to commit the tenant to.
24
+
25
+ ⭐ MOUNTED, and the gate is what says so rather than this sentence: `main.py:80` imports it and
26
+ `main.py:316` includes it, and `verify_web_agent.py` asserts the route answers rather than trusting
27
+ either line. This paragraph read "NOT MOUNTED YET" for a whole wave after the mount landed, which
28
+ is the same class of stale claim as a green gate on a router nobody wired: three finished routers
29
+ once shipped 404-dead behind entirely green gates, and prose is not the control that stops it.
30
+ """
31
+ from fastapi import APIRouter, Body, Depends
32
+
33
+ import web_agent
34
+ from deps import Session, require_session, err
35
+ from routes_admin import admin_gate
36
+
37
+ router = APIRouter(prefix="/api/v1")
38
+
39
+ MAX_URL = 2000
40
+ MAX_SELECTOR = 400
41
+
42
+
43
+ @router.get("/web-agent/capability")
44
+ def web_agent_capability(session: Session = Depends(require_session)):
45
+ """Can a web step run here at all? Configuration, not liveness β€” see `web_agent.capability`.
46
+
47
+ Deliberately NARROW: it answers the question a UI needs ("may I offer this, and what do I say
48
+ if not") and withholds the deployment detail (namespace, which token key, the image) that
49
+ only an operator has any use for. Nothing here is ever a credential.
50
+ """
51
+ cap = web_agent.capability()
52
+ return {"ready": bool(cap["ready"]), "reason": cap["reason"],
53
+ "runnableKinds": cap["runnableKinds"], "profile": cap["profile"]}
54
+
55
+
56
+ @router.post("/web-agent/test")
57
+ def web_agent_test(session: Session = Depends(admin_gate), body: dict = Body(...)):
58
+ """Run ONE real `web_read` and report exactly what the seam returned.
59
+
60
+ The response mirrors the seam's own contract rather than flattening it: `ok` plus a `value`,
61
+ or `ok:false` plus the SENTENCE. A test surface that turns a named failure into "something
62
+ went wrong" would hide the one thing it exists to show.
63
+ """
64
+ url = str(body.get("url") or "").strip()
65
+ selector = str(body.get("selector") or "").strip()
66
+ if len(url) > MAX_URL or len(selector) > MAX_SELECTOR:
67
+ raise err(400, "too_long", "the URL or selector is longer than this door accepts")
68
+
69
+ step = {"kind": "web_read", "id": "test", "url": url, "selector": selector,
70
+ "attr": (body.get("attr") or "text"), "all": bool(body.get("all")),
71
+ "timeoutMs": int(body.get("timeoutMs") or 20000)}
72
+ if body.get("waitFor"):
73
+ step["waitFor"] = str(body["waitFor"])
74
+
75
+ notes = []
76
+ result, error = web_agent.run_step(
77
+ step, {"tenant": session.tenant, "runId": f"test-{session.tenant}-{session.uname}",
78
+ "log": notes.append})
79
+ if error:
80
+ # 200 with `ok:false`, not a 4xx: the request was well-formed and the ANSWER is that the
81
+ # web step did not succeed. A 500 here would make an ordinary "the selector matched
82
+ # nothing" look like a server fault.
83
+ return {"ok": False, "error": error, "log": notes[-6:]}
84
+ return {"ok": True, "result": result, "log": notes[-6:]}
platform/aios_grid.py CHANGED
@@ -643,7 +643,18 @@ def clean_measure_field(raw, offered):
643
  "custom": True,
644
  "derived": True,
645
  "filterable": False,
646
- "agg": "sum" if mtype in ("currency", "int") else None,
 
 
 
 
 
 
 
 
 
 
 
647
  "note": str(raw.get("note") or "")[:2000],
648
  "measure": {"key": str(spec.get("key"))[:80], "window": window},
649
  }
@@ -735,7 +746,14 @@ def fields_from_workspace(workspace=None, cohorts=False, scope_key=None, fields_
735
  "custom": True,
736
  "derived": True,
737
  "filterable": False,
738
- "agg": "sum" if mtype in ("currency", "int") else None,
 
 
 
 
 
 
 
739
  "note": str(field.get("note") or "")[:2000],
740
  "measure": {"key": mkey[:80], "window": window},
741
  **_field_extras(field, mtype),
@@ -2016,6 +2034,30 @@ def clean_folders(raw):
2016
  icon = clean_folder_icon(f.get("icon")) # wave-9 I15 (C5); absent = default mark
2017
  if icon:
2018
  row["icon"] = icon
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2019
  items.append(row)
2020
  items.sort(key=lambda x: x["order"])
2021
  for i, f in enumerate(items):
 
643
  "custom": True,
644
  "derived": True,
645
  "filterable": False,
646
+ # W41-T21 (owner instruction 16): every numeric measure column arrives with a column
647
+ # summary, `pct` included. A pct measure used to fall through to `agg: None`, so a
648
+ # percentage column could never total at the bottom or per group.
649
+ # It defaults to AVERAGE rather than sum ON PURPOSE: twenty rows of 20% would "total"
650
+ # 400%, a number nobody can use. Currency and int keep `sum`. `average` is a member
651
+ # of FIELD_AGGS, which is the vocabulary the saved-field passthrough at the bottom
652
+ # of `fields_from_workspace` filters against, so it is not nulled on the way out.
653
+ # Do not tidy the pct arm back to `sum`. The `else None` arm stays fail closed for
654
+ # any future member of MEASURE_FIELD_TYPES that is not summable; today the `mtype`
655
+ # fallback above makes it unreachable.
656
+ "agg": ("average" if mtype == "pct"
657
+ else "sum" if mtype in ("currency", "int") else None),
658
  "note": str(raw.get("note") or "")[:2000],
659
  "measure": {"key": str(spec.get("key"))[:80], "window": window},
660
  }
 
746
  "custom": True,
747
  "derived": True,
748
  "filterable": False,
749
+ # W41-T21: the SAVED half of the same default, and it must stay identical to
750
+ # `clean_measure_field`. A measure column already sitting in somebody's
751
+ # workspace is rebuilt HERE, not there, so stamping only the create door would
752
+ # leave every existing pct column with no summary and ship the feature half
753
+ # dead. `pct` averages (a summed percentage is meaningless), currency and int
754
+ # sum. Do not tidy the pct arm back to `sum`.
755
+ "agg": ("average" if mtype == "pct"
756
+ else "sum" if mtype in ("currency", "int") else None),
757
  "note": str(field.get("note") or "")[:2000],
758
  "measure": {"key": mkey[:80], "window": window},
759
  **_field_extras(field, mtype),
 
2034
  icon = clean_folder_icon(f.get("icon")) # wave-9 I15 (C5); absent = default mark
2035
  if icon:
2036
  row["icon"] = icon
2037
+ # W41-T12 / T33 / OWNER INSTRUCTION 12 -- `parent` SURVIVES THIS FUNCTION NOW.
2038
+ # THIS ONE LINE IS WHY NESTING WAS DEAD. Every other half shipped and worked:
2039
+ # `ViewSidebar.tsx:316` takes a `parent`, `CustomerGrid.tsx:7432` puts it on the wire,
2040
+ # `table_store.validate_folder_tree` refuses cycles and chains over `MAX_FOLDER_DEPTH`
2041
+ # with a worded receipt, and `grid_events` already catches `FolderTreeError` into
2042
+ # `_folder_refuse`. This row build STRIPPED the key on the way in, so the validator
2043
+ # could never fire and a folder made inside a folder was gone on refresh -- a feature
2044
+ # dead behind four correct halves and every gate green. `grid_events.py` stated the
2045
+ # forward contract itself: "the day `clean_folders` learns `parent`, the refusal is
2046
+ # already wired and worded". This is that day.
2047
+ #
2048
+ # ABSENT, NEVER `None`, and never invented. An absent `parent` IS the root, which is
2049
+ # what every stored row has meant until now -- and `validate_folder_tree` promises that
2050
+ # a payload carrying no `parent` anywhere comes back byte-identical with zero repairs,
2051
+ # so writing a null key would break that promise for no gain.
2052
+ # SELF-PARENTING IS DROPPED HERE as well as refused downstream: the validator raises
2053
+ # `cycle` on it, and a row that cannot be legal should not reach the validator wearing
2054
+ # a repair. A parent naming a folder that is GONE is deliberately NOT dropped here --
2055
+ # deleting a folder creates exactly that, and `validate_folder_tree` repairs it to the
2056
+ # root WITH a receipt the user can read. Dropping it silently here would destroy the
2057
+ # receipt, and the user would never learn their folder had moved.
2058
+ parent = str(f.get("parent") or "").strip()[:80]
2059
+ if parent and parent != fid:
2060
+ row["parent"] = parent
2061
  items.append(row)
2062
  items.sort(key=lambda x: x["order"])
2063
  for i, f in enumerate(items):
platform/aios_grid_fields.json CHANGED
@@ -329,6 +329,14 @@
329
  "default": true,
330
  "description": "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
331
  },
 
 
 
 
 
 
 
 
332
  {
333
  "key": "product",
334
  "label": "Product",
@@ -791,6 +799,140 @@
791
  "default": false,
792
  "shared": true,
793
  "description": "Team-maintained. The 2027 workbook's own non-Odoo columns (Product Description, Packing) folded into one field. Shared with everyone in the workspace."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794
  }
795
  ]
796
  }
 
329
  "default": true,
330
  "description": "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
331
  },
332
+ {
333
+ "key": "product_id",
334
+ "label": "Odoo ID",
335
+ "type": "int",
336
+ "source": "odoo",
337
+ "default": false,
338
+ "description": "The Odoo product.product id, the key every Odoo document joins on. Carried on the row rather than derived, because a product's pid is a CRC32 of its SKU and the id cannot be recovered from it."
339
+ },
340
  {
341
  "key": "product",
342
  "label": "Product",
 
799
  "default": false,
800
  "shared": true,
801
  "description": "Team-maintained. The 2027 workbook's own non-Odoo columns (Product Description, Packing) folded into one field. Shared with everyone in the workspace."
802
+ },
803
+ {
804
+ "key": "sub_category",
805
+ "label": "Sub-Category",
806
+ "type": "select",
807
+ "source": "overlay",
808
+ "default": false,
809
+ "preset": false,
810
+ "shared": true,
811
+ "createdBy": "admin",
812
+ "permissions": {
813
+ "edit": "collaborative"
814
+ },
815
+ "description": "Fine grained product class from the Daily Mastersheet. Owned by the Administration account and shared with everyone in the workspace. Blank means the mastersheet carries no value for this SKU."
816
+ },
817
+ {
818
+ "key": "main_category",
819
+ "label": "Main Category",
820
+ "type": "select",
821
+ "source": "overlay",
822
+ "default": false,
823
+ "preset": false,
824
+ "shared": true,
825
+ "createdBy": "admin",
826
+ "permissions": {
827
+ "edit": "collaborative"
828
+ },
829
+ "description": "Top level product class from the Daily Mastersheet. Owned by the Administration account and shared with everyone in the workspace. Blank means the mastersheet carries no value for this SKU."
830
+ },
831
+ {
832
+ "key": "color",
833
+ "label": "Color",
834
+ "type": "multiselect",
835
+ "source": "overlay",
836
+ "default": false,
837
+ "preset": false,
838
+ "shared": true,
839
+ "createdBy": "admin",
840
+ "permissions": {
841
+ "edit": "collaborative"
842
+ },
843
+ "description": "Colors this SKU is offered in, from the Daily Mastersheet. Multi-select, because the sheet stores several colors in one comma separated cell and each one becomes its own value. Owned by the Administration account and shared with everyone in the workspace."
844
+ },
845
+ {
846
+ "key": "shape_style",
847
+ "label": "Shape/Style",
848
+ "type": "select",
849
+ "source": "overlay",
850
+ "default": false,
851
+ "preset": false,
852
+ "shared": true,
853
+ "createdBy": "admin",
854
+ "permissions": {
855
+ "edit": "collaborative"
856
+ },
857
+ "description": "Shape or styling of the piece, from the Daily Mastersheet. Owned by the Administration account and shared with everyone in the workspace. Blank means the mastersheet carries no value for this SKU."
858
+ },
859
+ {
860
+ "key": "material",
861
+ "label": "Material",
862
+ "type": "select",
863
+ "source": "overlay",
864
+ "default": false,
865
+ "preset": false,
866
+ "shared": true,
867
+ "createdBy": "admin",
868
+ "permissions": {
869
+ "edit": "collaborative"
870
+ },
871
+ "description": "What the piece is made of, from the Daily Mastersheet. Owned by the Administration account and shared with everyone in the workspace. Blank means the mastersheet carries no value for this SKU."
872
+ },
873
+ {
874
+ "key": "finish",
875
+ "label": "Finish",
876
+ "type": "select",
877
+ "source": "overlay",
878
+ "default": false,
879
+ "preset": false,
880
+ "shared": true,
881
+ "createdBy": "admin",
882
+ "permissions": {
883
+ "edit": "collaborative"
884
+ },
885
+ "description": "Surface finish of the piece, from the Daily Mastersheet. Owned by the Administration account and shared with everyone in the workspace. Blank means the mastersheet carries no value for this SKU."
886
+ },
887
+ {
888
+ "key": "occassion",
889
+ "label": "Occassion",
890
+ "type": "multiselect",
891
+ "source": "overlay",
892
+ "default": false,
893
+ "preset": false,
894
+ "shared": true,
895
+ "createdBy": "admin",
896
+ "permissions": {
897
+ "edit": "collaborative"
898
+ },
899
+ "description": "Occasions this SKU is bought for, from the Daily Mastersheet. Multi-select, because the sheet stores several occasions in one comma separated cell and each one becomes its own value. The label keeps the mastersheet's own spelling. Owned by the Administration account and shared with everyone in the workspace."
900
+ },
901
+ {
902
+ "key": "collection",
903
+ "label": "Collection",
904
+ "type": "select",
905
+ "source": "overlay",
906
+ "default": false,
907
+ "preset": false,
908
+ "shared": true,
909
+ "createdBy": "admin",
910
+ "permissions": {
911
+ "edit": "collaborative"
912
+ },
913
+ "description": "The merchandising collection this SKU belongs to, from the Daily Mastersheet. Owned by the Administration account and shared with everyone in the workspace. Blank means the mastersheet carries no value for this SKU."
914
+ },
915
+ {
916
+ "key": "size",
917
+ "label": "Size",
918
+ "type": "select",
919
+ "source": "overlay",
920
+ "default": false,
921
+ "preset": false,
922
+ "shared": true,
923
+ "createdBy": "admin",
924
+ "permissions": {
925
+ "edit": "collaborative"
926
+ },
927
+ "description": "Size band from the Daily Mastersheet. Owned by the Administration account and shared with everyone in the workspace. Blank means the mastersheet carries no value for this SKU."
928
+ },
929
+ {
930
+ "key": "upc",
931
+ "label": "UPC",
932
+ "type": "text",
933
+ "source": "odoo",
934
+ "default": false,
935
+ "description": "The SKU's barcode, read live from Odoo as product.product.barcode. Blank when Odoo carries no barcode for this product. The Daily Mastersheet has a UPC column of its own and it is deliberately not used: three quarters of it is a placeholder rather than a number."
936
  }
937
  ]
938
  }
platform/core/field_permissions.py CHANGED
@@ -6,6 +6,8 @@ that could never be true. This module is the single normalization point for the
6
  personal, collaborative, or specific users.
7
  """
8
 
 
 
9
  import core.shared_overlay as shared_overlay
10
  import core.shares as shares
11
 
@@ -14,6 +16,28 @@ FIELD_EDIT_MODES = ("personal", "collaborative", "users")
14
  MAX_FIELD_USERS = 50
15
  _ALIASES = {"everyone": "collaborative", "creator": "personal", "admins": "personal"}
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  def clean_permissions(raw, fallback="personal", known_users=None):
19
  """Return a small, canonical field permission bag or ``None`` for malformed input."""
@@ -115,6 +139,269 @@ def permissions_from_grants(entries):
115
  return clean_permissions({"edit": "users", "users": sorted(users)})
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  def _same_classification(left, right):
119
  """Do two canonical bags say the same thing about WHO?
120
 
@@ -213,6 +500,149 @@ def reconcile_shared_permissions(shared_key, grant_topic, st=None):
213
  return rewritten
214
 
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  def _field_is_migratable(field):
217
  return (isinstance(field, dict) and field.get("custom") is True
218
  and field.get("source") == "overlay")
@@ -229,7 +659,8 @@ def migrate_legacy_fields(table_key, st=None, known_users=None, grant_topic=None
229
  grant_key = str(grant_topic or key).strip()
230
  shared_key = str(shared_key or key).strip()
231
  if not key or st is None:
232
- return {"promoted": 0, "normalized": 0, "reclassified": 0}
 
233
  # ⭐⭐ W40-T03 β€” RECONCILE THE SHARED STRATUM'S CLASSIFICATION FIRST, IN ITS OWN SWALLOWING
234
  # GUARD. This is the ONE seam that reaches every shared-field surface: all three readers
235
  # (`routes_customers.shared_fields`, `routes_tables._ut_shared_fields`,
@@ -254,15 +685,21 @@ def migrate_legacy_fields(table_key, st=None, known_users=None, grant_topic=None
254
  try:
255
  document = st.get(key) or {}
256
  except Exception:
257
- return {"promoted": 0, "normalized": 0, "reclassified": reclassified}
 
258
  if not isinstance(document, dict):
259
- return {"promoted": 0, "normalized": 0, "reclassified": reclassified}
 
260
 
261
  promoted = 0
262
  normalized = 0
 
263
  remove = {}
 
 
 
264
  for owner, workspace in document.items():
265
- if owner == "__shared__" or not isinstance(workspace, dict):
266
  continue
267
  fields = workspace.get("fields") or {}
268
  if not isinstance(fields, dict):
@@ -270,6 +707,15 @@ def migrate_legacy_fields(table_key, st=None, known_users=None, grant_topic=None
270
  for field_key, original in list(fields.items()):
271
  if not _field_is_migratable(original):
272
  continue
 
 
 
 
 
 
 
 
 
273
  permissions = stored_permissions(original, fallback="collaborative",
274
  known_users=known_users)
275
  if permissions["edit"] == "personal":
@@ -327,10 +773,42 @@ def migrate_legacy_fields(table_key, st=None, known_users=None, grant_topic=None
327
  values.pop(field_key, None)
328
  return data
329
  st.update(key, _remove, flush="sync")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  # β›” `promoted` AND `normalized` KEEP THEIR NAMES AND THEIR MEANINGS.
331
  # `aios-web/api/verify_field_permissions.py` asserts `result["promoted"] == 1`; renaming or
332
  # folding either into the new count would turn a green gate red for a change it never made.
333
- return {"promoted": promoted, "normalized": normalized, "reclassified": reclassified}
 
 
 
334
 
335
 
336
  def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None):
@@ -391,6 +869,13 @@ def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None)
391
  for values in (ws.get("overlays") or {}).values():
392
  if isinstance(values, dict):
393
  values.pop(key, None)
 
 
 
 
 
 
 
394
  return data
395
  st.update(workspace_key, _remove, flush="sync")
396
  return shared
@@ -424,3 +909,93 @@ def demote_field(workspace_key, shared_key, grant_topic, field, st=None):
424
  shared_overlay.drop_field(shared_key, key, st=st)
425
  shares.drop_objects([("field", shares.field_oid(grant_topic, key))], st=st)
426
  return personal
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  personal, collaborative, or specific users.
7
  """
8
 
9
+ import time
10
+
11
  import core.shared_overlay as shared_overlay
12
  import core.shares as shares
13
 
 
16
  MAX_FIELD_USERS = 50
17
  _ALIASES = {"everyone": "collaborative", "creator": "personal", "admins": "personal"}
18
 
19
+ #: ⭐⭐ W41-T01 / RULING R5 / CONTRACT C1 β€” THE THREE BADGES' VOCABULARIES, DECLARED ONCE.
20
+ #: R5: *"A field is classified by THREE badges, and every right derives from them: Origin
21
+ #: (`Pre-set` = the platform made it / blank = a person made it) x Audience (`Private` /
22
+ #: `Shared by <name>` / `Everyone`) x Values (`Shared values` = one value everyone sees / blank =
23
+ #: yours alone)."* A consumer greps ONE place for the spellings; nothing derives a badge
24
+ #: independently.
25
+ FIELD_ORIGINS = ("preset", "user")
26
+ FIELD_AUDIENCES = ("private", "users", "everyone")
27
+ FIELD_VALUE_STRATA = ("shared", "personal")
28
+
29
+ #: `permissions_from_grants`' edit vocabulary -> C1's audience vocabulary. The two answer the SAME
30
+ #: question in different words (who can reach this column), so this map is the whole of the
31
+ #: translation and there is no second derivation to drift from it
32
+ #: ([[one-evaluator-per-question]]). β›” It is a MAP rather than a branch on purpose: a mode this
33
+ #: dict does not name falls to `private`, which is the fail-closed direction.
34
+ _AUDIENCE_FROM_EDIT = {"collaborative": "everyone", "users": "users", "personal": "private"}
35
+
36
+
37
+ def _uname(value):
38
+ """One spelling of a username, everywhere in this module."""
39
+ return str(value or "").strip().lower()
40
+
41
 
42
  def clean_permissions(raw, fallback="personal", known_users=None):
43
  """Return a small, canonical field permission bag or ``None`` for malformed input."""
 
139
  return clean_permissions({"edit": "users", "users": sorted(users)})
140
 
141
 
142
+ # --------------------------------------------------------------- W41-T01 / R5 / C1: the badges
143
+ def _governed(field):
144
+ """Does the per-field grant WALL apply to this column?
145
+
146
+ β›” ONE EVALUATOR, AND IT IS `perm_scope`'s. `granted_field_keys` is the cheap half of
147
+ `field_grant_hidden`; asking it here is what makes the BADGE and the WALL incapable of
148
+ disagreeing, which is this ticket's own acceptance. Re-spelling `f.get('granted') is True`
149
+ locally would be a second reader of the mark, and a second reader is how the two come apart.
150
+
151
+ β›” THE IMPORT IS LAZY, MATCHING `field_grant_hidden`'s OWN `import core.shares` AT ITS CALL
152
+ SITE. `perm_scope` pulls `core.perms` -> `context`/`registry`/`users`; this module is imported
153
+ lazily from `grid_events` precisely to stay cheap, and a top-level import here would widen its
154
+ graph for every importer to buy nothing.
155
+ """
156
+ try:
157
+ import core.perm_scope as perm_scope
158
+ except Exception: # noqa: BLE001
159
+ # Unresolvable means "not governed": the badge then reports the column's OTHER, true
160
+ # properties instead of asserting a permission state it cannot verify. The wall itself
161
+ # does not degrade with it β€” it lives in `perm_scope` and is unreachable from here.
162
+ return False
163
+ return bool(perm_scope.granted_field_keys([field] if isinstance(field, dict) else []))
164
+
165
+
166
+ def field_origin(field):
167
+ """R5's FIRST badge: `"preset"` (the platform made this column) or `"user"` (a person did).
168
+
169
+ β›” READ OFF MARKS THAT ALREADY EXIST, AND NO NEW ONE IS INVENTED. There are exactly two kinds
170
+ of positive evidence that a PERSON made a column, and both are already written by the create
171
+ doors: `custom: True` (`grid_events.field_upsert`) and `createdBy` (stamped at create and
172
+ preserved from `prior` on every later write). A contract-declared column carries neither β€”
173
+ `aios_grid_fields.json` names only `key/label/type/source/default/description/shared/...` β€” so
174
+ Supplier and every canonical Odoo column answer `"preset"` without a mark being added to the
175
+ contract file.
176
+
177
+ ⚠ `automation.preset` IS CHECKED FIRST AND IT WINS. It is the platform's own positive mark
178
+ (`automation_engine.ut_ensure(lock_fields=True)`), so a seeded column that somehow also carries
179
+ an author stays `"preset"`. β›” AND THE `type != 'rollup'` LEG OF THE CLIENT'S
180
+ `isSchemaLocked` IS DELIBERATELY NOT REPEATED HERE: that leg answers *"may this be edited"*
181
+ (`user_tables.preset_editable` returns True for exactly `rollup`), which is a RIGHT derived
182
+ from the badge, not the badge. A preset rollup was still made by the platform.
183
+
184
+ ⚠ ABSENCE FALLS TO `"preset"`, AND THE POLARITY IS THE CONSERVATIVE ONE. Rights derive from
185
+ this badge, so calling a platform column user-made is the direction that would offer somebody
186
+ a delete they must not have; calling a user column platform-made is cosmetic and visible.
187
+ β›” KNOWN GAP, OUT OF THIS TICKET'S FENCE: on a `ut_*` database `user_tables._clean_field` is a
188
+ strict allowlist that rebuilds `out` from scratch and keeps neither `custom` nor `createdBy`
189
+ (it does keep `automation`), so a user-created column there has no author mark left to read and
190
+ answers `"preset"`. That is a defect in the allowlist, not in this derivation, and it does not
191
+ reach `customer_data` or `product_data`, whose definitions keep both marks.
192
+ """
193
+ if not isinstance(field, dict):
194
+ return "preset"
195
+ automation = field.get("automation")
196
+ if isinstance(automation, dict) and automation.get("preset") is True:
197
+ return "preset"
198
+ if field.get("custom") is True or _uname(field.get("createdBy")):
199
+ return "user"
200
+ return "preset"
201
+
202
+
203
+ def _shared_value_keys(table_key, st=None):
204
+ """The columns of `table_key` whose VALUES are one tenant-wide stratum."""
205
+ key = str(table_key or "").strip()
206
+ if not key:
207
+ return set()
208
+ try:
209
+ return {str(k) for k in shared_overlay.fields(key, st=st)}
210
+ except Exception: # noqa: BLE001
211
+ return set()
212
+
213
+
214
+ def _empty_record():
215
+ return {"owner": None, "entries": []}
216
+
217
+
218
+ def _grant_record(field_key, table_key=None, grant_topic=None, st=None):
219
+ """ONE column's grant record, read straight from the registry."""
220
+ topic = str(grant_topic or table_key or "").strip()
221
+ key = str(field_key or "").strip()
222
+ if not topic or not key:
223
+ return _empty_record()
224
+ try:
225
+ return shares.grants("field", shares.field_oid(topic, key), st=st)
226
+ except Exception: # noqa: BLE001
227
+ return _empty_record()
228
+
229
+
230
+ def grant_records(grant_topic, st=None):
231
+ """`{field_key: {'owner', 'entries'}}` for one topic, on **ONE** read of `object_shares`.
232
+
233
+ ⭐⭐ THE BATCH EXISTS FOR A MEASURED REASON, NOT FOR TIDINESS. `shares.grants` opens the
234
+ registry per call and `store.get` hands back `json.loads(json.dumps(...))` β€” a fresh deep copy
235
+ every time. Badging a customer grid is 40-60 columns, so the per-field door is 40-60
236
+ serialise/deserialise round trips added to the hot read path. That is the exact shape
237
+ `grid_assembly`'s own W38-T20 note spent a ticket removing (*"three consumers want this dict on
238
+ one request, and letting each take its own copy is the shape D-214 spent a whole ticket
239
+ removing one document over"*).
240
+
241
+ β›” THE ENTRIES GO THROUGH `shares._clean_entries`, WHICH IS NOT OPTIONAL AND IS NOT TIDYING.
242
+ That normaliser DROPS an entry whose role is not in `shares.ROLES`, and `permissions_from_grants`
243
+ checks only the user. Reading the bucket raw would let a junk-role entry make this badge say
244
+ `users` while `shares.role_for` β€” which reads through `grants()`, i.e. through the normaliser β€”
245
+ walls that same person out. A badge that disagrees with the wall is precisely what R5's
246
+ *"every right derives from them"* forbids. Same normaliser, therefore same answer.
247
+
248
+ ⚠ THE TOPIC IS THE GRANT TOPIC, NOT THE BUCKET. `migrate_legacy_fields` already carries this
249
+ warning in full: `shared_key` names the store bucket (`customer_table_workspace__shared`) and
250
+ `grant_topic` names the registry namespace (`customer_data`). Handing this one the bucket finds
251
+ no record for any column and every badge silently reads as never-shared.
252
+ """
253
+ topic = str(grant_topic or "").strip()
254
+ if not topic:
255
+ return {}
256
+ try:
257
+ bucket = (shares._st(st).get(shares.SHARES_KEY) or {}).get("field") or {}
258
+ except Exception: # noqa: BLE001
259
+ return {}
260
+ if not isinstance(bucket, dict):
261
+ return {}
262
+ out = {}
263
+ for oid, rec in bucket.items():
264
+ table, key = shares.split_field_oid(oid)
265
+ # `split_field_oid` FAILS CLOSED on junk (`(None, None)`), so an unparseable id is skipped
266
+ # rather than being attributed to whichever database is being read.
267
+ if table != topic or not key or not isinstance(rec, dict):
268
+ continue
269
+ out[key] = {"owner": (_uname(rec.get("owner")) or None),
270
+ "entries": shares._clean_entries(rec.get("entries"))}
271
+ return out
272
+
273
+
274
+ def field_class(field, viewer, grants=None, *, table_key=None, grant_topic=None,
275
+ values_shared=None, st=None):
276
+ """⭐⭐ W41-T01 / R5 / CONTRACT C1 β€” **THE ONE PRODUCER OF A FIELD'S THREE BADGES.**
277
+
278
+ Returns `{origin, audience, sharedBy, values, owner}` and nothing else derives a badge
279
+ independently. The five member spellings are C1's and they are the wire's: the payload key is
280
+ `class`, its members are `origin`, `audience`, `sharedBy`, `values`, `owner`.
281
+
282
+ `viewer` is a USERNAME STRING (`session.uname`), never a user dict β€” a dict compares unequal to
283
+ every stored name, which would make `sharedBy` silently never match and `owner` never equal the
284
+ reader.
285
+
286
+ **origin** β€” `field_origin`, above.
287
+
288
+ **audience** β€” `private` | `users` | `everyone`, and it MIRRORS THE WALL rather than paraphrasing
289
+ it, because a badge that says something the wall does not enforce is worse than no badge:
290
+
291
+ * a column carrying `perm_scope.FIELD_GRANT_MARK` is GOVERNED, so the registry answers,
292
+ through the existing `permissions_from_grants` (extended, never duplicated): a `*` grant is
293
+ `everyone`, named grants are `users`, no grant at all is `private`;
294
+ * an UNGOVERNED column is not walled by `field_grant_hidden` at all, so whoever holds the
295
+ database holds the column. It is therefore `everyone` when the column is tenant-wide β€” a
296
+ contract-declared one (Supplier, and every canonical Odoo column) or one living in the
297
+ shared stratum β€” and `private` when it is not, because a per-user definition exists only in
298
+ its creator's own stratum and no other account ever receives it.
299
+
300
+ ⭐ SUPPLIER IS THE CASE THE THIRD BADGE EXISTS FOR, AND IT IS WHY THE CONTRACT LEG IS NOT
301
+ OPTIONAL. It answers `everyone` while having NO `object_shares` row at all β€” its audience comes
302
+ from the contract that declared it, not from the registry, which is exactly why `ShareDialog`
303
+ prints "Not shared with anyone yet." about a column the whole workspace can already read.
304
+
305
+ β›” THE TRAP, AND IT IS THIS TICKET'S OWN: `shared_overlay`'s `"shared": True` IS WRITTEN AND
306
+ NEVER READ (D-414). It is not consulted anywhere here. `FIELD_GRANT_MARK` is the only flag the
307
+ wall reads, so it is the only flag this function reads, and tenant-wide-ness is answered by
308
+ MEMBERSHIP (`values_shared`) rather than by that stale boolean.
309
+
310
+ ⚠ A ROUTE COLUMN MINTED BEFORE THE MARK EXISTED THEREFORE READS `everyone`, AND THAT IS THE
311
+ TRUTH RATHER THAN A MISS. Such a column carries `shared: True` alone, is not `granted`, and
312
+ genuinely leaks to the whole tenant; the badge reports the leak instead of papering over it.
313
+ Closing it is W41-T05's work, and this badge is how it becomes visible.
314
+
315
+ **sharedBy** β€” who shared it TO this viewer, or `None`. `shares.grants` carries `by` per entry
316
+ (D-474) and the owner is the fallback when a pre-D-474 record has none. `None` when the viewer
317
+ IS the owner, when nobody shared it, or when the viewer shared it to themselves.
318
+ ⚠ IT IS EMITTED UNDER AN `everyone` AUDIENCE TOO, not only under `users`. R5 renders the three
319
+ as alternative states of one badge, so a consumer that only wants a name for `Shared by <name>`
320
+ reads it under `users` and ignores it otherwise β€” but the fact ("alice let you in") is true in
321
+ both, and withholding it would make the producer decide a rendering question that belongs to
322
+ the consumer.
323
+
324
+ **values** β€” `shared` when this column's VALUES are one stratum everyone sees (membership of
325
+ `shared_overlay.fields()` for an overlay topic, or a module's `SHARED_KEYS()` for a
326
+ contract-declared one), else `personal`. This is R5's new information and it is INDEPENDENT of
327
+ audience: a route column is `private` with `shared` values, Supplier is `everyone` with `shared`
328
+ values, and a column you made is `private` with `personal` ones.
329
+ ⚠ `values_shared` IS LENT BY THE CALLER because the key set is a MODULE-level fact
330
+ (`product_data.SHARED_KEYS()`), and `core` never imports up. Absent, it is derived from
331
+ `shared_overlay.fields(table_key)`, which is right for an overlay topic and blind to a
332
+ contract-declared one.
333
+
334
+ **owner** β€” the registry's owner, falling back to the definition's `createdBy` when the
335
+ registry has no record. ⚠ THE FALLBACK IS NOT BELT-AND-BRACES: the create door writes the
336
+ definition FIRST and claims the grant SECOND, on purpose, so the window where a column exists
337
+ unclaimed is real. `field_grant_hidden` already relies on exactly this fallback to stop a
338
+ creator being walled out of the column they just made.
339
+ """
340
+ field = field if isinstance(field, dict) else {}
341
+ key = str(field.get("key") or "").strip()
342
+ me = _uname(viewer)
343
+
344
+ origin = field_origin(field)
345
+ record = grants if isinstance(grants, dict) else _grant_record(
346
+ key, table_key=table_key, grant_topic=grant_topic, st=st)
347
+ owner = _uname(record.get("owner")) or None
348
+ entries = [e for e in (record.get("entries") or ()) if isinstance(e, dict)]
349
+
350
+ if values_shared is None:
351
+ values_shared = _shared_value_keys(table_key, st=st)
352
+ values = "shared" if key and key in {str(k) for k in values_shared} else "personal"
353
+
354
+ if _governed(field):
355
+ audience = _AUDIENCE_FROM_EDIT.get(
356
+ permissions_from_grants(entries).get("edit"), "private")
357
+ elif values == "shared" or origin == "preset":
358
+ # Ungoverned AND tenant-wide: the database's holders all hold this column. `origin` covers
359
+ # a contract-declared column whose VALUES are per-user (a canonical Odoo column); the
360
+ # `values` leg covers a shared-stratum column the contract never named.
361
+ audience = "everyone"
362
+ else:
363
+ audience = "private"
364
+
365
+ shared_by = None
366
+ if me and me != owner:
367
+ mine = next((e for e in entries if _uname(e.get("user")) == me), None)
368
+ room = next((e for e in entries if _uname(e.get("user")) == shares.EVERYONE), None)
369
+ entry = mine if mine is not None else room
370
+ if entry is not None:
371
+ shared_by = _uname(entry.get("by")) or owner
372
+ if shared_by == me:
373
+ shared_by = None
374
+
375
+ return {"origin": origin, "audience": audience, "sharedBy": shared_by,
376
+ "values": values, "owner": owner or _uname(field.get("createdBy")) or None}
377
+
378
+
379
+ def field_classes(fields, viewer, *, table_key=None, grant_topic=None, values_shared=None,
380
+ st=None):
381
+ """`{field_key: <C1 bag>}` for a whole field list, on ONE registry read. See `grant_records`.
382
+
383
+ ⚠ EVERY COLUMN GETS A BAG, INCLUDING ONE THE REGISTRY HAS NEVER HEARD OF: the missing record is
384
+ substituted explicitly rather than left to `field_class` to re-read, which is what keeps the
385
+ single read single.
386
+ """
387
+ topic = str(grant_topic or table_key or "").strip()
388
+ records = grant_records(topic, st=st)
389
+ if values_shared is None:
390
+ values_shared = _shared_value_keys(table_key, st=st)
391
+ shared_keys = {str(k) for k in (values_shared or ())}
392
+ out = {}
393
+ for field in (fields or ()):
394
+ if not isinstance(field, dict):
395
+ continue
396
+ key = str(field.get("key") or "").strip()
397
+ if not key:
398
+ continue
399
+ out[key] = field_class(field, viewer, grants=records.get(key) or _empty_record(),
400
+ table_key=table_key, grant_topic=topic,
401
+ values_shared=shared_keys, st=st)
402
+ return out
403
+
404
+
405
  def _same_classification(left, right):
406
  """Do two canonical bags say the same thing about WHO?
407
 
 
500
  return rewritten
501
 
502
 
503
+ # ═════════ W41-T07 / OWNER INSTRUCTION 21 β€” A DELETED FIELD STAYS DELETED ═══════════════════
504
+ #
505
+ # Owner, verbatim: *"Deleting a field from Hide fields does not stick; 'August Campaign' on Odoo
506
+ # customer has reappeared three times."*
507
+ #
508
+ # β›”β›” MEASURED IN-PROCESS BEFORE ANY OF THIS WAS WRITTEN, because the scout listed the cause as a
509
+ # HYPOTHESIS and building on an unverified one is how a wave ships a correct fix for the wrong
510
+ # defect. `promote_field` (and the promotion arm of `migrate_legacy_fields` below) POPS the
511
+ # definition out of its creator's stratum, so on a PROMOTED column `table_store.delete_field` is a
512
+ # **total no-op**: the creator's `fields` map no longer holds the key, the shared bucket is a
513
+ # different document its `shared=` callback cannot reach, and every other account's fork is
514
+ # untouched. Observed: `anna['fields'] changed? False`, `shared_overlay STILL holds it? True`.
515
+ # The column the owner deleted three times was never deleted once.
516
+ #
517
+ # β›” AND `shared_overlay.drop_field` ALONE DOES NOT FIX IT, also measured: drop the shared
518
+ # definition, run ONE read, and `migrate_legacy_fields` promotes it straight back out of a fork
519
+ # sitting in another account's workspace. That is the fifth leg, and it is why the delete has to
520
+ # be a SWEEP of every stratum rather than a call to any one door.
521
+ #
522
+ # ⚠ WHICH FORKS RE-PROMOTE, measured, because the guard is untestable without it: a fork carrying
523
+ # NO permissions bag re-promotes (`stored_permissions` falls back to `collaborative`), one saying
524
+ # `collaborative` re-promotes, and one saying `personal` does not. A negative control built on a
525
+ # `personal` fork proves nothing at all: it would stay deleted with every guard disabled.
526
+
527
+ #: The tenant-wide member of a `<key>_table_workspace` document β€” `core.table_store.SHARED_KEY`.
528
+ #: ⚠ SPELLED, NOT IMPORTED, and deliberately: `table_store` imports nothing from this module and
529
+ #: an import the other way would close a cycle in `core`. `migrate_legacy_fields` has skipped this
530
+ #: member by the same literal since it was written, so the constant names what was already here.
531
+ WORKSPACE_SHARED_MEMBER = "__shared__"
532
+
533
+ #: Where a delete records itself: `__shared__.deletedFields = {field_key: <unix seconds>}`.
534
+ #:
535
+ #: ⭐⭐ THE RESIDENCY IS THE WHOLE COST ARGUMENT. `migrate_legacy_fields` ALREADY reads the
536
+ #: workspace document (`document = st.get(key)`) and a delete ALREADY writes it (the fork sweep),
537
+ #: so the tombstone is read for free on every read path and written in the SAME transaction as the
538
+ #: sweep it belongs to. One update, one flush, one failure mode β€” `table_store._update`'s own
539
+ #: argument for taking a `shared=` callback instead of making a second write.
540
+ #:
541
+ #: β›” IT IS A SIBLING OF `__shared__.fields`, NOT AN ENTRY IN IT, and that is what keeps it out of
542
+ #: everyone's way. `table_store._shared_fields` reads `__shared__['fields']`; `shared_views` reads
543
+ #: `__shared__['views']`; `assert_annotation_only` judges only the entries of `__shared__['fields']`
544
+ #: β€” so nothing that reads this member can see this book, and the C3 annotation law has nothing to
545
+ #: bite on. β›” AND IT IS NOT IN THE SHARED-OVERLAY BUCKET, which was the other candidate: that
546
+ #: document carries every shared CELL in the tenant and `store.get_projection` drops keys from
547
+ #: top-level VALUES rather than top-level keys, so reading it for a tombstone would buy a whole
548
+ #: deep copy of the cells on every read β€” the D-214 shape, paid on the hot path, for a dozen bytes.
549
+ DELETED_FIELDS_MEMBER = "deletedFields"
550
+
551
+ #: How long a tombstone suppresses the IMPLICIT legacy promotion. See `delete_field_everywhere`
552
+ #: for why a finite window is the anti-leak, not a weakness.
553
+ TOMBSTONE_TTL_S = 900
554
+
555
+ #: The book is compacted on every write, so this is a ceiling on a document nobody prunes.
556
+ MAX_TOMBSTONES = 200
557
+
558
+
559
+ def _live_tombstones(document, now=None):
560
+ """`{field_key: deleted_at}` for the deletes still inside their window, from a document the
561
+ caller ALREADY HOLDS. Costs no read, and never writes.
562
+
563
+ ⚠ EXPIRY IS READ-SIDE FILTERING, NOT A SWEEP. Retiring an expired entry would be a write on a
564
+ read path, on a tenant-wide document, triggered by opening a page β€” the exact shape
565
+ `reconcile_shared_permissions` had to argue its way past. An expired entry is INERT here and is
566
+ compacted by `_stamp_tombstone` on the next delete, which is a write somebody asked for.
567
+ """
568
+ member = (document or {}).get(WORKSPACE_SHARED_MEMBER)
569
+ book = member.get(DELETED_FIELDS_MEMBER) if isinstance(member, dict) else None
570
+ if not isinstance(book, dict):
571
+ return {}
572
+ now = int(time.time()) if now is None else int(now)
573
+ out = {}
574
+ for field_key, at in book.items():
575
+ # `bool` IS an `int` in Python, so it is excluded by name: `{'k': True}` would otherwise
576
+ # read as "deleted at epoch 1" and be permanently expired, i.e. a silently absent guard.
577
+ if isinstance(at, bool) or not isinstance(at, (int, float)):
578
+ continue
579
+ if now - int(at) < TOMBSTONE_TTL_S:
580
+ out[str(field_key)] = int(at)
581
+ return out
582
+
583
+
584
+ def _stamp_tombstone(shared_member, field_key, now):
585
+ """Record one delete in the `__shared__` member being written. Compacts as it goes.
586
+
587
+ ⚠ A SEPARATE MODULE-LEVEL FUNCTION RATHER THAN A CLOSURE, so the fifth-leg guard can be
588
+ DISABLED for a negative control without editing the delete. A guard nothing can turn off is a
589
+ guard nobody can prove is doing the work.
590
+ """
591
+ book = shared_member.get(DELETED_FIELDS_MEMBER)
592
+ book = book if isinstance(book, dict) else {}
593
+ fresh = {str(k): int(v) for k, v in book.items()
594
+ if not isinstance(v, bool) and isinstance(v, (int, float))
595
+ and now - int(v) < TOMBSTONE_TTL_S}
596
+ fresh[str(field_key)] = int(now)
597
+ if len(fresh) > MAX_TOMBSTONES:
598
+ for stale in sorted(fresh, key=lambda k: fresh[k])[:len(fresh) - MAX_TOMBSTONES]:
599
+ fresh.pop(stale, None)
600
+ shared_member[DELETED_FIELDS_MEMBER] = fresh
601
+ return fresh
602
+
603
+
604
+ def _retire_tombstone(document, field_key):
605
+ """Forget one delete, because somebody has explicitly said the column should exist again."""
606
+ member = (document or {}).get(WORKSPACE_SHARED_MEMBER)
607
+ book = member.get(DELETED_FIELDS_MEMBER) if isinstance(member, dict) else None
608
+ if isinstance(book, dict):
609
+ book.pop(str(field_key), None)
610
+ return document
611
+
612
+
613
+ def _purge_forks(document, field_key):
614
+ """Drop one column's DEFINITION and VALUES from EVERY account's stratum. Returns the owners hit.
615
+
616
+ β›”β›” EVERY ACCOUNT, WHICH IS THE ONE THING A PER-USER DELETE CANNOT DO AND THE REASON THIS
617
+ EXISTS. `table_store.delete_field(username, key)` reaches exactly one member of this document.
618
+ A fork left in any other member is re-promoted into the shared stratum by
619
+ `migrate_legacy_fields` on the very next read, and the column is back β€” measured, not reasoned.
620
+ ⚠ Deleting a COLUMN is already a tenant-wide act (`table_store.delete_field`'s own note makes
621
+ that argument about the tenant-wide summary), so reaching across accounts here is the same
622
+ act, not a wider one. The RIGHT to do it was answered before this function was reached.
623
+
624
+ ⚠ MODULE-LEVEL AND NOT A CLOSURE, for `_stamp_tombstone`'s reason: the negative control has to
625
+ be able to switch this off and watch the column come back.
626
+ """
627
+ key = str(field_key or "")
628
+ owners = []
629
+ for owner, workspace in list((document or {}).items()):
630
+ if owner == WORKSPACE_SHARED_MEMBER or not isinstance(workspace, dict):
631
+ continue
632
+ touched = False
633
+ fields = workspace.get("fields")
634
+ if isinstance(fields, dict) and fields.pop(key, None) is not None:
635
+ touched = True
636
+ overlays = workspace.get("overlays")
637
+ if isinstance(overlays, dict):
638
+ for values in overlays.values():
639
+ if isinstance(values, dict) and values.pop(key, None) is not None:
640
+ touched = True
641
+ if touched:
642
+ owners.append(str(owner))
643
+ return owners
644
+
645
+
646
  def _field_is_migratable(field):
647
  return (isinstance(field, dict) and field.get("custom") is True
648
  and field.get("source") == "overlay")
 
659
  grant_key = str(grant_topic or key).strip()
660
  shared_key = str(shared_key or key).strip()
661
  if not key or st is None:
662
+ return {"promoted": 0, "normalized": 0, "reclassified": 0,
663
+ "suppressed": 0, "purged": 0}
664
  # ⭐⭐ W40-T03 β€” RECONCILE THE SHARED STRATUM'S CLASSIFICATION FIRST, IN ITS OWN SWALLOWING
665
  # GUARD. This is the ONE seam that reaches every shared-field surface: all three readers
666
  # (`routes_customers.shared_fields`, `routes_tables._ut_shared_fields`,
 
685
  try:
686
  document = st.get(key) or {}
687
  except Exception:
688
+ return {"promoted": 0, "normalized": 0, "reclassified": reclassified,
689
+ "suppressed": 0, "purged": 0}
690
  if not isinstance(document, dict):
691
+ return {"promoted": 0, "normalized": 0, "reclassified": reclassified,
692
+ "suppressed": 0, "purged": 0}
693
 
694
  promoted = 0
695
  normalized = 0
696
+ suppressed = 0
697
  remove = {}
698
+ # ⭐⭐ W41-T07 β€” THE FIFTH LEG, READ FOR FREE OFF THE DOCUMENT ALREADY IN HAND. A key deleted
699
+ # inside the window is not promotable, no matter which account still holds a fork of it.
700
+ tombstoned = _live_tombstones(document)
701
  for owner, workspace in document.items():
702
+ if owner == WORKSPACE_SHARED_MEMBER or not isinstance(workspace, dict):
703
  continue
704
  fields = workspace.get("fields") or {}
705
  if not isinstance(fields, dict):
 
707
  for field_key, original in list(fields.items()):
708
  if not _field_is_migratable(original):
709
  continue
710
+ if str(field_key) in tombstoned:
711
+ # β›” SKIP, NEVER SWEEP. The delete already took every fork it could see; anything
712
+ # here now either arrived after it or survived a partial failure, and destroying
713
+ # somebody's column to tidy up is the one mistake with no undo β€” this store keeps
714
+ # no history ([[a-cleanup-deletes-what-it-did-not-create]]). Suppressing the
715
+ # PROMOTION is enough: the fork stays private and visible to its holder, and the
716
+ # tenant-wide resurrection the owner reported three times does not happen.
717
+ suppressed += 1
718
+ continue
719
  permissions = stored_permissions(original, fallback="collaborative",
720
  known_users=known_users)
721
  if permissions["edit"] == "personal":
 
773
  values.pop(field_key, None)
774
  return data
775
  st.update(key, _remove, flush="sync")
776
+
777
+ # ⭐⭐ W41-T07 β€” AND A SHARED DEFINITION THAT CAME BACK INSIDE THE WINDOW IS DROPPED AGAIN.
778
+ #
779
+ # β›” THIS IS THE LEG THAT MAKES A PARTIAL DELETE HEAL FORWARD, and it is why
780
+ # `delete_field_everywhere` sweeps the forks BEFORE it drops the shared definition. If the
781
+ # sweep and the tombstone land and the `shared_overlay.drop_field` after them does not, the
782
+ # definition would otherwise survive and be re-merged onto every grid forever. With this leg
783
+ # the next read finishes the delete instead. It also closes the concurrency window: a read
784
+ # that snapshotted the forks before the sweep can still write the promotion after it, and
785
+ # nothing else would ever take that resurrection away.
786
+ #
787
+ # ⚠ IT COSTS A READ OF THE SHARED BUCKET, AND ONLY WHEN A TOMBSTONE IS LIVE. Steady state is
788
+ # an empty book and this whole block is skipped, so the hot read path is unchanged; the cost
789
+ # is bounded to the `TOMBSTONE_TTL_S` window after somebody deletes a column.
790
+ purged = 0
791
+ if tombstoned:
792
+ try:
793
+ live = shared_overlay.fields(shared_key, st=st)
794
+ except Exception: # noqa: BLE001
795
+ live = {}
796
+ for dead in tombstoned:
797
+ if dead not in live:
798
+ continue
799
+ try:
800
+ if shared_overlay.drop_field(shared_key, dead, st=st):
801
+ purged += 1
802
+ except Exception: # noqa: BLE001
803
+ continue
804
+
805
  # β›” `promoted` AND `normalized` KEEP THEIR NAMES AND THEIR MEANINGS.
806
  # `aios-web/api/verify_field_permissions.py` asserts `result["promoted"] == 1`; renaming or
807
  # folding either into the new count would turn a green gate red for a change it never made.
808
+ # ⚠ `suppressed` and `purged` are ADDED beside them for the same reason: a caller that reads
809
+ # the old two keeps reading exactly what it read before.
810
+ return {"promoted": promoted, "normalized": normalized, "reclassified": reclassified,
811
+ "suppressed": suppressed, "purged": purged}
812
 
813
 
814
  def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None):
 
869
  for values in (ws.get("overlays") or {}).values():
870
  if isinstance(values, dict):
871
  values.pop(key, None)
872
+ # ⭐⭐ W41-T07 β€” AN EXPLICIT SHARE RETIRES THE TOMBSTONE, AND THIS LINE IS THE WHOLE
873
+ # ANTI-LEAK ARGUMENT FOR HAVING ONE. `delete_field_everywhere` suppresses the IMPLICIT
874
+ # legacy promotion of a deleted key; this function is the EXPLICIT one, reached from
875
+ # `routes_shares.put_share`, i.e. a person saying in as many words that this column is
876
+ # meant to exist and be shared. There is nothing left to be careful about after that, so
877
+ # the suppression ends immediately rather than waiting out `TOMBSTONE_TTL_S`.
878
+ _retire_tombstone(data, key)
879
  return data
880
  st.update(workspace_key, _remove, flush="sync")
881
  return shared
 
909
  shared_overlay.drop_field(shared_key, key, st=st)
910
  shares.drop_objects([("field", shares.field_oid(grant_topic, key))], st=st)
911
  return personal
912
+
913
+
914
+ def delete_field_everywhere(workspace_key, shared_key, grant_topic, field_key, st=None):
915
+ """⭐⭐ W41-T07 / OWNER INSTRUCTION 21 β€” **DELETE ONE COLUMN FROM EVERY STRATUM THAT HOLDS IT.**
916
+
917
+ Owner: *"Deleting a field from Hide fields does not stick; 'August Campaign' on Odoo customer
918
+ has reappeared three times."* It reappeared because no door had ever deleted it. Returns a
919
+ per-leg report; it decides no rights and refuses nobody.
920
+
921
+ β›”β›” THE RIGHT IS ANSWERED BEFORE THIS IS REACHED, AND IT IS NOT ANSWERED AGAIN HERE.
922
+ `user_tables.may_delete_field` / `delete_field_refusal` are C2's one pair, and C2's last
923
+ sentence is *"No surface re-implements either test"* β€” a module that both decided and executed
924
+ would be the second implementation. This is the EXECUTION half, on purpose.
925
+
926
+ **THE FIVE LEGS, and which line closes each:**
927
+
928
+ 1. the deleter's own per-user stratum -> `_purge_forks` (their member of the document)
929
+ 2. `shared_overlay`'s bucket -> `shared_overlay.drop_field`, the RIGHT document
930
+ 3. the re-merge onto the next fetch -> leg 2; `_merge_shared_fields` and
931
+ `_ut_shared_fields` project what is in the bucket,
932
+ so a definition that is gone cannot be re-injected
933
+ 4. the grant record -> `shares.drop_objects`
934
+ 5. every OTHER account's fork -> `_purge_forks` (all members) + `_stamp_tombstone`
935
+
936
+ β›” LEG 5 IS THE ONE THAT BITES, AND `drop_field` ALONE IS NOT ENOUGH β€” measured in-process
937
+ before this was written. `migrate_legacy_fields` runs on EVERY read and walks EVERY member of
938
+ the workspace document, so one fork left in one colleague's stratum re-promotes the column into
939
+ the shared bucket on the next page load. Deleting the shared definition without sweeping the
940
+ forks buys exactly one render.
941
+
942
+ β›” THE ORDER IS SWEEP-THEN-DROP AND IT IS LOAD-BEARING, not stylistic. The sweep and the
943
+ tombstone land in ONE transaction on the workspace document; only then is the shared definition
944
+ dropped. So a failure of the second half leaves a live tombstone, and the next read's
945
+ `migrate_legacy_fields` finishes the delete instead of re-merging the survivor forever. The
946
+ other order (drop first, sweep second) fails the opposite way: the definition is gone, the
947
+ forks are not, and the very next read puts it back with nothing recording that anyone objected.
948
+
949
+ ⚠ THE `__shared__.fields` COLUMN SUMMARY GOES TOO, for `table_store.delete_field`'s own stated
950
+ reason: *"an orphan `agg` under a deleted key is state nobody can see and nobody can clear, and
951
+ it would attach itself to the next column that happens to take the key back."*
952
+ """
953
+ key = str(field_key or "").strip()
954
+ ws_key = str(workspace_key or "").strip()
955
+ bucket = str(shared_key or "").strip()
956
+ topic = str(grant_topic or bucket or "").strip()
957
+ report = {"key": key, "personal": 0, "owners": [], "shared": False,
958
+ "grants": False, "tombstoned": False}
959
+ if not key or st is None:
960
+ return report
961
+ now = int(time.time())
962
+
963
+ if ws_key:
964
+ hit = {"owners": []}
965
+
966
+ def _purge(data):
967
+ data = data if isinstance(data, dict) else {}
968
+ hit["owners"] = _purge_forks(data, key)
969
+ member = data.setdefault(WORKSPACE_SHARED_MEMBER, {})
970
+ if isinstance(member, dict):
971
+ annotations = member.get("fields")
972
+ if isinstance(annotations, dict):
973
+ annotations.pop(key, None)
974
+ _stamp_tombstone(member, key, now)
975
+ return data
976
+
977
+ # β›” `flush='sync'`, matching every other STRUCTURAL write in this module. A delete the
978
+ # user has confirmed must not be the thing that is still coalescing when the process dies;
979
+ # `shared_overlay._write`'s own note draws the same line ("a lost column definition is a
980
+ # worse failure than a lost keystroke") and a lost DELETION is worse again, because the
981
+ # column comes back and the person deletes it a fourth time.
982
+ st.update(ws_key, _purge, flush="sync")
983
+ report["owners"] = list(hit["owners"])
984
+ report["personal"] = len(hit["owners"])
985
+ report["tombstoned"] = True
986
+
987
+ if bucket:
988
+ report["shared"] = bool(shared_overlay.drop_field(bucket, key, st=st))
989
+
990
+ if topic:
991
+ # ⚠ GUARDED, AND THE TOLERANCE IS `routes_tables.delete_shared_field`'s ALREADY: the column
992
+ # and its values are gone by now, so a registry hiccup must not turn a completed delete
993
+ # into a 500 that invites the client to repeat it. A surviving grant record is inert
994
+ # (`shares.role_for` resolves against an object that no longer exists) and is overwritten
995
+ # by the next `set_grants` on the same id.
996
+ try:
997
+ shares.drop_objects([("field", shares.field_oid(topic, key))], st=st)
998
+ report["grants"] = True
999
+ except Exception: # noqa: BLE001
1000
+ report["grants"] = False
1001
+ return report
platform/core/grid_events.py CHANGED
@@ -626,6 +626,122 @@ def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
626
  uname, {'views': {}, 'fields': {}, 'overlays': {}})
627
 
628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  def _granted_views(ctx, uname):
630
  """{view_id: STAMPED view} for every view GRANTED to `uname` by name (wave 21, item 9, C1).
631
 
@@ -710,6 +826,14 @@ def _granted_views(ctx, uname):
710
  # renders in the same "Shared with me" group the view leg feeds. Stamps live on the
711
  # PROJECTION only and are never written back, exactly as above.
712
  v['sharedFolder'] = str(folder.get('name') or '')[:80]
 
 
 
 
 
 
 
 
713
  out[str(vid)] = v
714
  return out
715
 
@@ -852,14 +976,23 @@ def _known_usernames():
852
  #: `code` is a stable machine token (the client may branch on it); `message` is the sentence a
853
  #: person reads. ⚠ The message must name what the CALLER can change β€” a refusal a user cannot act
854
  #: on is only marginally better than silence.
855
- def _refuse(ctx, code, message, key=''):
 
 
 
 
 
 
 
 
 
856
  """Record a NAMED refusal on the result and demand a repaint. Always returns True."""
857
  try:
858
  out = getattr(ctx, 'out', None)
859
  if out is not None and hasattr(out, 'refusals'):
860
  if len(out.refusals) < 24: # one per event in the window; never unbounded
861
  out.refusals.append({
862
- 'event': 'field_upsert',
863
  'key': str(key or '')[:80],
864
  'code': str(code)[:40],
865
  'message': str(message)[:300],
@@ -872,6 +1005,142 @@ def _refuse(ctx, code, message, key=''):
872
  return True
873
 
874
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  def handle_events(events, ctx):
876
  """The event LOG entry point β†’ one EventResult. The `[-24:]` window lives here.
877
 
@@ -1146,6 +1415,8 @@ def handle_one(event, ctx):
1146
  'widths': widths,
1147
  'memberPids': [pid for pid in list(cfg.get('memberPids') or [])
1148
  if isinstance(pid, int) and pid in allowed_pids],
 
 
1149
  }
1150
  # Wave-6 item 10: a non-grid display mode rides the view config, validated
1151
  # structurally (mode whitelist, refs must be this table's fields).
@@ -1885,11 +2156,22 @@ def handle_one(event, ctx):
1885
  # events touch ONE home β€” the table workspace's `folders` + `itemFolders` β€” so a cohort
1886
  # can be filed without the cohort STORE learning about folders at all.
1887
  import aios_grid as _agf
 
 
 
 
 
1888
  if not _store_of(ctx).available():
1889
- return False
 
 
 
1890
  surface = str(event.get('surface') or '')
1891
  if surface not in _agf.FOLDER_SURFACES:
1892
- return False
 
 
 
1893
  # The ids this user may actually file: their own views, their own cohorts. Fail-closed β€”
1894
  # an item_move naming somebody else's cohort is dropped by clean_item_folders anyway,
1895
  # but refusing here means we never write it in the first place.
@@ -1898,6 +2180,18 @@ def handle_one(event, ctx):
1898
  return set((cur_ws.get('views') or {}))
1899
  return set(_cohorts(ctx).all_for(uname))
1900
 
 
 
 
 
 
 
 
 
 
 
 
 
1901
  def _mutate(cur_ws):
1902
  folders = _agf.clean_folders(cur_ws.get('folders'))
1903
  placed = dict(cur_ws.get('itemFolders') or {})
@@ -1908,24 +2202,76 @@ def handle_one(event, ctx):
1908
  fid = str(event.get('folderId') or '').strip()[:80]
1909
  if kind == 'folder_create':
1910
  name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME]
1911
- if not fid or not name or len(lst) >= _agf.MAX_FOLDERS:
1912
- return None
 
 
 
 
 
 
 
 
 
1913
  if any(f['id'] == fid for f in lst):
1914
- return None
 
 
1915
  row = {'id': fid, 'name': name, 'order': len(lst)}
1916
  icon = _agf.clean_folder_icon(event.get('icon')) # wave-9 I15 (C5)
1917
  if icon:
1918
  row['icon'] = icon
 
 
 
 
 
 
 
 
 
 
 
 
1919
  lst.append(row)
1920
  elif kind == 'folder_rename':
1921
  name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME]
1922
  hit = next((f for f in lst if f['id'] == fid), None)
1923
- if not hit or not name:
1924
- return None
1925
- hit['name'] = name
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1926
  # wave-9 I15 (C5): the rename pane is also where the icon is changed. An event
1927
  # that carries NO icon key leaves the existing one alone (a rename must not
1928
  # silently strip a chosen icon); an explicit null clears it back to default.
 
 
 
 
 
 
1929
  if 'icon' in event:
1930
  icon = _agf.clean_folder_icon(event.get('icon'))
1931
  if icon:
@@ -1934,7 +2280,9 @@ def handle_one(event, ctx):
1934
  hit.pop('icon', None)
1935
  elif kind == 'folder_delete':
1936
  if not any(f['id'] == fid for f in lst):
1937
- return None
 
 
1938
  lst = [f for f in lst if f['id'] != fid]
1939
  # CONTENTS MOVE TO ROOT, never delete. Dropping the placements is exactly that:
1940
  # an item with no placement renders at the top level.
@@ -1958,7 +2306,9 @@ def handle_one(event, ctx):
1958
  # folder it merely failed to mention would be a data loss dressed as a sort.
1959
  order = [str(i).strip()[:80] for i in (event.get('order') or [])]
1960
  if not order:
1961
- return None
 
 
1962
  known = {f['id']: f for f in lst}
1963
  seen, ranked = set(), []
1964
  for fid_ in order:
@@ -1967,17 +2317,34 @@ def handle_one(event, ctx):
1967
  ranked.append(known[fid_])
1968
  ranked.extend(f for f in lst if f['id'] not in seen)
1969
  if len(ranked) != len(lst): # cannot happen; refuse rather than truncate
1970
- return None
 
 
 
1971
  for i, f in enumerate(ranked):
1972
  f['order'] = i
1973
  lst = ranked
1974
  elif kind == 'folder_duplicate':
1975
  new_id = str(event.get('newId') or '').strip()[:80]
1976
  src = next((f for f in lst if f['id'] == fid), None)
1977
- if not src or not new_id or len(lst) >= _agf.MAX_FOLDERS:
1978
- return None
 
 
 
 
 
 
 
 
 
 
 
1979
  if any(f['id'] == new_id for f in lst):
1980
- return None
 
 
 
1981
  lst.append({'id': new_id, 'name': f"{src['name']} copy"[:_agf.MAX_FOLDER_NAME],
1982
  'order': len(lst)})
1983
  # ⚠ The folder's CONTENTS are duplicated by the client emitting the ordinary
@@ -1988,8 +2355,19 @@ def handle_one(event, ctx):
1988
  else: # item_move
1989
  item_id = str(event.get('itemId') or '').strip()[:120]
1990
  target = event.get('folderId')
1991
- if not item_id or item_id not in _own_ids(surface, cur_ws):
1992
- return None
 
 
 
 
 
 
 
 
 
 
 
1993
  cur = dict(placed.get(surface) or {})
1994
  if target is None:
1995
  # ⚠ "UNFILE" β€” the client says nothing about where it went. Popping is right
@@ -2015,7 +2393,10 @@ def handle_one(event, ctx):
2015
  else:
2016
  tid = str(target)[:80]
2017
  if not any(f['id'] == tid for f in lst):
2018
- return None
 
 
 
2019
  cur[item_id] = tid
2020
  placed[surface] = cur
2021
  folders[surface] = lst
@@ -2031,8 +2412,48 @@ def handle_one(event, ctx):
2031
  # change inside a pure move.
2032
  _res = _mutate(table_workspace(ctx, consume_corrections=False))
2033
  if _res is None:
2034
- return False # refused: nothing is written
2035
- _tops(ctx).save_folders(uname, _res[0], _res[1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2036
  # True: the sidebars render from server state (which folder holds what), not from local
2037
  # optimism β€” the same contract the cohort panel's membership edits ride.
2038
  return True
@@ -2092,8 +2513,79 @@ def handle_one(event, ctx):
2092
  import aios_grid as _ag3
2093
  if not (key.startswith('custom_') or key.startswith(_ag3.MEASURE_FIELD_PREFIX)):
2094
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2095
  if _store_of(ctx).available():
2096
- _tops(ctx).delete_field(uname, key)
 
 
 
 
 
 
 
 
 
 
2097
  else:
2098
  _session_ready()
2099
  ws['fields'].pop(key, None)
 
626
  uname, {'views': {}, 'fields': {}, 'overlays': {}})
627
 
628
 
629
+ #: ⭐⭐ W41-T14 Β· OWNER INSTRUCTION 11 Β· CONTRACT C9 β€” THE GRANTED FOLDER'S OWN RECORD.
630
+ #:
631
+ #: β›” THE ICON WAS NEVER LOST IN TRANSIT; IT WAS NEVER SENT. `_granted_views` stamped
632
+ #: `v['sharedFolder'] = <name>` and nothing else, so `folders.ts::groupByFolder` RECONSTRUCTS
633
+ #: `{id: sharedFolderGroupId(owner, name), name}` for the receiver's rail β€” a folder assembled out
634
+ #: of two strings, with no icon on it β€” and `icons.tsx::FolderMark` then falls back to its default
635
+ #: shape and tone. That fallback is exactly what the owner is reporting. The record was always in
636
+ #: hand: `table_store.find_folder` returns `dict(hit)`, the whole stored row, icon included.
637
+ #:
638
+ #: ⚠ ADDITIVE, AND THAT IS THE CONTRACT DECISION. `sharedFolder` KEEPS its string type and its
639
+ #: meaning: `aios-web/web/verify_folders.py` asserts `typeof v.sharedFolder === "string"` and
640
+ #: `api/verify_api.py` reads the same key, so retyping it to a record would turn two gates red and
641
+ #: blank the receiver's grouping until the client half (W41-T35) lands. C8's rule one bullet above
642
+ #: C9 β€” *absent keys mean "not built yet", never "false"* β€” is what lets the two halves land in
643
+ #: either order, and [[two-lanes-one-contract-dead-feature]] is the scar it exists to prevent.
644
+ #:
645
+ #: β›” THE CLIENT MUST NOT USE THIS `id` AS THE GROUP KEY. `folders.ts::SHARED_FOLDER_PREFIX` says
646
+ #: it in its own words β€” *"NOTHING MAY EVER WRITE ONE… no `order`, no icon, no row in `folders`"* β€”
647
+ #: and every rail affordance gates on `isSyntheticFolderId`. Swapping the synthetic group id for
648
+ #: this real one would let the receiver emit `item_move` / `folder_rename` against a folder that
649
+ #: lives in somebody ELSE's store. `id` is here to identify the share, never to key the group.
650
+ #:
651
+ #: β›” `parent` IS DELIBERATELY NOT FORWARDED, which is why C9 spells it `parent?`. A parent id
652
+ #: names a folder in the OWNER's stratum that was NOT granted: the receiver cannot resolve it,
653
+ #: must not store it, and would gain only a pointer into a tree they cannot see. One folder is
654
+ #: shared, so one folder is delivered, flat.
655
+ #:
656
+ #: ⚠ BUILT MEMBER BY MEMBER, never `dict(folder)`. This row comes out of another account's
657
+ #: workspace; forwarding it whole would carry whatever else happens to be sitting on it across a
658
+ #: user boundary. The icon goes back out through `aios_grid.clean_folder_icon` β€” the ONE server
659
+ #: whitelist (12 shapes, 5 tones, and the legacy `grey` tone canonicalised) β€” so a receiver can
660
+ #: never be handed a shape no client knows how to draw.
661
+ #: ⭐⭐ W41-T20's DONE-WHEN CLAUSE 5, LANDED BY THE INTEGRATOR. Lane B proved BOTH ways that a
662
+ #: saved pivot round-trips to `None` without this, and correctly refused to write it: this file
663
+ #: is lane A's fence. The clause named a file its own ticket did not own, so it was
664
+ #: undischargeable by construction. [[two-lanes-one-contract-dead-feature]]
665
+ #:
666
+ #: β›” THE KEY IS WRITTEN UNCONDITIONALLY, like `important` above and unlike `cohortLock`. The
667
+ #: client CLEARS a pivot by omitting the key (`CustomerGrid.tsx:7195`
668
+ #: `const { secondaryView: _cleared, ...rest } = config`), so both spellings happen to clear
669
+ #: correctly today -- but this dict is REBUILT FROM THE ALLOWLIST on every autosave, and a
670
+ #: conditional key is one client change away from a pivot you can set and never clear. Lane A's
671
+ #: own note at :1331-1334 argues this for `important`; every word of it applies here.
672
+ #:
673
+ #: β›” FAIL-CLOSED ON `database` AND `viaField`, matching `types.ts::cleanSecondaryView` exactly:
674
+ #: a pivot missing either cannot be served by anything, so it stores as ABSENT (the ordinary view
675
+ #: of this table) rather than as a pivot that renders an empty grid reading as
676
+ #: "no related records" -- the distinction C10's `refusal` key exists to preserve.
677
+ #:
678
+ #: ⚠ THE NESTED `config` IS BOUNDED, NOT KEY-VALIDATED, AND THAT IS DELIBERATE. Its filters name
679
+ #: columns of the TARGET database, whose field list is not resolvable from this door -- `cfg`'s
680
+ #: `valid_keys` belong to the SOURCE. `routes_grid.py::pivot` already takes `filters` from the
681
+ #: request body as a raw list (`:1201`) and owns their semantics, so validating here would either
682
+ #: duplicate that or reject a legal tree. What this does own is SHAPE and SIZE, so nothing
683
+ #: unbounded reaches the store.
684
+ _SECONDARY_WINDOWS = ('all', 'last12m', 'ytd') # routes_grid.PIVOT_WINDOWS' key set
685
+
686
+
687
+ def _clean_secondary_view(raw):
688
+ """C6 -- the stored shape of a Relational pivot, or None. Mirrors `types.ts::cleanSecondaryView`."""
689
+ if not isinstance(raw, dict):
690
+ return None
691
+ database = str(raw.get('database') or '').strip()[:80]
692
+ via_field = str(raw.get('viaField') or '').strip()[:120]
693
+ if not database or not via_field:
694
+ return None # fail-closed: see the note above
695
+ window = raw.get('window')
696
+ cfg = raw.get('config') if isinstance(raw.get('config'), dict) else {}
697
+ height = cfg.get('rowHeightMode')
698
+ def _keys(value, cap):
699
+ return [str(k)[:120] for k in list(value or [])[:cap] if isinstance(k, str)] \
700
+ if isinstance(value, list) else []
701
+ widths = {}
702
+ if isinstance(cfg.get('widths'), dict):
703
+ for k, v in list(cfg['widths'].items())[:400]:
704
+ try:
705
+ widths[str(k)[:120]] = int(v)
706
+ except (TypeError, ValueError):
707
+ continue
708
+ return {
709
+ 'database': database,
710
+ 'viaField': via_field,
711
+ 'window': window if window in _SECONDARY_WINDOWS else 'all',
712
+ 'config': {
713
+ 'filters': list(cfg.get('filters') or [])[:200] if isinstance(cfg.get('filters'), list) else [],
714
+ 'filterConj': 'or' if cfg.get('filterConj') == 'or' else 'and',
715
+ 'sorts': list(cfg.get('sorts') or [])[:20] if isinstance(cfg.get('sorts'), list) else [],
716
+ 'groupBy': str(cfg['groupBy'])[:120] if isinstance(cfg.get('groupBy'), str) and cfg['groupBy'] else None,
717
+ 'colorBy': str(cfg['colorBy'])[:120] if isinstance(cfg.get('colorBy'), str) and cfg['colorBy'] else None,
718
+ 'rowHeightMode': height if height in ('medium', 'tall') else 'short',
719
+ 'order': _keys(cfg.get('order'), 400),
720
+ 'visible': _keys(cfg.get('visible'), 400),
721
+ 'widths': widths,
722
+ },
723
+ }
724
+
725
+
726
+ def _shared_folder_record(fid, folder):
727
+ """C9's wire record for a folder granted to somebody else: `{id, name, order?, icon?}`.
728
+
729
+ Absent `icon` means the owner chose none and the client draws its default mark, which is the
730
+ same thing absence has always meant on a stored folder row (`aios_grid.clean_folders`).
731
+ """
732
+ import aios_grid as _agf
733
+ src = folder if isinstance(folder, dict) else {}
734
+ rec = {'id': str(fid or '')[:80], 'name': str(src.get('name') or '')[:80]}
735
+ try:
736
+ rec['order'] = int(src.get('order'))
737
+ except (TypeError, ValueError):
738
+ pass # the owner's rail position rides when it is one, never invented
739
+ icon = _agf.clean_folder_icon(src.get('icon'))
740
+ if icon:
741
+ rec['icon'] = icon
742
+ return rec
743
+
744
+
745
  def _granted_views(ctx, uname):
746
  """{view_id: STAMPED view} for every view GRANTED to `uname` by name (wave 21, item 9, C1).
747
 
 
826
  # renders in the same "Shared with me" group the view leg feeds. Stamps live on the
827
  # PROJECTION only and are never written back, exactly as above.
828
  v['sharedFolder'] = str(folder.get('name') or '')[:80]
829
+ # ⭐⭐ W41-T14 (C9) β€” AND NOW THE WHOLE RECORD BESIDE IT, which is the line that
830
+ # carries the icon. See `_shared_folder_record`: `sharedFolder` is untouched so the
831
+ # existing readers and their two gates keep working, and `sharedFolderRecord` is the
832
+ # key W41-T35 reads the shape and tone off. Same projection-only rule as every stamp
833
+ # above β€” `views_from_defs` copies a saved view with `dict(saved)` and `scope_view`
834
+ # with `dict(view)`, so an unknown key rides all the way to the wire untouched, and
835
+ # the `view_upsert` save path builds from a fixed whitelist so it can never be stored.
836
+ v['sharedFolderRecord'] = _shared_folder_record(fid, folder)
837
  out[str(vid)] = v
838
  return out
839
 
 
976
  #: `code` is a stable machine token (the client may branch on it); `message` is the sentence a
977
  #: person reads. ⚠ The message must name what the CALLER can change β€” a refusal a user cannot act
978
  #: on is only marginally better than silence.
979
+ #:
980
+ #: β›”β›” W41-T13 β€” `event` USED TO BE THE HARDCODED LITERAL `'field_upsert'`, and that was fine only
981
+ #: while this seam had exactly one caller. It now has two, so a folder refusal filed through the
982
+ #: old body would have recorded `{'event': 'field_upsert'}` on an `item_move` β€” a disclosure
983
+ #: channel built to end unfalsifiable theories, telling the next reader the wrong thing about
984
+ #: which door refused. KEYWORD-ONLY WITH THE OLD LITERAL AS THE DEFAULT, deliberately: every
985
+ #: existing `field_upsert` call site is byte-unchanged, and W41-T02's own refusals keep reporting
986
+ #: exactly what they reported before. The default is load-bearing, not decorative β€” it is what
987
+ #: makes this a widening rather than a rewrite.
988
+ def _refuse(ctx, code, message, key='', *, event='field_upsert'):
989
  """Record a NAMED refusal on the result and demand a repaint. Always returns True."""
990
  try:
991
  out = getattr(ctx, 'out', None)
992
  if out is not None and hasattr(out, 'refusals'):
993
  if len(out.refusals) < 24: # one per event in the window; never unbounded
994
  out.refusals.append({
995
+ 'event': str(event or 'field_upsert')[:40],
996
  'key': str(key or '')[:80],
997
  'code': str(code)[:40],
998
  'message': str(message)[:300],
 
1005
  return True
1006
 
1007
 
1008
+ #: ⭐⭐ W41-T13 β€” THE CHANNEL THAT ACTUALLY REACHES A SCREEN, and the two are not the same one.
1009
+ #: `EventResult.refusals` is assembled by `api/routes_grid.py` into `results[].refused` and a
1010
+ #: top-level `refusals` array β€” and MEASURED 2026-08-24, **nothing under `aios-web/web/src` reads
1011
+ #: either key**. `apiBridge.ts` (~1461) unpacks exactly `{results, doc, toast, rerender, derived}`
1012
+ #: from a 200, so `toast` is the only member of that envelope a person ever sees.
1013
+ #:
1014
+ #: So a refusal that is only recorded is still a silent 200 to the user, which is the defect this
1015
+ #: ticket names. Both are written: `refusals` is the machine record a caller branches on and the
1016
+ #: next investigation reads, `toast` is the sentence.
1017
+ #:
1018
+ #: ⚠ `toast` IS ONE SLOT, read once by `routes_grid` after its whole per-event loop, and it is
1019
+ #: SHARED with `add_to_list`'s confirmation. THE PRECEDENCE IMPLEMENTED HERE: a refusal always
1020
+ #: claims the slot, so within one window the last refusal is what is shown. THE LIMIT, stated
1021
+ #: rather than left to be discovered: an `add_to_list` that lands AFTER a refused folder event in
1022
+ #: the same 24-event window still overwrites it, because that branch assigns the slot directly.
1023
+ #: Widening `EventResult` to a message LIST is the real fix and it is not this ticket's file.
1024
+ def _folder_refuse(ctx, kind, code, message, key=''):
1025
+ """Refuse ONE folder event out loud: the machine record AND the rendered sentence."""
1026
+ try:
1027
+ out = getattr(ctx, 'out', None)
1028
+ if out is not None:
1029
+ out.toast = str(message)[:300]
1030
+ except Exception: # noqa: BLE001
1031
+ pass
1032
+ # β›” RETURNS True, LIKE EVERY OTHER REFUSAL. `handle_one`'s return value means "repaint from
1033
+ # server state"; a folder refusal that answered False would leave the browser's OPTIMISTIC
1034
+ # placement on screen with nothing stored behind it β€” which is the springback the owner is
1035
+ # reporting, kept for another two minutes instead of corrected at once.
1036
+ return _refuse(ctx, code, message, key=key, event=kind)
1037
+
1038
+
1039
+ #: ⭐ W41-T13 β€” WHICH `save_folders` REPAIRS ARE WORTH A SENTENCE. W41-T12's receipt lists every
1040
+ #: correction it made; most mean *"something you sent was wrong and we fixed it"*, which a person
1041
+ #: wants to know about because their folder tree now differs from what they dragged.
1042
+ #:
1043
+ #: β›” `parent_is_root_sentinel` IS DELIBERATELY ABSENT AND MUST STAY ABSENT. It means "you said
1044
+ #: root the other legal way" β€” both spellings are correct, nothing was lost, and rendering it as a
1045
+ #: warning would fire on ordinary use. A notice that cries wolf on the normal path trains the user
1046
+ #: to ignore the ones that matter.
1047
+ #:
1048
+ #: ⚠ THE WHOLE NOTICE MUST FIT IN 300 CHARACTERS, which is where `EventResult.toast` is sliced.
1049
+ #: All three phrases plus both joiners and the frame is the worst case, and the first draft came
1050
+ #: to 328 β€” a sentence cut off mid-word, in the one channel a person actually reads. The phrases
1051
+ #: below are short for that reason and not for style; the proof pins the bound so the next person
1052
+ #: to add one finds out here rather than on a screen.
1053
+ _FOLDER_REPAIR_COPY = {
1054
+ 'parent_missing': 'named a folder that is gone, so it moved to the top level',
1055
+ 'placement_missing_folder': 'named a folder that is gone, so it moved to the top level',
1056
+ 'item_in_two_folders': 'was in two folders at once, so the first one was kept',
1057
+ }
1058
+ #: Everything else actionable collapses to one sentence: the row could not be read at all.
1059
+ _FOLDER_REPAIR_UNREADABLE = frozenset({
1060
+ 'duplicate_folder_id', 'parent_not_an_id', 'row_not_a_folder', 'folder_without_id',
1061
+ 'surface_not_a_list', 'placements_not_a_map', 'placement_without_item',
1062
+ 'placement_not_an_id',
1063
+ })
1064
+
1065
+
1066
+ def _folder_repair_notice(repairs):
1067
+ """The sentence for a `save_folders` receipt, or None when there is nothing worth saying."""
1068
+ seen = []
1069
+ for row in (repairs or ()):
1070
+ reason = str((row or {}).get('reason') or '')
1071
+ if reason == 'parent_is_root_sentinel':
1072
+ continue # see the constant above: the normal path, not a repair
1073
+ phrase = _FOLDER_REPAIR_COPY.get(reason)
1074
+ if phrase is None and reason in _FOLDER_REPAIR_UNREADABLE:
1075
+ phrase = 'could not be read, so it was left out'
1076
+ if phrase and phrase not in seen:
1077
+ seen.append(phrase)
1078
+ if not seen:
1079
+ return None
1080
+ return ('Your folders were saved with a correction. Something in them '
1081
+ + '; something else '.join(seen)
1082
+ + '. Reload the page to see the result.')
1083
+
1084
+
1085
+ def _commit_folder_write(tops):
1086
+ """⭐⭐ W41-T13 / OWNER INSTRUCTION 10 β€” MAKE THE FOLDER WRITE THAT JUST HAPPENED DURABLE, NOW.
1087
+
1088
+ β›” THE DEFECT, MEASURED WITH TWO REAL PROCESSES AGAINST A THROWAWAY STORE (2026-08-24):
1089
+ `table_store.save_folders` commits through `TableStore._update`, which is hardcoded to
1090
+ `flush='async'`. That applies the change to `Store._cache` and returns; the hub commit is
1091
+ handed to a coalescing worker that sleeps `_FLUSH_DELAY` (2s) and then, if this key committed
1092
+ inside the last `_FLUSH_MIN_GAP` (20s), sleeps the remainder of that too. **SIX accepted
1093
+ folder writes in a row left the hub on its original commit** and a second process read an
1094
+ empty `itemFolders` every single time. Read-your-writes held inside the process, which is
1095
+ exactly why no gate in this repo could see it ([[a-guard-closed-on-one-backend-is-not-closed]]).
1096
+
1097
+ The user-visible half is the owner's report: `viewEcho.ts::ECHO_RECENT_MS` holds the
1098
+ optimistic placement for 120s and then yields to the host copy, so a write that never reached
1099
+ the hub before the container was replaced springs back at about two minutes.
1100
+
1101
+ β›” `st.flush(key)` IS THE WRONG PRIMITIVE HERE, THREE TIMES OVER: it waits out
1102
+ `_FLUSH_MIN_GAP`, so it can block a drag for ~22 seconds; `harness.runtime.TenantRuntime` (the
1103
+ handle the HTTP door actually passes) does not proxy it at all; and the gates' store doubles
1104
+ do not all carry it. `update(key, identity, flush='sync')` is on EVERY handle β€” the module,
1105
+ `Store`, `TenantRuntime`, `store_pg`, and both fakes β€” and `_update_locked`'s D-305 branch
1106
+ makes it exactly the right one: with the key dirty and owned it takes the CACHE (never
1107
+ re-downloading over the pending change), commits it through `_commit` with the parent
1108
+ precondition and the replay journal intact, and clears the dirty mark so the worker does not
1109
+ then spend a second commit on identical bytes.
1110
+
1111
+ β›” BYPASSING `_FLUSH_MIN_GAP` IS THE POINT, NOT AN OVERSIGHT β€” do not "fix" this back to
1112
+ protect the 256-commits/hr budget. That floor exists to coalesce autosave chatter (a column
1113
+ resize, a filter tweak, a typed cell). A folder event is not chatter: `CustomerGrid.tsx`
1114
+ emits `item_move` and `folder_reorder` ON DROP, one per completed gesture, so the ceiling
1115
+ this adds is a handful of commits per session against a budget of 256 per hour.
1116
+
1117
+ ⚠ IT IS THE HANDLE `save_folders` ITSELF USED, resolved through `_tops` and never `_store_of`.
1118
+ Those two can be different objects (`_tops` can fall through to `cl_mod.TABLE_OPS`, built with
1119
+ NO store handle, while `_store_of` returns the request's `TenantRuntime`), and committing the
1120
+ wrong one would land nothing while looking exactly like success. `TableStore.st` and
1121
+ `.table_key` are the public pair, so this addresses byte-for-byte what `_update` addressed.
1122
+
1123
+ ⚠ A FAILED COMMIT IS NOT A REFUSED WRITE. The change is already accepted into the cache and
1124
+ the key is still marked dirty, so the coalescing worker keeps retrying with backoff. Logging
1125
+ and carrying on is therefore right; raising here would turn a saved change into a 500.
1126
+
1127
+ ⚠ ON POSTGRES this is a redundant transaction: `store_pg.update` accepts `flush=` and ignores
1128
+ it because every write is already committed when it returns. One extra RMW per user gesture,
1129
+ correctness-neutral. Named for the record rather than special-cased, because a backend test
1130
+ here would be a second definition of "which backend am I on".
1131
+ """
1132
+ try:
1133
+ st = getattr(tops, 'st', None)
1134
+ key = getattr(tops, 'table_key', None)
1135
+ if st is None or not key or not hasattr(st, 'update'):
1136
+ return False
1137
+ st.update(key, lambda doc: doc, flush='sync')
1138
+ return True
1139
+ except Exception as exc: # noqa: BLE001
1140
+ _tel.error('grid_events:folder_commit', exc)
1141
+ return False
1142
+
1143
+
1144
  def handle_events(events, ctx):
1145
  """The event LOG entry point β†’ one EventResult. The `[-24:]` window lives here.
1146
 
 
1415
  'widths': widths,
1416
  'memberPids': [pid for pid in list(cfg.get('memberPids') or [])
1417
  if isinstance(pid, int) and pid in allowed_pids],
1418
+ # ⭐⭐ W41-T20 clause 5 / C6 -- see `_clean_secondary_view` above.
1419
+ 'secondaryView': _clean_secondary_view(cfg.get('secondaryView')),
1420
  }
1421
  # Wave-6 item 10: a non-grid display mode rides the view config, validated
1422
  # structurally (mode whitelist, refs must be this table's fields).
 
2156
  # events touch ONE home β€” the table workspace's `folders` + `itemFolders` β€” so a cohort
2157
  # can be filed without the cohort STORE learning about folders at all.
2158
  import aios_grid as _agf
2159
+ # ⭐⭐ W41-T13 β€” NOT ONE BARE `False` LEAVES THIS BRANCH ANY MORE. Every exit below was a
2160
+ # `return False`, which `routes_grid` renders as `{"results":[{"rerender":false}]}` β€” byte
2161
+ # for byte a success. The user dragged a view, the rail sprang back, and the app said
2162
+ # nothing at all. `refused` carries the cause a caller branches on; `_folder_refuse` also
2163
+ # claims the one slot the client renders (see its note).
2164
  if not _store_of(ctx).available():
2165
+ return _folder_refuse(
2166
+ ctx, kind, 'store_unavailable',
2167
+ 'Your folders could not be saved because the workspace store is not reachable '
2168
+ 'right now, so nothing was changed. Try again in a moment.')
2169
  surface = str(event.get('surface') or '')
2170
  if surface not in _agf.FOLDER_SURFACES:
2171
+ return _folder_refuse(
2172
+ ctx, kind, 'surface_unknown',
2173
+ 'That folder change did not name a sidebar this page has, so nothing was saved. '
2174
+ 'Reload the page and try again.', key=surface)
2175
  # The ids this user may actually file: their own views, their own cohorts. Fail-closed β€”
2176
  # an item_move naming somebody else's cohort is dropped by clean_item_folders anyway,
2177
  # but refusing here means we never write it in the first place.
 
2180
  return set((cur_ws.get('views') or {}))
2181
  return set(_cohorts(ctx).all_for(uname))
2182
 
2183
+ # ⚠ A CELL THE CLOSURE FILLS, because `_mutate` has ONE way to say no β€” `return None` β€”
2184
+ # and eleven distinct reasons for saying it. Threading the cause out through the existing
2185
+ # sentinel keeps every refusal at the line that decided it, rather than re-deriving the
2186
+ # reason at the caller from state that has already moved on.
2187
+ why = {}
2188
+
2189
+ def _no(code, message, key=''):
2190
+ """Refuse from inside `_mutate`, naming the cause. Returns None, the sentinel."""
2191
+ why.clear()
2192
+ why.update({'code': code, 'message': message, 'key': str(key or '')})
2193
+ return None
2194
+
2195
  def _mutate(cur_ws):
2196
  folders = _agf.clean_folders(cur_ws.get('folders'))
2197
  placed = dict(cur_ws.get('itemFolders') or {})
 
2202
  fid = str(event.get('folderId') or '').strip()[:80]
2203
  if kind == 'folder_create':
2204
  name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME]
2205
+ if len(lst) >= _agf.MAX_FOLDERS:
2206
+ # ⚠ THE CAP IS SPLIT OUT FROM THE MALFORMED CASE, because it is the one a
2207
+ # person can actually do something about and the only one they will ever hit.
2208
+ return _no('folders_at_cap',
2209
+ f'This sidebar already holds {_agf.MAX_FOLDERS} folders, which is '
2210
+ f'all it can show, so the new one was not created. Delete a folder '
2211
+ f'you no longer need, then try again.', key=fid)
2212
+ if not fid or not name:
2213
+ return _no('folder_needs_a_name',
2214
+ 'A folder needs a name before it can be created, so nothing was '
2215
+ 'saved. Type a name and try again.', key=fid)
2216
  if any(f['id'] == fid for f in lst):
2217
+ return _no('folder_already_exists',
2218
+ 'A folder with that id is already on this sidebar, so nothing was '
2219
+ 'created. Reload the page to see the folders you have.', key=fid)
2220
  row = {'id': fid, 'name': name, 'order': len(lst)}
2221
  icon = _agf.clean_folder_icon(event.get('icon')) # wave-9 I15 (C5)
2222
  if icon:
2223
  row['icon'] = icon
2224
+ # OWNER INSTRUCTION 12 -- the client's `parent` reaches the store. Paired with
2225
+ # `aios_grid.clean_folders`, which carries it through the sanitiser; NEITHER EDIT
2226
+ # DOES ANYTHING ALONE. `clean_folders` runs over the whole list on every folder
2227
+ # event, so a rename or a reorder cannot un-nest a folder as a side effect either.
2228
+ # Bounded to 80 like every other id on this door, absent rather than null for the
2229
+ # root, and self-parenting dropped. A parent naming a folder that has since been
2230
+ # deleted is passed THROUGH on purpose: `validate_folder_tree` repairs it to the
2231
+ # root and returns a receipt, which `_folder_repair_notice` already turns into the
2232
+ # sentence the user reads.
2233
+ parent = str(event.get('parent') or '').strip()[:80]
2234
+ if parent and parent != fid:
2235
+ row['parent'] = parent
2236
  lst.append(row)
2237
  elif kind == 'folder_rename':
2238
  name = str(event.get('name') or '').strip()[:_agf.MAX_FOLDER_NAME]
2239
  hit = next((f for f in lst if f['id'] == fid), None)
2240
+ if not hit:
2241
+ return _no('folder_gone',
2242
+ 'That folder is not on this sidebar any more, so it could not be '
2243
+ 'renamed. Reload the page to see the folders you have.', key=fid)
2244
+ # ⭐⭐ W41-T14 Β· OWNER INSTRUCTION 11 β€” AN ICON-ONLY EVENT IS LEGAL ON THIS DOOR.
2245
+ #
2246
+ # β›” THE SECOND HALF OF THE INSTRUCTION IS *"allow changing the icon from the
2247
+ # '...' menu"* β€” a Change icon item, NOT the rename pane wave-9 built this branch
2248
+ # for. A dialog that changes only the icon has no name to send, and until this
2249
+ # line `{folderId, icon}` fell straight into the refusal below: the receiver of a
2250
+ # gesture that never mentioned a name was told *"A folder needs a name"*, the icon
2251
+ # was not stored, and both lanes were individually correct
2252
+ # ([[two-lanes-one-contract-dead-feature]], with a wrong sentence on top).
2253
+ #
2254
+ # ⚠ THE DISCRIMINATION IS THE `name` KEY, NOT ITS EMPTINESS, and that is what
2255
+ # keeps W41-T13's refusal intact: an event that CARRIES `name` and means blank is
2256
+ # still a rename to nothing and is still refused, in T13's exact words. Today's
2257
+ # client always sends one (`CustomerGrid.tsx::onFolderRename`), so every event
2258
+ # that has ever been emitted takes the byte-identical path. An event with neither
2259
+ # a usable name nor an `icon` key is malformed and is refused as it always was.
2260
+ if not name and ('name' in event or 'icon' not in event):
2261
+ return _no('folder_needs_a_name',
2262
+ 'A folder needs a name, so the new one was not saved and the old '
2263
+ 'name was kept. Type a name and try again.', key=fid)
2264
+ if name:
2265
+ hit['name'] = name
2266
  # wave-9 I15 (C5): the rename pane is also where the icon is changed. An event
2267
  # that carries NO icon key leaves the existing one alone (a rename must not
2268
  # silently strip a chosen icon); an explicit null clears it back to default.
2269
+ # ⚠ W41-T14 MEASURED THE LIMIT RATHER THAN WIDENING IT: `clean_folder_icon`
2270
+ # answers None for an explicit null AND for a junk shape, so this branch cannot
2271
+ # tell *"clear my icon"* from *"that shape does not exist"* and drops the stored
2272
+ # icon either way. Nothing junk is ever STORED, which is what this ticket owes;
2273
+ # the conflation is booked rather than fixed here, because a new refusal would
2274
+ # claim the single `toast` slot on the path W41-T35 is about to exercise.
2275
  if 'icon' in event:
2276
  icon = _agf.clean_folder_icon(event.get('icon'))
2277
  if icon:
 
2280
  hit.pop('icon', None)
2281
  elif kind == 'folder_delete':
2282
  if not any(f['id'] == fid for f in lst):
2283
+ return _no('folder_gone',
2284
+ 'That folder is not on this sidebar any more, so there was nothing '
2285
+ 'to delete. Reload the page to see the folders you have.', key=fid)
2286
  lst = [f for f in lst if f['id'] != fid]
2287
  # CONTENTS MOVE TO ROOT, never delete. Dropping the placements is exactly that:
2288
  # an item with no placement renders at the top level.
 
2306
  # folder it merely failed to mention would be a data loss dressed as a sort.
2307
  order = [str(i).strip()[:80] for i in (event.get('order') or [])]
2308
  if not order:
2309
+ return _no('order_named_no_folders',
2310
+ 'That rail arrangement named no folders, so the order you had was '
2311
+ 'kept. Drag the folder again.')
2312
  known = {f['id']: f for f in lst}
2313
  seen, ranked = set(), []
2314
  for fid_ in order:
 
2317
  ranked.append(known[fid_])
2318
  ranked.extend(f for f in lst if f['id'] not in seen)
2319
  if len(ranked) != len(lst): # cannot happen; refuse rather than truncate
2320
+ return _no('order_incomplete',
2321
+ 'That rail arrangement could not be applied without losing a '
2322
+ 'folder, so the order you had was kept. Reload the page and try '
2323
+ 'again.')
2324
  for i, f in enumerate(ranked):
2325
  f['order'] = i
2326
  lst = ranked
2327
  elif kind == 'folder_duplicate':
2328
  new_id = str(event.get('newId') or '').strip()[:80]
2329
  src = next((f for f in lst if f['id'] == fid), None)
2330
+ if len(lst) >= _agf.MAX_FOLDERS:
2331
+ return _no('folders_at_cap',
2332
+ f'This sidebar already holds {_agf.MAX_FOLDERS} folders, which is '
2333
+ f'all it can show, so the copy was not made. Delete a folder you '
2334
+ f'no longer need, then try again.', key=fid)
2335
+ if not src:
2336
+ return _no('folder_gone',
2337
+ 'That folder is not on this sidebar any more, so there was nothing '
2338
+ 'to copy. Reload the page to see the folders you have.', key=fid)
2339
+ if not new_id:
2340
+ return _no('duplicate_without_id',
2341
+ 'The copy was not given an id, so nothing was created. Try '
2342
+ 'duplicating the folder again.', key=fid)
2343
  if any(f['id'] == new_id for f in lst):
2344
+ return _no('folder_already_exists',
2345
+ 'A folder with that id is already on this sidebar, so the copy was '
2346
+ 'not made. Reload the page to see the folders you have.',
2347
+ key=new_id)
2348
  lst.append({'id': new_id, 'name': f"{src['name']} copy"[:_agf.MAX_FOLDER_NAME],
2349
  'order': len(lst)})
2350
  # ⚠ The folder's CONTENTS are duplicated by the client emitting the ordinary
 
2355
  else: # item_move
2356
  item_id = str(event.get('itemId') or '').strip()[:120]
2357
  target = event.get('folderId')
2358
+ # ⭐⭐ W41-T13 / OWNER INSTRUCTION 10 β€” THE TWO CAUSES ARE SPLIT, and the split is
2359
+ # the ticket's own argument: `not item_id` is a malformed event, `not in _own_ids`
2360
+ # is a permission answer, and one `return None` for both told the user neither.
2361
+ if not item_id:
2362
+ return _no('move_without_item',
2363
+ 'That move did not say which item to file, so nothing was saved. '
2364
+ 'Drag the item again.')
2365
+ if item_id not in _own_ids(surface, cur_ws):
2366
+ # ⚠ Worded for the person, not the wall: a view shared WITH someone is in
2367
+ # their rail and is not theirs to file, which is the case they will meet.
2368
+ return _no('item_not_yours',
2369
+ 'You can file only the items you own, and this one belongs to '
2370
+ 'somebody else, so it stayed where it was.', key=item_id)
2371
  cur = dict(placed.get(surface) or {})
2372
  if target is None:
2373
  # ⚠ "UNFILE" β€” the client says nothing about where it went. Popping is right
 
2393
  else:
2394
  tid = str(target)[:80]
2395
  if not any(f['id'] == tid for f in lst):
2396
+ return _no('folder_gone',
2397
+ 'That folder is not on this sidebar any more, so the item '
2398
+ 'stayed where it was. Reload the page to see the folders you '
2399
+ 'have, then try again.', key=tid)
2400
  cur[item_id] = tid
2401
  placed[surface] = cur
2402
  folders[surface] = lst
 
2412
  # change inside a pure move.
2413
  _res = _mutate(table_workspace(ctx, consume_corrections=False))
2414
  if _res is None:
2415
+ # Refused: nothing is written β€” and now the caller is TOLD, with the cause `_mutate`
2416
+ # recorded at the line that decided it. The fallback exists so a future `return None`
2417
+ # added without a `_no(...)` degrades to a generic sentence rather than to silence.
2418
+ return _folder_refuse(
2419
+ ctx, kind,
2420
+ why.get('code') or 'folder_change_refused',
2421
+ why.get('message') or ('That folder change could not be saved, so nothing was '
2422
+ 'altered. Reload the page and try again.'),
2423
+ key=why.get('key') or '')
2424
+ # ⭐⭐ W41-T12's HANDOVER, TAKEN. `save_folders` VALIDATES before it writes and RAISES
2425
+ # `FolderTreeError` on a cycle or an over-deep chain, having written nothing. Letting that
2426
+ # escape would 500 the whole event window; catching it and dropping the result would put
2427
+ # it straight back into the silent-200 path this ticket exists to close. `str(exc)` is
2428
+ # already the sentence a person reads and already C7-clean, and `folder_id` is an
2429
+ # attribute, so nothing is parsed out of the message.
2430
+ #
2431
+ # ⚠ UNREACHABLE FROM THIS DOOR TODAY, AND SAYING SO IS PART OF THE HANDOVER.
2432
+ # `_mutate` returns `_agf.clean_folders(...)`, whose row is `{id, name, order}` plus an
2433
+ # optional `icon` β€” it STRIPS `parent` entirely, so no nesting can reach the validator
2434
+ # through here and neither raise can fire. This is the forward contract with T12: the day
2435
+ # `clean_folders` learns `parent`, the refusal is already wired and worded.
2436
+ try:
2437
+ _receipt = _tops(ctx).save_folders(uname, _res[0], _res[1])
2438
+ except _tstore.FolderTreeError as _fte:
2439
+ return _folder_refuse(ctx, kind,
2440
+ str(getattr(_fte, 'reason', '') or 'folder_tree_illegal'),
2441
+ str(_fte), key=str(getattr(_fte, 'folder_id', '') or ''))
2442
+ # ⭐⭐ THE DURABILITY HALF β€” the owner's actual complaint. See `_commit_folder_write`: the
2443
+ # write above lands in `Store._cache` and the hub commit is deferred by up to ~22s, so
2444
+ # a second process (or the same one after a restart) cannot read it back. Forced here,
2445
+ # for EVERY folder kind rather than `item_move` alone: they all write one key through one
2446
+ # `save_folders`, they are all one discrete gesture, and a `folder_create` that evaporated
2447
+ # would take the move filed into it with it.
2448
+ _commit_folder_write(_tops(ctx))
2449
+ # W41-T12's receipt, partitioned. A repair means the stored tree differs from what was
2450
+ # dragged, which the user has to be told or their sidebar silently disagrees with them.
2451
+ _notice = _folder_repair_notice((_receipt or {}).get('repairs'))
2452
+ if _notice:
2453
+ try:
2454
+ ctx.out.toast = _notice[:300]
2455
+ except Exception: # noqa: BLE001
2456
+ pass
2457
  # True: the sidebars render from server state (which folder holds what), not from local
2458
  # optimism β€” the same contract the cohort panel's membership edits ride.
2459
  return True
 
2513
  import aios_grid as _ag3
2514
  if not (key.startswith('custom_') or key.startswith(_ag3.MEASURE_FIELD_PREFIX)):
2515
  return False
2516
+ # ⭐⭐ W41-T02 / RULING R6a+R6b / CONTRACT C2 β€” THE RIGHT, ASKED OF THE ONE PAIR THAT OWNS
2517
+ # IT. Until now this door had NO rights test at all: the prefix above was the whole of
2518
+ # the wall, so any holder of a grid could delete a column somebody else made and shared
2519
+ # with them, and an administrator could delete a pre-set the rename door had always
2520
+ # refused them. C2's last sentence is *"No surface re-implements either test"*, so the
2521
+ # answer comes from `user_tables.may_delete_field`, which derives it from C1's badges.
2522
+ #
2523
+ # ⚠ THE PREFIX TEST STAYS, AND IT IS NOT THE SAME WALL. It is STRUCTURAL β€” it says which
2524
+ # STRATUM a key could even be deleted from, which is what stops the browser claiming a
2525
+ # base contract column is deletable. The predicate is the RIGHTS wall over the columns
2526
+ # that survive it. Replacing one with the other would drop a check in either direction.
2527
+ #
2528
+ # β›” NO `_refuse` HERE, ON PURPOSE. That helper stamps `'event': 'field_upsert'` on every
2529
+ # record it writes, so reporting from this branch would file a refusal that lies about
2530
+ # which event it was. The refusal is real and silent for exactly one more ticket: W41-T07
2531
+ # owns turning this door's `return False` into a truthy result with a message, and this
2532
+ # line is what gives it a genuine refusal to carry. Until then the wall is proved from
2533
+ # STORE STATE β€” the definition is still there after a refused delete β€” never from a
2534
+ # refusal record this door cannot yet emit honestly.
2535
+ # ⚠ THE DEFINITION IS RESOLVED IN TWO PLACES, AND THE SECOND IS NOT BELT-AND-BRACES.
2536
+ # `field_by_key` is `ctx.fields`, bound ONCE per request, and it must be asked FIRST
2537
+ # because it is the only one carrying the tenant-wide SHARED stratum (merged into the
2538
+ # served contract by the assembly, never into this user's own bucket). But it is also
2539
+ # STALE inside a batch: `handle_events` walks a window of events with one ctx, so a
2540
+ # create and a delete arriving in the same burst would leave the delete looking at a
2541
+ # field list assembled before the create landed β€” and the pair fails CLOSED, so the
2542
+ # column would survive its own delete. `ws` is re-read per event a few lines up, so the
2543
+ # per-user stratum it holds is current. Neither lookup DECIDES anything; both just hand
2544
+ # the same one predicate the definition it is owed.
2545
+ _defn = field_by_key.get(key)
2546
+ if not isinstance(_defn, dict):
2547
+ _defn = (ws.get('fields') or {}).get(key)
2548
+ _share_key, _share_topic = _field_share_keys(ctx)
2549
+ import core.user_tables as _ut2
2550
+ if not _ut2.may_delete_field(
2551
+ _share_key, key, uname, bool(admin),
2552
+ ctx.st or getattr(ctx.table, 'st', None),
2553
+ field=_defn, grant_topic=_share_topic):
2554
+ return False
2555
+ # W41-T07 / OWNER INSTRUCTION 21 -- DELETE FROM EVERY STRATUM, NOT JUST THIS USER'S.
2556
+ # Owner: *"Deleting a field from Hide fields does not stick; 'August Campaign' on Odoo
2557
+ # customer has reappeared three times."* It reappeared because THIS door never deleted
2558
+ # it: `table_store.delete_field` reaches exactly one member of the workspace document --
2559
+ # the deleter's own -- while the definition survives in `shared_overlay`'s bucket and in
2560
+ # every other account's fork, and `migrate_legacy_fields` (which runs on EVERY read)
2561
+ # promotes one straight back. Measured in-process by the ticket that built the executor:
2562
+ # drop, one read, the column is back.
2563
+ #
2564
+ # W41-T07 BUILT `delete_field_everywhere` and wired it to `routes_tables.py::
2565
+ # delete_shared_field`, the generic `ut_*` door. THIS door -- the one serving
2566
+ # `customer_data` and `product_data`, which is the grid the owner named -- was left on the
2567
+ # single-stratum call, so the fix existed and the defect shipped anyway. A correct
2568
+ # mechanism on the wrong door is why every gate stayed green. [[two-lanes-one-contract-dead-feature]]
2569
+ #
2570
+ # THE RIGHT WAS ALREADY DECIDED ABOVE and is not decided again here: C2's last sentence is
2571
+ # *"No surface re-implements either test"*, and `may_delete_field` is that test. This is
2572
+ # the EXECUTION half only, which is exactly what the executor's own docstring says it is.
2573
+ # The keys are the pair `_field_share_keys` already resolved: `customer_data`'s per-user
2574
+ # and tenant-wide strata are named from ONE key (`routes_customers::_shared_key` --
2575
+ # *"the store key this topic's per-user AND tenant-wide strata are both named from"*), so
2576
+ # workspace and shared are the same argument here, and the grant topic is the second.
2577
  if _store_of(ctx).available():
2578
+ import core.field_permissions as _fp2
2579
+ _removed = _fp2.delete_field_everywhere(
2580
+ _share_key, _share_key, _share_topic, key,
2581
+ st=ctx.st or getattr(ctx.table, 'st', None))
2582
+ # The per-leg report is kept rather than reduced to a boolean: a delete that reached
2583
+ # the shared bucket but no fork, or the reverse, is a different fact from a clean one.
2584
+ try:
2585
+ ctx.out.derived = dict(getattr(ctx.out, 'derived', None) or {},
2586
+ fieldDeleteRemoved=_removed)
2587
+ except Exception: # noqa: BLE001
2588
+ pass
2589
  else:
2590
  _session_ready()
2591
  ws['fields'].pop(key, None)
platform/core/perm_scope.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/core/script_sandbox.py CHANGED
@@ -1,519 +1,519 @@
1
- """core/script_sandbox.py β€” WAVE 36 (R5 / R10, contract C1): running a tenant's OWN Python.
2
-
3
- Owner item 6: *"Add code script as an interface (database View) so a user can build whatever they
4
- want through the Agent chat interface."* Item 8: *"We need to really guardrail the reach of this
5
- script. So let's really grill this down."* R10 ruled it SERVER-SIDE PYTHON after the trade was
6
- stated, so this file is the guardrail, and one engine serves both items.
7
-
8
- ════════════════════════════════════════════════════════════════════════════════════════════════
9
- β›”β›” THE ONE PARAGRAPH TO READ BEFORE CHANGING ANYTHING HERE.
10
-
11
- In-process CPython cannot deliver two of this ticket's clauses. An AST allow-list plus a curated
12
- namespace stops import, file, network and environment access β€” but it **cannot cap memory and
13
- cannot interrupt a runaway loop**, because a `while True:` in the same interpreter is not a slow
14
- request, it is the tenant's ONE FastAPI process gone. So the script runs in a **SUBPROCESS**:
15
- `resource.setrlimit` for address space and CPU, a hard wall-clock kill from the parent, and the
16
- allow-list inside. Neither half is sufficient; both are load-bearing.
17
-
18
- ⭐ AND THE SUBPROCESS RECEIVES **ROWS, NEVER A STORE**. The parent calls C1's `scoped_table` under
19
- the CALLING user's record and serialises the result; the child imports nothing from this repo and
20
- holds no credential, no runtime and no store handle. Wiring W1 ("the sandbox has no second store
21
- path") is then true by CONSTRUCTION rather than by discipline, and it is checkable: the child
22
- reports its own `sys.modules`, and no `core.*` name may appear in it.
23
-
24
- β›” NEVER A BLACKLIST. Every rule below is an ALLOW-LIST β€” a set of node types, a set of attribute
25
- names, a dict of builtins. A blacklist of dangerous spellings is bypassable by construction, and
26
- the bypass is usually one string method away (`"{0.__class__}".format(x)` performs its attribute
27
- lookup inside `format`, so there is no `ast.Attribute` node to refuse).
28
- ════════════════════════════════════════════════════════════════════════════════════════════════
29
-
30
- The two layers, and they refuse DIFFERENT things on purpose:
31
-
32
- 1. `check_source()` β€” a pure function over source text. Refuses a construct the language offers
33
- and this sandbox does not: `import`, `class`, `with`, `async`, `yield`, `global`, and every
34
- attribute name outside `ALLOWED_ATTRS`.
35
- 2. `SANDBOX_BUILTINS` β€” the names that resolve at all. `__import__`, `open`, `eval`, `exec`,
36
- `compile`, `getattr`, `globals`, `vars` and `type` are simply absent, so a source that gets
37
- past layer 1 still finds nothing to call.
38
-
39
- ⚠ THAT DUPLICATION IS DELIBERATE AND IT CHANGES HOW THE GATE MUST BE WRITTEN. `import os` is
40
- refused twice, so a negative control that drops ONE layer sees the other refuse and reports
41
- green β€” the shape that already cost this wave one missed control in `routes_agent_harness`. So
42
- each layer is tested AT ITS OWN BOUNDARY: `check_source()` is called directly on source strings,
43
- and `run()` is driven end to end. An NC drops one entry from one frozenset and the matching
44
- boundary goes red.
45
- """
46
- import ast
47
- import json
48
- import os
49
- import subprocess
50
- import sys
51
- import tempfile
52
- import time
53
- from pathlib import Path
54
-
55
- #: Wall clock, enforced by the PARENT with a kill. The one cap that works on every platform.
56
- DEFAULT_TIMEOUT_S = 10.0
57
-
58
- #: Address space for the child (`RLIMIT_AS`). POSIX only β€” see `run()`'s `caps` report.
59
- DEFAULT_MEMORY_BYTES = 512 * 1024 * 1024
60
-
61
- #: CPU seconds for the child (`RLIMIT_CPU`). POSIX only. Deliberately above the wall clock: the
62
- #: wall-clock kill is the primary control and this is the backstop for a child that stops being
63
- #: reachable. A CPU limit BELOW the timeout would make every slow script look like a CPU refusal.
64
- DEFAULT_CPU_SECONDS = 15
65
-
66
- #: What the script may print, in bytes. `print` is a curated builtin writing to a capped buffer,
67
- #: and the child's real stdout goes to DEVNULL β€” so a script cannot fill a pipe, and anything
68
- #: that escaped far enough to write to fd 1 has nowhere for it to land.
69
- MAX_STDOUT_BYTES = 64 * 1024
70
-
71
- #: The serialised ROW payload handed to the child. β›” A REFUSAL, NEVER A TRUNCATION (standing rule
72
- #: 1): a short answer from a data tool is a wrong answer that looks right. Over this, `run()`
73
- #: returns a named limit carrying its cause and a recommendation.
74
- MAX_PAYLOAD_BYTES = 32 * 1024 * 1024
75
-
76
- #: The emitted spec. A render spec is a description of a picture; one larger than this is data
77
- #: pretending to be a description.
78
- MAX_SPEC_BYTES = 2 * 1024 * 1024
79
-
80
- MAX_SOURCE_BYTES = 128 * 1024
81
-
82
-
83
- # ══════════════════════════════════════════════════════ LAYER 1 β€” the AST allow-list ═══════════
84
- #: Every `ast` node class a script may contain. β›” THE ABSENCES ARE THE POLICY: `Import` /
85
- #: `ImportFrom` (no module reaches the script), `ClassDef` (a class body is a namespace with its
86
- #: own scoping rules and buys a data script nothing), `With` (a context manager is `__enter__`
87
- #: by another spelling), `Global` / `Nonlocal` (rebinding the sandbox's own names), and every
88
- #: `Async*` / `Await` / `Yield` form (this engine is synchronous; a coroutine that is never
89
- #: awaited is a silent no-op that looks like a working script).
90
- ALLOWED_NODES = frozenset("""
91
- Module Expr Assign AugAssign AnnAssign NamedExpr Return Pass Break Continue Delete Assert Raise
92
- If For While Try TryStar ExceptHandler FunctionDef Lambda arguments arg keyword
93
- BoolOp BinOp UnaryOp IfExp Dict Set List Tuple Starred Subscript Slice Compare Call Attribute Name
94
- Constant JoinedStr FormattedValue ListComp SetComp DictComp GeneratorExp comprehension
95
- Load Store Del
96
- And Or Not Invert UAdd USub
97
- Add Sub Mult Div FloorDiv Mod Pow LShift RShift BitOr BitXor BitAnd MatMult
98
- Eq NotEq Lt LtE Gt GtE Is IsNot In NotIn
99
- """.split())
100
-
101
- #: Every attribute name a script may READ or CALL. β›”β›” THIS IS THE LOAD-BEARING SET, and it is
102
- #: an allow-list of NAMES rather than a refusal of dunders, because the interesting escapes are
103
- #: ordinary-looking: `f.__globals__` on any function reaches the runner's own module namespace,
104
- #: `e.__traceback__.tb_frame.f_globals` reaches it from an exception handler, and `().__class__`
105
- #: reaches `object.__subclasses__`. None of those names is here, and neither is any name this
106
- #: sandbox has not been asked for.
107
- #: ⚠ `format` IS ABSENT DELIBERATELY. `"{0.__class__}".format(x)` performs the attribute lookup
108
- #: INSIDE `str.format`, where no `ast.Attribute` node exists for layer 1 to see. f-strings are
109
- #: fine β€” `f"{x.__class__}"` compiles to a real `Attribute` node and is refused.
110
- ALLOWED_ATTRS = frozenset("""
111
- append extend insert pop remove clear sort reverse copy count index
112
- keys values items get setdefault update
113
- add discard union intersection difference issubset issuperset
114
- join split rsplit splitlines strip lstrip rstrip lower upper title capitalize casefold
115
- replace startswith endswith find rfind zfill ljust rjust center partition removeprefix removesuffix
116
- isdigit isalpha isalnum isspace isupper islower isnumeric
117
- real imag numerator denominator
118
- """.split())
119
-
120
-
121
- class Refused(Exception):
122
- """A named refusal: `code` for a caller to branch on, `message` for a person to read."""
123
-
124
- def __init__(self, code, message):
125
- self.code, self.message = code, message
126
- super().__init__(f"{code}: {message}")
127
-
128
-
129
- def _attr_ok(name):
130
- """An attribute name passes only if it is on the list AND is not private.
131
-
132
- ⚠ THE SECOND TEST IS NOT A BLACKLIST β€” it narrows an allow-list that already excludes every
133
- private name. It is here so that adding a name to `ALLOWED_ATTRS` cannot open a dunder by
134
- accident, which is the one edit a future reader is most likely to make in a hurry.
135
- """
136
- return name in ALLOWED_ATTRS and not name.startswith("_")
137
-
138
-
139
- def check_source(source):
140
- """LAYER 1. Return a `Refused` for source this sandbox will not run, or `None`.
141
-
142
- ⭐ PURE, AND THAT IS WHAT MAKES IT TESTABLE AT ITS OWN BOUNDARY. It reads no file, spawns no
143
- process and touches no store, so a gate can hand it a hundred hostile strings for free and an
144
- NC can drop one entry from one frozenset and watch exactly this function change its answer.
145
- """
146
- text = str(source or "")
147
- if len(text.encode("utf-8", "replace")) > MAX_SOURCE_BYTES:
148
- return Refused("source_too_long",
149
- f"a script view is at most {MAX_SOURCE_BYTES // 1024} KB of source")
150
- try:
151
- tree = ast.parse(text)
152
- except SyntaxError as exc:
153
- return Refused("syntax", f"line {exc.lineno or 0}: {exc.msg}")
154
-
155
- for node in ast.walk(tree):
156
- kind = type(node).__name__
157
- if kind not in ALLOWED_NODES:
158
- return Refused("refused_construct",
159
- f"line {getattr(node, 'lineno', 0)}: this sandbox does not run "
160
- f"{_english(kind)}")
161
- if isinstance(node, ast.Attribute) and not _attr_ok(node.attr):
162
- return Refused("refused_attribute",
163
- f"line {getattr(node, 'lineno', 0)}: the attribute "
164
- f"'{node.attr}' is not available inside a script view")
165
- # β›” A NAME may not be private either. `_` prefixed names are the runner's own, and a
166
- # script that could bind one could shadow the machinery it runs on top of.
167
- if isinstance(node, ast.Name) and node.id.startswith("_"):
168
- return Refused("reserved_name",
169
- f"line {getattr(node, 'lineno', 0)}: names starting with an "
170
- f"underscore are reserved by the sandbox")
171
- if isinstance(node, (ast.FunctionDef, ast.arg, ast.ExceptHandler)) and str(
172
- getattr(node, "name", None) or getattr(node, "arg", "") or "").startswith("_"):
173
- return Refused("reserved_name",
174
- f"line {getattr(node, 'lineno', 0)}: names starting with an "
175
- f"underscore are reserved by the sandbox")
176
- if isinstance(node, ast.keyword) and str(node.arg or "").startswith("_"):
177
- return Refused("reserved_name",
178
- f"line {getattr(node, 'lineno', 0)}: keyword arguments starting with "
179
- f"an underscore are reserved by the sandbox")
180
- return None
181
-
182
-
183
- _ENGLISH = {
184
- "Import": "an import", "ImportFrom": "an import", "ClassDef": "a class definition",
185
- "With": "a with block", "AsyncWith": "a with block", "AsyncFor": "an async loop",
186
- "AsyncFunctionDef": "an async function", "Await": "await", "Yield": "yield",
187
- "YieldFrom": "yield from", "Global": "a global statement", "Nonlocal": "a nonlocal statement",
188
- "Match": "a match statement",
189
- }
190
-
191
-
192
- def _english(kind):
193
- return _ENGLISH.get(kind, f"a {kind} expression")
194
-
195
-
196
- # ══════════════════════════════════════════ LAYER 2 β€” the namespace, and the child program ═════
197
- #: The builtins a script may reach, BY NAME. Everything else is a `NameError` in the child.
198
- #: β›” THE ABSENCES, again, are the policy: `__import__` `open` `eval` `exec` `compile` `input`
199
- #: `getattr` `setattr` `delattr` `globals` `locals` `vars` `dir` `type` `super` `object` `help`
200
- #: `exit` `breakpoint` `memoryview` `id`. Several are harmless on their own; each one is a step
201
- #: on a published escape, and none has ever been asked for by a script that shapes rows.
202
- #: ⚠ THE EXCEPTION CLASSES ARE HERE BECAUSE `try:` IS, and a `try` block whose `except` clause
203
- #: cannot name what it catches is a construct that reads as supported and is not. They are safe
204
- #: for the same reason everything else is: `Exception.__subclasses__` needs an attribute this
205
- #: sandbox does not allow, so a class object in the namespace is a leaf, not a doorway.
206
- SANDBOX_BUILTIN_NAMES = (
207
- "abs all any bool bytes callable chr dict divmod enumerate filter float frozenset hash hex "
208
- "int isinstance issubclass iter len list map max min next oct ord pow range repr reversed "
209
- "round set slice sorted str sum tuple zip True False None "
210
- "Exception ValueError TypeError KeyError IndexError ZeroDivisionError ArithmeticError "
211
- "AttributeError StopIteration OverflowError"
212
- ).split()
213
-
214
- #: The literal program the child runs. It is TEXT rather than a module because the child must
215
- #: import nothing from this repo: a module would be found on `sys.path` and would drag `core`
216
- #: with it, which is exactly the second store path W1 forbids.
217
- #: ⚠ Every name in here is underscore-prefixed and layer 1 refuses a script from binding one, so
218
- #: the runner's own machinery cannot be shadowed by the source it executes.
219
- _RUNNER = r'''
220
- import json as _json, os as _os, sys as _sys
221
-
222
- _pay = _json.loads(open(_sys.argv[1], "r", encoding="utf-8").read())
223
- _out = {"ok": False, "code": "not_run", "message": "the script did not run",
224
- "stdout": "", "spec": None, "caps": {"wallClock": True, "memory": False, "cpu": False}}
225
-
226
- # ── the caps this platform can actually apply, reported either way (standing rule 1) ──────────
227
- try:
228
- import resource as _res
229
- _mem = int(_pay["memoryBytes"])
230
- _res.setrlimit(_res.RLIMIT_AS, (_mem, _mem))
231
- _out["caps"]["memory"] = True
232
- _cpu = int(_pay["cpuSeconds"])
233
- _res.setrlimit(_res.RLIMIT_CPU, (_cpu, _cpu))
234
- _out["caps"]["cpu"] = True
235
- except Exception:
236
- # `resource` is POSIX only. The wall-clock kill in the parent still applies, and `caps` says
237
- # which of the three held, never a silent partial.
238
- pass
239
-
240
- _printed = []
241
- _spent = [0]
242
- _LIMIT = int(_pay["maxStdout"])
243
-
244
-
245
- def _print(*_a, **_k):
246
- _text = (_k.get("sep") or " ").join(str(_x) for _x in _a) + (_k.get("end") or "\n")
247
- _room = _LIMIT - _spent[0]
248
- if _room > 0:
249
- _printed.append(_text[:_room])
250
- _spent[0] += len(_text)
251
-
252
-
253
- class _Refusal(Exception):
254
- """The SANDBOX refusing, as distinct from the SCRIPT failing.
255
-
256
- Without its own class these arrive as `ValueError`, indistinguishable from a `ValueError` the
257
- script raised itself, and the answer then says "refused" about an ordinary bug in the tenant's
258
- own code. Two different facts, two different codes.
259
- """
260
-
261
-
262
- _emitted = []
263
-
264
-
265
- def _emit(_spec):
266
- if not isinstance(_spec, dict):
267
- raise _Refusal("emit() takes a view spec, which is a dictionary")
268
- if _emitted:
269
- raise _Refusal("emit() was already called; a script view emits exactly one view")
270
- _emitted.append(_spec)
271
-
272
-
273
- _rows = _pay["rows"]
274
- _fields = _pay["fields"]
275
- _bound = _pay["table"]
276
-
277
-
278
- def _scoped_table(_table=None):
279
- if _table is not None and str(_table) != _bound:
280
- raise _Refusal(
281
- "this script view is bound to the database '" + _bound + "' and asked for '"
282
- + str(_table) + "'. A script view reads its own database only")
283
- return [dict(_r) for _r in _rows]
284
-
285
-
286
- def _scoped_fields():
287
- return [dict(_f) for _f in _fields]
288
-
289
-
290
- _ns = {"__builtins__": {_n: __builtins__[_n] if isinstance(__builtins__, dict)
291
- else getattr(__builtins__, _n)
292
- for _n in _pay["builtins"]}}
293
- _ns["__builtins__"]["print"] = _print
294
- _ns["print"] = _print
295
- _ns["emit"] = _emit
296
- _ns["scoped_table"] = _scoped_table
297
- _ns["scoped_fields"] = _scoped_fields
298
- _ns["table"] = _bound
299
-
300
- try:
301
- exec(compile(_pay["source"], "<script view>", "exec"), _ns)
302
- if not _emitted:
303
- _out.update(ok=False, code="no_view",
304
- message="the script finished without calling emit(spec)")
305
- else:
306
- _out.update(ok=True, code="", message="", spec=_emitted[0])
307
- except _Refusal as _e:
308
- _out.update(ok=False, code="refused", message=str(_e)[:400])
309
- except MemoryError:
310
- _out.update(ok=False, code="memory",
311
- message="the script used more memory than a script view is allowed")
312
- except NameError as _e:
313
- _out.update(ok=False, code="refused_name",
314
- message=str(_e)[:200] + ". A script view may use only the names the sandbox "
315
- "provides")
316
- except BaseException as _e:
317
- _out.update(ok=False, code="error",
318
- message=type(_e).__name__ + ": " + str(_e)[:400])
319
-
320
- _out["stdout"] = "".join(_printed)
321
- _out["truncated"] = _spent[0] > _LIMIT
322
- # ⭐ THE PROBE: what this child actually had. The PARENT strips it unless it was asked for, so a
323
- # production run never carries it and a gate can still prove that no `core.*` module and no
324
- # secret-shaped environment key was ever inside this process.
325
- # ⚠ SNAPSHOTTED AFTER `exec`, and `os` is imported at the TOP so this list does not depend on
326
- # dict-literal evaluation order. The first draft called `__import__("os")` inside this very
327
- # expression, so whether `os` appeared depended on which value Python built first: a probe whose
328
- # contents move with an unrelated edit is a probe a gate cannot assert against.
329
- _out["probe"] = {"modules": sorted(_sys.modules), "env": sorted(_os.environ)}
330
- open(_sys.argv[2], "w", encoding="utf-8").write(_json.dumps(_out, default=str))
331
- '''
332
-
333
-
334
- def _child_env():
335
- """The child's WHOLE environment. An allow-list of two keys, and neither is a credential.
336
-
337
- β›” NOT `os.environ.copy()` MINUS SOMETHING. A subtractive environment ships every key nobody
338
- thought to name: `HF_TOKEN`, `ODOO_PASSWORD`, `ANTHROPIC_API_KEY` and whatever the next
339
- connector adds. The three names below are here because Python will not start on Windows
340
- without them; on Linux this returns `{}` and the child runs with no environment at all.
341
- """
342
- env = {}
343
- for name in ("SystemRoot", "SYSTEMROOT", "WINDIR"):
344
- if os.environ.get(name):
345
- env[name] = os.environ[name]
346
- return env
347
-
348
-
349
- def run(source, rows, fields, table_key, *, timeout_s=DEFAULT_TIMEOUT_S,
350
- memory_bytes=DEFAULT_MEMORY_BYTES, cpu_seconds=DEFAULT_CPU_SECONDS, probe=False):
351
- """Run ONE script over rows that are ALREADY scoped. Returns C3's envelope plus `caps`.
352
-
353
- {ok, code, message, spec, stdout, truncated, ms, caps: {wallClock, memory, cpu}}
354
-
355
- β›” THIS FUNCTION NEVER TOUCHES A STORE, AND THAT IS THE POINT: it takes rows. `run_view()`
356
- below is the door that fetches them through C1; keeping the two apart is what lets a gate
357
- drive the sandbox with no tenant, no runtime and no credential anywhere in the process.
358
-
359
- ⚠ `caps` IS PART OF THE ANSWER, NOT DEBUG OUTPUT. On Windows `resource` does not exist, so
360
- the memory and CPU limits are NOT applied and this says so. A caller that reports `ok:true`
361
- without reading `caps` is claiming an enforcement that did not happen (standing rule 1).
362
- """
363
- started = time.monotonic()
364
- refusal = check_source(source)
365
- if refusal is not None:
366
- return _refusal(refusal.code, refusal.message, started)
367
-
368
- payload = {"source": str(source or ""), "rows": rows, "fields": fields,
369
- "table": str(table_key or ""), "builtins": SANDBOX_BUILTIN_NAMES,
370
- "maxStdout": MAX_STDOUT_BYTES, "memoryBytes": int(memory_bytes),
371
- "cpuSeconds": int(cpu_seconds)}
372
- try:
373
- blob = json.dumps(payload, default=str)
374
- except (TypeError, ValueError) as exc:
375
- return _refusal("bad_rows", f"these rows cannot be handed to a script ({exc})", started)
376
- if len(blob.encode("utf-8", "replace")) > MAX_PAYLOAD_BYTES:
377
- # β›” REPORTED, NOT TRUNCATED (standing rule 1's second sentence): cause and recommendation,
378
- # in the words the owner asked for, rather than a quietly short answer.
379
- return _refusal(
380
- "payload_too_large",
381
- f"this database's rows are larger than the {MAX_PAYLOAD_BYTES // (1024 * 1024)} MB a "
382
- f"script view can be handed at once. Narrow the view with a filter, or raise the "
383
- f"sandbox payload limit for this deployment", started)
384
-
385
- with tempfile.TemporaryDirectory(prefix="aios-script-") as work:
386
- pay_path = Path(work) / "payload.json"
387
- res_path = Path(work) / "result.json"
388
- pay_path.write_text(blob, encoding="utf-8")
389
- # `-I` isolates the interpreter (no PYTHON* env, no user site), `-S` skips site-packages,
390
- # and the program arrives on STDIN so there is no file for anything to import it as.
391
- # β›” `-X utf8` AND AN EXPLICIT `encoding` ARE NOT TIDINESS. Without them this pipe is
392
- # encoded with the parent's locale codec, which on this Windows box is cp1252: the runner
393
- # text below cannot be represented in it and `subprocess.run` died with
394
- # `UnicodeEncodeError` before the child ever started. A sandbox whose behaviour depends on
395
- # the operator's locale is a sandbox with two behaviours. `-I` implies `-E`, so
396
- # `PYTHONUTF8` in the environment could not have carried this, it has to be a flag.
397
- argv = [sys.executable, "-I", "-S", "-X", "utf8", "-", str(pay_path), str(res_path)]
398
- try:
399
- done = subprocess.run(
400
- argv, input=_RUNNER, text=True, encoding="utf-8", errors="replace",
401
- cwd=work, env=_child_env(),
402
- stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout_s)
403
- except subprocess.TimeoutExpired:
404
- return _refusal("timeout",
405
- f"the script ran longer than {timeout_s:g} seconds and was stopped",
406
- started)
407
- except OSError as exc:
408
- return _refusal("no_sandbox",
409
- f"a script view could not be started on this deployment ({exc})",
410
- started)
411
-
412
- if not res_path.is_file():
413
- # The child died without writing an answer: an rlimit signal, an OOM kill, or a crash.
414
- # ⚠ NAMED BY ITS RETURN CODE rather than reported as a generic failure β€” a memory kill
415
- # and a bug in this file must not read identically to an operator.
416
- return _refusal(*_died(done), started)
417
- try:
418
- out = json.loads(res_path.read_text(encoding="utf-8"))
419
- except (OSError, ValueError) as exc:
420
- return _refusal("unreadable", f"the script's answer could not be read ({exc})",
421
- started)
422
-
423
- out["ms"] = int((time.monotonic() - started) * 1000)
424
- if out.get("ok"):
425
- spec_error = _check_spec(out.get("spec"))
426
- if spec_error:
427
- out.update(ok=False, code="bad_spec", message=spec_error, spec=None)
428
- if not probe:
429
- out.pop("probe", None)
430
- return out
431
-
432
-
433
- def _died(done):
434
- """`(code, message)` for a child that produced no answer."""
435
- rc = done.returncode
436
- tail = " ".join((done.stderr or "").split())[-300:]
437
- if rc in (-9, 137):
438
- return "memory", "the script was stopped for using too much memory"
439
- if rc in (-24, 152):
440
- return "timeout", "the script used more processor time than a script view is allowed"
441
- return "crashed", f"the script view engine stopped without an answer{': ' + tail if tail else ''}"
442
-
443
-
444
- def _refusal(code, message, started):
445
- return {"ok": False, "code": code, "message": message, "spec": None, "stdout": "",
446
- "truncated": False, "ms": int((time.monotonic() - started) * 1000),
447
- "caps": {"wallClock": True, "memory": False, "cpu": False}}
448
-
449
-
450
- def _check_spec(spec):
451
- """C3: a spec is a DESCRIPTION the client draws. Never HTML, never a script, never a URL.
452
-
453
- β›” THE CHECK IS ON THE KEYS, NOT ON THE STRING CONTENTS. Scanning values for `<script>` is a
454
- blacklist and would pass `<SCR` + `IPT>`; refusing a spec that carries an `html`, `script`,
455
- `src` or `onclick` key refuses the SHAPE that would let a renderer be talked into executing
456
- something. The vocabulary of legal `kind`s is the ROUTE's business (W36-T37) β€” this is the
457
- floor every caller gets whether or not the route above it remembers.
458
- """
459
- if not isinstance(spec, dict):
460
- return "the script emitted something that is not a view spec"
461
- try:
462
- blob = json.dumps(spec)
463
- except (TypeError, ValueError):
464
- return "the emitted view spec is not something the client can be sent"
465
- if len(blob.encode("utf-8", "replace")) > MAX_SPEC_BYTES:
466
- return (f"the emitted view spec is over {MAX_SPEC_BYTES // (1024 * 1024)} MB. A view spec "
467
- f"describes a picture; it is not where the rows go")
468
- banned = {"html", "innerhtml", "script", "src", "srcdoc", "href", "style", "onclick", "onload"}
469
- found = sorted(k for k in _keys_of(spec) if str(k).lower() in banned)
470
- if found:
471
- return (f"a view spec may not carry {', '.join(found)}. The client DRAWS a spec, so a "
472
- f"markup or URL key would be a script by another name")
473
- return None
474
-
475
-
476
- def _keys_of(value, depth=0):
477
- """Every key anywhere in a nested spec. Bounded, so a deep structure cannot spin this."""
478
- if depth > 12:
479
- return
480
- if isinstance(value, dict):
481
- for key, sub in value.items():
482
- yield key
483
- yield from _keys_of(sub, depth + 1)
484
- elif isinstance(value, (list, tuple)):
485
- for sub in value:
486
- yield from _keys_of(sub, depth + 1)
487
-
488
-
489
- # ══════════════════════════════════════════════ THE DOOR β€” C1 is the ONLY way to a row ═════════
490
- def run_view(user, table_key, source, st=None, **kw):
491
- """Fetch through C1 under `user`'s scope, then run the script over what came back (R5).
492
-
493
- ⭐⭐ THE FETCH HAPPENS IN THE PARENT AND ONLY ROWS CROSS INTO THE CHILD. That is wiring W1
494
- made structural: the child has no runtime to ask, no store handle to open and no credential
495
- to use, so "a script cannot read what its caller cannot read" is not a rule anybody has to
496
- keep β€” there is no second path for it to be broken through.
497
-
498
- β›” C1'S THREE EXCEPTIONS ARE ANSWERED, NEVER SWALLOWED. `UnknownTable`, `Denied` and
499
- `Unresolvable` mean three different things to a person; collapsing them into "no rows" is the
500
- silent-empty answer C1 was written to make impossible. `Unresolvable.as_limit()` is handed
501
- through in the words it was raised with β€” standing rule 1's second sentence, verbatim.
502
- """
503
- import core.perm_scope as perm_scope
504
-
505
- key = str(table_key or "")
506
- try:
507
- rows = perm_scope.scoped_table(user, key, st=st)
508
- fields = perm_scope.scoped_fields(user, key, st=st)
509
- except perm_scope.UnknownTable as exc:
510
- return _refusal("unknown_table", str(exc) or f"there is no database '{key}'",
511
- time.monotonic())
512
- except perm_scope.Denied as exc:
513
- return _refusal("denied", str(exc) or "this account may not read that database",
514
- time.monotonic())
515
- except perm_scope.Unresolvable as exc:
516
- out = _refusal("unresolvable", str(exc), time.monotonic())
517
- out["limit"] = exc.as_limit()
518
- return out
519
- return run(source, rows, fields, key, **kw)
 
1
+ """core/script_sandbox.py β€” WAVE 36 (R5 / R10, contract C1): running a tenant's OWN Python.
2
+
3
+ Owner item 6: *"Add code script as an interface (database View) so a user can build whatever they
4
+ want through the Agent chat interface."* Item 8: *"We need to really guardrail the reach of this
5
+ script. So let's really grill this down."* R10 ruled it SERVER-SIDE PYTHON after the trade was
6
+ stated, so this file is the guardrail, and one engine serves both items.
7
+
8
+ ════════════════════════════════════════════════════════════════════════════════════════════════
9
+ β›”β›” THE ONE PARAGRAPH TO READ BEFORE CHANGING ANYTHING HERE.
10
+
11
+ In-process CPython cannot deliver two of this ticket's clauses. An AST allow-list plus a curated
12
+ namespace stops import, file, network and environment access β€” but it **cannot cap memory and
13
+ cannot interrupt a runaway loop**, because a `while True:` in the same interpreter is not a slow
14
+ request, it is the tenant's ONE FastAPI process gone. So the script runs in a **SUBPROCESS**:
15
+ `resource.setrlimit` for address space and CPU, a hard wall-clock kill from the parent, and the
16
+ allow-list inside. Neither half is sufficient; both are load-bearing.
17
+
18
+ ⭐ AND THE SUBPROCESS RECEIVES **ROWS, NEVER A STORE**. The parent calls C1's `scoped_table` under
19
+ the CALLING user's record and serialises the result; the child imports nothing from this repo and
20
+ holds no credential, no runtime and no store handle. Wiring W1 ("the sandbox has no second store
21
+ path") is then true by CONSTRUCTION rather than by discipline, and it is checkable: the child
22
+ reports its own `sys.modules`, and no `core.*` name may appear in it.
23
+
24
+ β›” NEVER A BLACKLIST. Every rule below is an ALLOW-LIST β€” a set of node types, a set of attribute
25
+ names, a dict of builtins. A blacklist of dangerous spellings is bypassable by construction, and
26
+ the bypass is usually one string method away (`"{0.__class__}".format(x)` performs its attribute
27
+ lookup inside `format`, so there is no `ast.Attribute` node to refuse).
28
+ ════════════════════════════════════════════════════════════════════════════════════════════════
29
+
30
+ The two layers, and they refuse DIFFERENT things on purpose:
31
+
32
+ 1. `check_source()` β€” a pure function over source text. Refuses a construct the language offers
33
+ and this sandbox does not: `import`, `class`, `with`, `async`, `yield`, `global`, and every
34
+ attribute name outside `ALLOWED_ATTRS`.
35
+ 2. `SANDBOX_BUILTINS` β€” the names that resolve at all. `__import__`, `open`, `eval`, `exec`,
36
+ `compile`, `getattr`, `globals`, `vars` and `type` are simply absent, so a source that gets
37
+ past layer 1 still finds nothing to call.
38
+
39
+ ⚠ THAT DUPLICATION IS DELIBERATE AND IT CHANGES HOW THE GATE MUST BE WRITTEN. `import os` is
40
+ refused twice, so a negative control that drops ONE layer sees the other refuse and reports
41
+ green β€” the shape that already cost this wave one missed control in `routes_agent_harness`. So
42
+ each layer is tested AT ITS OWN BOUNDARY: `check_source()` is called directly on source strings,
43
+ and `run()` is driven end to end. An NC drops one entry from one frozenset and the matching
44
+ boundary goes red.
45
+ """
46
+ import ast
47
+ import json
48
+ import os
49
+ import subprocess
50
+ import sys
51
+ import tempfile
52
+ import time
53
+ from pathlib import Path
54
+
55
+ #: Wall clock, enforced by the PARENT with a kill. The one cap that works on every platform.
56
+ DEFAULT_TIMEOUT_S = 10.0
57
+
58
+ #: Address space for the child (`RLIMIT_AS`). POSIX only β€” see `run()`'s `caps` report.
59
+ DEFAULT_MEMORY_BYTES = 512 * 1024 * 1024
60
+
61
+ #: CPU seconds for the child (`RLIMIT_CPU`). POSIX only. Deliberately above the wall clock: the
62
+ #: wall-clock kill is the primary control and this is the backstop for a child that stops being
63
+ #: reachable. A CPU limit BELOW the timeout would make every slow script look like a CPU refusal.
64
+ DEFAULT_CPU_SECONDS = 15
65
+
66
+ #: What the script may print, in bytes. `print` is a curated builtin writing to a capped buffer,
67
+ #: and the child's real stdout goes to DEVNULL β€” so a script cannot fill a pipe, and anything
68
+ #: that escaped far enough to write to fd 1 has nowhere for it to land.
69
+ MAX_STDOUT_BYTES = 64 * 1024
70
+
71
+ #: The serialised ROW payload handed to the child. β›” A REFUSAL, NEVER A TRUNCATION (standing rule
72
+ #: 1): a short answer from a data tool is a wrong answer that looks right. Over this, `run()`
73
+ #: returns a named limit carrying its cause and a recommendation.
74
+ MAX_PAYLOAD_BYTES = 32 * 1024 * 1024
75
+
76
+ #: The emitted spec. A render spec is a description of a picture; one larger than this is data
77
+ #: pretending to be a description.
78
+ MAX_SPEC_BYTES = 2 * 1024 * 1024
79
+
80
+ MAX_SOURCE_BYTES = 128 * 1024
81
+
82
+
83
+ # ══════════════════════════════════════════════════════ LAYER 1 β€” the AST allow-list ═══════════
84
+ #: Every `ast` node class a script may contain. β›” THE ABSENCES ARE THE POLICY: `Import` /
85
+ #: `ImportFrom` (no module reaches the script), `ClassDef` (a class body is a namespace with its
86
+ #: own scoping rules and buys a data script nothing), `With` (a context manager is `__enter__`
87
+ #: by another spelling), `Global` / `Nonlocal` (rebinding the sandbox's own names), and every
88
+ #: `Async*` / `Await` / `Yield` form (this engine is synchronous; a coroutine that is never
89
+ #: awaited is a silent no-op that looks like a working script).
90
+ ALLOWED_NODES = frozenset("""
91
+ Module Expr Assign AugAssign AnnAssign NamedExpr Return Pass Break Continue Delete Assert Raise
92
+ If For While Try TryStar ExceptHandler FunctionDef Lambda arguments arg keyword
93
+ BoolOp BinOp UnaryOp IfExp Dict Set List Tuple Starred Subscript Slice Compare Call Attribute Name
94
+ Constant JoinedStr FormattedValue ListComp SetComp DictComp GeneratorExp comprehension
95
+ Load Store Del
96
+ And Or Not Invert UAdd USub
97
+ Add Sub Mult Div FloorDiv Mod Pow LShift RShift BitOr BitXor BitAnd MatMult
98
+ Eq NotEq Lt LtE Gt GtE Is IsNot In NotIn
99
+ """.split())
100
+
101
+ #: Every attribute name a script may READ or CALL. β›”β›” THIS IS THE LOAD-BEARING SET, and it is
102
+ #: an allow-list of NAMES rather than a refusal of dunders, because the interesting escapes are
103
+ #: ordinary-looking: `f.__globals__` on any function reaches the runner's own module namespace,
104
+ #: `e.__traceback__.tb_frame.f_globals` reaches it from an exception handler, and `().__class__`
105
+ #: reaches `object.__subclasses__`. None of those names is here, and neither is any name this
106
+ #: sandbox has not been asked for.
107
+ #: ⚠ `format` IS ABSENT DELIBERATELY. `"{0.__class__}".format(x)` performs the attribute lookup
108
+ #: INSIDE `str.format`, where no `ast.Attribute` node exists for layer 1 to see. f-strings are
109
+ #: fine β€” `f"{x.__class__}"` compiles to a real `Attribute` node and is refused.
110
+ ALLOWED_ATTRS = frozenset("""
111
+ append extend insert pop remove clear sort reverse copy count index
112
+ keys values items get setdefault update
113
+ add discard union intersection difference issubset issuperset
114
+ join split rsplit splitlines strip lstrip rstrip lower upper title capitalize casefold
115
+ replace startswith endswith find rfind zfill ljust rjust center partition removeprefix removesuffix
116
+ isdigit isalpha isalnum isspace isupper islower isnumeric
117
+ real imag numerator denominator
118
+ """.split())
119
+
120
+
121
+ class Refused(Exception):
122
+ """A named refusal: `code` for a caller to branch on, `message` for a person to read."""
123
+
124
+ def __init__(self, code, message):
125
+ self.code, self.message = code, message
126
+ super().__init__(f"{code}: {message}")
127
+
128
+
129
+ def _attr_ok(name):
130
+ """An attribute name passes only if it is on the list AND is not private.
131
+
132
+ ⚠ THE SECOND TEST IS NOT A BLACKLIST β€” it narrows an allow-list that already excludes every
133
+ private name. It is here so that adding a name to `ALLOWED_ATTRS` cannot open a dunder by
134
+ accident, which is the one edit a future reader is most likely to make in a hurry.
135
+ """
136
+ return name in ALLOWED_ATTRS and not name.startswith("_")
137
+
138
+
139
+ def check_source(source):
140
+ """LAYER 1. Return a `Refused` for source this sandbox will not run, or `None`.
141
+
142
+ ⭐ PURE, AND THAT IS WHAT MAKES IT TESTABLE AT ITS OWN BOUNDARY. It reads no file, spawns no
143
+ process and touches no store, so a gate can hand it a hundred hostile strings for free and an
144
+ NC can drop one entry from one frozenset and watch exactly this function change its answer.
145
+ """
146
+ text = str(source or "")
147
+ if len(text.encode("utf-8", "replace")) > MAX_SOURCE_BYTES:
148
+ return Refused("source_too_long",
149
+ f"a script view is at most {MAX_SOURCE_BYTES // 1024} KB of source")
150
+ try:
151
+ tree = ast.parse(text)
152
+ except SyntaxError as exc:
153
+ return Refused("syntax", f"line {exc.lineno or 0}: {exc.msg}")
154
+
155
+ for node in ast.walk(tree):
156
+ kind = type(node).__name__
157
+ if kind not in ALLOWED_NODES:
158
+ return Refused("refused_construct",
159
+ f"line {getattr(node, 'lineno', 0)}: this sandbox does not run "
160
+ f"{_english(kind)}")
161
+ if isinstance(node, ast.Attribute) and not _attr_ok(node.attr):
162
+ return Refused("refused_attribute",
163
+ f"line {getattr(node, 'lineno', 0)}: the attribute "
164
+ f"'{node.attr}' is not available inside a script view")
165
+ # β›” A NAME may not be private either. `_` prefixed names are the runner's own, and a
166
+ # script that could bind one could shadow the machinery it runs on top of.
167
+ if isinstance(node, ast.Name) and node.id.startswith("_"):
168
+ return Refused("reserved_name",
169
+ f"line {getattr(node, 'lineno', 0)}: names starting with an "
170
+ f"underscore are reserved by the sandbox")
171
+ if isinstance(node, (ast.FunctionDef, ast.arg, ast.ExceptHandler)) and str(
172
+ getattr(node, "name", None) or getattr(node, "arg", "") or "").startswith("_"):
173
+ return Refused("reserved_name",
174
+ f"line {getattr(node, 'lineno', 0)}: names starting with an "
175
+ f"underscore are reserved by the sandbox")
176
+ if isinstance(node, ast.keyword) and str(node.arg or "").startswith("_"):
177
+ return Refused("reserved_name",
178
+ f"line {getattr(node, 'lineno', 0)}: keyword arguments starting with "
179
+ f"an underscore are reserved by the sandbox")
180
+ return None
181
+
182
+
183
+ _ENGLISH = {
184
+ "Import": "an import", "ImportFrom": "an import", "ClassDef": "a class definition",
185
+ "With": "a with block", "AsyncWith": "a with block", "AsyncFor": "an async loop",
186
+ "AsyncFunctionDef": "an async function", "Await": "await", "Yield": "yield",
187
+ "YieldFrom": "yield from", "Global": "a global statement", "Nonlocal": "a nonlocal statement",
188
+ "Match": "a match statement",
189
+ }
190
+
191
+
192
+ def _english(kind):
193
+ return _ENGLISH.get(kind, f"a {kind} expression")
194
+
195
+
196
+ # ══════════════════════════════════════════ LAYER 2 β€” the namespace, and the child program ═════
197
+ #: The builtins a script may reach, BY NAME. Everything else is a `NameError` in the child.
198
+ #: β›” THE ABSENCES, again, are the policy: `__import__` `open` `eval` `exec` `compile` `input`
199
+ #: `getattr` `setattr` `delattr` `globals` `locals` `vars` `dir` `type` `super` `object` `help`
200
+ #: `exit` `breakpoint` `memoryview` `id`. Several are harmless on their own; each one is a step
201
+ #: on a published escape, and none has ever been asked for by a script that shapes rows.
202
+ #: ⚠ THE EXCEPTION CLASSES ARE HERE BECAUSE `try:` IS, and a `try` block whose `except` clause
203
+ #: cannot name what it catches is a construct that reads as supported and is not. They are safe
204
+ #: for the same reason everything else is: `Exception.__subclasses__` needs an attribute this
205
+ #: sandbox does not allow, so a class object in the namespace is a leaf, not a doorway.
206
+ SANDBOX_BUILTIN_NAMES = (
207
+ "abs all any bool bytes callable chr dict divmod enumerate filter float frozenset hash hex "
208
+ "int isinstance issubclass iter len list map max min next oct ord pow range repr reversed "
209
+ "round set slice sorted str sum tuple zip True False None "
210
+ "Exception ValueError TypeError KeyError IndexError ZeroDivisionError ArithmeticError "
211
+ "AttributeError StopIteration OverflowError"
212
+ ).split()
213
+
214
+ #: The literal program the child runs. It is TEXT rather than a module because the child must
215
+ #: import nothing from this repo: a module would be found on `sys.path` and would drag `core`
216
+ #: with it, which is exactly the second store path W1 forbids.
217
+ #: ⚠ Every name in here is underscore-prefixed and layer 1 refuses a script from binding one, so
218
+ #: the runner's own machinery cannot be shadowed by the source it executes.
219
+ _RUNNER = r'''
220
+ import json as _json, os as _os, sys as _sys
221
+
222
+ _pay = _json.loads(open(_sys.argv[1], "r", encoding="utf-8").read())
223
+ _out = {"ok": False, "code": "not_run", "message": "the script did not run",
224
+ "stdout": "", "spec": None, "caps": {"wallClock": True, "memory": False, "cpu": False}}
225
+
226
+ # ── the caps this platform can actually apply, reported either way (standing rule 1) ──────────
227
+ try:
228
+ import resource as _res
229
+ _mem = int(_pay["memoryBytes"])
230
+ _res.setrlimit(_res.RLIMIT_AS, (_mem, _mem))
231
+ _out["caps"]["memory"] = True
232
+ _cpu = int(_pay["cpuSeconds"])
233
+ _res.setrlimit(_res.RLIMIT_CPU, (_cpu, _cpu))
234
+ _out["caps"]["cpu"] = True
235
+ except Exception:
236
+ # `resource` is POSIX only. The wall-clock kill in the parent still applies, and `caps` says
237
+ # which of the three held, never a silent partial.
238
+ pass
239
+
240
+ _printed = []
241
+ _spent = [0]
242
+ _LIMIT = int(_pay["maxStdout"])
243
+
244
+
245
+ def _print(*_a, **_k):
246
+ _text = (_k.get("sep") or " ").join(str(_x) for _x in _a) + (_k.get("end") or "\n")
247
+ _room = _LIMIT - _spent[0]
248
+ if _room > 0:
249
+ _printed.append(_text[:_room])
250
+ _spent[0] += len(_text)
251
+
252
+
253
+ class _Refusal(Exception):
254
+ """The SANDBOX refusing, as distinct from the SCRIPT failing.
255
+
256
+ Without its own class these arrive as `ValueError`, indistinguishable from a `ValueError` the
257
+ script raised itself, and the answer then says "refused" about an ordinary bug in the tenant's
258
+ own code. Two different facts, two different codes.
259
+ """
260
+
261
+
262
+ _emitted = []
263
+
264
+
265
+ def _emit(_spec):
266
+ if not isinstance(_spec, dict):
267
+ raise _Refusal("emit() takes a view spec, which is a dictionary")
268
+ if _emitted:
269
+ raise _Refusal("emit() was already called; a script view emits exactly one view")
270
+ _emitted.append(_spec)
271
+
272
+
273
+ _rows = _pay["rows"]
274
+ _fields = _pay["fields"]
275
+ _bound = _pay["table"]
276
+
277
+
278
+ def _scoped_table(_table=None):
279
+ if _table is not None and str(_table) != _bound:
280
+ raise _Refusal(
281
+ "this script view is bound to the database '" + _bound + "' and asked for '"
282
+ + str(_table) + "'. A script view reads its own database only")
283
+ return [dict(_r) for _r in _rows]
284
+
285
+
286
+ def _scoped_fields():
287
+ return [dict(_f) for _f in _fields]
288
+
289
+
290
+ _ns = {"__builtins__": {_n: __builtins__[_n] if isinstance(__builtins__, dict)
291
+ else getattr(__builtins__, _n)
292
+ for _n in _pay["builtins"]}}
293
+ _ns["__builtins__"]["print"] = _print
294
+ _ns["print"] = _print
295
+ _ns["emit"] = _emit
296
+ _ns["scoped_table"] = _scoped_table
297
+ _ns["scoped_fields"] = _scoped_fields
298
+ _ns["table"] = _bound
299
+
300
+ try:
301
+ exec(compile(_pay["source"], "<script view>", "exec"), _ns)
302
+ if not _emitted:
303
+ _out.update(ok=False, code="no_view",
304
+ message="the script finished without calling emit(spec)")
305
+ else:
306
+ _out.update(ok=True, code="", message="", spec=_emitted[0])
307
+ except _Refusal as _e:
308
+ _out.update(ok=False, code="refused", message=str(_e)[:400])
309
+ except MemoryError:
310
+ _out.update(ok=False, code="memory",
311
+ message="the script used more memory than a script view is allowed")
312
+ except NameError as _e:
313
+ _out.update(ok=False, code="refused_name",
314
+ message=str(_e)[:200] + ". A script view may use only the names the sandbox "
315
+ "provides")
316
+ except BaseException as _e:
317
+ _out.update(ok=False, code="error",
318
+ message=type(_e).__name__ + ": " + str(_e)[:400])
319
+
320
+ _out["stdout"] = "".join(_printed)
321
+ _out["truncated"] = _spent[0] > _LIMIT
322
+ # ⭐ THE PROBE: what this child actually had. The PARENT strips it unless it was asked for, so a
323
+ # production run never carries it and a gate can still prove that no `core.*` module and no
324
+ # secret-shaped environment key was ever inside this process.
325
+ # ⚠ SNAPSHOTTED AFTER `exec`, and `os` is imported at the TOP so this list does not depend on
326
+ # dict-literal evaluation order. The first draft called `__import__("os")` inside this very
327
+ # expression, so whether `os` appeared depended on which value Python built first: a probe whose
328
+ # contents move with an unrelated edit is a probe a gate cannot assert against.
329
+ _out["probe"] = {"modules": sorted(_sys.modules), "env": sorted(_os.environ)}
330
+ open(_sys.argv[2], "w", encoding="utf-8").write(_json.dumps(_out, default=str))
331
+ '''
332
+
333
+
334
+ def _child_env():
335
+ """The child's WHOLE environment. An allow-list of two keys, and neither is a credential.
336
+
337
+ β›” NOT `os.environ.copy()` MINUS SOMETHING. A subtractive environment ships every key nobody
338
+ thought to name: `HF_TOKEN`, `ODOO_PASSWORD`, `ANTHROPIC_API_KEY` and whatever the next
339
+ connector adds. The three names below are here because Python will not start on Windows
340
+ without them; on Linux this returns `{}` and the child runs with no environment at all.
341
+ """
342
+ env = {}
343
+ for name in ("SystemRoot", "SYSTEMROOT", "WINDIR"):
344
+ if os.environ.get(name):
345
+ env[name] = os.environ[name]
346
+ return env
347
+
348
+
349
+ def run(source, rows, fields, table_key, *, timeout_s=DEFAULT_TIMEOUT_S,
350
+ memory_bytes=DEFAULT_MEMORY_BYTES, cpu_seconds=DEFAULT_CPU_SECONDS, probe=False):
351
+ """Run ONE script over rows that are ALREADY scoped. Returns C3's envelope plus `caps`.
352
+
353
+ {ok, code, message, spec, stdout, truncated, ms, caps: {wallClock, memory, cpu}}
354
+
355
+ β›” THIS FUNCTION NEVER TOUCHES A STORE, AND THAT IS THE POINT: it takes rows. `run_view()`
356
+ below is the door that fetches them through C1; keeping the two apart is what lets a gate
357
+ drive the sandbox with no tenant, no runtime and no credential anywhere in the process.
358
+
359
+ ⚠ `caps` IS PART OF THE ANSWER, NOT DEBUG OUTPUT. On Windows `resource` does not exist, so
360
+ the memory and CPU limits are NOT applied and this says so. A caller that reports `ok:true`
361
+ without reading `caps` is claiming an enforcement that did not happen (standing rule 1).
362
+ """
363
+ started = time.monotonic()
364
+ refusal = check_source(source)
365
+ if refusal is not None:
366
+ return _refusal(refusal.code, refusal.message, started)
367
+
368
+ payload = {"source": str(source or ""), "rows": rows, "fields": fields,
369
+ "table": str(table_key or ""), "builtins": SANDBOX_BUILTIN_NAMES,
370
+ "maxStdout": MAX_STDOUT_BYTES, "memoryBytes": int(memory_bytes),
371
+ "cpuSeconds": int(cpu_seconds)}
372
+ try:
373
+ blob = json.dumps(payload, default=str)
374
+ except (TypeError, ValueError) as exc:
375
+ return _refusal("bad_rows", f"these rows cannot be handed to a script ({exc})", started)
376
+ if len(blob.encode("utf-8", "replace")) > MAX_PAYLOAD_BYTES:
377
+ # β›” REPORTED, NOT TRUNCATED (standing rule 1's second sentence): cause and recommendation,
378
+ # in the words the owner asked for, rather than a quietly short answer.
379
+ return _refusal(
380
+ "payload_too_large",
381
+ f"this database's rows are larger than the {MAX_PAYLOAD_BYTES // (1024 * 1024)} MB a "
382
+ f"script view can be handed at once. Narrow the view with a filter, or raise the "
383
+ f"sandbox payload limit for this deployment", started)
384
+
385
+ with tempfile.TemporaryDirectory(prefix="aios-script-") as work:
386
+ pay_path = Path(work) / "payload.json"
387
+ res_path = Path(work) / "result.json"
388
+ pay_path.write_text(blob, encoding="utf-8")
389
+ # `-I` isolates the interpreter (no PYTHON* env, no user site), `-S` skips site-packages,
390
+ # and the program arrives on STDIN so there is no file for anything to import it as.
391
+ # β›” `-X utf8` AND AN EXPLICIT `encoding` ARE NOT TIDINESS. Without them this pipe is
392
+ # encoded with the parent's locale codec, which on this Windows box is cp1252: the runner
393
+ # text below cannot be represented in it and `subprocess.run` died with
394
+ # `UnicodeEncodeError` before the child ever started. A sandbox whose behaviour depends on
395
+ # the operator's locale is a sandbox with two behaviours. `-I` implies `-E`, so
396
+ # `PYTHONUTF8` in the environment could not have carried this, it has to be a flag.
397
+ argv = [sys.executable, "-I", "-S", "-X", "utf8", "-", str(pay_path), str(res_path)]
398
+ try:
399
+ done = subprocess.run(
400
+ argv, input=_RUNNER, text=True, encoding="utf-8", errors="replace",
401
+ cwd=work, env=_child_env(),
402
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout_s)
403
+ except subprocess.TimeoutExpired:
404
+ return _refusal("timeout",
405
+ f"the script ran longer than {timeout_s:g} seconds and was stopped",
406
+ started)
407
+ except OSError as exc:
408
+ return _refusal("no_sandbox",
409
+ f"a script view could not be started on this deployment ({exc})",
410
+ started)
411
+
412
+ if not res_path.is_file():
413
+ # The child died without writing an answer: an rlimit signal, an OOM kill, or a crash.
414
+ # ⚠ NAMED BY ITS RETURN CODE rather than reported as a generic failure β€” a memory kill
415
+ # and a bug in this file must not read identically to an operator.
416
+ return _refusal(*_died(done), started)
417
+ try:
418
+ out = json.loads(res_path.read_text(encoding="utf-8"))
419
+ except (OSError, ValueError) as exc:
420
+ return _refusal("unreadable", f"the script's answer could not be read ({exc})",
421
+ started)
422
+
423
+ out["ms"] = int((time.monotonic() - started) * 1000)
424
+ if out.get("ok"):
425
+ spec_error = _check_spec(out.get("spec"))
426
+ if spec_error:
427
+ out.update(ok=False, code="bad_spec", message=spec_error, spec=None)
428
+ if not probe:
429
+ out.pop("probe", None)
430
+ return out
431
+
432
+
433
+ def _died(done):
434
+ """`(code, message)` for a child that produced no answer."""
435
+ rc = done.returncode
436
+ tail = " ".join((done.stderr or "").split())[-300:]
437
+ if rc in (-9, 137):
438
+ return "memory", "the script was stopped for using too much memory"
439
+ if rc in (-24, 152):
440
+ return "timeout", "the script used more processor time than a script view is allowed"
441
+ return "crashed", f"the script view engine stopped without an answer{': ' + tail if tail else ''}"
442
+
443
+
444
+ def _refusal(code, message, started):
445
+ return {"ok": False, "code": code, "message": message, "spec": None, "stdout": "",
446
+ "truncated": False, "ms": int((time.monotonic() - started) * 1000),
447
+ "caps": {"wallClock": True, "memory": False, "cpu": False}}
448
+
449
+
450
+ def _check_spec(spec):
451
+ """C3: a spec is a DESCRIPTION the client draws. Never HTML, never a script, never a URL.
452
+
453
+ β›” THE CHECK IS ON THE KEYS, NOT ON THE STRING CONTENTS. Scanning values for `<script>` is a
454
+ blacklist and would pass `<SCR` + `IPT>`; refusing a spec that carries an `html`, `script`,
455
+ `src` or `onclick` key refuses the SHAPE that would let a renderer be talked into executing
456
+ something. The vocabulary of legal `kind`s is the ROUTE's business (W36-T37) β€” this is the
457
+ floor every caller gets whether or not the route above it remembers.
458
+ """
459
+ if not isinstance(spec, dict):
460
+ return "the script emitted something that is not a view spec"
461
+ try:
462
+ blob = json.dumps(spec)
463
+ except (TypeError, ValueError):
464
+ return "the emitted view spec is not something the client can be sent"
465
+ if len(blob.encode("utf-8", "replace")) > MAX_SPEC_BYTES:
466
+ return (f"the emitted view spec is over {MAX_SPEC_BYTES // (1024 * 1024)} MB. A view spec "
467
+ f"describes a picture; it is not where the rows go")
468
+ banned = {"html", "innerhtml", "script", "src", "srcdoc", "href", "style", "onclick", "onload"}
469
+ found = sorted(k for k in _keys_of(spec) if str(k).lower() in banned)
470
+ if found:
471
+ return (f"a view spec may not carry {', '.join(found)}. The client DRAWS a spec, so a "
472
+ f"markup or URL key would be a script by another name")
473
+ return None
474
+
475
+
476
+ def _keys_of(value, depth=0):
477
+ """Every key anywhere in a nested spec. Bounded, so a deep structure cannot spin this."""
478
+ if depth > 12:
479
+ return
480
+ if isinstance(value, dict):
481
+ for key, sub in value.items():
482
+ yield key
483
+ yield from _keys_of(sub, depth + 1)
484
+ elif isinstance(value, (list, tuple)):
485
+ for sub in value:
486
+ yield from _keys_of(sub, depth + 1)
487
+
488
+
489
+ # ══════════════════════════════════════════════ THE DOOR β€” C1 is the ONLY way to a row ═════════
490
+ def run_view(user, table_key, source, st=None, **kw):
491
+ """Fetch through C1 under `user`'s scope, then run the script over what came back (R5).
492
+
493
+ ⭐⭐ THE FETCH HAPPENS IN THE PARENT AND ONLY ROWS CROSS INTO THE CHILD. That is wiring W1
494
+ made structural: the child has no runtime to ask, no store handle to open and no credential
495
+ to use, so "a script cannot read what its caller cannot read" is not a rule anybody has to
496
+ keep β€” there is no second path for it to be broken through.
497
+
498
+ β›” C1'S THREE EXCEPTIONS ARE ANSWERED, NEVER SWALLOWED. `UnknownTable`, `Denied` and
499
+ `Unresolvable` mean three different things to a person; collapsing them into "no rows" is the
500
+ silent-empty answer C1 was written to make impossible. `Unresolvable.as_limit()` is handed
501
+ through in the words it was raised with β€” standing rule 1's second sentence, verbatim.
502
+ """
503
+ import core.perm_scope as perm_scope
504
+
505
+ key = str(table_key or "")
506
+ try:
507
+ rows = perm_scope.scoped_table(user, key, st=st)
508
+ fields = perm_scope.scoped_fields(user, key, st=st)
509
+ except perm_scope.UnknownTable as exc:
510
+ return _refusal("unknown_table", str(exc) or f"there is no database '{key}'",
511
+ time.monotonic())
512
+ except perm_scope.Denied as exc:
513
+ return _refusal("denied", str(exc) or "this account may not read that database",
514
+ time.monotonic())
515
+ except perm_scope.Unresolvable as exc:
516
+ out = _refusal("unresolvable", str(exc), time.monotonic())
517
+ out["limit"] = exc.as_limit()
518
+ return out
519
+ return run(source, rows, fields, key, **kw)
platform/core/shared_overlay.py CHANGED
@@ -1,327 +1,521 @@
1
- """The TENANT-WIDE overlay stratum β€” one value per (row, column) for the whole workspace.
2
-
3
- Wave 29, item 20 / owner ruling R11, contract C5. The sibling of `core/table_store.py`, which
4
- holds the PER-USER strata (`store.get(key)[username]`) and always has.
5
-
6
- β›” THE DEFECT THIS EXISTS FOR IS NOT "SHARING WOULD BE NICE" β€” IT IS THAT A SHARED VIEW SILENTLY
7
- WIDENS. `modules/product_data.py` already states it, in the comment above the supplier master:
8
-
9
- "A user-created field and its values live in the PER-USER strata; only VIEWS are shared. So a
10
- shared 'Buy list' view that filtered on a user-created column would, for every OTHER account,
11
- name a column that does not exist β€” and an unknown column is an INACTIVE condition in the
12
- tri-state engine, which IGNORES it and therefore WIDENS. The buy list would silently show the
13
- whole catalogue to everyone but its author."
14
-
15
- That is why the four supplier columns were frozen as read-only CONTRACT columns rather than made
16
- editable, and why the owner's ask ("turn this Excel sheet into a User created Field that we can
17
- edit") has been parked for two waves. A column whose value is the same for every reader makes the
18
- shared view mean ONE thing, which is the precondition for editing it at all.
19
-
20
- ────────────────────────────────────────────────────────────────────────────────────────────────
21
- β›”β›” THIS MODULE IS NOT A PERMISSION WALL, AND MUST NEVER BECOME ONE BY ACCIDENT.
22
-
23
- It refuses no reader and no writer. The caller has already answered "may this session open this
24
- surface" (`user_tables.may_open`, `Session.require`, the BU pool) and this module answers only
25
- "what is stored". Two different questions; one of them belongs upstream, where the session is.
26
-
27
- What it DOES enforce is the one thing a caller can get wrong silently:
28
-
29
- ⭐ `cells(table_key, pids)` β€” `pids` IS REQUIRED, POSITIONALLY, AND THERE IS NO "EVERYTHING"
30
- CALL. A default of "all rows" is the widening hazard above wearing a friendly face: the
31
- caller passes the row set it has ALREADY scoped, so a cell for a row this reader may not see
32
- cannot come back to be merged. There is deliberately no `all_cells()` to reach for; a caller
33
- that genuinely needs the lot passes the lot, in writing, where a reviewer can see it.
34
-
35
- The row wall then holds twice over, because `aios_grid.rows_from_pool` iterates the POOL and looks
36
- each pid UP in the overlay β€” never the reverse. A cell for a row outside the pool has nothing to
37
- attach to. This module is built to keep that true rather than to re-implement it.
38
- ────────────────────────────────────────────────────────────────────────────────────────────────
39
-
40
- RESIDENCY. Its own bucket, `<table_key>__shared`, beside the per-user one β€” never a `__shared__`
41
- member inside it. Two reasons, and the first is `product_data.py`'s own argument for separate
42
- topic keys ("separate store keys make that structurally impossible, which is the whole reason the
43
- table-page factory exists"): a per-user reader iterating usernames cannot encounter shared data
44
- when there is no shared data in that bucket to encounter. The second is cost β€” the per-user bucket
45
- carries every user's views, and a cell edit should not read-modify-write all of it.
46
-
47
- SHAPE, chosen to be drop-in:
48
-
49
- {"fields": {field_key: Field}, # the tenant-wide column DEFINITIONS
50
- "cells": {"<pid>": {field_key: value}}} # exactly `rows_from_pool(overlays=...)`'s shape
51
-
52
- `cells()` returns STRING pid keys for that reason β€” `rows_from_pool` does `overlays.get(str(pid))`,
53
- so a caller merges the shared stratum over the per-user one with one `dict.update` and cannot get
54
- the key type wrong. (`table_store` stores `{str(pid): {...}}` too; one shape, three readers.)
55
- """
56
- import core.store as store
57
-
58
- #: The suffix that turns a topic's per-user workspace key into its shared one. Callers pass the
59
- #: key they ALREADY hold (`product_data.TABLE_KEY`, `f'{ut_key}_table_workspace'`) β€” one
60
- #: identifier for both strata, so there is no second naming convention to get wrong.
61
- BUCKET_SUFFIX = '__shared'
62
-
63
- #: One cell value's ceiling, matching the `json` field kind's. A shared cell is read by every user
64
- #: in the tenant, so an unbounded one is an unbounded cost for all of them.
65
- MAX_VALUE_CHARS = 32_768
66
-
67
-
68
- def bucket(table_key):
69
- """The store key this topic's shared stratum lives under."""
70
- key = str(table_key or '').strip()
71
- if not key:
72
- raise ValueError('shared_overlay: a table_key is required β€” it names the bucket')
73
- return f'{key}{BUCKET_SUFFIX}'
74
-
75
-
76
- def _st(st):
77
- return st if st is not None else store
78
-
79
-
80
- def _read(table_key, st=None):
81
- """The whole stratum, always in its full two-key shape. Lenient like every other display
82
- read: an unreachable store degrades to "nothing shared yet", never to an exception on a page
83
- that would otherwise render."""
84
- try:
85
- data = _st(st).get(bucket(table_key)) or {}
86
- except Exception:
87
- data = {}
88
- return {'fields': dict(data.get('fields') or {}),
89
- 'cells': dict(data.get('cells') or {})}
90
-
91
-
92
- def _write(table_key, change, st=None, flush='async'):
93
- """Read-modify-write one shared stratum.
94
-
95
- `flush='async'` by default for the same reason `table_store._update` uses it: a typed cell
96
- lands here inside the request round-trip, and the historical synchronous hub commit cost
97
- seconds per keystroke. Structural writes (a field definition) pass `flush='sync'` β€” they are
98
- rare, and a lost column definition is a worse failure than a lost keystroke.
99
- """
100
- def _up(data):
101
- data = data if isinstance(data, dict) else {}
102
- data.setdefault('fields', {})
103
- data.setdefault('cells', {})
104
- change(data)
105
- return data
106
- return _st(st).update(bucket(table_key), _up, flush=flush)
107
-
108
-
109
- def _pid(pid):
110
- """Pids are ints everywhere in the grid and strings in JSON. ONE coercion, here, so a caller
111
- passing either cannot write a row that a reader keyed the other way never finds."""
112
- return str(int(pid))
113
-
114
-
115
- def _value(value):
116
- """A cell holds a SCALAR. The Row contract is scalar on every surface β€” the grid, the filter
117
- engine, formulas, export β€” and the `json` kind is a validated STRING rather than an object.
118
-
119
- β›” A dict or a list RAISES rather than being dropped. This is called by our own code, so a
120
- non-scalar is a programming error, and silently storing nothing would surface later as "the
121
- shared column is blank for everyone" with no failure anywhere near the cause.
122
- """
123
- if isinstance(value, (dict, list, tuple, set)):
124
- raise ValueError(f'shared_overlay: a cell holds a scalar, not {type(value).__name__}')
125
- if value is None or isinstance(value, bool) or isinstance(value, (int, float)):
126
- return value
127
- text = str(value)
128
- return text[:MAX_VALUE_CHARS]
129
-
130
-
131
- # ----------------------------------------------------------------- the column DEFINITIONS
132
- def fields(table_key, st=None):
133
- """`{field_key: Field}` β€” the columns this topic shares tenant-wide.
134
-
135
- Unscoped on purpose, and it is the one thing here that is: a shared column's EXISTENCE is
136
- tenant-wide by definition β€” that is the whole feature, and it is what stops a shared view
137
- naming a column half the workspace lacks. Its VALUES are scoped by `cells()`.
138
- """
139
- return _read(table_key, st)['fields']
140
-
141
-
142
- def is_shared(table_key, field_key, st=None):
143
- """Is this column's value tenant-wide? The predicate a caller uses to decide WHICH stratum to
144
- read and write β€” one evaluator, so the read path and the write path cannot disagree about
145
- where a given column lives ([[one-evaluator-per-question]])."""
146
- return str(field_key or '') in _read(table_key, st)['fields']
147
-
148
-
149
- def put_field(table_key, field_key, defn, st=None):
150
- """Declare (or redefine) a shared column. Returns what was stored."""
151
- key = str(field_key or '').strip()
152
- if not key:
153
- raise ValueError('shared_overlay: a field needs a key')
154
- entry = dict(defn or {})
155
- entry['key'] = key
156
-
157
- def _add(data):
158
- data['fields'][key] = entry
159
-
160
- _write(table_key, _add, st, flush='sync')
161
- return entry
162
-
163
-
164
- def drop_field(table_key, field_key, st=None):
165
- """Remove a shared column AND every stored value for it.
166
-
167
- β›” The cells go with the definition, exactly as `table_store.delete_field` scrubs its own:
168
- orphaned values would silently resurface if the key were ever reused β€” and a resurrected
169
- value in a TENANT-WIDE stratum reappears for everybody at once.
170
- """
171
- key = str(field_key or '').strip()
172
- if not key:
173
- return False
174
- hit = [False]
175
-
176
- def _drop(data):
177
- hit[0] = data['fields'].pop(key, None) is not None
178
- for row in data['cells'].values():
179
- if isinstance(row, dict):
180
- row.pop(key, None)
181
- # ⚠ AND DROP THE ROWS THAT ARE NOW EMPTY. `cells()` already skips a blank row, so this
182
- # changes nothing a reader sees β€” but this bucket is read by every user in the tenant,
183
- # and a `{}` per pid that once held a since-deleted column is dead weight that only ever
184
- # grows. Caught by `prune()` reporting rows nobody could see as dropped.
185
- for pid in [p for p, row in data['cells'].items() if not row]:
186
- data['cells'].pop(pid, None)
187
-
188
- _write(table_key, _drop, st, flush='sync')
189
- return hit[0]
190
-
191
-
192
- # ----------------------------------------------------------------------------- the VALUES
193
- def cells(table_key, pids, st=None):
194
- """`{"<pid>": {field_key: value}}` for the rows named by `pids` β€” and ONLY those.
195
-
196
- β›” `pids` IS REQUIRED. See the module header: this is the row wall expressed as a signature,
197
- so a caller cannot get "every shared cell in the tenant" by forgetting an argument. Pass the
198
- pool you have already scoped for this session.
199
-
200
- An empty `pids` legitimately returns `{}` β€” a reader with no rows sees no cells, which is the
201
- correct answer rather than a special case.
202
- """
203
- if pids is None:
204
- raise TypeError('shared_overlay.cells: pids is required β€” pass the row set you have '
205
- 'already scoped for this reader (there is deliberately no "all" call)')
206
- wanted = {_pid(p) for p in pids}
207
- if not wanted:
208
- return {}
209
- stored = _read(table_key, st)['cells']
210
- return {pid: dict(row) for pid, row in stored.items()
211
- if pid in wanted and isinstance(row, dict) and row}
212
-
213
-
214
- def snapshot(table_key, pids, st=None):
215
- """`(fields, cells)` for `pids` β€” the shared stratum's SCHEMA and VALUES, from ONE read.
216
-
217
- β›”β›” THIS EXISTS BECAUSE TWO READS OF THIS BUCKET CAN DISAGREE, AND THE DISAGREEMENT OPENS A
218
- WALL. `perm_scope._enrich_for_wall` needs both halves: the schema decides WHICH user columns
219
- the row wall may answer, and the cells are the values it answers with. Taking them as two
220
- `st.get()` calls leaves a gap in which a concurrent write can land, and a wave-40 adversarial
221
- probe drove exactly that: with the second read returning an emptied `cells`, a blank is merged
222
- for a key the store cannot really serve and `custom_x is not West` flips from denying every row
223
- to ADMITTING a row whose true value IS West. `is empty` does the same. The set and the values
224
- must come from one snapshot or they are not talking about the same tenant state.
225
-
226
- ⚠ `pids` IS REQUIRED, exactly as in `cells()` β€” the row wall expressed as a signature, so no
227
- caller can get "every shared cell in the tenant" by forgetting an argument.
228
- """
229
- if pids is None:
230
- raise TypeError('shared_overlay.snapshot: pids is required β€” pass the row set you have '
231
- 'already scoped for this reader (there is deliberately no "all" call)')
232
- stored = _read(table_key, st)
233
- wanted = {_pid(p) for p in pids}
234
- served = {pid: dict(row) for pid, row in stored['cells'].items()
235
- if pid in wanted and isinstance(row, dict) and row} if wanted else {}
236
- return dict(stored['fields']), served
237
-
238
-
239
- def put_cell(table_key, pid, field_key, value, st=None):
240
- """Write ONE shared cell. Returns the value as stored (which may be truncated)."""
241
- return put_cells(table_key, pid, {field_key: value}, st=st).get(str(field_key or ''))
242
-
243
-
244
- def put_cells(table_key, pid, values, st=None):
245
- """Write several shared cells on one row, in one store update. Returns what was stored."""
246
- row_id = _pid(pid)
247
- clean = {str(k): _value(v) for k, v in dict(values or {}).items() if str(k)}
248
- if not clean:
249
- return {}
250
-
251
- def _patch(data):
252
- data['cells'].setdefault(row_id, {}).update(clean)
253
-
254
- _write(table_key, _patch, st)
255
- return clean
256
-
257
-
258
- def put_rows(table_key, rows, st=None):
259
- """Write shared cells on MANY rows in ONE store update. Returns `{row_id: {key: value}}`.
260
-
261
- ⭐⭐ THE WHOLE POINT IS THE *ONE*, AND IT IS NOT AN OPTIMISATION. `put_cells` is one row per
262
- store write, so a 1,397-row catalog import is 1,397 download-modify-upload cycles against one
263
- JSON document. This repo has a MEASURED scar for that shape: **18 writes against one document
264
- under the store's coalescing single-flight landed ZERO while answering 200 eighteen times.**
265
- Batching is what makes a bulk import land at all, not what makes it fast.
266
-
267
- ⚠ It deliberately takes the WHOLE SET rather than accepting a stream: a caller that loops over
268
- this function has simply rebuilt `put_cells` with extra steps, and the failure it reintroduces
269
- is silent. If the set does not fit in memory it does not fit in this store either β€” that is a
270
- signal to change substrate, not to chunk.
271
-
272
- β›” NO CAP HERE, ON PURPOSE. The bound belongs at the DOOR, where a caller identity and a
273
- reportable refusal exist (`routes_grid.bulk_cells`). A silent ceiling in a store primitive is
274
- exactly the shape the standing no-cap rule forbids.
275
- """
276
- clean = {}
277
- for pid, values in dict(rows or {}).items():
278
- row_id = _pid(pid)
279
- cells_for_row = {str(k): _value(v) for k, v in dict(values or {}).items() if str(k)}
280
- if cells_for_row:
281
- clean.setdefault(row_id, {}).update(cells_for_row)
282
- if not clean:
283
- return {}
284
-
285
- def _patch(data):
286
- for row_id, values in clean.items():
287
- data['cells'].setdefault(row_id, {}).update(values)
288
-
289
- # `flush='sync'` because a bulk import must be durable when the call returns: the caller is a
290
- # script that will report "1,397 rows written" and exit, and an async flush would make that
291
- # sentence a prediction rather than a fact.
292
- _write(table_key, _patch, st, flush='sync')
293
- return clean
294
-
295
-
296
- def clear_row(table_key, pid, st=None):
297
- """Forget every shared cell on one row. True when there was something to forget."""
298
- row_id = _pid(pid)
299
- hit = [False]
300
-
301
- def _clear(data):
302
- hit[0] = data['cells'].pop(row_id, None) is not None
303
-
304
- _write(table_key, _clear, st)
305
- return hit[0]
306
-
307
-
308
- def prune(table_key, live_pids, st=None):
309
- """Drop shared cells for rows that no longer exist. Returns how many rows were dropped.
310
-
311
- β›” NOT a scoped read wearing another name, and the difference is the whole reason `cells()`
312
- refuses to serve everything: `live_pids` here means *every row this TABLE has*, which is a
313
- fact about the data, whereas `pids` in `cells()` means *every row this READER may see*, which
314
- is a fact about the session. Calling this with one session's pool would delete the shared
315
- values of every row that session cannot see. The name says which is which; so does this note.
316
- """
317
- keep = {_pid(p) for p in live_pids}
318
- dropped = [0]
319
-
320
- def _prune(data):
321
- gone = [pid for pid in data['cells'] if pid not in keep]
322
- for pid in gone:
323
- data['cells'].pop(pid, None)
324
- dropped[0] = len(gone)
325
-
326
- _write(table_key, _prune, st, flush='sync')
327
- return dropped[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The TENANT-WIDE overlay stratum β€” one value per (row, column) for the whole workspace.
2
+
3
+ Wave 29, item 20 / owner ruling R11, contract C5. The sibling of `core/table_store.py`, which
4
+ holds the PER-USER strata (`store.get(key)[username]`) and always has.
5
+
6
+ β›” THE DEFECT THIS EXISTS FOR IS NOT "SHARING WOULD BE NICE" β€” IT IS THAT A SHARED VIEW SILENTLY
7
+ WIDENS. `modules/product_data.py` already states it, in the comment above the supplier master:
8
+
9
+ "A user-created field and its values live in the PER-USER strata; only VIEWS are shared. So a
10
+ shared 'Buy list' view that filtered on a user-created column would, for every OTHER account,
11
+ name a column that does not exist β€” and an unknown column is an INACTIVE condition in the
12
+ tri-state engine, which IGNORES it and therefore WIDENS. The buy list would silently show the
13
+ whole catalogue to everyone but its author."
14
+
15
+ That is why the four supplier columns were frozen as read-only CONTRACT columns rather than made
16
+ editable, and why the owner's ask ("turn this Excel sheet into a User created Field that we can
17
+ edit") has been parked for two waves. A column whose value is the same for every reader makes the
18
+ shared view mean ONE thing, which is the precondition for editing it at all.
19
+
20
+ ────────────────────────────────────────────────────────────────────────────────────────────────
21
+ β›”β›” THIS MODULE IS NOT A PERMISSION WALL, AND MUST NEVER BECOME ONE BY ACCIDENT.
22
+
23
+ It refuses no reader and no writer. The caller has already answered "may this session open this
24
+ surface" (`user_tables.may_open`, `Session.require`, the BU pool) and this module answers only
25
+ "what is stored". Two different questions; one of them belongs upstream, where the session is.
26
+
27
+ What it DOES enforce is the one thing a caller can get wrong silently:
28
+
29
+ ⭐ `cells(table_key, pids)` β€” `pids` IS REQUIRED, POSITIONALLY, AND THERE IS NO "EVERYTHING"
30
+ CALL. A default of "all rows" is the widening hazard above wearing a friendly face: the
31
+ caller passes the row set it has ALREADY scoped, so a cell for a row this reader may not see
32
+ cannot come back to be merged. There is deliberately no `all_cells()` to reach for; a caller
33
+ that genuinely needs the lot passes the lot, in writing, where a reviewer can see it.
34
+
35
+ The row wall then holds twice over, because `aios_grid.rows_from_pool` iterates the POOL and looks
36
+ each pid UP in the overlay β€” never the reverse. A cell for a row outside the pool has nothing to
37
+ attach to. This module is built to keep that true rather than to re-implement it.
38
+ ────────────────────────────────────────────────────────────────────────────────────────────────
39
+
40
+ RESIDENCY. Its own bucket, `<table_key>__shared`, beside the per-user one β€” never a `__shared__`
41
+ member inside it. Two reasons, and the first is `product_data.py`'s own argument for separate
42
+ topic keys ("separate store keys make that structurally impossible, which is the whole reason the
43
+ table-page factory exists"): a per-user reader iterating usernames cannot encounter shared data
44
+ when there is no shared data in that bucket to encounter. The second is cost β€” the per-user bucket
45
+ carries every user's views, and a cell edit should not read-modify-write all of it.
46
+
47
+ SHAPE, chosen to be drop-in:
48
+
49
+ {"fields": {field_key: Field}, # the tenant-wide column DEFINITIONS
50
+ "cells": {"<pid>": {field_key: value}}} # exactly `rows_from_pool(overlays=...)`'s shape
51
+
52
+ `cells()` returns STRING pid keys for that reason β€” `rows_from_pool` does `overlays.get(str(pid))`,
53
+ so a caller merges the shared stratum over the per-user one with one `dict.update` and cannot get
54
+ the key type wrong. (`table_store` stores `{str(pid): {...}}` too; one shape, three readers.)
55
+ """
56
+ import core.store as store
57
+
58
+ #: The suffix that turns a topic's per-user workspace key into its shared one. Callers pass the
59
+ #: key they ALREADY hold (`product_data.TABLE_KEY`, `f'{ut_key}_table_workspace'`) β€” one
60
+ #: identifier for both strata, so there is no second naming convention to get wrong.
61
+ BUCKET_SUFFIX = '__shared'
62
+
63
+ #: One cell value's ceiling, matching the `json` field kind's. A shared cell is read by every user
64
+ #: in the tenant, so an unbounded one is an unbounded cost for all of them.
65
+ MAX_VALUE_CHARS = 32_768
66
+
67
+ # ══════════════════════════ W41-T04 / CONTRACT C3 β€” ONE COLUMN-CREATION DOOR ══════════════════
68
+ #
69
+ # ⭐⭐ WHAT THIS ALLOWLIST IS FOR, AND WHY IT IS *HERE* RATHER THAN AT EACH CALLER. Before it,
70
+ # `put_field` stored whatever it was handed, verbatim. That is what let
71
+ # `routes_customers::route_order_write` become a documented BYPASS: the column menu's own door
72
+ # (`aios_grid._field_extras`) is a strict allowlist and WOULD HAVE STRIPPED the route bag, so the
73
+ # route feature reached around it into this function instead. A second minting path with different
74
+ # rules is the defect C3 exists to close, and the fix is not to police the caller: it is to widen
75
+ # THIS allowlist so the bypass has nothing left to buy.
76
+ #
77
+ # β›” THE `route` BAG IS THE WIDENING. It is the one key `_field_extras` cannot carry and the only
78
+ # reason the bypass was written. With `route` on the list, `route_order_write` is an ordinary
79
+ # caller of an ordinary door.
80
+ #
81
+ # ⚠ MINT-STRICT, REDEFINE CARRY-FORWARD, and the asymmetry is deliberate. On a MINT the allowlist
82
+ # is the whole story: an unrecognised key is a caller mistake, and storing it would be the wave-19
83
+ # `image` failure in reverse (a definition carrying state nothing reads). On a REDEFINE, a key the
84
+ # STORE already holds that this list does not know is carried through untouched, because most
85
+ # callers here are read-modify-write pass-throughs (`field_permissions` reconcile,
86
+ # `routes_shares.put_share`, the route rename) and an allowlist that deleted a stored key on the
87
+ # way past would be silent data loss on a tenant-wide document.
88
+ #
89
+ # ⚠ IT MUST AGREE WITH `aios_grid._field_extras`, WHICH IS THE SIBLING ALLOWLIST ON THE PER-USER
90
+ # DOOR. That one is narrower on purpose (it is a client passthrough and validates each value);
91
+ # this one is the union of the client `Field` interface (`web/src/customer-grid/types.ts`), the
92
+ # canonical contract (`aios_grid_fields.json`), the server-only grant mark
93
+ # (`perm_scope.FIELD_GRANT_MARK`) and `route`. `verify_field_doors.py` derives the union of keys
94
+ # every writer actually passes and reds if this set does not cover it, so the two cannot drift
95
+ # into silent stripping without a gate saying so.
96
+ ALLOWED_FIELD_KEYS = frozenset({
97
+ # identity and shape
98
+ 'key', 'label', 'type', 'source', 'kind',
99
+ # the description, in both its spellings: `note` is the user-authored one and `description`
100
+ # the canonical contract default. The client renders `note || description`.
101
+ 'note', 'description', '_note',
102
+ # display and behaviour
103
+ 'format', 'agg', 'options', 'optionColors', 'colorCodeOptions', 'default', 'pinned',
104
+ 'filterable', 'multi', 'max', 'derived', 'preset', 'custom', 'profile', 'link',
105
+ # the created strata's computation bags
106
+ 'formula', 'measure', 'metric', 'rollup', 'geocode', 'code', 'automation', 'aiEnrich',
107
+ 'scope',
108
+ # governance
109
+ 'createdBy', 'permissions', 'shared', 'granted', 'sharedRole', 'editRole', 'editRequestId',
110
+ # label-collision bookkeeping written by `table_store.save_field`
111
+ 'labelCorrectedFrom', 'labelCorrectionId',
112
+ # ⭐ W41-T04: the route-order fingerprint bag. THE WIDENING that retires the bypass.
113
+ 'route',
114
+ })
115
+
116
+ #: What `mint_field` requires of a column being born, named so a refusal can quote them.
117
+ #: `createdBy` because an ownerless governed column is unmanageable (`shares.may_administer`
118
+ #: fails closed on one, so nobody can ever share it); a grant list because "shared with nobody"
119
+ #: has to be STORED to be different from "never shared"; a description because a tenant-wide
120
+ #: column is read by people who did not make it.
121
+ MINT_REQUIRES = ('createdBy', 'grants', 'description')
122
+
123
+ #: The route bag's own ceiling. Small on purpose: it is a solve fingerprint, not a payload.
124
+ MAX_ROUTE_CHARS = 256
125
+
126
+
127
+ def bucket(table_key):
128
+ """The store key this topic's shared stratum lives under."""
129
+ key = str(table_key or '').strip()
130
+ if not key:
131
+ raise ValueError('shared_overlay: a table_key is required β€” it names the bucket')
132
+ return f'{key}{BUCKET_SUFFIX}'
133
+
134
+
135
+ def _st(st):
136
+ return st if st is not None else store
137
+
138
+
139
+ def _read(table_key, st=None):
140
+ """The whole stratum, always in its full two-key shape. Lenient like every other display
141
+ read: an unreachable store degrades to "nothing shared yet", never to an exception on a page
142
+ that would otherwise render."""
143
+ try:
144
+ data = _st(st).get(bucket(table_key)) or {}
145
+ except Exception:
146
+ data = {}
147
+ return {'fields': dict(data.get('fields') or {}),
148
+ 'cells': dict(data.get('cells') or {})}
149
+
150
+
151
+ def _write(table_key, change, st=None, flush='async'):
152
+ """Read-modify-write one shared stratum.
153
+
154
+ `flush='async'` by default for the same reason `table_store._update` uses it: a typed cell
155
+ lands here inside the request round-trip, and the historical synchronous hub commit cost
156
+ seconds per keystroke. Structural writes (a field definition) pass `flush='sync'` β€” they are
157
+ rare, and a lost column definition is a worse failure than a lost keystroke.
158
+ """
159
+ def _up(data):
160
+ data = data if isinstance(data, dict) else {}
161
+ data.setdefault('fields', {})
162
+ data.setdefault('cells', {})
163
+ change(data)
164
+ return data
165
+ return _st(st).update(bucket(table_key), _up, flush=flush)
166
+
167
+
168
+ def _pid(pid):
169
+ """Pids are ints everywhere in the grid and strings in JSON. ONE coercion, here, so a caller
170
+ passing either cannot write a row that a reader keyed the other way never finds."""
171
+ return str(int(pid))
172
+
173
+
174
+ def _value(value):
175
+ """A cell holds a SCALAR. The Row contract is scalar on every surface β€” the grid, the filter
176
+ engine, formulas, export β€” and the `json` kind is a validated STRING rather than an object.
177
+
178
+ β›” A dict or a list RAISES rather than being dropped. This is called by our own code, so a
179
+ non-scalar is a programming error, and silently storing nothing would surface later as "the
180
+ shared column is blank for everyone" with no failure anywhere near the cause.
181
+ """
182
+ if isinstance(value, (dict, list, tuple, set)):
183
+ raise ValueError(f'shared_overlay: a cell holds a scalar, not {type(value).__name__}')
184
+ if value is None or isinstance(value, bool) or isinstance(value, (int, float)):
185
+ return value
186
+ text = str(value)
187
+ return text[:MAX_VALUE_CHARS]
188
+
189
+
190
+ # ----------------------------------------------------------------- the column DEFINITIONS
191
+ def fields(table_key, st=None):
192
+ """`{field_key: Field}` β€” the columns this topic shares tenant-wide.
193
+
194
+ Unscoped on purpose, and it is the one thing here that is: a shared column's EXISTENCE is
195
+ tenant-wide by definition β€” that is the whole feature, and it is what stops a shared view
196
+ naming a column half the workspace lacks. Its VALUES are scoped by `cells()`.
197
+ """
198
+ return _read(table_key, st)['fields']
199
+
200
+
201
+ def is_shared(table_key, field_key, st=None):
202
+ """Is this column's value tenant-wide? The predicate a caller uses to decide WHICH stratum to
203
+ read and write β€” one evaluator, so the read path and the write path cannot disagree about
204
+ where a given column lives ([[one-evaluator-per-question]])."""
205
+ return str(field_key or '') in _read(table_key, st)['fields']
206
+
207
+
208
+ def _clean_route(bag):
209
+ """The `route` fingerprint bag, reduced to what a column definition may carry.
210
+
211
+ ⭐ THIS IS THE WIDENING THE BYPASS EXISTED TO BUY. `routes_customers` writes
212
+ `{inputsHash, roundTrip, startPid, stops, depot, solvedAt}` onto the DEFINITION rather than the
213
+ rows, because one `planRoute` run fingerprints the whole cohort identically and `_value` raises
214
+ on a dict anyway. Everything here is a scalar except `depot`, which is one flat level
215
+ (`{address, lat, lon}` or None), so the shape is bounded without being guessed at.
216
+
217
+ Returns None for anything that is not a dict, so a caller cannot store a `route` marker that
218
+ carries nothing.
219
+ """
220
+ if not isinstance(bag, dict):
221
+ return None
222
+
223
+ def _scalar(value):
224
+ if value is None or isinstance(value, bool) or isinstance(value, (int, float)):
225
+ return value
226
+ return str(value)[:MAX_ROUTE_CHARS]
227
+
228
+ out = {}
229
+ for name, value in bag.items():
230
+ name = str(name)[:40]
231
+ if not name:
232
+ continue
233
+ if name == 'depot':
234
+ out['depot'] = ({str(k)[:40]: _scalar(v) for k, v in value.items()
235
+ if not isinstance(v, (dict, list, tuple, set))}
236
+ if isinstance(value, dict) else None)
237
+ continue
238
+ if isinstance(value, (dict, list, tuple, set)):
239
+ continue
240
+ out[name] = _scalar(value)
241
+ return out or None
242
+
243
+
244
+ def put_field(table_key, field_key, defn, st=None):
245
+ """Declare (or redefine) a shared column, THROUGH THE ALLOWLIST. Returns what was stored.
246
+
247
+ ⭐⭐ W41-T04 / CONTRACT C3. This is the one function that writes a tenant-wide column
248
+ definition, and since this ticket it is a DOOR rather than a passthrough: see
249
+ `ALLOWED_FIELD_KEYS` for what it carries, why `route` is on the list, and why a MINT is strict
250
+ while a REDEFINE carries a stored stranger through.
251
+
252
+ ⚠ IT DOES NOT REQUIRE THE GOVERNANCE TRIPLE, AND THAT IS STATED HERE RATHER THAN LEFT TO BE
253
+ DISCOVERED. `mint_field` below is the entrance that requires `createdBy`, a grant list and a
254
+ description; this one cannot, because sixteen of the seventeen writers in the tree predate the
255
+ requirement and a raise here would refuse definitions that three ROSTER gates
256
+ (`platform_perm_scope`, `web_optimism`, `api_scopes`) mint in their fixtures today. Making the
257
+ triple a runtime refusal on this function is a real change and it needs those call sites moved
258
+ first; `verify_field_doors.py` reports the gap by name so it stays visible.
259
+ """
260
+ key = str(field_key or '').strip()
261
+ if not key:
262
+ raise ValueError('shared_overlay: a field needs a key')
263
+ incoming = dict(defn or {})
264
+ kept = {name: value for name, value in incoming.items() if name in ALLOWED_FIELD_KEYS}
265
+ route = _clean_route(incoming.get('route'))
266
+ if route:
267
+ kept['route'] = route
268
+ else:
269
+ kept.pop('route', None)
270
+ kept['key'] = key
271
+ # ⚠ SEEDED WITH THE ALLOWLISTED PAYLOAD, not left empty. `_write` only runs `_add` if the
272
+ # store actually invokes its callback, and the old body returned its `entry` either way.
273
+ # `grid_events.field_upsert` assigns this return straight back over `field` and then reads
274
+ # `createdBy` off it to claim the grant, so an empty answer on an unavailable store would
275
+ # hand ownership of the column to whoever happened to be saving.
276
+ stored = dict(kept)
277
+
278
+ def _add(data):
279
+ prior = data['fields'].get(key)
280
+ # ⚠ ONLY THE STRANGERS ARE CARRIED. A key this allowlist DOES know follows the caller's
281
+ # payload exactly, so clearing a formula or a note by omission keeps working the way it
282
+ # did before this door existed; a key it does not know is one the store already held and
283
+ # nothing here is entitled to delete.
284
+ entry = ({name: value for name, value in prior.items()
285
+ if name not in ALLOWED_FIELD_KEYS} if isinstance(prior, dict) else {})
286
+ entry.update(kept)
287
+ entry['key'] = key
288
+ data['fields'][key] = entry
289
+ stored.clear()
290
+ stored.update(entry)
291
+
292
+ _write(table_key, _add, st, flush='sync')
293
+ return dict(stored)
294
+
295
+
296
+ def mint_field(table_key, field_key, defn=None, *, created_by, grants, description,
297
+ grant_topic, st=None):
298
+ """⭐⭐ THE ONE COLUMN-CREATION DOOR (contract C3). Mint a governed tenant-wide column.
299
+
300
+ It requires the three things a column read by the whole tenant cannot be born without, and it
301
+ REFUSES BY NAME rather than storing a definition that is missing one:
302
+
303
+ `created_by` who owns it. `shares.may_administer` fails closed on an ownerless record, so
304
+ a governed column with no creator can never be shared by anybody, ever.
305
+ `grants` the grant list, and an EMPTY LIST IS A VALID ANSWER: `set_grants` keeps a
306
+ record with an owner and no entries, so "shared with nobody" is stored and is
307
+ a different fact from "never shared". `None` is the refusal, not `[]`.
308
+ `description` what the column means, for the people who did not make it.
309
+
310
+ β›” IT REFUSES TO OVERWRITE. A mint that finds the key already there is not a mint, and
311
+ answering 200 on one would let a second door quietly redefine a column somebody else owns.
312
+ Redefining is `put_field`.
313
+
314
+ ⭐ THE DEFINITION LANDS BEFORE THE CLAIM, AND THE ORDER IS THE SAFE ONE. If `set_grants`
315
+ fails, the column is already marked governed and nobody holds a grant, so only an admin sees
316
+ it: recoverable. The other order leaves a grant on a column that does not exist.
317
+ """
318
+ key = str(field_key or '').strip()
319
+ if not key:
320
+ raise ValueError('shared_overlay.mint_field: a field needs a key')
321
+ missing = []
322
+ if not str(created_by or '').strip():
323
+ missing.append('createdBy')
324
+ if grants is None or not isinstance(grants, (list, tuple)):
325
+ missing.append('grants')
326
+ if not str(description or '').strip():
327
+ missing.append('description')
328
+ if missing:
329
+ raise ValueError(
330
+ 'shared_overlay.mint_field refuses to create the column ' + key + ': '
331
+ + ', '.join(missing) + ' is required. A tenant-wide column is read by people who did '
332
+ 'not make it, so it is born with an owner, a grant list (an empty one means shared '
333
+ 'with nobody, which is a stored fact) and a description.')
334
+ if is_shared(table_key, key, st=st):
335
+ raise ValueError(
336
+ 'shared_overlay.mint_field refuses to create the column ' + key + ': this database '
337
+ 'already has one under that key. Changing a column that exists is put_field.')
338
+
339
+ entry = dict(defn or {})
340
+ entry['key'] = key
341
+ entry['createdBy'] = str(created_by).strip()
342
+ entry['note'] = str(description).strip()
343
+ entry['shared'] = True
344
+ entry['granted'] = True
345
+ stored = put_field(table_key, key, entry, st=st)
346
+
347
+ try:
348
+ import core.shares as shares
349
+ shares.set_grants('field', shares.field_oid(str(grant_topic or table_key), key),
350
+ list(grants), owner=entry['createdBy'], st=st)
351
+ except Exception: # noqa: BLE001
352
+ # Fails CLOSED: the column is marked governed and nobody holds a grant, so it is
353
+ # admin-and-creator only until somebody shares it. Recoverable; the other order is not.
354
+ pass
355
+ return stored
356
+
357
+
358
+ def drop_field(table_key, field_key, st=None):
359
+ """Remove a shared column AND every stored value for it.
360
+
361
+ β›” The cells go with the definition, exactly as `table_store.delete_field` scrubs its own:
362
+ orphaned values would silently resurface if the key were ever reused β€” and a resurrected
363
+ value in a TENANT-WIDE stratum reappears for everybody at once.
364
+ """
365
+ key = str(field_key or '').strip()
366
+ if not key:
367
+ return False
368
+ hit = [False]
369
+
370
+ def _drop(data):
371
+ hit[0] = data['fields'].pop(key, None) is not None
372
+ for row in data['cells'].values():
373
+ if isinstance(row, dict):
374
+ row.pop(key, None)
375
+ # ⚠ AND DROP THE ROWS THAT ARE NOW EMPTY. `cells()` already skips a blank row, so this
376
+ # changes nothing a reader sees β€” but this bucket is read by every user in the tenant,
377
+ # and a `{}` per pid that once held a since-deleted column is dead weight that only ever
378
+ # grows. Caught by `prune()` reporting rows nobody could see as dropped.
379
+ for pid in [p for p, row in data['cells'].items() if not row]:
380
+ data['cells'].pop(pid, None)
381
+
382
+ _write(table_key, _drop, st, flush='sync')
383
+ return hit[0]
384
+
385
+
386
+ # ----------------------------------------------------------------------------- the VALUES
387
+ def cells(table_key, pids, st=None):
388
+ """`{"<pid>": {field_key: value}}` for the rows named by `pids` β€” and ONLY those.
389
+
390
+ β›” `pids` IS REQUIRED. See the module header: this is the row wall expressed as a signature,
391
+ so a caller cannot get "every shared cell in the tenant" by forgetting an argument. Pass the
392
+ pool you have already scoped for this session.
393
+
394
+ An empty `pids` legitimately returns `{}` β€” a reader with no rows sees no cells, which is the
395
+ correct answer rather than a special case.
396
+ """
397
+ if pids is None:
398
+ raise TypeError('shared_overlay.cells: pids is required β€” pass the row set you have '
399
+ 'already scoped for this reader (there is deliberately no "all" call)')
400
+ wanted = {_pid(p) for p in pids}
401
+ if not wanted:
402
+ return {}
403
+ stored = _read(table_key, st)['cells']
404
+ return {pid: dict(row) for pid, row in stored.items()
405
+ if pid in wanted and isinstance(row, dict) and row}
406
+
407
+
408
+ def snapshot(table_key, pids, st=None):
409
+ """`(fields, cells)` for `pids` β€” the shared stratum's SCHEMA and VALUES, from ONE read.
410
+
411
+ β›”β›” THIS EXISTS BECAUSE TWO READS OF THIS BUCKET CAN DISAGREE, AND THE DISAGREEMENT OPENS A
412
+ WALL. `perm_scope._enrich_for_wall` needs both halves: the schema decides WHICH user columns
413
+ the row wall may answer, and the cells are the values it answers with. Taking them as two
414
+ `st.get()` calls leaves a gap in which a concurrent write can land, and a wave-40 adversarial
415
+ probe drove exactly that: with the second read returning an emptied `cells`, a blank is merged
416
+ for a key the store cannot really serve and `custom_x is not West` flips from denying every row
417
+ to ADMITTING a row whose true value IS West. `is empty` does the same. The set and the values
418
+ must come from one snapshot or they are not talking about the same tenant state.
419
+
420
+ ⚠ `pids` IS REQUIRED, exactly as in `cells()` β€” the row wall expressed as a signature, so no
421
+ caller can get "every shared cell in the tenant" by forgetting an argument.
422
+ """
423
+ if pids is None:
424
+ raise TypeError('shared_overlay.snapshot: pids is required β€” pass the row set you have '
425
+ 'already scoped for this reader (there is deliberately no "all" call)')
426
+ stored = _read(table_key, st)
427
+ wanted = {_pid(p) for p in pids}
428
+ served = {pid: dict(row) for pid, row in stored['cells'].items()
429
+ if pid in wanted and isinstance(row, dict) and row} if wanted else {}
430
+ return dict(stored['fields']), served
431
+
432
+
433
+ def put_cell(table_key, pid, field_key, value, st=None):
434
+ """Write ONE shared cell. Returns the value as stored (which may be truncated)."""
435
+ return put_cells(table_key, pid, {field_key: value}, st=st).get(str(field_key or ''))
436
+
437
+
438
+ def put_cells(table_key, pid, values, st=None):
439
+ """Write several shared cells on one row, in one store update. Returns what was stored."""
440
+ row_id = _pid(pid)
441
+ clean = {str(k): _value(v) for k, v in dict(values or {}).items() if str(k)}
442
+ if not clean:
443
+ return {}
444
+
445
+ def _patch(data):
446
+ data['cells'].setdefault(row_id, {}).update(clean)
447
+
448
+ _write(table_key, _patch, st)
449
+ return clean
450
+
451
+
452
+ def put_rows(table_key, rows, st=None):
453
+ """Write shared cells on MANY rows in ONE store update. Returns `{row_id: {key: value}}`.
454
+
455
+ ⭐⭐ THE WHOLE POINT IS THE *ONE*, AND IT IS NOT AN OPTIMISATION. `put_cells` is one row per
456
+ store write, so a 1,397-row catalog import is 1,397 download-modify-upload cycles against one
457
+ JSON document. This repo has a MEASURED scar for that shape: **18 writes against one document
458
+ under the store's coalescing single-flight landed ZERO while answering 200 eighteen times.**
459
+ Batching is what makes a bulk import land at all, not what makes it fast.
460
+
461
+ ⚠ It deliberately takes the WHOLE SET rather than accepting a stream: a caller that loops over
462
+ this function has simply rebuilt `put_cells` with extra steps, and the failure it reintroduces
463
+ is silent. If the set does not fit in memory it does not fit in this store either β€” that is a
464
+ signal to change substrate, not to chunk.
465
+
466
+ β›” NO CAP HERE, ON PURPOSE. The bound belongs at the DOOR, where a caller identity and a
467
+ reportable refusal exist (`routes_grid.bulk_cells`). A silent ceiling in a store primitive is
468
+ exactly the shape the standing no-cap rule forbids.
469
+ """
470
+ clean = {}
471
+ for pid, values in dict(rows or {}).items():
472
+ row_id = _pid(pid)
473
+ cells_for_row = {str(k): _value(v) for k, v in dict(values or {}).items() if str(k)}
474
+ if cells_for_row:
475
+ clean.setdefault(row_id, {}).update(cells_for_row)
476
+ if not clean:
477
+ return {}
478
+
479
+ def _patch(data):
480
+ for row_id, values in clean.items():
481
+ data['cells'].setdefault(row_id, {}).update(values)
482
+
483
+ # `flush='sync'` because a bulk import must be durable when the call returns: the caller is a
484
+ # script that will report "1,397 rows written" and exit, and an async flush would make that
485
+ # sentence a prediction rather than a fact.
486
+ _write(table_key, _patch, st, flush='sync')
487
+ return clean
488
+
489
+
490
+ def clear_row(table_key, pid, st=None):
491
+ """Forget every shared cell on one row. True when there was something to forget."""
492
+ row_id = _pid(pid)
493
+ hit = [False]
494
+
495
+ def _clear(data):
496
+ hit[0] = data['cells'].pop(row_id, None) is not None
497
+
498
+ _write(table_key, _clear, st)
499
+ return hit[0]
500
+
501
+
502
+ def prune(table_key, live_pids, st=None):
503
+ """Drop shared cells for rows that no longer exist. Returns how many rows were dropped.
504
+
505
+ β›” NOT a scoped read wearing another name, and the difference is the whole reason `cells()`
506
+ refuses to serve everything: `live_pids` here means *every row this TABLE has*, which is a
507
+ fact about the data, whereas `pids` in `cells()` means *every row this READER may see*, which
508
+ is a fact about the session. Calling this with one session's pool would delete the shared
509
+ values of every row that session cannot see. The name says which is which; so does this note.
510
+ """
511
+ keep = {_pid(p) for p in live_pids}
512
+ dropped = [0]
513
+
514
+ def _prune(data):
515
+ gone = [pid for pid in data['cells'] if pid not in keep]
516
+ for pid in gone:
517
+ data['cells'].pop(pid, None)
518
+ dropped[0] = len(gone)
519
+
520
+ _write(table_key, _prune, st, flush='sync')
521
+ return dropped[0]
platform/core/shares.py CHANGED
@@ -1,560 +1,584 @@
1
- """core/shares.py β€” ONE grant registry for every shareable object (wave 20, owner ruling R10).
2
-
3
- WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
4
- already use** (specific users or everyone; role = view | edit), plus one manage-access editor
5
- that can add or revoke people later, on any of the three.
6
-
7
- WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
8
- (`core/table_store.py`) and that stays β€” moving it would rewrite every stored view for no gain.
9
- But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
10
- definition; giving each its own grant field would put the same three-line permission decision in
11
- three files owned by two sessions, which is how the three drift. One registry, one predicate,
12
- three callers.
13
-
14
- shares.set_grants(kind, oid, entries, owner=…, granter=…, st=…) # replaces the whole set
15
- shares.grants(kind, oid, st=…) # -> {'owner': str, 'entries': [...]}
16
- shares.role_for(kind, oid, user, is_admin=…, st=…) # -> 'owner'|'edit'|'view'|None
17
- shares.max_grantable_role(kind, oid, user, …) # -> 'edit'|'view'|None (R4's ceiling)
18
- shares.shared_with(user, kind=…, st=…) # -> [oid] this user was granted
19
-
20
- THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
21
- `edit` = may also change the object's CONTENT.
22
-
23
- ⭐⭐ AND ON A VIEW, `edit` NOW ALSO MEANS "MAY RE-SHARE" β€” OWNER RULING R4, instruction 4 (*"Edit
24
- View so a member can share a View as well, not just an admin"*), built as W40-T02. This REVERSES
25
- the flat owner-or-admin rule this paragraph used to state, and it reverses it for ONE kind:
26
- `RESHARE_KINDS` below is where the widening is spelled, once, and `folder` / `database` / `field`
27
- are untouched β€” still the owner's or an admin's, for the reason given at that constant.
28
-
29
- β›” WHICH HALF OF THE OLD PROTECTION SURVIVES, BECAUSE THE FEAR IT NAMED WAS REAL AND ONLY HALF OF
30
- IT IS ANSWERED. The sentence here used to be *"a collaborator who could rewrite grants could grant
31
- themselves sole ownership of somebody else's object, or quietly widen a users-scoped share to
32
- everyone"*. Taking those one at a time:
33
- * **SOLE OWNERSHIP IS BLOCKED β€” and now stated rather than incidental.** The owner is STICKY in
34
- `set_grants` and `routes_shares.put_share` passes the EXISTING owner straight back through, so
35
- a re-share cannot transfer ownership and no grantee can lock the creator out of their own view.
36
- Ownership moves for nobody, by any path this registry offers.
37
- * **WIDENING IS NOW BLOCKED TOO β€” OWNER RULING 2026-08-24, D-473.** β›” THE SENTENCE HERE USED
38
- TO PRICE THIS AS A COST R4 ACCEPTED: *"an `edit` grantee may add somebody β€” `*` included β€” at
39
- `view`"*. Measured and reported as such, and the owner's answer was *"no a re-sharer can only
40
- share to specific people"*. So a re-sharer may name PEOPLE and may not reach `EVERYONE`, and
41
- the second half of the old fear is closed rather than merely capped. The ROLE ceiling below
42
- still stands beside it β€” they are two different questions and both are asked.
43
- * **THE ROLE CEILING SURVIVES UNCHANGED.** A re-share may never exceed the role the re-sharer
44
- holds, and the STRICTER reading of that ships: an `edit` grantee may add somebody at `view`,
45
- and can never mint `edit` access for anyone. Handing out `edit` stays the owner's (or an
46
- admin's) alone. A `view` grantee still cannot share at all. `max_grantable_role` is that
47
- ceiling, and `routes_shares.put_share` is where it bites.
48
- * **AND REVOCATION IS BOUNDED BY PROVENANCE β€” OWNER RULING 2026-08-24, D-474.** *"a re-sharer
49
- can only unshare the people it shared to"*. R4 bounded the role a re-sharer may HAND OUT and
50
- said nothing about what they may TAKE AWAY, so an `edit` grantee could `PUT []` and leave the
51
- view shared with nobody. Answering that needs a fact the record did not carry, which is what
52
- the `by` member on each entry is (`_clean_entries` / `set_grants`' `granter`). β›” An entry
53
- with NO `by` β€” every grant written before this change β€” is the OWNER's to revoke and nobody
54
- else's: a re-sharer must never revoke a grant they cannot prove they made.
55
-
56
- β›” AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β€” junk in the bucket, a missing
57
- owner, an unknown role string all resolve to None rather than to a default that opens something.
58
- [[aios-permissioning]]: no fail-open defaults, ever.
59
-
60
- ⚠ THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
61
- session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
62
- (`core.store`) is tenant #0 and exists for the same reason it does everywhere else β€” the ~28
63
- callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
64
- NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
65
- """
66
- import core.store as store
67
-
68
- #: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
69
- #: is a question across kinds β€” the "Shared with me" folder (R10) is exactly that query, and
70
- #: three separate buckets would make it three reads that can disagree about what a user can see.
71
- SHARES_KEY = 'object_shares'
72
-
73
- #: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
74
- #: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
75
- #:
76
- #: ⭐⭐ W38-T16 β€” `field` IS THE FOURTH, AND NOTHING IN THIS FILE BRANCHES ON IT. Every function
77
- #: below treats `kind` as an opaque bucket key (`_check_kind / grants / set_grants / role_for /
78
- #: may_see / may_edit / may_administer / shared_with / drop_objects`), so the kind's whole cost
79
- #: here is this tuple member. That is the point of one registry: the new object's WALL is written
80
- #: once in `core.perm_scope`, and its DOORS once in `routes_shares.py` β€” never a fourth
81
- #: permission decision in a fourth file ([[one-evaluator-per-question]]).
82
- KINDS = ('view', 'folder', 'database', 'field')
83
-
84
- #: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
85
- #: Spelled as a single character so it can never collide with a username (usernames are lower-case
86
- #: and non-empty by `core/users.py`, and are checked against this explicitly below).
87
- #:
88
- #: β›”β›” **AND WHAT THAT MEANS DEPENDS ON THE KIND β€” THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
89
- #: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
90
- #: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
91
- #: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
92
- #: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
93
- #: whole point:
94
- #: * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β€”
95
- #: the sentence holds. `require_session` plus the topic's own gate run first, and the
96
- #: receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
97
- #: merged, so a grant can only narrow-or-equal what that account could already reach.
98
- #: * `kind='database'` on a `ut_*` table β€” ⭐⭐ **THE SENTENCE HOLDS HERE TOO NOW, AND THAT IS
99
- #: W36-T21 / OWNER RULING R6 (audit S-8, CLOSED).** It did not until wave 36, and the reason
100
- #: is worth keeping: `routes_admin._clean_perms` 400'd any key outside
101
- #: `("customer_data", "product_data")`, so no row filter and no hidden field could even be
102
- #: DECLARED for a user table, `routes_tables.py` made zero `perm_scope` calls, and it passed
103
- #: `hidden_keys=frozenset()`. A `database` grant was therefore ALL-OR-NOTHING β€” every row,
104
- #: every column β€” and this registry was the only wall behind it.
105
- #:
106
- #: ⭐ WHAT CHANGED: `perm_scope.scoped_table` (contract C1) is the ONE door to any database's
107
- #: rows. `routes_tables` applies the permanent filter before `pids` is taken and the transitive
108
- #: hidden-field closure after `workspace_wire`, on EVERY `ut_*` read β€” the same code that walls
109
- #: `customer_data` β€” and a door that cannot apply them REFUSES rather than serving the lot. So a
110
- #: `database` grant is once again bounded by a second wall: it decides WHETHER an account reaches
111
- #: the database, and C1 decides WHICH rows and columns it then sees. An `*` grant still admits
112
- #: every account in the tenant, and each of them still only sees what their own wall allows.
113
- #:
114
- #: ⚠ `routes_shares.py`'s module docstring carries the OLD sentence at the other door and is in
115
- #: no wave-36 fence β€” the audit's own fix was "say so at both", so one of the two is now stale.
116
- #: Booked in `mailbox/C.md` (C-14) rather than edited across a fence
117
- #: [[two-gates-can-assert-opposite-things]].
118
- EVERYONE = '*'
119
-
120
- ROLES = ('view', 'edit')
121
-
122
- #: ⭐⭐ THE KINDS AN `edit` GRANTEE MAY RE-SHARE β€” R4 / W40-T02, and it is `view` ALONE.
123
- #:
124
- #: R4's words are "may re-share **A VIEW**", and the widening goes no further than the ruling
125
- #: names. That restraint is the whole reason this is a tuple rather than a bare `role == 'edit'`
126
- #: arm inside `may_administer`: every predicate in this file treats `kind` as an opaque bucket key
127
- #: (see the note on `KINDS`), so an UNGATED widening would reach `folder`, `database` and `field`
128
- #: in the same line β€” handing an `edit` grantee on a `ut_*` DATABASE the power to re-share the
129
- #: whole table. The asymmetry is one of blast radius: a view is one saved SELECTION, opened through
130
- #: the receiver's own module wall and row scope; a database grant admits an account to a database.
131
- #: `routes_shares.py`'s module docstring carries that argument at the door where it is enforced.
132
- #:
133
- #: ⚠ ONE SPELLING, DELIBERATELY. `may_administer` and `max_grantable_role` both consult this, so
134
- #: "who may open the editor" and "what may they hand out" cannot come apart about the same kind.
135
- RESHARE_KINDS = ('view',)
136
-
137
-
138
- class GrantsChanged(Exception):
139
- """A grant write was decided against a record that is no longer the stored one.
140
-
141
- Raised by `set_grants` when its `expect` does not match what is in the store at write time.
142
- The door turns it into a 409 rather than a 500: it is not an error in the request, it is two
143
- people editing the same sharing list at once, and the honest answer is to say so.
144
- """
145
-
146
-
147
- #: β›”β›” A FIELD's OBJECT ID IS TOPIC-QUALIFIED, AND THE SEPARATOR IS DECLARED HERE SO THERE IS ONE
148
- #: SPELLING OF IT. A bare field key is NOT unique: `notes` exists on a dozen databases, and a
149
- #: grant stored under it would admit a grantee to every `notes` column in the tenant at once β€”
150
- #: the widening direction, silently, forever. `table_key` is the qualifier because it is the same
151
- #: identifier `shared_overlay.bucket()` already keys the values by, so the grant and the data it
152
- #: governs are named by the same string ([[one-question-two-normalizers]]).
153
- #:
154
- #: ⚠ A `ut_*` key and a registry topic key both match `[a-z0-9_]+` and neither can contain `:`,
155
- #: so the split below is unambiguous in both directions.
156
- FIELD_OID_SEP = ':'
157
-
158
-
159
- def field_oid(table_key, field_key):
160
- """`"<table_key>:<field_key>"` β€” the share id of ONE column on ONE database."""
161
- table = str(table_key or '').strip()
162
- field = str(field_key or '').strip()
163
- if not table or not field:
164
- raise ValueError('shares.field_oid: a field share names BOTH a database and a column. '
165
- 'A bare field key repeats across tables and would grant all of them')
166
- return f'{table}{FIELD_OID_SEP}{field}'
167
-
168
-
169
- def split_field_oid(oid):
170
- """`(table_key, field_key)` or `(None, None)` for anything that is not a field oid.
171
-
172
- β›” FAIL-CLOSED ON JUNK, like every other read here: a caller that cannot learn WHICH database
173
- an id names must not fall back to "the one I happen to be looking at", which is how a grant
174
- on somebody else's column would be read as a grant on this one.
175
- """
176
- raw = str(oid or '')
177
- table, sep, field = raw.partition(FIELD_OID_SEP)
178
- if not sep or not table.strip() or not field.strip() or FIELD_OID_SEP in field:
179
- return (None, None)
180
- return (table.strip(), field.strip())
181
-
182
-
183
- def _st(st):
184
- return st if st is not None else store
185
-
186
-
187
- def _check_kind(kind):
188
- k = str(kind or '').strip().lower()
189
- if k not in KINDS:
190
- raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)}. '
191
- f'This door refuses to invent a namespace from a typo.')
192
- return k
193
-
194
-
195
- def _clean_entries(entries):
196
- """Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}] β€” plus an
197
- OPTIONAL `'by'`, present only when the record already carried one.
198
-
199
- Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
200
- that just listed the people it is about to grant, so a rejected row must not abort the whole
201
- save β€” but an entry with an unknown ROLE must not be stored as something else's default
202
- either. Dropped, never coerced.
203
-
204
- ⭐⭐ D-474 β€” `by` IS PROVENANCE: THE USERNAME OF WHOEVER PLACED THIS GRANT. Owner ruling
205
- 2026-08-24: *"a re-sharer can only unshare the people it shared to"*. That question has no
206
- answer in a record that stores only WHO HOLDS the grant, so the record grew a third member and
207
- this normaliser is where it survives the round trip (`grants` reads every stored entry back
208
- through here, so a member this function drops is a member the door can never consult).
209
-
210
- β›”β›” AND THIS FUNCTION STAYS A PURE NORMALISER: IT NEVER INVENTS A `by`, AND IT NEVER
211
- VALIDATES ONE. That is not tidiness, it is the whole security property. `set_grants` is handed
212
- a caller-supplied list, so a `by` arriving HERE may be forged β€” and the merge inside
213
- `set_grants._apply` therefore OVERWRITES it in both directions from the PRIOR record and the
214
- `granter`, never from the payload. Were this function to treat a submitted `by` as
215
- authoritative, a re-sharer could PUT `{user: victim, role: view, by: <themselves>}` to
216
- re-stamp somebody else's grant as their own, then PUT again omitting the victim, and the wall
217
- at the door would be decoration. Normalise the SHAPE here; decide the VALUE there.
218
-
219
- ⚠ KEY ORDER IS `user, role, by` AND `by` IS ABSENT RATHER THAN `None` WHEN THERE IS NONE.
220
- Absence is what keeps every pre-existing record byte-identical after this change (an added
221
- `by: None` would rewrite the whole bucket on the next save, for nothing), and it is what
222
- `aios-web/api/verify_field_permissions.py` reads when it compares `tuple(entry.values())`
223
- against a 2-tuple.
224
- """
225
- out, seen = [], set()
226
- for e in entries or ():
227
- if not isinstance(e, dict):
228
- continue
229
- user = str(e.get('user') or '').strip().lower()
230
- role = str(e.get('role') or '').strip().lower()
231
- if not user or role not in ROLES or user in seen:
232
- continue
233
- seen.add(user)
234
- row = {'user': user, 'role': role}
235
- by = str(e.get('by') or '').strip().lower()
236
- if by:
237
- row['by'] = by
238
- out.append(row)
239
- return out
240
-
241
-
242
- def grants(kind, oid, st=None):
243
- """`{'owner': str|None, 'entries': [{'user','role','by'?}]}` β€” never raises on a junk bucket.
244
-
245
- ⭐⭐ D-474 β€” `by` RIDES THE READ, because the DOOR is what has to consult it. The revocation
246
- wall lives in `routes_shares.put_share` (it needs the session, which this layer does not
247
- have), and a wall cannot read a member this function strips. `_clean_entries` carries it,
248
- so every reader here sees it and no reader has to reach into the raw bucket.
249
-
250
- ⚠ THE COST, STATED RATHER THAN DISCOVERED: `GET /api/v1/share/{kind}/{oid}` returns this
251
- record verbatim, so anyone who may read a grant list now also learns WHO ADDED each person,
252
- a `view` grantee included. That is a mild widening of *"who has this?"* into *"who let them
253
- in?"*. It is accepted deliberately: the alternative is a second, provenance-stripped read
254
- path, which is a second answer to one question and is how the two come apart
255
- ([[one-evaluator-per-question]]).
256
- """
257
- kind = _check_kind(kind)
258
- try:
259
- bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
260
- rec = bucket.get(str(oid)) or {}
261
- except Exception:
262
- return {'owner': None, 'entries': []}
263
- if not isinstance(rec, dict):
264
- return {'owner': None, 'entries': []}
265
- return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
266
- 'entries': _clean_entries(rec.get('entries'))}
267
-
268
-
269
- def set_grants(kind, oid, entries, owner=None, st=None, granter=None, expect=None):
270
- """REPLACE the grant set for one object. Returns the stored record.
271
-
272
- ⚠ REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
273
- entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
274
- manage-access editor R10 asks for is exactly "here is the list now".
275
-
276
- ⭐⭐ D-474 β€” `granter` IS WHO IS DOING THIS SAVE, AND IT IS THE ONLY SOURCE OF A NEW `by`.
277
- Owner ruling 2026-08-24: *"a re-sharer can only unshare the people it shared to"*. The wall
278
- is at the door (`routes_shares.put_share`), but the FACT it consults can only be recorded
279
- here, at the write.
280
-
281
- β›”β›” AND THE STAMP IS DECIDED INSIDE `_apply`, NOT ABOVE IT, BECAUSE `_apply` IS THE ONLY
282
- PLACE THE PRIOR RECORD IS VISIBLE. Two rules, and the first is the one that matters:
283
- * a user ALREADY in the prior record keeps their stored `by` VERBATIM. Never re-stamped β€”
284
- a re-save would otherwise transfer provenance to whoever saved last, so every re-sharer
285
- would silently inherit the right to revoke everybody the owner had ever added, simply by
286
- pressing Save. That is the ruling inverted, arriving by accident.
287
- * a user who is NEW to the record takes `by = granter`, or carries NO `by` at all when no
288
- granter was named.
289
-
290
- β›” IN BOTH ARMS THE VALUE IS OVERWRITTEN FROM (prior, granter) AND NEVER READ OFF THE
291
- SUBMITTED ENTRY. `_clean_entries` preserves a submitted `by` because a STORED one has to
292
- survive the read; a caller-supplied one is untrusted input. Filling in only a MISSING `by`
293
- would leave the two-step forgery open: PUT `{user: victim, by: <me>}` to claim provenance,
294
- then PUT again omitting the victim.
295
-
296
- ⭐ `granter` DEFAULTS TO `None`, AND THAT IS WHAT MAKES THIS SHAPE CHANGE SAFE. Every
297
- non-door caller β€” `core.field_permissions` (promote + reconcile), `modules.product_data`'s
298
- Image field, `core.grid_events`' cleanup on delete, the gates β€” replaces a whole grant set
299
- with no caller identity in hand, and each keeps producing EXACTLY the record it produced
300
- before this change: no `by`, no new bytes, no behaviour moved. A grant with no `by` is one
301
- nobody can prove they made, and the door treats it as the owner's alone to revoke
302
- (fail-closed). ⚠ A future edit that defaults this to a session, or stamps it from the entry,
303
- deletes that property without touching a line the compiler can complain about.
304
-
305
- ⚠ `_apply` MAY RUN MORE THAN ONCE. `store._update_locked` re-applies the mutation on a
306
- `parent_commit` rebase, so the merge builds FRESH dicts from `clean` on every invocation
307
- rather than mutating it in place; a second pass must see the same untouched input, and it
308
- must be free to reclassify a user who was "new" on the first pass and is "prior" on the
309
- second (exactly what a rebase against another writer's save looks like).
310
- """
311
- kind = _check_kind(kind)
312
- oid = str(oid)
313
- clean = _clean_entries(entries)
314
- owner_l = str(owner).strip().lower() if owner else None
315
- granter_l = str(granter).strip().lower() if granter else None
316
-
317
- def _apply(data):
318
- by_kind = dict(data.get(kind) or {})
319
- # β›”β›” COMPARE-AND-SET, AND IT IS A SECURITY BOUNDARY RATHER THAN A TIDINESS ONE.
320
- # `routes_shares.put_share` reads the record ONCE, decides against it (may this caller
321
- # remove that person, raise that role, add that audience), and only then calls this. The
322
- # store re-reads `prior` fresh in here, so between the decision and the write another
323
- # save can land and the decision is being applied to a record it never saw.
324
- #
325
- # Measured by a wave-40 adversarial probe, and it needs no attacker timing: the owner and
326
- # a re-sharer both have Manage Access open and both press Save. The re-sharer's PUT was
327
- # ACCEPTED with 200 and the grant the owner had just added was gone, with no error on
328
- # either screen. That is D-474's wall failing open in the one window where two people are
329
- # actually editing the same thing.
330
- #
331
- # ⚠ THE LOST UPDATE PREDATES THE WALL -- REPLACE semantics plus read-modify-write have
332
- # always meant last-write-wins here. What is new is that a PERMISSION decision now rests
333
- # on that read. So the caller states what it decided against, and a write that would land
334
- # on anything else is refused rather than merged.
335
- #
336
- # `expect=None` keeps every existing caller byte-identical: the server-derived callers
337
- # (`field_permissions`, `product_data`, the `[]` revokes) are not deciding anything about
338
- # a person and have nothing to compare.
339
- if expect is not None:
340
- _now = [(e['user'], e['role']) for e in _clean_entries(
341
- (by_kind.get(oid) or {}).get('entries') if isinstance(by_kind.get(oid), dict)
342
- else [])]
343
- if _now != [(e['user'], e['role']) for e in _clean_entries(expect)]:
344
- raise GrantsChanged(
345
- 'this item was shared with somebody else while you were editing, so nothing '
346
- 'was saved. Reopen the sharing panel to see who has access now, then make '
347
- 'your change again.')
348
- prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
349
- # Read the prior entries through the SAME normaliser every other reader uses, so "was
350
- # this user already here" cannot be answered one way by the store and another by the
351
- # door ([[one-question-two-normalizers]]).
352
- prior_by = {e['user']: e.get('by') for e in _clean_entries(prior.get('entries'))}
353
- merged = []
354
- for e in clean:
355
- row = {'user': e['user'], 'role': e['role']}
356
- stamp = prior_by[e['user']] if e['user'] in prior_by else granter_l
357
- if stamp:
358
- row['by'] = stamp
359
- merged.append(row)
360
- # The owner is STICKY: set once, and a later save that omits it must not orphan the
361
- # object. An ownerless grant record cannot answer "who may re-share this", so every
362
- # administer check would fail closed and the object would become unmanageable.
363
- keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
364
- if prior.get('owner') else None)
365
- if not merged and not keep_owner:
366
- by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
367
- else:
368
- by_kind[oid] = {'owner': keep_owner, 'entries': merged}
369
- data[kind] = by_kind
370
- return data
371
-
372
- _st(st).update(SHARES_KEY, _apply, flush='async')
373
- return grants(kind, oid, st=st)
374
-
375
-
376
- def role_for(kind, oid, user, is_admin=False, st=None):
377
- """`'owner'` | `'edit'` | `'view'` | `None` β€” the caller's effective role, fail-closed.
378
-
379
- An ADMIN reads as `'owner'`: an admin who could not administer an object could not
380
- administer the tenant either, which is `table_store._may_administer`'s existing rule and is
381
- kept identical here so the two cannot disagree about the same view.
382
- """
383
- user = str(user or '').strip().lower()
384
- if not user:
385
- return None
386
- rec = grants(kind, oid, st=st)
387
- if is_admin or (rec['owner'] and rec['owner'] == user):
388
- return 'owner'
389
- best = None
390
- for e in rec['entries']:
391
- if e['user'] == user or e['user'] == EVERYONE:
392
- # The STRONGER of the two wins when both a personal and an everyone grant exist:
393
- # naming somebody explicitly is how you RAISE them above the room, so an
394
- # everyone-view + alice-edit pair must leave alice editing.
395
- if e['role'] == 'edit':
396
- return 'edit'
397
- best = best or 'view'
398
- return best
399
-
400
-
401
- def may_see(kind, oid, user, is_admin=False, st=None):
402
- return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
403
-
404
-
405
- def may_edit(kind, oid, user, is_admin=False, st=None):
406
- return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
407
-
408
-
409
- def may_administer(kind, oid, user, is_admin=False, st=None):
410
- """May this caller change WHO ELSE reaches the object, and revoke them?
411
-
412
- ⭐⭐ R4 / W40-T02 β€” AN `edit` GRANTEE ANSWERS TRUE, ON A `RESHARE_KINDS` KIND. Owner
413
- instruction 4: *"Edit View so a member can share a View as well, not just an admin"*. That
414
- reverses this function's old owner-or-admin rule for exactly one kind; the module note says
415
- which half of the protection survives and `RESHARE_KINDS` says why it stops at `view`.
416
-
417
- β›” ADMINISTERING IS NOT GRANTING, AND THE SECOND QUESTION HAS ITS OWN PREDICATE. True here
418
- means "may open the editor and rewrite the list"; it does NOT mean every role is theirs to
419
- hand out. `max_grantable_role` is the ceiling, and R4's two halves are only both honoured if
420
- a caller consults both ([[one-evaluator-per-question]]).
421
-
422
- β›” THE GATE IS THE KIND, NOT THE ROLE ALONE. `role_for` is deliberately kind-agnostic, so
423
- testing `== 'edit'` without `RESHARE_KINDS` would widen all four kinds in one line.
424
- """
425
- role = role_for(kind, oid, user, is_admin=is_admin, st=st)
426
- if role == 'owner':
427
- return True
428
- return role == 'edit' and _check_kind(kind) in RESHARE_KINDS
429
-
430
-
431
- def max_grantable_role(kind, oid, user, is_admin=False, st=None):
432
- """The STRONGEST role this caller may hand SOMEBODY ELSE on this object, fail-closed.
433
-
434
- `'edit'` for the owner or an admin Β· `'view'` for an `edit` grantee on a `RESHARE_KINDS` kind
435
- Β· `None` for anybody who may not administer the object at all.
436
-
437
- ⭐⭐ WHY A SECOND PREDICATE RATHER THAN A FLAG ON `may_administer` (R4 / W40-T02). R4 grants an
438
- `edit` holder the right to re-share and caps it in the same breath: *"a re-share may never
439
- exceed the role the re-sharer holds"*. Those are two different questions, and a single boolean
440
- answering both is exactly how the cap gets dropped by the next caller that only needs the door.
441
-
442
- β›” THE NARROWER OF R4's TWO READINGS SHIPS, AND ON PURPOSE. "Never exceed the role you hold"
443
- reads either as *may grant up to and including `edit`* (an edit holder confers edit) or as *may
444
- confer strictly less than the owner can*. This returns `'view'` β€” the second β€” because it is the
445
- FAIL-CLOSED direction. A wrong `'view'` costs the owner one click to raise somebody; a wrong
446
- `'edit'` lets a chain of collaborators propagate edit access the owner never approved, with
447
- nothing in the store recording who widened it. W40-T02's `done-when` pins the same reading.
448
-
449
- ⚠ THIS CAPS WHAT A CALLER GRANTS, NOT WHAT THE STORED SET ALREADY HOLDS, and the difference is
450
- load-bearing. `routes_shares.put_share` enforces it against the DELTA β€” a name arriving at
451
- `edit`, or an existing `view` grantee raised to it β€” never against every row of a body, because
452
- the PUT REPLACES and the client therefore re-sends the whole list, the re-sharer's own `edit`
453
- row included. The evidence for that is at the call site, where the body is.
454
-
455
- β›”β›” AND IT IS ONE OF THREE BOUNDS ON A RE-SHARE, NOT THE BOUND. Owner ruling 2026-08-24 added
456
- two more, and both live at the door because both need the SESSION: `_audience_added` (D-473,
457
- a re-sharer may name people and may not reach `EVERYONE`) and `_unremovable` (D-474, a
458
- re-sharer may revoke only what their own `by` stamp says they granted). ⚠ A reader who takes
459
- this function for the whole ceiling will widen the other two by leaving them alone β€” which is
460
- exactly how R4 shipped with an audience hole and a revocation hole under a docstring that
461
- read like a complete account of the limits.
462
- """
463
- role = role_for(kind, oid, user, is_admin=is_admin, st=st)
464
- if role == 'owner':
465
- return 'edit'
466
- if role == 'edit' and _check_kind(kind) in RESHARE_KINDS:
467
- return 'view'
468
- return None
469
-
470
-
471
- def shared_with(user, kind=None, st=None):
472
- """Every object id this user has been granted (excluding what they own).
473
-
474
- This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
475
- you made is not something shared *with* you, and listing it there would make the system
476
- folder a duplicate of the rail above it.
477
- """
478
- user = str(user or '').strip().lower()
479
- if not user:
480
- return {}
481
- try:
482
- data = _st(st).get(SHARES_KEY) or {}
483
- except Exception:
484
- return {}
485
- out = {}
486
- for k in ([_check_kind(kind)] if kind else KINDS):
487
- hits = []
488
- for oid, rec in (data.get(k) or {}).items():
489
- if not isinstance(rec, dict):
490
- continue
491
- owner = str(rec.get('owner') or '').strip().lower()
492
- if owner == user:
493
- continue
494
- for e in _clean_entries(rec.get('entries')):
495
- if e['user'] in (user, EVERYONE):
496
- hits.append(str(oid))
497
- break
498
- out[k] = sorted(hits)
499
- return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
500
-
501
-
502
- def granted_oids(kind, st=None):
503
- """Every object id of one `kind` that carries AT LEAST ONE grant entry β€” in ONE bucket read.
504
-
505
- ⭐ W40-T01 / OWNER INSTRUCTION 2 ("a shared View must show the shared icon in EVERY account,
506
- not only the recipient's"). `shared_with` answers *what was granted TO me* and deliberately
507
- EXCLUDES what the caller owns β€” so it structurally cannot answer the other question a share
508
- mark asks: *does this object have grants at all*. That question has no viewer in it, which is
509
- why this is a separate function rather than a flag bolted onto `shared_with`.
510
-
511
- ⚠ ONE READ, NOT N β€” and that is the whole reason this exists rather than a loop at the caller.
512
- The obvious spelling is `grants(kind, oid)` per view, and `grants` re-reads the WHOLE bucket
513
- on every call; a busy rail carries dozens of views, so painting one icon would cost dozens of
514
- full reads of the tenant's entire grant registry. This reads the bucket once and hands back a
515
- set the caller tests in O(1).
516
-
517
- β›” AN ENTRY IS WHAT COUNTS, NOT A RECORD. `set_grants` keeps an owner-only HUSK after a full
518
- revoke β€” the owner is sticky, deliberately, see its own note β€” so testing for the record's
519
- mere EXISTENCE would leave the mark lit forever after the last person was removed. That is
520
- exactly the revoke case, so it is the difference between this being right and being decorative
521
- noise. `_clean_entries` is the same normaliser every other read here uses, so a junk entry
522
- cannot mark an object either.
523
-
524
- β›” FAIL-CLOSED like the rest of this module: an unreadable bucket answers the EMPTY set β€” no
525
- marks β€” never a default that claims something is shared. An unknown KIND still RAISES, exactly
526
- as `grants` / `shared_with` / `drop_objects` do: that is a typo in a caller, not junk in the
527
- store, and swallowing it into "nothing is shared" would hide a caller that never works.
528
- """
529
- kind = _check_kind(kind)
530
- try:
531
- by_kind = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
532
- rows = list(by_kind.items())
533
- except Exception:
534
- return set()
535
- return {str(oid) for oid, rec in rows
536
- if isinstance(rec, dict) and _clean_entries(rec.get('entries'))}
537
-
538
-
539
- def drop_objects(pairs, st=None):
540
- """Remove whole grant RECORDS, owner husk included β€” wave 21, item 6a (C3).
541
-
542
- A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
543
- into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
544
- transaction for the whole sweep β€” a table delete drops its database grant plus a view
545
- grant per view that lived in its bucket."""
546
- want = {}
547
- for kind, oid in pairs or ():
548
- want.setdefault(_check_kind(kind), set()).add(str(oid))
549
- if not want:
550
- return
551
-
552
- def _apply(data):
553
- for kind, oids in want.items():
554
- by_kind = data.get(kind)
555
- if isinstance(by_kind, dict):
556
- for oid in oids:
557
- by_kind.pop(oid, None)
558
- return data
559
-
560
- _st(st).update(SHARES_KEY, _apply, flush='async')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """core/shares.py β€” ONE grant registry for every shareable object (wave 20, owner ruling R10).
2
+
3
+ WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
4
+ already use** (specific users or everyone; role = view | edit), plus one manage-access editor
5
+ that can add or revoke people later, on any of the three.
6
+
7
+ WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
8
+ (`core/table_store.py`) and that stays β€” moving it would rewrite every stored view for no gain.
9
+ But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
10
+ definition; giving each its own grant field would put the same three-line permission decision in
11
+ three files owned by two sessions, which is how the three drift. One registry, one predicate,
12
+ three callers.
13
+
14
+ shares.set_grants(kind, oid, entries, owner=…, granter=…, st=…) # replaces the whole set
15
+ # ⚠ a NAMED owner REASSIGNS (R6d) β€” see it
16
+ shares.grants(kind, oid, st=…) # -> {'owner': str, 'entries': [...]}
17
+ shares.role_for(kind, oid, user, is_admin=…, st=…) # -> 'owner'|'edit'|'view'|None
18
+ shares.max_grantable_role(kind, oid, user, …) # -> 'edit'|'view'|None (R4's ceiling)
19
+ shares.shared_with(user, kind=…, st=…) # -> [oid] this user was granted
20
+
21
+ THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
22
+ `edit` = may also change the object's CONTENT.
23
+
24
+ ⭐⭐ AND ON A VIEW, `edit` NOW ALSO MEANS "MAY RE-SHARE" β€” OWNER RULING R4, instruction 4 (*"Edit
25
+ View so a member can share a View as well, not just an admin"*), built as W40-T02. This REVERSES
26
+ the flat owner-or-admin rule this paragraph used to state, and it reverses it for ONE kind:
27
+ `RESHARE_KINDS` below is where the widening is spelled, once, and `folder` / `database` / `field`
28
+ are untouched β€” still the owner's or an admin's, for the reason given at that constant.
29
+
30
+ β›” WHICH HALF OF THE OLD PROTECTION SURVIVES, BECAUSE THE FEAR IT NAMED WAS REAL AND ONLY HALF OF
31
+ IT IS ANSWERED. The sentence here used to be *"a collaborator who could rewrite grants could grant
32
+ themselves sole ownership of somebody else's object, or quietly widen a users-scoped share to
33
+ everyone"*. Taking those one at a time:
34
+ * **SOLE OWNERSHIP IS BLOCKED β€” and now stated rather than incidental.** The owner is STICKY in
35
+ `set_grants` and `routes_shares.put_share` passes the EXISTING owner straight back through, so
36
+ a re-share cannot transfer ownership and no grantee can lock the creator out of their own view.
37
+ Ownership moves for nobody, by any path this registry offers.
38
+ * **WIDENING IS NOW BLOCKED TOO β€” OWNER RULING 2026-08-24, D-473.** β›” THE SENTENCE HERE USED
39
+ TO PRICE THIS AS A COST R4 ACCEPTED: *"an `edit` grantee may add somebody β€” `*` included β€” at
40
+ `view`"*. Measured and reported as such, and the owner's answer was *"no a re-sharer can only
41
+ share to specific people"*. So a re-sharer may name PEOPLE and may not reach `EVERYONE`, and
42
+ the second half of the old fear is closed rather than merely capped. The ROLE ceiling below
43
+ still stands beside it β€” they are two different questions and both are asked.
44
+ * **THE ROLE CEILING SURVIVES UNCHANGED.** A re-share may never exceed the role the re-sharer
45
+ holds, and the STRICTER reading of that ships: an `edit` grantee may add somebody at `view`,
46
+ and can never mint `edit` access for anyone. Handing out `edit` stays the owner's (or an
47
+ admin's) alone. A `view` grantee still cannot share at all. `max_grantable_role` is that
48
+ ceiling, and `routes_shares.put_share` is where it bites.
49
+ * **AND REVOCATION IS BOUNDED BY PROVENANCE β€” OWNER RULING 2026-08-24, D-474.** *"a re-sharer
50
+ can only unshare the people it shared to"*. R4 bounded the role a re-sharer may HAND OUT and
51
+ said nothing about what they may TAKE AWAY, so an `edit` grantee could `PUT []` and leave the
52
+ view shared with nobody. Answering that needs a fact the record did not carry, which is what
53
+ the `by` member on each entry is (`_clean_entries` / `set_grants`' `granter`). β›” An entry
54
+ with NO `by` β€” every grant written before this change β€” is the OWNER's to revoke and nobody
55
+ else's: a re-sharer must never revoke a grant they cannot prove they made.
56
+
57
+ β›” AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β€” junk in the bucket, a missing
58
+ owner, an unknown role string all resolve to None rather than to a default that opens something.
59
+ [[aios-permissioning]]: no fail-open defaults, ever.
60
+
61
+ ⚠ THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
62
+ session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
63
+ (`core.store`) is tenant #0 and exists for the same reason it does everywhere else β€” the ~28
64
+ callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
65
+ NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
66
+ """
67
+ import core.store as store
68
+
69
+ #: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
70
+ #: is a question across kinds β€” the "Shared with me" folder (R10) is exactly that query, and
71
+ #: three separate buckets would make it three reads that can disagree about what a user can see.
72
+ SHARES_KEY = 'object_shares'
73
+
74
+ #: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
75
+ #: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
76
+ #:
77
+ #: ⭐⭐ W38-T16 β€” `field` IS THE FOURTH, AND NOTHING IN THIS FILE BRANCHES ON IT. Every function
78
+ #: below treats `kind` as an opaque bucket key (`_check_kind / grants / set_grants / role_for /
79
+ #: may_see / may_edit / may_administer / shared_with / drop_objects`), so the kind's whole cost
80
+ #: here is this tuple member. That is the point of one registry: the new object's WALL is written
81
+ #: once in `core.perm_scope`, and its DOORS once in `routes_shares.py` β€” never a fourth
82
+ #: permission decision in a fourth file ([[one-evaluator-per-question]]).
83
+ KINDS = ('view', 'folder', 'database', 'field')
84
+
85
+ #: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
86
+ #: Spelled as a single character so it can never collide with a username (usernames are lower-case
87
+ #: and non-empty by `core/users.py`, and are checked against this explicitly below).
88
+ #:
89
+ #: β›”β›” **AND WHAT THAT MEANS DEPENDS ON THE KIND β€” THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
90
+ #: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
91
+ #: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
92
+ #: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
93
+ #: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
94
+ #: whole point:
95
+ #: * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β€”
96
+ #: the sentence holds. `require_session` plus the topic's own gate run first, and the
97
+ #: receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
98
+ #: merged, so a grant can only narrow-or-equal what that account could already reach.
99
+ #: * `kind='database'` on a `ut_*` table β€” ⭐⭐ **THE SENTENCE HOLDS HERE TOO NOW, AND THAT IS
100
+ #: W36-T21 / OWNER RULING R6 (audit S-8, CLOSED).** It did not until wave 36, and the reason
101
+ #: is worth keeping: `routes_admin._clean_perms` 400'd any key outside
102
+ #: `("customer_data", "product_data")`, so no row filter and no hidden field could even be
103
+ #: DECLARED for a user table, `routes_tables.py` made zero `perm_scope` calls, and it passed
104
+ #: `hidden_keys=frozenset()`. A `database` grant was therefore ALL-OR-NOTHING β€” every row,
105
+ #: every column β€” and this registry was the only wall behind it.
106
+ #:
107
+ #: ⭐ WHAT CHANGED: `perm_scope.scoped_table` (contract C1) is the ONE door to any database's
108
+ #: rows. `routes_tables` applies the permanent filter before `pids` is taken and the transitive
109
+ #: hidden-field closure after `workspace_wire`, on EVERY `ut_*` read β€” the same code that walls
110
+ #: `customer_data` β€” and a door that cannot apply them REFUSES rather than serving the lot. So a
111
+ #: `database` grant is once again bounded by a second wall: it decides WHETHER an account reaches
112
+ #: the database, and C1 decides WHICH rows and columns it then sees. An `*` grant still admits
113
+ #: every account in the tenant, and each of them still only sees what their own wall allows.
114
+ #:
115
+ #: ⚠ `routes_shares.py`'s module docstring carries the OLD sentence at the other door and is in
116
+ #: no wave-36 fence β€” the audit's own fix was "say so at both", so one of the two is now stale.
117
+ #: Booked in `mailbox/C.md` (C-14) rather than edited across a fence
118
+ #: [[two-gates-can-assert-opposite-things]].
119
+ EVERYONE = '*'
120
+
121
+ ROLES = ('view', 'edit')
122
+
123
+ #: ⭐⭐ THE KINDS AN `edit` GRANTEE MAY RE-SHARE β€” R4 / W40-T02, and it is `view` ALONE.
124
+ #:
125
+ #: R4's words are "may re-share **A VIEW**", and the widening goes no further than the ruling
126
+ #: names. That restraint is the whole reason this is a tuple rather than a bare `role == 'edit'`
127
+ #: arm inside `may_administer`: every predicate in this file treats `kind` as an opaque bucket key
128
+ #: (see the note on `KINDS`), so an UNGATED widening would reach `folder`, `database` and `field`
129
+ #: in the same line β€” handing an `edit` grantee on a `ut_*` DATABASE the power to re-share the
130
+ #: whole table. The asymmetry is one of blast radius: a view is one saved SELECTION, opened through
131
+ #: the receiver's own module wall and row scope; a database grant admits an account to a database.
132
+ #: `routes_shares.py`'s module docstring carries that argument at the door where it is enforced.
133
+ #:
134
+ #: ⚠ ONE SPELLING, DELIBERATELY. `may_administer` and `max_grantable_role` both consult this, so
135
+ #: "who may open the editor" and "what may they hand out" cannot come apart about the same kind.
136
+ RESHARE_KINDS = ('view',)
137
+
138
+
139
+ class GrantsChanged(Exception):
140
+ """A grant write was decided against a record that is no longer the stored one.
141
+
142
+ Raised by `set_grants` when its `expect` does not match what is in the store at write time.
143
+ The door turns it into a 409 rather than a 500: it is not an error in the request, it is two
144
+ people editing the same sharing list at once, and the honest answer is to say so.
145
+ """
146
+
147
+
148
+ #: β›”β›” A FIELD's OBJECT ID IS TOPIC-QUALIFIED, AND THE SEPARATOR IS DECLARED HERE SO THERE IS ONE
149
+ #: SPELLING OF IT. A bare field key is NOT unique: `notes` exists on a dozen databases, and a
150
+ #: grant stored under it would admit a grantee to every `notes` column in the tenant at once β€”
151
+ #: the widening direction, silently, forever. `table_key` is the qualifier because it is the same
152
+ #: identifier `shared_overlay.bucket()` already keys the values by, so the grant and the data it
153
+ #: governs are named by the same string ([[one-question-two-normalizers]]).
154
+ #:
155
+ #: ⚠ A `ut_*` key and a registry topic key both match `[a-z0-9_]+` and neither can contain `:`,
156
+ #: so the split below is unambiguous in both directions.
157
+ FIELD_OID_SEP = ':'
158
+
159
+
160
+ def field_oid(table_key, field_key):
161
+ """`"<table_key>:<field_key>"` β€” the share id of ONE column on ONE database."""
162
+ table = str(table_key or '').strip()
163
+ field = str(field_key or '').strip()
164
+ if not table or not field:
165
+ raise ValueError('shares.field_oid: a field share names BOTH a database and a column. '
166
+ 'A bare field key repeats across tables and would grant all of them')
167
+ return f'{table}{FIELD_OID_SEP}{field}'
168
+
169
+
170
+ def split_field_oid(oid):
171
+ """`(table_key, field_key)` or `(None, None)` for anything that is not a field oid.
172
+
173
+ β›” FAIL-CLOSED ON JUNK, like every other read here: a caller that cannot learn WHICH database
174
+ an id names must not fall back to "the one I happen to be looking at", which is how a grant
175
+ on somebody else's column would be read as a grant on this one.
176
+ """
177
+ raw = str(oid or '')
178
+ table, sep, field = raw.partition(FIELD_OID_SEP)
179
+ if not sep or not table.strip() or not field.strip() or FIELD_OID_SEP in field:
180
+ return (None, None)
181
+ return (table.strip(), field.strip())
182
+
183
+
184
+ def _st(st):
185
+ return st if st is not None else store
186
+
187
+
188
+ def _check_kind(kind):
189
+ k = str(kind or '').strip().lower()
190
+ if k not in KINDS:
191
+ raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)}. '
192
+ f'This door refuses to invent a namespace from a typo.')
193
+ return k
194
+
195
+
196
+ def _clean_entries(entries):
197
+ """Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}] β€” plus an
198
+ OPTIONAL `'by'`, present only when the record already carried one.
199
+
200
+ Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
201
+ that just listed the people it is about to grant, so a rejected row must not abort the whole
202
+ save β€” but an entry with an unknown ROLE must not be stored as something else's default
203
+ either. Dropped, never coerced.
204
+
205
+ ⭐⭐ D-474 β€” `by` IS PROVENANCE: THE USERNAME OF WHOEVER PLACED THIS GRANT. Owner ruling
206
+ 2026-08-24: *"a re-sharer can only unshare the people it shared to"*. That question has no
207
+ answer in a record that stores only WHO HOLDS the grant, so the record grew a third member and
208
+ this normaliser is where it survives the round trip (`grants` reads every stored entry back
209
+ through here, so a member this function drops is a member the door can never consult).
210
+
211
+ β›”β›” AND THIS FUNCTION STAYS A PURE NORMALISER: IT NEVER INVENTS A `by`, AND IT NEVER
212
+ VALIDATES ONE. That is not tidiness, it is the whole security property. `set_grants` is handed
213
+ a caller-supplied list, so a `by` arriving HERE may be forged β€” and the merge inside
214
+ `set_grants._apply` therefore OVERWRITES it in both directions from the PRIOR record and the
215
+ `granter`, never from the payload. Were this function to treat a submitted `by` as
216
+ authoritative, a re-sharer could PUT `{user: victim, role: view, by: <themselves>}` to
217
+ re-stamp somebody else's grant as their own, then PUT again omitting the victim, and the wall
218
+ at the door would be decoration. Normalise the SHAPE here; decide the VALUE there.
219
+
220
+ ⚠ KEY ORDER IS `user, role, by` AND `by` IS ABSENT RATHER THAN `None` WHEN THERE IS NONE.
221
+ Absence is what keeps every pre-existing record byte-identical after this change (an added
222
+ `by: None` would rewrite the whole bucket on the next save, for nothing), and it is what
223
+ `aios-web/api/verify_field_permissions.py` reads when it compares `tuple(entry.values())`
224
+ against a 2-tuple.
225
+ """
226
+ out, seen = [], set()
227
+ for e in entries or ():
228
+ if not isinstance(e, dict):
229
+ continue
230
+ user = str(e.get('user') or '').strip().lower()
231
+ role = str(e.get('role') or '').strip().lower()
232
+ if not user or role not in ROLES or user in seen:
233
+ continue
234
+ seen.add(user)
235
+ row = {'user': user, 'role': role}
236
+ by = str(e.get('by') or '').strip().lower()
237
+ if by:
238
+ row['by'] = by
239
+ out.append(row)
240
+ return out
241
+
242
+
243
+ def grants(kind, oid, st=None):
244
+ """`{'owner': str|None, 'entries': [{'user','role','by'?}]}` β€” never raises on a junk bucket.
245
+
246
+ ⭐⭐ D-474 β€” `by` RIDES THE READ, because the DOOR is what has to consult it. The revocation
247
+ wall lives in `routes_shares.put_share` (it needs the session, which this layer does not
248
+ have), and a wall cannot read a member this function strips. `_clean_entries` carries it,
249
+ so every reader here sees it and no reader has to reach into the raw bucket.
250
+
251
+ ⚠ THE COST, STATED RATHER THAN DISCOVERED: `GET /api/v1/share/{kind}/{oid}` returns this
252
+ record verbatim, so anyone who may read a grant list now also learns WHO ADDED each person,
253
+ a `view` grantee included. That is a mild widening of *"who has this?"* into *"who let them
254
+ in?"*. It is accepted deliberately: the alternative is a second, provenance-stripped read
255
+ path, which is a second answer to one question and is how the two come apart
256
+ ([[one-evaluator-per-question]]).
257
+ """
258
+ kind = _check_kind(kind)
259
+ try:
260
+ bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
261
+ rec = bucket.get(str(oid)) or {}
262
+ except Exception:
263
+ return {'owner': None, 'entries': []}
264
+ if not isinstance(rec, dict):
265
+ return {'owner': None, 'entries': []}
266
+ return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
267
+ 'entries': _clean_entries(rec.get('entries'))}
268
+
269
+
270
+ def set_grants(kind, oid, entries, owner=None, st=None, granter=None, expect=None):
271
+ """REPLACE the grant set for one object. Returns the stored record.
272
+
273
+ ⚠ REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
274
+ entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
275
+ manage-access editor R10 asks for is exactly "here is the list now".
276
+
277
+ ⭐⭐ D-474 β€” `granter` IS WHO IS DOING THIS SAVE, AND IT IS THE ONLY SOURCE OF A NEW `by`.
278
+ Owner ruling 2026-08-24: *"a re-sharer can only unshare the people it shared to"*. The wall
279
+ is at the door (`routes_shares.put_share`), but the FACT it consults can only be recorded
280
+ here, at the write.
281
+
282
+ β›”β›” AND THE STAMP IS DECIDED INSIDE `_apply`, NOT ABOVE IT, BECAUSE `_apply` IS THE ONLY
283
+ PLACE THE PRIOR RECORD IS VISIBLE. Two rules, and the first is the one that matters:
284
+ * a user ALREADY in the prior record keeps their stored `by` VERBATIM. Never re-stamped β€”
285
+ a re-save would otherwise transfer provenance to whoever saved last, so every re-sharer
286
+ would silently inherit the right to revoke everybody the owner had ever added, simply by
287
+ pressing Save. That is the ruling inverted, arriving by accident.
288
+ * a user who is NEW to the record takes `by = granter`, or carries NO `by` at all when no
289
+ granter was named.
290
+
291
+ β›” IN BOTH ARMS THE VALUE IS OVERWRITTEN FROM (prior, granter) AND NEVER READ OFF THE
292
+ SUBMITTED ENTRY. `_clean_entries` preserves a submitted `by` because a STORED one has to
293
+ survive the read; a caller-supplied one is untrusted input. Filling in only a MISSING `by`
294
+ would leave the two-step forgery open: PUT `{user: victim, by: <me>}` to claim provenance,
295
+ then PUT again omitting the victim.
296
+
297
+ ⭐ `granter` DEFAULTS TO `None`, AND THAT IS WHAT MAKES THIS SHAPE CHANGE SAFE. Every
298
+ non-door caller β€” `core.field_permissions` (promote + reconcile), `modules.product_data`'s
299
+ Image field, `core.grid_events`' cleanup on delete, the gates β€” replaces a whole grant set
300
+ with no caller identity in hand, and each keeps producing EXACTLY the record it produced
301
+ before this change: no `by`, no new bytes, no behaviour moved. A grant with no `by` is one
302
+ nobody can prove they made, and the door treats it as the owner's alone to revoke
303
+ (fail-closed). ⚠ A future edit that defaults this to a session, or stamps it from the entry,
304
+ deletes that property without touching a line the compiler can complain about.
305
+
306
+ ⚠ `_apply` MAY RUN MORE THAN ONCE. `store._update_locked` re-applies the mutation on a
307
+ `parent_commit` rebase, so the merge builds FRESH dicts from `clean` on every invocation
308
+ rather than mutating it in place; a second pass must see the same untouched input, and it
309
+ must be free to reclassify a user who was "new" on the first pass and is "prior" on the
310
+ second (exactly what a rebase against another writer's save looks like).
311
+
312
+ ⭐⭐ W41-T03 / RULING R6(d) β€” `owner=` IS THE REASSIGNMENT CHANNEL, AND IT IS THE ONLY ONE.
313
+ *"An admin can reassign a field's owner."* The door is `routes_shares.reassign_owner`
314
+ (`PUT /share/field/{oid}/owner`), which walls on `role_for(...) == 'owner'` and writes through
315
+ here; nothing else decides who owns an object. `field_permissions.field_class` (contract C1)
316
+ reads this `owner` and consumers derive the rights from that bag, so moving it here is what
317
+ moves the badge AND the delete right together.
318
+
319
+ β›”β›” AND THE HAZARD THAT COMES WITH A NAMED OWNER WINNING: A CALLER THAT PASSES AN `owner` IT
320
+ RE-DERIVED CLOBBERS A REASSIGNMENT, SILENTLY. Measured on this tree, wave 41, not argued:
321
+ `core/grid_events.py` (the grid's field-definition save, under `permission_sync or
322
+ promoted_this_save`) passes `owner=field.get('createdBy') or uname`, and `modules/product_data`
323
+ passes `owner=IMAGE_FIELD_CREATOR` on its Image-field repair. Both therefore RESET the owner to
324
+ the definition's creator on their next write β€” so a column handed to somebody else reverts the
325
+ next time its permissions are saved through the grid. ⚠ THE CORRECT SHAPE FOR SUCH A CALLER IS
326
+ TO PASS NO `owner` AT ALL once a record exists (stickiness then keeps whatever is stored), or
327
+ to guard on `grants(...)['owner']` first the way `product_data`'s SECOND call site already
328
+ does. Fixing those two files is out of W41-T03's fence and is booked, not done β€” this note is
329
+ here so the next reader of this parameter learns it from the function rather than from a bug.
330
+ """
331
+ kind = _check_kind(kind)
332
+ oid = str(oid)
333
+ clean = _clean_entries(entries)
334
+ owner_l = str(owner).strip().lower() if owner else None
335
+ granter_l = str(granter).strip().lower() if granter else None
336
+
337
+ def _apply(data):
338
+ by_kind = dict(data.get(kind) or {})
339
+ # β›”β›” COMPARE-AND-SET, AND IT IS A SECURITY BOUNDARY RATHER THAN A TIDINESS ONE.
340
+ # `routes_shares.put_share` reads the record ONCE, decides against it (may this caller
341
+ # remove that person, raise that role, add that audience), and only then calls this. The
342
+ # store re-reads `prior` fresh in here, so between the decision and the write another
343
+ # save can land and the decision is being applied to a record it never saw.
344
+ #
345
+ # Measured by a wave-40 adversarial probe, and it needs no attacker timing: the owner and
346
+ # a re-sharer both have Manage Access open and both press Save. The re-sharer's PUT was
347
+ # ACCEPTED with 200 and the grant the owner had just added was gone, with no error on
348
+ # either screen. That is D-474's wall failing open in the one window where two people are
349
+ # actually editing the same thing.
350
+ #
351
+ # ⚠ THE LOST UPDATE PREDATES THE WALL -- REPLACE semantics plus read-modify-write have
352
+ # always meant last-write-wins here. What is new is that a PERMISSION decision now rests
353
+ # on that read. So the caller states what it decided against, and a write that would land
354
+ # on anything else is refused rather than merged.
355
+ #
356
+ # `expect=None` keeps every existing caller byte-identical: the server-derived callers
357
+ # (`field_permissions`, `product_data`, the `[]` revokes) are not deciding anything about
358
+ # a person and have nothing to compare.
359
+ if expect is not None:
360
+ _now = [(e['user'], e['role']) for e in _clean_entries(
361
+ (by_kind.get(oid) or {}).get('entries') if isinstance(by_kind.get(oid), dict)
362
+ else [])]
363
+ if _now != [(e['user'], e['role']) for e in _clean_entries(expect)]:
364
+ raise GrantsChanged(
365
+ 'this item was shared with somebody else while you were editing, so nothing '
366
+ 'was saved. Reopen the sharing panel to see who has access now, then make '
367
+ 'your change again.')
368
+ prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
369
+ # Read the prior entries through the SAME normaliser every other reader uses, so "was
370
+ # this user already here" cannot be answered one way by the store and another by the
371
+ # door ([[one-question-two-normalizers]]).
372
+ prior_by = {e['user']: e.get('by') for e in _clean_entries(prior.get('entries'))}
373
+ merged = []
374
+ for e in clean:
375
+ row = {'user': e['user'], 'role': e['role']}
376
+ stamp = prior_by[e['user']] if e['user'] in prior_by else granter_l
377
+ if stamp:
378
+ row['by'] = stamp
379
+ merged.append(row)
380
+ # The owner is STICKY: set once, and a later save that omits it must not orphan the
381
+ # object. An ownerless grant record cannot answer "who may re-share this", so every
382
+ # administer check would fail closed and the object would become unmanageable.
383
+ # β›”β›” STICKY IS ABOUT OMISSION, NOT IMMUTABILITY, AND THE DIFFERENCE IS NOW LOAD-BEARING:
384
+ # a NAMED `owner` still WINS over the stored one. That is what W41-T03 / R6(d) reassigns
385
+ # through, and it is also why a caller that means "leave the owner alone" must pass NOTHING
386
+ # rather than a value it derived for itself. See the hazard note on this function.
387
+ keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
388
+ if prior.get('owner') else None)
389
+ if not merged and not keep_owner:
390
+ by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
391
+ else:
392
+ by_kind[oid] = {'owner': keep_owner, 'entries': merged}
393
+ data[kind] = by_kind
394
+ return data
395
+
396
+ _st(st).update(SHARES_KEY, _apply, flush='async')
397
+ return grants(kind, oid, st=st)
398
+
399
+
400
+ def role_for(kind, oid, user, is_admin=False, st=None):
401
+ """`'owner'` | `'edit'` | `'view'` | `None` β€” the caller's effective role, fail-closed.
402
+
403
+ An ADMIN reads as `'owner'`: an admin who could not administer an object could not
404
+ administer the tenant either, which is `table_store._may_administer`'s existing rule and is
405
+ kept identical here so the two cannot disagree about the same view.
406
+ """
407
+ user = str(user or '').strip().lower()
408
+ if not user:
409
+ return None
410
+ rec = grants(kind, oid, st=st)
411
+ if is_admin or (rec['owner'] and rec['owner'] == user):
412
+ return 'owner'
413
+ best = None
414
+ for e in rec['entries']:
415
+ if e['user'] == user or e['user'] == EVERYONE:
416
+ # The STRONGER of the two wins when both a personal and an everyone grant exist:
417
+ # naming somebody explicitly is how you RAISE them above the room, so an
418
+ # everyone-view + alice-edit pair must leave alice editing.
419
+ if e['role'] == 'edit':
420
+ return 'edit'
421
+ best = best or 'view'
422
+ return best
423
+
424
+
425
+ def may_see(kind, oid, user, is_admin=False, st=None):
426
+ return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
427
+
428
+
429
+ def may_edit(kind, oid, user, is_admin=False, st=None):
430
+ return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
431
+
432
+
433
+ def may_administer(kind, oid, user, is_admin=False, st=None):
434
+ """May this caller change WHO ELSE reaches the object, and revoke them?
435
+
436
+ ⭐⭐ R4 / W40-T02 β€” AN `edit` GRANTEE ANSWERS TRUE, ON A `RESHARE_KINDS` KIND. Owner
437
+ instruction 4: *"Edit View so a member can share a View as well, not just an admin"*. That
438
+ reverses this function's old owner-or-admin rule for exactly one kind; the module note says
439
+ which half of the protection survives and `RESHARE_KINDS` says why it stops at `view`.
440
+
441
+ β›” ADMINISTERING IS NOT GRANTING, AND THE SECOND QUESTION HAS ITS OWN PREDICATE. True here
442
+ means "may open the editor and rewrite the list"; it does NOT mean every role is theirs to
443
+ hand out. `max_grantable_role` is the ceiling, and R4's two halves are only both honoured if
444
+ a caller consults both ([[one-evaluator-per-question]]).
445
+
446
+ β›” THE GATE IS THE KIND, NOT THE ROLE ALONE. `role_for` is deliberately kind-agnostic, so
447
+ testing `== 'edit'` without `RESHARE_KINDS` would widen all four kinds in one line.
448
+ """
449
+ role = role_for(kind, oid, user, is_admin=is_admin, st=st)
450
+ if role == 'owner':
451
+ return True
452
+ return role == 'edit' and _check_kind(kind) in RESHARE_KINDS
453
+
454
+
455
+ def max_grantable_role(kind, oid, user, is_admin=False, st=None):
456
+ """The STRONGEST role this caller may hand SOMEBODY ELSE on this object, fail-closed.
457
+
458
+ `'edit'` for the owner or an admin Β· `'view'` for an `edit` grantee on a `RESHARE_KINDS` kind
459
+ Β· `None` for anybody who may not administer the object at all.
460
+
461
+ ⭐⭐ WHY A SECOND PREDICATE RATHER THAN A FLAG ON `may_administer` (R4 / W40-T02). R4 grants an
462
+ `edit` holder the right to re-share and caps it in the same breath: *"a re-share may never
463
+ exceed the role the re-sharer holds"*. Those are two different questions, and a single boolean
464
+ answering both is exactly how the cap gets dropped by the next caller that only needs the door.
465
+
466
+ β›” THE NARROWER OF R4's TWO READINGS SHIPS, AND ON PURPOSE. "Never exceed the role you hold"
467
+ reads either as *may grant up to and including `edit`* (an edit holder confers edit) or as *may
468
+ confer strictly less than the owner can*. This returns `'view'` β€” the second β€” because it is the
469
+ FAIL-CLOSED direction. A wrong `'view'` costs the owner one click to raise somebody; a wrong
470
+ `'edit'` lets a chain of collaborators propagate edit access the owner never approved, with
471
+ nothing in the store recording who widened it. W40-T02's `done-when` pins the same reading.
472
+
473
+ ⚠ THIS CAPS WHAT A CALLER GRANTS, NOT WHAT THE STORED SET ALREADY HOLDS, and the difference is
474
+ load-bearing. `routes_shares.put_share` enforces it against the DELTA β€” a name arriving at
475
+ `edit`, or an existing `view` grantee raised to it β€” never against every row of a body, because
476
+ the PUT REPLACES and the client therefore re-sends the whole list, the re-sharer's own `edit`
477
+ row included. The evidence for that is at the call site, where the body is.
478
+
479
+ β›”β›” AND IT IS ONE OF THREE BOUNDS ON A RE-SHARE, NOT THE BOUND. Owner ruling 2026-08-24 added
480
+ two more, and both live at the door because both need the SESSION: `_audience_added` (D-473,
481
+ a re-sharer may name people and may not reach `EVERYONE`) and `_unremovable` (D-474, a
482
+ re-sharer may revoke only what their own `by` stamp says they granted). ⚠ A reader who takes
483
+ this function for the whole ceiling will widen the other two by leaving them alone β€” which is
484
+ exactly how R4 shipped with an audience hole and a revocation hole under a docstring that
485
+ read like a complete account of the limits.
486
+ """
487
+ role = role_for(kind, oid, user, is_admin=is_admin, st=st)
488
+ if role == 'owner':
489
+ return 'edit'
490
+ if role == 'edit' and _check_kind(kind) in RESHARE_KINDS:
491
+ return 'view'
492
+ return None
493
+
494
+
495
+ def shared_with(user, kind=None, st=None):
496
+ """Every object id this user has been granted (excluding what they own).
497
+
498
+ This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
499
+ you made is not something shared *with* you, and listing it there would make the system
500
+ folder a duplicate of the rail above it.
501
+ """
502
+ user = str(user or '').strip().lower()
503
+ if not user:
504
+ return {}
505
+ try:
506
+ data = _st(st).get(SHARES_KEY) or {}
507
+ except Exception:
508
+ return {}
509
+ out = {}
510
+ for k in ([_check_kind(kind)] if kind else KINDS):
511
+ hits = []
512
+ for oid, rec in (data.get(k) or {}).items():
513
+ if not isinstance(rec, dict):
514
+ continue
515
+ owner = str(rec.get('owner') or '').strip().lower()
516
+ if owner == user:
517
+ continue
518
+ for e in _clean_entries(rec.get('entries')):
519
+ if e['user'] in (user, EVERYONE):
520
+ hits.append(str(oid))
521
+ break
522
+ out[k] = sorted(hits)
523
+ return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
524
+
525
+
526
+ def granted_oids(kind, st=None):
527
+ """Every object id of one `kind` that carries AT LEAST ONE grant entry β€” in ONE bucket read.
528
+
529
+ ⭐ W40-T01 / OWNER INSTRUCTION 2 ("a shared View must show the shared icon in EVERY account,
530
+ not only the recipient's"). `shared_with` answers *what was granted TO me* and deliberately
531
+ EXCLUDES what the caller owns β€” so it structurally cannot answer the other question a share
532
+ mark asks: *does this object have grants at all*. That question has no viewer in it, which is
533
+ why this is a separate function rather than a flag bolted onto `shared_with`.
534
+
535
+ ⚠ ONE READ, NOT N β€” and that is the whole reason this exists rather than a loop at the caller.
536
+ The obvious spelling is `grants(kind, oid)` per view, and `grants` re-reads the WHOLE bucket
537
+ on every call; a busy rail carries dozens of views, so painting one icon would cost dozens of
538
+ full reads of the tenant's entire grant registry. This reads the bucket once and hands back a
539
+ set the caller tests in O(1).
540
+
541
+ β›” AN ENTRY IS WHAT COUNTS, NOT A RECORD. `set_grants` keeps an owner-only HUSK after a full
542
+ revoke β€” the owner is sticky, deliberately, see its own note β€” so testing for the record's
543
+ mere EXISTENCE would leave the mark lit forever after the last person was removed. That is
544
+ exactly the revoke case, so it is the difference between this being right and being decorative
545
+ noise. `_clean_entries` is the same normaliser every other read here uses, so a junk entry
546
+ cannot mark an object either.
547
+
548
+ β›” FAIL-CLOSED like the rest of this module: an unreadable bucket answers the EMPTY set β€” no
549
+ marks β€” never a default that claims something is shared. An unknown KIND still RAISES, exactly
550
+ as `grants` / `shared_with` / `drop_objects` do: that is a typo in a caller, not junk in the
551
+ store, and swallowing it into "nothing is shared" would hide a caller that never works.
552
+ """
553
+ kind = _check_kind(kind)
554
+ try:
555
+ by_kind = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
556
+ rows = list(by_kind.items())
557
+ except Exception:
558
+ return set()
559
+ return {str(oid) for oid, rec in rows
560
+ if isinstance(rec, dict) and _clean_entries(rec.get('entries'))}
561
+
562
+
563
+ def drop_objects(pairs, st=None):
564
+ """Remove whole grant RECORDS, owner husk included β€” wave 21, item 6a (C3).
565
+
566
+ A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
567
+ into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
568
+ transaction for the whole sweep β€” a table delete drops its database grant plus a view
569
+ grant per view that lived in its bucket."""
570
+ want = {}
571
+ for kind, oid in pairs or ():
572
+ want.setdefault(_check_kind(kind), set()).add(str(oid))
573
+ if not want:
574
+ return
575
+
576
+ def _apply(data):
577
+ for kind, oids in want.items():
578
+ by_kind = data.get(kind)
579
+ if isinstance(by_kind, dict):
580
+ for oid in oids:
581
+ by_kind.pop(oid, None)
582
+ return data
583
+
584
+ _st(st).update(SHARES_KEY, _apply, flush='async')
platform/core/store.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/core/table_store.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/core/user_tables.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/harness/connectors/odoo.py CHANGED
@@ -51,8 +51,24 @@ _MAP = {
51
  'f': {'product': 'product_id', 'qty': 'quantity', 'location': 'location_id'}},
52
  Entity.PRODUCT: {
53
  'model': 'product.product', 'date': None, 'dt': False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  'f': {'id': 'id', 'code': 'default_code', 'name': 'name', 'category': 'categ_id',
55
- 'cost': 'standard_price', 'active': 'active'}},
56
  Entity.CUSTOMER: {
57
  'model': 'res.partner', 'date': None, 'dt': False,
58
  'f': {'id': 'id', 'name': 'name', 'city': 'city', 'state': 'state_id',
 
51
  'f': {'product': 'product_id', 'qty': 'quantity', 'location': 'location_id'}},
52
  Entity.PRODUCT: {
53
  'model': 'product.product', 'date': None, 'dt': False,
54
+ # ⭐⭐ W41-T26 (owner instruction 28, "pull UPC live from Odoo") β€” `barcode` is the
55
+ # canonical UPC. It is declared HERE so the vocabulary is complete for the next connector
56
+ # and the next tenant, but be clear about what this line does and does not do TODAY:
57
+ # ⚠ NOTHING in the repo calls `.records()` or `.aggregate()` on `Entity.PRODUCT` (grepped
58
+ # 2026-08-24: the only two references to the symbol are this map and `canonical.FIELDS`).
59
+ # The product grid's barcode comes from `modules/products.py::catalogue()`, which issues
60
+ # its own `search_read` and does not route through this connector at all. So this is a
61
+ # CONTRACT entry, not the live path, and moving it will not move a cell on screen.
62
+ # ⚠ PAYLOAD: `records()` falls back to `tuple(meta['f'])` when a Query names no measures
63
+ # (`:163`), so an unmeasured product read widens 6 fields -> 7. No caller exists to pay
64
+ # that today; a future one reads one extra char column per product (~13 bytes x 5,874).
65
+ # β›” `harness/canonical.py::FIELDS[Entity.PRODUCT]` still reads {'id','code','name',
66
+ # 'category','cost','active'} and does NOT list 'barcode'. That file is outside this
67
+ # ticket's fence, so the two drifted apart deliberately rather than by oversight. Nothing
68
+ # ENFORCES FIELDS in code (grepped: it has no consumer), so this is a documentation gap
69
+ # and not a live defect β€” but the next person to touch canonical.py should close it.
70
  'f': {'id': 'id', 'code': 'default_code', 'name': 'name', 'category': 'categ_id',
71
+ 'cost': 'standard_price', 'active': 'active', 'barcode': 'barcode'}},
72
  Entity.CUSTOMER: {
73
  'model': 'res.partner', 'date': None, 'dt': False,
74
  'f': {'id': 'id', 'name': 'name', 'city': 'city', 'state': 'state_id',
platform/harness/datastore.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/harness/meta_store.py CHANGED
@@ -1,521 +1,521 @@
1
- """meta_store.py β€” the REST loader for a mirror whose only other loader is XML-RPC (W31-T48).
2
-
3
- python platform/harness/meta_store.py --sync pull into tenant #0's mirror
4
- python platform/harness/meta_store.py --sync --tenant gtmlab
5
- python platform/harness/meta_store.py --status what is in the mirror now
6
-
7
- β›” WHY THIS IS A SIBLING AND NOT A ROW IN `datastore.ENTITIES`. `datastore.sync_entity` is
8
- hardwired to XML-RPC β€” it does `import core.odoo as O` and speaks `search_read` β€” so no amount of
9
- spec data makes it fetch over HTTPS. What IS reusable is everything *below* the fetch: the
10
- per-tenant file (`path_for`), the process-singleton connection (`connect`), and the
11
- delete-then-insert upsert. This module borrows those and brings its own reader. **The mirror is one
12
- store with two loaders, not two stores.**
13
-
14
- ⭐ THE SHAPE OF THE WHOLE THING, because it is the owner's actual request: *"we just need to pull
15
- the data into our template database for Meta"*, working *"just like Odoo"*. Odoo's path is
16
- `XML-RPC -> DuckDB mirror -> odoo_relational -> ut_odoo_* locked grids`. Meta's is
17
- `Graph -> DuckDB mirror -> meta_relational -> ut_meta_* locked grids`. Same middle, same end, one
18
- different first hop.
19
-
20
- ⚠ EVERY COLUMN NAME BELOW WAS MEASURED, NOT READ OFF A DOC (R8). `proto/meta-entity-fields.json`
21
- carries the run: each level's list is what the API ACCEPTED when asked, on a real ad account.
22
- Ad Account 45 Β· Campaign 35 Β· Ad Set 54 Β· Ad 36 Β· Creative 58 Β· Insights 57
23
- β›” AND `ACCEPTED` IS NOT `RETURNED`. Graph omits null fields from a response, so a 20-field ask
24
- came back with 14 keys. Building a schema from what came back would drop ~30% of the columns with
25
- nothing going red β€” which is exactly the "do not drop any column" instruction, broken silently.
26
- The lists below are therefore the ASKED-AND-ACCEPTED set, and a column with no value is NULL.
27
-
28
- ⚠ `owner` IS ABSENT FROM THE AD ACCOUNT LIST AND THAT IS A REPORTED LIMIT, NOT AN OMISSION: it
29
- answers `403 (#200) Requires business_management permission`, which this token does not carry. One
30
- column of 285. R6's second sentence β€” a limit that cannot be removed gets stated with its cause.
31
-
32
- β›” IDS ARE TEXT, ALWAYS. A Meta object id is a 17-digit decimal string; `120273975028650555` is
33
- larger than 2^53, so any float or JS-number path silently corrupts it. Same ruling as `ig_id`
34
- (W26/R3), for the same reason, and it is why every `id` column here is VARCHAR.
35
- """
36
- import argparse
37
- import json
38
- import os
39
- import sys
40
- import time
41
- import urllib.error
42
- import urllib.parse
43
- import urllib.request
44
- from pathlib import Path
45
-
46
- _HERE = Path(__file__).resolve().parent
47
- if str(_HERE.parent) not in sys.path:
48
- sys.path.insert(0, str(_HERE.parent))
49
-
50
- from harness import datastore # noqa: E402
51
-
52
- GRAPH_VERSION = os.environ.get("META_GRAPH_VERSION") or "v21.0"
53
- GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}"
54
-
55
- #: Numeric columns, by name. Everything else is VARCHAR β€” including ids (see the header) and
56
- #: including anything Graph returns as a nested object, which is stored as compact JSON text.
57
- _INT = {"impressions", "reach", "clicks", "unique_clicks", "inline_link_clicks",
58
- "inline_post_engagement", "full_view_impressions", "estimated_ad_recallers",
59
- "account_status", "age", "timezone_id", "timezone_offset_hours_utc", "io_number",
60
- "min_daily_budget", "min_campaign_group_spend_cap"}
61
- _DBL = {"spend", "social_spend", "ctr", "unique_ctr", "cpc", "cpm", "cpp", "frequency",
62
- "inline_link_click_ctr", "outbound_clicks_ctr", "amount_spent", "balance", "spend_cap",
63
- "daily_budget", "lifetime_budget", "budget_remaining", "bid_amount", "canvas_avg_view_time",
64
- "daily_min_spend_target", "daily_spend_cap", "lifetime_min_spend_target",
65
- "lifetime_spend_cap", "lifetime_imps"}
66
-
67
- #: ⭐ THE MEASURED CATALOG. `edge` is the connection on the ad account; `None` means the account
68
- #: itself. `parent` names the column that ties a row to its parent, used by nothing here and by
69
- #: the grid links in `meta_relational`.
70
- ACCOUNT_FIELDS = (
71
- "account_id account_status age amount_spent balance business_city business_country_code "
72
- "business_name business_state business_street business_street2 business_zip capabilities "
73
- "created_time currency disable_reason end_advertiser end_advertiser_name funding_source "
74
- "has_migrated_permissions id io_number is_attribution_spec_system_default "
75
- "is_direct_deals_enabled is_notifications_enabled is_personal is_prepay_account "
76
- "is_tax_id_required line_numbers media_agency min_campaign_group_spend_cap min_daily_budget "
77
- "name offsite_pixels_tos_accepted partner spend_cap tax_id tax_id_status tax_id_type "
78
- "timezone_id timezone_name timezone_offset_hours_utc tos_accepted user_tasks user_tos_accepted"
79
- ).split()
80
-
81
- CAMPAIGN_FIELDS = (
82
- "account_id bid_strategy boosted_object_id budget_rebalance_flag budget_remaining buying_type "
83
- "campaign_group_active_time can_create_brand_lift_study can_use_spend_cap configured_status "
84
- "created_time daily_budget effective_status id is_skadnetwork_attribution issues_info "
85
- "last_budget_toggling_time lifetime_budget name objective pacing_type primary_attribution "
86
- "promoted_object smart_promotion_type source_campaign source_campaign_id special_ad_categories "
87
- "special_ad_category special_ad_category_country spend_cap start_time status stop_time "
88
- "topline_id updated_time"
89
- ).split()
90
-
91
- ADSET_FIELDS = (
92
- "account_id adlabels adset_schedule asset_feed_id attribution_spec bid_adjustments bid_amount "
93
- "bid_constraints bid_info bid_strategy billing_event budget_remaining campaign "
94
- "campaign_active_time campaign_attribution campaign_id configured_status created_time "
95
- "creative_sequence daily_budget daily_min_spend_target daily_spend_cap destination_type "
96
- "effective_status end_time frequency_control_specs id instagram_actor_id is_dynamic_creative "
97
- "issues_info learning_stage_info lifetime_budget lifetime_imps lifetime_min_spend_target "
98
- "lifetime_spend_cap multi_optimization_goal_weight name optimization_goal "
99
- "optimization_sub_event pacing_type promoted_object recurring_budget_semantics review_feedback "
100
- "rf_prediction_id source_adset source_adset_id start_time status targeting "
101
- "targeting_optimization_types time_based_ad_rotation_id_blocks "
102
- "time_based_ad_rotation_intervals updated_time use_new_app_click"
103
- ).split()
104
-
105
- AD_FIELDS = (
106
- "account_id ad_active_time ad_review_feedback ad_schedule_end_time ad_schedule_start_time "
107
- "adlabels adset adset_id bid_amount bid_info bid_type campaign campaign_id configured_status "
108
- "conversion_domain created_time creative demolink_hash display_sequence effective_status "
109
- "engagement_audience failed_delivery_checks id issues_info last_updated_by_app_id name "
110
- "preview_shareable_link priority recommendations source_ad source_ad_id status targeting "
111
- "tracking_and_conversion_with_defaults tracking_specs updated_time"
112
- ).split()
113
-
114
- CREATIVE_FIELDS = (
115
- "account_id actor_id adlabels applink_treatment asset_feed_spec authorization_category body "
116
- "branded_content_sponsor_page_id bundle_folder_id call_to_action_type categorization_criteria "
117
- "category_media_source collaborative_ads_lsb_image_bank_id degrees_of_freedom_spec "
118
- "destination_set_id dynamic_ad_voice effective_authorization_category "
119
- "effective_instagram_media_id effective_object_story_id enable_direct_install "
120
- "enable_launch_instant_app id image_crops image_hash image_url instagram_actor_id "
121
- "instagram_permalink_url instagram_story_id instagram_user_id interactive_components_spec "
122
- "link_deep_link_url link_destination_display_url link_og_id link_url "
123
- "messenger_sponsored_message name object_id object_store_url object_story_id object_story_spec "
124
- "object_type object_url place_page_set_id platform_customizations playable_asset_id "
125
- "portrait_customizations product_set_id recommender_settings source_instagram_media_id status "
126
- "template_url template_url_spec thumbnail_id thumbnail_url title url_tags "
127
- "use_page_actor_override video_id"
128
- ).split()
129
-
130
- INSIGHT_FIELDS = (
131
- "account_currency account_id account_name action_values actions ad_id ad_name adset_id "
132
- "adset_name attribution_setting buying_type campaign_id campaign_name canvas_avg_view_time "
133
- "clicks conversion_rate_ranking conversion_values conversions cost_per_action_type "
134
- "cost_per_inline_link_click cost_per_thruplay cost_per_unique_click cpc cpm cpp ctr date_start "
135
- "date_stop engagement_rate_ranking estimated_ad_recallers frequency full_view_impressions "
136
- "impressions inline_link_click_ctr inline_link_clicks inline_post_engagement objective "
137
- "optimization_goal outbound_clicks outbound_clicks_ctr purchase_roas quality_ranking reach "
138
- "social_spend spend unique_clicks unique_ctr unique_outbound_clicks "
139
- "video_avg_time_watched_actions video_p100_watched_actions video_p25_watched_actions "
140
- "video_p50_watched_actions video_p75_watched_actions video_p95_watched_actions "
141
- "video_play_actions video_thruplay_watched_actions website_purchase_roas"
142
- ).split()
143
-
144
- #: table -> (graph edge on the account | None, measured fields, parent column)
145
- SPECS = {
146
- "meta_ad_accounts": (None, ACCOUNT_FIELDS, None),
147
- "meta_campaigns": ("campaigns", CAMPAIGN_FIELDS, "account_id"),
148
- "meta_adsets": ("adsets", ADSET_FIELDS, "campaign_id"),
149
- "meta_ads": ("ads", AD_FIELDS, "adset_id"),
150
- "meta_creatives": ("adcreatives", CREATIVE_FIELDS, "account_id"),
151
- }
152
-
153
- #: The daily Insights grain (R2). One row per (ad, day) β€” the id is synthesised because Insights
154
- #: has no id of its own, and it must be STABLE so a re-run updates instead of appending.
155
- INSIGHTS_TABLE = "meta_insights_daily"
156
- INSIGHTS_LEVEL = os.environ.get("META_INSIGHTS_LEVEL") or "ad"
157
-
158
- #: β›” INSIGHTS IS FETCHED IN TIME SLICES, AND THE REASON IS MEASURED, NOT DEFENSIVE. All 57 fields
159
- #: at ad level over `last_90d` with a 100-row page answers **HTTP 500 "An unknown error occurred"**
160
- #: β€” Graph's way of saying the synchronous query is too heavy (the async report-run API is the
161
- #: other answer, and it costs a poll loop this does not need). The SAME 57 fields over 7 days at
162
- #: page 25 answer 200. So the window is walked in slices with every column intact:
163
- #: 57 fields Β· ad level Β· 7d Β· limit 25 -> 200, 25 rows
164
- #: 57 fields Β· account level Β· 7d -> 200, 7 rows
165
- #: ⚠ Narrowing the FIELD list would also have "fixed" it, and that is the wrong fix twice over β€”
166
- #: it drops columns R2 requires, and it does so invisibly.
167
- INSIGHTS_DAYS = int(os.environ.get("META_INSIGHTS_DAYS") or 90)
168
- INSIGHTS_SLICE = int(os.environ.get("META_INSIGHTS_SLICE_DAYS") or 7)
169
- INSIGHTS_PAGE = int(os.environ.get("META_INSIGHTS_PAGE") or 25)
170
-
171
-
172
- def _slices(days, size):
173
- """[(since, until)] covering the last `days`, oldest first, in `size`-day windows."""
174
- from datetime import date, timedelta
175
- end = date.today()
176
- start = end - timedelta(days=max(1, days) - 1)
177
- out, cur = [], start
178
- while cur <= end:
179
- stop = min(cur + timedelta(days=max(1, size) - 1), end)
180
- out.append((cur.isoformat(), stop.isoformat()))
181
- cur = stop + timedelta(days=1)
182
- return out
183
-
184
- #: ⚠ A REAL CEILING, AND R6's SECOND SENTENCE APPLIES TO IT. Graph pages at 25-100 rows; this is
185
- #: the number of PAGES a single edge may walk before the loader stops and SAYS it stopped. It is
186
- #: not a row cap on a connected source (R6 forbids that) β€” it is a runaway guard, and reaching it
187
- #: is reported as a problem, never absorbed.
188
- MAX_PAGES = int(os.environ.get("META_MAX_PAGES") or 200)
189
- PAGE = int(os.environ.get("META_PAGE_SIZE") or 100)
190
-
191
-
192
- class MetaError(RuntimeError):
193
- """A Graph refusal carrying Meta's own words. Safe to print: no token ever reaches it."""
194
-
195
-
196
- def token():
197
- """The Meta token from the environment or gitignored `platform/.env`; "" when absent.
198
-
199
- ⚠ Same resolver `aios-web/api/connectors_meta.py` uses. Duplicated deliberately and minimally:
200
- `platform/` must not import from `aios-web/api/`, which is the layering rule this repo keeps
201
- (`core` never imports up). Fifteen lines is the price of that boundary.
202
- """
203
- tok = os.environ.get("META_ADS_ACCESS_TOKEN") or ""
204
- if tok:
205
- return tok.strip()
206
- env = _HERE.parent / ".env"
207
- if env.exists():
208
- for line in env.read_text(encoding="utf-8", errors="replace").splitlines():
209
- if line.strip().startswith("META_ADS_ACCESS_TOKEN"):
210
- _, _, v = line.partition("=")
211
- return v.strip().strip('"').strip("'")
212
- return ""
213
-
214
-
215
- def _get(path, tok, **params):
216
- params["access_token"] = tok
217
- url = f"{GRAPH}/{path.lstrip('/')}?" + urllib.parse.urlencode(params)
218
- req = urllib.request.Request(url, headers={"User-Agent": "aios-meta-store/1"})
219
- try:
220
- with urllib.request.urlopen(req, timeout=120) as r:
221
- return json.loads(r.read().decode("utf-8", "replace"))
222
- except urllib.error.HTTPError as e:
223
- try:
224
- msg = (json.loads(e.read().decode("utf-8", "replace")).get("error") or {}
225
- ).get("message") or ""
226
- except Exception:
227
- msg = ""
228
- raise MetaError(f"HTTP {e.code} on /{path.lstrip('/')}: {msg[:200]}") from None
229
- except Exception as e:
230
- raise MetaError(f"{type(e).__name__} on /{path.lstrip('/')}") from None
231
-
232
-
233
- #: Graph's own words when a page is too heavy. It arrives as an HTTP **500**, not a 4xx, which is
234
- #: why it cannot be treated as "the server is broken, give up".
235
- _TOO_MUCH = "reduce the amount of data"
236
-
237
- #: β›” THE ADS-MANAGEMENT RATE LIMIT, WHICH IS PER AD ACCOUNT AND NOT PER TOKEN. Measured: after a
238
- #: heavy backfill Graph answers **HTTP 400 "There have been too many calls to this ad-account.
239
- #: Wait a bit and try again."** It is a 4xx, so nothing about the status code says "retry" β€” the
240
- #: MESSAGE is the only signal, which is why it is matched here rather than inferred from a code.
241
- #: ⚠ It persists for minutes, so the backoff is measured in minutes and BOUNDED: after
242
- #: `_RATE_TRIES` waits the loader STOPS and reports what it got, rather than sitting in a retry
243
- #: loop nobody can see. A partial mirror that says it is partial beats a hung sync.
244
- _RATE_LIMITED = "too many calls"
245
- _RATE_TRIES = int(os.environ.get("META_RATE_TRIES") or 3)
246
- _RATE_WAIT = int(os.environ.get("META_RATE_WAIT_S") or 90)
247
-
248
-
249
- def _walk(path, tok, log, **params):
250
- """Every page of an edge, paged by CURSOR under our own parameters.
251
-
252
- β›” IT DOES NOT FOLLOW GRAPH'S `paging.next` URL, AND THAT IS THE WHOLE POINT. Measured: the
253
- first call to `/adcreatives` at limit=100 answers **HTTP 500 "Please reduce the amount of data
254
- you're asking for"**, the retry at limit=25 succeeds β€” and then `next` carries the ORIGINAL
255
- limit=100 and fails again on page 2. A backoff that cannot reach every page is not a backoff.
256
- Re-issuing each page ourselves with `after=<cursor>` keeps the reduced limit for the whole walk.
257
-
258
- ⭐ It shrinks the PAGE, never the FIELD LIST. Dropping columns to make a request fit is the
259
- silent omission R2 forbids, and nothing downstream could see it. Fewer rows per call, always
260
- every column per row.
261
-
262
- -> (rows, hit_page_cap)
263
- """
264
- out, pages = [], 0
265
- limit = int(params.pop("limit", None) or PAGE)
266
- after, waited = None, 0
267
- while True:
268
- call = dict(params, limit=limit)
269
- if after:
270
- call["after"] = after
271
- try:
272
- body = _get(path, tok, **call)
273
- except MetaError as e:
274
- if _TOO_MUCH in str(e) and limit > 5:
275
- limit = max(5, limit // 4)
276
- log(f" page too heavy for /{path} - retrying at limit={limit} "
277
- f"(a payload ceiling, not a row cap; every column is still asked for)")
278
- continue
279
- if _RATE_LIMITED in str(e).lower() and waited < _RATE_TRIES:
280
- waited += 1
281
- log(f" rate-limited on /{path} (per AD ACCOUNT, not per token) - waiting "
282
- f"{_RATE_WAIT}s, attempt {waited}/{_RATE_TRIES}")
283
- time.sleep(_RATE_WAIT)
284
- continue
285
- if _RATE_LIMITED in str(e).lower():
286
- log(f" !! GIVING UP on /{path} after {waited} waits: still rate-limited. "
287
- f"{len(out)} row(s) collected so far are kept. Cause: the ads-management "
288
- f"limit is per ad account and persists for minutes. Fix: re-run "
289
- f"`--sync --only <table>` later; the upsert is idempotent.")
290
- return out, True
291
- raise
292
- out.extend(body.get("data") or [])
293
- pages += 1
294
- after = ((body.get("paging") or {}).get("cursors") or {}).get("after")
295
- has_next = bool((body.get("paging") or {}).get("next")) and bool(after)
296
- if not has_next:
297
- return out, False
298
- if pages >= MAX_PAGES:
299
- log(f" !! STOPPED at MAX_PAGES={MAX_PAGES} on /{path} with more pages left. "
300
- f"Cause: a runaway guard, not a row cap. Fix: raise META_MAX_PAGES, or narrow the "
301
- f"window with META_INSIGHTS_DAYS.")
302
- return out, True
303
-
304
-
305
- def _cell(value):
306
- """One Graph value -> one DuckDB cell. Nested structures become compact JSON TEXT rather than
307
- being dropped: R2 says every field the API returns, and `targeting` is a field."""
308
- if value is None or isinstance(value, (str, int, float, bool)):
309
- return value
310
- return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
311
-
312
-
313
- def _coltype(name, rows=None):
314
- """The DuckDB type for one column β€” decided by the DATA when there is data, by the name only
315
- as a fallback.
316
-
317
- β›” THE NAME LIST WAS WRONG AND ONLY THE API COULD SAY SO. `cost_per_unique_click`,
318
- `cost_per_action_type`, `purchase_roas` and friends READ like money and are **arrays of
319
- action-type objects** on the Insights edge:
320
- [{"action_type":"outbound_click","value":"1.459854"}]
321
- Typed DOUBLE from `_DBL`, the insert died with `Conversion Error: Could not convert string
322
- '[{...}]' to DOUBLE` β€” after five entity tables had already been written, so the sync looked
323
- like it worked and then blew up on the last table.
324
- ⭐ Same principle the field CATALOG is built on, applied one layer down: **ask the response,
325
- do not assert from a list.** A value that ever arrives as a list or dict is JSON TEXT, because
326
- that is what `_cell` stores; anything else falls back to the measured name hints.
327
- """
328
- if rows:
329
- seen, kinds = 0, set()
330
- for r in rows:
331
- v = r.get(name)
332
- if v is None or v == "":
333
- continue
334
- kinds.add("json" if isinstance(v, (list, dict)) else
335
- "bool" if isinstance(v, bool) else
336
- "int" if isinstance(v, int) else
337
- "float" if isinstance(v, float) else "str")
338
- seen += 1
339
- if seen >= 200:
340
- break
341
- if kinds:
342
- if "json" in kinds or "str" in kinds:
343
- return "VARCHAR" # a JSON blob, or a numeric STRING
344
- if kinds <= {"int", "bool"}:
345
- return "BIGINT" if name in _INT else ("VARCHAR" if "bool" in kinds else "BIGINT")
346
- return "DOUBLE"
347
- if name in _INT:
348
- return "BIGINT"
349
- if name in _DBL:
350
- return "DOUBLE"
351
- return "VARCHAR" # ids included β€” see the header
352
-
353
-
354
- def _ensure(con, table, fields, rows=None):
355
- """Create or widen the table, with every column typed from `rows` when they are available.
356
-
357
- ⚠ AN EXISTING COLUMN WHOSE TYPE IS NOW WRONG IS REBUILT, NOT PATCHED. DuckDB cannot retype a
358
- column in place, and this table is DERIVED data that can be re-pulled in minutes β€” so a type
359
- disagreement drops and recreates rather than limping on with a column that refuses every
360
- insert. The alternative is a mirror that is permanently unwritable for one bad guess.
361
- """
362
- want = {f: _coltype(f, rows) for f in fields}
363
- have = {r[1]: str(r[2]).upper() for r in con.execute(f"PRAGMA table_info('{table}')").fetchall()}
364
- if have:
365
- clash = [f for f, ty in want.items() if f in have and have[f] != ty
366
- and not (have[f].startswith("VARCHAR") and ty == "VARCHAR")]
367
- if clash:
368
- con.execute(f"DROP TABLE {table}")
369
- have = {}
370
- if not have:
371
- cols = ", ".join(f"{f} {want[f]}" for f in fields)
372
- con.execute(f"CREATE TABLE IF NOT EXISTS {table} (id VARCHAR PRIMARY KEY, {cols})"
373
- if "id" not in fields else
374
- f"CREATE TABLE IF NOT EXISTS {table} ({cols})")
375
- return
376
- for f in fields:
377
- if f not in have:
378
- con.execute(f"ALTER TABLE {table} ADD COLUMN {f} {want[f]}")
379
-
380
-
381
- def _upsert(con, table, fields, rows):
382
- """Delete-then-insert by id β€” the same idempotence `datastore._upsert` gives the Odoo half, so
383
- a re-sync updates in place and can never append a second copy of the same object."""
384
- if not rows:
385
- return 0
386
- cols = list(fields)
387
- ids = [str(r.get("id") or "") for r in rows]
388
- q = ",".join("?" for _ in ids)
389
- con.execute(f"DELETE FROM {table} WHERE id IN ({q})", ids)
390
- con.executemany(
391
- f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({', '.join('?' for _ in cols)})",
392
- [[_cell(r.get(c)) for c in cols] for r in rows])
393
- return len(rows)
394
-
395
-
396
- def sync(tenant_key="royal-imports", tok=None, log=print, insights=True, insights_days=None):
397
- """Pull every level into the tenant's mirror. -> a report dict; raises only on a bad token.
398
-
399
- Idempotent: re-running updates rows in place. Safe to call at boot and on the resync loop,
400
- exactly as `odoo_relational.refresh` is.
401
- """
402
- tok = tok or token()
403
- # β›” THE WINDOW IS A PARAMETER, NOT AN ENVIRONMENT READ AT CALL TIME β€” and the difference cost a
404
- # live deploy. `INSIGHTS_DAYS` binds at IMPORT (module scope), so `main._pull_meta`'s
405
- # `os.environ.setdefault("META_INSIGHTS_DAYS", "7")` executed AFTER this module was already
406
- # imported and changed nothing: every boot pulled **90 days**, not 7, which is the slow path
407
- # that trips the per-ad-account rate limit and never finishes. The comment beside that call
408
- # claimed a short window the code could not deliver.
409
- # ⭐ The shape: **a knob read at import cannot be turned by a caller at runtime.** Passing it
410
- # makes the caller's intent effective instead of aspirational; the env var stays the DEFAULT.
411
- days = int(insights_days or INSIGHTS_DAYS)
412
- report = {"tenant": tenant_key, "tables": {}, "problems": [], "accounts": []}
413
- if not tok:
414
- report["problems"].append(
415
- "META_ADS_ACCESS_TOKEN is not set in the environment or in platform/.env, so nothing "
416
- "was pulled. This is a missing CREDENTIAL, not a missing capability.")
417
- return report
418
-
419
- path = datastore.path_for(tenant_key)
420
- if Path(datastore.DB_PATH) != Path(path):
421
- datastore.use_path(path)
422
- con = datastore.connect()
423
- log(f" mirror: {Path(path).name}")
424
-
425
- accts, _ = _walk("me/adaccounts", tok, log, fields="id,name", limit=PAGE)
426
- report["accounts"] = [a.get("id") for a in accts]
427
- if not accts:
428
- report["problems"].append("the token reaches no ad accounts")
429
- return report
430
-
431
- # ── the five entity levels ────────────────────────────────────────────────────────────────
432
- for table, (edge, fields, _parent) in SPECS.items():
433
- rows, capped = [], False
434
- for acct in accts:
435
- aid = acct["id"]
436
- if edge is None:
437
- rows.append(_get(aid, tok, fields=",".join(fields)))
438
- else:
439
- got, hit = _walk(f"{aid}/{edge}", tok, log, fields=",".join(fields), limit=PAGE)
440
- rows.extend(got)
441
- capped = capped or hit
442
- _ensure(con, table, fields, rows)
443
- n = _upsert(con, table, fields, rows)
444
- total = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
445
- report["tables"][table] = {"pulled": n, "in_mirror": total, "capped": capped}
446
- log(f" {table:<20} pulled {n:>6} mirror total {total:>6}"
447
- + (" !! PAGE CAP HIT" if capped else ""))
448
- if capped:
449
- report["problems"].append(f"{table}: stopped at MAX_PAGES={MAX_PAGES}")
450
-
451
- # ── the daily Insights grain ──────────────────────────────────────────────────────────────
452
- if insights:
453
- rows, capped = [], False
454
- for acct in accts:
455
- for since, until in _slices(days, INSIGHTS_SLICE):
456
- got, hit = _walk(f"{acct['id']}/insights", tok, log,
457
- fields=",".join(INSIGHT_FIELDS), level=INSIGHTS_LEVEL,
458
- time_increment="1", limit=INSIGHTS_PAGE,
459
- time_range=json.dumps({"since": since, "until": until}))
460
- rows.extend(got)
461
- capped = capped or hit
462
- # β›” Insights rows carry no id. The key must be STABLE across runs or every re-sync
463
- # appends a second copy of the same day β€” so it is composed from the grain itself.
464
- for r in rows:
465
- r["id"] = ":".join(str(r.get(k) or "") for k in
466
- (f"{INSIGHTS_LEVEL}_id", "date_start", "date_stop"))
467
- fields = ["id"] + INSIGHT_FIELDS
468
- _ensure(con, INSIGHTS_TABLE, fields, rows)
469
- n = _upsert(con, INSIGHTS_TABLE, fields, rows)
470
- total = con.execute(f"SELECT count(*) FROM {INSIGHTS_TABLE}").fetchone()[0]
471
- report["tables"][INSIGHTS_TABLE] = {"pulled": n, "in_mirror": total, "capped": capped}
472
- log(f" {INSIGHTS_TABLE:<20} pulled {n:>6} mirror total {total:>6}"
473
- + (" !! PAGE CAP HIT" if capped else ""))
474
-
475
- con.execute("INSERT OR REPLACE INTO _sync_state VALUES (?,?,?,?,?,?)",
476
- ["meta", "done", 0, f"{days}d/{INSIGHTS_SLICE}d@{INSIGHTS_LEVEL}",
477
- sum(t["in_mirror"] for t in report["tables"].values()),
478
- time.strftime("%Y-%m-%d %H:%M:%S")])
479
- return report
480
-
481
-
482
- def status(tenant_key="royal-imports"):
483
- """What is in the mirror right now, per table. Never fetches."""
484
- path = datastore.path_for(tenant_key)
485
- if Path(datastore.DB_PATH) != Path(path):
486
- datastore.use_path(path)
487
- out = {}
488
- con = datastore.connect()
489
- for table in list(SPECS) + [INSIGHTS_TABLE]:
490
- try:
491
- out[table] = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
492
- except Exception:
493
- out[table] = None # table absent = never synced
494
- return out
495
-
496
-
497
- def main(argv=None):
498
- ap = argparse.ArgumentParser(description="Pull Meta Ads into the tenant's DuckDB mirror.")
499
- ap.add_argument("--sync", action="store_true")
500
- ap.add_argument("--status", action="store_true")
501
- ap.add_argument("--tenant", default="royal-imports")
502
- ap.add_argument("--no-insights", action="store_true")
503
- ap.add_argument("--insights-days", type=int, default=None,
504
- help="override the Insights window for THIS run (default INSIGHTS_DAYS); the env var is the default, this is the caller's say")
505
- a = ap.parse_args(argv)
506
- if a.status:
507
- for k, v in status(a.tenant).items():
508
- print(f" {k:<20} {'(never synced)' if v is None else v}")
509
- return 0
510
- if not a.sync:
511
- ap.print_help()
512
- return 2
513
- rep = sync(a.tenant, insights=not a.no_insights, insights_days=a.insights_days)
514
- for p in rep["problems"]:
515
- print(" PROBLEM:", p)
516
- print(f" accounts: {len(rep['accounts'])} tables: {len(rep['tables'])}")
517
- return 1 if rep["problems"] else 0
518
-
519
-
520
- if __name__ == "__main__":
521
- sys.exit(main())
 
1
+ """meta_store.py β€” the REST loader for a mirror whose only other loader is XML-RPC (W31-T48).
2
+
3
+ python platform/harness/meta_store.py --sync pull into tenant #0's mirror
4
+ python platform/harness/meta_store.py --sync --tenant gtmlab
5
+ python platform/harness/meta_store.py --status what is in the mirror now
6
+
7
+ β›” WHY THIS IS A SIBLING AND NOT A ROW IN `datastore.ENTITIES`. `datastore.sync_entity` is
8
+ hardwired to XML-RPC β€” it does `import core.odoo as O` and speaks `search_read` β€” so no amount of
9
+ spec data makes it fetch over HTTPS. What IS reusable is everything *below* the fetch: the
10
+ per-tenant file (`path_for`), the process-singleton connection (`connect`), and the
11
+ delete-then-insert upsert. This module borrows those and brings its own reader. **The mirror is one
12
+ store with two loaders, not two stores.**
13
+
14
+ ⭐ THE SHAPE OF THE WHOLE THING, because it is the owner's actual request: *"we just need to pull
15
+ the data into our template database for Meta"*, working *"just like Odoo"*. Odoo's path is
16
+ `XML-RPC -> DuckDB mirror -> odoo_relational -> ut_odoo_* locked grids`. Meta's is
17
+ `Graph -> DuckDB mirror -> meta_relational -> ut_meta_* locked grids`. Same middle, same end, one
18
+ different first hop.
19
+
20
+ ⚠ EVERY COLUMN NAME BELOW WAS MEASURED, NOT READ OFF A DOC (R8). `proto/meta-entity-fields.json`
21
+ carries the run: each level's list is what the API ACCEPTED when asked, on a real ad account.
22
+ Ad Account 45 Β· Campaign 35 Β· Ad Set 54 Β· Ad 36 Β· Creative 58 Β· Insights 57
23
+ β›” AND `ACCEPTED` IS NOT `RETURNED`. Graph omits null fields from a response, so a 20-field ask
24
+ came back with 14 keys. Building a schema from what came back would drop ~30% of the columns with
25
+ nothing going red β€” which is exactly the "do not drop any column" instruction, broken silently.
26
+ The lists below are therefore the ASKED-AND-ACCEPTED set, and a column with no value is NULL.
27
+
28
+ ⚠ `owner` IS ABSENT FROM THE AD ACCOUNT LIST AND THAT IS A REPORTED LIMIT, NOT AN OMISSION: it
29
+ answers `403 (#200) Requires business_management permission`, which this token does not carry. One
30
+ column of 285. R6's second sentence β€” a limit that cannot be removed gets stated with its cause.
31
+
32
+ β›” IDS ARE TEXT, ALWAYS. A Meta object id is a 17-digit decimal string; `120273975028650555` is
33
+ larger than 2^53, so any float or JS-number path silently corrupts it. Same ruling as `ig_id`
34
+ (W26/R3), for the same reason, and it is why every `id` column here is VARCHAR.
35
+ """
36
+ import argparse
37
+ import json
38
+ import os
39
+ import sys
40
+ import time
41
+ import urllib.error
42
+ import urllib.parse
43
+ import urllib.request
44
+ from pathlib import Path
45
+
46
+ _HERE = Path(__file__).resolve().parent
47
+ if str(_HERE.parent) not in sys.path:
48
+ sys.path.insert(0, str(_HERE.parent))
49
+
50
+ from harness import datastore # noqa: E402
51
+
52
+ GRAPH_VERSION = os.environ.get("META_GRAPH_VERSION") or "v21.0"
53
+ GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}"
54
+
55
+ #: Numeric columns, by name. Everything else is VARCHAR β€” including ids (see the header) and
56
+ #: including anything Graph returns as a nested object, which is stored as compact JSON text.
57
+ _INT = {"impressions", "reach", "clicks", "unique_clicks", "inline_link_clicks",
58
+ "inline_post_engagement", "full_view_impressions", "estimated_ad_recallers",
59
+ "account_status", "age", "timezone_id", "timezone_offset_hours_utc", "io_number",
60
+ "min_daily_budget", "min_campaign_group_spend_cap"}
61
+ _DBL = {"spend", "social_spend", "ctr", "unique_ctr", "cpc", "cpm", "cpp", "frequency",
62
+ "inline_link_click_ctr", "outbound_clicks_ctr", "amount_spent", "balance", "spend_cap",
63
+ "daily_budget", "lifetime_budget", "budget_remaining", "bid_amount", "canvas_avg_view_time",
64
+ "daily_min_spend_target", "daily_spend_cap", "lifetime_min_spend_target",
65
+ "lifetime_spend_cap", "lifetime_imps"}
66
+
67
+ #: ⭐ THE MEASURED CATALOG. `edge` is the connection on the ad account; `None` means the account
68
+ #: itself. `parent` names the column that ties a row to its parent, used by nothing here and by
69
+ #: the grid links in `meta_relational`.
70
+ ACCOUNT_FIELDS = (
71
+ "account_id account_status age amount_spent balance business_city business_country_code "
72
+ "business_name business_state business_street business_street2 business_zip capabilities "
73
+ "created_time currency disable_reason end_advertiser end_advertiser_name funding_source "
74
+ "has_migrated_permissions id io_number is_attribution_spec_system_default "
75
+ "is_direct_deals_enabled is_notifications_enabled is_personal is_prepay_account "
76
+ "is_tax_id_required line_numbers media_agency min_campaign_group_spend_cap min_daily_budget "
77
+ "name offsite_pixels_tos_accepted partner spend_cap tax_id tax_id_status tax_id_type "
78
+ "timezone_id timezone_name timezone_offset_hours_utc tos_accepted user_tasks user_tos_accepted"
79
+ ).split()
80
+
81
+ CAMPAIGN_FIELDS = (
82
+ "account_id bid_strategy boosted_object_id budget_rebalance_flag budget_remaining buying_type "
83
+ "campaign_group_active_time can_create_brand_lift_study can_use_spend_cap configured_status "
84
+ "created_time daily_budget effective_status id is_skadnetwork_attribution issues_info "
85
+ "last_budget_toggling_time lifetime_budget name objective pacing_type primary_attribution "
86
+ "promoted_object smart_promotion_type source_campaign source_campaign_id special_ad_categories "
87
+ "special_ad_category special_ad_category_country spend_cap start_time status stop_time "
88
+ "topline_id updated_time"
89
+ ).split()
90
+
91
+ ADSET_FIELDS = (
92
+ "account_id adlabels adset_schedule asset_feed_id attribution_spec bid_adjustments bid_amount "
93
+ "bid_constraints bid_info bid_strategy billing_event budget_remaining campaign "
94
+ "campaign_active_time campaign_attribution campaign_id configured_status created_time "
95
+ "creative_sequence daily_budget daily_min_spend_target daily_spend_cap destination_type "
96
+ "effective_status end_time frequency_control_specs id instagram_actor_id is_dynamic_creative "
97
+ "issues_info learning_stage_info lifetime_budget lifetime_imps lifetime_min_spend_target "
98
+ "lifetime_spend_cap multi_optimization_goal_weight name optimization_goal "
99
+ "optimization_sub_event pacing_type promoted_object recurring_budget_semantics review_feedback "
100
+ "rf_prediction_id source_adset source_adset_id start_time status targeting "
101
+ "targeting_optimization_types time_based_ad_rotation_id_blocks "
102
+ "time_based_ad_rotation_intervals updated_time use_new_app_click"
103
+ ).split()
104
+
105
+ AD_FIELDS = (
106
+ "account_id ad_active_time ad_review_feedback ad_schedule_end_time ad_schedule_start_time "
107
+ "adlabels adset adset_id bid_amount bid_info bid_type campaign campaign_id configured_status "
108
+ "conversion_domain created_time creative demolink_hash display_sequence effective_status "
109
+ "engagement_audience failed_delivery_checks id issues_info last_updated_by_app_id name "
110
+ "preview_shareable_link priority recommendations source_ad source_ad_id status targeting "
111
+ "tracking_and_conversion_with_defaults tracking_specs updated_time"
112
+ ).split()
113
+
114
+ CREATIVE_FIELDS = (
115
+ "account_id actor_id adlabels applink_treatment asset_feed_spec authorization_category body "
116
+ "branded_content_sponsor_page_id bundle_folder_id call_to_action_type categorization_criteria "
117
+ "category_media_source collaborative_ads_lsb_image_bank_id degrees_of_freedom_spec "
118
+ "destination_set_id dynamic_ad_voice effective_authorization_category "
119
+ "effective_instagram_media_id effective_object_story_id enable_direct_install "
120
+ "enable_launch_instant_app id image_crops image_hash image_url instagram_actor_id "
121
+ "instagram_permalink_url instagram_story_id instagram_user_id interactive_components_spec "
122
+ "link_deep_link_url link_destination_display_url link_og_id link_url "
123
+ "messenger_sponsored_message name object_id object_store_url object_story_id object_story_spec "
124
+ "object_type object_url place_page_set_id platform_customizations playable_asset_id "
125
+ "portrait_customizations product_set_id recommender_settings source_instagram_media_id status "
126
+ "template_url template_url_spec thumbnail_id thumbnail_url title url_tags "
127
+ "use_page_actor_override video_id"
128
+ ).split()
129
+
130
+ INSIGHT_FIELDS = (
131
+ "account_currency account_id account_name action_values actions ad_id ad_name adset_id "
132
+ "adset_name attribution_setting buying_type campaign_id campaign_name canvas_avg_view_time "
133
+ "clicks conversion_rate_ranking conversion_values conversions cost_per_action_type "
134
+ "cost_per_inline_link_click cost_per_thruplay cost_per_unique_click cpc cpm cpp ctr date_start "
135
+ "date_stop engagement_rate_ranking estimated_ad_recallers frequency full_view_impressions "
136
+ "impressions inline_link_click_ctr inline_link_clicks inline_post_engagement objective "
137
+ "optimization_goal outbound_clicks outbound_clicks_ctr purchase_roas quality_ranking reach "
138
+ "social_spend spend unique_clicks unique_ctr unique_outbound_clicks "
139
+ "video_avg_time_watched_actions video_p100_watched_actions video_p25_watched_actions "
140
+ "video_p50_watched_actions video_p75_watched_actions video_p95_watched_actions "
141
+ "video_play_actions video_thruplay_watched_actions website_purchase_roas"
142
+ ).split()
143
+
144
+ #: table -> (graph edge on the account | None, measured fields, parent column)
145
+ SPECS = {
146
+ "meta_ad_accounts": (None, ACCOUNT_FIELDS, None),
147
+ "meta_campaigns": ("campaigns", CAMPAIGN_FIELDS, "account_id"),
148
+ "meta_adsets": ("adsets", ADSET_FIELDS, "campaign_id"),
149
+ "meta_ads": ("ads", AD_FIELDS, "adset_id"),
150
+ "meta_creatives": ("adcreatives", CREATIVE_FIELDS, "account_id"),
151
+ }
152
+
153
+ #: The daily Insights grain (R2). One row per (ad, day) β€” the id is synthesised because Insights
154
+ #: has no id of its own, and it must be STABLE so a re-run updates instead of appending.
155
+ INSIGHTS_TABLE = "meta_insights_daily"
156
+ INSIGHTS_LEVEL = os.environ.get("META_INSIGHTS_LEVEL") or "ad"
157
+
158
+ #: β›” INSIGHTS IS FETCHED IN TIME SLICES, AND THE REASON IS MEASURED, NOT DEFENSIVE. All 57 fields
159
+ #: at ad level over `last_90d` with a 100-row page answers **HTTP 500 "An unknown error occurred"**
160
+ #: β€” Graph's way of saying the synchronous query is too heavy (the async report-run API is the
161
+ #: other answer, and it costs a poll loop this does not need). The SAME 57 fields over 7 days at
162
+ #: page 25 answer 200. So the window is walked in slices with every column intact:
163
+ #: 57 fields Β· ad level Β· 7d Β· limit 25 -> 200, 25 rows
164
+ #: 57 fields Β· account level Β· 7d -> 200, 7 rows
165
+ #: ⚠ Narrowing the FIELD list would also have "fixed" it, and that is the wrong fix twice over β€”
166
+ #: it drops columns R2 requires, and it does so invisibly.
167
+ INSIGHTS_DAYS = int(os.environ.get("META_INSIGHTS_DAYS") or 90)
168
+ INSIGHTS_SLICE = int(os.environ.get("META_INSIGHTS_SLICE_DAYS") or 7)
169
+ INSIGHTS_PAGE = int(os.environ.get("META_INSIGHTS_PAGE") or 25)
170
+
171
+
172
+ def _slices(days, size):
173
+ """[(since, until)] covering the last `days`, oldest first, in `size`-day windows."""
174
+ from datetime import date, timedelta
175
+ end = date.today()
176
+ start = end - timedelta(days=max(1, days) - 1)
177
+ out, cur = [], start
178
+ while cur <= end:
179
+ stop = min(cur + timedelta(days=max(1, size) - 1), end)
180
+ out.append((cur.isoformat(), stop.isoformat()))
181
+ cur = stop + timedelta(days=1)
182
+ return out
183
+
184
+ #: ⚠ A REAL CEILING, AND R6's SECOND SENTENCE APPLIES TO IT. Graph pages at 25-100 rows; this is
185
+ #: the number of PAGES a single edge may walk before the loader stops and SAYS it stopped. It is
186
+ #: not a row cap on a connected source (R6 forbids that) β€” it is a runaway guard, and reaching it
187
+ #: is reported as a problem, never absorbed.
188
+ MAX_PAGES = int(os.environ.get("META_MAX_PAGES") or 200)
189
+ PAGE = int(os.environ.get("META_PAGE_SIZE") or 100)
190
+
191
+
192
+ class MetaError(RuntimeError):
193
+ """A Graph refusal carrying Meta's own words. Safe to print: no token ever reaches it."""
194
+
195
+
196
+ def token():
197
+ """The Meta token from the environment or gitignored `platform/.env`; "" when absent.
198
+
199
+ ⚠ Same resolver `aios-web/api/connectors_meta.py` uses. Duplicated deliberately and minimally:
200
+ `platform/` must not import from `aios-web/api/`, which is the layering rule this repo keeps
201
+ (`core` never imports up). Fifteen lines is the price of that boundary.
202
+ """
203
+ tok = os.environ.get("META_ADS_ACCESS_TOKEN") or ""
204
+ if tok:
205
+ return tok.strip()
206
+ env = _HERE.parent / ".env"
207
+ if env.exists():
208
+ for line in env.read_text(encoding="utf-8", errors="replace").splitlines():
209
+ if line.strip().startswith("META_ADS_ACCESS_TOKEN"):
210
+ _, _, v = line.partition("=")
211
+ return v.strip().strip('"').strip("'")
212
+ return ""
213
+
214
+
215
+ def _get(path, tok, **params):
216
+ params["access_token"] = tok
217
+ url = f"{GRAPH}/{path.lstrip('/')}?" + urllib.parse.urlencode(params)
218
+ req = urllib.request.Request(url, headers={"User-Agent": "aios-meta-store/1"})
219
+ try:
220
+ with urllib.request.urlopen(req, timeout=120) as r:
221
+ return json.loads(r.read().decode("utf-8", "replace"))
222
+ except urllib.error.HTTPError as e:
223
+ try:
224
+ msg = (json.loads(e.read().decode("utf-8", "replace")).get("error") or {}
225
+ ).get("message") or ""
226
+ except Exception:
227
+ msg = ""
228
+ raise MetaError(f"HTTP {e.code} on /{path.lstrip('/')}: {msg[:200]}") from None
229
+ except Exception as e:
230
+ raise MetaError(f"{type(e).__name__} on /{path.lstrip('/')}") from None
231
+
232
+
233
+ #: Graph's own words when a page is too heavy. It arrives as an HTTP **500**, not a 4xx, which is
234
+ #: why it cannot be treated as "the server is broken, give up".
235
+ _TOO_MUCH = "reduce the amount of data"
236
+
237
+ #: β›” THE ADS-MANAGEMENT RATE LIMIT, WHICH IS PER AD ACCOUNT AND NOT PER TOKEN. Measured: after a
238
+ #: heavy backfill Graph answers **HTTP 400 "There have been too many calls to this ad-account.
239
+ #: Wait a bit and try again."** It is a 4xx, so nothing about the status code says "retry" β€” the
240
+ #: MESSAGE is the only signal, which is why it is matched here rather than inferred from a code.
241
+ #: ⚠ It persists for minutes, so the backoff is measured in minutes and BOUNDED: after
242
+ #: `_RATE_TRIES` waits the loader STOPS and reports what it got, rather than sitting in a retry
243
+ #: loop nobody can see. A partial mirror that says it is partial beats a hung sync.
244
+ _RATE_LIMITED = "too many calls"
245
+ _RATE_TRIES = int(os.environ.get("META_RATE_TRIES") or 3)
246
+ _RATE_WAIT = int(os.environ.get("META_RATE_WAIT_S") or 90)
247
+
248
+
249
+ def _walk(path, tok, log, **params):
250
+ """Every page of an edge, paged by CURSOR under our own parameters.
251
+
252
+ β›” IT DOES NOT FOLLOW GRAPH'S `paging.next` URL, AND THAT IS THE WHOLE POINT. Measured: the
253
+ first call to `/adcreatives` at limit=100 answers **HTTP 500 "Please reduce the amount of data
254
+ you're asking for"**, the retry at limit=25 succeeds β€” and then `next` carries the ORIGINAL
255
+ limit=100 and fails again on page 2. A backoff that cannot reach every page is not a backoff.
256
+ Re-issuing each page ourselves with `after=<cursor>` keeps the reduced limit for the whole walk.
257
+
258
+ ⭐ It shrinks the PAGE, never the FIELD LIST. Dropping columns to make a request fit is the
259
+ silent omission R2 forbids, and nothing downstream could see it. Fewer rows per call, always
260
+ every column per row.
261
+
262
+ -> (rows, hit_page_cap)
263
+ """
264
+ out, pages = [], 0
265
+ limit = int(params.pop("limit", None) or PAGE)
266
+ after, waited = None, 0
267
+ while True:
268
+ call = dict(params, limit=limit)
269
+ if after:
270
+ call["after"] = after
271
+ try:
272
+ body = _get(path, tok, **call)
273
+ except MetaError as e:
274
+ if _TOO_MUCH in str(e) and limit > 5:
275
+ limit = max(5, limit // 4)
276
+ log(f" page too heavy for /{path} - retrying at limit={limit} "
277
+ f"(a payload ceiling, not a row cap; every column is still asked for)")
278
+ continue
279
+ if _RATE_LIMITED in str(e).lower() and waited < _RATE_TRIES:
280
+ waited += 1
281
+ log(f" rate-limited on /{path} (per AD ACCOUNT, not per token) - waiting "
282
+ f"{_RATE_WAIT}s, attempt {waited}/{_RATE_TRIES}")
283
+ time.sleep(_RATE_WAIT)
284
+ continue
285
+ if _RATE_LIMITED in str(e).lower():
286
+ log(f" !! GIVING UP on /{path} after {waited} waits: still rate-limited. "
287
+ f"{len(out)} row(s) collected so far are kept. Cause: the ads-management "
288
+ f"limit is per ad account and persists for minutes. Fix: re-run "
289
+ f"`--sync --only <table>` later; the upsert is idempotent.")
290
+ return out, True
291
+ raise
292
+ out.extend(body.get("data") or [])
293
+ pages += 1
294
+ after = ((body.get("paging") or {}).get("cursors") or {}).get("after")
295
+ has_next = bool((body.get("paging") or {}).get("next")) and bool(after)
296
+ if not has_next:
297
+ return out, False
298
+ if pages >= MAX_PAGES:
299
+ log(f" !! STOPPED at MAX_PAGES={MAX_PAGES} on /{path} with more pages left. "
300
+ f"Cause: a runaway guard, not a row cap. Fix: raise META_MAX_PAGES, or narrow the "
301
+ f"window with META_INSIGHTS_DAYS.")
302
+ return out, True
303
+
304
+
305
+ def _cell(value):
306
+ """One Graph value -> one DuckDB cell. Nested structures become compact JSON TEXT rather than
307
+ being dropped: R2 says every field the API returns, and `targeting` is a field."""
308
+ if value is None or isinstance(value, (str, int, float, bool)):
309
+ return value
310
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
311
+
312
+
313
+ def _coltype(name, rows=None):
314
+ """The DuckDB type for one column β€” decided by the DATA when there is data, by the name only
315
+ as a fallback.
316
+
317
+ β›” THE NAME LIST WAS WRONG AND ONLY THE API COULD SAY SO. `cost_per_unique_click`,
318
+ `cost_per_action_type`, `purchase_roas` and friends READ like money and are **arrays of
319
+ action-type objects** on the Insights edge:
320
+ [{"action_type":"outbound_click","value":"1.459854"}]
321
+ Typed DOUBLE from `_DBL`, the insert died with `Conversion Error: Could not convert string
322
+ '[{...}]' to DOUBLE` β€” after five entity tables had already been written, so the sync looked
323
+ like it worked and then blew up on the last table.
324
+ ⭐ Same principle the field CATALOG is built on, applied one layer down: **ask the response,
325
+ do not assert from a list.** A value that ever arrives as a list or dict is JSON TEXT, because
326
+ that is what `_cell` stores; anything else falls back to the measured name hints.
327
+ """
328
+ if rows:
329
+ seen, kinds = 0, set()
330
+ for r in rows:
331
+ v = r.get(name)
332
+ if v is None or v == "":
333
+ continue
334
+ kinds.add("json" if isinstance(v, (list, dict)) else
335
+ "bool" if isinstance(v, bool) else
336
+ "int" if isinstance(v, int) else
337
+ "float" if isinstance(v, float) else "str")
338
+ seen += 1
339
+ if seen >= 200:
340
+ break
341
+ if kinds:
342
+ if "json" in kinds or "str" in kinds:
343
+ return "VARCHAR" # a JSON blob, or a numeric STRING
344
+ if kinds <= {"int", "bool"}:
345
+ return "BIGINT" if name in _INT else ("VARCHAR" if "bool" in kinds else "BIGINT")
346
+ return "DOUBLE"
347
+ if name in _INT:
348
+ return "BIGINT"
349
+ if name in _DBL:
350
+ return "DOUBLE"
351
+ return "VARCHAR" # ids included β€” see the header
352
+
353
+
354
+ def _ensure(con, table, fields, rows=None):
355
+ """Create or widen the table, with every column typed from `rows` when they are available.
356
+
357
+ ⚠ AN EXISTING COLUMN WHOSE TYPE IS NOW WRONG IS REBUILT, NOT PATCHED. DuckDB cannot retype a
358
+ column in place, and this table is DERIVED data that can be re-pulled in minutes β€” so a type
359
+ disagreement drops and recreates rather than limping on with a column that refuses every
360
+ insert. The alternative is a mirror that is permanently unwritable for one bad guess.
361
+ """
362
+ want = {f: _coltype(f, rows) for f in fields}
363
+ have = {r[1]: str(r[2]).upper() for r in con.execute(f"PRAGMA table_info('{table}')").fetchall()}
364
+ if have:
365
+ clash = [f for f, ty in want.items() if f in have and have[f] != ty
366
+ and not (have[f].startswith("VARCHAR") and ty == "VARCHAR")]
367
+ if clash:
368
+ con.execute(f"DROP TABLE {table}")
369
+ have = {}
370
+ if not have:
371
+ cols = ", ".join(f"{f} {want[f]}" for f in fields)
372
+ con.execute(f"CREATE TABLE IF NOT EXISTS {table} (id VARCHAR PRIMARY KEY, {cols})"
373
+ if "id" not in fields else
374
+ f"CREATE TABLE IF NOT EXISTS {table} ({cols})")
375
+ return
376
+ for f in fields:
377
+ if f not in have:
378
+ con.execute(f"ALTER TABLE {table} ADD COLUMN {f} {want[f]}")
379
+
380
+
381
+ def _upsert(con, table, fields, rows):
382
+ """Delete-then-insert by id β€” the same idempotence `datastore._upsert` gives the Odoo half, so
383
+ a re-sync updates in place and can never append a second copy of the same object."""
384
+ if not rows:
385
+ return 0
386
+ cols = list(fields)
387
+ ids = [str(r.get("id") or "") for r in rows]
388
+ q = ",".join("?" for _ in ids)
389
+ con.execute(f"DELETE FROM {table} WHERE id IN ({q})", ids)
390
+ con.executemany(
391
+ f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({', '.join('?' for _ in cols)})",
392
+ [[_cell(r.get(c)) for c in cols] for r in rows])
393
+ return len(rows)
394
+
395
+
396
+ def sync(tenant_key="royal-imports", tok=None, log=print, insights=True, insights_days=None):
397
+ """Pull every level into the tenant's mirror. -> a report dict; raises only on a bad token.
398
+
399
+ Idempotent: re-running updates rows in place. Safe to call at boot and on the resync loop,
400
+ exactly as `odoo_relational.refresh` is.
401
+ """
402
+ tok = tok or token()
403
+ # β›” THE WINDOW IS A PARAMETER, NOT AN ENVIRONMENT READ AT CALL TIME β€” and the difference cost a
404
+ # live deploy. `INSIGHTS_DAYS` binds at IMPORT (module scope), so `main._pull_meta`'s
405
+ # `os.environ.setdefault("META_INSIGHTS_DAYS", "7")` executed AFTER this module was already
406
+ # imported and changed nothing: every boot pulled **90 days**, not 7, which is the slow path
407
+ # that trips the per-ad-account rate limit and never finishes. The comment beside that call
408
+ # claimed a short window the code could not deliver.
409
+ # ⭐ The shape: **a knob read at import cannot be turned by a caller at runtime.** Passing it
410
+ # makes the caller's intent effective instead of aspirational; the env var stays the DEFAULT.
411
+ days = int(insights_days or INSIGHTS_DAYS)
412
+ report = {"tenant": tenant_key, "tables": {}, "problems": [], "accounts": []}
413
+ if not tok:
414
+ report["problems"].append(
415
+ "META_ADS_ACCESS_TOKEN is not set in the environment or in platform/.env, so nothing "
416
+ "was pulled. This is a missing CREDENTIAL, not a missing capability.")
417
+ return report
418
+
419
+ path = datastore.path_for(tenant_key)
420
+ if Path(datastore.DB_PATH) != Path(path):
421
+ datastore.use_path(path)
422
+ con = datastore.connect()
423
+ log(f" mirror: {Path(path).name}")
424
+
425
+ accts, _ = _walk("me/adaccounts", tok, log, fields="id,name", limit=PAGE)
426
+ report["accounts"] = [a.get("id") for a in accts]
427
+ if not accts:
428
+ report["problems"].append("the token reaches no ad accounts")
429
+ return report
430
+
431
+ # ── the five entity levels ────────────────────────────────────────────────────────────────
432
+ for table, (edge, fields, _parent) in SPECS.items():
433
+ rows, capped = [], False
434
+ for acct in accts:
435
+ aid = acct["id"]
436
+ if edge is None:
437
+ rows.append(_get(aid, tok, fields=",".join(fields)))
438
+ else:
439
+ got, hit = _walk(f"{aid}/{edge}", tok, log, fields=",".join(fields), limit=PAGE)
440
+ rows.extend(got)
441
+ capped = capped or hit
442
+ _ensure(con, table, fields, rows)
443
+ n = _upsert(con, table, fields, rows)
444
+ total = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
445
+ report["tables"][table] = {"pulled": n, "in_mirror": total, "capped": capped}
446
+ log(f" {table:<20} pulled {n:>6} mirror total {total:>6}"
447
+ + (" !! PAGE CAP HIT" if capped else ""))
448
+ if capped:
449
+ report["problems"].append(f"{table}: stopped at MAX_PAGES={MAX_PAGES}")
450
+
451
+ # ── the daily Insights grain ──────────────────────────────────────────────────────────────
452
+ if insights:
453
+ rows, capped = [], False
454
+ for acct in accts:
455
+ for since, until in _slices(days, INSIGHTS_SLICE):
456
+ got, hit = _walk(f"{acct['id']}/insights", tok, log,
457
+ fields=",".join(INSIGHT_FIELDS), level=INSIGHTS_LEVEL,
458
+ time_increment="1", limit=INSIGHTS_PAGE,
459
+ time_range=json.dumps({"since": since, "until": until}))
460
+ rows.extend(got)
461
+ capped = capped or hit
462
+ # β›” Insights rows carry no id. The key must be STABLE across runs or every re-sync
463
+ # appends a second copy of the same day β€” so it is composed from the grain itself.
464
+ for r in rows:
465
+ r["id"] = ":".join(str(r.get(k) or "") for k in
466
+ (f"{INSIGHTS_LEVEL}_id", "date_start", "date_stop"))
467
+ fields = ["id"] + INSIGHT_FIELDS
468
+ _ensure(con, INSIGHTS_TABLE, fields, rows)
469
+ n = _upsert(con, INSIGHTS_TABLE, fields, rows)
470
+ total = con.execute(f"SELECT count(*) FROM {INSIGHTS_TABLE}").fetchone()[0]
471
+ report["tables"][INSIGHTS_TABLE] = {"pulled": n, "in_mirror": total, "capped": capped}
472
+ log(f" {INSIGHTS_TABLE:<20} pulled {n:>6} mirror total {total:>6}"
473
+ + (" !! PAGE CAP HIT" if capped else ""))
474
+
475
+ con.execute("INSERT OR REPLACE INTO _sync_state VALUES (?,?,?,?,?,?)",
476
+ ["meta", "done", 0, f"{days}d/{INSIGHTS_SLICE}d@{INSIGHTS_LEVEL}",
477
+ sum(t["in_mirror"] for t in report["tables"].values()),
478
+ time.strftime("%Y-%m-%d %H:%M:%S")])
479
+ return report
480
+
481
+
482
+ def status(tenant_key="royal-imports"):
483
+ """What is in the mirror right now, per table. Never fetches."""
484
+ path = datastore.path_for(tenant_key)
485
+ if Path(datastore.DB_PATH) != Path(path):
486
+ datastore.use_path(path)
487
+ out = {}
488
+ con = datastore.connect()
489
+ for table in list(SPECS) + [INSIGHTS_TABLE]:
490
+ try:
491
+ out[table] = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
492
+ except Exception:
493
+ out[table] = None # table absent = never synced
494
+ return out
495
+
496
+
497
+ def main(argv=None):
498
+ ap = argparse.ArgumentParser(description="Pull Meta Ads into the tenant's DuckDB mirror.")
499
+ ap.add_argument("--sync", action="store_true")
500
+ ap.add_argument("--status", action="store_true")
501
+ ap.add_argument("--tenant", default="royal-imports")
502
+ ap.add_argument("--no-insights", action="store_true")
503
+ ap.add_argument("--insights-days", type=int, default=None,
504
+ help="override the Insights window for THIS run (default INSIGHTS_DAYS); the env var is the default, this is the caller's say")
505
+ a = ap.parse_args(argv)
506
+ if a.status:
507
+ for k, v in status(a.tenant).items():
508
+ print(f" {k:<20} {'(never synced)' if v is None else v}")
509
+ return 0
510
+ if not a.sync:
511
+ ap.print_help()
512
+ return 2
513
+ rep = sync(a.tenant, insights=not a.no_insights, insights_days=a.insights_days)
514
+ for p in rep["problems"]:
515
+ print(" PROBLEM:", p)
516
+ print(f" accounts: {len(rep['accounts'])} tables: {len(rep['tables'])}")
517
+ return 1 if rep["problems"] else 0
518
+
519
+
520
+ if __name__ == "__main__":
521
+ sys.exit(main())
platform/model/metrics/sales.yml CHANGED
@@ -1,149 +1,149 @@
1
- # Metrics: sales β€” the core wholesale metrics, defined ONCE (OM-0). Every surface (pages, the
2
- # metric dictionary, the Analyst, MCP) resolves these by key through harness/semantic.py.
3
- # Fields per metric:
4
- # key/label/description β€” identity + the human definition (visibility = trust)
5
- # agg + field β€” sum | count_distinct over the topic's entity
6
- # agg: ratio β€” numerator/denominator are metric KEYS (resolved recursively)
7
- # agg: derived + expr β€” arithmetic over metric keys (safe parser; +,-,*,/ and parens only)
8
- # format β€” usd | int | pct (rendering hint for surfaces)
9
- # ai_context β€” what a small model must know to use the metric correctly
10
- # validate β€” the INDEPENDENT Odoo cross-check contract (named method implemented in
11
- # harness/semantic.py _VALIDATORS; a metric that can't tie out says so)
12
- # empty β€” W37 C1's EMPTY-WINDOW FAMILY. See below; a metric an ENTITY topic
13
- # offers as a lookback column MUST declare one.
14
- #
15
- # β›”β›” `empty:` β€” WHAT A ROW WITH NO ACTIVITY IN THE WINDOW RENDERS AS (wave 37, contract C1).
16
- # Measured and load-bearing: only 1,646 of 5,836 active products sold in the last 90 days, so a
17
- # per-SKU lookback metric has NO GROUP for 72% of the catalogue. Get the default wrong and every
18
- # product grid reads as broken. Two values, and the difference is whether the blank cell would be
19
- # a TRUE STATEMENT:
20
- # empty: zero ADDITIVE β€” units, revenue, margin $, COGS. "It sold nothing" is a real
21
- # measurement, so 0 is the honest cell and a blank would hide a fact.
22
- # empty: blank RATIO / DERIVED-FROM-A-RATIO β€” GM %, ASP. A 0% margin on zero sales is a
23
- # FALSE statement, not a missing one. β›” AND THE GUARD IS THE DENOMINATOR, NOT
24
- # THE MISSING GROUP: `semantic._post_compute` returns 0.0 for `num/0`, so a SKU
25
- # that DID sell at $0 would print "0.0%" with a group behind it. The resolver
26
- # blanks on a zero denominator, which is the only reading that is never a lie.
27
- # ⚠ A metric with no `empty:` is REFUSED by `entity_measures()` rather than defaulted β€” a
28
- # defaulted family is a guess about truth, and C1 says a metric that cannot say which family it
29
- # is in does not ship.
30
- topic: sales_lines
31
- metrics:
32
- - key: revenue
33
- label: Revenue
34
- agg: sum
35
- field: price_subtotal
36
- format: usd
37
- empty: zero
38
- description: "Untaxed revenue of confirmed wholesale order lines (the owner's 'sales' number)."
39
- ai_context: "Always untaxed; excludes Amazon (GIFTWARE DEALS) and unconfirmed orders. Filter one BU via team_id (Fisch=5, Royal=6)."
40
- validate:
41
- method: order_level_revenue
42
- note: "Ξ£ line price_subtotal must equal Ξ£ parent-order amount_untaxed under the same scope, to the cent (line vs order basis β€” the built-in cross-check)."
43
-
44
- - key: units
45
- label: Units sold
46
- agg: sum
47
- field: product_uom_qty
48
- format: int
49
- empty: zero
50
- description: "Total quantity across confirmed wholesale order lines."
51
- ai_context: "Mixed UoMs are summed as ordered quantity; for weight/case analysis convert per product UoM first."
52
-
53
- - key: margin
54
- label: Gross margin $
55
- agg: sum
56
- field: margin
57
- format: usd
58
- empty: zero
59
- description: "Line revenue minus line cost (Odoo Margin module), summed."
60
- ai_context: "margin is read_group-aggregatable (Margin module installed). COGS = revenue - margin. purchase_price is per-UNIT cost β€” never sum it as a total."
61
-
62
- - key: cogs
63
- label: COGS
64
- agg: derived
65
- expr: "revenue - margin"
66
- format: usd
67
- empty: zero
68
- description: "Cost of goods sold, derived: revenue minus gross margin."
69
- ai_context: "Derived, not pulled β€” Odoo carries cost on lines as margin; COGS is the difference."
70
-
71
- - key: margin_pct
72
- label: Gross margin %
73
- agg: ratio
74
- numerator: margin
75
- denominator: revenue
76
- format: pct
77
- empty: blank
78
- description: "Gross margin as a share of revenue."
79
- ai_context: "Compare across BUs/categories at the same scope only. ⚠ TWO ZERO-REVENUE BEHAVIOURS, deliberately: the scalar/analyst path returns 0 (semantic._post_compute's guard), while an ENTITY LOOKBACK COLUMN renders BLANK (empty: blank) β€” on a grid, a printed 0.0% beside 5,836 products would assert a margin nobody measured."
80
-
81
- # ⭐ W37-T10 β€” ASP, the fifth SHIP-FIRST per-SKU metric (proto/P3). Blended $18.76 over 1,646
82
- # SKUs in the 90 days to 2026-08-19. A RATIO of two same-topic base measures, so it survives a
83
- # GROUPED store_query (only a CROSS-TOPIC component is refused β€” that is what stops `aov`,
84
- # whose denominator `orders` lives on sales_orders).
85
- # ⚠ UNITS ARE AS-ORDERED, mixed UoM. `units` says so and this inherits it: a SKU sold in cases
86
- # and in singles has an ASP blended across both, which is the true average selling price of a
87
- # line unit and NOT a per-piece price.
88
- - key: asp
89
- label: Average selling price
90
- agg: ratio
91
- numerator: revenue
92
- denominator: units
93
- format: usd
94
- empty: blank
95
- description: "Revenue per unit sold β€” the blended average selling price."
96
- ai_context: "revenue Γ· units at identical scope. Mixed units of measure are summed as ordered quantity, so this is per ordered unit, not per piece. Blank when nothing sold: an ASP of $0 on zero units is a false statement, not a missing one."
97
-
98
- - key: orders
99
- label: Orders
100
- topic: sales_orders
101
- agg: count
102
- format: int
103
- description: "Confirmed wholesale orders in the window (order-header count β€” what the scorecards show)."
104
- ai_context: "Order-level count; slightly higher than distinct-orders-from-lines because a few confirmed orders carry zero lines (a data-health artifact the reconciliation contract counts exactly)."
105
- validate:
106
- method: order_count
107
- note: "Order-level count must equal distinct line-parent orders + line-less orders, exactly (surfaces empty orders instead of hiding them in a tolerance)."
108
-
109
- - key: customers
110
- label: Active customers
111
- agg: count_distinct
112
- field: order_partner_id
113
- format: int
114
- empty: zero
115
- description: "Distinct customers with at least one confirmed wholesale order line in the window."
116
- ai_context: "Customer = the order's partner. Agent attribution uses res.partner.agent_ids, NOT the order user_id."
117
-
118
- - key: aov
119
- label: Average order value
120
- agg: ratio
121
- numerator: revenue
122
- denominator: orders
123
- format: usd
124
- description: "Revenue per distinct order."
125
- ai_context: "Ratio of two registered metrics at identical scope; never average per-order averages."
126
-
127
- # ⭐ Wave 21 R2 β€” the FULLY-INVOICED basis, as separate metrics (owner ruling: picker entries,
128
- # not a basis dropdown). Same topics, same scope, ONE predicate narrower: the order's Odoo
129
- # invoice_status = 'invoiced'. `store_filter_sql` filters the store path (sum/count CASE);
130
- # `live_domain` filters the live path β€” BOTH or store_parity compares two different questions.
131
- - key: revenue_invoiced
132
- label: Sales β€” fully invoiced
133
- agg: sum
134
- field: price_subtotal
135
- store_filter_sql: "o.invoice_status = 'invoiced'"
136
- live_domain: [["order_id.invoice_status", "=", "invoiced"]]
137
- format: usd
138
- description: "Untaxed revenue of confirmed wholesale order lines whose parent order Odoo marks fully invoiced (invoice_status = invoiced)."
139
- ai_context: "Same scope as revenue, narrowed by the ORDER-level fully-invoiced flag. This is NOT posted-invoice-line revenue: a partially invoiced order is excluded entirely until Odoo flips the flag. Reconciled store-vs-live per BU by store_parity."
140
-
141
- - key: orders_invoiced
142
- label: Orders β€” fully invoiced
143
- topic: sales_orders
144
- agg: count
145
- store_filter_sql: "o.invoice_status = 'invoiced'"
146
- live_domain: [["invoice_status", "=", "invoiced"]]
147
- format: int
148
- description: "Confirmed wholesale orders Odoo marks fully invoiced (invoice_status = invoiced)."
149
- ai_context: "Order-header count under the orders scope plus the fully-invoiced flag; partially invoiced and not-yet-invoiced orders are excluded. Reconciled store-vs-live per BU by store_parity."
 
1
+ # Metrics: sales β€” the core wholesale metrics, defined ONCE (OM-0). Every surface (pages, the
2
+ # metric dictionary, the Analyst, MCP) resolves these by key through harness/semantic.py.
3
+ # Fields per metric:
4
+ # key/label/description β€” identity + the human definition (visibility = trust)
5
+ # agg + field β€” sum | count_distinct over the topic's entity
6
+ # agg: ratio β€” numerator/denominator are metric KEYS (resolved recursively)
7
+ # agg: derived + expr β€” arithmetic over metric keys (safe parser; +,-,*,/ and parens only)
8
+ # format β€” usd | int | pct (rendering hint for surfaces)
9
+ # ai_context β€” what a small model must know to use the metric correctly
10
+ # validate β€” the INDEPENDENT Odoo cross-check contract (named method implemented in
11
+ # harness/semantic.py _VALIDATORS; a metric that can't tie out says so)
12
+ # empty β€” W37 C1's EMPTY-WINDOW FAMILY. See below; a metric an ENTITY topic
13
+ # offers as a lookback column MUST declare one.
14
+ #
15
+ # β›”β›” `empty:` β€” WHAT A ROW WITH NO ACTIVITY IN THE WINDOW RENDERS AS (wave 37, contract C1).
16
+ # Measured and load-bearing: only 1,646 of 5,836 active products sold in the last 90 days, so a
17
+ # per-SKU lookback metric has NO GROUP for 72% of the catalogue. Get the default wrong and every
18
+ # product grid reads as broken. Two values, and the difference is whether the blank cell would be
19
+ # a TRUE STATEMENT:
20
+ # empty: zero ADDITIVE β€” units, revenue, margin $, COGS. "It sold nothing" is a real
21
+ # measurement, so 0 is the honest cell and a blank would hide a fact.
22
+ # empty: blank RATIO / DERIVED-FROM-A-RATIO β€” GM %, ASP. A 0% margin on zero sales is a
23
+ # FALSE statement, not a missing one. β›” AND THE GUARD IS THE DENOMINATOR, NOT
24
+ # THE MISSING GROUP: `semantic._post_compute` returns 0.0 for `num/0`, so a SKU
25
+ # that DID sell at $0 would print "0.0%" with a group behind it. The resolver
26
+ # blanks on a zero denominator, which is the only reading that is never a lie.
27
+ # ⚠ A metric with no `empty:` is REFUSED by `entity_measures()` rather than defaulted β€” a
28
+ # defaulted family is a guess about truth, and C1 says a metric that cannot say which family it
29
+ # is in does not ship.
30
+ topic: sales_lines
31
+ metrics:
32
+ - key: revenue
33
+ label: Revenue
34
+ agg: sum
35
+ field: price_subtotal
36
+ format: usd
37
+ empty: zero
38
+ description: "Untaxed revenue of confirmed wholesale order lines (the owner's 'sales' number)."
39
+ ai_context: "Always untaxed; excludes Amazon (GIFTWARE DEALS) and unconfirmed orders. Filter one BU via team_id (Fisch=5, Royal=6)."
40
+ validate:
41
+ method: order_level_revenue
42
+ note: "Ξ£ line price_subtotal must equal Ξ£ parent-order amount_untaxed under the same scope, to the cent (line vs order basis β€” the built-in cross-check)."
43
+
44
+ - key: units
45
+ label: Units sold
46
+ agg: sum
47
+ field: product_uom_qty
48
+ format: int
49
+ empty: zero
50
+ description: "Total quantity across confirmed wholesale order lines."
51
+ ai_context: "Mixed UoMs are summed as ordered quantity; for weight/case analysis convert per product UoM first."
52
+
53
+ - key: margin
54
+ label: Gross margin $
55
+ agg: sum
56
+ field: margin
57
+ format: usd
58
+ empty: zero
59
+ description: "Line revenue minus line cost (Odoo Margin module), summed."
60
+ ai_context: "margin is read_group-aggregatable (Margin module installed). COGS = revenue - margin. purchase_price is per-UNIT cost β€” never sum it as a total."
61
+
62
+ - key: cogs
63
+ label: COGS
64
+ agg: derived
65
+ expr: "revenue - margin"
66
+ format: usd
67
+ empty: zero
68
+ description: "Cost of goods sold, derived: revenue minus gross margin."
69
+ ai_context: "Derived, not pulled β€” Odoo carries cost on lines as margin; COGS is the difference."
70
+
71
+ - key: margin_pct
72
+ label: Gross margin %
73
+ agg: ratio
74
+ numerator: margin
75
+ denominator: revenue
76
+ format: pct
77
+ empty: blank
78
+ description: "Gross margin as a share of revenue."
79
+ ai_context: "Compare across BUs/categories at the same scope only. ⚠ TWO ZERO-REVENUE BEHAVIOURS, deliberately: the scalar/analyst path returns 0 (semantic._post_compute's guard), while an ENTITY LOOKBACK COLUMN renders BLANK (empty: blank) β€” on a grid, a printed 0.0% beside 5,836 products would assert a margin nobody measured."
80
+
81
+ # ⭐ W37-T10 β€” ASP, the fifth SHIP-FIRST per-SKU metric (proto/P3). Blended $18.76 over 1,646
82
+ # SKUs in the 90 days to 2026-08-19. A RATIO of two same-topic base measures, so it survives a
83
+ # GROUPED store_query (only a CROSS-TOPIC component is refused β€” that is what stops `aov`,
84
+ # whose denominator `orders` lives on sales_orders).
85
+ # ⚠ UNITS ARE AS-ORDERED, mixed UoM. `units` says so and this inherits it: a SKU sold in cases
86
+ # and in singles has an ASP blended across both, which is the true average selling price of a
87
+ # line unit and NOT a per-piece price.
88
+ - key: asp
89
+ label: Average selling price
90
+ agg: ratio
91
+ numerator: revenue
92
+ denominator: units
93
+ format: usd
94
+ empty: blank
95
+ description: "Revenue per unit sold β€” the blended average selling price."
96
+ ai_context: "revenue Γ· units at identical scope. Mixed units of measure are summed as ordered quantity, so this is per ordered unit, not per piece. Blank when nothing sold: an ASP of $0 on zero units is a false statement, not a missing one."
97
+
98
+ - key: orders
99
+ label: Orders
100
+ topic: sales_orders
101
+ agg: count
102
+ format: int
103
+ description: "Confirmed wholesale orders in the window (order-header count β€” what the scorecards show)."
104
+ ai_context: "Order-level count; slightly higher than distinct-orders-from-lines because a few confirmed orders carry zero lines (a data-health artifact the reconciliation contract counts exactly)."
105
+ validate:
106
+ method: order_count
107
+ note: "Order-level count must equal distinct line-parent orders + line-less orders, exactly (surfaces empty orders instead of hiding them in a tolerance)."
108
+
109
+ - key: customers
110
+ label: Active customers
111
+ agg: count_distinct
112
+ field: order_partner_id
113
+ format: int
114
+ empty: zero
115
+ description: "Distinct customers with at least one confirmed wholesale order line in the window."
116
+ ai_context: "Customer = the order's partner. Agent attribution uses res.partner.agent_ids, NOT the order user_id."
117
+
118
+ - key: aov
119
+ label: Average order value
120
+ agg: ratio
121
+ numerator: revenue
122
+ denominator: orders
123
+ format: usd
124
+ description: "Revenue per distinct order."
125
+ ai_context: "Ratio of two registered metrics at identical scope; never average per-order averages."
126
+
127
+ # ⭐ Wave 21 R2 β€” the FULLY-INVOICED basis, as separate metrics (owner ruling: picker entries,
128
+ # not a basis dropdown). Same topics, same scope, ONE predicate narrower: the order's Odoo
129
+ # invoice_status = 'invoiced'. `store_filter_sql` filters the store path (sum/count CASE);
130
+ # `live_domain` filters the live path β€” BOTH or store_parity compares two different questions.
131
+ - key: revenue_invoiced
132
+ label: Sales β€” fully invoiced
133
+ agg: sum
134
+ field: price_subtotal
135
+ store_filter_sql: "o.invoice_status = 'invoiced'"
136
+ live_domain: [["order_id.invoice_status", "=", "invoiced"]]
137
+ format: usd
138
+ description: "Untaxed revenue of confirmed wholesale order lines whose parent order Odoo marks fully invoiced (invoice_status = invoiced)."
139
+ ai_context: "Same scope as revenue, narrowed by the ORDER-level fully-invoiced flag. This is NOT posted-invoice-line revenue: a partially invoiced order is excluded entirely until Odoo flips the flag. Reconciled store-vs-live per BU by store_parity."
140
+
141
+ - key: orders_invoiced
142
+ label: Orders β€” fully invoiced
143
+ topic: sales_orders
144
+ agg: count
145
+ store_filter_sql: "o.invoice_status = 'invoiced'"
146
+ live_domain: [["invoice_status", "=", "invoiced"]]
147
+ format: int
148
+ description: "Confirmed wholesale orders Odoo marks fully invoiced (invoice_status = invoiced)."
149
+ ai_context: "Order-header count under the orders scope plus the fully-invoiced flag; partially invoiced and not-yet-invoiced orders are excluded. Reconciled store-vs-live per BU by store_parity."
platform/model/topics/odoo_accounts.yml CHANGED
@@ -1,60 +1,60 @@
1
- # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
- # the databases a person actually opens, not only the line and document grains.
3
- #
4
- # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
- # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
- # field row: add the column to the grid contract and re-emit, or the agent is being
7
- # trained on a schema the product does not have.
8
- key: odoo_accounts
9
- label: "Odoo GL accounts"
10
- entity: account.account
11
- # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
- grid: ut_odoo_accounts
13
- subject: "odoo:account.account"
14
- grain: "one row per GL account in the chart of accounts"
15
- scope:
16
- population: "every account.account record (192 measured)"
17
- store:
18
- table: account_account
19
- alias: a
20
- # NO date_col β€” a registry is not a dated event stream. Stated rather than
21
- # omitted, so its absence reads as a fact and not as an unfinished file.
22
- dims:
23
- account_type: {label: "Account type"}
24
-
25
- # key / label / type / kind, derived from the grid contract. `kind` says where the
26
- # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
27
- # from another topic; a `link` points at another database.
28
- fields:
29
- - key: account_code
30
- label: "Code"
31
- type: text
32
- kind: data
33
- - key: account_name
34
- label: "Account"
35
- type: text
36
- kind: data
37
- - key: odoo_id
38
- label: "Odoo ID"
39
- type: int
40
- kind: data
41
- means: "The `account.account` id. Also this row's id."
42
- - key: account_type
43
- label: "Type"
44
- type: select
45
- kind: data
46
- - key: is_expense
47
- label: "Expense account"
48
- type: checkbox
49
- kind: data
50
- means: "Ticked for the expense family - the same predicate the semantic layer's gl_lines topic uses, so this column and that topic cannot disagree."
51
- - key: refreshed
52
- label: "Refreshed"
53
- type: date
54
- kind: data
55
- means: "When this row was last reconciled against Odoo."
56
-
57
- ai_context: >
58
- The chart of accounts β€” the registry gl_lines posts against.
59
- Use it to resolve an account code to a name or a type.
60
- ⚠ `is_expense` is a DERIVED boolean standing in for Odoo's five-value `internal_group` (asset/liability/equity/income/expense), which the mirror does not yet carry.
 
1
+ # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
+ # the databases a person actually opens, not only the line and document grains.
3
+ #
4
+ # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
+ # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
+ # field row: add the column to the grid contract and re-emit, or the agent is being
7
+ # trained on a schema the product does not have.
8
+ key: odoo_accounts
9
+ label: "Odoo GL accounts"
10
+ entity: account.account
11
+ # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
+ grid: ut_odoo_accounts
13
+ subject: "odoo:account.account"
14
+ grain: "one row per GL account in the chart of accounts"
15
+ scope:
16
+ population: "every account.account record (192 measured)"
17
+ store:
18
+ table: account_account
19
+ alias: a
20
+ # NO date_col β€” a registry is not a dated event stream. Stated rather than
21
+ # omitted, so its absence reads as a fact and not as an unfinished file.
22
+ dims:
23
+ account_type: {label: "Account type"}
24
+
25
+ # key / label / type / kind, derived from the grid contract. `kind` says where the
26
+ # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
27
+ # from another topic; a `link` points at another database.
28
+ fields:
29
+ - key: account_code
30
+ label: "Code"
31
+ type: text
32
+ kind: data
33
+ - key: account_name
34
+ label: "Account"
35
+ type: text
36
+ kind: data
37
+ - key: odoo_id
38
+ label: "Odoo ID"
39
+ type: int
40
+ kind: data
41
+ means: "The `account.account` id. Also this row's id."
42
+ - key: account_type
43
+ label: "Type"
44
+ type: select
45
+ kind: data
46
+ - key: is_expense
47
+ label: "Expense account"
48
+ type: checkbox
49
+ kind: data
50
+ means: "Ticked for the expense family - the same predicate the semantic layer's gl_lines topic uses, so this column and that topic cannot disagree."
51
+ - key: refreshed
52
+ label: "Refreshed"
53
+ type: date
54
+ kind: data
55
+ means: "When this row was last reconciled against Odoo."
56
+
57
+ ai_context: >
58
+ The chart of accounts β€” the registry gl_lines posts against.
59
+ Use it to resolve an account code to a name or a type.
60
+ ⚠ `is_expense` is a DERIVED boolean standing in for Odoo's five-value `internal_group` (asset/liability/equity/income/expense), which the mirror does not yet carry.
platform/model/topics/odoo_agents.yml CHANGED
@@ -1,91 +1,91 @@
1
- # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
- # the databases a person actually opens, not only the line and document grains.
3
- #
4
- # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
- # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
- # field row: add the column to the grid contract and re-emit, or the agent is being
7
- # trained on a schema the product does not have.
8
- key: odoo_agents
9
- label: "Odoo agents"
10
- entity: res.partner
11
- # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
- grid: ut_odoo_agents
13
- subject: "odoo:res.partner.agent"
14
- grain: "one row per sales agent"
15
- scope:
16
- population: "a UNION of two disagreeing sources β€” partners carrying commission lines, and partners flagged res_partner.agent = TRUE"
17
- why_union: "measured 2026-08-09: 16 carry commission lines, 17 carry the flag, and the union is 19 β€” either source alone silently drops real agents"
18
- store:
19
- table: res_partner
20
- alias: p
21
- # NO date_col β€” a registry is not a dated event stream. Stated rather than
22
- # omitted, so its absence reads as a fact and not as an unfinished file.
23
-
24
- # ⭐⭐ W37-T12 / owner item 4 (R1) β€” THE LOOKBACK-MEASURE BINDING, the agent half of
25
- # *"It should apply to Odoo agents database as well."* Same contract as
26
- # `odoo_products.yml`: a FACT topic, and the dim of that topic which carries THIS
27
- # entity's identity.
28
- #
29
- # β›”β›” WHICH AGENT SOURCE, STATED β€” because there are THREE in this Odoo and they name
30
- # DIFFERENT PEOPLE (`proto/P3-metric-catalog.md`: 9 agents carry route-1 revenue, 12
31
- # carry commission lines, 17 carry the flag). This binds ROUTE 1, the CUSTOMER-MASTER
32
- # BOOK: `sales_lines.agent` is `rp.agent_id`, the customer's assigned agent, so every
33
- # order of that customer counts toward their agent. That is "whose book is this" and it
34
- # is the right question for a column on the AGENT REGISTRY.
35
- # ⚠ It is NOT commission. `account.invoice.line.agent` (topic `commission_lines`) is the
36
- # per-INVOICE-LINE credited agent and answers a different question; `sales_lines.yml`'s
37
- # own `agent` dim comment carries the full disagreement and says never to "fix" one by
38
- # reading the other.
39
- #
40
- # ⭐ THE JOIN NEEDS NO NEW DIM, unlike the product side. `sales_lines.agent` declares a
41
- # `name_col`, so `store_query` emits `agent_id` β€” which IS `res.partner.id`, which IS
42
- # this grid's `odoo_id` identity. Product needed `product_code` because its grid keys on
43
- # `default_code`; this one already keys on the same integer the fact topic groups by.
44
- measures:
45
- source: sales_lines
46
- dim: agent
47
- keys: [revenue, units, margin, cogs, margin_pct, asp, customers]
48
- not_yet:
49
- - key: orders / aov
50
- cause: "`orders` lives on topic `sales_orders`, so under a `group_by` it is a CROSS-TOPIC measure and `semantic.store_query` refuses it (scalar-only). `aov` inherits the refusal through its denominator"
51
- fix: "add an ORDER-COUNT metric to `sales_lines` itself β€” `count_distinct` over `l.order_id` β€” which answers the same question at this grain in one pass"
52
- - key: commission_amount
53
- cause: "the commission basis lives on `account.invoice.line.agent` (topic `commission_lines`), a different topic AND a different grain; `invoice_lines` deduplicates to at most one agent per line"
54
- fix: "bind a SECOND measures block per source once the contract allows more than one, and label the columns so the two routes can never be read as the same number"
55
-
56
- # key / label / type / kind, derived from the grid contract. `kind` says where the
57
- # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
58
- # from another topic; a `link` points at another database.
59
- fields:
60
- - key: agent
61
- label: "Agent"
62
- type: text
63
- kind: data
64
- - key: odoo_id
65
- label: "Odoo ID"
66
- type: int
67
- kind: data
68
- means: "The `res.partner` id. Also this row's id."
69
- - key: agent_id
70
- label: "Odoo agent id"
71
- type: int
72
- kind: data
73
- - key: flagged
74
- label: "Flagged in Odoo"
75
- type: checkbox
76
- kind: data
77
- means: "Ticked = `res.partner.agent` is set. Unticked agents were found by their commission lines instead - both are real, which is why this table is the union of the two."
78
- - key: commissioned
79
- label: "Has commission lines"
80
- type: checkbox
81
- kind: data
82
- - key: refreshed
83
- label: "Refreshed"
84
- type: date
85
- kind: data
86
- means: "When this row was last reconciled against Odoo."
87
-
88
- ai_context: >
89
- The sales-agent registry.
90
- `flagged` and `commissioned` are the two SOURCES, kept as separate columns rather than merged, because they disagree and the disagreement is information.
91
- For an agent's BOOK use sales_lines/commission_lines with the agent dim.
 
1
+ # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
+ # the databases a person actually opens, not only the line and document grains.
3
+ #
4
+ # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
+ # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
+ # field row: add the column to the grid contract and re-emit, or the agent is being
7
+ # trained on a schema the product does not have.
8
+ key: odoo_agents
9
+ label: "Odoo agents"
10
+ entity: res.partner
11
+ # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
+ grid: ut_odoo_agents
13
+ subject: "odoo:res.partner.agent"
14
+ grain: "one row per sales agent"
15
+ scope:
16
+ population: "a UNION of two disagreeing sources β€” partners carrying commission lines, and partners flagged res_partner.agent = TRUE"
17
+ why_union: "measured 2026-08-09: 16 carry commission lines, 17 carry the flag, and the union is 19 β€” either source alone silently drops real agents"
18
+ store:
19
+ table: res_partner
20
+ alias: p
21
+ # NO date_col β€” a registry is not a dated event stream. Stated rather than
22
+ # omitted, so its absence reads as a fact and not as an unfinished file.
23
+
24
+ # ⭐⭐ W37-T12 / owner item 4 (R1) β€” THE LOOKBACK-MEASURE BINDING, the agent half of
25
+ # *"It should apply to Odoo agents database as well."* Same contract as
26
+ # `odoo_products.yml`: a FACT topic, and the dim of that topic which carries THIS
27
+ # entity's identity.
28
+ #
29
+ # β›”β›” WHICH AGENT SOURCE, STATED β€” because there are THREE in this Odoo and they name
30
+ # DIFFERENT PEOPLE (`proto/P3-metric-catalog.md`: 9 agents carry route-1 revenue, 12
31
+ # carry commission lines, 17 carry the flag). This binds ROUTE 1, the CUSTOMER-MASTER
32
+ # BOOK: `sales_lines.agent` is `rp.agent_id`, the customer's assigned agent, so every
33
+ # order of that customer counts toward their agent. That is "whose book is this" and it
34
+ # is the right question for a column on the AGENT REGISTRY.
35
+ # ⚠ It is NOT commission. `account.invoice.line.agent` (topic `commission_lines`) is the
36
+ # per-INVOICE-LINE credited agent and answers a different question; `sales_lines.yml`'s
37
+ # own `agent` dim comment carries the full disagreement and says never to "fix" one by
38
+ # reading the other.
39
+ #
40
+ # ⭐ THE JOIN NEEDS NO NEW DIM, unlike the product side. `sales_lines.agent` declares a
41
+ # `name_col`, so `store_query` emits `agent_id` β€” which IS `res.partner.id`, which IS
42
+ # this grid's `odoo_id` identity. Product needed `product_code` because its grid keys on
43
+ # `default_code`; this one already keys on the same integer the fact topic groups by.
44
+ measures:
45
+ source: sales_lines
46
+ dim: agent
47
+ keys: [revenue, units, margin, cogs, margin_pct, asp, customers]
48
+ not_yet:
49
+ - key: orders / aov
50
+ cause: "`orders` lives on topic `sales_orders`, so under a `group_by` it is a CROSS-TOPIC measure and `semantic.store_query` refuses it (scalar-only). `aov` inherits the refusal through its denominator"
51
+ fix: "add an ORDER-COUNT metric to `sales_lines` itself β€” `count_distinct` over `l.order_id` β€” which answers the same question at this grain in one pass"
52
+ - key: commission_amount
53
+ cause: "the commission basis lives on `account.invoice.line.agent` (topic `commission_lines`), a different topic AND a different grain; `invoice_lines` deduplicates to at most one agent per line"
54
+ fix: "bind a SECOND measures block per source once the contract allows more than one, and label the columns so the two routes can never be read as the same number"
55
+
56
+ # key / label / type / kind, derived from the grid contract. `kind` says where the
57
+ # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
58
+ # from another topic; a `link` points at another database.
59
+ fields:
60
+ - key: agent
61
+ label: "Agent"
62
+ type: text
63
+ kind: data
64
+ - key: odoo_id
65
+ label: "Odoo ID"
66
+ type: int
67
+ kind: data
68
+ means: "The `res.partner` id. Also this row's id."
69
+ - key: agent_id
70
+ label: "Odoo agent id"
71
+ type: int
72
+ kind: data
73
+ - key: flagged
74
+ label: "Flagged in Odoo"
75
+ type: checkbox
76
+ kind: data
77
+ means: "Ticked = `res.partner.agent` is set. Unticked agents were found by their commission lines instead - both are real, which is why this table is the union of the two."
78
+ - key: commissioned
79
+ label: "Has commission lines"
80
+ type: checkbox
81
+ kind: data
82
+ - key: refreshed
83
+ label: "Refreshed"
84
+ type: date
85
+ kind: data
86
+ means: "When this row was last reconciled against Odoo."
87
+
88
+ ai_context: >
89
+ The sales-agent registry.
90
+ `flagged` and `commissioned` are the two SOURCES, kept as separate columns rather than merged, because they disagree and the disagreement is information.
91
+ For an agent's BOOK use sales_lines/commission_lines with the agent dim.
platform/model/topics/odoo_bills.yml CHANGED
@@ -1,86 +1,86 @@
1
- # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
- # the databases a person actually opens, not only the line and document grains.
3
- #
4
- # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
- # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
- # field row: add the column to the grid contract and re-emit, or the agent is being
7
- # trained on a schema the product does not have.
8
- key: odoo_bills
9
- label: "Odoo vendor bills"
10
- entity: account.move
11
- # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
- grid: ut_odoo_bills
13
- subject: "odoo:account.move.vendor"
14
- grain: "one row per posted VENDOR bill or vendor credit note (document grain)"
15
- scope:
16
- population: "move_type in (in_invoice, in_refund) AND state = posted"
17
- signs: "for payables the residual is negative on the Odoo side; take the absolute value for an AP figure"
18
- store:
19
- table: account_move
20
- alias: m
21
- date_col: "m.invoice_date"
22
- dims:
23
- vendor: {label: "Vendor"}
24
- payment_state: {label: "Payment state"}
25
- move_type: {label: "Document type"}
26
-
27
- # key / label / type / kind, derived from the grid contract. `kind` says where the
28
- # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
29
- # from another topic; a `link` points at another database.
30
- fields:
31
- - key: bill_no
32
- label: "Bill"
33
- type: text
34
- kind: data
35
- - key: odoo_id
36
- label: "Odoo ID"
37
- type: int
38
- kind: data
39
- means: "The `account.move` id. Also this row's id."
40
- - key: vendor
41
- label: "Vendor"
42
- type: text
43
- kind: data
44
- - key: vendor_id
45
- label: "Odoo vendor id"
46
- type: int
47
- kind: data
48
- - key: invoice_date
49
- label: "Bill date"
50
- type: date
51
- kind: data
52
- - key: due_date
53
- label: "Due date"
54
- type: date
55
- kind: data
56
- - key: amount_untaxed
57
- label: "Billed $"
58
- type: currency
59
- kind: data
60
- - key: residual
61
- label: "Outstanding $"
62
- type: currency
63
- kind: data
64
- - key: payment_state
65
- label: "Payment state"
66
- type: select
67
- kind: data
68
- - key: move_type
69
- label: "Document"
70
- type: select
71
- kind: data
72
- - key: vendor_link
73
- label: "Vendor record"
74
- type: link
75
- kind: link
76
- to_grid: ut_odoo_vendors
77
- - key: refreshed
78
- label: "Refreshed"
79
- type: date
80
- kind: data
81
- means: "When this row was last reconciled against Odoo."
82
-
83
- ai_context: >
84
- Vendor bills at DOCUMENT grain β€” the payables side of account_move.
85
- Use it for AP questions.
86
- ⚠ The census found `ref` (the VENDOR's own invoice number), `journal_id` and `currency_id` populated in Odoo and absent from the mirror, so they cannot be answered from here yet.
 
1
+ # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
+ # the databases a person actually opens, not only the line and document grains.
3
+ #
4
+ # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
+ # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
+ # field row: add the column to the grid contract and re-emit, or the agent is being
7
+ # trained on a schema the product does not have.
8
+ key: odoo_bills
9
+ label: "Odoo vendor bills"
10
+ entity: account.move
11
+ # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
+ grid: ut_odoo_bills
13
+ subject: "odoo:account.move.vendor"
14
+ grain: "one row per posted VENDOR bill or vendor credit note (document grain)"
15
+ scope:
16
+ population: "move_type in (in_invoice, in_refund) AND state = posted"
17
+ signs: "for payables the residual is negative on the Odoo side; take the absolute value for an AP figure"
18
+ store:
19
+ table: account_move
20
+ alias: m
21
+ date_col: "m.invoice_date"
22
+ dims:
23
+ vendor: {label: "Vendor"}
24
+ payment_state: {label: "Payment state"}
25
+ move_type: {label: "Document type"}
26
+
27
+ # key / label / type / kind, derived from the grid contract. `kind` says where the
28
+ # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
29
+ # from another topic; a `link` points at another database.
30
+ fields:
31
+ - key: bill_no
32
+ label: "Bill"
33
+ type: text
34
+ kind: data
35
+ - key: odoo_id
36
+ label: "Odoo ID"
37
+ type: int
38
+ kind: data
39
+ means: "The `account.move` id. Also this row's id."
40
+ - key: vendor
41
+ label: "Vendor"
42
+ type: text
43
+ kind: data
44
+ - key: vendor_id
45
+ label: "Odoo vendor id"
46
+ type: int
47
+ kind: data
48
+ - key: invoice_date
49
+ label: "Bill date"
50
+ type: date
51
+ kind: data
52
+ - key: due_date
53
+ label: "Due date"
54
+ type: date
55
+ kind: data
56
+ - key: amount_untaxed
57
+ label: "Billed $"
58
+ type: currency
59
+ kind: data
60
+ - key: residual
61
+ label: "Outstanding $"
62
+ type: currency
63
+ kind: data
64
+ - key: payment_state
65
+ label: "Payment state"
66
+ type: select
67
+ kind: data
68
+ - key: move_type
69
+ label: "Document"
70
+ type: select
71
+ kind: data
72
+ - key: vendor_link
73
+ label: "Vendor record"
74
+ type: link
75
+ kind: link
76
+ to_grid: ut_odoo_vendors
77
+ - key: refreshed
78
+ label: "Refreshed"
79
+ type: date
80
+ kind: data
81
+ means: "When this row was last reconciled against Odoo."
82
+
83
+ ai_context: >
84
+ Vendor bills at DOCUMENT grain β€” the payables side of account_move.
85
+ Use it for AP questions.
86
+ ⚠ The census found `ref` (the VENDOR's own invoice number), `journal_id` and `currency_id` populated in Odoo and absent from the mirror, so they cannot be answered from here yet.
platform/model/topics/odoo_invoices.yml CHANGED
@@ -1,98 +1,98 @@
1
- # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
- # the databases a person actually opens, not only the line and document grains.
3
- #
4
- # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
- # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
- # field row: add the column to the grid contract and re-emit, or the agent is being
7
- # trained on a schema the product does not have.
8
- key: odoo_invoices
9
- label: "Odoo invoices"
10
- entity: account.move
11
- # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
- grid: ut_odoo_invoices
13
- subject: "odoo:account.move.customer"
14
- grain: "one row per posted CUSTOMER invoice or credit note (document grain)"
15
- scope:
16
- population: "move_type in (out_invoice, out_refund) AND state = posted"
17
- signs: "amount_residual_signed is POSITIVE for an invoice and NEGATIVE for a credit note, so summing it nets the credit notes correctly"
18
- store:
19
- table: account_move
20
- alias: m
21
- date_col: "m.invoice_date"
22
- dims:
23
- customer: {label: "Customer"}
24
- payment_state: {label: "Payment state"}
25
- move_type: {label: "Document type"}
26
-
27
- # key / label / type / kind, derived from the grid contract. `kind` says where the
28
- # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
29
- # from another topic; a `link` points at another database.
30
- fields:
31
- - key: invoice_no
32
- label: "Invoice"
33
- type: text
34
- kind: data
35
- - key: odoo_id
36
- label: "Odoo ID"
37
- type: int
38
- kind: data
39
- means: "The `account.move` id. Also this row's id."
40
- - key: customer
41
- label: "Customer"
42
- type: text
43
- kind: data
44
- - key: partner_id
45
- label: "Odoo partner id"
46
- type: int
47
- kind: data
48
- - key: invoice_date
49
- label: "Invoice date"
50
- type: date
51
- kind: data
52
- - key: due_date
53
- label: "Due date"
54
- type: date
55
- kind: data
56
- - key: residual
57
- label: "Outstanding $"
58
- type: currency
59
- kind: data
60
- means: "Odoo's signed residual. Exactly 0 on every settled document, which is why AR rollups need no filter."
61
- - key: amount_untaxed
62
- label: "Invoiced $"
63
- type: currency
64
- kind: data
65
- - key: payment_state
66
- label: "Payment state"
67
- type: select
68
- kind: data
69
- - key: move_type
70
- label: "Document"
71
- type: select
72
- kind: data
73
- - key: wholesale_scope
74
- label: "In wholesale scope"
75
- type: checkbox
76
- kind: data
77
- means: "Unticked = the GIFTWARE DEALS / Amazon channel, which every wholesale metric in this product excludes. The row is kept so no Odoo id is missing; filter on this column to reconcile against the AR page."
78
- - key: origin_order
79
- label: "Source order"
80
- type: text
81
- kind: data
82
- means: "Odoo's `invoice_origin` - usually the order name, sometimes blank."
83
- - key: order_link
84
- label: "Order record"
85
- type: link
86
- kind: link
87
- to_grid: ut_odoo_orders
88
- - key: refreshed
89
- label: "Refreshed"
90
- type: date
91
- kind: data
92
- means: "When this row was last reconciled against Odoo."
93
-
94
- ai_context: >
95
- Customer invoices at DOCUMENT grain β€” one row per invoice, not per line.
96
- Use it for AR questions: what is open, what is overdue, what was invoiced.
97
- For anything at product or line grain use invoice_lines; for the reconciled receivables view use receivables.
98
- `origin_order` is the single stored key that joins an invoice back to the order it was raised from.
 
1
+ # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
+ # the databases a person actually opens, not only the line and document grains.
3
+ #
4
+ # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
+ # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
+ # field row: add the column to the grid contract and re-emit, or the agent is being
7
+ # trained on a schema the product does not have.
8
+ key: odoo_invoices
9
+ label: "Odoo invoices"
10
+ entity: account.move
11
+ # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
+ grid: ut_odoo_invoices
13
+ subject: "odoo:account.move.customer"
14
+ grain: "one row per posted CUSTOMER invoice or credit note (document grain)"
15
+ scope:
16
+ population: "move_type in (out_invoice, out_refund) AND state = posted"
17
+ signs: "amount_residual_signed is POSITIVE for an invoice and NEGATIVE for a credit note, so summing it nets the credit notes correctly"
18
+ store:
19
+ table: account_move
20
+ alias: m
21
+ date_col: "m.invoice_date"
22
+ dims:
23
+ customer: {label: "Customer"}
24
+ payment_state: {label: "Payment state"}
25
+ move_type: {label: "Document type"}
26
+
27
+ # key / label / type / kind, derived from the grid contract. `kind` says where the
28
+ # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
29
+ # from another topic; a `link` points at another database.
30
+ fields:
31
+ - key: invoice_no
32
+ label: "Invoice"
33
+ type: text
34
+ kind: data
35
+ - key: odoo_id
36
+ label: "Odoo ID"
37
+ type: int
38
+ kind: data
39
+ means: "The `account.move` id. Also this row's id."
40
+ - key: customer
41
+ label: "Customer"
42
+ type: text
43
+ kind: data
44
+ - key: partner_id
45
+ label: "Odoo partner id"
46
+ type: int
47
+ kind: data
48
+ - key: invoice_date
49
+ label: "Invoice date"
50
+ type: date
51
+ kind: data
52
+ - key: due_date
53
+ label: "Due date"
54
+ type: date
55
+ kind: data
56
+ - key: residual
57
+ label: "Outstanding $"
58
+ type: currency
59
+ kind: data
60
+ means: "Odoo's signed residual. Exactly 0 on every settled document, which is why AR rollups need no filter."
61
+ - key: amount_untaxed
62
+ label: "Invoiced $"
63
+ type: currency
64
+ kind: data
65
+ - key: payment_state
66
+ label: "Payment state"
67
+ type: select
68
+ kind: data
69
+ - key: move_type
70
+ label: "Document"
71
+ type: select
72
+ kind: data
73
+ - key: wholesale_scope
74
+ label: "In wholesale scope"
75
+ type: checkbox
76
+ kind: data
77
+ means: "Unticked = the GIFTWARE DEALS / Amazon channel, which every wholesale metric in this product excludes. The row is kept so no Odoo id is missing; filter on this column to reconcile against the AR page."
78
+ - key: origin_order
79
+ label: "Source order"
80
+ type: text
81
+ kind: data
82
+ means: "Odoo's `invoice_origin` - usually the order name, sometimes blank."
83
+ - key: order_link
84
+ label: "Order record"
85
+ type: link
86
+ kind: link
87
+ to_grid: ut_odoo_orders
88
+ - key: refreshed
89
+ label: "Refreshed"
90
+ type: date
91
+ kind: data
92
+ means: "When this row was last reconciled against Odoo."
93
+
94
+ ai_context: >
95
+ Customer invoices at DOCUMENT grain β€” one row per invoice, not per line.
96
+ Use it for AR questions: what is open, what is overdue, what was invoiced.
97
+ For anything at product or line grain use invoice_lines; for the reconciled receivables view use receivables.
98
+ `origin_order` is the single stored key that joins an invoice back to the order it was raised from.
platform/model/topics/odoo_orders.yml CHANGED
@@ -1,87 +1,87 @@
1
- # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
- # the databases a person actually opens, not only the line and document grains.
3
- #
4
- # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
- # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
- # field row: add the column to the grid contract and re-emit, or the agent is being
7
- # trained on a schema the product does not have.
8
- key: odoo_orders
9
- label: "Odoo orders"
10
- entity: sale.order
11
- # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
- grid: ut_odoo_orders
13
- subject: "odoo:sale.order"
14
- grain: "one row per confirmed sales order (document grain)"
15
- scope:
16
- population: "state in (sale, done) β€” quotations and cancellations excluded"
17
- vs_sales_orders: "⚠ DISTINCT from the `sales_orders` topic: that one is the wholesale-scoped analytical view (teams 5/6, house accounts excluded); THIS one is the registry a person opens as a grid, and carries the row set the app shows"
18
- store:
19
- table: sale_order
20
- alias: o
21
- date_col: "o.date_order"
22
- dims:
23
- customer: {label: "Customer"}
24
- team: {label: "Business unit"}
25
- state: {label: "State"}
26
- invoice_status: {label: "Invoice status"}
27
-
28
- # key / label / type / kind, derived from the grid contract. `kind` says where the
29
- # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
30
- # from another topic; a `link` points at another database.
31
- fields:
32
- - key: order_no
33
- label: "Order"
34
- type: text
35
- kind: data
36
- - key: odoo_id
37
- label: "Odoo ID"
38
- type: int
39
- kind: data
40
- means: "The `sale.order` id. Also this row's id."
41
- - key: customer
42
- label: "Customer"
43
- type: text
44
- kind: data
45
- - key: partner_id
46
- label: "Odoo partner id"
47
- type: int
48
- kind: data
49
- - key: order_date
50
- label: "Order date"
51
- type: date
52
- kind: data
53
- - key: amount_untaxed
54
- label: "Order $"
55
- type: currency
56
- kind: data
57
- - key: team
58
- label: "Business unit"
59
- type: select
60
- kind: data
61
- - key: state
62
- label: "State"
63
- type: select
64
- kind: data
65
- - key: invoice_status
66
- label: "Invoice status"
67
- type: select
68
- kind: data
69
- - key: wholesale_scope
70
- label: "In wholesale scope"
71
- type: checkbox
72
- kind: data
73
- means: "Unticked = the GIFTWARE DEALS / Amazon channel, which every wholesale metric in this product excludes. The row is kept so no Odoo id is missing; filter on this column to reconcile against the AR page."
74
- - key: invoices
75
- label: "Invoices"
76
- type: link
77
- kind: link
78
- to_grid: ut_odoo_invoices
79
- - key: refreshed
80
- label: "Refreshed"
81
- type: date
82
- kind: data
83
- means: "When this row was last reconciled against Odoo."
84
-
85
- ai_context: >
86
- Sales orders at DOCUMENT grain, as the app's grid shows them.
87
- β›” If you are asked for revenue, prefer `sales_lines` (line grain, wholesale scope) or `sales_orders` β€” this topic exists to describe the ORDER RECORD a person can open, and its scope is the grid's, not the analytical model's.
 
1
+ # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
+ # the databases a person actually opens, not only the line and document grains.
3
+ #
4
+ # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
+ # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
+ # field row: add the column to the grid contract and re-emit, or the agent is being
7
+ # trained on a schema the product does not have.
8
+ key: odoo_orders
9
+ label: "Odoo orders"
10
+ entity: sale.order
11
+ # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
+ grid: ut_odoo_orders
13
+ subject: "odoo:sale.order"
14
+ grain: "one row per confirmed sales order (document grain)"
15
+ scope:
16
+ population: "state in (sale, done) β€” quotations and cancellations excluded"
17
+ vs_sales_orders: "⚠ DISTINCT from the `sales_orders` topic: that one is the wholesale-scoped analytical view (teams 5/6, house accounts excluded); THIS one is the registry a person opens as a grid, and carries the row set the app shows"
18
+ store:
19
+ table: sale_order
20
+ alias: o
21
+ date_col: "o.date_order"
22
+ dims:
23
+ customer: {label: "Customer"}
24
+ team: {label: "Business unit"}
25
+ state: {label: "State"}
26
+ invoice_status: {label: "Invoice status"}
27
+
28
+ # key / label / type / kind, derived from the grid contract. `kind` says where the
29
+ # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
30
+ # from another topic; a `link` points at another database.
31
+ fields:
32
+ - key: order_no
33
+ label: "Order"
34
+ type: text
35
+ kind: data
36
+ - key: odoo_id
37
+ label: "Odoo ID"
38
+ type: int
39
+ kind: data
40
+ means: "The `sale.order` id. Also this row's id."
41
+ - key: customer
42
+ label: "Customer"
43
+ type: text
44
+ kind: data
45
+ - key: partner_id
46
+ label: "Odoo partner id"
47
+ type: int
48
+ kind: data
49
+ - key: order_date
50
+ label: "Order date"
51
+ type: date
52
+ kind: data
53
+ - key: amount_untaxed
54
+ label: "Order $"
55
+ type: currency
56
+ kind: data
57
+ - key: team
58
+ label: "Business unit"
59
+ type: select
60
+ kind: data
61
+ - key: state
62
+ label: "State"
63
+ type: select
64
+ kind: data
65
+ - key: invoice_status
66
+ label: "Invoice status"
67
+ type: select
68
+ kind: data
69
+ - key: wholesale_scope
70
+ label: "In wholesale scope"
71
+ type: checkbox
72
+ kind: data
73
+ means: "Unticked = the GIFTWARE DEALS / Amazon channel, which every wholesale metric in this product excludes. The row is kept so no Odoo id is missing; filter on this column to reconcile against the AR page."
74
+ - key: invoices
75
+ label: "Invoices"
76
+ type: link
77
+ kind: link
78
+ to_grid: ut_odoo_invoices
79
+ - key: refreshed
80
+ label: "Refreshed"
81
+ type: date
82
+ kind: data
83
+ means: "When this row was last reconciled against Odoo."
84
+
85
+ ai_context: >
86
+ Sales orders at DOCUMENT grain, as the app's grid shows them.
87
+ β›” If you are asked for revenue, prefer `sales_lines` (line grain, wholesale scope) or `sales_orders` β€” this topic exists to describe the ORDER RECORD a person can open, and its scope is the grid's, not the analytical model's.
platform/model/topics/odoo_products.yml CHANGED
@@ -25,7 +25,17 @@ subject: "odoo:product.product"
25
  grain: "one row per SKU in the active catalogue β€” a CATALOGUE, not a dated event stream"
26
  scope:
27
  population: "every ACTIVE product, sold or not (measured 5,862). Deliberately not the sales-window universe: that lands at 2,717 and hides ~2,550 SKUs that have never sold in wholesale scope"
28
- identity: "the SKU code (`default_code`). ⚠ 33 active products carry NO code and are keyed `pid:<odoo product id>` instead, so every active product has exactly one row and none is dropped"
 
 
 
 
 
 
 
 
 
 
29
  merged: "W33-T44 retired `ut_odoo_products`, which presented this same subject on Odoo's product_id. This topic describes the SURVIVING database, `product_data`"
30
  no_date: "a catalogue has no date dimension; ask sales_lines for movement"
31
  store:
@@ -145,6 +155,11 @@ fields:
145
  type: text
146
  kind: data
147
  means: "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
 
 
 
 
 
148
  - key: product
149
  label: "Product"
150
  type: text
 
25
  grain: "one row per SKU in the active catalogue β€” a CATALOGUE, not a dated event stream"
26
  scope:
27
  population: "every ACTIVE product, sold or not (measured 5,862). Deliberately not the sales-window universe: that lands at 2,717 and hides ~2,550 SKUs that have never sold in wholesale scope"
28
+ # ⭐⭐ W41-T17 (owner instruction 25) rewrote this line. TWO IDS, and that is the honest
29
+ # difference from `odoo_customers`, whose own `scope.identity` can say "there is ONE id per row
30
+ # and no second one". Here the BUSINESS key and the ODOO key are genuinely different values, and
31
+ # `pid` is a CRC32 of the first, so it is neither of them and must never be offered as a join.
32
+ # β›” A CUSTOMERS->PRODUCTS EDGE JOINS ON `product_id`, never on this row's `pid` and never on the
33
+ # product NAME. Measured 2026-08-24 against the mirror: 5,873 active products, 33 codeless, ZERO
34
+ # with a null Odoo id, so the column resolves for every row the grid carries.
35
+ # ⚠ Which does NOT relax the `measures:` binding below. That block groups a FACT topic by its own
36
+ # `product_code` dim and stays code-keyed; `sales_lines` and `stock_moves` spell the identical
37
+ # COALESCE fallback, so the 33 codeless rows still match there on `pid:<id>` and are not lost.
38
+ identity: "the SKU code (`default_code`) is the BUSINESS key, and the `product_id` column (W41-T17) is the Odoo `product.product` id every Odoo document joins on. ⚠ 33 active products carry NO code and are keyed `pid:<odoo product id>` instead, so every active product has exactly one row and none is dropped; those 33 still carry a real `product_id`"
39
  merged: "W33-T44 retired `ut_odoo_products`, which presented this same subject on Odoo's product_id. This topic describes the SURVIVING database, `product_data`"
40
  no_date: "a catalogue has no date dimension; ask sales_lines for movement"
41
  store:
 
155
  type: text
156
  kind: data
157
  means: "The SKU code β€” the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
158
+ - key: product_id
159
+ label: "Odoo ID"
160
+ type: int
161
+ kind: data
162
+ means: "The Odoo product.product id, the key every Odoo document joins on. Carried on the row rather than derived, because a product's pid is a CRC32 of its SKU and the id cannot be recovered from it."
163
  - key: product
164
  label: "Product"
165
  type: text
platform/model/topics/odoo_vendors.yml CHANGED
@@ -1,103 +1,103 @@
1
- # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
- # the databases a person actually opens, not only the line and document grains.
3
- #
4
- # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
- # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
- # field row: add the column to the grid contract and re-emit, or the agent is being
7
- # trained on a schema the product does not have.
8
- key: odoo_vendors
9
- label: "Odoo vendors"
10
- entity: res.partner
11
- # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
- grid: ut_odoo_vendors
13
- subject: "odoo:res.partner.vendor"
14
- grain: "one row per partner we have posted a vendor bill to"
15
- scope:
16
- population: "DERIVED FROM THE BILLS β€” a partner with no posted vendor bill has no payable history to show, so the bill is the population"
17
- asymmetry: "deliberately unlike odoo_customers, which is NOT derived from its documents; there is no second document universe for vendors"
18
- overlap: "measured: only 9 of the vendors also appear in the customer population, so a vendor is not a customer row"
19
- store:
20
- table: res_partner
21
- alias: p
22
- # NO date_col β€” a registry is not a dated event stream. Stated rather than
23
- # omitted, so its absence reads as a fact and not as an unfinished file.
24
- dims:
25
- country: {label: "Country"}
26
-
27
- # key / label / type / kind, derived from the grid contract. `kind` says where the
28
- # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
29
- # from another topic; a `link` points at another database.
30
- fields:
31
- - key: vendor
32
- label: "Vendor"
33
- type: text
34
- kind: data
35
- - key: odoo_id
36
- label: "Odoo ID"
37
- type: int
38
- kind: data
39
- means: "The `res.partner` id. Also this row's id."
40
- - key: vendor_id
41
- label: "Odoo vendor id"
42
- type: int
43
- kind: data
44
- - key: country
45
- label: "Country"
46
- type: text
47
- kind: data
48
- - key: email
49
- label: "Email"
50
- type: text
51
- kind: data
52
- - key: phone
53
- label: "Phone"
54
- type: text
55
- kind: data
56
- - key: mobile
57
- label: "Mobile"
58
- type: text
59
- kind: data
60
- - key: vat
61
- label: "Tax ID"
62
- type: text
63
- kind: data
64
- means: "Odoo `vat` β€” the vendor's tax/VAT registration number."
65
- - key: vendor_ref
66
- label: "Vendor reference"
67
- type: text
68
- kind: data
69
- means: "Odoo `res.partner.ref` β€” our internal reference for this vendor."
70
- - key: website
71
- label: "Website"
72
- type: url
73
- kind: data
74
- - key: street
75
- label: "Street"
76
- type: text
77
- kind: data
78
- - key: street2
79
- label: "Street 2"
80
- type: text
81
- kind: data
82
- - key: city
83
- label: "City"
84
- type: text
85
- kind: data
86
- - key: zip
87
- label: "ZIP"
88
- type: text
89
- kind: data
90
- - key: bills
91
- label: "Bills"
92
- type: link
93
- kind: link
94
- to_grid: ut_odoo_bills
95
- - key: refreshed
96
- label: "Refreshed"
97
- type: date
98
- kind: data
99
- means: "When this row was last reconciled against Odoo."
100
-
101
- ai_context: >
102
- The vendor/supplier registry, with contact and tax identity (W33-T48 widened it from 5 columns to 15 after a census found 76 populated fields on the underlying partner)
103
- ⚠ The contact columns are SPARSE by nature β€” measured 65/393 with an email, 13/393 with a tax id β€” so 'blank' means Odoo has no value, never that the sync failed.
 
1
+ # ⭐⭐ W33-T49 (owner item 13) β€” an ENTITY topic: the semantic layer can finally see
2
+ # the databases a person actually opens, not only the line and document grains.
3
+ #
4
+ # β›” THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
5
+ # and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
6
+ # field row: add the column to the grid contract and re-emit, or the agent is being
7
+ # trained on a schema the product does not have.
8
+ key: odoo_vendors
9
+ label: "Odoo vendors"
10
+ entity: res.partner
11
+ # The database this topic DESCRIBES β€” the same store key the nav opens (W33-T46).
12
+ grid: ut_odoo_vendors
13
+ subject: "odoo:res.partner.vendor"
14
+ grain: "one row per partner we have posted a vendor bill to"
15
+ scope:
16
+ population: "DERIVED FROM THE BILLS β€” a partner with no posted vendor bill has no payable history to show, so the bill is the population"
17
+ asymmetry: "deliberately unlike odoo_customers, which is NOT derived from its documents; there is no second document universe for vendors"
18
+ overlap: "measured: only 9 of the vendors also appear in the customer population, so a vendor is not a customer row"
19
+ store:
20
+ table: res_partner
21
+ alias: p
22
+ # NO date_col β€” a registry is not a dated event stream. Stated rather than
23
+ # omitted, so its absence reads as a fact and not as an unfinished file.
24
+ dims:
25
+ country: {label: "Country"}
26
+
27
+ # key / label / type / kind, derived from the grid contract. `kind` says where the
28
+ # value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
29
+ # from another topic; a `link` points at another database.
30
+ fields:
31
+ - key: vendor
32
+ label: "Vendor"
33
+ type: text
34
+ kind: data
35
+ - key: odoo_id
36
+ label: "Odoo ID"
37
+ type: int
38
+ kind: data
39
+ means: "The `res.partner` id. Also this row's id."
40
+ - key: vendor_id
41
+ label: "Odoo vendor id"
42
+ type: int
43
+ kind: data
44
+ - key: country
45
+ label: "Country"
46
+ type: text
47
+ kind: data
48
+ - key: email
49
+ label: "Email"
50
+ type: text
51
+ kind: data
52
+ - key: phone
53
+ label: "Phone"
54
+ type: text
55
+ kind: data
56
+ - key: mobile
57
+ label: "Mobile"
58
+ type: text
59
+ kind: data
60
+ - key: vat
61
+ label: "Tax ID"
62
+ type: text
63
+ kind: data
64
+ means: "Odoo `vat` β€” the vendor's tax/VAT registration number."
65
+ - key: vendor_ref
66
+ label: "Vendor reference"
67
+ type: text
68
+ kind: data
69
+ means: "Odoo `res.partner.ref` β€” our internal reference for this vendor."
70
+ - key: website
71
+ label: "Website"
72
+ type: url
73
+ kind: data
74
+ - key: street
75
+ label: "Street"
76
+ type: text
77
+ kind: data
78
+ - key: street2
79
+ label: "Street 2"
80
+ type: text
81
+ kind: data
82
+ - key: city
83
+ label: "City"
84
+ type: text
85
+ kind: data
86
+ - key: zip
87
+ label: "ZIP"
88
+ type: text
89
+ kind: data
90
+ - key: bills
91
+ label: "Bills"
92
+ type: link
93
+ kind: link
94
+ to_grid: ut_odoo_bills
95
+ - key: refreshed
96
+ label: "Refreshed"
97
+ type: date
98
+ kind: data
99
+ means: "When this row was last reconciled against Odoo."
100
+
101
+ ai_context: >
102
+ The vendor/supplier registry, with contact and tax identity (W33-T48 widened it from 5 columns to 15 after a census found 76 populated fields on the underlying partner)
103
+ ⚠ The contact columns are SPARSE by nature β€” measured 65/393 with an email, 13/393 with a tax id β€” so 'blank' means Odoo has no value, never that the sync failed.
platform/modules/agent.py CHANGED
@@ -1,385 +1,385 @@
1
- """Agent module β€” per-agent (res.partner.agent_ids) analytics.
2
-
3
- An *agent* owns a **book** of customers (the same attribute the Customers module slices by). This
4
- module reports that book the way Sales/Customers/SKU report the whole company: a period scorecard
5
- with custom date windows (Today / WTD / Last week / MTD / QTD / YTD / any custom range), a sales
6
- trend, returns, top SKUs (with profit/order) and the FULL customer list β€” INCLUDING inactive
7
- accounts (no recent orders) so a rep sees who they've stopped selling to.
8
-
9
- Scope: reuses the Sales `order_domain` (Fisch+Royal, excluded accounts removed, state sale/done)
10
- so numbers tie to every other module. Returns are consolidated (credit notes aren't BU-tagged);
11
- everything else is BU-filterable via team_id.
12
- """
13
- import sys
14
- import datetime as dt
15
- from pathlib import Path
16
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17
- import core.odoo as O
18
- import core.periods as P
19
- import modules.sales as sales_mod
20
- import modules.customers as cust_mod
21
-
22
-
23
- def options(t=None, team_id=None):
24
- """Agent names with book activity β€” for the page/drawer picker."""
25
- return cust_mod.agent_options(t, team_id)
26
-
27
-
28
- def _book(name):
29
- """frozenset of every partner id assigned to the agent (incl. inactive). None only for 'All'."""
30
- return cust_mod.agent_partner_ids(name)
31
-
32
-
33
- def _rev(date_from, date_to, team_id, book):
34
- return O.sum_field('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
35
- 'amount_untaxed')
36
-
37
-
38
- def _orders(date_from, date_to, team_id, book):
39
- return O.get_odoo().search_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book))
40
-
41
-
42
- def _custs(date_from, date_to, team_id, book):
43
- return O.distinct_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
44
- 'partner_id')
45
-
46
-
47
- # ------------------------------------------------------------ period scorecard (custom dating)
48
- # Today / WTD / Last week / MTD / QTD / YTD β€” each carries its window so the UI can decompose it
49
- # and so "what did this agent sell this/last week" is a click, not a date-math exercise.
50
- _PERIODS = [('Today', 'today'), ('Week to date', 'wtd'), ('Last week', 'lwk'),
51
- ('Month to date', 'mtd'), ('Quarter to date', 'qtd'), ('Year to date', 'ytd')]
52
-
53
-
54
- def _window(key, t):
55
- if key == 'today':
56
- return P._d(t), P._d(t)
57
- if key == 'lwk': # the full prior Mon–Sun week
58
- f, _tt = P.wtd(t)
59
- start = dt.date.fromisoformat(f) - dt.timedelta(days=7)
60
- return start.isoformat(), (dt.date.fromisoformat(f) - dt.timedelta(days=1)).isoformat()
61
- return {'wtd': P.wtd, 'mtd': P.mtd, 'qtd': P.qtd, 'ytd': P.ytd}[key](t)
62
-
63
-
64
- def scorecard(name, t=None, team_id=None):
65
- """Book revenue (+ YoY same-period), orders for Today / WTD / Last week / MTD / QTD / YTD."""
66
- t = t or P.today()
67
- book = _book(name)
68
- out = []
69
- for label, key in _PERIODS:
70
- f, tt = _window(key, t)
71
- wk = key in ('today', 'wtd', 'lwk') # weekday-align the short windows' LY compare
72
- cf, ct = P.shift_year(f, tt, weeks=wk)
73
- rev, rev_ly = _rev(f, tt, team_id, book), _rev(cf, ct, team_id, book)
74
- out.append({'key': key, 'label': label, 'date_from': f, 'date_to': tt, 'cmp_from': cf, 'cmp_to': ct,
75
- 'revenue': rev, 'revenue_ly': rev_ly, 'yoy_pct': P.yoy_pct(rev, rev_ly),
76
- 'orders': _orders(f, tt, team_id, book)})
77
- return out
78
-
79
-
80
- def headline(name, date_from, date_to, team_id=None):
81
- """Book KPIs for an ARBITRARY window (custom dating): revenue + YoY (same window LY), orders,
82
- active customers, AOV and returns $ / return rate."""
83
- book = _book(name)
84
- cf, ct = P.shift_year(date_from, date_to, weeks=False)
85
- rev, rev_ly = _rev(date_from, date_to, team_id, book), _rev(cf, ct, team_id, book)
86
- orders = _orders(date_from, date_to, team_id, book)
87
- ret = sales_mod._returns_amt(date_from, date_to, book)
88
- return {'date_from': date_from, 'date_to': date_to, 'cmp_from': cf, 'cmp_to': ct,
89
- 'revenue': rev, 'revenue_ly': rev_ly,
90
- 'yoy_pct': P.yoy_pct(rev, rev_ly), 'orders': orders,
91
- 'customers': _custs(date_from, date_to, team_id, book), 'aov': (rev / orders) if orders else 0.0,
92
- 'returns': ret, 'return_rate_pct': (ret / rev * 100.0) if rev else 0.0}
93
-
94
-
95
- # ------------------------------------------------------------ sales trend
96
- def _book_monthly_rev(book, date_from, date_to, team_id=None):
97
- g = O.read_group('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
98
- ['amount_untaxed:sum'], ['date_order:month'], lazy=False)
99
- out = {}
100
- for r in g:
101
- ym = ((r.get('__range') or {}).get('date_order:month') or {}).get('from', '')[:7]
102
- if ym:
103
- out[ym] = r.get('amount_untaxed') or 0.0
104
- return out
105
-
106
-
107
- def monthly(name, n=13, t=None, team_id=None):
108
- """Book sales per month vs the same month last year (one month-grouped query over 2 years)."""
109
- t = t or P.today()
110
- book = _book(name)
111
- mrev = _book_monthly_rev(book, dt.date(t.year - 2, t.month, 1).isoformat(), t.isoformat(), team_id)
112
- rows = []
113
- for ym, _s, _e in P.month_starts(n, t):
114
- y, m = int(ym[:4]) - 1, int(ym[5:7])
115
- this, last = mrev.get(ym, 0.0), mrev.get(f'{y:04d}-{m:02d}', 0.0)
116
- rows.append({'month': ym, 'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)})
117
- return rows
118
-
119
-
120
- # ------------------------------------------------------------ full customer book (incl. inactive)
121
- def customers(name, t=None, team_id=None):
122
- """EVERY customer in the agent's book, including inactive accounts (no YTD/LY orders) β€” those
123
- show $0 with status 'Inactive'/'Dormant'. Each row is clickable to the customer drawer and
124
- carries recency so a rep can see who's gone quiet. Sorted by YTD revenue desc (inactive last)."""
125
- t = t or P.today()
126
- book = _book(name)
127
- yf, yt = P.ytd(t)
128
- lf, lt = P.ytd_last_year(t)
129
- this = cust_mod._cust_rev(yf, yt, team_id, book)
130
- last = cust_mod._cust_rev(lf, lt, team_id, book)
131
- lastord = cust_mod._last_order_dates(None, None, team_id, book) # all-time last order = recency
132
- ids = list(book) if book is not None else list(set(this) | set(last))
133
- attrs = cust_mod._partner_attrs(set(ids))
134
- namemap = {r['id']: r.get('name') for r in O.search_read('res.partner', [('id', 'in', ids)], ['name'])}
135
- rows = []
136
- for p in ids:
137
- tr = this.get(p, {}).get('rev', 0.0)
138
- lr = last.get(p, {}).get('rev', 0.0)
139
- a = attrs.get(p, {})
140
- lo = lastord.get(p, '')
141
- recency = (t - dt.date.fromisoformat(lo)).days if lo else None
142
- status = 'Active' if tr > 0 else ('Dormant' if (lr > 0 or lo) else 'Inactive')
143
- rows.append({'pid': p, 'customer': namemap.get(p) or (this.get(p) or last.get(p) or {}).get('name', '?'),
144
- 'rev_ytd': tr, 'rev_ly': lr, 'change': tr - lr, 'yoy_pct': P.yoy_pct(tr, lr),
145
- 'orders': this.get(p, {}).get('orders', 0), 'last_order': lo, 'recency_days': recency,
146
- 'status': status, 'city': a.get('city', '(none)'), 'state': a.get('state', '(none)'),
147
- 'agent': a.get('agent', name)})
148
- rows.sort(key=lambda r: (r['rev_ytd'] <= 0, -r['rev_ytd'], -(r['rev_ly'])))
149
- return rows
150
-
151
-
152
- # ------------------------------------------------------------ top SKUs (with profit/order)
153
- def top_skus(name, t=None, team_id=None, top=30):
154
- """The book's top SKUs YTD (line-level), each with revenue, units, margin and profit/order."""
155
- t = t or P.today()
156
- book = _book(name)
157
- if book is not None and not book:
158
- return []
159
- yf, yt = P.ytd(t)
160
- lex = [('order_partner_id', 'in', list(book))] if book is not None else None
161
- return sales_mod.decompose(yf, yt, team_id, line_extra=lex, top=top)['skus']
162
-
163
-
164
- # ------------------------------------------------------------ returns (book-scoped, consolidated)
165
- def returns_trend(name, n=13, t=None):
166
- return sales_mod.returns_monthly(n, t, partner_ids=_book(name))
167
-
168
-
169
- def returns_headline(name, t=None):
170
- return sales_mod.returns_headline(t, partner_ids=_book(name))
171
-
172
-
173
- # ------------------------------------------------------------ all-agents rollup (the page table)
174
- def _returns_by_agent(t=None):
175
- """{agent_name: returns$ YTD} β€” credit notes mapped to each customer's agent."""
176
- by_p = sales_mod.returns_by_partner(t)
177
- attrs = cust_mod._partner_attrs(list(by_p))
178
- agg = {}
179
- for pid, amt in by_p.items():
180
- a = (attrs.get(pid) or {}).get('agent') or '(none)'
181
- agg[a] = agg.get(a, 0.0) + amt
182
- return agg
183
-
184
-
185
- def rollup(t=None, team_id=None):
186
- """Every agent ranked by YTD book revenue (+ YoY, customers, orders) with returns $ and return
187
- rate. Reuses the Customers MECE agent rollup, so Ξ£(agents) == total YTD revenue."""
188
- rows = cust_mod.by_dimension('agent', t, team_id=team_id)
189
- ret = _returns_by_agent(t)
190
- for r in rows:
191
- r['agent'] = r['group']
192
- r['returns'] = ret.get(r['group'], 0.0)
193
- r['return_rate_pct'] = (r['returns'] / r['revenue'] * 100.0) if r.get('revenue') else 0.0
194
- return rows
195
-
196
-
197
- # ------------------------------------------------------------ VALIDATION
198
- def validate(t=None, team_id=None):
199
- """Reconcile the agent rollup to Odoo. (1) Ξ£(agent book revenue) == total YTD revenue β€” the
200
- rollup is MECE over customers. (2) A sampled agent's scorecard YTD == its headline YTD."""
201
- t = t or P.today()
202
- yf, yt = P.ytd(t)
203
- checks = []
204
- rows = rollup(t, team_id=team_id)
205
- agent_sum = sum(r['revenue'] for r in rows)
206
- total = O.sum_field('sale.order', sales_mod.order_domain(yf, yt, team_id), 'amount_untaxed')
207
- checks.append({'check': 'YTD revenue: Ξ£(agent book) == total', 'a': round(agent_sum, 2),
208
- 'b': round(total, 2), 'gap': round(agent_sum - total, 2),
209
- 'ok': abs(agent_sum - total) <= max(1.0, 0.001 * (total or 1))})
210
- # sampled agent: scorecard YTD == headline YTD for the same window
211
- sample = next((r['agent'] for r in rows if r['agent'] not in ('(none)',)), None)
212
- if sample:
213
- sc_ytd = next((s['revenue'] for s in scorecard(sample, t, team_id) if s['key'] == 'ytd'), 0.0)
214
- hl = headline(sample, yf, yt, team_id)['revenue']
215
- checks.append({'check': f'Agent "{sample}": scorecard YTD == headline YTD', 'a': round(sc_ytd, 2),
216
- 'b': round(hl, 2), 'gap': round(sc_ytd - hl, 2), 'ok': abs(sc_ytd - hl) <= 1.0})
217
- checks.extend(validate_measures(t=t, team_id=team_id))
218
- return checks
219
-
220
-
221
- def validate_measures(t=None, team_id=None, days=90):
222
- """⭐⭐ W37-T12 β€” the minted PER-AGENT lookback columns, against a DIRECT Odoo aggregate.
223
-
224
- β›” WHICH AGENT SOURCE, AND THE TICKET REQUIRES IT SAID OUT LOUD: this reconciles ROUTE 1, the
225
- CUSTOMER-MASTER BOOK (`res.partner.agent_ids` -> the mirror's `res_partner.agent_id`, which is
226
- `sales_lines`' `agent` dim). It is NOT the OCA commission route below β€” measured 2026-08-19,
227
- 9 agents carry route-1 revenue against 12 on commission lines and 17 carrying the flag, so the
228
- two produce materially different rankings and a check that mixed them would be comparing two
229
- different questions and calling the gap an error.
230
-
231
- ⚠ THE ORACLE IS THE ORDER HEADER, not the mirror the columns are served from. `sale.order`
232
- grouped by `partner_id`, mapped to each customer's agent CLIENT-SIDE β€” a different model, a
233
- different grain and a different code path from `store_query`'s line-level sum, so agreement
234
- between them is evidence rather than tautology.
235
- ⚠ Windowed to the MIRROR'S newest order, like `product_data.validate_measures`, so the
236
- residual is about EDITS to a shared period and not about orders the mirror has never seen.
237
- """
238
- from harness import datastore as DS
239
- from harness import semantic as sem
240
-
241
- t = t or P.today()
242
- checks = []
243
- try:
244
- if not DS.ready():
245
- return [{'check': 'agent lookback measures reconcile to Odoo', 'a': 'no mirror',
246
- 'b': '-', 'ok': False,
247
- 'detail': 'the tenant store is not readable, so this is UNPROVEN, which standing rule '
248
- '8 does not accept as green'}]
249
- except Exception as e: # noqa: BLE001
250
- return [{'check': 'agent lookback measures reconcile to Odoo', 'a': type(e).__name__,
251
- 'b': '-', 'ok': False, 'detail': str(e)[:200]}]
252
-
253
- offer = sem.entity_measures('odoo_agents')
254
- checks.append({'check': 'the agent measure OFFER is non-empty and every key resolves '
255
- '(owner item 4 / R1)',
256
- 'a': len(offer), 'b': '>0', 'ok': bool(offer),
257
- 'detail': {'keys': [m['key'] for m in offer],
258
- 'refused': sem.entity_measure_refusals('odoo_agents')}})
259
- if not offer:
260
- return checks
261
-
262
- con = DS.ro_cursor()
263
- try:
264
- newest = con.execute('SELECT max(date_order) FROM sale_order').fetchone()
265
- finally:
266
- con.close()
267
- d_to = t - dt.timedelta(days=2)
268
- if newest and newest[0]:
269
- try:
270
- d_to = min(d_to, dt.date.fromisoformat(str(newest[0])[:10]) - dt.timedelta(days=1))
271
- except ValueError:
272
- pass
273
- d_from = d_to - dt.timedelta(days=days)
274
- DF, DT = d_from.isoformat(), d_to.isoformat()
275
-
276
- ours = sem.entity_measure_values('odoo_agents', ['revenue'], date_from=DF, date_to=DT,
277
- team_id=team_id, offer=offer)
278
- # THE ORACLE β€” order headers, grouped by customer, mapped to that customer's agent here.
279
- o = O.get_odoo()
280
- grp = o.read_group('sale.order', sales_mod.order_domain(DF, DT, team_id),
281
- ['partner_id', 'amount_untaxed:sum'], ['partner_id'], lazy=False)
282
- pids = sorted({r['partner_id'][0] for r in grp if r.get('partner_id')})
283
- agent_of = {}
284
- for i in range(0, len(pids), 500):
285
- for p in o.search_read('res.partner', [('id', 'in', pids[i:i + 500])],
286
- ['id', 'agent_ids']):
287
- ag = (p.get('agent_ids') or [])
288
- if ag:
289
- agent_of[p['id']] = ag[0] # the Customers-module convention: agent_ids[0]
290
- theirs = {}
291
- for r in grp:
292
- if not r.get('partner_id'):
293
- continue
294
- a = agent_of.get(r['partner_id'][0])
295
- if a is not None:
296
- theirs[a] = theirs.get(a, 0.0) + r['amount_untaxed']
297
-
298
- ours_tot = round(sum(c.get('revenue', 0) for c in ours.values()), 2)
299
- theirs_tot = round(sum(theirs.values()), 2)
300
- # ⚠ ORDER-HEADER vs LINE-SUM is a REAL basis difference (an order's untaxed total includes
301
- # lines this topic's service filter drops), so the tolerance is a stated 3% rather than a
302
- # cent β€” and the FIGURE is reported so a drift is readable instead of absorbed.
303
- gap = ours_tot - theirs_tot
304
- checks.append({
305
- 'check': 'per-agent revenue (BOOK route) vs an INDEPENDENT Odoo order-header aggregate '
306
- 'mapped through res.partner.agent_ids',
307
- 'a': ours_tot, 'b': theirs_tot, 'gap': round(gap, 2),
308
- 'ok': bool(theirs_tot) and abs(gap) <= 0.03 * theirs_tot,
309
- 'detail': {'window': [DF, DT], 'agents_ours': len(ours), 'agents_theirs': len(theirs),
310
- 'gap_pct': round(gap / theirs_tot * 100, 3) if theirs_tot else None,
311
- 'route': 'customer-master book (res.partner.agent_ids), NOT the OCA '
312
- 'commission table; the two name different people'}})
313
- # β›” AND PER AGENT, because a total can agree while every row is keyed wrong β€” the join-key
314
- # trap contract C1 names. Here the key is the `res.partner` id at both ends.
315
- off_by = sorted(((abs(ours.get(a, {}).get('revenue', 0.0) - v), a)
316
- for a, v in theirs.items() if v), reverse=True)
317
- bad = [(a, round(ours.get(a, {}).get('revenue', 0.0), 2), round(theirs[a], 2))
318
- for d, a in off_by if d > 0.03 * theirs[a]]
319
- checks.append({
320
- 'check': 'each agent\'s own figure ties (the C1 join-key test: a wrong key agrees in '
321
- 'total and disagrees on every row)',
322
- 'a': len(bad), 'b': 0, 'ok': not bad,
323
- 'detail': {'worst': [(a, ours_v, th_v) for a, ours_v, th_v in bad[:5]],
324
- 'agents_compared': len(theirs)}})
325
- return checks
326
-
327
-
328
- # ------------------------------------------------------------ INVOICE-LINE ATTRIBUTION (2026-07-28)
329
- # A SECOND agent source. Everything above this line attributes by BOOK β€” the customer's assigned
330
- # agent (res.partner.agent_ids) β€” over confirmed SALES ORDERS. This section attributes per INVOICE
331
- # LINE, from the OCA sale-commission module, via the semantic layer (topics invoice_lines /
332
- # commission_lines). The two disagree on purpose and answer different questions:
333
- #
334
- # book -> "whose customer is this / who owns the relationship" (order basis)
335
- # invoice -> "what was actually credited to an agent on the billing" + the ONLY source that can
336
- # say what is NOT allocated to an agent (invoice basis)
337
- #
338
- # ⚠ A NAME ON A COMMISSION LINE IS NOT NECESSARILY AN AGENT β€” `res.partner.agent` is the flag.
339
- # "Anna" and "Shantal Erlich" are internal SALESPEOPLE who carry commission lines; the `agent`
340
- # dim excludes them and `include_salespeople` folds them back in as a clearly-labelled variant.
341
- # See [[invoice-line-agent-commission]].
342
-
343
- _ALLOC_LABEL = {'agent': 'Allocated to an agent', 'salesperson': 'Salesperson only',
344
- 'none': 'Not allocated'}
345
-
346
-
347
- def invoice_line_rollup(t=None, team_id=None, include_salespeople=False):
348
- """Per-name invoice-line revenue + the MECE allocation split, for a YTD window.
349
-
350
- Returns {'by_agent': [...], 'allocation': [...], 'total': float, 'allocated': float,
351
- 'unallocated': float, 'basis': str} β€” or {'error': msg} when the tenant store is not
352
- ready (this path is store-only; there is no live fallback that stays honest about the
353
- unallocated bucket).
354
- """
355
- import harness.semantic as S
356
- t = t or P.today()
357
- yf, yt = P.ytd(t)
358
- dim = 'commission_name' if include_salespeople else 'agent'
359
- try:
360
- by = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=[dim],
361
- date_from=yf, date_to=yt, team_id=team_id, limit=200).get('rows') or []
362
- alloc = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=['allocation'],
363
- date_from=yf, date_to=yt, team_id=team_id, limit=10).get('rows') or []
364
- tot = (S.store_query('invoice_lines', ['invoiced_line_sales'], date_from=yf, date_to=yt,
365
- team_id=team_id).get('rows') or [{}])[0].get('invoiced_line_sales') or 0.0
366
- except Exception as e: # store not ready / model error β€” say so, don't fake
367
- return {'error': str(e)}
368
- # store_query row shape: the DIM KEY carries the display NAME and `<dim>_id` the raw value
369
- # (allocation -> 'Salesperson only', allocation_id -> 'salesperson'). Reading `<dim>_name`
370
- # returns None for every row and silently renders the whole table as "no agent".
371
- rows = [{'agent': (r.get(dim) or '(no agent on the line)'),
372
- 'revenue': r.get('invoiced_line_sales') or 0.0} for r in by]
373
- rows.sort(key=lambda r: -r['revenue'])
374
- amap = {r.get('allocation_id') or 'none': (r.get('invoiced_line_sales') or 0.0) for r in alloc}
375
- allocated = amap.get('agent', 0.0)
376
- return {
377
- 'by_agent': rows,
378
- 'allocation': [{'bucket': _ALLOC_LABEL[k], 'revenue': amap.get(k, 0.0)}
379
- for k in ('agent', 'salesperson', 'none') if k in amap or True],
380
- 'total': tot, 'allocated': allocated, 'unallocated': tot - allocated,
381
- 'strict_none': amap.get('none', 0.0), 'salesperson_only': amap.get('salesperson', 0.0),
382
- 'window': (yf, yt),
383
- 'basis': ('invoice line Β· commission names incl. salespeople' if include_salespeople
384
- else 'invoice line Β· real agents only'),
385
- }
 
1
+ """Agent module β€” per-agent (res.partner.agent_ids) analytics.
2
+
3
+ An *agent* owns a **book** of customers (the same attribute the Customers module slices by). This
4
+ module reports that book the way Sales/Customers/SKU report the whole company: a period scorecard
5
+ with custom date windows (Today / WTD / Last week / MTD / QTD / YTD / any custom range), a sales
6
+ trend, returns, top SKUs (with profit/order) and the FULL customer list β€” INCLUDING inactive
7
+ accounts (no recent orders) so a rep sees who they've stopped selling to.
8
+
9
+ Scope: reuses the Sales `order_domain` (Fisch+Royal, excluded accounts removed, state sale/done)
10
+ so numbers tie to every other module. Returns are consolidated (credit notes aren't BU-tagged);
11
+ everything else is BU-filterable via team_id.
12
+ """
13
+ import sys
14
+ import datetime as dt
15
+ from pathlib import Path
16
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17
+ import core.odoo as O
18
+ import core.periods as P
19
+ import modules.sales as sales_mod
20
+ import modules.customers as cust_mod
21
+
22
+
23
+ def options(t=None, team_id=None):
24
+ """Agent names with book activity β€” for the page/drawer picker."""
25
+ return cust_mod.agent_options(t, team_id)
26
+
27
+
28
+ def _book(name):
29
+ """frozenset of every partner id assigned to the agent (incl. inactive). None only for 'All'."""
30
+ return cust_mod.agent_partner_ids(name)
31
+
32
+
33
+ def _rev(date_from, date_to, team_id, book):
34
+ return O.sum_field('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
35
+ 'amount_untaxed')
36
+
37
+
38
+ def _orders(date_from, date_to, team_id, book):
39
+ return O.get_odoo().search_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book))
40
+
41
+
42
+ def _custs(date_from, date_to, team_id, book):
43
+ return O.distinct_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
44
+ 'partner_id')
45
+
46
+
47
+ # ------------------------------------------------------------ period scorecard (custom dating)
48
+ # Today / WTD / Last week / MTD / QTD / YTD β€” each carries its window so the UI can decompose it
49
+ # and so "what did this agent sell this/last week" is a click, not a date-math exercise.
50
+ _PERIODS = [('Today', 'today'), ('Week to date', 'wtd'), ('Last week', 'lwk'),
51
+ ('Month to date', 'mtd'), ('Quarter to date', 'qtd'), ('Year to date', 'ytd')]
52
+
53
+
54
+ def _window(key, t):
55
+ if key == 'today':
56
+ return P._d(t), P._d(t)
57
+ if key == 'lwk': # the full prior Mon–Sun week
58
+ f, _tt = P.wtd(t)
59
+ start = dt.date.fromisoformat(f) - dt.timedelta(days=7)
60
+ return start.isoformat(), (dt.date.fromisoformat(f) - dt.timedelta(days=1)).isoformat()
61
+ return {'wtd': P.wtd, 'mtd': P.mtd, 'qtd': P.qtd, 'ytd': P.ytd}[key](t)
62
+
63
+
64
+ def scorecard(name, t=None, team_id=None):
65
+ """Book revenue (+ YoY same-period), orders for Today / WTD / Last week / MTD / QTD / YTD."""
66
+ t = t or P.today()
67
+ book = _book(name)
68
+ out = []
69
+ for label, key in _PERIODS:
70
+ f, tt = _window(key, t)
71
+ wk = key in ('today', 'wtd', 'lwk') # weekday-align the short windows' LY compare
72
+ cf, ct = P.shift_year(f, tt, weeks=wk)
73
+ rev, rev_ly = _rev(f, tt, team_id, book), _rev(cf, ct, team_id, book)
74
+ out.append({'key': key, 'label': label, 'date_from': f, 'date_to': tt, 'cmp_from': cf, 'cmp_to': ct,
75
+ 'revenue': rev, 'revenue_ly': rev_ly, 'yoy_pct': P.yoy_pct(rev, rev_ly),
76
+ 'orders': _orders(f, tt, team_id, book)})
77
+ return out
78
+
79
+
80
+ def headline(name, date_from, date_to, team_id=None):
81
+ """Book KPIs for an ARBITRARY window (custom dating): revenue + YoY (same window LY), orders,
82
+ active customers, AOV and returns $ / return rate."""
83
+ book = _book(name)
84
+ cf, ct = P.shift_year(date_from, date_to, weeks=False)
85
+ rev, rev_ly = _rev(date_from, date_to, team_id, book), _rev(cf, ct, team_id, book)
86
+ orders = _orders(date_from, date_to, team_id, book)
87
+ ret = sales_mod._returns_amt(date_from, date_to, book)
88
+ return {'date_from': date_from, 'date_to': date_to, 'cmp_from': cf, 'cmp_to': ct,
89
+ 'revenue': rev, 'revenue_ly': rev_ly,
90
+ 'yoy_pct': P.yoy_pct(rev, rev_ly), 'orders': orders,
91
+ 'customers': _custs(date_from, date_to, team_id, book), 'aov': (rev / orders) if orders else 0.0,
92
+ 'returns': ret, 'return_rate_pct': (ret / rev * 100.0) if rev else 0.0}
93
+
94
+
95
+ # ------------------------------------------------------------ sales trend
96
+ def _book_monthly_rev(book, date_from, date_to, team_id=None):
97
+ g = O.read_group('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
98
+ ['amount_untaxed:sum'], ['date_order:month'], lazy=False)
99
+ out = {}
100
+ for r in g:
101
+ ym = ((r.get('__range') or {}).get('date_order:month') or {}).get('from', '')[:7]
102
+ if ym:
103
+ out[ym] = r.get('amount_untaxed') or 0.0
104
+ return out
105
+
106
+
107
+ def monthly(name, n=13, t=None, team_id=None):
108
+ """Book sales per month vs the same month last year (one month-grouped query over 2 years)."""
109
+ t = t or P.today()
110
+ book = _book(name)
111
+ mrev = _book_monthly_rev(book, dt.date(t.year - 2, t.month, 1).isoformat(), t.isoformat(), team_id)
112
+ rows = []
113
+ for ym, _s, _e in P.month_starts(n, t):
114
+ y, m = int(ym[:4]) - 1, int(ym[5:7])
115
+ this, last = mrev.get(ym, 0.0), mrev.get(f'{y:04d}-{m:02d}', 0.0)
116
+ rows.append({'month': ym, 'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)})
117
+ return rows
118
+
119
+
120
+ # ------------------------------------------------------------ full customer book (incl. inactive)
121
+ def customers(name, t=None, team_id=None):
122
+ """EVERY customer in the agent's book, including inactive accounts (no YTD/LY orders) β€” those
123
+ show $0 with status 'Inactive'/'Dormant'. Each row is clickable to the customer drawer and
124
+ carries recency so a rep can see who's gone quiet. Sorted by YTD revenue desc (inactive last)."""
125
+ t = t or P.today()
126
+ book = _book(name)
127
+ yf, yt = P.ytd(t)
128
+ lf, lt = P.ytd_last_year(t)
129
+ this = cust_mod._cust_rev(yf, yt, team_id, book)
130
+ last = cust_mod._cust_rev(lf, lt, team_id, book)
131
+ lastord = cust_mod._last_order_dates(None, None, team_id, book) # all-time last order = recency
132
+ ids = list(book) if book is not None else list(set(this) | set(last))
133
+ attrs = cust_mod._partner_attrs(set(ids))
134
+ namemap = {r['id']: r.get('name') for r in O.search_read('res.partner', [('id', 'in', ids)], ['name'])}
135
+ rows = []
136
+ for p in ids:
137
+ tr = this.get(p, {}).get('rev', 0.0)
138
+ lr = last.get(p, {}).get('rev', 0.0)
139
+ a = attrs.get(p, {})
140
+ lo = lastord.get(p, '')
141
+ recency = (t - dt.date.fromisoformat(lo)).days if lo else None
142
+ status = 'Active' if tr > 0 else ('Dormant' if (lr > 0 or lo) else 'Inactive')
143
+ rows.append({'pid': p, 'customer': namemap.get(p) or (this.get(p) or last.get(p) or {}).get('name', '?'),
144
+ 'rev_ytd': tr, 'rev_ly': lr, 'change': tr - lr, 'yoy_pct': P.yoy_pct(tr, lr),
145
+ 'orders': this.get(p, {}).get('orders', 0), 'last_order': lo, 'recency_days': recency,
146
+ 'status': status, 'city': a.get('city', '(none)'), 'state': a.get('state', '(none)'),
147
+ 'agent': a.get('agent', name)})
148
+ rows.sort(key=lambda r: (r['rev_ytd'] <= 0, -r['rev_ytd'], -(r['rev_ly'])))
149
+ return rows
150
+
151
+
152
+ # ------------------------------------------------------------ top SKUs (with profit/order)
153
+ def top_skus(name, t=None, team_id=None, top=30):
154
+ """The book's top SKUs YTD (line-level), each with revenue, units, margin and profit/order."""
155
+ t = t or P.today()
156
+ book = _book(name)
157
+ if book is not None and not book:
158
+ return []
159
+ yf, yt = P.ytd(t)
160
+ lex = [('order_partner_id', 'in', list(book))] if book is not None else None
161
+ return sales_mod.decompose(yf, yt, team_id, line_extra=lex, top=top)['skus']
162
+
163
+
164
+ # ------------------------------------------------------------ returns (book-scoped, consolidated)
165
+ def returns_trend(name, n=13, t=None):
166
+ return sales_mod.returns_monthly(n, t, partner_ids=_book(name))
167
+
168
+
169
+ def returns_headline(name, t=None):
170
+ return sales_mod.returns_headline(t, partner_ids=_book(name))
171
+
172
+
173
+ # ------------------------------------------------------------ all-agents rollup (the page table)
174
+ def _returns_by_agent(t=None):
175
+ """{agent_name: returns$ YTD} β€” credit notes mapped to each customer's agent."""
176
+ by_p = sales_mod.returns_by_partner(t)
177
+ attrs = cust_mod._partner_attrs(list(by_p))
178
+ agg = {}
179
+ for pid, amt in by_p.items():
180
+ a = (attrs.get(pid) or {}).get('agent') or '(none)'
181
+ agg[a] = agg.get(a, 0.0) + amt
182
+ return agg
183
+
184
+
185
+ def rollup(t=None, team_id=None):
186
+ """Every agent ranked by YTD book revenue (+ YoY, customers, orders) with returns $ and return
187
+ rate. Reuses the Customers MECE agent rollup, so Ξ£(agents) == total YTD revenue."""
188
+ rows = cust_mod.by_dimension('agent', t, team_id=team_id)
189
+ ret = _returns_by_agent(t)
190
+ for r in rows:
191
+ r['agent'] = r['group']
192
+ r['returns'] = ret.get(r['group'], 0.0)
193
+ r['return_rate_pct'] = (r['returns'] / r['revenue'] * 100.0) if r.get('revenue') else 0.0
194
+ return rows
195
+
196
+
197
+ # ------------------------------------------------------------ VALIDATION
198
+ def validate(t=None, team_id=None):
199
+ """Reconcile the agent rollup to Odoo. (1) Ξ£(agent book revenue) == total YTD revenue β€” the
200
+ rollup is MECE over customers. (2) A sampled agent's scorecard YTD == its headline YTD."""
201
+ t = t or P.today()
202
+ yf, yt = P.ytd(t)
203
+ checks = []
204
+ rows = rollup(t, team_id=team_id)
205
+ agent_sum = sum(r['revenue'] for r in rows)
206
+ total = O.sum_field('sale.order', sales_mod.order_domain(yf, yt, team_id), 'amount_untaxed')
207
+ checks.append({'check': 'YTD revenue: Ξ£(agent book) == total', 'a': round(agent_sum, 2),
208
+ 'b': round(total, 2), 'gap': round(agent_sum - total, 2),
209
+ 'ok': abs(agent_sum - total) <= max(1.0, 0.001 * (total or 1))})
210
+ # sampled agent: scorecard YTD == headline YTD for the same window
211
+ sample = next((r['agent'] for r in rows if r['agent'] not in ('(none)',)), None)
212
+ if sample:
213
+ sc_ytd = next((s['revenue'] for s in scorecard(sample, t, team_id) if s['key'] == 'ytd'), 0.0)
214
+ hl = headline(sample, yf, yt, team_id)['revenue']
215
+ checks.append({'check': f'Agent "{sample}": scorecard YTD == headline YTD', 'a': round(sc_ytd, 2),
216
+ 'b': round(hl, 2), 'gap': round(sc_ytd - hl, 2), 'ok': abs(sc_ytd - hl) <= 1.0})
217
+ checks.extend(validate_measures(t=t, team_id=team_id))
218
+ return checks
219
+
220
+
221
+ def validate_measures(t=None, team_id=None, days=90):
222
+ """⭐⭐ W37-T12 β€” the minted PER-AGENT lookback columns, against a DIRECT Odoo aggregate.
223
+
224
+ β›” WHICH AGENT SOURCE, AND THE TICKET REQUIRES IT SAID OUT LOUD: this reconciles ROUTE 1, the
225
+ CUSTOMER-MASTER BOOK (`res.partner.agent_ids` -> the mirror's `res_partner.agent_id`, which is
226
+ `sales_lines`' `agent` dim). It is NOT the OCA commission route below β€” measured 2026-08-19,
227
+ 9 agents carry route-1 revenue against 12 on commission lines and 17 carrying the flag, so the
228
+ two produce materially different rankings and a check that mixed them would be comparing two
229
+ different questions and calling the gap an error.
230
+
231
+ ⚠ THE ORACLE IS THE ORDER HEADER, not the mirror the columns are served from. `sale.order`
232
+ grouped by `partner_id`, mapped to each customer's agent CLIENT-SIDE β€” a different model, a
233
+ different grain and a different code path from `store_query`'s line-level sum, so agreement
234
+ between them is evidence rather than tautology.
235
+ ⚠ Windowed to the MIRROR'S newest order, like `product_data.validate_measures`, so the
236
+ residual is about EDITS to a shared period and not about orders the mirror has never seen.
237
+ """
238
+ from harness import datastore as DS
239
+ from harness import semantic as sem
240
+
241
+ t = t or P.today()
242
+ checks = []
243
+ try:
244
+ if not DS.ready():
245
+ return [{'check': 'agent lookback measures reconcile to Odoo', 'a': 'no mirror',
246
+ 'b': '-', 'ok': False,
247
+ 'detail': 'the tenant store is not readable, so this is UNPROVEN, which standing rule '
248
+ '8 does not accept as green'}]
249
+ except Exception as e: # noqa: BLE001
250
+ return [{'check': 'agent lookback measures reconcile to Odoo', 'a': type(e).__name__,
251
+ 'b': '-', 'ok': False, 'detail': str(e)[:200]}]
252
+
253
+ offer = sem.entity_measures('odoo_agents')
254
+ checks.append({'check': 'the agent measure OFFER is non-empty and every key resolves '
255
+ '(owner item 4 / R1)',
256
+ 'a': len(offer), 'b': '>0', 'ok': bool(offer),
257
+ 'detail': {'keys': [m['key'] for m in offer],
258
+ 'refused': sem.entity_measure_refusals('odoo_agents')}})
259
+ if not offer:
260
+ return checks
261
+
262
+ con = DS.ro_cursor()
263
+ try:
264
+ newest = con.execute('SELECT max(date_order) FROM sale_order').fetchone()
265
+ finally:
266
+ con.close()
267
+ d_to = t - dt.timedelta(days=2)
268
+ if newest and newest[0]:
269
+ try:
270
+ d_to = min(d_to, dt.date.fromisoformat(str(newest[0])[:10]) - dt.timedelta(days=1))
271
+ except ValueError:
272
+ pass
273
+ d_from = d_to - dt.timedelta(days=days)
274
+ DF, DT = d_from.isoformat(), d_to.isoformat()
275
+
276
+ ours = sem.entity_measure_values('odoo_agents', ['revenue'], date_from=DF, date_to=DT,
277
+ team_id=team_id, offer=offer)
278
+ # THE ORACLE β€” order headers, grouped by customer, mapped to that customer's agent here.
279
+ o = O.get_odoo()
280
+ grp = o.read_group('sale.order', sales_mod.order_domain(DF, DT, team_id),
281
+ ['partner_id', 'amount_untaxed:sum'], ['partner_id'], lazy=False)
282
+ pids = sorted({r['partner_id'][0] for r in grp if r.get('partner_id')})
283
+ agent_of = {}
284
+ for i in range(0, len(pids), 500):
285
+ for p in o.search_read('res.partner', [('id', 'in', pids[i:i + 500])],
286
+ ['id', 'agent_ids']):
287
+ ag = (p.get('agent_ids') or [])
288
+ if ag:
289
+ agent_of[p['id']] = ag[0] # the Customers-module convention: agent_ids[0]
290
+ theirs = {}
291
+ for r in grp:
292
+ if not r.get('partner_id'):
293
+ continue
294
+ a = agent_of.get(r['partner_id'][0])
295
+ if a is not None:
296
+ theirs[a] = theirs.get(a, 0.0) + r['amount_untaxed']
297
+
298
+ ours_tot = round(sum(c.get('revenue', 0) for c in ours.values()), 2)
299
+ theirs_tot = round(sum(theirs.values()), 2)
300
+ # ⚠ ORDER-HEADER vs LINE-SUM is a REAL basis difference (an order's untaxed total includes
301
+ # lines this topic's service filter drops), so the tolerance is a stated 3% rather than a
302
+ # cent β€” and the FIGURE is reported so a drift is readable instead of absorbed.
303
+ gap = ours_tot - theirs_tot
304
+ checks.append({
305
+ 'check': 'per-agent revenue (BOOK route) vs an INDEPENDENT Odoo order-header aggregate '
306
+ 'mapped through res.partner.agent_ids',
307
+ 'a': ours_tot, 'b': theirs_tot, 'gap': round(gap, 2),
308
+ 'ok': bool(theirs_tot) and abs(gap) <= 0.03 * theirs_tot,
309
+ 'detail': {'window': [DF, DT], 'agents_ours': len(ours), 'agents_theirs': len(theirs),
310
+ 'gap_pct': round(gap / theirs_tot * 100, 3) if theirs_tot else None,
311
+ 'route': 'customer-master book (res.partner.agent_ids), NOT the OCA '
312
+ 'commission table; the two name different people'}})
313
+ # β›” AND PER AGENT, because a total can agree while every row is keyed wrong β€” the join-key
314
+ # trap contract C1 names. Here the key is the `res.partner` id at both ends.
315
+ off_by = sorted(((abs(ours.get(a, {}).get('revenue', 0.0) - v), a)
316
+ for a, v in theirs.items() if v), reverse=True)
317
+ bad = [(a, round(ours.get(a, {}).get('revenue', 0.0), 2), round(theirs[a], 2))
318
+ for d, a in off_by if d > 0.03 * theirs[a]]
319
+ checks.append({
320
+ 'check': 'each agent\'s own figure ties (the C1 join-key test: a wrong key agrees in '
321
+ 'total and disagrees on every row)',
322
+ 'a': len(bad), 'b': 0, 'ok': not bad,
323
+ 'detail': {'worst': [(a, ours_v, th_v) for a, ours_v, th_v in bad[:5]],
324
+ 'agents_compared': len(theirs)}})
325
+ return checks
326
+
327
+
328
+ # ------------------------------------------------------------ INVOICE-LINE ATTRIBUTION (2026-07-28)
329
+ # A SECOND agent source. Everything above this line attributes by BOOK β€” the customer's assigned
330
+ # agent (res.partner.agent_ids) β€” over confirmed SALES ORDERS. This section attributes per INVOICE
331
+ # LINE, from the OCA sale-commission module, via the semantic layer (topics invoice_lines /
332
+ # commission_lines). The two disagree on purpose and answer different questions:
333
+ #
334
+ # book -> "whose customer is this / who owns the relationship" (order basis)
335
+ # invoice -> "what was actually credited to an agent on the billing" + the ONLY source that can
336
+ # say what is NOT allocated to an agent (invoice basis)
337
+ #
338
+ # ⚠ A NAME ON A COMMISSION LINE IS NOT NECESSARILY AN AGENT β€” `res.partner.agent` is the flag.
339
+ # "Anna" and "Shantal Erlich" are internal SALESPEOPLE who carry commission lines; the `agent`
340
+ # dim excludes them and `include_salespeople` folds them back in as a clearly-labelled variant.
341
+ # See [[invoice-line-agent-commission]].
342
+
343
+ _ALLOC_LABEL = {'agent': 'Allocated to an agent', 'salesperson': 'Salesperson only',
344
+ 'none': 'Not allocated'}
345
+
346
+
347
+ def invoice_line_rollup(t=None, team_id=None, include_salespeople=False):
348
+ """Per-name invoice-line revenue + the MECE allocation split, for a YTD window.
349
+
350
+ Returns {'by_agent': [...], 'allocation': [...], 'total': float, 'allocated': float,
351
+ 'unallocated': float, 'basis': str} β€” or {'error': msg} when the tenant store is not
352
+ ready (this path is store-only; there is no live fallback that stays honest about the
353
+ unallocated bucket).
354
+ """
355
+ import harness.semantic as S
356
+ t = t or P.today()
357
+ yf, yt = P.ytd(t)
358
+ dim = 'commission_name' if include_salespeople else 'agent'
359
+ try:
360
+ by = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=[dim],
361
+ date_from=yf, date_to=yt, team_id=team_id, limit=200).get('rows') or []
362
+ alloc = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=['allocation'],
363
+ date_from=yf, date_to=yt, team_id=team_id, limit=10).get('rows') or []
364
+ tot = (S.store_query('invoice_lines', ['invoiced_line_sales'], date_from=yf, date_to=yt,
365
+ team_id=team_id).get('rows') or [{}])[0].get('invoiced_line_sales') or 0.0
366
+ except Exception as e: # store not ready / model error β€” say so, don't fake
367
+ return {'error': str(e)}
368
+ # store_query row shape: the DIM KEY carries the display NAME and `<dim>_id` the raw value
369
+ # (allocation -> 'Salesperson only', allocation_id -> 'salesperson'). Reading `<dim>_name`
370
+ # returns None for every row and silently renders the whole table as "no agent".
371
+ rows = [{'agent': (r.get(dim) or '(no agent on the line)'),
372
+ 'revenue': r.get('invoiced_line_sales') or 0.0} for r in by]
373
+ rows.sort(key=lambda r: -r['revenue'])
374
+ amap = {r.get('allocation_id') or 'none': (r.get('invoiced_line_sales') or 0.0) for r in alloc}
375
+ allocated = amap.get('agent', 0.0)
376
+ return {
377
+ 'by_agent': rows,
378
+ 'allocation': [{'bucket': _ALLOC_LABEL[k], 'revenue': amap.get(k, 0.0)}
379
+ for k in ('agent', 'salesperson', 'none') if k in amap or True],
380
+ 'total': tot, 'allocated': allocated, 'unallocated': tot - allocated,
381
+ 'strict_none': amap.get('none', 0.0), 'salesperson_only': amap.get('salesperson', 0.0),
382
+ 'window': (yf, yt),
383
+ 'basis': ('invoice line Β· commission names incl. salespeople' if include_salespeople
384
+ else 'invoice line Β· real agents only'),
385
+ }
platform/modules/collections_send.py CHANGED
@@ -1,267 +1,267 @@
1
- """Collections statements β€” the send layer behind the Collections page's Statements section.
2
-
3
- Folded in from the standalone collections_app (2026-07-05). THE one sanctioned exception to the
4
- app's read-only-on-Odoo rule, unchanged from the standalone tool: WRITE is whitelisted to exactly
5
- one operation β€”
6
-
7
- create on mail.mail (queueing an outbound statement email)
8
-
9
- A mail.mail record with state='outgoing' is picked up by Odoo's "Mail: Email Queue Manager" cron
10
- (every ~15 min) and delivered through the company's Office 365 relay. Because we set model/res_id,
11
- each sent statement also appears in the customer's chatter in Odoo β€” the audit log lives where AR
12
- already works. This module has its OWN narrow XML-RPC client; the app-wide odoo_client stays
13
- hard-blocking on all writes. The UI gates the section to admin users; SAFE_MODE is enforced HERE
14
- in the data layer so the UI cannot bypass it.
15
-
16
- Env (Space secrets / .env): ODOO_URL, ODOO_DB, ODOO_USER, ODOO_API_KEY
17
- Optional: SAFE_MODE (default ON), SAFE_RECIPIENTS, SENDER_NAME, SENDER_EMAIL, REPLY_TO,
18
- COMPANY_NAME, ROYAL_MAIL_SERVER_ID, ROYAL_AUTHOR_ID
19
- """
20
- import os
21
- import datetime as dt
22
- import xmlrpc.client
23
- from pathlib import Path
24
-
25
- try: # self-contained: load credentials from the app root .env if present (HF uses Secrets)
26
- from dotenv import load_dotenv
27
- load_dotenv(Path(__file__).resolve().parents[1] / '.env')
28
- except Exception:
29
- pass
30
-
31
- WRITE_WHITELIST = {('mail.mail', 'create')}
32
-
33
- EXCLUDE_NAMES = {'GIFTWARE DEALS'} # the Amazon channel β€” not part of Fisch or Royal collections
34
- DOMAIN = [('followup_reminder_type', '=', 'automatic'), ('credit', '>', 1)]
35
-
36
- COMPANY = os.environ.get('COMPANY_NAME', 'Royal Imports')
37
-
38
- # --- Sender identity (statements go out AS Royal Imports) ---
39
- # Odoo routes outbound mail to the matching ir.mail_server by from_filter, and the Office 365
40
- # relay only accepts sends as its authenticated address. The "Office 365 - Royal" server (id 2)
41
- # authenticates as contact@royalimports.com with from_filter='contact@royalimports.com' β€” so the
42
- # From MUST be that address for delivery to succeed. Friendly display name; replies routed to AR.
43
- SENDER_NAME = os.environ.get('SENDER_NAME', 'Royal Imports Accounts Receivable')
44
- SENDER_EMAIL = os.environ.get('SENDER_EMAIL', 'contact@royalimports.com')
45
- REPLY_TO = os.environ.get('REPLY_TO', 'accounting@royalimports.com')
46
- ROYAL_MAIL_SERVER_ID = int(os.environ.get('ROYAL_MAIL_SERVER_ID', '2'))
47
- ROYAL_AUTHOR_ID = int(os.environ.get('ROYAL_AUTHOR_ID', '8978')) # "Royal Imports" partner
48
-
49
- SENDER_DISPLAY = f'"{SENDER_NAME}" <{SENDER_EMAIL}>'
50
-
51
-
52
- class WriteBlocked(RuntimeError):
53
- pass
54
-
55
-
56
- class SafeModeBlocked(RuntimeError):
57
- pass
58
-
59
-
60
- # --- Testing guardrail -------------------------------------------------------
61
- # While SAFE_MODE is on, NO email can be queued to any address outside the allow-list β€” enforced
62
- # here in the data layer so the UI cannot bypass it. Default: ON. To go live for real customers,
63
- # set the Space secret SAFE_MODE=0.
64
- SAFE_MODE = os.environ.get('SAFE_MODE', '1').strip().lower() not in ('0', 'false', 'no', '')
65
- SAFE_RECIPIENTS = {a.strip().lower() for a in
66
- os.environ.get('SAFE_RECIPIENTS', 'farhan@teamroyalimports.com').split(',')
67
- if a.strip()}
68
-
69
-
70
- def safe_recipient_ok(addr):
71
- return (not SAFE_MODE) or (str(addr or '').strip().lower() in SAFE_RECIPIENTS)
72
-
73
-
74
- class Odoo:
75
- """Narrow client: read anything, write ONLY the whitelisted mail.mail create."""
76
-
77
- def __init__(self):
78
- self.url = os.environ.get('ODOO_URL', '').rstrip('/')
79
- self.db = os.environ.get('ODOO_DB', '')
80
- self.user = os.environ.get('ODOO_USER', '')
81
- self.key = os.environ.get('ODOO_API_KEY', '')
82
- missing = [k for k, v in [('ODOO_URL', self.url), ('ODOO_DB', self.db),
83
- ('ODOO_USER', self.user), ('ODOO_API_KEY', self.key)] if not v]
84
- if missing:
85
- raise RuntimeError(f"Missing secrets: {', '.join(missing)}")
86
- common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common')
87
- self.uid = common.authenticate(self.db, self.user, self.key, {})
88
- if not self.uid:
89
- raise RuntimeError('Odoo authentication failed')
90
- self.models = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/object')
91
-
92
- def _exec(self, model, method, args, kwargs=None):
93
- mutating = method in ('write', 'create', 'unlink', 'copy') or \
94
- any(method.startswith(p) for p in ('action_', 'button_', 'do_', 'send_',
95
- 'set_', 'update_', 'process_'))
96
- if mutating and (model, method) not in WRITE_WHITELIST:
97
- raise WriteBlocked(f'{method} on {model} is not allowed from this app')
98
- return self.models.execute_kw(self.db, self.uid, self.key,
99
- model, method, args, kwargs or {})
100
-
101
- def search_read(self, model, domain=None, fields=None, limit=None, order=None):
102
- kw = {'fields': fields or []}
103
- if limit is not None:
104
- kw['limit'] = limit
105
- if order:
106
- kw['order'] = order
107
- return self._exec(model, 'search_read', [domain or []], kw)
108
-
109
- def queue_mail(self, payload):
110
- """The single allowed write: queue an outbound email."""
111
- return self._exec('mail.mail', 'create', [payload])
112
-
113
-
114
- # --------------------------------------------------------------- data builders
115
- def load_collection_list(odoo):
116
- """The saved follow-up filter (Reminders=Automatic, Receivable>$1), GIFTWARE excluded,
117
- with priority tiers."""
118
- partners = odoo.search_read('res.partner', DOMAIN,
119
- ['name', 'credit', 'total_overdue', 'followup_status',
120
- 'followup_next_action_date', 'followup_responsible_id',
121
- 'email', 'phone', 'mobile'])
122
- partners = [p for p in partners if (p['name'] or '').strip().upper() not in EXCLUDE_NAMES]
123
- pids = [p['id'] for p in partners]
124
-
125
- docs = odoo.search_read('account.move',
126
- [('move_type', 'in', ['out_invoice', 'out_refund']), ('state', '=', 'posted'),
127
- ('payment_state', 'in', ['not_paid', 'partial']), ('partner_id', 'in', pids)],
128
- ['name', 'partner_id', 'move_type', 'invoice_date', 'invoice_date_due',
129
- 'amount_total', 'amount_residual_signed'])
130
-
131
- today = dt.date.today()
132
- by_partner = {}
133
- for d in docs:
134
- pid = d['partner_id'][0]
135
- due = d.get('invoice_date_due')
136
- try:
137
- days = (today - dt.date.fromisoformat(due)).days if due else 0
138
- except Exception:
139
- days = 0
140
- d['days_overdue'] = max(days, 0)
141
- d['open_signed'] = d['amount_residual_signed']
142
- by_partner.setdefault(pid, []).append(d)
143
-
144
- rows = []
145
- for p in partners:
146
- pid = p['id']
147
- odoo_overdue = float(p.get('total_overdue') or 0)
148
- docs_p = sorted(by_partner.get(pid, []), key=lambda x: x.get('invoice_date_due') or '')
149
- inv_overdue = sum(d['open_signed'] for d in docs_p if d['days_overdue'] > 0)
150
- oldest = max((d['days_overdue'] for d in docs_p), default=0)
151
- gap = inv_overdue - odoo_overdue
152
- reconcile = abs(gap) > 50
153
-
154
- if odoo_overdue >= 5000 or (odoo_overdue > 0 and oldest > 90):
155
- tier = 'A-Urgent'
156
- elif odoo_overdue >= 1000 or (odoo_overdue > 0 and oldest > 30):
157
- tier = 'B-Active'
158
- elif odoo_overdue > 0:
159
- tier = 'C-Light'
160
- else:
161
- tier = 'Monitor'
162
-
163
- rows.append({
164
- 'partner_id': pid,
165
- 'Customer': p['name'],
166
- 'Tier': tier,
167
- 'Overdue': odoo_overdue,
168
- 'Receivable': float(p.get('credit') or 0),
169
- 'Oldest (days)': oldest,
170
- 'Open Docs': len(docs_p),
171
- 'Email': p.get('email') or '',
172
- 'Phone': p.get('phone') or p.get('mobile') or '',
173
- 'Status': (p.get('followup_status') or '').replace('_', ' '),
174
- 'Reconcile?': 'YES' if reconcile else '',
175
- '_docs': docs_p,
176
- })
177
- tier_rank = {'A-Urgent': 0, 'B-Active': 1, 'C-Light': 2, 'Monitor': 3}
178
- rows.sort(key=lambda r: (tier_rank[r['Tier']], -r['Overdue']))
179
- return rows
180
-
181
-
182
- # --------------------------------------------------------------- statement email
183
- # β›” STANDING RULE 2, AND THIS IS THE ONE STRING IN THE PRODUCT THAT LEAVES THE BUILDING.
184
- # Every other finding `web_prose` reports is copy on a screen somebody here opens; this is the
185
- # SUBJECT LINE of mail queued to a real debtor, over the single sanctioned Odoo write. It read
186
- # 'Statement of Account (em dash) {company} (em dash) {month}' until W36-T42.
187
- DEFAULT_SUBJECT = 'Statement of Account from {company}, {month}'
188
- DEFAULT_INTRO = (
189
- 'Dear {customer},<br><br>'
190
- 'Please find below your current statement of account with {company}. '
191
- 'According to our records, the following invoices remain open:'
192
- )
193
- DEFAULT_FOOTER = (
194
- 'If you have already sent payment, please disregard this notice, and thank you. '
195
- 'For any questions about an invoice, simply reply to this email.<br><br>'
196
- 'Thank you for your business,<br>{company}<br>Accounts Receivable'
197
- )
198
-
199
-
200
- def render_statement_html(row, intro_tpl=DEFAULT_INTRO, footer_tpl=DEFAULT_FOOTER):
201
- month = dt.date.today().strftime('%B %Y')
202
- intro = intro_tpl.format(customer=row['Customer'], company=COMPANY, month=month)
203
- footer = footer_tpl.format(customer=row['Customer'], company=COMPANY, month=month)
204
-
205
- lines = []
206
- total_open = 0.0
207
- for d in row['_docs']:
208
- kind = 'Credit Note' if d['move_type'] == 'out_refund' else 'Invoice'
209
- amt = d['open_signed']
210
- total_open += amt
211
- overdue_txt = f"{d['days_overdue']}d overdue" if d['days_overdue'] > 0 else 'current'
212
- color = '#C0392B' if d['days_overdue'] > 0 else '#1F4E78'
213
- lines.append(
214
- f"<tr><td style='padding:6px 10px;border-bottom:1px solid #e3e8ef'>{d['name']} <span style='color:#888'>({kind})</span></td>"
215
- f"<td style='padding:6px 10px;border-bottom:1px solid #e3e8ef'>{d.get('invoice_date') or ''}</td>"
216
- f"<td style='padding:6px 10px;border-bottom:1px solid #e3e8ef'>{d.get('invoice_date_due') or ''}</td>"
217
- f"<td style='padding:6px 10px;border-bottom:1px solid #e3e8ef;color:{color}'>{overdue_txt}</td>"
218
- f"<td style='padding:6px 10px;border-bottom:1px solid #e3e8ef;text-align:right'>${amt:,.2f}</td></tr>")
219
-
220
- table = (
221
- "<table style='border-collapse:collapse;font-size:14px;margin:14px 0'>"
222
- "<tr style='background:#1F4E78;color:#fff'>"
223
- "<th style='padding:7px 10px;text-align:left'>Document</th>"
224
- "<th style='padding:7px 10px;text-align:left'>Date</th>"
225
- "<th style='padding:7px 10px;text-align:left'>Due</th>"
226
- "<th style='padding:7px 10px;text-align:left'>Status</th>"
227
- "<th style='padding:7px 10px;text-align:right'>Open Balance</th></tr>"
228
- + ''.join(lines) +
229
- f"<tr><td colspan='4' style='padding:8px 10px;font-weight:bold;text-align:right'>Total open</td>"
230
- f"<td style='padding:8px 10px;font-weight:bold;text-align:right'>${total_open:,.2f}</td></tr>"
231
- f"<tr><td colspan='4' style='padding:2px 10px;font-weight:bold;text-align:right;color:#C0392B'>Of which overdue</td>"
232
- f"<td style='padding:2px 10px;font-weight:bold;text-align:right;color:#C0392B'>${row['Overdue']:,.2f}</td></tr>"
233
- "</table>")
234
-
235
- return (f"<div style='font-family:Calibri,Arial,sans-serif;color:#1a1a1a;font-size:14px'>"
236
- f"{intro}{table}{footer}</div>")
237
-
238
-
239
- def queue_statement(odoo, row, subject_tpl=DEFAULT_SUBJECT,
240
- intro_tpl=DEFAULT_INTRO, footer_tpl=DEFAULT_FOOTER,
241
- override_to=None):
242
- """Queue one statement email in Odoo. Returns mail.mail id.
243
- override_to: send to a different address (used by the test-send button)."""
244
- to = override_to or row['Email']
245
- if not to:
246
- raise ValueError(f"{row['Customer']} has no email address")
247
- # Hard guardrail β€” refuse any recipient outside the allow-list while SAFE_MODE is on.
248
- if not safe_recipient_ok(to):
249
- raise SafeModeBlocked(
250
- f"Guardrail ON: refusing to email {to}. Only {', '.join(sorted(SAFE_RECIPIENTS))} "
251
- f"is allowed right now. (Set SAFE_MODE=0 to send to real customers.)")
252
- month = dt.date.today().strftime('%B %Y')
253
- subject = subject_tpl.format(customer=row['Customer'], company=COMPANY, month=month)
254
- payload = {
255
- 'subject': subject,
256
- 'body_html': render_statement_html(row, intro_tpl, footer_tpl),
257
- 'email_to': to,
258
- 'email_from': SENDER_DISPLAY, # From: Royal Imports
259
- 'reply_to': REPLY_TO, # replies -> AR
260
- 'mail_server_id': ROYAL_MAIL_SERVER_ID, # force Royal O365 relay
261
- 'author_id': ROYAL_AUTHOR_ID, # clean attribution in chatter
262
- 'state': 'outgoing',
263
- 'auto_delete': False,
264
- 'model': 'res.partner',
265
- 'res_id': row['partner_id'],
266
- }
267
- return odoo.queue_mail(payload)
 
1
+ """Collections statements β€” the send layer behind the Collections page's Statements section.
2
+
3
+ Folded in from the standalone collections_app (2026-07-05). THE one sanctioned exception to the
4
+ app's read-only-on-Odoo rule, unchanged from the standalone tool: WRITE is whitelisted to exactly
5
+ one operation β€”
6
+
7
+ create on mail.mail (queueing an outbound statement email)
8
+
9
+ A mail.mail record with state='outgoing' is picked up by Odoo's "Mail: Email Queue Manager" cron
10
+ (every ~15 min) and delivered through the company's Office 365 relay. Because we set model/res_id,
11
+ each sent statement also appears in the customer's chatter in Odoo β€” the audit log lives where AR
12
+ already works. This module has its OWN narrow XML-RPC client; the app-wide odoo_client stays
13
+ hard-blocking on all writes. The UI gates the section to admin users; SAFE_MODE is enforced HERE
14
+ in the data layer so the UI cannot bypass it.
15
+
16
+ Env (Space secrets / .env): ODOO_URL, ODOO_DB, ODOO_USER, ODOO_API_KEY
17
+ Optional: SAFE_MODE (default ON), SAFE_RECIPIENTS, SENDER_NAME, SENDER_EMAIL, REPLY_TO,
18
+ COMPANY_NAME, ROYAL_MAIL_SERVER_ID, ROYAL_AUTHOR_ID
19
+ """
20
+ import os
21
+ import datetime as dt
22
+ import xmlrpc.client
23
+ from pathlib import Path
24
+
25
+ try: # self-contained: load credentials from the app root .env if present (HF uses Secrets)
26
+ from dotenv import load_dotenv
27
+ load_dotenv(Path(__file__).resolve().parents[1] / '.env')
28
+ except Exception:
29
+ pass
30
+
31
+ WRITE_WHITELIST = {('mail.mail', 'create')}
32
+
33
+ EXCLUDE_NAMES = {'GIFTWARE DEALS'} # the Amazon channel β€” not part of Fisch or Royal collections
34
+ DOMAIN = [('followup_reminder_type', '=', 'automatic'), ('credit', '>', 1)]
35
+
36
+ COMPANY = os.environ.get('COMPANY_NAME', 'Royal Imports')
37
+
38
+ # --- Sender identity (statements go out AS Royal Imports) ---
39
+ # Odoo routes outbound mail to the matching ir.mail_server by from_filter, and the Office 365
40
+ # relay only accepts sends as its authenticated address. The "Office 365 - Royal" server (id 2)
41
+ # authenticates as contact@royalimports.com with from_filter='contact@royalimports.com' β€” so the
42
+ # From MUST be that address for delivery to succeed. Friendly display name; replies routed to AR.
43
+ SENDER_NAME = os.environ.get('SENDER_NAME', 'Royal Imports Accounts Receivable')
44
+ SENDER_EMAIL = os.environ.get('SENDER_EMAIL', 'contact@royalimports.com')
45
+ REPLY_TO = os.environ.get('REPLY_TO', 'accounting@royalimports.com')
46
+ ROYAL_MAIL_SERVER_ID = int(os.environ.get('ROYAL_MAIL_SERVER_ID', '2'))
47
+ ROYAL_AUTHOR_ID = int(os.environ.get('ROYAL_AUTHOR_ID', '8978')) # "Royal Imports" partner
48
+
49
+ SENDER_DISPLAY = f'"{SENDER_NAME}" <{SENDER_EMAIL}>'
50
+
51
+
52
+ class WriteBlocked(RuntimeError):
53
+ pass
54
+
55
+
56
+ class SafeModeBlocked(RuntimeError):
57
+ pass
58
+
59
+
60
+ # --- Testing guardrail -------------------------------------------------------
61
+ # While SAFE_MODE is on, NO email can be queued to any address outside the allow-list β€” enforced
62
+ # here in the data layer so the UI cannot bypass it. Default: ON. To go live for real customers,
63
+ # set the Space secret SAFE_MODE=0.
64
+ SAFE_MODE = os.environ.get('SAFE_MODE', '1').strip().lower() not in ('0', 'false', 'no', '')
65
+ SAFE_RECIPIENTS = {a.strip().lower() for a in
66
+ os.environ.get('SAFE_RECIPIENTS', 'farhan@teamroyalimports.com').split(',')
67
+ if a.strip()}
68
+
69
+
70
+ def safe_recipient_ok(addr):
71
+ return (not SAFE_MODE) or (str(addr or '').strip().lower() in SAFE_RECIPIENTS)
72
+
73
+
74
+ class Odoo:
75
+ """Narrow client: read anything, write ONLY the whitelisted mail.mail create."""
76
+
77
+ def __init__(self):
78
+ self.url = os.environ.get('ODOO_URL', '').rstrip('/')
79
+ self.db = os.environ.get('ODOO_DB', '')
80
+ self.user = os.environ.get('ODOO_USER', '')
81
+ self.key = os.environ.get('ODOO_API_KEY', '')
82
+ missing = [k for k, v in [('ODOO_URL', self.url), ('ODOO_DB', self.db),
83
+ ('ODOO_USER', self.user), ('ODOO_API_KEY', self.key)] if not v]
84
+ if missing:
85
+ raise RuntimeError(f"Missing secrets: {', '.join(missing)}")
86
+ common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common')
87
+ self.uid = common.authenticate(self.db, self.user, self.key, {})
88
+ if not self.uid:
89
+ raise RuntimeError('Odoo authentication failed')
90
+ self.models = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/object')
91
+
92
+ def _exec(self, model, method, args, kwargs=None):
93
+ mutating = method in ('write', 'create', 'unlink', 'copy') or \
94
+ any(method.startswith(p) for p in ('action_', 'button_', 'do_', 'send_',
95
+ 'set_', 'update_', 'process_'))
96
+ if mutating and (model, method) not in WRITE_WHITELIST:
97
+ raise WriteBlocked(f'{method} on {model} is not allowed from this app')
98
+ return self.models.execute_kw(self.db, self.uid, self.key,
99
+ model, method, args, kwargs or {})
100
+
101
+ def search_read(self, model, domain=None, fields=None, limit=None, order=None):
102
+ kw = {'fields': fields or []}
103
+ if limit is not None:
104
+ kw['limit'] = limit
105
+ if order:
106
+ kw['order'] = order
107
+ return self._exec(model, 'search_read', [domain or []], kw)
108
+
109
+ def queue_mail(self, payload):
110
+ """The single allowed write: queue an outbound email."""
111
+ return self._exec('mail.mail', 'create', [payload])
112
+
113
+
114
+ # --------------------------------------------------------------- data builders
115
+ def load_collection_list(odoo):
116
+ """The saved follow-up filter (Reminders=Automatic, Receivable>$1), GIFTWARE excluded,
117
+ with priority tiers."""
118
+ partners = odoo.search_read('res.partner', DOMAIN,
119
+ ['name', 'credit', 'total_overdue', 'followup_status',
120
+ 'followup_next_action_date', 'followup_responsible_id',
121
+ 'email', 'phone', 'mobile'])
122
+ partners = [p for p in partners if (p['name'] or '').strip().upper() not in EXCLUDE_NAMES]
123
+ pids = [p['id'] for p in partners]
124
+
125
+ docs = odoo.search_read('account.move',
126
+ [('move_type', 'in', ['out_invoice', 'out_refund']), ('state', '=', 'posted'),
127
+ ('payment_state', 'in', ['not_paid', 'partial']), ('partner_id', 'in', pids)],
128
+ ['name', 'partner_id', 'move_type', 'invoice_date', 'invoice_date_due',
129
+ 'amount_total', 'amount_residual_signed'])
130
+
131
+ today = dt.date.today()
132
+ by_partner = {}
133
+ for d in docs:
134
+ pid = d['partner_id'][0]
135
+ due = d.get('invoice_date_due')
136
+ try:
137
+ days = (today - dt.date.fromisoformat(due)).days if due else 0
138
+ except Exception:
139
+ days = 0
140
+ d['days_overdue'] = max(days, 0)
141
+ d['open_signed'] = d['amount_residual_signed']
142
+ by_partner.setdefault(pid, []).append(d)
143
+
144
+ rows = []
145
+ for p in partners:
146
+ pid = p['id']
147
+ odoo_overdue = float(p.get('total_overdue') or 0)
148
+ docs_p = sorted(by_partner.get(pid, []), key=lambda x: x.get('invoice_date_due') or '')
149
+ inv_overdue = sum(d['open_signed'] for d in docs_p if d['days_overdue'] > 0)
150
+ oldest = max((d['days_overdue'] for d in docs_p), default=0)
151
+ gap = inv_overdue - odoo_overdue
152
+ reconcile = abs(gap) > 50
153
+
154
+ if odoo_overdue >= 5000 or (odoo_overdue > 0 and oldest > 90):
155
+ tier = 'A-Urgent'
156
+ elif odoo_overdue >= 1000 or (odoo_overdue > 0 and oldest > 30):
157
+ tier = 'B-Active'
158
+ elif odoo_overdue > 0:
159
+ tier = 'C-Light'
160
+ else:
161
+ tier = 'Monitor'
162
+
163
+ rows.append({
164
+ 'partner_id': pid,
165
+ 'Customer': p['name'],
166
+ 'Tier': tier,
167
+ 'Overdue': odoo_overdue,
168
+ 'Receivable': float(p.get('credit') or 0),
169
+ 'Oldest (days)': oldest,
170
+ 'Open Docs': len(docs_p),
171
+ 'Email': p.get('email') or '',
172
+ 'Phone': p.get('phone') or p.get('mobile') or '',
173
+ 'Status': (p.get('followup_status') or '').replace('_', ' '),
174
+ 'Reconcile?': 'YES' if reconcile else '',
175
+ '_docs': docs_p,
176
+ })
177
+ tier_rank = {'A-Urgent': 0, 'B-Active': 1, 'C-Light': 2, 'Monitor': 3}
178
+ rows.sort(key=lambda r: (tier_rank[r['Tier']], -r['Overdue']))
179
+ return rows
180
+
181
+
182
+ # --------------------------------------------------------------- statement email
183
+ # β›” STANDING RULE 2, AND THIS IS THE ONE STRING IN THE PRODUCT THAT LEAVES THE BUILDING.
184
+ # Every other finding `web_prose` reports is copy on a screen somebody here opens; this is the
185
+ # SUBJECT LINE of mail queued to a real debtor, over the single sanctioned Odoo write. It read
186
+ # 'Statement of Account (em dash) {company} (em dash) {month}' until W36-T42.
187
+ DEFAULT_SUBJECT = 'Statement of Account from {company}, {month}'
188
+ DEFAULT_INTRO = (
189
+ 'Dear {customer},<br><br>'
190
+ 'Please find below your current statement of account with {company}. '
191
+ 'According to our records, the following invoices remain open:'
192
+ )
193
+ DEFAULT_FOOTER = (
194
+ 'If you have already sent payment, please disregard this notice, and thank you. '
195
+ 'For any questions about an invoice, simply reply to this email.<br><br>'
196
+ 'Thank you for your business,<br>{company}<br>Accounts Receivable'
197
+ )
198
+
199
+
200
+ def render_statement_html(row, intro_tpl=DEFAULT_INTRO, footer_tpl=DEFAULT_FOOTER):
201
+ month = dt.date.today().strftime('%B %Y')
202
+ intro = intro_tpl.format(customer=row['Customer'], company=COMPANY, month=month)
203
+ footer = footer_tpl.format(customer=row['Customer'], company=COMPANY, month=month)
204
+
205
+ lines = []
206
+ total_open = 0.0
207
+ for d in row['_docs']:
208
+ kind = 'Credit Note' if d['move_type'] == 'out_refund' else 'Invoice'
209
+ amt = d['open_signed']
210
+ total_open += amt
211
+ overdue_txt = f"{d['days_overdue']}d overdue" if d['days_overdue'] > 0 else 'current'
212
+ color = '#C0392B' if d['days_overdue'] > 0 else '#1F4E78'
213
+ lines.append(
214
+ f"<tr><td style='padding:6px 10px;border-bottom:1px solid #e3e8ef'>{d['name']} <span style='color:#888'>({kind})</span></td>"
215
+ f"<td style='padding:6px 10px;border-bottom:1px solid #e3e8ef'>{d.get('invoice_date') or ''}</td>"
216
+ f"<td style='padding:6px 10px;border-bottom:1px solid #e3e8ef'>{d.get('invoice_date_due') or ''}</td>"
217
+ f"<td style='padding:6px 10px;border-bottom:1px solid #e3e8ef;color:{color}'>{overdue_txt}</td>"
218
+ f"<td style='padding:6px 10px;border-bottom:1px solid #e3e8ef;text-align:right'>${amt:,.2f}</td></tr>")
219
+
220
+ table = (
221
+ "<table style='border-collapse:collapse;font-size:14px;margin:14px 0'>"
222
+ "<tr style='background:#1F4E78;color:#fff'>"
223
+ "<th style='padding:7px 10px;text-align:left'>Document</th>"
224
+ "<th style='padding:7px 10px;text-align:left'>Date</th>"
225
+ "<th style='padding:7px 10px;text-align:left'>Due</th>"
226
+ "<th style='padding:7px 10px;text-align:left'>Status</th>"
227
+ "<th style='padding:7px 10px;text-align:right'>Open Balance</th></tr>"
228
+ + ''.join(lines) +
229
+ f"<tr><td colspan='4' style='padding:8px 10px;font-weight:bold;text-align:right'>Total open</td>"
230
+ f"<td style='padding:8px 10px;font-weight:bold;text-align:right'>${total_open:,.2f}</td></tr>"
231
+ f"<tr><td colspan='4' style='padding:2px 10px;font-weight:bold;text-align:right;color:#C0392B'>Of which overdue</td>"
232
+ f"<td style='padding:2px 10px;font-weight:bold;text-align:right;color:#C0392B'>${row['Overdue']:,.2f}</td></tr>"
233
+ "</table>")
234
+
235
+ return (f"<div style='font-family:Calibri,Arial,sans-serif;color:#1a1a1a;font-size:14px'>"
236
+ f"{intro}{table}{footer}</div>")
237
+
238
+
239
+ def queue_statement(odoo, row, subject_tpl=DEFAULT_SUBJECT,
240
+ intro_tpl=DEFAULT_INTRO, footer_tpl=DEFAULT_FOOTER,
241
+ override_to=None):
242
+ """Queue one statement email in Odoo. Returns mail.mail id.
243
+ override_to: send to a different address (used by the test-send button)."""
244
+ to = override_to or row['Email']
245
+ if not to:
246
+ raise ValueError(f"{row['Customer']} has no email address")
247
+ # Hard guardrail β€” refuse any recipient outside the allow-list while SAFE_MODE is on.
248
+ if not safe_recipient_ok(to):
249
+ raise SafeModeBlocked(
250
+ f"Guardrail ON: refusing to email {to}. Only {', '.join(sorted(SAFE_RECIPIENTS))} "
251
+ f"is allowed right now. (Set SAFE_MODE=0 to send to real customers.)")
252
+ month = dt.date.today().strftime('%B %Y')
253
+ subject = subject_tpl.format(customer=row['Customer'], company=COMPANY, month=month)
254
+ payload = {
255
+ 'subject': subject,
256
+ 'body_html': render_statement_html(row, intro_tpl, footer_tpl),
257
+ 'email_to': to,
258
+ 'email_from': SENDER_DISPLAY, # From: Royal Imports
259
+ 'reply_to': REPLY_TO, # replies -> AR
260
+ 'mail_server_id': ROYAL_MAIL_SERVER_ID, # force Royal O365 relay
261
+ 'author_id': ROYAL_AUTHOR_ID, # clean attribution in chatter
262
+ 'state': 'outgoing',
263
+ 'auto_delete': False,
264
+ 'model': 'res.partner',
265
+ 'res_id': row['partner_id'],
266
+ }
267
+ return odoo.queue_mail(payload)
platform/modules/product_data.py CHANGED
@@ -636,6 +636,13 @@ def pool(team_id=None, t=None):
636
  # leaf and WIDENS to the whole catalogue. That is the exact failure `_seed_wave17`'s
637
  # buy-list comment was written about, and a blank here would reintroduce it.
638
  "discontinued": meta.get("discontinued") or "No",
 
 
 
 
 
 
 
639
  # ⭐⭐ W33-T43 (R2 / amendment A2) β€” ODOO'S OWN PRODUCT ID, beside the hashed `pid`.
640
  # R2 retires `ut_odoo_products` onto this key and keeps every data column it had; this
641
  # is that column. β›” NOT DERIVABLE DOWNSTREAM: `pid` is `crc32(default_code)` here,
@@ -844,6 +851,81 @@ def pool(team_id=None, t=None):
844
  return rows
845
 
846
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
847
  #: ⭐ THE BUY SIGNAL, AS THE FORMULA FIELD EVALUATES IT (owner, 2026-08-03).
848
  #:
849
  #: The owner's ruling was that "Buy signal" is not a preset field β€” it is a formula over two
 
636
  # leaf and WIDENS to the whole catalogue. That is the exact failure `_seed_wave17`'s
637
  # buy-list comment was written about, and a blank here would reintroduce it.
638
  "discontinued": meta.get("discontinued") or "No",
639
+ # W41-T26 (owner I28, "pull UPC live from Odoo") - the barcode, carried from
640
+ # `products.catalogue()` for the same reason `product_id` is: `pool()` assembles
641
+ # this row key by key and never spreads `meta`, so a key the catalogue gains
642
+ # reaches nothing until it is named here.
643
+ # BLANK, never a placeholder: Odoo carries zero "-" and zero "0" barcodes, so the
644
+ # mastersheet's 75-percent-"-" column cannot leak in through the source.
645
+ "upc": meta.get("upc") or "",
646
  # ⭐⭐ W33-T43 (R2 / amendment A2) β€” ODOO'S OWN PRODUCT ID, beside the hashed `pid`.
647
  # R2 retires `ut_odoo_products` onto this key and keeps every data column it had; this
648
  # is that column. β›” NOT DERIVABLE DOWNSTREAM: `pid` is `crc32(default_code)` here,
 
851
  return rows
852
 
853
 
854
+ #: ⭐⭐ W41-T17 (owner instruction 25) β€” THE SYNTHETIC BUSINESS KEY'S PREFIX.
855
+ #:
856
+ #: `products.catalogue()` keys a product with no `default_code` as `f"pid:{p['id']}"`, and three
857
+ #: other functions in `modules/products.py` plus `modules/backorders.py` spell that same literal.
858
+ #: This constant exists so the REPORT below reads the convention by name instead of adding a sixth
859
+ #: copy of the spelling; it deliberately does not try to make the producers use it, because those
860
+ #: files are the ones that would have to change and a constant nobody writes through is a second
861
+ #: source for one fact ([[gate-pins-a-spelling-not-a-claim]]).
862
+ #: ⚠ IT IS A FALLBACK KEY, NOT AN ODOO ID. The digits after the colon happen to BE the
863
+ #: `product.product` id, which is exactly why reading them back out would look reasonable and be
864
+ #: wrong: `product_id` is a declared column carrying that value as an int, and parsing it out of a
865
+ #: text cell would be a second derivation of a number already on the row.
866
+ CODELESS_KEY_PREFIX = "pid:"
867
+
868
+
869
+ def identity_report(rows):
870
+ """R6's second sentence as data, for the PRODUCT grid's two identity columns:
871
+ `{subject, cause, recommendation, effect}` plus the counts it is asserting.
872
+
873
+ ⭐⭐ W41-T17 / owner instruction 25 ("check whether Odoo customer's unique ID should have been
874
+ the Odoo ID"). The answer for CUSTOMERS was yes and already shipped: a customer row's `pid` IS
875
+ the `res.partner` id, so `odoo_customers.yml` can say "there is ONE id per row and no second
876
+ one". PRODUCTS are the gap this reports on: `pid` is a CRC32 of the SKU, the Odoo id is a
877
+ separate value, and 33 active products carry no SKU at all and wear a `pid:<id>` business key.
878
+
879
+ β›” THIS IS THE "REPORTED, NEVER SILENTLY ENFORCED" HALF OF STANDING RULE 1, AND IT IS THE
880
+ REASON THE FUNCTION EXISTS RATHER THAN A COMMENT. Those 33 rows are not dropped, not capped
881
+ and not blank; what they lack is a REAL business key, and before this ticket nothing on the
882
+ wire said so. A reader who filtered `code` for a SKU pattern silently lost them, and a reader
883
+ who saw `pid:40170` had no way to learn whether that was a code or a placeholder.
884
+
885
+ ⚠ `effect` IS `reported`, WHICH IS A THIRD VALUE, AND THAT IS DELIBERATE.
886
+ `core/user_tables.limit_report` ships exactly two (`read_through` and `refused`) and its
887
+ docstring makes a point of `truncated` never being one of them, because every site it speaks
888
+ for REFUSES. Nothing is refused here and nothing is read through: all 5,872 grid rows ship
889
+ with a resolved `product_id`. Forcing this into `refused` would make that invariant read as
890
+ broken; inventing a quiet fourth meaning for `read_through` would be worse. So the shape is
891
+ borrowed and the vocabulary is extended, in writing.
892
+
893
+ ⭐ TAKES ROWS, DOES NOT BUILD THEM. The caller passes the rows it is actually about to serve,
894
+ AFTER the row wall has run, so a BU-scoped reader is told about the products in THEIR grid.
895
+ Counting `catalogue()` here instead would print 33 beside a narrower grid, which is the class
896
+ of number that disagrees with the screen and is believed anyway.
897
+ """
898
+ rows = list(rows or [])
899
+ total = len(rows)
900
+ codeless = [r for r in rows
901
+ if str(r.get("code") or "").startswith(CODELESS_KEY_PREFIX)]
902
+ # ⚠ `is None`, NOT falsy: 0 is a real Odoo id (the same distinction `pool` makes on this key
903
+ # one screen up), so `not r.get("product_id")` would count a legitimate id as a missing one.
904
+ missing_id = [r for r in rows if r.get("product_id") is None]
905
+ return {
906
+ "subject": "product identity",
907
+ "effect": "reported",
908
+ "rows": total,
909
+ "resolved": total - len(missing_id),
910
+ "unresolved": len(missing_id),
911
+ "codeless": len(codeless),
912
+ "codeless_sample": sorted(str(r.get("code") or "") for r in codeless)[:5],
913
+ "cause": (
914
+ "a product's row id is a CRC32 of its SKU, so it is not the Odoo id and the two "
915
+ "cannot be recovered from each other. `product_id` carries the real "
916
+ "`product.product` id, and `code` stays the business key. "
917
+ f"{len(codeless)} of these {total} products carry no SKU in Odoo, so their `code` is "
918
+ "the fallback `pid:<odoo id>` rather than a code anyone quotes"
919
+ ),
920
+ "recommendation": (
921
+ "join and group on `product_id`, which resolves for every product carrying an Odoo "
922
+ "id. Treat a `code` beginning `pid:` as absent rather than as a SKU: filter on "
923
+ "`product_id` when the question is about the record, and on `code` only when the "
924
+ "question is genuinely about the catalogue number"
925
+ ),
926
+ }
927
+
928
+
929
  #: ⭐ THE BUY SIGNAL, AS THE FORMULA FIELD EVALUATES IT (owner, 2026-08-03).
930
  #:
931
  #: The owner's ruling was that "Buy signal" is not a preset field β€” it is a formula over two