diff --git "a/api/routes_automation.py" "b/api/routes_automation.py" --- "a/api/routes_automation.py" +++ "b/api/routes_automation.py" @@ -1,1902 +1,1896 @@ -"""routes_automation.py — wave-18 item 5 (contract C4-AUTO): the automation surface's API. - -The router is thin on purpose: everything that can be wrong about an automation — a malformed -cron, a URL the SSRF rail refuses, a key field that is not one of the mapped columns — is decided -in `automation_engine`, which is a pure-ish module a gate can drive without a server. This file -does auth, shape and status codes. - -⛔ THE TICK ENDPOINT IS THE ONE UNAUTHENTICATED ROUTE, AND IT IS FAIL-CLOSED TWICE OVER. It takes -no session (an external cron has no cookie), so it is gated on a shared secret in -`X-AIOS-TICK-TOKEN`; and when `AIOS_AUTOMATION_TICK_TOKEN` is UNSET the route refuses everything -rather than admitting everyone. A "no token configured means no check" default is how an internal -trigger becomes a public one — the same class of mistake as an empty-200 permission answer. -""" -import os -import re -import time -from datetime import datetime, timezone - -from fastapi import APIRouter, Body, Depends, Header, Request - -import ai_review -import automation_engine as engine -import oauth_connect -import routes_oauth -import scope_cache -from deps import Session, err, module_gate, require_session - -router = APIRouter(prefix="/api/v1") - -# C5 (wave 22): the OAuth connector surface rides INSIDE this router — main.py belongs to no -# session this wave, and this router is already mounted there. `/api/v1` + `/oauth/...`. -router.include_router(routes_oauth.router) -# ⛔⛔ D-203 — THE NESTED INCLUDE OF `routes_connectors` IS REMOVED, and the reason it existed is -# worth keeping because it was a GOOD reason that expired. -# -# Wave 23 (C11) mounted the connectors directory here rather than in `main.py`, because this router -# was already mounted and `main.py` belonged to another session — a sensible way to avoid a -# cross-fence ask. `main.py:227` includes `routes_connectors` DIRECTLY now, so this line stopped -# being the only door and became a SECOND one. -# ⚠ AND A ROUTER MOUNTED TWICE DOES NOT SERVE THE SAME PATHS TWICE — it serves the prefix twice. -# `routes_connectors` carries its own `/api/v1`, so nesting it inside this router's `/api/v1` -# produced **`/api/v1/api/v1/connectors/directory`**: a live, session-gated, entirely dead path -# that nothing links to and every route audit has to explain. MEASURED before removal: 123 served -# paths, exactly 1 of them doubled. -# ⚠ `routes_oauth` above is NOT the same case and stays: `main.py` does not mount it, so this -# router is genuinely its only door. Deleting it because its neighbour was wrong is how a real -# route dies for a tidy-up. -import routes_connectors # noqa: E402,F401 - -#: The registry key this surface carries (C-AUTONAV — A adds the row; the gate is live now, so -#: the day the row lands the wall is already the one that was tested). -MODULE = "automation" - -_GATE = module_gate(MODULE) - - -def _wire(defn, tenant, rt=None): - """One automation, as the client reads it. `running` is PROCESS state, never store state — - see the engine header on why a persisted 'running' is a permanent lock.""" - live = engine.running(tenant, defn.get("id")) - sched = defn.get("schedule") or {} - nxt = engine.next_fire(sched.get("cron")) if sched.get("enabled") else None - status = dict(defn.get("status") or {}) - if live: - status = {**status, "state": "running", "startedAt": live.get("startedAt"), - "step": live.get("step")} - return { - "id": defn.get("id"), "name": defn.get("name"), "kind": defn.get("kind"), - "config": defn.get("config") or {}, "schedule": sched, "status": status, - "runs": list(defn.get("runs") or [])[:engine.MAX_RUNS], - "created": defn.get("created"), "createdBy": defn.get("createdBy"), - "nextRunAt": nxt.strftime("%Y-%m-%d %H:%M") if nxt else "", - "running": bool(live), - # ⭐ WAVE 27 (contract C6) — DEBT D-70: "a paid search is already outstanding at the - # vendor". THE MONEY GUARD, and it was INERT for a whole wave because this line was - # missing: the client declared `awaitingResults` REQUIRED and read it in `runBlock()`, - # nothing ever sent it, so `undefined` was falsy and the Run button stayed armed. - # - # ⛔ WHY IT IS NOT `running`. `running` above is PROCESS state (`engine.running`) and is - # FALSE for the entire 20–30 MINUTE vendor wait, which IS the hazard window: the ticks - # are 3 minutes apart, so between them the automation is idle and the button re-arms. - # MEASURED the expensive way on 2026-08-06 (~$0.025): with no snapshot outstanding a - # Run-now press returned 200 and started a BRAND-NEW billable corpus search. - # - # ⛔ AND NOT A PERSISTED "running" FLAG EITHER — the engine header forbids one (it - # outlives the process and locks the automation forever). `state.pendingSnapshot` is the - # field that already exists, is already persisted, is already carried untouched through - # `clean_definition`, and is the SAME expression `pending_collect_ids()` uses to decide - # what to collect. One truth, two readers. - "awaitingResults": bool(str((defn.get("state") or {}).get("pendingSnapshot") - or "").strip()), - # C3 (wave 22): the trigger config rides whole — the webhook token included, because - # the person configuring the external caller has to be shown the URL somewhere, and - # this payload is session-gated behind the same wall as everything else here. - # ⭐⭐ WAVE 35 · T36 / R10 — THE PARKED BATCH, SUMMARISED. Without this line the whole - # ticket would be unreachable: `_wire` is a WHITELIST, so a key the engine parks on the - # definition simply never arrives, and the detail pane could not know a batch was waiting - # ([[reachable-is-not-the-same-as-built]] — the exact shape `flow` was caught in above). - # ⚠ COUNT AND NOTES ONLY, NEVER `items`. A 200-statement batch carries 200 rendered HTML - # mails; this payload rides on the automations LIST, which every rail paint reads. - # `GET /admin/statements/agent/{id}` serves the detail when somebody opens the batch. - "pendingStatements": ({ - "count": int((defn.get("pendingStatements") or {}).get("count") or 0), - "ts": (defn.get("pendingStatements") or {}).get("ts") or "", - "notes": list((defn.get("pendingStatements") or {}).get("notes") or [])[:25], - } if isinstance(defn.get("pendingStatements"), dict) else None), - # ⭐⭐ WAVE 35 · T37 — `system` FOR A **STORED** ROW. Until now only SYNTHETIC rows carried - # it, stamped by their builders AFTER this function (`_odoo_sync_row`, `_field_agent_rows`), - # so a stored row with the marker had it enforced on the server (`delete_automation` 409s) - # and INVISIBLE to the client — which paints a live-looking Delete that answers with a - # refusal. `AutomationDetail` already reads `automation.system` to disable that button and - # explain why; this is the line that lets it. - # ⚠ Absent stays absent rather than becoming `""`: every ordinary automation is not-a-system - # -agent, and an empty string is a value a client could accidentally treat as one. - **({"system": str(defn.get("system") or "")} if defn.get("system") else {}), - "trigger": defn.get("trigger") or None, - "statusNote": defn.get("statusNote") or "", - # The one-sentence summary (airtable-brief rec 7), composed from the definition so it - # cannot describe steps the engine does not run. - "sentence": engine.compose_sentence(defn), - # THE CANVAS TOPOLOGY (R5) rides with the automation rather than being rebuilt in the - # client, for the same reason `cronPresets` does: the engine that RUNS the steps is the - # only thing entitled to say what the steps are. - "graph": engine.graph(defn), - # ⭐ WAVE 23 (C4/C5) — THE BUILDER'S OWN STATE, and its absence here was a silent-drop - # bug caught in review rather than by a gate: `flow` was stored, patchable and validated, - # but never sent back. B would have saved a flow through PATCH, got a 200, and watched - # every action vanish on reload — the classic "it didn't save" with nothing red anywhere. - "flow": defn.get("flow") or {"actions": []}, - # ⭐⭐ WAVE 32 · T45 (owner item 10) — CONFIGURED / UNCONFIGURED, per action, on the wire. - # - # ⛔ THE SERVER SAYS IT, not the client, and that is the whole reason it is here: the same - # `engine.action_needs` answers this label, `engine.run_refusal`'s 400 and `run_now`'s own - # refusal, so a card cannot read "Configured" over an action the run will refuse. Three - # readers, one predicate — the alternative is a client-side rule that agrees with the - # server until somebody adds a key to one of them (`awaitingResults` above is this file's - # own record of what the other shape costs). - # ⚠ IT IS A LIST, KEYED BY ACTION ID, and it names the NESTED actions too — an unconfigured - # step inside an If / then branch is exactly the one a person cannot see. - # ⚠ COSTS NO STORE READ. `action_needs` is pure over `(kind, config)`; this route is the - # one W30-T12 took the `user_tables` deep copy off, and a label is not worth putting it - # back. What needs the target database's schema (an enrich binding resolved by the profile - # FLAG) stays a run-time refusal — see `ACTION_REQUIRED`'s note. - "unconfigured": engine.unconfigured_actions(defn), - # ⭐⭐ WAVE 36 · W36-T38 (owner item 8 / R9) — WHICH ACTIONS AN AGENT BUILT. - # `{action_id: {agent, agentName, created, by}}`, empty for an ordinary automation. - # ⛔ THE CONFIG IS NOT HIDDEN AND MUST NOT BE. R9 is a WRITE rule: the client uses this to - # draw a lock and explain who owns the step, never to withhold what the step does — an - # automation nobody can audit is worse than one nobody can edit. - # ⚠ `rt=None` YIELDS `{}` RATHER THAN OMITTING THE KEY, so a caller that forgets the - # runtime produces "nothing is agent-owned" (a visible, wrong-but-safe answer) instead of - # an absent prop the client silently reads as undefined - # [[flag-shipped-without-its-writer]]. - "agentActions": (_agent_marks(rt).get(str(defn.get("id") or "")) or {}) if rt else {}, - } - - -#: ⭐⭐ WAVE 34 · CONTRACTS C3 + C4 — WHY A ROW CANNOT BE DELETED, as a slug. -#: -#: ⚠ ALWAYS A STRING, NEVER A BOOLEAN, and the two contracts disagreed about that (C3 says -#: `system: true`, C4 says `system: ""`). Raised as ask `E-13`; built as a slug because one -#: key with two types is [[one-question-two-normalizers]] before a line is written, and because a -#: refusal has to be able to NAME the reason. Truthiness is unchanged for any reader that only -#: asks "is this a system agent". -SYSTEM_FIELD_AGENT = "field_agent" -SYSTEM_ODOO_SYNC = "odoo_sync" - - -def _field_agent_rows(session): - """⭐⭐ CONTRACT C3 — one synthetic Agent row per `ai_enrich` column in the tenant. - - R13: *"Every field agent must be VIEWABLE under the Agent module as a simple Trigger whose - first step is the field enrichment, with the Field and Database shown under Config."* - - ⛔⛔ THESE ARE DERIVED, NOT STORED, AND NOTHING HERE MAY WRITE. A field agent's only source of - truth is the column definition F's `_clean_field` stamps; a copy in the automations bucket - would be a second one, and the first PATCH through the enrichment editor would silently - diverge from it. `delete_automation` refuses these ids for the same reason: accepting a delete - that cannot delete anything is the `view_upsert` failure mode (200 OK, nothing written) in a - new place. - - ⛔⛔ AND IT USES THE ROWS-FREE PROJECTION, WHICH IS THE WHOLE ENGINEERING PROBLEM OF THIS - TICKET. `GET /automations` is the route W29-T01 and W30-T12 spent two waves taking the - whole-document read OFF: it reads `user_tables` exactly ONCE PER TENANT PER PROCESS now, and - this file's own `unconfigured` note says *"a label is not worth putting it back"*. Walking - every table's fields for `ai_enrich` columns is exactly that read. So this uses - `user_tables.all_defs` — the same projection `/nav` uses, MEASURED there at 1,750 ms -> 1.4 ms, - because tenant #0's document is 99.89% rows and a field definition is not a row. - ⚠ `all_defs` RAISES on `rows` rather than answering `{}`. Nothing here wants a row; if that - ever changes, the error names the projection instead of painting an empty grid. - - ⚠ THE PERMISSION WALL IS CALLED, NEVER RE-IMPLEMENTED. `may_open` decides which databases this - caller may see, lent the projected document exactly as `/nav` lends it. This route's own - `automation_tables` carries the scar that makes that non-negotiable: it once re-implemented - the wall, got it WIDER, and showed people databases they were refused the moment they clicked. - """ - import core.user_tables as user_tables - - try: - defs = user_tables.all_defs(st=session.runtime) or {} - lent = user_tables.lend(session.runtime, **{user_tables.STORE_KEY: defs}) - except Exception: # noqa: BLE001 - # A store blip must not take the whole rail down with it. The stored automations are the - # payload's real subject; field agents are an addition to it. - return [] - - out = [] - for key, defn in sorted(defs.items(), key=lambda kv: (kv[1].get("label") or "").lower()): - if not user_tables.may_open(key, session.uname, session.admin, st=lent): - continue - table_label = defn.get("label") or key - for f in user_tables.ai_enrich_fields(defn): - bag = f.get("automation") if isinstance(f.get("automation"), dict) else {} - if bag.get("kind") != SYSTEM_FIELD_AGENT: - continue - col = str(f.get("key") or "") - if not col: - continue - # ⚠ THE ID CARRIES THE TABLE AND THE COLUMN, and `table` is NOT read off the bag: - # F's `_clean_field` is handed ONE field dict with no table context, so it cannot - # stamp one (mailbox `F-3`). E holds that key because E is the side that iterates - # tables. Two sources for one fact was the alternative, and it drifts. - trig = bag.get("trigger") if isinstance(bag.get("trigger"), dict) else {} - mode = str(trig.get("mode") or "manual") - cron = str(trig.get("cron") or "").strip() - defn_syn = { - "id": f"field:{key}:{col}", - "name": f.get("label") or col, - "kind": SYSTEM_FIELD_AGENT, - # ⛔⛔ TWO TRIGGER VOCABULARIES MEET HERE AND THEY ARE NOT THE SAME ONE. A field - # agent's trigger is the ENRICHMENT vocabulary (`{mode, cron}`, values - # manual/on_change/schedule); an automation's is the ENGINE's (`{key}` plus a - # separate `schedule {cron, enabled}`). Handing the engine's shape a `mode` it - # cannot read is not a type error — it is SILENT: `compose_sentence` fell back to - # "When you press Run now" for a column scheduled at 06:00, and `graph()` drew a - # trigger node with no trigger. Measured before this mapping existed, which is the - # only reason it does. - # ⚠ THE MAP IS EXACT WHERE AN EQUIVALENT EXISTS AND HONEST WHERE IT DOES NOT: - # `manual` and `schedule` are the engine's own keys, and `on_change` becomes - # `event_field` ("When a record matches conditions"), the nearest thing the engine - # has to "a cell this column reads has moved". `mode` is kept verbatim beside it so - # nothing is lost in translation and the enrichment editor stays the source. - "trigger": {"key": {"manual": "manual", "schedule": "schedule", - "on_change": "event_field"}.get(mode, "manual"), - "mode": mode, **({"cron": cron} if cron else {})}, - **({"schedule": {"cron": cron, "enabled": True}} - if mode == "schedule" and cron else {}), - "flow": {"actions": [{"id": "act_1", "kind": "ai_enrich", - "config": {"table": key, "field": col, - "tableLabel": table_label}}]}, - } - # ⭐ THROUGH `_wire`, NOT BESIDE IT. A synthetic row hand-built to "look like" a - # stored one is a second shape that agrees until somebody adds a key to the real one - # — the exact way `awaitingResults` shipped inert for a whole wave. Passing the - # synthetic DEFINITION through the same function makes them identical by - # construction; only `system` is stamped afterwards, because no stored row has it. - row = _wire(defn_syn, session.tenant, rt=session.runtime) - row["system"] = SYSTEM_FIELD_AGENT - out.append(row) - return out - - -#: ⭐⭐ WAVE 34 · W34-T47 (mailbox D-5) — R6 APPLIED TO WORDS A MODEL WROTE. -#: -#: ⛔ `web_prose` READS FILES, so it is structurally blind to a dash that arrives at RUN TIME. -#: `POST /automations/draft` returns three strings a language model authored (the flow's name, -#: each step's `why`, and the dropped/notes sentences) and `W34-T42` is what put them on a screen. -#: Lane D MEASURED that a prompt instruction does not hold: their system prompt ends "Never use an -#: em dash" and the very next live cerebras turn came back with one. So this is code at the -#: boundary, not a better prompt. -#: ⚠ A DIGIT RANGE IS A DIFFERENT SENTENCE and gets the first rule: "10-20" means "10 to 20", and -#: turning it into "10, 20" states two numbers where the model stated a span. -_DRAFT_DASH = "[" + chr(0x2014) + chr(0x2013) + "]" - - -def _draft_no_dashes(text): - """Model prose with no em or en dash, and no meaning changed on the way.""" - text = str(text or "") - text = re.sub(rf"(?<=\d)\s*{_DRAFT_DASH}\s*(?=\d)", " to ", text) - text = re.sub(rf"\s*{_DRAFT_DASH}\s*(?=[,.;:!?])", "", text) - text = re.sub(rf"(?<=[,;:])\s*{_DRAFT_DASH}\s*", " ", text) - return re.sub(rf"\s*{_DRAFT_DASH}\s*", ", ", text) - - -ODOO_SYNC_ID = "system:odoo_sync" -#: The cadence presets in the words a person reads. ⚠ Keyed on `odoo_relational.SYNC_PRESETS`, and -#: a key with no phrase here falls back to "Every " rather than raising — a missing caption -#: must degrade to something readable, not take the agents list down. -ODOO_CADENCE_PHRASE = {"30m": "Every 30 minutes", "1h": "Every hour", - "4h": "Every 4 hours", "daily": "Every day"} - - -def _odoo_sync_row(session, detail=False): - """⭐⭐ CONTRACT C4 (ruling R22) — the Odoo sync, as an agent you can SEE. - - R22: *"Implicit time-triggered work (Odoo syncing) becomes EXPLICIT: a pre-set agent that - cannot be deleted, whose Canvas shows Trigger = time and Action = data syncing from Odoo, with - the keychain/API configuration shown."* - - ⛔⛔ DERIVED, NOT STORED, AND C4 SAYS "a stored automation may carry `system:`". THE MECHANISM - C4 ASKS FOR IS BUILT (`delete_automation` refuses any stored row carrying `system`); this - particular agent does not use it, for three measured reasons, and the deviation is raised as - ask `E-15` rather than taken quietly: - 1. NOTHING CAN SEED IT. There is no system-automation seeding path anywhere, and `D-195` - measured three times that a CLI write to the tenant store while the Space is live REPORTS - SUCCESS and is reverted within a minute. A stored row would have to be minted by the - container, i.e. a write-on-read on the busiest list route in the product. - 2. STORED MEANS RUNNABLE BY MACHINERY BUILT FOR USER AUTOMATIONS. If its runs went through - `_commit_run`, `CONSECUTIVE_FAILURE_PAUSE` would flip `schedule.enabled` off after K bad - passes — silently disabling the tenant's REAL Odoo cadence, not a cosmetic card. - 3. TWO COPIES OF ONE CADENCE. The loop reads `odoo_relational.sync_seconds`; a stored - automation would carry its own `schedule.cron`, and C4's own requirement is that the card - "cannot drift from the running loop". Deriving makes drift impossible instead of - forbidden. - - ⚠ `detail=False` IS THE LIST PATH AND IT STAYS CHEAP. The keychain label and the last-sync - stamp each cost their own store read, and `GET /automations` is the route two waves were spent - taking reads OFF. They are resolved only on the DETAIL fetch, which is one automation and one - click. Returns `None` when this tenant has no Odoo at all — an agent for a connector nobody - connected is a card that lies. - """ - try: - import odoo_relational as rel - except Exception: # noqa: BLE001 - return None - try: - cfg = rel.read_config(session.runtime) - secs = rel.sync_seconds(session.runtime) - frozen = bool(rel.frozen(session.runtime)) - except Exception: # noqa: BLE001 - return None - - every = str(cfg.get("syncEvery") or rel.DEFAULT_SYNC) - manual = secs is None - defn = { - "id": ODOO_SYNC_ID, - "name": "Odoo sync", - "kind": SYSTEM_ODOO_SYNC, - # ⚠ THE CADENCE IS READ, NEVER STORED HERE. `everySeconds` is what the loop will actually - # sleep for, so the card cannot claim a schedule the loop is not keeping. - "trigger": {"key": "manual" if manual else "schedule", "preset": every, - "everySeconds": secs, "presets": sorted(rel.SYNC_PRESETS)}, - **({} if manual else {"schedule": {"cron": "", "enabled": True}}), - "flow": {"actions": [{"id": "act_1", "kind": "odoo_sync", - "config": {"connector": "odoo", "every": every, - "frozen": frozen}}]}, - } - row = _wire(defn, session.tenant, rt=session.runtime) - row["system"] = SYSTEM_ODOO_SYNC - # ⛔ THE SENTENCE IS OVERRIDDEN, AND IT IS A FIX RATHER THAN A PREFERENCE. `compose_sentence` - # speaks the automation vocabulary — it builds "When , run N actions" out of - # `schedule.cron` — and this agent has no cron: its cadence is a PRESET in seconds that the - # resync loop sleeps on. MEASURED before this line existed, the card read exactly - # `", run 1 action."`: a leading comma where the trigger phrase should have been. Two - # vocabularies again, and this one fails in punctuation rather than in behaviour, which is - # why only reading the output catches it. - # ⚠ AND THE CADENCE IS SPELLED OUT RATHER THAN INTERPOLATED. `f"Every {every}"` produced - # "Every daily, sync data from Odoo." — the preset KEYS are storage tokens ("30m", "daily"), - # not English, and a sentence built by pasting one in is only accidentally readable for the - # three that happen to be durations. Caught by reading the output, not by any assertion. - row["sentence"] = ("Odoo is disconnected, so nothing syncs." if frozen else - "Manually, sync data from Odoo." if manual else - f"{ODOO_CADENCE_PHRASE.get(every, f'Every {every}')}, sync data from Odoo.") - # ⛔ R6's SECOND SENTENCE, WHICH IS THE HALF THAT GETS DROPPED: a limit that cannot be removed - # must be REPORTED with its cause. Two are reported here rather than left for somebody to - # discover by watching a mirror not move. - notes = [] - if frozen: - notes.append("Odoo is disconnected, so this agent is not syncing. Reconnect it in " - "Connectors.") - if manual: - notes.append("The cadence is set to manual, so nothing syncs on a timer.") - row["statusNote"] = " ".join(notes) - if not detail: - return row - - # ── the DETAIL half: two store reads, on a route that fetches one automation ────────────── - try: - import routes_keychain as kc_routes # noqa: PLC0415 - row["flow"]["actions"][0]["config"]["keychain"] = ( - kc_routes.odoo_config(session).get("label") or "Odoo") - except Exception: # noqa: BLE001 - # A label is a nicety; failing to read one must not make the agent unopenable. - row["flow"]["actions"][0]["config"]["keychain"] = "Odoo" - try: - from harness import datastore as ds # noqa: PLC0415 - # ⭐ THE LAST RUN IS THE MIRROR'S OWN `_sync_state`, not a run history we would have to - # write. That table already carries a real per-entity `updated` stamp, so the card reports - # what actually happened rather than what this module remembers happening. - st = ds.status() or {} - stamps = sorted(str((v or {}).get("updated") or "") for v in st.values() if v) - last = [s for s in stamps if s] - if last: - row["status"] = {**(row.get("status") or {}), "state": "ok", "lastRunAt": last[-1], - "lastSummary": f"{len(last)} datasets synced"} - except Exception: # noqa: BLE001 - pass - return row - - -def _patch_odoo_sync(session, body): - """⭐⭐ CONTRACT C4 — THE ONE THING ON THIS CARD THAT IS EDITABLE: how often it runs. - - C4: *"Its Canvas is READ-ONLY except `trigger.config.everySeconds`, which writes through to the - same value `odoo_relational.sync_seconds` reads, so the card cannot drift from the running - loop."* - - ⛔ IT WRITES THE CONNECTOR'S OWN CONFIG KEY, NOT AN AUTOMATION. `rel.CONFIG_KEY` / - `rel.SYNC_PRESETS` are the same key and the same vocabulary `sync_seconds` reads and - `_store_resync_loop` sleeps on, so there is one value and the card reads back exactly what the - loop will use. Storing a cron on a synthetic automation would have been a second copy with a - guaranteed drift date. - - ⚠ EVERY OTHER FIELD IS REFUSED RATHER THAN IGNORED. A PATCH that silently kept only the part - it liked would let somebody rename this agent, watch the 200, and find the name gone on reload - — the failure shape this module has already paid for twice. - - ⚠ AND THE LATENCY IS REPORTED, because it is real and a person would otherwise call it a bug: - the loop re-reads the cadence at the TOP of its cycle and then sleeps, so a change takes effect - on the NEXT pass — up to one full OLD interval away (24 hours if it was on `daily`). - """ - import odoo_relational as rel # noqa: PLC0415 - - known = {"trigger", "every", "syncEvery"} - extra = sorted(k for k in body if k not in known) - if extra: - raise err(409, "system_agent", - "this agent is the Odoo connection's own sync. Only how often it runs can be " - f"changed here; {', '.join(extra)} belongs to the connector in Connectors") - every = str(body.get("syncEvery") or body.get("every") - or ((body.get("trigger") or {}) if isinstance(body.get("trigger"), dict) else {}) - .get("preset") or "").strip().lower() - if every not in rel.SYNC_PRESETS: - raise err(400, "bad_cadence", - f"pick one of: {', '.join(sorted(rel.SYNC_PRESETS))}") - - def _up(cur): - cur = dict(cur) if isinstance(cur, dict) else {} - cur["syncEvery"] = every - return cur - - session.runtime.update(rel.CONFIG_KEY, _up, flush="sync") - row = _odoo_sync_row(session, detail=True) - if row is None: - raise err(404, "unknown_automation", "no automation with that id") - secs = row["trigger"].get("everySeconds") - note = ("It syncs on a timer again from the next cycle." if secs - else "Nothing will sync on a timer now.") - return {"automation": row, - "notes": [f"The Odoo sync is set to {every}. {note} A change takes effect on the " - f"loop's next pass, so it can be up to one of the OLD intervals away."]} - - -def _triggers_vocab(session): - """C3's server-owned trigger list: `[{key, label, ready, needs, planned}]`, keys EXACTLY - `engine.TRIGGER_KEYS` + `engine.TRIGGER_PLANNED`. `ready:false` + `needs` renders as a - not-configured state — never a dead control, never a client-side union. - - ⭐ WAVE 23 (R2): the list is the WHOLE Airtable-parity vocabulary, and the two triggers we - have not built ride it with `planned: true`. That is the honest version of "show all, wire - eight": the picker paints them faded with a reason instead of a shorter list that quietly - implies the missing ones do not exist. `clean_trigger` refuses them, so the faded state is - enforced at the door and not merely in the client's `disabled` attribute. - """ - tick_on = _tick_state()["enabled"] - g = oauth_connect.status(session.runtime, session.uname).get("google") or {} - email_ready = bool(g.get("configured")) and bool(g.get("connected")) \ - and not g.get("reconnect") - email_needs = "" if email_ready else ( - "connect_gmail" if g.get("configured") else "configure_google") - per = { - "manual": (True, ""), - "schedule": (tick_on, "" if tick_on else "arm_tick"), - "event_field": (True, ""), - "record_updated": (True, ""), - "record_created": (True, ""), - "enters_view": (True, ""), - "webhook": (True, ""), - "email": (email_ready, email_needs), - "form_submitted": (True, ""), - # ⭐ WAVE 24 (C-TRIG) — Instagram discovery, now a trigger. Readiness is the vendor key, - # the same bit `paidReady` carries: with no key the search door is closed and the picker - # must say so rather than offering a control that silently finds nothing. `clean_trigger` - # still ACCEPTS it either way — readiness is a deployment fact, not a validity one, which - # is the same split `email` already makes. - "ig_profile_match": (engine.bd_ready(), "" if engine.bd_ready() else "configure_brightdata"), - # ⭐ WAVE 29 (D-9 / R1) — TikTok, live. ⛔ IT NEEDS ITS OWN ROW EVEN THOUGH THE ANSWER IS - # IDENTICAL, and the reason is the `.get(k, (True, ""))` default below: a trigger this dict - # forgets is reported READY, so a deployment with no vendor key would offer TikTok search - # as configured and the search would find nothing. Same key, same readiness bit, stated. - "tiktok_profile_match": (engine.bd_ready(), - "" if engine.bd_ready() else "configure_brightdata"), - } - #: ⭐ WAVE 24 — the server's own one-line description per trigger. C-TYPES: the picker renders - #: THIS under the option, because a CLIENT paraphrase of a server vocabulary is a second copy - #: of it, free to drift. Absent = the client shows nothing, never something invented. - detail = { - "manual": "It runs only when you press Run now", - "schedule": "It runs on a repeating schedule", - "event_field": "A record in the database starts matching a condition you set", - "record_updated": "Any of the columns you watch is changed", - "record_created": "A new record is added to the database", - "enters_view": "A record starts appearing in a saved view", - "webhook": "Something outside calls this automation's URL", - "email": "A message arrives in the connected mailbox", - "form_submitted": "Somebody submits one of this database's forms", - "ig_profile_match": "Search Instagram for profiles matching your filters, on a schedule", - "button_clicked": "Somebody presses a button on a record", - "comment_added": "Somebody comments on a record", - "web_page_changed": "A page you are watching is different from last time", - "tiktok_profile_match": "Search TikTok for profiles matching your filters, on a schedule", - } - - def _taxonomy(k): - """⭐ WAVE 25 · C2 — the four taxonomy keys, composed from the ENGINE's maps. - - ⛔ `.get(k) or FALLBACK`, NEVER `TRIGGER_GROUP_OF[k]`. The first draft of this indexed the - map on the reasoning that a default is how a Connector trigger quietly appears under - Database — and the gate rejected it, correctly, against the incident `per.get(k, ...)` - eight lines below records: a key added to `TRIGGER_KEYS` without remembering a dict beside - it raised KeyError and took `GET /automations` down, i.e. the whole surface, which polls - this every 2.5 s. A mis-grouped row is cosmetic; a 500 is not, and the ranking is not - close. - ⚠ THE CLASSIFICATION IS STILL MANDATORY — it is enforced at the GATE (no shipped trigger - may land in `other`) rather than at the request. Soft here, hard there. - """ - g = engine.TRIGGER_GROUP_OF.get(k) or engine.TRIGGER_GROUP_FALLBACK - return {"group": g, - "groupLabel": engine.TRIGGER_GROUPS[g]["label"], - "groupOrder": engine.TRIGGER_GROUPS[g]["order"], - # The SUB-group inside "Connector"; None everywhere else. ⚠ Group by this key, - # render its label — it is NOT a connector-directory slug (see the engine's note). - "connector": engine.TRIGGER_CONNECTOR.get(k), - # D-55: "the cron drives this one". Derived from the engine's schedule set MINUS - # `manual`, so the client's `CRON_DRIVEN_TRIGGERS` copy can be deleted. - "schedules": k in engine.TRIGGER_CRON_KEYS} - - out = [] - for k in engine.TRIGGER_KEYS: - # ⚠ `.get` WITH A DEFAULT, NOT `per[k]`. This loop walks the ENGINE's vocabulary and - # indexed a hand-maintained dict beside it: adding a key to `TRIGGER_KEYS` without - # remembering this dict raised KeyError and 500'd `GET /automations` — the payload the - # whole automation surface polls every 2.5 s — with every gate and `tsc` still green. - # Defaulting to "ready, needs nothing" is the honest fallback: a trigger the engine - # offers and this route has no readiness opinion about is simply available. - ready, needs = per.get(k, (True, "")) - row = {"key": k, "label": engine.TRIGGER_LABELS[k], "ready": ready, "needs": needs, - "planned": False, "detail": detail.get(k, ""), - # A3(3): the connect affordance is SERVER-COMPOSED — the client never maps a - # `needs` token to a route, so B's CONNECT_PROVIDERS shim deletes itself. - "connect": None, **_taxonomy(k)} - if not ready and needs == "connect_gmail": - row["connect"] = {"provider": "google", - "startUrl": "/api/v1/oauth/google/start"} - out.append(row) - for k in engine.TRIGGER_PLANNED: - out.append({"key": k, "label": engine.TRIGGER_LABELS[k], "ready": False, - "needs": "coming_soon", "planned": True, "connect": None, - "detail": detail.get(k, ""), **_taxonomy(k)}) - return out - - -def _tick_state(): - """⭐ WAVE 21 (C6 amendment A1) — can a SCHEDULE fire on this deployment? - - TWO independent paths can: the in-process scheduler (`AIOS_AUTOMATIONS=1`, - `automation_engine.py` module bottom) and an external cron POSTing `/automations/tick`, - gated on `AIOS_AUTOMATION_TICK_TOKEN` (AWS EventBridge in production). The Step-1 Trigger - card must be honest in both directions: "schedules won't fire" on a deployment where - EventBridge demonstrably fires them daily is the exact lie R9 forbids. `external` means - "the door is OPEN", never "the caller is alive" — the client's copy says so.""" - inproc = os.environ.get("AIOS_AUTOMATIONS") == "1" - ext = bool(os.environ.get("AIOS_AUTOMATION_TICK_TOKEN")) - return {"enabled": bool(inproc or ext), - "source": "in-process" if inproc else ("external" if ext else "")} - - -#: W29-T01 — the Board retirement ran, per tenant, this process. Same shape and same reasoning as -#: `_C8_MIGRATED` below: a one-way cleanup of data no living code path creates any more, whose -#: cost is a full walk of every row-cell in the tenant and whose result on pass 2..N is always -#: "nothing to do". ⚠ A module-global keyed by tenant is deliberately NOT reset by a table create -#: or delete — a new database cannot contain the legacy stage cells this retires. -_BOARD_RETIRED = set() - -#: ⭐ WAVE 35 · T37 — tenants whose statements agent has been considered THIS PROCESS. Same shape as -#: `_BOARD_RETIRED` and, like it, purely a cost saver: the DURABLE idempotency is the row's own -#: existence, which `engine.seed_statements_agent` re-checks inside its own store mutation. -#: ⚠ SO A RESTART RE-CHECKING IS HARMLESS BY CONSTRUCTION, which is the property to preserve. If -#: this set were ever the only guard, a process restart would mint a second agent. -_STATEMENTS_SEEDED = set() - -#: ⭐⭐ WAVE 30 · T12 — THE PICKER DEFAULT, MEMOISED. `{tenant: (stamp, table_key)}`, the shape -#: `scope_cache` stores. -#: -#: ⛔ WHY THE DERIVED STRING AND NOT THE DOCUMENT. The obvious cache here is the `user_tables` -#: bucket itself, and it is the wrong one: that bucket is up to 35.8 MB per tenant and this box is -#: the HF free tier, so caching it would trade a latency problem for a memory one. What the warm -#: path actually needs is `discover_default_table`'s ANSWER — one short string. -#: -#: ⛔ AND WHY NOT A PLAIN `_BOARD_RETIRED`-STYLE ONCE-PER-PROCESS SET, which would have been less -#: code: the election reads which profile databases exist and which hold rows, and BOTH change -#: while the process lives (somebody creates a database, an automation writes the first row). A -#: once-per-process memo would pin the picker's default to whatever was true at boot and never -#: correct itself — a default that disagrees with the save door, which is exactly the wave-25 R2 -#: defect the `"table"` line's own comment records. A TTL bounds the staleness instead. -#: -#: ⚠ `scope_cache` rather than a hand-rolled dict, because it is the house pattern for precisely -#: this (`routes_customers`, `routes_products`, `pages` all use it) and it is stale-while-refresh: -#: once a copy exists NO request blocks on a rebuild. Automation was the one module importing it -#: nowhere, which the wave-30 scout named as the reason every other surface feels fast. -_DISCOVER_DEFAULT = {} -#: 5 minutes — the same order as `apiBridge.ts:CUSTOMERS_FRESH_MS` on the client. Overridable so a -#: gate can pin it rather than sleep. -_DISCOVER_DEFAULT_TTL = float(os.environ.get("AIOS_AUTOMATION_DEFAULT_TTL") or 300) - - -#: The one store key `_LentTables` intercepts. DERIVED from the engine's own constant rather than -#: written out here: `engine.ut_all` reads the bucket through it, so if that key ever moves, the -#: lend moves with it instead of silently becoming a pass-through that still looks correct. -_UT_STORE_KEY = engine.UT_STORE_KEY - - -def _LentTables(runtime, tables): - """⭐⭐ WAVE 30 · T13, NOW W31-C1 — a read-only `st` that serves ONE already-read `user_tables` - document and passes every other key straight through to the real runtime. - - ⭐⭐ WAVE 31 · C1 — THE BODY IS NOW `core.user_tables.lend`, AND THE CLASS THAT USED TO BE HERE - IS GONE. W30's version carried its own note that the general fix was unavailable at the time: - *"adding a `tables=` parameter to `may_open` would mean editing platform/core/user_tables.py, - which belongs to another lane this wave."* It is session B's lane THIS wave, they built the - generalisation (`user_tables.lend` + the `_LENDABLE` allow-list) for W31-T10, and this is the - second caller adopting it rather than becoming a third copy of the shape. - - ⭐ AND THE SWAP FIXES SOMETHING THIS FUNCTION NEVER COVERED. The old class intercepted exactly - one key, so `may_open`'s LAST branch — `shares.may_see` → `role_for` → `st.get('object_shares')` - — still took a fresh read PER TABLE. `_LENDABLE` covers both buckets, so a shared database no - longer costs a second whole-document read per row of the picker. The name is kept because - `verify_automation`'s NC66 pins this symbol as the thing it replaces to restore the `1 + N` - state; keeping it a function means that control still has exactly one seam to swap. - - ⛔ THE OBVIOUS FIX IS STILL THE FORBIDDEN ONE. Inlining the creator-or-admin test here would - remove the N reads and re-create the exact defect wave 20 fixed: this route USED to carry its - own wider rule (`createdBy in (uname, 'automation', 'scheduler')`), so a non-admin saw a - database in the picker and was refused the moment they opened it. `may_open` stays THE one - resolver, unmodified and still called per table; it is simply no longer charged for a document - the caller is already holding. - """ - # ⚠ IMPORTED HERE, not at module scope — `core` is deliberately kept out of this module's - # import-time graph (the same reason `automation_tables` does it locally 300 lines down). - import core.user_tables as _ut - return _ut.lend(runtime, **{_UT_STORE_KEY: tables}) - - -def _discover_default(session, tables): - """The discovery picker's default table for this tenant — WITHOUT a bucket read on a warm call. - - ⚠ `tables` is the document the caller ALREADY holds on a cold call (the retirement pass reads - one). Passing it through means the cold path elects from the copy it has rather than taking a - second one, so this is never an extra read — only ever a saved one. - """ - if not session.runtime.available(): - return "" - if tables is not None: - # Cold call: the document is in hand. Elect from it and prime the memo in the same pass. - value = engine.discover_default_table(session.runtime, tables=tables) - _DISCOVER_DEFAULT[session.tenant] = (time.time(), value) - return value - return scope_cache.get(_DISCOVER_DEFAULT, session.tenant, _DISCOVER_DEFAULT_TTL, - lambda: engine.discover_default_table(session.runtime)) - - -def warm_default(rt): - """⭐⭐ WAVE 30 · T12, THE COLD HALF. Elect the discovery picker's default at BOOT. - - ⛔ THE HALF THE MEMO CANNOT FIX, and it is why this exists rather than being a nicety. The memo - above makes calls 2..N free; **call 1 is still a full `user_tables` download**, and it lands on - whoever clicks Automation first after a deploy — the one visitor with no cache anywhere, - waiting on a document whose documented ceiling is 35.8 MB / ~1.4 s, taken under the store's - single lock. That is the owner's *"only automation has a loading screen"* on a cold Space, and - two waves of memoising the warm path could never touch it. Automation was the only module - importing `scope_cache` nowhere AND the only one absent from `_prewarm`. - - ⭐ IT ELECTS, IT DOES NOT CACHE THE DOCUMENT — deliberately, and this is the whole design. - `discover_default_table` reads the bucket once here, in the prewarm daemon thread where nobody - is waiting, and what survives is a DERIVED STRING. Holding the 35.8 MB document resident would - trade a latency the tenant notices for memory the HF free tier does not have, which is the - argument `_DISCOVER_DEFAULT`'s own note makes against caching it. - - ⚠ SAME TTL AS THE REQUEST PATH, not a permanent set: the election reads which databases exist - and which hold rows, and both change while the process lives. Priming by assignment is exactly - what the cold request path already does one function up, so there is one way this memo is - filled, not two. - - Returns the elected key (`""` when the store is unavailable), so a caller can log it rather - than guess whether the warm-up did anything. Called from `main.py:_prewarm` only. - """ - tenant = str(getattr(rt, "key", "") or "") - if not tenant or not rt.available(): - return "" - value = engine.discover_default_table(rt) - _DISCOVER_DEFAULT[tenant] = (time.time(), value) - return value - - -@router.get("/automations") -def list_automations(session: Session = Depends(_GATE)): - """`{automations: [...], kinds: [...], cronPresets: [...]}` — the rail's whole payload. - - The vocabularies ride WITH the list rather than sitting in a client constant: the cron - presets and the kind list are the server's, and a client copy of either is a thing that goes - stale silently (the editor would offer a preset the parser rejects).""" - # ⭐⭐ WAVE 29 (W29-T01, owner item 5: "Automation takes a while to appear"). THE COMPLAINT WAS - # THIS ROUTE, and the cost was never the automations: it read the whole `user_tables` bucket - # THREE TIMES per call — once for the stage-field retirement scan, twice more inside - # `discover_default_table`. That bucket's documented ceiling is 35.8 MB / ~1.4 s to serialize - # (`automation_engine.py` header) and `core/store.py` re-serializes on EVERY `.get()`, hit or - # miss, UNDER THE STORE LOCK — so the reads also serialize behind each other. ~4 s of deep - # copying to render a rail that shows a name and a toggle. - # - # Now: ONE read, lent to both callers. And the retirement SCAN — O(all row-cells in the - # tenant), which hits its own `if not stage_keys: return` only AFTER walking every row of - # every table — runs once per tenant per process, mirroring the `_C8_MIGRATED` guard below. - # - # ⛔ WHY SKIPPING THE SCAN ON CALLS 2..N IS SAFE, and it is not "because it is idempotent": - # `all_definitions` STRIPS retired board state on every read (`automation_engine.py`'s - # `_without_retired_board`), so the persist step is hygiene, not correctness. No client can - # ever be shown state this skip left behind. Nothing writes new `stage_auto_` cells either — - # wave 26's R6 deleted the board that made them. - # ⭐⭐ WAVE 30 · T12 (owner items 4/5, the complaint that has now survived TWO waves). - # ⛔ W29-T01 GUARDED THE SCAN AND NOT THE READ, and that is the whole of what was left. The - # line below used to be unconditional — every single request deep-copied the tenant's entire - # `user_tables` document (documented ceiling 35.8 MB / ~1.4 s, and `core/store.py:Store.get` - # re-serializes on EVERY `.get()`, hit or miss, UNDER THE STORE LOCK so the copies also queue - # behind each other) — while on calls 2..N the result was DISCARDED: `tables` had exactly two - # consumers, the `_BOARD_RETIRED` scan (skipped after call 1) and a picker DEFAULT STRING. - # A whole-tenant document, per request, to render a rail showing a name and a toggle. - tables = None - if session.runtime.available() and session.tenant not in _BOARD_RETIRED: - tables = engine.ut_all(session.runtime) - _BOARD_RETIRED.add(session.tenant) - # Idempotent Board retirement removes only engine-marked stage fields and legacy Board - # state. User-created Status/Stage columns remain intact. - engine.retire_automation_board_state(session.runtime, tables=tables) - # ⭐⭐ WAVE 35 · T37 / OWNER RULING R10 — ROYAL IMPORTS' STATEMENTS AGENT EXISTS BEFORE ANYBODY - # ASKS FOR IT. It is minted ONCE, by the container, SWITCHED OFF; every subsequent call finds - # it present and returns immediately. See `engine.seed_statements_agent` for why this one is - # STORED where wave 34's Odoo agent is derived, and why the row's existence is a sufficient - # idempotency key (it cannot be deleted). - # ⚠ GUARDED SO IT CAN NEVER TAKE THE RAIL DOWN. A tenant with no Odoo, a store mid-outage or a - # validator change must degrade to "no statements agent", never to a 500 on the one route the - # whole Agents surface polls. The same posture `_odoo_sync_row` takes for the same reason. - if session.tenant not in _STATEMENTS_SEEDED: - try: - engine.seed_statements_agent(session.runtime, username=session.uname or "system") - except Exception: # noqa: BLE001 - pass - _STATEMENTS_SEEDED.add(session.tenant) - defs = engine.all_definitions(session.runtime) - items = [_wire(d, session.tenant, rt=session.runtime) for _, d in - sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())] - # ⭐⭐ WAVE 34 · CONTRACT C3 (W34-T48) — field agents join the list as SYNTHETIC rows. - # ⚠ MERGED AND RE-SORTED, not appended in a block at the end. R13 asks for a field agent to be - # "VIEWABLE under the Agent module", i.e. one list of agents, not a list with a second list - # stapled to it — and a rail that sorts by name everywhere except its last few rows reads as a - # rendering bug. `_field_agent_rows` costs no whole-document read; see its docstring. - _sys_rows = [r for r in (_odoo_sync_row(session),) if r] - items = sorted(items + _field_agent_rows(session) + _sys_rows, - key=lambda r: str(r.get("name") or "").lower()) - return {"automations": items, - # ⭐ WAVE 24 — DERIVED from the engine's own `KINDS`, not a hand-written trio. It was - # three literals that happened to match, which is a second copy of a server - # vocabulary; R6 has just made two of them uncreatable and `plain` has joined, so a - # hand list would now be wrong in three ways at once. `creatable` carries R6 onto the - # wire, so the ruling is a fact the client can read rather than one it must remember. - "kinds": [{"key": k, "label": engine.KIND_LABELS.get(k, k), - "creatable": k not in engine.RETIRED_KINDS} - for k in engine.KINDS], - "cronPresets": engine.CRON_PRESETS, - # ⚠ A BOOLEAN, NEVER THE KEY. The surface needs to say "the paid rung is not - # configured" honestly instead of offering a tier that will silently refuse — and - # that needs exactly one bit. Shipping the key itself to a browser would put a - # billable secret in every user's devtools. - "paidReady": engine.bd_ready(), - # THE SOURCE REGISTRY (D-9's seam), on the wire for the same reason `cronPresets` is: - # the module that RUNS a source is the only thing entitled to say what it can do, and - # a client copy of "Instagram can discover, TikTok cannot" goes stale in silence. - "sources": engine.source_status(), - # The discovery vocabulary, likewise server-owned: every name here is MEASURED- - # accepted by the vendor's own validator, so a client that invented one would build a - # query the API rejects. `lead` is the subset seen carrying VALUES on real rows. - # ⭐⭐ WAVE 32 · T46 (D-167) — THE VOCABULARY HAS A PLATFORM, AND `byKind` IS ADDITIVE - # ON PURPOSE. `fields`/`lead` keep INSTAGRAM's 21 and 3, so no stored automation and no - # client that has not adopted this changes behaviour today; `byKind` carries the per- - # corpus answer, and the SERVER already refuses a TikTok predicate naming one of the 16 - # fields TikTok's dataset does not have (`clean_predicates(..., kind)`). The door is - # closed either way — this is what lets the Find panel stop OFFERING them. - # ⚠ Derived through `engine.filter_fields`, the same accessor the validator uses, so - # the published vocabulary and the enforced one cannot drift — which is precisely what - # D-167 was: a route serving 21 names and a validator checking the same 21, both wrong - # about TikTok together, with nothing able to notice. - "discoverByKind": {k: {"fields": list(engine.filter_fields(k)[0]), - "lead": list(engine.filter_fields(k)[1])} - for k in engine.DISCOVERY_KINDS}, - "discover": {"fields": list(engine.BD_FILTER_FIELDS), - "lead": list(engine.BD_FILTER_LEAD), - "operators": list(engine.BD_FILTER_OPS), - "nullaryOperators": list(engine.BD_NULLARY_OPS), - "maxRecords": engine.BD_MAX_RECORDS, - # Wave 22 C4 — ADDITIVE: per-field flags for the toggle rows (the 3 - # PII fields stay structurally absent, R3) + the too-big guard's - # numbers, so the surface can say WHY a filter is refused before the - # server has to. - "filterMeta": engine.filter_meta(), - "guard": {"minNarrowing": engine.BD_MIN_NARROWING, - "maxRecords": engine.BD_MAX_RECORDS}, - # ⛔ D-59's `categoryOptions` IS DELIBERATELY NOT HERE — it is served by - # `GET /automations/discover/categories`. It sat on this payload for one - # commit and that was a real defect: THIS ROUTE IS POLLED EVERY 2.5 s by - # the whole automation surface, and deriving the options reads two user - # tables plus the PLATFORM MASTER, which is a different HF repo — i.e. a - # network round-trip per poll, per open tab. Caught by the gate's own - # output, which started carrying `store:get:master_snapshots` errors from - # a suite whose stated contract is that it touches no network. - # C6/R5's vocabulary, so the seed picker cannot offer a source the - # validator refuses. - "seedSources": list(engine.SEED_SOURCES), - "seedMaxRows": engine.SEED_MAX_ROWS, - # W29-T01: `tables` is the snapshot read once at the top of this handler. - # ⚠ IT WAS TAKEN BEFORE THE RETIREMENT WROTE, and that is deliberate and - # harmless: retirement only removes `stage_auto_*` FIELDS and pops those - # same keys off rows, and the election reads the preset-profile vocabulary - # (`handle` + N preset keys) and whether a table has ANY rows. A stage key - # is in neither set, and no row is ever deleted — so the pre-write - # snapshot and the post-write bucket cannot elect different tables. - # 2026-08-10 — the OFFER must name the table the SAVE will actually use. - # This was the bare `DISCOVER_TABLE` constant while `create`/`patch` now - # resolve a targetless discovery flow to the profile database the tenant - # already has, so the picker would have shown `ut_ig_candidates` and the - # save would have written somewhere else — a default that disagrees with - # itself across two panels, which is the shape wave 25's R2 fixed for - # `targetTable` vs the action's `table`. - # ⭐ WAVE 30 · T12 — through the per-tenant memo. The ELECTION rule and - # everything the comment above says about it are unchanged; what changed - # is that a warm request no longer re-reads a 35.8 MB document to - # recompute a string that did not move. - "table": (_discover_default(session, tables) - or engine.DISCOVER_TABLE)}, - "storeAvailable": bool(session.runtime.available()), - # Wave 22 C3 — the trigger vocabulary, session-scoped because email readiness is a - # per-USER fact (the poll runs through the creator's own Gmail connection). - "triggers": _triggers_vocab(session), - # ⭐ WAVE 23 C4 (R3) — the ACTION MENU, including what we have not built. Each row - # carries `ready`, so B paints "Send email" and "Run script" faded with the server's - # own reason instead of omitting them — the owner asked for Airtable's full menu, and - # a shorter list would imply those actions do not exist. `clean_actions` REFUSES an - # unready kind, so the faded state is a wall rather than a styling choice. - # ⭐ W35-T35 (C8): `session.runtime` is what withholds a TENANT-GATED row. Passing it - # here is the whole of "the menu does not offer Send statements to another tenant"; - # the STORE-side wall is `clean_actions(rt=)`, deliberately separate, because a picker - # is not a security boundary [[opening-a-route-widens-every-field]]. - "actionsCatalog": engine.action_catalog(session.runtime), - # The builder's own vocabulary: how deep a condition tree may nest, how deep groups - # may nest, and the ceilings. B reads these instead of hard-coding the same numbers - # into its "+ Add condition" affordance. - "flow": {"condOps": list(engine.LANE_OPS), - "nullaryCondOps": list(engine.LANE_NULLARY_OPS), - "maxCondDepth": engine.MAX_COND_DEPTH, - "maxCondChildren": engine.MAX_COND_CHILDREN, - "maxGroupDepth": engine.MAX_GROUP_DEPTH, - "maxActions": engine.MAX_ACTIONS, - # ⭐ W29-T09 (owner item 11) — THE POST-GROUP VOCABULARY, on the wire for the - # same reason `cronPresets` is: `clean_post_groups` REFUSES a type it does - # not know, so a client that invented one would build a config the save door - # rejects with a sentence about a word the person never typed. Both halves - # ride — the stored key and the label a person reads — because a client-side - # translation of `video` into "Reels" is a second copy of this list. - "postTypes": [{"key": t, "label": engine.POST_TYPE_LABELS.get(t, t)} - for t in engine.POST_TYPES], - # The ceiling a group's limit is judged against. `clean_post_groups` bounds a - # group by the action's OWN `maxPosts`, not by a constant, so the control can - # only warn honestly if it reads the same number the validator uses. - "maxPostsPerPull": engine.MAX_POSTS_PER_PULL, - # ⭐⭐ W33-T52 (owner item 16) — WHICH CONFIG KEYS A KIND REQUIRES, on the - # wire, for exactly the reason `postTypes` above is: the panel paints a red - # `*` beside a required control, and a client-side table of which keys those - # are would be a SECOND copy of `ACTION_REQUIRED` living in another file. The - # two would agree on the day they were written and diverge the first time a - # kind gained a key — the panel would then mark a control optional that the - # runner blocks on, and the person would read "this is fine" from the one - # surface that is meant to tell them it is not. - # ⛔ BOTH HALVES RIDE, phrase AND key, because the phrase is the ONLY human - # wording of that requirement anywhere ("a column to write into"), and the - # runner's own refusal sentence is built from it. A client that re-worded it - # would give the same requirement two names. - # ⚠ It is `ACTION_REQUIRED`, not `WEB_REQUIRED`: the table is the one the - # run refusal and `unconfigured_actions` already read, so a kind added to it - # later paints its `*` with no client change at all. - "actionRequired": {kind: [{"phrase": phrase, "key": key} - for phrase, key in reqs] - for kind, reqs in engine.ACTION_REQUIRED.items()}}, - # Wave 21 C6-A1: {"enabled": bool, "source": "in-process"|"external"|""} — see - # `_tick_state` for why one boolean off AIOS_AUTOMATIONS alone would lie. - "tick": _tick_state()} - - -@router.post("/automations/discover/estimate") -def discover_estimate(body: dict = Body(default=None), session: Session = Depends(_GATE)): - """What would this search cost? Shown BEFORE the run, never after. - - ⚠ THE ANSWER IS AN ESTIMATE AND SAYS SO IN ITS OWN PAYLOAD (`basis: "SPEC"`). Bright Data - never returns a price before a run — the funds gate fires first and `price: 0` means "not - priced", not "free" — and this account's token cannot read a balance (`/customer/balance` - answers 403). A number presented as billed would be the invented measurement this whole - module refuses to make. - """ - return engine.discover_estimate((body or {}).get("recordsLimit")) - - -#: C8's migration ran, per tenant, this process. Once is the point: the sweep only writes when -#: an unbound bag exists, so after the first pass this is a read that finds nothing. -_C8_MIGRATED = set() - - -def _table_views(session, key): - """⭐ WAVE 24 (item 8) — the saved views on ONE user table as `[{id, label}]`, or **None** - when the workspace bucket could not be read at all. - - The client said "This server did not offer a view list" because the payload genuinely had - no `views` key. C-TYPES: **absent stays ABSENT.** `[]` is a MEASUREMENT ("this database has - no saved views") and `None` is a STATE ("nobody looked") — collapsing them is precisely how - an empty picker comes to read as a claim about the database. - - ⛔ SOURCED FROM `core.table_store`, WHICH IS THE READER `view_filter` ALREADY USES — not a - second one, the same one, listed instead of looked up. The brief said to source it the way - the grid does; the grid's projection (`aios_grid.views_from_defs`) is the wrong list HERE and - the difference matters: it INJECTS the system view ("All records") and PROJECTS cohorts as - views, and neither of those lives in the stored bucket — so `view_filter` answers "view no - longer exists" for every one of them. A picker built from that projection would offer options - the `enters_view` trigger cannot resolve, which is a worse bug than the missing key it fixes. - (An `enters_view` trigger on "All records" would also mean "fire on every record", so its - absence is correct rather than a gap.) - - ⚠ `consume_corrections=False`: this is a READ for a picker, and the default MUTATES — it - takes and clears the pending field-correction acknowledgement, so listing views would eat a - protocol message meant for the grid. - """ - try: - import core.table_store as table_store - # ⛔ THE REACHABILITY PROBE IS NOT REDUNDANT, and leaving it out was a real defect this - # gate's own NC caught: EVERY `TableStore` accessor wraps its store read in `try/except` - # and returns `{}` on failure. So a bucket that could not be read is indistinguishable - # from one with no views — which collapses exactly the ABSENT/EMPTY distinction this - # function exists to preserve, and the caller would ship `views: []` as a measurement - # nobody took. The one read that is allowed to raise has to be ours. - session.runtime.get(f"{key}_table_workspace") - tops = table_store.make(f"{key}_table_workspace", st=session.runtime) - own = (tops.workspace(session.uname, consume_corrections=False) or {}).get("views") or {} - shared = tops.shared_views(session.uname, session.admin) or {} - except Exception: # noqa: BLE001 - return None - merged = {**shared, **own} # a view lives in ONE home; the merge is belt-and-braces - return sorted( - ({"id": str(vid), "label": str((v or {}).get("name") or vid)} - for vid, v in merged.items() if isinstance(v, dict)), - key=lambda r: r["label"].lower()) - - -@router.get("/automations/tables") -def automation_tables(session: Session = Depends(_GATE)): - """The blank databases an automation can target, with their fields. - - ⚠ A READ-ONLY MIRROR of C3-UT's `GET /api/v1/tables`, under this router's own prefix so the - two can never collide. It exists because the automation editor needs the table+field list to - build a config at all, and D must not block on A's route landing. When C3-UT is live this - keeps working (same bucket) — it is a duplicate reader, never a second writer. - - ⛔ WAVE 20, item 3 — IT NO LONGER MIRRORS THE WALL, IT CALLS IT. This route re-implemented - `may_open` and got it WIDER: it also admitted `createdBy in ('automation', 'scheduler')`, so - a non-admin saw automation-created databases here and was refused the moment they opened, - edited or deleted one. A duplicate READER is fine; a duplicate WALL is not, because the two - only disagree in front of a user. One resolver now, and the engine stamps a human owner so - the merge takes nothing legitimate away (`ut_ensure`, `MACHINE_OWNERS`). - """ - import core.user_tables as user_tables - - # C8 (wave 22): bind pre-law automation bags to the definitions that write them — once - # per tenant per process, and a no-op read after the first real pass. A write-on-read, - # stated out loud: this is the surface whose stale bags mislead (the column picker), so - # it is where the truth gets repaired. - if session.tenant not in _C8_MIGRATED and session.runtime.available(): - _C8_MIGRATED.add(session.tenant) - try: - engine.bind_unbound_fields(session.runtime) - except Exception: # noqa: BLE001 - pass - - out = [] - # ⭐⭐ WAVE 30 · T13 — ONE read of the tenant document, LENT to the wall for every table. - # It was `1 + N` full deep copies (this `ut_all`, then `may_open` → `get` → `all_tables` per - # table), each of a document with a 35.8 MB / ~370 ms ceiling, all of them queued behind - # `Store._lock`. `may_open` is unchanged and still asked about every table — see `_LentTables` - # on why re-implementing the wall here is the one fix that is NOT available. - _tables_doc = engine.ut_all(session.runtime) - _lent = _LentTables(session.runtime, _tables_doc) - for key, t in sorted(_tables_doc.items(), - key=lambda kv: (kv[1].get("label") or "").lower()): - if not user_tables.may_open(key, session.uname, session.admin, st=_lent): - continue - row = {"key": key, "label": t.get("label") or key, - "source": t.get("source") or "Blank", - "rowCount": len(t.get("rows") or {}), - "fields": [{"key": f.get("key"), "label": f.get("label"), - "type": f.get("type") or "text", - "automation": f.get("automation") or None} - for f in (t.get("fields") or [])]} - views = _table_views(session, key) - if views is not None: - row["views"] = views # absent when the bucket did not answer — never [] - out.append(row) - return {"tables": out} - - -@router.get("/automations/presets") -def automation_presets(table: str = "", session: Session = Depends(_GATE)): - """⭐ WAVE 25 · C1 / R2b — the Instagram preset columns, diffed against ONE database. - - `{fields: [{key, label, type, present}], willUse: [...], willCreate: [...]}` — the - "already in this database" / "will be created" split the owner asked for, so the Create record - action's configuration can SHOW what pointing it here does before anything is spent. - - ⛔ DECLARED ABOVE `/automations/{auto_id}`, AND THAT IS LOAD-BEARING RATHER THAN TIDY. - FastAPI matches routes in DECLARATION order, so a literal path registered after a sibling - path-parameter route is never reached — this handler would simply never run and - `get_automation` would answer "no automation with that id" for the id `presets`. A 404 with a - plausible sentence is the worst possible failure here, because it reads as "the endpoint is - fine, the data is missing". `/automations/tables` sits above the same route for the same - reason; this follows it rather than inventing a second convention. - - ⚠ WALLED BY `may_open`, LIKE EVERY OTHER TABLE READER. Without it, asking about a table you - cannot open would answer which of its columns exist — a small disclosure, and exactly the - duplicate-wall mistake `automation_tables` records at wave 20. - """ - import core.user_tables as user_tables - - key = str(table or "").strip() - if key and not user_tables.may_open(key, session.uname, session.admin, - st=session.runtime): - raise err(404, "unknown_table", "no such database") - return engine.preset_plan(session.runtime, key) - - -@router.get("/automations/discover/categories") -def automation_categories(session: Session = Depends(_GATE)): - """⭐ DEBT D-59 — the Category combobox's options: the values this deployment has ACTUALLY - SEEN, each with its observed count. `{options: [{value, count}]}`. - - ⛔ ITS OWN ROUTE, ON PURPOSE. This belongs to the Find surface and is asked for when that - panel opens — it must never ride `GET /automations`, which the whole automation surface polls - every 2.5 s: deriving these reads two user tables AND the platform master (a different HF - repo), so on the polled payload it is a network round-trip per tab per poll. - - ⚠ THE CONTROL STAYS A COMBOBOX. These are the values we have seen, not the values that exist. - D-59 is explicit that transcribing Instagram's published taxonomy would be worse than no - dropdown — a filter on a value the corpus does not use returns zero rows and looks exactly - like an honest "no such accounts exist", which misleads precisely when it looks authoritative. - """ - return {"options": engine.observed_categories(session.runtime)} - - -@router.post("/automations/seed/derive") -def automation_seed_derive(body: dict = Body(default=None), session: Session = Depends(_GATE)): - """⭐ WAVE 25 · C6 / R5 — "find me more accounts like the ones in this view". - - Body `{source: "view"|"cohort", table: "", id: ""}` → - `{derived: [...], basis: {rows, fields:[{name,label,value,coverage}], related, note}}` - - ⛔ IT DERIVES AND RETURNS; IT SAVES NOTHING. R5: the derived conditions are "visible and - editable, never hidden" — so the client drops them into the ordinary condition rows, where the - user edits them like anything else, and the ordinary PATCH stores them. A door that both - derived and saved would make the suggestion feel like a decision. - - ⚠ `basis` IS NOT DECORATION AND MUST BE RENDERED. It carries how many rows were read and the - MEASURED coverage of each characteristic, which is the difference between "12 of 12 of these - bios say florist" and "7 of 12 do" — presented identically, the weak one reads as authority. - - ⚠ Declared ABOVE `/automations/{auto_id}` for the reason `/automations/presets` is (FastAPI - matches in declaration order); that ordering is gated. - """ - import core.user_tables as user_tables - - body = body or {} - table = str(body.get("table") or "").strip() - if not table: - raise err(400, "no_table", "name the database to read the seed records from") - if not user_tables.may_open(table, session.uname, session.admin, st=session.runtime): - raise err(404, "unknown_table", "no such database") - source = str(body.get("source") or "view").strip().lower() - if source not in engine.SEED_SOURCES: - raise err(400, "bad_seed_source", - f"a seed comes from one of: {', '.join(engine.SEED_SOURCES)}") - rows, problem = engine.seed_rows(session.runtime, table, str(body.get("id") or "")) - if problem: - raise err(400, "bad_seed", problem) - derived, basis = engine.seed_predicates(rows) - return {"source": source, "table": table, "id": str(body.get("id") or ""), - "derived": derived, "basis": basis} - - -#: The injected chat transport, for gates only — `None` in every shipped path, so the route takes -#: the real ladder. Set by `verify_automation.py` to prove this door end to end with NO API key and -#: NO spend (`routes_query._call_model`'s `chat` argument is the same idea and the same reason). -_DRAFT_CHAT = [None] - - -@router.post("/automations/draft") -def draft_automation(body: dict = Body(default=None), session: Session = Depends(_GATE)): - """⭐⭐ WAVE 33 · W33-T54/T55 (owner item 7, ruling R3) — A DESCRIPTION BECOMES A DRAFT FLOW. - - `{prompt}` → `{draft: {name, trigger, table, actions:[{id,kind,config,why}]}, provider, - dropped: [...], saved: false}` — or a 400 carrying ONE plain sentence. - - ⛔⛔ NOTHING IS SAVED HERE, AND THE `saved: false` ON THE WIRE IS NOT DECORATION. T54's - `done-when` is *"gets back a DRAFT flow they can see BEFORE anything is saved"*; a door that - wrote first and showed second would satisfy every check about the flow's contents and none about - the promise. Accepting a draft is the ORDINARY `POST /automations` — R3's *"indistinguishable - from a hand-built one"* is achieved by there being no second write path, not by making the - second one look similar. - - ⛔⛔ CONTRACT C7, AND THIS ROUTE IS WHERE IT IS ENFORCED: *"the output must pass `clean_actions` - unchanged."* So it is RUN, here, before the person ever sees the draft — and the result is - DIFFED against what the model wrote. `clean_actions` has no disclosure channel (D-75): it drops - a config key it does not recognise and answers 200. Without this diff a person would accept a - flow, open it, and find a step configured differently from the one they were shown, with nothing - anywhere having said so. `dropped` is that channel, built here because this is the first caller - that needed one. - ⚠ A draft whose actions `clean_actions` REFUSES outright is a 400, never a partial flow: half a - flow presented as a whole one is the one outcome worse than a refusal. - - ⚠ DECLARED ABOVE `/automations/{auto_id}`, and that is load-bearing rather than tidy — FastAPI - matches in DECLARATION order, so a literal path registered after a sibling path-parameter route - is never reached, and `get_automation` would answer *"no automation with that id"* for the id - `draft`. The same reason `/automations/tables` and `/automations/presets` sit up here. - """ - prompt = str((body or {}).get("prompt") or "").strip() - if not prompt: - raise err(400, "no_prompt", "type what you want the automation to do") - # ⭐⭐ ASK D-18 (2026-08-18) — THE KEY THE CLIENT SENDS IS NOW READ. This door took `prompt` off - # the body and nothing else, so the Agent chat's model toggle (W36-T34) was a control that - # configured nothing: the value was accepted and dropped, which is the shape three tickets in - # this wave already tripped over. D held T34 rather than mark a picker BUILT while its value - # stopped at the browser, and was right to. - # ⚠ UNKNOWN OR UNCONFIGURED FALLS BACK TO THE LADDER, never refuses (D-18's asked-for posture, - # and C5's everywhere else): a model the ladder stops offering must not turn every later draft - # into an error. The response already names the rung that ANSWERED, which is the honest half. - _model = str((body or {}).get("model") or "").strip().lower() - if _model in ("", "auto"): - _model = None - # ⚠ THE TENANT'S OWN TABLES, THROUGH THE EXISTING WALL. `automation_tables` applies `may_open` - # per table, so the model is shown exactly the databases this caller may already see and cannot - # name one they were not granted — the permission wall re-used, never a second one built beside - # it. (`QueryPage`'s `granted` prop carries the same clause on the client side.) - tables = (automation_tables(session) or {}).get("tables") or [] - # ⭐⭐ WAVE 34 · W34-T42 / R18 — THE DRAFTER IS OFFERED WHAT THE PICKER OFFERS, nothing more. - # - # R18 collapsed six web actions into one on the menu. The other five stay in the catalog - # because the surviving "Web agent" composes its own steps out of them server-side — but a - # DRAFT is read and accepted by a PERSON, who then edits it on the Canvas. Handing the model - # the unfiltered catalog would let it draft a step kind that is not in the action picker: the - # reader could not add a second one, could not reason about where it came from, and the flow - # they accepted would not be one they could have built by hand. R3's *"indistinguishable from - # a hand-built one"* is a claim about what a person can REACH, not only about the write path. - # ⚠ This is the ONE place the filter is applied on this route, and it is applied to the - # drafter's vocabulary only. `clean_actions` below still validates against the FULL catalog, - # so a flow that already holds a hidden kind is unaffected and nothing 400s (D-65). - # ⚠ AND `required` IS NARROWED WITH IT, which is the half that is easy to miss. `draft_flow` - # builds the ENUM from `catalog` and the "settings you must write" prose from `required`, so - # filtering one and not the other describes settings for kinds the model cannot choose. That is - # not merely untidy: it spends prompt on unreachable options and invites the model to reach for - # one. `_ai_agent_plan` already narrows both in exactly this way; this follows it. - # W35-T35 (C8): tenant-gated rows are withheld here too, or the DRAFTER would offer a kind - # the same tenant's own save door then refuses — a flow the assistant proposes and the product - # will not accept. - menu_catalog = [r for r in engine.action_catalog(session.runtime) if r.get("menu") is not False] - _offered = {r["kind"] for r in menu_catalog} - draft, refusal, provider = ai_review.draft_flow( - prompt=prompt, - catalog=menu_catalog, - required={k: v for k, v in engine.ACTION_REQUIRED.items() if k in _offered}, - triggers=_triggers_vocab(session), - tables=tables, - # ⭐ W35 · CONTRACT C7 (`NOTE E-16`) — the spend is ATTRIBUTED. Unlike the two engine call - # sites, this one has a real person behind it: somebody typed the sentence, so `user` is - # the caller rather than the automation's owner. - st=session.runtime, user=getattr(session, "uname", "") or "", - model=_model, - chat=_DRAFT_CHAT[0]) - if refusal or not draft: - raise err(400, "draft_refused", refusal or "no automation could be drafted from that") - - # ── C7: run the real save-door validator and DIFF it ────────────────────────────────── - wrote = draft.get("actions") or [] - # W35-T35 (C8): the SAME `rt` the menu was built with. Without it this validator would refuse - # a tenant-gated kind it had just offered the model — the draft door disagreeing with the save - # door about one tenant, which reads as "the assistant produced an invalid flow". - cleaned, why = engine.clean_actions([{"kind": a.get("kind"), "config": a.get("config") or {}} - for a in wrote], rt=session.runtime) - if why or cleaned is None: - raise err(400, "draft_invalid", - f"the assistant produced a flow this deployment will not accept: {why}") - dropped = [] - for before, after in zip(wrote, cleaned): - b_cfg = before.get("config") or {} - a_cfg = after.get("config") or {} - gone = sorted(k for k in b_cfg - if k not in a_cfg or str(a_cfg.get(k)) != str(b_cfg.get(k))) - if gone: - dropped.append({"kind": str(after.get("kind") or ""), "keys": gone}) - # ⛔⛔ WHAT IS STILL MISSING FROM EACH STEP, PER STEP — and this is the fix for the sharpest - # thing the verifier found. The system prompt TELLS the model *"leave one blank rather than - # inventing a web address, a CSS selector or a column name"* (which is right), and the panel - # skips empty values (which is also right, on its own). Together they meant a `web_read` with - # no selector and no target column painted as a FINISHED step — url, attr, timeout, nothing - # else — and the ordinary create door then accepted it. A person approved a step that reads - # nothing into nowhere, having been shown no blank at all. - # ⚠ `engine.action_needs` IS THE PREDICATE, not a second list: the same function the builder's - # Configured/Unconfigured label and the run refusal read. Three readers, one answer. - _tables_by_key = {str(t.get("key")): t for t in tables} - _target_cols = {str(f.get("key")) for f - in (_tables_by_key.get(str(draft.get("table") or "")) or {}).get("fields") or []} - shown = [] - for i, after in enumerate(cleaned): - row = dict(after, why=str((wrote[i] or {}).get("why") or ""), - needs=engine.action_needs(after)) - # ⛔ AND A COLUMN NAME THE DATABASE DOES NOT HAVE. `field` is a free string that nobody - # validated at draft, at accept or at store — so a model writing the column's LABEL - # ("Price") instead of its key ("price"), which is exactly what a person would say and an - # obedient model would echo, stored a write into a column that does not exist. No schema - # was violated; the flow was well-formed and did something other than what was asked. - _f = str((after.get("config") or {}).get("field") or "") - if _f and _target_cols and _f not in _target_cols: - row["unknownField"] = _f - shown.append(row) - # ⚠ A TRIGGER THE CREATE DOOR WOULD REFUSE IS CORRECTED HERE, NOT SHOWN AND THEN 400'd. The - # enum makes this need a model that ignores its own schema, but the failure mode is ugly: the - # draft paints, the person clicks Accept, and the save refuses with a sentence about a word - # they never chose. Falling back to `manual` and SAYING SO keeps the draft usable. - _trig = str(draft.get("trigger") or "").strip() - _notes = [] - if _trig and _trig not in tuple(engine.TRIGGER_KEYS): - _notes.append(f"the assistant asked for a trigger this deployment does not have " - f"({_trig}). Set to manual instead") - _trig = "" - _asked = int(draft.get("asked") or len(wrote)) - if _asked > len(shown): - _notes.append(f"the assistant wrote {_asked} steps and a draft carries at most " - f"{ai_review.MAX_DRAFT_ACTIONS}; the last {_asked - len(shown)} were not " - f"kept. Describe the job in two automations, or shorten it.") - # ⭐⭐ WAVE 34 · W34-T47 (mailbox D-5) — THE MODEL'S OWN WORDS ARE NORMALISED, BECAUSE R6 - # CANNOT BE ENFORCED BY A SOURCE SCAN. - # - # ⛔ `web_prose` READS FILES. Every string below is written by a language model at run time, - # so the gate is structurally blind to it: the sweep every lane did this wave is undone by our - # own drafter the first time it answers with an em dash. Lane D MEASURED that a prompt - # instruction does not hold (their system prompt says "Never use an em dash" and the very next - # cerebras turn came back with one), so this is code, at the boundary, not a nicer prompt. - # ⚠ THREE STRINGS REACH A SCREEN FROM HERE and all three are covered: the flow's NAME, each - # step's `why`, and the `dropped`/`notes` sentences. `W34-T42` is what made them visible. - # ⚠ A DIGIT RANGE IS A DIFFERENT SENTENCE: "10-20" means "10 to 20", and rewriting it as - # "10, 20" states two numbers where the model stated a span. It gets its own rule, first. - # Same shape as `routes_query._no_dashes`, deliberately: one behaviour, two doors. - # ⚠ MODULE-LEVEL, not a closure. A normaliser defined inside this handler is unreachable to - # anything that is not an HTTP request, so the only way to test it would be to drive the whole - # route and read the answer — which is how a rule ends up asserted by a grep instead of a call. - # ⛔ `dropped` IS NOT NORMALISED AND THAT IS DELIBERATE: it is `[{kind, keys}]`, an action kind - # from OUR catalog and config keys from OUR allowlist, never a model's sentence. The first - # version of this block mapped the normaliser over it and turned each dict into the string - # `"{'kind': ..., 'keys': [...]}"` — `verify_automation`'s own W33 section caught it with a - # `TypeError` on `d["kind"]`. Applying a text rule to a structure is how a channel stops - # carrying what its reader expects, and the reader here is a person's list of what changed. - shown = [dict(r, why=_draft_no_dashes(r.get("why"))) for r in shown] - _notes = [_draft_no_dashes(n) for n in _notes] - return {"draft": {"name": _draft_no_dashes(draft.get("name")) or "New automation", - "trigger": _trig or "manual", - "table": draft.get("table") or "", - "actions": shown}, - "provider": provider, "dropped": dropped, "notes": _notes, "saved": False} - - -# ══════════════════ WAVE 36 · W36-T38 (owner item 8, ruling R9) — AGENT-AUTHORED ACTIONS ══════ -# -# Owner, verbatim (2026-08-18): *"Create the ability for an automation agent to create any -# 'Action' under the Canvas, so its configuration can only be touched by the agent. Be it a tool a -# script etc. We need to really guardrail the reach of this script."* -# -# ⭐⭐ R9 IS A **WRITE** RULE, NOT A VISIBILITY RULE, and the ticket says so in as many words: the -# user MAY read an agent-authored action's configuration and MAY delete it; what they may not do -# is hand-edit it. Hiding a config from the person whose workspace it runs in is a different -# product, and a worse one — nobody can audit what they cannot see. -# -# ⛔ AND IT IS ENFORCED AT THE DOOR, NEVER IN A `disabled` ATTRIBUTE. `routes_automation` already -# carries that lesson for a different control; a client-side lock is a suggestion, and the whole -# point of R9 is that the agent's configuration stays coherent with what the agent believes it -# built. -# -# ⚠ WHY THE MARK IS A SEPARATE BUCKET RATHER THAN A KEY ON THE ACTION. `clean_actions` builds every -# action KEY BY KEY from an allowlist and drops anything it does not recognise (D-75) — so a -# marker stored inside the action would be silently erased on the next save, and the wall would -# quietly stop existing with every gate still green. This is an ANNOTATION layer keyed by -# `(automation id, action id)`; the configuration itself stays where it always was, in the -# automation, with exactly one writer. -AGENT_ACTIONS_KEY = "automation_agent_actions" - -#: Ids this door mints. ⚠ It must match `clean_actions`' `act_[a-z0-9_]{1,32}` or the engine -#: re-mints it and the annotation points at an action that no longer carries that id. -_AGENT_ACTION_PREFIX = "act_ag" - - -def _agent_marks(rt): - """`{auto_id: {action_id: mark}}` for one tenant. `{}` on any failure — an unreadable - annotation must degrade to "nothing is agent-owned", never to a 500 on the automations rail. - - ⛔ AND "DEGRADE TO NOTHING IS OWNED" IS THE SAFE DIRECTION HERE, which is worth stating because - it usually is not. The wall this feeds protects the AGENT's coherence, not the tenant's data: a - lost mark lets a person edit a config they own anyway, in their own workspace, on a step they - could always have deleted outright. A wall that failed CLOSED would instead make an automation - permanently unsavable because one annotation read timed out. - """ - try: - found = rt.get(AGENT_ACTIONS_KEY) or {} - except Exception: # noqa: BLE001 - return {} - return found if isinstance(found, dict) else {} - - -def _flat_actions(actions, out=None, depth=0): - """Every action in a flow, INCLUDING the ones nested inside a group's arms. - - ⚠ NESTED ACTIONS ARE THE ONES THIS MUST NOT MISS. `unconfigured_actions` walks them for the - same reason: a step inside an If/then branch is exactly the one a person cannot see, and a - wall that only looked at the top level would leave the agent's own nested step editable. - """ - out = [] if out is None else out - if depth > 6 or not isinstance(actions, list): - return out - for action in actions: - if not isinstance(action, dict): - continue - out.append(action) - for key in ("then", "else", "actions"): - _flat_actions(action.get(key), out, depth + 1) - return out - - -def _flow_actions(defn): - return _flat_actions(((defn or {}).get("flow") or {}).get("actions") or []) - - -def _same_action(left, right, rt): - """Do these two actions carry the SAME kind and configuration? - - ⛔ COMPARED AFTER `clean_actions`, ON BOTH SIDES, and that is the difference between a wall and - a nuisance. The stored action has already been through the cleaner; a client round-trip has - not, so it may carry a key order, a blank string or a dropped condition that means nothing. - Comparing raw shapes would refuse an edit that changes nothing, and a wall that fires on a - no-op is one an operator learns to route around [[one-question-two-normalizers]]. - """ - def _clean(action): - cleaned, error = engine.clean_actions([dict(action or {}, id="act_1")], rt=rt) - if error or not cleaned: - return None - one = dict(cleaned[0]) - one.pop("id", None) - return one - - a, b = _clean(left), _clean(right) - return a is not None and a == b - - -def _agent_owned_guard(session, auto_id, body): - """R9 AT THE DOOR: a user may not change an agent-authored action's kind or configuration. - - Three outcomes, and the middle one is the ruling: - * the action is ABSENT from the incoming flow -> a DELETE, and R9 allows it - * the action is present and CHANGED -> **409**, naming the action and the agent - * the action is present and identical -> nothing happens, so an ordinary save of a - flow that merely CONTAINS an agent action - is not refused - """ - marks = _agent_marks(session.runtime).get(str(auto_id)) or {} - if not isinstance(marks, dict) or not marks: - return - incoming = (body or {}).get("flow") - if not isinstance(incoming, dict) or "actions" not in incoming: - return - stored = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {} - was = {str(a.get("id") or ""): a for a in _flow_actions(stored)} - now = {str(a.get("id") or ""): a - for a in _flat_actions(incoming.get("actions") or [])} - for action_id, mark in marks.items(): - action_id = str(action_id) - if action_id not in now or action_id not in was: - continue # removed, or never stored: not an EDIT - if _same_action(was[action_id], now[action_id], session.runtime): - continue - who = str((mark or {}).get("agentName") or (mark or {}).get("agent") or "an agent") - raise err(409, "agent_owned", - f"this step was built by {who} and only {who} can change how it is set up. " - f"You can read it, and you can delete it, but it cannot be edited by hand") - - -def _prune_marks(session, auto_id): - """Drop annotations whose action is gone, and the whole entry when the automation is. - - ⚠ CALLED AFTER EVERY WRITE, because the alternative is an annotation bucket that only ever - grows: a mark on a deleted action would keep refusing an id nobody can see, and a mark on a - deleted automation would sit in the tenant document forever. - """ - auto_id = str(auto_id) - - def _set(cur): - cur = dict(cur or {}) - marks = cur.get(auto_id) - if not isinstance(marks, dict): - cur.pop(auto_id, None) - return cur - defn = (engine.all_definitions(session.runtime) or {}).get(auto_id) - if defn is None: - cur.pop(auto_id, None) - return cur - alive = {str(a.get("id") or "") for a in _flow_actions(defn)} - kept = {k: v for k, v in marks.items() if str(k) in alive} - if kept: - cur[auto_id] = kept - else: - cur.pop(auto_id, None) - return cur - - session.runtime.update(AGENT_ACTIONS_KEY, _set, flush="sync") - - -@router.post("/automations/{auto_id}/agent-actions") -def agent_author_action(auto_id: str, body: dict = Body(default=None), - session: Session = Depends(_GATE)): - """THE AGENT'S DOOR: add or replace one Action under the Canvas, marked agent-owned (R9). - - {agent: "", action: {kind, config, when?, id?}} - -> {automation, actionId, agent} - - ⛔ THE ACTION GOES THROUGH `clean_actions` LIKE EVERY OTHER ONE, and that is what "guardrail - the reach of this script" means in code: an agent cannot invent a config key, cannot name a - kind that is not in the catalog, and cannot reach a kind this tenant is not entitled to. The - agent gets a different WALL on editing, never a wider vocabulary. - - ⛔ AND IT CANNOT AUTHOR A KIND THAT IS NOT BUILT. `run_script` is `ready: False` in the - catalog, so `clean_actions` refuses it here exactly as it refuses it for a person — see this - lane's mailbox for why the script ARM is booked rather than half-built: a per-row script - contract and a client card are both missing, and a step that reports success and does nothing - is the failure this repo has already paid for. - - ⚠ THE AGENT ID IS CHECKED AGAINST THE TENANT'S OWN AGENTS. A mark naming an agent that does - not exist would refuse every future edit with a sentence naming nobody. - """ - import routes_slack # noqa: PLC0415 - - body = body if isinstance(body, dict) else {} - agent_id = str(body.get("agent") or "").strip() - agent = routes_slack._agents(session.runtime).get(agent_id) - if not isinstance(agent, dict): - raise err(404, "no_agent", "there is no agent with that id in this workspace") - action = body.get("action") - if not isinstance(action, dict) or not str(action.get("kind") or "").strip(): - raise err(400, "no_action", "an action needs a kind") - - defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) - if defn is None: - raise err(404, "unknown_automation", "no automation with that id") - if str(defn.get("system") or "").strip(): - raise err(409, "system_agent", engine.system_agent_refusal(str(defn["system"]))) - - existing = list(((defn.get("flow") or {}).get("actions") or [])) - taken = {str(a.get("id") or "") for a in _flat_actions(existing)} - action_id = str(action.get("id") or "").strip() - if not action_id or action_id not in taken: - n = 1 - while f"{_AGENT_ACTION_PREFIX}{n}" in taken: - n += 1 - action_id = f"{_AGENT_ACTION_PREFIX}{n}" - fresh = dict(action, id=action_id) - - # ⚠ VALIDATED BEFORE ANYTHING IS WRITTEN, so a refusal leaves the automation exactly as it was. - checked, error = engine.clean_actions([fresh], rt=session.runtime) - if error or not checked: - raise err(400, "invalid_action", error or "that action could not be built") - - replaced = False - for i, one in enumerate(existing): - if isinstance(one, dict) and str(one.get("id") or "") == action_id: - existing[i], replaced = fresh, True - break - if not replaced: - existing.append(fresh) - - notes = [] - updated, error = engine.patch(session.runtime, str(auto_id), - {"flow": {**(defn.get("flow") or {}), "actions": existing}}, - username=session.uname, notes=notes) - if error: - raise err(400, "invalid_automation", error) - - def _set(cur): - cur = dict(cur or {}) - marks = dict(cur.get(str(auto_id)) or {}) - marks[action_id] = {"agent": agent_id, - "agentName": str(agent.get("label") or agent.get("channelName") - or agent_id), - "created": datetime.now(timezone.utc).isoformat(timespec="seconds"), - "by": session.uname} - cur[str(auto_id)] = marks - return cur - - session.runtime.update(AGENT_ACTIONS_KEY, _set, flush="sync") - _prune_marks(session, auto_id) - return {"automation": _wire(updated, session.tenant, rt=session.runtime), - "actionId": action_id, "agent": agent_id, "notes": notes} - - -@router.get("/automations/{auto_id}") -def get_automation(auto_id: str, session: Session = Depends(_GATE)): - """One automation, by id. - - ⛔⛔ WAVE 34 · CONTRACT C3 — A SYNTHETIC ROW MUST BE FETCHABLE BY ID, NOT ONLY LISTABLE, AND - THIS IS THE HALF THAT IS EASY TO SHIP BROKEN. `list_automations` and this route are SEPARATE - lookups: a row appended only to the list appears in the rail, looks entirely real, and 404s - the instant somebody clicks it. `W34-T48`'s `done-when` is *"opening it shows a Trigger and - one step"* — i.e. this route, not the list — so the field agents are resolved here too, from - the same builder, and a person cannot reach a row the detail route does not know. - ⚠ THE SAME BUILDER, NOT A SECOND ONE. `_field_agent_rows` is called and the id looked up in - its output rather than re-deriving one row from the field: two derivations of one row is how - the list and the editor start disagreeing about a name. - """ - if str(auto_id) == ODOO_SYNC_ID: - # ⚠ `detail=True` — the keychain label and the last-sync stamp cost a store read each and - # are resolved HERE, on a route that fetches one automation, never on the list. - row = _odoo_sync_row(session, detail=True) - if row is None: - raise err(404, "unknown_automation", "no automation with that id") - return {"automation": row} - if str(auto_id).startswith("field:"): - row = next((r for r in _field_agent_rows(session) if r.get("id") == str(auto_id)), None) - if row is None: - # ⚠ The SAME 404 as any other unknown id. A field agent whose column has been deleted - # is genuinely gone, and inventing a different error for it would make a normal - # outcome look like a fault. - raise err(404, "unknown_automation", "no automation with that id") - return {"automation": row} - defn = engine.all_definitions(session.runtime).get(str(auto_id)) - if defn is None: - raise err(404, "unknown_automation", "no automation with that id") - return {"automation": _wire(defn, session.tenant, rt=session.runtime)} - - -@router.post("/automations") -def create_automation(body: dict = Body(default=None), session: Session = Depends(_GATE)): - """Create an automation. ⭐ WAVE 23 (C2): the body may carry - `target: {mode: "existing"|"new"|"automated", table?, label?}` — the wizard's FIRST question, - answered before the kind. `new` mints the blank database in the same call, so the automation - is never saved pointing at a table that does not exist yet.""" - if not session.runtime.available(): - raise err(503, "store_unavailable", "the tenant store is unavailable. Nothing was saved") - # ⭐⭐ D-75 — THE DISCLOSURE CHANNEL, CONSUMED. `clean_actions` silently drops a - # `create_record` condition (D-65's law: dropping is recoverable, refusing locks the door) and - # any config key an arm's allowlist does not keep. Both are deliberate; what was missing was - # anybody being TOLD, so a person saved a flow and got a different one with nothing said. - # ⚠ It rides the SAVE's own response, beside `unconfigured`, because that is the moment the - # person is looking — a note in a log they never open is the same silence with extra steps. - _notes = [] - defn, error = engine.create(session.runtime, body or {}, username=session.uname, - notes=_notes) - if error: - raise err(400, "invalid_automation", error) - return {"automation": _wire(defn, session.tenant, rt=session.runtime), "notes": _notes} - - -@router.patch("/automations/{auto_id}") -def patch_automation(auto_id: str, body: dict = Body(default=None), - session: Session = Depends(_GATE)): - if not session.runtime.available(): - raise err(503, "store_unavailable", "the tenant store is unavailable. Nothing was saved") - # ⛔⛔ WAVE 34 · CONTRACT C3 — A SYNTHETIC ROW IS NOT PATCHABLE, AND IT MUST SAY SO. A field - # agent is DERIVED from a column definition; there is nothing in the automations bucket to - # write. `engine.patch` would answer "no such automation" (a 404 that reads as "your agent - # vanished") or, worse for a future id shape, find nothing and report success. The refusal - # names where the setting actually lives, because a wall that does not say what to do instead - # sends somebody looking for a bug in a decision made on purpose (D-65's own lesson). - if str(auto_id).startswith("field:"): - raise err(409, "system_agent", - "this agent is an AI enrichment column. Change its prompt, model or schedule on " - "the column itself and this page follows") - if str(auto_id) == ODOO_SYNC_ID: - return _patch_odoo_sync(session, body or {}) - # D-75, same channel as `create` above — an EDIT is where this matters most, because the - # person has just typed the thing that gets dropped. - # ⛔⛔ W36-T38 / R9 — THE AGENT-OWNED WALL, AT THE DOOR, BEFORE ANY WRITE. A `disabled` - # attribute in the builder is a suggestion; this is the refusal. It permits a DELETE of the - # step and permits an ordinary save of a flow that merely contains one. - _agent_owned_guard(session, auto_id, body or {}) - _notes = [] - defn, error = engine.patch(session.runtime, auto_id, body or {}, username=session.uname, - notes=_notes) - if error: - raise err(400 if error != "no such automation" else 404, - "invalid_automation" if error != "no such automation" else "unknown_automation", - error) - # An action the user removed takes its annotation with it, or the mark outlives its subject - # and refuses an id nobody can see. - _prune_marks(session, auto_id) - return {"automation": _wire(defn, session.tenant, rt=session.runtime), "notes": _notes} - - -@router.delete("/automations/{auto_id}") -def delete_automation(auto_id: str, session: Session = Depends(_GATE)): - """Delete the DEFINITION. ⚠ The database it filled is NOT touched — an automation is the - thing that writes rows, not the thing that owns them, and deleting a job must never be a way - to lose data (the same rule the orphan count encodes). - - ⭐⭐ WAVE 34 · CONTRACTS C3 + C4 — A SYSTEM AGENT REFUSES, LOUDLY, AND THE REFUSAL SAYS WHERE - TO GO INSTEAD. Two families are undeletable and they are undeletable for different reasons, so - the sentence names the reason rather than saying "no": a FIELD AGENT is a column (delete the - column), and the ODOO SYNC is the connector's own schedule (disconnect the connector). - - ⛔ REFUSED, NOT IGNORED, AND THAT IS THE POINT OF THE FIRST BRANCH. A synthetic id is not in - the automations bucket, so `engine.remove` would find nothing, do nothing, and this route - would answer `200 {"deleted": ...}` — a delete that reports success and changes nothing, which - is the `view_upsert` failure mode (200 OK, zero writes) arriving in a new place. The row would - then reappear on the next poll and read as a bug in the rail. - """ - # ⛔⛔ EVERY SYNTHETIC ID, NOT JUST THE FIELD ONES. The first version of this guard listed - # `field:` alone, and `verify_automation`'s own C4 leg caught what that left open: deleting - # `system:odoo_sync` fell straight through to `engine.remove`, which popped a key that was - # never in the bucket and answered `200 {"deleted": ...}`. A delete that reports success and - # changes nothing, on the one row the ruling says must be undeletable — the exact defect the - # guard exists for, one id shape away. Both prefixes are refused by the same branch now. - if str(auto_id).startswith("field:"): - raise err(409, "system_agent", engine.system_agent_refusal(SYSTEM_FIELD_AGENT)) - if str(auto_id) == ODOO_SYNC_ID: - raise err(409, "system_agent", engine.system_agent_refusal(SYSTEM_ODOO_SYNC)) - if engine.running(session.tenant, auto_id): - raise err(409, "automation_running", "it is running. Wait for it to finish") - # ⚠ READ BEFORE THE REMOVE, not after: `engine.remove` is the thing being refused. - _defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {} - _sys = str(_defn.get("system") or "").strip() - if _sys: - raise err(409, "system_agent", engine.system_agent_refusal(_sys)) - engine.remove(session.runtime, auto_id) - # R9's other half: the user MAY delete an agent-authored step, and deleting the whole - # automation takes its annotations with it rather than stranding them in the document. - _prune_marks(session, auto_id) - return {"deleted": str(auto_id)} - - -@router.post("/automations/preview") -def preview_source(body: dict = Body(default=None), session: Session = Depends(_GATE)): - """What does this URL actually offer? The field-map step — never writes anything.""" - body = body or {} - url = str(body.get("url") or "").strip() - if not url: - raise err(400, "no_url", "give a page URL to read") - try: - return engine.preview(url, str(body.get("extract") or "table"), - int(body.get("tableIndex") or 0)) - except engine.Refused as e: - raise err(400, "refused_url", str(e)) - except Exception as e: # noqa: BLE001 - raise err(502, "fetch_failed", f"could not read that page. {type(e).__name__}: " - f"{str(e)[:160]}") - - -@router.post("/automations/{auto_id}/run") -def run_automation(auto_id: str, session: Session = Depends(_GATE)): - """Start a run on a background thread. 409 when one is already in flight.""" - defn = engine.all_definitions(session.runtime).get(str(auto_id)) - if defn is None: - raise err(404, "unknown_automation", "no automation with that id") - # ⛔ WAVE 32 · T45 (owner item 10) — REFUSED, NAMING THE ACTION. `run_now` refuses too, for the - # tick and the webhook; this one exists so the person who pressed the button reads the reason - # instead of watching a run start and end with nothing done. The two ask the SAME function, so - # they cannot come to disagree about what "configured" means. - refusal = engine.run_refusal(defn) - if refusal: - raise err(400, "action_unconfigured", refusal) - if not engine.run_async(session.runtime, session.tenant, auto_id, username=session.uname): - raise err(409, "automation_running", "that automation is already running") - return {"started": str(auto_id), "startedAt": time.strftime("%Y-%m-%dT%H:%M:%S")} - - -@router.post("/automations/{auto_id}/nodes/{node_id}/toggle") -def toggle_automation_node(auto_id: str, node_id: str, session: Session = Depends(_GATE)): - """Flip one step on the canvas. The SERVER decides what a node's switch means (see - `engine.NODE_TOGGLES`) — the client only reports which node was clicked. - - A node with no switch answers 400 with the sentence saying why, rather than silently doing - nothing: a control that appears to work and does not is worse than one that refuses. - """ - if not session.runtime.available(): - raise err(503, "store_unavailable", "the tenant store is unavailable. Nothing was saved") - if engine.running(session.tenant, auto_id): - raise err(409, "automation_running", "it is running. Wait for it to finish") - defn, error = engine.toggle_node(session.runtime, auto_id, node_id) - if error: - raise err(404 if error == "no such automation" else 400, - "unknown_automation" if error == "no such automation" else "node_not_toggleable", - error) - return {"automation": _wire(defn, session.tenant, rt=session.runtime)} - - -@router.post("/automations/{auto_id}/hook/{token}") -async def automation_hook(auto_id: str, token: str, request: Request): - """C3's webhook trigger — the tick-endpoint pattern one level down: unauthenticated BY - DESIGN (the external caller has no cookie), gated on a per-automation token minted when the - trigger was configured, constant-time compared. The tenant is FOUND by the (id, token) - pair — a wrong token answers 403 for every tenant, so the route confirms nothing about - which slugs exist. - - ⭐ WAVE 24 (D-41) — THE BODY IS READ DEFENSIVELY AND IS NEVER A REASON TO REFUSE. - ⛔ It is deliberately NOT declared as `body: dict = Body(...)`, which is the obvious way to - write this and would be a live regression: FastAPI would then VALIDATE the payload, so an - existing caller posting text, form-encoding, an empty body or slightly malformed JSON would - start getting a 422 from a door that has accepted anything since wave 22. A webhook sender is - somebody else's system; we do not get to change what it must send in order to fire a flow. - An unreadable body simply maps nothing — the flow still fires, exactly as it did before. - - `run_in_threadpool` keeps the store I/O off the event loop: `hook_fire` walks every tenant - and may commit a row, and this handler had to become `async` only to read the request body. - """ - from starlette.concurrency import run_in_threadpool - from harness import runtime as _rt - - try: - payload_in = await request.json() - except Exception: # noqa: BLE001 - payload_in = None - - def _fire(): - last = (404, {"error": "unknown_automation", - "message": "no automation with that id and token"}) - for slug in _rt.known_tenants(): - try: - # A non-production Pg Space may display tenant #0 but can never persist an - # automation webhook there. Skip before building its runtime, exactly as the - # scheduler/tick path does; a skipped tenant remains indistinguishable from an - # unknown hook to this unauthenticated caller. - if engine._pg_tick_refusal(slug): - continue - rt = _rt.get_runtime(slug) - except Exception: # noqa: BLE001 - continue - status, payload = engine.hook_fire(rt, slug, auto_id, token, body=payload_in) - if status == 200: - return 200, payload - if status != 404: - last = (status, payload) - return last - - status, payload = await run_in_threadpool(_fire) - if status == 200: - return payload - raise err(status, str(payload.get("error") or "refused"), - str(payload.get("message") or "refused")) - - -@router.post("/automations/tick") -def tick(request: Request, x_aios_tick_token: str = Header(default="")): - """Fire every due schedule, for every tenant. The durable-cron entry point (R5). - - Unauthenticated BY DESIGN and gated on a shared secret instead — an EventBridge rule has no - cookie. Refuses when the secret is not configured (see the module header).""" - want = os.environ.get("AIOS_AUTOMATION_TICK_TOKEN") or "" - if not want: - raise err(403, "tick_disabled", - "AIOS_AUTOMATION_TICK_TOKEN is not configured. The tick endpoint is closed") - got = x_aios_tick_token or request.headers.get("X-AIOS-TICK-TOKEN") or "" - if got != want: - raise err(403, "bad_tick_token", "that token is not valid for this deployment") - started = engine.tick_all() - return {"started": started, "at": time.strftime("%Y-%m-%dT%H:%M:%S")} - - -@router.post("/automations/ig/purge") -def purge_ig_subject(body: dict = Body(default=None), session: Session = Depends(_GATE)): - """D-24 (wave 22): the right-to-erasure door for ONE Instagram subject — walks the - tenant's four `ut_ig_*` tables AND the platform master (R2 pooled a copy there, so a purge - that skipped it would not be erasure). Admin-only: erasure is a compliance act, not a - grid gesture. Answers the per-table removal counts — the one aggregate whose drill is the - rows' ABSENCE.""" - if not session.admin: - raise err(403, "admin_only", "erasing a subject is an admin action") - if not session.runtime.available(): - raise err(503, "store_unavailable", "the tenant store is unavailable. Nothing was " - "purged") - handle = str((body or {}).get("handle") or "").strip() - if not handle: - raise err(400, "no_handle", "name the Instagram handle to erase") - counts = engine.purge_subject(session.runtime, handle) - return {"handle": handle.lstrip("@").lower(), "removed": counts, - "total": sum(counts.values())} - - -@router.get("/automations/metrics/{table_key}/{field_key}/{row_id}/rows") -def metric_drill(table_key: str, field_key: str, row_id: str, - session: Session = Depends(_GATE)): - """C7's drill: the EXACT master snapshot rows behind one metric cell - ([[no-unverifiable-aggregates]]) — recomputed on ask with the same function that filled - the cell, so the drill can never disagree with the number by construction.""" - import core.user_tables as user_tables - - if not user_tables.may_open(table_key, session.uname, session.admin, st=session.runtime): - raise err(404, "unknown_table", "no such database") - t = engine.ut_get(session.runtime, table_key) or {} - fdef = next((f for f in (t.get("fields") or []) if f.get("key") == field_key), None) - if not fdef or not isinstance(fdef.get("metric"), dict): - raise err(404, "not_a_metric", "that column is not a metric field") - row = (t.get("rows") or {}).get(str(row_id)) - if row is None: - raise err(404, "unknown_row", "that record is not in the database") - import ig_master - url_field = next((f.get("key") for f in (t.get("fields") or []) - if f.get("type") == "url"), "") - handle = engine._table_handle(row, url_field) - bag = fdef["metric"] - series = ig_master.series_for({handle}) if handle else {} - value, rows = engine.metric_value(series.get(handle), bag.get("measure"), - bag.get("window"), bag.get("agg") or "") - return {"table": table_key, "field": field_key, "rowId": str(row_id), "handle": handle, - "measure": bag.get("measure"), "window": bag.get("window"), - "value": value, "rows": rows[:200], - "note": "" if value is not None else - "no master data answers this window. The cell is honestly blank"} - - -@router.get("/automations/{auto_id}/rows") -def run_rows(auto_id: str, session: Session = Depends(_GATE)): - """The rows the LAST run touched — the drill-down behind a run's counts. - - Every count in this product drills to the exact rows behind it ([[no-unverifiable-aggregates]]); - a run history that said "412 updated" and could not show which would be the thing that rule - exists to forbid. - """ - defn = engine.all_definitions(session.runtime).get(str(auto_id)) - if defn is None: - raise err(404, "unknown_automation", "no automation with that id") - last = (defn.get("runs") or [{}])[0] - table_key = (defn.get("config") or {}).get("targetTable") or "" - t = engine.ut_get(session.runtime, table_key) or {} - rows = t.get("rows") or {} - ids = [str(i) for i in (last.get("affected") or [])] - return {"table": table_key, "label": t.get("label") or table_key, - "fields": [{"key": f.get("key"), "label": f.get("label")} - for f in (t.get("fields") or [])], - "rows": [{"id": i, **(rows.get(i) or {})} for i in ids if i in rows], - "truncated": len(ids) >= 200} - - -# --- the in-process scheduler ------------------------------------------------------------ -# ⚠ OPT-IN (`AIOS_AUTOMATIONS=1`), and that is an AMENDMENT to the wave brief's "default-on", -# made on a measurement: `verify_api.py` runs for 196 s, i.e. longer than three tick intervals, -# and `tick_all` reaches `runtime.get_runtime()` — which builds tenants and moves the LRU cache -# that verify_api's own isolation checks read. Default-on would put a background thread inside -# the subject of another session's gate. `AIOS_PREWARM=1` at `main.py:353` is the same decision -# for the same reason, so this follows it rather than inventing a second convention. -# -# The loop also SLEEPS FIRST (`engine.scheduler_loop`) — defence in depth, proven in -# verify_automation.py section T rather than assumed. -# -# ⛔ THE DEPLOY MUST SET `AIOS_AUTOMATIONS=1` (with `AIOS_AUTOMATION_TICK_TOKEN`) or schedules -# only ever fire from the external cron POSTing /automations/tick. Both paths work; neither is -# implicit. Booked in the session-D mailbox. -engine.start_scheduler() - -# C3 (wave 22): register the trigger listener onto the platform's row-event seam. THIS module -# is where the registration belongs — it is the one place that already imports both sides, so -# neither the engine nor platform/core grows a dependency on the other. Idempotent: a reimport -# must not double-fire every trigger. -import core.user_tables as _ut_hooks # noqa: E402 - -if engine.grid_hook not in _ut_hooks.ROW_HOOKS: - _ut_hooks.ROW_HOOKS.append(engine.grid_hook) - -# ⭐⭐ W31 QA — DECLARE THE MACHINE-OWNED CHILD DATABASES, for the same reason and in the same -# place as the hook above: this module already imports both sides, so neither the engine nor -# `platform/core` grows a dependency on the other. Idempotent by construction (a set). -# -# ⛔ THE OWNER FOUND WHAT THIS FIXES, IN PRODUCTION, AFTER THE TICKET READ GREEN. W31-T32 locked -# the TikTok children by stamping `recordMode` at the two SPAWN sites, and proved the stamp -# arrives "on the next write". That is true and it is not the `done-when`: every TikTok database -# already sitting in a tenant kept offering "+ New record" until somebody re-ran a TikTok -# automation, and nobody had. Owner, verbatim (2026-08-13): *"the databases for Tiktok do not have -# the small lock icon as I asked"* — and the rule, restated: *"just like Instagram Post database -# (which is locked), only the IG Profile and TT Profile should be editable."* -# ⚠ A DECLARATION NEEDS NO WRITE, so it is true for EVERY tenant the moment the API boots — no -# migration, no boot ordering, no per-tenant walk, and nothing that a stale store can undo. The -# stored flag still locks a table nobody declares; the two are OR'd. -_ut_hooks.register_locked_records(engine.LOCKED_CHILD_TABLES) +"""routes_automation.py — wave-18 item 5 (contract C4-AUTO): the automation surface's API. + +The router is thin on purpose: everything that can be wrong about an automation — a malformed +cron, a URL the SSRF rail refuses, a key field that is not one of the mapped columns — is decided +in `automation_engine`, which is a pure-ish module a gate can drive without a server. This file +does auth, shape and status codes. + +⛔ THE TICK ENDPOINT IS THE ONE UNAUTHENTICATED ROUTE, AND IT IS FAIL-CLOSED TWICE OVER. It takes +no session (an external cron has no cookie), so it is gated on a shared secret in +`X-AIOS-TICK-TOKEN`; and when `AIOS_AUTOMATION_TICK_TOKEN` is UNSET the route refuses everything +rather than admitting everyone. A "no token configured means no check" default is how an internal +trigger becomes a public one — the same class of mistake as an empty-200 permission answer. +""" +import os +import re +import time +from datetime import datetime, timezone + +from fastapi import APIRouter, Body, Depends, Header, Request + +import ai_review +import automation_engine as engine +import oauth_connect +import routes_oauth +import scope_cache +from deps import Session, err, module_gate, require_session + +router = APIRouter(prefix="/api/v1") + +# C5 (wave 22): the OAuth connector surface rides INSIDE this router — main.py belongs to no +# session this wave, and this router is already mounted there. `/api/v1` + `/oauth/...`. +router.include_router(routes_oauth.router) +# ⛔⛔ D-203 — THE NESTED INCLUDE OF `routes_connectors` IS REMOVED, and the reason it existed is +# worth keeping because it was a GOOD reason that expired. +# +# Wave 23 (C11) mounted the connectors directory here rather than in `main.py`, because this router +# was already mounted and `main.py` belonged to another session — a sensible way to avoid a +# cross-fence ask. `main.py:227` includes `routes_connectors` DIRECTLY now, so this line stopped +# being the only door and became a SECOND one. +# ⚠ AND A ROUTER MOUNTED TWICE DOES NOT SERVE THE SAME PATHS TWICE — it serves the prefix twice. +# `routes_connectors` carries its own `/api/v1`, so nesting it inside this router's `/api/v1` +# produced **`/api/v1/api/v1/connectors/directory`**: a live, session-gated, entirely dead path +# that nothing links to and every route audit has to explain. MEASURED before removal: 123 served +# paths, exactly 1 of them doubled. +# ⚠ `routes_oauth` above is NOT the same case and stays: `main.py` does not mount it, so this +# router is genuinely its only door. Deleting it because its neighbour was wrong is how a real +# route dies for a tidy-up. +import routes_connectors # noqa: E402,F401 + +#: The registry key this surface carries (C-AUTONAV — A adds the row; the gate is live now, so +#: the day the row lands the wall is already the one that was tested). +MODULE = "automation" + +_GATE = module_gate(MODULE) + + +def _wire(defn, tenant, rt=None): + """One automation, as the client reads it. `running` is PROCESS state, never store state — + see the engine header on why a persisted 'running' is a permanent lock.""" + live = engine.running(tenant, defn.get("id")) + sched = defn.get("schedule") or {} + nxt = engine.next_fire(sched.get("cron")) if sched.get("enabled") else None + status = dict(defn.get("status") or {}) + if live: + status = {**status, "state": "running", "startedAt": live.get("startedAt"), + "step": live.get("step")} + return { + "id": defn.get("id"), "name": defn.get("name"), "kind": defn.get("kind"), + "config": defn.get("config") or {}, "schedule": sched, "status": status, + "runs": list(defn.get("runs") or [])[:engine.MAX_RUNS], + "created": defn.get("created"), "createdBy": defn.get("createdBy"), + "nextRunAt": nxt.strftime("%Y-%m-%d %H:%M") if nxt else "", + "running": bool(live), + # ⭐ WAVE 27 (contract C6) — DEBT D-70: "a paid search is already outstanding at the + # vendor". THE MONEY GUARD, and it was INERT for a whole wave because this line was + # missing: the client declared `awaitingResults` REQUIRED and read it in `runBlock()`, + # nothing ever sent it, so `undefined` was falsy and the Run button stayed armed. + # + # ⛔ WHY IT IS NOT `running`. `running` above is PROCESS state (`engine.running`) and is + # FALSE for the entire 20–30 MINUTE vendor wait, which IS the hazard window: the ticks + # are 3 minutes apart, so between them the automation is idle and the button re-arms. + # MEASURED the expensive way on 2026-08-06 (~$0.025): with no snapshot outstanding a + # Run-now press returned 200 and started a BRAND-NEW billable corpus search. + # + # ⛔ AND NOT A PERSISTED "running" FLAG EITHER — the engine header forbids one (it + # outlives the process and locks the automation forever). `state.pendingSnapshot` is the + # field that already exists, is already persisted, is already carried untouched through + # `clean_definition`, and is the SAME expression `pending_collect_ids()` uses to decide + # what to collect. One truth, two readers. + "awaitingResults": bool(str((defn.get("state") or {}).get("pendingSnapshot") + or "").strip()), + # C3 (wave 22): the trigger config rides whole — the webhook token included, because + # the person configuring the external caller has to be shown the URL somewhere, and + # this payload is session-gated behind the same wall as everything else here. + # ⭐⭐ WAVE 35 · T36 / R10 — THE PARKED BATCH, SUMMARISED. Without this line the whole + # ticket would be unreachable: `_wire` is a WHITELIST, so a key the engine parks on the + # definition simply never arrives, and the detail pane could not know a batch was waiting + # ([[reachable-is-not-the-same-as-built]] — the exact shape `flow` was caught in above). + # ⚠ COUNT AND NOTES ONLY, NEVER `items`. A 200-statement batch carries 200 rendered HTML + # mails; this payload rides on the automations LIST, which every rail paint reads. + # `GET /admin/statements/agent/{id}` serves the detail when somebody opens the batch. + "pendingStatements": ({ + "count": int((defn.get("pendingStatements") or {}).get("count") or 0), + "ts": (defn.get("pendingStatements") or {}).get("ts") or "", + "notes": list((defn.get("pendingStatements") or {}).get("notes") or [])[:25], + } if isinstance(defn.get("pendingStatements"), dict) else None), + # ⭐⭐ WAVE 35 · T37 — `system` FOR A **STORED** ROW. Until now only SYNTHETIC rows carried + # it, stamped by their builders AFTER this function (`_odoo_sync_row`, `_field_agent_rows`), + # so a stored row with the marker had it enforced on the server (`delete_automation` 409s) + # and INVISIBLE to the client — which paints a live-looking Delete that answers with a + # refusal. `AutomationDetail` already reads `automation.system` to disable that button and + # explain why; this is the line that lets it. + # ⚠ Absent stays absent rather than becoming `""`: every ordinary automation is not-a-system + # -agent, and an empty string is a value a client could accidentally treat as one. + **({"system": str(defn.get("system") or "")} if defn.get("system") else {}), + "trigger": defn.get("trigger") or None, + "statusNote": defn.get("statusNote") or "", + # The one-sentence summary (airtable-brief rec 7), composed from the definition so it + # cannot describe steps the engine does not run. + "sentence": engine.compose_sentence(defn), + # THE CANVAS TOPOLOGY (R5) rides with the automation rather than being rebuilt in the + # client, for the same reason `cronPresets` does: the engine that RUNS the steps is the + # only thing entitled to say what the steps are. + "graph": engine.graph(defn), + # ⭐ WAVE 23 (C4/C5) — THE BUILDER'S OWN STATE, and its absence here was a silent-drop + # bug caught in review rather than by a gate: `flow` was stored, patchable and validated, + # but never sent back. B would have saved a flow through PATCH, got a 200, and watched + # every action vanish on reload — the classic "it didn't save" with nothing red anywhere. + "flow": defn.get("flow") or {"actions": []}, + # ⭐⭐ WAVE 32 · T45 (owner item 10) — CONFIGURED / UNCONFIGURED, per action, on the wire. + # + # ⛔ THE SERVER SAYS IT, not the client, and that is the whole reason it is here: the same + # `engine.action_needs` answers this label, `engine.run_refusal`'s 400 and `run_now`'s own + # refusal, so a card cannot read "Configured" over an action the run will refuse. Three + # readers, one predicate — the alternative is a client-side rule that agrees with the + # server until somebody adds a key to one of them (`awaitingResults` above is this file's + # own record of what the other shape costs). + # ⚠ IT IS A LIST, KEYED BY ACTION ID, and it names the NESTED actions too — an unconfigured + # step inside an If / then branch is exactly the one a person cannot see. + # ⚠ COSTS NO STORE READ. `action_needs` is pure over `(kind, config)`; this route is the + # one W30-T12 took the `user_tables` deep copy off, and a label is not worth putting it + # back. What needs the target database's schema (an enrich binding resolved by the profile + # FLAG) stays a run-time refusal — see `ACTION_REQUIRED`'s note. + "unconfigured": engine.unconfigured_actions(defn), + # ⭐⭐ WAVE 36 · W36-T38 (owner item 8 / R9) — WHICH ACTIONS AN AGENT BUILT. + # `{action_id: {agent, agentName, created, by}}`, empty for an ordinary automation. + # ⛔ THE CONFIG IS NOT HIDDEN AND MUST NOT BE. R9 is a WRITE rule: the client uses this to + # draw a lock and explain who owns the step, never to withhold what the step does — an + # automation nobody can audit is worse than one nobody can edit. + # ⚠ `rt=None` YIELDS `{}` RATHER THAN OMITTING THE KEY, so a caller that forgets the + # runtime produces "nothing is agent-owned" (a visible, wrong-but-safe answer) instead of + # an absent prop the client silently reads as undefined + # [[flag-shipped-without-its-writer]]. + "agentActions": (_agent_marks(rt).get(str(defn.get("id") or "")) or {}) if rt else {}, + } + + +#: ⭐⭐ WAVE 34 · CONTRACTS C3 + C4 — WHY A ROW CANNOT BE DELETED, as a slug. +#: +#: ⚠ ALWAYS A STRING, NEVER A BOOLEAN, and the two contracts disagreed about that (C3 says +#: `system: true`, C4 says `system: ""`). Raised as ask `E-13`; built as a slug because one +#: key with two types is [[one-question-two-normalizers]] before a line is written, and because a +#: refusal has to be able to NAME the reason. Truthiness is unchanged for any reader that only +#: asks "is this a system agent". +SYSTEM_FIELD_AGENT = "field_agent" +SYSTEM_ODOO_SYNC = "odoo_sync" + + +def _field_agent_rows(session): + """⭐⭐ CONTRACT C3 — one synthetic Agent row per `ai_enrich` column in the tenant. + + R13: *"Every field agent must be VIEWABLE under the Agent module as a simple Trigger whose + first step is the field enrichment, with the Field and Database shown under Config."* + + ⛔⛔ THESE ARE DERIVED, NOT STORED, AND NOTHING HERE MAY WRITE. A field agent's only source of + truth is the column definition F's `_clean_field` stamps; a copy in the automations bucket + would be a second one, and the first PATCH through the enrichment editor would silently + diverge from it. `delete_automation` refuses these ids for the same reason: accepting a delete + that cannot delete anything is the `view_upsert` failure mode (200 OK, nothing written) in a + new place. + + ⛔⛔ AND IT USES THE ROWS-FREE PROJECTION, WHICH IS THE WHOLE ENGINEERING PROBLEM OF THIS + TICKET. `GET /automations` is the route W29-T01 and W30-T12 spent two waves taking the + whole-document read OFF: it reads `user_tables` exactly ONCE PER TENANT PER PROCESS now, and + this file's own `unconfigured` note says *"a label is not worth putting it back"*. Walking + every table's fields for `ai_enrich` columns is exactly that read. So this uses + `user_tables.all_defs` — the same projection `/nav` uses, MEASURED there at 1,750 ms -> 1.4 ms, + because tenant #0's document is 99.89% rows and a field definition is not a row. + ⚠ `all_defs` RAISES on `rows` rather than answering `{}`. Nothing here wants a row; if that + ever changes, the error names the projection instead of painting an empty grid. + + ⚠ THE PERMISSION WALL IS CALLED, NEVER RE-IMPLEMENTED. `may_open` decides which databases this + caller may see, lent the projected document exactly as `/nav` lends it. This route's own + `automation_tables` carries the scar that makes that non-negotiable: it once re-implemented + the wall, got it WIDER, and showed people databases they were refused the moment they clicked. + """ + import core.user_tables as user_tables + + try: + defs = user_tables.all_defs(st=session.runtime) or {} + lent = user_tables.lend(session.runtime, **{user_tables.STORE_KEY: defs}) + except Exception: # noqa: BLE001 + # A store blip must not take the whole rail down with it. The stored automations are the + # payload's real subject; field agents are an addition to it. + return [] + + out = [] + for key, defn in sorted(defs.items(), key=lambda kv: (kv[1].get("label") or "").lower()): + if not user_tables.may_open(key, session.uname, session.admin, st=lent): + continue + table_label = defn.get("label") or key + for f in user_tables.ai_enrich_fields(defn): + bag = f.get("automation") if isinstance(f.get("automation"), dict) else {} + if bag.get("kind") != SYSTEM_FIELD_AGENT: + continue + col = str(f.get("key") or "") + if not col: + continue + # ⚠ THE ID CARRIES THE TABLE AND THE COLUMN, and `table` is NOT read off the bag: + # F's `_clean_field` is handed ONE field dict with no table context, so it cannot + # stamp one (mailbox `F-3`). E holds that key because E is the side that iterates + # tables. Two sources for one fact was the alternative, and it drifts. + trig = bag.get("trigger") if isinstance(bag.get("trigger"), dict) else {} + mode = str(trig.get("mode") or "manual") + cron = str(trig.get("cron") or "").strip() + defn_syn = { + "id": f"field:{key}:{col}", + "name": f.get("label") or col, + "kind": SYSTEM_FIELD_AGENT, + # ⛔⛔ TWO TRIGGER VOCABULARIES MEET HERE AND THEY ARE NOT THE SAME ONE. A field + # agent's trigger is the ENRICHMENT vocabulary (`{mode, cron}`, values + # manual/on_change/schedule); an automation's is the ENGINE's (`{key}` plus a + # separate `schedule {cron, enabled}`). Handing the engine's shape a `mode` it + # cannot read is not a type error — it is SILENT: `compose_sentence` fell back to + # "When you press Run now" for a column scheduled at 06:00, and `graph()` drew a + # trigger node with no trigger. Measured before this mapping existed, which is the + # only reason it does. + # ⚠ THE MAP IS EXACT WHERE AN EQUIVALENT EXISTS AND HONEST WHERE IT DOES NOT: + # `manual` and `schedule` are the engine's own keys, and `on_change` becomes + # `event_field` ("When a record matches conditions"), the nearest thing the engine + # has to "a cell this column reads has moved". `mode` is kept verbatim beside it so + # nothing is lost in translation and the enrichment editor stays the source. + "trigger": {"key": {"manual": "manual", "schedule": "schedule", + "on_change": "event_field"}.get(mode, "manual"), + "mode": mode, **({"cron": cron} if cron else {})}, + **({"schedule": {"cron": cron, "enabled": True}} + if mode == "schedule" and cron else {}), + "flow": {"actions": [{"id": "act_1", "kind": "ai_enrich", + "config": {"table": key, "field": col, + "tableLabel": table_label}}]}, + } + # ⭐ THROUGH `_wire`, NOT BESIDE IT. A synthetic row hand-built to "look like" a + # stored one is a second shape that agrees until somebody adds a key to the real one + # — the exact way `awaitingResults` shipped inert for a whole wave. Passing the + # synthetic DEFINITION through the same function makes them identical by + # construction; only `system` is stamped afterwards, because no stored row has it. + row = _wire(defn_syn, session.tenant, rt=session.runtime) + row["system"] = SYSTEM_FIELD_AGENT + out.append(row) + return out + + +#: ⭐⭐ WAVE 34 · W34-T47 (mailbox D-5) — R6 APPLIED TO WORDS A MODEL WROTE. +#: +#: ⛔ `web_prose` READS FILES, so it is structurally blind to a dash that arrives at RUN TIME. +#: `POST /automations/draft` returns three strings a language model authored (the flow's name, +#: each step's `why`, and the dropped/notes sentences) and `W34-T42` is what put them on a screen. +#: Lane D MEASURED that a prompt instruction does not hold: their system prompt ends "Never use an +#: em dash" and the very next live cerebras turn came back with one. So this is code at the +#: boundary, not a better prompt. +#: ⚠ A DIGIT RANGE IS A DIFFERENT SENTENCE and gets the first rule: "10-20" means "10 to 20", and +#: turning it into "10, 20" states two numbers where the model stated a span. +_DRAFT_DASH = "[" + chr(0x2014) + chr(0x2013) + "]" + + +def _draft_no_dashes(text): + """Model prose with no em or en dash, and no meaning changed on the way.""" + text = str(text or "") + text = re.sub(rf"(?<=\d)\s*{_DRAFT_DASH}\s*(?=\d)", " to ", text) + text = re.sub(rf"\s*{_DRAFT_DASH}\s*(?=[,.;:!?])", "", text) + text = re.sub(rf"(?<=[,;:])\s*{_DRAFT_DASH}\s*", " ", text) + return re.sub(rf"\s*{_DRAFT_DASH}\s*", ", ", text) + + +ODOO_SYNC_ID = "system:odoo_sync" +#: The cadence presets in the words a person reads. ⚠ Keyed on `odoo_relational.SYNC_PRESETS`, and +#: a key with no phrase here falls back to "Every " rather than raising — a missing caption +#: must degrade to something readable, not take the agents list down. +ODOO_CADENCE_PHRASE = {"30m": "Every 30 minutes", "1h": "Every hour", + "4h": "Every 4 hours", "daily": "Every day"} + + +def _odoo_sync_row(session, detail=False): + """⭐⭐ CONTRACT C4 (ruling R22) — the Odoo sync, as an agent you can SEE. + + R22: *"Implicit time-triggered work (Odoo syncing) becomes EXPLICIT: a pre-set agent that + cannot be deleted, whose Canvas shows Trigger = time and Action = data syncing from Odoo, with + the keychain/API configuration shown."* + + ⛔⛔ DERIVED, NOT STORED, AND C4 SAYS "a stored automation may carry `system:`". THE MECHANISM + C4 ASKS FOR IS BUILT (`delete_automation` refuses any stored row carrying `system`); this + particular agent does not use it, for three measured reasons, and the deviation is raised as + ask `E-15` rather than taken quietly: + 1. NOTHING CAN SEED IT. There is no system-automation seeding path anywhere, and `D-195` + measured three times that a CLI write to the tenant store while the Space is live REPORTS + SUCCESS and is reverted within a minute. A stored row would have to be minted by the + container, i.e. a write-on-read on the busiest list route in the product. + 2. STORED MEANS RUNNABLE BY MACHINERY BUILT FOR USER AUTOMATIONS. If its runs went through + `_commit_run`, `CONSECUTIVE_FAILURE_PAUSE` would flip `schedule.enabled` off after K bad + passes — silently disabling the tenant's REAL Odoo cadence, not a cosmetic card. + 3. TWO COPIES OF ONE CADENCE. The loop reads `odoo_relational.sync_seconds`; a stored + automation would carry its own `schedule.cron`, and C4's own requirement is that the card + "cannot drift from the running loop". Deriving makes drift impossible instead of + forbidden. + + ⚠ `detail=False` IS THE LIST PATH AND IT STAYS CHEAP. The keychain label and the last-sync + stamp each cost their own store read, and `GET /automations` is the route two waves were spent + taking reads OFF. They are resolved only on the DETAIL fetch, which is one automation and one + click. Returns `None` when this tenant has no Odoo at all — an agent for a connector nobody + connected is a card that lies. + """ + try: + import odoo_relational as rel + except Exception: # noqa: BLE001 + return None + try: + cfg = rel.read_config(session.runtime) + secs = rel.sync_seconds(session.runtime) + frozen = bool(rel.frozen(session.runtime)) + except Exception: # noqa: BLE001 + return None + + every = str(cfg.get("syncEvery") or rel.DEFAULT_SYNC) + manual = secs is None + defn = { + "id": ODOO_SYNC_ID, + "name": "Odoo sync", + "kind": SYSTEM_ODOO_SYNC, + # ⚠ THE CADENCE IS READ, NEVER STORED HERE. `everySeconds` is what the loop will actually + # sleep for, so the card cannot claim a schedule the loop is not keeping. + "trigger": {"key": "manual" if manual else "schedule", "preset": every, + "everySeconds": secs, "presets": sorted(rel.SYNC_PRESETS)}, + **({} if manual else {"schedule": {"cron": "", "enabled": True}}), + "flow": {"actions": [{"id": "act_1", "kind": "odoo_sync", + "config": {"connector": "odoo", "every": every, + "frozen": frozen}}]}, + } + row = _wire(defn, session.tenant, rt=session.runtime) + row["system"] = SYSTEM_ODOO_SYNC + # ⛔ THE SENTENCE IS OVERRIDDEN, AND IT IS A FIX RATHER THAN A PREFERENCE. `compose_sentence` + # speaks the automation vocabulary — it builds "When , run N actions" out of + # `schedule.cron` — and this agent has no cron: its cadence is a PRESET in seconds that the + # resync loop sleeps on. MEASURED before this line existed, the card read exactly + # `", run 1 action."`: a leading comma where the trigger phrase should have been. Two + # vocabularies again, and this one fails in punctuation rather than in behaviour, which is + # why only reading the output catches it. + # ⚠ AND THE CADENCE IS SPELLED OUT RATHER THAN INTERPOLATED. `f"Every {every}"` produced + # "Every daily, sync data from Odoo." — the preset KEYS are storage tokens ("30m", "daily"), + # not English, and a sentence built by pasting one in is only accidentally readable for the + # three that happen to be durations. Caught by reading the output, not by any assertion. + row["sentence"] = ("Odoo is disconnected, so nothing syncs." if frozen else + "Manually, sync data from Odoo." if manual else + f"{ODOO_CADENCE_PHRASE.get(every, f'Every {every}')}, sync data from Odoo.") + # ⛔ R6's SECOND SENTENCE, WHICH IS THE HALF THAT GETS DROPPED: a limit that cannot be removed + # must be REPORTED with its cause. Two are reported here rather than left for somebody to + # discover by watching a mirror not move. + notes = [] + if frozen: + notes.append("Odoo is disconnected, so this agent is not syncing. Reconnect it in " + "Connectors.") + if manual: + notes.append("The cadence is set to manual, so nothing syncs on a timer.") + row["statusNote"] = " ".join(notes) + if not detail: + return row + + # ── the DETAIL half: two store reads, on a route that fetches one automation ────────────── + try: + import routes_keychain as kc_routes # noqa: PLC0415 + row["flow"]["actions"][0]["config"]["keychain"] = ( + kc_routes.odoo_config(session).get("label") or "Odoo") + except Exception: # noqa: BLE001 + # A label is a nicety; failing to read one must not make the agent unopenable. + row["flow"]["actions"][0]["config"]["keychain"] = "Odoo" + try: + from harness import datastore as ds # noqa: PLC0415 + # ⭐ THE LAST RUN IS THE MIRROR'S OWN `_sync_state`, not a run history we would have to + # write. That table already carries a real per-entity `updated` stamp, so the card reports + # what actually happened rather than what this module remembers happening. + st = ds.status() or {} + stamps = sorted(str((v or {}).get("updated") or "") for v in st.values() if v) + last = [s for s in stamps if s] + if last: + row["status"] = {**(row.get("status") or {}), "state": "ok", "lastRunAt": last[-1], + "lastSummary": f"{len(last)} datasets synced"} + except Exception: # noqa: BLE001 + pass + return row + + +def _patch_odoo_sync(session, body): + """⭐⭐ CONTRACT C4 — THE ONE THING ON THIS CARD THAT IS EDITABLE: how often it runs. + + C4: *"Its Canvas is READ-ONLY except `trigger.config.everySeconds`, which writes through to the + same value `odoo_relational.sync_seconds` reads, so the card cannot drift from the running + loop."* + + ⛔ IT WRITES THE CONNECTOR'S OWN CONFIG KEY, NOT AN AUTOMATION. `rel.CONFIG_KEY` / + `rel.SYNC_PRESETS` are the same key and the same vocabulary `sync_seconds` reads and + `_store_resync_loop` sleeps on, so there is one value and the card reads back exactly what the + loop will use. Storing a cron on a synthetic automation would have been a second copy with a + guaranteed drift date. + + ⚠ EVERY OTHER FIELD IS REFUSED RATHER THAN IGNORED. A PATCH that silently kept only the part + it liked would let somebody rename this agent, watch the 200, and find the name gone on reload + — the failure shape this module has already paid for twice. + + ⚠ AND THE LATENCY IS REPORTED, because it is real and a person would otherwise call it a bug: + the loop re-reads the cadence at the TOP of its cycle and then sleeps, so a change takes effect + on the NEXT pass — up to one full OLD interval away (24 hours if it was on `daily`). + """ + import odoo_relational as rel # noqa: PLC0415 + + known = {"trigger", "every", "syncEvery"} + extra = sorted(k for k in body if k not in known) + if extra: + raise err(409, "system_agent", + "this agent is the Odoo connection's own sync. Only how often it runs can be " + f"changed here; {', '.join(extra)} belongs to the connector in Connectors") + every = str(body.get("syncEvery") or body.get("every") + or ((body.get("trigger") or {}) if isinstance(body.get("trigger"), dict) else {}) + .get("preset") or "").strip().lower() + if every not in rel.SYNC_PRESETS: + raise err(400, "bad_cadence", + f"pick one of: {', '.join(sorted(rel.SYNC_PRESETS))}") + + def _up(cur): + cur = dict(cur) if isinstance(cur, dict) else {} + cur["syncEvery"] = every + return cur + + session.runtime.update(rel.CONFIG_KEY, _up, flush="sync") + row = _odoo_sync_row(session, detail=True) + if row is None: + raise err(404, "unknown_automation", "no automation with that id") + secs = row["trigger"].get("everySeconds") + note = ("It syncs on a timer again from the next cycle." if secs + else "Nothing will sync on a timer now.") + return {"automation": row, + "notes": [f"The Odoo sync is set to {every}. {note} A change takes effect on the " + f"loop's next pass, so it can be up to one of the OLD intervals away."]} + + +def _triggers_vocab(session): + """C3's server-owned trigger list: `[{key, label, ready, needs, planned}]`, keys EXACTLY + `engine.TRIGGER_KEYS` + `engine.TRIGGER_PLANNED`. `ready:false` + `needs` renders as a + not-configured state — never a dead control, never a client-side union. + + ⭐ WAVE 23 (R2): the list is the WHOLE Airtable-parity vocabulary, and the two triggers we + have not built ride it with `planned: true`. That is the honest version of "show all, wire + eight": the picker paints them faded with a reason instead of a shorter list that quietly + implies the missing ones do not exist. `clean_trigger` refuses them, so the faded state is + enforced at the door and not merely in the client's `disabled` attribute. + """ + tick_on = _tick_state()["enabled"] + g = oauth_connect.status(session.runtime, session.uname).get("google") or {} + email_ready = bool(g.get("configured")) and bool(g.get("connected")) \ + and not g.get("reconnect") + email_needs = "" if email_ready else ( + "connect_gmail" if g.get("configured") else "configure_google") + per = { + "manual": (True, ""), + "schedule": (tick_on, "" if tick_on else "arm_tick"), + "event_field": (True, ""), + "record_updated": (True, ""), + "record_created": (True, ""), + "enters_view": (True, ""), + "webhook": (True, ""), + "email": (email_ready, email_needs), + "form_submitted": (True, ""), + # ⭐ WAVE 24 (C-TRIG) — Instagram discovery, now a trigger. Readiness is the vendor key, + # the same bit `paidReady` carries: with no key the search door is closed and the picker + # must say so rather than offering a control that silently finds nothing. `clean_trigger` + # still ACCEPTS it either way — readiness is a deployment fact, not a validity one, which + # is the same split `email` already makes. + "ig_profile_match": (engine.bd_ready(), "" if engine.bd_ready() else "configure_brightdata"), + # ⭐ WAVE 29 (D-9 / R1) — TikTok, live. ⛔ IT NEEDS ITS OWN ROW EVEN THOUGH THE ANSWER IS + # IDENTICAL, and the reason is the `.get(k, (True, ""))` default below: a trigger this dict + # forgets is reported READY, so a deployment with no vendor key would offer TikTok search + # as configured and the search would find nothing. Same key, same readiness bit, stated. + "tiktok_profile_match": (engine.bd_ready(), + "" if engine.bd_ready() else "configure_brightdata"), + } + #: ⭐ WAVE 24 — the server's own one-line description per trigger. C-TYPES: the picker renders + #: THIS under the option, because a CLIENT paraphrase of a server vocabulary is a second copy + #: of it, free to drift. Absent = the client shows nothing, never something invented. + detail = { + "manual": "It runs only when you press Run now", + "schedule": "It runs on a repeating schedule", + "event_field": "A record in the database starts matching a condition you set", + "record_updated": "Any of the columns you watch is changed", + "record_created": "A new record is added to the database", + "enters_view": "A record starts appearing in a saved view", + "webhook": "Something outside calls this automation's URL", + "email": "A message arrives in the connected mailbox", + "form_submitted": "Somebody submits one of this database's forms", + "ig_profile_match": "Search Instagram for profiles matching your filters, on a schedule", + "button_clicked": "Somebody presses a button on a record", + "comment_added": "Somebody comments on a record", + "web_page_changed": "A page you are watching is different from last time", + "tiktok_profile_match": "Search TikTok for profiles matching your filters, on a schedule", + } + + def _taxonomy(k): + """⭐ WAVE 25 · C2 — the four taxonomy keys, composed from the ENGINE's maps. + + ⛔ `.get(k) or FALLBACK`, NEVER `TRIGGER_GROUP_OF[k]`. The first draft of this indexed the + map on the reasoning that a default is how a Connector trigger quietly appears under + Database — and the gate rejected it, correctly, against the incident `per.get(k, ...)` + eight lines below records: a key added to `TRIGGER_KEYS` without remembering a dict beside + it raised KeyError and took `GET /automations` down, i.e. the whole surface, which polls + this every 2.5 s. A mis-grouped row is cosmetic; a 500 is not, and the ranking is not + close. + ⚠ THE CLASSIFICATION IS STILL MANDATORY — it is enforced at the GATE (no shipped trigger + may land in `other`) rather than at the request. Soft here, hard there. + """ + g = engine.TRIGGER_GROUP_OF.get(k) or engine.TRIGGER_GROUP_FALLBACK + return {"group": g, + "groupLabel": engine.TRIGGER_GROUPS[g]["label"], + "groupOrder": engine.TRIGGER_GROUPS[g]["order"], + # The SUB-group inside "Connector"; None everywhere else. ⚠ Group by this key, + # render its label — it is NOT a connector-directory slug (see the engine's note). + "connector": engine.TRIGGER_CONNECTOR.get(k), + # D-55: "the cron drives this one". Derived from the engine's schedule set MINUS + # `manual`, so the client's `CRON_DRIVEN_TRIGGERS` copy can be deleted. + "schedules": k in engine.TRIGGER_CRON_KEYS} + + out = [] + for k in engine.TRIGGER_KEYS: + # ⚠ `.get` WITH A DEFAULT, NOT `per[k]`. This loop walks the ENGINE's vocabulary and + # indexed a hand-maintained dict beside it: adding a key to `TRIGGER_KEYS` without + # remembering this dict raised KeyError and 500'd `GET /automations` — the payload the + # whole automation surface polls every 2.5 s — with every gate and `tsc` still green. + # Defaulting to "ready, needs nothing" is the honest fallback: a trigger the engine + # offers and this route has no readiness opinion about is simply available. + ready, needs = per.get(k, (True, "")) + row = {"key": k, "label": engine.TRIGGER_LABELS[k], "ready": ready, "needs": needs, + "planned": False, "detail": detail.get(k, ""), + # A3(3): the connect affordance is SERVER-COMPOSED — the client never maps a + # `needs` token to a route, so B's CONNECT_PROVIDERS shim deletes itself. + "connect": None, **_taxonomy(k)} + if not ready and needs == "connect_gmail": + row["connect"] = {"provider": "google", + "startUrl": "/api/v1/oauth/google/start"} + out.append(row) + for k in engine.TRIGGER_PLANNED: + out.append({"key": k, "label": engine.TRIGGER_LABELS[k], "ready": False, + "needs": "coming_soon", "planned": True, "connect": None, + "detail": detail.get(k, ""), **_taxonomy(k)}) + return out + + +def _tick_state(): + """⭐ WAVE 21 (C6 amendment A1) — can a SCHEDULE fire on this deployment? + + TWO independent paths can: the in-process scheduler (`AIOS_AUTOMATIONS=1`, + `automation_engine.py` module bottom) and an external cron POSTing `/automations/tick`, + gated on `AIOS_AUTOMATION_TICK_TOKEN` (AWS EventBridge in production). The Step-1 Trigger + card must be honest in both directions: "schedules won't fire" on a deployment where + EventBridge demonstrably fires them daily is the exact lie R9 forbids. `external` means + "the door is OPEN", never "the caller is alive" — the client's copy says so.""" + inproc = os.environ.get("AIOS_AUTOMATIONS") == "1" + ext = bool(os.environ.get("AIOS_AUTOMATION_TICK_TOKEN")) + return {"enabled": bool(inproc or ext), + "source": "in-process" if inproc else ("external" if ext else "")} + + +#: W29-T01 — the Board retirement ran, per tenant, this process. Same shape and same reasoning as +#: `_C8_MIGRATED` below: a one-way cleanup of data no living code path creates any more, whose +#: cost is a full walk of every row-cell in the tenant and whose result on pass 2..N is always +#: "nothing to do". ⚠ A module-global keyed by tenant is deliberately NOT reset by a table create +#: or delete — a new database cannot contain the legacy stage cells this retires. +_BOARD_RETIRED = set() + +#: ⭐ WAVE 35 · T37 — tenants whose statements agent has been considered THIS PROCESS. Same shape as +#: `_BOARD_RETIRED` and, like it, purely a cost saver: the DURABLE idempotency is the row's own +#: existence, which `engine.seed_statements_agent` re-checks inside its own store mutation. +#: ⚠ SO A RESTART RE-CHECKING IS HARMLESS BY CONSTRUCTION, which is the property to preserve. If +#: this set were ever the only guard, a process restart would mint a second agent. +_STATEMENTS_SEEDED = set() + +#: ⭐⭐ WAVE 30 · T12 — THE PICKER DEFAULT, MEMOISED. `{tenant: (stamp, table_key)}`, the shape +#: `scope_cache` stores. +#: +#: ⛔ WHY THE DERIVED STRING AND NOT THE DOCUMENT. The obvious cache here is the `user_tables` +#: bucket itself, and it is the wrong one: that bucket is up to 35.8 MB per tenant and this box is +#: the HF free tier, so caching it would trade a latency problem for a memory one. What the warm +#: path actually needs is `discover_default_table`'s ANSWER — one short string. +#: +#: ⛔ AND WHY NOT A PLAIN `_BOARD_RETIRED`-STYLE ONCE-PER-PROCESS SET, which would have been less +#: code: the election reads which profile databases exist and which hold rows, and BOTH change +#: while the process lives (somebody creates a database, an automation writes the first row). A +#: once-per-process memo would pin the picker's default to whatever was true at boot and never +#: correct itself — a default that disagrees with the save door, which is exactly the wave-25 R2 +#: defect the `"table"` line's own comment records. A TTL bounds the staleness instead. +#: +#: ⚠ `scope_cache` rather than a hand-rolled dict, because it is the house pattern for precisely +#: this (`routes_customers`, `routes_products`, `pages` all use it) and it is stale-while-refresh: +#: once a copy exists NO request blocks on a rebuild. Automation was the one module importing it +#: nowhere, which the wave-30 scout named as the reason every other surface feels fast. +_DISCOVER_DEFAULT = {} +#: 5 minutes — the same order as `apiBridge.ts:CUSTOMERS_FRESH_MS` on the client. Overridable so a +#: gate can pin it rather than sleep. +_DISCOVER_DEFAULT_TTL = float(os.environ.get("AIOS_AUTOMATION_DEFAULT_TTL") or 300) + + +#: The one store key `_LentTables` intercepts. DERIVED from the engine's own constant rather than +#: written out here: `engine.ut_all` reads the bucket through it, so if that key ever moves, the +#: lend moves with it instead of silently becoming a pass-through that still looks correct. +_UT_STORE_KEY = engine.UT_STORE_KEY + + +def _LentTables(runtime, tables): + """⭐⭐ WAVE 30 · T13, NOW W31-C1 — a read-only `st` that serves ONE already-read `user_tables` + document and passes every other key straight through to the real runtime. + + ⭐⭐ WAVE 31 · C1 — THE BODY IS NOW `core.user_tables.lend`, AND THE CLASS THAT USED TO BE HERE + IS GONE. W30's version carried its own note that the general fix was unavailable at the time: + *"adding a `tables=` parameter to `may_open` would mean editing platform/core/user_tables.py, + which belongs to another lane this wave."* It is session B's lane THIS wave, they built the + generalisation (`user_tables.lend` + the `_LENDABLE` allow-list) for W31-T10, and this is the + second caller adopting it rather than becoming a third copy of the shape. + + ⭐ AND THE SWAP FIXES SOMETHING THIS FUNCTION NEVER COVERED. The old class intercepted exactly + one key, so `may_open`'s LAST branch — `shares.may_see` → `role_for` → `st.get('object_shares')` + — still took a fresh read PER TABLE. `_LENDABLE` covers both buckets, so a shared database no + longer costs a second whole-document read per row of the picker. The name is kept because + `verify_automation`'s NC66 pins this symbol as the thing it replaces to restore the `1 + N` + state; keeping it a function means that control still has exactly one seam to swap. + + ⛔ THE OBVIOUS FIX IS STILL THE FORBIDDEN ONE. Inlining the creator-or-admin test here would + remove the N reads and re-create the exact defect wave 20 fixed: this route USED to carry its + own wider rule (`createdBy in (uname, 'automation', 'scheduler')`), so a non-admin saw a + database in the picker and was refused the moment they opened it. `may_open` stays THE one + resolver, unmodified and still called per table; it is simply no longer charged for a document + the caller is already holding. + """ + # ⚠ IMPORTED HERE, not at module scope — `core` is deliberately kept out of this module's + # import-time graph (the same reason `automation_tables` does it locally 300 lines down). + import core.user_tables as _ut + return _ut.lend(runtime, **{_UT_STORE_KEY: tables}) + + +def _discover_default(session, tables): + """The discovery picker's default table for this tenant — WITHOUT a bucket read on a warm call. + + ⚠ `tables` is the document the caller ALREADY holds on a cold call (the retirement pass reads + one). Passing it through means the cold path elects from the copy it has rather than taking a + second one, so this is never an extra read — only ever a saved one. + """ + if not session.runtime.available(): + return "" + if tables is not None: + # Cold call: the document is in hand. Elect from it and prime the memo in the same pass. + value = engine.discover_default_table(session.runtime, tables=tables) + _DISCOVER_DEFAULT[session.tenant] = (time.time(), value) + return value + return scope_cache.get(_DISCOVER_DEFAULT, session.tenant, _DISCOVER_DEFAULT_TTL, + lambda: engine.discover_default_table(session.runtime)) + + +def warm_default(rt): + """⭐⭐ WAVE 30 · T12, THE COLD HALF. Elect the discovery picker's default at BOOT. + + ⛔ THE HALF THE MEMO CANNOT FIX, and it is why this exists rather than being a nicety. The memo + above makes calls 2..N free; **call 1 is still a full `user_tables` download**, and it lands on + whoever clicks Automation first after a deploy — the one visitor with no cache anywhere, + waiting on a document whose documented ceiling is 35.8 MB / ~1.4 s, taken under the store's + single lock. That is the owner's *"only automation has a loading screen"* on a cold Space, and + two waves of memoising the warm path could never touch it. Automation was the only module + importing `scope_cache` nowhere AND the only one absent from `_prewarm`. + + ⭐ IT ELECTS, IT DOES NOT CACHE THE DOCUMENT — deliberately, and this is the whole design. + `discover_default_table` reads the bucket once here, in the prewarm daemon thread where nobody + is waiting, and what survives is a DERIVED STRING. Holding the 35.8 MB document resident would + trade a latency the tenant notices for memory the HF free tier does not have, which is the + argument `_DISCOVER_DEFAULT`'s own note makes against caching it. + + ⚠ SAME TTL AS THE REQUEST PATH, not a permanent set: the election reads which databases exist + and which hold rows, and both change while the process lives. Priming by assignment is exactly + what the cold request path already does one function up, so there is one way this memo is + filled, not two. + + Returns the elected key (`""` when the store is unavailable), so a caller can log it rather + than guess whether the warm-up did anything. Called from `main.py:_prewarm` only. + """ + tenant = str(getattr(rt, "key", "") or "") + if not tenant or not rt.available(): + return "" + value = engine.discover_default_table(rt) + _DISCOVER_DEFAULT[tenant] = (time.time(), value) + return value + + +@router.get("/automations") +def list_automations(session: Session = Depends(_GATE)): + """`{automations: [...], kinds: [...], cronPresets: [...]}` — the rail's whole payload. + + The vocabularies ride WITH the list rather than sitting in a client constant: the cron + presets and the kind list are the server's, and a client copy of either is a thing that goes + stale silently (the editor would offer a preset the parser rejects).""" + # ⭐⭐ WAVE 29 (W29-T01, owner item 5: "Automation takes a while to appear"). THE COMPLAINT WAS + # THIS ROUTE, and the cost was never the automations: it read the whole `user_tables` bucket + # THREE TIMES per call — once for the stage-field retirement scan, twice more inside + # `discover_default_table`. That bucket's documented ceiling is 35.8 MB / ~1.4 s to serialize + # (`automation_engine.py` header) and `core/store.py` re-serializes on EVERY `.get()`, hit or + # miss, UNDER THE STORE LOCK — so the reads also serialize behind each other. ~4 s of deep + # copying to render a rail that shows a name and a toggle. + # + # Now: ONE read, lent to both callers. And the retirement SCAN — O(all row-cells in the + # tenant), which hits its own `if not stage_keys: return` only AFTER walking every row of + # every table — runs once per tenant per process, mirroring the `_C8_MIGRATED` guard below. + # + # ⛔ WHY SKIPPING THE SCAN ON CALLS 2..N IS SAFE, and it is not "because it is idempotent": + # `all_definitions` STRIPS retired board state on every read (`automation_engine.py`'s + # `_without_retired_board`), so the persist step is hygiene, not correctness. No client can + # ever be shown state this skip left behind. Nothing writes new `stage_auto_` cells either — + # wave 26's R6 deleted the board that made them. + # ⭐⭐ WAVE 30 · T12 (owner items 4/5, the complaint that has now survived TWO waves). + # ⛔ W29-T01 GUARDED THE SCAN AND NOT THE READ, and that is the whole of what was left. The + # line below used to be unconditional — every single request deep-copied the tenant's entire + # `user_tables` document (documented ceiling 35.8 MB / ~1.4 s, and `core/store.py:Store.get` + # re-serializes on EVERY `.get()`, hit or miss, UNDER THE STORE LOCK so the copies also queue + # behind each other) — while on calls 2..N the result was DISCARDED: `tables` had exactly two + # consumers, the `_BOARD_RETIRED` scan (skipped after call 1) and a picker DEFAULT STRING. + # A whole-tenant document, per request, to render a rail showing a name and a toggle. + tables = None + if session.runtime.available() and session.tenant not in _BOARD_RETIRED: + tables = engine.ut_all(session.runtime) + _BOARD_RETIRED.add(session.tenant) + # Idempotent Board retirement removes only engine-marked stage fields and legacy Board + # state. User-created Status/Stage columns remain intact. + engine.retire_automation_board_state(session.runtime, tables=tables) + # ⭐⭐ WAVE 35 · T37 / OWNER RULING R10 — ROYAL IMPORTS' STATEMENTS AGENT EXISTS BEFORE ANYBODY + # ASKS FOR IT. It is minted ONCE, by the container, SWITCHED OFF; every subsequent call finds + # it present and returns immediately. See `engine.seed_statements_agent` for why this one is + # STORED where wave 34's Odoo agent is derived, and why the row's existence is a sufficient + # idempotency key (it cannot be deleted). + # ⚠ GUARDED SO IT CAN NEVER TAKE THE RAIL DOWN. A tenant with no Odoo, a store mid-outage or a + # validator change must degrade to "no statements agent", never to a 500 on the one route the + # whole Agents surface polls. The same posture `_odoo_sync_row` takes for the same reason. + if session.tenant not in _STATEMENTS_SEEDED: + try: + engine.seed_statements_agent(session.runtime, username=session.uname or "system") + except Exception: # noqa: BLE001 + pass + _STATEMENTS_SEEDED.add(session.tenant) + defs = engine.all_definitions(session.runtime) + items = [_wire(d, session.tenant, rt=session.runtime) for _, d in + sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())] + # ⭐⭐ WAVE 34 · CONTRACT C3 (W34-T48) — field agents join the list as SYNTHETIC rows. + # ⚠ MERGED AND RE-SORTED, not appended in a block at the end. R13 asks for a field agent to be + # "VIEWABLE under the Agent module", i.e. one list of agents, not a list with a second list + # stapled to it — and a rail that sorts by name everywhere except its last few rows reads as a + # rendering bug. `_field_agent_rows` costs no whole-document read; see its docstring. + _sys_rows = [r for r in (_odoo_sync_row(session),) if r] + items = sorted(items + _field_agent_rows(session) + _sys_rows, + key=lambda r: str(r.get("name") or "").lower()) + return {"automations": items, + # ⭐ WAVE 24 — DERIVED from the engine's own `KINDS`, not a hand-written trio. It was + # three literals that happened to match, which is a second copy of a server + # vocabulary; R6 has just made two of them uncreatable and `plain` has joined, so a + # hand list would now be wrong in three ways at once. `creatable` carries R6 onto the + # wire, so the ruling is a fact the client can read rather than one it must remember. + "kinds": [{"key": k, "label": engine.KIND_LABELS.get(k, k), + "creatable": k not in engine.RETIRED_KINDS} + for k in engine.KINDS], + "cronPresets": engine.CRON_PRESETS, + # ⚠ A BOOLEAN, NEVER THE KEY. The surface needs to say "the paid rung is not + # configured" honestly instead of offering a tier that will silently refuse — and + # that needs exactly one bit. Shipping the key itself to a browser would put a + # billable secret in every user's devtools. + "paidReady": engine.bd_ready(), + # THE SOURCE REGISTRY (D-9's seam), on the wire for the same reason `cronPresets` is: + # the module that RUNS a source is the only thing entitled to say what it can do, and + # a client copy of "Instagram can discover, TikTok cannot" goes stale in silence. + "sources": engine.source_status(), + # The discovery vocabulary, likewise server-owned: every name here is MEASURED- + # accepted by the vendor's own validator, so a client that invented one would build a + # query the API rejects. `lead` is the subset seen carrying VALUES on real rows. + # ⭐⭐ WAVE 32 · T46 (D-167) — THE VOCABULARY HAS A PLATFORM, AND `byKind` IS ADDITIVE + # ON PURPOSE. `fields`/`lead` keep INSTAGRAM's 21 and 3, so no stored automation and no + # client that has not adopted this changes behaviour today; `byKind` carries the per- + # corpus answer, and the SERVER already refuses a TikTok predicate naming one of the 16 + # fields TikTok's dataset does not have (`clean_predicates(..., kind)`). The door is + # closed either way — this is what lets the Find panel stop OFFERING them. + # ⚠ Derived through `engine.filter_fields`, the same accessor the validator uses, so + # the published vocabulary and the enforced one cannot drift — which is precisely what + # D-167 was: a route serving 21 names and a validator checking the same 21, both wrong + # about TikTok together, with nothing able to notice. + "discoverByKind": {k: {"fields": list(engine.filter_fields(k)[0]), + "lead": list(engine.filter_fields(k)[1])} + for k in engine.DISCOVERY_KINDS}, + "discover": {"fields": list(engine.BD_FILTER_FIELDS), + "lead": list(engine.BD_FILTER_LEAD), + "operators": list(engine.BD_FILTER_OPS), + "nullaryOperators": list(engine.BD_NULLARY_OPS), + "maxRecords": engine.BD_MAX_RECORDS, + # Wave 22 C4 — ADDITIVE: per-field flags for the toggle rows (the 3 + # PII fields stay structurally absent, R3) + the too-big guard's + # numbers, so the surface can say WHY a filter is refused before the + # server has to. + "filterMeta": engine.filter_meta(), + "guard": {"minNarrowing": engine.BD_MIN_NARROWING, + "maxRecords": engine.BD_MAX_RECORDS}, + # ⛔ D-59's `categoryOptions` IS DELIBERATELY NOT HERE — it is served by + # `GET /automations/discover/categories`. It sat on this payload for one + # commit and that was a real defect: THIS ROUTE IS POLLED EVERY 2.5 s by + # the whole automation surface, and deriving the options reads two user + # tables plus the PLATFORM MASTER, which is a different HF repo — i.e. a + # network round-trip per poll, per open tab. Caught by the gate's own + # output, which started carrying `store:get:master_snapshots` errors from + # a suite whose stated contract is that it touches no network. + # C6/R5's vocabulary, so the seed picker cannot offer a source the + # validator refuses. + "seedSources": list(engine.SEED_SOURCES), + "seedMaxRows": engine.SEED_MAX_ROWS, + # W29-T01: `tables` is the snapshot read once at the top of this handler. + # ⚠ IT WAS TAKEN BEFORE THE RETIREMENT WROTE, and that is deliberate and + # harmless: retirement only removes `stage_auto_*` FIELDS and pops those + # same keys off rows, and the election reads the preset-profile vocabulary + # (`handle` + N preset keys) and whether a table has ANY rows. A stage key + # is in neither set, and no row is ever deleted — so the pre-write + # snapshot and the post-write bucket cannot elect different tables. + # 2026-08-10 — the OFFER must name the table the SAVE will actually use. + # This was the bare `DISCOVER_TABLE` constant while `create`/`patch` now + # resolve a targetless discovery flow to the profile database the tenant + # already has, so the picker would have shown `ut_ig_candidates` and the + # save would have written somewhere else — a default that disagrees with + # itself across two panels, which is the shape wave 25's R2 fixed for + # `targetTable` vs the action's `table`. + # ⭐ WAVE 30 · T12 — through the per-tenant memo. The ELECTION rule and + # everything the comment above says about it are unchanged; what changed + # is that a warm request no longer re-reads a 35.8 MB document to + # recompute a string that did not move. + "table": (_discover_default(session, tables) + or engine.DISCOVER_TABLE)}, + "storeAvailable": bool(session.runtime.available()), + # Wave 22 C3 — the trigger vocabulary, session-scoped because email readiness is a + # per-USER fact (the poll runs through the creator's own Gmail connection). + "triggers": _triggers_vocab(session), + # ⭐ WAVE 23 C4 (R3) — the ACTION MENU, including what we have not built. Each row + # carries `ready`, so B paints "Send email" and "Run script" faded with the server's + # own reason instead of omitting them — the owner asked for Airtable's full menu, and + # a shorter list would imply those actions do not exist. `clean_actions` REFUSES an + # unready kind, so the faded state is a wall rather than a styling choice. + # ⭐ W35-T35 (C8): `session.runtime` is what withholds a TENANT-GATED row. Passing it + # here is the whole of "the menu does not offer Send statements to another tenant"; + # the STORE-side wall is `clean_actions(rt=)`, deliberately separate, because a picker + # is not a security boundary [[opening-a-route-widens-every-field]]. + "actionsCatalog": engine.action_catalog(session.runtime), + # The builder's own vocabulary: how deep a condition tree may nest, how deep groups + # may nest, and the ceilings. B reads these instead of hard-coding the same numbers + # into its "+ Add condition" affordance. + "flow": {"condOps": list(engine.LANE_OPS), + "nullaryCondOps": list(engine.LANE_NULLARY_OPS), + "maxCondDepth": engine.MAX_COND_DEPTH, + "maxCondChildren": engine.MAX_COND_CHILDREN, + "maxGroupDepth": engine.MAX_GROUP_DEPTH, + "maxActions": engine.MAX_ACTIONS, + # ⭐ W29-T09 (owner item 11) — THE POST-GROUP VOCABULARY, on the wire for the + # same reason `cronPresets` is: `clean_post_groups` REFUSES a type it does + # not know, so a client that invented one would build a config the save door + # rejects with a sentence about a word the person never typed. Both halves + # ride — the stored key and the label a person reads — because a client-side + # translation of `video` into "Reels" is a second copy of this list. + "postTypes": [{"key": t, "label": engine.POST_TYPE_LABELS.get(t, t)} + for t in engine.POST_TYPES], + # The ceiling a group's limit is judged against. `clean_post_groups` bounds a + # group by the action's OWN `maxPosts`, not by a constant, so the control can + # only warn honestly if it reads the same number the validator uses. + "maxPostsPerPull": engine.MAX_POSTS_PER_PULL, + # ⭐⭐ W33-T52 (owner item 16) — WHICH CONFIG KEYS A KIND REQUIRES, on the + # wire, for exactly the reason `postTypes` above is: the panel paints a red + # `*` beside a required control, and a client-side table of which keys those + # are would be a SECOND copy of `ACTION_REQUIRED` living in another file. The + # two would agree on the day they were written and diverge the first time a + # kind gained a key — the panel would then mark a control optional that the + # runner blocks on, and the person would read "this is fine" from the one + # surface that is meant to tell them it is not. + # ⛔ BOTH HALVES RIDE, phrase AND key, because the phrase is the ONLY human + # wording of that requirement anywhere ("a column to write into"), and the + # runner's own refusal sentence is built from it. A client that re-worded it + # would give the same requirement two names. + # ⚠ It is `ACTION_REQUIRED`, not `WEB_REQUIRED`: the table is the one the + # run refusal and `unconfigured_actions` already read, so a kind added to it + # later paints its `*` with no client change at all. + "actionRequired": {kind: [{"phrase": phrase, "key": key} + for phrase, key in reqs] + for kind, reqs in engine.ACTION_REQUIRED.items()}}, + # Wave 21 C6-A1: {"enabled": bool, "source": "in-process"|"external"|""} — see + # `_tick_state` for why one boolean off AIOS_AUTOMATIONS alone would lie. + "tick": _tick_state()} + + +@router.post("/automations/discover/estimate") +def discover_estimate(body: dict = Body(default=None), session: Session = Depends(_GATE)): + """What would this search cost? Shown BEFORE the run, never after. + + ⚠ THE ANSWER IS AN ESTIMATE AND SAYS SO IN ITS OWN PAYLOAD (`basis: "SPEC"`). Bright Data + never returns a price before a run — the funds gate fires first and `price: 0` means "not + priced", not "free" — and this account's token cannot read a balance (`/customer/balance` + answers 403). A number presented as billed would be the invented measurement this whole + module refuses to make. + """ + return engine.discover_estimate((body or {}).get("recordsLimit")) + + +#: C8's migration ran, per tenant, this process. Once is the point: the sweep only writes when +#: an unbound bag exists, so after the first pass this is a read that finds nothing. +_C8_MIGRATED = set() + + +def _table_views(session, key): + """⭐ WAVE 24 (item 8) — the saved views on ONE user table as `[{id, label}]`, or **None** + when the workspace bucket could not be read at all. + + The client said "This server did not offer a view list" because the payload genuinely had + no `views` key. C-TYPES: **absent stays ABSENT.** `[]` is a MEASUREMENT ("this database has + no saved views") and `None` is a STATE ("nobody looked") — collapsing them is precisely how + an empty picker comes to read as a claim about the database. + + ⛔ SOURCED FROM `core.table_store`, WHICH IS THE READER `view_filter` ALREADY USES — not a + second one, the same one, listed instead of looked up. The brief said to source it the way + the grid does; the grid's projection (`aios_grid.views_from_defs`) is the wrong list HERE and + the difference matters: it INJECTS the system view ("All records") and PROJECTS cohorts as + views, and neither of those lives in the stored bucket — so `view_filter` answers "view no + longer exists" for every one of them. A picker built from that projection would offer options + the `enters_view` trigger cannot resolve, which is a worse bug than the missing key it fixes. + (An `enters_view` trigger on "All records" would also mean "fire on every record", so its + absence is correct rather than a gap.) + + ⚠ `consume_corrections=False`: this is a READ for a picker, and the default MUTATES — it + takes and clears the pending field-correction acknowledgement, so listing views would eat a + protocol message meant for the grid. + """ + try: + import core.table_store as table_store + # ⛔ THE REACHABILITY PROBE IS NOT REDUNDANT, and leaving it out was a real defect this + # gate's own NC caught: EVERY `TableStore` accessor wraps its store read in `try/except` + # and returns `{}` on failure. So a bucket that could not be read is indistinguishable + # from one with no views — which collapses exactly the ABSENT/EMPTY distinction this + # function exists to preserve, and the caller would ship `views: []` as a measurement + # nobody took. The one read that is allowed to raise has to be ours. + session.runtime.get(f"{key}_table_workspace") + tops = table_store.make(f"{key}_table_workspace", st=session.runtime) + own = (tops.workspace(session.uname, consume_corrections=False) or {}).get("views") or {} + shared = tops.shared_views(session.uname, session.admin) or {} + except Exception: # noqa: BLE001 + return None + merged = {**shared, **own} # a view lives in ONE home; the merge is belt-and-braces + return sorted( + ({"id": str(vid), "label": str((v or {}).get("name") or vid)} + for vid, v in merged.items() if isinstance(v, dict)), + key=lambda r: r["label"].lower()) + + +@router.get("/automations/tables") +def automation_tables(session: Session = Depends(_GATE)): + """The blank databases an automation can target, with their fields. + + ⚠ A READ-ONLY MIRROR of C3-UT's `GET /api/v1/tables`, under this router's own prefix so the + two can never collide. It exists because the automation editor needs the table+field list to + build a config at all, and D must not block on A's route landing. When C3-UT is live this + keeps working (same bucket) — it is a duplicate reader, never a second writer. + + ⛔ WAVE 20, item 3 — IT NO LONGER MIRRORS THE WALL, IT CALLS IT. This route re-implemented + `may_open` and got it WIDER: it also admitted `createdBy in ('automation', 'scheduler')`, so + a non-admin saw automation-created databases here and was refused the moment they opened, + edited or deleted one. A duplicate READER is fine; a duplicate WALL is not, because the two + only disagree in front of a user. One resolver now, and the engine stamps a human owner so + the merge takes nothing legitimate away (`ut_ensure`, `MACHINE_OWNERS`). + """ + import core.user_tables as user_tables + + # C8 (wave 22): bind pre-law automation bags to the definitions that write them — once + # per tenant per process, and a no-op read after the first real pass. A write-on-read, + # stated out loud: this is the surface whose stale bags mislead (the column picker), so + # it is where the truth gets repaired. + if session.tenant not in _C8_MIGRATED and session.runtime.available(): + _C8_MIGRATED.add(session.tenant) + try: + engine.bind_unbound_fields(session.runtime) + except Exception: # noqa: BLE001 + pass + + out = [] + # ⭐⭐ WAVE 30 · T13 — ONE read of the tenant document, LENT to the wall for every table. + # It was `1 + N` full deep copies (this `ut_all`, then `may_open` → `get` → `all_tables` per + # table), each of a document with a 35.8 MB / ~370 ms ceiling, all of them queued behind + # `Store._lock`. `may_open` is unchanged and still asked about every table — see `_LentTables` + # on why re-implementing the wall here is the one fix that is NOT available. + _tables_doc = engine.ut_all(session.runtime) + _lent = _LentTables(session.runtime, _tables_doc) + for key, t in sorted(_tables_doc.items(), + key=lambda kv: (kv[1].get("label") or "").lower()): + if not user_tables.may_open(key, session.uname, session.admin, st=_lent): + continue + row = {"key": key, "label": t.get("label") or key, + "source": t.get("source") or "Blank", + "rowCount": len(t.get("rows") or {}), + "fields": [{"key": f.get("key"), "label": f.get("label"), + "type": f.get("type") or "text", + "automation": f.get("automation") or None} + for f in (t.get("fields") or [])]} + views = _table_views(session, key) + if views is not None: + row["views"] = views # absent when the bucket did not answer — never [] + out.append(row) + return {"tables": out} + + +@router.get("/automations/presets") +def automation_presets(table: str = "", session: Session = Depends(_GATE)): + """⭐ WAVE 25 · C1 / R2b — the Instagram preset columns, diffed against ONE database. + + `{fields: [{key, label, type, present}], willUse: [...], willCreate: [...]}` — the + "already in this database" / "will be created" split the owner asked for, so the Create record + action's configuration can SHOW what pointing it here does before anything is spent. + + ⛔ DECLARED ABOVE `/automations/{auto_id}`, AND THAT IS LOAD-BEARING RATHER THAN TIDY. + FastAPI matches routes in DECLARATION order, so a literal path registered after a sibling + path-parameter route is never reached — this handler would simply never run and + `get_automation` would answer "no automation with that id" for the id `presets`. A 404 with a + plausible sentence is the worst possible failure here, because it reads as "the endpoint is + fine, the data is missing". `/automations/tables` sits above the same route for the same + reason; this follows it rather than inventing a second convention. + + ⚠ WALLED BY `may_open`, LIKE EVERY OTHER TABLE READER. Without it, asking about a table you + cannot open would answer which of its columns exist — a small disclosure, and exactly the + duplicate-wall mistake `automation_tables` records at wave 20. + """ + import core.user_tables as user_tables + + key = str(table or "").strip() + if key and not user_tables.may_open(key, session.uname, session.admin, + st=session.runtime): + raise err(404, "unknown_table", "no such database") + return engine.preset_plan(session.runtime, key) + + +@router.get("/automations/discover/categories") +def automation_categories(session: Session = Depends(_GATE)): + """⭐ DEBT D-59 — the Category combobox's options: the values this deployment has ACTUALLY + SEEN, each with its observed count. `{options: [{value, count}]}`. + + ⛔ ITS OWN ROUTE, ON PURPOSE. This belongs to the Find surface and is asked for when that + panel opens — it must never ride `GET /automations`, which the whole automation surface polls + every 2.5 s: deriving these reads two user tables AND the platform master (a different HF + repo), so on the polled payload it is a network round-trip per tab per poll. + + ⚠ THE CONTROL STAYS A COMBOBOX. These are the values we have seen, not the values that exist. + D-59 is explicit that transcribing Instagram's published taxonomy would be worse than no + dropdown — a filter on a value the corpus does not use returns zero rows and looks exactly + like an honest "no such accounts exist", which misleads precisely when it looks authoritative. + """ + return {"options": engine.observed_categories(session.runtime)} + + +@router.post("/automations/seed/derive") +def automation_seed_derive(body: dict = Body(default=None), session: Session = Depends(_GATE)): + """⭐ WAVE 25 · C6 / R5 — "find me more accounts like the ones in this view". + + Body `{source: "view"|"cohort", table: "", id: ""}` → + `{derived: [...], basis: {rows, fields:[{name,label,value,coverage}], related, note}}` + + ⛔ IT DERIVES AND RETURNS; IT SAVES NOTHING. R5: the derived conditions are "visible and + editable, never hidden" — so the client drops them into the ordinary condition rows, where the + user edits them like anything else, and the ordinary PATCH stores them. A door that both + derived and saved would make the suggestion feel like a decision. + + ⚠ `basis` IS NOT DECORATION AND MUST BE RENDERED. It carries how many rows were read and the + MEASURED coverage of each characteristic, which is the difference between "12 of 12 of these + bios say florist" and "7 of 12 do" — presented identically, the weak one reads as authority. + + ⚠ Declared ABOVE `/automations/{auto_id}` for the reason `/automations/presets` is (FastAPI + matches in declaration order); that ordering is gated. + """ + import core.user_tables as user_tables + + body = body or {} + table = str(body.get("table") or "").strip() + if not table: + raise err(400, "no_table", "name the database to read the seed records from") + if not user_tables.may_open(table, session.uname, session.admin, st=session.runtime): + raise err(404, "unknown_table", "no such database") + source = str(body.get("source") or "view").strip().lower() + if source not in engine.SEED_SOURCES: + raise err(400, "bad_seed_source", + f"a seed comes from one of: {', '.join(engine.SEED_SOURCES)}") + rows, problem = engine.seed_rows(session.runtime, table, str(body.get("id") or "")) + if problem: + raise err(400, "bad_seed", problem) + derived, basis = engine.seed_predicates(rows) + return {"source": source, "table": table, "id": str(body.get("id") or ""), + "derived": derived, "basis": basis} + + +#: The injected chat transport, for gates only — `None` in every shipped path, so the route takes +#: the real ladder. Set by `verify_automation.py` to prove this door end to end with NO API key and +#: NO spend (`routes_query._call_model`'s `chat` argument is the same idea and the same reason). +_DRAFT_CHAT = [None] + + +@router.post("/automations/draft") +def draft_automation(body: dict = Body(default=None), session: Session = Depends(_GATE)): + """⭐⭐ WAVE 33 · W33-T54/T55 (owner item 7, ruling R3) — A DESCRIPTION BECOMES A DRAFT FLOW. + + `{prompt}` → `{draft: {name, trigger, table, actions:[{id,kind,config,why}]}, provider, + dropped: [...], saved: false}` — or a 400 carrying ONE plain sentence. + + ⛔⛔ NOTHING IS SAVED HERE, AND THE `saved: false` ON THE WIRE IS NOT DECORATION. T54's + `done-when` is *"gets back a DRAFT flow they can see BEFORE anything is saved"*; a door that + wrote first and showed second would satisfy every check about the flow's contents and none about + the promise. Accepting a draft is the ORDINARY `POST /automations` — R3's *"indistinguishable + from a hand-built one"* is achieved by there being no second write path, not by making the + second one look similar. + + ⛔⛔ CONTRACT C7, AND THIS ROUTE IS WHERE IT IS ENFORCED: *"the output must pass `clean_actions` + unchanged."* So it is RUN, here, before the person ever sees the draft — and the result is + DIFFED against what the model wrote. `clean_actions` has no disclosure channel (D-75): it drops + a config key it does not recognise and answers 200. Without this diff a person would accept a + flow, open it, and find a step configured differently from the one they were shown, with nothing + anywhere having said so. `dropped` is that channel, built here because this is the first caller + that needed one. + ⚠ A draft whose actions `clean_actions` REFUSES outright is a 400, never a partial flow: half a + flow presented as a whole one is the one outcome worse than a refusal. + + ⚠ DECLARED ABOVE `/automations/{auto_id}`, and that is load-bearing rather than tidy — FastAPI + matches in DECLARATION order, so a literal path registered after a sibling path-parameter route + is never reached, and `get_automation` would answer *"no automation with that id"* for the id + `draft`. The same reason `/automations/tables` and `/automations/presets` sit up here. + """ + prompt = str((body or {}).get("prompt") or "").strip() + if not prompt: + raise err(400, "no_prompt", "type what you want the automation to do") + # ⭐⭐ ASK D-18 (2026-08-18) — THE KEY THE CLIENT SENDS IS NOW READ. This door took `prompt` off + # the body and nothing else, so the Agent chat's model toggle (W36-T34) was a control that + # configured nothing: the value was accepted and dropped, which is the shape three tickets in + # this wave already tripped over. D held T34 rather than mark a picker BUILT while its value + # stopped at the browser, and was right to. + # ⚠ UNKNOWN OR UNCONFIGURED FALLS BACK TO THE LADDER, never refuses (D-18's asked-for posture, + # and C5's everywhere else): a model the ladder stops offering must not turn every later draft + # into an error. The response already names the rung that ANSWERED, which is the honest half. + _model = str((body or {}).get("model") or "").strip().lower() + if _model in ("", "auto"): + _model = None + # ⚠ THE TENANT'S OWN TABLES, THROUGH THE EXISTING WALL. `automation_tables` applies `may_open` + # per table, so the model is shown exactly the databases this caller may already see and cannot + # name one they were not granted — the permission wall re-used, never a second one built beside + # it. (`QueryPage`'s `granted` prop carries the same clause on the client side.) + tables = (automation_tables(session) or {}).get("tables") or [] + # ⭐⭐ WAVE 34 · W34-T42 / R18 — THE DRAFTER IS OFFERED WHAT THE PICKER OFFERS, nothing more. + # + # R18 collapsed six web actions into one on the menu. The other five stay in the catalog + # because the surviving "Web agent" composes its own steps out of them server-side — but a + # DRAFT is read and accepted by a PERSON, who then edits it on the Canvas. Handing the model + # the unfiltered catalog would let it draft a step kind that is not in the action picker: the + # reader could not add a second one, could not reason about where it came from, and the flow + # they accepted would not be one they could have built by hand. R3's *"indistinguishable from + # a hand-built one"* is a claim about what a person can REACH, not only about the write path. + # ⚠ This is the ONE place the filter is applied on this route, and it is applied to the + # drafter's vocabulary only. `clean_actions` below still validates against the FULL catalog, + # so a flow that already holds a hidden kind is unaffected and nothing 400s (D-65). + # ⚠ AND `required` IS NARROWED WITH IT, which is the half that is easy to miss. `draft_flow` + # builds the ENUM from `catalog` and the "settings you must write" prose from `required`, so + # filtering one and not the other describes settings for kinds the model cannot choose. That is + # not merely untidy: it spends prompt on unreachable options and invites the model to reach for + # one. `_ai_agent_plan` already narrows both in exactly this way; this follows it. + # W35-T35 (C8): tenant-gated rows are withheld here too, or the DRAFTER would offer a kind + # the same tenant's own save door then refuses — a flow the assistant proposes and the product + # will not accept. + menu_catalog = [r for r in engine.action_catalog(session.runtime) if r.get("menu") is not False] + _offered = {r["kind"] for r in menu_catalog} + draft, refusal, provider = ai_review.draft_flow( + prompt=prompt, + catalog=menu_catalog, + required={k: v for k, v in engine.ACTION_REQUIRED.items() if k in _offered}, + triggers=_triggers_vocab(session), + tables=tables, + # ⭐ W35 · CONTRACT C7 (`NOTE E-16`) — the spend is ATTRIBUTED. Unlike the two engine call + # sites, this one has a real person behind it: somebody typed the sentence, so `user` is + # the caller rather than the automation's owner. + st=session.runtime, user=getattr(session, "uname", "") or "", + model=_model, + chat=_DRAFT_CHAT[0]) + if refusal or not draft: + raise err(400, "draft_refused", refusal or "no automation could be drafted from that") + + # ── C7: run the real save-door validator and DIFF it ────────────────────────────────── + wrote = draft.get("actions") or [] + # W35-T35 (C8): the SAME `rt` the menu was built with. Without it this validator would refuse + # a tenant-gated kind it had just offered the model — the draft door disagreeing with the save + # door about one tenant, which reads as "the assistant produced an invalid flow". + cleaned, why = engine.clean_actions([{"kind": a.get("kind"), "config": a.get("config") or {}} + for a in wrote], rt=session.runtime) + if why or cleaned is None: + raise err(400, "draft_invalid", + f"the assistant produced a flow this deployment will not accept: {why}") + dropped = [] + for before, after in zip(wrote, cleaned): + b_cfg = before.get("config") or {} + a_cfg = after.get("config") or {} + gone = sorted(k for k in b_cfg + if k not in a_cfg or str(a_cfg.get(k)) != str(b_cfg.get(k))) + if gone: + dropped.append({"kind": str(after.get("kind") or ""), "keys": gone}) + # ⛔⛔ WHAT IS STILL MISSING FROM EACH STEP, PER STEP — and this is the fix for the sharpest + # thing the verifier found. The system prompt TELLS the model *"leave one blank rather than + # inventing a web address, a CSS selector or a column name"* (which is right), and the panel + # skips empty values (which is also right, on its own). Together they meant a `web_read` with + # no selector and no target column painted as a FINISHED step — url, attr, timeout, nothing + # else — and the ordinary create door then accepted it. A person approved a step that reads + # nothing into nowhere, having been shown no blank at all. + # ⚠ `engine.action_needs` IS THE PREDICATE, not a second list: the same function the builder's + # Configured/Unconfigured label and the run refusal read. Three readers, one answer. + _tables_by_key = {str(t.get("key")): t for t in tables} + _target_cols = {str(f.get("key")) for f + in (_tables_by_key.get(str(draft.get("table") or "")) or {}).get("fields") or []} + shown = [] + for i, after in enumerate(cleaned): + row = dict(after, why=str((wrote[i] or {}).get("why") or ""), + needs=engine.action_needs(after)) + # ⛔ AND A COLUMN NAME THE DATABASE DOES NOT HAVE. `field` is a free string that nobody + # validated at draft, at accept or at store — so a model writing the column's LABEL + # ("Price") instead of its key ("price"), which is exactly what a person would say and an + # obedient model would echo, stored a write into a column that does not exist. No schema + # was violated; the flow was well-formed and did something other than what was asked. + _f = str((after.get("config") or {}).get("field") or "") + if _f and _target_cols and _f not in _target_cols: + row["unknownField"] = _f + shown.append(row) + # ⚠ A TRIGGER THE CREATE DOOR WOULD REFUSE IS CORRECTED HERE, NOT SHOWN AND THEN 400'd. The + # enum makes this need a model that ignores its own schema, but the failure mode is ugly: the + # draft paints, the person clicks Accept, and the save refuses with a sentence about a word + # they never chose. Falling back to `manual` and SAYING SO keeps the draft usable. + _trig = str(draft.get("trigger") or "").strip() + _notes = [] + if _trig and _trig not in tuple(engine.TRIGGER_KEYS): + _notes.append(f"the assistant asked for a trigger this deployment does not have " + f"({_trig}). Set to manual instead") + _trig = "" + _asked = int(draft.get("asked") or len(wrote)) + if _asked > len(shown): + _notes.append(f"the assistant wrote {_asked} steps and a draft carries at most " + f"{ai_review.MAX_DRAFT_ACTIONS}; the last {_asked - len(shown)} were not " + f"kept. Describe the job in two automations, or shorten it.") + # ⭐⭐ WAVE 34 · W34-T47 (mailbox D-5) — THE MODEL'S OWN WORDS ARE NORMALISED, BECAUSE R6 + # CANNOT BE ENFORCED BY A SOURCE SCAN. + # + # ⛔ `web_prose` READS FILES. Every string below is written by a language model at run time, + # so the gate is structurally blind to it: the sweep every lane did this wave is undone by our + # own drafter the first time it answers with an em dash. Lane D MEASURED that a prompt + # instruction does not hold (their system prompt says "Never use an em dash" and the very next + # cerebras turn came back with one), so this is code, at the boundary, not a nicer prompt. + # ⚠ THREE STRINGS REACH A SCREEN FROM HERE and all three are covered: the flow's NAME, each + # step's `why`, and the `dropped`/`notes` sentences. `W34-T42` is what made them visible. + # ⚠ A DIGIT RANGE IS A DIFFERENT SENTENCE: "10-20" means "10 to 20", and rewriting it as + # "10, 20" states two numbers where the model stated a span. It gets its own rule, first. + # Same shape as `routes_query._no_dashes`, deliberately: one behaviour, two doors. + # ⚠ MODULE-LEVEL, not a closure. A normaliser defined inside this handler is unreachable to + # anything that is not an HTTP request, so the only way to test it would be to drive the whole + # route and read the answer — which is how a rule ends up asserted by a grep instead of a call. + # ⛔ `dropped` IS NOT NORMALISED AND THAT IS DELIBERATE: it is `[{kind, keys}]`, an action kind + # from OUR catalog and config keys from OUR allowlist, never a model's sentence. The first + # version of this block mapped the normaliser over it and turned each dict into the string + # `"{'kind': ..., 'keys': [...]}"` — `verify_automation`'s own W33 section caught it with a + # `TypeError` on `d["kind"]`. Applying a text rule to a structure is how a channel stops + # carrying what its reader expects, and the reader here is a person's list of what changed. + shown = [dict(r, why=_draft_no_dashes(r.get("why"))) for r in shown] + _notes = [_draft_no_dashes(n) for n in _notes] + return {"draft": {"name": _draft_no_dashes(draft.get("name")) or "New automation", + "trigger": _trig or "manual", + "table": draft.get("table") or "", + "actions": shown}, + "provider": provider, "dropped": dropped, "notes": _notes, "saved": False} + + +# ══════════════════ WAVE 36 · W36-T38 (owner item 8, ruling R9) — AGENT-AUTHORED ACTIONS ══════ +# +# Owner, verbatim (2026-08-18): *"Create the ability for an automation agent to create any +# 'Action' under the Canvas, so its configuration can only be touched by the agent. Be it a tool a +# script etc. We need to really guardrail the reach of this script."* +# +# ⭐⭐ R9 IS A **WRITE** RULE, NOT A VISIBILITY RULE, and the ticket says so in as many words: the +# user MAY read an agent-authored action's configuration and MAY delete it; what they may not do +# is hand-edit it. Hiding a config from the person whose workspace it runs in is a different +# product, and a worse one — nobody can audit what they cannot see. +# +# ⛔ AND IT IS ENFORCED AT THE DOOR, NEVER IN A `disabled` ATTRIBUTE. `routes_automation` already +# carries that lesson for a different control; a client-side lock is a suggestion, and the whole +# point of R9 is that the agent's configuration stays coherent with what the agent believes it +# built. +# +# ⚠ WHY THE MARK IS A SEPARATE BUCKET RATHER THAN A KEY ON THE ACTION. `clean_actions` builds every +# action KEY BY KEY from an allowlist and drops anything it does not recognise (D-75) — so a +# marker stored inside the action would be silently erased on the next save, and the wall would +# quietly stop existing with every gate still green. This is an ANNOTATION layer keyed by +# `(automation id, action id)`; the configuration itself stays where it always was, in the +# automation, with exactly one writer. +AGENT_ACTIONS_KEY = "automation_agent_actions" + +#: Ids this door mints. ⚠ It must match `clean_actions`' `act_[a-z0-9_]{1,32}` or the engine +#: re-mints it and the annotation points at an action that no longer carries that id. +_AGENT_ACTION_PREFIX = "act_ag" + + +def _agent_marks(rt): + """`{auto_id: {action_id: mark}}` for one tenant. `{}` on any failure — an unreadable + annotation must degrade to "nothing is agent-owned", never to a 500 on the automations rail. + + ⛔ AND "DEGRADE TO NOTHING IS OWNED" IS THE SAFE DIRECTION HERE, which is worth stating because + it usually is not. The wall this feeds protects the AGENT's coherence, not the tenant's data: a + lost mark lets a person edit a config they own anyway, in their own workspace, on a step they + could always have deleted outright. A wall that failed CLOSED would instead make an automation + permanently unsavable because one annotation read timed out. + """ + try: + found = rt.get(AGENT_ACTIONS_KEY) or {} + except Exception: # noqa: BLE001 + return {} + return found if isinstance(found, dict) else {} + + +def _flat_actions(actions, out=None, depth=0): + """Every action in a flow, INCLUDING the ones nested inside a group's arms. + + ⚠ NESTED ACTIONS ARE THE ONES THIS MUST NOT MISS. `unconfigured_actions` walks them for the + same reason: a step inside an If/then branch is exactly the one a person cannot see, and a + wall that only looked at the top level would leave the agent's own nested step editable. + """ + out = [] if out is None else out + if depth > 6 or not isinstance(actions, list): + return out + for action in actions: + if not isinstance(action, dict): + continue + out.append(action) + for key in ("then", "else", "actions"): + _flat_actions(action.get(key), out, depth + 1) + return out + + +def _flow_actions(defn): + return _flat_actions(((defn or {}).get("flow") or {}).get("actions") or []) + + +def _same_action(left, right, rt): + """Do these two actions carry the SAME kind and configuration? + + ⛔ COMPARED AFTER `clean_actions`, ON BOTH SIDES, and that is the difference between a wall and + a nuisance. The stored action has already been through the cleaner; a client round-trip has + not, so it may carry a key order, a blank string or a dropped condition that means nothing. + Comparing raw shapes would refuse an edit that changes nothing, and a wall that fires on a + no-op is one an operator learns to route around [[one-question-two-normalizers]]. + """ + def _clean(action): + cleaned, error = engine.clean_actions([dict(action or {}, id="act_1")], rt=rt) + if error or not cleaned: + return None + one = dict(cleaned[0]) + one.pop("id", None) + return one + + a, b = _clean(left), _clean(right) + return a is not None and a == b + + +def _agent_owned_guard(session, auto_id, body): + """R9 AT THE DOOR: a user may not change an agent-authored action's kind or configuration. + + Three outcomes, and the middle one is the ruling: + * the action is ABSENT from the incoming flow -> a DELETE, and R9 allows it + * the action is present and CHANGED -> **409**, naming the action and the agent + * the action is present and identical -> nothing happens, so an ordinary save of a + flow that merely CONTAINS an agent action + is not refused + """ + marks = _agent_marks(session.runtime).get(str(auto_id)) or {} + if not isinstance(marks, dict) or not marks: + return + incoming = (body or {}).get("flow") + if not isinstance(incoming, dict) or "actions" not in incoming: + return + stored = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {} + was = {str(a.get("id") or ""): a for a in _flow_actions(stored)} + now = {str(a.get("id") or ""): a + for a in _flat_actions(incoming.get("actions") or [])} + for action_id, mark in marks.items(): + action_id = str(action_id) + if action_id not in now or action_id not in was: + continue # removed, or never stored: not an EDIT + if _same_action(was[action_id], now[action_id], session.runtime): + continue + who = str((mark or {}).get("agentName") or (mark or {}).get("agent") or "an agent") + raise err(409, "agent_owned", + f"this step was built by {who} and only {who} can change how it is set up. " + f"You can read it, and you can delete it, but it cannot be edited by hand") + + +def _prune_marks(session, auto_id): + """Drop annotations whose action is gone, and the whole entry when the automation is. + + ⚠ CALLED AFTER EVERY WRITE, because the alternative is an annotation bucket that only ever + grows: a mark on a deleted action would keep refusing an id nobody can see, and a mark on a + deleted automation would sit in the tenant document forever. + """ + auto_id = str(auto_id) + + def _set(cur): + cur = dict(cur or {}) + marks = cur.get(auto_id) + if not isinstance(marks, dict): + cur.pop(auto_id, None) + return cur + defn = (engine.all_definitions(session.runtime) or {}).get(auto_id) + if defn is None: + cur.pop(auto_id, None) + return cur + alive = {str(a.get("id") or "") for a in _flow_actions(defn)} + kept = {k: v for k, v in marks.items() if str(k) in alive} + if kept: + cur[auto_id] = kept + else: + cur.pop(auto_id, None) + return cur + + session.runtime.update(AGENT_ACTIONS_KEY, _set, flush="sync") + + +@router.post("/automations/{auto_id}/agent-actions") +def agent_author_action(auto_id: str, body: dict = Body(default=None), + session: Session = Depends(_GATE)): + """THE AGENT'S DOOR: add or replace one Action under the Canvas, marked agent-owned (R9). + + {agent: "", action: {kind, config, when?, id?}} + -> {automation, actionId, agent} + + ⛔ THE ACTION GOES THROUGH `clean_actions` LIKE EVERY OTHER ONE, and that is what "guardrail + the reach of this script" means in code: an agent cannot invent a config key, cannot name a + kind that is not in the catalog, and cannot reach a kind this tenant is not entitled to. The + agent gets a different WALL on editing, never a wider vocabulary. + + ⛔ AND IT CANNOT AUTHOR A KIND THAT IS NOT BUILT. `run_script` is `ready: False` in the + catalog, so `clean_actions` refuses it here exactly as it refuses it for a person — see this + lane's mailbox for why the script ARM is booked rather than half-built: a per-row script + contract and a client card are both missing, and a step that reports success and does nothing + is the failure this repo has already paid for. + + ⚠ THE AGENT ID IS CHECKED AGAINST THE TENANT'S OWN AGENTS. A mark naming an agent that does + not exist would refuse every future edit with a sentence naming nobody. + """ + import routes_slack # noqa: PLC0415 + + body = body if isinstance(body, dict) else {} + agent_id = str(body.get("agent") or "").strip() + agent = routes_slack._agents(session.runtime).get(agent_id) + if not isinstance(agent, dict): + raise err(404, "no_agent", "there is no agent with that id in this workspace") + action = body.get("action") + if not isinstance(action, dict) or not str(action.get("kind") or "").strip(): + raise err(400, "no_action", "an action needs a kind") + + defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) + if defn is None: + raise err(404, "unknown_automation", "no automation with that id") + if str(defn.get("system") or "").strip(): + raise err(409, "system_agent", engine.system_agent_refusal(str(defn["system"]))) + + existing = list(((defn.get("flow") or {}).get("actions") or [])) + taken = {str(a.get("id") or "") for a in _flat_actions(existing)} + action_id = str(action.get("id") or "").strip() + if not action_id or action_id not in taken: + n = 1 + while f"{_AGENT_ACTION_PREFIX}{n}" in taken: + n += 1 + action_id = f"{_AGENT_ACTION_PREFIX}{n}" + fresh = dict(action, id=action_id) + + # ⚠ VALIDATED BEFORE ANYTHING IS WRITTEN, so a refusal leaves the automation exactly as it was. + checked, error = engine.clean_actions([fresh], rt=session.runtime) + if error or not checked: + raise err(400, "invalid_action", error or "that action could not be built") + + replaced = False + for i, one in enumerate(existing): + if isinstance(one, dict) and str(one.get("id") or "") == action_id: + existing[i], replaced = fresh, True + break + if not replaced: + existing.append(fresh) + + notes = [] + updated, error = engine.patch(session.runtime, str(auto_id), + {"flow": {**(defn.get("flow") or {}), "actions": existing}}, + username=session.uname, notes=notes) + if error: + raise err(400, "invalid_automation", error) + + def _set(cur): + cur = dict(cur or {}) + marks = dict(cur.get(str(auto_id)) or {}) + marks[action_id] = {"agent": agent_id, + "agentName": str(agent.get("label") or agent.get("channelName") + or agent_id), + "created": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "by": session.uname} + cur[str(auto_id)] = marks + return cur + + session.runtime.update(AGENT_ACTIONS_KEY, _set, flush="sync") + _prune_marks(session, auto_id) + return {"automation": _wire(updated, session.tenant, rt=session.runtime), + "actionId": action_id, "agent": agent_id, "notes": notes} + + +@router.get("/automations/{auto_id}") +def get_automation(auto_id: str, session: Session = Depends(_GATE)): + """One automation, by id. + + ⛔⛔ WAVE 34 · CONTRACT C3 — A SYNTHETIC ROW MUST BE FETCHABLE BY ID, NOT ONLY LISTABLE, AND + THIS IS THE HALF THAT IS EASY TO SHIP BROKEN. `list_automations` and this route are SEPARATE + lookups: a row appended only to the list appears in the rail, looks entirely real, and 404s + the instant somebody clicks it. `W34-T48`'s `done-when` is *"opening it shows a Trigger and + one step"* — i.e. this route, not the list — so the field agents are resolved here too, from + the same builder, and a person cannot reach a row the detail route does not know. + ⚠ THE SAME BUILDER, NOT A SECOND ONE. `_field_agent_rows` is called and the id looked up in + its output rather than re-deriving one row from the field: two derivations of one row is how + the list and the editor start disagreeing about a name. + """ + if str(auto_id) == ODOO_SYNC_ID: + # ⚠ `detail=True` — the keychain label and the last-sync stamp cost a store read each and + # are resolved HERE, on a route that fetches one automation, never on the list. + row = _odoo_sync_row(session, detail=True) + if row is None: + raise err(404, "unknown_automation", "no automation with that id") + return {"automation": row} + if str(auto_id).startswith("field:"): + row = next((r for r in _field_agent_rows(session) if r.get("id") == str(auto_id)), None) + if row is None: + # ⚠ The SAME 404 as any other unknown id. A field agent whose column has been deleted + # is genuinely gone, and inventing a different error for it would make a normal + # outcome look like a fault. + raise err(404, "unknown_automation", "no automation with that id") + return {"automation": row} + defn = engine.all_definitions(session.runtime).get(str(auto_id)) + if defn is None: + raise err(404, "unknown_automation", "no automation with that id") + return {"automation": _wire(defn, session.tenant, rt=session.runtime)} + + +@router.post("/automations") +def create_automation(body: dict = Body(default=None), session: Session = Depends(_GATE)): + """Create an automation. ⭐ WAVE 23 (C2): the body may carry + `target: {mode: "existing"|"new"|"automated", table?, label?}` — the wizard's FIRST question, + answered before the kind. `new` mints the blank database in the same call, so the automation + is never saved pointing at a table that does not exist yet.""" + if not session.runtime.available(): + raise err(503, "store_unavailable", "the tenant store is unavailable. Nothing was saved") + # ⭐⭐ D-75 — THE DISCLOSURE CHANNEL, CONSUMED. `clean_actions` silently drops a + # `create_record` condition (D-65's law: dropping is recoverable, refusing locks the door) and + # any config key an arm's allowlist does not keep. Both are deliberate; what was missing was + # anybody being TOLD, so a person saved a flow and got a different one with nothing said. + # ⚠ It rides the SAVE's own response, beside `unconfigured`, because that is the moment the + # person is looking — a note in a log they never open is the same silence with extra steps. + _notes = [] + defn, error = engine.create(session.runtime, body or {}, username=session.uname, + notes=_notes) + if error: + raise err(400, "invalid_automation", error) + return {"automation": _wire(defn, session.tenant, rt=session.runtime), "notes": _notes} + + +@router.patch("/automations/{auto_id}") +def patch_automation(auto_id: str, body: dict = Body(default=None), + session: Session = Depends(_GATE)): + if not session.runtime.available(): + raise err(503, "store_unavailable", "the tenant store is unavailable. Nothing was saved") + # ⛔⛔ WAVE 34 · CONTRACT C3 — A SYNTHETIC ROW IS NOT PATCHABLE, AND IT MUST SAY SO. A field + # agent is DERIVED from a column definition; there is nothing in the automations bucket to + # write. `engine.patch` would answer "no such automation" (a 404 that reads as "your agent + # vanished") or, worse for a future id shape, find nothing and report success. The refusal + # names where the setting actually lives, because a wall that does not say what to do instead + # sends somebody looking for a bug in a decision made on purpose (D-65's own lesson). + if str(auto_id).startswith("field:"): + raise err(409, "system_agent", + "this agent is an AI enrichment column. Change its prompt, model or schedule on " + "the column itself and this page follows") + if str(auto_id) == ODOO_SYNC_ID: + return _patch_odoo_sync(session, body or {}) + # D-75, same channel as `create` above — an EDIT is where this matters most, because the + # person has just typed the thing that gets dropped. + # ⛔⛔ W36-T38 / R9 — THE AGENT-OWNED WALL, AT THE DOOR, BEFORE ANY WRITE. A `disabled` + # attribute in the builder is a suggestion; this is the refusal. It permits a DELETE of the + # step and permits an ordinary save of a flow that merely contains one. + _agent_owned_guard(session, auto_id, body or {}) + _notes = [] + defn, error = engine.patch(session.runtime, auto_id, body or {}, username=session.uname, + notes=_notes) + if error: + raise err(400 if error != "no such automation" else 404, + "invalid_automation" if error != "no such automation" else "unknown_automation", + error) + # An action the user removed takes its annotation with it, or the mark outlives its subject + # and refuses an id nobody can see. + _prune_marks(session, auto_id) + return {"automation": _wire(defn, session.tenant, rt=session.runtime), "notes": _notes} + + +@router.delete("/automations/{auto_id}") +def delete_automation(auto_id: str, session: Session = Depends(_GATE)): + """Delete the DEFINITION. ⚠ The database it filled is NOT touched — an automation is the + thing that writes rows, not the thing that owns them, and deleting a job must never be a way + to lose data (the same rule the orphan count encodes). + + ⭐⭐ WAVE 34 · CONTRACTS C3 + C4 — A SYSTEM AGENT REFUSES, LOUDLY, AND THE REFUSAL SAYS WHERE + TO GO INSTEAD. Two families are undeletable and they are undeletable for different reasons, so + the sentence names the reason rather than saying "no": a FIELD AGENT is a column (delete the + column), and the ODOO SYNC is the connector's own schedule (disconnect the connector). + + ⛔ REFUSED, NOT IGNORED, AND THAT IS THE POINT OF THE FIRST BRANCH. A synthetic id is not in + the automations bucket, so `engine.remove` would find nothing, do nothing, and this route + would answer `200 {"deleted": ...}` — a delete that reports success and changes nothing, which + is the `view_upsert` failure mode (200 OK, zero writes) arriving in a new place. The row would + then reappear on the next poll and read as a bug in the rail. + """ + # ⛔⛔ EVERY SYNTHETIC ID, NOT JUST THE FIELD ONES. The first version of this guard listed + # `field:` alone, and `verify_automation`'s own C4 leg caught what that left open: deleting + # `system:odoo_sync` fell straight through to `engine.remove`, which popped a key that was + # never in the bucket and answered `200 {"deleted": ...}`. A delete that reports success and + # changes nothing, on the one row the ruling says must be undeletable — the exact defect the + # guard exists for, one id shape away. Both prefixes are refused by the same branch now. + if str(auto_id).startswith("field:"): + raise err(409, "system_agent", engine.system_agent_refusal(SYSTEM_FIELD_AGENT)) + if str(auto_id) == ODOO_SYNC_ID: + raise err(409, "system_agent", engine.system_agent_refusal(SYSTEM_ODOO_SYNC)) + if engine.running(session.tenant, auto_id): + raise err(409, "automation_running", "it is running. Wait for it to finish") + # ⚠ READ BEFORE THE REMOVE, not after: `engine.remove` is the thing being refused. + _defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {} + _sys = str(_defn.get("system") or "").strip() + if _sys: + raise err(409, "system_agent", engine.system_agent_refusal(_sys)) + engine.remove(session.runtime, auto_id) + # R9's other half: the user MAY delete an agent-authored step, and deleting the whole + # automation takes its annotations with it rather than stranding them in the document. + _prune_marks(session, auto_id) + return {"deleted": str(auto_id)} + + +@router.post("/automations/preview") +def preview_source(body: dict = Body(default=None), session: Session = Depends(_GATE)): + """What does this URL actually offer? The field-map step — never writes anything.""" + body = body or {} + url = str(body.get("url") or "").strip() + if not url: + raise err(400, "no_url", "give a page URL to read") + try: + return engine.preview(url, str(body.get("extract") or "table"), + int(body.get("tableIndex") or 0)) + except engine.Refused as e: + raise err(400, "refused_url", str(e)) + except Exception as e: # noqa: BLE001 + raise err(502, "fetch_failed", f"could not read that page. {type(e).__name__}: " + f"{str(e)[:160]}") + + +@router.post("/automations/{auto_id}/run") +def run_automation(auto_id: str, session: Session = Depends(_GATE)): + """Start a run on a background thread. 409 when one is already in flight.""" + defn = engine.all_definitions(session.runtime).get(str(auto_id)) + if defn is None: + raise err(404, "unknown_automation", "no automation with that id") + # ⛔ WAVE 32 · T45 (owner item 10) — REFUSED, NAMING THE ACTION. `run_now` refuses too, for the + # tick and the webhook; this one exists so the person who pressed the button reads the reason + # instead of watching a run start and end with nothing done. The two ask the SAME function, so + # they cannot come to disagree about what "configured" means. + refusal = engine.run_refusal(defn) + if refusal: + raise err(400, "action_unconfigured", refusal) + if not engine.run_async(session.runtime, session.tenant, auto_id, username=session.uname): + raise err(409, "automation_running", "that automation is already running") + return {"started": str(auto_id), "startedAt": time.strftime("%Y-%m-%dT%H:%M:%S")} + + +@router.post("/automations/{auto_id}/nodes/{node_id}/toggle") +def toggle_automation_node(auto_id: str, node_id: str, session: Session = Depends(_GATE)): + """Flip one step on the canvas. The SERVER decides what a node's switch means (see + `engine.NODE_TOGGLES`) — the client only reports which node was clicked. + + A node with no switch answers 400 with the sentence saying why, rather than silently doing + nothing: a control that appears to work and does not is worse than one that refuses. + """ + if not session.runtime.available(): + raise err(503, "store_unavailable", "the tenant store is unavailable. Nothing was saved") + if engine.running(session.tenant, auto_id): + raise err(409, "automation_running", "it is running. Wait for it to finish") + defn, error = engine.toggle_node(session.runtime, auto_id, node_id) + if error: + raise err(404 if error == "no such automation" else 400, + "unknown_automation" if error == "no such automation" else "node_not_toggleable", + error) + return {"automation": _wire(defn, session.tenant, rt=session.runtime)} + + +@router.post("/automations/{auto_id}/hook/{token}") +async def automation_hook(auto_id: str, token: str, request: Request): + """C3's webhook trigger — the tick-endpoint pattern one level down: unauthenticated BY + DESIGN (the external caller has no cookie), gated on a per-automation token minted when the + trigger was configured, constant-time compared. The tenant is FOUND by the (id, token) + pair — a wrong token answers 403 for every tenant, so the route confirms nothing about + which slugs exist. + + ⭐ WAVE 24 (D-41) — THE BODY IS READ DEFENSIVELY AND IS NEVER A REASON TO REFUSE. + ⛔ It is deliberately NOT declared as `body: dict = Body(...)`, which is the obvious way to + write this and would be a live regression: FastAPI would then VALIDATE the payload, so an + existing caller posting text, form-encoding, an empty body or slightly malformed JSON would + start getting a 422 from a door that has accepted anything since wave 22. A webhook sender is + somebody else's system; we do not get to change what it must send in order to fire a flow. + An unreadable body simply maps nothing — the flow still fires, exactly as it did before. + + `run_in_threadpool` keeps the store I/O off the event loop: `hook_fire` walks every tenant + and may commit a row, and this handler had to become `async` only to read the request body. + """ + from starlette.concurrency import run_in_threadpool + from harness import runtime as _rt + + try: + payload_in = await request.json() + except Exception: # noqa: BLE001 + payload_in = None + + def _fire(): + last = (404, {"error": "unknown_automation", + "message": "no automation with that id and token"}) + for slug in _rt.known_tenants(): + try: + rt = _rt.get_runtime(slug) + except Exception: # noqa: BLE001 + continue + status, payload = engine.hook_fire(rt, slug, auto_id, token, body=payload_in) + if status == 200: + return 200, payload + if status != 404: + last = (status, payload) + return last + + status, payload = await run_in_threadpool(_fire) + if status == 200: + return payload + raise err(status, str(payload.get("error") or "refused"), + str(payload.get("message") or "refused")) + + +@router.post("/automations/tick") +def tick(request: Request, x_aios_tick_token: str = Header(default="")): + """Fire every due schedule, for every tenant. The durable-cron entry point (R5). + + Unauthenticated BY DESIGN and gated on a shared secret instead — an EventBridge rule has no + cookie. Refuses when the secret is not configured (see the module header).""" + want = os.environ.get("AIOS_AUTOMATION_TICK_TOKEN") or "" + if not want: + raise err(403, "tick_disabled", + "AIOS_AUTOMATION_TICK_TOKEN is not configured. The tick endpoint is closed") + got = x_aios_tick_token or request.headers.get("X-AIOS-TICK-TOKEN") or "" + if got != want: + raise err(403, "bad_tick_token", "that token is not valid for this deployment") + started = engine.tick_all() + return {"started": started, "at": time.strftime("%Y-%m-%dT%H:%M:%S")} + + +@router.post("/automations/ig/purge") +def purge_ig_subject(body: dict = Body(default=None), session: Session = Depends(_GATE)): + """D-24 (wave 22): the right-to-erasure door for ONE Instagram subject — walks the + tenant's four `ut_ig_*` tables AND the platform master (R2 pooled a copy there, so a purge + that skipped it would not be erasure). Admin-only: erasure is a compliance act, not a + grid gesture. Answers the per-table removal counts — the one aggregate whose drill is the + rows' ABSENCE.""" + if not session.admin: + raise err(403, "admin_only", "erasing a subject is an admin action") + if not session.runtime.available(): + raise err(503, "store_unavailable", "the tenant store is unavailable. Nothing was " + "purged") + handle = str((body or {}).get("handle") or "").strip() + if not handle: + raise err(400, "no_handle", "name the Instagram handle to erase") + counts = engine.purge_subject(session.runtime, handle) + return {"handle": handle.lstrip("@").lower(), "removed": counts, + "total": sum(counts.values())} + + +@router.get("/automations/metrics/{table_key}/{field_key}/{row_id}/rows") +def metric_drill(table_key: str, field_key: str, row_id: str, + session: Session = Depends(_GATE)): + """C7's drill: the EXACT master snapshot rows behind one metric cell + ([[no-unverifiable-aggregates]]) — recomputed on ask with the same function that filled + the cell, so the drill can never disagree with the number by construction.""" + import core.user_tables as user_tables + + if not user_tables.may_open(table_key, session.uname, session.admin, st=session.runtime): + raise err(404, "unknown_table", "no such database") + t = engine.ut_get(session.runtime, table_key) or {} + fdef = next((f for f in (t.get("fields") or []) if f.get("key") == field_key), None) + if not fdef or not isinstance(fdef.get("metric"), dict): + raise err(404, "not_a_metric", "that column is not a metric field") + row = (t.get("rows") or {}).get(str(row_id)) + if row is None: + raise err(404, "unknown_row", "that record is not in the database") + import ig_master + url_field = next((f.get("key") for f in (t.get("fields") or []) + if f.get("type") == "url"), "") + handle = engine._table_handle(row, url_field) + bag = fdef["metric"] + series = ig_master.series_for({handle}) if handle else {} + value, rows = engine.metric_value(series.get(handle), bag.get("measure"), + bag.get("window"), bag.get("agg") or "") + return {"table": table_key, "field": field_key, "rowId": str(row_id), "handle": handle, + "measure": bag.get("measure"), "window": bag.get("window"), + "value": value, "rows": rows[:200], + "note": "" if value is not None else + "no master data answers this window. The cell is honestly blank"} + + +@router.get("/automations/{auto_id}/rows") +def run_rows(auto_id: str, session: Session = Depends(_GATE)): + """The rows the LAST run touched — the drill-down behind a run's counts. + + Every count in this product drills to the exact rows behind it ([[no-unverifiable-aggregates]]); + a run history that said "412 updated" and could not show which would be the thing that rule + exists to forbid. + """ + defn = engine.all_definitions(session.runtime).get(str(auto_id)) + if defn is None: + raise err(404, "unknown_automation", "no automation with that id") + last = (defn.get("runs") or [{}])[0] + table_key = (defn.get("config") or {}).get("targetTable") or "" + t = engine.ut_get(session.runtime, table_key) or {} + rows = t.get("rows") or {} + ids = [str(i) for i in (last.get("affected") or [])] + return {"table": table_key, "label": t.get("label") or table_key, + "fields": [{"key": f.get("key"), "label": f.get("label")} + for f in (t.get("fields") or [])], + "rows": [{"id": i, **(rows.get(i) or {})} for i in ids if i in rows], + "truncated": len(ids) >= 200} + + +# --- the in-process scheduler ------------------------------------------------------------ +# ⚠ OPT-IN (`AIOS_AUTOMATIONS=1`), and that is an AMENDMENT to the wave brief's "default-on", +# made on a measurement: `verify_api.py` runs for 196 s, i.e. longer than three tick intervals, +# and `tick_all` reaches `runtime.get_runtime()` — which builds tenants and moves the LRU cache +# that verify_api's own isolation checks read. Default-on would put a background thread inside +# the subject of another session's gate. `AIOS_PREWARM=1` at `main.py:353` is the same decision +# for the same reason, so this follows it rather than inventing a second convention. +# +# The loop also SLEEPS FIRST (`engine.scheduler_loop`) — defence in depth, proven in +# verify_automation.py section T rather than assumed. +# +# ⛔ THE DEPLOY MUST SET `AIOS_AUTOMATIONS=1` (with `AIOS_AUTOMATION_TICK_TOKEN`) or schedules +# only ever fire from the external cron POSTing /automations/tick. Both paths work; neither is +# implicit. Booked in the session-D mailbox. +engine.start_scheduler() + +# C3 (wave 22): register the trigger listener onto the platform's row-event seam. THIS module +# is where the registration belongs — it is the one place that already imports both sides, so +# neither the engine nor platform/core grows a dependency on the other. Idempotent: a reimport +# must not double-fire every trigger. +import core.user_tables as _ut_hooks # noqa: E402 + +if engine.grid_hook not in _ut_hooks.ROW_HOOKS: + _ut_hooks.ROW_HOOKS.append(engine.grid_hook) + +# ⭐⭐ W31 QA — DECLARE THE MACHINE-OWNED CHILD DATABASES, for the same reason and in the same +# place as the hook above: this module already imports both sides, so neither the engine nor +# `platform/core` grows a dependency on the other. Idempotent by construction (a set). +# +# ⛔ THE OWNER FOUND WHAT THIS FIXES, IN PRODUCTION, AFTER THE TICKET READ GREEN. W31-T32 locked +# the TikTok children by stamping `recordMode` at the two SPAWN sites, and proved the stamp +# arrives "on the next write". That is true and it is not the `done-when`: every TikTok database +# already sitting in a tenant kept offering "+ New record" until somebody re-ran a TikTok +# automation, and nobody had. Owner, verbatim (2026-08-13): *"the databases for Tiktok do not have +# the small lock icon as I asked"* — and the rule, restated: *"just like Instagram Post database +# (which is locked), only the IG Profile and TT Profile should be editable."* +# ⚠ A DECLARATION NEEDS NO WRITE, so it is true for EVERY tenant the moment the API boots — no +# migration, no boot ordering, no per-tenant walk, and nothing that a stale store can undo. The +# stored flag still locks a table nobody declares; the two are OR'd. +_ut_hooks.register_locked_records(engine.LOCKED_CHILD_TABLES)