Deploy AIOS web (React glide grid + FastAPI slice)
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- RELEASES.json +1 -1
- VERSION +1 -1
- api/ai_review.py +591 -591
- api/automation_engine.py +0 -0
- api/connectors_tt.py +568 -568
- api/main.py +0 -0
- api/odoo_relational.py +0 -0
- api/providers.py +0 -0
- api/routes_admin.py +257 -1
- api/routes_agent_harness.py +455 -455
- api/routes_alerts.py +668 -668
- api/routes_customers.py +0 -0
- api/routes_grid.py +0 -0
- api/routes_keychain.py +0 -0
- api/routes_nav.py +0 -0
- api/routes_oauth.py +114 -114
- api/routes_products.py +20 -0
- api/routes_publish.py +0 -0
- api/routes_records.py +211 -211
- api/routes_script_views.py +439 -439
- api/routes_shares.py +0 -0
- api/routes_slack.py +789 -789
- api/routes_statements.py +298 -298
- api/routes_tables.py +0 -0
- api/routes_web_agent.py +84 -84
- platform/aios_grid.py +44 -2
- platform/aios_grid_fields.json +142 -0
- platform/core/field_permissions.py +580 -5
- platform/core/grid_events.py +514 -22
- platform/core/perm_scope.py +0 -0
- platform/core/script_sandbox.py +519 -519
- platform/core/shared_overlay.py +521 -327
- platform/core/shares.py +584 -560
- platform/core/store.py +0 -0
- platform/core/table_store.py +0 -0
- platform/core/user_tables.py +0 -0
- platform/harness/connectors/odoo.py +17 -1
- platform/harness/datastore.py +0 -0
- platform/harness/meta_store.py +521 -521
- platform/model/metrics/sales.yml +149 -149
- platform/model/topics/odoo_accounts.yml +60 -60
- platform/model/topics/odoo_agents.yml +91 -91
- platform/model/topics/odoo_bills.yml +86 -86
- platform/model/topics/odoo_invoices.yml +98 -98
- platform/model/topics/odoo_orders.yml +87 -87
- platform/model/topics/odoo_products.yml +16 -1
- platform/model/topics/odoo_vendors.yml +103 -103
- platform/modules/agent.py +385 -385
- platform/modules/collections_send.py +267 -267
- platform/modules/product_data.py +82 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "a8583e6",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ==
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 1890 |
surface = str(event.get('surface') or '')
|
| 1891 |
if surface not in _agf.FOLDER_SURFACES:
|
| 1892 |
-
return
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1912 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1913 |
if any(f['id'] == fid for f in lst):
|
| 1914 |
-
return
|
|
|
|
|
|
|
| 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
|
| 1924 |
-
return
|
| 1925 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1978 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1979 |
if any(f['id'] == new_id for f in lst):
|
| 1980 |
-
return
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1992 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2035 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
"""
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
"""
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
return
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
def
|
| 245 |
-
"""
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
"""
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
_write(table_key,
|
| 293 |
-
return
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
def
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 16 |
-
shares.
|
| 17 |
-
shares.
|
| 18 |
-
shares.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
`
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
and
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
#:
|
| 70 |
-
#:
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
#:
|
| 75 |
-
#:
|
| 76 |
-
#:
|
| 77 |
-
#:
|
| 78 |
-
#:
|
| 79 |
-
#:
|
| 80 |
-
#:
|
| 81 |
-
#:
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
#:
|
| 86 |
-
#:
|
| 87 |
-
#:
|
| 88 |
-
#:
|
| 89 |
-
#:
|
| 90 |
-
#:
|
| 91 |
-
#:
|
| 92 |
-
#:
|
| 93 |
-
#:
|
| 94 |
-
#:
|
| 95 |
-
#:
|
| 96 |
-
#:
|
| 97 |
-
#:
|
| 98 |
-
#:
|
| 99 |
-
#:
|
| 100 |
-
#:
|
| 101 |
-
#:
|
| 102 |
-
#:
|
| 103 |
-
#:
|
| 104 |
-
#:
|
| 105 |
-
#:
|
| 106 |
-
#:
|
| 107 |
-
#:
|
| 108 |
-
#:
|
| 109 |
-
#:
|
| 110 |
-
#: `
|
| 111 |
-
#:
|
| 112 |
-
#:
|
| 113 |
-
#:
|
| 114 |
-
#:
|
| 115 |
-
#:
|
| 116 |
-
#:
|
| 117 |
-
#:
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
#:
|
| 124 |
-
#:
|
| 125 |
-
#:
|
| 126 |
-
#:
|
| 127 |
-
#:
|
| 128 |
-
#:
|
| 129 |
-
#:
|
| 130 |
-
#:
|
| 131 |
-
#:
|
| 132 |
-
#:
|
| 133 |
-
#:
|
| 134 |
-
#:
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
#:
|
| 149 |
-
#:
|
| 150 |
-
#:
|
| 151 |
-
#:
|
| 152 |
-
#:
|
| 153 |
-
#:
|
| 154 |
-
#:
|
| 155 |
-
#:
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
`
|
| 215 |
-
|
| 216 |
-
re-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
`
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
`
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
#
|
| 350 |
-
#
|
| 351 |
-
#
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
"
|
| 463 |
-
role
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
return
|
| 559 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|