diff --git a/RELEASES.json b/RELEASES.json index 01bb71874ad37617636af0cca8152ea252adf580..9c5a48de2226a3fefd9e74b3ca2272039caa4414 100644 --- a/RELEASES.json +++ b/RELEASES.json @@ -1,5 +1,5 @@ { - "current": "7b711f1", + "current": "91ad73a", "releases": [ { "version": "v25", diff --git a/VERSION b/VERSION index 5142ff81116e74e34bdf59b34c9ba1e4bd68259e..c396bb084be50d480c2c597a125d7c7ba370f65c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7b711f1 +91ad73a diff --git a/api/ai_enrich.py b/api/ai_enrich.py new file mode 100644 index 0000000000000000000000000000000000000000..4c9536bf8f4d88562527ad99e16300e3dc47c0f8 --- /dev/null +++ b/api/ai_enrich.py @@ -0,0 +1,520 @@ +"""ai_enrich.py -- the AI ENRICHMENT column (wave 34, owner ruling R13). + +R13: *"A field kind called AI enrichment: a prompt per row that populates text. Detailed +configuration in the field's own config, including a token-usage limit."* + +WHERE THE HALVES LIVE, and why the line is drawn here rather than anywhere else: + + core/user_tables.py the CONTRACT and the STORAGE -- `UT_FIELD_TYPES`, `_clean_ai_enrich`, + the per-cell provenance stratum, and the law about what an automatic + run may overwrite. It owns the store document, so it owns the marks. + api/ai_enrich.py THIS file: the PROMPT vocabulary, the run PLAN, and the token ceiling's + own report. `core` may not import upward, so anything that needs to + reach a vendor or an HTTP session lives here. + +Everything in this module is a PURE FUNCTION of data it is handed. That is deliberate: it is the +half a gate can exercise without a network, a store, or a running server, which is what makes +`api_ai_enrich` a real gate rather than a mount check. + +TOKEN ACCOUNTING, and the hole it is filling. `api/ai_review.py::decide` -- the product's only +other LLM entry -- has no token accounting of ANY kind: `max_tokens: 300` per call, one 20 s +timeout, no retry, and nothing anywhere counts what a run spent. R13 asks for a usage limit, so +there was nothing to build on and the ledger starts here. The two measured community complaints +about this feature elsewhere are both REPORTING gaps rather than capability gaps (a preview said +15 credits and the run spent 360; a finished run gave no completion signal), which is why +`ceiling_report` exists beside the ceiling itself. +""" +from __future__ import annotations + +import os +import re + +import requests + +#: A prompt names other columns the way a formula does, so a user who has written one already +#: knows this syntax. ⚠ ONE parser, exported, because `user_tables.ai_enrich_input_hash` fingerprints +#: the referenced cells and a second copy of this pattern would disagree with it the first time the +#: token syntax gained a form ([[one-question-two-normalizers]]). +_REF = re.compile(r'\{([a-zA-Z0-9_]{1,60})\}') + +#: What a cell can say about where its value came from. Mirrors +#: `user_tables.AI_ENRICH_STATES` plus the two states that are DERIVED rather than stored: +#: `stale` (the inputs moved since the value was written) and `empty` (nothing has run). +#: ⛔ Re-exported rather than re-declared: the stored vocabulary has exactly one owner. +CELL_STATES = ('empty', 'agent', 'human', 'stale', 'error') + +#: ⛔ THERE IS DELIBERATELY NO PROVIDER LIST IN THIS FILE. The product already has TWO opinions +#: about provider order (`harness/analyst.PROVIDERS` says groq-first, `routes_query. +#: QUERY_PROVIDER_ORDER` says cerebras-first) and `W34-T37` exists to reconcile them. Declaring a +#: third here would make that ticket harder and this feature wrong in a new way, so the catalogue +#: is BORROWED from `ai_review` (`PROVIDERS` + `ladder()` + the key names + the cheap-first order +#: owner ruling R14 set). ⚠ The CALL is ours because the requirements differ: `ai_review` hardcodes +#: `max_tokens: 300` and reads no `usage`, and R13 needs a per-column ceiling and a real ledger. +#: One provider catalogue, two call shapes, and the reason is written down rather than inferred. + +#: The run's per-cell wall clock. Separate from `ai_review.TIMEOUT_SECONDS` because a review is +#: one classification a person is waiting on and this is a loop over rows. +TIMEOUT_SECONDS = float(os.environ.get('AIOS_AI_ENRICH_TIMEOUT') or 30) +#: How many failing rows carry their error into the report before it is summarised. A report that +#: lists 900 identical `429`s is a report nobody reads. +MAX_REPORTED_ERRORS = 10 +#: Bound on what one row hands the model, mirroring `ai_review`'s own two caps: a table can hold a +#: 32 KB JSON cell per row and no prompt needs it. +MAX_CELL_CHARS = 400 + + +def prompt_refs(prompt): + """The column keys a prompt names, in first-appearance order, de-duplicated. + + `"Summarise {name} for {segment}"` -> `['name', 'segment']`. + """ + seen, out = set(), [] + for key in _REF.findall(str(prompt or '')): + if key not in seen: + seen.add(key) + out.append(key) + return out + + +def render_prompt(prompt, row, field_keys=()): + """`(text, unresolved)` -- the prompt with `{token}` replaced by this row's cells. + + ⛔ AN UNKNOWN TOKEN IS REPORTED, NOT SILENTLY BLANKED. A prompt naming a column that does not + exist is a prompt that will quietly ask a different question on every row, and the resulting + text looks exactly like a correct answer. `unresolved` is what the caller shows the user; + R6's standing rule is the same sentence one layer up (a limit that cannot be removed must be + reported with its cause). + + ⚠ A token naming a real column whose CELL is empty is NOT unresolved: an empty cell is data. + """ + known = set(field_keys or ()) + unresolved = [] + + def _sub(match): + key = match.group(1) + if known and key not in known: + unresolved.append(key) + return match.group(0) + return str((row or {}).get(key, '')) + + return _REF.sub(_sub, str(prompt or '')), sorted(set(unresolved)) + + +def ceiling_report(spent, ceiling, rows_done, rows_total): + """R6's second sentence as DATA, or None while the run is still inside its budget. + + `{subject, limit, spent, effect, cause, recommendation}` -- the same shape + `user_tables.limit_report` returns, so the client renders one vocabulary for every limit the + product enforces rather than a second one for this feature. + + ⛔ `effect` is `stopped`, never `truncated`. A run that quietly stopped filling cells leaves a + column that looks complete and is not, which is the failure the whole reporting rule is + arranged against. The caller STOPS and says so. + """ + if ceiling is None or spent < ceiling: + return None + left = max(0, int(rows_total or 0) - int(rows_done or 0)) + rest = f'{left:,} rows' if left != 1 else '1 row' + return { + 'subject': 'tokens', + 'limit': int(ceiling), + 'spent': int(spent), + 'effect': 'stopped', + 'cause': f'this run reached its token ceiling of {int(ceiling):,} after filling ' + f'{int(rows_done or 0):,} of {int(rows_total or 0):,} rows', + 'recommendation': f'raise the column\'s token limit, shorten its prompt, or run the ' + f'remaining {rest} again. Nothing was truncated and no cell holds a ' + f'partial answer', + } + + +def plan_rows(rows, col_id, config, marks, cost_of=None): + """Which rows an automatic run should fill, and why the others were left alone. + + Returns `{'run': [row_id, ...], 'skipped': {reason: count}, 'estimate': int}`. + + ⛔ THE SELECTION IS SEPARATE FROM THE RUN ON PURPOSE. It is pure, so a gate can prove the + never-overwrite-a-human-edit law without a vendor, and so the client can PREVIEW a run + honestly before it spends anything -- which is exactly the complaint R13 cites (a preview + that said 15 credits against a run that spent 360). + + `cost_of(row)` estimates one row's tokens; absent, the column's own ceiling is used per row, + which is the pessimistic direction and the right one for a preview. + """ + import core.user_tables as ut + + cfg = config if isinstance(config, dict) else {} + prompt = str(cfg.get('prompt') or '') + policy = str(cfg.get('overwrite') or 'blank') + refs = prompt_refs(prompt) + per_row = int(cfg.get('maxTokens') or 0) + run, skipped, estimate = [], {}, 0 + for row_id, row in (rows or {}).items(): + row = row if isinstance(row, dict) else {} + mark = (marks or {}).get(str(row_id)) or {} + fresh = ut.ai_enrich_input_hash(prompt, row, refs) + has_value = bool(str(row.get(str(col_id)) or '').strip()) + if ut.ai_enrich_may_write(mark, policy, fresh, has_value=has_value): + run.append(str(row_id)) + estimate += int(cost_of(row)) if callable(cost_of) else per_row + else: + # The same predicate again, so the count a preview shows and the cells a run leaves + # alone can never be two different sets. + reason = ('human_edited' if ut.ai_enrich_human_authored(mark, has_value) + else 'unchanged' if has_value else 'skipped') + skipped[reason] = skipped.get(reason, 0) + 1 + return {'run': run, 'skipped': skipped, 'estimate': estimate} + + +def cell_state(mark, config, row, col_id): + """What ONE cell should say about itself: one of `CELL_STATES`. + + ⚠ `stale` and `empty` are computed here and never stored -- see the stratum's own note in + `core/user_tables.py`. A stored staleness flag needs a sweep to stay true, and a sweep nobody + runs is a flag shipped without its writer. + """ + import core.user_tables as ut + + mark = mark if isinstance(mark, dict) else {} + has_value = bool(str((row or {}).get(str(col_id)) or '').strip()) + if str(mark.get('state') or '') == 'error': + return 'error' + # ⛔ THE SAME PREDICATE THE RUNNER OBEYS, called rather than restated. A cell this reports as + # the human's while `ai_enrich_may_write` would overwrite it is the worst of both answers. + if ut.ai_enrich_human_authored(mark, has_value): + return 'human' + if str(mark.get('state') or '') != 'agent': + return 'empty' + cfg = config if isinstance(config, dict) else {} + prompt = str(cfg.get('prompt') or '') + fresh = ut.ai_enrich_input_hash(prompt, row or {}, prompt_refs(prompt)) + return 'stale' if ut.ai_enrich_is_stale(mark, fresh) else 'agent' + + +# ══════════════════════════════════════════════════════════════════════ THE RUN +# ⛔⛔ THE LEDGER IS THE POINT OF THIS HALF. `ai_review.decide` (the product's only other LLM entry) +# counts nothing: it sends `max_tokens: 300` and never reads `usage` off the response, so no run in +# this product has ever been able to say what it cost. R13 asks for a token-usage limit, so the +# accounting starts here, and every leg below reads `usage` even where it is not needed, because a +# ledger with one blind provider is not a ledger. + + +def _usage_tokens(body): + """Total tokens for one call, from whichever shape the provider answered in. + + ⚠ RETURNS None WHEN THE PROVIDER DID NOT SAY, and the caller treats that as a real unknown + rather than as zero. A ledger that silently books an unmeasured call at 0 reports a cheaper + run than happened, which is exactly the cost-surprise complaint R13 cites. + """ + usage = (body or {}).get('usage') + if not isinstance(usage, dict): + return None + for key in ('total_tokens', 'totalTokens'): + got = usage.get(key) + if isinstance(got, int) and not isinstance(got, bool): + return got + ins = usage.get('prompt_tokens', usage.get('input_tokens')) + outs = usage.get('completion_tokens', usage.get('output_tokens')) + if isinstance(ins, int) and isinstance(outs, int): + return ins + outs + return None + + +#: R6's subject: the em dash (U+2014) and the en dash (U+2013), as a regex character class. +#: ⛔ BUILT FROM CODE POINTS, NOT TYPED, AND THIS IS NOT COSMETIC. `web_prose` reads a Python +#: string's VALUE off the AST, so a class written `'[]'` is reported as two findings in +#: the one function whose entire job is to REMOVE those characters. That is a false positive, and +#: the wrong fix would be to silence the gate or to weaken the pattern. `chr()` keeps the value +#: byte-identical while putting no dash in a literal, so the gate sees what it should see and the +#: normalizer keeps working. ⚠ Lane D hit the same wall in `routes_query` (note D-13). +_DASH = '[' + chr(0x2014) + chr(0x2013) + ']' + + +def no_dashes(text): + """R6 applied to MODEL-AUTHORED text, before it is stored. + + ⛔⛔ A PROMPT INSTRUCTION DOES NOT ENFORCE THIS, AND THE MEASUREMENT IS LANE D'S, NOT A GUESS + (mailbox note D-4, 2026-08-16): a system prompt ending *"Never use an em dash or an en dash"* + was answered on the very next live turn with *"Which view type would you like-grid, chart, + list, or another?"* carrying U+2014 (cerebras). `web_prose` scans SOURCE, so it is + structurally blind to a dash that arrives at runtime. + ⚠ AND THIS COLUMN IS THE WORST CASE IN THE WAVE, which is why D routed the note here: chat + prose is read once, but an enrichment value is WRITTEN INTO A CELL, then re-read and + re-rendered forever. Normalising after the write would never reach the rows already stored. + + ⚠ A DIGIT RANGE IS A DIFFERENT SENTENCE and gets the first rule: "10-20" means "10 to 20", and + rewriting it as "10, 20" states two numbers where the model stated a span. That is a wrong + value, not a punctuation fix. + + ⛔⛔ THIS IS THE SECOND IMPLEMENTATION IN THE PRODUCT and that is a known cost, taken + deliberately rather than by drift. The first is `routes_query._no_dashes` (lane D's, shipped + first). Importing it would point a LIBRARY module at a ROUTE module, which is backwards, and + lane D's file is not in this lane's fence to move it. So the two are held in step by a PARITY + CHECK instead of by hope: `verify_ai_enrich` section 10 imports D's function and asserts both + agree over a corpus including every shape either docstring names. One shared home is owed and + is booked as a PENDING line, not left implicit ([[one-question-two-normalizers]]). + """ + text = str(text or '') + text = re.sub(rf'(?<=\d)\s*{_DASH}\s*(?=\d)', ' to ', text) + text = re.sub(rf'\s*{_DASH}\s*(?=[,.;:!?])', '', text) # abutting punctuation: it just goes + text = re.sub(rf'(?<=[,;:])\s*{_DASH}\s*', ' ', text) # already punctuated: one space + return re.sub(rf'\s*{_DASH}\s*', ', ', text) + + +#: What the model is told about its job. It fills ONE cell, so anything conversational it adds is +#: a defect in the column rather than a nicety. `UNKNOWN` is the honest out: a row the data cannot +#: answer is recorded as an error against that row, never as an invented value. +#: ⚠ The dash sentence is here for the same reason lane D kept theirs: it costs nothing and it +#: reduces how often `no_dashes` has to act. It is NOT the enforcement; `no_dashes` is. +_SYSTEM = ('You fill in ONE cell of a spreadsheet. Answer with the value only: no preamble, no ' + 'quotes, no markdown, no explanation. Never use an em dash or an en dash; use a comma, ' + 'a colon or a full stop. If the information given does not let you answer, reply with ' + 'exactly: UNKNOWN') + + +def _ask(provider, model, prompt, max_tokens, timeout): + """One cell's answer. `(text, tokens_or_None, problem)`; a truthy `problem` means no text. + + ⚠ Both request shapes are `ai_review`'s, changed in exactly two ways: they carry the COLUMN's + ceiling instead of a hardcoded 300, and they read `usage` back. + """ + key = (os.environ.get(provider['env']) or '').strip() + if not key: + return '', None, f"{provider['name']} has no key on this deployment" + try: + if provider['shape'] == 'anthropic': + r = requests.post( + provider['url'], timeout=timeout, + headers={'x-api-key': key, 'anthropic-version': '2023-06-01', + 'content-type': 'application/json'}, + json={'model': model, 'max_tokens': max_tokens, 'system': _SYSTEM, + 'messages': [{'role': 'user', 'content': prompt}]}) + if r.status_code >= 400: + return '', None, f"{provider['name']} answered {r.status_code}" + body = r.json() + # ⛔ stop_reason FIRST. A safety refusal is a 200 with an EMPTY content list, so + # reading content[0] before this turns a refusal into an IndexError inside the loop. + if body.get('stop_reason') == 'refusal': + return '', _usage_tokens(body), f"{provider['name']} declined this row" + parts = [b.get('text') or '' for b in (body.get('content') or []) + if isinstance(b, dict) and b.get('type') == 'text'] + return ''.join(parts).strip(), _usage_tokens(body), '' + r = requests.post( + provider['url'], timeout=timeout, + headers={'Authorization': f"Bearer {key}", 'Content-Type': 'application/json'}, + json={'model': model, 'max_tokens': max_tokens, 'temperature': 0, + 'messages': [{'role': 'system', 'content': _SYSTEM}, + {'role': 'user', 'content': prompt}]}) + if r.status_code >= 400: + return '', None, f"{provider['name']} answered {r.status_code}" + body = r.json() + choices = body.get('choices') or [] + if not choices: + return '', _usage_tokens(body), f"{provider['name']} returned no choices" + text = ((choices[0] or {}).get('message') or {}).get('content') or '' + return str(text).strip(), _usage_tokens(body), '' + except Exception as exc: # noqa: BLE001 + return '', None, f"{provider['name']} failed: {type(exc).__name__}" + + +def on_change_fields(defn, changed_keys): + """Which `ai_enrich` columns in this table want a run because one of their inputs moved. + + A column qualifies when its trigger is `on_change` AND its prompt names at least one of the + columns that just changed. ⛔ A column that names NOTHING (a prompt with no `{token}`) never + fires on change, whatever its trigger says: it would re-ask the same question of the same row + forever, once per unrelated edit, and bill for every one. + + ⚠ PURE, AND HANDED THE DEFINITION IT SHOULD USE. `automation_engine.grid_hook` calls + `all_definitions(st)` once PER EVENT, which turns a 20,000-row import into 20,000 whole + document reads (`D-134`); this function reads no store at all, so the caller can do one read + for a whole batch. Rebuilding that shape here was the one thing T54's ticket said not to do. + """ + import core.user_tables as ut + + touched = {str(k) for k in (changed_keys or ())} + if not touched: + return [] + out = [] + for field in ut.ai_enrich_fields(defn): + cfg = field.get('aiEnrich') or {} + if (cfg.get('trigger') or {}).get('mode') != 'on_change': + continue + refs = set(prompt_refs(cfg.get('prompt'))) + # ⚠ A column never fires on a change to ITSELF. Its own value moving is either the run + # that just wrote it or a person typing over it, and both would loop. + refs.discard(str(field.get('key') or '')) + if refs & touched: + out.append(field) + return out + + +def scheduled_fields(defn): + """`[(field, cron)]` for every enrichment column that runs on a cadence. + + ⛔ IT DOES NOT PARSE THE CRON AND MUST NOT. `core.user_tables` stores that string opaque + precisely so the cadence vocabulary has ONE owner, and the owner is the scheduler + (`automation_engine`), not the field layer. A second parser here would be a door that starts + refusing a cadence the other one accepts, which is exactly the divergence the whole field + contract keeps paying for ([[one-question-two-normalizers]]). + + ⚠ SO THIS IS HALF A FEATURE ON PURPOSE, and the other half is a cross-fence ask: something has + to TICK. Nothing in lane F's fence runs on a timer. + """ + import core.user_tables as ut + + out = [] + for field in ut.ai_enrich_fields(defn): + trigger = ((field.get('aiEnrich') or {}).get('trigger') or {}) + if trigger.get('mode') == 'schedule' and str(trigger.get('cron') or '').strip(): + out.append((field, str(trigger['cron']))) + return out + + +def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, budget=None, + timeout=None, ask=None): + """Fill one `ai_enrich` column over the rows an automatic run is allowed to touch. + + Returns a REPORT and never raises for a vendor problem: + `{planned, filled, failed, skipped{reason: count}, tokens, provider, model, + limit: , errors[], problem}` + + ⛔ THE RUN BUDGET IS DERIVED FROM THE COLUMN'S OWN CEILING, and the arithmetic is the point: + `budget = maxTokens * planned`. `maxTokens` bounds the OUTPUT of one call while spend counts + INPUT tokens too, so a real run crosses this before it finishes every row. That is intended: + R13 asked for a token-usage limit and a ceiling that can never bite is not one. A caller may + pass a TIGHTER `budget`; nothing may pass a looser one. + + ⛔ TWO STORE WRITES FOR THE WHOLE RUN, not two per row: one `patch_many_cells` for the values + and one `stamp_ai_enrich` for the marks. Each is a read-modify-write of the whole tenant + document, which is why `patch_cells` in a loop was never an option (D-134's shape). + + ⚠ `ask` is injectable so a gate can exercise the ledger, the ceiling and every failure path + with no network and no vendor bill. It defaults to the real `_ask`. + """ + import ai_review + import core.user_tables as ut + + defn = ut.get(table_key, st=st) or {} + field = next((f for f in ut.ai_enrich_fields(defn) if f.get('key') == str(col_id)), None) + if field is None: + return {'problem': f'{col_id!r} is not an AI enrichment column on this database', + 'planned': 0, 'filled': 0, 'failed': 0, 'skipped': {}, 'tokens': 0, + 'errors': [], 'limit': None, 'provider': '', 'model': ''} + cfg = field.get('aiEnrich') or {} + # ⭐ THE BULK MENU'S TWO CHOICES ARE THIS ONE OVERRIDE (`W34-T54`): "Rows never filled" is + # `blank` and "All rows" is `always`. ⛔ It overrides the column's SAVED POLICY for one run and + # NOTHING ELSE: `ai_enrich_may_write` still refuses a human-edited cell under every value, so + # "All rows" means every row the AGENT wrote, which is exactly what the menu label says. + # ⚠ An unrecognised value falls back to the column's own setting rather than to a default: a + # typo'd override must not quietly widen what a run touches. + if policy in ut.AI_ENRICH_OVERWRITE: + cfg = {**cfg, 'overwrite': policy} + all_rows = defn.get('rows') or {} + marks = ut.ai_enrich_marks(table_key, col_id, st=st) + # ⛔⛔ `rows` NARROWS; `manual` DECIDES WHETHER THE POLICY APPLIES. They are two questions and + # an early draft of this function conflated them, which would have let an ON-CHANGE fire (an + # automatic act, scoped to one row) overwrite a cell a person had typed into. That is the one + # law this whole feature is arranged around, broken by a parameter name. + # rows=None -> the automatic plan over every row + # rows=[...], manual=False -> the automatic plan, INTERSECTED with those rows (on_change) + # rows=[...], manual=True -> exactly those rows, policy skipped + # `manual` is skippable because a person clicking "run this cell" has asked, in front of the + # value being replaced; refusing them would be the product overruling an explicit act with a + # rule written for an implicit one. `ai_enrich_may_write`'s own docstring says exactly this. + # ⚠ A named row is always INTERSECTED with the table's own row set, so a stale caller cannot + # name a record that has since been deleted. + named = None if rows is None else [str(r) for r in rows if str(r) in all_rows] + if named is not None and manual: + plan = {'run': named, 'skipped': {}, + 'estimate': int(cfg.get('maxTokens') or 0) * len(named)} + else: + plan = plan_rows(all_rows, col_id, cfg, marks) + if named is not None: + keep = set(named) + dropped = [r for r in plan['run'] if r not in keep] + plan = {**plan, 'run': [r for r in plan['run'] if r in keep]} + if dropped: + plan['skipped'] = {**plan['skipped'], 'not_in_this_run': len(dropped)} + rows = all_rows + keys = [f.get('key') for f in (defn.get('fields') or []) if f.get('key')] + report = {'planned': len(plan['run']), 'filled': 0, 'failed': 0, + 'skipped': dict(plan['skipped']), 'tokens': 0, 'errors': [], 'limit': None, + 'provider': '', 'model': '', 'problem': ''} + if not plan['run']: + return report + + caller = ask or _ask + ceiling = int(cfg.get('maxTokens') or 0) * len(plan['run']) + if isinstance(budget, int) and not isinstance(budget, bool): + ceiling = min(ceiling, max(0, budget)) + live = ai_review.ladder() + if not live and ask is None: + # ⛔ FAIL CLOSED AND SAY SO. A run that quietly filled nothing looks identical to a run + # with nothing to do; `planned` above is the number that distinguishes them. + report['problem'] = 'no AI provider is configured on this deployment' + return report + provider = (live or [{'name': 'injected', 'model': 'injected', 'env': '', + 'shape': 'openai', 'url': ''}])[0] + model = str(cfg.get('model') or provider['model']) + report['provider'], report['model'] = provider['name'], model + tmo = float(timeout or TIMEOUT_SECONDS) + + values, stamps = {}, {} + for row_id in plan['run']: + if ceiling and report['tokens'] >= ceiling: + # ⛔ STOP AND REPORT (R6's second sentence). Cells already written STAY written and + # carry honest marks; nothing is truncated and no cell holds half an answer. + report['limit'] = ceiling_report(report['tokens'], ceiling, + report['filled'], report['planned']) + left = report['planned'] - report['filled'] - report['failed'] + if left > 0: + report['skipped']['over_budget'] = left + break + row = rows.get(row_id) or {} + text, unresolved = render_prompt(cfg.get('prompt'), row, keys) + if unresolved: + # Reported per row rather than refused up front: the prompt is valid for the column, + # and naming the missing token is what lets somebody fix it. + why = ('the prompt names ' + ', '.join(unresolved) + + ', which is not a column on this database') + report['failed'] += 1 + stamps[row_id] = {'state': 'error', 'error': why} + if len(report['errors']) < MAX_REPORTED_ERRORS: + report['errors'].append({'row': row_id, 'error': why}) + continue + answer, tokens, problem = caller(provider, model, text[:MAX_CELL_CHARS * 8], + int(cfg.get('maxTokens') or 300), tmo) + if isinstance(tokens, int) and not isinstance(tokens, bool): + report['tokens'] += tokens + if problem or not answer or answer.strip().upper() == 'UNKNOWN': + # ⛔ THE CELL IS LEFT ALONE. An errored row keeps whatever it held; only the MARK + # changes, so a failed run never destroys a value it could not replace. + why = problem or ('the model had nothing to answer from' if not answer + else 'the model answered UNKNOWN for this row') + report['failed'] += 1 + stamps[row_id] = {'state': 'error', 'error': why, 'model': model} + if len(report['errors']) < MAX_REPORTED_ERRORS: + report['errors'].append({'row': row_id, 'error': why}) + continue + # ⛔ NORMALISED BEFORE THE WRITE, NOT AFTER (note D-6). This is the ONE line where model + # prose becomes stored product data; a cell fixed after the fact leaves every row already + # written carrying the dash, and `web_prose` can never see it because it scans source. + values[row_id] = no_dashes(answer)[:MAX_CELL_CHARS] + stamps[row_id] = {'state': 'agent', 'model': model, + 'hash': ut.ai_enrich_input_hash(cfg.get('prompt'), row, + prompt_refs(cfg.get('prompt'))), + 'tokens': tokens if isinstance(tokens, int) else 0} + report['filled'] += 1 + + if values: + ut.patch_many_cells(table_key, {r: {str(col_id): v} for r, v in values.items()}, st=st) + if stamps: + ut.stamp_ai_enrich(table_key, col_id, stamps, st=st) + # ⚠ The ceiling is re-asked AFTER the loop too: a run whose LAST row crossed the budget spent + # over it without re-entering the guard, and staying silent there would under-report the one + # run most likely to surprise somebody. + if report['limit'] is None and ceiling and report['tokens'] >= ceiling: + report['limit'] = ceiling_report(report['tokens'], ceiling, + report['filled'], report['planned']) + return report diff --git a/api/automation_engine.py b/api/automation_engine.py index 8e10a3845d227a38a734c89154bc3c1cf4b15a5b..70e7bdd612b5789d407c897277b7d5017311ac9d 100644 --- a/api/automation_engine.py +++ b/api/automation_engine.py @@ -1,13949 +1,14038 @@ -"""automation_engine.py — wave-18 item 5 (contract C4-AUTO): the automation RUNTIME. - -THE SHAPE, and why it is this shape. An automation is a DEFINITION that lives in the tenant -store and a RUN that lives in this process. The definition is durable, small and rarely written; -the run is hot, chatty and worthless five minutes later. Conflating them is the failure this file -is built to avoid, and it has two halves: - - * **Run state is NEVER persisted while it is running.** A `state: 'running'` written to the - store outlives the process that wrote it, so a container restart mid-run leaves an automation - that can never run again — the 409 sees a `running` nothing will ever clear. Hot state lives - in `_RUNNING` (a process dict, cleared by definition on restart) and the store is written - exactly ONCE per run, at the end. - * **One coalesced store update per run per bucket.** `core/store.py` coalesces on a 20 s - floor per key against a 256-commits/hour repo budget, so a per-ROW write is the wrong shape - by two orders of magnitude — the S&P demo alone is ~500 rows. Every runner below computes - its whole result first and commits it in a single `update` callback. - -WHAT IS VENDORED HERE AND WHY. `.claude/skills/browse/scrape.py` is not on `sys.path` and is not -deployed, so its extraction core is copied into this file (the wave doc's instruction), ported -httpx → requests (the only HTTP dependency this API already carries). The SSRF rail comes with -it, HARDENED rather than merely preserved — see `guard`/`fetch`. That hardening is the point: -the skill version takes a URL from a developer on a CLI; this takes one from a request body. -""" -from __future__ import annotations - -import copy -import datetime as _dt -import hashlib -import hmac -import ipaddress -import json -import os -import re -import socket -import threading -import time -from urllib.parse import urljoin, urlparse - -import requests - -# ⭐ `providers` IS DELIBERATELY NOT IMPORTED HERE ANY MORE (wave 27 item 23). The capability -# router had exactly one consumer in this file — the views top-up inside the Bright Data rung — -# and that rung now lives in `connectors_ig`. So the vendor-routing seam is reached only by the -# connector that routes, which is the shape the split was for: this file no longer knows that -# there is more than one vendor, or that vendors cost money. Re-adding this import is therefore -# a signal, not a convenience — it means something in the automation runtime started making a -# vendor decision, and that belongs one layer down. - -try: # the parser the extraction half needs - from bs4 import BeautifulSoup -except Exception: # pragma: no cover — deploy lag; see mailbox ② - BeautifulSoup = None - -# --------------------------------------------------------------------------------------------- -# THE BUCKET (C4-AUTO) -# --------------------------------------------------------------------------------------------- - -#: The per-tenant store key. Colocated with its reader, the `routes_nav._NAV_PREFS_KEY` pattern. -STORE_KEY = "automations" -#: The user-table bucket. ⚠ Read/written HERE through `TenantRuntime`, never through -#: `core.user_tables` — that module writes the UNPREFIXED module-global key, which is correct for -#: tenant #0 (empty prefix) and silently cross-tenant for every R2 tenant after it. Booked in the -#: session-D mailbox as a finding against A's file rather than fixed from here. -UT_STORE_KEY = "user_tables" -UT_PREFIX = "ut_" - -MAX_AUTOMATIONS = 40 -MAX_RUNS = 20 # trimmed history per automation -MAX_NAME = 80 -#: ⛔ IT DOES **NOT** MIRROR `core.user_tables.MAX_ROWS`, AND THE COMMENT THAT SAID SO WAS A 12x -#: STALE CLAIM — `MAX_ROWS` is 60,000. That sentence is most of what made D-143 hard to see: it -#: read as "the substrate's bound, kept in step", so nobody asked whether a flow was silently -#: walking 5,000 of 32,826 connected rows. It was. -#: **This is the FALLBACK ONLY.** The live answer is per TABLE and comes from -#: `core.user_tables.row_limit` via `_flow_record_cap` — `None` for a connected source (R6: no -#: cap), `MAX_ROWS` for the editable substrate, `0` for a read-through grid. This constant is -#: reached only when that import fails, and it is deliberately the CONSERVATIVE direction. -#: ⚠ Do not "fix" it by raising it to 60,000: a fallback that runs when the evaluator is missing -#: should not also be the widest one. And do not delete it — D-143's own row says the editable -#: substrate still needs a bound. -MAX_UT_ROWS = 5000 -MAX_UT_TABLES = 40 - -#: ⛔⛔ D-112's SENTENCE, AS A CONSTANT, BECAUSE TWO PLACES DEPEND ON IT AGREEING. -#: `enrich_selection` produces it when a step's `fromView` names a view that no longer resolves; -#: `run_flow` tests for it to turn that run `partial` instead of `ok`. Measured live on a real -#: tenant: an enrich action pointed at a deleted view walked ZERO records and reported **`ok`** — -#: indistinguishable on every surface from a run that worked, and a strong candidate for why the -#: owner's enrichment kept appearing to do nothing. -#: ⚠ THE WORDING IS FREE TO CHANGE; the AGREEMENT is not. Both sites read this name, so a better -#: sentence stays a one-line edit instead of a silent regression [[constant-two-features-share]]. -ENRICH_VIEW_UNREADABLE = "the enrich step names a view it cannot read" - -#: ⚠ THE APPEND TABLES NEED A DIFFERENT CAP, and the reason is arithmetic rather than taste. -#: `MAX_UT_ROWS` was sized for a scraped LIST — a page of ~500 companies that is re-read, so the -#: row count is bounded by the page. The `ut_ig_*` tables are the opposite shape: every pull -#: INSERTS a timestamped row (see `SNAPSHOT_FIELDS` and the compound keys below), so at R1's -#: 1k-profiles-daily the snapshot table crosses 5000 rows on **day five** — and the old behaviour -#: was a SILENT `skipped++`, i.e. the table would quietly stop growing and the run would still -#: report success. A time series that stops after five days without saying so is worse than one -#: that was never built. -#: 200k is the owner-directed ceiling (R1's "~200k"). ⚠ IT BUYS VERY DIFFERENT HORIZONS FOR THE -#: TWO APPEND TABLES, and the difference is worth knowing before anyone plans around it: -#: ut_ig_snapshots 1 row per profile per pull -> ~200 days at 1k profiles/day -#: ut_ig_post_snapshots maxPosts rows per pull -> ~8 days at 1k profiles x 24 posts -#: So the post series is the one that fills, and it fills FAST. That is exactly why the breach had -#: to become LOUD (a distinct `capped` count -> a `partial` run naming the table): at this rate the -#: silent version would have flatlined a chart inside a fortnight with a green dot over it. -#: -#: ⚠ MEASURED COST AT THE CEILING (2026-08-04, this box): a full `ut_ig_post_snapshots` serialises -#: to **35.8 MB** of JSON (~1.4 s). `UT_STORE_KEY` is ONE bucket for ALL of a tenant's user tables, -#: so that cost is paid by every unrelated automation write in the tenant too. Booked for the B-3 -#: Postgres tripwires (R2 clause b) rather than papered over — the fix is a row store, not a -#: smaller number. -#: (W19-C; the GENERIC loud-breach for every other table stays booked as DEBT D-11.) -MAX_UT_IG_ROWS = 200_000 -IG_TABLE_PREFIX = "ut_ig_" - -#: ⚠ THE RAISED CEILING BELONGS TO THE **APPEND** TABLES, NOT TO A NAME PREFIX (wave 20). It was -#: keyed off `ut_ig_`, which was exactly right while every `ut_ig_*` table was an append table — -#: and stopped being right the moment discovery added `ut_ig_candidates`, which is UPSERTED BY -#: HANDLE and is bounded by how many accounts exist rather than by how often we look. It would -#: have inherited a 200,000-row ceiling, and with it the measured 35.8 MB single-bucket -#: serialisation cost, purely by an accident of naming. Naming the append tables is the version -#: that stays true when the next `ut_ig_*` table is not one. -IG_SNAPSHOTS_TABLE = "ut_ig_snapshots" -IG_POSTS_TABLE = "ut_ig_posts" -IG_POST_SNAPSHOTS_TABLE = "ut_ig_post_snapshots" -IG_COMMENTS_TABLE = "ut_ig_comments" - -# --------------------------------------------------------------------------------------------- -# ⭐⭐ WAVE 29 (item 7, DEBT D-9, owner rulings R1 + R2) — TIKTOK, AS A PARALLEL FAMILY -# --------------------------------------------------------------------------------------------- -# R1: FULL PARITY — profile AND posts AND comments, not a profile tier. -# R2: a PARALLEL `ut_tt_*` family. `ut_ig_*` is untouched: zero migration, zero risk to the live -# Instagram rows, and the two schemas may DIVERGE where the vendors do. -# -# ⛔ WHY A SECOND FAMILY RATHER THAN A `platform` COLUMN ON THE FIRST, stated here because it is -# the question every reader will ask. `PRESET_PROFILE_FIELDS` carries `platform` and its identity -# is `(platform, handle)` — so a TikTok PROFILE row could already have lived in `ut_ig_profile`. -# The other four tables carry NO discriminator at all: `ut_ig_posts`/`ut_ig_comments` key on -# `shortcode` and the snapshot tables on `influencer_key`, so an Instagram post and a TikTok video -# that happened to share a code would MERGE SILENTLY. Adding `platform` to four live append tables -# holding hundreds of thousands of rows is a migration on production data to buy a shared grid -# nobody asked for. R2 chose the version with no migration. -# -# ⚠ THE ACCEPTED COST, so it is not rediscovered as a defect: the engine, rollups and grids learn -# two families, and a cross-platform view needs a union. In exchange the two schemas can be HONEST -# about their vendors — which is why `ut_tt_posts` has no `plays` column and `ut_tt_profile` says -# `tt_id` rather than inheriting a column labelled "Instagram id". -TT_TABLE_PREFIX = "ut_tt_" -TT_PROFILE_TABLE = "ut_tt_profile" -TT_SNAPSHOTS_TABLE = "ut_tt_snapshots" -TT_POSTS_TABLE = "ut_tt_posts" -TT_POST_SNAPSHOTS_TABLE = "ut_tt_post_snapshots" -TT_COMMENTS_TABLE = "ut_tt_comments" - -AUTOMATION_RECORD_MODE = "automation" -#: ⚠ THE RAISED CEILING FOLLOWS THE APPEND SHAPE, NOT THE PLATFORM. `ut_tt_snapshots` and -#: `ut_tt_post_snapshots` are append tables for exactly the reason their IG twins are — one row per -#: profile per pull, `maxPosts` rows per pull — so they inherit the ceiling by JOINING THIS SET, -#: which is the mechanism the note above says survives the next table that is not an append table. -APPEND_TABLES = frozenset({IG_SNAPSHOTS_TABLE, IG_POST_SNAPSHOTS_TABLE, - TT_SNAPSHOTS_TABLE, TT_POST_SNAPSHOTS_TABLE}) - -#: ⭐ WAVE 24 (owner ruling R6) — `plain` IS WHAT AN AUTOMATION IS NOW, and it is the DEFAULT. -#: The create wizard is deleted, so nobody picks a kind any more: a new automation is a trigger -#: plus actions and NO machine step. The other three are MACHINE kinds — a scrape, an Instagram -#: column, an Instagram search — and they survive on the automations that already use them -#: (`discover_instagram` stays reachable through the `ig_profile_match` trigger; see C-TRIG). -#: -#: ⚠ ADDING A KIND HERE IS THE SMALL HALF, and the reason is worth reading before adding a fifth: -#: TWO dispatches in this file used to end in a bare `else` that belonged to a SPECIFIC kind -#: rather than to a default — `graph()`'s was `scrape_db`'s and `compose_sentence`'s was -#: `discover_instagram`'s. A kind added here alone would have inherited another kind's whole -#: description: three machine nodes it does not have, and a one-line summary about searching -#: Instagram for up to 0 profiles. Both are explicit arms now, and neither has an `else`. -#: ⭐ WAVE 29 (D-9 / R1) — `discover_tiktok` joins, reachable ONLY through the -#: `tiktok_profile_match` trigger (the same law that makes `discover_instagram` reachable). It -#: lands here IN THE SAME CHANGE as its `RUNNERS` entry and its flip law: a kind in this tuple -#: with no runner is a control that must refuse. -KINDS = ("plain", "scrape_db", "field_instagram", "discover_instagram", "discover_tiktok") -#: ⭐⭐ WAVE 30 · T04 — THE DISCOVERY KINDS UNDER ONE NAME, and this constant is a bug fix rather -#: than tidying. `discover_tiktok` shipped in wave 29 by being added to `KINDS`, `RUNNERS`, -#: `clean_config` and `TRIGGER_*` — four sites that were found — while FIVE more tested the string -#: `"discover_instagram"` directly and were not. The visible symptom was the owner's: picking the -#: TikTok trigger 400'd with *"say how many profiles to fetch"* on the FIRST save, because the seed -#: below was one of the five. The other four are silent — a canvas with no `find` panel, a flow -#: table resolving to the wrong default, and a summary sentence announcing the automation would -#: "do nothing yet". -#: -#: ⛔ SO THE RULE IS: A DISCOVERY BRANCH TESTS MEMBERSHIP OF THIS TUPLE, NEVER A KIND STRING. -#: Three parallel string comparisons is precisely how the third platform gets missed twice more, -#: and the misses are individually invisible — each one degrades a different surface, none of them -#: raises, and every gate stays green (this whole family shipped green in wave 29). -#: ⚠ The kind ↔ platform facts that genuinely DIFFER — the dataset id, the default target table, -#: the field map — stay resolved per kind where they are used. This tuple answers *"is this a -#: corpus search?"* and nothing else; widening it into a platform registry would just move the -#: problem somewhere with a longer name. -DISCOVERY_KINDS = ("discover_instagram", "discover_tiktok") -#: ⭐ WAVE 30 · T06 — THE TRIGGER HALF, and it is a SEPARATE map because the two are NOT -#: interchangeable, which cost a red to learn. Law 1 makes a discovery TRIGGER choose its kind, so -#: `trigger ⇒ kind` always holds — but the converse does NOT: a definition can carry -#: `kind: "discover_instagram"` with no trigger at all (a direct API create does exactly that, and -#: `verify_automation`'s `_discover_defn` fixture is one). Testing the KIND where the rule is about -#: the trigger therefore fires on definitions the picker could never produce — measured: it spawned -#: a preset database under a dry-run fixture that asserts none exists. -#: ⛔ SO: "did somebody PICK a corpus search in the picker?" reads THIS. "Is this stored definition a -#: corpus search?" reads `DISCOVERY_KINDS`. Two questions, two maps, and the gate asserts this one -#: agrees with law 1 rather than trusting that it does. -DISCOVERY_TRIGGER_KIND = {"ig_profile_match": "discover_instagram", - "tiktok_profile_match": "discover_tiktok"} -#: ⭐ WAVE 30 · T08 — the ENRICH action kinds, one per network. Same argument as `DISCOVERY_KINDS` -#: one paragraph up: these two share a validator, a selection, a cooldown and a run summary, and the -#: only things that differ are which connector answers and which tables the rows land in. -#: ⛔ ONE VALIDATOR, NOT TWO. `clean_actions`' enrich branch is ~40 lines of clamps whose comments -#: record why each one is shaped the way it is (`submitted=False` because the client re-posts the -#: whole action list; `limit` clamped twice because the run sees STORED configs). A second copy for -#: TikTok would start identical and drift, and the way it fails is that one network silently accepts -#: a limit the other refuses. -ENRICH_KINDS = ("enrich_instagram", "enrich_tiktok") -#: What a definition with no kind becomes (C-TRIG law 2) — the shape `POST /automations` stores -#: when the body names none, which after R6 is every create the client makes. -DEFAULT_KIND = "plain" -#: R6: no NEW automation may be either of these. They still RUN, still PATCH and still validate — -#: the ruling retires the door, not the two automations behind it (see `create`). -#: ⚠ `discover_instagram` is NOT here: it stays creatable, through the `ig_profile_match` trigger. -RETIRED_KINDS = ("scrape_db", "field_instagram") -#: ⛔ DEBT D-65 — WHAT TO DO INSTEAD, said at every door that refuses one of these. The kinds were -#: not deleted, they were REPLACED by actions any flow can take, and a refusal that does not name -#: the replacement sends somebody looking for a bug in a decision made on purpose. One sentence -#: per kind, in one place, because `create` and `clean_definition` both say it. -RETIRED_KIND_REPLACEMENT = { - "field_instagram": "Add the 'Enrich Instagram profile' action to any automation instead", - "scrape_db": "Add a scrape step to any automation instead", -} -#: ⛔ D-65 — THE RETIRED KINDS THAT ACTUALLY HAVE SOMEWHERE ELSE TO GO, and the distinction is the -#: whole reason this is a second, narrower tuple rather than a reuse of `RETIRED_KINDS`. -#: `field_instagram` was REPLACED: W25/R4 shipped `enrich_instagram` as a `ready:true` action any -#: flow can take, so refusing the kind costs a person nothing but a different click. -#: ⚠ `scrape_db` IS DELIBERATELY ABSENT. Its replacement — the five `web_*` actions — is declared -#: `ready:false` and is DEBT D-51, so refusing a PATCH to it would delete the only way to build a -#: scrape automation and call it tidying up. W24/R6 retired its CREATE door and kept the patch -#: door open on purpose, and there is a gate check that says so in those words. A kind may only be -#: walled off at a door once something else answers the same need. -#: (`REPLACED_KINDS` was here and is deleted with the refusal it gated — see `clean_definition`.) -KIND_LABELS = {"plain": "Automation", "scrape_db": "Web page to database", - "field_instagram": "Instagram profile column", - "discover_instagram": "Find Instagram profiles", - "discover_tiktok": "Find TikTok profiles"} -EXTRACTS = ("table", "jsonld") -STATES = ("idle", "running", "ok", "error", "partial") -#: Which capture rung a `field_instagram` automation is allowed to reach for (R1's hybrid). -#: `anonymous` = the $0 ladder only. `brightdata` = the paid rung FIRST, then the ladder as a -#: fallback (unless the fallback is switched off — see `graph`'s `fallback` toggle). -TIERS = ("anonymous", "brightdata") -#: ⚠ STORED CONFIGS SAY `hiker`, AND THEY MEAN "THE PAID RUNG" (wave-20 D-21). The vendor swap -#: must not silently answer that request with the free ladder: `clean_config` refuses an unknown -#: tier by falling back to `anonymous`, so without this alias every existing Instagram automation -#: would quietly stop reaching for exact counts and nothing would say so. Mapping FORWARD keeps -#: the user's expressed intent (they turned the paid step ON) at a cost of ~$0.0015 a profile. -TIER_ALIASES = {"hiker": "brightdata"} - - -def clean_tier(raw): - """A stored/posted tier → a tier this engine runs, or '' when it is neither.""" - t = str(raw or "").strip().lower() - t = TIER_ALIASES.get(t, t) - return t if t in TIERS else "" - - -def row_cap(table_key): - """The row ceiling for ONE table. Per-table rather than global — see `MAX_UT_IG_ROWS`. - - An APPEND table (one row per subject per pull, forever) gets the raised ceiling; everything - else — including the `ut_ig_` table that is an UPSERT — keeps the list-table one. - """ - return MAX_UT_IG_ROWS if str(table_key or "") in APPEND_TABLES else MAX_UT_ROWS - -#: Schedule presets the editor offers. Kept here (not in the client) so the vocabulary the UI -#: shows and the vocabulary the parser accepts cannot drift. -CRON_PRESETS = [ - {"cron": "*/15 * * * *", "label": "Every 15 minutes"}, - {"cron": "0 * * * *", "label": "Hourly"}, - {"cron": "0 6 * * *", "label": "Daily at 06:00"}, - {"cron": "0 6 * * 1", "label": "Weekly (Monday 06:00)"}, - {"cron": "0 6 1 * *", "label": "Monthly (1st, 06:00)"}, -] - -UA = "Mozilla/5.0 (compatible; AIOS-automation/1.0; +https://aios.local/automation)" - - -def _now(): - return _dt.datetime.now() - - -def _stamp(dt=None): - return (dt or _now()).strftime("%Y-%m-%d %H:%M") - - -def _iso(dt=None): - """An ISO stamp **WITH its UTC offset** — `2026-08-05T14:03:11+07:00` (DEBT D-18). - - ⚠ THE OFFSET IS NOT COSMETIC. These stamps are the time axis of the `ut_ig_*` append tables - and they are rendered by a browser, which can only subtract from an instant it can LOCATE. A - naive `2026-08-05T14:03:11` is read as the *reader's* local time, so a container running UTC - minted cells a Jakarta browser would place seven hours in the future — and "2 minutes ago" is - not expressible at all. With the offset the same string is an instant, and relative rendering - becomes possible without migrating a single stored row. - - ⚠ `_parse_iso` reads it BACK as naive local ON PURPOSE. Every cron / `is_due` comparison in - this module is against a naive `_now()`, and mixing aware and naive datetimes raises - `TypeError` — so the offset rides on the WIRE and never enters the arithmetic. - """ - dt = dt or _now() - if dt.tzinfo is None: - dt = dt.astimezone() # a naive stamp from this process IS local time - return dt.isoformat(timespec="seconds") - - -#: ⭐ WAVE 26 · R3 — the DAY out of any stamp we have ever written, for the `date`-typed columns. -#: -#: ⛔ IT MUST READ EVERY SHAPE THE STORE HOLDS, which is the same trap `_parse_iso` documents one -#: function down: `first_found` cells exist as post-D-18 offset stamps -#: (`2026-08-05T14:03:11+07:00`), as pre-D-18 naive ones (`2026-08-05T14:03:11`), as `_stamp()`'s -#: space-separated minute form (`2026-08-05 14:03`) and, after this wave, as bare days. A -#: converter that understood only the newest shape would blank the oldest rows — and a migration -#: that empties cells is indistinguishable from one that moved them. -#: ⚠ Returns "" for anything it cannot read rather than guessing a day. The migration treats "" -#: as LEAVE ALONE, never as a value to write, so an unparseable cell keeps its original text and -#: shows up as itself instead of disappearing. -def _day(s): - """`2026-08-05T14:03:11+07:00` → `2026-08-05`. "" when there is no day in there.""" - raw = str(s or "").strip() - if not raw: - return "" - head = raw.replace("T", " ").split(" ")[0] - try: - _dt.date.fromisoformat(head) - except ValueError: - dt = _parse_iso(raw) - return dt.strftime("%Y-%m-%d") if dt else "" - return head - - -#: ⭐ WAVE 26 · AMENDMENT C1-a — the vendor's 0–1 engagement fraction → this product's 0–100 `pct`. -#: -#: MEASURED on corpus rows: `0.0074`, `0.0656`, `0.0014`, `0.0148`, `0.0274`, `0.0091`. Our `pct` -#: renderer prints the stored number and appends `%`, so storing the raw fraction would show every -#: creator in the book as `0.0%` — a measurement replaced by a wrong measurement, which is worse -#: than the blank cell it came from ([[analyst-chart-library]]: this repo already carries a 0–1 -#: dialect and a 0–100 dialect, and they meet here). -#: ⚠ BLANK STAYS BLANK. `""` means the vendor did not send one — both scrape probe rows were null -#: — and `0.0` would claim we measured zero engagement. -def _pct100(v): - """A 0–1 engagement fraction → a 0–100 percentage. "" when there is nothing to convert.""" - if v is None or (isinstance(v, str) and not v.strip()): - return "" - try: - return _s(round(float(v) * 100.0, 4)) - except (TypeError, ValueError): - return "" - - -def _parse_iso(s): - """A stamp → a NAIVE LOCAL datetime, with or without an offset. - - Both shapes exist in the store simultaneously and always will: every run committed before - D-18 wrote a naive stamp, and nothing rewrites history. A parser that understood only the new - shape would silently return None for them — and `is_due` reads None as "never ran", which - would re-fire every schedule once. Reading both is what makes the change additive. - """ - raw = str(s or "").strip().replace("Z", "+00:00") - dt = None - try: - dt = _dt.datetime.fromisoformat(raw) - except Exception: # noqa: BLE001 - try: - dt = _dt.datetime.strptime(raw[:19], "%Y-%m-%dT%H:%M:%S") - except Exception: # noqa: BLE001 - return None - return dt.astimezone().replace(tzinfo=None) if dt.tzinfo is not None else dt - - -# --------------------------------------------------------------------------------------------- -# THE SSRF RAIL — vendored from scrape.py `guard`, then hardened for a SERVER-SIDE fetcher -# --------------------------------------------------------------------------------------------- -# The skill version guards the URL a developer typed. This one guards a URL that arrived in a -# request body, which changes the threat model in two ways the original does not cover: -# -# 1. REDIRECTS. `httpx.Client(follow_redirects=True)` / `requests.get(allow_redirects=True)` -# never re-enter the guard, so `http://evil.example/x` → 302 → `http://169.254.169.254/…` -# sails straight past a guard that only ever saw the first URL. The negative control ("a -# localhost URL is refused") would still pass while the rail was wide open. `fetch` below -# therefore takes the hops MANUALLY and re-guards every one. -# 2. DNS. A perfectly public hostname may resolve to a private address. `guard` resolves the -# host and checks EVERY answer, not just the literal. -# -# ⚠ STATED, NOT HIDDEN: this is check-then-connect, so a DNS-rebinding attacker who flips the -# record between the guard and the socket is not stopped by it. Closing that needs a pinned -# connection to the validated IP (a custom adapter). Out of scope for v1 and recorded here rather -# than implied away — the rail refuses the realistic cases and says what it does not cover. - -class Refused(ValueError): - """The rail refused a URL. A distinct type so a refusal is never logged as a fetch error.""" - - -def _ip_public(ip): - return not (ip.is_private or ip.is_loopback or ip.is_link_local - or ip.is_reserved or ip.is_unspecified or ip.is_multicast) - - -def guard(url, allowed=()): - """PUBLIC-WEB-ONLY rail: http(s) only, no loopback/private/link-local host, DNS answers - checked too, plus the optional `--allowed` domain rail scrape.py carries.""" - p = urlparse(url or "") - if p.scheme not in ("http", "https"): - raise Refused(f"only http(s) allowed, not {p.scheme!r}") - host = (p.hostname or "").strip() - if not host: - raise Refused("no host in the URL") - low = host.lower() - if (low in ("localhost", "localhost.localdomain") - or low.endswith(".local") or low.endswith(".internal") - or low.endswith(".localhost")): - raise Refused(f"refused non-public host {host!r} (public web only)") - try: # a bare-IP host is decided on the literal - ip = ipaddress.ip_address(low) - except ValueError: - ip = None - if ip is not None and not _ip_public(ip): - raise Refused(f"refused non-public IP {host} (public web only)") - if allowed and not any(low == d.lower() or low.endswith("." + d.lower()) for d in allowed): - raise Refused(f"host {host!r} not in the allowed domains {list(allowed)}") - if ip is None: - try: - infos = socket.getaddrinfo(host, None) - except Exception as e: - raise Refused(f"could not resolve {host!r}: {type(e).__name__}") - for info in infos: - addr = info[4][0] - try: - resolved = ipaddress.ip_address(addr.split("%")[0]) - except ValueError: - continue - if not _ip_public(resolved): - raise Refused( - f"refused {host!r}: it resolves to the non-public address {resolved}") - return True - - -def fetch(url, timeout=20.0, max_kb=2048, allowed=(), max_hops=5, headers=None): - """GET `url`, taking redirects BY HAND so every hop passes `guard`. - - Returns `(status, final_url, body_bytes)`. A hop chain longer than `max_hops` is a refusal, - not a silent truncation — an endless redirect is indistinguishable from an attempt to walk - the fetcher somewhere it was told not to go. - """ - current = url - hdrs = {"User-Agent": UA, "Accept-Language": "en-US,en;q=0.9"} - hdrs.update(headers or {}) - for _hop in range(max_hops + 1): - guard(current, allowed) - r = requests.get(current, timeout=timeout, headers=hdrs, - allow_redirects=False, stream=True) - if r.status_code in (301, 302, 303, 307, 308): - loc = r.headers.get("Location") - r.close() - if not loc: - raise Refused(f"redirect {r.status_code} with no Location header") - current = urljoin(current, loc) - continue - body = r.raw.read(max_kb * 1024, decode_content=True) or b"" - status, final = r.status_code, str(r.url) - r.close() - return status, final, body - raise Refused(f"more than {max_hops} redirects — refusing to follow further") - - -def fetch_json(url, body, timeout=60.0, max_kb=8192, headers=None): - """POST a JSON body through the SAME guard. Returns `(status, body_bytes)`. - - ⛔ NO REDIRECT FOLLOWING, AND THAT IS THE WHOLE DIFFERENCE FROM `fetch`. `fetch` walks hops by - hand because a redirected GET is ordinary. A redirected POST is not: re-sending a - credential-bearing body to a Location the *server* chose is precisely the hop the SSRF rail - exists to refuse, and `requests`' own `allow_redirects=True` would do it without ever - re-entering `guard`. So a 3xx here is a REFUSAL with the reason on it, never a second request. - (The vendor calls below are the only POSTs this module makes, and they carry the API key.) - """ - guard(url) - hdrs = {"User-Agent": UA, "Content-Type": "application/json", "Accept": "application/json"} - hdrs.update(headers or {}) - r = requests.post(url, timeout=timeout, headers=hdrs, allow_redirects=False, - data=json.dumps(body if body is not None else {}), stream=True) - try: - if r.status_code in (301, 302, 303, 307, 308): - raise Refused(f"the POST answered {r.status_code} — a redirected POST is refused, " - f"never re-sent to a location the server picked") - return r.status_code, (r.raw.read(max_kb * 1024, decode_content=True) or b"") - finally: - r.close() - - -# --------------------------------------------------------------------------------------------- -# EXTRACTION — vendored from scrape.py, unchanged in behaviour -# --------------------------------------------------------------------------------------------- - -def _soup(body): - if BeautifulSoup is None: - raise RuntimeError( - "beautifulsoup4 is not installed in this environment — the automation engine's " - "extraction half needs it (see the wave-18 mailbox: add beautifulsoup4 + lxml to " - "aios-web/requirements.txt).") - try: - return BeautifulSoup(body, "lxml") - except Exception: - return BeautifulSoup(body, "html.parser") - - -def tables(soup): - """Every HTML table as rows. Dict rows when the first row looks like a header.""" - out = [] - for t in soup.find_all("table"): - rows = [] - for tr in t.find_all("tr"): - cells = [c.get_text(" ", strip=True) for c in tr.find_all(["th", "td"])] - if cells: - rows.append(cells) - if not rows: - continue - head, body = rows[0], rows[1:] - if body and len(head) == len(body[0]) and all(head): - out.append([dict(zip(head, r)) for r in body if len(r) == len(head)]) - else: - out.append(rows) - return out - - -def jsonld(soup): - out = [] - for s in soup.find_all("script", attrs={"type": "application/ld+json"}): - try: - out.append(json.loads(s.string or s.get_text())) - except Exception: - pass - return out - - -def meta(soup): - m = {} - if soup.title and soup.title.string: - m["title"] = soup.title.string.strip() - for tag in soup.find_all("meta"): - k = tag.get("name") or tag.get("property") - v = tag.get("content") - if k and v and (k in ("description", "keywords", "author") - or k.startswith(("og:", "twitter:"))): - m[k] = v.strip() - return m - - -def preview(url, extract="table", table_index=0, allowed=()): - """The field-map preview the editor calls BEFORE anything is created: what columns does this - page actually offer, and what do the first rows look like? Never writes.""" - status, final, body = fetch(url, allowed=allowed) - if not (200 <= status < 300): - return {"ok": False, "status": status, "url": final, - "note": f"the page answered {status} — it may be bot-gated or gone", - "columns": [], "sample": [], "rowCount": 0} - soup = _soup(body) - rows = [] - if extract == "jsonld": - blocks = jsonld(soup) - flat = [] - for b in blocks: - if isinstance(b, list): - flat.extend([x for x in b if isinstance(x, dict)]) - elif isinstance(b, dict): - items = b.get("itemListElement") - flat.extend([x for x in items if isinstance(x, dict)] if isinstance(items, list) - else [b]) - rows = [{k: _scalar(v) for k, v in d.items()} for d in flat] - else: - found = tables(soup) - idx = max(0, min(int(table_index or 0), len(found) - 1)) if found else 0 - picked = found[idx] if found else [] - rows = [r for r in picked if isinstance(r, dict)] - cols, seen = [], set() - for r in rows[:50]: - for k in r: - if k not in seen: - seen.add(k) - cols.append(k) - return {"ok": True, "status": status, "url": final, "columns": cols, - "sample": rows[:8], "rowCount": len(rows), - "tableCount": len(tables(soup)) if extract != "jsonld" else 0, - "title": (meta(soup) or {}).get("title", "")} - - -def _scalar(v): - if isinstance(v, (str, int, float)) and not isinstance(v, bool): - return str(v) - if isinstance(v, bool): - return "1" if v else "" - if isinstance(v, dict): - return str(v.get("name") or v.get("@id") or "") - if isinstance(v, list): - return ", ".join(_scalar(x) for x in v[:8]) - return "" - - -# --------------------------------------------------------------------------------------------- -# CRON — a 5-field parser and a "what was the last fire time" walk -# --------------------------------------------------------------------------------------------- -# Deliberately NOT `croniter` (a dependency for ~60 lines) and deliberately NOT a forward -# scheduler. The question a tick asks is backwards-looking — *"was there a scheduled minute -# between the last run and now?"* — and answering it that way is what makes a missed tick -# self-healing: a container that was asleep for two hours runs once on wake, not eleven times and -# not never. - -_FIELD_RANGES = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)] - - -def _parse_field(spec, lo, hi): - out = set() - for part in str(spec).split(","): - part = part.strip() - if not part: - raise ValueError("empty cron field part") - step = 1 - if "/" in part: - part, _, s = part.partition("/") - step = int(s) - if step < 1: - raise ValueError("cron step must be >= 1") - if part in ("*", "?"): - a, b = lo, hi - elif "-" in part.lstrip("-"): - a_s, _, b_s = part.partition("-") - a, b = int(a_s), int(b_s) - else: - a = b = int(part) - if a < lo or b > hi or a > b: - raise ValueError(f"cron value {part!r} out of range {lo}..{hi}") - out.update(range(a, b + 1, step)) - return out - - -def parse_cron(expr): - """`'m h dom mon dow'` → `(minutes, hours, doms, months, dows, dom_restricted, dow_restricted)`. - - Raises ValueError on anything malformed — a schedule that cannot be parsed must be refused at - WRITE time, because a cron string nobody can evaluate is an automation that silently never - runs and reports no error anywhere. - """ - parts = str(expr or "").split() - if len(parts) != 5: - raise ValueError("a cron schedule has exactly 5 fields: minute hour day month weekday") - sets = [_parse_field(p, lo, hi) for p, (lo, hi) in zip(parts, _FIELD_RANGES)] - dom_r = parts[2].strip() not in ("*", "?") - dow_r = parts[4].strip() not in ("*", "?") - return (sets[0], sets[1], sets[2], sets[3], sets[4] | ({0} if 7 in sets[4] else set()), - dom_r, dow_r) - - -def _day_matches(day, doms, months, dows, dom_r, dow_r): - if day.month not in months: - return False - # POSIX rule: with BOTH day-of-month and weekday restricted the match is the UNION, not the - # intersection. `0 6 1 * 1` means "the 1st, and every Monday" — getting this backwards makes - # a schedule that looks right fire almost never. - dow = (day.weekday() + 1) % 7 # python Mon=0 -> cron Sun=0 - if dom_r and dow_r: - return day.day in doms or dow in dows - if dom_r: - return day.day in doms - if dow_r: - return dow in dows - return True - - -def prev_fire(expr, now=None, lookback_days=400): - """The most recent scheduled minute at or before `now`, or None inside the lookback. - - Walks by DAY (≤400 iterations) rather than by minute (≥500k) — the day fields decide first, - and only a matching day needs its hours/minutes searched. - """ - minutes, hours, doms, months, dows, dom_r, dow_r = parse_cron(expr) - now = (now or _now()).replace(second=0, microsecond=0) - for back in range(lookback_days + 1): - day = (now - _dt.timedelta(days=back)).date() - if not _day_matches(day, doms, months, dows, dom_r, dow_r): - continue - same_day = back == 0 - for h in sorted(hours, reverse=True): - if same_day and h > now.hour: - continue - for m in sorted(minutes, reverse=True): - if same_day and h == now.hour and m > now.minute: - continue - return _dt.datetime(day.year, day.month, day.day, h, m) - return None - - -def is_due(defn, now=None): - """Should the scheduler run this automation right now? - - Due when a scheduled minute exists strictly AFTER the reference point (the last run, else the - moment the schedule was enabled, else creation) and at or before now. Anchoring on - `enabledAt` rather than firing on the first tick is what stops "enable a daily 06:00 job at - 14:00" from running immediately and looking like a bug. - """ - if not isinstance(defn, dict): - return False - sched = defn.get("schedule") or {} - if not sched.get("enabled"): - return False - try: - fire = prev_fire(sched.get("cron"), now=now) - except ValueError: - return False - if fire is None: - return False - since = (_parse_iso((defn.get("status") or {}).get("lastRunAt")) - or _parse_iso(sched.get("enabledAt")) - or _parse_iso(defn.get("created"))) - return since is None or fire > since - - -def next_fire(expr, now=None, lookahead_days=400): - """The next scheduled minute strictly after `now` — display only (the rail is `is_due`).""" - try: - minutes, hours, doms, months, dows, dom_r, dow_r = parse_cron(expr) - except ValueError: - return None - now = (now or _now()).replace(second=0, microsecond=0) - for ahead in range(lookahead_days + 1): - day = (now + _dt.timedelta(days=ahead)).date() - if not _day_matches(day, doms, months, dows, dom_r, dow_r): - continue - same_day = ahead == 0 - for h in sorted(hours): - if same_day and h < now.hour: - continue - for m in sorted(minutes): - if same_day and h == now.hour and m <= now.minute: - continue - return _dt.datetime(day.year, day.month, day.day, h, m) - return None - - -# --------------------------------------------------------------------------------------------- -# THE UPSERT — pure, so the arithmetic is testable without a store or a network -# --------------------------------------------------------------------------------------------- - -#: ⛔⛔ D-116 — THE SIX CELLS A CORPUS RE-FIND MUST NOT OVERWRITE ONCE AN ENRICHMENT MEASURED THEM. -#: A discovery re-find emits these on EVERY match, not only on insert, so the moment a scheduled -#: search re-matched an account somebody had PAID to enrich, six exact measurements were replaced by -#: pre-collected corpus values of unknown vintage — no error, no visible change other than the -#: number. Measured 2026-08-10 on a live tenant: 0 of 105 profiles had been re-found yet, so this -#: was a defect waiting for its first scheduled re-run rather than one anybody had seen. -#: ⚠ SCALARS ONLY, and that is the ruling. The OBSERVATION is appended either way — the corpus -#: genuinely saw that account at that follower count, and the snapshot series is where a corpus read -#: belongs. Suppressing the observation would be the same defect arriving from the other side. -CORPUS_SOFT_KEYS = ("followers", "following", "avg_engagement", "verified", "category", "bio") - - -def corpus_protect(before, _src): - """Which keys this incoming CORPUS row may not overwrite on `before`. D-116's precedence rule. - - ⛔ THE TEST IS `enriched_at`, i.e. "did an exact read ever write this row", NOT "is the cell - non-empty". The exit condition rules the second one out by name, and rightly: *"NOT by making - `upsert_rows` skip non-empty cells — that would break every re-scrape in the product."* A - re-scrape SHOULD move a number the corpus owns; what it may not do is move one an exact read - owns. - """ - return CORPUS_SOFT_KEYS if str((before or {}).get("enriched_at") or "").strip() else () - - -def upsert_rows(existing, incoming, key_field, cap=None, protect=None): - """Merge scraped rows into a user table's rows BY KEY. Returns `(rows, counts)`. - - `protect` is an optional `f(before, src) -> keys` naming, PER ROW, the keys this incoming row - may not overwrite. Default `None` = the old behaviour exactly, so the other nine call sites are - untouched — the precedence rule belongs to the CALLER that knows its data's provenance, not to - the merge, and a rule baked in here would apply to nine paths that never asked for one. - - THE RULE THAT MATTERS: **an orphan is COUNTED, NEVER DELETED.** A row that has stopped - appearing on the source page has not necessarily stopped existing — the page changed its - filter, the fetch was partial, the site paginated. Deleting on absence turns any upstream - hiccup into silent data loss, and the row may be carrying user-typed overlay values in - columns the scrape never touches. So the run reports `orphans: N` and leaves them alone. - - Only MAPPED keys are written: a re-run never clobbers a column a user added by hand. - - ⚠ CALL THIS ONCE PER TABLE PER RUN, NOT ONCE PER ROW. It rebuilds the whole row dict on - entry, so it is O(existing) per call — fine once, quadratic in a loop. The IG runner used to - call it per POST, which was survivable only because the cap was 5000; against `MAX_UT_IG_ROWS` - that same loop is hundreds of millions of dict copies and the automation simply never - finishes. Raising a cap and batching the writer are ONE change, not two. (W19-C.) - - ⚠ `capped` IS ITS OWN COUNT, deliberately not folded into `skipped`. They are different - facts: `skipped` means "this row had no key, so it could not be upserted" — a property of the - DATA, and usually benign. `capped` means "this table is full and the run is now losing rows" — - a property of the SYSTEM, and never benign. One number for both meant a table hitting its - ceiling was indistinguishable from a page with a few blank cells, which is how a time series - stops silently. The runners turn any `capped` into a `partial` run that NAMES the table. - """ - cap = MAX_UT_ROWS if cap is None else int(cap) - rows = {str(k): dict(v or {}) for k, v in (existing or {}).items()} - counts = {"inserted": 0, "updated": 0, "unchanged": 0, - "skipped": 0, "duplicates": 0, "orphans": 0, "capped": 0} - by_key, dupe_ids = {}, set() - for rid, row in rows.items(): - kv = str(row.get(key_field, "") or "").strip() - if not kv: - continue - if kv in by_key: - dupe_ids.add(rid) # a pre-existing duplicate: first id wins, second left - continue - by_key[kv] = rid - next_id = max((int(r) for r in rows if str(r).isdigit()), default=0) + 1 - seen_keys, incoming_dupes = set(), 0 - for src in incoming or []: - kv = str((src or {}).get(key_field, "") or "").strip() - if not kv: - counts["skipped"] += 1 # no key -> cannot be upserted; never guessed - continue - if kv in seen_keys: - incoming_dupes += 1 # the SOURCE listed it twice; first wins - continue - seen_keys.add(kv) - rid = by_key.get(kv) - if rid is None: - if len(rows) >= cap: - counts["capped"] += 1 # LOUD: the runner turns this into a partial run - continue - rid = str(next_id) - next_id += 1 - rows[rid] = dict(src) - by_key[kv] = rid - counts["inserted"] += 1 - continue - before = rows[rid] - # ⛔ D-116's PRECEDENCE RULE, applied per ROW because provenance is a property of the row. - # `held` counts the cells a lower-provenance source was refused, so the run can SAY it - # rather than quietly doing the right thing — a protection nobody is told about is - # indistinguishable from a source that happened to agree. - keep = set(protect(before, src) or ()) if protect else set() - use = {k: v for k, v in src.items() if k not in keep} if keep else src - if keep: - counts["held"] = counts.get("held", 0) + sum( - 1 for k in keep if k in src and str(before.get(k, "")) != str(src.get(k))) - changed = {k: v for k, v in use.items() if str(before.get(k, "")) != str(v)} - if changed: - before.update(use) - counts["updated"] += 1 - else: - counts["unchanged"] += 1 - counts["duplicates"] = incoming_dupes + len(dupe_ids) - counts["orphans"] = sum( - 1 for kv, rid in by_key.items() if kv not in seen_keys and rid not in dupe_ids) - return rows, counts - - -def dedupe_canonical_rows(existing, key_field, newest_by=""): - """Collapse duplicate logical rows while preserving the lowest stable row id. - - Canonical entity tables use this before every upsert. Snapshot tables deliberately do not: - repeated shortcodes there are new timestamped observations, not duplicate posts. Values are - taken newest-first and then filled from older rows, so a sparse fresh projection does not - erase a field an earlier row knew. - """ - rows = {str(k): dict(v or {}) for k, v in (existing or {}).items()} - groups = {} - for rid, row in rows.items(): - identity = str(row.get(key_field) or "").strip() - if identity: - groups.setdefault(identity, []).append((rid, row)) - removed = 0 - for members in groups.values(): - if len(members) < 2: - continue - keep = min((rid for rid, _row in members), key=lambda r: (not r.isdigit(), int(r) if r.isdigit() else r)) - ordered = sorted(members, key=lambda item: str(item[1].get(newest_by) or ""), reverse=True) \ - if newest_by else members - merged = {} - for _rid, row in ordered: - for key, value in row.items(): - if key not in merged or str(merged.get(key) or "").strip() == "": - merged[key] = value - rows[keep] = merged - for rid, _row in members: - if rid != keep: - rows.pop(rid, None) - removed += 1 - return rows, removed - - -# --------------------------------------------------------------------------------------------- -# USER TABLES — read/write through the RUNTIME (never core.user_tables' module-global key) -# --------------------------------------------------------------------------------------------- - -def _ut_slug(label): - s = re.sub(r"[^a-z0-9]+", "_", str(label or "").strip().lower()).strip("_") - return (s or "table")[:40] - - -def ut_all(rt): - try: - return dict(rt.get(UT_STORE_KEY) or {}) - except Exception: - return {} - - -def disable_for_table(rt, table_key, note="target database deleted"): - """Wave 21 (item 6a, C3): a deleted table's automations are DISABLED loudly, never deleted. - - The definition survives with `schedule.enabled = False` + a `statusNote`, so the rail still - shows what existed and why it stopped — silently deleting a user's automation because its - target died would read as data loss. Returns the ids it touched.""" - key = str(table_key or "") - touched = [] - - def _up(cur): - for aid, d in (cur or {}).items(): - if isinstance(d, dict) and (d.get("config") or {}).get("targetTable") == key: - sch = d.get("schedule") - if not isinstance(sch, dict): - sch = d["schedule"] = {} - sch["enabled"] = False - d["statusNote"] = note - touched.append(str(aid)) - return cur - - if key: - _store_update(rt, _up, flush="sync") - return touched - - -def ut_get(rt, key): - return ut_all(rt).get(str(key)) - - -def retire_automation_stage_fields(rt, tables=None): - """Delete obsolete Board-only fields and their hidden cells, never user fields. - - The authoritative selector is the engine's own ``automation.stageField`` / ``cyclesField`` - metadata — labels such as “Stage” are ordinary user vocabulary and are not touched. Old - generated timestamp/cycle cells are cleared too, including rows whose field definition was - removed by an interrupted earlier migration. Re-running this migration is a no-op. - - ⭐ WAVE 29 (W29-T01) — ``tables`` LETS A CALLER LEND ITS SNAPSHOT. The SCAN below is - O(all row-cells in the tenant) over a bucket whose documented ceiling is 35.8 MB / ~1.4 s to - deep-copy (see this module's header), and `GET /automations` was paying for THREE independent - copies of it per request. The scan is read-only, so borrowing the caller's copy is free; the - WRITE below still goes through `rt.update`, which re-reads under the store lock, so a lent - snapshot can never be the thing that gets written back. - """ - tables = ut_all(rt) if tables is None else tables - stage_keys = set() - for table in tables.values(): - if not isinstance(table, dict): - continue - for field in table.get("fields") or []: - auto = field.get("automation") if isinstance(field, dict) else None - if isinstance(auto, dict) and (auto.get("stageField") or auto.get("cyclesField")): - stage_keys.add(str(field.get("key") or "")) - for row in (table.get("rows") or {}).values(): - for key in (row or {}): - text = str(key) - if text.startswith("stage_auto_"): - stage_keys.add(text.removesuffix("_at").removesuffix("_cycles")) - stage_keys.discard("") - if not stage_keys: - return {"tables": 0, "fields": 0, "cells": 0} - - changed = {"tables": set(), "fields": 0, "cells": 0} - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - for table_key, table in cur.items(): - if not isinstance(table, dict): - continue - kept, removed = [], set() - for field in table.get("fields") or []: - auto = field.get("automation") if isinstance(field, dict) else None - key = str(field.get("key") or "") if isinstance(field, dict) else "" - if key in stage_keys and isinstance(auto, dict) and \ - (auto.get("stageField") or auto.get("cyclesField")): - removed.add(key) - changed["fields"] += 1 - continue - kept.append(field) - if removed: - table["fields"] = kept - changed["tables"].add(str(table_key)) - for row in (table.get("rows") or {}).values(): - if not isinstance(row, dict): - continue - for stage_key in stage_keys: - for key in (stage_key, stage_key + "_at", stage_key + "_cycles"): - if key in row: - row.pop(key, None) - changed["cells"] += 1 - changed["tables"].add(str(table_key)) - return cur - - rt.update(UT_STORE_KEY, _up, flush="sync") - return {"tables": len(changed["tables"]), "fields": changed["fields"], - "cells": changed["cells"]} - - -def bind_unbound_fields(rt): - """C8's migration (wave 22, stated choice): existing automation bags WITHOUT a `flowId` - are bound to the definition that already writes them — matched on the (targetTable, - fieldKey) pair the definition carries, which is the binding W21-C2 established. A bag no - definition references is DISABLED with the reason on it rather than deleted or guessed: - the column was already dead (nothing runs a column no flow names), and now it says so. - Returns `(bound, disabled)`; costs zero store commits when there is nothing to migrate.""" - by_binding = {} - for aid, d in all_definitions(rt).items(): - cfg = d.get("config") or {} - if cfg.get("fieldKey") and cfg.get("targetTable"): - by_binding[(str(cfg["targetTable"]), str(cfg["fieldKey"]))] = str(aid) - plan = {} - for tk, t in ut_all(rt).items(): - for f in (t.get("fields") or []): - a = f.get("automation") - # An already-DISABLED bag is a decision this migration made on a previous pass — - # re-planning it every call would turn the once-per-process sweep into a write - # per read. - if isinstance(a, dict) and not a.get("flowId") and not a.get("stageField") \ - and not a.get("disabled"): - plan[(tk, str(f.get("key")))] = by_binding.get((tk, str(f.get("key")))) - if not plan: - return 0, 0 - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - for (tk, fk), aid in plan.items(): - for f in ((cur.get(tk) or {}).get("fields") or []): - if f.get("key") == fk and isinstance(f.get("automation"), dict): - if aid: - f["automation"]["flowId"] = aid - else: - f["automation"]["disabled"] = True - f["automation"]["statusNote"] = ( - "not bound to any flow — create an automation for this column " - "or delete it") - return cur - - rt.update(UT_STORE_KEY, _up, flush="sync") - bound = sum(1 for v in plan.values() if v) - return bound, len(plan) - bound - - -def ut_key_for(label, key=None): - """The table key a label resolves to. Factored out of `ut_ensure` so the DRY-RUN path (which - must not create anything) resolves the same key by construction rather than by copying the - derivation and drifting from it.""" - k = str(key or (UT_PREFIX + _ut_slug(label))) - return k if k.startswith(UT_PREFIX) else UT_PREFIX + k - - -#: Machine names, mirrored from `core.user_tables.MACHINE_OWNERS`. A LOCAL literal for the same -#: reason `UT_FIELD_TYPES` is one over there — this module must stay importable without dragging -#: `core` into the API's boot path — and the two are held in step by a gate check. -MACHINE_OWNERS = ("automation", "scheduler") - - -def ut_ensure(rt, label, fields, username="automation", key=None, flow_tag="", - record_mode="", lock_fields=False, tables=None): - """Create the table if it is missing; return its key. Idempotent — a re-run of an automation - that owns a table must not spawn `ut_x_2`, so the key is DERIVED from the label (or given) - and an existing table with that key is adopted, not duplicated. - - ⭐⭐ WAVE 31 · T30 — `tables` LETS A CALLER LEND THE SNAPSHOT IT IS ALREADY HOLDING, and it is - the SKIP TEST below that it pays for. `rt.get` deep-copies the whole tenant document on every - call (documented ceiling 35.8 MB / ~1.4 s), so a caller that ensures FOUR tables in one pass - paid four copies to answer four questions about one document. Owner item 7, verbatim: *"It still - takes forever to change the Config for automation as well. It just say Saving..."* — measured - **7,912 ms** for one save on `4258a93`. - - ⚠ THE LEND IS READ-ONLY AND SAFE BY INSPECTION, not by hope — the same argument - `retire_automation_stage_fields` (this module, same parameter name, same contract) already - makes: only the `have` lookup below reads it, and the WRITE still goes through `rt.update`, - whose `_up` re-reads the live document under the store lock. **A lent snapshot can therefore - never be the thing that gets written back**, and the worst a stale one can do is spend a commit - that would have been skipped — never write a wrong value. Callers that ensure two tables - under the SAME key in one pass pass `tables=None` for the second (see `_spawn_presets`). - - Fields are MERGED, never replaced: a user who added a column to an automation's table keeps - it, and a new source column joins on the next run. - - `flow_tag` (wave 22, C7/item 5): every field THIS call adds is stamped - `automation: {flowId: }` — the pre-set columns an IG automation spawns carry their - provenance. Fields already on the table keep whatever tag they have (first flow wins; - shared tables like ut_ig_snapshots are fed by many flows and the tag is provenance, not - ownership). - - ⛔ AND IT STAMPS A HUMAN OWNER, WHICH IT DID NOT (wave 20, item 3). `createdBy` was whoever - or WHATEVER ran the automation, so the same table belonged to a person if its first run was - manual and to `"scheduler"` if the schedule got there first — and `user_tables.may_open` - admits only the creator or an admin, so **whether you could open your own Instagram - snapshots depended on a race you never saw**. The owner is now the automation's creator, and - a table already stamped with a machine name is ADOPTED the next time a run knows a human - one. Adoption is a repair, not a widening: the automation's creator is the person who asked - for the table in the first place. - """ - key = ut_key_for(label, key) - # ⭐ WAVE 26 — THE MIGRATION RIDES THE WRITE PATH, and that placement is the point. - # - # `ut_ensure` MERGES fields by key and never re-types an existing column, so on its own it - # would leave every table already in production on the old `text` schema forever while new - # ones got the honest types — the split schema R3's note warned about, arriving through the - # very function the note was written on. Migrating here means a table is brought forward - # immediately BEFORE anything appends to it, so no caller has to remember anything and no - # tenant is left behind by a script nobody ran. - # ⚠ Cheap by construction: `migrate_ig_tables` returns without a write when the table is - # already current, which after the first run is every call. - if key and {f.get("key") for f in (fields or [])} & set(PRESET_PROFILE_KEYS): - try: - migrate_ig_tables(rt, log=lambda *_a: None, only=key) - except Exception: # noqa: BLE001 - # A migration that cannot run must not stop the automation from writing its rows. - # The old schema still reads; a refused write loses the pull we just paid for. - pass - wanted = [] - for raw_field in (fields or []): - field = dict(raw_field) - if flow_tag or lock_fields: - automation = dict(field.get("automation") or {}) - if flow_tag: - automation.setdefault("flowId", str(flow_tag)) - if lock_fields: - automation["preset"] = True - field["automation"] = automation - wanted.append(field) - created = _iso() - human = username if username and username not in MACHINE_OWNERS else "" - - # ⚠ SKIP THE WRITE WHEN NOTHING WOULD CHANGE. Without this, every re-run spends a store - # commit re-writing an identical definition — against a 20 s flush floor and a 256/hr repo - # budget, an idempotent helper that always writes is the same defect as a per-row insert. - have = ut_get(rt, key) if tables is None else tables.get(str(key)) - have_fields = {str(f.get("key") or ""): f for f in ((have or {}).get("fields") or [])} - missing = [f for f in wanted if f.get("key") not in have_fields] - missing_locks = [f for f in wanted - if lock_fields and f.get("key") in have_fields - and (not isinstance(have_fields[f.get("key")].get("automation"), dict) - or have_fields[f.get("key")]["automation"].get("preset") is not True)] - if (have is not None and not missing and not missing_locks - and not (record_mode and have.get("recordMode") != record_mode) - and not (human and (have.get("createdBy") or "") in MACHINE_OWNERS)): - return key - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - t = cur.get(key) - if t is None: - if len(cur) >= MAX_UT_TABLES: - return cur - cur[key] = {"key": key, "label": str(label)[:60], "source": "Automation", - "createdBy": username, "created": created, - "fields": wanted, "rows": {}} - if record_mode: - cur[key]["recordMode"] = record_mode - return cur - have = {f.get("key") for f in (t.get("fields") or [])} - for f in wanted: - if f.get("key") not in have: - t.setdefault("fields", []).append(f) - have.add(f.get("key")) - elif lock_fields: - stored = next((g for g in (t.get("fields") or []) - if g.get("key") == f.get("key")), None) - if stored is not None: - automation = dict(stored.get("automation") or {}) - if flow_tag: - automation.setdefault("flowId", str(flow_tag)) - automation["preset"] = True - stored["automation"] = automation - # ADOPTION: a machine name is not an owner. It never overwrites a human one. - if human and (t.get("createdBy") or "") in MACHINE_OWNERS: - t["createdBy"] = human - # An automation's table SAYS an automation owns it — the nav badge reads from this. - t["source"] = t.get("source") or "Automation" - if record_mode: - t["recordMode"] = record_mode - return cur - - rt.update(UT_STORE_KEY, _up, flush="sync") - return key - - -def ut_write_rows(rt, key, rows): - """ONE store update for the WHOLE row set — the flush-ceiling rule (see the module header).""" - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - t = cur.get(key) - if t is not None: - t["rows"] = rows - return cur - rt.update(UT_STORE_KEY, _up, flush="sync") - - -def ut_set_cell(rt, key, row_id, field_key, value, extra_rows=None): - """Write one automation-owned cell (+ optional whole extra tables) in ONE update.""" - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - t = cur.get(key) - if t is not None: - t.setdefault("rows", {}).setdefault(str(row_id), {})[field_key] = value - for k, rws in (extra_rows or {}).items(): - tt = cur.get(k) - if tt is not None: - tt["rows"] = rws - return cur - rt.update(UT_STORE_KEY, _up, flush="sync") - - -IG_FIELD_DESCRIPTIONS = { - "alt_text": "Accessibility text attached to the Instagram post.", - # ⚠ WIDENED 2026-08-10, because the column gained a second writer and the old sentence would - # have made it lie. It was written for the anonymous rung, whose numbers are genuinely ROUNDED - # ("10.4K followers"). A DISCOVERY observation is exact-looking and STALE instead — read off - # the vendor's pre-collected corpus at a collection time we are not told. Both mean the same - # thing to a reader ("do not treat this as an exact count taken at Pulled at"), and a - # description that named only the first would have quietly excluded the second. - "approx": "Checked when the counts are rounded, or were read from a pre-collected corpus " - "rather than measured at the time shown.", - "avg_comments_12": "Average comments across the 12 most recent captured posts.", - "avg_engagement": "Average engagement rate reported for the profile.", - "avg_likes_12": "Average likes across the 12 most recent captured posts.", - "avg_plays_12": "Average plays across the 12 most recent captured posts.", - "avg_views_12": "Average views across the 12 most recent captured posts.", - "views": "View count for the post, as Instagram displays it.", - "video_duration": "Length of the video in seconds.", - "comments_disabled": "Checked when the creator has turned comments off for this post.", - "plays": "Times the video started playing, including replays.", - "bio": "Biography shown on the Instagram profile.", - "bio_hashtags": "Hashtags listed in the profile biography.", - "business_category": "Instagram's business category for the account.", - "caption": "Caption published with the Instagram post.", - "category": "Instagram's category for the profile.", - "comment_key": "Unique ID for the captured comment record.", - "commented_at": "Date the comment was posted.", - "comments": "Comment count reported for the post.", - "comments_captured": "Number of distinct comment records linked to this profile.", - "comments_link": "Comment records linked to this record.", - "country_code": "Country code reported for the profile.", - "created_by": "User whose automation first added this lead.", - "enriched_at": "Date the profile was last enriched.", - # ⭐ ITEM 16. The description says GUESS out loud, because the column is one and the number - # beside it is the only thing that says how much of one. - "location_guess": "Most common city tagged across this profile's captured posts - a guess, " - "not a stated location.", - "location_confidence": "Share of this profile's geotagged posts that agree on the guessed " - "city.", - "external_url": "Website linked from the Instagram biography.", - "external_url_title": "Title Instagram shows for the biography link.", - "fbid": "Facebook ID associated with the Instagram account.", - "first_found": "Date an automation first found this profile.", - "followers": "Latest reported follower count.", - "following": "Latest reported number of accounts followed.", - "found_count": "Number of times discovery automations found this profile.", - "full_name": "Display name shown on the Instagram profile.", - "handle": "Instagram username without the @ symbol.", - "has_channel": "Checked when the profile has an Instagram channel.", - "hashtags": "Hashtags extracted from the post caption.", - "highlights_count": "Total story highlight collections reported for the profile.", - "ig_id": "Instagram's internal ID for the account.", - "influencer_key": "Normalized handle linking this row to its profile.", - "is_business": "Checked when Instagram marks the account as a business.", - "is_joined_recently": "Checked when Instagram marks the account as recently joined.", - "is_private": "Checked when the Instagram profile is private.", - "is_professional": "Checked when Instagram marks the account as professional.", - "last_found": "Date an automation most recently found this profile.", - "likes": "Like count reported for this record.", - "measured_at": "Date engagement metrics on this post were last read.", - "measurements_captured": "Number of measurement rows stored for this post.", - "paid_partnership": "Checked when Instagram marks the post as a paid partnership.", - "partner": "Brand named in the post's paid partnership metadata.", - "partner_id": "Partner ID reported for the Instagram profile.", - "platform": "Social network for this profile.", - "plays": "Video plays, including repeat plays, reported for the post.", - "post_link": "Post record linked to this measurement or comment.", - "post_measurements_captured": "Number of distinct post measurements linked to this profile.", - "post_snapshot_key": "Unique ID for this post measurement.", - "post_snapshots_link": "Engagement measurement rows linked to this record.", - "posted_at": "Date the Instagram post was published.", - "posts_captured": "Number of distinct post records captured for this profile.", - "posts_count": "Total posts reported for the Instagram profile.", - "posts_link": "Post records linked to this profile.", - "profile_name": "Profile name returned by the data provider.", - "profile_reads": "Number of profile snapshots stored for this profile.", - "profile_snapshots_link": "Profile snapshot rows linked to this profile.", - "profile_url": "Direct URL to the Instagram profile.", - "pronouns": "Pronouns shown on the Instagram profile.", - "pulled_at": "Date this snapshot was collected.", - "related_accounts": "Accounts Instagram suggests alongside this profile.", - "replies": "Reply count reported for the comment.", - "shortcode": "Instagram shortcode that uniquely identifies the post.", - "source_payload": "Full source response for this record.", - "snapshot_key": "Unique ID for this profile snapshot.", - "source": "Method or provider used to read this profile.", - "tagged_location": "Location tagged on the post; it is not the creator's residence.", - "text": "What the comment says. The commenter's name is not stored as a column.", - "type": "Instagram media type: image, video, or carousel.", - "url": "Direct URL to the Instagram post.", - "verified": "Checked when Instagram marks the profile as verified.", -} - - -#: ⭐ WAVE 29 (R2) — TIKTOK'S OWN DESCRIPTIONS, and the reason this exists rather than reusing the -#: map above is one line of `field_def`: it defaults `description` to -#: `IG_FIELD_DESCRIPTIONS.get(key)`. Nine `ut_tt_*` columns share a KEY with an Instagram column -#: (`shortcode`, `type`, `url`, `verified`, `caption`, `likes`, `comments`, `replies`, -#: `tagged_location`), so a TikTok video's Type column would have shipped explaining "Instagram -#: media type" — a wrong sentence in the header tooltip of a column nobody would think to check. -#: ⚠ A KEY ABSENT HERE GETS NO DESCRIPTION AT ALL, deliberately: silence is honest, and inheriting -#: the Instagram sentence is the failure this map exists to prevent. -TT_FIELD_DESCRIPTIONS = { - "platform": "Network this handle is on.", - "handle": "TikTok @name; the unique account handle.", - "full_name": "Display name shown on the TikTok profile.", - "tt_id": "TikTok's own numeric id for the account.", - "profile_url": "Direct URL to the TikTok profile.", - "bio": "Profile biography text.", - "external_url": "Link in the TikTok bio.", - "verified": "Checked when TikTok marks the account as verified.", - "is_private": "Checked when the account is private.", - "followers": "Follower count at the time of the pull.", - "following": "Accounts this profile follows.", - "posts_count": "Videos published by this account.", - "likes_received": "Total likes this account's videos have received.", - "avg_engagement": "Average engagement rate, stored as a percentage.", - "comment_engagement": "Comment engagement rate, stored as a percentage.", - "like_engagement": "Like engagement rate, stored as a percentage.", - "is_business": "Approximate: set when TikTok flags the account as a commerce user.", - "country_code": "Two-letter country code reported for the account.", - "predicted_lang": "Language TikTok predicts for this account.", - "account_created_at": "When the TikTok account itself was created.", - "region": "Region reported for the account.", - "shortcode": "TikTok's numeric id for the video; unique per post.", - "type": "TikTok post type: video or image.", - "url": "Direct URL to the TikTok post.", - "caption": "Post description text.", - "posted_at": "When the creator published the post.", - "likes": "Likes reported for the post.", - "comments": "Comment count reported for the post.", - "views": "Play count TikTok reports for the video.", - "shares": "Times the post was shared.", - "saves": "Times the post was saved to a collection.", - "video_duration": "Video length in seconds.", - "hashtags": "Hashtags used in the post.", - "tagged_location": "Commerce location reported for the post; it is not the creator's home.", - "influencer_key": "Handle of the account this record belongs to.", - "comment_key": "Unique ID for this comment.", - "commented_at": "When the comment was posted.", - "text": "What the comment says. The commenter's name is not stored as a column.", - "replies": "Reply count reported for the comment.", - "snapshot_key": "Unique ID for this profile snapshot.", - "post_snapshot_key": "Unique ID for this post measurement.", - "pulled_at": "When this measurement was read.", - "enriched_at": "When this row was last enriched.", - "source": "Method or provider used to read this record.", - "source_payload": "Full source response for this record.", -} - - -def field_def(text_key, label, ftype="text", **extra): - """One machine-spawned column definition. - - ⭐ 2026-08-07 — `**extra` carries the per-field DECLARATIONS the preset set needs (`pinned`, - `profile`), and it stays ONE constructor rather than growing a second for "special" fields. - ⛔ Every key passed here must survive `core.user_tables._clean_field` UNCHANGED — `verify_api`'s - W25-1 section pins the drift at ZERO, so a key the validator drops or rewrites turns the whole - preset list red rather than failing quietly ([[default-must-pass-its-own-guard]]). - - ⭐ WAVE 25 — `editRole` IS DECLARED HERE, and adding it closes a bypass rather than adding a - feature. `ut_ensure` writes these dicts STRAIGHT into the `user_tables` bucket, so - `core.user_tables._clean_field` — the single validator every field created through the ordinary - door passes — has never judged a single field this engine spawned. The difference was exactly - one key: `_clean_field` emits `editRole: 'admins'` and this did not, so `_clean_field(f) != f` - for every automation column in the product. - ⚠ NOTHING CHANGES BEHAVIOURALLY — `may_edit_field` asks `editRole == 'everyone'`, and an - absent key was already not that, so the bypass was fail-closed and therefore silent. It is - stated now, and `verify_automation` asserts the equality field-by-field, so the next key - `_clean_field` grows cannot go unnoticed here ([[default-must-pass-its-own-guard]]). - """ - description = " ".join(str(extra.pop( - "description", IG_FIELD_DESCRIPTIONS.get(text_key, "")) or "").split()) - out = {"key": text_key, "label": label, "type": ftype, "source": "overlay", - "editRole": "admins"} - if description: - out["description"] = description - out.update(extra) - return out - - -def tt_field_def(text_key, label, ftype="text", **extra): - """One `ut_tt_*` column. `field_def` with TikTok's description map bound (wave 29, R2). - - ⛔ `description` IS ALWAYS SUPPLIED, even when blank, so `field_def`'s Instagram default can - never be reached from here. An explicit empty string makes `field_def` omit the key, which is - the same shape an undescribed column already has — the point is that the sentence a TikTok - column carries is one somebody wrote about TikTok, or none at all. - """ - extra.setdefault("description", TT_FIELD_DESCRIPTIONS.get(text_key, "")) - return field_def(text_key, label, ftype, **extra) - - -# --------------------------------------------------------------------------------------------- -# DEFINITIONS — validation and the bucket's read/write half -# --------------------------------------------------------------------------------------------- - -def _s(v, n=200): - return str(v if v is not None else "")[:n] - - -#: ⭐ WAVE 26 · C5 / R2 — HOW MANY POSTS ONE PULL MAY KEEP, and the ceiling is the VENDOR'S. -#: -#: ⛔ MEASURED 2026-08-05 and recorded at `_bd_posts_count`: **a Profiles row carries the TOP 12 -#: posts — a cap, not a count.** Asking for more does not fetch more; it just makes the config -#: disagree with what the run can possibly do. -#: ⚠ THIS REPLACES A SILENT `max(1, min(n, 200))` AT TWO CALL SITES, and the clamp was the defect -#: rather than the number: a person typing 50 got a stored 50, a UI that read back 50, and twelve -#: posts — with nothing anywhere saying why. A control that accepts a value it cannot honour is -#: worse than one that refuses it, because the refusal is the only place the ceiling can be -#: taught. So this REFUSES, and the sentence names the cap and who set it. -#: -#: ⭐⭐ 2026-08-09 — RAISED TO 30 (owner: *"move the max limit to 30 posts per profile"*), and the -#: paragraph above needed correcting to do it honestly: **12 was OUR ROUTE'S cap, not the -#: vendor's.** Re-measured the same day — the PROFILE record still returns 12 however many you ask -#: for (asked 40, got 12), but the documented discover-by-url route answered `num_of_posts: 30` -#: with exactly 30 rows in 90 s (23 Reels + 7 Carousels, @theresalearns). The ceiling was a -#: property of the call we happened to make. -#: ⛔ SO THE NUMBER MOVED AND THE CAPTURE MUST FOLLOW. Until `bd_profile_posts` is wired ahead of -#: the views top-up, a `maxPosts` above 12 is honoured by the CONFIG and bounded by the PROFILE -#: read at run time — the exact accept-what-you-cannot-honour shape this constant exists to -#: prevent, now surviving in one place instead of two. It is recorded rather than hidden, and it -#: is why `clean_post_groups` bounds a group by `maxPosts` rather than by 12. -MAX_POSTS_PER_PULL = 30 -DEFAULT_POSTS_PER_PULL = 10 - -#: ⭐⭐ THE WINDOW THE `avg_*_12` PRESET ROLLUPS AVERAGE OVER — ITS OWN CONSTANT, deliberately -#: NOT `MAX_POSTS_PER_PULL`. -#: -#: ⛔ THEY WERE THE SAME NUMBER AND THAT WAS A LATENT BUG, caught by the gate the moment the cap -#: moved: four columns are NAMED `avg_views_12` and LABELLED "Avg views · last 12 posts", so -#: raising the capture cap to 30 silently made every one of them average thirty posts under a -#: label that says twelve. Nobody would have looked at those columns again to check. -#: ⚠ Raising the CAPTURE ceiling and redefining an EXISTING named measure are two different -#: decisions, and only the first one was made. To widen the average, change this constant AND the -#: four keys and labels together — a column whose meaning changes underneath its own name is -#: worse than one that is merely narrow. -AVG_WINDOW_POSTS = 12 - - -#: The post kinds a group filter may name — exactly what `_bd_type` maps a vendor row onto, so a -#: filter cannot ask for a category that can never match a stored row. -POST_TYPES = ("video", "image", "carousel") -#: ⭐ W29-T09 — the words a PERSON reads for those three keys, server-owned for the same reason -#: `KIND_LABELS` and `TRIGGER_LABELS` are. The owner asked for *"the last 12 reels"*, and `video` -#: is the stored key: a client that translated it locally would be a second copy of this -#: vocabulary, free to drift the day a fourth kind appears or a name changes. -#: ⚠ "Reels & videos", not "Reels": `_bd_type` maps every non-image, non-carousel post here, so a -#: label naming only reels would over-promise on a plain video post. -POST_TYPE_LABELS = {"video": "Reels & videos", "image": "Photos", "carousel": "Carousels"} - - -def clean_post_groups(raw, max_posts): - """`(groups, error)` for `config.postGroups` — "last N reels, last M images". - - ⭐ 2026-08-09 (owner: *"not just last 12 but by group also"*). Shape: - `[{"type": "video", "limit": 12}, {"type": "image", "limit": 6}]`. - - ⛔ ABSENT IS OFF, and off must stay the default forever: an enrich action stored before today - carries no such key, and inventing a filter for it would silently start DROPPING posts those - automations have always captured. Empty list and missing are the same answer. - - ⚠ A MALFORMED GROUP IS REFUSED, not dropped — the opposite of `tier`/`noFallback`, and the - difference is legitimate. Those are RETIRED keys that live in stored data, so refusing them - would 400 old automations forever (D-65). This key is NEW: nothing stored can carry a broken - one, so the only way to see one is a person typing it, and a filter that silently ignores the - type you asked for is how you end up paying for reels and storing carousels. - """ - if raw in (None, "", [], {}): - return [], None - if not isinstance(raw, list): - return None, "the post groups have to be a list of {type, limit} entries" - out, seen = [], set() - for item in raw: - if not isinstance(item, dict): - return None, "each post group has to be a {type, limit} entry" - t = str(item.get("type") or "").strip().lower() - if t not in POST_TYPES: - return None, (f"'{t or 'blank'}' is not a post type — use one of " - f"{', '.join(POST_TYPES)}") - if t in seen: - return None, f"the post groups name '{t}' twice — one limit per type" - seen.add(t) - try: - lim = int(item.get("limit")) - except (TypeError, ValueError): - return None, f"how many {t} posts to keep has to be a whole number" - if lim < 1: - return None, f"a {t} group has to keep at least one post" - # ⛔ BOUNDED BY WHAT THE RUN ACTUALLY BUYS. A group asking for 30 when the pull captures - # 12 is not an error a person can act on — it is a promise the run cannot keep — so it is - # refused HERE, where the number they typed is still on the screen in front of them. - if lim > max_posts: - return None, (f"this enrichment captures {max_posts} posts per profile, so a {t} " - f"group cannot keep {lim} — raise the post count first") - out.append({"type": t, "limit": lim}) - return out, None - - -def clean_max_posts(raw, default=DEFAULT_POSTS_PER_PULL, submitted=True): - """`(maxPosts, error)` — refuses out-of-range rather than clamping into range. - - ⛔ `submitted=False` CLAMPS INSTEAD OF REFUSING, and that asymmetry is the whole reason this - takes a flag. **Every automation stored before wave 26 carries `maxPosts: 24`** — the old - clamp's default, which this cap now forbids. If an inherited value were refused the same way - a typed one is, every one of those automations would 400 on its next Save **forever**, for a - number nobody on that screen chose or can see. That is D-65's lesson exactly: a validator - that refuses stored data does not protect the user from it, it locks them out of their own - automation. - So: a value the panel SENT is the user asking for it, and gets the sentence. A value merely - INHERITED gets silently brought inside the cap it was already effectively subject to at the - vendor — the run was returning 12 either way ([[default-must-pass-its-own-guard]]). - """ - if raw is None or (isinstance(raw, str) and not raw.strip()): - return default, None - try: - n = int(raw) - except (TypeError, ValueError): - if not submitted: - return default, None - return None, "the post limit must be a whole number" - if not submitted: - return max(1, min(n, MAX_POSTS_PER_PULL)), None - if n < 1: - return None, "keep at least one post per profile, or turn the post capture off" - if n > MAX_POSTS_PER_PULL: - return None, (f"a profile pull returns at most {MAX_POSTS_PER_PULL} posts — that is the " - f"vendor's cap, not ours, so {n} would store the same " - f"{MAX_POSTS_PER_PULL} and read as more") - return n, None - - -def clean_config(kind, raw, previous=None): - """Validate a kind's config. Returns `(config, error)`; error is a user-facing sentence. - - ⚠ THE NODE-SWITCH FLAGS FALL BACK TO `previous` WHEN THE KEY IS ABSENT, and that is a rail - rather than a nicety. `patch` replaces the whole config, and the canvas's config panels do not - edit `postMetrics` / `commentMetrics` / `dryRun` — those are node SWITCHES. So a plain "Save" - from a panel that never knew about them would silently turn off the dry run, or turn ON a - per-post purchase: a save that quietly changes what the automation costs. Absent ⇒ keep; - present ⇒ take it, including `false`. - """ - raw = raw if isinstance(raw, dict) else {} - prev = previous if isinstance(previous, dict) else {} - - def flag(name): - return bool(raw[name]) if name in raw else bool(prev.get(name)) - if kind == "plain": - # A plain automation has no machine step, so it only needs the database its records walk. - # - # ⚠ THE TARGET IS OPTIONAL ON PURPOSE. An automation triggered by `record_updated` on - # `ut_foo` already knows its table from the trigger — `_flow_table` resolves target-else- - # trigger — so requiring a second copy of that fact here would be a mandatory field with - # exactly one legal answer, and a Save that refuses until you retype what you just picked. - # There is no `dryRun`: dry-run means "run the machine step but do not write", and there - # is no machine step to run. - target = _s(raw.get("targetTable"), 60).strip() - if target and not target.startswith(UT_PREFIX): - target = UT_PREFIX + target - return {"targetTable": target, - "targetLabel": _s(raw.get("targetLabel"), 60).strip()}, None - if kind == "scrape_db": - url = _s(raw.get("url"), 2000).strip() - if not url: - return None, "a source URL is required" - try: - guard(url) - except Refused as e: - return None, str(e) - extract = raw.get("extract") if raw.get("extract") in EXTRACTS else "table" - fmap = {} - for k, v in list((raw.get("fieldMap") or {}).items())[:60]: - tk = re.sub(r"[^a-z0-9_]+", "_", _s(v, 60).strip().lower()).strip("_") - if tk: - fmap[_s(k, 120)] = tk[:60] - if not fmap: - return None, "map at least one source column to a field" - key_field = _s(raw.get("keyField"), 60).strip() - if key_field not in fmap.values(): - return None, "the key field must be one of the mapped fields" - target = _s(raw.get("targetTable"), 60).strip() - if target and not target.startswith(UT_PREFIX): - target = UT_PREFIX + target - return {"url": url, "extract": extract, - "tableIndex": max(0, min(int(raw.get("tableIndex") or 0), 50)), - "fieldMap": fmap, "keyField": key_field, - "targetTable": target, - "dryRun": flag("dryRun"), - "targetLabel": _s(raw.get("targetLabel"), 60).strip() or "Scraped table"}, None - if kind == "field_instagram": - table = _s(raw.get("targetTable"), 60).strip() - if not table.startswith(UT_PREFIX): - return None, "an Instagram automation runs against a blank database (ut_*)" - fkey = _s(raw.get("fieldKey"), 80).strip() - if not fkey: - return None, "pick the automation column this run writes into" - # ⛔⛔ `tier` AND `noFallback` ARE ACCEPTED AND IGNORED (wave 28 / R5, contract C2). - # They are read from nothing and written to nothing: a stored definition carrying either - # still SAVES — it simply loses them on the next write — and neither is ever a reason to - # refuse. That asymmetry is D-65's law and it is not squeamishness: refusing an unknown - # key would 400 every automation a tenant stored before this wave, forever, on a screen - # that gives them no way to remove it. Dropping a retired key is a migration; refusing it - # is an outage. - # ⚠ `clean_tier`/`TIERS`/`bd_ready` KEEP THEIR NAMES. They are VENDOR vocabulary - # (`brightdata` is a provider, and `verify_automation` fences the name), not the retired - # USER concept — renaming them would be a second, unrelated change wearing this one's - # justification. - # C5: absent ⇒ keep whatever is stored, like every other switch in this branch — a panel - # that does not edit the post count must not reset it to the default on Save. And an - # INHERITED value is clamped rather than refused; see `clean_max_posts`. - sent = "maxPosts" in raw - max_posts, perr = clean_max_posts( - raw.get("maxPosts") if sent else prev.get("maxPosts"), submitted=sent) - if perr: - return None, perr - return {"targetTable": table, "fieldKey": fkey, - "urlField": _s(raw.get("urlField"), 80).strip(), - # ⛔ THE ONE FLAG THAT MULTIPLIES THE BILL BY THE POST COUNT. Off unless asked - # for: the Profiles row carries post IDENTITY for free but NO engagement - # (measured 2026-08-05), so likes/comments cost one extra vendor record PER POST. - "postMetrics": flag("postMetrics"), - # The full Comments dataset can bill many rows per post. It is an explicit, - # independent opt-in and never follows the per-post switch automatically. - "commentMetrics": flag("commentMetrics"), - "dryRun": flag("dryRun"), - "maxPosts": max_posts}, None - # ⭐ WAVE 29 (D-9 / R1) — BOTH discovery kinds share this branch, because they ask the vendor - # the same question of two different corpora: a record ceiling, a predicate list, a join word - # and where to write. ⛔ THE ONLY DIFFERENCE IS THE DEFAULT TABLE, and it is resolved from the - # kind rather than hard-coded — a TikTok search falling back to `ut_ig_profile` would write - # TikTok rows into the Instagram family, which is precisely what R2's parallel family exists - # to prevent, and it would do it silently. - if kind in DISCOVERY_KINDS: - # ⭐ WAVE 30 · T05 — the inline tuple became the named one, and the two defaults below now - # come from `discovery_facts` rather than being spelled out here. They were correct; they - # were also the FIFTH copy of "which table does this kind write to", and the other four - # were the ones wave 29 forgot to update. - _, _disc_table, _disc_label, _ = discovery_facts(kind) - limit = int(raw.get("recordsLimit") or 0) - if limit < 1: - return None, ("say how many profiles to fetch — an UNBOUNDED discovery query is the " - "one shape the vendor refuses outright (NOT_ENOUGH_FUNDS)") - if limit > BD_MAX_RECORDS: - return None, f"a single discovery run may ask for at most {BD_MAX_RECORDS} profiles" - # ⚠ THE JOIN IS RESOLVED BEFORE THE PREDICATES ARE JUDGED, because it is part of what - # makes them broad or narrow (see `narrowing_refusal`). Validating them first and reading - # the operator afterwards is how the OR hole survived: the guard was handed the branches - # and never told they were a union. - join = "or" if str(raw.get("operator") or "").lower() == "or" else "and" - # ⭐ WAVE 32 · T46 (D-167) — AND THE KIND GOES WITH THEM. `clean_config` already knows which - # corpus this automation searches; passing it is what stops a `discover_tiktok` being built - # on the 16 Instagram fields TikTok's dataset does not carry — a search that does not error, - # returns nothing, and reads as "no such creators exist" after the money is spent. - preds, perr = clean_predicates(raw.get("predicates"), join, kind) - if perr: - return None, perr - target = _s(raw.get("targetTable"), 60).strip() or _disc_table - if not target.startswith(UT_PREFIX): - target = UT_PREFIX + target - seed, serr = clean_seed(raw.get("seed") if "seed" in raw else prev.get("seed")) - if serr: - return None, serr - return {"recordsLimit": limit, "predicates": preds, - "operator": join, - "targetTable": target, - "targetLabel": _s(raw.get("targetLabel"), 60).strip() or _disc_label, - # C6/R5: WHERE the conditions above came from, when they were derived. Stored so - # the surface can say "these were filled in from the 'Florists' view, over 12 - # records" instead of presenting them as if somebody typed them. - "seed": seed, - "dryRun": flag("dryRun")}, None - return None, f"unknown automation kind {kind!r}" - - -def clean_seed(raw): - """C6: validate `config.seed`. Returns `({}, None)` when there is none — a discovery filter - somebody typed by hand has no seed, and that is the ordinary case. - - ⚠ `derived` AND `basis` ARE STORED AS PROVENANCE, NOT AS A SECOND FILTER. R5 is explicit that - the derived conditions are "written into `config.predicates` as ordinary conditions — it is a - filling-in, not a parallel filter", so the RUN never reads this bag: it reads `predicates`, - like every other search. Keeping it means the surface can say where those rows came from, and - a user editing them freely is exactly what is supposed to happen. - """ - if raw in (None, "", {}): - return {}, None - if not isinstance(raw, dict): - return None, "the seed must be an object" - source = _s(raw.get("source"), 12).strip().lower() - if source not in SEED_SOURCES: - return None, f"{source or 'that seed'!r} is not one of: " + ", ".join(SEED_SOURCES) - table = _s(raw.get("table"), 60).strip() - if table and not table.startswith(UT_PREFIX): - return None, f"a seed reads a blank database (ut_*) — {table!r} is not one" - basis = raw.get("basis") if isinstance(raw.get("basis"), dict) else {} - derived, _err = clean_predicates(raw.get("derived") or [], "and") - return {"source": source, "table": table, "id": _s(raw.get("id"), 80).strip(), - # ⛔ A MALFORMED `derived` IS DROPPED, NEVER A REFUSAL. This bag is a RECORD of what - # was suggested; the conditions that matter are already in `predicates` and were - # validated there. Refusing a Save because a stored provenance note aged badly would - # block the user from editing the very filter it describes. - "derived": derived or [], - "basis": {"rows": max(0, int(basis.get("rows") or 0)), - "fields": [f for f in (basis.get("fields") or []) - if isinstance(f, dict)][:SEED_MAX_PREDICATES], - "related": basis.get("related") - if isinstance(basis.get("related"), dict) else {}, - "note": _s(basis.get("note"), 200)}}, None - - -def clean_schedule(raw, previous=None): - """Validate `{cron, enabled}` and stamp `enabledAt` on the OFF→ON edge (the anchor `is_due` - measures from — without it, enabling a daily job fires it immediately).""" - raw = raw if isinstance(raw, dict) else {} - cron = _s(raw.get("cron"), 120).strip() or "0 6 * * *" - parse_cron(cron) # raises -> the route answers 400 - enabled = bool(raw.get("enabled")) - prev = previous or {} - out = {"cron": cron, "enabled": enabled} - if enabled: - out["enabledAt"] = (prev.get("enabledAt") if prev.get("enabled") else None) or _iso() - return out - - -# ── WAVE 23 · C5 — the ENDING, and the cycle counter that makes a loop countable. ───────────── -# The owner's words: "we can make this automation a loop, so the ending should always be defined, -# either it ends somewhere deterministic like Closed/Failed, or it goes to reset automatically, -# or the user have to click a button to reset, or after a certain amount of time it can -# automatically reset to first cycle." -# -# ⛔ `terminal` IS THE DEFAULT and every existing automation gets it, because a stored definition -# that predates this field must not start moving records on its own the day the code ships. A -# loop is a thing somebody turns on. -def clean_flow(raw, previous=None, notes=None): - """Validate the builder's ordered action list. `(flow, error)`.""" - raw = raw if isinstance(raw, dict) else {} - prev = previous if isinstance(previous, dict) else {} - actions, err = clean_actions( - raw.get("actions") if "actions" in raw else prev.get("actions"), notes=notes) - if err: - return None, err - return {"actions": actions}, None - - -#: AMENDMENT A1 — what the `ig_profile_match` kind-flip seeds as `recordsLimit` when the -#: definition carries none. -#: -#: ⚠ 25 BECAUSE THE INPUT SAYS 25 (2026-08-06). It was 10 — the size wave 21 proved live at -#: $0.15 — while `AutomationDetail` initialises its own box to 25, and the two never met: the -#: flow node read "Up to 10 profiles · about $0.025" beside a field reading 25, on a freshly -#: created automation. Two numbers for one fact, in the panel, before anybody had typed -#: anything. **Read off the screenshot; every assertion in the battery was green.** -#: -#: This is the same species as the default-versus-guard split fixed the same day: a value -#: decided in one file and a value decided in another, describing the same thing. Aligning the -#: constants closes the only window in which they can disagree — the seed — because every later -#: state comes from the stored config. -DISCOVER_SEED_RECORDS = 25 - - -def discovery_seed_spec(kind): - """-> (default table, enrich action kind) for a discovery KIND. - - ⭐⭐ WAVE 30, OWNER REPORT 2026-08-12, verbatim: *"'When a Tiktok profile fits a criteria' - should ALWAYS have a 'Create record' EXACTLY like the Instagram one … THE ONLY DIFFERENCE IS - THE COLUMNS AND DATA SCHEMA"*. They were right, and the seeding path was the one family of - sites this wave widened everywhere ELSE: `clean_definition` planted step 1 and step 2 only when - the trigger was `ig_profile_match`, so a TikTok search stored `flow.actions = []` and had - nowhere to put what it found — MEASURED live against `auto_1` (Instagram: create_record + - enrich) versus a fresh TikTok discovery (empty). - - ⭐ THIS IS A LOOKUP, NOT A SECOND SEEDER, and that is the whole point of the ruling. One - builder plants both platforms' steps; the only things that vary are the table the record lands - in and which enrich action reads it — literally "the columns and data schema". A forked - `_ensure_tt_action` would start identical and drift, which is the failure `ENRICH_KINDS` and - `DISCOVERY_KINDS` were introduced to prevent one screen up. - - ⚠ Resolved in a FUNCTION rather than a module-level dict because `DISCOVER_TABLE` is defined - far below this line; a dict here would raise at import. - """ - if kind == "discover_tiktok": - return TT_PROFILE_TABLE, "enrich_tiktok" - return DISCOVER_TABLE, "enrich_instagram" - - -def _ensure_ig_action(flow_raw, table, enrich_kind="enrich_instagram"): - """Create record is PERMANENT step 1 on a DISCOVERY trigger — it cannot be deleted or moved. - - ⚠ THE NAME IS INSTAGRAM'S AND THE BEHAVIOUR IS BOTH PLATFORMS' (wave 30). Renaming it would - churn six gate references for no behaviour change; `enrich_kind` is what makes it general, and - it defaults to Instagram's so every pre-existing caller keeps its exact meaning. - - ⭐ OWNER RULING 2026-08-06, AND IT REVERSES WAVE 24's LAW 3. That law seeded this action once, - on the edge into the trigger, and ended "deleting it is their call, not a refusal" — with a - comment warning that re-seeding on every clean would be "a control that will not take no for - an answer". The owner's answer: *"should ALWAYS have a 'Create record' Step 1, that can't be - deleted, because the nature of that automation is that it needs to first create a record in a - database somewhere from the list of profiles to fetch."* - - Which is right, and the earlier reasoning had the category wrong: a flow whose TRIGGER - produces rows has nowhere to put them until something writes them, so an Instagram search - with no Create record is not a customised automation — it is a search whose results are - discarded. That is not a preference to respect. - - ⚠ IT KEEPS THE USER'S EDITS. Presence and POSITION are guaranteed; the table it writes to and - the values it maps are theirs. An existing `create_record` further down is MOVED to the front - rather than duplicated — re-seeding a second one would quietly double every run's writes. - """ - flow = dict(flow_raw or {}) if isinstance(flow_raw, dict) else {} - actions = [a for a in (flow.get("actions") or []) if isinstance(a, dict)] - if actions and actions[0].get("kind") == "create_record": - return _pin_unique(_ensure_enrich_step( - flow_raw if isinstance(flow_raw, dict) else flow, enrich_kind)) - at = next((i for i, a in enumerate(actions) if a.get("kind") == "create_record"), -1) - if at > 0: - actions.insert(0, actions.pop(at)) - else: - # The seed must be a config `clean_actions` ACCEPTS, or the builder shows a red banner - # and no card: the wave-23 header records four kinds that shipped with illegal seeds and - # did exactly that. `create_record` needs a ut_-prefixed table and at least one value. - actions.insert(0, { - "id": "act_1", "kind": "create_record", "enabled": True, "when": None, - # `config.label` follows the `review` action's precedent in `_clean_action_config` — - # an action naming itself, inside the untyped config bag, so no shared TS type changes. - "config": {"table": str(table or DISCOVER_TABLE), - "label": "Save the profile", - # C5: the picture and the real write finally agree — see `_pin_unique`. - "uniqueOn": "handle", - "values": {"handle": "{{handle}}"}}, - }) - flow["actions"] = actions - return _pin_unique(_ensure_enrich_step(flow, enrich_kind)) - - -def _ensure_enrich_step(flow_raw, enrich_kind="enrich_instagram"): - """⭐ 2026-08-07 (owner ruling) — ENRICH IS STEP 2 ON A DISCOVERY SEARCH. - - ⚠ WAVE 30: `enrich_kind` selects the network. Instagram's is the default so every pre-existing - caller means exactly what it meant before; TikTok passes `enrich_tiktok` and gets the identical - step shape, which is the owner's *"only the columns and data schema differ"*. - - Owner: *"make it default that this enrichment action is Step 2 always, and under Config of - Step 2 … we can have a toggle on or off."* Which is the same shape as step 1's ruling and for - the same reason: a search that finds profiles and never reads them has done half a job. The - difference is the control — step 1 is permanent because a flow without it discards its - results, while this one is permanent because the TOGGLE is how you turn it off. Deleting and - disabling would be two ways to say one thing, and only one of them survives a re-save. - - ⚠ SEEDED ON, AND ON THE FREE RUNG. `tier: "anonymous"` costs nothing, so a search that gains - this step by upgrading does not quietly start spending; switching the Source to the paid - provider is an explicit choice a person makes in front of the sentence that names the cost. - ⚠ THE COOLDOWN IS SEEDED ON TOO (30 days). On a table nobody has enriched it changes nothing — - a blank `enriched_at` is never "recent" — and the moment there IS history it stops the flow - re-buying the same profile nightly. Off-by-default would make the expensive behaviour the - accident. - - ⛔ THE EXISTENCE TEST WALKS THE FORKS (`walk_actions`). A person who moved enrichment inside an - If/then branch has one; seeding a second at the top level would enrich twice and bill twice, - which is the duplication `apply_actions` already paid for once with the pinned step 1. - """ - flow = dict(flow_raw or {}) if isinstance(flow_raw, dict) else {} - actions = [a for a in (flow.get("actions") or []) if isinstance(a, dict)] - if any(a.get("kind") == enrich_kind for a in walk_actions(actions)): - return flow_raw if isinstance(flow_raw, dict) else flow - seeded = list(actions) - # Index 1 — after the pinned Create record, because there is nothing to enrich until the - # profiles have been written as records. `insert` past the end is a plain append, so a flow - # with only step 1 lands this at the end, which IS step 2. - seeded.insert(1, { - "id": "act_enrich", "kind": enrich_kind, "enabled": True, "when": None, - "config": {"postMetrics": False, "commentMetrics": False, - "dryRun": False, "maxPosts": DEFAULT_POSTS_PER_PULL, - "fromView": "", "sortField": DEFAULT_ENRICH_SORT, "sortDir": "desc", - "limit": DEFAULT_ENRICH_LIMIT, "skipRecent": True, - "skipRecentDays": DEFAULT_ENRICH_COOLDOWN_DAYS}, - }) - return {**flow, "actions": seeded} - - -#: The key the discovery writer really upserts on. Both discovery runners key candidates on -#: `candidate_key(platform, handle)`; `handle` is the half a person can see and the half this action's -#: values carry, which is why the PINNED card shows that rather than the pair — `platform` is -#: supplied by the runner, never typed by a person. -#: ⛔ DEBT D-73 (closed wave 29): this note used to name wave 22's C6 compound — the one that -#: paired the handle with its finder — as the live key. Wave 26 · R4/R5 retired it: the identity is -#: the pair above and the TENANT is the unit, while `created_by` survives as an informational -#: "Found by" stamp that no run may branch on. -#: ⚠ The retired pair is DESCRIBED here and not reproduced, deliberately: a gate asserts this note -#: names the key `_ck` actually takes, and a verbatim quotation of the wrong one reads to that gate -#: exactly like the defect. A stale comment on correct code is how the next session reintroduces a -#: bug with a rationale attached. -IG_PINNED_UNIQUE = "handle" - - -def _pin_unique(flow): - """C5: keep `uniqueOn: "handle"` on the PINNED step 1 across every save. - - ⭐ WHY IT IS RE-STAMPED RATHER THAN MERELY SEEDED. `_clean_action_config` has no `previous` — - the builder posts the whole action list on every save — so a client that omitted the key would - silently reset it to `""`, i.e. back to append. The pinned card is a PICTURE of the engine's - own upsert (`apply_actions` skips it at runtime, see the note there), so the picture claiming - "append" while the engine upserts is exactly the surface-disagrees-with-the-engine defect this - module refuses everywhere else. - - ⛔ AND IT CANNOT MINT A CONFIG THE GUARD REFUSES — HARD RULE 11, which is the whole reason - this is a function and not one line. `_clean_action_config` refuses a `uniqueOn` the action - does not write, so the stamp is applied ONLY when `handle` is among the action's own values. - A user who remaps the card to write different columns keeps their edit and still saves; the - alternative — stamping unconditionally — is a product-seeded default that its own validator - would 400, which is precisely the post-W24 hotfix this rule exists because of. - - ⚠ NON-MUTATING, and that is load-bearing rather than tidiness. `clean_definition` passes - `prev.get("flow")` here when a PATCH carries no flow of its own — that is the STORED - definition's own dict, so writing into it would edit the live store object in memory, before - (and regardless of) any commit. Every touched level is copied instead. - """ - if not isinstance(flow, dict): - return flow - acts = flow.get("actions") - if not isinstance(acts, list) or not acts or not isinstance(acts[0], dict): - return flow - first = acts[0] - if first.get("kind") != "create_record": - return flow - cfg = first.get("config") - if not isinstance(cfg, dict) or IG_PINNED_UNIQUE not in (cfg.get("values") or {}): - return flow - if str(cfg.get("uniqueOn") or "").strip(): - return flow - return {**flow, - "actions": [{**first, "config": {**cfg, "uniqueOn": IG_PINNED_UNIQUE}}] + acts[1:]} - - -#: The two steps `_ensure_ig_action` / `_ensure_enrich_step` plant, as `(kind, id)`. Named here so -#: the seeder and the un-seeder cannot disagree about what "seeded" means. -#: ⚠ WAVE 30 — `enrich_tiktok` shares `act_enrich`. The map is keyed by KIND, and only one enrich -#: step is ever seeded per flow (the network follows the trigger), so the id cannot collide. -_IG_SEED_IDS = {"create_record": "act_1", "enrich_instagram": "act_enrich", - "enrich_tiktok": "act_enrich"} - - -def _is_untouched_ig_seed(action, table, enrich_kind="enrich_instagram"): - """Is this action still EXACTLY what the Instagram trigger planted? (wave 27 item 15) - - ⛔ THE COMPARISON IS AGAINST A FRESHLY BUILT SEED, not against a list of remembered keys, and - that is the only version that stays true: `_ensure_ig_action` and `_ensure_enrich_step` build - the same dicts one screen above, so a step gaining a config key next wave gains it here too. - A remembered key list would quietly start calling every seeded action "edited". - - ⛔ AND THE FRESH SEED GOES THROUGH `clean_actions` FIRST, which cost a red check to learn. The - stored action has been cleaned — `_clean_action_config` normalises it and adds the keys the - kind declares (`profileField: ""` on an enrich step, for one) — so comparing against the RAW - dict `_ensure_enrich_step` writes reports every seeded enrich step as "edited by a person", - and the un-seeding silently never fires for it. Comparing cleaned against cleaned is the only - version where the two sides are the same kind of object. - - ⚠ `enabled` IS DELIBERATELY NOT PART OF THE TEST for the enrich step. Its ruling says the - TOGGLE is how you turn it off — so a person who switched it off has expressed an opinion about - a step they still want, and an off seed is still a seed. - """ - if not isinstance(action, dict): - return False - kind = str(action.get("kind") or "") - if _IG_SEED_IDS.get(kind) != str(action.get("id") or ""): - return False - seeded, _seed_err = clean_actions( - (_ensure_ig_action({"actions": []}, table, enrich_kind).get("actions") or [])) - fresh = {a.get("kind"): a for a in (seeded or [])} - seed = fresh.get(kind) - if not seed: - return False - cfg, seed_cfg = dict(action.get("config") or {}), dict(seed.get("config") or {}) - if kind in ENRICH_KINDS: - cfg.pop("enabled", None) - seed_cfg.pop("enabled", None) - return cfg == seed_cfg and (action.get("when") or None) is None - - -def _drop_ig_seeds(flow_raw, table, enrich_kind="enrich_instagram"): - """Strip the trigger's own seeded steps, keeping any the person has since made theirs. - - ⭐⭐ WAVE 27 ITEM 15 (owner) — *"stale pinned create_record on trigger change"*. THE COMMENT - THAT USED TO SIT AT THE KIND FLIP ARGUED THE OPPOSITE and is rewritten there; it read: *"it - does not delete the seeded actions … a create_record the person has since re-pointed at their - own table with their own values is THEIR action now"*. That reasoning is still correct and is - exactly what this function preserves — but it was applied to EVERY seeded action, including - the ones nobody had ever opened, and the result is what the owner reported: switch an - Instagram search to Manual and you are left holding a "Save the profile" step writing - `{{handle}}` into `ut_ig_candidates` on a flow that no longer produces handles. That is not - somebody's work being protected; it is the machine's own leftover, pointed at a table the - automation has nothing to do with any more. - - ⛔ SO THE TEST IS "DID ANYBODY TOUCH IT", NOT "WAS IT SEEDED" — the same guard shape as - `_vestigial_name_field` and `_retired_tracked_field`, and for the same reason: this is a - branch that destroys something, so the conservative half is the load-bearing half. - """ - flow = dict(flow_raw or {}) if isinstance(flow_raw, dict) else {} - actions = [a for a in (flow.get("actions") or []) if isinstance(a, dict)] - kept = [a for a in actions if not _is_untouched_ig_seed(a, table, enrich_kind)] - if len(kept) == len(actions): - return flow_raw - return {**flow, "actions": kept} - - -def ig_action_pinned(defn, index): - """Is this action the one that cannot be removed? Derived, never stored — a stored `pinned` - flag is a second copy of the rule, and the copy is what a hand-written PATCH omits.""" - # ⭐ WAVE 30 — BOTH discovery kinds. It read `== "discover_instagram"`, so TikTok's step 1 (once - # seeded) would have rendered as an ordinary draggable, deletable card: the same rule the owner - # asked for "EXACTLY", applied to only one network. - return (defn or {}).get("kind") in DISCOVERY_KINDS and index == 0 - - -def clean_definition(raw, previous=None, username="", notes=None): - """Whole-definition validation. Returns `(defn, error)`.""" - raw = raw if isinstance(raw, dict) else {} - prev = previous or {} - # ⭐ WAVE 24 · C-TRIG — THE TRIGGER IS RESOLVED FIRST NOW, because law 1 makes it the thing - # that CHOOSES the kind. Reading `raw["trigger"]["key"]` here instead would be a second - # reader of the trigger vocabulary, free to disagree with `clean_trigger` about which keys - # exist and which are refused. Gate-visible consequence, recorded in AMENDMENT A1: a payload - # invalid in BOTH its trigger and its config now answers with the trigger's sentence. - trigger, terr = clean_trigger(raw.get("trigger") if "trigger" in raw - else prev.get("trigger"), prev.get("trigger")) - if terr: - return None, terr - # C-TRIG law 2 (wave 24): a definition that names no kind is a `plain` one. Before R6 this - # refused with "unknown automation kind ''" — correct while a wizard always sent one, and a - # dead end the moment the wizard was deleted and the client's create body became {name}. - kind = _s(raw.get("kind") or prev.get("kind"), 40) or DEFAULT_KIND - drop_ig_seeds = False - if (trigger or {}).get("key") == "ig_profile_match": - # LAW 1: the trigger IS how `discover_instagram` gets chosen now. Unconditional rather - # than "when the kind is unset" — a definition whose trigger says Instagram-discovery and - # whose kind says something else is not a preference to respect, it is two halves of one - # answer disagreeing, and the trigger is the half the person actually picked. - kind = "discover_instagram" - elif (trigger or {}).get("key") == "tiktok_profile_match": - # ⭐ WAVE 29 (D-9 / R1) — LAW 1, TIKTOK'S HALF. Same rule, same reason: the trigger is how - # `discover_tiktok` gets chosen, and it is unconditional for the same reason the Instagram - # arm is — a definition whose trigger says TikTok discovery and whose kind says something - # else is two halves of one answer disagreeing. - kind = "discover_tiktok" - elif (kind == "discover_tiktok" - and ((prev.get("trigger") or {}) or {}).get("key") == "tiktok_profile_match" - and (trigger or {}).get("key") != "tiktok_profile_match"): - # ⛔⛔ LAW 1'S INVERSE, AND ITS ABSENCE ON THE INSTAGRAM SIDE WAS A MONEY BUG (see the arm - # below): with nothing to flip the kind BACK, switching the trigger to Manual left - # `RUNNERS[kind]` pointing at the discovery runner, so **Run now fired a PAID corpus - # search on a flow the person had just made manual.** Shipped here in the same change as - # law 1 rather than discovered the same way twice. - # ⚠ THE TEST IS THAT THE TRIGGER **MOVED** — comparing the RESOLVED key against the - # PREVIOUS one. Asking `"trigger" in raw` would fire on every empty patch, because - # `patch()` builds its raw as `dict(prev)` plus the caller's keys. - kind = DEFAULT_KIND - # ⭐ WAVE 30 — un-seed on the way out, exactly as the Instagram arm below does. This line - # was ABSENT and harmless for as long as TikTok had no seeds to leave behind; the moment - # the seeder above learned TikTok, its absence became wave-27 item 15's defect on the other - # network — a flow switched to Manual still carrying a "Save the profile" step nobody - # planted deliberately. Found by widening the seeder and asking what else assumed only - # Instagram could have seeds. - drop_ig_seeds = True - elif (kind == "discover_instagram" - and ((prev.get("trigger") or {}) or {}).get("key") == "ig_profile_match" - and (trigger or {}).get("key") != "ig_profile_match"): - # ⭐⭐ 2026-08-07 (owner report) — LAW 1 HAS AN INVERSE, AND ITS ABSENCE WAS A MONEY BUG. - # - # Law 1 above flips the kind TO `discover_instagram` when the trigger says Instagram - # discovery. Nothing flipped it BACK. So `kind` was inherited from `prev` forever: switch - # the trigger to Manual and the automation stayed `discover_instagram`, which means - # - # · `RUNNERS[kind]` is still `run_discover_instagram`, so pressing **Run now** on a flow - # the person had just made MANUAL fired a PAID Bright Data corpus search; - # · `ig_action_pinned` keys on the kind, so the seeded "Create record" stayed - # UNDELETABLE — the owner's report, verbatim: *"When a trigger change from the - # instagram trigger, the 'always first' create record just stuck there"*. The server - # would have accepted the delete; the CLIENT hid the control, because both halves ask - # the kind and the kind was lying. - # - # ⛔ THE TEST IS THAT THE TRIGGER **MOVED**, and the first version of this got it wrong in - # a way worth recording. It asked `"trigger" in raw` — but `patch()` builds its raw as - # `merged = dict(prev)` plus the caller's keys, so **`"trigger"` is present on EVERY - # patch**, and an empty `patch(rt, id, {})` flipped a discovery automation to `plain`. - # That turned five `section_bd` checks red and crashed the suite on a `StopIteration` - # three sections later. Comparing the RESOLVED key against the PREVIOUS one is the honest - # question: did this automation stop being an Instagram search? - # - # ⚠ NARROW ON PURPOSE, twice over. It fires only when the automation WAS on the Instagram - # trigger — a `discover_instagram` created with an explicit kind and some other trigger is - # somebody's deliberate state, not a mistake to correct. And only FROM - # `discover_instagram`: `scrape_db` and `field_instagram` are surviving kinds whose - # triggers are their own business, and clobbering them would retire them by accident. - # - # ⭐⭐ WAVE 27 ITEM 15 — IT NOW DROPS THE SEEDS NOBODY TOUCHED, and the paragraph this - # replaces argued the other way, so here is why it was half right. It read: *"it does not - # delete the seeded actions … a create_record the person has since re-pointed at their own - # table with their own values is THEIR action now, and quietly destroying it is the silent - # data loss this module refuses everywhere else."* Every word of that is still true and is - # exactly what `_is_untouched_ig_seed` protects. What it got wrong was applying the - # protection to actions NOBODY HAD EVER OPENED: the owner's report is a flow switched to - # Manual still carrying a "Save the profile" step writing `{{handle}}` into - # `ut_ig_candidates` — the machine's own leftover on a flow that no longer produces - # handles, unpinned but still there, still runnable, and still pointed at a table that has - # nothing to do with this automation. - # ⚠ APPLIED BELOW, at the flow, not here: `flow_raw` is not resolved yet at this line. - kind = DEFAULT_KIND - drop_ig_seeds = True - if kind not in KINDS: - return None, f"unknown automation kind {kind!r}" - # ⛔ DEBT D-65 — THE REFUSAL THAT BELONGS HERE IS NOT SHIPPED, AND THAT IS A DECISION. - # D-65 offers two exits for `field_instagram`: convert a surviving definition to a `plain` - # flow carrying `enrich_instagram`, or REFUSE it here with a sentence naming the replacement. - # The refusal was built and then REVERTED, because W24/R6 deliberately made `patch()` the way - # a retired kind legally comes into existence — `create` refuses, `patch` accepts, and the two - # live automations save through this function every time their owner edits them. The gate's - # own fixture helper says so in the strongest terms available: *"If R6's refusal ever moved - # from `create` into `clean_definition` (the tempting simplification), this helper would go - # red across a dozen sections, which is the alarm that change deserves."* It did, and the - # alarm worked. - # ⚠ WHAT SHIPPED INSTEAD is the half that costs nothing and was the actual complaint: every - # door that DOES refuse now names the replacement (`RETIRED_KIND_REPLACEMENT`), so nobody - # meets "unknown automation kind" for a decision made on purpose. The rest of D-65 is an - # owner-visible behaviour change — either new `field_instagram` mints stop working, or stored - # ones are rewritten under their owner — and that is a ruling, not a refactor. - name = " ".join(_s(raw.get("name") or prev.get("name") or "", MAX_NAME).split()) - if not name: - return None, "name the automation" - cfg_raw = raw.get("config") if isinstance(raw.get("config"), dict) else prev.get("config") - if kind in DISCOVERY_KINDS and prev.get("kind") != kind: - # ⭐⭐ WAVE 30 · T04 — WIDENED FROM `discover_instagram` TO BOTH DISCOVERY KINDS, AND THIS - # ONE LINE WAS THE WHOLE OF THE OWNER'S ITEM 3. Picking "When a TikTok profile fits a - # criteria" 400'd on the very first Save with *"say how many profiles to fetch — an - # UNBOUNDED discovery query is the one shape the vendor refuses outright - # (NOT_ENOUGH_FUNDS)"*, and the trigger was therefore never STORED, which is what the - # owner saw as "the Trigger won't load". - # ⛔ INSTAGRAM WAS NEVER SURVIVING ON ITS OWN MERITS: `AutomationDetail.buildConfig` sends - # no `recordsLimit` for EITHER platform on a fresh automation (its `kind` is still `plain` - # at that moment, so it falls through to `return { targetTable }`). IG worked only because - # this seed caught it. So the defect was never "TikTok is missing something Instagram has" - # — it was one hard-coded string in the single line that rescues both. - # ⚠ `prev.get("kind") != kind` is the EXACT generalisation of the old - # `prev.get("kind") != "discover_instagram"`, not a loosening: it still fires only on a - # kind FLIP, so a later save that carries a real limit is not re-seeded (asserted). - # AMENDMENT A1: the kind FLIP seeds the one field `clean_config` insists on, or the very - # first Save after picking this trigger 400s — against the stored-inert-with- - # `configured:false` pattern the whole picker is built on (see `clean_trigger`'s A3 note). - # ⛔ THE REFUSAL ITSELF IS UNTOUCHED: an explicit 0 still gets its sentence. That guard - # exists because an unbounded query is the one shape the vendor refuses outright, and - # coercing a blank into a number would be exactly the silent widening it protects against. - # Seeding cannot spend — `clean_schedule` defaults `enabled` False, so nothing runs until - # a person presses Run now or arms the schedule. - cfg_raw = dict(cfg_raw or {}) - if not _ig_int(cfg_raw.get("recordsLimit")): - cfg_raw["recordsLimit"] = DISCOVER_SEED_RECORDS - config, err = clean_config(kind, cfg_raw, prev.get("config")) - if err: - return None, err - if trigger: - # A2: re-ask completeness now that the config is validated — `ig_profile_match`'s own - # configuration IS the config, and `clean_trigger` could not see it. - trigger["configured"] = _trigger_configured(trigger, config) - try: - schedule = clean_schedule( - raw.get("schedule") if isinstance(raw.get("schedule"), dict) - else prev.get("schedule"), prev.get("schedule")) - except ValueError as e: - return None, str(e) - # (`clean_trigger` ran at the top — law 1 needs the trigger before the kind.) - flow_raw = raw.get("flow") if "flow" in raw else prev.get("flow") - # ⭐ EVERY CLEAN, NOT ONLY THE EDGE (owner ruling 6 — see `_ensure_ig_action`). The edge-only - # call was what made the action deletable; running it on every save is what makes step 1 - # permanent, and it is deliberate rather than a widened condition nobody noticed. - # ⭐⭐ WAVE 30 (owner, 2026-08-12) — BOTH DISCOVERY TRIGGERS SEED, not just Instagram's. This - # single `==` was the whole of *"why is it not copied EXACTLY"*: a TikTok search stored an - # EMPTY flow, so the product's own rule — a search that finds profiles must have somewhere to - # put them — held for one network and not the other. - if (trigger or {}).get("key") in DISCOVERY_TRIGGER_KIND: - _seed_table, _seed_enrich = discovery_seed_spec(kind) - flow_raw = _ensure_ig_action( - flow_raw, config.get("targetTable") or _seed_table, _seed_enrich) - elif drop_ig_seeds: - # ITEM 15: the trigger just STOPPED being a discovery one. Un-seed what the trigger - # planted and nobody has since made theirs — measured against the table the seeds were - # planted for, which is the PREVIOUS config's, not the one being saved. - # ⚠ And against the PREVIOUS kind's enrich action, for the same reason: the seeds to strip - # are TikTok's if that is what was planted, and asking "is this Instagram's seed?" of a - # TikTok flow answers no and silently leaves the leftover behind. - _prev_table, _prev_enrich = discovery_seed_spec(prev.get("kind")) - flow_raw = _drop_ig_seeds( - flow_raw, (prev.get("config") or {}).get("targetTable") or _prev_table, _prev_enrich) - flow, ferr = clean_flow(flow_raw, prev.get("flow"), notes=notes) - if ferr: - return None, ferr - # ⭐ WAVE 30 — widened with the seeder above: TikTok now HAS a pinned step 1, so the rule that - # its `config.table` is authoritative has to reach it, or the two fields that name one database - # would disagree on exactly the network that just gained the card. - if (trigger or {}).get("key") in DISCOVERY_TRIGGER_KIND: - # ⭐ WAVE 25 · R2 — THE CREATE RECORD ACTION'S `config.table` IS AUTHORITATIVE, and - # `config.targetTable` FOLLOWS IT. Two fields have been naming the same database since - # wave 24: the pinned step 1 says where profiles are written, and the config says where - # the automation's records live — and for a discovery flow those are the same database by - # construction. When they disagreed, every surface picked a different winner (the canvas - # Write node read the config, the actual write read the action), which is a bug you can - # only see by comparing two panels. - # - # ⛔ THE ACTION WINS BECAUSE IT IS THE ONE A PERSON EDITS. R2b puts the preset list on the - # action's own configuration, so the table named there is the one they were looking at. - # - # ⚠ THIS IS ALSO THE MIGRATION, and it is a migration in the shape laws 4/5 established: - # a stored definition carrying two different values is reconciled on its next clean, - # silently and exactly once, because nothing writes the losing value back. A refusal here - # would 400 every Save of a definition that was legal yesterday. - # ⭐⭐ WAVE 27 ITEM 11 — WHICHEVER SIDE MOVED WINS, and R2's "the action wins" is now the - # TIE-BREAK rather than the whole rule. The owner's report: change the database on the - # TRIGGER, save, and the picker springs back to the old one with no message. The cause is - # the branch below read unconditionally — the pinned action still named yesterday's table, - # so it overwrote a change the person had just made in front of it, silently, every time. - # - # ⛔ R2 IS NOT REVERSED, and the distinction is which QUESTION the code asks. R2 settled - # "when the two disagree, whose value is real?" — the action's, because R2b puts the - # preset list on the action's own panel, so that is the one they were looking at. That - # answers a definition at REST. A save is different: it carries an INTENT, and the honest - # question is which side this particular save changed. So: - # · the trigger-side picker moved -> it wins, and the pinned action is re-pointed to - # follow it (leaving the action behind would just re-revert on the next save); - # · only the action moved, or neither did and they were already inconsistent -> R2's - # migration, unchanged, reconciling a stored definition exactly once. - # · BOTH moved in one payload -> the action still wins. That is R2 literally, and it is - # also the only reading that cannot lose a value: the action's table is the one with - # the field mapping attached to it. - first = (flow.get("actions") or [{}])[0] - pinned = str((first.get("config") or {}).get("table") or "") \ - if first.get("kind") == "create_record" else "" - prev_target = str((prev.get("config") or {}).get("targetTable") or "") - prev_pinned = str(((((prev.get("flow") or {}).get("actions") or [{}])[0] - ).get("config") or {}).get("table") or "") - target_moved = bool(prev_target) and config.get("targetTable") != prev_target - action_moved = bool(prev_pinned) and pinned != prev_pinned - if pinned and target_moved and not action_moved: - # The person changed the trigger's database. Carry it INTO the pinned step so the two - # agree, instead of throwing their edit away to keep a stale copy consistent. - cfg1 = dict(first.get("config") or {}) - cfg1["table"] = str(config.get("targetTable") or "") - flow["actions"] = [{**first, "config": cfg1}, *(flow.get("actions") or [])[1:]] - elif pinned and pinned != config.get("targetTable"): - config["targetTable"] = pinned - # The stored label described a DIFFERENT database, so keeping it would caption the new - # one with the old one's name. Blank falls back to the key everywhere, and `ut_ensure` - # never relabels a table that already exists — so an adopted hand-made database keeps - # the name its owner gave it. - config["targetLabel"] = "" - return { - "id": _s(prev.get("id") or raw.get("id"), 40), - "name": name, "kind": kind, "config": config, "schedule": schedule, - "trigger": trigger, - # The Builder's ordered actions. An absent flow is simply an automation with no actions. - "flow": flow, - "status": prev.get("status") or {"state": "idle", "lastRunAt": "", "lastSummary": ""}, - "runs": list(prev.get("runs") or [])[:MAX_RUNS], - # ⚠ ENGINE-OWNED CONTINUATION STATE, and it is NOT config. A discovery snapshot takes ~20 - # minutes to build, so a run persists its id here and the NEXT run collects it. It is - # carried through `patch` untouched because a user pressing Save must not silently orphan - # a set the vendor is already building (and already counting against the funds gate). - "state": dict(prev.get("state") or {}), - "created": prev.get("created") or _iso(), - "createdBy": prev.get("createdBy") or username, - }, None - - -def set_state(rt, auto_id, patch_state): - """Merge into ONE automation's engine state. A key set to None is removed. - - Its own tiny store write rather than a field on `_commit_run`, because the handoff must - survive a run that ends in `error` — a snapshot the vendor is building does not stop existing - because the run that started it failed afterwards. - """ - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - d = cur.get(str(auto_id)) - if d is None: - return cur - st = dict(d.get("state") or {}) - for k, v in (patch_state or {}).items(): - st.pop(k, None) if v is None else st.__setitem__(k, v) - d["state"] = st - return cur - - # ⚠ W31-T35 — THE ONE `keeps_defs=True` IN THIS MODULE. `set_state` writes `state` and never a - # trigger, and `grid_hook` calls it once per CREATED RECORD; clearing the definitions memo here - # would re-read the whole bucket one row later than before and undo D-134 entirely. Safe - # because `grid_hook` reads exactly one state key (`rcHighwater`) and mirrors its own write - # into the memo in the same statement. Read `_store_update`'s note before adding a second. - _store_update(rt, _up, flush="sync", keeps_defs=True) - - -def _without_retired_board(definition): - """Return a definition with retired Board-only state removed, without mutating the store. - - This protects scheduled/manual execution before the next UI list request persists the - migration. It removes only the former Board configuration, actions, and audit trail; normal - actions and all user data remain intact. - """ - if not isinstance(definition, dict): - return definition, False - out, changed = dict(definition), False - cfg = out.get("config") - if isinstance(cfg, dict) and "lanes" in cfg: - cfg = dict(cfg) - cfg.pop("lanes", None) - out["config"] = cfg - changed = True - - def _actions(actions): - nonlocal changed - clean = [] - for action in actions if isinstance(actions, list) else []: - if not isinstance(action, dict): - clean.append(action) - continue - if action.get("kind") == "review": - changed = True - continue - item = dict(action) - if item.get("kind") == "group" and isinstance(item.get("config"), dict): - group_cfg = dict(item["config"]) - branches = group_cfg.get("branches") - if isinstance(branches, list): - kept = [] - for branch in branches: - if not isinstance(branch, dict): - changed = True - continue - next_actions = _actions(branch.get("actions")) - if not next_actions: - changed = True - continue - kept.append({**branch, "actions": next_actions}) - if not kept: - changed = True - continue - if kept != branches: - changed = True - group_cfg["branches"] = kept - item["config"] = group_cfg - elif isinstance(group_cfg.get("actions"), list): - nested = _actions(group_cfg["actions"]) - if not nested: - changed = True - continue - if nested != group_cfg["actions"]: - group_cfg["actions"] = nested - item["config"] = group_cfg - clean.append(item) - return clean - - flow = out.get("flow") - if isinstance(flow, dict): - next_flow = dict(flow) - actions = _actions(flow.get("actions")) - if actions != flow.get("actions"): - next_flow["actions"] = actions - if "ending" in next_flow: - next_flow.pop("ending", None) - changed = True - out["flow"] = next_flow - if "reviews" in out: - out.pop("reviews", None) - changed = True - return out, changed - - -def retire_automation_board_state(rt, tables=None): - """Persist the idempotent Board retirement, then remove its generated table fields. - - ``tables`` is the optional lent snapshot of the `user_tables` bucket (W29-T01) — it reaches - the stage scan and nothing else. - """ - fields = retire_automation_stage_fields(rt, tables=tables) - try: - stored = dict(rt.get(STORE_KEY) or {}) - except Exception: - return {"definitions": 0, **fields} - plan = {str(aid): cleaned for aid, raw in stored.items() - for cleaned, changed in [_without_retired_board(raw)] if changed} - if not plan: - return {"definitions": 0, **fields} - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - for aid, cleaned in plan.items(): - if aid in cur: - cur[aid] = cleaned - return cur - - _store_update(rt, _up, flush="sync") - return {"definitions": len(plan), **fields} - - -#: ⭐⭐ WAVE 31 · T35 (D-134) — the definitions memo, and the two things that make it safe. -#: `tenant -> (monotonic_at, defs)`. Read ONLY through `all_definitions(..., cached=True)`, which -#: exactly one caller uses (`grid_hook`). -_DEFS_MEMO = {} -#: Deliberately SHORT. This exists to collapse one burst of row events into one read, not to be a -#: cache — an import of 20,000 rows arrives in far less than this, and a stale trigger for two -#: seconds is bounded and recoverable where a stale one for a minute is a mystery. -_DEFS_TTL = 2.0 - - -def _store_update(rt, fn, flush="sync", keeps_defs=False): - """THE one door every write to the automations bucket goes through — and the ONLY reason it - exists is that the memo's invalidation must be DERIVED rather than remembered. - - ⚠ `keeps_defs=True` IS AN EXPLICIT, SINGLE-SITE OPT-OUT, and it is here because the safe - default would otherwise make the memo useless on the exact path it was built for. `set_state` - writes `state` and NEVER a trigger, and it is called by `grid_hook` itself once per created - record — so clearing on it would mean a 20,000-row import re-read the bucket 20,000 times - anyway, one row later than before. The opt-out is safe because `grid_hook` reads exactly one - state key (`rcHighwater`) and mirrors its own write into the memo in the same statement. - ⛔ The DEFAULT is the safe one, so a thirteenth writer added next wave gets invalidation - without knowing this exists; only a caller that has read this paragraph can opt out. - - ⛔ THE ALTERNATIVE WAS TWELVE CALL SITES. `rt.update(STORE_KEY, …)` appeared twelve times in - this module; hanging an invalidation on each would be a hand-maintained list, and the way that - fails is silent: a thirteenth writer added next wave leaves `grid_hook` firing triggers off a - definition set that no longer exists, with nothing red. Same defect class as `patch`'s - hand-maintained patchable-key list, which this module already documents as *"the silent-drop - seat"* [[a-constant-two-features-share]]. - - ⚠ IT CLEARS THE WHOLE MEMO, not this tenant's row, on purpose: identifying the tenant here - would mean trusting `getattr(rt, 'key')` at a WRITE, and a wrong answer there is a stale - trigger set for another tenant. The memo holds at most a handful of entries and the correct, - boring thing costs nothing. - """ - if not keeps_defs: - _DEFS_MEMO.clear() - return rt.update(STORE_KEY, fn, flush=flush) - - -def all_definitions(rt, cached=False): - """Every automation definition for this tenant. - - ⭐⭐ `cached=True` IS D-134's FIX, AND IT IS OPT-IN FOR A REASON. `grid_hook` runs ONCE PER ROW - EVENT and called this unconditionally, so a 20,000-row import performed 20,000 whole-document - deep copies of the automations bucket — `Store.get` re-serialises on every call, hit or miss, - under the store lock. Every other caller is a request handler that reads it once, so widening - the memo to all of them would trade a real guarantee (a request sees the current store) for - nothing measurable. - - ⛔ THE MEMO IS THE SAME OBJECT ON A HIT, and `grid_hook` MUTATES it deliberately — see the - `rcHighwater` write there. That is not a leak of an implementation detail, it is the fix to the - hazard a naive memo creates: `grid_hook` both READS `state.rcHighwater` and WRITES it through - `set_state`, so a memo that went stale against its own write would re-fire `record_created` - for records it had already handled, breaking A2(4)'s *"once per record EVER — undo-proof"*. - Writes through `_store_update` drop the memo; the one write `grid_hook` makes to its OWN copy - is mirrored into it in the same statement. - """ - if cached: - key = str(getattr(rt, "key", "") or "") - hit = _DEFS_MEMO.get(key) - if hit is not None and (time.monotonic() - hit[0]) < _DEFS_TTL: - return hit[1] - try: - raw = dict(rt.get(STORE_KEY) or {}) - except Exception: - return {} - out = {str(aid): _without_retired_board(defn)[0] for aid, defn in raw.items()} - if cached: - _DEFS_MEMO[str(getattr(rt, "key", "") or "")] = (time.monotonic(), out) - return out - - -def _new_id(existing): - n = 1 - while f"auto_{n}" in existing: - n += 1 - return f"auto_{n}" - - -#: C2's three answers to "which database does this automation work on?" — the FIRST question the -#: create wizard asks (owner item 3: the kind is not the first thing a person picks, the data is). -TARGET_MODES = ("existing", "new", "automated") - - -def resolve_target(rt, raw, username=""): - """C2: turn the wizard's `target` into a bound table key. Returns `(config_patch, error)`. - - - `existing` — bind a database that is already there. - - `new` — mint a blank one now, so the automation has somewhere to write before its first - run instead of conjuring a table the person never agreed to. - - `automated` — leave it to the runner: a scraping automation MINTS its target on first run - (`ut_ensure`), stamped `source: "Automation"`, which is what makes it a machine database. - Nothing is created here, deliberately — an empty table created up front for a scrape that - never runs is litter nobody can explain. - """ - if not isinstance(raw, dict) or not raw: - return {}, None # no target block = the pre-wizard shape - mode = _s(raw.get("mode"), 20).strip() or "existing" - if mode not in TARGET_MODES: - return None, f"{mode!r} is not one of: " + ", ".join(TARGET_MODES) - if mode == "automated": - label = " ".join(_s(raw.get("label"), 60).split()) - return ({"targetLabel": label} if label else {}), None - if mode == "existing": - key = _s(raw.get("table"), 60).strip() - if not key: - return None, "choose the database this automation works on" - t = ut_get(rt, key) - if t is None: - return None, f"{key!r} is not a database in this workspace" - return {"targetTable": key, "targetLabel": t.get("label") or key}, None - label = " ".join(_s(raw.get("label"), 60).split()) - if not label: - return None, "name the new database" - import core.user_tables as _ut_new - key = _ut_new.create(label, username or "automation", st=rt) - if not key: - return None, ("the database could not be created — this workspace may be at its table " - "limit") - return {"targetTable": key, "targetLabel": label}, None - - -def _unmint(rt, key): - """Delete a database this call minted moments ago, because the call is refusing (D-50). - - ⚠ FAIL-QUIET BY DESIGN. The caller is already returning a refusal with a sentence the user - needs to read; a rollback that raised would replace that sentence with a 500 and lose the - reason the create failed in the first place. The worst case of a swallowed failure here is - the orphan we had before — strictly no worse, and the refusal still reaches the person. - """ - if not key: - return - try: - import core.user_tables as _ut_del - _ut_del.delete(key, st=rt) - except Exception: # noqa: BLE001 - pass - - -def create(rt, raw, username="", notes=None): - existing = all_definitions(rt) - if len(existing) >= MAX_AUTOMATIONS: - return None, f"this workspace is at the {MAX_AUTOMATIONS}-automation limit" - raw = dict(raw or {}) - # ⭐ WAVE 24 — DEBT D-50. `resolve_target` MINTS a database for `mode:"new"`, and validation - # happens after it, so a correct refusal ("a source URL is required") used to leave an - # orphaned `ut_*` table behind with no automation pointing at it. MEASURED at the W23 close: - # three refused creates, two orphaned databases, deleted by hand. A person mis-filling a form - # twice silently accumulated empty databases in their nav. - # - # ⛔ THE ROLLBACK IS DERIVED, NOT DECLARED, and that is the point of doing it this way. It - # does not test for `mode == "new"`; it asks "did the table this call's target resolved to - # exist BEFORE this call?" So it closes the hole for any future target mode that mints, and - # a fifth mode cannot reopen it by forgetting a branch. Scoped to THIS call's own target - # rather than "any table that appeared", so a concurrent create in another request is never - # collateral. - before = set(ut_all(rt) or {}) - patch_cfg, terr = resolve_target(rt, raw.pop("target", None), username) - if terr: - return None, terr - minted = str((patch_cfg or {}).get("targetTable") or "") - minted = minted if minted and minted not in before else "" - if patch_cfg: - raw["config"] = {**(raw.get("config") or {}), **patch_cfg} - # 2026-08-10 — point a targetless discovery flow at the profile database this tenant ALREADY - # has, instead of letting the pure validator mint a second empty one. See - # `discover_default_table`; a target the caller named is never rewritten. - raw = _apply_discover_default(rt, raw) - defn, err = clean_definition(raw, None, username, notes=notes) - if err: - _unmint(rt, minted) - return None, err - # ⭐ WAVE 24 · OWNER RULING R6 — "scrape_db and field_instagram survive on the automations - # that already use them and NO NEW ONE CAN BE CREATED." - # - # ⛔ HERE, IN `create`, AND NEVER IN `clean_definition`. A PATCH of one of the two live - # automations runs through `clean_definition` with `prev["kind"]` already set, so refusing - # the kind there would make both of them unsaveable — the ruling retires the door, not the - # automations behind it. Enforced on the server rather than left to the deleted wizard: a - # wall that holds only because the client stopped asking is a convention, not a wall. - if defn["kind"] in RETIRED_KINDS: - _unmint(rt, minted) - return None, (f"{KIND_LABELS.get(defn['kind'], defn['kind'])!r} automations are no " - f"longer created — the ones already using it keep working. " - f"{RETIRED_KIND_REPLACEMENT.get(defn['kind'], '')}") - defn["id"] = _new_id(existing) - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - cur[defn["id"]] = defn - return cur - - # `async` for the reason spelled out at `remove` and applied at `patch` — a create is the same - # blocking commit inside the same request, and the person is waiting on it in the same way. - # ⚠ NO PRESET GUARD HERE, deliberately: `patch` may skip the spawn because it can compare - # against a PREVIOUS definition, and a create has none — R10's columns must exist before a run, - # so this call stays unconditional. - _store_update(rt, _up, flush="async") - _presets_after_write(rt, defn, username) # R10: the columns exist before a run - if defn.get("trigger"): - _seed_event_state(rt, defn) - return defn, None - - -def patch(rt, auto_id, raw, username="", notes=None): - existing = all_definitions(rt) - prev = existing.get(str(auto_id)) - if prev is None: - return None, "no such automation" - merged = dict(prev) - # ⚠ A HAND-MAINTAINED PATCHABLE-KEY LIST, and it is the silent-drop seat of this module: a - # key missing here is accepted by the route, validated by `clean_definition`, and then - # DISCARDED — the client shows a saved flow that the store never received, and nothing goes - # red. `flow` joined it in the same edit that created `flow` (wave 23), which is the only - # ordering that never has a window where the bug exists. - for k in ("name", "config", "schedule", "kind", "trigger", "flow"): - if k in (raw or {}): - merged[k] = raw[k] - # 2026-08-10 — the PATCH door needs it too, and this is the one that actually bit: picking the - # Instagram trigger on an existing automation flips the kind, and the very next Save runs a - # config that has never carried a target through the pure validator, which mints the empty - # `ut_ig_candidates`. `create` alone would have left the commonest path untouched. - merged = _apply_discover_default(rt, merged) - defn, err = clean_definition(merged, prev, username, notes=notes) - if err: - return None, err - defn["id"] = str(auto_id) - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - cur[defn["id"]] = defn - return cur - - # ⛔ WHY THIS IS `async` — owner report 2026-08-12 (wave 30), MEASURED on the deployed build. - # Owner, verbatim: *"debug Automation module, its extremely slow, even renaming an Automation - # takes forever"*. A rename paid a BLOCKING HF commit inside the request, and `Store.update`'s - # sync branch holds `self._lock` across a strict DOWNLOAD **and** the upload — the same lock - # `Store.get` takes — so one rename stalls every OTHER reader of that tenant's store for its - # whole duration. That is the "module is slow" half a per-route fix never reaches. - # The reasoning, the read-your-writes contract and the `user_tables.add_row` precedent are - # written out in full at `remove` above (same wave, same owner report); this is that ruling - # applied to the save path rather than a second argument for it. - _store_update(rt, _up, flush="async") - # ⛔ THE PRESET SPAWN RUNS ONLY WHEN ITS OWN INPUTS MOVED — the other half of the same report. - # MEASURED as `nurilab-admin` on `4258a93`: **7,912 ms** for one rename against a **288 ms** - # warm `GET /automations` on the same connection, i.e. 27x the read this wave just made fast - # (T12/T13). A rename changes `name`; `_presets_after_write` reads `config`, `flow` and - # `trigger` and NOTHING else, then pays a full `user_tables` read (`ut_get`/`ut_ensure`; - # `rt.get` deep-copies by design, documented ceiling 35.8 MB / ~1.4 s) to re-derive columns - # that cannot have changed. ⭐ The generalisable half: T12/T13 took `ut_all` off the automation - # READ path this wave and every sibling WRITE path still carried it — a performance fix scoped - # to "the slow page" leaves the same call in every route beside it - # [[read-a-gates-predicate-for-what-it-excludes]]. - # ⛔ THE DANGEROUS DIRECTION IS SKIPPING, NEVER RUNNING, so the predicate is the INPUT SET of - # the function being skipped — not a hand-listed set of "cheap" edits, which is how a guard - # silently un-ships W25/R10 the next time the spawn learns to read a fourth key. When none of - # them moved the call is a no-op by construction (it re-derives from those same keys), and the - # run-time `ut_ensure` R10 deliberately kept is still the backstop if the TABLE drifted - # underneath — which is not a thing a rename should be repairing. Shaped after the trigger - # comparison directly below, which already compares `prev` against the cleaned definition. - # ⭐⭐ WAVE 31 · T30 — AND THE PREDICATE IS NOW THE SPAWN'S OWN ARGUMENT, which is the fix. - # The four-whole-objects test above was right in principle and inert in practice: `persist` - # sends a REBUILT `config`, so `prev["config"] != defn["config"]` on essentially every save and - # the guard fired every time — sparing only *rename*, the one edit the owner stopped - # complaining about after wave 30. `preset_inputs` is the canonical projection of exactly what - # `_spawn_presets` reads, and `_spawn_presets` is handed nothing else, so this comparison - # cannot go stale behind a future input the way a hand-listed key set would. The full argument, - # including why this is STRONGER than what it replaces rather than a relaxation, is in - # `preset_inputs`' own docstring — read that before widening or narrowing this line. - if preset_inputs(prev) != preset_inputs(defn): - _presets_after_write(rt, defn, username) # R10: also on the save that RE-TARGETS - # A2(1): a NEW or CHANGED condition trigger starts with everything currently matching - # DISARMED — enabling never fires for records already in the state. - if (defn.get("trigger") or {}) != ((prev.get("trigger")) or {}): - _seed_event_state(rt, defn) - return defn, None - - -def _presets_after_write(rt, defn, username): - """⭐ WAVE 25 · R10 — THE PRESET COLUMNS APPEAR ON SAVE, not on the first run. - - Owner ruling R10: choosing the trigger / naming the Create record target spawns the preset - columns immediately, "so R2b's list is real and the database is visible before anything is - spent". That last clause is the point — a person can look at the database, see the sixteen - columns, and decide whether to arm the schedule, instead of paying a vendor to find out what - they are agreeing to. - - ⛔ THE RUN-TIME `ut_ensure` STAYS AS THE BACKSTOP (R10 says so explicitly), and it costs - nothing to keep: `ut_ensure` skips the write entirely when no field would change, so the first - run of a saved automation finds the table already correct and spends no commit. - - ⚠ IT SPAWNS `CANDIDATE_FIELDS`, NOT `PRESET_PROFILE_FIELDS`. The discovery target needs the - search bookkeeping (`found_count`, `first_found`, `created_by`) as well as the - preset set, and spawning a subset here would mean the columns changed between Save and the - first run — two different answers to "what does this database look like", which is the exact - disagreement R10 exists to remove. - - ⚠ A WORKSPACE AT ITS TABLE CEILING SPAWNS NOTHING AND THE SAVE STILL SUCCEEDS. `ut_ensure` - returns the key without creating when `MAX_UT_TABLES` is reached; refusing to save an - automation because the workspace is full would be a worse answer than saving one whose table - arrives later. The run-time path reports that condition LOUDLY (`ut_missing`, D-11), so the - honest failure still has exactly one home. - """ - _spawn_presets(rt, preset_inputs(defn), username) - - -#: The enrich actions whose OWN config decides which preset columns and child databases a save -#: spawns, and the exact keys read off each. DECLARED here rather than spelled inside -#: `preset_inputs`, so `_spawn_presets` and the save-path guard cannot drift apart by one key. -PRESET_ACTION_INPUTS = { - "enrich_instagram": ("profileField",), - "enrich_tiktok": ("profileField", "postMetrics", "commentMetrics"), -} - - -def preset_inputs(defn): - """⭐⭐ WAVE 31 · T30 — EXACTLY the values `_spawn_presets` consumes off a definition, canonical. - - ⛔ THIS IS THE WHOLE POINT OF THE TICKET, so it is worth being precise about what changed and - why it is SAFER than what it replaces, not weaker. Owner item 7, verbatim: *"It still takes - forever to change the Config for automation as well. It just say Saving... and takes a long time - for me to change options and configs etc."* - - Wave 30 guarded the spawn on `any(prev[k] != defn[k] for k in ("config","flow","trigger", - "kind"))` and reasoned — correctly — that *"the predicate is the INPUT SET of the function being - skipped, not a hand-listed set of cheap edits"*. The flaw was not the reasoning; it was that the - input set was stated as four WHOLE OBJECTS while the spawn reads a handful of leaves out of - them. `AutomationDetail.tsx::persist` sends `config: buildConfig()` — a value RECONSTRUCTED from - React state, not the stored object — so the comparison is against something rebuilt on every - save and the guard never fires on the one edit the owner is complaining about. W30's fix spared - *rename* (the client omits `flow`/`trigger` entirely, and `kind`/`name` are unchanged), which is - exactly the edit the owner stopped complaining about after wave 30. - - ⭐ WHY NARROWING IS SAFE HERE AND WAS NOT SAFE THEN: this projection is not a hand-listed - allow-list that a future wave can silently outgrow. `_presets_after_write` hands this dict to - `_spawn_presets` and `_spawn_presets` **never receives `defn` at all** — so it is structurally - incapable of reading a fifth key that this function does not carry. Teaching the spawn to read - something new REQUIRES adding it here, and the guard then follows for free. That is a stronger - guarantee than wave 30's, which held only as long as somebody remembered the comment. - - ⚠ CANONICAL, because the input is a rebuild: each leaf is normalised exactly the way the spawn - itself normalises it (`str(...).strip()` for a field name, `bool(...)` for a switch). Without - that, `postMetrics: false` and a missing `postMetrics` would compare unequal while the spawn - treats them identically — a guard that fires on a difference its consumer cannot see is the - same defect in a smaller font. - - ⚠ IT DELIBERATELY COVERS THE DISCOVERY BRANCH **AND** THE ENRICH BRANCH IN ONE PROJECTION, even - though today's control flow returns before the second can run for a discovery automation. That - early return is D-178 (W31-T34) and it is going away; a projection built around today's branch - would silently narrow the guard the moment it does. - """ - defn = defn or {} - cfg = defn.get("config") or {} - actions = ((defn.get("flow") or {}).get("actions") or []) - return { - "target": str(_flow_table(defn) or cfg.get("targetTable") or ""), - "label": str(cfg.get("targetLabel") or ""), - "flowId": str(defn.get("id") or ""), - "discoveryKind": DISCOVERY_TRIGGER_KIND.get( - (defn.get("trigger") or {}).get("key") or ""), - "actions": { - kind: [{k: (_norm_switch(a, k) if k != "profileField" - else str((a.get("config") or {}).get(k) or "").strip()) - for k in keys} - for a in _actions_of_kind(actions, kind)] - for kind, keys in PRESET_ACTION_INPUTS.items() - }, - } - - -def _norm_switch(action, key): - """A capture switch as the spawn reads it — `bool`, so absent and False are ONE value.""" - return bool((action.get("config") or {}).get(key)) - - -def _spawn_presets(rt, inputs, username): - """The spawn itself. Takes `preset_inputs(defn)` — never the definition — see that docstring. - - ⭐⭐ WAVE 31 · T30, THE SECOND HALF: **ONE `user_tables` read for the whole pass, not five.** - Every `ut_ensure` below used to take its own copy of the tenant document through - `ut_get` → `ut_all` → `rt.get`, which deep-copies unconditionally (ceiling 35.8 MB / ~1.4 s), and - the TikTok arm reaches FOUR of them plus the `ut_get` above — measured **7,912 ms** on - `4258a93`. Lending one snapshot is the same fix W30-T13 made on the automation READ path - (`routes_automation.py::_LentTables`); this is deliberately NOT a third copy of that class, but - the `tables=` parameter `retire_automation_stage_fields` in this very module already - established, because here every consumer is a function we own and pass to directly. - """ - target = inputs["target"] - if not target: - return - tag = inputs["flowId"] - ig_actions = inputs["actions"]["enrich_instagram"] - tt_actions = inputs["actions"]["enrich_tiktok"] - try: - # ⭐ THE ONE READ. Everything below answers out of this snapshot. - tables = ut_all(rt) - # ⭐⭐ WAVE 30 · T06 — BOTH DISCOVERY KINDS SPAWN ON SAVE, and TikTok's absence here was - # R10 simply not holding for the second platform: `TT_PROFILE_FIELDS` reached `ut_ensure` - # at exactly ONE site — inside `run_discover_tiktok` — so the database did not exist until - # money had already been spent finding out what was in it. That is the precise pre-R10 - # behaviour `verify_automation`'s NC39 forbids on the Instagram side. - # ⛔⛔ THE DISCRIMINATOR IS THE **TRIGGER**, NOT THE KIND, AND THE GATE PAID FOR THAT - # SENTENCE. Widening this to `kind in DISCOVERY_KINDS` looks equivalent — law 1 makes a - # discovery trigger choose its kind — but the implication only runs ONE WAY. A definition - # may carry `kind: "discover_instagram"` with **no trigger at all**: law 1 sets the kind - # from the trigger and never the reverse, so any direct API create can do it, and one of - # this repo's own fixtures does. MEASURED: the kind test spawned a preset database under a - # dry-run fixture whose check asserts that no table exists — a red on correct-looking code, - # caught only because that check happened to exist. - # ⇒ R10 is a rule about what somebody PICKED IN THE PICKER, so it reads the picker's own - # answer. `DISCOVERY_TRIGGER_KIND` is that map, and a gate asserts it agrees with law 1. - # ⚠ TRACKED because it is the ONE key several `ut_ensure` calls in this pass can share: the - # discovery arm and both enrich arms all ensure `target`. A later one must not answer out of - # a snapshot an earlier one invalidated — see the `tables=` arguments below. - wrote_target = False - _dkind = inputs["discoveryKind"] - if _dkind: - _, _, _spawn_label, _spawn_fields = discovery_facts(_dkind) - ut_ensure(rt, inputs["label"] or _spawn_label, _spawn_fields, username, - key=target, flow_tag=tag, lock_fields=True, tables=tables) - # ⭐⭐ WAVE 31 · T34 (D-178) — AND THE `return` THAT USED TO BE HERE IS GONE. - # - # ⛔ WHAT IT COST: a DISCOVERY automation could never spawn its child databases. The - # capture switches below are the ones that promise `ut_tt_posts` / `ut_tt_comments` / - # `ut_tt_post_snapshots` at SAVE, before any money is spent (W30-T10) — and only a - # `plain` flow ever reached them, because a discovery flow left this function three - # lines earlier. So the exact automation the owner would build for TikTok discovery — - # find profiles, then capture their posts — silently got the profile table and nothing - # else, and the toggles it showed were promises the save path never kept. - # ⚠ IT IS A TRAP, NOT TODAY'S ONLY SYMPTOM: nurilab's child tables are absent for a - # CONFIGURATION reason as well, so fixing this alone will not make them appear there. - # - # ⚠ THE SNAPSHOT IS NOW STALE FOR `target`, and the arms below resolve their profile - # binding out of that very table (`bound` reads its `fields`). One re-read, paid only on - # a save that actually reached the discovery spawn — correctness before the read count, - # and it is one read against the 41 this function used to take. `wrote_target` stays - # False precisely BECAUSE we refreshed: it tracks staleness, not "did somebody write". - tables = ut_all(rt) - enrich_actions = ig_actions - table = tables.get(target) or {} - named = next((a["profileField"] for a in enrich_actions if a["profileField"]), "") - bound = next((str(f.get("key") or "") for f in table.get("fields") or [] - if isinstance(f.get("profile"), dict)), "") or named - if enrich_actions and bound: - ut_ensure(rt, table.get("label") or target, _profile_schema_for(bound), username, - key=target, flow_tag=tag, lock_fields=True, tables=tables) - wrote_target = True - # ⭐ WAVE 30 · T08 — the TikTok half of W25/R10: the columns an `enrich_tiktok` step will - # fill appear when the automation is SAVED, not when money is first spent. Resolved - # through `profile_field_key(..., source=PROFILE_SOURCE_TT)` so it cannot adopt an - # Instagram binding, and through the SAME `_tt_profile_schema_for` the runner's own top-up - # uses — R10's rule is that the column set does not change between Save and the first run, - # and one shared resolver is what makes that structural instead of a convention. - if tt_actions: - tt_named = next((a["profileField"] for a in tt_actions if a["profileField"]), "") - tt_bound = profile_field_key(table, tt_named, source=PROFILE_SOURCE_TT) - if tt_bound: - ut_ensure(rt, table.get("label") or target, _tt_profile_schema_for(tt_bound), - username, key=target, flow_tag=tag, lock_fields=True, - tables=None if wrote_target else tables) - # ⭐ WAVE 30 · T10 — and the CHILD databases the step's own switches promise, at - # SAVE, before any money. R10's rule is "the columns a step will fill appear when - # you press Save"; a `postMetrics` toggle whose database only exists after a paid - # run is the same complaint one level up. - # ⛔ GATED ON THE SWITCHES, never spawned unconditionally: a database that exists - # because somebody looked at a toggle, and then never fills, is the "SECOND, empty - # database" the discovery default was rewritten to stop producing. - for _flag, _child in (("postMetrics", TT_POSTS_TABLE), - ("postMetrics", TT_POST_SNAPSHOTS_TABLE), - ("commentMetrics", TT_COMMENTS_TABLE)): - if any(a[_flag] for a in tt_actions): - # R9 (W31-T32): the child arrives LOCKED, from its own declaration. - ut_ensure(rt, TT_TABLE_LABELS[_child], TT_TABLE_FIELDS[_child], username, - key=_child, flow_tag=tag, lock_fields=True, tables=tables, - record_mode=tt_record_mode(_child)) - except Exception as e: # noqa: BLE001 - # ⛔ NEVER FAILS THE SAVE. The definition is already committed by the time this runs, so - # raising here would answer 500 for an automation that IS stored — the caller would retry - # and create a second one. The columns then arrive on the first run, which is precisely - # the behaviour this function is an improvement on rather than a replacement for. - print(f"[aios-auto] preset spawn deferred to the first run: {type(e).__name__}: {e}") - - -def remove(rt, auto_id): - aid = str(auto_id) - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - cur.pop(aid, None) - return cur - # ⛔ WHY THIS IS `async` AND NOT `sync` (owner report 2026-08-12, wave 30, MEASURED). - # Owner, verbatim: *"it takes forever to delete an automation"*. A delete was paying up to - # TWO BLOCKING UPLOADS inside the request — this one, plus the whole-document rewrite below, - # which fires for every DISCOVERY automation because those always tag the columns they - # spawned (deleting one marked all 30 columns of `ut_tt_profile` disabled). Measured 977 ms - # for the CHEAPEST case (a `plain` flow, no tagged columns) against a 757 ms `GET /automations` - # baseline on the same connection; the discovery case is strictly worse by a full-document - # read plus a full-document commit. - # - # `flush="async"` is not a weakening: `Store.update`'s async branch applies the mutation to - # the in-process cache, marks it owned+dirty and schedules a COALESCING worker, and the - # class's read-your-writes contract means the very next `GET /automations` already sees the - # deletion. This is the same trade `user_tables.add_row` makes for ROW creation — strictly - # more valuable data than an automation definition — so a delete is not the place to be - # stricter than a row insert. [[sync-write-eats-pending-async-write]] is the hazard in the - # OTHER direction (a sync writer discarding a pending async write) and `Store.update`'s dirty - # branch already handles it. - _store_update(rt, _up, flush="async") - # C8 — the fields this flow tagged are DISABLED with the reason on them, never orphaned - # silently: the column keeps its values and its config, and says why it stopped. Scanned - # first so a delete with no tagged fields costs no store commit. - # ⚠ HONEST RESIDUAL, stated rather than smoothed: this scan still costs ONE full read of the - # tenant's `user_tables` document on EVERY delete (`rt.get` deep-copies by design), and the - # update below reads it a second time. Removing that needs a tagged-field index, which is a - # feature and not a wave-tail edit — booked rather than improvised. What it no longer costs - # is the part the owner could feel: the blocking network commits. - # ⭐ WAVE 31 · T39(a) — D-177(a): ONE WALK, AND THE PLAN CARRIES WHAT IT FOUND. - # This used to scan the whole document to answer a boolean (`hit`), then walk the WHOLE - # document again inside `_disable` to re-derive the same matches. The scan now records the - # `(table, field)` pairs it found and the mutation applies exactly those — the shape - # `bind_unbound_fields` in this module already uses, so a delete costs one walk instead of two - # and the write touches only the fields it named. - # ⭐⭐ WAVE 33 (D-179) — THE SCAN NO LONGER READS THE ROWS, and that is where the cost was. - # - # This walk asks ONE question — "which fields did this flow tag?" — and it has never looked at - # a row to answer it. It was nevertheless taking `ut_all`, a whole deep copy of the tenant's - # `user_tables` document, of which **99.9% of the bytes are `rows` nothing here reads** - # (measured on tenant #0: 28.6 MB, 81,330 rows, 703 ms warm). A's C5 projection makes that - # ~0.1% of the bytes. - # ⛔ HANDED TO A READ AND NOTHING ELSE. `lend_defs` serves `_Projected` values, so reaching - # `defn['rows']` raises a `KeyError` NAMING the projection — by design — and `plan` carries - # only `(table_key, field_key)` STRINGS out of this scope. The mutation below takes its own - # strict document from `rt.update` and never sees a projected value, which is C5's second - # clause: a projected snapshot is never handed to a post-write read-back. - # ⚠ HONEST RESIDUAL, STATED RATHER THAN SMOOTHED (R6's second sentence): D-179's exit condition - # asks for ZERO full-document reads and this is ONE — `rt.update` below takes a strict read - # that belongs to the WRITE and cannot be removed from here. What is gone is the expensive one. - # ⚠ AND THE FALLBACK IS DELIBERATE: `all_defs` degrades to the whole read on any failure, so a - # store that cannot project still deletes correctly, only slower. - try: - import core.user_tables as _ut_defs # noqa: PLC0415 - _scan = dict(_ut_defs.all_defs(rt) or {}) - except Exception: # noqa: BLE001 - _scan = ut_all(rt) - plan = [(tk, str(f.get("key") or "")) - for tk, t in _scan.items() - for f in ((t or {}).get("fields") or []) - if str((f.get("automation") or {}).get("flowId") or "") == aid] - if not plan: - return - - def _disable(cur): - cur = cur if isinstance(cur, dict) else {} - note = f"its automation was deleted {_stamp()}" - for tk, fk in plan: - for f in ((cur.get(tk) or {}).get("fields") or []): - a = f.get("automation") - if f.get("key") == fk and isinstance(a, dict): - a["disabled"] = True - a["statusNote"] = note - return cur - # The expensive half of the owner's complaint: a whole-document rewrite, committed while the - # person waits. Async for the reason given above — the disable is visible to the next read - # immediately; only the upload is deferred, and it coalesces with any other pending write to - # the same key instead of racing it. - rt.update(UT_STORE_KEY, _disable, flush="async") - - -# --------------------------------------------------------------------------------------------- -# RUN STATE — process memory only (see the module header for why this may never be persisted) -# --------------------------------------------------------------------------------------------- - -_RUN_LOCK = threading.RLock() -_RUNNING = {} # (tenant, id) -> {'startedAt', 'step'} - - -def running(tenant, auto_id): - with _RUN_LOCK: - return dict(_RUNNING.get((tenant, str(auto_id))) or {}) or None - - -def _claim(tenant, auto_id): - with _RUN_LOCK: - if (tenant, str(auto_id)) in _RUNNING: - return False - _RUNNING[(tenant, str(auto_id))] = {"startedAt": _iso(), "step": "starting"} - return True - - -def _step(tenant, auto_id, text): - with _RUN_LOCK: - cur = _RUNNING.get((tenant, str(auto_id))) - if cur is not None: - cur["step"] = text - - -def _no_step(_text): - """The default `step` sink for a runner nobody gave a live slot to (a gate driving a runner - directly). A runner must never REQUIRE the slot — the slot is process state, and the runner - is the part a test is allowed to call on its own.""" - - -def _release(tenant, auto_id): - with _RUN_LOCK: - _RUNNING.pop((tenant, str(auto_id)), None) - - -def _commit_run(rt, auto_id, state, summary, counts, ok, affected=None, steps=None, notes=None): - """THE ONE store write a run performs on the `automations` bucket. - - `steps` is the per-NODE outcome map the canvas paints its dots from. It is recorded here - because it is a MEASUREMENT the runner took as it walked — see `graph`, which refuses to - invent a node status when this map is absent (every run stored before W19-C has no `steps`, - and painting those nodes green from the run's overall state would be a fabrication). - - ⭐⭐ `notes` CLOSES DEBT D-103 (2026-08-09). A run's `counts` are integers and the - comprehension below drops everything else — so the per-record sentence a vendor gave us had - literally nowhere to live, and *"1 profile read(s) were blocked"* was the whole of what the - product could say. MEASURED on nurilab: the same record blocked on three separate runs and - the reason was unrecoverable from the store afterwards, so the diagnosis had to be rebuilt by - calling the vendors by hand. The note is the most valuable thing a run produces — a dead - handle, a vendor hiccup and an exhausted key are three different actions — and it was the one - thing thrown away. - ⚠ ON THE RUN, NOT ON THE RECORD. D-103's own prescription: no tenant table gains a column it - did not ask for, and W25/R4 already retired writing status STRINGS into people's grids. - """ - entry = {"ts": _iso(), "ok": bool(ok), "summary": _s(summary, 400), - "counts": {k: int(v) for k, v in (counts or {}).items() - if isinstance(v, (int, float))}, - "steps": {str(k): str(v) for k, v in (steps or {}).items()}, - # Bounded on both axes: a 100-profile run must not put 100 sentences into a store - # entry that is kept 20 deep per automation. - "notes": [_s(n, 300) for n in (notes or []) if str(n or "").strip()][:25], - "affected": list(affected or [])[:200]} - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - d = cur.get(str(auto_id)) - if d is None: - return cur - d["status"] = {"state": state, "lastRunAt": entry["ts"], - "lastSummary": entry["summary"]} - d["runs"] = ([entry] + list(d.get("runs") or []))[:MAX_RUNS] - # Wave 22 (airtable-brief rec 6): K consecutive FAILURES auto-pause the automation with - # the reason on it — a dead credential must not burn quota (or a paid vendor's records) - # 96 times a day while a green toggle sits over a buried error log. Errors only: - # `partial` is the honest-progress state and pausing on it would punish honesty. - runs = d.get("runs") or [] - if state == "error" and len(runs) >= CONSECUTIVE_FAILURE_PAUSE and all( - not r.get("ok") for r in runs[:CONSECUTIVE_FAILURE_PAUSE]): - sch = d.get("schedule") - if isinstance(sch, dict) and sch.get("enabled"): - sch["enabled"] = False - trg = d.get("trigger") - if isinstance(trg, dict): - trg["paused"] = True - d["statusNote"] = (f"auto-paused after {CONSECUTIVE_FAILURE_PAUSE} consecutive " - f"failures — fix the cause, then re-enable: " - f"{entry['summary'][:120]}") - return cur - - _store_update(rt, _up, flush="sync") - _notify_outcome(rt, auto_id, state, entry) - return entry - - -def _notify_outcome(rt, auto_id, state, entry): - """⭐⭐ WAVE 27 ITEM 31 / CONTRACT C5 — the bell rings on EVERY outcome, good and bad (I7). - - Owner: notify on both success and error. `_commit_run` is the ONE place a run's outcome is - written, so it is the only place this can go without a second definition of "what happened". - - ⛔ AMENDMENT A1 MOVED TWO REQUIREMENTS HERE AND THE FIRST IS A CROSS-TENANT DEFECT IF MISSED. - 1. **`st=` IS PASSED EXPLICITLY.** `core.alerts.notify`'s `st=None` default resolves to the - MODULE-level store, which is tenant #0's (Royal Imports). The automation tick runs on a - background thread with no session, so an omitted `st` files nurilab's automation failures - in Royal Imports' inbox — D-16's class, silently, forever. - 2. **THE OWNER IS THE AUTOMATION'S `createdBy`, NEVER THE RUNNER'S IDENTITY.** The scheduler - has no session at all, so the alternative is not "the wrong person" but "nobody", and - `notify` returns None on a blank owner — the notification would simply never exist, on - exactly the runs nobody was watching. - ⚠ AND NO SERVER ADMISSION WAS NEEDED. A1 measured that `routes_alerts._TOPICS` gates only - view-alert CREATION; `notify()` writes straight into the notifications bucket and - `GET /notifications` filters by nothing, so an `automation` topic already reaches the bell. - ⚠ FAILURE HERE IS SWALLOWED. A notification that cannot be filed must not turn a run that - SUCCEEDED into one that raised — the run entry is already committed one line above, and the - bell is a courtesy on top of it. - """ - try: - defn = (rt.get(STORE_KEY) or {}).get(str(auto_id)) or {} - owner = str(defn.get("createdBy") or "").strip() - if not owner: - return - from core import alerts as _alerts - _alerts.notify( - owner=owner, - label=_s(defn.get("name") or "Automation", 80), - topic="automation", - key=str(auto_id), - detail=f"{state}: {entry.get('summary') or ''}"[:300], - st=rt) - except Exception: # noqa: BLE001 - pass - - -# --------------------------------------------------------------------------------------------- -# INSTAGRAM (R7, extended by R1) — PUBLIC DATA ONLY, NEVER AN INSTAGRAM LOGIN -# --------------------------------------------------------------------------------------------- -# ⚠ AMENDED W19-C, vendor swapped W20. This section read "ANONYMOUS PUBLIC ENDPOINTS ONLY" and -# that is no longer the whole truth: R1 added a paid VENDOR rung (Bright Data, further down) -# which does authenticate — to the vendor. The rail that matters is unchanged and is sharper: -# -# **NOTHING HERE EVER AUTHENTICATES TO INSTAGRAM.** No login, no password, no session cookie, -# no account to get banned, public data only. The vendor key is a key to a SUPPLIER, and the -# supplier takes the scraping risk; it is never an Instagram credential and this module must -# never be given one. -# -# The rungs below this line are the ANONYMOUS ladder ($0, no key of any kind). -# -# ⚠ HONEST STATUSES ARE THE FEATURE. Instagram rate-limits and outright blocks datacenter egress -# (the HF Space and any AWS Lambda are both in that class), and its anonymous surface has been -# progressively closed for years. So this returns a STATE — ok / partial / blocked / error — and -# the cell says which. A pull that quietly wrote zero posts and reported success would be worse -# than no automation at all: the table would look maintained and be empty. -# -# THE LADDER, tried in order with pacing between rungs. Each rung records how it did in `via`, -# so a run history answers "what still works anonymously?" from data rather than from memory. - -PACE_SECONDS = float(os.environ.get("AIOS_IG_PACE_SECONDS") or 2.5) - -#: ⭐ MAP THE SCHEMA, NOT JUST WHAT WAS POPULATED ON THE PROBE — and the reason is this page's -#: own headline. **HISTORY IS UNBUYABLE.** No vendor sells "followers on 1 January"; every number -#: is a NOW value, so the series starts the day capture starts and a field we do not capture -#: TODAY is permanently lost for today. Dropping a column because it was null on two probe rows -#: has exactly the same cost as delaying capture — for that column — and is justified by exactly -#: the evidence (n=2) that was judged too thin to close D-25. Adding a column that turns out to -#: be usually-empty costs a blank cell; omitting one costs the months before somebody notices. -#: Every returned provider field is retained in Source data. Promoted columns make commonly used -#: values filterable; the raw source document prevents a new or uncommon field from being lost. -#: ⚠ A blank in any of these means NOT READ — never "they have none". The anonymous rungs expose -#: almost none of them, which is what the `source` column is for. -SNAPSHOT_FIELDS = [ - field_def("snapshot_key", "Snapshot"), field_def("influencer_key", "Influencer"), - field_def("pulled_at", "Pulled at", "date"), field_def("followers", "Followers", "int"), - field_def("following", "Following", "int"), - field_def("posts_count", "Post count", "int"), - field_def("full_name", "Name"), field_def("bio", "Bio"), - field_def("verified", "Verified", "checkbox"), field_def("source", "Read via"), - field_def("approx", "Counts are approximate", "checkbox"), - # The link in bio — the paid rung's field, and commercially the most useful single string an - # influencer row can carry (it is the shop/affiliate destination). - field_def("external_url", "Link in bio", "url"), - # --- the rest of the vendor's Profiles schema. - field_def("ig_id", "Instagram id"), - field_def("profile_url", "Profile", "url"), - field_def("avg_engagement", "Avg engagement", "pct"), - field_def("category", "Category"), - field_def("business_category", "Business category"), - field_def("is_business", "Business account", "checkbox"), - field_def("is_professional", "Professional account", "checkbox"), - field_def("is_private", "Private", "checkbox"), - field_def("highlights_count", "Story highlight count", "int"), - field_def("bio_hashtags", "Bio hashtags"), - field_def("pronouns", "Pronouns"), - # ⭐ 2026-08-07 — the rest of the vendor's Profiles schema (see `_bd_profile`). The snapshot - # row maps the WHOLE schema by design, so these belong here the moment the map reads them; - # leaving them out would be the "captured but unrecorded" half of the same loss the note at - # the top of this list is about. - field_def("profile_name", "Profile name"), - field_def("is_joined_recently", "Joined recently", "checkbox"), - field_def("has_channel", "Has channel", "checkbox"), - field_def("partner_id", "Partner id"), - field_def("external_url_title", "Link title"), - field_def("fbid", "Facebook id"), - field_def("related_accounts", "Related accounts"), - field_def("country_code", "Country"), - field_def("source_payload", "Source data", "json"), -] -POST_FIELDS = [ - field_def("shortcode", "Shortcode"), field_def("influencer_key", "Influencer"), - field_def("posted_at", "Posted at", "date"), - field_def("type", "Type", "select", options=["image", "video", "carousel"]), - field_def("caption", "Caption"), field_def("url", "URL", "url"), - # A tagged place is useful content context and can be filtered as ordinary text. It is never - # presented as the creator's location: a creator can tag a holiday, venue or brand location. - field_def("tagged_location", "Tagged location"), - # The per-field map below gives operational fields first; this locked JSON document retains - # every other value Bright Data supplied, so a vendor schema addition is preserved - # instead of being silently discarded while the canonical field model catches up. - field_def("source_payload", "Source data", "json"), - # Sponsored-post detection — §2c called it one of the fields worth having that the anonymous - # ladder cannot reach, and it came back MEASURED-populated (`True`, with the brand attached). - field_def("paid_partnership", "Paid partnership", "checkbox"), - field_def("partner", "Partner brand"), - field_def("hashtags", "Hashtags"), - field_def("alt_text", "Alt text"), - # ⭐⭐ 2026-08-07 — THE LATEST-VALUE ENGAGEMENT COLUMNS, AND THEY EXIST TO KEEP A ROLLUP AT - # ONE HOP. - # - # "Average views over the last N posts" is naturally TWO hops: profile → posts → each post's - # most recent snapshot → `views`. Airtable's rollup is one hop, and growing a second one is a - # much bigger build with a much worse failure mode (a rollup over a rollup, invalidated - # transitively). So the post row carries its own LATEST value and the rollup reads it - # directly. - # - # ⛔ R3's "ONE STORE FOR ONE SERIES" IS UNTOUCHED, and this is exactly the pattern the profile - # row already uses one level up: LATEST on the row (+ `enriched_at` to date it), the SERIES in - # `ut_ig_post_snapshots`. These three cells are rewritten on every pull from the snapshot that - # was just appended — they are a projection of that store, never a second copy of it, and - # deleting them would cost a convenience rather than a measurement. - # ⚠ BLANK, NEVER ZERO — and `_ig_zero_is_blank` is what finally ENFORCES it. `postMetrics` is - # off by default (it buys one vendor record per post), so on most pulls this stays empty, and - # empty means "not read", which is what makes an honest average possible at all. A 0 here - # would claim a post nobody watched. ⚠ The rule is scoped to this paid rung: a zero LIKE or - # COMMENT count is a real measurement and must survive. - # - # ⛔⛔ THERE IS NO `views` COLUMN HERE, AND THAT IS A RULING, NOT AN OVERSIGHT (2026-08-08, - # instagram-capture.md §4e/§4f — owner-approved after the evidence was bought). - # Bright Data's `views` is delivered at ACCOUNT grain: one value across up to 12 distinct - # reels, on 18 of 19 creators, through BOTH collection modes, while `likes` varies richly in - # the very same rows. Mapping it here is what put one number on many unrelated posts. We - # cannot say what it measures at ANY grain (`sriyynntt` returns 0 at 11,102 followers; - # `reviewby_ayyaa` returns null at 20k–328k likes), so it is not promoted anywhere — it stays - # in Source data, unlabelled, making no claim. Nothing is lost: the payloads are retained - # whole, so if the vendor ever populates it per-reel the history is re-derivable. - # ⚠ ENUMERATED, so nobody re-opens this hoping for a differently-named field: across all 30 - # fields of a Reels row, `views` and `video_play_count` are the ONLY view-shaped keys, and the - # Posts dataset carries none at all. `plays` below IS the per-reel equivalent, correctly named - # and correctly mapped. It is blank because Meta retired the Plays metric on 2025-04-10 - # (folded into a single "Views"), not because we are reading the wrong key. - # ⭐⭐ VIEWS IS BACK (2026-08-08), AND FROM A DIFFERENT SOURCE THAN THE ONE THAT WAS RETIRED. - # The retired column was fed by Bright Data's account-grain `views`. This one is fed by the - # `ig_post_views` CAPABILITY (providers.py), which resolves to Apify's `videoPlayCount` — - # measured against a browser-read ground truth to the digit. Same column name, same type, same - # rollup; the engine underneath is swappable and the preset database never moved. That is the - # owner's schema ruling working exactly as intended. - # ⚠ STILL BLANK, NEVER ZERO, and still only on the paid rung. - field_def("views", "Views", "int"), - field_def("plays", "Plays", "int"), - field_def("likes", "Likes", "int"), - field_def("comments", "Comments", "int"), - # ⭐⭐ 2026-08-09 (owner: *"whatever APIfy has more than BD pls use it and fix the post data - # further"*). Two fields the primary source does not return AT ALL, already paid for inside - # the same engagement response — so capturing them costs nothing extra. - # ⛔ A COLUMN MUST EXIST BEFORE A CELL CAN BE WRITTEN. `user_tables` filters an unknown key on - # every write door, so a normaliser that emits `video_duration` without this line writes - # nothing and reports success — the wave-28 defect that swallowed nine preset cells. - field_def("video_duration", "Video length (s)", "int"), - field_def("comments_disabled", "Comments off", "checkbox"), - field_def("measured_at", "Engagement read at", "date"), - # ⭐ 2026-08-07 — the second half of "spawn relevant Post/Comment database that is LINKED": - # a post reaches its own comment rows the same way a profile reaches its posts. Derived from - # `shortcode`, so it is correct the moment a comment row exists and needs no maintenance. - # ⚠ It resolves to nothing until comment capture is switched on, which is the honest state - # for a relation whose far side is empty — the same standing `ut_ig_post_snapshots` has when - # `postMetrics` is off. - field_def("comments_link", "Comment rows", "link", - description="Comment records linked to this post.", - link={"table": IG_COMMENTS_TABLE, "on": "shortcode", "from": "shortcode"}), - field_def("post_snapshots_link", "Measurement rows", "link", - description="Engagement measurements linked to this post.", - link={"table": IG_POST_SNAPSHOTS_TABLE, "on": "shortcode", "from": "shortcode"}), - field_def("measurements_captured", "Measurements captured", "rollup", - rollup={"link": "post_snapshots_link", "fn": "countall"}), -] - -#: ⭐⭐ 2026-08-07 (owner instruction) — THE COMMENT DATABASE. -#: -#: Owner: *"an enrichment automation should spawn relevant Post/Comment database that is linked to -#: the profile automatically."* So the schema and the LINK ship; what does NOT ship on by default -#: is the capture. -#: -#: ⛔ THIS REVERSES A STANDING RULING (D-22 / R11) AND THE REVERSAL IS DELIBERATE AND BOUNDED. -#: Comment capture was refused on two grounds, and BOTH ARE STILL TRUE: -#: 1. COST — comments are ~98% of a full-history bill (~$56,000 at 37.5M comments, §2a of -#: `instagram-capture.md`). They are the single most expensive thing this product can buy. -#: 2. PRIVACY — Bright Data flags `comment_user` and `post_user` as PII, and the append law has -#: no erasure path for third parties who never appeared in the tracked set (D-24). A comment -#: thread ingests identifiable people who never entered anybody's influencer list. -#: ⛔ SO WHAT SHIPS HERE IS THE SCHEMA AND THE LINK. **NOTHING CAPTURES COMMENTS TODAY** — no code -#: calls Bright Data's Comments dataset (`gd_ltppn085pokosxh13`), there is no `config.comments` -#: switch, and this table stays EMPTY until somebody builds one. Stated in the present tense on -#: purpose: an earlier draft of this note described the opt-in switch as if it existed, which is -#: exactly the doc that "reads as authority and silently ages" that §0 of `instagram-capture.md` -#: is written against. -#: ⚠ WHEN IT IS BUILT it should be an OPT-IN defaulted OFF, the same posture as -#: `config.postMetrics` and the Bright Data money switch W26/R15 kept — the relation costs -#: nothing; the spend and the third-party ingest are a click somebody makes knowingly. -#: Embedded comment payloads delivered with a Profile/Post/Reel response are retained because -#: they are part of a record already paid for. The separate full Comments dataset remains OFF -#: unless `commentMetrics` is explicitly enabled on the enrichment action. -COMMENT_FIELDS = [ - field_def("comment_key", "Comment"), field_def("shortcode", "Shortcode"), - field_def("influencer_key", "Influencer"), - # ⭐⭐ OWNER RULING 2026-08-12 — the same one that put `text` on the TikTok comment schema, and - # it lands on BOTH networks in the same change on purpose: the two comment tables are read side - # by side, and one carrying the content while the other does not is the divergence this repo - # keeps paying for. ⛔ TEXT ONLY — `comment_user` / `comment_user_url` are vendor-FLAGGED PII - # and stay in `source_payload`, uncolumned. - field_def("text", "Comment"), - field_def("commented_at", "Commented at", "date"), - field_def("likes", "Likes", "int"), - field_def("replies", "Replies", "int"), - field_def("source_payload", "Source data", "json"), - field_def("post_link", "Post", "link", - description="Post record linked to this comment.", - link={"table": IG_POSTS_TABLE, "on": "shortcode", "from": "shortcode", - "single": True}), - # Author/text and every provider-specific value stay intact in Source data. The promoted - # columns intentionally keep the grid concise while Likes and Replies make comment engagement - # directly filterable and rollup-ready. -] -POST_SNAPSHOT_FIELDS = [ - field_def("post_snapshot_key", "Snapshot"), field_def("shortcode", "Shortcode"), - field_def("influencer_key", "Influencer"), - field_def("pulled_at", "Pulled at", "date"), field_def("likes", "Likes", "int"), - field_def("comments", "Comments", "int"), - # ⭐⭐ 2026-08-10 — TWO DENORMALISED POST FACTS, and they are worth having on their own merits - # before any rollup argument is made. - # - # `pulled_at` says when we LOOKED; `posted_at` says when the creator POSTED. A fact table with - # only the first can describe a measurement but not its AGE, so the single most useful thing - # this series can compute — views at N days old, i.e. velocity — is not expressible over it at - # all. `type` is the same shape one column over: "reels only" is the default lens on this data - # and without it every question has to go back through `ut_ig_posts` to ask what kind of post - # this was. - # - # ⚠ THE WRITER HAS BOTH IN HAND (`capture_rows` builds the identity row and the measurement row - # from the same vendor record), so this costs one dict key each and no extra call. - # - # ⛔ `options` IS NOT OPTIONAL ON A `select`. A select declaring none is the wave-26 item-24 - # shape: the filter panel receives an empty list as an ANSWER, `if ([])` is truthy, and the - # control renders dead with nothing to say. Same three values `POST_FIELDS` declares — copied - # from it deliberately rather than shared, because these are two different tables' columns that - # happen to agree today, and `verify_automation` asserts the agreement. - # ⛔⛔ AND THEY DO NOT MAKE "THE LAST 10 REELS" EXPRESSIBLE OVER THIS TABLE. That takes TWO - # orderings (newest measurement per post, then newest posts), the rollup bag has one `sortBy`, - # and `ut_ig_posts` already performs the first of them. See the verdict in - # `.claude/wiki/research/entity-vs-series.md` — these columns are for AGE and KIND, not for - # moving the window here. - field_def("posted_at", "Posted at", "date"), - field_def("type", "Type", "select", options=["image", "video", "carousel"]), - # Plays move independently of likes on video, so it is its own series rather than a thing to - # derive. Paid rung only; blank means not read. - # The series regains Views alongside the latest-value projection on the Post row — one store - # for one series (R3) is untouched; this IS that store. - field_def("views", "Views", "int"), - field_def("plays", "Plays", "int"), - field_def("source_payload", "Source data", "json"), - field_def("post_link", "Post", "link", - description="Post record linked to this measurement.", - link={"table": IG_POSTS_TABLE, "on": "shortcode", "from": "shortcode", - "single": True}), -] - -#: ⭐⭐ WAVE 32 · T41 — THE INSTAGRAM TWIN OF `TT_TABLE_FIELDS`, WHICH DID NOT EXIST. -#: -#: `TT_TABLE_FIELDS`' own note said so and named the consequence: *"The Instagram side has no -#: equivalent map, which is exactly why its table names are scattered across the module."* It is a -#: map now, for a concrete reason rather than symmetry: A's boot-time delivery sweep (`W32-T07`) has -#: to call `ut_ensure` for every locked child of BOTH platforms, and a sweep that can ask TikTok for -#: its table set and must hand-type Instagram's is one platform away from the drift this pair of -#: maps exists to stop. -#: -#: ⛔ THESE FOUR LISTS ARE THE *CANONICAL* DECLARATION — the per-tenant BACKLINK fields -#: `ensure_ig_graph` appends are NOT here, and must not be. A backlink is derived per profile -#: database (`_profile_backlink_field`), so folding it into a module constant would make one -#: tenant's relation a global fact. `ensure_ig_graph` reads this map and adds its own backlinks on -#: top, which is why that function still owns the graph write and this only owns the schema. -IG_TABLE_FIELDS = { - IG_SNAPSHOTS_TABLE: SNAPSHOT_FIELDS, - IG_POSTS_TABLE: POST_FIELDS, - IG_POST_SNAPSHOTS_TABLE: POST_SNAPSHOT_FIELDS, - IG_COMMENTS_TABLE: COMMENT_FIELDS, -} -#: The labels those four wear in the database list. ⚠ LIFTED VERBATIM from `ensure_ig_graph`'s own -#: `graph` literal, which is now derived from this map — a renamed label here renames the table -#: everywhere rather than leaving two spellings of one database. -IG_TABLE_LABELS = { - IG_SNAPSHOTS_TABLE: "IG snapshots", - IG_POSTS_TABLE: "IG posts", - IG_POST_SNAPSHOTS_TABLE: "IG post snapshots", - IG_COMMENTS_TABLE: "IG comments", -} - - -# --------------------------------------------------------------------------------------------- -# ⭐⭐ TIKTOK — THE FIVE `ut_tt_*` SCHEMAS (wave 29 · item 7 · D-9 · rulings R1 + R2) -# --------------------------------------------------------------------------------------------- -# ⛔ EVERY VENDOR FIELD NAME BELOW IS FROM ONE PROBED SOURCE — `waves/wave29/proto/tiktok-schema.md`, -# 40 profile / 43 post / 17 comment fields read live from `GET /datasets/{id}/metadata` for $0.00, -# each carrying the vendor's own type, description and `pii` flag. NOTHING HERE IS GUESSED, and the -# names live in `connectors_tt.py` (the map), not here (the schema). This file declares what a -# COLUMN is; the connector declares what the wire calls it. -# -# ⭐ THE SAME LAW AS THE INSTAGRAM LISTS ABOVE: map the schema, not just what was populated on the -# probe. HISTORY IS UNBUYABLE — no vendor sells "followers on 1 January" — so a field we do not -# capture today is permanently lost for today, and an occasionally-empty column costs a blank cell -# while a missing one costs the months before somebody notices. -# -# ⚠ TWO DELIBERATE DIVERGENCES FROM THE INSTAGRAM SCHEMA, both licensed by R2 ("the two schemas may -# diverge where the vendors do") and both recorded so neither reads as an omission: -# -# 1. NO `plays` COLUMN. TikTok returns ONE number, `play_count`, and it is the count TikTok -# itself displays under a video — i.e. our `views`. Instagram has two columns because Meta -# once had two metrics (`plays` retired 2025-04-10). Writing one vendor number into two of our -# columns would manufacture a second measurement that a rollup could average or double-count, -# and blank-vs-zero discipline says an unmeasured column must be blank, not a copy. -# ⇒ `play_count` -> `views`, and `plays` does not exist on this family. -# 2. `tt_id`, NOT `ig_id`. The probe doc flags this: our profile key is literally NAMED `ig_id`. -# A TikTok row carrying a column labelled "Instagram id" is a header that lies, and this is a -# NEW table family with no stored rows to migrate — so the honest name costs nothing. -# -# ⚠ THE COLUMNS THAT STAY BLANK ARE NAMED RATHER THAN DROPPED SILENTLY. TikTok has no equivalent of -# `category`, `business_category`, `is_professional`, `highlights_count`, `bio_hashtags`, -# `pronouns`, `profile_name`, `is_joined_recently`, `has_channel`, `partner_id`, -# `external_url_title`, `fbid` or `related_accounts` (profile), nor of `alt_text`, -# `comments_disabled`, `paid_partnership` or `partner` (post) — `commerce_info` is a LOCATION, not -# a paid-partnership flag, and TikTok's `comment_setting` is profile-level rather than per-post. -# Those columns are ABSENT here rather than declared and permanently blank — a column nothing can -# ever write is a promise the grid keeps making that the vendor cannot keep. - -#: ⭐ WAVE 29 (contract C2) — the value the `profile` FLAG carries on a TikTok handle column: the -#: twin of `PROFILE_SOURCE_IG`, which is declared further down beside the `PLATFORM_*` vocabulary -#: with the note on why those two families of string are NOT the same thing. It sits here rather -#: than there because the lists below are evaluated at IMPORT and would raise a NameError otherwise. -#: ⛔ THIS CONSTANT IS ONLY HALF THE CONTRACT, AND THE OTHER HALF HAS LANDED (verified 2026-08-12, -#: W30-T08): `platform/core/user_tables.py:PROFILE_SOURCES` now reads `('instagram', 'tiktok')`. -#: Before it did, `_clean_profile` REFUSED this flag and the handle column of a `ut_tt_profile` -#: spawn was dropped — fail-closed and loud by design, because the alternative is a profile -#: database whose profile column silently is not one. Left written down rather than deleted: the -#: refusal is what a TikTok binding looks like on any deployment where that line is missing. -PROFILE_SOURCE_TT = "tiktok" - -#: The profile row: LATEST values plus `enriched_at`. The SERIES lives in `ut_tt_snapshots` — -#: R3's "one store for one series" carries over unchanged, because the reason for it does (no -#: vendor sells history, so the append table is the only history there will ever be). -TT_PROFILE_FIELDS = [ - # `platform` FIRST, exactly as the Instagram preset set has it — and on all five tables here, - # not just this one. The IG family carries it on the profile table alone, which is why a TikTok - # POST could never have lived beside an Instagram post; declaring it everywhere is what makes a - # future cross-platform union view a UNION rather than a guess. - tt_field_def("platform", "Platform"), - tt_field_def("handle", "Handle", pinned=True, - profile={"source": PROFILE_SOURCE_TT}), - tt_field_def("full_name", "Name"), - tt_field_def("tt_id", "TikTok id"), - tt_field_def("profile_url", "Profile", "url"), - tt_field_def("bio", "Bio"), - tt_field_def("external_url", "Link in bio", "url"), - tt_field_def("verified", "Verified", "checkbox"), - tt_field_def("is_private", "Private", "checkbox"), - tt_field_def("is_business", "Business account", "checkbox"), - tt_field_def("followers", "Followers", "int"), - tt_field_def("following", "Following", "int"), - tt_field_def("posts_count", "Video count", "int"), - # TikTok gives THREE engagement rates where Instagram gives one. All three are stored ×100 for - # the same reason `avg_engagement` is on the IG side (C1-a): the vendor sends a 0–1 fraction and - # our `pct` renderer appends the sign to the stored number, so the raw fraction would print a - # 6.6% creator as `0.0%`. - tt_field_def("avg_engagement", "Avg engagement", "pct"), - tt_field_def("like_engagement", "Like engagement", "pct"), - tt_field_def("comment_engagement", "Comment engagement", "pct"), - # Total likes RECEIVED across the account's videos — a TikTok-only number with no Instagram - # equivalent, and one of the few profile-level engagement facts a vendor gives away. - tt_field_def("likes_received", "Likes received", "int"), - tt_field_def("country_code", "Country"), - tt_field_def("region", "Region"), - tt_field_def("predicted_lang", "Language"), - # ⚠ ACCOUNT AGE, NOT A MEASUREMENT STAMP. The vendor's `create_time` on a profile is when the - # ACCOUNT was created; it is emphatically not "when `followers` was true". TikTok carries no - # measurement timestamp at all, exactly like Instagram — which is why the append law below - # (`@`) is the only thing that can date a number. - tt_field_def("account_created_at", "Account created", "date"), - tt_field_def("first_found", "First found", "date"), - tt_field_def("last_found", "Last found", "date"), - tt_field_def("found_count", "Times found", "int"), - tt_field_def("created_by", "Found by"), - tt_field_def("enriched_at", "Enriched at", "date"), - tt_field_def("source", "Read via"), - tt_field_def("source_payload", "Source data", "json"), - tt_field_def("profile_snapshots_link", "Measurement rows", "link", - description="Profile measurements linked to this account.", - link={"table": TT_SNAPSHOTS_TABLE, "on": "influencer_key", "from": "handle"}), - tt_field_def("posts_link", "Post rows", "link", - description="Post records linked to this account.", - link={"table": TT_POSTS_TABLE, "on": "influencer_key", "from": "handle"}), -] - -#: The profile SERIES. One row per profile per pull, keyed `@` — the append law -#: from `instagram-capture.md` §3, unchanged, because its cause is unchanged: the vendor stamps -#: nothing, so the only honest date a number can carry is the moment WE read it. -TT_SNAPSHOT_FIELDS = [ - tt_field_def("platform", "Platform"), - tt_field_def("snapshot_key", "Snapshot"), - tt_field_def("influencer_key", "Influencer"), - tt_field_def("pulled_at", "Pulled at", "date"), - tt_field_def("followers", "Followers", "int"), - tt_field_def("following", "Following", "int"), - tt_field_def("posts_count", "Video count", "int"), - tt_field_def("likes_received", "Likes received", "int"), - tt_field_def("full_name", "Name"), - tt_field_def("bio", "Bio"), - tt_field_def("verified", "Verified", "checkbox"), - tt_field_def("is_private", "Private", "checkbox"), - tt_field_def("is_business", "Business account", "checkbox"), - tt_field_def("avg_engagement", "Avg engagement", "pct"), - tt_field_def("like_engagement", "Like engagement", "pct"), - tt_field_def("comment_engagement", "Comment engagement", "pct"), - tt_field_def("external_url", "Link in bio", "url"), - tt_field_def("tt_id", "TikTok id"), - tt_field_def("profile_url", "Profile", "url"), - tt_field_def("country_code", "Country"), - tt_field_def("region", "Region"), - tt_field_def("predicted_lang", "Language"), - tt_field_def("account_created_at", "Account created", "date"), - tt_field_def("source", "Read via"), - tt_field_def("approx", "Counts are approximate", "checkbox", - description="Checked when the counts on this row are rounded, not exact."), - tt_field_def("source_payload", "Source data", "json"), -] - -#: One row per TikTok post. Identity is `shortcode`, and on TikTok that is a 19-digit numeric id — -#: ✅ the SAME shape as the Comments dataset's `post_id`, so the comments→posts link joins on -#: equality with NO normaliser (measured in the probe doc; Instagram needed one). -TT_POST_FIELDS = [ - tt_field_def("platform", "Platform"), - tt_field_def("shortcode", "Post id"), - tt_field_def("influencer_key", "Influencer"), - tt_field_def("posted_at", "Posted at", "date"), - # ⛔ THE VENDOR'S VOCABULARY IS `"video"` / `"content"` AND OURS HAS NO `"content"`. Declaring - # the vendor's word would put an untranslated API token in front of a user; declaring an option - # the mapper can emit but the select does not list would fail `_clean_field`. So the OPTIONS - # stay this product's words and `connectors_tt.normalize_post` does the translation — the same - # posture every other vendor value here takes. `carousel` is listed because a TikTok photo post - # carrying more than one image IS one, and the mapper decides from `carousel_images` rather - # than from the type token, which cannot express it. - tt_field_def("type", "Type", "select", options=["image", "video", "carousel"]), - tt_field_def("caption", "Caption"), - tt_field_def("url", "URL", "url"), - tt_field_def("hashtags", "Hashtags"), - tt_field_def("tagged_location", "Commerce location"), - # ⛔ `views` AND NO `plays` — see the divergence note at the top of this section. One vendor - # number, one column. - tt_field_def("views", "Views", "int"), - tt_field_def("likes", "Likes", "int"), - tt_field_def("comments", "Comments", "int"), - tt_field_def("shares", "Shares", "int"), - tt_field_def("saves", "Saves", "int"), - tt_field_def("video_duration", "Video length (s)", "int"), - tt_field_def("measured_at", "Engagement read at", "date", - description="When the engagement numbers on this row were read."), - tt_field_def("source_payload", "Source data", "json"), - tt_field_def("comments_link", "Comment rows", "link", - description="Comment records linked to this post.", - link={"table": TT_COMMENTS_TABLE, "on": "shortcode", "from": "shortcode"}), - tt_field_def("post_snapshots_link", "Measurement rows", "link", - description="Engagement measurements linked to this post.", - link={"table": TT_POST_SNAPSHOTS_TABLE, "on": "shortcode", "from": "shortcode"}), - tt_field_def("measurements_captured", "Measurements captured", "rollup", - description="How many measurements this post has.", - rollup={"link": "post_snapshots_link", "fn": "countall"}), -] - -#: The POST series. ⚠ D-117 is live on the Instagram twin — `posted_at`/`type` are denormalised onto -#: snapshot rows there and never reconcile with the post row afterwards. That pattern is NOT -#: reproduced: this table carries the measurement and its identity, and asks `ut_tt_posts` for what -#: kind of post it was. A fact that can disagree with its own dimension table is a fact nobody can -#: trust, and the convenience it buys (one fewer hop in a rollup) is not worth a column that can be -#: wrong. -TT_POST_SNAPSHOT_FIELDS = [ - tt_field_def("platform", "Platform"), - tt_field_def("post_snapshot_key", "Snapshot"), - tt_field_def("shortcode", "Post id"), - tt_field_def("influencer_key", "Influencer"), - tt_field_def("pulled_at", "Pulled at", "date"), - tt_field_def("views", "Views", "int"), - tt_field_def("likes", "Likes", "int"), - tt_field_def("comments", "Comments", "int"), - tt_field_def("shares", "Shares", "int"), - tt_field_def("saves", "Saves", "int"), - tt_field_def("source_payload", "Source data", "json"), - tt_field_def("post_link", "Post", "link", - description="Post record linked to this measurement.", - link={"table": TT_POSTS_TABLE, "on": "shortcode", "from": "shortcode", - "single": True}), -] - -#: Comments. Same posture as Instagram's: the SCHEMA and the LINK ship, the CAPTURE is opt-in and -#: defaults OFF (`commentMetrics`), because comments are the single most expensive thing this -#: product can buy and they ingest identifiable third parties who never entered anybody's list -#: (D-24). ⚠ TikTok's `replies` is an ARRAY where ours is an INT count — a name collision, resolved -#: in the mapper by storing `num_replies` and leaving the array in Source data. -TT_COMMENT_FIELDS = [ - tt_field_def("platform", "Platform"), - tt_field_def("comment_key", "Comment"), - tt_field_def("shortcode", "Post id"), - tt_field_def("influencer_key", "Influencer"), - # ⭐⭐ OWNER RULING 2026-08-12, verbatim: *"why isn't any of the comments column actually HAS - # the comments content, fix it."* The text was bought, stored and INVISIBLE — retained whole in - # `source_payload` under the 2026-08-07 superseding ruling (`instagram-capture.md` §4b), but - # promoted to no column, so a person paying per comment record saw only Likes and Replies. - # ⛔ THE COMMENT TEXT ONLY — the commenter's IDENTITY (`commenter_user_name`, `commenter_id`, - # `commenter_url`; the vendor FLAGS the first as PII) stays in `source_payload` and gets no - # column. The ruling names the comments' CONTENT, and promoting a third party's name and - # profile URL into a filterable, exportable column is a different decision that nobody made. - tt_field_def("text", "Comment"), - tt_field_def("commented_at", "Commented at", "date"), - tt_field_def("likes", "Likes", "int"), - tt_field_def("replies", "Replies", "int"), - tt_field_def("source_payload", "Source data", "json"), - tt_field_def("post_link", "Post", "link", - description="Post record linked to this comment.", - link={"table": TT_POSTS_TABLE, "on": "shortcode", "from": "shortcode", - "single": True}), -] - -#: table key -> the field list that defines it. ⭐ DERIVED CONSUMPTION IS THE POINT: `ut_ensure`, -#: the gates and every future runner read THIS rather than naming five constants, so adding a sixth -#: `ut_tt_*` table is one entry instead of a sweep. The Instagram side has no equivalent map, which -#: is exactly why its table names are scattered across the module. -TT_TABLE_FIELDS = { - TT_PROFILE_TABLE: TT_PROFILE_FIELDS, - TT_SNAPSHOTS_TABLE: TT_SNAPSHOT_FIELDS, - TT_POSTS_TABLE: TT_POST_FIELDS, - TT_POST_SNAPSHOTS_TABLE: TT_POST_SNAPSHOT_FIELDS, - TT_COMMENTS_TABLE: TT_COMMENT_FIELDS, -} -#: The human labels a spawned `ut_tt_*` table wears in the database list. -TT_TABLE_LABELS = { - TT_PROFILE_TABLE: "TikTok profiles", - TT_SNAPSHOTS_TABLE: "TikTok profile measurements", - TT_POSTS_TABLE: "TikTok posts", - TT_POST_SNAPSHOTS_TABLE: "TikTok post measurements", - TT_COMMENTS_TABLE: "TikTok comments", -} -#: ⭐⭐ WAVE 31 · OWNER RULING R9 — the `ut_tt_*` children a person may not type records into. -#: Owner, verbatim: *"Tiktok database for post and comments and their snapshots should also be a -#: locked database with the lock icon, exactly like how instagram"*. -#: -#: ⛔ DECLARED ONCE BECAUSE THE DEFECT WAS TWO CALL SITES DISAGREEING WITH A THIRD. `ensure_ig_graph` -#: passes `record_mode=AUTOMATION_RECORD_MODE` for all four Instagram children; TikTok's two spawn -#: sites — `_spawn_presets`' child loop (SAVE) and `_tt_write_tables` (RUN) — each passed -#: `lock_fields=True` and no `record_mode` at all. That is the whole bug: not a missing feature, one -#: argument missing at two places, which is exactly the shape `_tt_write_tables`' own docstring warns -#: about (*"one function, two callers, so the inline and deferred paths cannot answer differently"*). -#: A SET plus a resolver means a sixth `ut_tt_*` table joins by declaration, not by remembering. -#: -#: ⚠ FOUR, NOT THE THREE R9 ENUMERATES, and the fourth is argued rather than assumed. -#: `ut_tt_snapshots` is the profile measurement series — the exact twin of `ut_ig_snapshots`, which -#: IS locked. R9's own comparison is *"exactly like how instagram"*, and the alternative is to leave -#: a machine-append time series that a person can type into, one table away from three that they -#: cannot. That is the fix-applied-to-one-platform-and-not-its-twin shape this very wave is closing -#: elsewhere (D-177(c)). `ut_tt_profile` is deliberately ABSENT: it is the database a human adds -#: handles to, and its Instagram counterpart is not locked either. -#: -#: ⚠ LOCKED DATABASE, NOT READ-ONLY (DESIGN.md §4): `records_mutable` goes False, so records cannot -#: be added/edited/deleted — **adding a FIELD must still work**, and a UI hiding add-field here is a -#: defect, not the intent. -TT_LOCKED_TABLES = frozenset({TT_SNAPSHOTS_TABLE, TT_POSTS_TABLE, TT_POST_SNAPSHOTS_TABLE, - TT_COMMENTS_TABLE}) -#: ⭐ THE INSTAGRAM HALF, NAMED. It was only ever the four keys `ensure_ig_graph`'s loop happens to -#: iterate, which is a set that exists but cannot be ASKED — so nothing could assert that IG and -#: TikTok lock the same shape, and the QA sweep below could not be written at all. -IG_LOCKED_TABLES = frozenset({IG_SNAPSHOTS_TABLE, IG_POSTS_TABLE, IG_POST_SNAPSHOTS_TABLE, - IG_COMMENTS_TABLE}) -#: ⭐⭐ W31 QA — OWNER, VERBATIM (2026-08-13): *"just like Instagram Post database (which is -#: locked), only the IG Profile and TT Profile should be editable."* That is this set plus its -#: complement: every `ut_ig_*`/`ut_tt_*` child is locked, and `ut_ig_profile` / `ut_tt_profile` are -#: the two a person types handles into. Registered into `core.user_tables` at import so the lock is -#: a DECLARATION rather than a flag some past automation run happened to stamp — see that module's -#: `_LOCKED_RECORD_KEYS` for why the stored flag alone left production unlocked. -#: ⛔ THE REGISTRATION ITSELF IS IN `main.py`, NOT HERE, and that is this module's own rule rather -#: than a preference: it imports NOTHING from `core` at module level (see `MAX_UT_ROWS` and -#: `MACHINE_OWNERS`, both local literals "so this module stays dependency-light for the API's boot -#: path"). Adding `import core.user_tables` at line 3570 would put `core` on the import path of -#: every process that touches the engine, to run one line. The composition root wires it, and -#: `verify_automation` asserts that main.py CARRIES that call — a declaration whose registrar is -#: missing is [[flag-shipped-without-its-writer]], which is the defect class this whole fix is in. -LOCKED_CHILD_TABLES = IG_LOCKED_TABLES | TT_LOCKED_TABLES - - -def tt_record_mode(table_key): - """R9's answer for one `ut_tt_*` key — the `record_mode` its `ut_ensure` must carry. - - `""` for anything outside the locked set, which is what `ut_ensure` already treats as "say - nothing about record mode", so an unlocked table is untouched rather than explicitly opened. - """ - return AUTOMATION_RECORD_MODE if str(table_key) in TT_LOCKED_TABLES else "" - - -# --------------------------------------------------------------------------------------------- -# THE INSTAGRAM CONNECTOR — moved out (wave 27 item 23). See `connectors_ig.py`. -# --------------------------------------------------------------------------------------------- -# Bright Data, Apify, the corpus routes and the anonymous ladder used to be ~1,900 lines HERE. -# They are a CONNECTOR: they know what a vendor's rows look like. Nothing in the automation -# runtime needs that, and the runtime is what this file is for. -# -# ⭐ THE LIST BELOW IS A DEPENDENCY STATEMENT, NOT A CONVENIENCE. It is every name the automation -# runtime still reaches for after the split — twenty, down from the seventy-one that used to be -# defined here — so `git diff` on this block is how anyone sees the engine growing a new vendor -# dependency. Re-exporting them is also REQUIRED rather than tidy: `routes_automation` and -# `routes_connectors` call `engine.bd_ready()` on the module object, and this is what keeps that -# true without those files having to learn where the wire went. -# ⚠ SO THE RE-EXPORT ALSO MEANS `engine.bd_call` STILL RESOLVES, and a gate that only checked -# that would be green whether or not anything moved. `verify_automation`'s `section_split` -# therefore asserts `__module__` on the whole moved set — it derives the split from the RUNNING -# system instead of trusting this import line ([[gate-answers-the-wrong-question]]). -# -# ⚠ AND IT IS DELIBERATELY HERE, mid-file, rather than at the top. `connectors_ig` reaches back -# for the SSRF rail (`fetch`/`fetch_json`/`Refused`) and for `PACE_SECONDS`; it does so lazily, -# inside its functions, precisely so the two modules cannot cycle. This import sits BELOW the rail -# and below `PACE_SECONDS`, so even if somebody later makes one of those reach-backs a -# module-level import, the names it wants already exist and the cycle still resolves. -# ⭐⭐ WAVE 30 · T09 (D-128) — TWO IMPORT BLOCKS NOW, AND THE SPLIT BETWEEN THEM IS THE POINT. -# `connectors_bd` is the SUPPLIER's wire — it serves both platforms and knows neither. `connectors_ig` -# is INSTAGRAM's vocabulary: its dataset ids, its row mappers, its free rungs. Anything that would -# have to change to serve a third platform belongs in the second block, not the first. -# ⚠ `bd_ready` is re-exported through this module ON PURPOSE — `routes_automation` and -# `routes_connectors` both reach `engine.bd_ready()` on the module object, and those files belong to -# other sessions. Dropping it here would 500 two surfaces that never mention a vendor. -from connectors_bd import ( # noqa: E402 - BD_EXCLUDE_MAX, BD_PATH_SNAPSHOT, BD_RECORD_PRICE_SPEC, - _bd_deferral, _bd_first_url, _bd_rows, - _first, _ig_int, - bd_call, bd_filter_rows, bd_filter_start, bd_filter_status, bd_ready, - bd_snapshot_progress, depth_refusal, -) -from connectors_ig import ( # noqa: E402 - BD_DS_COMMENTS, BD_DS_POSTS, BD_DS_PROFILES, BD_DS_REELS, - _bd_comment, _bd_post_metrics, _bd_profile, - _bd_tagged_location, - DEFERRED_MARK, - apify_profile, - bd_profiles_batch, - ig_handle, public_source, pull_profile, select_post_groups, top_up_views, -) - - -# --------------------------------------------------------------------------------------------- -# DISCOVERY — sourcing handles we do NOT already know (owner ruling R7, DEBT D-23) -# --------------------------------------------------------------------------------------------- -# The engine until now only ENRICHED a profile set somebody typed in. This queries the vendor's -# **620,000,000-record pre-collected Profiles corpus** with a real query language and returns -# handles nobody here has ever seen. MEASURED end-to-end 2026-08-04: `followers 10k–200k AND -# biography includes "floral"` returned five real profiles, one of them a verified 20.8k-follower -# Georgia/Florida floral-design company — Fisch Floral's actual market. -# -# ⛔ FIVE PROPERTIES, EACH OF THEM A MEASURED FAILURE MODE RATHER THAN A PREFERENCE: -# -# 1. **A DIFFERENT ROUTE AND A DIFFERENT NAMESPACE.** `POST /datasets/filter` — ⚠ with NO `/v3/` -# segment; `/datasets/v3/filter` is a 404 that once got written down as "the corpus is -# unreachable". Its snapshots are `snap_…` and are read at `/datasets/snapshot/…`; the -# scraper's are `sd_…` at `/datasets/v3/snapshot/…`, and the two 404 each other. -# 2. **`records_limit` IS REQUIRED.** Unbounded queries die `NOT_ENOUGH_FUNDS` (code 104, -# `per_set` billing). ⚠ And bounding is NOT sufficient: **scan time tracks PREDICATE BREADTH, -# not row count** — a `records_limit:10` query on a bare `followers > 10000` was still -# `building` 50 minutes later. So the predicate is capped too, and the runner tolerates a -# snapshot that is not ready when it looks. -# 3. **IT IS SLOW, AND THE RUN HANDS OFF RATHER THAN HOLDING A THREAD.** MEASURED delivery -# latency on the narrow query: **19.6 minutes** (`created` 12:38:00 → `delivery_time` -# 12:57:42). Blocking a worker thread for twenty minutes on a free-tier container to poll is -# the wrong shape, so a run polls for a short budget and then PERSISTS the snapshot id; the -# next run collects it. A `partial` that says "still building, the next run collects it" is -# the honest report of exactly what happened. -# 4. **PROMOTION IS MANUAL, ALWAYS (R7).** The measured result set was ~40% on-target and -# included a hashtag-aggregator account that is not a person. Auto-promoting a candidate into -# a wider profile set would silently multiply the enrichment bill AND pollute the -# snapshot series with rows nobody chose. This path never decides WHICH rows to enrich. -# 5. **SERIAL.** `429 too_many_parallel_jobs` is real and the original probe's most important -# test died on it. -# -# ⚠ THE PRICE IS NOT MEASURED AND MAY NOT BE PRESENTED AS IF IT WERE. The funds gate fires BEFORE -# the API returns a number, `price: 0` means "not priced" rather than "free", and -# `/customer/balance` answers 403 for our token — so there is no balance to read and no spend to -# report. Every estimate below is derived from the PRICING PAGE and says so. - -#: The table a discovery run writes when the tenant has no Instagram profile database yet — the -#: FIRST one, never a second (`discover_default_table` elects an existing one before this is -#: reached). Upsert by handle, so re-finding a profile is free. -#: -#: ⭐ 2026-08-10 — RENAMED FROM `ut_ig_candidates` / "IG candidates" (owner: *"We only need ONE IG -#: profile so it's not confusing"*). The old pair was two kinds of wrong at once. The KEY said -#: `candidates` while every other table in the graph says what it holds (`ut_ig_posts`, -#: `ut_ig_comments`, `ut_ig_snapshots`, `ut_ig_post_snapshots`) — and "candidate" was a concept -#: W26/R6 retired when it deleted `tracked`: a row here is a PROFILE, and whether anyone wants it -#: is a stage column's business, not the table's name. The LABEL then disagreed with the key on -#: any tenant who renamed the table, which is exactly the mismatch the owner hit. -#: -#: ⚠ SAFE TO MOVE ONLY BECAUSE NOTHING HOLDS THE OLD KEY. Census 2026-08-10 across all three -#: tenant stores: royal-imports ABSENT, gtmlab has no tables at all, and nurilab's was deleted the -#: same day (0 rows, 0 views, 0 dependent automations). An automation that stored the old key -#: explicitly still resolves to it — an explicit target is never rewritten — so a legacy tenant -#: would keep working; there simply is not one. -#: ⛔ A future rename is NOT this cheap. Once rows exist under a key, moving it is a migration -#: touching rows, `link.table`, every stored `targetTable` and the `ut__*` bucket family. -DISCOVER_TABLE = "ut_ig_profile" -#: The label that key is CREATED with. `ut_ensure` sets a label only on create and never relabels, -#: so changing this can rename nothing that already exists. One constant because it was three -#: copies of the same string literal, and a default spelled three times is one edit from drifting. -DISCOVER_LABEL = "IG profile" -#: A hard ceiling on one run's ask — and it is DERIVED, not chosen. A corpus row MEASURED at -#: ~35 KB (`file_size` 175,617 for 5 rows), so 1,000 records ≈ 33 MB would have crossed -#: `BD_MAX_KB` and come back as a truncated document **after being billed**. 500 leaves real -#: headroom. ⚠ If the row size grows, this number is the thing to re-derive. -BD_MAX_RECORDS = 500 -#: How long ONE run waits on a building snapshot before handing off to the next run (see 3 above). -BD_FILTER_WAIT = float(os.environ.get("AIOS_BD_FILTER_WAIT") or 120) -BD_FILTER_POLL = float(os.environ.get("AIOS_BD_FILTER_POLL") or 15) - -#: The operator set, enumerated BY REJECTION — send a bogus one and the API's own validation error -#: lists the legal set. The cheapest kind of measurement, and it makes this list a fact. -BD_FILTER_OPS = ("=", "!=", "<", "<=", ">", ">=", "in", "not_in", "includes", "not_includes", - "array_includes", "not_array_includes", "is_null", "is_not_null") -#: Operators that take NO value (everything else requires one). -BD_NULLARY_OPS = ("is_null", "is_not_null") - -#: Fields a predicate may name. MEASURED-accepted (24 of 25 probed; **`country_code` is REJECTED** -#: by the API and is therefore absent rather than offered-and-broken). -#: ⛔ `email_address`, `phone_number` and `business_email` ARE filterable and are DELIBERATELY -#: MISSING. Selecting people BY CONTACT DETAIL across 620M records is the materially heavier -#: privacy posture D-24 flags — a different licence question from per-URL enrichment, and one the -#: owner has not been asked. A vocabulary that cannot express it cannot be asked for it by -#: accident; adding them back is a decision, not a typo. -BD_FILTER_FIELDS = ("followers", "following", "posts_count", "avg_engagement", "biography", - "category_name", "business_category_name", "is_business_account", - "is_professional_account", "is_verified", "account", "full_name", - "external_url", "bio_hashtags", "post_hashtags", "profile_url", - "related_accounts", "profile_name", "id", "fbid", "highlights_count") -#: ⭐ The three the FIND SURFACE leads with, and the reason is measurement rather than taste: -#: these are the fields seen carrying values on real CORPUS rows. `category_name` and -#: `related_accounts` are filterable and were null/empty on every row we have looked at — a filter -#: on an unpopulated field returns nothing and looks exactly like "no such influencers exist". -BD_FILTER_LEAD = ("followers", "biography", "avg_engagement") - -#: ⭐⭐ WAVE 32 · T46 / DEBT D-167 — THE FILTER VOCABULARY HAS A PLATFORM NOW, AND IT COST MONEY -#: NOT TO. `BD_FILTER_FIELDS` above was measured against INSTAGRAM in wave 22; waves 29 and 30 hung -#: a second platform on the same tuple, so a `discover_tiktok` could be built on any of the **16 -#: fields Bright Data's TikTok Profiles dataset does not have** — and `BD_FILTER_LEAD`'s third -#: field, `avg_engagement`, is one of the three the Find panel LEADS with and is absent there. -#: A search on a field the corpus does not carry does not error: it returns nothing, and reads -#: exactly like *"no such creators exist"* after the money has been spent. -#: -#: ⛔ MEASURED, NOT REASONED — W30-B20, against TikTok's 40 declared dataset fields: **5 of the 21 -#: names survive.** They are these. Anything else is refused at the door with the field named, -#: which is `clean_predicates`' existing posture (*"refuses rather than coerces … the alternative -#: turns 'find me verified accounts in Georgia' into 'find me any account' and bills for the -#: difference"*) — now applied per platform rather than per product. -#: ⚠ NOT DERIVED FROM `connectors_tt.normalize_profile`, TEMPTING AS THAT IS. That mapper declares -#: the vendor's READ names on a returned row; these are the names its FILTER validator accepts, and -#: the two vocabularies are not the same thing on either platform (`awg_engagement_rate` reads, -#: `avg_engagement` filters). Deriving one from the other would look rigorous and be wrong. -TT_FILTER_FIELDS = ("followers", "following", "biography", "is_verified", "id") -#: The two of `BD_FILTER_LEAD`'s three that TikTok actually carries. `avg_engagement` is dropped -#: for the reason above — leading with a field the corpus cannot answer is worse than leading with -#: two. -TT_FILTER_LEAD = ("followers", "biography") - - -def filter_fields(kind=""): - """The searchable field names for one discovery kind — `(fields, lead)`. - - ⛔ ONE ACCESSOR, so the ROUTE that publishes the vocabulary and the VALIDATOR that enforces it - cannot come to disagree about it. That divergence is D-167 itself: the route served 21 names - with no platform dimension and `clean_predicates` validated against the same tuple with no kind - parameter, so both halves were consistently wrong together and nothing could notice. - """ - if str(kind or "") == "discover_tiktok": - return TT_FILTER_FIELDS, TT_FILTER_LEAD - return BD_FILTER_FIELDS, BD_FILTER_LEAD - -#: ⛔ C4 (wave 22) — THE TOO-BIG GUARD, and its unit is the PREDICATE, not the row count. -#: MEASURED (2026-08-04): `records_limit: 10` over a bare `followers > 10000` was still -#: `building` 50 minutes later — scan time tracks PREDICATE BREADTH; bounding the rows does not -#: bound the scan. What actually narrows a 620M-row corpus is CONTENT: a text/array match on a -#: field that discriminates. Numeric ranges and booleans partition the corpus into slabs the -#: scanner still has to walk, so they refine a search and cannot BE one. -BD_NARROWING_FIELDS = frozenset({ - "biography", "account", "full_name", "profile_name", "category_name", - "business_category_name", "external_url", "bio_hashtags", "post_hashtags", - "related_accounts", "id", "fbid", "profile_url"}) -#: The ops that actually pin content — `!=`/`not_includes` on a text field matches nearly the -#: whole corpus, which is breadth wearing a condition's clothes. -BD_NARROWING_OPS = frozenset({"=", "in", "includes", "array_includes"}) -#: Fields MEASURED carrying values on real corpus rows — D-25's fill-rate table, 50 mixed -#: profiles (snapshot `snap_msfrlbmt7qvej8uby`, collected 2026-08-05). The bar is ≥60% -#: populated: below it a filter misses more corpus than it matches, and the surface should -#: say so. MEASURED AND EXCLUDED: category_name 48%, business_category_name 34%, -#: related_accounts 22%, post_hashtags 18%, bio_hashtags 10% — and the PII trio the map never -#: carries anyway read 2%/0%/0%, so even the thing R3 forbids would barely have worked. -#: ⚠ posts_count was 100% populated on CORPUS rows — the fabricated-zero finding -#: (`_bd_posts_count`) is a SCRAPE-path fact and both stay true. -BD_POPULATED_FIELDS = frozenset({ - "account", "followers", "following", "posts_count", "avg_engagement", "biography", - "external_url", "is_business_account", "is_professional_account", "is_verified", - "full_name", "profile_name", "highlights_count", "id", "fbid", "profile_url"}) -BD_MIN_NARROWING = 1 - -# ── THE FIELD SCHEMA — what a person sees, and what they are allowed to ask. ────────────────── -# ⛔ THE SURFACE USED TO SHOW THE VENDOR'S OWN COLUMN NAMES AND ALL FOURTEEN OPERATORS ON EVERY -# ROW. So `is_business_account` offered `>=`, `fbid` sat in the list with no explanation of what -# it is, and `not_array_includes` was a thing a customer was expected to reason about. Owner: -# *"look at each damn schema and only provide toggles operator that make sense"*. -# -# ⚠ THE LABEL IS NOW LOAD-BEARING IN BOTH DIRECTIONS. The old comment in `AutomationFind.tsx` -# defended raw names on the grounds that a refusal names the field exactly as the row does — and -# it was right, which is why `field_label()` is used by the REFUSALS too. Prettifying only the UI -# is how you get an error message about `bio_hashtags` on a row labelled "Hashtags in bio". -BD_FIELD_LABELS = { - "followers": "Followers", "following": "Following", "posts_count": "Post count", - "avg_engagement": "Engagement rate", "biography": "Bio", "category_name": "Category", - "business_category_name": "Business category", "is_business_account": "Business account", - "is_professional_account": "Professional account", "is_verified": "Verified", - "account": "Handle", "full_name": "Name", "profile_name": "Profile name", - "external_url": "Link in bio", "profile_url": "Profile link", - "bio_hashtags": "Hashtags in bio", "post_hashtags": "Hashtags in posts", - "related_accounts": "Related accounts", "id": "Instagram ID", "fbid": "Facebook ID", - "highlights_count": "Story highlight count", -} -#: A one-line "what IS this" for the rows nobody can be expected to guess. Absent = self-evident; -#: a hint on all 21 rows is a hint on none of them (the wave-24 chip lesson). -BD_FIELD_HINTS = { - "account": "The @username", - "avg_engagement": "Likes and comments as a share of followers", - "id": "Instagram's internal number for the account — for matching a list you already have", - "fbid": "The linked Facebook id — for matching a list you already have", - "profile_name": "The name shown above the bio, when it differs from the account name", - "related_accounts": "Accounts Instagram suggests alongside this one", -} -#: The KIND decides the comparisons offered and the control drawn for the value. -BD_FIELD_KINDS = { - **{n: "number" for n in ("followers", "following", "posts_count", "avg_engagement", - "highlights_count")}, - **{n: "boolean" for n in ("is_business_account", "is_professional_account", "is_verified")}, - **{n: "tags" for n in ("bio_hashtags", "post_hashtags", "related_accounts")}, - **{n: "choice" for n in ("category_name", "business_category_name")}, - **{n: "text" for n in ("biography", "account", "full_name", "profile_name", "external_url", - "profile_url", "id", "fbid")}, -} -#: ⛔ ONLY WHAT MAKES SENSE, and the ORDER is the order they are offered in — the first entry of -#: each list is what `default_operator` must agree with. -BD_OPS_BY_KIND = { - # ⚠ `in`/`not_in` are DELIBERATELY ABSENT. "is any of" is what `=` becomes the moment a second - # value is typed (see `BD_MULTI_VALUE_OPS`), so offering both would be two controls for one - # idea — and the second one is the one written in vendor grammar. - "text": ("includes", "not_includes", "=", "is_not_null", "is_null"), - "choice": ("=", "!=", "is_not_null", "is_null"), - "number": (">=", "<=", "=", ">", "<"), - # A yes/no field has exactly one sensible comparison and a two-option value. `!=` on a boolean - # is `=` with the other value, written the confusing way. - "boolean": ("=",), - "tags": ("array_includes", "not_array_includes", "is_not_null", "is_null"), -} -#: Plain English for every comparison the surface can offer. The vendor's token stays on the wire -#: (it is what the API accepts); nobody has to read it. -BD_OP_LABELS = { - "includes": "contains", "not_includes": "does not contain", - "=": "is", "!=": "is not", "in": "is any of", "not_in": "is none of", - ">=": "at least", "<=": "at most", ">": "more than", "<": "less than", - "array_includes": "has any of", "not_array_includes": "does not have", - "is_null": "is empty", "is_not_null": "is not empty", -} -#: Yes/No, so a boolean is a two-item dropdown instead of a box you type `true` into. -BD_BOOLEAN_OPTIONS = [{"value": "true", "label": "Yes"}, {"value": "false", "label": "No"}] - -#: ⭐ THE OPERATORS THAT MAY CARRY SEVERAL VALUES — owner item 3, "if i want to include many -#: keywords like floral, flower, beauty". One condition row holds the list; `bd_filter_start` -#: expands it into a NESTED OR group, so it composes with the other conditions instead of forcing -#: the whole search to "match any" (which would also drag Followers into the union — the hole the -#: OR guard closed the same day). -#: -#: ⛔ POSITIVE OPERATORS ONLY, and this is a correctness line rather than a scope line. "does not -#: contain floral OR does not contain flower" matches nearly every account alive — a negative over -#: a list is an AND (De Morgan), and quietly OR-ing it would build a filter that reads like a -#: narrowing and behaves like the whole corpus. Negatives stay single-valued until someone needs -#: them enough to write the AND branch. -BD_MULTI_VALUE_OPS = frozenset({"includes", "=", "array_includes"}) -MAX_PREDICATE_VALUES = 12 - - -def field_label(name): - """The human name for a searchable field — used by the SURFACE and by every REFUSAL.""" - return BD_FIELD_LABELS.get(name) or str(name or "that field") - - -def field_kind(name): - return BD_FIELD_KINDS.get(name, "text") - - -def ops_for(name): - """The comparisons this field may be asked. A tuple, in offer order.""" - return BD_OPS_BY_KIND.get(field_kind(name), BD_OPS_BY_KIND["text"]) - -#: ⭐ WHAT A **FRESH** CONDITION ON A FIELD STARTS AS — and it lives HERE, beside the narrowing -#: law, because the two drifted apart and the drift cost a user their first automation. -#: -#: ⛔ THE SCAR, IN THREE WAVES. Wave 21 fixed a seeded condition the server refused for having no -#: value by making a new condition NULLARY (`is_not_null` — "this field has any value"), and its -#: comment called that "narrowing-in-the-right-direction". Wave 22 then wrote `BD_NARROWING_OPS` -#: and `is_not_null` is NOT IN IT, which silently made that default a condition the save door -#: always refuses. Wave 24 deleted the create wizard, so the Find panel became the ONLY way in — -#: and the refusal moved from a corner case to the first thing a new user meets. Three waves, one -#: sentence of drift, and nothing red anywhere at any point. -#: -#: So the rule is now DERIVED and ASSERTED (`verify_automation.py`): a fresh condition on a field -#: that CAN narrow must narrow. The client asks for `defaultOperator` per field and never picks -#: one itself — a client-side default is a second copy of this table, free to disagree with the -#: guard that judges it, which is exactly what happened. -BD_ARRAY_FIELDS = frozenset({"bio_hashtags", "post_hashtags", "related_accounts"}) -BD_BOOLEAN_FIELDS = frozenset({"is_business_account", "is_professional_account", "is_verified"}) -#: Narrowing fields whose values are IDENTIFIERS — a substring match on a handle or an id is a -#: worse question than an equality, and both narrow. -BD_EXACT_FIELDS = frozenset({"account", "id", "fbid", "profile_url"}) - - -def default_operator(name): - """The operator a NEW condition on `name` starts with. - - Narrowing wherever the field can narrow, so a fresh condition is ONE step (type a value) from - saveable rather than two (change the comparison, then type a value) — with the second step - being one the surface never told anyone to take. - """ - kind = field_kind(name) - if kind == "tags": - return "array_includes" - if kind in ("boolean", "choice"): - # ⚠ `choice` LANDS HERE AND NOT ON `includes`, which is the arm it used to fall through - # to (Category is a narrowing field). `includes` is not in `BD_OPS_BY_KIND["choice"]`, so - # the default would have been an operator the same module refuses to offer — the exact - # default-versus-guard split this function was written to close, reintroduced one commit - # later by adding a kind. Asserted per field in `verify_automation.py`. - return "=" - if kind == "number": - # `>=` cannot narrow the scan and is not pretending to — it is the comparison a person - # reaching for Followers means, and the guard's sentence is the honest answer when it is - # the ONLY condition present. - return ">=" - return "=" if name in BD_EXACT_FIELDS else "includes" - - -def predicate_narrows(p): - """Does ONE predicate narrow the corpus scan? Content field + content op, nothing else.""" - return (str((p or {}).get("name") or "") in BD_NARROWING_FIELDS - and str((p or {}).get("operator") or "") in BD_NARROWING_OPS) - - -def narrowing_refusal(preds, operator="and", kind=""): - """C4's server law: the sentence when a predicate set does not narrow, else ''. One function - so create/patch (via `clean_predicates`) and RUN (legacy stored configs predate the law) - refuse in the same words. - - ⭐ THE JOIN IS PART OF THE LAW, and it was missed for two waves. The guard asked "does ANY - predicate narrow?" — a question that is only correct under AND. Under **OR** the result is a - UNION, so the query is exactly as broad as its WIDEST branch: `biography includes "florist" OR - followers >= 10000` saved clean and asked the vendor for every account over ten thousand - followers, which is the measured 50-minute / NOT_ENOUGH_FUNDS shape the guard exists to stop. - A money wall that a dropdown three rows above it can walk through is not a wall. - - So: under OR every branch must narrow; under AND one is enough. An EMPTY set never narrows - either way — `all([])` is True, which would have made "no conditions at all" the widest legal - query of the lot. - """ - preds = list(preds or []) - if str(operator or "").lower() == "or": - if preds and all(predicate_narrows(p) for p in preds): - return "" - return ("with Match set to any, every condition has to describe the account itself " - "(Bio, Handle, Name, a hashtag). Matching any means the results are added " - "together, so one follower or yes/no condition widens the whole search. Narrow " - "every condition, or set Match to all") - if any(predicate_narrows(p) for p in preds): - return "" - # ⭐ WAVE 32 · T46 (D-167's neighbour) — THE NETWORK'S OWN NAME. This sentence said - # "Instagram" to somebody building a TIKTOK search, on the refusal they are most likely to - # read, because the string predates the second platform. `discovery_facts` already carries the - # network's name for exactly this class of string, so there is no second literal. - _net = discovery_facts(kind)[0] if kind else PLATFORM_INSTAGRAM - return ("add a condition that describes the account itself — Bio contains, Handle is, a " - f"hashtag. Follower counts and yes/no conditions alone match too much of {_net} " - "to search") - - -def filter_meta(): - """C4's per-field flags for the Find surface: `[{name, populated, narrowing, defaultOperator}]` - over the same 21 names `BD_FILTER_FIELDS` offers (the 3 PII fields stay structurally absent, - R3). - - `defaultOperator` rides here rather than being a client constant for the reason written over - `default_operator`: the client's own default was `is_not_null` for every field, which the - narrowing guard refuses on every field. - """ - return [{"name": n, - "label": field_label(n), - "hint": BD_FIELD_HINTS.get(n, ""), - "kind": field_kind(n), - "populated": n in BD_POPULATED_FIELDS, - "narrowing": n in BD_NARROWING_FIELDS, - "defaultOperator": default_operator(n), - # The comparisons THIS field may be asked, already in plain English and already in - # offer order. A client that filtered a global list by field kind would be a second - # copy of `BD_OPS_BY_KIND`, and the copy is what goes stale. - "operators": [{"value": o, "label": BD_OP_LABELS.get(o, o), - "nullary": o in BD_NULLARY_OPS, - # `in`/`is any of` and the tag operators take a LIST — the control - # has to know that, and deriving it from the token in the client is - # the same second-copy mistake one level down. - "multi": o in BD_MULTI_VALUE_OPS} - for o in ops_for(n)], - "options": BD_BOOLEAN_OPTIONS if field_kind(n) == "boolean" else []} - for n in BD_FILTER_FIELDS] - -#: ⭐ WAVE 25 · CONTRACT C1 — THE UNIFIED, CROSS-TENANT INSTAGRAM PRESET SET. -#: -#: ONE server-owned list. Every tenant gets the same keys and the same labels, which is what -#: "unified across tenant" means and what makes the pooled master series joinable at all — two -#: tenants calling the same number `followers` and `follower_count` is a pool you cannot query. -#: ⛔ THERE IS NO CLIENT COPY OF THIS LIST. C and D read it off the wire (`GET -#: /automations/presets`); a client-side copy is the wave-9 silent-drop failure in a new hat. -#: -#: ⭐ R3 — THESE HOLD **LATEST**, AND NOTHING ELSE. The full time series stays as append rows in -#: `ut_ig_snapshots`. ONE STORE FOR ONE SERIES: no per-record `json` history column, no second copy -#: of a number that already has a home. -#: ⛔ W29-T34 — THIS LINE USED TO ADD *"which the metric fields already read"*, AND THAT WAS WRONG. -#: A `metric` field reads the PLATFORM-WIDE MASTER, not this tenant's snapshot table: -#: `compute_metric_cells` imports `ig_master` and calls `ig_master.series_for(...)` — a -#: cross-tenant pooled repo in a different HF dataset, which is the whole point of C6's pooling and -#: which no rollup kind can reach. The metric header below says exactly that, so the module was -#: contradicting itself on which store answers the question. -#: ⚠ And the practical finding behind the correction, recorded because it is surprising: a live -#: census of all four tenants (13 tables / 228 fields / 64 store files) found **ZERO** metric fields -#: in existence, while 47 rollups are live. The vocabulary stays by owner ruling; nothing uses it. -#: -#: ⭐⭐ 2026-08-07 — THIS IS NOW *EVERY* PROFILE FIELD THE VENDOR RETURNS, BY OWNER INSTRUCTION, -#: AND THAT REVERSES D-25's FILL-RATE RULE. Owner, verbatim: *"make sure to have ALL Fields -#: available to us from Bright Data to be pre-set Fields for us and populated."* -#: -#: ⛔ THE RULE IT REPLACES IS WRITTEN DOWN HERE RATHER THAN DELETED, because it was a good rule -#: and the next reader will otherwise re-derive it and prune these columns back. It said: a -#: LATEST-value column costs something a snapshot row does not — it is a column every user sees on -#: their grid forever — so the split followed D-25's MEASURED fill-rates (50 mixed profiles, -#: snapshot `snap_msfrlbmt7qvej8uby`) and a field earned a column only at ≥60% populated. That is -#: why `business_category` (34%), `bio_hashtags` (10%), `post_hashtags` (18%) and `pronouns` were -#: captured into the snapshots and were NOT preset columns. -#: -#: ⚠ THE MEASUREMENT IS UNCHANGED AND STILL WORTH KNOWING — several of these columns WILL be -#: mostly blank on the scrape path, and D-25's central finding is why: **the scrape path and the -#: corpus path are different data.** `avg_engagement` is 0% populated on scrape and 88% on corpus; -#: `category` is 0% on scrape and 70% on corpus. So a blank here is very often "this ROUTE does -#: not carry it", not "this account does not have it" — which is exactly what `enriched_at` and -#: the blank-never-zero law exist to keep honest. What changed is the ANSWER to "does a -#: sometimes-blank column earn a place on the grid", and that was always the owner's call to make. -#: Source data retains every field the provider returns. The only separate decision is whether to -#: request the paid full Comments dataset (`commentMetrics`, default OFF). -#: -#: ⭐ WAVE 26 · R5 — THE NETWORK VOCABULARY. A handle is only unique WITHIN a network, so this is -#: half of the candidate identity (contract C3: the upsert key is `(platform, handle)`). -#: -#: ⚠ THE VALUES ARE DISPLAY STRINGS ON PURPOSE. They land in an ordinary `text` cell that a person -#: reads, filters and groups by, so `"Instagram"` beats `"ig"` — and the set is small, closed and -#: written down here rather than inferred from a runner's module name, because the day a second -#: runner spells it `"instagram"` is the day the dedup key stops working and nothing errors: the -#: profile simply appears twice. -#: ⭐ 2026-08-07 — WHERE A PROFILE HANDLE POINTS, as `core.user_tables.PROFILE_SOURCES` spells it. -#: A local literal for the same boot-path reason `MACHINE_OWNERS` and `UT_FIELD_TYPES` are locals -#: here, and held in step by a gate rather than an import. ⛔ NOT the same vocabulary as -#: `PLATFORM_*` below: this names the FLAG's source (which validator reads the cell), that names -#: the NETWORK a row belongs to (half of the identity). They read alike and mean different things, -#: which is exactly why both are written down instead of inferred. -PROFILE_SOURCE_IG = "instagram" -#: ⚠ Its TikTok twin, `PROFILE_SOURCE_TT`, is declared UP with the `ut_tt_*` schemas (wave 29): the -#: field lists there are evaluated at import and would not see a constant defined here. - -PLATFORM_INSTAGRAM = "Instagram" -#: ⭐ WAVE 29 — the `ut_tt_*` family stamps this on every row of all five of its tables. (It really -#: was written by nothing until D-9 landed; the note that said so is kept in the log, not here.) -PLATFORM_TIKTOK = "TikTok" -PLATFORM_FACEBOOK = "Facebook" -PLATFORMS = (PLATFORM_INSTAGRAM, PLATFORM_TIKTOK, PLATFORM_FACEBOOK) - - -def discovery_facts(kind): - """⭐⭐ WAVE 30 · T05 — THE THREE THINGS THAT GENUINELY DIFFER BETWEEN THE DISCOVERY KINDS: - the network's NAME, the database a run writes to when the config names none, and that - database's label. `(platform, table, label)`. - - ⛔ WHY A FUNCTION AND NOT A DICT LITERAL: `DISCOVER_TABLE` and `DISCOVER_LABEL` are declared - several hundred lines BELOW this point, so a dict evaluated here would NameError at import. - A function body resolves at call time and does not care. (`PROFILE_SOURCE_IG`'s note directly - above records the same import-order trap costing a constant its natural home.) - - ⛔ AND WHY IT EXISTS AT ALL. These three facts were written out inline at FIVE call sites — - `clean_config`, `graph`'s discovery arm, `graph`'s `det` dict, `_flow_table` and - `compose_sentence` — and wave 29 taught four of the five about TikTok by not touching them: - they tested the string `"discover_instagram"`, so a stored TikTok automation fell through to - a default meant for Instagram, or to no arm at all. Every one of those misses was SILENT and - every gate stayed green. `DISCOVERY_KINDS` answers *"is this a corpus search?"*; this answers - *"whose?"* — and between them a sixth platform is one tuple entry and one arm here, rather - than a hunt through the file for string comparisons somebody has to think to look for. - - ⚠ DELIBERATELY NOT A PLATFORM REGISTRY. The dataset ids, the field maps and the row mappers - stay in the connector modules that own them. This is the presentation-and-default quartet the - ENGINE needs, and widening it is how it becomes a second `SOURCES` with a longer name. - - ⭐ T06 ADDED THE FOURTH ELEMENT, `spawn_fields` — the field list `_presets_after_write` gives - the target database on SAVE. It belongs here rather than beside that function because it must - equal what the RUN-TIME `ut_ensure` uses, and wave 25's R10 exists precisely to stop those two - disagreeing: columns that change between Save and the first run are two different answers to - "what does this database look like". - ⚠ THE TWO PLATFORMS PASS DIFFERENT-LOOKING LISTS AND THAT IS CORRECT, not an oversight. - Instagram spawns `CANDIDATE_FIELDS` (= the preset set PLUS the discovery bookkeeping) because - its preset list alone is a subset of what its runner writes; TikTok's `TT_PROFILE_FIELDS` - already carries its own bookkeeping columns, so it IS the whole set. Each side is the list its - own runner ensures — which is the rule, rather than "both use the one named CANDIDATE". - """ - if kind == "discover_tiktok": - return (PLATFORM_TIKTOK, TT_PROFILE_TABLE, TT_TABLE_LABELS[TT_PROFILE_TABLE], - TT_PROFILE_FIELDS) - return PLATFORM_INSTAGRAM, DISCOVER_TABLE, DISCOVER_LABEL, CANDIDATE_FIELDS - -#: ⭐ WAVE 26 · R3 — THE TYPES ARE HONEST NOW, AND THE MIGRATION IS THE PRICE OF THAT. -#: -#: This block used to say the `text` types were deliberate, and the reasoning was sound as far as -#: it went: `ut_ensure` merges by KEY and never re-types an existing column, so re-declaring -#: `followers` as `int` gives NEW tables a schema every table already in production does not have -#: — one vocabulary with two shapes, the exact drift this list exists to prevent. That argument -#: was never wrong; it was an argument for doing the MIGRATION, and the note used it as a reason -#: not to. Owner, 2026-08-06: *"Why is everything that instagram field found is in a text format? -#: change this."* -#: ⛔ SO THE TWO HALVES ARE INSEPARABLE AND SHIP TOGETHER. Declaring a type here without -#: `migrate_ig_field_types()` reintroduces exactly the split-schema the old note feared — and it -#: would do it silently, because a merged-by-key field list produces no error when two tables -#: disagree about what a column IS. -#: -#: ⚠ `avg_engagement` CARRIES A UNIT CONVERSION, NOT JUST A TYPE (amendment C1-a). The vendor -#: sends a 0–1 fraction (MEASURED: 0.0074 / 0.0656 / 0.0014 / 0.0148 / 0.0274 / 0.0091) and this -#: product's `pct` renders the stored number with a `%` appended — so storing the raw fraction -#: would print every creator in the book as `0.0%`. It is stored ×100 from here on, at the -#: mapping and in the migration both. See `_pct100`. -PRESET_PROFILE_FIELDS = [ - # ⭐ WAVE 26 · R5 — WHICH NETWORK THIS HANDLE IS ON, and it is half of the identity now. - # `@inayma` on Instagram and `@inayma` on TikTok are two accounts owned by two different - # people as often as not, so the dedup key is (platform, handle) and never handle alone - # (C3). ⛔ NOT called `source`: `SNAPSHOT_FIELDS` already spends that word on *which rung - # answered* (`field_def("source", "Read via")`), and one word meaning two things across two - # tables in one product is the drift this whole list exists to prevent. - field_def("platform", "Platform"), - # ⭐⭐ 2026-08-07 (owner ruling) — THE HANDLE IS THE PRIMARY COLUMN *AND* THE ENRICH BINDING. - # Owner: *"if a user use this automation for instagram scraping, the Unique ID will always be - # REPLACED or made as the Handle. in any case the user can add their own record right and edit - # those records."* Two declarations, one field, and they are the same sentence read twice: - # - # `pinned` — the grid's identity column is `find(f => f.pinned) ?? fields[0]`, so WITHOUT - # this the primary is an accident of creation order. It is a DECLARATION, not a - # reorder: the stored field order is left alone (saved view orders keep working) - # and the client pins the column to position 0 wherever it sits. That is why this - # fixes tables that already exist without moving a single stored field. - # `profile` — C3/R7's flag. It makes `handle` the column `enrich_instagram` BINDS to - # (`profile_field_key` step 2), which is what turns "I added a profile to my - # database, how do I enrich it?" from an unanswerable question into a step. It - # also validates the cell (`@Name`, a pasted instagram.com link and a bare handle - # all normalise to the bare handle) and routes a typed value to the SHARED - # definition row rather than one user's overlay (C3-A1) — which is precisely what - # makes the owner's second clause true: a record you add BY HAND is a record the - # engine can read and enrich. - # - # ⛔ THE TWO KEYS MUST SURVIVE `_clean_field` UNCHANGED or `verify_api` W25-1 goes red. The flag - # is also why this field can never be retyped: `_clean_field` REFUSES a profile flag on - # anything but `text`. - field_def("handle", "Handle", pinned=True, profile={"source": PROFILE_SOURCE_IG}), - field_def("profile_url", "Profile", "url"), - field_def("full_name", "Name"), field_def("followers", "Followers", "int"), - field_def("following", "Following", "int"), - field_def("avg_engagement", "Avg engagement", "pct"), - field_def("bio", "Bio"), field_def("external_url", "Link in bio", "url"), - field_def("verified", "Verified", "checkbox"), field_def("category", "Category"), - # --- the enrichment half: what `run_field_instagram` already pulls, kept to the fields - # MEASURED ≥60% populated (see the note above). - field_def("posts_count", "Post count", "int"), - field_def("highlights_count", "Story highlight count", "int"), - field_def("is_business", "Business account", "checkbox"), - field_def("is_professional", "Professional account", "checkbox"), - # ⚠ STAYS `text`. It is a 17-digit opaque identifier, not a quantity — typing it `int` would - # invite a grid to sum it, and some of them exceed 2^53 so a JS reader would round it. - field_def("ig_id", "Instagram id"), - # --- ⭐ 2026-08-07: the rest of the vendor's profile schema, promoted by owner instruction - # (see the block comment above for what that reverses). Types are HONEST from birth, which - # costs nothing here: no table has ever carried these columns, so there is no stored `text` - # definition for the migration to convert — R3's conversion rule applies to the columns that - # already exist, and these are new everywhere. - field_def("business_category", "Business category"), - field_def("is_private", "Private account", "checkbox"), - field_def("bio_hashtags", "Bio hashtags"), - field_def("pronouns", "Pronouns"), - field_def("profile_name", "Profile name"), - field_def("is_joined_recently", "Joined recently", "checkbox"), - field_def("has_channel", "Has channel", "checkbox"), - field_def("partner_id", "Partner id"), - field_def("external_url_title", "Link title"), - # ⚠ `text` for `ig_id`'s reason, one identifier over: it is a name, not a quantity. - field_def("fbid", "Facebook id"), - field_def("related_accounts", "Related accounts"), - field_def("country_code", "Country"), - field_def("source_payload", "Source data", "json"), - # ⭐⭐ 2026-08-07 (owner instruction) — THE RELATION AND THE ROLLUPS OVER IT. - # - # Owner: *"an enrichment automation should spawn relevant Post/Comment database that is - # linked to the profile automatically… and this rollup needs to have formula that we can use - # to calculate things like average Views over last N posts."* Both halves are these five - # columns, and they are PRESETS rather than something a person assembles, because "linked - # automatically" is the instruction — a relation you have to wire up by hand is the feature - # not existing. - # - # ⛔ `from` IS NOT DECLARED, ON PURPOSE. `_link_from_key` falls back to the PROFILE-flagged - # column and then the pinned one, both of which are `handle` on every preset table — so this - # links correctly on a database where the handle column was renamed, and on one where the - # flag sits somewhere unexpected. Naming `handle` here would be the hard-coded subject - # [[gate-answers-the-wrong-question]] warns about, one layer down. - # ⚠ "Post rows", NOT "Post count" — one opens records and one reports the account total. - # Two count-like columns with the same label would be unreadable. Caught by the label-collision - # gate rather than on screen, which is what that gate is for. - field_def("posts_link", "Post rows", "link", - description="Post records linked to this profile.", - link={"table": IG_POSTS_TABLE, "on": "influencer_key"}), - field_def("profile_snapshots_link", "Profile history", "link", - description="Profile snapshots linked to this profile.", - link={"table": IG_SNAPSHOTS_TABLE, "on": "influencer_key"}), - field_def("post_snapshots_link", "Post measurement rows", "link", - description="Post engagement measurements linked to this profile.", - link={"table": IG_POST_SNAPSHOTS_TABLE, "on": "influencer_key"}), - field_def("comments_link", "Comment rows", "link", - description="Comment records linked to this profile.", - link={"table": IG_COMMENTS_TABLE, "on": "influencer_key"}), - # ⚠ THE ROLLUPS READ `ut_ig_posts`' OWN LATEST COLUMNS, which is why those exist — a rollup - # is ONE HOP (Airtable's rule and ours), and the engagement SERIES lives one table further - # out in `ut_ig_post_snapshots`. - # ⚠ 12 IS THIS MEASURE'S OWN WINDOW (`AVG_WINDOW_POSTS`), NOT THE CAPTURE CAP. It used to be - # `MAX_POSTS_PER_PULL` and read "the vendor's ceiling" — both halves are now wrong: the cap is 30 - # (owner, 2026-08-09) and 30 is not the vendor's limit either. Binding these four to the cap meant - # raising it silently redefined every column named `_12`. `sortBy` is mandatory alongside a - # `limit`, and this is why — "the last 12" has to name what makes one post later than another. - # ⭐ UN-RETIRED 2026-08-08 together with the column it averages. It was retired for four hours - # because its input was an account-grain constant; with the input now a real per-reel - # measurement the average means what its label says again. - field_def("avg_views_12", "Avg views · last 12 posts", "rollup", - rollup={"link": "posts_link", "field": "views", "fn": "average", - "limit": AVG_WINDOW_POSTS, "sortBy": "posted_at", "sortDir": "desc", - "distinctBy": "shortcode"}), - field_def("avg_plays_12", "Avg plays - last 12 posts", "rollup", - rollup={"link": "posts_link", "field": "plays", "fn": "average", - "limit": AVG_WINDOW_POSTS, "sortBy": "posted_at", "sortDir": "desc", - "distinctBy": "shortcode"}), - field_def("avg_likes_12", "Avg likes · last 12 posts", "rollup", - rollup={"link": "posts_link", "field": "likes", "fn": "average", - "limit": AVG_WINDOW_POSTS, "sortBy": "posted_at", "sortDir": "desc", - "distinctBy": "shortcode"}), - field_def("avg_comments_12", "Avg comments · last 12 posts", "rollup", - rollup={"link": "posts_link", "field": "comments", "fn": "average", - "limit": AVG_WINDOW_POSTS, "sortBy": "posted_at", "sortDir": "desc", - "distinctBy": "shortcode"}), - # ⭐ THE ONE HONEST POST COUNT WE HAVE. D-82: the vendor's `posts_count` is a FABRICATED ZERO - # on the paid rung (49/49 rows measured), so it is discarded and that column reads blank after - # a paid enrich. This counts the post rows actually captured — a different number and a true - # one, which is why it gets its own column and its own label rather than quietly filling - # `posts_count` with something that is not what that field means. - field_def("posts_captured", "Posts captured", "rollup", - rollup={"link": "posts_link", "fn": "countall", "distinctBy": "shortcode"}), - field_def("profile_reads", "Profile reads", "rollup", - rollup={"link": "profile_snapshots_link", "fn": "countall", - "distinctBy": "snapshot_key"}), - field_def("post_measurements_captured", "Post measurements captured", "rollup", - rollup={"link": "post_snapshots_link", "fn": "countall", - "distinctBy": "post_snapshot_key"}), - field_def("comments_captured", "Comments captured", "rollup", - rollup={"link": "comments_link", "fn": "countall", - "distinctBy": "comment_key"}), - # ⭐⭐ WAVE 27 ITEM 16 — WHERE THIS CREATOR ACTUALLY IS, DERIVED, FOR NOTHING. - # - # ⛔ THE VENDOR DOES NOT SELL THIS. `country_code` was MEASURED `None` on every corpus row we - # have ever looked at, and the filter API REFUSES it as a predicate — so residency is the one - # thing a buyer most wants and the one thing the profile row cannot answer. The competitor - # teardown found the same gap solved by GUESSING from bio words, two buckets deep - # ([[janney-ai-teardown]]). - # - # ⭐ WE ARE ALREADY HOLDING THE ANSWER AND WERE THROWING IT AWAY. Each post carries the place - # it was tagged in, and the enrich has ALREADY BOUGHT twelve of them: `tagged_location` on the - # post row, plus the vendor's fuller `location`/`location_details` inside the paid - # `source_payload` that `_bd_tagged_location` normalises away. The modal city across a - # creator's own posts is a far better residency signal than a word in a bio, and it costs a - # dict comprehension. **Zero new vendor spend** — this is the whole reason it is a v1. - # - # ⚠ IT IS A GUESS AND THE COLUMN SAYS SO, IN ITS NAME AND IN ITS NEIGHBOUR. A travel creator - # posting from twelve cities gets a low confidence rather than a confident wrong answer, and - # `location_confidence` is the number a person filters on before trusting the guess. A single - # geotagged post produces NO guess at all — n=1 dressed as a pattern is what - # `SEED_MIN_ROWS_SHARING` refuses fifty lines up, and it would read as 100% certain. - field_def("location_guess", "Location (guess)"), - field_def("location_confidence", "Location confidence", "pct"), - # ⭐ R3's stamp. WITHOUT IT THE WHOLE SET IS UNREADABLE: a blank `followers` means "never - # enriched" and a stale one means "enriched in March", and no cell on the row can tell them - # apart. It is the single field that turns the other fifteen from numbers into measurements. - field_def("enriched_at", "Enriched at", "date"), - # ⭐ WAVE 26 · R1 — THE LAST-N POST WINDOW, AND IT IS A VIEW RATHER THAN A STORE. - # ⛔ READ THE R3 NOTE ABOVE BEFORE CHANGING THIS. "One store for one series" still holds: the - # authoritative post record is `ut_ig_posts` (keyed by shortcode, so it ACCUMULATES) and the - # authoritative engagement series is `ut_ig_post_snapshots` (append-per-pull, carrying - # views/likes/comments at a `pulled_at`). This cell is a DERIVED window over those two, - # rewritten each run, so a person reading the profile row can see the recent posts without a - # join — and deleting it would cost a convenience, never a measurement. Shape: contract C2. -] -#: The keys the preset set owns — derived, so a field added above cannot be forgotten here. -PRESET_PROFILE_KEYS = tuple(f["key"] for f in PRESET_PROFILE_FIELDS) - -#: ⭐ 2026-08-07 — WHICH preset field declares the primary column, and WHICH declares the profile -#: flag. DERIVED for the same reason `PRESET_PROFILE_KEYS` is, and the negative control is what -#: argued for it: with `"handle"` hard-coded in the migration, stripping the declaration left the -#: migration cheerfully stamping a column the product no longer claimed. Moving a declaration now -#: moves the migration with it, and REMOVING one stops the migration rather than leaving it to -#: enforce a rule nobody declares any more. -#: ⚠ Both are `""` when nothing declares them, and every reader treats `""` as "do nothing" — -#: never as "field number zero". -PRESET_PINNED_KEY = next((f["key"] for f in PRESET_PROFILE_FIELDS if f.get("pinned")), "") -PRESET_FLAG_KEY = next((f["key"] for f in PRESET_PROFILE_FIELDS if f.get("profile")), "") - -#: Discovery's table is the preset set PLUS its own bookkeeping. ⛔ DERIVED, NEVER RE-TYPED: the -#: alternative is two lists that describe the same columns and drift one label at a time, which is -#: the failure C1 exists to prevent. The five below are facts about the SEARCH (how often we found -#: them, who for, and a human's decision) rather than about the profile, so they are discovery's -#: and not part of the cross-tenant set. -CANDIDATE_FIELDS = [ - *PRESET_PROFILE_FIELDS, - # ⭐ WAVE 26 · R3 — `first_found` / `last_found` ARE DATES, not ISO strings in a text cell. - # They were written by `_iso()`, so the cell read `2026-08-05T14:03:11+07:00` and the owner - # called that format "extremely confusing" — correctly: it is a machine timestamp shown to a - # person, unsortable as a date and unfilterable by "last 7 days". The STAMP still carries its - # offset everywhere it is a time axis (`ut_ig_snapshots.pulled_at` is untouched); what - # changed is that "when did we first see this account" is a DAY, and a day is all anybody - # asks it for. `migrate_ig_field_types()` converts the stored strings. - field_def("found_count", "Times found", "int"), - field_def("first_found", "First found", "date"), - field_def("last_found", "Last found", "date"), - # ⛔ DEBT D-73 — THIS NOTE STATED A RETIRED LAW AS CURRENT FACT, directly above the field it - # describes, which is the first place anybody looks up what this column means. It read: *"the - # candidate pool is PER-USER — the upsert key is the COMPOUND (handle, created_by), so two - # people discovering the same profile each get their own row, their own found_count and their - # own review card."* Wave 26 · R4/R5 retired every clause of that. - # - # ⭐ THE CURRENT LAW: the identity is `(platform, handle)` and the TENANT is the unit. Two - # people in one workspace who discover the same profile share ONE row, one `found_count` and - # one history — because they are looking at one company's leads, not two private lists. - # `created_by` survives as an INFORMATIONAL stamp only: "Found by", answering who saw it - # first. It is no part of the dedup key, and a run must never branch on it. - # ⚠ Stamped by the RUNNER (the automation's creator), never by `_candidate_row` — unchanged, - # and the one clause of the old note that was still true. - field_def("created_by", "Found by"), - # ⛔ WAVE 26 · R6 — `tracked` WAS HERE AND IS DELETED. Owner, 2026-08-06: *"We already have - # a Field called stage to track the progress of the Automation per Record. We don't need - # another checkbox for this."* Correct, and it had been true since wave 22 shipped the stage - # column: every candidate carried BOTH a `stage_` select saying where it was AND a - # boolean saying whether it was kept — two progress fields that could disagree, with no rule - # about which one won. The stage field is the one progress column. Nothing replaces this. -] - - -def _profile_backlink_field(table_key, table_label, profile_key): - """A deterministic reciprocal link from one canonical IG table to one profile database. - - The key includes the target table identity, so ten separate Profile databases can all point - into the same canonical Posts/Comments/history tables without one relation overwriting the - next. The join is derived in both directions and therefore needs no cross-table fan-out write. - """ - digest = hashlib.sha1(str(table_key).encode("utf-8")).hexdigest()[:10] - return field_def( - f"profiles_{digest}", f"Profiles - {str(table_label or table_key)[:48]}", "link", - description=f"Profile records from {str(table_label or table_key)[:48]} linked to this row.", - link={"table": str(table_key), "on": str(profile_key), "from": "influencer_key"}, - ) - - -def _profile_schema_for(bound_key): - """Preset fields for an existing Profile table without inventing a second identity column.""" - out = [] - for field in PRESET_PROFILE_FIELDS: - item = dict(field) - if item.get("key") == PRESET_FLAG_KEY and bound_key != PRESET_FLAG_KEY: - item.pop("profile", None) - out.append(item) - return out - - -def _tt_profile_schema_for(bound_key): - """⭐ WAVE 30 · T08 — TikTok preset columns for a database whose profile column is `bound_key`. - - ⛔ NOT `_profile_schema_for` WITH A LIST ARGUMENT, and the difference is not stylistic. That - function walks `PRESET_PROFILE_FIELDS` and only ever removes the flag from `PRESET_FLAG_KEY`, - because on the Instagram side the flag lives on exactly one known column. TikTok's binding may - be ANY column a person named (`profile_field_key` step 1), so the rule here has to be stated - the other way round: **every field that is not the bound one arrives as DATA**, stripped of - both the identity flag and `pinned`. - ⚠ Otherwise a database whose TikTok column is `creator` would gain a rival `handle` carrying - `profile: {source: "tiktok"}` — a SECOND profile column, which `user_tables` refuses at both - write doors, so the whole top-up would be rejected and every preset cell would then be dropped - for want of a column. One shared helper for the save-time and run-time paths, so those two - cannot answer "which columns does a TikTok enrich need" differently. - """ - out = [] - for field in TT_PROFILE_FIELDS: - item = dict(field) - if str(item.get("key") or "") != str(bound_key or ""): - item.pop("profile", None) - item.pop("pinned", None) - out.append(item) - return out - - -def _locked_ig_field(field): - """One canonical Instagram field with the immutable preset declaration attached.""" - item = dict(field) - automation = dict(item.get("automation") or {}) - automation["preset"] = True - item["automation"] = automation - return item - - -def _profile_binding(table): - """The declared Profile identity field, falling back only to the canonical handle.""" - fields = list((table or {}).get("fields") or []) - flagged = next((str(f.get("key") or "") for f in fields - if isinstance(f.get("profile"), dict)), "") - if flagged: - return flagged - return PRESET_FLAG_KEY if any(f.get("key") == PRESET_FLAG_KEY for f in fields) else "" - - -def _ig_schema_contract(rt, profile_tables=None): - """The complete per-tenant Instagram graph contract, including every Profile backlink. - - The fixed child tables are shared. Profile tables are discovered structurally, so an older - user-named target and the built-in candidate database receive the same preset columns, Links, - Rollups, locks, and reciprocal fields. Retired machine keys are the only columns deleted. - """ - tables = ut_all(rt) - # ⭐ WAVE 32 · T42 — `_ig_contract_tables`, NOT `_ig_profile_tables`: this function decides - # which databases RECEIVE Instagram's 48 columns, and the owner's rule is that a database gets - # them only if it is used for Instagram. See that function for why the detector stays wider. - profile_keys = sorted(set(profile_tables if profile_tables is not None - else _ig_contract_tables(rt))) - backlinks, wanted, drops = [], {}, { - IG_SNAPSHOTS_TABLE: {"post_hashtags"}, - # ⚠ `views` IS DELIBERATELY ABSENT FROM THIS DROP SET AGAIN. It was dropped earlier today - # while it carried Bright Data's account-grain number; it is now declared in POST_FIELDS - # and fed by the `ig_post_views` capability. Leaving it here would have made the migration - # delete, on every authenticated read, the column the same release just added — the - # drop set and the field list are two halves of ONE contract and must move together. - } - for table_key in profile_keys: - table = tables.get(table_key) or {} - bound = _profile_binding(table) - if not bound: - continue - label = str(table.get("label") or table_key) - backlinks.append(_profile_backlink_field(table_key, label, bound)) - schema = _profile_schema_for(bound) - # ⭐⭐ 2026-08-10 (owner: *"retire the old hardcoded method and replace the Instagram - # database work with correct Rollup and Link fields"*) — THE DERIVED OVERLAY, AND IT - # BELONGS HERE RATHER THAN IN A SEPARATE MIGRATION. - # - # ⛔ A standalone converter would LOSE. `_reconcile_ig_graph_fields` overwrites every - # contract key it owns — `rollup` included, by its own note — so a column converted beside - # the contract is re-typed back to `int` on the next reconcile, silently, hours later. - # Making the CONTRACT itself say "this column is derived" leaves exactly one writer of the - # schema instead of two that disagree. - # - # ⚠ THE SET IS PER TENANT AND IS A PROOF, NOT A LIST. `_derived_profile_columns` converts a - # column only where this tenant's own rows show the fold already equals the stored value — - # measured, because a named list would have blanked 221 live cells on nurilab alone - # (`avg_engagement` and `category`, 62 each). A tenant whose series is thinner converts - # fewer columns and loses nothing; as its history fills in, later passes convert more. - derived = set(_derived_profile_columns(table, tables.get(IG_SNAPSHOTS_TABLE) or {})) - wanted[table_key] = [_as_derived_field(f) if str(f.get("key")) in derived else f - for f in schema] - # `tracked` was the pre-Stage progress checkbox. Keeping both allows two progress states - # to disagree, so it is retired by key just like the old nested Posts JSON. - drops[table_key] = {"posts", "post_hashtags", "tracked"} - wanted.update({ - IG_SNAPSHOTS_TABLE: [*SNAPSHOT_FIELDS, *backlinks], - IG_POSTS_TABLE: [*POST_FIELDS, *backlinks], - IG_POST_SNAPSHOTS_TABLE: [*POST_SNAPSHOT_FIELDS, *backlinks], - IG_COMMENTS_TABLE: [*COMMENT_FIELDS, *backlinks], - }) - return wanted, drops - - -# Only a unit-changing retype needs a cell conversion. Integer/checkbox/date cells already use -# the scalar strings their renderers expect; engagement is the exception because Bright Data's -# 0-1 fraction becomes this product's 0-100 percentage. -_IG_RETYPE_CONVERTERS = { - (IG_SNAPSHOTS_TABLE, "avg_engagement"): _pct100, -} - - -def _reconcile_ig_graph_fields(rt, wanted_by_table, drop_by_table=None): - """Repair machine-owned IG field declarations in one coalesced store update. - - `ut_ensure` deliberately merges only missing keys. That is correct for user columns but not - sufficient for a canonical relation: a stale `link.table`, rollup function, or type would - survive forever. This pass overwrites the contract keys for the fields we own, preserves - unrelated/user fields, and removes only explicitly retired preset columns plus their cells. - """ - drop_by_table = drop_by_table or {} - wanted_by_table = { - key: [_locked_ig_field(f) for f in fields] - for key, fields in wanted_by_table.items() - } - changed = False - - def _up(cur): - nonlocal changed - cur = cur if isinstance(cur, dict) else {} - for table_key, wanted_fields in wanted_by_table.items(): - table = cur.get(table_key) - if table is None: - continue - wanted = {str(f.get("key")): dict(f) for f in wanted_fields} - drops = set(drop_by_table.get(table_key) or ()) - fields, seen = [], set() - drops = _guarded_drops(table, drops) - for stored in table.get("fields") or []: - key = str(stored.get("key") or "") - if key in drops: - changed = True - continue - desired = wanted.get(key) - if desired is None: - fields.append(stored) - continue - # ⭐⭐ 2026-08-09 (owner: *"everything is custom and changeable always"*) — A - # COLUMN A HUMAN HAS TAKEN OVER IS LEFT ALONE. Every key below is overwritten - # from the shipped contract, `rollup` included, so without this the newly - # unlocked "edit a preset rollup" would save, render, recompute and then revert - # at the next enrichment run — an edit that looks like it worked and undoes - # itself hours later. `user_tables.user_edited` is the ONE reader of the stamp. - if _ut().user_edited(stored): - fields.append(stored) - seen.add(key) - continue - repaired = dict(stored) - for dkey, value in desired.items(): - if dkey != "automation": - repaired[dkey] = value - automation = dict(stored.get("automation") or {}) - automation.update(desired.get("automation") or {}) - repaired["automation"] = automation - # These bags define behaviour, not decoration. If the desired field does not use - # one, a stale bag from an earlier type must not remain attached to it. - for bag in ("link", "rollup", "profile", "pinned"): - if bag not in desired: - repaired.pop(bag, None) - if desired.get("type") not in ("select", "multiselect"): - repaired.pop("options", None) - converter = _IG_RETYPE_CONVERTERS.get((table_key, key)) - if (converter and stored.get("type") in ("text", "", None) - and repaired.get("type") != stored.get("type")): - for row in (table.get("rows") or {}).values(): - if str(row.get(key) or "").strip(): - converted = converter(row.get(key)) - if converted: - row[key] = converted - changed = True - if repaired != stored: - changed = True - fields.append(repaired) - seen.add(key) - for key, desired in wanted.items(): - if key not in seen and not any(str(f.get("key") or "") == key for f in fields): - fields.append(desired) - changed = True - if drops: - for row in (table.get("rows") or {}).values(): - for key in drops: - if key in row: - row.pop(key, None) - changed = True - table["fields"] = fields - return cur - - # Avoid a commit when the declarations are already exact. - snapshot = ut_all(rt) - needs = False - for table_key, wanted_fields in wanted_by_table.items(): - if table_key not in snapshot: - continue - table = snapshot.get(table_key) or {} - by_key = {str(f.get("key") or ""): f for f in table.get("fields") or []} - # ⚠ THE SAME GUARD, OR THE MIGRATION STOPS BEING IDEMPOTENT. This precheck exists to skip - # the commit when nothing would change; a spared `tracked` column left in the drop set - # answers "yes, work to do" on every single call, forever, and the schema pass would - # rewrite the table on every authenticated read without ever changing a byte. - drops = _guarded_drops(table, drop_by_table.get(table_key) or ()) - if drops & set(by_key): - needs = True - break - for desired in wanted_fields: - stored = by_key.get(str(desired.get("key") or "")) - mismatch = stored is None - if stored is not None: - for key, value in desired.items(): - if key == "automation": - if any((stored.get("automation") or {}).get(k) != v - for k, v in value.items()): - mismatch = True - break - elif stored.get(key) != value: - mismatch = True - break - if not mismatch: - stale_bags = {"link", "rollup", "profile", "pinned"} - set(desired) - mismatch = any(k in stored for k in stale_bags) - if (not mismatch and desired.get("type") not in ("select", "multiselect") - and "options" in stored): - mismatch = True - if mismatch: - needs = True - break - if needs: - break - if needs: - rt.update(UT_STORE_KEY, _up, flush="sync") - return changed - - -def ensure_ig_graph(rt, username="automation", flow_tag="", profile_table="", profile_field=""): - """Ensure the ONE per-tenant Instagram relational graph and return its canonical keys. - - Every enrichment path calls this function. Fixed table keys prevent a flow aimed at a new - Profile database from spawning `IG posts 2`; deterministic reciprocal link fields connect - each Profile database to the same Posts, Comments, profile-history, and post-history stores. - """ - profile = ut_get(rt, profile_table) if profile_table else None - bound = str(profile_field or "").strip() - if profile is not None and not bound: - bound = next((str(f.get("key") or "") for f in profile.get("fields") or [] - if isinstance(f.get("profile"), dict)), "") - profile_label = str((profile or {}).get("label") or profile_table) - backlink = [_profile_backlink_field(profile_table, profile_label, bound)] \ - if profile_table and profile is not None and bound else [] - - # ⭐ WAVE 32 · T41 — DERIVED from `IG_TABLE_FIELDS`/`IG_TABLE_LABELS`, which used to be this - # literal. The backlinks stay HERE because they are per-tenant, per-profile-database facts; the - # schema is a module constant. Splitting it that way is what let A's delivery sweep ask this - # module for Instagram's child set instead of hand-typing a fifth copy of these four names. - graph = {key: (IG_TABLE_LABELS[key], [*fields, *backlink]) - for key, fields in IG_TABLE_FIELDS.items()} - keys = {} - for key, (label, fields) in graph.items(): - keys[key] = ut_ensure(rt, label, fields, username, key=key, flow_tag=flow_tag, - record_mode=AUTOMATION_RECORD_MODE, lock_fields=True) - - if profile_table and profile is not None and bound: - profile_fields = _profile_schema_for(bound) - ut_ensure(rt, profile_label or profile_table, profile_fields, username, - key=profile_table, flow_tag=flow_tag, lock_fields=True) - # Reconcile the WHOLE tenant graph, not just this run's target. That is what keeps an older - # Profile database and IG candidates on the same universal schema while all of them point to - # the same four children. Existing flow provenance is merged, never replaced. - wanted, drops = _ig_schema_contract(rt) - _reconcile_ig_graph_fields(rt, wanted, drops) - return keys - - -# ── ⭐ WAVE 26 · THE MIGRATION (owner rulings R3 + R4/R5, contracts C1-a and C3) ─────────────── -# -# Two changes land on data that already exists, and neither is optional once the field defs move: -# -# R3 the preset columns take honest types, so the CELLS have to match them — an ISO stamp in a -# `date` column and a "20872" string in an `int` column are the split-schema the old -# "types are text on purpose" note correctly feared. Re-typing without converting is worse -# than not re-typing. -# R4 the candidate identity becomes `(platform, handle)`, so rows that exist today as -# `(handle, created_by)` DUPLICATES must be merged rather than left to collide. -# -# ⛔ IDEMPOTENCY IS THE WHOLE DESIGN, and the key is the STORED FIELD TYPE — never a flag, never a -# marker column. Run twice, an `avg_engagement` of 0.0074 would go 0.74 then 74, and nothing would -# look wrong until somebody read a 74% engagement rate off a creator with 3,000 followers. So a -# column is converted only while its STORED definition still says `text`; the moment it says `pct` -# the work is done and re-running is a no-op. That makes the migration safe to call on every boot, -# which is what it is for. -# -# ⚠ THE TABLE LIST IS DERIVED, NOT NAMED — D-71's lesson, one wave old and already paid for twice. -# `observed_categories` named its two source tables and went silently empty the day wave 25 let a -# user point an automation somewhere else. Naming tables here would leave exactly those user-named -# databases on the old schema, which is the same bug wearing a migration's clothes. - -#: A table is an Instagram profile table if it declares `handle` plus a real slice of the preset -#: vocabulary. Deliberately structural: it finds `ut_beauty_influencer_leads` without being told. -MIGRATE_MIN_PRESET_FIELDS = 3 -#: key -> the converter its new type needs. `checkbox` needs none (the writers already emit '1'/''). -_MIGRATE_CONVERT = { - "first_found": _day, "last_found": _day, "enriched_at": _day, - "avg_engagement": _pct100, -} - - -#: ⭐⭐ WAVE 30 · T08 — THE COLUMNS THAT CAN ONLY BE TIKTOK'S, DERIVED rather than typed out, so -#: that adding a column to either declaration keeps this set correct instead of quietly emptying -#: it. Eight today: `tt_id`, `like_engagement`, `comment_engagement`, `likes_received`, `region`, -#: `predicted_lang`, `account_created_at`, `source`. -TT_ONLY_PROFILE_KEYS = (frozenset(f["key"] for f in TT_PROFILE_FIELDS) - - frozenset(PRESET_PROFILE_KEYS) - - frozenset(f["key"] for f in CANDIDATE_FIELDS)) - - -def _is_tt_profile_table(tbl): - """⭐⭐ WAVE 30 · T08 — is this database a TIKTOK profile table? - - ⛔ THE DEFECT THIS EXISTS TO CLOSE WAS LIVE AND SHIPPED, and it converted a customer's TikTok - database into an Instagram one on the SECOND write to it. MEASURED: a `ut_tt_profile` spawned - by W30-T06 comes out correct — `handle` carrying `profile: {source: "tiktok"}` — and one more - `ut_ensure` on that table (a re-save, the discovery runner's own ensure, the next run) leaves - it carrying `profile: {source: "instagram"}` and the description *"Instagram username without - the @ symbol"*. `profile_field_key(..., source="tiktok")` then answers `""` forever, so every - TikTok enrich step on that database reports UNBOUND, and every row gets stamped - `platform: Instagram` — which is half of W26/R4's `(platform, handle)` identity, so two - different people's accounts merge under one key with nothing red anywhere. - ⚠ It hid because the migration SKIPS a table that does not exist yet, so the spawn itself is - always clean and only the second write is not. A gate that creates and asserts once cannot see - it; the assertion has to survive a second `ut_ensure`. - - ⭐ TWO LEGS, and the second is the one that still works after the first has been eaten: - 1. the table SAYS SO — a column declaring `profile: {source: "tiktok"}` is the database's own - statement of which network it is about, and it is the same declaration `profile_field_key` - reads, so there is one answer to "whose profile table is this"; - 2. it declares a column only TikTok has. Needed because leg 1 is exactly what the defect - destroys: on a table already corrupted in production, the flag now says Instagram, and a - detector resting on it alone would agree with the corruption and keep re-applying it. - """ - fields = (tbl or {}).get("fields") or [] - for f in fields: - p = f.get("profile") - if isinstance(p, dict) and str(p.get("source") or "") == PROFILE_SOURCE_TT: - return True - return bool({f.get("key") for f in fields} & TT_ONLY_PROFILE_KEYS) - - -#: ⭐⭐ WAVE 32 · T40 — THE MIRROR OF `TT_ONLY_PROFILE_KEYS`, and it is the subject of owner item 1: -#: the profile columns that can only ever be INSTAGRAM's. 26 today. Derived by the same subtraction -#: in the other direction, so a column moved between the two declarations changes both sets at once -#: rather than leaving one of them quietly asserting a stale fence. -#: ⚠ IT IS DERIVED FROM `CANDIDATE_FIELDS`, NOT `PRESET_PROFILE_FIELDS` — discovery's bookkeeping -#: columns (`found_count`, `first_found`, `last_found`, `created_by`) are declared on BOTH platforms, -#: so subtracting the preset list alone would report four keys as Instagram-only that TikTok's own -#: table has carried since wave 29. -IG_ONLY_PROFILE_KEYS = (frozenset(f["key"] for f in CANDIDATE_FIELDS) - - frozenset(f["key"] for f in TT_PROFILE_FIELDS)) - -#: ⭐⭐ WAVE 32 · T42 — the preset columns NOBODY TYPES: every `link` and `rollup` in the Instagram -#: preset set. Derived from the declaration, so adding a rollup to the contract widens this for -#: free. `_carries_ig_presets` uses it as the leg that survives when the `automation.preset` stamp -#: is absent — a hand-made creator list has `handle` and `followers`; it does not have `posts_link`. -PRESET_MACHINE_ONLY_KEYS = frozenset( - f["key"] for f in PRESET_PROFILE_FIELDS if f.get("type") in ("link", "rollup")) - - -def platform_schema(platform): - """⭐⭐ WAVE 32 · T41 — ONE ASKABLE DECLARATION OF WHAT A PLATFORM'S DATABASES ARE. - - Returns, for `PLATFORM_INSTAGRAM` or `PLATFORM_TIKTOK`: - - {"platform", "profile_table", "profile_label", "profile_fields", "preset_keys", - "only_keys", "locked_tables", "children": {key: {"label", "fields", "record_mode"}}} - - ⛔ **WHY IT IS A FUNCTION AND NOT A DICT LITERAL — the same import-order trap `discovery_facts` - records twenty lines from its own declaration.** `CANDIDATE_FIELDS` and `DISCOVER_TABLE` are - declared HUNDREDS of lines below `TT_TABLE_FIELDS`, so any module-level dict spanning both - platforms NameErrors at import. A function body resolves at call time and does not care. That is - not a style preference here; it is the reason two earlier attempts at a platform registry in this - file ended up as five inline copies instead. - - ⛔⛔ **AND IT IMPORTS NOTHING FROM `core`, WHICH IS THE ACTUAL CONTRACT WITH `main.py`.** This - module is deliberately dependency-light on the API's boot path (`MAX_UT_ROWS`, `MACHINE_OWNERS` - and `LOCKED_CHILD_TABLES` all say so in their own notes), and the REGISTRAR — the thing that - turns these names into `core.user_tables` state — lives in the composition root. So this returns - plain lists and dicts: a caller can `register_locked_records(...)`, `ut_ensure(...)` or diff it - against a live tenant without `core` ever appearing on the engine's import path. `verify_ - automation` asserts that, by importing this module in a CLEAN interpreter and checking - `core.user_tables` is absent from `sys.modules` afterwards — [[artifact-with-no-importer]] in - reverse: a declaration whose registrar cannot reach it is the same defect as a registrar with - nothing to register. - - ⚠ FIELD DICTS ARE COPIED ONE LEVEL. Every existing call site passes the module lists straight - into `ut_ensure`, which is safe only because nobody has yet appended to one; a boot sweep looping - over both platforms is exactly the caller that would. The copy costs a few hundred dict - constructions once per boot and removes the whole question. - - ⚠ `profile_fields` is the platform's PRESET set as spawned — Instagram's is `CANDIDATE_FIELDS` - (the preset set PLUS discovery's bookkeeping), not `PRESET_PROFILE_FIELDS`, because that is what - `discovery_facts` actually hands the spawn. Two answers to "Instagram's field set" is the drift - this function exists to end, so it gives the one the product uses. - """ - name = str(platform or "") - if name == PLATFORM_TIKTOK: - children = {k: {"label": TT_TABLE_LABELS[k], "fields": [dict(f) for f in TT_TABLE_FIELDS[k]], - "record_mode": tt_record_mode(k)} - for k in sorted(TT_TABLE_FIELDS) if k != TT_PROFILE_TABLE} - return { - "platform": PLATFORM_TIKTOK, - "profile_table": TT_PROFILE_TABLE, - "profile_label": TT_TABLE_LABELS[TT_PROFILE_TABLE], - "profile_fields": [dict(f) for f in TT_PROFILE_FIELDS], - "preset_keys": tuple(f["key"] for f in TT_PROFILE_FIELDS), - "only_keys": TT_ONLY_PROFILE_KEYS, - "locked_tables": TT_LOCKED_TABLES, - "children": children, - } - if name == PLATFORM_INSTAGRAM: - children = {k: {"label": IG_TABLE_LABELS[k], "fields": [dict(f) for f in fields], - # ⚠ NOT `tt_record_mode`'s twin by accident: `ensure_ig_graph` passes - # `AUTOMATION_RECORD_MODE` for all four unconditionally, and - # `IG_LOCKED_TABLES` is exactly those four. Derived from the SET so that - # unlocking one there unlocks it here, rather than from the constant. - "record_mode": (AUTOMATION_RECORD_MODE if k in IG_LOCKED_TABLES else "")} - for k, fields in sorted(IG_TABLE_FIELDS.items())} - return { - "platform": PLATFORM_INSTAGRAM, - "profile_table": DISCOVER_TABLE, - "profile_label": DISCOVER_LABEL, - "profile_fields": [dict(f) for f in CANDIDATE_FIELDS], - "preset_keys": tuple(f["key"] for f in CANDIDATE_FIELDS), - "only_keys": IG_ONLY_PROFILE_KEYS, - "locked_tables": IG_LOCKED_TABLES, - "children": children, - } - raise ValueError(f"no schema is declared for platform {platform!r}") - - -def definition_platforms(defn): - """Which networks ONE automation uses — `{"Instagram"}`, `{"TikTok"}`, both, or empty. - - ⭐ WAVE 32 · T42. Three ways a definition names a network, and all three count, because the - owner's rule is about what a database is USED FOR rather than about how the automation was - built: its discovery KIND (a corpus search is a network by construction), its enrich ACTIONS - (a `plain` flow that enriches is using that network), and the retired `field_instagram` kind, - which is uncreatable but alive on stored definitions (D-65) and still runs Instagram. - """ - defn = defn or {} - kind = str(defn.get("kind") or "") - out = set() - if kind in DISCOVERY_KINDS: - out.add(discovery_facts(kind)[0]) - if kind == "field_instagram": - out.add(PLATFORM_INSTAGRAM) - actions = (defn.get("flow") or {}).get("actions") or [] - if _actions_of_kind(actions, "enrich_instagram"): - out.add(PLATFORM_INSTAGRAM) - if _actions_of_kind(actions, "enrich_tiktok"): - out.add(PLATFORM_TIKTOK) - return out - - -def automation_platforms_by_table(rt, definitions=None): - """⭐⭐ WAVE 32 · T42 (owner item 1, third clause) — WHICH NETWORKS EACH DATABASE IS USED FOR. - - `{table_key: {"Instagram", "TikTok"}}`, over every stored automation. The owner's words are the - whole specification: *"if a user decide to use one database for both Tiktok and Instagram - scraping, only then can the pre-set Fields can exist in the same database."* — so the question a - schema pass has to be able to ask is *"which networks target THIS database"*, and until now - nothing in this module could answer it. - - ⛔ ASKABLE AND `core`-FREE, for `platform_schema`'s reason one function up: it reads only the - automations bucket through `all_definitions`, returns plain sets, and the T41 subprocess probe - covers it — a second askable declaration that quietly needed `core` would make the import-purity - guarantee mean "true for the things we remembered". - - ⚠ `definitions` is a LENT list, same contract as `ut_ensure`'s `tables=`: a caller already - holding the bucket must not pay for a second deep copy of it. - """ - # ⚠ `all_definitions` answers a DICT keyed by id, not a list — and iterating it directly hands - # every consumer a STRING that looks like a definition until the first `.get`. Normalised here - # so a caller lending its own list gets the same treatment. - defs = all_definitions(rt) if definitions is None else definitions - defs = list(defs.values()) if isinstance(defs, dict) else list(defs or []) - out = {} - for defn in defs: - if not isinstance(defn, dict): - continue - table = str(_flow_table(defn) or (defn.get("config") or {}).get("targetTable") or "") - if not table: - continue - found = definition_platforms(defn) - if found: - out.setdefault(table, set()).update(found) - return out - - -def platform_schemas(): - """Both declarations, in `PLATFORMS` order — what a boot sweep loops over. - - ⚠ `PLATFORM_FACEBOOK` is in `PLATFORMS` (it is a value the `platform` COLUMN may hold) and has - no schema, so this filters rather than raising: a caller sweeping every declared platform must - not be broken by a vocabulary entry that names no databases. `platform_schema` still raises for - it, which is the right answer to somebody ASKING for a schema that does not exist. - """ - out = [] - for name in PLATFORMS: - try: - out.append(platform_schema(name)) - except ValueError: - continue - return out - - -def _ig_profile_tables(rt, tables=None): - """Every `ut_*` table in this tenant that looks like an Instagram profile table. - - ``tables`` is the optional lent snapshot (W29-T01): read-only walk, so a caller that already - holds the bucket must not pay for a second deep copy of it. - - ⛔ WAVE 30 · T08 — AND THE TEST IS NOW EXCLUSIVE, because "looks like" was network-blind and a - TikTok profile table looks EXACTLY like one: 18 of `ut_tt_profile`'s 30 columns are Instagram - preset keys, and the bar here is `handle` plus three. Every caller of this function then treats - the match as an Instagram table — `migrate_ig_tables` rewrites its declarations to Instagram's - contract, and `discover_default_table` offers it as the default target for an Instagram search. - """ - out = [] - for key, tbl in sorted(((ut_all(rt) if tables is None else tables) or {}).items()): - keys = {f.get("key") for f in (tbl or {}).get("fields") or []} - if "handle" not in keys or len(keys & set(PRESET_PROFILE_KEYS)) < MIGRATE_MIN_PRESET_FIELDS: - continue - if _is_tt_profile_table(tbl): - continue - out.append(key) - return out - - -def _ig_contract_tables(rt, tables=None): - """⭐⭐ WAVE 32 · T42 — the tables Instagram's preset CONTRACT may be applied to. - - ⛔⛔ THIS IS A DIFFERENT QUESTION FROM `_ig_profile_tables` AND CONFLATING THEM COST FIVE RED - CHECKS. That function answers *"does this database LOOK like an Instagram profile table?"* and - two callers need exactly that: `discover_default_table`, which elects an existing table so a new - automation does not mint a second empty one — and it necessarily runs BEFORE any automation - targets that table, so a targeted-only test makes it elect nothing forever — and the detector's - own negative control. THIS function answers *"may we write Instagram's 48 columns into it?"*, - which is the owner's rule and a strictly narrower set. One normalizer answering two questions is - how a fix in one becomes a silent regression in the other ([[one-question-two-normalizers]]). - - A table qualifies when it looks like one AND any of: - - it is `ut_ig_profile`, Instagram's canonical database by declaration; - - it ALREADY carries the contract (`_carries_ig_presets`) — narrowing must never orphan a - table that has the columns, or its links and rollups silently stop being repaired; - - an INSTAGRAM automation targets it, which is the owner's rule stated in code. - - ⚠ The automations bucket is read LAZILY, on the first candidate that needs the question asked — - a tenant whose tables all already carry the contract pays nothing, on a path this wave is - otherwise trying to make faster. - """ - targets = None - out = [] - for key in _ig_profile_tables(rt, tables=tables): - tbl = (ut_all(rt) if tables is None else tables).get(key) or {} - if key != DISCOVER_TABLE and not _carries_ig_presets(tbl): - if targets is None: - targets = automation_platforms_by_table(rt) - if PLATFORM_INSTAGRAM not in targets.get(key, set()): - continue - out.append(key) - return out - - -def _preset_owned(field): - """Is this column MACHINE-authored, i.e. may `retract_foreign_presets` delete it? - - ⛔ MODULE-LEVEL, NOT A CLOSURE, AND THAT IS WHY IT MOVED. It was a nested `_machine` and the - negative control written against it came out BLIND: nothing outside the function could break - the one guard standing between a boot-time repair and a customer's own column, so the control - ended up patching the key SETS instead and proved something else. A guard a control cannot - reach is a guard nobody has tested [[gate-negative-control]]. - - Two legs, the second for tables that predate the stamp: the `automation.preset` mark that - `ut_ensure(lock_fields=True)` and `_locked_ig_field` both write, or a key that is a preset - LINK/ROLLUP — nobody types `posts_link` by hand. - """ - automation = (field or {}).get("automation") - if isinstance(automation, dict) and automation.get("preset") is True: - return True - return str((field or {}).get("key") or "") in PRESET_MACHINE_ONLY_KEYS - - -def _filled_count(rows, key): - """How many rows carry a non-blank value in `key` — the number that decides REPORT vs DELETE. - - ⛔ ALSO MODULE-LEVEL FOR ITS CONTROL'S SAKE. This one number is the whole of W30/R6's second - sentence inside `retract_foreign_presets`: a foreign column with cells in it is reported, never - deleted. A control that cannot make this lie cannot prove the report is doing anything. - """ - return sum(1 for r in (rows or {}).values() - if isinstance(r, dict) and str(r.get(key) or "").strip()) - - -def retract_foreign_presets(rt, log=print): - """⭐⭐ WAVE 32 · T46 / DEBT D-152 — REMOVE THE PRESET COLUMNS OF A NETWORK A DATABASE IS NOT - USED FOR. The RETRACTION half of `_ig_contract_tables`' recruitment rule, and A's boot sweep - (`W32-T07`) calls it once per tenant. - - ⛔ WHY IT HAS TO EXIST AT ALL, and this is the wave's own thesis one layer down. W30-T08 fixed - the DETECTOR, so the corruption cannot recur — and nothing undoes it. MEASURED on a fixture: a - `ut_tt_profile` corrupted before that fix keeps ALL 26 Instagram-only columns and its - `profile: {source: "instagram"}` flag through `ut_ensure`, `migrate_ig_tables` AND the discovery - spawn. Three write paths, zero repairs. *"The corruption cannot recur"* and *"the corruption is - gone"* are different claims [[a-migration-that-runs-on-the-next-write]]. - - Returns `{"tables", "columns", "cells", "flags", "kept"}`. **`kept` is the important one** — - see guarantee 4. - - THE FOUR GUARANTEES, in the order they matter: - 1. **Only machine-authored columns go.** A field must be stamped `automation.preset: True` - (or be a link/rollup this contract owns) to be eligible. A column a PERSON typed is never - touched, whatever it is named — the D-152 row calls this *"a VALUE BACKFILL over live - customer rows"*, and that is the line it must not cross. - 2. **Only the EXCLUSIVE sets.** `IG_ONLY_PROFILE_KEYS` (26) and `TT_ONLY_PROFILE_KEYS` (8), - both derived. A key both platforms declare — `platform`, `handle`, `followers`, the four - discovery bookkeeping columns — is shared vocabulary and is never a foreign column. - 3. **Idempotent.** A second call finds nothing and writes nothing, so it is safe on every - boot, which is what makes it callable from `main.py` rather than being a script somebody - has to remember to run (the whole reason D-201 is still open). - 4. ⛔ **A FOREIGN COLUMN THAT HOLDS DATA IS REPORTED, NOT DELETED.** W30/R6's second sentence: - a limit that cannot be removed is reported with its cause. Deleting a customer's populated - cells to make a grid look clean is not a repair, and this is precisely the *"deserves its - own supervision"* clause of D-152. `kept` names every such column with its row count, so - the caller can print it and a person can decide. - - ⚠ THE FLAG IS REPAIRED TOO, and it is the half D-152 names explicitly: a TikTok profile table - whose `handle` was re-declared `profile: {source: "instagram"}` answers `""` to - `profile_field_key(..., source="tiktok")` forever, so every TikTok enrich on it reports UNBOUND. - Repaired only where `_is_tt_profile_table` recognises the table by its OWN TikTok-only columns, - i.e. by the leg the corruption cannot have eaten. - """ - stats = {"tables": 0, "columns": 0, "cells": 0, "flags": 0, "kept": []} - targets = automation_platforms_by_table(rt) - exclusive = {PLATFORM_INSTAGRAM: IG_ONLY_PROFILE_KEYS, - PLATFORM_TIKTOK: TT_ONLY_PROFILE_KEYS} - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - for key, tbl in sorted(cur.items()): - if not isinstance(tbl, dict): - continue - fields = tbl.get("fields") or [] - keys = {str(f.get("key") or "") for f in fields} - # Only PROFILE databases are in scope: `handle` plus a real slice of either preset - # vocabulary. A posts or comments table shares no profile columns and is never a - # candidate; a database with nothing in common with either is not one either. - if "handle" not in keys: - continue - tt = _is_tt_profile_table(tbl) - used = set(targets.get(key) or ()) - if not used: - # Nothing targets it. Its own declaration is then the only statement of what it is - # for — and a table that says TikTok is not an Instagram table, whatever columns a - # past migration wrote into it. - used = {PLATFORM_TIKTOK} if tt else {PLATFORM_INSTAGRAM} - foreign = set() - for platform, own in exclusive.items(): - if platform not in used: - foreign |= set(own) - if not foreign: - continue - rows = tbl.get("rows") or {} - drop, touched = [], False - for f in fields: - fkey = str(f.get("key") or "") - if fkey not in foreign or not _preset_owned(f): - continue - filled = _filled_count(rows, fkey) - if filled: - # Guarantee 4 — REPORTED, never deleted. - stats["kept"].append({"table": key, "column": fkey, "rows": filled}) - continue - drop.append(fkey) - if drop: - tbl["fields"] = [f for f in fields if str(f.get("key") or "") not in drop] - for r in rows.values(): - if not isinstance(r, dict): - continue - for fkey in drop: - if fkey in r: - r.pop(fkey, None) - stats["cells"] += 1 - stats["columns"] += len(drop) - touched = True - # ⚠ THE FLAG, and only on a table TikTok's own columns still identify. - if tt: - for f in tbl.get("fields") or []: - p = f.get("profile") - if isinstance(p, dict) and str(p.get("source") or "") == PROFILE_SOURCE_IG: - f["profile"] = {**p, "source": PROFILE_SOURCE_TT} - stats["flags"] += 1 - touched = True - if touched: - stats["tables"] += 1 - return cur - - # ⚠ The precheck is the WHOLE mutation, run against a snapshot, so the common case (nothing to - # do) spends no commit — the same posture `_reconcile_ig_graph_fields` takes, and the reason - # this is safe to call on every boot. - probe = copy.deepcopy(ut_all(rt)) - _up(probe) - if not (stats["columns"] or stats["flags"]): - if stats["kept"]: - log(f"[aios-auto] retract: {len(stats['kept'])} foreign column(s) KEPT because they " - f"hold data — " + ", ".join(f"{k['table']}.{k['column']} ({k['rows']} rows)" - for k in stats["kept"])) - return stats - stats = {"tables": 0, "columns": 0, "cells": 0, "flags": 0, "kept": []} - rt.update(UT_STORE_KEY, _up, flush="sync") - log(f"[aios-auto] retract: {stats['columns']} foreign column(s), {stats['cells']} cell(s) and " - f"{stats['flags']} mis-declared flag(s) across {stats['tables']} database(s)") - if stats["kept"]: - log(f"[aios-auto] retract: {len(stats['kept'])} foreign column(s) KEPT because they hold " - f"data — " + ", ".join(f"{k['table']}.{k['column']} ({k['rows']} rows)" - for k in stats["kept"])) - return stats - - -def _carries_ig_presets(tbl): - """Has this table ALREADY been given Instagram's preset contract? - - ⭐⭐ WAVE 32 · T42 — THE RECRUIT/REPAIR LINE, AND IT RESTS ON A STAMP THE CODE ALREADY WRITES. - `ut_ensure(lock_fields=True)` and `_locked_ig_field` both set `automation: {preset: True}` on - every preset column, and `migrate_ig_tables` BACKFILLS it on a table that predates the stamp - (MEASURED: an unstamped 44-column fixture comes out 44/44 stamped after one pass). So "already - carries the contract" is a declaration to read, not a threshold to invent — and inventing one - was the alternative, because `MIGRATE_MIN_PRESET_FIELDS` is 3 and cannot tell a recruited table - from a hand-made creator list that happens to have `handle`, `followers` and `bio`. - - ⛔ WHY THE LINE EXISTS AT ALL (owner item 1's third clause, MEASURED): a hand-made 5-column - database that NO automation targets went to **45 columns** the moment an Instagram automation - aimed at a DIFFERENT database was SAVED — because `_ig_schema_contract` walks every - structurally-matching table in the tenant and hands each one the full 48-column contract. The - owner's rule is that the preset fields may live in a database only if it is used for that - scraping; recruitment by resemblance is the opposite of that rule. - - ⚠ NARROWING NEVER DROPS A COLUMN. `_reconcile_ig_graph_fields` deletes only the fixed retired-key - `drops` set, so a de-recruited table keeps every field and cell it has — it stops being ADDED to - and REPAIRED, which is the whole of the change. Asserted on a fixture rather than reasoned. - ⚠ AND THE COST IS PAID ONLY WHEN IT HAS TO BE: `_ig_profile_tables` reads the automations bucket - lazily, on the first candidate that is neither stamped nor the canonical `ut_ig_profile` — so a - tenant whose tables are all genuinely Instagram's pays nothing on a path this wave is otherwise - trying to make faster. - - ⛔⛔ TWO LEGS, AND THE SECOND IS THE ONE THAT KEEPS THIS FROM BREAKING A LIVE TENANT. The stamp - is written by the very passes this predicate now gates, so a table recruited BEFORE the stamp - existed and targeted by no surviving automation would be de-recruited and never stamped — - silently losing its rollup/link repair, which is the slowest and worst failure available here - (MEASURED on a stripped fixture: 44 columns, 0 stamps, 0 repaired). So a table ALSO counts as - already-carrying when it declares one of the preset set's LINK or ROLLUP columns. Those are - machine-authored by construction — nobody types `posts_link` or `avg_views_12` into a hand-made - creator list — so the second leg is derived from the contract itself, not a threshold somebody - picked. [[gate-and-nc-must-not-share-a-binding]]'s cousin: a discriminator written by the thing - it discriminates needs an independent second leg, exactly as `_is_tt_profile_table` needed one. - """ - fields = (tbl or {}).get("fields") or [] - for f in fields: - if str(f.get("key") or "") not in PRESET_PROFILE_KEYS: - continue - automation = f.get("automation") - if isinstance(automation, dict) and automation.get("preset") is True: - return True - return bool({str(f.get("key") or "") for f in fields} & PRESET_MACHINE_ONLY_KEYS) - - -def discover_default_table(rt, tables=None): - """Which database should a discovery automation write to when nobody has said? (2026-08-10) - - Owner: *"the damn database is supposed to be dynamic for whatever instagram profile is there. - We only need ONE IG profile so it's not confusing."* - - ⛔ THE COMPLAINT WAS ABOUT A SECOND EMPTY DATABASE, NOT A HARDCODED KEY. `ut_beauty_influencer_ - leads` appears NOWHERE in this product as a literal — it is a slug `ut_ensure` minted from the - label the tenant typed, and `_ig_profile_tables` finds it structurally (that function's own - note says so). What IS hardcoded is `DISCOVER_TABLE`, the FALLBACK target — and because it is - a constant rather than a question, a tenant that already had an Instagram profile database got - a SECOND, empty one the first time a discovery automation was saved without a target. - MEASURED on nurilab: `ut_ig_candidates` (0 rows, 44 preset columns) sitting beside - `ut_beauty_influencer_leads` (105 rows), both matching the profile predicate, one of them - pure confusion. - - So the default becomes a question asked of the tenant: - - exactly one profile database (excluding the fallback) -> that one - several, exactly one of which holds rows -> the one in use - several in use, or none at all -> "" (the caller keeps DISCOVER_TABLE) - - ⚠ RETURNS "" RATHER THAN GUESSING. Two populated profile databases is a real ambiguity and - picking one silently would write a paid discovery run into a database the user did not name — - worse than the empty table this exists to prevent. "" means "no opinion", and the caller's - existing fallback stands, which is exactly today's behaviour for that case. - ⚠ THE FALLBACK IS EXCLUDED FROM ITS OWN ELECTION. `ut_ig_candidates` carries the full preset - schema, so it matches `_ig_profile_tables` — without this line a tenant that already has the - empty table would keep re-electing it and nothing would ever change. - - ⭐ WAVE 29 (W29-T01) — ONE READ, NOT TWO. This function read the whole `user_tables` bucket - here and `_ig_profile_tables` read it AGAIN one line below: two 35.8 MB-ceiling deep copies to - answer one question, on a route the automation surface calls on every open. The election is - now computed from a single snapshot, which is also the only way it can be internally - consistent — the two reads could disagree with each other under a concurrent write. - ``tables`` lets `GET /automations` lend the copy it already holds; the answer is still derived - fresh on every call, so there is no memo to invalidate when a database is created or deleted. - """ - try: - tables = (ut_all(rt) if tables is None else tables) or {} - found = [k for k in _ig_profile_tables(rt, tables=tables) if k != DISCOVER_TABLE] - except Exception: # noqa: BLE001 - return "" - if len(found) == 1: - return found[0] - if len(found) > 1: - used = [k for k in found if (tables.get(k) or {}).get("rows")] - if len(used) == 1: - return used[0] - return "" - - -def _apply_discover_default(rt, raw): - """Fill in a discovery automation's target BEFORE the pure validator invents one. - - ⛔ IT HAS TO HAPPEN HERE, AND THE REASON IS THE WHOLE DESIGN. `clean_config` is the thing that - turns a missing target into `DISCOVER_TABLE` — and it takes no `rt`, by design (it is a pure - validator with a two-argument contract every gate and route depends on). Resolving only at the - RUN sites would be cosmetic: the literal is written into the STORED config at save time, so - `cfg.get("targetTable")` is truthy forever after and no runtime resolver is ever consulted. - `create`/`patch` are the one pair that both know the tenant and sit above that validator. - ⚠ ONLY WHEN THE CALLER SAID NOTHING. An explicit target — including one a previous save - stored — is the user's choice and is never rewritten. - """ - cfg = raw.get("config") - if not isinstance(cfg, dict) or str(cfg.get("targetTable") or "").strip(): - return raw - default = discover_default_table(rt) - if not default: - return raw - return {**raw, "config": {**cfg, "targetTable": default}} - - -def _vestigial_name_field(fields, rows): - """⭐ 2026-08-07 — the born-blank `Name` column, or None. The owner's *"REPLACED"* half. - - `user_tables.create()` mints EXACTLY ONE column on a hand-made database — - `{key:'name', label:'Name', type:'text', default:True}` — and `ut_ensure` then APPENDS the - automation's columns after it, never reordering. So on every Instagram database somebody - created before pointing an automation at it, column 1 is a `Name` nothing will ever write. - - ⛔ IT IS ONLY VESTIGIAL IF NOBODY EVER USED IT, and the test is deliberately conservative - because this is the one branch in the migration that DESTROYS something. A column carrying any - declaration (an automation binding, a metric, a profile flag) is somebody's work; a column with - a value in any row is somebody's data. Either way it survives as an ordinary column and merely - stops being the locked primary, which the `pinned` half achieves on its own. - - ⚠ `key == 'name'` IS ALREADY DECISIVE and the rest is belt: a user-created column is minted - `custom__` (`buildOverlayField`) and every machine one comes from a `field_def` - list, so nothing but `create()`'s default can hold this key. The label and type are checked - anyway — a renamed or retyped column is a column someone touched on purpose. - """ - f = next((f for f in fields if f.get("key") == "name"), None) - if f is None or f.get("label") != "Name" or f.get("type") != "text": - return None - if any(f.get(k) for k in ("automation", "metric", "profile", "measure", "formula")): - return None - if any(str(r.get("name") or "").strip() for r in rows.values()): - return None - return f - - -def _merge_candidates(rows): - """C3's merge rule, applied to rows now colliding on `(platform, handle)`. - - Earliest `first_found` wins - latest `last_found` wins - `found_count` SUMS - every other cell - takes the first non-blank, preferring the most recently enriched row. - - ⚠ THE SURVIVING ROW KEEPS THE LOWEST ID. Row ids are referenced by saved views, comments and - board cards; minting a new one would orphan all of them, so a merge picks a survivor rather - than creating one. - """ - groups, keepers = {}, {} - for rid in sorted(rows, key=lambda r: (len(str(r)), str(r))): - r = rows[rid] - h = str(r.get("handle") or "").strip() - if not h: - # ⛔⛔ THESE USED TO BE `continue`d, AND THE OUTPUT REPLACES THE TABLE'S ROWS — so a - # row with no handle was SILENTLY DELETED. Owner, 2026-08-07: *"How come when I add a - # manual handle in my 'Beauty influencer leads' database, after an automation run, it - # gets deleted?"* - # - # A row you add by hand is born blank and stays blank until you type into it, so the - # window is not narrow — it is every row, from creation until the cell is committed. - # And before the profile flag shipped (834decd) a typed handle landed in the TYPIST'S - # OVERLAY stratum, leaving the definition row's `handle` empty forever: the row was - # dropped even after it looked filled in on screen. - # - # ⚠ THE FUNCTION'S JOB IS TO MERGE DUPLICATE CANDIDATES, and a row with no handle is - # not a candidate — it is somebody's row. It cannot collide with anything (there is - # nothing to key it on), so it is carried through UNTOUCHED rather than judged. A - # merge pass that deletes what it cannot classify is not a merge, it is a filter. - keepers[rid] = r - continue - groups.setdefault((str(r.get("platform") or PLATFORM_INSTAGRAM).strip(), h), - []).append((rid, r)) - out, merged = dict(keepers), 0 - for _key, members in groups.items(): - if len(members) == 1: - out[members[0][0]] = members[0][1] - continue - merged += len(members) - 1 - # Most-recently-enriched first, so "first non-blank" prefers the freshest measurement. - ordered = sorted(members, key=lambda m: str(m[1].get("enriched_at") or ""), reverse=True) - keep_id = sorted(rid for rid, _r in members)[0] - acc = {} - for _rid, r in ordered: - for k, v in r.items(): - if k not in acc and str(v or "").strip(): - acc[k] = v - firsts = [str(r.get("first_found") or "").strip() for _i, r in members if r.get("first_found")] - lasts = [str(r.get("last_found") or "").strip() for _i, r in members if r.get("last_found")] - if firsts: - acc["first_found"] = min(firsts) - if lasts: - acc["last_found"] = max(lasts) - total = sum(_ig_int(r.get("found_count")) or 0 for _i, r in members) - if total: - acc["found_count"] = str(total) - out[keep_id] = acc - return out, merged - - -#: ⛔ DEBT D-74 + D-81 (wave 27 item 12) — `tracked` WAS DELETED FROM THE VOCABULARY AND NEVER -#: FROM THE TABLES. W26/R6 removed the column from `CANDIDATE_FIELDS` and from ~14 engine sites -#: *"we already have a Field called stage… we don't need another checkbox for this"* — and -#: MEASURED 2026-08-07 on `royal-imports/aios-nurilab-data`, `ut_beauty_influencer_leads` still -#: declared it, filled on 1 of 41 rows. A checkbox on the owner's flagship database that nothing -#: writes, nothing reads, and anybody can still click. -#: -#: ⚠ THE DECISION WAS WHICH, NOT WHETHER (D-74 stated both futures): sweep the ticks and lose the -#: record of who approved what before the wave, or leave them and let the next hand-made `tracked` -#: column silently inherit them. The wave takes the sweep — R6 deleted the concept, so a surviving -#: tick is not a decision anybody can act on, and an inert cell that reappears in the next EXPORT -#: as a column nobody declared is the worse half. -TRACKED_KEY = "tracked" - - -def _retired_tracked_field(fields): - """The retired `tracked` column when it is still the MACHINE's own, else None. - - ⛔ GUARDED THE WAY `_vestigial_name_field` IS, and for the same reason: this is a branch that - DESTROYS something. `tracked` was minted `{type: "checkbox"}` by the discovery preset, so a - column still declaring a checkbox is the one R6 retired. A column somebody has since RETYPED, - or bound a formula/link/rollup/measure to, is their work wearing an old key — it survives as - an ordinary column and merely stops being fed by anything, which was already true. - - ⚠ THE ASYMMETRY WITH THE TWO KEYS BESIDE IT IS DELIBERATE. `posts` and `post_hashtags` are - dropped unguarded: both are machine-derived cells with no history of anyone editing them. - `tracked` is the one a HUMAN ticked, so it is the one that gets the conservative test. - """ - f = next((f for f in fields if str(f.get("key") or "") == TRACKED_KEY), None) - if f is None or f.get("type") not in ("checkbox", "", None): - return None - if any(f.get(k) for k in ("formula", "link", "rollup", "measure", "metric")): - return None - return f - - -def _guarded_drops(table, drops): - """`drops` minus any key this table is allowed to keep — today, exactly `tracked` (D-81). - - ⛔ THE GUARD HAS TO LIVE WHERE THE DROP HAPPENS, and finding that out cost a red check. - `tracked` is deleted from TWO places: `migrate_ig_tables`' profile loop, which asks - `_retired_tracked_field` whether the column is still the machine's own, and the schema - reconciliation, which drops by KEY with no question asked. Guarding only the first was - decorative — the schema pass runs immediately afterwards on the same table and deleted the - retyped column the guard had just spared. Two paths to one deletion with one of them checked - is the shape where a control looks present and enforces nothing - ([[defects-that-mask-each-other]]). - """ - drops = set(drops or ()) - if TRACKED_KEY in drops: - stored = [f for f in (table or {}).get("fields") or [] - if str(f.get("key") or "") == TRACKED_KEY] - if stored and _retired_tracked_field(stored) is None: - drops.discard(TRACKED_KEY) - return drops - - -def _orphan_tracked_rows(fields, rows): - """Row ids carrying a `tracked` CELL that no field declares — D-74's actual subject. - - ⛔ THE COLUMN AND THE CELLS WERE RETIRED SEPARATELY, WHICH IS WHY THIS IS NOT COVERED BY THE - BRANCH ABOVE. R6 deleted the field DEFINITION from `CANDIDATE_FIELDS`; a table whose - declaration was already dropped keeps every `tracked` key in its row dicts, invisible on every - surface that renders from `fields` — and a migration that only looks at `fields` finds nothing - to do and `continue`s past the table. Storage, export and any future re-declaration of the key - all still see them. - """ - if any(str(f.get("key") or "") == TRACKED_KEY for f in fields): - return [] # the column above owns its own cells - return [rid for rid, r in rows.items() if TRACKED_KEY in (r or {})] - - -def backfill_corpus_snapshots(rt, log=print): - """The BACKFILL half: every profile row holding a measurement with no series behind it gets - its corpus observation. Returns how many were written. - - ⛔ ONE BUILDER, TWO CALLERS — `run_discover_instagram` for rows arriving now, this for rows - that arrived before the forward fix existed. A second row-shape here would be two ideas of - what a corpus observation is, and they would disagree on the next promoted column. - - ⚠ THE CONDITION IS "NO SERIES AT ALL", not "no series for this day", and the narrowness is - deliberate. A profile that HAS been enriched already owns real observations; synthesising a - corpus row dated `last_found` for it could out-rank the exact read in a `latest` rollup and - replace a measured number with a corpus one. The gap this closes is a row with NOTHING behind - it, which is the only case where a corpus observation can only improve matters. - - ⚠ IDEMPOTENT BY THE SNAPSHOT KEY (`@`), never by a flag — so this is safe on - every write, which is what `migrate_ig_tables`' placement requires. After one pass the row has - a series, so it stops matching and the scan costs a dict comparison. - """ - snaps = ut_get(rt, IG_SNAPSHOTS_TABLE) - if snaps is None: - return 0 # no series store — see `append_ig_snapshots` on why we never mint one - known = {str((r or {}).get("influencer_key") or "").strip().lower() - for r in (snaps.get("rows") or {}).values()} - known.discard("") - #: A cell counts as a MEASUREMENT if the snapshot schema has somewhere to put it — derived - #: from `SNAPSHOT_FIELDS` rather than named, for `_ig_profile_tables`' own reason (D-71: a - #: named list goes silently empty the day the schema moves). - measured = tuple(fd["key"] for fd in SNAPSHOT_FIELDS - if fd["key"] not in _SNAPSHOT_OWN_KEYS - and fd.get("type") in ("int", "pct")) - incoming = [] - for table_key in _ig_profile_tables(rt): - for row in ((ut_get(rt, table_key) or {}).get("rows") or {}).values(): - handle = str((row or {}).get("handle") or "").strip().lstrip("@").lower() - if not handle or handle in known: - continue - if not any(str((row or {}).get(k) or "").strip() for k in measured): - continue # nothing was ever read about this row — there is no observation to record - built = corpus_snapshot_row(row) - if built: - incoming.append(built) - known.add(handle) # two profile tables holding one handle write ONE observation - return append_ig_snapshots(rt, incoming, log=log) - - -def backfill_post_snapshot_grain(rt, log=print): - """Copy `posted_at`/`type` off the POST row onto every measurement missing them. - - ⛔ STRUCTURAL, NEVER A FLAG: the row matches when the snapshot's cell is blank AND the post's - is not, so a pass that has already run matches nothing and a post that genuinely has no date - is never re-visited on a promise it cannot keep. Same idempotency discipline as the migration - around it. - - ⚠ IT FILLS BLANKS AND NEVER OVERWRITES. A snapshot's `posted_at` is what the vendor said WHEN - THE MEASUREMENT WAS TAKEN; if a later pull disagrees with the post row, the measurement's own - record of the moment is the one to keep — a fact table that lets a dimension rewrite its - history is not a fact table. - """ - psnaps = ut_get(rt, IG_POST_SNAPSHOTS_TABLE) - posts = ut_get(rt, IG_POSTS_TABLE) - if psnaps is None or posts is None: - return 0 - have = {f.get("key") for f in (psnaps.get("fields") or [])} - carried = [k for k in ("posted_at", "type") if k in have] - if not carried: - return 0 # the columns are not declared yet — `ut_ensure` adds them first - by_shortcode = {} - for row in (posts.get("rows") or {}).values(): - code = str((row or {}).get("shortcode") or "").strip() - if code: - by_shortcode[code] = row or {} - changes = {} - for rid, row in (psnaps.get("rows") or {}).items(): - post = by_shortcode.get(str((row or {}).get("shortcode") or "").strip()) - if not post: - continue - for key in carried: - want = str(post.get(key) or "").strip() - if want and not str((row or {}).get(key) or "").strip(): - changes.setdefault(str(rid), {})[key] = _s(want, 200) - if not changes: - return 0 - - def _up(cur): - cur = cur if isinstance(cur, dict) else {} - table = cur.get(IG_POST_SNAPSHOTS_TABLE) - if table is not None: - for rid, values in changes.items(): - table.setdefault("rows", {}).setdefault(rid, {}).update(values) - return cur - - rt.update(UT_STORE_KEY, _up, flush="sync") - log(f"[ig-migrate] post-measurement grain: {len(changes)} row(s) gained a post date/kind") - return len(changes) - - -#: ⭐⭐ 2026-08-10 (owner: *"retire the old hardcoded method and replace the Instagram database work -#: with correct Rollup and Link fields"*) — THE PROFILE COLUMNS THAT MAY BECOME ROLLUPS. -#: -#: A column qualifies when the SERIES has somewhere to put it: `SNAPSHOT_FIELDS` carries the same -#: key, so `latest` over `profile_snapshots_link` can re-derive it. That is 27 of the 44 preset -#: columns — the other 17 are identity/bookkeeping (`handle`, `platform`, `enriched_at`), already -#: relational (the 12 links and rollups), or have no observation behind them at all -#: (`location_guess`/`location_confidence`, which WE infer rather than read). -#: -#: ⛔⛔ AND "DERIVABLE" IS NOT "SAFE", WHICH IS THE WHOLE REASON THIS IS A PREDICATE AND NOT A LIST. -#: MEASURED on nurilab before any conversion: a bare `latest` would BLANK 221 filled cells — -#: `avg_engagement` and `category` lose 62 each — because the discovery reads that produced those -#: values were never recorded as observations (the hole this same wave closed going forward, in its -#: historical form: 62 profiles hold exactly ONE observation, the enrichment scrape, dated three -#: days AFTER the discovery run whose numbers are on their row). A migration that converts on a -#: NAMED LIST would do that damage on any tenant whose series is thinner than the list's author -#: assumed. -#: -#: ⇒ So the migration converts a column only where it can PROVE, on this tenant's own rows, that -#: the derived value equals the stored one everywhere. See `_convertible_profile_columns`. -#: ⚠ Re-runnable by design: as the series fills in, later passes convert more. A column that is not -#: safe today is not refused forever, it is simply not converted yet. -IG_DERIVABLE_KEYS = tuple( - fd["key"] for fd in PRESET_PROFILE_FIELDS - if fd.get("type") not in ("link", "rollup") - and fd["key"] not in ("platform", "handle", "enriched_at", "first_found", "last_found", - "found_count", "created_by") - and fd["key"] in {s["key"] for s in SNAPSHOT_FIELDS}) - - -def _derived_profile_bag(key): - """The rollup that replaces the mapper's write of ONE profile column. - - ⚠ `where: is_not_empty` IS NOT DECORATION. A bare `latest` returns the newest observation's - value INCLUDING ITS BLANK, so a cheap pull that did not read the field would erase what an - expensive one learned — the exact asymmetry `upsert_rows`' merge exists to prevent on the - scalar side. `where` runs BEFORE the ranking (contract C1), so this reads "the newest - observation that ACTUALLY READ this field", which is what the materialised cell meant. - MEASURED: it rescues 5 of the 12 columns a bare `latest` would damage; the other 7 need the - observation itself, which is why the guard below is a proof and not a hope. - """ - return {"link": "profile_snapshots_link", "field": key, "fn": "latest", - "sortBy": "pulled_at", "sortDir": "desc", - "where": [{"field": key, "op": "is_not_empty"}], "whereConj": "and"} - - -def _derived_profile_columns(table, snapshots): - """Which of `IG_DERIVABLE_KEYS` should be DECLARED as rollups on THIS table. - - = the ones already converted, PLUS the ones whose derived value equals the stored value on - every row. Returns keys. - - ⛔⛔ IT MUST RE-ASSERT THE ONES ALREADY CONVERTED, and forgetting that is a one-line revert with - a several-hour fuse. `_reconcile_ig_graph_fields` overwrites every contract key it owns — - `rollup` included, by design — so a converted column that this function stopped naming would be - re-typed back to `int`/`text` on the next reconcile, its bag dropped, and its value re-written - by the mapper. The edit would look applied and undo itself later, which is exactly - [[preset-unlock-needs-a-custody-stamp]]'s shape. An already-converted column is therefore - included unconditionally rather than re-proved: its cells ARE the derived values, so a proof - would be comparing the fold against itself. - - ⛔ THE PROOF RUNS PER TENANT, PER COLUMN, AT MIGRATION TIME — never against a list somebody - measured on one workspace. `followers` derives exactly on nurilab and could be thin somewhere - else; `category` is damaged on nurilab and might be perfect elsewhere. The only honest - authority is the data in front of the migration. - ⚠ A column with NO stored values anywhere converts too, and that is deliberate rather than an - oversight: there is nothing to lose, and leaving it materialised would mean the schema differs - between two tenants for no reason a reader could discover. - """ - fields = {str(f.get("key")): f for f in (table or {}).get("fields") or []} - rows = (table or {}).get("rows") or {} - by_subject = {} - for snap in ((snapshots or {}).get("rows") or {}).values(): - key = str((snap or {}).get("influencer_key") or "").strip().lower() - if key: - by_subject.setdefault(key, []).append(snap or {}) - # The engine's own ordering, once per subject — newest first, unrankable rows last. - ordered = {} - for subject, observations in by_subject.items(): - keyed = [(o, _sort_key(o.get("pulled_at"), "date")) for o in observations] - rankable = [(o, k) for o, k in keyed if k is not None] - rankable.sort(key=lambda ok: ok[1], reverse=True) - ordered[subject] = [o for o, _k in rankable] + [o for o, k in keyed if k is None] - # ⛔ A rollup needs the LINK it folds. A profile table that has not been through the graph - # reconcile has no `profile_snapshots_link`, and declaring one there would mint a column whose - # link resolves to nothing — a blank cell wearing a configured column's clothes. - if "profile_snapshots_link" not in fields: - return [] - out = [] - for key in IG_DERIVABLE_KEYS: - field = fields.get(key) - if not field: - continue - if field.get("type") == "rollup": - out.append(key) # already converted — see the note above on why - continue - declared = str(field.get("type") or "text") - safe = True - for row in rows.values(): - stored = str((row or {}).get(key) or "").strip() - window = [o for o in ordered.get( - str((row or {}).get("handle") or "").strip().lower(), []) - if str(o.get(key) or "").strip() != ""] # the `where` guard, applied here - derived = str(_rollup_fold("latest", [o.get(key) for o in window], declared) - or "").strip() - if stored != derived: - safe = False - break - if safe: - out.append(key) - return out - - -def _as_derived_field(field): - """One preset scalar declaration → the same column declared as a rollup over the series.""" - key = str(field.get("key") or "") - return {**{k: v for k, v in field.items() if k not in ("agg",)}, - "type": "rollup", "rollup": _derived_profile_bag(key)} - - -def migrate_ig_tables(rt, log=print, only=""): - """Bring every Instagram Profile and canonical child table onto one current contract. - - Returns counted changes rather than a reassuring line. Profile cells are converted before - their type declarations change; then the graph reconciliation adds any missing preset - Links/Rollups, repairs child types, stamps locks, adds all reciprocal backlinks, and deletes - only retired machine fields. - - `only` narrows to a single table, which is how `ut_ensure` calls it: the migration then rides - the WRITE PATH, so a table is brought forward immediately before anything appends to it and no - caller has to remember to run anything. - """ - stats = {"tables": 0, "retyped": 0, "converted": 0, "stamped": 0, "merged": 0, - # ⭐ 2026-08-07 (owner ruling) — the primary-column half. Counted separately from - # `stamped` because they answer different questions: that one is "how many ROWS got a - # platform", these are "how many TABLES changed shape". - "pinned": 0, "flagged": 0, "droppedName": 0, - "droppedRecentPosts": 0, "droppedPostHashtags": 0, - "droppedTracked": 0, "droppedTrackedCells": 0, "schema": 0} - want = {f["key"]: f["type"] for f in CANDIDATE_FIELDS} - # ⛔ `only` NARROWS THE SET, IT DOES NOT BYPASS THE TEST — and skipping that cost six red - # checks the first time. `ut_ensure` calls this with the key it is ABOUT TO CREATE, so on a - # first run the table does not exist yet: with the detection bypassed it fell through to the - # writer and stamped a stub table with empty fields, clobbering the creation that was the whole - # point of the call (the owner stamp and the flow tag went with it). A table that does not - # exist, or that is not an Instagram profile table, has nothing to migrate. - # ⭐ WAVE 32 · T42 — the CONTRACT set, not the detector's. This loop retypes columns, stamps - # `platform` and drops retired keys, i.e. it edits the schema — so it answers to the owner's - # rule about which databases Instagram may write into, exactly like `_ig_schema_contract` at - # the bottom of this function. `_ig_profile_tables` stays the wider question and keeps its own - # callers (`discover_default_table`, `backfill_corpus_snapshots`, the detector's NC). - detected = set(_ig_contract_tables(rt)) - targets = ([only] if only in detected else []) if only else sorted(detected) - # ⭐⭐ WAVE 31 · T30 — `only` NOW NARROWS THE WHOLE FUNCTION, AND IT DID NOT. - # - # ⛔ THE BUG WAS THAT `only` NARROWED THE LOOP ABOVE AND NOTHING ELSE. The tenant-wide passes - # at the bottom — two full `_ig_schema_contract` + `_reconcile_ig_graph_fields` rounds and two - # backfills — ignore `only` entirely, so `migrate_ig_tables(only=X)` paid a whole-tenant - # Instagram reconciliation no matter what X was. And `ut_ensure` calls it that way on EVERY - # ensure whose field set intersects `PRESET_PROFILE_KEYS` — which TikTok's profile schema does, - # because the two networks share column names. - # MEASURED on the wave-30 fixture, `only="ut_tt_profile"`: `_ig_profile_tables` returns `[]`, - # `stats` comes back completely empty and ZERO store writes happen — at a cost of **10 - # whole-document reads**, each a `Store.get` deep copy (documented ceiling 35.8 MB / ~1.4 s). - # Across one save's four `ut_ensure` calls that was **40 of the 41** reads behind owner item 7's - # *"it just say Saving... and takes a long time"*. The migration was not slow; it was running at - # all. - # ⚠ THE COMMENT ONE SCREEN DOWN IS WHY THIS WENT UNSEEN FOR THREE WAVES: *"Both are guarded on - # 'is there anything to do' and cost a dict scan in the steady state, which is what makes them - # safe on a function that rides every `ut_ensure`."* That guard is on the WRITE. The read was - # never guarded, and a scan of a freshly deep-copied 35 MB document is not a dict scan - # [[read-a-gates-predicate-for-what-it-excludes]]. - # ⛔ SCOPED TO THE PROVEN-EMPTY CASE ONLY, deliberately. When `only` IS a detected profile table - # the function is unchanged, tenant-wide passes included — that is the Instagram path every - # migration check in `verify_automation` exercises. And a caller passing no `only` still gets - # the full sweep. The claim being made here is narrow and checkable: *a table that is not in the - # Instagram graph has no Instagram migration*, which is the same thing the `detected` test above - # already decided one line earlier and then threw away. - # ⛔ AND IT IS NOT A LEND. Lending one snapshot through the rest of this function would be the - # obvious next fix and it is WRONG: the second contract pass exists precisely because it must - # read what the first pass WROTE (see its own note below), so a read-write-read sequence cannot - # answer out of one snapshot without silently un-fixing that idempotency. - if only and not targets: - return stats - for table_key in targets: - tbl = ut_get(rt, table_key) or {} - fields = [dict(f) for f in tbl.get("fields") or []] - rows = {rid: dict(r) for rid, r in (tbl.get("rows") or {}).items()} - # ⭐ THE IDEMPOTENCY KEY. Only a column whose STORED type is still the old one is touched, - # so the cell conversions below can never run twice on the same value. - stale = [f for f in fields - if f.get("key") in want and f.get("type") != want[f["key"]] - and f.get("type") in ("text", "", None)] - stale_keys = {f["key"] for f in stale} - has_platform = any(f.get("key") == "platform" for f in fields) - needs_stamp = [rid for rid, r in rows.items() if not str(r.get("platform") or "").strip()] - # ⭐⭐ 2026-08-07 (owner ruling) — MAKE THE HANDLE THE PRIMARY COLUMN AND THE BINDING. - # `ut_ensure` merges by KEY and never updates a field that already exists, so declaring the - # two keys on `PRESET_PROFILE_FIELDS` reaches NEW tables only. Every table already in - # production needs them stamped here, and that is the whole reason this branch exists. - # - # ⚠ IDEMPOTENT BY READING THE STORED DECLARATION, never by a marker column — W26's rule, - # and it is free here: re-stamping a boolean and a two-key dict is a no-op by nature, which - # is exactly why these are safe to run on every write where a value conversion would not be. - pin_f = next((f for f in fields if f.get("key") == PRESET_PINNED_KEY), None) \ - if PRESET_PINNED_KEY else None - flag_f = next((f for f in fields if f.get("key") == PRESET_FLAG_KEY), None) \ - if PRESET_FLAG_KEY else None - # ⛔ AT MOST ONE PROFILE COLUMN PER TABLE is the law `user_tables` enforces at both write - # doors, and a migration is not exempt from it. If somebody has already flagged another - # column on this table, stamping the handle too would leave the table in a state its own - # validator refuses — and `_profile_of` returns the FIRST match, so the enrich action would - # silently bind to whichever came first in the field list. Leave it alone and pin only. - other_profile = next((f for f in fields if f is not flag_f - and isinstance(f.get("profile"), dict)), None) - want_pin = pin_f is not None and pin_f.get("pinned") is not True - want_flag = (flag_f is not None and other_profile is None - and not isinstance(flag_f.get("profile"), dict)) - vestigial = _vestigial_name_field(fields, rows) - retired = {f.get("key") for f in fields} & {"posts", "post_hashtags"} - if _retired_tracked_field(fields) is not None: - retired.add(TRACKED_KEY) - orphan_tracked = _orphan_tracked_rows(fields, rows) - if (not stale_keys and has_platform and not needs_stamp - and not want_pin and not want_flag and vestigial is None and not retired - and not orphan_tracked): - continue - stats["tables"] += 1 - if want_pin: - pin_f["pinned"] = True - stats["pinned"] += 1 - if want_flag: - flag_f["profile"] = {"source": PROFILE_SOURCE_IG} - stats["flagged"] += 1 - if vestigial is not None: - # ⚠ THE CELLS GO WITH THE COLUMN. A row dict keeping a `name` key whose field no longer - # exists is invisible everywhere except the next export, where it reappears as a column - # nobody declared. `_vestigial_name_field` has already proven every one of them blank. - fields = [f for f in fields if f is not vestigial] - for r in rows.values(): - r.pop("name", None) - stats["droppedName"] += 1 - if retired: - fields = [f for f in fields if f.get("key") not in retired] - for r in rows.values(): - for key in retired: - r.pop(key, None) - stats["droppedRecentPosts"] += int("posts" in retired) - stats["droppedPostHashtags"] += int("post_hashtags" in retired) - stats["droppedTracked"] += int(TRACKED_KEY in retired) - for rid in orphan_tracked: - # D-74: a tick whose column was already gone. Counted apart from the column drop - # because they answer different questions — "how many tables still declared it" and - # "how many rows were still carrying it after the declaration went". - rows[rid].pop(TRACKED_KEY, None) - stats["droppedTrackedCells"] += 1 - - for key in stale_keys: - conv = _MIGRATE_CONVERT.get(key) - if not conv: - continue # int/checkbox already store the right text shape - for r in rows.values(): - if str(r.get(key) or "").strip(): - new = conv(r[key]) - # "" from a converter means UNREADABLE, and the cell keeps its original text. - # A migration that empties a cell is indistinguishable from one that moved it. - if new: - r[key], stats["converted"] = new, stats["converted"] + 1 - for f in stale: - f["type"] = want[f["key"]] - stats["retyped"] += 1 - for rid in needs_stamp: - rows[rid]["platform"] = PLATFORM_INSTAGRAM - stats["stamped"] += 1 - if not has_platform: - fields = [dict(f) for f in CANDIDATE_FIELDS if f["key"] == "platform"] + fields - rows, merged = _merge_candidates(rows) - stats["merged"] += merged - - def _up(cur, _f=fields, _r=rows): - cur = cur or {} - t = dict(cur.get(table_key) or {}) - t["fields"], t["rows"] = _f, _r - cur[table_key] = t - return cur - - rt.update(UT_STORE_KEY, _up) - # ⭐⭐ WAVE 33 (D-187) — A SINGLE-TABLE CALL STOPS PAYING FOR A TENANT-WIDE RECONCILIATION. - # - # W31-T30 closed HALF of this: `only` naming a table outside the Instagram graph returns early - # (`if only and not targets` above). What it left is the case the row is actually about — `only` - # naming a table that IS in the graph, i.e. **every save an Instagram tenant makes**. The four - # passes below are tenant-wide by construction: two `_ig_schema_contract` + - # `_reconcile_ig_graph_fields` rounds and two backfills, each walking the whole document, none - # of them reading `only`. So one `ut_ensure` on one table paid a whole-tenant Instagram - # reconciliation, and `ut_ensure` runs several times per save. - # - # ⛔ THE ROW'S EXIT OFFERS TWO BRANCHES AND THIS TAKES THE FIRST ONE — *"the tenant-wide passes - # are reachable from a scheduled/boot caller"* — because they ARE, and were before this change: - # `routes_tables.py:882` (`_ig_forward`) calls `migrate_ig_tables(rt)` with NO `only`, once per - # tenant per process, guarded by `_IG_FORWARDED`. That is the right home for a whole-tenant - # repair: once, off the save path, rather than on every ensure. - # ⚠ THE SECOND BRANCH — "bound them by an anything-to-do test guarded on the READ" — is NOT - # available here and the comment above says why: the second contract pass exists to read what - # the first pass WROTE, so it cannot be answered out of one snapshot without silently - # un-fixing the idempotency everything else depends on. - # ⛔ NARROW AND CHECKABLE, like its W31-T30 sibling: a call that NAMES ONE TABLE does that - # table's migration. A call that names none still does the whole sweep, unchanged, and every - # tenant-wide check in `verify_automation` goes through that path. - if only: - return stats - # The profile loop owns value conversions and deduplication. The schema pass owns the - # declarations across ALL related databases and is safe only after those conversions, because - # the stored old type is the idempotency key for unit-changing values such as engagement. - wanted, drops = _ig_schema_contract(rt) - stats["schema"] = int(_reconcile_ig_graph_fields(rt, wanted, drops)) - # ⛔⛔ A SECOND CONTRACT PASS, AND IT IS NOT BELT-AND-BRACES — WITHOUT IT THIS FUNCTION IS NOT - # IDEMPOTENT, which is the one property everything else here is built on. - # - # The derived overlay (`_derived_profile_columns`) decides by COMPARING the profile cell to the - # fold of the series. The pass above REPAIRS THE SERIES — it retypes the child columns and runs - # `_IG_RETYPE_CONVERTERS`, which rewrites `avg_engagement` from the vendor's 0-1 fraction to our - # 0-100 percent. So on a tenant whose children are stale, the first proof compares a repaired - # profile cell against an UNREPAIRED observation, finds them different, and declines to convert - # a column that is in fact perfectly derivable. The next call then converts it — and - # `verify_automation`'s "a SECOND pass is a no-op" check went red, which is exactly what that - # check is for. MEASURED on the wave-26 fixture: 24 columns converted on pass 2, none on pass 1. - # - # ⚠ The module already knew this shape one level up — the profile loop's own note says the - # schema pass "is safe only after those conversions, because the stored old type is the - # idempotency key". Same argument, one table further down. - # ⚠ CHEAP WHEN THERE IS NOTHING TO DO: on a current tenant the contract recomputes to the same - # declarations and `_reconcile_ig_graph_fields` writes nothing. Twice, not looped: the repair is - # what moves the data, and it has already run. - wanted2, drops2 = _ig_schema_contract(rt) - stats["schema"] = int(bool(stats["schema"] - | int(_reconcile_ig_graph_fields(rt, wanted2, drops2)))) - # ⭐⭐ 2026-08-10 — THE TWO VALUE BACKFILLS, and they run AFTER the schema pass on purpose: - # `backfill_post_snapshot_grain` writes into columns that pass has just declared, and running - # it first would find nothing to fill and report a truthful zero about the wrong moment. - # Both are guarded on "is there anything to do" and cost a dict scan in the steady state, which - # is what makes them safe on a function that rides every `ut_ensure`. - stats["series"] = backfill_corpus_snapshots(rt, log=log) - stats["postGrain"] = backfill_post_snapshot_grain(rt, log=log) - if stats["tables"] or stats["schema"] or stats["series"] or stats["postGrain"]: - log(f"[ig-migrate] {stats}") - return stats - - -# ── WAVE 25 · C6 (owner ruling R5) — SEEDING A SEARCH FROM A VIEW OR COHORT. ────────────────── -# "Find me more accounts like the ones in this view." The server reads the seed rows, ranks their -# SHARED characteristics, and FILLS IN the discovery conditions — visible and editable, never -# hidden (R5). -# -# ⛔ THE RANKING IS RESTRICTED TWICE, AND BOTH RESTRICTIONS ARE MEASURED RATHER THAN CHOSEN: -# 1. R5 restricts it to `BD_POPULATED_FIELDS` — a characteristic extracted from a field the -# corpus barely populates produces a filter that returns nothing and looks exactly like -# "no such accounts exist" (the trap `BD_FILTER_LEAD` already exists to warn about). -# 2. A derived predicate must NARROW, or `narrowing_refusal` refuses to save it. The -# intersection of the two sets is MEASURED at exactly eight fields — `account`, `biography`, -# `external_url`, `fbid`, `full_name`, `id`, `profile_name`, `profile_url` — and each of them -# narrows under its own `default_operator`, asserted in the gate rather than assumed. -# -# ⭐ AND FOUR OF THOSE EIGHT CANNOT GENERALISE, which is the part worth stating: `account`, `id`, -# `fbid` and `profile_url` are IDENTITIES. A filter derived from them re-finds the seed rows and -# nothing else — a search that returns what you gave it, at full price. So the ranker reads only -# the three that describe a KIND of account: shared words in the bio, a shared domain in the link, -# and shared words in the name. -SEED_SOURCES = ("view", "cohort") -SEED_MAX_ROWS = 200 # bounded and DISCLOSED in `basis.rows` — never a silent [:N] -SEED_MAX_PREDICATES = 3 # a filter of ten derived guesses is not a filter -SEED_MIN_COVERAGE = 0.5 # a "shared" characteristic under half the rows share is not shared -SEED_MIN_ROWS = 2 # one row has nothing to share WITH -#: ⛔ AND A COVERAGE BAR ALONE IS NOT ENOUGH, which the gate caught rather than review: at two -#: seed rows, `coverage >= 0.5` is satisfied by a word appearing in exactly ONE of them. So every -#: bio word in a two-row seed qualified as "shared", and the surface would have offered a -#: characteristic derived from a single record as the thing those records have in common. A -#: shared characteristic needs at least two rows to be shared BY — n=1 dressed as a pattern is -#: the fabrication this module refuses everywhere else. -SEED_MIN_ROWS_SHARING = 2 -#: Which ROW column feeds which VENDOR field. The row keys are the C1 preset set's; the vendor -#: names are `BD_FILTER_FIELDS`'. Two vocabularies for one thing, so the mapping is written down. -SEED_FROM_ROW = (("bio", "biography"), ("external_url", "external_url"), - ("full_name", "full_name")) -#: Words that are shared by everything and therefore discriminate nothing. Deliberately SHORT — -#: an aggressive list would silently drop a real signal, and the coverage bar already removes most -#: noise. Anything ≤2 characters is dropped by length instead of by enumeration. -SEED_STOPWORDS = frozenset({ - "the", "and", "for", "with", "you", "your", "our", "are", "not", "all", "any", "from", - "this", "that", "have", "has", "was", "com", "www", "http", "https", "out", "new", "more", - "who", "how", "その", "инст"} | {"official", "welcome", "contact", "email", "dm", "link"}) - - -def _seed_tokens(text): - """One cell → the distinct words worth matching on. Lowercased, de-punctuated, ≥3 chars.""" - words = re.findall(r"[a-z0-9]{3,}", str(text or "").lower()) - return {w for w in words if w not in SEED_STOPWORDS} - - -def _seed_domain(url): - """A link-in-bio → its registrable-ish host, or ''. `linktr.ee/x` and `linktr.ee/y` share a - domain and that IS the shared characteristic; the path is the thing that differs.""" - raw = str(url or "").strip() - if not raw: - return "" - host = (urlparse(raw if "//" in raw else "https://" + raw).hostname or "").lower() - return host[4:] if host.startswith("www.") else host - - -def seed_predicates(rows): - """R5: seed rows → `(derived_predicates, basis)`. PURE — no store, no vendor, no network. - - `basis` is what makes the offer honest rather than magic: how many rows were read, which - characteristics were extracted, and **the MEASURED coverage of each**, so a weak signal reads - as weak instead of being presented with the same confidence as a strong one. - - ⛔ EVERY DERIVED PREDICATE IS ASSERTED AGAINST `narrowing_refusal` BEFORE IT IS OFFERED - (HARD RULE 11). A suggestion the product's own save door then refuses is the post-W24 - default-versus-guard defect rebuilt — and it would be worse here, because the user did not - type it: they would be told their own product's suggestion is invalid. - - ⚠ AN EMPTY ANSWER IS A LEGITIMATE ANSWER. When the seed rows share nothing above the coverage - bar, `derived` is `[]` and `basis` says why. An honest nothing beats a filter that looks - plausible, returns zero rows, and reads as "no such accounts exist". - """ - rows = [r for r in (rows or []) if isinstance(r, dict)] - total = len(rows) - basis = {"rows": total, "fields": [], - # R5's second lane, reported as the MEASUREMENT it is rather than sold as a feature. - # `related_accounts` is 22% populated corpus-wide (D-25) and is not part of the C1 - # preset set, so on our own seed rows it is usually simply absent — which the surface - # must SAY, or a lane that adds nothing reads as a lane that failed. - "related": {"tried": total, - "found": sum(1 for r in rows - if str(r.get("related_accounts") or "").strip())}, - "note": ""} - if total < SEED_MIN_ROWS: - basis["note"] = ("a seed needs at least two records — one record has nothing to share " - "with anything") - return [], basis - ranked = [] - for row_key, vendor_field in SEED_FROM_ROW: - counts = {} - for r in rows: - vals = ({_seed_domain(r.get(row_key))} if vendor_field == "external_url" - else _seed_tokens(r.get(row_key))) - for v in vals: - if v: - counts[v] = counts.get(v, 0) + 1 - for value, n in counts.items(): - cov = n / total - if cov >= SEED_MIN_COVERAGE and n >= SEED_MIN_ROWS_SHARING: - ranked.append({"name": vendor_field, "value": value, "coverage": round(cov, 3), - "rows": n}) - # Strongest first; ties broken by the LONGER value, which is the more specific one. - ranked.sort(key=lambda f: (-f["coverage"], -len(f["value"]), f["name"])) - derived, seen_fields = [], set() - for f in ranked: - if len(derived) >= SEED_MAX_PREDICATES: - break - # One predicate per FIELD: two `biography includes` rows AND-ed together narrow to the - # accounts carrying both words, which is a much smaller search than the seed implies. - if f["name"] in seen_fields: - continue - p = {"name": f["name"], "operator": default_operator(f["name"]), "value": f["value"]} - if not predicate_narrows(p): - continue # cannot be offered; it would be refused at the save door - seen_fields.add(f["name"]) - derived.append(p) - basis["fields"].append({"name": f["name"], "label": field_label(f["name"]), - "value": f["value"], "coverage": f["coverage"]}) - if derived and narrowing_refusal(derived, "and"): - # Belt AND braces: the per-predicate check above should make this unreachable, and it is - # asserted in the gate. If the LAW ever changes shape, this fails closed with an honest - # empty rather than offering something the save door will reject. - basis["note"] = ("the shared characteristics found are not specific enough to search on " - "— add a condition of your own") - return [], basis - if not derived: - basis["note"] = basis["note"] or ( - "these records share no bio words, link domain or name in common — nothing could be " - "derived, so the conditions are yours to write") - return derived, basis - - -def seed_rows(rt, table_key, view_id=""): - """The rows a seed source selects. `(rows, problem)` — bounded, and the bound is DISCLOSED by - `basis.rows` rather than silently applied ([[no-unverifiable-aggregates]]).""" - t = ut_get(rt, str(table_key or "")) - if t is None: - return [], f"{table_key!r} is not a database in this workspace" - rows = list((t.get("rows") or {}).values()) - if str(view_id or "").strip(): - # ⛔ THE SAME RESOLVER `enters_view` USES, not a second one. A seed that selected a - # different row set from the view it names would be describing a view nobody has. - tree, _fields, problem = view_filter(rt, table_key, view_id) - if problem: - return [], problem - rows = [r for r in rows if lane_match(tree, r)] - return rows[:SEED_MAX_ROWS], "" - - -def automation_tables(defn): - """Every `ut_*` table THIS automation writes into — its config target plus every - `create_record` action's table, deduped, order preserved. - - ⚠ THE SAME TWO AUTHORITIES `observed_category_tables` READS, narrowed to one definition. - Split out rather than parameterised because the two questions are different: that one asks - "where could a Category value be, anywhere in this tenant" and is deliberately generous; this - one asks "which rows has THIS automation already found" and must not reach into a table it - does not write, or a nightly search would start excluding another automation's finds. - """ - keys, seen = [], set() - cfg = (defn or {}).get("config") or {} - for raw in [cfg.get("targetTable"), - *(((a or {}).get("config") or {}).get("table") - for a in walk_actions(((defn or {}).get("flow") or {}).get("actions")))]: - k = str(raw or "").strip() - if k.startswith(UT_PREFIX) and k not in seen: - seen.add(k) - keys.append(k) - return keys - - -def candidate_key(platform, handle): - """The discovery upsert identity: `(platform, handle)`, as one string. - - ⭐ WAVE 29 — LIFTED OUT OF `run_discover_instagram`, where it was a closure, because a second - runner now needs the same identity. Two closures computing "the same" key is how one of them - grows a different default for a blank `platform` and the two networks quietly start sharing - rows — which is the exact data-loss shape wave 26's R4 added `platform` to prevent. - - ⚠ A BLANK PLATFORM DEFAULTS TO INSTAGRAM, and that is a FACT about the stored data rather than - an assumption: every row written before wave 26 came from the Instagram runner. Blank-keyed - rows would fail to match their own re-find and duplicate the whole table on the next run. - """ - return f"{str(platform or PLATFORM_INSTAGRAM).strip()}\n{str(handle or '').strip()}" - - -def already_found_handles(rt, defn, cap=None): - """The handles this automation has already written, for the vendor-side exclusion (item 7). - - ⭐⭐ THE POINT IS THE BILL, not the row count. Discovery upserts by handle, so re-finding a - profile has always been harmless — and never free: the vendor bills for every record it - returns, so a nightly search over stable keywords pays again, in full, for the same accounts - every night and reports them as `seen_again`. MEASURED shape of the waste: nurilab's beauty - scout re-found its whole result set on every run. This is the list that stops it, sent as one - `not_in` the vendor evaluates BEFORE billing. - - ⚠ SCOPED TO THIS AUTOMATION'S OWN TABLES. Excluding handles another automation found would - hide profiles this one has never seen — cheaper, and wrong: two scouts with different keywords - are two questions, and one must not answer with "somebody already looked at that". - """ - cap = BD_EXCLUDE_MAX if cap is None else int(cap) - out, seen = [], set() - for table_key in automation_tables(defn): - for row in ((ut_get(rt, table_key) or {}).get("rows") or {}).values(): - h = str((row or {}).get("handle") or "").strip().lstrip("@").lower() - if h and h not in seen: - seen.add(h) - out.append(h) - if len(out) >= cap: - return out - return out - - -def observed_category_tables(rt): - """Every table a Category value could have been written into, DERIVED from the automations - this tenant actually has — the default discovery table and the snapshot table are the FLOOR, - not the list. - - ⭐ Two authorities, because wave 24 and wave 25 each moved where the target is declared: - `config.targetTable` (the discovery runner's own write) and any `create_record` action's - `config.table` (R2 — authoritative since wave 25, and the field the Builder's Database picker - writes). Both are read, deduped, order preserved so the defaults come first. - """ - keys, seen = [], set() - - def _add(k): - k = str(k or "").strip() - if k and k.startswith("ut_") and k not in seen: - seen.add(k) - keys.append(k) - - _add(DISCOVER_TABLE) - _add("ut_ig_snapshots") - try: - for defn in (all_definitions(rt) or {}).values(): - cfg = (defn or {}).get("config") or {} - _add(cfg.get("targetTable")) - for act in walk_actions(((defn or {}).get("flow") or {}).get("actions")): - _add(((act or {}).get("config") or {}).get("table")) - except Exception: # noqa: BLE001 - # A definition set that cannot be read costs the DERIVED lanes and keeps the defaults — - # the same posture as the master lane below: degrade to less vocabulary, never to an error. - pass - return keys - - -def observed_categories(rt, limit=60): - """⭐ DEBT D-59 — the Category values WE HAVE ACTUALLY SEEN, for a combobox that still accepts - free text. - - D-59's exit condition, verbatim: *"EITHER derive the options from values we have actually seen - (the `category` column on `ut_ig_candidates` + the master store, offered as a combobox that - still accepts free text), OR buy a sample large enough to enumerate the real vocabulary. **Not**: - transcribe a published taxonomy and hope it lines up."* This is the first branch. - - ⛔ WHY A PUBLISHED TAXONOMY WOULD HAVE BEEN 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" — so a picker built from Instagram's own category list would mislead precisely when it - looked most authoritative. Every option here has been observed on a real row, and each carries - its COUNT so a value seen once reads differently from one seen forty times. - - ⚠ IT STAYS A COMBOBOX. These are the values we have seen, not the values that exist — a - control that refused anything else would be a second, quieter version of the same lie. - """ - seen = {} - - def _eat(rows, key): - for r in rows or []: - v = " ".join(str((r or {}).get(key) or "").split())[:60] - if v: - seen[v] = seen.get(v, 0) + 1 - - # ⛔ THE TABLES ARE DERIVED, NOT NAMED — and this is a REGRESSION BY OMISSION that shipped - # green. The two constants below were the whole list, which was correct until **wave 25 R2 - # made the Create record action's `config.table` AUTHORITATIVE**: from that ruling on, a - # discovery automation writes wherever the user pointed it, and `ut_ig_candidates` is merely - # the DEFAULT nobody keeps. MEASURED on nurilab 2026-08-06 — `ut_ig_candidates` held 0 rows - # while the tenant's two real target tables held 20 each, so this function honestly reported - # `{options: []}` and the combobox it feeds had nothing to offer. A vocabulary harvester that - # names its sources goes quietly empty the moment the product lets a user choose one. - for key in observed_category_tables(rt): - _eat(((ut_get(rt, key) or {}).get("rows") or {}).values(), "category") - try: - # ...and the PLATFORM master, which is the whole point of pooling it: a tenant with three - # candidates still gets a vocabulary drawn from every profile the platform has captured. - import ig_master - if ig_master.configured(): - # ⚠ `_handle().get(SNAP_BUCKET)` — a dict of rows keyed by id. NOT `_upsert()`, which - # returns the upsert FUNCTION; calling that and reading `.get(...)` off it answers - # None, so the master lane would have degraded to nothing INSIDE the except below and - # this would have shipped as a silent no-op with the gate green. Verified against - # `ig_master.series_for`, which reads the same bucket the same way. - _eat((ig_master._handle().get(ig_master.SNAP_BUCKET) or {}).values(), "category") - except Exception: # noqa: BLE001 - # A master that cannot be read costs the tenant its own values and nothing else. - pass - return [{"value": v, "count": n} - for v, n in sorted(seen.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]] - - -def preset_plan(rt, table_key): - """C1 / R2b: the preset set diffed against ONE database's CURRENT fields. - - `{table, fields: [{key, label, type, present}], willUse: [...], willCreate: [...]}` - - ⛔ COMPOSED HERE, NOT IN THE CLIENT. The owner's ask is a config panel that says which columns - an automation will USE and which it will CREATE — and the only thing that can answer it is - whatever holds both lists. A client that diffed the preset set against a table's fields would - be a second implementation of `ut_ensure`'s merge rule, free to disagree with the merge that - actually runs; it would be wrong precisely when the two lists differ, which is the only case - anybody is asking about. - - ⚠ A TABLE THAT DOES NOT EXIST IS NOT AN ERROR — it is the ordinary state at R10's spawn-on-save - moment, and the honest answer is "all of them will be created". `present` is a fact about the - table, so it reads False for every field rather than the call refusing. - - ⛔ 2026-08-07 — THE PROJECTION IS FOUR KEYS AND DELIBERATELY DROPS EVERY DECLARATION BAG - (`link`, `rollup`, `metric`, `profile`, `pinned`). Measured off the live Space the day the - relational pair shipped: `posts_link` and the four rollups arrive here with their bags EMPTY. - That is CORRECT for this payload — it answers "which columns will be used vs created", where - a name and a type are the whole question — and it is safe today because nothing RENDERS a - column from this list: the grid reads full field dicts from `/tables` (`scoped_pool` passes - `dict(f)` straight through), and the column itself is spawned server-side by `ut_ensure` from - `PRESET_PROFILE_FIELDS`, which carries the bag. - ⚠ **It stops being safe the moment somebody previews a rollup from this payload** — they would - render "Avg views - last 12 posts" configured by nothing. Read the field off `/tables`, or - widen this projection deliberately; do not assume a bag is here because the field has one. - """ - have = {str(f.get("key")) for f in ((ut_get(rt, str(table_key or "")) or {}).get("fields") - or [])} - fields = [{"key": f["key"], "label": f["label"], "type": f.get("type") or "text", - "present": f["key"] in have} - for f in PRESET_PROFILE_FIELDS] - return {"table": str(table_key or ""), - "exists": ut_get(rt, str(table_key or "")) is not None, - "fields": fields, - "willUse": [f for f in fields if f["present"]], - "willCreate": [f for f in fields if not f["present"]]} - - -def clean_predicates(raw, operator="and", kind=""): - """Validate a discovery filter into the vendor's shape. Returns `(predicates, error)`. - - Refuses rather than coerces: a predicate naming a field the API rejects comes back as a - 400 with the field named, because the alternative — dropping it — turns "find me verified - accounts in Georgia" into "find me any account" and bills for the difference. - - ⭐ WAVE 32 · T46 (D-167) — `kind` NARROWS THE VOCABULARY TO THE PLATFORM'S. Default `""` keeps - Instagram's 21 names, so every existing caller and every stored Instagram automation is - unchanged; a `discover_tiktok` is validated against the 5 names its corpus actually has. The - refusal it produces is the same sentence, listing the platform's own fields — which is the - difference between a search that says why it cannot be built and one that is built, paid for, - and comes back empty. - """ - fields, _lead = filter_fields(kind) - out = [] - for p in (raw or [])[:12]: - if not isinstance(p, dict): - continue - name = _s(p.get("name") or p.get("field"), 60).strip() - op = _s(p.get("operator") or p.get("op"), 20).strip() - if name not in fields: - return None, ("that field cannot be searched — the searchable ones are: " - + ", ".join(field_label(f) for f in fields)) - # ⭐ PER-FIELD, not the global list. `is_business_account >= 3` used to validate cleanly - # because every operator was legal on every field; a yes/no column offering "at least" - # is a question with no meaning that the vendor is nevertheless asked. - allowed = ops_for(name) - if op not in allowed: - return None, (f"{field_label(name)} cannot be asked {BD_OP_LABELS.get(op, op)!r} — " - "it takes: " - + ", ".join(BD_OP_LABELS.get(o, o) for o in allowed)) - entry = {"name": name, "operator": op} - if op not in BD_NULLARY_OPS: - raw_v = p.get("value") - # ONE value or SEVERAL — several is the "contains any of" shape (owner item 3), and - # it is stored as a list rather than as N sibling predicates so the row a person sees - # and the row that is stored are the same thing. - vals = raw_v if isinstance(raw_v, list) else [raw_v] - vals = [v.strip() if isinstance(v, str) else v for v in vals] - vals = [v for v in vals if not (v is None or (isinstance(v, str) and not v))] - if not vals: - return None, f"give {field_label(name)} something to compare against" - if len(vals) > 1 and op not in BD_MULTI_VALUE_OPS: - return None, (f"{BD_OP_LABELS.get(op, op)!r} takes one value — " - f"{field_label(name)} can only be given a list with " - + ", ".join(BD_OP_LABELS[o] for o in ops_for(name) - if o in BD_MULTI_VALUE_OPS)) - if len(vals) > MAX_PREDICATE_VALUES: - return None, (f"{field_label(name)} takes at most {MAX_PREDICATE_VALUES} values " - "in one condition") - if field_kind(name) == "boolean": - ok = {o["value"] for o in BD_BOOLEAN_OPTIONS} - bad = [v for v in vals if str(v).lower() not in ok] - if bad: - return None, f"{field_label(name)} is answered Yes or No" - # ⛔ A REAL BOOLEAN, NOT THE STRING. MEASURED 2026-08-06: the filter API answers - # **400** to `{"name": "is_verified", "operator": "=", "value": "true"}` and - # accepts `True` (`snap_msh2rp4kh3v833q0u`). Nobody had ever sent one — the field - # was a free-text box until this wave, so the Yes/No dropdown that makes it easy - # to ask is also the thing that would have made every boolean condition fail. - # The dropdown's option VALUES stay "true"/"false" (a carries strings); + # the conversion belongs here, at the edge that talks to the vendor. + vals = [str(v).lower() == "true" for v in vals] + if field_kind(name) == "number": + try: + vals = [float(v) if "." in str(v) else int(v) for v in vals] + except (TypeError, ValueError): + return None, f"{field_label(name)} takes a number" + # A single value stays a SCALAR on the wire. The vendor has only ever been sent + # scalars for these operators; the list form is expanded at send time and the + # one-value case must not quietly start exercising an untested shape. + entry["value"] = vals[0] if len(vals) == 1 else vals + out.append(entry) + if not out: + # ⭐ WAVE 24 · AMENDMENT A2 — AN EMPTY FILTER IS INCOMPLETE, NOT WRONG, so it STORES. + # This used to refuse, which was right while a wizard collected the filters before the + # automation existed. C-TRIG law 1 inverts that: picking the `ig_profile_match` trigger + # CREATES the automation, and the filters are typed afterwards — so a save-time refusal + # here meant the trigger could never be picked at all. It is the A3 + # stored-inert-with-`configured:false` pattern this module already runs on. + # + # ⛔ NOTHING IS LOST AT THE MONEY DOOR, which is why this is safe rather than convenient: + # `run_discover_instagram` ALREADY calls `narrowing_refusal(preds)` before starting a + # search, deliberately, because "a stored config can predate the law, and the vendor + # bills for breadth whether the filter was saved yesterday or last month". The RUN is the + # wall. Every MALFORMED predicate above is still refused here, and the narrowing law + # below still refuses a non-empty filter that narrows nothing — only EMPTY changed. + return [], None + # C4 (wave 22): breadth is refused at WRITE time too — see `narrowing_refusal` for the + # measured hang this guards against. Same sentence at create/patch and at run. + guard_err = narrowing_refusal(out, operator, kind) or depth_refusal(out, operator) + if guard_err: + return None, guard_err + return out, None + + +def discover_estimate(records_limit): + """The cost preview a Find node shows BEFORE it runs. **SPEC, never measured** — see the + section header. Returned as structured data so the UI cannot accidentally drop the caveat.""" + n = max(0, int(records_limit or 0)) + return {"records": n, "usd": round(n * BD_RECORD_PRICE_SPEC, 4), + "unitUsd": BD_RECORD_PRICE_SPEC, "basis": "SPEC", + "note": "estimated from the published rate. An exact price is only known after a run" + "a price before a run, and this account's token cannot read a balance"} + + +def _candidate_row(row, stamp): + """One corpus row → a `ut_ig_candidates` row. None when it has no handle. + + ⛔ NEVER EMITS `found_count` — it is arithmetic the runner does against what is already + stored, so writing it here would reset the counter to 1 on every re-find. (It never emitted + `tracked` either; R6 deleted that column in wave 26.) + """ + if not isinstance(row, dict): + return None + handle = str(_first(row, "account", "username", default="") or "").strip() + if not handle: + return None + return { + # ⭐ R5 — half the identity. See `PLATFORM_INSTAGRAM`: this runner only ever reads + # Instagram, so it is a constant here rather than something derived from the row; the + # TikTok runner will stamp its own and the (platform, handle) key keeps the two apart. + "platform": PLATFORM_INSTAGRAM, + "handle": handle, + "profile_url": str(_first(row, "profile_url", "url", + default=f"https://www.instagram.com/{handle}/")), + "full_name": str(_first(row, "full_name", "profile_name", default="") or ""), + "followers": _s(_ig_int(_first(row, "followers"))), + "following": _s(_ig_int(_first(row, "following"))), + # ⭐ POPULATED ON CORPUS ROWS and null on scrape rows — the two paths differ, and this is + # the path where it carries values (measured on all five of the 2026-08-04 result set). + # ⚠ ×100 since wave 26 (amendment C1-a): the vendor's fraction is not our `pct`. + "avg_engagement": _pct100(_first(row, "avg_engagement", default="")), + "bio": _s(_first(row, "biography", "bio", default=""), 500), + "external_url": _s(_bd_first_url(_first(row, "external_url", "external_urls")), 300), + "verified": "1" if _first(row, "is_verified", default=False) else "", + "category": _s(_first(row, "category_name", "business_category_name", default="")), + # R3: a DAY, matching the `date` type the column now declares. The full-precision stamp + # still exists on the snapshot series, which is where a time axis belongs. + "last_found": _day(stamp), + } + + +# ── ⭐⭐ 2026-08-10 — DISCOVERY'S MISSING OBSERVATION ────────────────────────────────────────── +# +# ⛔ THE DEFECT, MEASURED ON NURILAB BEFORE ANY OF THIS WAS WRITTEN: 105 profiles, 104 carrying a +# `followers` number, and only 97 with a single row of history behind it. Seven accounts had a +# measurement nothing could re-derive, date or audit — `19,448 followers`, as of never, read via +# nothing. `_candidate_row` above writes six MEASUREMENTS onto a profile row (followers, +# following, engagement, verified, category, bio) and the discovery runner wrote no snapshot at +# all, so discovery was the one rung in this module that read numbers and recorded no observation. +# +# ⛔ IT IS NOT A "MISSING ENRICHMENT". Those seven rows are not waiting to be enriched — they hold +# real corpus numbers that are already on screen and already filterable. The gap is that the +# ENTITY row is the only copy, which is the exact arrangement R3's "one store for one series" law +# exists to forbid one level up. +# +# ⚠ A CORPUS ROW IS AN HONEST OBSERVATION, NOT A FAKE MEASUREMENT, and the two columns that make +# it honest already existed: `source` says **Discovery** (this was read off the vendor's +# pre-collected corpus, not measured for you) and `approx` is checked. Together they say "do not +# read this as an exact count taken at `pulled_at`" — which is the whole difference between +# recording what we know and inventing what we do not. +# +# ⭐ AND THE DATE GRAIN MAKES THE TIE-BREAK COME OUT RIGHT, which is worth stating because it +# looks like an accident: `pulled_at` here is a DAY (`last_found`), so `_sort_key` reads it as +# MIDNIGHT, while an enrichment on that same day carries a real timestamp. A `latest` rollup over +# the series therefore prefers the exact read over the corpus read whenever both happened on one +# day — the ordering you would have to hand-write, falling out of the grain. + +#: The `via` a DISCOVERY read reports. `PUBLIC_SOURCE` maps it to the word in the cell. +IG_VIA_DISCOVERY = "brightdata:discovery" + +#: The five keys a snapshot row owns about ITSELF. Everything else it carries is a measurement +#: copied off the profile row, and the list of those is DERIVED from `SNAPSHOT_FIELDS` rather +#: than typed out — a hand-list is what silently stops carrying the next promoted column. +_SNAPSHOT_OWN_KEYS = ("snapshot_key", "influencer_key", "pulled_at", "source", "approx") + + +def corpus_snapshot_row(row, day=""): + """ONE profile row read from the corpus → its `ut_ig_snapshots` observation. None if unusable. + + `day` overrides the row's own `last_found`, which is what the BACKFILL passes when a row's + only date is `first_found`. + + ⛔ IDEMPOTENT BY ITS KEY, never by a flag or a marker column. `snapshot_key` is + `@`, so re-running this over the same rows on the same day rewrites the same key + and `upsert_rows` merges it — the migration is safe to call on every write, which is the only + way it can be safe to call at all (W26's rule, and the same reason `migrate_ig_tables` keys + idempotency on the stored TYPE). + + ⚠ BLANKS ARE OMITTED RATHER THAN WRITTEN. `capture_rows` writes every key including the empty + ones because a paid pull's blank means "this rung did not read it" and the row is append-keyed. + Here the merge is the hazard instead: a second discovery on the same day returning a thinner + corpus record would otherwise ERASE what the first one learned. An absent key renders exactly + as an empty cell, so nothing is lost by leaving it out. + """ + handle = str((row or {}).get("handle") or "").strip().lstrip("@").lower() + if not handle: + return None + when = _day(day or (row or {}).get("last_found") or (row or {}).get("first_found") or "") + if not when: + return None + out = {"snapshot_key": f"{handle}@{when}", "influencer_key": handle, "pulled_at": when, + "source": _s(public_source(IG_VIA_DISCOVERY)), "approx": "1"} + for fd in SNAPSHOT_FIELDS: + key = fd["key"] + if key in _SNAPSHOT_OWN_KEYS: + continue + value = (row or {}).get(key) + if str(value or "").strip(): + out[key] = str(value) if key == "source_payload" else _s(value, 400) + return out + + +def append_ig_snapshots(rt, incoming, snap_key=IG_SNAPSHOTS_TABLE, log=print): + """Merge corpus observations into an EXISTING `ut_ig_snapshots`. Returns rows written. + + ⛔ IT NEVER CREATES THE TABLE, and that refusal is structural rather than cautious: the only + honest way to create it is `ensure_ig_graph`, which calls `ut_ensure`, which calls + `migrate_ig_tables` — and the BACKFILL caller is inside `migrate_ig_tables`. Reaching for the + creator from there is unbounded recursion. A tenant with no series store has nothing to + back-fill INTO; the forward path (`run_ig_discovery`) creates the graph properly and this + function then has somewhere to write. + """ + rows_in = [r for r in (incoming or []) if r] + if not rows_in: + return 0 + existing = ut_get(rt, snap_key) + if existing is None: + return 0 + merged, counts = upsert_rows(dict(existing.get("rows") or {}), rows_in, "snapshot_key", + cap=row_cap(snap_key)) + if counts["capped"]: + # D-11's law: a full append table means the SERIES has stopped growing, which is the one + # failure a chart cannot show you. + log(f"[aios-auto] corpus series: {counts['capped']} observation(s) refused by " + f"{snap_key}'s row cap") + written = counts["inserted"] + counts["updated"] + if not written: + return 0 + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + if cur.get(snap_key) is not None: + cur[snap_key]["rows"] = merged + return cur + + rt.update(UT_STORE_KEY, _up, flush="sync") + return written + + +# --------------------------------------------------------------------------------------------- +# RUNNERS +# --------------------------------------------------------------------------------------------- + +def cap_note(capped, missing=()): + """The sentence(s) a cap breach adds to a run summary. + + `capped` = `[(table_key, rows_lost), …]` — the ROW ceiling was hit. + `missing` = `[table_key, …]` — the TABLE ceiling was hit: the database could not + be created at all. + + ⛔ IT NAMES THE TABLE. "142 rows skipped" sends somebody to look at the source page; "the + ut_ig_post_snapshots database is FULL" sends them to the actual problem. A loud failure that + does not say WHERE is only marginally better than a silent one. + + ⛔ AND THE TABLE CEILING IS NOW AS LOUD AS THE ROW ONE (closes DEBT D-11). `ut_ensure` refused + SILENTLY at `MAX_UT_TABLES`: it returned a key for a database it had not created, the + runners' `if tgt is not None` write guard then skipped that bucket, and the run reported + success over a database that does not exist. Exactly the silent-stop failure the row cap + already had, one level up — and harder to spot, because the table is not there to look at. + """ + out = [f"⚠ the {k} database is FULL ({row_cap(k):,} rows). {n} row{'' if n == 1 else 's'} " + f"from this run were NOT written" for k, n in (capped or []) if n] + out += [f"⚠ the {k} database could NOT BE CREATED. This workspace is at its " + f"{MAX_UT_TABLES}-database limit, so nothing from this run reached it" + for k in (missing or [])] + return "; ".join(out) + + +def ut_missing(rt, *keys): + """Which of `keys` do NOT exist after an `ut_ensure` — the table-ceiling detector (D-11).""" + have = ut_all(rt) + return [k for k in keys if k and k not in have] + + +def run_scrape_db(rt, defn, username="automation", log=print, step=_no_step, rows=None): + """Automation #1 (R6): a public page → a blank database, re-runnable, UPSERT by key.""" + cfg = defn.get("config") or {} + fmap, key_field = cfg.get("fieldMap") or {}, cfg.get("keyField") + dry = bool(cfg.get("dryRun")) + # Per-NODE outcomes for the canvas. MEASURED as the run walks, never inferred afterwards from + # the rollup — inferring is exactly how a green dot ended up over an empty table (see the + # rollup comment in `run_field_instagram`). + steps = {"trigger": "ok", "fetch": "idle", "extract": "idle", "write": "idle"} + step(f"Fetching {urlparse(str(cfg.get('url') or '')).hostname or 'the page'}") + status, final, body = fetch(cfg["url"]) + if not (200 <= status < 300): + steps["fetch"] = "error" + return ("error", f"{cfg['url']} answered {status}. Nothing was written", + {"status": status}, [], steps) + steps["fetch"] = "ok" + soup = _soup(body) + if cfg.get("extract") == "jsonld": + blocks = jsonld(soup) + flat = [] + for b in blocks: + if isinstance(b, list): + flat.extend([x for x in b if isinstance(x, dict)]) + elif isinstance(b, dict): + items = b.get("itemListElement") + flat.extend([x for x in items if isinstance(x, dict)] if isinstance(items, list) + else [b]) + raw_rows = [{k: _scalar(v) for k, v in d.items()} for d in flat] + else: + found = tables(soup) + idx = max(0, min(int(cfg.get("tableIndex") or 0), len(found) - 1)) if found else 0 + raw_rows = [r for r in (found[idx] if found else []) if isinstance(r, dict)] + if not raw_rows: + steps["extract"] = "error" + return ("error", "the page parsed but the selected table had no rows", {"rows": 0}, [], + steps) + steps["extract"] = "ok" + step(f"Extracting {len(raw_rows)} row{'' if len(raw_rows) == 1 else 's'}") + + incoming = [] + for r in raw_rows: + mapped = {tgt: _s(r.get(src, ""), 500) for src, tgt in fmap.items()} + if any(v for v in mapped.values()): + incoming.append(mapped) + + fields = [field_def(tgt, src if len(src) <= 60 else src[:60]) + for src, tgt in fmap.items()] + # the key column leads, so the table opens on the identity it is upserted by + fields.sort(key=lambda f: 0 if f["key"] == key_field else 1) + label = cfg.get("targetLabel") or defn.get("name") or "Scraped table" + if dry: + # THE WRITE NODE IS OFF — read, compute, report, touch nothing. Not even `ut_ensure`, + # which would create the table: a dry run that leaves a new empty database behind is not + # a dry run. + table_key = ut_key_for(label, cfg.get("targetTable") or None) + else: + table_key = ut_ensure(rt, label, fields, username, key=cfg.get("targetTable") or None) + t = ut_get(rt, table_key) or {} + # ⛔ D-116 — THE ONE CALL SITE THAT NEEDS A PRECEDENCE RULE, because it is the one whose rows + # come from a CORPUS. Every other `upsert_rows` caller writes measurements or appends a series. + rows, counts = upsert_rows(t.get("rows") or {}, incoming, key_field, + cap=row_cap(table_key), protect=corpus_protect) + touched = [rid for rid, row in rows.items() + if str(row.get(key_field, "")) in {str(i.get(key_field)) for i in incoming}] + if not dry: + ut_write_rows(rt, table_key, rows) + counts["source_rows"] = len(incoming) + affected = touched[:200] + summary = (f"{counts['inserted']} new, {counts['updated']} updated, " + f"{counts['unchanged']} unchanged, {counts['orphans']} no longer on the page " + f"(kept)") + # ⛔ D-116 — SAY IT. A search that quietly declines to overwrite six measured cells is doing + # the right thing invisibly, which is indistinguishable from a corpus that happened to agree — + # and the next person to wonder why a number did not move has nothing to read. The observation + # still lands in the series either way; this sentence is about the SCALAR. + if counts.get("held"): + summary += (f", {counts['held']} measured value(s) kept over the corpus " + f"(an enrichment read them exactly; the search's own numbers are in the " + f"snapshot series)") + capped = [(table_key, counts.get("capped", 0))] + missing = [] if dry else ut_missing(rt, table_key) + counts["missing_tables"] = len(missing) + note = cap_note(capped, missing) + if note: + summary += f". {note}" + steps["write"] = "partial" + if dry: + summary = f"Test run. Nothing saved. Would have written: {summary}" + steps["write"] = "skipped" + elif not note: + steps["write"] = "ok" + state = "partial" if (counts.get("capped") or counts.get("skipped") or missing + or dry) else "ok" + log(f"[aios-auto] scrape_db {defn.get('id')} -> {table_key}: {summary}") + return (state, summary, counts, affected, steps) + + +def capture_rows(res, pulled): + """ONE pull → `(snapshot_row, post_identity_rows, post_metric_rows, comment_rows)`. + + ⭐ WAVE 25 (C4) — FACTORED OUT OF `run_field_instagram` SO THE ENRICH ACTION CAN REUSE IT + RATHER THAN FORK IT. C4's instruction is literal: "Reuse `pull_profile` and the write path — + do not fork them." A second copy of this mapping is the shape that goes wrong invisibly, + because the two copies would each be *plausible* and would disagree only on the rows a + particular rung happened to return. + + ⚠ THE TWO WRITE DISCIPLINES ARE OPPOSITE HERE, AND BOTH ARE DELIBERATE: + * A SNAPSHOT ROW IS APPEND-KEYED (`@`), so every key is written every + time and a blank is unambiguous — it means THIS PULL did not read it. + * AN IDENTITY ROW OMITS ITS BLANKS, because it is UPSERTED: `posted_at`/`caption`/`type` are + readable on some rungs and not others (the Bright Data PROFILE row carries post identity + with `datetime: None`, measured 24/24, while the engagement rung knows `date_posted`), and + `upsert_rows` writes exactly the keys it is handed — so sending "" would let a cheap run + ERASE what an expensive one learned. + * AN ENGAGEMENT SNAPSHOT IS APPENDED ONLY WHEN SOMETHING WAS MEASURED. The post series is + the append table that FILLS (maxPosts rows per profile per pull); with the paid engagement + rung OFF every one of those rows would carry three blanks — pure noise, eating a 200k + ceiling and drawing a chart of nothing. A row in a time series should mean "this was true + then"; a row meaning "nobody looked" belongs nowhere. + """ + prof = (res or {}).get("profile") or {} + snap_row = { + "snapshot_key": f"{prof.get('username')}@{pulled}", + "influencer_key": prof.get("username"), "pulled_at": pulled, + # ⛔ THE PUBLIC WORD, NOT THE VENDOR KEY — this cell is rendered in a grid column called + # "Read via". `res["via"]` keeps the real key for the call graph and the server log. + "source": _s(public_source((res or {}).get("via"))), "approx": _s(prof.get("approx")), + "bio": _s(prof.get("bio"), 500), + "external_url": _s(prof.get("external_url"), 300), + } + for fd in SNAPSHOT_FIELDS: + k = fd["key"] + if k not in snap_row: + value = prof.get(k) + # Profile history declares the same percentage dialect as the current Profile row. + # Converting only the latest projection left the append series at 0-1 while its field + # now said pct (0-100), so the two views of one measurement disagreed by 100x. + if k == "source_payload": + snap_row[k] = str(value or "") + else: + snap_row[k] = _s(_pct100(value) if k == "avg_engagement" else value, 400) + idents, metrics_rows, comment_rows = [], [], [] + for p in (res or {}).get("posts") or []: + ident = {"shortcode": p["shortcode"], "influencer_key": prof.get("username"), + "url": _s(p.get("url"), 300)} + for k, n in (("posted_at", 200), ("type", 200), ("caption", 800), + ("paid_partnership", 8), ("partner", 120), ("hashtags", 400), + ("alt_text", 400), ("tagged_location", 400)): + if p.get(k): + ident[k] = _s(p.get(k), n) + if p.get("source_payload"): + ident["source_payload"] = str(p["source_payload"]) + metrics = {k: p.get(k) for k in ("likes", "comments", "views", "plays")} + # ⭐⭐ 2026-08-07 — THE LATEST ENGAGEMENT VALUES, ONTO THE POST ROW ITSELF. + # + # This is what keeps a rollup at ONE HOP (Airtable's rule and ours): without it, "average + # views over the last 12 posts" would have to walk profile → posts → each post's most + # recent snapshot, and a two-hop rollup is a much larger feature with a much worse + # invalidation story. The post row now carries its own latest, exactly as the profile row + # carries LATEST + `enriched_at` while its series lives in `ut_ig_snapshots` (R3). + # + # ⛔ ONE STORE FOR ONE SERIES IS UNTOUCHED: `ut_ig_post_snapshots` below is still the + # authoritative engagement series and still gets its appended row. These cells are a + # projection of the row being appended in the same breath, never a second source. + # ⛔ A KEY IS WRITTEN ONLY WHEN THE VENDOR ANSWERED. `upsert_rows` merges, so an absent + # key leaves the previous pull's value standing — which is the correct behaviour for a + # LATEST column and the reason this cannot be a blanket `_s(...)` over all three: writing + # "" on a run that did not buy metrics would ERASE what an earlier paid run learned, the + # same failure the snapshot-append rule above is written against. + measured = {k: v for k, v in metrics.items() if v is not None and str(v).strip() != ""} + if measured: + ident.update({k: _s(v, 40) for k, v in measured.items()}) + # The stamp is what makes the numbers readable: a blank `plays` beside a + # `measured_at` of last week means "we looked and the vendor had nothing", and with + # no stamp it means that AND "we never looked", indistinguishably. + ident["measured_at"] = _day(pulled) + idents.append(ident) + if any(v is not None for v in metrics.values()): + metrics_rows.append({ + "post_snapshot_key": f"{p['shortcode']}@{pulled}", + "shortcode": p["shortcode"], "influencer_key": prof.get("username"), + "pulled_at": pulled, + # The two denormalised post facts (see `POST_SNAPSHOT_FIELDS`). Written from the + # SAME vendor record the identity row above is built from, so they cannot disagree + # with it on this pull. + # ⚠ BLANK IS EXPECTED AND IS NOT A BUG on the profile rung: `wave20-split` + # measured `datetime` as None on 24/24 posts from the Profiles dataset, so a pull + # that learns a post's engagement often does not learn its date in the same + # breath. `backfill_post_snapshot_grain` fills those from the post row, which by + # then may have learned it from a different rung. + "posted_at": _s(p.get("posted_at"), 200), "type": _s(p.get("type"), 200), + "likes": _s(metrics["likes"]), "comments": _s(metrics["comments"]), + "views": _s(metrics["views"]), "plays": _s(metrics["plays"]), + "source_payload": str(p.get("source_payload") or "")}) + for comment in p.get("embedded_comments") or []: + # The embedded shape often names only the commenter. Its parent Post is the + # authoritative owner, so bind every preview to this profile and post explicitly. + comment_rows.append({**comment, "shortcode": p["shortcode"], + "influencer_key": prof.get("username") or ""}) + comment_rows.extend((res or {}).get("comments") or []) + # The same comment can be returned as both `latest_comments` and `top_comments`, or by the + # optional full endpoint after an embedded preview. The canonical key is the one row truth. + unique_comments = {} + for row in comment_rows: + key = str((row or {}).get("comment_key") or "") + if key: + unique_comments[key] = {**(unique_comments.get(key) or {}), **row} + return snap_row, idents, metrics_rows, list(unique_comments.values()) + + +#: C4 — how ONE pulled profile becomes the C1 preset CELLS on the record being enriched. +#: `profile key -> preset column key`, so the mapping is a table rather than sixteen lines of +#: `row[...] = prof.get(...)` that a future field addition can silently miss. +#: ⚠ Keys absent from a pull are simply not written (R3: a blank preset cell means "this pull did +#: not read it", and `enriched_at` is what distinguishes that from "never enriched"). +PRESET_FROM_PROFILE = { + "username": "handle", "profile_url": "profile_url", "full_name": "full_name", + "followers": "followers", "following": "following", "avg_engagement": "avg_engagement", + "bio": "bio", "external_url": "external_url", "verified": "verified", + "category": "category", "posts_count": "posts_count", + "highlights_count": "highlights_count", "is_business": "is_business", + "is_professional": "is_professional", "ig_id": "ig_id", + # ⭐ 2026-08-07 — the promoted fields. A preset column with no row in this table is a column + # that can only ever be blank, so "make the preset fields populated" is THIS half of the + # owner's instruction and the field list is only the other half. + # ⚠ The two lists are held in step by a derived gate rather than by care: every + # `PRESET_PROFILE_KEYS` entry must either be written by this map or be explicitly declared + # as written elsewhere (`platform`, `enriched_at`, `posts` are stamped by `preset_cells`). + "business_category": "business_category", "is_private": "is_private", + "bio_hashtags": "bio_hashtags", + "pronouns": "pronouns", "profile_name": "profile_name", + "is_joined_recently": "is_joined_recently", "has_channel": "has_channel", + "partner_id": "partner_id", "external_url_title": "external_url_title", + "fbid": "fbid", "related_accounts": "related_accounts", + "country_code": "country_code", "source_payload": "source_payload", +} + +#: ⭐ 2026-08-07 — the preset keys `PRESET_FROM_PROFILE` deliberately does NOT carry, because a +#: different writer stamps them. DERIVED gates compare the two lists, and without this the gate +#: could only be written as "these three are fine" — a hard-coded exception list, which is the +#: shape [[gate-answers-the-wrong-question]] warns about. +#: ⚠ The five relational columns are written by `compute_relation_cells` on the tick, NOT by an +#: enrichment run — which is the whole point of them: they stay true when the LINKED table +#: changes, and a pull that touched no profile still updates a profile's post count. +PRESET_WRITTEN_ELSEWHERE = ( + "platform", "handle", "enriched_at", + "posts_link", "profile_snapshots_link", "post_snapshots_link", "comments_link", + "avg_views_12", "avg_plays_12", "avg_likes_12", "avg_comments_12", + "posts_captured", "profile_reads", "post_measurements_captured", "comments_captured", +) + + +#: A location guess needs at least this many GEOTAGGED posts to agree with. One post is not a +#: pattern and would render as 100% confident, which is the fabrication `SEED_MIN_ROWS_SHARING` +#: refuses on the discovery side for exactly the same reason. +LOCATION_MIN_POSTS = 2 + + +def _post_place(post): + """The city a post was tagged in, or ''. Reads the normalised field FIRST, the paid payload + second. + + ⛔ THE SECOND READ IS THE POINT AND IT COSTS NOTHING. `_bd_tagged_location` flattens the + vendor's `location` array into "Capanema, Pará, Brasil" and DISCARDS the rest — including the + `lat`/`lng`/`name` object Bright Data returns on some rows and the whole `location_details` + shape. The complete vendor row is retained in `source_payload` on the same record, already + paid for, so a post whose normalised field came back blank can still be read here. + ⚠ THE FIRST COMPONENT IS THE CITY. The vendor's array is ordered outward — city, region, + country — so the head is the narrowest thing it told us, and "Jakarta" is the answer to + "where is this person"; "Indonesia" mostly is not. + """ + if not isinstance(post, dict): + return "" + raw = str(post.get("tagged_location") or "").strip() + if not raw: + try: + payload = json.loads(post.get("source_payload") or "{}") + except Exception: # noqa: BLE001 + payload = {} + raw = str(_bd_tagged_location(payload) or "") if isinstance(payload, dict) else "" + return raw.split(",")[0].strip() + + +def location_guess(posts): + """`(city, confidence_pct)` from the places a creator's own posts were tagged in. + + `confidence` is the share of GEOTAGGED posts that agree on the modal city — deliberately not + the share of ALL posts, because a creator who geotags three of twelve is telling us about + three, and dividing by twelve would report a real signal as weak. How thin the evidence is + stays visible a different way: `LOCATION_MIN_POSTS` refuses to answer at all below two. + """ + places = [p for p in (_post_place(x) for x in posts or []) if p] + if len(places) < LOCATION_MIN_POSTS: + return "", None + counts = {} + for p in places: + counts[p] = counts.get(p, 0) + 1 + # ⚠ TIE-BROKEN BY FIRST APPEARANCE, never by sort order: two cities at 3/3 would otherwise be + # resolved alphabetically, which is an arbitrary answer wearing a confident number. + best = max(places, key=lambda p: (counts[p], -places.index(p))) + return best, round(100.0 * counts[best] / len(places), 1) + + +def preset_cells(res, pulled): + """C4/R3: one pull → the LATEST-value cells written onto the enriched record. + + ⛔ `enriched_at` IS ALWAYS WRITTEN when a pull succeeded, and it is the field that makes the + other fifteen readable: without it a blank `followers` means both "never enriched" and + "enriched in March and the vendor had nothing", and no cell on the row can tell them apart. + """ + prof = (res or {}).get("profile") or {} + cells = {} + for src, dest in PRESET_FROM_PROFILE.items(): + v = prof.get(src) + if v is not None and v != "": + # ⚠ WAVE 26 / C1-a — the ONE key whose value is not carried straight across. The + # vendor's engagement is a 0–1 fraction and our `pct` column is 0–100, so a + # straight-through copy here would write the same wrong number the discovery path + # used to write. Both writers convert; neither is the exception. + cells[dest] = (_pct100(v) if dest == "avg_engagement" else + (str(v) if dest == "source_payload" else _s(v, 500))) + # ⭐ R5 — stamped by the writer, not carried from the pull: this function only ever sees an + # Instagram profile. It matters on an ENRICH of a row somebody typed by hand, which may have + # arrived with no platform at all — and a blank half of the dedup key is how one account + # becomes two rows. + cells["platform"] = PLATFORM_INSTAGRAM + # ⭐ ITEM 16 — the residency guess, off posts this pull already paid for. Written only when + # there is one: a blank is "we could not tell", and overwriting a good guess from a run whose + # twelve posts happened to carry no geotag would make the column worse the more it ran. + place, confidence = location_guess((res or {}).get("posts")) + if place: + cells["location_guess"] = _s(place, 120) + cells["location_confidence"] = str(confidence) + # R3: `enriched_at` is a `date` column now. It answers "how stale is this number", which is a + # question in days; the full stamp keeps its precision on the snapshot series. + cells["enriched_at"] = _day(pulled) + return cells + + +#: Keys the TikTok SNAPSHOT row owns itself, so the copy loop below never overwrites them from the +#: profile map. Mirrors `_SNAPSHOT_OWN_KEYS` on the Instagram side. +_TT_SNAPSHOT_OWN_KEYS = ("snapshot_key", "influencer_key", "pulled_at", "source", "approx") + + +def tt_preset_cells(res, pulled): + """⭐⭐ WAVE 30 · T08 — one TikTok pull → the LATEST-value cells written onto the record. + + ⛔ THIS IS NOT `preset_cells` WITH A FLAG, AND THAT IS THE WHOLE DESIGN DECISION. Instagram's + `preset_cells` walks `PRESET_FROM_PROFILE` (an Instagram map), stamps + `cells["platform"] = PLATFORM_INSTAGRAM` UNCONDITIONALLY, and computes a location guess from + posts. Passing a TikTok row through it would stamp every TikTok creator as Instagram — which is + the identity half of W26's `(platform, handle)` key, so it would not merely mislabel a row, it + would MERGE two different people's accounts under one key. + ⭐ AND THE TIKTOK ROW DOES NOT NEED A MAP AT ALL. `connectors_tt.normalize_profile` already + emits our column names — it is the SAME function the discovery runner uses — so a scraped row + and a corpus row cannot disagree about which vendor key became which column. All this adds is + what the FETCH knows and the map cannot: when it was read, and by which route. + + ⚠ FILTERED TO THE DECLARED SCHEMA. `ut_write_rows`/`_clean_field` DROP an undeclared key with + no error and a successful-looking run (the defect `section_w29_tiktok_schema`'s emit-vs-declare + sweep exists for), so an unknown key is refused here where it is visible rather than swallowed + three layers down. + """ + prof = (res or {}).get("profile") or {} + declared = {f["key"] for f in TT_PROFILE_FIELDS} + cells = {k: v for k, v in prof.items() if k in declared} + # `source` is the ROUTE that answered, not the vendor's name — the same thing `via` carries on + # the Instagram snapshot, and the reason a stored measurement can always say how it was read. + if (res or {}).get("via"): + cells["source"] = _s((res or {}).get("via"), 60) + # R3, as on the Instagram side: a `date`, because "how stale is this number" is a question in + # days. Without it a blank `followers` means both "never enriched" and "enriched and empty". + cells["enriched_at"] = _day(pulled) + return cells + + +#: The metric columns a TikTok post SNAPSHOT carries. ⛔ DERIVED from the declaration, minus the +#: keys the snapshot owns itself — so adding a metric to `TT_POST_SNAPSHOT_FIELDS` starts being +#: captured, and adding an IDENTITY column to it never does. +_TT_PSNAP_OWN_KEYS = ("platform", "post_snapshot_key", "shortcode", "influencer_key", "pulled_at", + "post_link") +TT_PSNAP_METRIC_KEYS = tuple(f["key"] for f in TT_POST_SNAPSHOT_FIELDS + if f["key"] not in _TT_PSNAP_OWN_KEYS) + + +def tt_capture_rows(res, pulled): + """⭐⭐ WAVE 30 · T10 — one TikTok pull → `(post_identity_rows, post_metric_rows, comment_rows)`. + + The TikTok twin of `capture_rows`, and separate for the same reason `tt_preset_cells` is: that + function walks Instagram's `SNAPSHOT_FIELDS`/`PRESET_FROM_PROFILE` and stamps Instagram's + platform. The mappers have already done the vendor→our-keys work here + (`connectors_tt.normalize_post` / `normalize_comment`), so this adds only what the FETCH knows. + + ⚠ THE TWO WRITE DISCIPLINES ARE OPPOSITE, exactly as on the Instagram side: + * AN IDENTITY ROW OMITS ITS BLANKS, because it is UPSERTED — `upsert_rows` writes the keys it + is handed, so sending "" would let a thin run ERASE what a full one learned. + * A METRIC SNAPSHOT IS APPENDED ONLY WHEN SOMETHING WAS MEASURED. A row in a time series + should mean "this was true then"; a row meaning "nobody looked" eats the cap and draws a + chart of nothing. + + ⛔⛔ D-117 IS NOT REPRODUCED HERE, AND THAT IS THE INSTRUCTION THE TICKET LEADS WITH. + Instagram denormalises `posted_at` and `type` onto its post SNAPSHOT rows, where they never + reconcile against the identity table and quietly become a second, ageing answer to a question + the posts table already answers. This snapshot carries the METRICS and the join key and nothing + else — `TT_PSNAP_METRIC_KEYS` is derived from the declaration minus the keys the snapshot owns, + so the exclusion is structural rather than a list somebody has to remember to keep short. + """ + prof = (res or {}).get("profile") or {} + who = str(prof.get("handle") or "").strip().lstrip("@").lower() + idents, metric_rows, comment_rows = [], [], [] + for p in (res or {}).get("posts") or []: + shortcode = str((p or {}).get("shortcode") or "").strip() + if not shortcode: + continue + ident = {k: v for k, v in p.items() if v not in (None, "")} + # ⚠ The Posts dataset carries `profile_username`, but a row that omits it must still join: + # the profile whose `top_videos` we scraped IS the influencer, and that is a fact of the + # call rather than of the row. + ident.setdefault("influencer_key", who) + ident["platform"] = PLATFORM_TIKTOK + idents.append(ident) + measured = {k: p.get(k) for k in TT_PSNAP_METRIC_KEYS + if k != "source_payload" and p.get(k) is not None} + if not measured: + continue + row = {"platform": PLATFORM_TIKTOK, "shortcode": shortcode, + "influencer_key": str(p.get("influencer_key") or who), + "post_snapshot_key": f"{shortcode}@{pulled}", "pulled_at": pulled} + for k, v in measured.items(): + row[k] = _s(v, 400) + metric_rows.append(row) + for c in (res or {}).get("comments") or []: + if not str((c or {}).get("comment_key") or "").strip(): + continue + row = {k: v for k, v in c.items() if v not in (None, "")} + # Same fact-of-the-call argument: the Comments dataset has no influencer field at all, so + # without this the comments table could never be filtered by creator. + row.setdefault("influencer_key", who) + row["platform"] = PLATFORM_TIKTOK + comment_rows.append(row) + return idents, metric_rows, comment_rows + + +def tt_snapshot_row(res, pulled): + """One TikTok pull → its `ut_tt_snapshots` observation, or None when there is no handle. + + ⛔ THE APPEND LAW, UNCHANGED FROM INSTAGRAM AND FOR THE SAME MEASURED REASON: TikTok's Profiles + dataset carries **no measurement timestamp** (`create_time` is when the ACCOUNT was made, not + when `followers` was true — probed, `tiktok-capture.md`). So the series is dated by when WE + read it, and the key is `@` — idempotent by its KEY rather than by a flag, + so re-running over the same rows rewrites the same row instead of growing the table. + ⚠ `pulled_at` keeps the FULL stamp while the record's `enriched_at` is a day: the record answers + "how stale", the series answers "when exactly", and collapsing the second into the first would + make two reads on one day indistinguishable. + """ + prof = (res or {}).get("profile") or {} + handle = str(prof.get("handle") or "").strip().lstrip("@").lower() + if not handle: + return None + out = {"platform": PLATFORM_TIKTOK, "snapshot_key": f"{handle}@{pulled}", + "influencer_key": handle, "pulled_at": pulled, + "source": _s((res or {}).get("via") or "brightdata", 60)} + for fd in TT_SNAPSHOT_FIELDS: + key = fd["key"] + if key in _TT_SNAPSHOT_OWN_KEYS or key == "platform": + continue + value = prof.get(key) + # ⚠ A ZERO IS A MEASUREMENT AND SURVIVES — the test is `is not None`, never truthiness. + # `str(value or "").strip()` would drop a genuine 0 follower count, and this is a series + # whose whole purpose is that a number moved. + if value is not None and str(value).strip() != "": + out[key] = str(value) if key == "source_payload" else _s(value, 400) + return out + + +def run_field_instagram(rt, defn, username="automation", log=print, step=_no_step, + rows=None): + """Automation #2 (R7): for every row of a database that carries a profile URL, pull the public + profile through the paid capability chain, write a status string into the automation column, + and append a timestamped row to each of the three IG tables. + + ⭐ WAVE 28 / R5 — there is no tier and no rung choice here any more. `pull_profile` routes per + CAPABILITY and reports `blocked` rather than downgrading to an approximate row, so the two + "which rung answered" counters this function used to keep have nothing left to distinguish.""" + cfg = defn.get("config") or {} + table_key, fkey = cfg.get("targetTable"), cfg.get("fieldKey") + post_metrics = bool(cfg.get("postMetrics")) + comment_metrics = bool(cfg.get("commentMetrics")) + dry = bool(cfg.get("dryRun")) + # ⚠ THE STEP KEYS ARE THE CANVAS NODE IDS (contract C3) and the two must move together — a + # status written under a node id `graph()` no longer emits is a dot nothing renders, which is + # indistinguishable from a step that never ran. + steps = {"trigger": "ok", "source": "idle", + "capture_posts": "idle" if post_metrics else "skipped", + "capture_comments": "idle" if comment_metrics else "skipped", + "write": "idle"} + t = ut_get(rt, table_key) + if not t: + steps["source"] = "error" + return ("error", f"{table_key} is not a database in this workspace", {}, [], steps) + url_field = cfg.get("urlField") or _auto_url_field(t, fkey) + if not url_field: + steps["source"] = "error" + return ("error", "the automation column has no URL field bound to it", {}, [], steps) + steps["source"] = "ok" + rows = dict(t.get("rows") or {}) + # the relational tables the pull lands in (R7): every row timestamped for time-range filters + if dry: + # The Write node is off: resolve the keys, create nothing. (`ut_ensure` writes.) + snap_key, post_key, ps_key, comment_key = (IG_SNAPSHOTS_TABLE, IG_POSTS_TABLE, + IG_POST_SNAPSHOTS_TABLE, IG_COMMENTS_TABLE) + else: + graph = ensure_ig_graph(rt, username, str(defn.get("id") or ""), + profile_table=table_key) + snap_key, post_key, ps_key, comment_key = (graph[IG_SNAPSHOTS_TABLE], graph[IG_POSTS_TABLE], + graph[IG_POST_SNAPSHOTS_TABLE], graph[IG_COMMENTS_TABLE]) + missing = [] if dry else ut_missing(rt, snap_key, post_key, ps_key, comment_key) + snaps = dict((ut_get(rt, snap_key) or {}).get("rows") or {}) + posts = dict((ut_get(rt, post_key) or {}).get("rows") or {}) + psnaps = dict((ut_get(rt, ps_key) or {}).get("rows") or {}) + comments = dict((ut_get(rt, comment_key) or {}).get("rows") or {}) + + counts = {"profiles": 0, "ok": 0, "partial": 0, "blocked": 0, "error": 0, + "posts": 0, "new_posts": 0, "paid": 0, "capped": 0, "metrics": 0, "comment_rows": 0, + "missing_tables": len(missing)} + cells, affected, notes = {}, [], [] + pending_metrics = [] + # ⛔ ACCUMULATE HERE, UPSERT ONCE PER TABLE AFTER THE LOOP. This used to call `upsert_rows` + # once per POST, which is O(existing) per call — survivable only while the cap was 5000 rows. + # `MAX_UT_IG_ROWS` makes the same loop hundreds of millions of dict copies, i.e. an automation + # that no longer finishes. **Raising a cap and batching its writer are ONE change.** (W19-C.) + in_snaps, in_posts, in_psnaps, in_comments = [], [], [], [] + targets = [(rid, str(r.get(url_field, "") or "").strip()) + for rid, r in rows.items() if str(r.get(url_field, "") or "").strip()] + for i, (rid, url) in enumerate(targets): + if i: + time.sleep(PACE_SECONDS) # ≥2 s between profiles (R7) + # ITEM 6: the other genuinely long runner — ≥2 s of pacing per profile means a 60-profile + # column automation blocks for minutes by design. A counting step is the difference + # between "working through them" and "stuck". + step(f"Capturing profile {i + 1} of {len(targets)}") + counts["profiles"] += 1 + res = pull_profile(url, max_posts=cfg.get("maxPosts") or DEFAULT_POSTS_PER_PULL, log=log, + post_metrics=post_metrics, comment_metrics=comment_metrics, + pending_metrics=(pending_metrics if not dry else None)) + pulled = _iso() + via = res.get("via") or "" + read_ok = res["state"] in ("ok", "partial") + # ⚠ `counts["paid"]` SURVIVES R5 AND IT IS NOT THE RETIRED TIER. It counts profiles a PAID + # vendor actually answered, which is the run's spend report — the thing the owner reads to + # reconcile a bill. What died is the free rung it used to be contrasted with, so the test + # is now simply "did a vendor answer" rather than "did the vendor we were told to try". + if read_ok and via in ("brightdata", "apify"): + counts["paid"] += 1 + if read_ok: + counts["ok" if res["state"] == "ok" else "partial"] += 1 + prof = res["profile"] + snap_row, ident_rows, metric_rows, captured_comments = capture_rows(res, pulled) + in_snaps.append(snap_row) + in_posts.extend(ident_rows) + in_psnaps.extend(metric_rows) + in_comments.extend(captured_comments) + counts["metrics"] += len(metric_rows) + counts["comment_rows"] += len(captured_comments) + counts["posts"] += len(res.get("posts") or []) + detail = f"{prof.get('followers') or '?'} followers" + if prof.get("approx"): + detail += " (approx)" + if res.get("posts"): + detail += f", {len(res['posts'])} posts" + elif res["state"] == "partial": + detail += ", posts not readable on the rung that answered" + cells[rid] = f"{res['state']} · {_stamp()} · {detail}" + elif res["state"] == "blocked": + counts["blocked"] += 1 + cells[rid] = f"blocked · {_stamp()} · {_s(res.get('note'), 90)}" + notes.append(res.get("note") or "blocked") + else: + counts["error"] += 1 + cells[rid] = f"error · {_stamp()} · {_s(res.get('note'), 90)}" + notes.append(res.get("note") or "error") + affected.append(rid) + + # --- THE THREE UPSERTS. Once each, over the whole run's accumulated rows. + snaps, c_snap = upsert_rows(snaps, in_snaps, "snapshot_key", cap=row_cap(snap_key)) + posts, collapsed_posts = dedupe_canonical_rows(posts, "shortcode", newest_by="measured_at") + posts, c_post = upsert_rows(posts, in_posts, "shortcode", cap=row_cap(post_key)) + c_post["duplicates"] += collapsed_posts + psnaps, c_ps = upsert_rows(psnaps, in_psnaps, "post_snapshot_key", cap=row_cap(ps_key)) + comments, collapsed_comments = dedupe_canonical_rows(comments, "comment_key") + comments, c_comments = upsert_rows(comments, in_comments, "comment_key", cap=row_cap(comment_key)) + c_comments["duplicates"] += collapsed_comments + counts["new_posts"] = c_post["inserted"] + capped = [(snap_key, c_snap["capped"]), (post_key, c_post["capped"]), + (ps_key, c_ps["capped"]), (comment_key, c_comments["capped"])] + counts["capped"] = sum(n for _k, n in capped) + + if not dry: + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + tt = cur.get(table_key) + if tt is not None: + for rid, val in cells.items(): + tt.setdefault("rows", {}).setdefault(str(rid), {})[fkey] = val + for k, rws in ((snap_key, snaps), (post_key, posts), (ps_key, psnaps), + (comment_key, comments)): + tgt = cur.get(k) + if tgt is not None: + tgt["rows"] = rws + _refresh_relations_inplace(cur, log=log) + return cur + + rt.update(UT_STORE_KEY, _up, flush="sync") # ONE coalesced update for all four tables + queued = queue_pending_metric_snapshots(rt, str(defn.get("id") or ""), pending_metrics) + if queued: + counts["metric_batches_pending"] = queued + + # --- C6 (R2): WRITE-THROUGH to the platform master. Three postures, never conflated: + # `ok` silent, `off` an honest aside (the tenant copy IS the story on this deployment), + # `error` a LOUD partial — a pooled history with silent holes is worse than none. + master_note = "" + if not dry and (in_snaps or in_posts or in_psnaps): + import ig_master + m_status, m_note = ig_master.append_run(getattr(rt, "key", ""), + in_snaps, in_posts, in_psnaps) + if m_status == "error": + master_note = f"⚠ the platform master copy FAILED. {m_note}; the tenant copy " \ + f"is complete and the next run re-appends" + counts["master_failed"] = 1 + elif m_status == "ok": + counts["master"] = len(in_snaps) + len(in_psnaps) + + # ⚠ A RUN IS ONLY 'ok' IF EVERY PROFILE WAS. The first live run rolled up to 'ok' while + # every CELL said `partial`, because the rollup only asked about blocked/error — so the rail + # showed a green dot over a table with no posts in it. A summary that disagrees with the + # cells it summarises is the failure this whole module's honest-status rule exists to + # prevent, so `partial` now propagates. Measured 2026-08-04. + read = counts["ok"] + counts["partial"] + if not read: + state = "error" if counts["error"] else "partial" if counts["blocked"] else "ok" + elif counts["blocked"] or counts["error"] or counts["partial"]: + state = "partial" + else: + state = "ok" + if counts.get("metric_batches_pending"): + state = "partial" + # --- per-node outcomes for the canvas (see the same note in `run_scrape_db`) + if not targets: + cap_state = "idle" + elif not read: + cap_state = "error" if counts["error"] else "blocked" + else: + cap_state = "partial" if (counts["blocked"] or counts["error"] + or counts["partial"]) else "ok" + # ⭐ C3 — THE CAPTURE FORK IS GONE, SO ITS OUTCOME LANDS ON `source`. There is one way to + # read a profile now, and the node that names the profile set is the honest owner of "did + # reading them work". The old `capture`/`capture_paid`/`capture_anon` trio described a branch + # that no longer exists in behaviour OR on screen. + steps["source"] = cap_state if targets else steps["source"] + # ⚠ MEASURED, LIKE EVERY OTHER DOT: it is `ok` only if an engagement row was actually + # appended. "It was switched on" is not the same fact as "it answered", and painting the + # second from the first is the fabrication `node_status` refuses to make. + # ⚠ AND `blocked` IS A CLAIM ABOUT THE VENDOR, so it needs something to have been ASKED. A + # run that found no posts to enrich did not have a rung refuse it — nothing was requested — + # so that reads `skipped`, the same word an off switch earns. + steps["capture_posts"] = ("ok" if counts["metrics"] + else "partial" if counts.get("metric_batches_pending") + else "blocked" if (post_metrics and counts["posts"]) + else "skipped") + steps["capture_comments"] = ("ok" if counts.get("comments") + else "blocked" if (comment_metrics and counts["posts"]) + else "skipped") + + summary = (f"{read}/{counts['profiles']} profiles read, {counts['posts']} posts " + f"({counts['new_posts']} new)") + if counts.get("metric_batches_pending"): + summary += (f", {counts['metric_batches_pending']} post-engagement batch" + f"{'' if counts['metric_batches_pending'] == 1 else 'es'} still building " + "(collected automatically)") + if counts["paid"]: + summary += f", {counts['paid']} with exact counts" + if counts["metrics"]: + summary += f", {counts['metrics']} post engagement snapshots" + elif post_metrics and counts["posts"] and not counts.get("metric_batches_pending"): + # ⚠ ASKED FOR AND NOT DELIVERED IS ITS OWN SENTENCE. Silence here would read as "there + # was no engagement", which is a claim about Instagram rather than about our run. + summary += ", no post engagement was readable" + if counts["comment_rows"]: + summary += f", {counts['comment_rows']} comment rows captured" + elif comment_metrics and counts["posts"]: + summary += ", no comment engagement was readable" + if counts["partial"]: + summary += f", {counts['partial']} profile-only (posts not readable)" + if counts["blocked"]: + summary += f", {counts['blocked']} blocked" + note = cap_note(capped, missing) + if note: + summary += f". {note}" + state = "partial" if state != "error" else state + steps["write"] = "partial" + elif not dry: + steps["write"] = "ok" + if master_note: + summary += f". {master_note}" + state = "partial" if state != "error" else state + steps["write"] = "partial" + if dry: + summary = f"Test run. Nothing saved. Would have written: {summary}" + steps["write"] = "skipped" + state = "partial" if state != "error" else state + if notes: + summary += f". {notes[0][:120]}" + if not dry: + # C7: the fresh master rows are exactly what the table's metric cells summarise. + try: + compute_metric_cells(rt, table_key) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] metric compute {table_key} failed: {type(e).__name__}: {e}") + return (state, summary, counts, affected, steps) + + +def _auto_url_field(table, fkey): + """The URL field an automation column is bound to: its own `automation.urlField`, else the + first url-typed column on the table. Never guessed silently — the caller reports which.""" + for f in table.get("fields") or []: + if f.get("key") == fkey: + bound = ((f.get("automation") or {}).get("urlField") or "").strip() + if bound: + return bound + for f in table.get("fields") or []: + if f.get("type") == "url": + return f.get("key") + return "" + + +#: ⭐ W29-T04 — the discovery-run bookkeeping columns, so `run_discover_tiktok` and the D-116 merge +#: rule agree on what discovery OWNS versus what enrichment owns. Anything not in this set is a +#: MEASUREMENT, and a corpus re-find must never overwrite an enriched measurement with its own +#: cheaper approximation. +TT_DISCOVERY_KEYS = frozenset({"platform", "handle", "created_by", "found_count", + "first_found", "last_found", "source", "source_payload"}) + + +def _tt_candidate_row(row, stamp): + """One TikTok corpus row → a `ut_tt_profile` candidate row. None without a handle. + + ⛔ NEVER EMITS `found_count` — the runner computes it against what is already stored, so + writing it here would reset the counter to 1 on every re-find. Same rule, same reason, as + `_candidate_row` on the Instagram side. + ⭐ THE FIELD MAP IS THE CONNECTOR'S, not a second one written here. `normalize_profile` is what + the enrich path will use too, so a corpus row and a scrape row cannot disagree about which + vendor key becomes which column. + """ + import connectors_tt as _tt + cells = _tt.normalize_profile(row if isinstance(row, dict) else {}) + if not str(cells.get("handle") or "").strip(): + return None + return {**cells, "last_found": stamp, "source": "corpus"} + + + +def _ig_discovery_series(rt, table_key, incoming, stamp, username, auto_id, counts, log): + """⭐⭐ THE FORWARD HALF (2026-08-10), Instagram only. Every candidate a run wrote also leaves + an OBSERVATION behind — see `corpus_snapshot_row`. Without it a backfill is theatre: the next + discovery run re-opens exactly the gap the backfill just closed, which is how a data defect + becomes a recurring one. + + ⚠ `ensure_ig_graph`, NOT a snapshot-only `ut_ensure`, and it is a real behaviour change worth + owning: a tenant that has never enriched gets the four canonical child databases the first time + discovery runs (four of `MAX_UT_TABLES = 40`). The alternative writes a series into a store the + profile table has no LINK to — the numbers would be recorded and unreachable, which is half a + feature wearing a whole one's clothes. + ⚠ `incoming`, not `merged`: the observation belongs to the profiles THIS RUN read, not to every + row the table happens to hold. + """ + try: + graph = ensure_ig_graph(rt, username, auto_id, profile_table=table_key) + counts["series"] = append_ig_snapshots( + rt, [corpus_snapshot_row(c, day=stamp) for c in incoming], + graph[IG_SNAPSHOTS_TABLE], log=log) + except Exception as e: # noqa: BLE001 + # The candidates already landed. A series write that fails must not throw away the rows we + # just paid the vendor for — the same posture the preset top-up takes. + log(f"[aios-auto] discover {auto_id}: corpus series write failed: " + f"{type(e).__name__}: {e}") + + +def _ig_discovery_metrics(rt, table_key, auto_id, log): + """C7 — the metric FIELDS computed off the master series. See the field runner.""" + try: + compute_metric_cells(rt, table_key) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] metric compute {table_key} failed: {type(e).__name__}: {e}") + + +#: ⭐⭐ WAVE 30 · T09 (DEBT D-129) — ONE DISCOVERY RUNNER, TWO CORPORA, AND EVERY DIFFERENCE BETWEEN +#: THEM IS A ROW IN THIS TABLE. +#: +#: `run_discover_tiktok` was written in wave 29 as a deliberate SIBLING of `run_discover_instagram` +#: — its own docstring said so and gave the reason: *"it would rewrite the one code path this +#: product bills money through"*. That was the right call with one platform's worth of evidence and +#: it stopped being right when the duplication reached ~210 lines, because a sibling does not +#: inherit fixes. The proof is already in the copy below: the TikTok runner carries the **D-116** +#: precedence rule (a corpus re-find must not overwrite an ENRICHED measurement with the corpus's +#: cheaper approximation) and the Instagram one, which is the one with paying rows in it, does not. +#: +#: ⛔ **THE COLLAPSE IS BEHAVIOUR-PRESERVING, DELIBERATELY, AND `discovery_keys` IS WHERE YOU CAN +#: SEE THAT DECISION RATHER THAN INFER IT.** Setting Instagram's to `IG_DISCOVERY_KEYS` would close +#: D-116 in one line — and it would change what a live, billed, nightly automation writes to rows +#: the owner reads, inside a refactor whose whole claim is that nothing moved. It is REPORTED +#: instead (see D-116's row), which is the standing rule for a limit that is not being removed +#: today. ⚠ The one-line fix is now visible in a table instead of buried 300 lines apart, which is +#: most of the value of doing this at all. +#: +#: ⚠ `dataset` IS A CALLABLE, not a string: `connectors_tt` imports this module at import time, so +#: the engine may only reach it from INSIDE a function. `mapper` has the same shape for the same +#: reason on the TikTok side. +#: ⚠ `handle_field` is the column the engine-added `not_in` exclusion names, and it is per platform +#: because B-20 MEASURED that Bright Data's TikTok Profiles dataset calls Instagram's `account` +#: `account_id`. The exclusion never appears as a condition row, so nothing a person types can +#: correct it. +DISCOVERY_SPECS = { + "discover_instagram": { + "noun": "profile", + "log_tag": "discover", + "platform": PLATFORM_INSTAGRAM, + "dataset": lambda: BD_DS_PROFILES, + "handle_field": "account", + "mapper": lambda row, stamp: _candidate_row(row, stamp), + "table": DISCOVER_TABLE, + "label": DISCOVER_LABEL, + "fields": CANDIDATE_FIELDS, + # ⛔ D-116 IS OPEN ON THIS SIDE. `None` = discovery overwrites whatever it re-finds. + "discovery_keys": None, + "on_write": _ig_discovery_series, + "after_write": _ig_discovery_metrics, + }, + "discover_tiktok": { + "noun": "TikTok profile", + "log_tag": "discover-tt", + "platform": PLATFORM_TIKTOK, + "dataset": lambda: _tt_module().TT_DS_PROFILES, + "handle_field": "account_id", + "mapper": lambda row, stamp: _tt_candidate_row(row, stamp), + "table": TT_PROFILE_TABLE, + "label": TT_TABLE_LABELS[TT_PROFILE_TABLE], + "fields": TT_PROFILE_FIELDS, + "discovery_keys": TT_DISCOVERY_KEYS, + # ⚠ NO series and NO metric fields on this side, and that is not an oversight: both hooks + # write into Instagram's own snapshot store, through Instagram's own vocabulary. TikTok's + # series is W29-T06's `ut_tt_post_snapshots`, which the ENRICH path owns. + "on_write": None, + "after_write": None, + }, +} + + +def _tt_module(): + """`connectors_tt`, imported LAZILY. It imports this module at module level, so a top-level + import here would close the cycle.""" + import connectors_tt as _tt + return _tt + + +def run_discovery(rt, defn, username="automation", log=print, step=_no_step, rows=None): + """Automation #3 (owner ruling R7 / D-23) and DEBT D-9: FIND handles nobody here has typed in, + on whichever corpus this automation's KIND names. + + Two-phase by construction — see the DISCOVERY section header. A run either STARTS a corpus + query and hands its snapshot id to the next run, or COLLECTS one a previous run started. Every + outcome is a sentence about what actually happened; none of them is a green zero. + + ⛔ THE PLATFORM COMES FROM `defn["kind"]`, WHICH IS THE ONLY THING THAT DECIDES IT NOW. Before + wave 30 the choice was made by WHICH FUNCTION `RUNNERS` pointed at, so a fixture (or a stored + definition) with a stale kind still ran the corpus its caller had in mind. It cannot any more: + an unknown kind is refused rather than defaulted, because defaulting here means billing one + platform's corpus for another platform's search. + """ + kind = str(defn.get("kind") or "") + spec = DISCOVERY_SPECS.get(kind) + cfg = defn.get("config") or {} + dry = bool(cfg.get("dryRun")) + limit = int(cfg.get("recordsLimit") or 5) + preds = list(cfg.get("predicates") or []) + auto_id = str(defn.get("id") or "") + steps = {"trigger": "ok", "find": "idle", "collect": "idle", "write": "idle"} + counts = {"asked": limit, "found": 0, "new": 0, "seen_again": 0, "capped": 0, + "missing_tables": 0} + if spec is None: + # ⛔ NEVER A DEFAULT. `RUNNERS` maps exactly the kinds in `DISCOVERY_SPECS` onto this + # function, so reaching here means somebody widened one of the two without the other. + steps["find"] = "blocked" + return ("error", f"this automation's kind ({kind or 'blank'}) has no profile corpus, so " + f"nothing was searched and nothing was charged", counts, [], steps) + noun = spec["noun"] + + if not bd_ready(): + # ⛔ THE FAIL-CLOSED PATH (C4). Discovery has NO anonymous rung to drop to — the corpus is + # the vendor's — so this is where the run stops, saying which env var is missing. + steps["find"] = "blocked" + return ("partial", "Profile search is not set up yet. Nothing was searched and nothing " + "was charged", counts, [], steps) + + pending = str((defn.get("state") or {}).get("pendingSnapshot") or "") + if not pending: + # C4 (wave 22): the guard holds at RUN too — a stored config can predate the law, and + # the vendor bills for breadth whether the filter was saved yesterday or last month. + # D-68: the SHAPE guard rides beside the breadth guard, at save AND at run - a config + # stored before this check existed must not reach the vendor either. + # ⭐ W32-T46: the RUN-time twin of the save-time guard says the right network's name too — + # `defn["kind"]` is what decides the corpus everywhere else in this function. + guard_err = (narrowing_refusal(preds, cfg.get("operator"), str(defn.get("kind") or "")) + or depth_refusal(preds, cfg.get("operator"))) + if guard_err: + steps["find"] = "blocked" + return ("error", f"the search was not started. {guard_err}", counts, [], steps) + if dry: + est = discover_estimate(limit) + steps["find"] = steps["write"] = "skipped" + return ("partial", + f"Test run. Nothing saved. Would look for up to {limit} " + f"{noun}{'' if limit == 1 else 's'} matching " + f"{_predicate_sentence(preds, cfg.get('operator'))}, for about " + f"${est['usd']}", counts, [], steps) + step("Starting the search") + # ⭐ ITEM 7 — DO NOT PAY FOR WHAT WE ALREADY HAVE. The exclusion is engine-added: it never + # appears as a condition row, and `bd_filter_start` drops it rather than risk a shape the + # vendor refuses. Reported either way, because "why did this run find fewer" and "why did + # this run cost the same as last night" are both questions this line answers. + # ⚠ `already_found_handles` NEEDS NO PER-PLATFORM BRANCH — it is scoped to THIS definition's + # own tables (`automation_tables`), so a TikTok automation targeting `ut_tt_profile` + # excludes exactly the handles it has itself found. Verified rather than assumed: the + # function reads `config.targetTable` plus each `create_record` action's table, and names + # no IG constant. + excl = {} + sid, note = bd_filter_start(preds, cfg.get("operator") or "and", limit, + dataset_id=spec["dataset"](), + exclude_handles=already_found_handles(rt, defn), + applied=excl, handle_field=spec["handle_field"]) + if excl.get("excluded"): + step(f"excluded {excl['excluded']} already-found " + f"profile{'' if excl['excluded'] == 1 else 's'} at the provider") + elif excl.get("dropped"): + step(f"searching without the already-found list. {excl['dropped']}") + if note: + steps["find"] = "blocked" + return ("error", f"the search was not started. {note}", counts, [], steps) + pending = sid + # ⛔ LOG THE ID *BEFORE* PERSISTING IT, and the order is the whole point. `set_state` goes + # through `rt.update`, which can RAISE on an unavailable store — and that exception + # becomes an error run, so a `log()` placed after it never executes. The snapshot would + # then exist, be billed, and have its id in neither the store nor the logs. Logging first + # means the worst case is still recoverable by a human reading the Space output. + log(f"[aios-auto] {spec['log_tag']} {auto_id}: started {sid}") + # PERSIST BEFORE POLLING. A snapshot the vendor is already building does not stop + # existing because this process dies thirty seconds later, and a lost id is a set we + # paid attention to and can never collect. + set_state(rt, auto_id, {"pendingSnapshot": sid, "pendingSince": _iso()}) + steps["find"] = "ok" + + waited, status, size, note = 0.0, "", 0, "" + while True: + # ⭐ ITEM 6 — THE ONE PLACE THE LIVE STEP HAS TO MOVE. This loop blocks for up to + # BD_FILTER_WAIT at the vendor, and D measured that this wait IS the whole of "Run once now + # is laggy / looks stuck". A counter here is what makes a legitimate wait distinguishable + # from a hung thread; without it the rendered step reads the same word for both, which is a + # progress indicator that cannot indicate progress. + step(f"Searching. {int(waited)}s of " + f"{int(BD_FILTER_WAIT)}s") + status, size, note = bd_filter_status(pending) + if note: + steps["collect"] = "error" + set_state(rt, auto_id, {"pendingSnapshot": None, "pendingSince": None}) + return ("error", note, counts, [], steps) + if status != "building" or waited >= BD_FILTER_WAIT: + break + time.sleep(BD_FILTER_POLL) + waited += BD_FILTER_POLL + step("Reading the profiles the search found") + + if status == "building": + # ⚠ THE HANDOFF, AND IT IS A SUCCESSFUL OUTCOME OF A SORT. Measured build latency is ~20 + # minutes; holding a worker thread that long on a free-tier container to poll would be + # the wrong shape. Say plainly that it is still running and who collects it. + # ⚠ THE SENTENCE NAMES WHO COLLECTS IT, because the old one did not and that is what made + # this read as "stuck": *"The next run picks up the results"* is a promise about a run + # that, on a MANUAL automation, nobody had scheduled. `pending_collect_ids` makes the tick + # finish it, so the promise is now kept by something rather than by the reader. + # ⭐ WAVE 27 ITEM 14 (owner) — THE BRAG IS GONE. The sentence used to explain the wait by + # naming the corpus size. It was TRUE, and it was answering a question the person had not + # asked. It was also the one place a vendor's catalogue size was quoted to a customer, so + # it would have gone stale the day the vendor grew. THE SHAPE OF THE ANSWER SURVIVES and is + # the part that mattered: the wait does not shrink when you ask for fewer records, and a + # person who does not know that reads "20 minutes for 10 rows" as a fault. + mins = 0 + try: + since = (defn.get("state") or {}).get("pendingSince") + if since: + mins = max(0, int((_dt.datetime.now(_dt.timezone.utc) + - _dt.datetime.fromisoformat(str(since))).total_seconds() + // 60)) + except Exception: # noqa: BLE001 + mins = 0 + been = f". {mins} min so far" if mins else "" + steps["collect"] = "partial" + return ("partial", + f"Still searching at the provider{been}. A corpus search takes about 20 minutes " + f"however few records you asked for. Nothing is lost, you are not charged twice, " + f"and the results are collected automatically as soon as they are ready", + counts, [], steps) + if status == "empty": + # A search that matched nothing RAN CORRECTLY. It is the ordinary result of a keyword + # that is too specific, so it says what to do about it instead of reporting a fault. + steps["collect"] = "ok" + steps["write"] = "skipped" + set_state(rt, auto_id, {"pendingSnapshot": None, "pendingSince": None}) + return ("partial", "No profiles matched. Try a shorter or more common keyword. " + "'floral' finds more accounts than 'floral design studio'", + counts, [], steps) + if status != "ready": + steps["collect"] = "error" + set_state(rt, auto_id, {"pendingSnapshot": None, "pendingSince": None}) + return ("error", f"the search ended as {status or 'unreadable'} and returned nothing", + counts, [], steps) + + rows, dnote = bd_filter_rows(pending) + if dnote: + # Delivery lags `ready` by minutes (measured). Keep the id; the next run collects it. + steps["collect"] = "partial" + return ("partial", f"the search finished with {size} match" + f"{'' if size == 1 else 'es'} but {dnote}", counts, [], steps) + set_state(rt, auto_id, {"pendingSnapshot": None, "pendingSince": None}) + steps["collect"] = "ok" + + stamp = _iso() + _map = spec["mapper"] + incoming = [c for c in (_map(r, stamp) for r in rows) if c] + counts["found"] = len(incoming) + # ⚠ COUNT THE ROWS WE COULD NOT USE. A corpus row with no handle cannot be a candidate, and + # dropping it silently means "the vendor delivered 50 and we kept 3" reads identically to + # "the vendor delivered 3". It also catches the shape where an error document comes back + # down the rows path and parses as one unusable row instead of an error. + counts["dropped"] = max(0, len(rows) - len(incoming)) + label = cfg.get("targetLabel") or spec["label"] + table_key = (ut_key_for(label, cfg.get("targetTable") or spec["table"]) if dry + else ut_ensure(rt, label, spec["fields"], username, + key=cfg.get("targetTable") or spec["table"], + flow_tag=auto_id, lock_fields=True)) + existing = dict((ut_get(rt, table_key) or {}).get("rows") or {}) + # --- ⭐ WAVE 26 · C3 / owner ruling R4 — THE UPSERT KEY IS `(platform, handle)`. + # + # ⛔ THIS RETIRES WAVE 22's C6 COMPOUND `(handle, created_by)`, deliberately, and the reasoning + # that built it is worth stating before it is discarded rather than after: per-user rows gave + # each person their own `found_count` and their own review card, so two colleagues scouting the + # same market never edited each other's work. Owner, 2026-08-06: one profile is ONE row. The + # tenant is the unit, not the user. `created_by` survives as "Found by" — informational, and no + # longer part of the identity. + # + # ⚠ AND THE OWNER ADDED THE GUARD THAT MAKES IT SAFE, WHICH IS THE HALF THAT WOULD HAVE BEEN + # MISSED: a handle is only unique WITHIN a network. `@inayma` on Instagram and `@inayma` on + # TikTok are routinely different people, so keying on the handle ALONE would have silently + # merged two accounts into one row the day the TikTok runner ships (D-9) — a data-loss bug with + # no error, discovered months later by someone wondering why a creator's followers halved. + # Hence `platform`, and hence it being part of the key rather than a label beside it. + _ck = candidate_key + + # ⚠ A ROW WITH NO `platform` CELL IS STAMPED WITH **THIS CORPUS'S** PLATFORM, and the default is + # per spec rather than global. On the Instagram table every legacy row came from the Instagram + # runner, so `Instagram` is a FACT about the stored data; in a `ut_tt_*` table the same blank + # means TikTok for exactly the same reason. Blank-keyed rows would fail to match their own + # re-find and duplicate the whole table on the next run. + for r in existing.values(): + if not str(r.get("platform") or "").strip(): + r["platform"] = spec["platform"] + existing2 = {rid: {**r, "_ckey": _ck(r.get("platform"), r.get("handle"))} + for rid, r in existing.items()} + # `found_count` is ARITHMETIC OVER WHAT IS STORED, not a value from the vendor — re-finding + # a profile is the signal that it keeps matching, so the counter grows instead of + # resetting. `first_found` is written only when the pair is new, so it never moves. + seen = {r["_ckey"]: r for r in existing2.values()} + _dkeys = spec["discovery_keys"] + for c in incoming: + # "Found by" — the first finder is recorded and later finders do not overwrite them, which + # is what the column means now that it is no longer part of the identity. + c["created_by"] = username + c["_ckey"] = _ck(c.get("platform"), c["handle"]) + prev_row = seen.get(c["_ckey"]) + if prev_row and str(prev_row.get("created_by") or "").strip(): + c["created_by"] = prev_row["created_by"] + if prev_row: + counts["seen_again"] += 1 + c["found_count"] = str((_ig_int(prev_row.get("found_count")) or 0) + 1) + # ⛔⛔ DEBT D-116 — THE ENRICHED-MEASUREMENT PRECEDENCE RULE, AND IT RUNS ONLY WHERE THE + # SPEC DECLARES A `discovery_keys` SET. A discovery re-find OVERWRITES an enriched + # `followers` with the corpus number — the corpus is cheaper, rounder and older than a + # scrape, so the row silently gets WORSE every night while the automation reports + # success. The fix is a precedence rule, not a blank check: a row that has been ENRICHED + # (`enriched_at` is stamped only by the enrich path) keeps its exact measurements, and + # discovery may only fill what is genuinely empty and update its own bookkeeping. + # ⚠ The declared set is what discovery OWNS; everything else on a corpus row is a + # measurement, and an approximation must never replace an exact one. + if _dkeys and str(prev_row.get("enriched_at") or "").strip(): + for k in list(c): + if (k not in _dkeys and k != "_ckey" + and str(prev_row.get(k) or "").strip()): + c.pop(k) + else: + counts["new"] += 1 + c["found_count"] = "1" + c["first_found"] = stamp + # ⭐ WAVE 26 · ITEM 1 — THE STAGE STAMP IS PER-AUTOMATION, SO IT CANNOT HANG OFF + # "is this row new to the TABLE". + # + # ⛔ MEASURED DEFECT, two discovery automations pointed at one database: the second one's + # board was EMPTY. It reported "2 profiles found — 0 new, 2 seen before", which is true, + # and then drew nothing, because this stamp lived inside the `else` above. `skey` is + # `stage_`; the branch it sat in asks whether some OTHER automation had + # already created the row. So the first automation to reach a handle claimed it, and every + # later automation sharing that database silently had no cards — with a summary saying it + # had found them. + # + # ⚠ THE ORIGINAL LAW IS PRESERVED, and it is the reason this is a condition rather than an + # unconditional write: a re-find must never pull a card somebody has already moved back + # into Review. So the question is "has THIS automation placed this record yet?" — not "is + # this record new?" A blank stage cell for this automation means unplaced, which is + # exactly the state a new card is in. + merged, mc = upsert_rows(existing2, incoming, "_ckey", cap=row_cap(table_key)) + merged = {rid: {k: v for k, v in r.items() if k != "_ckey"} + for rid, r in merged.items()} + counts["capped"] = mc["capped"] + missing = [] if dry else ut_missing(rt, table_key) + counts["missing_tables"] = len(missing) + if not dry and not missing: + ut_write_rows(rt, table_key, merged) + steps["write"] = "ok" + if spec["on_write"]: + spec["on_write"](rt, table_key, incoming, stamp, username, auto_id, counts, log) + elif dry: + steps["write"] = "skipped" + + affected = [rid for rid, r in merged.items() + if str(r.get("handle")) in {c["handle"] for c in incoming}][:200] + est = discover_estimate(counts["found"]) + summary = (f"{counts['found']} {noun}{'' if counts['found'] == 1 else 's'} found. " + f"{counts['new']} new, {counts['seen_again']} seen before. About " + f"${est['usd']}") + if counts["dropped"]: + summary += (f". {counts['dropped']} result{'' if counts['dropped'] == 1 else 's'} had no " + f"username and could not be saved") + cnote = cap_note([(table_key, counts["capped"])], missing) + if cnote: + summary += f". {cnote}" + steps["write"] = "partial" + if dry: + summary = f"Test run. Nothing saved. Would have written: {summary}" + state = "partial" if (dry or counts["capped"] or missing or not counts["found"]) else "ok" + if not dry and not missing and spec["after_write"]: + spec["after_write"](rt, table_key, auto_id, log) + log(f"[aios-auto] {spec['log_tag']} {auto_id} -> {table_key}: {summary}") + return (state, summary, counts, affected, steps) + + +def _predicate_sentence(preds, operator="and"): + """A predicate list as something a person can read back. Used in summaries and the canvas. + + Human words on BOTH halves — it used to render `biography includes floral`, the vendor's + column name beside the vendor's operator token, in a sentence shown to a customer. + """ + joiner = " or " if str(operator or "").lower() == "or" else " and " + parts = [] + for p in preds or []: + v = p.get("value") + shown = " or ".join(str(x) for x in v) if isinstance(v, list) else v + parts.append(f"{field_label(p.get('name'))} " + f"{BD_OP_LABELS.get(p.get('operator'), p.get('operator'))}" + + ("" if shown is None else f" {shown}")) + return joiner.join(parts) or "no conditions" + + +#: How many records ONE `plain` run walks. Deliberately the ut table's OWN row ceiling rather +#: than a second, smaller number nobody could explain: a plain automation's records ARE its +#: table's rows. What this actually bounds is the APPEND tables (`MAX_UT_IG_ROWS`, 200k), which a +#: flow could be pointed at and which would not finish. +#: ⚠ W31-T36 (D-143 / R6) — THIS IS NOW THE FALLBACK ONLY: what a flow may walk when the table's +#: own limit cannot be resolved (no runtime, or a key `core.user_tables` does not know). The real +#: answer is per TABLE and comes from `core.user_tables.row_limit`; see `_flow_record_cap`. +#: ⛔ Do not re-point a caller at this constant — a private copy of "is this database bounded" is +#: exactly the second evaluator D-143 was. +MAX_FLOW_RECORDS = MAX_UT_ROWS + + +def _flow_record_cap(table_key, rt=None): + """How many records may a flow walk in this database? `None` means NO CAP (R6). + + ⛔ DELEGATES, NEVER DECIDES. `core.user_tables.row_limit` is the one evaluator for "is this + database bounded", and it distinguishes three states this module must not re-derive: `0` + (read-through — its rows are not in the document at all), `None` (connected and UNCAPPED, which + is R6's whole point), and `MAX_ROWS` (the editable substrate, genuinely bounded by the shared + document). A second copy here is exactly the defect D-143 IS. + + ⚠ `0` MEANS "NO ROWS LIVE HERE", NOT "WALK NOTHING". A read-through grid's rows arrive through + the mirror route, so a flow that reached this function already holds ids from somewhere else; + capping it at zero would refuse a walk whose records are in hand. Treated as uncapped, and the + honest place to fix a read-through walk is the walker. + """ + try: + import core.user_tables as _ut_cap + cap = _ut_cap.row_limit(str(table_key or ""), st=rt) + except Exception: # noqa: BLE001 + return MAX_FLOW_RECORDS # the fallback, and it is the conservative direction + return None if not cap else int(cap) + + +def _flow_cap_reason(table_key, rt=None): + """R6's SECOND sentence, appended to the run's own note: the cause and the recommended fix. + + ⛔ R6 IS TWO SENTENCES AND THE SECOND IS THE ONE THAT GETS DROPPED — *"if there is lag or it + can't be done, you need to explicitly tell me why and recommend a fix."* A run that says only + *"walked the first 5000"* has disclosed the number and hidden everything a person could act on. + `core.user_tables.limit_report` already carries both, so this READS them rather than writing a + second wording that would drift from the one the grid shows. + """ + try: + import core.user_tables as _ut_cap + rep = _ut_cap.limit_report(str(table_key or ""), st=rt) or {} + except Exception: # noqa: BLE001 + return "" + cause, fix = str(rep.get("cause") or ""), str(rep.get("recommendation") or "") + if not cause and not fix: + return "" + return f". {cause}." + (f" To walk them all: {fix}." if fix else "") + + +def run_plain(rt, defn, username="automation", log=print, step=_no_step, rows=None): + """⭐ WAVE 24 / R6 — the runner for an automation with NO machine step: its flow IS the whole + automation. Returns the same 5-tuple every other runner does, so `run_now` needs no special + case and `apply_actions` still runs at the ONE call site that already exists. + + Before this existed, `RUNNERS.get("plain")` was None and pressing Run now committed + `error: unknown automation kind 'plain'` — on the kind every new automation now has. + + ⛔ AN UNBOUND FLOW ANSWERS `partial`, NEVER `ok`, and that is the load-bearing line here. + `apply_actions` returns immediately when there is no table, so a flow whose only action is + `create_record` into some other database does NOTHING — and a green dot over nothing is the + exact defect this module names in three other places. It reports the honest state and says + which fact is missing. + """ + table = _flow_table(defn) + if not table: + return ("partial", + "no database is bound yet. A plain automation walks the records of its target " + "database, and this one names none (pick one on the trigger, or in Properties)", + {}, [], {}) + all_rows = (ut_get(rt, table) or {}).get("rows") or {} + # ⛔ THE TRIGGER'S RECORDS WIN OVER THE WHOLE TABLE, and getting this wrong is a day-one bug + # on the kind every new automation now has. `plain` has no machine step, so it has nothing of + # its own to call "the records this run touched" — and walking the WHOLE table would mean + # "When a record is CREATED in ut_leads -> set status = New" writes `New` onto every lead the + # first time one is added. The trigger knows exactly which rows fired; `run_now` threads them + # here. `rows=None` (manual, schedule, Run now) still means the whole table, which is what + # those genuinely mean. + ids = ([r for r in (rows or []) if str(r) in all_rows] if rows + else sorted(all_rows, key=_rid_num)) + + # An enrichment-only flow is the one exception to the ordinary manual-run rule above. Its + # saved View and quota are not merely how the action decides whether to spend; they ARE the + # set of records the automation was asked to process. Walking every row first meant a run + # bound to a 10-record "Pending" view announced (and needlessly visited) all 61 table rows. + # Besides being misleading, that shape made the runtime scale with unrelated historical rows. + # + # Keep this deliberately narrow: any flow with another action must still hand that action the + # whole manual/scheduled scope. Only one direct, enabled enrich action has no other record + # semantics to preserve, so it can begin at its selected records safely. + flow_actions = list((defn.get("flow") or {}).get("actions") or []) + enabled_direct = [a for a in flow_actions if isinstance(a, dict) and a.get("enabled", True)] + enrich_scope_note = "" + if rows is None and len(enabled_direct) == 1 \ + and enabled_direct[0].get("kind") == "enrich_instagram": + enrich_cfg = enabled_direct[0].get("config") or {} + profile_key = profile_field_key(ut_get(rt, table), enrich_cfg.get("profileField")) + if profile_key: + # ⛔⛔ THE SAME QUESTION MUST GET THE SAME ANSWER IN BOTH PLACES. + # `enrich_selection` is called TWICE per run — here, to decide which records the + # runner walks and announces, and again inside `_walk` to decide which the action + # SPENDS on. MEASURED LIVE 2026-08-09: this call omitted the not-found verdicts, so + # the run announced *"(1 of the 30 asked for)"* in its summary while its own note + # from the second call said *"0 of the 30"* — one run, two answers, both printed. + # ⚠ `gone` is read from the definition here rather than passed in, because this + # function has the definition and `_walk` does the same read; a third source of the + # same fact is how the two would drift again ([[one-evaluator-per-question]]). + selected, enrich_scope_note = enrich_selection( + rt, table, enrich_cfg, profile_key, + gone=dict(((defn or {}).get("state") or {}).get("enrichNotFound") or {})) + # ⛔ D-112 — AND THE CONSTANT IS THE FIX, NOT THE `if`. This guard was a `startswith` + # against the sentence SPELLED OUT, twelve hundred lines from the only place that + # produces it. Reword the message in `enrich_selection` — a perfectly ordinary edit, + # since it is a sentence a person reads — and this line silently stops matching, the + # run goes back to reporting `ok`, and the defect D-112 describes returns with nothing + # anywhere going red [[gate-pins-a-spelling-not-a-claim]], on the product side. + # ONE constant, two readers, so the wording is free to change and the behaviour is not. + if enrich_scope_note.startswith(ENRICH_VIEW_UNREADABLE): + return ("partial", f"nothing walked in {table}. {enrich_scope_note}", {}, [], {}) + ids = selected + step(f"Walking {len(ids)} record{'' if len(ids) == 1 else 's'} in {table}") + counts = {"records": len(ids)} + # ⭐⭐ WAVE 31 · T36 (D-143, owner ruling R6) — THE CAP IS THE TABLE'S, NOT THIS MODULE'S. + # + # ⛔ WHAT WAS WRONG, and it was a SECOND EVALUATOR rather than a wrong number. This line read + # `MAX_FLOW_RECORDS = MAX_UT_ROWS = 5000` — a constant mirrored from the EDITABLE substrate's + # ceiling — and applied it to every database alike. `ut_odoo_orders` is a CONNECTED source + # (32,826 rows live), and R6 is explicit that connected-source data has NO CAP: *"I thought we + # decided there is no cap in how many data from the API source … can be pulled into the app."* + # So a flow over it silently walked 5,000 of 32,826 — 15% of the records — and every total it + # produced understated the book while looking exactly like a complete run. + # + # ⭐ `core.user_tables.row_limit` IS THE ONE EVALUATOR for this question and it already answers + # the three cases (0 = read-through, None = connected and UNCAPPED, MAX_ROWS = editable). Using + # it here retires this module's private copy instead of correcting it — one question, one + # normaliser [[one-question-two-normalizers]]. `limit_report` is R6's SECOND sentence already + # expressed as data, so the sentence a person reads carries the cause and the recommended fix + # rather than just a number. + _cap = _flow_record_cap(table, rt) + if _cap is not None and len(ids) > _cap: + # The cap is DISCLOSED, never silent ([[no-unverifiable-aggregates]]): the summary names + # both numbers so "it only processed some of them" is readable rather than deducible — and + # since T36, WHY it applies and what to do about it. + total = len(ids) + ids = ids[:_cap] + return ("partial", + f"{total} records in {table}. This run walked the first {_cap}" + + _flow_cap_reason(table, rt), counts, ids, {}) + n = len(ids) + # ⭐⭐ 2026-08-07 (owner report) — SAY IT IN WORDS A PERSON CAN CHECK. + # Owner: *"how come it says that its 51 records walked in ut_beauty_influencer_leads (all)? + # wth is even ut_beauty_influencer_leads (all)??"* — and both halves of that name were ours: + # · `ut_beauty_influencer_leads` is the storage KEY. Every other surface in the product + # shows the database's LABEL ("Beauty influencer leads"); this one leaked the key. + # · `(all)` meant "every record, because a manual run has no trigger records to narrow to", + # which is unguessable from the word. It reads as a filter nobody chose. + # ⚠ THE NUMBER WAS ALWAYS HONEST and is unchanged: a `plain` automation walks its whole + # database, and a per-STEP narrowing (an enrich step's `fromView`) is a different count that + # this sentence never claimed to report. What was wrong was that nothing said so. + label = str((ut_get(rt, table) or {}).get("label") or "").strip() or table + scope = ("the records that fired the trigger" if rows + else ("the records selected for Instagram enrichment" + if len(enabled_direct) == 1 + and enabled_direct[0].get("kind") == "enrich_instagram" + else "every record. This run was started by hand, so nothing narrowed it")) + if enrich_scope_note: + scope += f" ({enrich_scope_note})" + # ⭐⭐ AND ON THE RUN, not only inside the summary sentence. `summary` is capped at 400 + # characters by `_commit_run`, and this note is the longest thing a run says — it names + # every skipped handle and what to do about them. MEASURED: a run that selects NOTHING + # (every candidate is a known-dead handle) never enters `_walk`, so the walk's own copy + # of this sentence is never produced. Without this line the explanation exists only in a + # string that is about to be truncated, on exactly the runs that look like the automation + # has stopped working. + counts[RUN_NOTES_KEY] = [enrich_scope_note] + return ("ok", f"{n} record{'' if n == 1 else 's'} walked in {label}: {scope}", + counts, ids, {}) + + +RUNNERS = {"plain": run_plain, "scrape_db": run_scrape_db, + "field_instagram": run_field_instagram, + # ⭐ WAVE 30 · T09 (D-129) — BOTH DISCOVERY KINDS MAP ONTO THE SAME FUNCTION. The kind is + # no longer chosen by which callable this dict holds; `run_discovery` reads it off the + # definition and looks the corpus up in `DISCOVERY_SPECS`. The two dicts are asserted + # key-for-key by the gate, because a kind in one and not the other is either an + # unroutable automation or a refused run. + "discover_instagram": run_discovery, + "discover_tiktok": run_discovery} + + +# --------------------------------------------------------------------------------------------- +# THE SOURCE REGISTRY (DEBT D-9's seam, wave-20 item 6b) +# --------------------------------------------------------------------------------------------- +# WHY THIS EXISTS BEFORE THERE IS A SECOND SOURCE. The vendor swap that produced it (HikerAPI → +# Bright Data) touched a dozen places: a tier name, two node ids, a config flag, a readiness +# boolean on the wire, four client strings and two gate sections. That is what a hard-coded +# vendor costs, and TikTok (D-9) would have paid it again from scratch. +# +# So a SOURCE is declared once — what it is called, which vendor answers it, and which of the +# three verbs it can do — and the surfaces read the declaration instead of naming a vendor: +# +# probe "is this configured?" -> the honest readiness bit the UI shows +# capture enrich a handle we know -> `pull_profile`-shaped +# discover find handles we do not -> the corpus query +# +# ⚠ A SOURCE DECLARES ONLY WHAT IT CAN ACTUALLY DO. `discover: None` is not a gap to fill in +# later; it is the honest statement that this source has no discovery route, and a surface that +# offers one anyway is offering a button that must refuse. +SOURCES = { + "instagram": { + # ⛔ `vendor` NAMES NO COMPANY (owner instruction 2026-08-09). It reaches operator-facing + # copy, and which provider serves a capability is a routing decision the product owns — + # `providers.py` still holds the real keys, chains and costs. + "key": "instagram", "label": "Instagram", "vendor": "Scraper", + "probe": bd_ready, + "capture": pull_profile, + "discover": bd_filter_start, + "captureKind": "field_instagram", + "discoverKind": "discover_instagram", + "tiers": TIERS, + }, + # ⭐⭐ WAVE 29 — D-9 LANDS, AND IT LANDED AS THE ONE ENTRY THIS REGISTRY PREDICTED IT WOULD. + # The canvas, the append tables, the caps and the honest-status contract were already in place; + # what TikTok added was a field map, a discovery runner and three dataset ids. + "tiktok": { + # `vendor` NAMES NO COMPANY (owner instruction 2026-08-09) — it reaches operator-facing + # copy, and which provider serves a capability is a routing decision `providers.py` owns. + "key": "tiktok", "label": "TikTok", "vendor": "Scraper", + "probe": bd_ready, + # ⭐ WAVE 30 · T08 — CAPTURE IS WIRED, and it is a LAZY reference rather than the function + # object every other row here holds. `connectors_tt` imports THIS module at module level, + # so naming `connectors_tt.pull_profile_tt` at import time is a circular import — which is + # why every existing site does `import connectors_tt` inside the function body. A + # zero-argument callable keeps the registry's shape (a reader gets a callable, not a + # string) without moving the import. + # ⚠ AND SETTING IT SWITCHES NOTHING ON. `SOURCES` is never subscripted anywhere; its one + # reader is `source_status()`, which reads `key/label/vendor/probe/discover`. An action + # kind is gated in three other places entirely (`ACTION_CATALOG`, `clean_actions`, + # `apply_actions`). This row is documentation that must not lie, not wiring. + "capture": lambda *a, **k: __import__("connectors_tt").pull_profile_tt(*a, **k), + "discover": bd_filter_start, + "captureKind": "enrich_tiktok", + "discoverKind": "discover_tiktok", + # No tier ladder: R6/R7 retired the free rung from enrichment entirely, and TikTok never + # had one. An empty tuple is the honest statement, not a gap to fill in later. + "tiers": (), + }, +} + + +def source_status(): + """`[{key,label,vendor,ready,canDiscover}]` — what the surface says about each source. + + ⚠ A BOOLEAN, NEVER THE KEY (the wire rule the `hikerReady` bit already followed): a surface + needs exactly one bit to say "the paid rung is not configured" honestly, and shipping the + credential to a browser would put a billable secret in every user's devtools. + """ + # ⛔ `vendor` IS NOT ON THE WIRE (2026-08-06). It stays in `SOURCES` as the record of which + # supplier this rung actually uses — that history is worth keeping in the code — but a + # customer has no use for it, and a server-supplied string rendered by a generic component + # is precisely the half a client-file scan cannot see. + return [{"key": s["key"], "label": s["label"], + "ready": bool(s["probe"]()), "canDiscover": bool(s.get("discover"))} + for s in SOURCES.values()] + + +# --------------------------------------------------------------------------------------------- +# THE CANVAS GRAPH (R5) — topology on the SERVER, pixels on the client +# --------------------------------------------------------------------------------------------- +# WHY THE TOPOLOGY LIVES HERE. The canvas draws what an automation DOES; the runners above are +# what it does. Two descriptions of one thing in two languages drift, and the drift is invisible — +# a canvas that still shows a step the engine stopped running looks completely fine. So the node +# list is derived from the DEFINITION by the same module that executes it, rides on the payload +# the way `CRON_PRESETS` and the kind list already do, and is asserted in `verify_automation.py`. +# The client owns layout (pixels, pan/zoom, hit targets) and nothing else. +# +# ⚠ BRANCHES RENDER, THEY DO NOT EXECUTE (R5, accepted at grill; DEBT D-7). The `capture` node is +# a real branch in BEHAVIOUR — the paid rung answers or the anonymous ladder does — but the engine +# walks it linearly. Nothing here should be read as a general branching runtime. + +#: A node's dot vocabulary. Deliberately WIDER than the run-level `STATES`: a node can be +#: `blocked` or `skipped` in ways a whole run cannot, and folding those into `partial` would throw +#: away the only information the dot is there to carry. +NODE_STATES = ("idle", "ok", "partial", "error", "blocked", "skipped") + +#: node id -> the switch it flips. A node ABSENT from this map has no switch, and that is a +#: deliberate answer rather than an omission: a "Fetch the page" step that can be turned off is +#: not an automation with a disabled step, it is a broken automation with a lie on it. The ones +#: here are all REAL — each changes what the next run does: +#: schedule the trigger fires on its cron, or only by hand +#: postMetrics likes/comments per post are bought, or the engagement series does not grow — +#: it costs a vendor record PER POST rather than per profile (measured: the +#: profile row carries post identity and no engagement) +#: commentMetrics the Comments dataset is bought, which can bill many rows per post +#: write DRY RUN — read everything, compute the counts, write nothing anywhere +#: +#: ⭐⭐ WAVE 28 / R5+R6 (contract C3) — `paid` AND `fallback` ARE GONE, and what replaced them is +#: the point of the ruling: the money switches used to be "which rung do we try" (a question about +#: our plumbing), and they are now "what do you want captured" (a question about the user's data). +#: Profile is always captured and has no switch — an automation that fetches nothing is not an +#: automation with a step turned off, it is a broken one with a lie on it. +#: ⚠ `trigger: "schedule"` IS NOT PART OF THAT COLLAPSE. It is the trigger card's own on/off and +#: has its own branch in `toggle_node` (event triggers flip themselves, not the cron); C3's "the +#: three toggles" names the CAPTURE ladder it is reshaping. +NODE_TOGGLES = {"trigger": "schedule", "capture_posts": "postMetrics", + "capture_comments": "commentMetrics", "write": "write"} + + +def _cron_label(cron): + return next((p["label"] for p in CRON_PRESETS if p["cron"] == cron), cron or "") + + +def node_status(steps, nid): + """A node's dot — from the last run's MEASURED step outcome, or `idle`. + + ⛔ NEVER INFERRED FROM THE RUN'S OVERALL STATE, and this is a module-level function precisely + so a negative control can prove that. Every run stored before W19-C has no `steps` map at all, + and painting those nodes green because the run said `ok` would manufacture a measurement — the + same defect as the green dot over the empty posts table recorded in `run_field_instagram`. + No data ⇒ `idle`. An unrecognised word ⇒ `idle`, never passed through to the UI. + """ + v = str((steps or {}).get(nid) or "") + return v if v in NODE_STATES else "idle" + + +def graph(defn): + """One automation → `{nodes, edges}`, left to right. Pure over the definition.""" + defn = defn if isinstance(defn, dict) else {} + cfg = defn.get("config") or {} + sched = defn.get("schedule") or {} + runs = defn.get("runs") or [] + last = runs[0] if runs and isinstance(runs[0], dict) else {} + steps = last.get("steps") if isinstance(last.get("steps"), dict) else {} + + def node(nid, kind, title, subtitle, col, row=0, panel="", detail="", on=True): + return {"id": nid, "kind": kind, "title": title, "subtitle": subtitle, "detail": detail, + "col": col, "row": row, "panel": panel or nid, "enabled": bool(on), + "toggle": NODE_TOGGLES.get(nid, ""), "status": node_status(steps, nid)} + + on = bool(sched.get("enabled")) + trg = defn.get("trigger") or {} + if trg.get("key") in TRIGGER_KEYS and trg.get("key") not in ("manual", "schedule"): + # C3 (wave 22): the trigger node SAYS which trigger this flow has — the board and rail + # read the same node, so an event-triggered flow must not read "Schedule". The membership + # test is the KEY SET, not a hand-listed tuple: wave 23 added four triggers and the old + # tuple would have quietly rendered every one of them as "Schedule" (they are stored, so + # the else-branch was reachable) — green, compiling, and wrong on screen. + t_on = bool(trg.get("enabled", True)) and not trg.get("paused") + sub = TRIGGER_LABELS.get(trg["key"], trg["key"]) + watched = ", ".join(trg.get("fields") or []) or "any field" + # ⭐ WAVE 24 · laws 4/5 — `event_field` no longer names a FIELD (its condition is the + # whole of it) and `record_updated` no longer names a condition (its watched fields are). + # These two lines are the migration's visible half: a node still describing a `.field` + # the validator has stopped storing would be the surface and the store disagreeing. + # ⚠ W34-T47 — A COLON, NOT A FULL STOP. R6's sweep turned these four em dashes into + # sentence breaks, which is right for a REFUSAL and wrong here: these are compact node + # SUBTITLES ("ut_leads: view v1"), not prose, and a full stop mid-label reads as two + # truncated fields rather than one qualified name. The rule the sweep encodes is "a + # sentence that used a dash usually wants a period"; a LABEL usually wants a colon. + det = {"event_field": (f"{trg.get('table', '')}" + + (f": {_lane_sentence(trg.get('when'))}" + if trg.get("when") else "")), + "record_updated": f"{trg.get('table', '')}: {watched}", + "record_created": str(trg.get("table") or ""), + "enters_view": f"{trg.get('table', '')}: view {trg.get('viewId') or 'unset'}", + "form_submitted": str(trg.get("table") or ""), + "webhook": "POST the hook URL to fire it", + # ⭐ WAVE 30 · T05 — BOTH corpus triggers, and the value is identical because the + # question is: a discovery trigger's detail line IS its filter. Without the TikTok + # key this fell to `.get(..., "")` and the trigger node rendered with a blank + # subtitle, which reads as "nothing configured" on a fully configured automation. + "ig_profile_match": (_predicate_sentence(cfg.get("predicates"), + cfg.get("operator"))[:80] + if cfg.get("predicates") + else "No filters yet. Nothing to search for"), + "tiktok_profile_match": (_predicate_sentence(cfg.get("predicates"), + cfg.get("operator"))[:80] + if cfg.get("predicates") + else "No filters yet. Nothing to search for"), + "email": str(trg.get("query") or "")}.get(trg["key"], "") + if not trg.get("configured", True): + det = "Finish setting this trigger up before it can fire" + if trg.get("paused"): + det = str(defn.get("statusNote") or "paused") + nodes = [node("trigger", "trigger", sub, + "On" if t_on else "Off", 0, panel="trigger", on=t_on, detail=det)] + else: + # ⭐ 2026-08-07 (owner ruling) — THE CARD NAMES WHAT ACTUALLY FIRES IT. With the cron off + # this node is a MANUAL trigger, and titling it "Schedule" was the screen disagreeing with + # the user's own pick — the same class of defect as the Database picker that could not be + # reached. ⚠ The SUBTITLE is untouched ("Manual only" / the cron label): it is what the + # disabled-schedule gate asserts, and it was never the wrong half. + # ⚠ `panel="schedule"` STAYS whichever way it reads. Picking Manual says how this fires + # TODAY, not that it may never be scheduled — the cron has to stay one click away, and + # this is the only node that offers it. + nodes = [node("trigger", "trigger", "Schedule" if on else "Manual", + _cron_label(sched.get("cron")) if on else "Manual only", 0, + panel="schedule", on=on, + detail="" if on else "It still runs when you press Run now")] + edges = [] + + if defn.get("kind") == "field_instagram": + # ⭐⭐ WAVE 28 / CONTRACT C3 — FOUR NODES, THREE OF THEM SWITCHES OVER WHAT IS CAPTURED. + # This branch used to draw a FORK: `Capture` splitting into `Exact counts` (paid) and + # `Estimated counts` (the free anonymous ladder), rejoining at `Post engagement`. R5 + # deleted the ladder, so the fork had one arm; keeping it would have drawn a decision the + # engine no longer makes, with a switch (`fallback`) flipping a config key the cleaners + # now discard. A canvas that offers a choice the runtime ignores is worse than no canvas. + # ⚠ `Profile set` STILL CARRIES NO SWITCH, and now that is the whole ruling rather than an + # implementation detail: the profile is always captured (R6), Posts and Comments are the + # opt-ins, and both are OFF until asked for. + metrics_on = bool(cfg.get("postMetrics")) + comments_on = bool(cfg.get("commentMetrics")) + max_posts = cfg.get("maxPosts") or DEFAULT_POSTS_PER_PULL + nodes += [ + node("source", "source", "Profile set", cfg.get("targetTable") or "No database", 1, + panel="source", + detail=f"URL column: {cfg.get('urlField')}" if cfg.get("urlField") + else "URL column: the one the field is bound to"), + # ⚠ ITS OWN NODE BECAUSE IT IS ITS OWN BILL. The profile row carries post IDENTITY + # and no engagement (measured), so likes/comments are a SECOND vendor call per post. + # A switch that multiplies a run's cost by the post count deserves to be visible on + # the canvas rather than buried in a config panel. + node("capture_posts", "capture", "Post data", + "Likes and comments per post" if metrics_on else "Off", + 2, panel="capture", on=metrics_on, + detail=(f"Up to {max_posts} posts per profile" if metrics_on else + "ut_ig_post_snapshots only grows while this is on")), + node("capture_comments", "capture", "Comment data", + "Comments on those posts" if comments_on else "Off", + 3, panel="capture", on=comments_on, + detail=("The full comments dataset. Many rows per post" if comments_on else + "Comments already embedded in a paid post row are kept either way")), + node("write", "write", "Write", + cfg.get("targetTable") or "No database", 4, panel="write", + on=not cfg.get("dryRun"), + detail="+ ut_ig_snapshots · ut_ig_posts · ut_ig_post_snapshots"), + ] + edges = [{"from": "trigger", "to": "source", "label": ""}, + {"from": "source", "to": "capture_posts", "label": ""}, + {"from": "capture_posts", "to": "capture_comments", "label": ""}, + {"from": "capture_comments", "to": "write", "label": ""}] + elif defn.get("kind") in DISCOVERY_KINDS: + # ⭐⭐ WAVE 30 · T05 — WIDENED FROM `== "discover_instagram"`, AND THIS IS THE FAILURE T04 + # UNMASKS. With the seed fixed but this arm still Instagram-only, a stored TikTok search + # fell past every arm to the no-bare-`else` promise below and returned THE TRIGGER NODE + # ALONE — so no node carried `panel="find"`, and `AutomationFind` (which mounts on + # `panelKey === "find"`) could never appear. The person would have picked the trigger + # successfully and then found nowhere to type a filter: a second, stranger bug, arriving + # as the reward for fixing the first one. + _platform, _default_table, _, _ = discovery_facts(defn.get("kind")) + limit = int(cfg.get("recordsLimit") or 0) + est = discover_estimate(limit) + pending = str((defn.get("state") or {}).get("pendingSnapshot") or "") + nodes += [ + node("find", "source", f"Find {_platform} profiles", + _predicate_sentence(cfg.get("predicates"), cfg.get("operator"))[:60], 1, + panel="find", + detail=(f"Up to {limit} profiles · about ${est['usd']}" + if bd_ready() + else "Profile search is not set up yet")), + node("collect", "capture", "Collect results", + "Searching…" if pending else "Takes about 20 minutes", 2, + panel="find", + detail=""), + node("write", "write", "Save results", + cfg.get("targetTable") or _default_table, 3, panel="write", + on=not cfg.get("dryRun"), + detail=""), + ] + edges = [{"from": "trigger", "to": "find", "label": ""}, + {"from": "find", "to": "collect", "label": ""}, + {"from": "collect", "to": "write", "label": ""}] + elif defn.get("kind") == "scrape_db": + url = str(cfg.get("url") or "") + host = (urlparse(url).hostname or url or "No URL") if url else "No URL" + which = ("Structured data (JSON-LD)" if cfg.get("extract") == "jsonld" + else f"HTML table #{int(cfg.get('tableIndex') or 0)}") + mapped = len(cfg.get("fieldMap") or {}) + nodes += [ + node("fetch", "source", "Fetch page", host, 1, panel="source", detail=url[:90]), + node("extract", "capture", "Extract", which, 2, panel="columns", + detail=f"{mapped} column{'' if mapped == 1 else 's'} mapped · key: " + f"{cfg.get('keyField') or ', '}"), + node("write", "write", "Write", cfg.get("targetLabel") or "Scraped table", 3, + panel="write", on=not cfg.get("dryRun"), + detail=cfg.get("targetTable") or "a new database"), + ] + edges = [{"from": "trigger", "to": "fetch", "label": ""}, + {"from": "fetch", "to": "extract", "label": ""}, + {"from": "extract", "to": "write", "label": ""}] + # ⛔ WAVE 24 — THERE IS NO BARE `else` HERE ANY MORE, and its absence is the point. + # It used to be `scrape_db`'s branch, which meant a kind this function had never been taught + # about inherited a scrape's three machine nodes: `Fetch page / No URL`, `Extract / HTML + # table #0`, `Write / Scraped table`, over a definition with no URL and no field map. A + # surface that draws nothing is obviously incomplete; one that draws a fetch step that does + # not exist is confidently wrong, and it is wrong on the canvas, the builder and the board at + # once because all three read this. + # + # ⭐ SO: `plain` (R6) — and any future kind, until somebody writes its arm — ANSWERS THE + # TRIGGER NODE AND NOTHING ELSE: `{"nodes": [], "edges": []}`. Exactly one node, + # guaranteed. The Builder's node filter and `stages_for` (hence the Board) both depend on + # that promise, so it is stated rather than left to be inferred from the control flow. + return {"nodes": nodes, "edges": edges} + + +def toggle_node(rt, auto_id, node_id): + """Flip ONE node's switch. Returns `(defn, error)`. + + The node → config mapping lives on the SERVER for the reason the whole graph does: a client + that knew which field each node writes would be a second copy of that knowledge, free to + drift. The canvas just says "this node was clicked". + """ + prev = all_definitions(rt).get(str(auto_id)) + if prev is None: + return None, "no such automation" + target = next((n for n in graph(prev)["nodes"] if n["id"] == str(node_id)), None) + if target is None: + return None, f"there is no {node_id!r} step in this automation" + if not target.get("toggle"): + return None, (f"the {target['title']} step cannot be turned off. It is what this " + f"automation is") + cfg, sched = dict(prev.get("config") or {}), dict(prev.get("schedule") or {}) + which = target["toggle"] + trg_key = str((prev.get("trigger") or {}).get("key") or "") + if which == "schedule" and trg_key and trg_key not in TRIGGER_SCHEDULE_KEYS: + # The trigger node's switch flips THE TRIGGER when the flow has an event one — flipping + # the cron under a node labelled "When a field changes" would be a switch that lies. + # ⭐ WAVE 24 — DERIVED from `TRIGGER_SCHEDULE_KEYS` instead of the hand-listed tuple that + # was here, which omitted `record_updated`, `enters_view` and `form_submitted` and so + # told exactly that lie for all three. The set names the SMALL side (schedule-driven), so + # a trigger added to `TRIGGER_KEYS` defaults to flipping itself, which is the safe half. + trg = dict(prev.get("trigger") or {}) + trg["enabled"] = not (bool(trg.get("enabled", True)) and not trg.get("paused")) + trg["paused"] = False # a deliberate flip clears an auto-pause: human wins + return patch(rt, auto_id, {"trigger": trg}) + if which == "schedule": + sched["enabled"] = not sched.get("enabled") + elif which == "postMetrics": + cfg["postMetrics"] = not cfg.get("postMetrics") + elif which == "commentMetrics": + cfg["commentMetrics"] = not cfg.get("commentMetrics") + elif which == "write": + cfg["dryRun"] = not cfg.get("dryRun") + return patch(rt, auto_id, {"config": cfg, "schedule": sched}) + + +# --------------------------------------------------------------------------------------------- +# THE BOARD IS DELETED (wave 27 item 12, owner ruling R3) — what is left, and why it stayed +# --------------------------------------------------------------------------------------------- +# Wave 22 made the automation detail a kanban board: columns were the flow's stages, cards were +# records of the target database, and each record's position lived in a single-select STAGE FIELD +# the engine created on the customer's own database. Wave 26 deleted the board's built-in +# terminals; wave 27 deletes the rest — `board()`, `move_card()`, `stages_for()`, +# `ensure_stage_field()`, the four card builders, the lane vocabulary on the wire, the review +# branch of the action walk, and the endings that sent a terminal card back round. +# +# ⛔ THE REASON IS A DATA REASON, NOT A UI ONE, and it is the sentence to keep: the board wrote +# MACHINE COLUMNS INTO A TENANT'S OWN TABLE — a stage select, an `_at` stamp and a `_cycles` +# counter per automation — to render a view of state the RUN LOG already holds. The customer paid +# for that in columns they did not ask for, in a store commit on every scheduled run, and in a +# permission wall (`humanMoves`/`arrive`) that existed only to stop them editing the columns we +# had added. `migrate_ig_tables` now drops those columns wherever an automation created them. +# +# ⛔⛔ `notify_review` IS GONE FROM HERE AND ITS CONSUMER IS STILL ALIVE — DEBT D-101, and this +# paragraph is its TOMBSTONE, placed where the next reader of the review machinery will look. +# +# `notify_review` was the producer of the `automation_review` notification: a card reaching a +# review stage told a human to come and look. It had ZERO callers even before R3 (wave-27 session +# C found it and correctly left it alone as another lane's subject), and R3's deletion took the +# function with the rest of the board. `verify_automation`'s W23-B section asserts its absence by +# name, so it cannot come back by accident. +# +# ⚠ THE SWEEP FOR ITS READERS, RUN 2026-08-12 (W31-T37) AND REPORTED RATHER THAN ASSUMED EMPTY: +# · SERVER — `notify_review` appears in exactly ONE file, `verify_automation.py`, inside the +# list of names asserted GONE. **No `.py` file anywhere produces an `automation_review` +# notification**; `core.alerts.notify` defaults `topic='automation'`, which is a different, +# live shape (the run-outcome alert). +# · CLIENT — the branch is FULLY ALIVE and is NOT in this fence: +# `web/src/alerts/alertsModel.ts` (`AUTOMATION_REVIEW_KIND`, `isAutomationReview`, the +# `autoId` guard) and `web/src/alerts/AlertsPane.tsx` (two call sites), plus legs in +# `web/verify_alerts.py`. All four are session B's files. +# ⇒ **The producer is deleted; the consumer guards an event that can never arrive.** D-101's own +# exit condition says these are one defect seen from two ends and must be closed in ONE change — +# so this half is the tombstone plus the sweep, and the client half is routed to B rather than +# reached across a fence. If a future wave gives review stages a real producer, THIS is the note +# that says the client already knows how to render it. +# +# ⚠ WHAT SURVIVES, AND IT IS DELIBERATE IN EACH CASE: +# * `lane_match` + `LANE_OPS` + the condition trees below — never board-only. They are the +# evaluator for ACTION conditions, trigger conditions and `find_records`, and the board was +# one of four callers. The name is the last thing the board left behind here. +# * `retire_automation_stage_fields` / `retire_automation_board_state` / `_without_retired_board` +# — the MIGRATION. It must outlive the thing it retires, or a definition written before the +# retirement walks into a runtime that no longer understands it. +# * `ai_decide` + `review_audit` — R3 keeps review "as an AI decision without lanes"; they are +# parked with no caller and say so at their own definitions. + +LANE_OPS = ("=", "!=", ">", ">=", "<", "<=", "includes", "not_includes", + "is_empty", "is_not_empty") +LANE_NULLARY_OPS = ("is_empty", "is_not_empty") + +# ───────────────────────────────────────────────────────────────────────────────────────────── +# WAVE 23 · C4 — CONDITION TREES. `Cond = leaf | {all: [Cond…]} | {any: [Cond…]}` +# +# A leaf is exactly what wave 22 called a lane condition (`{field, op, value?}`), which is why +# this generalisation needed no migration: a stored leaf IS a valid tree, `cond_match` dispatches +# on shape, and nothing at rest is rewritten until the owner saves that automation. (The doc +# calls this "legacy single-condition lanes auto-wrap as {all:[leaf]} on read" — wrapping is the +# same function applied one level up, so the cheaper honest version is to evaluate the leaf where +# it lies and never touch the bytes.) +# +# ⛔ DEPTH IS BOUNDED AND THE BOUND IS ENFORCED AT WRITE TIME, not at evaluation time. An +# unbounded tree is an unbounded evaluation on a hook that runs inside somebody's keystroke, and +# "the server got slow" is the failure nobody traces back to a filter somebody nested 40 deep. +# ⛔ REFUSE-NEVER-COERCE all the way down (`clean_predicates`' discipline): an empty group, an +# unknown comparison, a valueless compare are all REFUSED with the reason named. A group that +# quietly dropped its unanswerable leaf would WIDEN — the tri-state scar the filter engine +# carries ([[cg-filter-engine-sql-port]]), reproduced here where nothing would report it. +MAX_COND_DEPTH = 3 +MAX_COND_CHILDREN = 12 +COND_GROUP_KEYS = ("all", "any") + + +def clean_cond(raw, depth=0, where=""): + """Validate one condition TREE. Returns `(cond|None, error)`; `(None, None)` means "no + condition", which is legal everywhere a condition is optional (the catch-all lane, an + unconditioned trigger, an action group that always runs).""" + if raw in (None, "", {}): + return None, None + at = f" on {where}" if where else "" + if not isinstance(raw, dict): + return None, f"the condition{at} must be an object" + conj = [k for k in COND_GROUP_KEYS if k in raw] + if len(conj) > 1: + return None, (f"the condition{at} sets both 'all' and 'any'. A group is one or the " + f"other") + if conj: + key = conj[0] + if depth + 1 > MAX_COND_DEPTH: + return None, (f"conditions nest at most {MAX_COND_DEPTH} levels deep. " + f"the group{at} is deeper") + kids_raw = raw.get(key) + if not isinstance(kids_raw, list) or not kids_raw: + return None, f"the '{key}' group{at} needs at least one condition inside it" + if len(kids_raw) > MAX_COND_CHILDREN: + return None, (f"a group holds at most {MAX_COND_CHILDREN} conditions. " + f"the '{key}' group{at} has {len(kids_raw)}") + kids = [] + for child in kids_raw: + c, err = clean_cond(child, depth + 1, where) + if err: + return None, err + if c is None: + return None, (f"an empty condition sits inside the '{key}' group{at}. " + f"finish it or remove it") + kids.append(c) + return {key: kids}, None + field = _s(raw.get("field"), 80).strip() + op = _s(raw.get("op") or raw.get("operator"), 20).strip() + if not field: + # ⚠ WAVE 26 — the owner met this sentence repeatedly and it told them nothing they could + # act on. It states a fact about the stored tree; it never said what to DO, and the usual + # cause is not a typo but an empty field picker (the flow had no walking record to offer + # columns from). The fix belongs in the message. + return None, (f"the condition{at} names no field. Pick a column, or remove the " + f"condition") + if op not in LANE_OPS: + return None, f"{op or 'that comparison'!r} is not one of: " + ", ".join(LANE_OPS) + cond = {"field": field, "op": op} + if op not in LANE_NULLARY_OPS: + v = raw.get("value") + if v is None or (isinstance(v, str) and not v.strip()): + return None, f"give a value to compare {field!r} against{at}" + cond["value"] = v.strip() if isinstance(v, str) else v + return cond, None + + +def cond_fields(cond): + """Every field name a tree reads — the set a caller must have on the row image before the + answer means anything.""" + if not isinstance(cond, dict): + return set() + for key in COND_GROUP_KEYS: + if key in cond: + out = set() + for child in cond.get(key) or []: + out |= cond_fields(child) + return out + f = cond.get("field") + return {f} if f else set() +#: Lane labels a flow already uses for its fixed stages. A lane literally called "Review" would +#: collide with the stage the label is a choice FOR, and the board could no longer tell a +#: routed card from a gated one. +# ⚠ `tracked` / `declined` STAY RESERVED even though R6 deleted the stages that used them: the +# labels still sit in boards stored before this wave, and a user lane named "Tracked" beside a +# legacy stamped cell would read as the same lane while behaving as a different one. +def _lane_num(v): + try: + return float(str(v).replace(",", "")) + except (TypeError, ValueError): + return None + + +def lane_match(cond, row): + """Does `row` satisfy this condition TREE? None matches everything (the catch-all). + + Named for the lane that first needed it; it is now C4's whole evaluator, and every wave-22 + caller (`route_record`, `_settle_eval`, `_seed_event_state`) gained tree support by keeping + this name rather than growing a second entry point that could disagree with it. + + ⛔ REFUSE-NEVER-COERCE, per record: an ordering comparison whose either side does not parse + as a number answers False — the record simply does not enter the lane — never "treat blank + as 0", which is the `toNum(null)=0` widening the 2026-08-03 owner wave was about. A blank + cell is an unknown, and an unknown cannot be less than 10,000. + """ + if cond is None: + return True + if isinstance(cond, dict): + if "all" in cond: + return all(lane_match(c, row) for c in cond.get("all") or []) + if "any" in cond: + return any(lane_match(c, row) for c in cond.get("any") or []) + raw = (row or {}).get(cond.get("field")) + op = cond.get("op") + if op == "is_empty": + return raw in (None, "") + if op == "is_not_empty": + return raw not in (None, "") + want = cond.get("value") + if op in (">", ">=", "<", "<="): + a, b = _lane_num(raw), _lane_num(want) + if a is None or b is None: + return False + return {"<": a < b, "<=": a <= b, ">": a > b, ">=": a >= b}[op] + have_s, want_s = str(raw if raw is not None else ""), str(want if want is not None else "") + if op == "includes": + return want_s.lower() in have_s.lower() + if op == "not_includes": + return want_s.lower() not in have_s.lower() + a, b = _lane_num(raw), _lane_num(want) + same = (a is not None and b is not None and a == b) or have_s == want_s + return same if op == "=" else (not same) if op == "!=" else False + + +# ───────────────────────────────────────────────────────────────────────────────────────────── +# WAVE 23 · C4 — ACTIONS (owner ruling R3). The half of the Airtable builder that DOES things. +# +# `flow.actions` is an ordered list walked per RECORD, after the flow's machine steps have run. +# A `group` holds nested actions behind a condition — Airtable's "conditional action group", +# which the owner explicitly asked to be NESTABLE (R3 supersedes the wave-22 research brief's +# rec-8 "no nesting" verdict; that recommendation was about keeping a CANVAS legible, and this +# builder is a column, not a canvas). +# +# ⛔ THE CATALOG IS SERVER-OWNED AND INCLUDES WHAT WE HAVE NOT BUILT. `ACTION_CATALOG` carries a +# `ready` flag per kind, so the "+ Add advanced logic or action" menu paints Send email / Slack / +# Run script / Generate with AI faded with a reason instead of omitting them — the same honesty +# rule the trigger list follows, and the same enforcement: `clean_actions` REFUSES an unready +# kind with a sentence, so the faded state is a wall and not a styling choice. +MAX_ACTIONS = 20 # per automation, counting nested ones +MAX_GROUP_DEPTH = 2 # a group inside a group inside a group is a flowchart, not a flow +MAX_BRANCHES = 6 # per If / then +MAX_ACTION_VALUES = 20 # cells one update/create action may write +FIND_LIMIT_MAX = 100 +#: ⭐⭐ W31-T38 / E-4 — HOW MANY BROWSER JOBS ONE RUN MAY SUBMIT, and it belongs HERE because this +#: is the only layer that knows a record WALK is happening. `web_agent.MAX_STEPS` bounds steps per +#: JOB; nothing bounded jobs per RUN, and the web arm submits one job PER RECORD. A `web_read` on a +#: flow over the Customer grid is therefore thousands of ~10 s submissions against HF's +#: 6-concurrent cap — a run whose wall-clock is `records x 10 s` and a burst the `/hf-jobs` rules +#: exist to prevent. Session E raised it and could not fix it: the seam sees one step and cannot +#: know a walk is happening. +#: ⚠ 50 IS A STATED CHOICE, NOT A MEASUREMENT: at E's measured ~9-32 s per job that is roughly +#: 8-27 minutes of serial browsing, which is a long automation run and not a runaway one. It is +#: DISCLOSED when it binds (R6's second sentence — see the web arm), never silently applied. +#: ⛔ THE REAL FIX IS BATCHING, NOT A BIGGER NUMBER: `web_agent.run_plan(steps, ctx)` already takes +#: a list and the cost is per JOB, so a flow's web steps collected into ONE call would make this +#: ceiling nearly unreachable. That is E's own PENDING row; this bound is what stops the damage +#: until it lands. +MAX_WEB_JOBS_PER_RUN = 50 +#: ⭐⭐ W33-T58 (D-191) — HOW MANY WEB STEPS MAY SHARE ONE JOB. The comment above finally got its +#: batching, so the ceiling above now counts JOBS rather than pages and a record's consecutive web +#: steps cost one cold start between them instead of ~9 s each. +#: ⚠ 20 IS THE RUNNER'S OWN `MAX_STEPS` (`jobs/web_agent_job.py`), not a number chosen here: a plan +#: longer than that is refused by the job with `kind="too_many_steps"`, which would turn a +#: performance improvement into a whole record's worth of failed steps. Kept in step by +#: `verify_web_agent.py`, which reads both. +MAX_WEB_STEPS_PER_JOB = 20 +#: ⭐⭐ W33-T56 — HOW MANY STEPS ONE `ai_agent` ACTION MAY COMPOSE FOR ITSELF. +#: ⛔ A CEILING, NOT A TARGET, and it is the only bound between a sentence and a browser session. +#: The five `web_*` kinds are each ONE action a person wrote down; this one is a description that +#: the model turns into a journey at run time, so the number of real browser actions it performs is +#: decided by a paragraph rather than by the flow. Twelve is enough for "log in, search, open the +#: third result, read the price" and short of anything that reads as a program. +#: ⚠ Bounded by `MAX_WEB_STEPS_PER_JOB` as well, since the composed steps ride ONE job. +AI_AGENT_MAX_STEPS = 12 + + +#: The test seam for `_ai_agent_plan` — `None` in every shipped path. `verify_automation.py` sets it +#: so the fuzzy step is proven end to end with NO API key and NO spend, exactly as +#: `routes_automation._DRAFT_CHAT` does for the drafting door. +_AI_AGENT_CHAT = [None] + + +def _ai_agent_plan(cfg, row, log=print): + """A description + one record -> `(steps, "")` for `run_plan`, or `(None, sentence)`. + + ⭐⭐ W33-T56. This is the whole of the fuzzy step: the instruction and the record's own values + go to the model, and CONCRETE `web_*` steps come back — the same vocabulary a person could have + written by hand, so everything downstream (the seam, the job, the checkpoint, the per-step + verdicts) is unchanged and none of it has to know an assistant was involved. + + ⛔ IT MAY COMPOSE ONLY BROWSER STEPS. `ai_review.draft_flow` is handed a catalog filtered to + `WEB_KINDS`, so the enum in the tool schema cannot express `create_record` or a connector call. + A fuzzy sentence therefore cannot be talked into writing the tenant's data: the worst a bad + instruction can do is waste one browser session. That is a property of the SCHEMA, not of the + prompt, which is the only version of it worth relying on. + ⛔ AND IT IS BOUNDED. `maxSteps` is clamped at save time and re-applied here, because the + number of real browser actions is otherwise decided by a paragraph. + ⚠ `interpolate` FIRST, so the model is told about THIS record — `{{Website}}` is a different + page for every row, and a journey composed against the template would be one guess repeated. + """ + import ai_review # noqa: PLC0415 + url = interpolate(str(cfg.get("url") or ""), row) + instruction = interpolate(str(cfg.get("instruction") or ""), row) + cap = max(1, min(int(cfg.get("maxSteps") or AI_AGENT_MAX_STEPS), MAX_WEB_STEPS_PER_JOB)) + web_only = [r for r in ACTION_CATALOG if r.get("kind") in WEB_KINDS and r.get("ready")] + draft, why, _prov = ai_review.draft_flow( + prompt=(f"Starting page: {url}\n\nDo this: {instruction}\n\n" + f"Answer with at most {cap} steps. The FIRST step must open the starting page."), + catalog=web_only, + required={k: v for k, v in ACTION_REQUIRED.items() if k in WEB_KINDS}, + triggers=[], tables=[], + chat=_AI_AGENT_CHAT[0]) + if why or not draft: + return None, (why or "the assistant produced no steps for that instruction") + steps, seen_url = [], False + for i, a in enumerate(draft.get("actions") or [], 1): + kind = str(a.get("kind") or "") + if kind not in WEB_KINDS: + # Belt and braces behind the enum: a rung that ignores its own schema is stopped here + # rather than reaching the browser. + return None, f"the assistant asked for a step this action cannot perform ({kind})" + c = a.get("config") or {} + step = {"kind": kind, "id": f"ai{i}", + "url": str(c.get("url") or "") or (url if not seen_url else ""), + "selector": str(c.get("selector") or ""), + "attr": str(c.get("attr") or "text"), + "all": bool(c.get("all")), + "waitFor": str(c.get("waitFor") or "") or None, + "timeoutMs": int(cfg.get("timeoutMs") or WEB_READ_TIMEOUT_MS)} + if c.get("value"): + step["value"] = str(c.get("value")) + if c.get("hint"): + step["hint"] = str(c.get("hint")) + if cfg.get("dryRun"): + step["dryRun"] = True + seen_url = seen_url or bool(step["url"]) + steps.append(step) + if len(steps) >= cap: + break + if not steps: + return None, "the assistant produced no steps for that instruction" + # ⛔ THE SEAM REFUSES A JOURNEY WHOSE FIRST STEP CARRIES NO ADDRESS — there is no page to act + # on yet — and the action's own `url` is exactly the answer. Supplying it here beats letting + # the whole job be refused for a sentence the model happened not to repeat. + if not steps[0].get("url"): + steps[0]["url"] = url + return steps, "" + + +def _web_missing(kind, cfg): + """Which of this kind's REQUIRED config keys are blank — the human phrases, in table order. + + ⚠ PER KIND, because they do not need the same things: `web_goto` needs a url and no selector; + `web_fill` needs a value nobody else takes; only `web_read` needs a column to write into. One + shared three-field test would have blocked every `web_goto` ever configured for want of a + selector it does not use. + ⭐ Lifted out of `apply_actions` at W33-T58 so the BATCH BUILDER and the per-step arm ask the + same question of the same table. Two copies of "is this step configured" would let a batch + include a step the arm then refuses — or worse, exclude one it would have run. + """ + return [n for n, key in WEB_REQUIRED.get(kind, ()) if not str((cfg or {}).get(key) or "").strip()] +#: ⭐⭐ W31 QA — THE FIVE WEB KINDS THE ENGINE DISPATCHES, and this is the THIRD list that names +#: them (`web_agent.RUNNABLE_KINDS` is the seam's, `jobs/web_agent_job.py::RUNNABLE_KINDS` is the +#: runner's). ⛔ IT IS DELIBERATELY NOT AN IMPORT: `_web_agent()` resolves the seam LAZILY so an +#: absent module cannot break the engine, and a module-level `from web_agent import RUNNABLE_KINDS` +#: would throw that property away for a tuple of five strings. The three lists are held in step by +#: `verify_web_agent.py::section_one_kind_only`, which reds if any pair disagrees — a per-kind +#: allow-list in three places that CAN disagree is the defect, whichever way it points. +WEB_KINDS = ("web_read", "web_goto", "web_fill", "web_click", "web_repair") +#: What each kind cannot run WITHOUT. Absence is never refused at SAVE (a step is addable before it +#: is configured — see `_clean_action_config`'s web arm); it is refused at RUN with a sentence +#: naming what is missing. `url` is required only by `web_goto`: every other kind acts on the page +#: the flow is already on, and the runner supplies its own refusal when it genuinely needs one. +WEB_REQUIRED = { + "web_read": (("a URL", "url"), ("a CSS selector", "selector"), + ("a column to write into", "field")), + "web_goto": (("a URL", "url"),), + "web_click": (("a CSS selector", "selector"),), + "web_repair": (("a CSS selector", "selector"),), + "web_fill": (("a CSS selector", "selector"), ("a value to type", "value")), + # ⭐⭐ W33-T56 — the fuzzy step needs a page to start on and a job to do, and nothing else. + # ⚠ NO `selector` AND NO `field`: working out the selector IS the step, and where to put the + # answer is optional (a journey may only need to have been performed). + "ai_agent": (("a URL", "url"), ("a description of what to do", "instruction")), +} +#: ⭐⭐ WAVE 32 · T45 (owner item 10) — WHAT EACH ACTION KIND CANNOT RUN WITHOUT, for every kind. +#: +#: `WEB_REQUIRED` above already WAS this table for five kinds, complete with the human phrase each +#: refusal says out loud, so this is its widening rather than a second opinion — spread in, never +#: re-typed, or a wave that adds a web key would teach the run refusal and not the label. +#: +#: ⛔ MOST KINDS ARE ABSENT, AND THE ABSENCES ARE THE INTERESTING PART. `create_record`, +#: `update_record`, `find_records` and `group` are REFUSED AT SAVE when their config is incomplete +#: (`_clean_action_config`: *"the create record action names no database"*, *"…writes no values"*, +#: *"a branch with no actions inside it does nothing"*), so a stored one is configured by +#: construction and a row here would be a second, weaker copy of a wall that already holds. +#: +#: ⛔⛔ AND `enrich_instagram` / `enrich_tiktok` ARE DELIBERATELY ABSENT DESPITE BEING THE OBVIOUS +#: CANDIDATE. Their `profileField` may be empty on purpose: the C3 profile FLAG on the target +#: database resolves the binding at run time (`profile_field_key`), so an empty one is a working +#: action on any flagged database and marking it Unconfigured would put a red label on the +#: commonest correct configuration there is. The genuinely unbound case still fails closed at RUN +#: with its own sentence — which is the honest division of labour: this table holds what can be +#: answered from the ACTION ALONE, and anything needing the target database's schema stays a +#: run-time refusal rather than becoming a store read on the automations LIST path (W30-T12 took +#: that read off this route; putting it back to draw a label would undo the wave before this one). +ACTION_REQUIRED = dict(WEB_REQUIRED) +#: ⭐ W31-T38 (C5) — the web step's own clamps. `20000` matches the default E's seam documents; +#: the ceiling exists because this blocks the record walk (~9-32 s of cold start ALREADY), and an +#: automation that can be configured to wait ten minutes per record is a stalled run, not a slow one. +WEB_READ_TIMEOUT_MS = 20000 +WEB_READ_TIMEOUT_MAX_MS = 120000 +#: ⭐ WAVE 24 · C-ACT — THE MENU'S GROUP ORDER IS THE SERVER'S. The client sorts by `groupOrder` +#: and never by a literal list of group names: a client-side ordering is a second copy of this +#: table, and the way it fails is that a group added here renders last, or not at all, with +#: nothing red anywhere. +ACTION_GROUP_ORDER = {"Web action": 1, "Database": 2, "Connected": 3, "Advanced logic": 4} +ACTION_CATALOG = [ + # ── 1. WEB ACTION (owner rulings R1-R5) — DECLARED HERE, NOT YET BUILT (DEBT D-51). ──────── + # ⛔ NOT A TEASE. `clean_actions` refuses an unready kind with a sentence, so the faded row + # is a WALL, not a styling choice — and each `detail` says the true reason rather than a + # placeholder, because "coming soon" on five rows is how a menu stops being believed. + # + # ⚠ WAVE 25 RETARGETED THE ONE STRING THAT NAMED A WAVE, and the reason generalises: W24 + # scheduled this build for wave 25 and wave 25's six owner items do not include it, so + # "building next (wave 25)" became false the moment this wave shipped — a menu that dates its + # own promises has to be re-read by whoever misses the date, and a stale date is worse than + # none because it reads as a commitment somebody already broke. The replacement names the + # missing CAPABILITY, like its four siblings always did; D-51 carries the schedule. + # ⭐⭐ WAVE 31 · T38 + QA (C5) — ALL FIVE ROWS ARE READY, AND THE RULING IS WHY. + # + # ⛔ THIS REVERSES T38's ORIGINAL `how:`, ON THE OWNER'S OWN WORDS. T38 shipped `web_read` + # alone and held the other four at `ready:False`, citing PRD R10 ("the ones that WRITE to a + # third party do not flip without R5's approval gate existing"). **R5 was REVOKED the same + # day, and the revocation is recorded as an amendment on this wave's board** + # (`TICKETS.md:1418`, `mailbox/E.md:278`, `web_agent.py:103`) — owner, verbatim: + # *"make sure we unblock all web actions, we don't need approval step first wtf, I never ask + # for that."* Session C declined E's ask against the superseded half of R10, so four actions + # the owner asked for shipped unusable, and `verify_web_agent` was RED on correct-by-ruling + # code for the whole wave. Confirmed at QA against four independent recordings of the quote. + # ⚠ THE FADED ROW IS A WALL, NOT A STYLE: `clean_actions` refuses an unready kind with a + # sentence, so this flag is what makes the action storable at all — which is exactly why a + # row left `False` against a ruling is a feature that does not exist. + # ⚠ THE `detail` STRINGS CHANGED WITH THE FLAGS, deliberately. They said *"Needs the browser + # job"* and *"Needs the recorder that captures a selector"* — both false since E's runner + # landed. A menu that dates its own promises is this module's own recorded complaint one + # comment up; a row that advertises a missing prerequisite it already has is the same defect. + # ⭐⭐ WAVE 33 · W33-T56 (owner item 7, ruling R3) — THE FUZZY STEP. + # ⛔ IT ADDS; IT REPLACES NOTHING. R3 is explicit: *"No existing `web_*` kind is removed — + # dropping a kind from the catalog 400s every stored automation using it, forever"* (D-65). + # This is the step for a journey somebody can DESCRIBE but not spell as a selector; the five + # kinds below stay exactly as they are for the journeys they can already express, and a person + # who knows the selector should still use `web_read`, which costs no model call. + # ⭐⭐ WAVE 34 · W34-T44 / R18 — SIX ROWS BECAME ONE, AND THE OTHER FIVE DID NOT LEAVE THE + # CATALOG. Owner, verbatim: *"Remove Do this on a page / Open a page / Fill a field / Click + # something / Read from the page / Repair a broken step completely. One action, Web agent."* + # + # ⛔⛔ WHY THEY ARE HIDDEN AND NOT DELETED, AND IT IS NOT D-65 THIS TIME — IT IS WORSE. + # `_ai_agent_plan` builds the model's tool schema as + # `[r for r in ACTION_CATALOG if r["kind"] in WEB_KINDS and r["ready"]]` + # so DELETING these five rows empties that list, and the ONE action the ruling keeps would be + # left able to compose nothing at all. Deleting the six would have deleted the survivor, in + # silence, and `ai_agent`'s own tests would still pass because they stub the chat rung + # (`_AI_AGENT_CHAT`). D-65's usual argument (a deleted kind 400s every stored automation + # forever) applies as well and is the smaller half. + # + # ⭐ SO THE LINE MOVED FROM "IS IT IN THE CATALOG" TO "IS IT ON THE MENU". `menu: False` is + # withheld by `action_catalog()` from the picker, while `clean_actions` (which reads this + # constant, not the wire) still validates the kind, the runner still runs it, and a stored + # automation built before today keeps working and stays editable. + # ⚠ THE LABELS CHANGED TOO, and that is R18's "appear NOWHERE" clause taken literally: a + # hidden row's label is still rendered on a STORED step's card, so leaving the six captions in + # place would have kept them on screen for exactly the people who already use them. They now + # name the mechanism instead, and no old caption survives as a substring in any case. + {"kind": "ai_agent", "label": "Web agent", "group": "Web action", "ready": True, + "detail": "Describe a job on a website in words; the agent works out the steps, runs them in " + "a browser and reports what it actually did"}, + {"kind": "web_goto", "label": "Web agent step (navigate)", "group": "Web action", + "ready": True, "menu": False, + "detail": "Opens a page in a browser job and reports the title it landed on"}, + {"kind": "web_fill", "label": "Web agent step (type)", "group": "Web action", + "ready": True, "menu": False, + "detail": "Types a value into a field. Mark it secret and the value is masked in the log"}, + {"kind": "web_click", "label": "Web agent step (click)", "group": "Web action", + "ready": True, "menu": False, + "detail": "Clicks the element a selector names, and reports where it landed"}, + # ⚠ `"kind": "web_read"` AND `"ready": True` STAY ON ONE LINE. `verify_wiring`'s C5 row matches + # `"kind": "web_read".*?"ready": True` without DOTALL, so wrapping this row the way its four + # siblings are wrapped turns that cross-fence assertion red — on a formatting change, with the + # mount and the flag both intact. The row is A's file and its CLAIM is right (a catalog kind + # the client can add must be one the runner will execute); the layout is what it happens to + # depend on, so the layout is preserved here rather than the assertion weakened there. + {"kind": "web_read", "ready": True, "label": "Web agent step (extract)", + "group": "Web action", "menu": False, + "detail": "Reads one value off a live page in a browser job. Expect ~10-30 s per step"}, + {"kind": "web_repair", "label": "Web agent step (relocate)", "group": "Web action", + "ready": True, "menu": False, + "detail": "Follows a label to its control when a selector has gone stale, and proposes one"}, + # ── 2. DATABASE ────────────────────────────────────────────────────────────────────────── + {"kind": "update_record", "label": "Update record", "group": "Database", "ready": True, + "detail": "Write values onto the record walking the flow"}, + {"kind": "create_record", "label": "Create record", "group": "Database", "ready": True, + "detail": "Add a row to another database"}, + {"kind": "find_records", "label": "Find records", "group": "Database", "ready": True, + "detail": "Look rows up by condition; the run log opens them"}, + # ── 3. CONNECTED ───────────────────────────────────────────────────────────────────────── + # ⭐ WAVE 25 · C4 (owner rulings R3/R4) — THE ENRICH ACTION, and it REPLACES a whole KIND. + # `field_instagram` was an automation you created to fill one column; this is a step any flow + # can take, which is the shape it should always have had — enriching a profile is something + # you do TO a record, not a species of automation. + # ⭐ WAVE 27 · C4 (item 33) — `connector` NESTS THIS ROW UNDER "Scraper" in the action menu, + # exactly as `TRIGGER_CONNECTOR` already nests the trigger picker. See `ACTION_CONNECTOR`. + {"kind": "enrich_instagram", "label": "Enrich Instagram profile", "group": "Connected", + "ready": True, "connector": "scraper", + "detail": "Fill this record's Instagram columns from its profile, and add a point to its " + "history"}, + # ⭐⭐ WAVE 30 · T08 / CONTRACT C3 — THE SECOND NETWORK, AND THIS ROW IS THE LAST SWITCH THAT + # LANDS, deliberately. `apply_actions._walk`'s kind dispatch has NO terminal `else` (measured + # from the AST, not read): an unknown kind is walked, counted, reports the run `ok`, and + # writes nothing. So a catalog entry ahead of its runner arm would ship an action that is + # addable, clickable, storable and silently inert — strictly worse than the state before it, + # where `clean_actions` refuses the kind with a sentence. + # ⚠ `connector: "scraper"` NESTS IT UNDER THE SAME BUCKET AS INSTAGRAM (R3), which is the same + # value T07 gave the trigger — one word, both menus. + {"kind": "enrich_tiktok", "label": "Enrich TikTok profile", "group": "Connected", + "ready": True, "connector": "scraper", + "detail": "Fill this record's TikTok columns from its profile, and add a point to its " + "history"}, + {"kind": "send_email", "label": "Send email", "group": "Connected", "ready": False, + "detail": "Needs a send scope on the Gmail connection"}, + {"kind": "slack", "label": "Send Slack message", "group": "Connected", "ready": False, + "detail": "Needs the Slack connector"}, + # ── 4. ADVANCED LOGIC ──────────────────────────────────────────────────────────────────── + # `group` is relabelled "If / then" (C-ACT): "Conditional logic" described the mechanism, + # and R8 made it a FORK with lettered branches, which is a thing people already have a name + # for. The KIND is untouched — renaming it would orphan every stored action for a caption. + {"kind": "group", "label": "If / then", "group": "Advanced logic", "ready": True, + "detail": "Send the record down one of several branches, by condition"}, + {"kind": "repeating_group", "label": "Repeating group", "group": "Advanced logic", + "ready": False, "detail": "Run the same actions on every item in a list"}, + {"kind": "run_script", "label": "Run script", "group": "Advanced logic", "ready": False, + "detail": "Not built. A sandbox is its own decision"}, + {"kind": "generate_ai", "label": "Generate with AI", "group": "Advanced logic", + "ready": False, "detail": "Needs an AI action implementation"}, +] + + +#: ⭐ WAVE 27 · C4 (owner item 33) — WHICH CONNECTOR AN ACTION BELONGS TO, so the action menu can +#: nest exactly as the trigger picker already does (`reference/Airtable Automation 10.png`: an +#: "Integrations" header, one row per connector, a chevron into that connector's own submenu). +#: +#: ⚠ SAME SHAPE AS `TRIGGER_CONNECTOR`, DELIBERATELY, and the same warning applies: these are +#: GROUPING HANDLES for the picker, not `/connectors/directory` slugs. A client must group by the +#: key and render the label, never join it against the directory. +#: ⛔ AND THE LABEL IS NOT DECLARED HERE AT ALL — it is LOOKED UP in `TRIGGER_CONNECTOR` by key. +#: A "Scraper" nest in the trigger menu and a "Scrapers" nest in the action menu would be one +#: connector wearing two names on two screens somebody sees within a second of each other, and a +#: second literal is how that happens. This tuple says only WHICH connectors an action may name; +#: what they are CALLED has exactly one source. +ACTION_CONNECTORS = ("scraper",) + + +def _connector_meta(key): + """`{key, label}` for a connector key, from the one place either picker declares it.""" + key = str(key or "") + if key not in ACTION_CONNECTORS: + return None + return next((dict(v) for v in TRIGGER_CONNECTOR.values() if v.get("key") == key), None) + + +def action_catalog(): + """The catalog as the wire carries it — a copy, because a caller that mutated the module + constant would change every later reader's answer. + + ⭐ WAVE 24 (C-ACT): each row is stamped with its `groupOrder`, DERIVED from + `ACTION_GROUP_ORDER` rather than hand-written per row, so a group cannot be given two + different orders by two rows that claim to be in it. A group nobody has ordered sorts LAST + (not first) — a new group appearing above "Web action" because its order defaulted to 0 is + the failure that would look deliberate. + + ⭐ WAVE 27 (C4): a row naming a `connector` is stamped with the full `{key, label}` the client + nests on — RESOLVED here rather than written out per row, for the reason `groupOrder` is: two + rows in one nest cannot disagree about what that nest is called. A row naming an unknown + connector loses the stamp instead of inventing a nest with a raw slug for a title. + + ⭐⭐ WAVE 34 · W34-T44 / R18: `menu` is stamped on EVERY row, never left absent. A row the + picker must not offer carries `menu: False` and STILL RIDES THE WIRE, because the client + resolves a STORED step's label out of this same list (`AutomationBuilder`: + `catalog.find(c => c.kind === a.kind)` then `row?.label || a.kind`) — withholding the row + entirely would make an existing web step render its raw kind token at somebody, which this + module forbids in those words elsewhere. + ⚠ STAMPED RATHER THAN LEFT TO DEFAULT: absent-means-true is a rule two codebases have to + remember the same way, and a client filter is one `!== false` away from meaning the opposite. + An explicit boolean on every row cannot be read two ways. + """ + last = max(ACTION_GROUP_ORDER.values()) + 1 + out = [] + for a in ACTION_CATALOG: + row = {**a, "groupOrder": ACTION_GROUP_ORDER.get(a.get("group"), last), + "menu": bool(a.get("menu", True))} + conn = _connector_meta(a.get("connector")) + if conn: + row["connector"] = dict(conn) + else: + row.pop("connector", None) + out.append(row) + return out + + +def action_needs(action): + """⭐⭐ WAVE 32 · T45 (owner item 10) — what THIS action is still missing, in the words a person + reads. `[]` means Configured. + + Pure over `(kind, config)` — no runtime, no store read. That is what lets `_wire` stamp every + action on the automations LIST without putting the `user_tables` document back on a route + W30-T12 just took it off. + """ + action = action or {} + cfg = action.get("config") if isinstance(action.get("config"), dict) else {} + return [name for name, key in ACTION_REQUIRED.get(str(action.get("kind") or ""), ()) + if not str(cfg.get(key) or "").strip()] + + +def _walk_actions(actions): + """Every action in a flow, INCLUDING the ones nested inside If / then branches. + + ⛔ A FLAT `for a in actions` MISSES HALF A FLOW. `group.config.branches[].actions` is where a + conditional puts its real work, and a configured-check that only saw the top level would + report a flow ready to run while the step inside branch B had never been filled in — which is + the exact class of defect this ticket exists to surface, hiding inside the ticket's own fix. + """ + for action in actions or []: + if not isinstance(action, dict): + continue + yield action + for branch in ((action.get("config") or {}).get("branches") or []): + if isinstance(branch, dict): + yield from _walk_actions(branch.get("actions")) + + +def unconfigured_actions(defn): + """Every ENABLED action of this automation that cannot run, as `[{id, kind, label, needs}]`. + + ⚠ DISABLED ACTIONS ARE SKIPPED, and that is the point of being able to disable one: a step + somebody switched off is not a step blocking the run. `apply_actions` already ignores them. + """ + out = [] + for action in _walk_actions(((defn or {}).get("flow") or {}).get("actions")): + if not action.get("enabled", True): + continue + needs = action_needs(action) + if needs: + out.append({"id": str(action.get("id") or ""), "kind": str(action.get("kind") or ""), + "label": _action_label(action), "needs": needs}) + return out + + +def run_refusal(defn): + """⛔ WAVE 32 · T45 — the sentence a run is refused with, or `""`. + + Owner item 10: *"running an automation with ANY unconfigured action is REFUSED with a message + naming which action"*. Naming it is half the requirement and the half that is easy to drop — a + bare *"an action is not configured"* on a twelve-step flow is a hunt, not a message. + + ⛔ IT LIVES ON THE ENGINE, NOT ON THE ROUTE, BECAUSE THE ROUTE IS NOT THE ONLY DOOR. The tick + runs automations on a schedule, the webhook door runs them, and a check mounted in + `POST /automations/{id}/run` alone would refuse the button and let the cron sail past it — + [[seal-the-transport-not-the-rung]], and D-112's own shape (an action bound to a deleted view + walked zero records and reported `ok`). `run_now` refuses for every caller; the route asks + first only so the person clicking gets a 400 with the sentence instead of a silent no-op. + + ⚠ THIS IS A BEHAVIOUR CHANGE FOR STORED AUTOMATIONS AND IT IS THE RULING. A flow with one + unconfigured web step used to run, skip that step with a note, and report `ok`; it now does not + run at all. That is what "blocks the run" means, and the alternative — running everything else + and reporting success — is precisely what the owner is asking to stop. + """ + missing = unconfigured_actions(defn) + if not missing: + return "" + return "; ".join(f"{m['label']} still needs " + ", ".join(m["needs"]) for m in missing) + + +def _action_label(act): + for row in ACTION_CATALOG: + if row["kind"] == act.get("kind"): + return row["label"] + return str(act.get("kind") or "Action") + + +def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None): + """Validate `flow.actions`. Returns `(actions, error)` — refuses, never coerces. + + Ids are STABLE: a caller's well-formed `id` is kept, so selecting an action in the builder + survives a Save. A missing or colliding one is minted `act_`; minting on every clean would + move the selection under the person editing it. + + ⭐⭐ D-75 — `notes` IS THE DISCLOSURE CHANNEL, AND IT IS OPT-IN. Pass a list and this function + appends one plain sentence per thing it SILENTLY CHANGED: a `create_record` condition dropped + (see the note at that branch), and any config key an arm's allowlist did not keep. + + ⛔ WHY IT IS AN OUT-PARAMETER RATHER THAN A THIRD RETURN VALUE. D-75's own exit says *"this is + a signature change across its callers"* and that is exactly what makes the obvious fix + dangerous mid-wave: `clean_actions` is called from `clean_flow`, from `_is_untouched_ig_seed` + and recursively from its own group arm, and a caller that unpacked two values from a + three-tuple fails at RUN, not at import. An optional list defaults to `None`, every existing + caller is untouched by construction, and the one door that wants to tell somebody opts in. + ⚠ IT REPORTS, IT NEVER REFUSES. Every drop here is deliberate and D-65 is the reason — refusing + a stored automation's condition would 400 it forever, with no way to edit it out, because the + editor cannot save the automation it needs to fix. A drop is recoverable; a locked door is not. + What was missing was never the refusal, it was somebody being told. + """ + if raw in (None, ""): + return [], None + if not isinstance(raw, list): + return None, "actions must be a list" + seen = _seen if _seen is not None else set() + count = _count if _count is not None else [0] + out = [] + for entry in raw: + if not isinstance(entry, dict): + return None, "each action must be an object with a kind" + kind = _s(entry.get("kind"), 30).strip() + row = next((r for r in ACTION_CATALOG if r["kind"] == kind), None) + if row is None: + return None, (f"{kind or 'that action'!r} is not one of: " + + ", ".join(a["kind"] for a in ACTION_CATALOG)) + if not row["ready"]: + return None, (f"{row['label']!r} is on the menu but not built yet. " + f"{row['detail'][0].lower()}{row['detail'][1:]}") + count[0] += 1 + if count[0] > MAX_ACTIONS: + return None, f"an automation runs at most {MAX_ACTIONS} actions" + aid = _s(entry.get("id"), 40).strip() + if not re.fullmatch(r"act_[a-z0-9_]{1,32}", aid or "") or aid in seen: + n = 1 + while f"act_{n}" in seen: + n += 1 + aid = f"act_{n}" + seen.add(aid) + # ⭐ WAVE 26 · C4 / owner ruling R9 — A `create_record` CARRIES NO CONDITION, EVER. + # + # Owner, correcting their own earlier answer mid-grill: *"if it is Create Record, I don't + # think you can even add Conditions at all. That's not how the Create Record works."* + # Airtable agrees, and so does the shape: every other action operates ON the record the + # flow is walking, so "run this only when …" is a question about something + # that exists. Create record MAKES one. There is nothing to test yet, which is why the + # picker on this panel was empty and why every condition saved against it named no field — + # the owner's *"the condition on action act_1 names no field"*. The condition belongs on + # the trigger, or on a conditional group wrapping the action. + # + # ⛔ DROPPED, NOT REFUSED, AND THE DIFFERENCE IS D-65. Refusing would 400 every stored + # automation that already carries one — forever, with no way to edit it out, because the + # editor cannot save the automation it needs to fix. A drop is recoverable; a refusal is a + # locked door. + # ⚠ Dropped SILENTLY, and that is defensible only because the panel goes with it: wave 26 + # removes the condition editor from this action kind entirely (C4), so there is no control + # whose value could appear to be ignored. If a `create_record` condition editor ever comes + # back, this needs a disclosure channel — `clean_actions` has none today. + if kind == "create_record": + # D-75: the drop is unchanged; what is new is that a caller can be TOLD about it. + if notes is not None and entry.get("when"): + notes.append(f"{aid}: the condition on a Create-record step was dropped. A " + f"Create record has no record to test yet. Put the condition on the " + f"trigger, or on an If wrapping this step.") + when = None + else: + when, cerr = clean_cond(entry.get("when"), where=f"action {aid}") + if cerr: + return None, cerr + cfg_raw = entry.get("config") if isinstance(entry.get("config"), dict) else {} + cfg, cerr = _clean_action_config(kind, cfg_raw, depth, seen, count, notes=notes) + if cerr: + return None, cerr + # ⛔ D-75 — THE ALLOWLIST'S OWN DROPS, DIFFED HERE RATHER THAN REPORTED BY EACH ARM. Every + # arm builds a fresh `out = {...}`, so none of them knows what it did not keep; the caller + # of the arm does, because it holds both dicts. One diff, and a new arm is covered the day + # it is written rather than the day somebody remembers to instrument it. + if notes is not None and isinstance(cfg, dict): + gone = [k for k in cfg_raw if k not in cfg] + if gone: + notes.append(f"{aid}: {', '.join(sorted(gone))} " + f"{'is' if len(gone) == 1 else 'are'} not a setting a " + f"{KIND_LABELS.get(kind, kind)!r} step uses, so " + f"{'it was' if len(gone) == 1 else 'they were'} not saved.") + out.append({"id": aid, "kind": kind, + "enabled": bool(entry.get("enabled", True)), + "when": when, "config": cfg}) + return out, None + + +def _clean_action_config(kind, cfg, depth, seen, count, notes=None): + """One action's `config`, per kind. Returns `(config, error)`.""" + if kind == "group": + # ⭐ WAVE 24 · C-FORK (owner ruling R8) — a group is a FORK now: `{branches: [...]}`, + # each `{id, label, cond, actions}`. It was one condition with one action list, i.e. an + # if with no else, so expressing "otherwise" meant a second group carrying the negation + # by hand — two conditions a later edit could put out of step with each other. + if depth + 1 > MAX_GROUP_DEPTH: + return None, (f"conditional groups nest at most {MAX_GROUP_DEPTH} deep. " + f"past that a flow is a flowchart and belongs on the board") + raw_branches = cfg.get("branches") + if not isinstance(raw_branches, list) or not raw_branches: + # ⛔ MIGRATION, NOT A REFUSAL. The shipped shape is exactly one implicit branch, and + # the live automations carry it — refusing it would 400 every Save of a flow that + # was legal yesterday. Read-side, so nothing has to be rewritten in the store. + raw_branches = [{"id": "b1", "label": "A", "cond": cfg.get("cond"), + "actions": cfg.get("actions")}] + if len(raw_branches) > MAX_BRANCHES: + return None, (f"an If / then has at most {MAX_BRANCHES} branches. Past that the " + f"record is being routed, which is what the board's lanes are for") + out_branches, bseen = [], set() + for i, br in enumerate(raw_branches): + if not isinstance(br, dict): + return None, "each branch of an If / then is an object" + cond, cerr = clean_cond(br.get("cond"), where="the branch") + if cerr: + return None, cerr + if cond is None and i != len(raw_branches) - 1: + # ⛔ ONE Otherwise, and it is LAST. `_walk` takes the first branch that matches + # and a null condition matches everything, so a catch-all above another branch + # would make every branch below it dead code — silently, and only for the + # records that reached it. Refused with the reason rather than reordered: this + # module does not quietly rewrite what somebody built. + return None, ("only the LAST branch of an If / then can be the Otherwise leg. " + "a catch-all above another branch makes the ones below it " + "unreachable") + # D-75: threaded into the branch too — an unconfigured step inside an If is + # exactly the one a person cannot see, which is `_walk_actions`' own argument. + kids, kerr = clean_actions(br.get("actions"), depth + 1, seen, count, + notes=notes) + if kerr: + return None, kerr + if not kids: + return None, "a branch with no actions inside it does nothing" + bid = _s(br.get("id"), 40).strip() + if not re.fullmatch(r"b[a-z0-9_]{0,32}", bid or "") or bid in bseen: + bid = f"b{i + 1}" + bseen.add(bid) + label = " ".join(_s(br.get("label"), 40).split()) or ( + "Otherwise" if cond is None else _branch_letter(i)) + out_branches.append({"id": bid, "label": label, "cond": cond, "actions": kids}) + return {"branches": out_branches}, None + if kind in ("update_record", "create_record"): + values = cfg.get("values") + if not isinstance(values, dict) or not values: + return None, f"the {kind.replace('_', ' ')} action writes no values" + if len(values) > MAX_ACTION_VALUES: + return None, f"one action writes at most {MAX_ACTION_VALUES} cells" + clean_vals = {} + for k, v in values.items(): + key = _s(k, 80).strip() + if not key: + return None, "a value is written to a field with no name" + if v is not None and not isinstance(v, (str, int, float, bool)): + return None, (f"the value for {key!r} must be text or a number. " + f"an automation writes cells, not objects") + clean_vals[key] = _s(v, 500) if isinstance(v, str) else v + out = {"values": clean_vals} + # ⭐ WAVE 24 — an OPTIONAL self-given name, so a SEEDED action can say what it is + # ("Create an Instagram record", C-TRIG law 3) instead of wearing the catalog's generic + # "Create record". Follows the `review` arm below, which has carried `config.label` since + # wave 23 — one precedent, not a new mechanism, and it lives inside the untyped `config` + # bag so no shared client type has to change for it. + lbl = " ".join(_s(cfg.get("label"), 60).split()) + if lbl: + out["label"] = lbl + if kind == "create_record": + table = _s(cfg.get("table"), 60).strip() + if not table: + return None, "the create record action names no database" + if not table.startswith(UT_PREFIX): + return None, (f"actions write to blank databases (ut_*) this wave. " + f"{table!r} is not one") + out["table"] = table + # ⭐ WAVE 25 · C5 (owner ruling R1a) — UNIQUE ON. Without it this action APPENDS + # forever (`_commit_action_writes` mints `max+1`), so ANY scheduled flow carrying a + # Create record duplicates a row per run — silently, and worse every day. + # + # ⛔ `""` IS THE STORED DEFAULT AND IT MEANS TODAY'S APPEND. Not a migration, not a + # guess: a stored automation must not change meaning because this key arrived. The + # upsert is a thing somebody turns on, the same shape `clean_ending` gave `terminal`. + # + # ⛔ AND IT MUST NAME A FIELD THIS ACTION ACTUALLY WRITES. Upserting on a key the + # action never sets means every incoming row carries a BLANK key — `upsert_rows` + # counts those as `skipped` and writes nothing at all. That is a control that reads + # as "no duplicates" and delivers "no rows", which is the worse failure by a distance. + # Same shape as `scrape_db`'s "the key field must be one of the mapped fields" + # (`clean_config`, above) — one precedent, not a second mechanism. + unique = _s(cfg.get("uniqueOn"), 80).strip() + if unique and unique not in clean_vals: + return None, (f"this action does not write {unique!r}, so it cannot keep records " + f"unique on it. It writes: " + ", ".join(sorted(clean_vals))) + out["uniqueOn"] = unique + return out, None + if kind in ENRICH_KINDS: + # ⭐ WAVE 30 · T08 — BOTH networks share this branch. See `ENRICH_KINDS`: the selection, the + # cooldown, the limit clamps and their reasons are network-agnostic, and a second copy for + # TikTok would drift until one network accepted a limit the other refused. + # ⭐ WAVE 25 · C4. The switches are `field_instagram`'s, unchanged in meaning — the paid + # rung, its fallback, the per-post engagement buy and the dry run — because they are the + # things that decide what a run COSTS and R4 moves the capability, not the controls. + # + # ⚠ `profileField` MAY BE EMPTY, and that is the A3 stored-inert pattern rather than + # laxness: the C3 profile FLAG resolves it at run time, so the action can be added to a + # flow before anybody has flagged the column. An unresolvable binding fails CLOSED with a + # sentence at run (`profile_field_key`), which is the honest half — refusing the save + # would make the action unaddable on a database that has not been flagged yet. + # C5: ONE validator, shared with `clean_config`'s machine-kind branch. Two independent + # clamps on the same key is how the action and the legacy kind come to disagree about + # what "10 posts" means. + # + # ⚠ `submitted=False` HERE, AND IT IS THE OPPOSITE OF THE OTHER CALL SITE. The difference + # is not the value, it is what "present" MEANS on each path. `clean_config` gets a PATCH, + # so a key being there is a person having typed it. This function gets the whole action + # list re-posted on every save (`_pin_unique`'s note says so in as many words), so a + # `maxPosts` here is usually just the client echoing back what was already stored — and + # every action written before this wave stored the old default of 24. Refusing would + # therefore 400 those automations forever on a number nobody chose, which is D-65. + # The ceiling is still taught, in the place where a person can act on it: the panel's + # input is bounded at MAX_POSTS_PER_PULL (C5 tells B not to offer more). + max_posts, perr = clean_max_posts(cfg.get("maxPosts"), submitted=False) + if perr: + return None, perr + # ⭐ 2026-08-07 (owner ruling) — THE SELECTION, and every key here is OPTIONAL with a + # working default. That is D-65's lesson applied ahead of time rather than after: an enrich + # action stored before this wave carries none of these, and it must keep saving and keep + # running. Absent `fromView` = the whole database; absent `limit` = DEFAULT_ENRICH_LIMIT; + # `skipRecent` absent = OFF, because a cooldown nobody asked for silently stops enriching. + # ⚠ `limit` is CLAMPED here and again in `enrich_selection`. Not belt-and-braces: this door + # sees a person's typed number and can refuse politely, while the selection sees whatever + # is STORED — including configs written before the cap existed. Clamping only here would + # let an old 5000 through at run time. + try: + e_limit = int(cfg.get("limit") or DEFAULT_ENRICH_LIMIT) + except (TypeError, ValueError): + return None, "how many records to enrich has to be a number" + if e_limit < 1: + return None, "an enrich step has to be allowed at least one record" + try: + e_days = int(cfg.get("skipRecentDays") or DEFAULT_ENRICH_COOLDOWN_DAYS) + except (TypeError, ValueError): + return None, "the number of days has to be a number" + groups, gerr = clean_post_groups(cfg.get("postGroups"), max_posts) + if gerr: + return None, gerr + return {"profileField": _s(cfg.get("profileField"), 80).strip(), + "fromView": _s(cfg.get("fromView"), 80).strip(), + "sortField": _s(cfg.get("sortField"), 60).strip() or DEFAULT_ENRICH_SORT, + # Anything that is not "asc" is newest-first, so a typo cannot invert the order a + # person is spending against — it lands on the documented default instead. + "sortDir": "asc" if str(cfg.get("sortDir") or "").strip().lower() == "asc" + else "desc", + "limit": min(e_limit, MAX_ENRICH_PER_RUN), + "skipRecent": bool(cfg.get("skipRecent")), + "skipRecentDays": max(1, e_days), + # ⛔ `tier`/`noFallback` are ACCEPTED AND IGNORED here for the same reason as in + # `clean_config` (R5 / C2): a stored action carrying them still saves and simply + # loses them, because refusing a retired key 400s every definition written before + # this wave (D-65). + # ⛔ THE FLAG THAT MULTIPLIES THE BILL BY THE POST COUNT. Off unless asked for. + # ⭐ WAVE 30 · T10 — THE TIKTOK FORCE-OFF IS GONE, because the capability it was + # standing in for now exists. T08 shipped `... and kind != "enrich_tiktok"` here + # deliberately: the key was ACCEPTED (never a 400, D-65's lesson) and answered + # honestly with `False`, because storing a `True` nothing acts on is a money switch + # claiming a capability nobody built. Both networks now read the SAME two keys. + "postMetrics": bool(cfg.get("postMetrics")), + # The separate Comments dataset is more granular than a post read and stays off + # until the user intentionally enables it. + "commentMetrics": bool(cfg.get("commentMetrics")), + "dryRun": bool(cfg.get("dryRun")), + # ⭐ 2026-08-09 (owner) — "last 12 reels / 12 videos", per GROUP. Absent = OFF, so + # every action stored before today keeps saving and keeps running unchanged. + "postGroups": groups, + "maxPosts": max_posts}, None + if kind == "find_records": + table = _s(cfg.get("table"), 60).strip() + if not table: + return None, "the find records action names no database" + cond, cerr = clean_cond(cfg.get("cond"), where="the find") + if cerr: + return None, cerr + try: + limit = int(cfg.get("limit") or 25) + except (TypeError, ValueError): + return None, "the find records limit must be a whole number" + if limit < 1 or limit > FIND_LIMIT_MAX: + return None, f"the find records limit is between 1 and {FIND_LIMIT_MAX}" + return {"table": table, "cond": cond, "limit": limit}, None + if kind == "ai_agent": + # ⭐⭐ W33-T56 — THE FUZZY STEP'S CONFIG. Same posture as the web arm below in every + # respect that matters: an ALLOWLIST, a shape test on the url, a clamped timeout, and an + # EMPTY config stored rather than refused so the step can be added before it is configured + # (the wave-23 illegal-default-seed defect, and D-79's scar). + # ⚠ IT DELIBERATELY DOES NOT TAKE A `selector`. Working the selector out is the step; a + # selector box here would be a control that contradicts the action's own reason to exist. + _u = _s(cfg.get("url"), 500).strip() + if _u and not (_u.startswith("http://") or _u.startswith("https://") + or _u.startswith("{{")): + return None, "a web address must start with http:// or https://" + try: + _t = int(cfg.get("timeoutMs") or WEB_READ_TIMEOUT_MS) + except (TypeError, ValueError): + return None, "the web step timeout must be a whole number of milliseconds" + try: + _max = int(cfg.get("maxSteps") or AI_AGENT_MAX_STEPS) + except (TypeError, ValueError): + return None, "the step ceiling must be a whole number" + out = {"url": _u, + # ⚠ LONGER THAN A SELECTOR AND SHORTER THAN A PROMPT. This is a sentence or two + # describing a job, and the model is given the page's own vocabulary at run time — + # a 4,000-character instruction is a sign somebody is writing a program in prose. + "instruction": _s(cfg.get("instruction"), 600), + "field": _s(cfg.get("field"), 80).strip(), + "timeoutMs": max(1000, min(_t, WEB_READ_TIMEOUT_MAX_MS)), + # ⛔ THE CEILING IS THE POINT OF THE CLAMP, not the default. Every step this action + # composes is a real browser action inside one job, and a fuzzy instruction is + # exactly the input that produces twenty of them. Bounded here so a run cannot be + # talked into an unbounded journey by a sentence. + "maxSteps": max(1, min(_max, AI_AGENT_MAX_STEPS))} + if cfg.get("dryRun"): + out["dryRun"] = True + return out, None + if kind in WEB_KINDS: + # ⭐⭐ WAVE 31 · T38 (C5 / R10) — AND THIS ARM IS THE HALF THAT WOULD HAVE SHIPPED MISSING. + # + # ⭐⭐ W31 QA WIDENED IT FROM `web_read` TO ALL FIVE KINDS, AND THAT IS THE SAME DEFECT THIS + # ARM'S OWN COMMENT DESCRIBES, ARRIVING A SECOND TIME. When the four write kinds flipped to + # `ready:True` on the owner's revocation of R5, they became storable — and with this arm + # still testing `kind == "web_read"` they fell through to `return {}, None` below, keeping + # their KIND and losing `url` / `selector` / `value` / `hint` **silently, with no error + # anywhere**. Addable, storable, runnable, and forever unconfigured. The paragraph below + # was written about exactly this, one kind earlier; widening the test is what makes it + # true of the feature rather than of one row in it. + # + # ⛔ FOUND BY THE GATE, NOT BY REVIEW, AND IT IS THE TICKET'S OWN FAILURE MODE. Flipping + # `ready:True` and mounting the dispatch arm is not enough: with no branch here the + # function falls through to `return {}, None` below, so a stored `web_read` action keeps + # its KIND and loses its `url`, `selector` and `field` **silently, with no error anywhere**. + # The action would be addable, storable, runnable — and would fetch `""` forever. That is + # precisely the *"addable and inert"* outcome E's hand-off warned about, arriving through a + # different door than the one being watched, and the same silent-drop class as `patch`'s + # hand-maintained key list one screen up. + # + # ⚠ THE URL IS NOT `guard`ed HERE, on purpose. `guard` does DNS, and this is a validator + # that runs on every save of every automation — a save must not block on name resolution, + # and a template like `https://{Domain}/p` is not resolvable until a record supplies the + # value. The rail is enforced where the fetch happens (the job, and `web_agent`'s own + # refusal), which is the only place the FINAL url exists. What is enforced here is the + # shape: http(s) or an interpolation that could become one. + # ⛔⛔ AN EMPTY CONFIG IS STORED, NOT REFUSED — THE A3 STORED-INERT PATTERN, and the first + # draft of this arm got it wrong in a way only a gate could see. Requiring `url` / + # `selector` / `field` here makes the action UNADDABLE: `AutomationBuilder::seedFor` mints + # a new step from a seed and persists it immediately, so a validator that refuses an empty + # seed yields a red banner and no card — the wave-23 illegal-default-seed defect, caught by + # `verify_automation_ui`'s catalog-derived seed check the moment `web_read` became ready. + # ⇒ The same posture the enrich arm already takes with `profileField`: a step may be added + # to a flow before it is configured, and it FAILS CLOSED AT RUN with a sentence naming what + # is missing. What is refused here is a value somebody actually TYPED and got wrong — never + # the absence of one they have not typed yet. + url = _s(cfg.get("url"), 500).strip() + # ⚠ `{{` IS THE TEMPLATE FORM — `interpolate`'s syntax is `{{field_key}}`, NOT `{field}`. + # A url that STARTS with a placeholder is legitimate (the record supplies the host), so the + # shape test admits it; anything else must be an absolute http(s) address. + if url and not (url.startswith("http://") or url.startswith("https://") + or url.startswith("{{")): + return None, "a web address must start with http:// or https://" + selector = _s(cfg.get("selector"), 200).strip() + field = _s(cfg.get("field"), 80).strip() + try: + timeout = int(cfg.get("timeoutMs") or WEB_READ_TIMEOUT_MS) + except (TypeError, ValueError): + return None, "the web step timeout must be a whole number of milliseconds" + timeout = max(1000, min(timeout, WEB_READ_TIMEOUT_MAX_MS)) + out = {"url": url, "selector": selector, "field": field, + "attr": _s(cfg.get("attr"), 40).strip() or "text", + "all": bool(cfg.get("all")), "timeoutMs": timeout} + wait = _s(cfg.get("waitFor"), 200).strip() + if wait: + out["waitFor"] = wait + # ⭐ THE FOUR WRITE KINDS' OWN KEYS. Carried for EVERY web kind rather than switched on + # `kind`, because the cost of carrying an unused key is nothing and the cost of dropping a + # used one is a step that runs forever against a value the user typed and cannot see. + # `value` is the text `web_fill` types; `secret` masks it in the run log; `hint` is the + # human label `web_repair` follows when a selector has gone stale; `dryRun` rehearses a + # write without performing it (E measured the field reading back empty afterwards). + value = _s(cfg.get("value"), 500) + if value: + out["value"] = value + hint = _s(cfg.get("hint"), 200).strip() + if hint: + out["hint"] = hint + if cfg.get("secret"): + out["secret"] = True + if cfg.get("dryRun"): + out["dryRun"] = True + return out, None + return {}, None + + +def _branch_letter(i): + """0 -> 'A', 1 -> 'B', … (R8's lettering). Past 'Z' it doubles rather than wrapping, so a + label is never reused — `MAX_BRANCHES` makes that unreachable, and a silent collision is + worse than an ugly name.""" + return chr(65 + i % 26) * (1 + i // 26) + + +def group_branches(act): + """The branches of a group action, MIGRATING the pre-wave-24 `{cond, actions}` shape. + + ⭐ ONE READER for the fork's shape, because the alternative is what this module keeps paying + for: `clean_actions`, `walk_actions` and the runner would each need to know that a stored + group might carry either shape, and the one that forgot would silently skip a live + automation's actions. A definition cleaned since wave 24 always carries `branches`; one read + straight out of the store (the runner reads definitions, not payloads) may not. + """ + cfg = (act or {}).get("config") or {} + brs = cfg.get("branches") + if isinstance(brs, list) and brs: + return [b for b in brs if isinstance(b, dict)] + return [{"id": "b1", "label": "A", "cond": cfg.get("cond"), + "actions": cfg.get("actions") or []}] + + +def walk_actions(actions): + """Every action in the tree, depth-first, groups included. One walker, so "how many actions + does this flow have" and "which review stages exist" cannot answer differently. + + ⚠ WAVE 24: a group's children live under its BRANCHES now. This walker feeds the action + COUNT, `review_actions` (hence the board's stages) and `compose_sentence` — so a version + that still read `config.actions` would report a fork's contents as zero actions, hide every + review inside a branch from the board, and let `MAX_ACTIONS` be exceeded without noticing. + """ + for act in actions or []: + yield act + if act.get("kind") == "group": + for br in group_branches(act): + for kid in walk_actions(br.get("actions")): + yield kid + + +def interpolate(text, row): + """`{{field_key}}` → the record's value. Unknown keys resolve to EMPTY, deliberately: an + action that wrote the literal `{{status}}` into a cell because somebody typo'd the key would + put template syntax in front of a customer, and a blank is the visible failure.""" + if not isinstance(text, str) or "{{" not in text: + return text + def _sub(m): + v = (row or {}).get(m.group(1).strip()) + return "" if v is None else str(v) + return re.sub(r"\{\{([^}]{1,80})\}\}", _sub, text) + + +# ───────────────────────────────────────────────────────────────────────────────────────────── +# WAVE 23 · C4/C5/C6 — THE ACTION RUNNER. One record at a time, actions in declared order. +# +# ⛔ ONE COALESCED WRITE PER TABLE PER RUN. The runner accumulates patches in memory and commits +# them once at the end (the 256-commits/hr budget every writer in this module respects). A +# per-action `rt.update` would be correct and would also spend the tenant's whole commit budget +# on one busy flow. +# ⛔ A REVIEW SUSPENDS THAT RECORD AND NOTHING ELSE. When a card reaches a review action the +# runner stamps its stage and stops walking THAT record — later actions belong to the branch a +# human (or the AI) has not chosen yet. Other records keep going; a gate is not a global pause. +# ⛔ EVERY WRITE HERE IS MACHINE-ORIGIN and goes through this module's own writers, never the +# human doors — which is exactly what keeps A2(2)'s loop prevention structural: an action's write +# cannot fire an event trigger, its own or a sibling's. + +def _act_row_patch(patches, table, rid, values): + patches.setdefault(table, {}).setdefault(str(rid), {}).update(values) + + +def profile_field_key(table, named="", source=PROFILE_SOURCE_IG): + """C3/C4: WHICH COLUMN on this database carries the profile handle. '' when none does. + + Resolution order, and the missing branch is the important one: + 1. the column the action NAMES — the person picked it, so it is not a guess; + 2. otherwise the column carrying C3's flag `profile: {source: "instagram"}`; + 3. otherwise **NOTHING**, and the run says so. + + ⛔ THERE IS DELIBERATELY NO FALL-BACK TO `_auto_url_field`. That helper finds "the first `url` + column" and is exactly right for `field_instagram`, whose whole configuration was a URL column + — but here it would silently bind the enrich step to whatever URL column happens to be first, + on a database where nobody has said which column is a profile. The failure would be a run that + reports success having enriched from the wrong column, which is worse than one that refuses: + a wrong number is harder to notice than a missing one, and it would be written into the + permanent history as if it had been measured. + + ⭐ WAVE 30 · T08 — `source` PARAMETERISES STEP 2 ONLY, defaulting to Instagram so every + existing caller is byte-for-byte unchanged. Step 1 (the column the person NAMED) is already + network-agnostic: a named column is a decision, not a guess, and second-guessing it against a + flag would refuse a binding somebody made on purpose. + ⛔ AND THE TWO SOURCES MUST NOT BE MERGED INTO "any profile flag". `ut_tt_profile.handle` + carries `profile: {source: "tiktok"}` and `ut_ig_profile.handle` carries `"instagram"`; a + resolver that accepted either would let a TikTok enrich step bind to an Instagram column and + spend money asking a TikTok dataset about an Instagram handle — a wrong number written into a + permanent history, which is the exact failure the no-fallback rule above exists to prevent. + """ + fields = (table or {}).get("fields") or [] + want = str(named or "").strip() + if want: + return want if any(str(f.get("key")) == want for f in fields) else "" + for f in fields: + p = f.get("profile") + if isinstance(p, dict) and str(p.get("source") or "") == str(source): + return str(f.get("key") or "") + return "" + + +def _flow_table(defn): + """The database a flow's actions and ending operate on: the automation's own target, or the + table its event trigger watches when the flow has no target of its own. + + ⭐ WAVE 30 · T05 — the discovery arm covers BOTH kinds. It read `== "discover_instagram"`, so a + TikTok discovery with no stored `targetTable` fell to the trigger's table — which a corpus + search does not have — and resolved to `""`. `_presets_after_write` asks this function where to + spawn the preset columns, so an empty answer there is a database that never appears. + """ + cfg = (defn or {}).get("config") or {} + if defn.get("kind") in DISCOVERY_KINDS: + return cfg.get("targetTable") or discovery_facts(defn.get("kind"))[1] + return cfg.get("targetTable") or ((defn.get("trigger") or {}).get("table") or "") + + +#: ⭐ 2026-08-07 (owner ruling) — WHAT ONE ENRICH STEP MAY SPEND IN A SINGLE RUN. +#: `limit` is the number the panel offers; this is the ceiling the module enforces whatever the +#: panel sends, and it is DISCLOSED when it bites rather than applied as a silent `[:N]` +#: ([[no-unverifiable-aggregates]]). 100 profiles is ~4 minutes of vendor pacing on its own. +MAX_ENRICH_PER_RUN = 100 +DEFAULT_ENRICH_LIMIT = 25 +DEFAULT_ENRICH_COOLDOWN_DAYS = 30 +#: Which column the selection orders by when nobody has said. `first_found` newest-first is the +#: owner's own example ("top N enrichment sorted by date") and the useful default: it enriches what +#: discovery just found rather than re-walking the oldest leads in the book. +DEFAULT_ENRICH_SORT = "first_found" + +#: ⭐⭐ 2026-08-09 (DEBT D-103) — WHERE A RUN'S PER-RECORD REASONS TRAVEL. +#: A run's `counts` are integers and `_commit_run` drops everything non-numeric, so the sentence +#: a vendor gave us had nowhere to ride and was thrown away at three separate layers. This key is +#: a deliberate passenger IN the counts dict, popped by `run_now` before the counts are stored. +#: ⛔ A RESERVED KEY, NOT A COUNT: it must never be rendered as one, which is why it starts with +#: an underscore — `COUNT_LABELS` on the client is an allow-list and cannot pick it up by +#: accident, and `_commit_run`'s numeric filter is the second net under it. +RUN_NOTES_KEY = "_notes" +#: ⭐⭐ WAVE 28 / OWNER RULING R9 — `NOT_FOUND_RETRY_DAYS` IS RETIRED. A handle a VENDOR SAID DOES +#: NOT EXIST is now a TOMBSTONE: never auto-retried, at any interval. +#: ⛔ THE 30-DAY BACKOFF WAS THE DEFENSIBLE ANSWER AND IT WAS STILL WRONG, which is the sentence +#: worth keeping. Its argument was that a handle can be renamed back or a suspension lifted, so +#: the verdict should expire. But nothing about US changes on day 31 — the only new information +#: is a guess that the world moved — so the timer buys one paid vendor call per dead handle per +#: month, forever, on a row nobody is looking at, and reports it as "1 blocked". A tenant with a +#: hundred stale handles pays a hundred times a month to be told the same thing. +#: ⭐ WHAT RE-ARMS IT IS A HUMAN, and there are two doors: +#: 1. the KEY is `(platform, handle)` — so correcting a typo re-arms IMMEDIATELY, with no +#: wiring at all, because the corrected handle simply is not the one we recorded; and +#: 2. `clear_gone()` — called when the profile CELL is written, so re-typing the SAME handle +#: (a person saying "try it again") also re-arms. +#: ⚠ The run keeps NAMING the skipped handles every time, not only on the run that discovered +#: them: a tombstone the owner cannot see is just a disappearance. +#: A queued profile snapshot the vendor never finishes is dropped after this, with a note. An +#: unbounded pending list is the forever-loop this whole change exists to remove, wearing the +#: opposite mask ([[gate-answers-the-wrong-question]]). +PENDING_PROFILE_MAX_HOURS = 24 + + +def _order_key(value): + """Order one CELL. Numbers numerically, everything else as text. + + ⚠ Cells are stored as STRINGS, so `"20872"` and `"9"` compare in the wrong order as text — + which on a `followers` sort is not a cosmetic wrong order, it is the wrong 25 accounts getting + paid for. Numbers are tried first and text is the fallback, never the other way round. + ⚠ Dates need no special case: R3 made these columns `date` and they store `YYYY-MM-DD`, where + lexicographic order IS chronological order. If that format ever changes, this comment is the + thing that was relied on. + """ + s = str(value if value is not None else "").strip() + try: + return (0, float(s.replace(",", "")), "") + except ValueError: + return (1, 0.0, s.lower()) + + +def _days_since(day, today=None): + """Whole days between a `YYYY-MM-DD` stamp and today, or None when it cannot be read. + + ⛔ None means UNKNOWN and every caller must treat it as "not measured", never as 0 or as a + large number — the cooldown below reads an unreadable stamp as "never enriched", which spends + money rather than skipping. That is the right way round: a skipped record is invisible, and a + stamp we cannot parse is our bug, not a reason to silently stop enriching somebody's data. + """ + import datetime as _dt + s = str(day or "").strip()[:10] + if not s: + return None + try: + d = _dt.date.fromisoformat(s) + except ValueError: + return None + return ((today or _dt.date.today()) - d).days + + +def _hours_since(stamp): + """Whole hours since a full ISO stamp, or None when it cannot be read. + + ⚠ ISO, NOT `YYYY-MM-DD` — `_days_since` above answers a question in DAYS about a `date` + column and truncates to 10 characters. A pending snapshot's age is measured in hours and its + stamp carries a time, so reusing that function would read every entry as "queued at + midnight". Same reason its None means UNKNOWN: an unreadable stamp must not age a paid, + outstanding snapshot out of the queue. + """ + import datetime as _dt + s = str(stamp or "").strip() + if not s: + return None + try: + t = _dt.datetime.fromisoformat(s.replace("Z", "+00:00")) + except ValueError: + return None + now = _dt.datetime.now(_dt.timezone.utc) + if t.tzinfo is None: + t = t.replace(tzinfo=_dt.timezone.utc) + return max(0, int((now - t).total_seconds() // 3600)) + + +def _gone_key(row, handle, platform=None): + """The identity a "this account does not exist" verdict is remembered under. + + `(platform, handle)` — wave 26 R4's dedup key, not the handle alone, because `@x` on + Instagram and `@x` on TikTok are two accounts. Blank platform means Instagram, which is what + every row on a preset profile table is and what `preset_cells` stamps. + + ⭐ WAVE 30 · T08 — `platform` OVERRIDES THE ROW, and the override is what makes the key honest + on a table the row cannot speak for. The fallback above reads the row's own `platform` cell, + which the TikTok runner writes — but only AFTER the first successful pull. A hand-typed row on + an arbitrary database that a TikTok step is bound to by NAME (`profile_field_key` step 1, + which is deliberately network-blind) carries no such cell, so a TikTok "this account does not + exist" verdict would have been filed under `instagram:`. A tombstone has no expiry + (W28/R9), so that would permanently suppress an Instagram read of a DIFFERENT person's + account with the same handle — silently, and without ever spending the money that would have + shown it was wrong. The caller knows which network it asked; the row does not. + """ + plat = str(platform or (row or {}).get("platform") or PLATFORM_INSTAGRAM).strip().lower() + return f"{plat}:{str(handle or '').strip().lstrip('@').lower()}" + + +def clear_gone(rt, table_key, handle, row=None): + """R9's re-arm door: forget the "this account does not exist" verdict for ONE handle. + + Returns the number of automations whose memory was changed — 0 is the ordinary answer and is + not an error, because most cell edits are not on a dead handle. + + ⭐⭐ WHY THIS IS A PUBLIC FUNCTION IN THE ENGINE RATHER THAN A LOOKUP IN THE ROW WRITER. + The verdict lives on the AUTOMATION (`state.enrichNotFound`), not on the row — it has to, + because it is a fact about what a run learned and paid for. But the thing that re-arms it is a + ROW event, and the row writer must not need to know the shape of an automation's state to + trigger it. So the seam is one call with the three things the writer already has, and every + walk of `all_definitions` stays on this side of the fence. + ⚠ IT MATCHES THE SAME WAY THE SKIP DOES. `_gone_key` is the one implementation of "which + handle is this", so a verdict can never be recorded under a key this cannot find + ([[one-evaluator-per-question]]). + + ⛔ WITHOUT ITS CALLER THIS IS INERT, AND THAT IS RECORDED RATHER THAN ASSUMED. R9's first + door — correcting a typo — works with no wiring at all, because the key IS the handle and a + different handle is simply not the one we recorded. This second door only opens when the row + write path calls it, which lives in `routes_tables.patch_row` (another session's fence). + Until that line lands, re-typing the SAME dead handle stays skipped + ([[flag-shipped-without-its-writer]] — named on purpose, so it is not discovered later). + """ + key = _gone_key(row or {}, handle) + if not str(handle or "").strip(): + return 0 + changed = 0 + for auto_id, defn in (all_definitions(rt) or {}).items(): + if str((defn.get("config") or {}).get("targetTable") or "") != str(table_key): + continue + known = dict(((defn.get("state") or {}).get("enrichNotFound")) or {}) + if key not in known: + continue + known.pop(key, None) + # ⚠ `or None` — an empty dict must clear the key rather than store `{}`, which is what + # every other state writer here does and what keeps a definition from growing a graveyard + # of empty maps. + set_state(rt, str(auto_id), {"enrichNotFound": known or None}) + changed += 1 + return changed + + +def _prime_enrich_batch(chosen, rows, profile_key, table, enrich, prefetch, + step=_no_step, log=print): + """Buy the selection's profiles in ONE vendor call per chunk. Returns how many resolved. + + ⭐⭐ 2026-08-09 — THE FIX FOR "WHY IS THIS ONE RECORD PER CALL". `bd_scrape` has always taken a + LIST of URLs and its own docstring names the failure mode ("…turning a 20-profile run into an + hour"); the only profile caller passed a single-element list from inside the per-record walk. + MEASURED on nurilab: a 25-record walk still running at 67 minutes, 25 billed snapshots, 25 + vendor emails. This runs once, where the whole chosen set and the row bodies are both in hand. + + ⛔ IT IS A FAST PATH AND MUST STAY ONE. Everything it fails to resolve is simply absent from + `prefetch`, and the per-record rung then behaves exactly as it does today — including its + corpus fallback and its Apify second opinion. A batch that cannot make the run WRONG is a + batch that can be shipped on a paid path; that is why the vendor call is wrapped and a failure + returns 0 instead of raising. + + ⛔ A DEFERRED CHUNK IS FANNED OUT PER DESTINATION, NOT PER SNAPSHOT. One snapshot id now + serves many records, so each pending task keeps its OWN `influencer`/`table`/`rowId` and they + share the id — `_pending_profile_tasks` is explicit that a profile cannot be resolved from the + handle alone ("two databases may both hold `@x`"). The handle is ALSO written into `prefetch` + behind `DEFERRED_MARK` so the walk does not buy the same snapshot a second time. + """ + want, dests, handles = [], {}, [] + for rid in (chosen or []): + row = rows.get(str(rid)) or {} + h = str(row.get(profile_key, "") or "").strip().lstrip("@").lower() + if not h or h in prefetch: + continue + want.append((h, str(rid))) + if h not in dests: + handles.append(h) + dests.setdefault(h, []).append(str(rid)) + if not handles: + return 0 + step(f"Reading {len(handles)} profile{'' if len(handles) == 1 else 's'} from the source") + deferrals = [] + try: + nodes, note = bd_profiles_batch(handles, deferred=deferrals) + except Exception as exc: # noqa: BLE001 — never fatal, see docstring + log(f"[aios-auto] profile batch failed, falling back to per-record reads: " + f"{type(exc).__name__}: {exc}") + return 0 + prefetch.update(nodes) + if note: + log(f"[aios-auto] profile batch note: {_s(note, 300)}") + for d in deferrals: + sid = str(d.get("snapshotId") or "") + if not sid: + continue + for u in (d.get("urls") or []): + h = str(ig_handle(str(u)) or "").strip().lstrip("@").lower() + if not h or h in nodes: + continue + prefetch[h] = {DEFERRED_MARK: sid} + for rid in dests.get(h, []): + enrich["pendingProfiles"].append( + {"snapshotId": sid, "datasetId": str(d.get("datasetId") or ""), + "urls": [str(u)], "kind": "profile", "influencer": h, + "table": table, "rowId": rid, "requestedAt": _iso()}) + return len(nodes) + + +def enrich_selection(rt, table_key, cfg, profile_key, today=None, gone=None, platform=None): + """⭐ 2026-08-07 (owner ruling) — WHICH records this enrich step spends on, in order. + + Returns `(ordered_row_ids, note)`. The note is the honest account of what the selection did + and is surfaced in the run summary; `""` means there is nothing worth saying. + + ⛔ **`limit` IS A QUOTA OF WORK DONE, NOT A WINDOW OF ROWS EXAMINED**, and that is the owner's + ruling in as many words: *"if a user choose to enrich 30 and from that sorted list of 30, 20 is + enriched last 30 days, then it goes to next list like this"*. So the walk CONTINUES past every + skipped record until 30 have actually been enriched or the list runs out. The naive reading — + take the top 30, then filter — would have billed for 10 and reported success, and the number in + the box would have meant something different every run depending on how much of the top of the + list happened to be fresh. A quota is predictable; a filtered window is not. + + The order of operations, each step narrowing the one above: + 1. the optional saved VIEW — resolved through `view_filter`, the SAME resolver `enters_view` + and `seed_rows` use. A view that has been deleted is a PROBLEM, never an empty tree: an + empty tree matches everything, so degrading would turn "enrich my shortlist" into "enrich + the entire database", at vendor prices. + 2. the SORT — the owner's "top N sorted by date", blanks always last in both directions + (a blank is unknown, not smallest). + 3. the COOLDOWN — skip anything enriched within N days, when the toggle is on. + 4. the QUOTA — stop at `limit`, itself clamped to `MAX_ENRICH_PER_RUN`. + + ⚠ A record with a BLANK handle is skipped and never counted against the quota — there is + nothing to enrich and it must not consume a slot somebody paid for. + """ + t = ut_get(rt, str(table_key or "")) + if t is None: + return [], f"{table_key!r} is not a database in this workspace" + rows = dict(t.get("rows") or {}) + notes = [] + + view_id = str(cfg.get("fromView") or "").strip() + if view_id: + tree, fields, problem = view_filter(rt, table_key, view_id) + if problem: + # ⛔ REFUSE, do not widen. See the docstring — this is the branch where a quiet + # fallback costs real money. + return [], f"{ENRICH_VIEW_UNREADABLE}. {problem}" + # ⭐⭐ 2026-08-07 (owner report) — `filter_eval.matches`, NOT `lane_match`. THIS LINE WAS + # A THIRD IMPLEMENTATION OF "does this row match", AND IT SPOKE THE WRONG LANGUAGE. + # + # `view_filter` returns a SAVED VIEW's filter tree — `{"nodes": [{colId, op, value}], + # "conj"}` in the GRID's dialect, whose operators are `contains/eq/neq/gt/gte/lt/lte/ + # isEmpty/isNotEmpty/between/within`. `lane_match` reads an AUTOMATION LANE condition — + # `{field, op, value}` with `=/!=/>/includes/is_empty/…` — and dispatches on + # `COND_GROUP_KEYS`. Handed a view tree it found no group key, read `raw.get("field")`, + # got `""`, and answered **False for every row**: MEASURED on the owner's own automation, + # a view matching exactly one record selected NOTHING, reported NO error, and the run + # committed `ok`. *"I just chose the View 'Enrichment test' where there is only 1 manual + # record... how come it says 51 records walked?"* + # + # ⛔ AND THE RIGHT EVALUATOR WAS ALREADY IN THE FILE. `_row_gate`'s `enters_view` branch + # resolves the SAME tree through `harness.filter_eval.matches(tree, row, fields)` and has + # always been correct. `_seed_event_state`'s own docstring states the law this line broke: + # *"Two implementations of 'does this row match' is how a seed disagrees with the edge it + # is supposed to arm."* There were three. Now there are two callers of one function. + # ⚠ `fields` is no longer discarded — `matches` needs the column TYPES to compare a date + # as a date and a number as a number, which is the half `lane_match` could not have had. + import harness.filter_eval as filter_eval + rows = {rid: r for rid, r in rows.items() if filter_eval.matches(tree, r, fields)} + + sort_field = str(cfg.get("sortField") or DEFAULT_ENRICH_SORT).strip() or DEFAULT_ENRICH_SORT + newest_first = str(cfg.get("sortDir") or "desc").strip().lower() != "asc" + # ⭐⭐ 2026-08-07 (owner ruling) — A MISSING DATE MEANS **NOW**, NOT "UNKNOWN". + # + # Owner: *"treat a missing first found as now, and manual entry should go first, instead of + # treated as last. I want to see my inayma manual entry works with automation enrichment."* + # And they are right about the semantics, not just the preference: on these tables the ONLY + # rows without a `first_found` are ones a PERSON typed, because the discovery runner stamps it + # on every row it writes. So a blank is not missing data — it is a row that was first found + # today, by the person sitting in front of it, and the one they most want enriched. + # + # The old rule sorted blanks LAST in both directions, so a hand-typed handle sat at position + # 41 of 41 and fell outside a limit of 25 — the row the whole feature exists for was the one + # it never reached. + # + # ⛔ DATE COLUMNS ONLY, and the narrowing is the honest half. "Blank means now" is a fact about + # a TIMESTAMP; a blank `followers` is not "the most followers", it is genuinely unknown, and + # treating it as the maximum would spend the budget on the rows we know least about. So a + # non-date sort keeps the old rule: unknown sorts last, in both directions. + # + # ⚠ NOTE THIS FALLS OUT OF THE KEY RATHER THAN BEING A SECOND PASS — `(0, value)` for a real + # date and `(1, "")` for a blank, with `reverse` doing the rest. Newest-first puts blanks at + # the front (they are "now"); oldest-first puts them at the back (they are still "now"). One + # rule, both directions, no branch that can disagree with itself. + ftype = str(((next((f for f in (t.get("fields") or []) + if str(f.get("key")) == sort_field), None)) or {}).get("type") or "") + if ftype == "date": + ordered = sorted(rows.items(), + key=lambda x: (0, str(x[1].get(sort_field) or "").strip()) + if str(x[1].get(sort_field) or "").strip() else (1, ""), + reverse=newest_first) + else: + have = [(rid, r) for rid, r in rows.items() if str(r.get(sort_field) or "").strip()] + blank = [(rid, r) for rid, r in rows.items() if not str(r.get(sort_field) or "").strip()] + have.sort(key=lambda x: _order_key(x[1].get(sort_field)), reverse=newest_first) + blank.sort(key=lambda x: _rid_num(x[0])) + ordered = have + blank + + try: + quota = int(cfg.get("limit") or DEFAULT_ENRICH_LIMIT) + except (TypeError, ValueError): + quota = DEFAULT_ENRICH_LIMIT + quota = max(1, min(quota, MAX_ENRICH_PER_RUN)) + if cfg.get("limit") and quota != int(cfg.get("limit") or 0): + notes.append(f"the limit was capped at {MAX_ENRICH_PER_RUN} for one run") + + cooling = bool(cfg.get("skipRecent")) + try: + days = int(cfg.get("skipRecentDays") or DEFAULT_ENRICH_COOLDOWN_DAYS) + except (TypeError, ValueError): + days = DEFAULT_ENRICH_COOLDOWN_DAYS + days = max(1, days) + + picked, cooled, blank_handle = [], 0, 0 + # ⭐⭐ 2026-08-09 — HANDLES A VENDOR HAS ALREADY SAID DO NOT EXIST. + # + # ⛔ THE DEFECT THIS CLOSES IS STRUCTURAL, AND IT IS THE WORD "AGAIN" IN THE OWNER'S REPORT. + # A blocked read writes NO cells, so `enriched_at` stays unset, so `_days_since(None)` is + # None, so the cooldown above can never exclude the row — while `Followers is empty` keeps it + # in the Pending cohort by construction. MEASURED on nurilab: one dead handle, re-bought at + # 06:00 on three consecutive days, reported each time as an opaque "1 blocked". No vendor fix + # removes that loop; only a memory of the verdict does. + # + # ⚠ THEY ARE **NAMED**, NOT SILENTLY DROPPED. The whole point is that the owner can act — the + # note goes into the run every single time, not only on the run that discovered it, because a + # run that quietly reports "0 records walked" tomorrow puts them straight back at "wtf". + skipped_gone = [] + for rid, r in ordered: + if len(picked) >= quota: + break + raw_handle = str(r.get(profile_key) or "").strip() + if not raw_handle: + blank_handle += 1 + continue + # ⛔ R9 — NO EXPIRY. There is deliberately no date arithmetic here any more: a verdict is + # a verdict until a human edits the cell. An `at` stamp is still STORED (it is what the + # owner reads to know when we last paid to be told this), it is simply not a clock. + # ⚠ WAVE 30 · T08 — the SAME `platform` the runner will file a new verdict under, so the + # skip and the write cannot disagree about which account a tombstone belongs to. + if isinstance((gone or {}).get(_gone_key(r, raw_handle, platform)), dict): + skipped_gone.append(raw_handle) + continue + if cooling: + since = _days_since(r.get("enriched_at"), today=today) + if since is not None and since < days: + cooled += 1 + continue + picked.append(str(rid)) + + # ⛔ THE HONEST ACCOUNT. "10 enriched" reads as success whether the quota was 10 or 30, so the + # run says when it could NOT fill the quota and why — the same disclosure rule `cap_note` and + # `run_plain` follow. Silence here would make a shrinking selection invisible. + if cooled: + notes.append(f"{cooled} skipped as enriched in the last {days} days") + if skipped_gone: + shown = ", ".join(f"@{h}" for h in skipped_gone[:5]) + # ⚠ THE SENTENCE IS THE FEATURE. It must name the handles AND the two things a person can + # do, because nothing else will ever retry them — under R9 this note is the only path + # back from a tombstone, so a vaguer version would strand the row permanently. + notes.append(f"{len(skipped_gone)} skipped because Instagram has no such account " + f"({shown}{', …' if len(skipped_gone) > 5 else ''}). Delete the row or " + f"correct the handle; they are not retried automatically") + if blank_handle: + notes.append(f"{blank_handle} skipped with no handle") + if len(picked) < quota and (cooled or blank_handle or skipped_gone or ordered): + notes.append(f"{len(picked)} of the {quota} asked for. The list ran out") + return picked, "; ".join(notes) + + +def _has_action(actions, kind): + """Does this flow contain `kind` ANYWHERE, forks included? + + ⛔ FORKS ARE THE WHOLE REASON THIS IS A FUNCTION. A group's children live under + `config.branches[].actions` (C-FORK), so a flat scan of the top level answers False for an + enrich step somebody put inside an If — and the caller would then skip a schema top-up the + run genuinely needs. Same walk `mapTree` does on the client, and the same trap wave 24 + recorded when four hand-rolled walks all forgot to descend. + """ + for a in (actions or []): + if not isinstance(a, dict): + continue + if a.get("kind") == kind: + return True + for br in ((a.get("config") or {}).get("branches") or []): + if _has_action((br or {}).get("actions") or [], kind): + return True + return False + + +def _actions_of_kind(actions, kind): + """Every action of `kind`, including actions nested inside If branches.""" + out = [] + for action in actions or []: + if not isinstance(action, dict): + continue + if action.get("kind") == kind: + out.append(action) + for branch in ((action.get("config") or {}).get("branches") or []): + out.extend(_actions_of_kind((branch or {}).get("actions") or [], kind)) + return out + + +def _web_agent(): + """C5's seam, resolved LAZILY — `web_agent.run_step(step, ctx) -> (dict|None, str)`. + + ⚠ IMPORTED INSIDE THE CALL, like every `connectors_tt` site in this module, and here it also + buys a failure mode worth having: if `web_agent.py` is ever missing from a deployment, the + engine still imports and every OTHER action still runs — the web step alone reports a sentence. + A module-level import would turn one absent file into a dead automation module. + + ⛔ AND THE ABSENCE IS REPORTED, NEVER SWALLOWED (R6's second sentence). The shim below answers + the same `(None, sentence)` contract the real seam does, so the caller's `if why:` branch is + the only branch there has ever been. + """ + try: + import web_agent + return web_agent + except Exception as exc: # noqa: BLE001 + class _Absent: + @staticmethod + def run_step(_step, _ctx=None): + return None, ("The web-browsing agent is not available in this deployment " + f"({type(exc).__name__}). Nothing was read.") + return _Absent + + +def apply_actions(rt, defn, table_key, row_ids, username="automation", log=print, + step=_no_step): + """Walk `flow.actions` for each of `row_ids`, then apply the ENDING. Returns the run counts. + + ⚠ The ending runs even when there are NO actions, and that is not a detail: a discovery + automation's review stage comes from `stages_for`, not from actions, + so an early return on an empty action list would have silently disabled the owner's loop + feature on the flagship flow — the one kind most likely to want `auto_reset`. Caught in + review; the tests all happened to pass a non-empty action list, which is why it was green. + """ + defn, _retired = _without_retired_board(defn) + flow = (defn or {}).get("flow") or {} + actions = flow.get("actions") or [] + # ⛔ THE PINNED STEP IS A PICTURE OF THE ENGINE'S OWN WRITE, NOT A SECOND ONE. + # + # MEASURED 2026-08-06: a discovery run wrote its candidates through `ut_write_rows` (the + # "Save results" node) and THEN walked the flow over the rows it had just written — where + # the seeded `create_record` inserted every one of them AGAIN. Two rows became four, on + # every run, silently, since wave 24 seeded that action. Making step 1 permanent (owner + # ruling, same day) would have made the duplication permanent and unavoidable with it. + # + # ⚠ THE CARD STAYS. It is what the owner asked for and it is honest — "this search puts what + # it finds in a database" is exactly what the engine does. What it must not do is do it + # twice. Skipped HERE rather than not-seeded, so the builder still shows the step and the + # server still refuses to let it be removed. + if ig_action_pinned(defn, 0) and actions: + actions = actions[1:] + # ⭐ `webRead` / `webBlocked` JOIN HERE (W31-T38, C5): declared up front like every other + # counter, so a run that read nothing and a run with no web step are DIFFERENT numbers. R6's + # second sentence is a reporting rule, and a counter that only exists once it is non-zero + # cannot report a refusal. + counts = {"actionsRun": 0, "updated": 0, "created": 0, "found": 0, + "webRead": 0, "webBlocked": 0} + #: W31-T38 — the once-per-run markers the web arm uses, so an unconfigured step says so ONCE + #: rather than once per record. Same shape as the enrich accumulators' `notes`. + web_notes = [] + # ⭐ C4 — THE ENRICH ACCUMULATOR. Every append row the enrich action produces is collected + # across the WHOLE walk and written once at the end, for the reason the module header states + # and `run_field_instagram` learned the hard way: `upsert_rows` rebuilds the row dict on + # entry, so calling it per record is O(existing) per record — survivable at 5,000 rows and an + # automation that never finishes at `MAX_UT_IG_ROWS`. One flush, three upserts. + enrich = {"snaps": [], "posts": [], "psnaps": [], "comments": [], "pending": [], + # ⭐ 2026-08-09 — deferred PROFILE snapshots (paid, still building at the vendor) + # and handles a vendor has said do not exist. Both are collected across the whole + # walk and written once, for the same reason every other list here is. + "pendingProfiles": [], "gone": {}, + "profiles": 0, "ok": 0, "blocked": 0, "notes": [], "dry": False} + # ⭐⭐ WAVE 30 · T08 — THE TIKTOK ACCUMULATOR IS ITS OWN, and the separation is the design + # decision rather than a convenience. `_enrich_flush` is not network-agnostic: it calls + # `ensure_ig_graph`, addresses the four `IG_*_TABLE` constants and writes through to the + # platform Instagram master (`ig_master.append_run`). Routing TikTok rows through it would + # append a TikTok creator's followers into Instagram's pooled history, which the metric + # FIELDS read (W29-T34) — a wrong number in a permanent series, i.e. the same failure class + # as `preset_cells`' unconditional `PLATFORM_INSTAGRAM` stamp that `tt_preset_cells` exists + # to avoid. + # ⚠ AND THE KEYS IT FLUSHES ARE PREFIXED `tt…` FOR A MEASURED REASON: `apply_actions` merges + # both flushes into ONE `counts` dict, so a shared `enriched`/`enrichBlocked` key would let a + # flow carrying BOTH an Instagram and a TikTok step report one network's numbers under the + # other's name, last writer winning, with no error anywhere. + # ⭐ W30 · D-156 — `pending` is the TikTok twin of Instagram's, and it is a RUN-level list for + # the same reason theirs is: a media snapshot belongs to a HANDLE, not to a row, so nothing + # about it needs the per-record sink that `pendingProfiles` needs. + tt = {"snaps": [], "posts": [], "psnaps": [], "comments": [], "pending": [], + "profiles": 0, "ok": 0, "blocked": 0, "notes": [], "dry": False} + # The verdicts this automation already holds, read ONCE — `enrich_selection` consults them + # per record and re-reading the definition per row would be a store read per record. + known_gone = dict(((defn or {}).get("state") or {}).get("enrichNotFound") or {}) + # ⭐ 2026-08-07 — THE SELECTION, RESOLVED ONCE PER ACTION AND NOT ONCE PER RECORD. + # `enrich_selection` sorts and scans the whole table; doing that inside the per-record walk + # would be O(rows²) and, worse, would re-answer "which 25 records" every time a record walked + # through — so the quota could never be honoured. Keyed by ACTION id, because two enrich steps + # in one flow are two independent budgets. + enrich_plan = {} + # ⭐⭐ 2026-08-09 — `{handle: node}` FOR THE WHOLE SELECTION, BOUGHT IN ONE CALL PER CHUNK. + # The vendor read used to happen inside the per-record walk, one URL per `/v3/scrape` and a + # `PACE_SECONDS` floor between records, so a 25-record walk was 25 billed snapshots and was + # MEASURED still running at 67 minutes. `bd_scrape` always accepted a list; nothing ever + # handed it one. Run-scoped rather than per-action: two enrich steps over the same table are + # two budgets (`enrich_plan`) but the same profiles, and a handle already bought is not worth + # buying twice. + enrich_prefetch = {} + if not table_key: + return counts + table = str(table_key) + # ⭐⭐ 2026-08-07 (owner report) — THE PRESET COLUMNS MUST EXIST BEFORE THE CELLS ARE WRITTEN. + # + # ⛔ MEASURED on the owner's own row: a successful enrich reported `enriched: 1` and filled + # NINE cells out of a pull that carried far more, because `_act_row_patch` can only write a + # cell whose COLUMN is declared — `user_tables` filters an unknown key on every write door. + # So every preset the target table did not already happen to have was silently dropped, and + # the run still said ok. Owner: *"UNTIL I SEE THAT ALL OF INAYMA'S INSTAGRAM FIELDS GET + # FILLED"* — this is the half that was swallowing them. + # + # ⚠ WHY IT WAS MISSING RATHER THAN BROKEN: the columns are spawned by `_presets_after_write`, + # which runs when a **Create record** action is SAVED (W25/R10). A `plain` automation whose + # only step is `enrich_instagram` never saves one, so nothing had ever declared them — the + # feature worked on discovery tables purely because discovery creates them for its own reasons. + # + # ⚠ ONCE PER RUN, not once per record, and only when an enrich step is actually present: + # `ut_ensure` MERGES by key and returns without a write when nothing is new, so the steady + # state is one dict comparison. Guarded by the same walk that would use it, so a flow with no + # enrich step never touches the schema of the database it walks. + # ⛔ ONLY ON A TABLE THAT IS ALREADY BOUND, and the narrowing is the careful half. The preset + # set declares `handle` with `pinned` AND `profile: {source: 'instagram'}` — so topping up an + # ARBITRARY database would (a) reshape someone's schema with 36 columns because they pointed a + # step at it, and (b) add a SECOND profile column to a table that already flagged a different + # one, which `user_tables` forbids at both write doors. A table with no profile column is an + # UNBOUND enrich: it must stay unbound and say so, which is a different defect (D-79) with its + # own honest refusal, not something to paper over by inventing the binding. + _tbl_now = ut_get(rt, table) or {} + _enrich_actions = _actions_of_kind(actions, "enrich_instagram") + _bound = next((f for f in (_tbl_now.get("fields") or []) + if isinstance(f.get("profile"), dict)), None) + if _bound is None: + _named = next((str((a.get("config") or {}).get("profileField") or "").strip() + for a in _enrich_actions + if str((a.get("config") or {}).get("profileField") or "").strip()), "") + _bound = next((f for f in (_tbl_now.get("fields") or []) + if str(f.get("key") or "") == _named), None) + if _bound and _enrich_actions: + # ⚠ AND THE DECLARATIONS ARE STRIPPED FROM ANYTHING NEW. The binding already exists — + # `_bound` is it — so a preset arriving now must carry DATA, never a second identity: a + # table whose profile column is `ig_handle` would otherwise gain a rival `handle`. + topup = [({k: v for k, v in f.items() if k not in ("profile", "pinned")} + if f.get("key") != _bound.get("key") else dict(f)) + for f in PRESET_PROFILE_FIELDS] + try: + if any(not bool((a.get("config") or {}).get("dryRun")) for a in _enrich_actions): + ensure_ig_graph(rt, username, str(defn.get("id") or ""), + profile_table=table, profile_field=str(_bound.get("key") or "")) + else: + # Dry run keeps its original no-create contract; only the already-established + # Profile table is topped up, and no canonical related database is spawned. + ut_ensure(rt, _tbl_now.get("label") or table, topup, username, key=table, + flow_tag=str(defn.get("id") or ""), lock_fields=True) + except Exception as exc: # noqa: BLE001 + # A schema top-up that cannot run must not stop the enrichment: the cells that DO + # have columns still land, which is strictly better than the pull being thrown away. + log(f"[aios-auto] preset top-up on {table} failed: {type(exc).__name__}: {exc}") + # ⭐⭐ WAVE 30 · T08 — THE SAME TOP-UP FOR TIKTOK, AND THE SAME NARROWING: only on a table that + # is ALREADY BOUND. Separate from the block above because every constant in it is Instagram's + # (`PRESET_PROFILE_FIELDS`, `ensure_ig_graph`) and because the binding is resolved by a + # DIFFERENT question — `profile_field_key(..., source=PROFILE_SOURCE_TT)`, so a TikTok step + # cannot adopt an Instagram column and go on to ask a TikTok dataset about an Instagram handle. + # ⚠ It runs on a dry run too, exactly as the Instagram branch does: the target's own columns + # are topped up, and the related append database is NOT spawned (that is the flush's job, and + # it returns before writing anything on a dry run). + _tt_actions = _actions_of_kind(actions, "enrich_tiktok") + if _tt_actions: + _tt_named = next((str((a.get("config") or {}).get("profileField") or "").strip() + for a in _tt_actions + if str((a.get("config") or {}).get("profileField") or "").strip()), "") + _tt_bound = profile_field_key(_tbl_now, _tt_named, source=PROFILE_SOURCE_TT) + if _tt_bound: + try: + ut_ensure(rt, _tbl_now.get("label") or table, + _tt_profile_schema_for(_tt_bound), username, key=table, + flow_tag=str(defn.get("id") or ""), lock_fields=True) + except Exception as exc: # noqa: BLE001 + log(f"[aios-auto] TikTok preset top-up on {table} failed: " + f"{type(exc).__name__}: {exc}") + rows = dict((ut_get(rt, table) or {}).get("rows") or {}) + if not rows: + return counts + # ⛔ NOTHING HERE TRACKS WHERE A RECORD "GOT TO" ANY MORE (wave 27 item 12, ruling R3). The + # walk used to keep a `reached` map and stamp each record with the furthest action it made — + # a board column's worth of bookkeeping written into the tenant's own table on every run. + # The board is deleted, so the stamp had no reader, and a machine column nobody reads is a + # cell the customer has to look at and a store commit we pay for on every scheduled run. + # The RUN LOG is the operational record of what happened now, and it is the only one. + patches, creates = {}, {} + + #: ⭐⭐ W33-T58 · D-191 — this record's web results, keyed by ACTION ID, and the run's job + #: count. `web_done` is cleared per record (a batch is built from one row's interpolated + #: values and means nothing for the next one); `web_jobs` counts JOBS across the whole run, + #: which is what `MAX_WEB_JOBS_PER_RUN` has always been trying to bound. + #: ⚠ A key present with `None` means "this step was in a batch that refused" — distinct from + #: absent, which means "no batch has covered it yet". Collapsing the two would re-run the + #: whole batch once per step of it, which is the defect this fixes, inverted. + web_done: dict = {} + web_jobs = [0] + + def _web_step_dict(act, row): + """One action + one record → the step dict the seam takes. Interpolated HERE, as before. + + ⚠ `interpolate` on the CALLER's side, exactly as the update/create arms do it, so + `{{Field}}` works in a url, a selector or a typed value. The seam takes a plain dict and + does not know about rows. + """ + cfg = act.get("config") or {} + wait = cfg.get("waitFor") + step = {"kind": act.get("kind"), "id": str(act.get("id") or ""), + "url": interpolate(str(cfg.get("url") or ""), row), + "selector": interpolate(str(cfg.get("selector") or ""), row), + "attr": str(cfg.get("attr") or "text"), + "all": bool(cfg.get("all")), + "waitFor": interpolate(str(wait), row) if wait else None, + "timeoutMs": int(cfg.get("timeoutMs") or 20000)} + # ⚠ ADDED ONLY WHEN PRESENT: the seam distinguishes a key that is absent from one that is + # empty, and an empty `value` on a `web_read` would be a typed blank. + if cfg.get("value"): + step["value"] = interpolate(str(cfg.get("value")), row) + if cfg.get("hint"): + step["hint"] = interpolate(str(cfg.get("hint")), row) + if cfg.get("secret"): + step["secret"] = True + if cfg.get("dryRun"): + step["dryRun"] = True + return step + + def _run_web_batch(acts, start, row, rid): + """Run the longest safe run of consecutive web steps from `acts[start]` in ONE job. + + ⛔ **CONSECUTIVE, AND ONLY WHILE NOTHING IN THE BATCH DEPENDS ON THE BATCH.** `run_plan` + sends every step to one browser at once, so a step whose url/selector/value interpolates a + column an EARLIER step in the same batch writes would be interpolated against the value + that column had BEFORE the batch ran. That is a wrong answer rather than a slow one, so the + batch is cut immediately before any such step and the remainder becomes the next batch. + The single-step case is then exactly the old behaviour, which is what makes this safe to + land on a live flow. + ⚠ A step that is disabled, filtered out by its `when`, or unconfigured ENDS the batch + rather than being skipped inside it: each of those is a reason this record does not run + that step, and the loop's own arms already report them one at a time with their own + sentences. Ending here keeps exactly one place that decides what a blocked step says. + """ + batch, produced = [], set() + for act in acts[start:]: + kind = act.get("kind") + if kind not in WEB_KINDS or not act.get("enabled", True): + break + if not lane_match(act.get("when"), row): + break + cfg = act.get("config") or {} + if _web_missing(kind, cfg): + break + # The dependency cut. `interpolate` reads `{{Name}}`; a step naming a column an + # earlier step in THIS batch writes has to wait for the next job. + refs = " ".join(str(cfg.get(k) or "") for k in ("url", "selector", "value", "hint")) + if any(("{{" + f) in refs or ("{{ " + f) in refs for f in produced): + break + batch.append(act) + if str(cfg.get("field") or ""): + produced.add(str(cfg.get("field"))) + if len(batch) >= MAX_WEB_STEPS_PER_JOB: + break + if not batch: + return + steps = [_web_step_dict(a, row) for a in batch] + # ⛔ THE SEAM REFUSES A JOURNEY WHOSE FIRST STEP CARRIES NO ADDRESS — there is no page to + # act on yet — and a refusal is for the WHOLE plan. Batching a url-less first step would + # therefore take its followers down with it, where one-job-per-step only lost that step. + # A batch that cannot start is cut to one, which is exactly the old behaviour. + if not str(steps[0].get("url") or "").strip(): + batch, steps = batch[:1], steps[:1] + web_jobs[0] += 1 + + def _block(a, why_one): + web_done[str(a.get("id") or "")] = None + counts["webBlocked"] += 1 + if why_one: + log(f"[aios-auto] {a.get('kind')}: {why_one}") + + # ⛔ THE SEAM'S OWN BOUNDARY IS ON `run_step`, NOT ON `run_plan` — `run_step` wraps its + # call in `try/except` and calls that "the LAST boundary". Calling `run_plan` directly + # steps around it, and this code runs inside a record walk where an escaping exception + # ends the whole run. So the boundary moves here with the call. + try: + rows_out, why = _web_agent().run_plan( + steps, {"tenant": str(defn.get("tenant") or ""), + "automationId": str(defn.get("id") or ""), + "runId": str(defn.get("id") or ""), "log": log}) + except Exception as exc: # noqa: BLE001 — the LAST boundary + why, rows_out = (f"The web steps failed unexpectedly ({type(exc).__name__}: " + f"{str(exc).splitlines()[0][:200]}). Nothing was read."), None + if why: + log(f"[aios-auto] web: {why}") + for a in batch: + _block(a, "") + return + # ⚠ MATCHED BY ID, NEVER BY POSITION. The job returns a row for a step that FAILED and for + # every step after it that was never attempted, so the list can be shorter than, or + # misaligned with, the plan — and a positional read would hand step 3's caller step 2's + # answer, writing a wrong value into a real column, which is worse than the missing one it + # replaced. `_clean_step` mints an id for every step and the runner echoes it back, so the + # id is carried the whole way and is the only thing worth matching on. + by_id = {str(r.get("id") or ""): r for r in (rows_out or []) if isinstance(r, dict)} + for a in batch: + hit = by_id.get(str(a.get("id") or "")) + # ⛔ `ok` IS CHECKED HERE BECAUSE `run_step` USED TO CHECK IT. It turned a row with + # `ok:false` into a sentence and returned no result; reading `hit` without that test + # would take a failed step's empty `value` and write it over a real cell. + if not hit or not hit.get("ok"): + _block(a, str((hit or {}).get("error") or "") + or "The browser job returned nothing for this step.") + continue + web_done[str(a.get("id") or "")] = hit + + def _walk(acts, row, rid, depth=0): + """Walk the actions in order for ONE record. + + ⚠ THE RETURN VALUE IS VESTIGIAL and is deliberately kept as `False`. It used to mean + "this record was SUSPENDED by a review gate" — the one branch that could stop a walk + early. With review retired nothing suspends anything, so every record walks its whole + flow; the signature stays so a future gate-style action has an obvious place to say so. + """ + for idx, act in enumerate(acts): + if not act.get("enabled", True): + continue + if not lane_match(act.get("when"), row): + continue + kind = act.get("kind") + cfg = act.get("config") or {} + counts["actionsRun"] += 1 + if kind == "group": + # ⭐ WAVE 24 · C-FORK — FIRST MATCHING BRANCH WINS, and only that one runs. + # Declaration order is priority order, the same rule `route_record` applies to + # the board's lanes, and the Otherwise leg (`cond: null`) is simply the branch + # nothing above it beat — `lane_match(None, row)` is True, and `clean_actions` + # has already guaranteed a null condition can only be LAST. + for br in group_branches(act): + if lane_match(br.get("cond"), row): + if _walk(br.get("actions") or [], row, rid, depth + 1): + return True + break + elif kind == "update_record": + vals = {k: interpolate(v, row) for k, v in (cfg.get("values") or {}).items()} + row.update(vals) # later actions see the write, as they must + _act_row_patch(patches, table, rid, vals) + counts["updated"] += 1 + elif kind == "create_record": + target = str(cfg.get("table") or "") + vals = {k: interpolate(v, row) for k, v in (cfg.get("values") or {}).items()} + # C5: keyed by (table, uniqueOn) rather than by table alone, because two actions + # may legitimately write to ONE database on different keys — collapsing them onto + # the table would silently apply one action's uniqueness rule to the other's rows. + creates.setdefault((target, str(cfg.get("uniqueOn") or "")), []).append(vals) + counts["created"] += 1 + elif kind == "ai_agent": + # ⭐⭐ W33-T56 (owner item 7, ruling R3) — THE FUZZY STEP, AT RUN TIME. + # + # A description becomes concrete web steps HERE, against this record's own values, + # and then rides the ordinary seam. Composing at run time rather than at save time + # is the whole point: `{{Website}}` is a different page for every row, so a journey + # fixed at save time would be the same guess repeated. + # ⛔ IT COMPOSES ONLY `web_*` KINDS. The composer is handed the same catalog the + # AI-agent module uses, filtered to what a browser job can perform — so a fuzzy + # instruction cannot talk this action into writing a record or calling a connector. + # The blast radius of a bad sentence is one browser session, not the tenant. + # ⚠ AND IT REPORTS THE STEPS IT ACTUALLY TOOK, which is the ticket's own + # `done-when`. A step that composes a journey and reports only its final value is + # unauditable: nobody can tell a right answer from a lucky one. + _missing = _web_missing(kind, cfg) + if _missing: + _note = f"{kind}_unconfigured" + if _note not in web_notes: + web_notes.append(_note) + log(f"[aios-auto] {kind}: this step still needs " + + ", ".join(_missing) + ". Nothing was done") + counts["webBlocked"] += 1 + continue + if web_jobs[0] >= MAX_WEB_JOBS_PER_RUN: + if "web_cap" not in web_notes: + web_notes.append("web_cap") + log(f"[aios-auto] web: this run stopped after {MAX_WEB_JOBS_PER_RUN} " + f"browser jobs of about 10-30 seconds each.") + counts["webBlocked"] += 1 + continue + _plan, _why = _ai_agent_plan(cfg, row, log) + if _why: + # ⛔ NAMED, NEVER OPAQUE — the second half of the `done-when`. "The assistant + # could not work out how to do that" with the reason attached is actionable; + # a blank cell is not. + log(f"[aios-auto] ai_agent: {_why}") + counts["webBlocked"] += 1 + continue + web_jobs[0] += 1 + try: + _rows_out, _why2 = _web_agent().run_plan( + _plan, {"tenant": str(defn.get("tenant") or ""), + "automationId": str(defn.get("id") or ""), + "runId": str(defn.get("id") or ""), "log": log}) + except Exception as _exc: # noqa: BLE001 — the LAST boundary + _rows_out, _why2 = None, ( + f"the browser job failed unexpectedly ({type(_exc).__name__}: " + f"{str(_exc).splitlines()[0][:200]}). Nothing was done.") + if _why2: + log(f"[aios-auto] ai_agent: {_why2}") + counts["webBlocked"] += 1 + continue + # THE ACCOUNT OF WHAT IT DID — one line per step, in order, with each step's own + # verdict. This is what makes a fuzzy step auditable at all. + _done = [r for r in (_rows_out or []) if isinstance(r, dict)] + for _i, _r in enumerate(_done, 1): + log(f"[aios-auto] ai_agent step {_i}/{len(_plan)}: {_r.get('kind')} " + f"{'ok' if _r.get('ok') else 'FAILED'}" + + (f". {str(_r.get('error'))[:160]}" if not _r.get("ok") else "")) + _last = _done[-1] if _done else {} + if not _done or not _last.get("ok"): + log("[aios-auto] ai_agent: the journey did not finish. " + + str((_last or {}).get("error") + or "the browser job returned nothing for the last step")) + counts["webBlocked"] += 1 + continue # ⛔ NOTHING IS WRITTEN on an unfinished journey. + _target = str(cfg.get("field") or "") + if _target: + vals = {_target: _last.get("value")} + row.update(vals) + _act_row_patch(patches, table, rid, vals) + counts["webRead"] += len(_done) + elif kind in WEB_KINDS: + # ⭐⭐ WAVE 31 · T38 (C5) — THE WEB-BROWSING AGENT'S LIVE ARM, ALL FIVE KINDS. + # + # ⛔ WHY THIS ARM EXISTS SEPARATELY FROM THE RUNNER: session E built + # `web_agent.run_step` and **could not verify its own mounting**. An unmounted + # runner is a whole, correct, unreachable feature — the exact class five wave-29 + # features shipped as — so the mount and its `verify_wiring` row are C's, in one + # change. + # ⭐⭐ W31 QA WIDENED THIS ARM FROM `web_read` TO ALL FIVE KINDS on the owner's + # revocation of D-51/R5 (`TICKETS.md:1418`). ⚠ IT NEEDS NO PER-KIND HANDLING, and + # that is E's design rather than an omission: the runner normalises EVERY kind to + # set `result["value"]` (read → the text · goto → the title · click → the title it + # landed on · fill → the typed value, masked when secret · repair → the proposed + # selector), precisely so this one arm does not grow a switch that would be a + # second copy of the runner's table living in another lane's file. + # + # ⛔ IT BLOCKS FOR ~9-32 s (E measured it; `proto/web-agent-job.md` §4). That is + # tolerable HERE and nowhere else: this is the automation RUNNER, already a + # background walk. It must never be called from a route a person is waiting on. + # + # ⚠ `interpolate` ON THE CALLER'S SIDE, exactly as the update/create arms do it, so + # `{Field}` works in a url or a selector. E's seam takes a plain dict and does not + # know about rows. + # ⛔ FAIL CLOSED, ONCE, WITH THE REASON — the counterpart to the validator storing + # an unconfigured step (see `_clean_action_config`'s `web_read` arm). Reported once + # per RUN and not per record: the missing config is a property of the flow, so a + # 100-record walk would otherwise print the same sentence a hundred times and bury + # everything else. The same shape the enrich arm's `unbound` note uses. + # ⚠ PER KIND, because they do not need the same things: `web_goto` needs a url and + # no selector; `web_fill` needs a value nobody else takes; only `web_read` needs a + # column to write into. One shared three-field test would have blocked every + # `web_goto` ever configured for want of a selector it does not use. + _missing = _web_missing(kind, cfg) + if _missing: + # ⚠ THE NOTE KEY CARRIES THE KIND. It used to be the literal + # `"web_read_unconfigured"`, so a flow with an unconfigured `web_goto` AND an + # unconfigured `web_fill` would have reported the first and swallowed the + # second — once-per-RUN is the property, not once-per-FLOW. + _note = f"{kind}_unconfigured" + if _note not in web_notes: + web_notes.append(_note) + log(f"[aios-auto] {kind}: this step still needs " + + ", ".join(_missing) + ". Nothing was done") + counts["webBlocked"] += 1 + continue + # ⛔⛔ THE PER-RUN JOB CEILING (E-4). A browser job is ~9-32 s against HF's + # 6-concurrent cap — a flow over a few thousand rows would submit a few thousand + # jobs and run for days. Reported ONCE with its cause AND the fix, which is R6's + # second sentence: a limit that cannot be removed today must say why and what would + # remove it. `MAX_WEB_JOBS_PER_RUN` carries the reasoning. + # ⭐⭐ W33-T58 (D-191) — THE CEILING NOW COUNTS **JOBS**, NOT PAGES, because the + # two stopped being the same thing on the line below. A record whose three web + # steps batch into one job spends ONE of these, not three. + if web_jobs[0] >= MAX_WEB_JOBS_PER_RUN: + if "web_cap" not in web_notes: + web_notes.append("web_cap") + log(f"[aios-auto] web_read: this run stopped after " + f"{MAX_WEB_JOBS_PER_RUN} browser jobs of about 10-30 seconds each, " + f"which do not run in parallel. A record's consecutive web steps " + f"already share ONE job; to read more, narrow the flow's records.") + counts["webBlocked"] += 1 + continue + # ⭐⭐ W33-T58 · D-191 — ONE JOB FOR A RECORD'S CONSECUTIVE WEB STEPS. + # `run_plan(steps, ctx)` has always taken a list and nothing ever called it with + # more than one: the arm called `run_step`, which wraps `[step]`, so a flow with + # three web reads paid THREE ~9 s cold starts to do what one job does. The batch is + # built at WALK time rather than from the stored flow, because whether a step runs + # at all depends on this record (`enabled`, `when`, and whether it is configured). + if str(act.get("id") or "") not in web_done: + _run_web_batch(acts, idx, row, rid) + result = web_done.get(str(act.get("id") or "")) + if result is None: + continue # its batch refused; the reason was logged once, there + _target = str(cfg.get("field") or "") + if _target: + vals = {_target: (result or {}).get("value")} + row.update(vals) # later actions see the write, as they must + _act_row_patch(patches, table, rid, vals) + counts["webRead"] += 1 + elif kind == "enrich_instagram": + # ⭐ C4 (R3/R4). Reuses `pull_profile` and `capture_rows` — the SAME functions + # `run_field_instagram` calls, not a second implementation of either. + pkey = profile_field_key(ut_get(rt, table), cfg.get("profileField")) + if not pkey: + # FAIL CLOSED, ONCE, WITH THE REASON. Not per record: the binding is a + # property of the flow, so a 100-record run would otherwise put the same + # sentence in the log a hundred times and bury everything else. + if "unbound" not in enrich["notes"]: + enrich["notes"].append("unbound") + log("[aios-auto] enrich_instagram: no profile column on " + f"{table}. Name one on the action, or mark a text column as an " + "Instagram profile") + continue + # ⭐ 2026-08-07 (owner ruling) — IS THIS RECORD IN THIS RUN'S BUDGET? + # Resolved once (see `enrich_plan`) and then a membership test. A record outside + # the selection is NOT an error and NOT a skip worth logging per row — it is simply + # not this run's work, and the selection's own note already accounts for it. + aid = str(act.get("id") or "") + if aid not in enrich_plan: + chosen, sel_note = enrich_selection(rt, table, cfg, pkey, gone=known_gone) + enrich_plan[aid] = set(chosen) + if sel_note: + enrich["notes"].append(sel_note) + # ⭐ ONE VENDOR CALL PER CHUNK FOR THE WHOLE SELECTION, here and nowhere else: + # this is the only place the full chosen set and the row bodies are both in + # hand. Anything it resolves the per-record rung below reads from memory. + _prime_enrich_batch(chosen, rows, pkey, table, enrich, + enrich_prefetch, step, log) + if str(rid) not in enrich_plan[aid]: + continue + handle_raw = str(row.get(pkey, "") or "").strip() + if not handle_raw: + continue # nothing to enrich on this record, not an error + # ⚠ THE PACE FLOOR IS THE VENDOR'S, SO IT IS PAID ONLY WHEN THE VENDOR IS CALLED. + # A handle already in `enrich_prefetch` was bought by the batch above and is read + # from memory; sleeping 2.5 s before a dictionary lookup would hand most of the + # batching win straight back (25 records = ~62 s of pure waiting). + if enrich["profiles"] and str(handle_raw).strip().lstrip("@").lower() \ + not in enrich_prefetch: + time.sleep(PACE_SECONDS) # >=2 s between profiles (R7), as the runner does + enrich["profiles"] += 1 + # ⭐ THE DEFERRED-PROFILE SINK IS PER RECORD so the snapshot can be stamped with + # the row it belongs to: the collector writes preset cells back onto THAT record, + # and a run-wide list would have no way to say which handle each snapshot was for. + pend_prof = [] + # ⭐ W31-T39(c) — the DEFERRAL WATERMARK, taken before the pull. See the `partial` + # report below: a capability that was deferred is not a capability that failed, + # and `pull_profile` appends into these two sinks from inside the call. + _pend_before = len(enrich["pending"]) + res = pull_profile(handle_raw, + max_posts=cfg.get("maxPosts") or DEFAULT_POSTS_PER_PULL, + log=log, + post_metrics=bool(cfg.get("postMetrics")), + comment_metrics=bool(cfg.get("commentMetrics")), + pending_metrics=(enrich["pending"] + if not cfg.get("dryRun") else None), + pending_profile=(pend_prof + if not cfg.get("dryRun") else None), + prefetch=enrich_prefetch, + post_groups=cfg.get("postGroups")) + # ⭐ 2026-08-09 (owner: *"a Filter on our enrichment so we only get Last 12 reels / + # 12 videos etc … not just last 12 but by group also"*). `config.postGroups` is + # `[{"type": "video", "limit": 12}, …]`; a type nobody names is dropped. + # ⛔ DEFAULT OFF — with no `postGroups` the list is returned unchanged, so this + # costs nothing and changes nothing until somebody configures it. + # ⭐⭐ WAVE 30 · T16 — THE WINDOW IS NOW WIDENED BEFORE IT IS FILTERED, and the + # paragraph that used to sit here explaining why it could not be is gone with the + # reason. It said: *"it FILTERS the window we already bought … the views top-up + # runs INSIDE `pull_profile` against the posts it captured, so swapping a wider + # list in afterwards would hand back posts with no view counts."* Correct, and + # exactly why the widening went INTO `pull_profile` (`post_groups=`, above) rather + # than being bolted on out here — ahead of the top-up, not after it. + # ⚠ THIS LINE STAYS, and it is not redundant. A single-type ask is served by a + # native route that returns only that type; a MIXED ask has no such route, so the + # wide window comes back mixed and this is what keeps N of each. + if cfg.get("postGroups"): + res = {**res, "posts": select_post_groups(res.get("posts") or [], + cfg.get("postGroups"))} + for _p in pend_prof: + _p["table"], _p["rowId"], _p["requestedAt"] = table, str(rid), _iso() + enrich["pendingProfiles"].extend(pend_prof) + pulled = _iso() + if res["state"] in ("ok", "partial"): + enrich["ok"] += 1 + snap_row, idents, metric_rows, comment_rows = capture_rows(res, pulled) + enrich["snaps"].append(snap_row) + enrich["posts"].extend(idents) + enrich["psnaps"].extend(metric_rows) + enrich["comments"].extend(comment_rows) + # R3: LATEST onto the record itself. `row.update` too, so a LATER action in + # this same flow sees the enriched values — the rule `update_record` already + # follows, and without it "enrich, then route by follower count" would judge + # the record on the values it had before the pull. + # ⭐⭐ WAVE 31 · T39(c) — D-177(c): THE FIX APPLIED TO ONE PLATFORM AND NOT ITS + # TWIN, and that is the shape worth naming rather than the line. + # + # W30-T11 found this on TikTok the expensive way: a paid run enriched two + # profiles with `postMetrics` ON, wrote no posts, and said NOTHING — because + # `partial` takes THIS branch and only the `else` ever appended a note, so the + # reason `pull_profile` carries in `res["note"]` was discarded. Instagram has + # the identical branch and never got the fix; the sibling arm below has carried + # it since wave 30. One defect, two networks, one of them repaired — which is + # exactly how the last four TikTok/Instagram divergences were found. + # + # ⛔ ONLY WHEN IT WAS ASKED FOR, and ⛔ NOT WHEN THE BATCH DEFERRED — both + # conditions copied deliberately from the TikTok arm rather than re-reasoned. + # With `postMetrics` off, a `partial` is the normal answer for every profile + # and reporting it would put one useless line per record in the run entry; a + # DEFERRAL is a paid snapshot already filed for collection, and reporting it as + # a miss teaches a person to re-run and buy it twice. + # ⚠ Instagram's deferral sinks are `enrich["pending"]` (post metrics) and + # `pend_prof` (the profile snapshot) — the two lists `pull_profile` appends + # into — where TikTok reads `res["deferredMedia"]`. Different signal, same + # question: did this record's work get filed rather than lost? + if (cfg.get("postMetrics") and not (res.get("posts") or []) + and len(enrich["pending"]) == _pend_before and not pend_prof): + _why = _s(res.get("note"), 300) + if _why: + enrich["notes"].append(f"@{handle_raw}: {_why}") + cells = preset_cells(res, pulled) + if cfg.get("dryRun"): + enrich["dry"] = True + else: + row.update(cells) + _act_row_patch(patches, table, rid, cells) + else: + enrich["blocked"] += 1 + # ⭐⭐ D-103 — THE NOTE IS NAMED AND KEPT AT FULL LENGTH. + # It used to be `_s(note, 90)` with no handle attached: three runs blocked + # the same record and the store could not say which record, let alone why. + # 90 characters also truncated the one measured sentence mid-clause. This is + # the most valuable thing the run produces and it now reaches the run entry. + enrich["notes"].append( + f"@{handle_raw}: {_s(res.get('note'), 300) or res['state']}") + # A vendor STATING that the account does not exist is remembered, so the next + # run stops paying to be told the same thing. + if res.get("gone"): + enrich["gone"][_gone_key(row, handle_raw)] = { + "at": _iso(), "handle": handle_raw, + "note": _s(res.get("note"), 300)} + elif kind == "enrich_tiktok": + # ⭐⭐ WAVE 30 · T08 (carrying wave-29's dropped T05). THE SECOND NETWORK, and it + # is a sibling of the branch above rather than a flag inside it. What is genuinely + # shared is shared by CALL — `enrich_selection`, `enrich_plan`, the pace floor, + # the tombstone memory; what differs is the four things B-9 measured as + # Instagram-hardcoded, and each has a TikTok counterpart of its own name. + pkey = profile_field_key(ut_get(rt, table), cfg.get("profileField"), + source=PROFILE_SOURCE_TT) + if not pkey: + # D-79, on the network that did not exist when D-79 was written: FAIL CLOSED, + # ONCE, WITH THE REASON — and the marker is its OWN, so a flow carrying both + # steps cannot report the Instagram sentence about the TikTok one. + if "unbound" not in tt["notes"]: + tt["notes"].append("unbound") + log("[aios-auto] enrich_tiktok: no profile column on " + f"{table}. Name one on the action, or mark a text column as a " + "TikTok profile") + continue + aid = str(act.get("id") or "") + if aid not in enrich_plan: + # ⚠ ONE plan dict for both networks is correct and not an oversight: it is + # keyed by ACTION id, and an action has exactly one kind. Two steps over one + # table are two budgets whichever networks they read. + chosen, sel_note = enrich_selection(rt, table, cfg, pkey, gone=known_gone, + platform=PLATFORM_TIKTOK) + enrich_plan[aid] = set(chosen) + if sel_note: + tt["notes"].append(sel_note) + if str(rid) not in enrich_plan[aid]: + continue + handle_raw = str(row.get(pkey, "") or "").strip() + if not handle_raw: + continue # nothing to enrich on this record, not an error + # ⚠ THE PACE FLOOR IS THE VENDOR'S. There is no batch prefetch on this path yet + # (`pull_profile_tt` accepts one and nothing writes it — see the mailbox), so + # every profile after the first pays it. + if tt["profiles"]: + time.sleep(PACE_SECONDS) + tt["profiles"] += 1 + import connectors_tt as _tt_conn # lazy: connectors_tt imports this module + pend_prof = [] + res = _tt_conn.pull_profile_tt( + handle_raw, log=log, + pending_profile=(pend_prof if not cfg.get("dryRun") else None), + # ⭐ W30-T10. Both INCLUDE axes default OFF (W28/R5-R7), and the same + # `config` keys the Instagram step reads — one vocabulary, two networks. + max_posts=int(cfg.get("maxPosts") or 0), + post_metrics=bool(cfg.get("postMetrics")), + comment_metrics=bool(cfg.get("commentMetrics"))) + for _p in pend_prof: + _p["table"], _p["rowId"], _p["requestedAt"] = table, str(rid), _iso() + # The deferred-profile queue is the Instagram one BY DESIGN: it is a vendor + # snapshot id waiting to be collected, and `_pending_profile_tasks` keys tasks by + # table+row, not by network. Sharing it is what makes the tick finish a TikTok + # read it has already been charged for. + enrich["pendingProfiles"].extend(pend_prof) + # ⭐⭐ W30 · D-156 — A DEFERRED MEDIA BATCH IS FILED, NOT NARRATED. Before this, + # a posts or comments scrape the vendor took too long over was reported in the + # note and collected by NOTHING: paid for, and recoverable only by a human reading + # a sentence. `connectors_tt` has already filtered these to the media corpora, so + # a profile snapshot cannot arrive here; what the engine adds is the vocabulary the + # QUEUE speaks — the `kind` (from the dataset id, which is one-to-one on TikTok) + # and the HANDLE, which the transport never knew. + # ⚠ `dryRun` queues NOTHING: a dry run buys nothing, so an entry here could only + # be a fixture leaking, and filing it would make the tick collect a snapshot that + # was never paid for. + if not cfg.get("dryRun"): + for _d in (res.get("deferredMedia") or []): + _kind = tt_metric_kind(_d.get("datasetId")) + if not _kind: + continue # not a media corpus ⇒ not this queue's business + tt["pending"].append({**_d, "kind": _kind, "requestedAt": _iso(), + "influencer": str(handle_raw).strip() + .lstrip("@").lower()}) + pulled = _iso() + if res["state"] in ("ok", "partial"): + tt["ok"] += 1 + snap_row = tt_snapshot_row(res, pulled) + if snap_row: + tt["snaps"].append(snap_row) + tt_idents, tt_metrics, tt_comments = tt_capture_rows(res, pulled) + tt["posts"].extend(tt_idents) + tt["psnaps"].extend(tt_metrics) + tt["comments"].extend(tt_comments) + # ⭐⭐ WAVE 30 · T11 — A CAPABILITY THAT WAS ASKED FOR AND DID NOT ARRIVE IS + # REPORTED. This is R6's second sentence applied to a capability rather than to + # a row cap, and it was found the expensive way: the 09:14 UTC paid run on + # nurilab enriched two profiles with `postMetrics` ON, wrote no posts, and said + # NOTHING — `ok: true`, no note, no count — because `partial` takes the branch + # ABOVE and only the `else` ever appended a note. Every `partial` return in + # `pull_profile_tt` carries the reason in `note` (*"this account's row carried + # no post links"*, *"the post source returned nothing"*, or the vendor's own + # words), and all of them were being discarded. A person then sees two enriched + # rows, an empty posts database and no explanation anywhere. + # ⛔ ONLY WHEN IT WAS ASKED FOR, which is the difference between a report and + # noise: with `postMetrics` off, `pull_profile_tt` returns `partial` + *"post + # capture is off for this step"* for EVERY profile, and appending that would put + # one useless line per record into the run entry and let the summary quote it. + # ⛔ AND NOT WHEN THE BATCH DEFERRED — a deferral is not a failure to deliver, + # it is a paid snapshot already filed for collection + # (`ttEnrichMetricBatchesPending`, D-156), and reporting it as a miss would + # teach a person to re-run and buy it twice. + if (cfg.get("postMetrics") and not (res.get("posts") or []) + and not (res.get("deferredMedia") or [])): + _why = _s(res.get("note"), 300) + if _why: + tt["notes"].append(f"@{handle_raw} (TikTok): {_why}") + cells = tt_preset_cells(res, pulled) + if cfg.get("dryRun"): + tt["dry"] = True + else: + row.update(cells) + _act_row_patch(patches, table, rid, cells) + else: + tt["blocked"] += 1 + # ⚠ TAGGED, because `run_notes` now carries BOTH networks' per-record reasons + # (the two flushes are concatenated). The summary picks ONE note to quote, so an + # untagged TikTok line could be quoted under Instagram's sentence and vice versa + # — which would undo the whole point of giving each network its own sentence. + tt["notes"].append( + f"@{handle_raw} (TikTok): {_s(res.get('note'), 300) or res['state']}") + # ⛔ AND THERE IS DELIBERATELY NO TOMBSTONE WRITER HERE, which is the opposite + # of an oversight. On the Instagram side `res["gone"]` has exactly ONE source — + # Apify answering `ACCOUNT_GONE_NOTE` (`connectors_ig`); Bright Data has no + # not-found verdict at all, and `DEFAULT_CHAINS["tt_profile"]` is deliberately + # single-provider, so nothing on this chain can say "no account exists". A + # phrase match invented against unmeasured vendor output would file a + # PERMANENT verdict (W28/R9 — no expiry) on the strength of a guess. + # ⚠ The READ side is still network-scoped above (`platform=PLATFORM_TIKTOK`) + # and that half is load-bearing today: `known_gone` is shared, Apify DOES + # write `instagram:` verdicts, and without the scoping one of those + # would silently suppress a TikTok read of a different person's account. + elif kind == "find_records": + found = find_records(rt, cfg.get("table"), cfg.get("cond"), + int(cfg.get("limit") or 25)) + counts["found"] += len(found) + # ⛔ THE `review` BRANCH IS DELETED (wave 27 item 12, owner ruling R3), and it had + # already stopped being reachable one wave earlier — which is the part worth reading. + # `_without_retired_board` strips every `review` action out of a definition on the + # READ path, so `all_definitions` and this function have not seen one since the board + # was retired. What was left behind was a branch referencing `skey`, `stamp` and + # `ai_budget` — three names with NO DEFINITION anywhere in this module. It was not + # dead code that merely wasted space: it was a `NameError` held back by a migration + # rather than by a guard, and any change that let one stored `review` action through + # would have crashed the whole action walk for every record in that flow. + # ⚠ `ai_decide` and `review_audit` SURVIVE as library code with no caller — R3 keeps + # review "as an AI decision without lanes", and the cheap-first provider ladder behind + # it is real, working, measured work. They are PARKED, deliberately and in writing, + # not orphaned; see their own notes. + return False + + if actions: + for rid in list(row_ids or [])[:FLOOD_LIMIT]: + row = dict(rows.get(str(rid)) or {}) + if not row: + continue + # ⭐ D-191 — the web batch is built from THIS row's interpolated values, so it means + # nothing for the next one. Cleared here rather than inside `_walk`, which recurses + # into branches and would wipe a batch its own caller is still consuming. + web_done.clear() + _walk(actions, row, str(rid)) + # ⭐⭐ WAVE 30 · T08 — TWO FLUSHES, ONE `counts`, AND THE NOTES ARE CONCATENATED RATHER THAN + # OVERWRITTEN. `RUN_NOTES_KEY` is the one key both flushes legitimately produce, so a plain + # `counts.update(a); counts.update(b)` would drop every Instagram per-record reason the moment + # a flow also carried a TikTok step — silently, and precisely on the mixed flows where a + # person most needs to know which half failed. Every other key is prefixed and cannot collide. + _ig_out = _enrich_flush(rt, defn, username, enrich, log) + _tt_out = _tt_enrich_flush(rt, defn, username, tt, log) + _flush_notes = (list(_ig_out.pop(RUN_NOTES_KEY, None) or []) + + list(_tt_out.pop(RUN_NOTES_KEY, None) or [])) + counts.update(_ig_out) + counts.update(_tt_out) + if _flush_notes: + counts[RUN_NOTES_KEY] = _flush_notes + # ⭐ C5: the REALIZED numbers overwrite the walk's attempt count. `counts["created"]` was + # incremented once per create the flow decided to make; what landed is what the store says, + # and with `uniqueOn` on they are routinely different (a re-run of a scheduled flow matches + # every row it made last time — which is the whole point of the feature). + counts.update(_commit_action_writes(rt, table, patches, creates, username, log)) + return counts + + +def migrate_field_instagram(rt, defn): + """⭐ WAVE 25 · R4 — one stored `field_instagram` definition → a `plain` one carrying the + `enrich_instagram` action. Returns `(definition, changed)`. + + R4: "the ENRICH ACTION REPLACES the `field_instagram` KIND… migrate the one live + `field_instagram` automation to a plain flow carrying it; delete the kind and its label." + + ⛔ THE BINDING IS RESOLVED HERE, NOT LEFT TO THE FLAG, and this is the line the migration + turns on. `run_field_instagram` reads `cfg.urlField` **or falls back to `_auto_url_field`** — + so a live automation whose `urlField` is blank has been working off that fallback for months. + The enrich action deliberately has no such fallback (see `profile_field_key`), so migrating a + blank `urlField` verbatim would produce an automation that USED to work and now refuses. The + fallback is therefore evaluated ONCE, here, and the answer is written down as an explicit + binding — which is also the honest outcome: the column stops being implicit. + + ⚠ ONE BEHAVIOUR DOES CHANGE, AND IT IS THE POINT OF THE RULING RATHER THAN A REGRESSION. The + old kind wrote a STATUS STRING ("ok · 2026-08-06 · 12,400 followers") into `config.fieldKey`'s + column; the action writes the C1 PRESET CELLS instead. The status column is left in place and + simply stops being written — deleting somebody's column as part of a migration would be data + loss, and a stale cell beside a fresh `enriched_at` is readable for what it is. + + ⚠ PURE OVER THE DEFINITION apart from the one table READ. It writes nothing, so a caller can + run it to INSPECT what a migration would do — which is exactly how R4's "must be PROVEN + against the real stored definition" is meant to be satisfied. + """ + if (defn or {}).get("kind") != "field_instagram": + return defn, False + cfg = dict(defn.get("config") or {}) + target = str(cfg.get("targetTable") or "") + bound = str(cfg.get("urlField") or "").strip() + if not bound and target: + bound = str(_auto_url_field(ut_get(rt, target) or {}, cfg.get("fieldKey")) or "") + try: + max_posts = int(cfg.get("maxPosts") or DEFAULT_POSTS_PER_PULL) + except (TypeError, ValueError): + max_posts = 24 + act = {"id": "act_enrich", "kind": "enrich_instagram", "enabled": True, "when": None, + "config": {"profileField": bound, + "postMetrics": bool(cfg.get("postMetrics")), + "commentMetrics": bool(cfg.get("commentMetrics")), + "dryRun": bool(cfg.get("dryRun")), + "maxPosts": max(1, min(max_posts, 200))}} + flow = dict(defn.get("flow") or {}) + out = dict(defn) + out["kind"] = DEFAULT_KIND + # The enrich step goes FIRST — it is what the automation was, and every action the owner + # added afterwards was written expecting the capture to have happened. + out["flow"] = {"actions": [act] + list(flow.get("actions") or [])} + out["config"] = {"targetTable": target, + "targetLabel": str(cfg.get("targetLabel") or "")} + return out, True + + +def _enrich_flush(rt, defn, username, acc, log): + """C4: the enrich action's canonical IG tables, written ONCE for the whole run. + + Returns the counts the run reports. Empty when no enrich action ran, so a flow without one + pays nothing and its run history is unchanged (`run_now` merges only non-zero keys). + + ⛔ THE SAME WRITE PATH AS `run_field_instagram`, NOT A PARALLEL ONE: `ut_ensure` the three + tables, `upsert_rows` on their own keys with their own caps, one coalesced `rt.update`, then + the write-through to the platform master. Forking any of those would give the enrich action a + history that the metric fields could not see — and it is the LAST step, the write-through to + the platform master, that they read (W29-T34: `compute_metric_cells` → `ig_master.series_for`, + never this tenant's `ut_ig_snapshots`), so dropping that one line is the version of this fork + that would look harmless. + """ + if "unbound" in (acc.get("notes") or []): + # ⛔ DEBT D-79, SECOND HALF — THE SILENT FAILURE, AND IT WAS THE WORSE HALF. An enrich step + # on a database with no profile column `continue`s BEFORE `enrich["profiles"] += 1`, so + # this function used to return `{}`, `run_now` merged only non-zero keys, and the run + # committed **`ok` — "N records walked"** having written nothing at all. The sentence + # naming the fix went to `log()`, i.e. the server console, which no customer reads. That + # is "green over nothing" on the one path where somebody is waiting for data. + # ⚠ IT IS A COUNT, not a flag, so `run_now`'s existing non-zero merge carries it without a + # special case — and so the run entry itself records that this happened. + return {"enrichUnbound": 1} + # ⭐⭐ 2026-08-09 — THE NOTES SURVIVE A RUN THAT READ NOTHING, and that is not a detail. + # `if not acc["profiles"]: return {}` is exactly the branch a run takes when EVERY candidate + # was skipped as a known-dead handle — so the sentence explaining why the automation appears + # to do nothing would have been dropped on precisely the runs that most need it, and the + # owner would be back at "0 records walked, wtf". The selection note is produced before any + # profile is read and must outlive that early return. + if not acc.get("profiles"): + notes = list(acc.get("notes") or []) + return {RUN_NOTES_KEY: notes} if notes else {} + out = {"enriched": acc["ok"], "enrichBlocked": acc["blocked"]} + if acc.get("notes"): + out[RUN_NOTES_KEY] = list(acc["notes"]) + # ⭐ D-103's own prescription: "the block note survives on the RUN … not a status column, so + # no table gains a column it did not ask for". `RUN_NOTES_KEY` is that channel. + if not acc.get("dry"): + # The vendor's not-found verdicts, merged into engine state (never a tenant column and + # never a status string — W25/R4 retired those). Merged rather than replaced: a run that + # walked one record must not forget what earlier runs learned about the others. + if acc.get("gone"): + merged = dict(((defn or {}).get("state") or {}).get("enrichNotFound") or {}) + merged.update(acc["gone"]) + set_state(rt, str(defn.get("id") or ""), {"enrichNotFound": merged}) + pending_profiles = queue_pending_profile_snapshots( + rt, str(defn.get("id") or ""), acc.get("pendingProfiles") or []) + if pending_profiles: + out["enrichProfileBatchesPending"] = pending_profiles + queued = queue_pending_metric_snapshots(rt, str(defn.get("id") or ""), + acc.get("pending") or []) + if queued: + out["enrichMetricBatchesPending"] = queued + if acc.get("dry") or not (acc["snaps"] or acc["posts"] or acc["psnaps"] or acc["comments"]): + # A dry run resolves nothing and writes nothing — not even `ut_ensure`, which CREATES. + if acc.get("dry"): + out["enrichDryRun"] = acc["profiles"] + return out + tag = str(defn.get("id") or "") + profile_table = str(_flow_table(defn) or "") + graph = ensure_ig_graph(rt, username, tag, profile_table=profile_table) + snap_key, post_key, ps_key, comment_key = (graph[IG_SNAPSHOTS_TABLE], graph[IG_POSTS_TABLE], + graph[IG_POST_SNAPSHOTS_TABLE], graph[IG_COMMENTS_TABLE]) + missing = ut_missing(rt, snap_key, post_key, ps_key, comment_key) + snaps, c_snap = upsert_rows(dict((ut_get(rt, snap_key) or {}).get("rows") or {}), + acc["snaps"], "snapshot_key", cap=row_cap(snap_key)) + old_posts, collapsed_posts = dedupe_canonical_rows( + dict((ut_get(rt, post_key) or {}).get("rows") or {}), "shortcode", newest_by="measured_at") + posts, c_post = upsert_rows(old_posts, acc["posts"], "shortcode", cap=row_cap(post_key)) + c_post["duplicates"] += collapsed_posts + psnaps, c_ps = upsert_rows(dict((ut_get(rt, ps_key) or {}).get("rows") or {}), + acc["psnaps"], "post_snapshot_key", cap=row_cap(ps_key)) + old_comments, collapsed_comments = dedupe_canonical_rows( + dict((ut_get(rt, comment_key) or {}).get("rows") or {}), "comment_key") + comments, c_comments = upsert_rows(old_comments, acc["comments"], "comment_key", + cap=row_cap(comment_key)) + c_comments["duplicates"] += collapsed_comments + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + for k, rws in ((snap_key, snaps), (post_key, posts), (ps_key, psnaps), + (comment_key, comments)): + if cur.get(k) is not None: + cur[k]["rows"] = rws + _refresh_relations_inplace(cur, log=log) + return cur + + rt.update(UT_STORE_KEY, _up, flush="sync") # ONE coalesced update for all three tables + # LOUD, never silent (D-11): a full append table means the SERIES has stopped growing, which + # is the failure a chart cannot show you. + capped = c_snap["capped"] + c_post["capped"] + c_ps["capped"] + c_comments["capped"] + if capped: + out["enrichCapped"] = capped + log(f"[aios-auto] enrich_instagram: {capped} row(s) refused by a table's row cap") + if missing: + out["enrichMissingTables"] = len(missing) + log(f"[aios-auto] enrich_instagram: could not create {', '.join(missing)}") + out["enrichPoints"] = len(acc["snaps"]) + out["enrichPosts"] = c_post["inserted"] + out["enrichComments"] = c_comments["inserted"] + # C6/R2's write-through to the PLATFORM MASTER. Three postures, never conflated — the same + # contract `run_field_instagram` follows, because a pooled history with silent holes is worse + # than none. + try: + import ig_master + m_status, m_note = ig_master.append_run(getattr(rt, "key", ""), acc["snaps"], + acc["posts"], acc["psnaps"]) + if m_status == "error": + out["enrichMasterFailed"] = 1 + log(f"[aios-auto] enrich_instagram: the platform master copy FAILED. {m_note}; " + f"the tenant copy is complete and the next run re-appends") + elif m_status == "ok": + out["enrichMaster"] = len(acc["snaps"]) + len(acc["psnaps"]) + except Exception as e: # noqa: BLE001 + out["enrichMasterFailed"] = 1 + log(f"[aios-auto] enrich_instagram: master write-through raised " + f"{type(e).__name__}: {e}") + return out + + +#: How many candidate columns an unbound-enrich sentence names before it stops. Three, because the +#: sentence is read in a run log line: naming forty columns is the same as naming none. +UNBOUND_HINT_MAX = 3 + + +def _unbound_hint(rt, table_key): + """⭐ WAVE 30 · T14 — the *"…which column to mark"* half of D-79's sentence. + + Returns `" — this database's text columns are Handle, Who"` or `""`. Never a binding. + + ⛔ IT SUGGESTS AND DOES NOT RESOLVE, and the distinction is the whole reason this is a string + rather than a fallback. `profile_field_key`'s own docstring refuses to guess — *"the failure + would be a run that reports success having enriched from the wrong column … a wrong number is + harder to notice than a missing one"*. That argument is about BINDING. It says nothing against + telling a person, in the sentence they are already reading, which columns are even eligible: + the enrich still refuses, nothing is written, and the human makes the choice. + ⚠ `text` ONLY, matching the writer F shipped for the flag (`ColumnMenu`'s toggle is gated on + `editType === "text"`), so the sentence cannot offer a column the editor would then refuse. + """ + fields = (ut_get(rt, table_key) or {}).get("fields") or [] + # ⚠ AND A PRESET/LOCKED COLUMN IS NOT OFFERED. `Platform` is a `text` column on every preset + # profile table and carries `automation.preset` + an `editRole`, so the field editor refuses to + # retype it — offering it would send a person to a control that says no, which is exactly the + # claim the docstring above makes and did not honour on its first draft. + names = [str(f.get("label") or f.get("key") or "").strip() for f in fields + if str(f.get("type") or "text") == "text" + and not (f.get("automation") or {}).get("preset") + and not str(f.get("editRole") or "").strip() + and str(f.get("label") or f.get("key") or "").strip()] + if not names: + return "" + shown = ", ".join(names[:UNBOUND_HINT_MAX]) + more = len(names) - UNBOUND_HINT_MAX + return (f". This database's text columns are {shown}" + + (f" (+{more} more)" if more > 0 else "")) + + +def _tt_write_tables(rt, tag, username, snaps, posts, psnaps, comments, log=print): + """The four `ut_tt_*` tables, created-if-needed and written in ONE coalesced store update. + Returns `(inserted_by_table, capped, missing)`. + + ⭐⭐ WAVE 30 · D-156 — ONE WRITER, TWO CALLERS, AND THE SECOND CALLER IS WHY IT EXISTS. + This was the tail of `_tt_enrich_flush`, i.e. reachable only from an INLINE enrich. The + deferred collector needs exactly the same write, and the repo has already paid once for the + version where it did not: `top_up_views` lived inside `pull_profile_bd`, so it ran only when + the Posts scrape answered in time, and every DEFERRED Instagram run wrote posts with a blank + Views column. The fix there was this same shape — one function, two callers, so the inline and + deferred paths cannot answer differently — and copying the block instead would reintroduce the + class rather than the bug. + + ⭐ ONE LOOP OVER `(table, rows, key)` RATHER THAN FOUR HAND-WRITTEN BLOCKS, and the field list + comes from `TT_TABLE_FIELDS` — so a fifth `ut_tt_*` table is one tuple, and no table can be + created with a field list that disagrees with its own declaration. + ⚠ NO ROWS ⇒ NO DATABASE. Spawning `ut_tt_comments` on a collect that carried none gives a + person a database to watch never fill, which is the visible half of green-over-nothing. + """ + plan = [(TT_SNAPSHOTS_TABLE, snaps or [], "snapshot_key"), + (TT_POSTS_TABLE, posts or [], "shortcode"), + (TT_POST_SNAPSHOTS_TABLE, psnaps or [], "post_snapshot_key"), + (TT_COMMENTS_TABLE, comments or [], "comment_key")] + written, capped, missing = {}, 0, [] + inserted = {} + for table_key, rows_in, key_field in plan: + if not rows_in: + continue + # ⭐ R9 (W31-T32) — THE RUN PATH IS THE SITE THAT ACTUALLY CREATED TODAY'S TABLES, and it + # is the one the ticket's `how:` does not name. `waves/wave30/proof/tiktok-e2e-ut_tt_posts + # .png` shows `ut_tt_posts` with 8 real rows offering "+ New record" — those rows arrived + # HERE, not through the save path (which, until W31-T34, returned before the child spawn for + # a discovery automation). Stamping only the save site would have left every table that + # already exists unlocked. ⭐ And an EXISTING table does come forward on the next call: + # `ut_ensure`'s skip test carries `not (record_mode and have.get("recordMode") != + # record_mode)`, so no separate migration is needed. + real_key = ut_ensure(rt, TT_TABLE_LABELS[table_key], TT_TABLE_FIELDS[table_key], username, + key=table_key, flow_tag=tag, lock_fields=True, + record_mode=tt_record_mode(table_key)) + missing.extend(ut_missing(rt, real_key)) + merged, counts_ = upsert_rows(dict((ut_get(rt, real_key) or {}).get("rows") or {}), + rows_in, key_field, cap=row_cap(real_key)) + written[real_key] = merged + inserted[table_key] = counts_["inserted"] + capped += counts_["capped"] + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + for k, rws in written.items(): + if cur.get(k) is not None: + cur[k]["rows"] = rws + _refresh_relations_inplace(cur, log=log) + return cur + + if written: + rt.update(UT_STORE_KEY, _up, flush="sync") # ONE coalesced update for every table + return inserted, capped, missing + + +def _tt_enrich_flush(rt, defn, username, acc, log): + """⭐⭐ WAVE 30 · T08 — the TikTok enrich action's append table, written ONCE for the whole run. + + The twin of `_enrich_flush` and deliberately NOT a call into it. That function is bound to + Instagram at four points — `ensure_ig_graph`, the four `IG_*_TABLE` keys, `PRESET_*` and the + write-through to the platform Instagram master — and the last of those is the one that would + look harmless: `ig_master.append_run` is what the metric FIELDS read (W29-T34), so a TikTok + creator's followers appended there would become an Instagram number in a pooled history that + no later run could tell apart. + + ⛔ EVERY KEY IT RETURNS IS PREFIXED `tt…`. `apply_actions` merges both flushes into one + `counts`, so `enriched`/`enrichBlocked` would be one name for two networks' numbers and a + mixed flow would report whichever flushed last. + + ⭐ WAVE 30 · T10 — FOUR TABLES NOW, AND EACH IS CREATED ONLY WHEN IT HAS A ROW. Spawning + `ut_tt_posts` on a run that captured no posts gives a person a database to watch never fill, + which is the visible half of green-over-nothing and the same complaint that produced + `discover_default_table` (*"a SECOND, empty database"*). So the create rides the rows. + """ + if "unbound" in (acc.get("notes") or []): + # D-79 on the second network: a COUNT, so `run_now`'s existing non-zero merge carries it + # and the run entry itself records that the step could not run. Its own key, so the + # summary can name TikTok rather than borrowing Instagram's sentence. + return {"ttEnrichUnbound": 1} + if not acc.get("profiles"): + # The selection's note outlives a run that read nothing — the same reason as the IG side: + # "every candidate was skipped" is exactly the run whose silence needs explaining. + notes = list(acc.get("notes") or []) + return {RUN_NOTES_KEY: notes} if notes else {} + out = {"ttEnriched": acc["ok"], "ttEnrichBlocked": acc["blocked"]} + if acc.get("notes"): + out[RUN_NOTES_KEY] = list(acc["notes"]) + # ⭐⭐ W30 · D-156 — QUEUED BEFORE THE DRY-RUN AND EMPTY-ROWS RETURNS BELOW, and the order is + # the point. A run whose media batch DEFERRED has no posts and no comments to write, so it + # takes the `not (snaps or posts or ...)` exit — the exact run that owns a paid snapshot id. + # Filing after that return would have collected nothing, forever, which is the shape the + # inline-vs-deferred split keeps producing. + # ⚠ Its own count key (`tt…`), like every other key here: `apply_actions` merges both + # networks' flushes into ONE dict, so sharing Instagram's name would let a mixed flow report + # one network's pending batches under the other's. + if not acc.get("dry"): + queued = queue_pending_metric_snapshots(rt, str(defn.get("id") or ""), + acc.get("pending") or []) + if queued: + out["ttEnrichMetricBatchesPending"] = queued + if acc.get("dry") or not (acc.get("snaps") or acc.get("posts") or acc.get("psnaps") + or acc.get("comments")): + # A dry run resolves nothing and writes nothing — not even `ut_ensure`, which CREATES. + if acc.get("dry"): + out["ttEnrichDryRun"] = acc["profiles"] + return out + tag = str(defn.get("id") or "") + inserted, capped, missing = _tt_write_tables( + rt, tag, username, acc.get("snaps"), acc.get("posts"), acc.get("psnaps"), + acc.get("comments"), log) + # LOUD, never silent (D-11): a full append table means the SERIES has stopped growing, which + # is the failure a chart cannot show you. + if capped: + out["ttEnrichCapped"] = capped + log(f"[aios-auto] enrich_tiktok: {capped} row(s) refused by a table's row cap") + if missing: + out["ttEnrichMissingTables"] = len(missing) + log(f"[aios-auto] enrich_tiktok: could not create {', '.join(missing)}") + out["ttEnrichPoints"] = len(acc.get("snaps") or []) + if inserted.get(TT_POSTS_TABLE): + out["ttEnrichPosts"] = inserted[TT_POSTS_TABLE] + if inserted.get(TT_COMMENTS_TABLE): + out["ttEnrichComments"] = inserted[TT_COMMENTS_TABLE] + # ⛔ AND NO PLATFORM-MASTER WRITE-THROUGH, which is a deliberate absence rather than a missing + # line. `ig_master` is Instagram's pooled history and there is no TikTok equivalent yet; the + # tenant's own `ut_tt_snapshots` is the complete record today, and inventing a second store + # for a series nobody reads would be the fork this function exists to avoid. + return out + + +_PENDING_METRIC_DATASETS = frozenset((BD_DS_POSTS, BD_DS_REELS, BD_DS_COMMENTS)) +_PENDING_METRIC_KINDS = frozenset(("posts", "comments")) + +#: ⭐⭐ WAVE 30 · D-156 — THE TIKTOK HALF OF THE METRIC QUEUE, BUILT LAZILY FROM `connectors_tt`'s +#: OWN CONSTANTS. Re-declaring the two ids here would be a second copy of a vendor identifier that +#: nothing compares — the drift `MAX_UT_ROWS` demonstrated at 12x — so this reads them from the one +#: module that owns them, through `_tt_module()` because `connectors_tt` imports THIS module. +#: ⚠ It is a MAP rather than a set because on TikTok one dataset is exactly one kind, which is why +#: this network needs no `_tag_metric_deferrals` twin: the id the transport already recorded says +#: whether a batch is posts or comments, so nothing downstream has to be told twice. +_TT_METRIC_KIND_BY_DATASET = None + + +def tt_metric_kind(dataset_id): + """`"posts"` / `"comments"` for a TikTok media dataset id; `""` for anything else. + + ⛔ THE `""` IS LOAD-BEARING AND IS THE PLATFORM TEST. `collect_pending_metric_snapshots` + branches on it, so a dataset this map does not know keeps Instagram's mappers — which is the + safe direction, because Instagram's are what every stored pre-wave-30 task needs. + """ + global _TT_METRIC_KIND_BY_DATASET + if _TT_METRIC_KIND_BY_DATASET is None: + _tt = _tt_module() + _TT_METRIC_KIND_BY_DATASET = {str(_tt.TT_DS_POSTS): "posts", + str(_tt.TT_DS_COMMENTS): "comments"} + return _TT_METRIC_KIND_BY_DATASET.get(str(dataset_id or ""), "") + + +def _pending_metric_tasks(defn): + """Read validated, deduplicated paid metric snapshots from automation continuation state. + + A snapshot ID is a vendor-issued capability for work already paid for. It is intentionally + stored as engine state beside discovery's pending corpus snapshot, never in a Profile cell or + the user-editable flow. Invalid/stale shapes are ignored rather than sent back to a vendor + endpoint, and the collector never starts a second scrape request. + """ + raw = ((defn or {}).get("state") or {}).get("pendingMetricSnapshots") or [] + out, seen = [], set() + for item in raw if isinstance(raw, list) else []: + if not isinstance(item, dict): + continue + sid = str(item.get("snapshotId") or "").strip() + dataset = str(item.get("datasetId") or "").strip() + kind = str(item.get("kind") or "").strip() + handle = str(item.get("influencer") or "").strip().lstrip("@").lower() + # ⭐ W30 · D-156 — BOTH NETWORKS' MEDIA CORPORA ARE COLLECTABLE NOW. The membership test + # stays a WHITELIST (a snapshot id is a vendor capability that has already been paid for; + # accepting an unknown dataset would send our key at a corpus no mapper here can read), + # and TikTok's half is asked of the map that owns it rather than listed again. + if (not sid.startswith("sd_") + or (dataset not in _PENDING_METRIC_DATASETS and not tt_metric_kind(dataset)) + or kind not in _PENDING_METRIC_KINDS or not handle): + continue + key = (sid, dataset, kind, handle) + if key in seen: + continue + seen.add(key) + out.append({"snapshotId": sid, "datasetId": dataset, "kind": kind, + "influencer": handle, "requestedAt": str(item.get("requestedAt") or ""), + "lastChecked": str(item.get("lastChecked") or ""), + "lastNote": _s(item.get("lastNote"), 160)}) + return out + + +def queue_pending_metric_snapshots(rt, auto_id, pending): + """Durably retain new engagement snapshots without creating another paid provider request.""" + aid = str(auto_id or "").strip() + if not aid: + return 0 + incoming = _pending_metric_tasks({"state": {"pendingMetricSnapshots": pending}}) + if not incoming: + return 0 + added = [0] + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + definition = cur.get(aid) + if not isinstance(definition, dict): + return cur + state = definition.setdefault("state", {}) + existing = _pending_metric_tasks(definition) + known = {(x["snapshotId"], x["datasetId"], x["kind"], x["influencer"]) + for x in existing} + for task in incoming: + key = (task["snapshotId"], task["datasetId"], task["kind"], task["influencer"]) + if key not in known: + existing.append(task) + known.add(key) + added[0] += 1 + state["pendingMetricSnapshots"] = existing + return cur + + _store_update(rt, _up, flush="sync") + return added[0] + + +def _match_profile_row(rows, handle): + """THIS handle's row out of a snapshot that may now hold several. `None` when it is not there. + + ⭐⭐ 2026-08-09 — `rows[0]` WAS SAFE ONLY WHILE EVERY SNAPSHOT HELD EXACTLY ONE PROFILE. With + `_prime_enrich_batch` buying five URLs per call, one snapshot id serves up to five records, and + taking the first row would write ONE profile's followers onto all of them — a wrong number that + looks exactly like a right one. Identity comes off the row (`account`/`username`), which is the + same field `_bd_profile` reads first. + + ⚠ THE SINGLE-ROW FALLBACK IS DELIBERATE AND NARROW. Every task queued before this change points + at a one-URL snapshot, and some of those rows have an unreadable identity; for exactly that + shape the task's own handle is still the best evidence. It applies only when the snapshot holds + ONE row, so it can never mis-assign inside a batch. + """ + want = str(handle or "").strip().lstrip("@").lower() + usable = [n for n in (rows or []) if isinstance(n, dict)] + for node in usable: + got = str(_first(node, "account", "username", default="") or "").strip() + if got.lstrip("@").lower() == want and want: + return node + if len(usable) == 1: + return usable[0] + return None + + +def _pending_profile_tasks(defn): + """Validated, deduplicated deferred PROFILE snapshots from automation state. + + ⭐⭐ ITS OWN LIST, NOT `pendingMetricSnapshots`, and the separation is load-bearing rather + than tidy. `_pending_metric_tasks` filters on `datasetId in {posts, reels, comments}` and + `kind in {posts, comments}` — so a profile entry appended to that list is silently dropped to + zero by its own validator, and even if it survived, the collector would hand it to + `_write_collected_metric_rows`, a posts/comments writer that has nothing to do with a profile + row. Reusing the name would have shipped a green no-op of exactly the class this change + exists to remove. + + ⚠ A PROFILE TASK CARRIES ITS DESTINATION (`table` + `rowId`). A collected profile is written + back as PRESET CELLS onto the record that asked for it, so unlike a metric batch it cannot be + resolved from the handle alone: two databases may both hold `@x`. + """ + raw = ((defn or {}).get("state") or {}).get("pendingProfileSnapshots") or [] + out, seen = [], set() + for item in raw if isinstance(raw, list) else []: + if not isinstance(item, dict): + continue + sid = str(item.get("snapshotId") or "").strip() + dataset = str(item.get("datasetId") or "").strip() + handle = str(item.get("influencer") or "").strip().lstrip("@").lower() + table = str(item.get("table") or "").strip() + row_id = str(item.get("rowId") or "").strip() + # ⛔ `sd_` ONLY. A `snap_…` corpus id sent to `/datasets/v3/…` is a flat 404 about a + # snapshot that is alive (§2c), and a malformed id must never be handed back to a vendor + # endpoint at all. + if (not sid.startswith("sd_") or dataset != BD_DS_PROFILES or not handle + or not table or not row_id): + continue + if sid in seen: + continue + seen.add(sid) + out.append({"snapshotId": sid, "datasetId": dataset, "kind": "profile", + "influencer": handle, "table": table, "rowId": row_id, + "requestedAt": str(item.get("requestedAt") or ""), + "lastChecked": str(item.get("lastChecked") or ""), + "lastNote": _s(item.get("lastNote"), 200)}) + return out + + +def queue_pending_profile_snapshots(rt, auto_id, pending): + """Durably retain deferred profile snapshots. Starts no new paid request. Returns how many + were newly added.""" + aid = str(auto_id or "").strip() + if not aid: + return 0 + incoming = _pending_profile_tasks({"state": {"pendingProfileSnapshots": pending}}) + if not incoming: + return 0 + added = [0] + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + definition = cur.get(aid) + if not isinstance(definition, dict): + return cur + state = definition.setdefault("state", {}) + existing = _pending_profile_tasks(definition) + known = {x["snapshotId"] for x in existing} + for task in incoming: + if task["snapshotId"] not in known: + existing.append(task) + known.add(task["snapshotId"]) + added[0] += 1 + state["pendingProfileSnapshots"] = existing + return cur + + _store_update(rt, _up, flush="sync") + return added[0] + + +def collect_pending_profile_snapshots(rt, defn, username="automation", log=print, step=_no_step): + """Finish deferred PROFILE reads the tenant has already paid for. Starts no new scrape. + + ⭐⭐ 2026-08-09 — THE HALF THAT DID NOT EXIST. `bd_scrape` has always accepted a `deferred` + list, and every post/reel/comment call passed one; the PROFILE call did not, so a profile the + vendor took longer than `BD_SCRAPE_WAIT` to collect was billed and its snapshot id thrown + away — on every run, forever. MEASURED on nurilab: `collection_duration` 320 s against a + 180 s budget, two abandoned `sd_…` snapshots in two runs. + + ⛔ IT CLOSES A TASK THAT FINISHED EMPTY. A pending entry that can never resolve is the same + forever-loop wearing a different mask, so `bd_snapshot_progress` deciding `done` with zero + records ends the task with the vendor's reason attached — and, when the vendor blames the + target rather than itself, records the not-found verdict so the handle stops being re-bought. + """ + tasks = _pending_profile_tasks(defn) + if not tasks: + return ("ok", "No pending profile reads.", {}, [], {"source": "idle"}) + step(f"Collecting {len(tasks)} deferred profile read{'' if len(tasks) == 1 else 's'}") + remaining, patches, affected, notes = [], {}, [], [] + acc = {"snaps": [], "posts": [], "psnaps": [], "comments": [], "pending": [], + "pendingProfiles": [], "gone": {}, "profiles": 0, "ok": 0, "blocked": 0, + "notes": [], "dry": False} + ready = waiting = closed = 0 + # ⭐ ONE SNAPSHOT, ONE STATUS CALL, ONE FETCH. A batched read gives several tasks the SAME + # `snapshotId`, and asking the vendor about it once per task would spend the round trips the + # batching just saved. Memoised for this collection pass only — a snapshot's state must be + # re-read on the NEXT run, which is a fresh call and a fresh dict. + _progress_memo, _rows_memo = {}, {} + + def _progress_of(sid): + if sid not in _progress_memo: + _progress_memo[sid] = bd_snapshot_progress(sid) + return _progress_memo[sid] + + def _store_profile(task, profile, via): + """Write ONE collected profile — snapshot row + preset cells on the record that asked. + + ONE implementation, TWO callers (the primary snapshot and the backup rung below), so the + two paths cannot drift into writing different things — the same rule `top_up_views` + follows for the inline/deferred split. + """ + res = {"state": "ok", "profile": profile, "posts": [], "comments": [], + "via": via, "note": ""} + pulled = _iso() + snap_row, idents, metric_rows, comment_rows = capture_rows(res, pulled) + acc["snaps"].append(snap_row) + acc["posts"].extend(idents) + acc["psnaps"].extend(metric_rows) + acc["comments"].extend(comment_rows) + _act_row_patch(patches, task["table"], task["rowId"], preset_cells(res, pulled)) + affected.append(task["rowId"]) + + def _backup_profile(handle): + """The second rung, asked ONLY once the first has finished and delivered nothing. + + ⭐ 2026-08-09 (owner: *"when it errors like this, just route to APIfy"*). THE GAP THIS + CLOSES: `pull_profile` has walked `ig_profile -> (primary, backup)` since 2026-08-08, but + a profile that DEFERRED never came back through `pull_profile` — it came back here, and + this collector had no second rung at all. So the one path where the primary is most + likely to have failed was the one path with no fallback. + + ⛔ NEVER A TOP-UP. It runs only in the finished-and-empty branch, so a profile the primary + answered is never re-bought from a second vendor — the mistake the capability split exists + to prevent. A refusal returns None and the caller reports blocked exactly as before. + """ + try: + import providers as _p + if not _p.PROVIDERS["apify"].can("ig_profile"): + return None + prof, note = apify_profile(str(handle)) + except Exception as exc: # noqa: BLE001 — a backup must not raise + log(f"[aios-auto] backup profile rung failed for @{handle}: " + f"{type(exc).__name__}: {exc}") + return None + if prof and prof.get("followers") is not None: + return prof + return None + + def _rows_of(sid): + if sid not in _rows_memo: + payload, err = bd_call(f"{BD_PATH_SNAPSHOT}/{sid}", {"format": "json"}) + got = _bd_rows(payload) if not err else [] + if got and (_bd_deferral(payload) or + (len(got) == 1 and str(got[0].get("status") or "") in + ("running", "building", "collecting"))): + got = [] + _rows_memo[sid] = got + return _rows_memo[sid] + + for task in tasks: + stale_h = _hours_since(task.get("requestedAt")) + state, records, empty_note = _progress_of(task["snapshotId"]) + if state in ("done", "failed") and not records: + acc["profiles"] += 1 + # ⭐ ASK THE BACKUP BEFORE GIVING UP. The primary has FINISHED and delivered nothing, + # so there is nothing left to wait for and no risk of buying the same record twice. + backup = _backup_profile(task["influencer"]) + if backup is not None: + ready += 1 + acc["ok"] += 1 + _store_profile(task, backup, "apify") + note = (f"@{task['influencer']}: the primary source returned nothing, so a backup " + f"source supplied the profile ({backup.get('followers')} followers)") + notes.append(note) + acc["notes"].append(note) + continue + closed += 1 + acc["blocked"] += 1 + note = f"@{task['influencer']}: {_s(empty_note, 240)}" + notes.append(note) + acc["notes"].append(note) + # ⭐ `failed` = the vendor finished, collected nothing, and blamed the TARGET. On a + # profile request that means the account could not be reached at all, so the verdict + # is remembered and the selection stops re-buying it (R9: permanently, until a + # human edits the handle cell — see `clear_gone`). + # ⚠ `done`-with-zero is NOT remembered: "we found no matches" is a statement about + # the query, and turning it into "this account does not exist" would silently retire + # live handles. + if state == "failed": + acc["gone"][_gone_key({}, task["influencer"])] = { + "at": _iso(), "handle": task["influencer"], "note": _s(empty_note, 300)} + continue + rows = _rows_of(task["snapshotId"]) if state != "running" else [] + if not rows: + # ⚠ BOUNDED. A snapshot the vendor never finishes must not be polled until the end of + # time; after `PENDING_PROFILE_MAX_HOURS` it is dropped WITH a sentence, never + # silently. An unbounded queue is the forever-loop this change removes, inverted. + if stale_h is not None and stale_h >= PENDING_PROFILE_MAX_HOURS: + closed += 1 + note = (f"@{task['influencer']}: the source never finished the profile read " + f"queued {int(stale_h)}h ago ({task['snapshotId']}). It was dropped; " + f"the next run will ask again") + notes.append(note) + acc["notes"].append(note) + continue + waiting += 1 + remaining.append({**task, "lastChecked": _iso(), + "lastNote": _s("still building", 200)}) + continue + # ⛔ THIS HANDLE'S ROW, NOT THE FIRST ONE — see `_match_profile_row`. A batched snapshot + # holds several profiles and `rows[0]` would write one creator's numbers onto every record + # in the chunk. + node = _match_profile_row(rows, task["influencer"]) + if node is None: + closed += 1 + acc["profiles"] += 1 + acc["blocked"] += 1 + note = (f"@{task['influencer']}: the source delivered {len(rows)} profile" + f"{'' if len(rows) == 1 else 's'} for that batch, none of them this handle. " + f"it was dropped from the batch and the next run will ask again") + notes.append(note) + acc["notes"].append(note) + continue + ready += 1 + acc["profiles"] += 1 + profile = _bd_profile(node, task["influencer"]) + if profile.get("followers") is None and profile.get("following") is None: + acc["blocked"] += 1 + note = (f"@{task['influencer']}: the source delivered the profile but no " + f"follower/following counts were readable in it") + notes.append(note) + acc["notes"].append(note) + continue + acc["ok"] += 1 + _store_profile(task, profile, "brightdata:deferred") + notes.append(f"@{task['influencer']}: collected the profile the source had already been " + f"paid for ({profile.get('followers')} followers)") + + set_state(rt, str(defn.get("id") or ""), + {"pendingProfileSnapshots": remaining or None}) + counts = _enrich_flush(rt, defn, username, acc, log) + counts.pop(RUN_NOTES_KEY, None) # this function owns the note list below + counts.update(_commit_action_writes(rt, str(_flow_table(defn) or ""), patches, {}, + username, log)) + counts.update({"profileBatchesCollected": ready, "profileBatchesPending": waiting, + "profileBatchesEmpty": closed}) + if notes: + counts[RUN_NOTES_KEY] = notes + head = (f"{ready} deferred profile read{'' if ready == 1 else 's'} collected" + if ready else "no deferred profile read was ready") + tail = "".join([f"; {closed} finished with nothing to collect" if closed else "", + f"; {waiting} still building" if waiting else ""]) + state = "ok" if ready and not closed else "partial" + return (state, head + tail, counts, affected, + {"source": "ok" if ready else "partial", "write": "ok" if ready else "idle"}) + + +def _write_collected_metric_rows(rt, defn, username, idents, snapshots, comments, log): + """Write a completed metric snapshot through the canonical Post/Comment graph once.""" + if not (idents or snapshots or comments): + return {"posts": 0, "snapshots": 0, "comments": 0} + profile_table = str(_flow_table(defn) or "") + if not profile_table: + raise Refused("the pending engagement snapshot has no Profile database to link to") + graph = ensure_ig_graph(rt, username, str(defn.get("id") or ""), + profile_table=profile_table) + post_key, ps_key, comment_key = (graph[IG_POSTS_TABLE], graph[IG_POST_SNAPSHOTS_TABLE], + graph[IG_COMMENTS_TABLE]) + old_posts, collapsed_posts = dedupe_canonical_rows( + dict((ut_get(rt, post_key) or {}).get("rows") or {}), "shortcode", newest_by="measured_at") + posts, c_post = upsert_rows(old_posts, idents, "shortcode", cap=row_cap(post_key)) + c_post["duplicates"] += collapsed_posts + psnaps, c_ps = upsert_rows(dict((ut_get(rt, ps_key) or {}).get("rows") or {}), snapshots, + "post_snapshot_key", cap=row_cap(ps_key)) + old_comments, collapsed_comments = dedupe_canonical_rows( + dict((ut_get(rt, comment_key) or {}).get("rows") or {}), "comment_key") + comments_rows, c_comments = upsert_rows(old_comments, comments, "comment_key", + cap=row_cap(comment_key)) + c_comments["duplicates"] += collapsed_comments + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + for key, rows in ((post_key, posts), (ps_key, psnaps), (comment_key, comments_rows)): + if cur.get(key) is not None: + cur[key]["rows"] = rows + _refresh_relations_inplace(cur, log=log) + return cur + + rt.update(UT_STORE_KEY, _up, flush="sync") + try: + import ig_master + status, note = ig_master.append_run(getattr(rt, "key", ""), [], idents, snapshots) + if status == "error": + log(f"[aios-auto] deferred post metrics master copy FAILED: {note}") + except Exception as exc: # noqa: BLE001 + log(f"[aios-auto] deferred post metrics master copy FAILED: {type(exc).__name__}: {exc}") + return {"posts": c_post["inserted"], "snapshots": c_ps["inserted"], + "comments": c_comments["inserted"]} + + +def collect_pending_metric_snapshots(rt, defn, username="automation", log=print, step=_no_step): + """Collect deferred paid Post/Reel/Comment snapshots; never launches a new scrape request.""" + tasks = _pending_metric_tasks(defn) + if not tasks: + return ("ok", "No pending post-engagement snapshots.", {}, [], {"capture_posts": "idle"}) + step(f"Collecting {len(tasks)} post-engagement batch{'' if len(tasks) == 1 else 'es'}") + remaining, idents, snapshots, comments = [], [], [], [] + # ⭐ W30 · D-156 — TikTok's rows accumulate SEPARATELY and are written by TikTok's own writer. + # One queue can hold both networks' snapshots (they are keyed by dataset), but the two write + # paths address different tables and neither may touch the other's. + tt_collected = {"posts": [], "psnaps": [], "comments": []} + ready, waiting, closed, run_notes = 0, 0, 0, [] + for task in tasks: + # ⭐⭐ 2026-08-09 — ASK THE STATUS DOCUMENT, NOT THE ROWS. `building` used to be + # `not rows or …`, so a snapshot the vendor had FINISHED with zero records was re-queued + # as "still building" on every tick — forever, because nothing about it would ever + # change. That is the same forever-loop the profile path was measured in, one dataset + # over, and it was latent here the whole time. + state, records, empty_note = bd_snapshot_progress(task["snapshotId"]) + if state in ("done", "failed") and not records: + # ⛔ CLOSED, NOT RE-QUEUED. The vendor is finished and there is nothing to collect; + # keeping the entry would be a pending task that can never resolve. + closed += 1 + run_notes.append(f"{task['influencer']}: {_s(empty_note, 160)}") + continue + payload, note = bd_call(f"{BD_PATH_SNAPSHOT}/{task['snapshotId']}", {"format": "json"}) + rows = _bd_rows(payload) if not note else [] + building = (state == "running" or not rows or _bd_deferral(payload) or + (len(rows) == 1 and str(rows[0].get("status") or "") in + ("running", "building", "collecting"))) + if building: + remaining.append({**task, "lastChecked": _iso(), + "lastNote": _s(note or "still building", 160)}) + waiting += 1 + continue + ready += 1 + pulled = _iso() + # ⭐⭐ WAVE 30 · D-156 — THE TIKTOK ARM. Before this, `_PENDING_METRIC_DATASETS` was + # Instagram's three, so a TikTok media batch the vendor deferred was paid for and + # collected by NOTHING — the note carried the snapshot id and a human was the only + # collector. It routes through `connectors_tt`'s own mappers and `tt_capture_rows`, + # never Instagram's, because every one of those stamps `PLATFORM_INSTAGRAM`. + # ⛔ TWO THINGS THE INSTAGRAM ARM DOES THAT THIS ONE MUST NOT, both deliberate: + # * NO `top_up_views`. TikTok's `play_count` arrives inline on the posts row, so there + # is no views capability to route to; calling it would buy an Instagram permalink. + # * NO `ig_master.append_run`. There is no TikTok master by design (`_tt_enrich_flush`), + # and a write-through would put a TikTok creator into Instagram's pooled history, + # which the metric FIELDS read — a wrong number in a permanent series. + if tt_metric_kind(task["datasetId"]): + _tt = _tt_module() + who = task["influencer"] + if task["kind"] == "posts": + mapped = [m for m in (_tt.normalize_post(r) for r in rows) if m] + for m in mapped: + # The snapshot was bought from THIS profile's own permalinks, so the backlink + # is a fact of the call even when a vendor row omits the author field. + m.setdefault("influencer_key", who) + res_tt = {"profile": {"handle": who}, "posts": mapped, "comments": []} + else: + mapped = [m for m in (_tt.normalize_comment(r) for r in rows) if m] + res_tt = {"profile": {"handle": who}, "posts": [], "comments": mapped} + tt_idents, tt_metrics, tt_comments = tt_capture_rows(res_tt, pulled) + tt_collected["posts"].extend(tt_idents) + tt_collected["psnaps"].extend(tt_metrics) + tt_collected["comments"].extend(tt_comments) + continue + if task["kind"] == "posts": + mapped_posts = [] + for raw in rows: + post = _bd_post_metrics(raw) + if post: + # The snapshot was created from this Profile's canonical post URLs. Keep + # that explicit backlink even when a vendor response omits `user_posted`. + post["influencer_key"] = task["influencer"] + mapped_posts.append(post) + if mapped_posts: + # ⭐⭐ 2026-08-09 — THE VIEWS TOP-UP RUNS HERE TOO, and its absence is why the + # owner's `theresalearns` run filled every column except Views. + # + # ⛔ Bright Data is DECLARED INCAPABLE of `ig_post_views` (`providers.py`), so a + # Posts row physically cannot carry a view count — MEASURED on the stored + # payloads: 12 rows, `content_type: "Reel"`, and no view/play key in any of them. + # Views only ever comes from the Apify capability. That top-up lived INSIDE + # `pull_profile_bd`, so it ran only when the Posts scrape answered within the wait + # budget; when the batch deferred — which is routine, and what happened here — the + # rows came back through THIS function and Apify was never asked. + # ⇒ `top_up_views` is now one function with two callers rather than a copy, so + # the inline and deferred paths cannot answer this differently again. + # ⚠ It mutates `mapped_posts` in place and must run BEFORE `capture_rows`, which + # is what freezes the values into the post + snapshot rows. + v_note = top_up_views(mapped_posts, log=log) + if v_note: + run_notes.append(f"@{task['influencer']}: {v_note}") + _unused, post_rows, metric_rows, embedded = capture_rows( + {"state": "ok", "profile": {"username": task["influencer"]}, + "posts": mapped_posts, "comments": [], "via": "brightdata:deferred"}, pulled) + idents.extend(post_rows) + snapshots.extend(metric_rows) + comments.extend(embedded) + else: + for raw in rows: + comment = _bd_comment(raw, influencer_key=task["influencer"]) + if comment: + comments.append(comment) + + set_state(rt, str(defn.get("id") or ""), + {"pendingMetricSnapshots": remaining or None}) + written = _write_collected_metric_rows(rt, defn, username, idents, snapshots, comments, log) + counts = {"metricBatchesCollected": ready, "metricBatchesPending": waiting, + "metricBatchesEmpty": closed, + "postEngagementSnapshots": written["snapshots"], + "commentsCollected": written["comments"]} + # ⭐ W30 · D-156 — the TikTok write, through the SAME function the inline enrich uses, so a + # collected batch and an inline one cannot land differently. Its counts carry the `tt` prefix + # for the reason every other TikTok count does. + if any(tt_collected.values()): + tt_inserted, tt_capped, tt_missing = _tt_write_tables( + rt, str(defn.get("id") or ""), username, [], tt_collected["posts"], + tt_collected["psnaps"], tt_collected["comments"], log) + for key, name in ((TT_POSTS_TABLE, "ttPostsCollected"), + (TT_POST_SNAPSHOTS_TABLE, "ttPostSnapshotsCollected"), + (TT_COMMENTS_TABLE, "ttCommentsCollected")): + if tt_inserted.get(key): + counts[name] = tt_inserted[key] + if tt_capped: + counts["ttCollectCapped"] = tt_capped + log(f"[aios-auto] collect: {tt_capped} TikTok row(s) refused by a table's row cap") + if tt_missing: + log(f"[aios-auto] collect: could not create {', '.join(tt_missing)}") + # ⚠ THE RUNNER CONTRACT STAYS A 5-TUPLE and the notes ride in `counts` under the reserved + # `RUN_NOTES_KEY`, which `run_now` pops. Widening the tuple for one runner would make four + # other call sites disagree about the shape of a run — and `_commit_run` already drops + # non-numeric count values, so a pop that is ever missed degrades to today's behaviour rather + # than to a crash. + if run_notes: + counts[RUN_NOTES_KEY] = run_notes + # ⚠ THE EMPTY ONES ARE NAMED, not silently dropped. A batch that finished with no records is + # a real outcome the tenant paid for and it must read as an answer, not as a disappearance. + tail = (f"; {closed} finished with nothing to collect" if closed else "") + if waiting: + return ("partial", + f"{ready} post-engagement batch{'' if ready == 1 else 'es'} collected; " + f"{waiting} still building and will be collected automatically{tail}", + counts, [], {"capture_posts": "partial", "write": "ok"}) + return ("ok", f"{ready} post-engagement batch{'' if ready == 1 else 'es'} collected{tail}", + counts, [], {"capture_posts": "ok", "write": "ok"}) + + +def ai_decide(rt, defn, act, row, row_id="", log=print): + """R4/C6: let the model pick this card's next stage. Returns the chosen stage LABEL, or "" + to leave the card for a person. + + ⛔ FAIL-CLOSED IN EVERY DIRECTION: no provider configured, a network failure, a malformed + answer, or a label the review does not offer all return "" — and "" means the card sits at + the review gate exactly as it would with no AI at all. The feature can be broken, absent or + wrong and the worst outcome is a human doing the work. + + ⚠⚠ PARKED SINCE WAVE 27 — THIS FUNCTION HAS NO CALLER, AND THAT IS RECORDED RATHER THAN + ACCIDENTAL. Its one caller was the `review` branch of the action walk, deleted with the board + under R3, which keeps review "as an AI decision without lanes". What is parked is genuinely + worth parking: the cheap-first provider ladder in `ai_review` (groq → cerebras → openrouter → + anthropic), the fail-closed posture above, and the audit shape `review_audit` writes. What is + MISSING is only the door — an action kind that asks a question and takes an answer, without a + stage column to write it into. **Do not delete this in a dead-code sweep without reading that + sentence first**; equally, do not treat it as shipped — nothing reaches it today. + """ + cfg = act.get("config") or {} + options = list(cfg.get("next") or []) + if not options: + return "" + try: + import ai_review + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] ai review unavailable: {type(e).__name__}: {e}") + return "" + fields = [f.get("key") for f in + (ut_get(rt, ((defn.get("config") or {}).get("targetTable") + or (defn.get("trigger") or {}).get("table") or "")) or {} + ).get("fields") or []] + choice, meta = ai_review.decide(prompt=cfg.get("prompt") or "", options=options, + row=row, fields=[f for f in fields if f], + label=cfg.get("label") or "Review") + if not choice: + if meta.get("problem"): + log(f"[aios-auto] ai review declined to answer: {meta['problem']}") + return "" + review_audit(rt, defn.get("id"), row_id, cfg.get("label") or "Review", + choice, meta.get("provider") or "ai", by="ai", + note=meta.get("reason") or "", model=meta.get("model") or "") + return choice + + +def find_records(rt, table_key, cond, limit=25): + """The `find_records` action's read: matching row ids, bounded and DISCLOSED (the caller puts + the count in the run log, where it drills — [[no-unverifiable-aggregates]]).""" + rows = (ut_get(rt, str(table_key or "")) or {}).get("rows") or {} + out = [] + for rid, row in rows.items(): + if lane_match(cond, row or {}): + out.append(str(rid)) + if len(out) >= max(1, min(int(limit or 25), FIND_LIMIT_MAX)): + break + return out + + +def _commit_action_writes(rt, table, patches, creates, username, log): + """The ONE write. Row patches merge into the target's rows; creates land in their own tables — + APPENDED, or UPSERTED on the action's `uniqueOn` key (C5 / owner ruling R1a). + + Returns the REALIZED create counts, and returning them is the point rather than a + convenience. `apply_actions` counts an ATTEMPT per create while it walks the records; only + this function knows how many of those became rows, how many matched one that was already + there, and how many the cap refused. A run that reported the attempt as "created" would be + the summary-disagrees-with-what-happened defect this module names in three other places. + + ⛔ THE UPSERT IS `upsert_rows`, NOT A MATCH LOOP WRITTEN HERE. D-6 closed on "ONE + implementation repo-wide" after a second one was deleted from `core/user_tables.py`; hand + rolling a third inside this function is that debt returning with a new name — and it would + quietly diverge on the two rules that took a wave each to get right (an orphan is COUNTED, + NEVER DELETED, and `capped` is its own count rather than folded into `skipped`). + """ + out = {"created": 0, "createUpdated": 0, "createUnchanged": 0, + "createCapped": 0, "createSkipped": 0} + if not patches and not creates: + return out + # ⭐ PATCHES ARE STAGED, NOT WRITTEN YET (2026-08-06). They used to commit here, one + # `ut_write_rows` per patched table, and that was free while the only patcher was a review + # gate on a table no create action touched. Owner item 1 stamps EVERY walked record with the + # step it reached, so the flow's own table is now patched on essentially every run — and a + # flow that also creates into that same table would have committed it TWICE per run, against + # the 20 s flush floor and the 256-commits/hr repo budget this module is shaped around. + # Staged into `staged` and handed to the creates pass, which writes each table exactly once. + staged = {} + for tkey, rowpatch in (patches or {}).items(): + cur = dict((ut_get(rt, tkey) or {}).get("rows") or {}) + for rid, vals in rowpatch.items(): + cur[str(rid)] = {**(cur.get(str(rid)) or {}), **vals} + staged[str(tkey)] = cur + # ⛔ ONE WRITE PER TABLE, even when several actions target it under different keys. The + # accumulator is keyed by (table, uniqueOn), so a naive loop would call `ut_write_rows` once + # per GROUP — two store commits for one table, against the 20 s flush floor and the 256/hr + # repo budget this whole module is shaped around. + by_table = {} + for (tkey, unique), new_rows in (creates or {}).items(): + by_table.setdefault(str(tkey), []).append((str(unique or ""), new_rows)) + for tkey, groups in by_table.items(): + t = ut_get(rt, tkey) + if t is None: + n = sum(len(r) for _u, r in groups) + log(f"[aios-auto] create_record: {tkey} no longer exists. {n} skipped") + out["createSkipped"] += n + continue + # The PATCHED rows when this table was also stamped this run, so the creates land on top + # of the stamp rather than on a copy of the store that predates it. + cur = staged.pop(tkey, None) + cur = dict(t.get("rows") or {}) if cur is None else cur + cap = row_cap(tkey) + for unique, new_rows in groups: + if unique: + cur, c = upsert_rows(cur, new_rows, unique, cap=cap) + out["created"] += c["inserted"] + out["createUpdated"] += c["updated"] + out["createUnchanged"] += c["unchanged"] + out["createCapped"] += c["capped"] + # ⚠ `skipped` here means "this row had no value for the unique key", which for a + # create action is a mapped value that interpolated to nothing — worth surfacing, + # because the symptom is otherwise a run that says it created less than it walked. + out["createSkipped"] += c["skipped"] + if c["capped"]: + log(f"[aios-auto] create_record: {tkey} at its {cap}-row cap. " + f"{c['capped']} row(s) not written") + continue + nxt = max([int(r) for r in cur if str(r).isdigit()] or [0]) + 1 + for i, vals in enumerate(new_rows): + if len(cur) >= cap: + # THE [:N] HONESTY RULE ([[no-unverifiable-aggregates]]): the number DROPPED is + # named, here and in the run's counts. This used to `break` with a log line + # that said the cap was hit and never said how much was lost. + out["createCapped"] += len(new_rows) - i + log(f"[aios-auto] create_record: {tkey} at its {cap}-row cap. " + f"{len(new_rows) - i} row(s) not written") + break + cur[str(nxt)] = dict(vals) + nxt += 1 + out["created"] += 1 + ut_write_rows(rt, tkey, cur) + # Whatever the creates pass did NOT claim: tables this run only STAMPED. Written last and + # once each, so "one store write per table" holds whether a table was patched, created into, + # or both. + for tkey, cur in staged.items(): + ut_write_rows(rt, tkey, cur) + return out + + +def _lane_sentence(cond, top=True): + """One condition tree → the sentence a step's `detail` carries on the canvas. + + C4: a GROUP renders as its children joined by "and"/"or" and parenthesised when nested, so a + label never claims a flat comparison the tree does not make. The SERVER composes it, for the + same reason it composes every other `detail` — a client paraphrase of a structure the engine + evaluates is a second implementation of the same sentence, free to drift from it. + + ⚠ The name is board-era ("lane") and the board is gone; the caller is `graph()`, which is + live. Renamed nothing on purpose: this string is compared in a gate and read in a log, and a + rename would be churn on a working function to fix a word. + """ + if cond is None: + return "Everything else" if top else "" + if isinstance(cond, dict): + for key, joiner in (("all", " and "), ("any", " or ")): + if key in cond: + parts = [_lane_sentence(c, False) for c in cond.get(key) or []] + parts = [p for p in parts if p] + if not parts: + return "" + inner = joiner.join(parts) + return inner if top or len(parts) == 1 else f"({inner})" + v = cond.get("value") + return f"{cond.get('field')} {cond.get('op')}" + ("" if v is None else f" {v}") + + +def _rid_num(rid): + return int(rid) if str(rid).isdigit() else 10 ** 9 + + +#: How many review decisions an automation remembers. Bounded like `runs` — an audit that can +#: grow a definition without limit is a serialisation cost wearing a compliance hat. +MAX_REVIEWS = 100 + + +def review_audit(rt, auto_id, row_id, from_label, to_label, username, + by="user", note="", model=""): + """C3-A2(5): a review decision is AUDITED — who moved which card where, when. Appended to + the definition (newest first, bounded). + + ⭐ WAVE 23 (R4/C6): an AI decision writes THE SAME ROW with `by: "ai"` plus the model that + made it and the one-line reason it gave. One audit log, not two — a reader asking "who + decided this card" must not have to know there are two places to look, and the moment an + AI decision is invisible beside a human one the log stops being an audit. + + ⚠ PARKED SINCE WAVE 27, with `ai_decide` and for the same reason. Both of its doors are gone: + `move_card` was deleted with the board (R3), and the grid door in `core.grid_events` wrote + this shape off a stage field's `flowId` — a field the migration now drops. Kept because the + SHAPE is the contract a future decision action would write, and re-deriving an audit format + is how two of them end up existing. + """ + entry = {"ts": _iso(), "user": _s(username, 80), "rowId": str(row_id), + "from": _s(from_label, 60), "to": _s(to_label, 60), + "by": "ai" if by == "ai" else "user"} + if note: + entry["note"] = _s(note, 300) + if model: + entry["model"] = _s(model, 60) + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + d = cur.get(str(auto_id)) + if d is not None: + d["reviews"] = ([entry] + list(d.get("reviews") or []))[:MAX_REVIEWS] + return cur + + _store_update(rt, _up, flush="sync") + + +# --------------------------------------------------------------------------------------------- +# METRIC FIELDS (wave 22, contract C7) + THE SUBJECT PURGE (D-24) +# --------------------------------------------------------------------------------------------- +# A metric field is `{measure, window, agg}` over the MASTER series (C6's pooling benefit — +# the value reflects every pull the platform has, not just this tenant's). Computed by the +# ENGINE and stored as machine cells (C7 amendment: run+tick time — the ut wire lives in files +# no session owns this wave), human-write-refused at the grid door, and every number drills to +# the exact snapshot rows behind it. +# +# ⛔ NO DATA IS BLANK, NEVER ZERO. A handle the master has never seen, a window with no +# snapshots in it, a post series with no measured engagement — all read as an EMPTY cell. Zero +# is a measurement ("they have none"); blank is an admission ("we have not looked / it was not +# readable") — the `_bd_posts_count` law, one layer up. + +METRIC_MEASURES = ("followers", "avg_engagement", "likes", "comments") +METRIC_WINDOWS = ("latest", "last_3_posts", "last_7d", "last_30d") +#: Which measures read the PROFILE series vs the POST series — and which windows/aggs each +#: side can answer. A profile count over `last_3_posts` is a question the data cannot answer; +#: refused at clean time, never bent (mirrored in `core.user_tables`, gate-pinned). +PROFILE_MEASURES = ("followers", "avg_engagement") +METRIC_AGGS = ("avg", "sum", "latest") + + +def metric_value(series, measure, window, agg="", today=None): + """One metric over one handle's master series → `(value_string_or_None, drill_rows)`. + + `today` is a PARAMETER ([[date-window-vocabulary]]) — the caller decides the reference + day; post-count windows count back from the newest post. `drill_rows` are the exact rows + the number came from, so the route can honour [[no-unverifiable-aggregates]] without + recomputing differently.""" + series = series or {} + today = today or _now() + if measure in PROFILE_MEASURES: + rows = [r for r in series.get("snapshots") or [] + if str(r.get(measure) if r.get(measure) is not None else "").strip() != ""] + if window in ("last_7d", "last_30d"): + days = 7 if window == "last_7d" else 30 + floor = today - _dt.timedelta(days=days) + rows = [r for r in rows + if (_parse_iso(r.get("pulled_at")) or _dt.datetime.min) >= floor] + if not rows: + return None, [] + if window == "latest" or agg == "latest": + picked = [rows[-1]] + else: + picked = rows + vals = [_lane_num(r.get(measure)) for r in picked] + vals = [v for v in vals if v is not None] + if not vals: + return None, [] + out = vals[-1] if (window == "latest" or agg == "latest") else \ + (sum(vals) if agg == "sum" else sum(vals) / len(vals)) + if measure == "avg_engagement": + # The vendor's rate is 0-1; the pct cell renders POINTS (the semantic-pct vs + # transform-pct scar) — scaled exactly once, here. + return f"{out * 100:.2f}", picked + return (f"{out:.0f}" if float(out).is_integer() else f"{out:.2f}"), picked + # --- post measures: select POSTS by window, then each post's LATEST measured snapshot. + posts = [p for p in series.get("posts") or [] if str(p.get("posted_at") or "").strip()] + posts.sort(key=lambda p: str(p.get("posted_at"))) + if window == "latest": + picked_posts = posts[-1:] + elif window == "last_3_posts": + picked_posts = posts[-3:] + else: + days = 7 if window == "last_7d" else 30 + floor = today - _dt.timedelta(days=days) + picked_posts = [p for p in posts + if (_parse_iso(str(p.get("posted_at")).replace(" ", "T")) + or _dt.datetime.min) >= floor] + vals, drill = [], [] + for p in picked_posts: + snaps = (series.get("postSnapshots") or {}).get(str(p.get("shortcode") or "")) or [] + for snap in reversed(snaps): + raw = snap.get(measure) + v = _lane_num(raw) + if v is not None and str(raw).strip() != "": + vals.append(v) + drill.append(snap) + break + if not vals: + return None, [] + out = sum(vals) if (agg or "sum") == "sum" else \ + (vals[-1] if agg == "latest" else sum(vals) / len(vals)) + return (f"{out:.0f}" if float(out).is_integer() else f"{out:.2f}"), drill + + +def metric_fields_of_table(t): + return [f for f in ((t or {}).get("fields") or []) if isinstance(f.get("metric"), dict)] + + +def _table_handle(row, url_field): + return str(row.get("handle") or ig_handle(str(row.get(url_field or "") or "")) + or "").strip().lower() + + +def compute_metric_cells(rt, table_key, today=None): + """Recompute every metric cell on ONE table from the master series. One coalesced write, + only when something actually changed (the flush-ceiling law); zero reads when the table + has no metric fields or the master is off. Returns the number of rows touched.""" + t = ut_get(rt, table_key) + mfields = metric_fields_of_table(t) + if not mfields: + return 0 + import ig_master + if not ig_master.configured(): + return 0 + url_field = next((f.get("key") for f in (t.get("fields") or []) + if f.get("type") == "url"), "") + rows = t.get("rows") or {} + handles = {rid: _table_handle(row or {}, url_field) for rid, row in rows.items()} + series = ig_master.series_for({h for h in handles.values() if h}) + changes = {} + for rid, row in rows.items(): + s = series.get(handles.get(rid) or "") + for f in mfields: + bag = f["metric"] + val, _drill = (metric_value(s, bag.get("measure"), bag.get("window"), + bag.get("agg") or "", today=today) + if s else (None, [])) + want = "" if val is None else str(val) + if str((row or {}).get(f["key"], "")) != want: + changes.setdefault(str(rid), {})[f["key"]] = want + if not changes: + return 0 + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + tt = cur.get(table_key) + if tt is not None: + for rid, vals in changes.items(): + tt.setdefault("rows", {}).setdefault(rid, {}).update(vals) + return cur + + rt.update(UT_STORE_KEY, _up, flush="sync") + return len(changes) + + +# ── ⭐⭐ THE RELATIONAL PASS (2026-08-07) — derived LINK cells and ROLLUP cells ──────────────── +# +# Owner: *"adding relational database function with links and rollups… exactly like how Airtable +# does it… and this rollup needs to have formula that we can use to calculate things like average +# Views over last N posts."* +# +# ⛔ WHY THIS IS SERVER-SIDE AND MATERIALISED, when `formula` is client-side and is not. A formula +# reads ONE ROW; a rollup reads ANOTHER TABLE'S ROWS, which the client has not loaded and must not +# have to. So this rides `compute_metric_cells`' pattern exactly — recompute, diff, ONE coalesced +# write only when something changed — and inherits its flush-ceiling discipline for free. +# +# ⭐ AND R1's "ONE STORE FOR ONE SERIES" SURVIVES, which is the thing to check before touching +# this. The authoritative post record is `ut_ig_posts`; the authoritative engagement series is +# `ut_ig_post_snapshots`. A rollup cell is a PROJECTION refreshed from them — the same standing as +# a `metric` cell, and the same standing as the `posts` json window (which is even allowed to SHED +# posts to fit, precisely because the store still holds them). ⛔ A rollup may only ever READ. The +# moment one writes a number nothing else can re-derive, it has become a third copy. + +def _ut(): + """`core.user_tables`, imported LAZILY — and the laziness is measured, not stylistic. + + Pulling this module in at import time initialises the store layer earlier than + `automation_engine` used to, and the last time that happened it moved a store-commit COUNT + from three to four on an unrelated gate. The relational pass + needs three constants and two predicates from the field layer; it does not need to change + when this module is imported. + """ + import core.user_tables as _m + return _m + + +#: The fns by family — how a value is folded, and what a blank means in each. +_ROLLUP_NUM_FNS = frozenset({"sum", "average", "stdev", "min", "max"}) +_ROLLUP_BOOL_FNS = frozenset({"and", "or", "xor"}) +#: The count/order family — folded by their own arms above the lanes. +_ROLLUP_SEQ_FNS = frozenset({"countall", "counta", "count", "latest"}) +#: The text family. ⛔ IT IS A NAMED SET NOW AND IT USED TO BE THE FALL-THROUGH, which is how +#: wave 28 nearly shipped a wrong number that looked like data: `stdev` validated and STORED +#: (`core.user_tables.ROLLUP_FNS`) a commit before this function learned it, and an fn no arm +#: claims fell past both lanes into the join below — so a "Std deviation" column rendered +#: `"137684, 19561, 8123"`. Filled, plausible, and not a statistic. An unrecognised fn now +#: returns `""` ([[gate-answers-the-wrong-question]]: blank is the honest answer to a question +#: nothing can answer; a comma-joined list is a different question's answer wearing this label). +_ROLLUP_TEXT_FNS = frozenset({"concatenate", "arrayjoin", "arraycompact", "arrayunique"}) +#: ⭐⭐ WHAT THIS FOLD ACTUALLY IMPLEMENTS, DERIVED FROM THE ARMS RATHER THAN RESTATED. +#: `verify_automation` asserts this is IDENTICAL to `core.user_tables.ROLLUP_FNS` name for name +#: (contract C1's parity leg), so the validator can never again accept a function the fold cannot +#: compute — in EITHER direction. A hand-listed copy in the gate would have gone green on the +#: defect above, because the defect was that the two lists already disagreed. +ROLLUP_FOLD_FNS = frozenset(_ROLLUP_NUM_FNS | _ROLLUP_BOOL_FNS | _ROLLUP_SEQ_FNS + | _ROLLUP_TEXT_FNS) +_ROLLUP_TEXT_FNS = frozenset({"concatenate", "arrayjoin", "arraycompact", "arrayunique"}) +#: What `arrayjoin` puts between values. Airtable uses ", "; `concatenate` uses nothing. +_ROLLUP_JOIN = ", " +#: A rollup's own cell ceiling. The text fns can concatenate a whole column into one cell, and a +#: cell nobody can read is not an aggregate. +ROLLUP_MAX_CHARS = 4000 + + +def _sort_key(value, ftype): + """One cell → a sortable key, typed by the LINKED field's declared type. `None` when the cell + is blank or does not parse as its declared type. + + ⛔ TYPE-AWARE ON PURPOSE. `posted_at` is a `date` and `views` is an `int`; sorting either as a + string puts `2026-9-1` before `2026-10-1` and `9` after `100`. Since `sortBy` is what decides + WHICH rows a `limit` keeps, getting this wrong does not mis-order a display — it silently + averages the wrong twelve posts. + + ⛔⛔ **BLANKS ARE PARTITIONED OUT BY THE CALLER, NEVER RANKED BY A FLAG — AND THE FLAG VERSION + SHIPPED BROKEN FOR ONE DEPLOY.** This returned `(1, 0.0, "")` for a blank and `(0, key, "")` + otherwise, documented as "a blank sorts LAST under desc". It does the OPPOSITE: `reverse=True` + flips the whole tuple, so `(1, …)` sorted FIRST and an undated row displaced the most recent + real one out of the window. A sentinel inside the key cannot mean "last" in both directions, + because the direction is applied to the sentinel too. + ⚠ AND IT WAS NOT A CORNER CASE ON THE RUNG THAT MATTERS: `wave20-split` MEASURED `datetime` as + `None` on 24/24 posts from the Profiles dataset, and `_bd_post_identity` writes `posted_at` + only when the vendor sent one — so on a paid profile pull EVERY post row is blank here, every + key tied, and `limit 12` took whatever twelve came first in dict order. The e2e test passed + because its fixture posts all carried dates. + ⇒ `None` means "not comparable", the caller keeps those rows at the END whichever way it + sorts, and "an unknown date is not a recent one" is finally what the code does. + """ + raw = "" if value is None else str(value).strip() + if raw == "": + return None + if ftype in ("int", "currency", "pct", "rating"): + n = _lane_num(raw) + return (n,) if n is not None else None + if ftype == "date": + d = _parse_iso(raw.replace(" ", "T")) + return (d.timestamp(),) if d is not None else None + return (raw.lower(),) + + +def _sample_stdev(nums): + """SAMPLE standard deviation (n-1) of `nums`, or None under two values. + + ⭐ ONE IMPLEMENTATION, TWO READERS, and that is the point of lifting four lines into a + function: `_rollup_fold` RENDERS this number into a "Std deviation" column and + `_rollup_ref_threshold` COMPARES rows against it inside a `sigmas` condition. A second copy + would let a column and the filter beside it disagree about the same word on the same set — + the exact drift `top_up_views` was extracted to prevent one module over + ([[one-evaluator-per-question]]). + ⛔ None means UNANSWERABLE and no caller may read it as 0. + """ + if len(nums) < 2: + return None + mean = sum(nums) / len(nums) + return (sum((n - mean) ** 2 for n in nums) / (len(nums) - 1)) ** 0.5 + + +def _rollup_ref_threshold(rows, field, sigmas): + """`mean + sigmas*stdev` of `field` over `rows` — a statistic OF THE SCOPED SET (contract C1). + + ⛔ `rows` MUST already be the scoped window: ranked by `sortBy`, deduped, and cut by `limit`, + and NOT yet filtered by the conditions this threshold feeds. That order is the contract + (`core.user_tables.ROLLUP_REF_OPS`'s note says so in as many words) and both other orders + produce a plausible number: computing it before `limit` answers "2 sigma of everything this + account ever posted" under a column that says "of the last 10", and computing it after the + filter makes the threshold depend on the rows it is choosing — a definition that chases + itself. + + Returns None when the window cannot answer — fewer than two numeric values in `field`. + ⛔ THE CALLER MUST DROP THE ROW, NOT KEEP IT. "Beyond 2 sigma of one post" is not a question + with a permissive answer; letting an unanswerable leaf pass everything would silently turn + "the outliers" into "all of them", which is this module's worst failure mode wearing a filter. + """ + nums = [n for n in (_lane_num((r or {}).get(field)) for r in rows) if n is not None] + sd = _sample_stdev(nums) + if sd is None: + return None + return (sum(nums) / len(nums)) + float(sigmas) * sd + + +def _rollup_fold(fn, values, ftype="text"): + """`values` (raw cells, in the order the window kept them) → the aggregate, as a STRING. + + Returns `""` for "nothing to aggregate", NEVER `0`. ⛔ That distinction is this module's + oldest law and it bites hardest here: `sum` over no linked records is not zero, it is a + question with no rows to answer it, and a 0 in an "Avg views" column reads as a measurement + that the creator gets no views. + ⚠ The ONE exception is the count family, where zero IS the answer — "how many linked records" + over an empty set is genuinely 0, not unknown. + + `ftype` is the SOURCE column's DECLARED type on the linked table (2026-08-10, D-92). Only + `min`/`max` read it today; it defaults to `text` so every existing caller and every test that + folds a bare list keeps its exact previous answer. + """ + if fn == "countall": + return str(len(values)) + if fn == "latest": + # Ordering belongs to the rollup bag (`sortBy` is mandatory for this function). Preserve + # a blank on the newest row rather than reaching backwards and presenting an older value + # as current. + return "" if not values or values[0] is None else str(values[0])[:ROLLUP_MAX_CHARS] + if fn == "counta": + return str(len([v for v in values if str(v or "").strip() != ""])) + if fn == "count": + # Airtable's COUNT counts NUMERIC values; COUNTA counts non-empty ones. Keeping them + # distinct is the whole reason both exist. + return str(len([v for v in values if _lane_num(v) is not None])) + # ⭐⭐ 2026-08-10 — D-92 CLOSED: `min`/`max` OVER A DATE COLUMN. + # + # Both were numeric-only, so a rollup over `posted_at`, `due_date` or `order_date` rendered + # BLANK forever while looking completely configured — the failure this module refuses + # everywhere else, arriving through the one fold that had no type awareness. "Earliest order" + # and "latest invoice due" are the two most ordinary date rollups there are, and + # `odoo_relational` already ships `latest` over `due_date`, so the vocabulary claimed dates + # and the fold did not. + # + # ⛔ NOT FIXED BY SNIFFING THE VALUES. An ISO date sorts correctly as a string, so a + # string-compare fallback would have worked on well-formed data and silently mis-ordered a + # `Aug 5, 2026` or a `2026-9-1` — [[measure-the-real-call]]'s shape. The DECLARED type is + # already at this call site (`linked_types`), and `_sort_key` is already the one function that + # turns a typed cell into a comparable key, blanks partitioned out. This reuses both rather + # than growing a second idea of what a date is. + # ⚠ RETURNS THE CELL, NOT THE KEY. `_sort_key` yields a comparison tuple; the answer a person + # wants in the column is the stored date string exactly as the source row spells it. + # ⚠ `min`/`max` ONLY. `sum`/`average`/`stdev` over dates are not blank by oversight — the mean + # of two timestamps is a number this product has no column type for, and inventing one here + # would be a value with no author. + if fn in ("min", "max") and ftype == "date": + keyed = [(k, str(v)) for k, v in + ((_sort_key(v, "date"), v) for v in values) if k is not None] + if not keyed: + return "" + return (min(keyed) if fn == "min" else max(keyed))[1][:ROLLUP_MAX_CHARS] + if fn in _ROLLUP_NUM_FNS: + nums = [n for n in (_lane_num(v) for v in values) if n is not None] + if not nums: + return "" + if fn == "stdev": + # ⭐⭐ SAMPLE standard deviation (n-1), and the divisor is a ruling, not a preference + # (C1 / `core.user_tables.ROLLUP_FNS`'s note): a rollup folds the rows that happen to + # be LINKED, which is a sample of an account's posting history and not its entirety. + # ⚠ Fixture to check a refactor against: [2,4,4,4,5,5,7,9] -> 2.14. The POPULATION + # form gives 2.00 on the same input, so a test that ever reads 2.00 has silently + # switched divisors. + # ⛔ FEWER THAN TWO VALUES IS "" AND NEVER "0". n-1 = 0 would divide by zero, but the + # honest reason is upstream of the arithmetic: one measurement has no spread to + # report, and a 0 in a "Std deviation" column reads as PERFECT CONSISTENCY — the + # single most confident thing this column can say, asserted from a single row. Same + # law as the blank `sum`, and it bites harder here. + out = _sample_stdev(nums) + if out is None: + return "" + else: + out = (sum(nums) if fn == "sum" else min(nums) if fn == "min" + else max(nums) if fn == "max" else sum(nums) / len(nums)) + return f"{out:.0f}" if float(out).is_integer() else f"{out:.2f}" + if fn in _ROLLUP_BOOL_FNS: + # A checkbox cell is '1'/'' in this product, so truth is "non-blank and not a zero". + flags = [str(v or "").strip() not in ("", "0", "false", "False") for v in values] + if not flags: + return "" + hit = (all(flags) if fn == "and" else any(flags) if fn == "or" + else sum(1 for f in flags if f) % 2 == 1) + return "1" if hit else "" + # --- the text family. ⛔ CLAIMED BY NAME, NEVER BY FALL-THROUGH — see `_ROLLUP_TEXT_FNS`. + # An fn no arm above recognises returns "" rather than a comma-joined dump of every value, + # which is the shape a not-yet-implemented aggregate wore for one commit of wave 28. + if fn not in _ROLLUP_TEXT_FNS: + return "" + vals = [str(v).strip() for v in values if str(v or "").strip() != ""] + if fn == "arrayunique": + seen, uniq = set(), [] + for v in vals: + if v.lower() not in seen: + seen.add(v.lower()) + uniq.append(v) + vals = uniq + if not vals: + return "" + text = ("".join(vals) if fn == "concatenate" else _ROLLUP_JOIN.join(vals)) + return text[:ROLLUP_MAX_CHARS] + + +def _link_from_key(fields, bag): + """Which column on THIS table supplies the join value. + + Declared `from` wins; otherwise the PROFILE-flagged column, then the PINNED one. ⭐ That + fallback chain is what makes an Instagram database link up with no configuration at all — + the "automatically" in the owner's instruction — and it is the same chain the grid already + uses to decide a table's identity column, rather than a second opinion about it. + """ + declared = str((bag or {}).get("from") or "").strip() + if declared: + return declared + prof = next((f for f in fields if isinstance(f.get("profile"), dict)), None) + if prof: + return str(prof.get("key") or "") + pin = next((f for f in fields if f.get("pinned") is True), None) + return str(pin.get("key") or "") if pin else "" + + +def _linked_rows_by_join(linked, on_key): + """`{join value (lower-cased) -> [(row_id, row)]}` over one linked table, built ONCE. + + ⚠ Lower-cased because the join values this exists for are Instagram handles, which the + profile flag already normalises to lower case on one side and which a hand-typed cell on the + other side may not. A join that misses on case is a relation that silently reports zero. + """ + idx = {} + for rid, row in ((linked or {}).get("rows") or {}).items(): + k = str((row or {}).get(on_key) or "").strip().lower() + if k: + idx.setdefault(k, []).append((str(rid), row or {})) + return idx + + +def _rollup_condition_matches(row, condition, field_types, ref_value=None): + """Evaluate one Airtable-style linked-record condition against a candidate row. + + `ref_value` is the threshold a `ref: {sigmas}` leaf compares against, already computed by the + caller over the SCOPED set (`_rollup_ref_threshold`). ⛔ It is passed IN rather than computed + here because this function sees one row and the statistic is a property of the whole window — + a version that reached for the set from inside would be recomputing the same mean once per + row, and would have to be handed the window anyway. + ⚠ `None` means the window could not answer, and the leaf then matches NOTHING. See the + threshold helper for why the permissive reading is the dangerous one. + """ + field = str((condition or {}).get("field") or "") + op = str((condition or {}).get("op") or "") + raw = (row or {}).get(field) + text = str(raw or "").strip() + if op == "is_empty": + return text == "" + if op == "is_not_empty": + return text != "" + if (condition or {}).get("ref") is not None: + # ⛔ NUMERIC LANE ONLY, BOTH SIDES. The validator already restricts `ref` to the ordering + # ops, and a row whose cell is blank or unparseable has no position relative to a computed + # threshold — it is not "below" it. Dropping it is the same partition law `_sort_key` + # follows: unrankable is not a rank ([[sentinel-in-a-sort-key]]). + left_num = _lane_num(text) + if ref_value is None or left_num is None: + return False + return ((op == "gt" and left_num > ref_value) + or (op == "gte" and left_num >= ref_value) + or (op == "lt" and left_num < ref_value) + or (op == "lte" and left_num <= ref_value)) + wanted = str((condition or {}).get("value") or "").strip() + if op == "contains": + return wanted.casefold() in text.casefold() + if op == "not_contains": + return wanted.casefold() not in text.casefold() + if op in ("eq", "neq"): + left_num, right_num = _lane_num(text), _lane_num(wanted) + equal = (left_num == right_num if left_num is not None and right_num is not None + else text.casefold() == wanted.casefold()) + return equal if op == "eq" else not equal + ftype = (field_types or {}).get(field, "text") + left = _sort_key(text, ftype) + right = _sort_key(wanted, ftype) + if left is None or right is None: + return False + return ((op == "gt" and left > right) or (op == "gte" and left >= right) + or (op == "lt" and left < right) or (op == "lte" and left <= right)) + + +def compute_relation_cells(rt, table_key, tables=None): + """Recompute every DERIVED LINK cell and every ROLLUP cell on ONE table. Returns rows touched. + + Zero store reads when the table declares neither kind — the same cheap-by-construction shape + `compute_metric_cells` has, so walking every table on a tick costs a dict scan per table. + """ + store = tables if tables is not None else ut_all(rt) + t = (store or {}).get(table_key) + fields = list((t or {}).get("fields") or []) + links = [f for f in fields if _ut().is_derived_link(f)] + rollups = [f for f in fields if isinstance(f.get("rollup"), dict)] + if not links and not rollups: + return 0 + rows = (t or {}).get("rows") or {} + by_key = {str(f.get("key")): f for f in fields} + + # --- resolve every link field ONCE per table, not once per row. + # `resolved[link_key][row_id] = [(linked_row_id, linked_row), ...]` + resolved, linked_types = {}, {} + for f in links + [by_key.get(str((r.get("rollup") or {}).get("link"))) for r in rollups]: + lk_key = str((f or {}).get("key") or "") + if not lk_key or lk_key in resolved or not isinstance((f or {}).get("link"), dict): + continue + bag = f["link"] + linked = (store or {}).get(str(bag.get("table") or "")) or {} + linked_types[lk_key] = {str(lf.get("key")): str(lf.get("type") or "text") + for lf in (linked.get("fields") or [])} + if bag.get("inverse"): + # Airtable's reciprocal side: this row is linked to every SOURCE row whose ordinary + # link cell contains this row id. The source cell remains the one relationship truth. + source_rows = linked.get("rows") or {} + inverse_key = str(bag.get("inverse") or "") + inverse_index = {} + for source_id, source_row in source_rows.items(): + for target_id in [part.strip() for part in + str((source_row or {}).get(inverse_key) or "").split(",")]: + if target_id: + inverse_index.setdefault(target_id, []).append( + (str(source_id), source_row or {})) + resolved[lk_key] = {str(rid): inverse_index.get(str(rid), []) for rid in rows} + elif bag.get("on"): + idx = _linked_rows_by_join(linked, str(bag["on"])) + from_key = _link_from_key(fields, bag) + resolved[lk_key] = { + str(rid): idx.get(str((row or {}).get(from_key) or "").strip().lower(), []) + for rid, row in rows.items()} if from_key else {} + else: + # An ORDINARY link: the cell IS the relation. Ids the linked table no longer holds + # are dropped rather than carried — a link to a deleted row is not a link. + lrows = (linked.get("rows") or {}) + resolved[lk_key] = { + str(rid): [(i, lrows[i]) for i in + [s.strip() for s in str((row or {}).get(lk_key) or "").split(",")] + if i and i in lrows] + for rid, row in rows.items()} + + changes = {} + for rid, row in rows.items(): + rid = str(rid) + row = row or {} + for f in links: + fk = str(f["key"]) + hits = (resolved.get(fk) or {}).get(rid) or [] + if f["link"].get("single"): + hits = hits[:1] + # ⛔ THE CAP IS A DISPLAY CAP AND THE ROLLUPS DO NOT READ THROUGH IT. A derived cell + # is a projection of `resolved`, which is uncapped and is what every rollup below + # consumes — so a profile with 900 posts shows the first 500 ids and still averages + # over all 900. Same argument the `posts` window already makes: shedding is safe + # precisely because the authoritative store still holds everything. + want = ",".join(i for i, _r in hits[:_ut().LINK_MAX_IDS]) + if str(row.get(fk, "")) != want: + changes.setdefault(rid, {})[fk] = want + for f in rollups: + fk, bag = str(f["key"]), f["rollup"] + lk_key = str(bag.get("link") or "") + hits = list((resolved.get(lk_key) or {}).get(rid) or []) + # ⚠ A rollup whose link field does not exist (renamed, deleted) resolves to NOTHING + # and therefore to a blank cell — never to a stale number. A column that keeps + # printing yesterday's answer after its input is gone is the worst of the options. + # ⭐⭐ WAVE 28 / CONTRACT C1 — SCOPE FIRST, FILTER SECOND, AND THE TWO USED TO BE THE + # OTHER WAY ROUND. The conditions block stood HERE, above the ranking, so + # "last 10 posts where views > X" meant *the 10 most recent of the posts over X* + # rather than *the ones over X among the last 10* — two different windows wearing one + # sentence. Harmless while a threshold was a literal; incoherent the moment a + # threshold is a statistic OF the window, because the set being described and the set + # doing the describing would be different sets. + # ⛔ THIS ORDER IS THE CONTRACT, not an implementation choice: `core.user_tables`'s + # `ROLLUP_REF_OPS` note states it ("the scope picks the window, THEN the threshold is + # computed over that window, THEN the conditions filter it") and the validator half + # was written against it. + # ⚠ IT IS A BEHAVIOUR CHANGE FOR EXACTLY ONE SHAPE: a stored rollup carrying BOTH + # `conditions` AND `limit`. No shipped preset does (measured across `odoo_relational` + # and the IG presets — the one preset with conditions, `_OPEN_ONLY`, is a `countall` + # with no limit), so the blast radius is user-built rollups only. + # ⭐⭐ 2026-08-09 (owner) — THE PRE-FILTER, ABOVE THE RANKING. Owner: *"instead of last + # 12 posts, we also want to make it so its last N record, where the record's Status is + # video."* `conditions` cannot answer that: C1 moved them BELOW the window on purpose, + # so they select among the rows the window already kept. `where` selects WHICH rows + # the window is spent on. + # ⛔ ABOVE `distinctBy` TOO, not merely above the sort. Dedup keeps the first row per + # identity; run it first and a carousel could claim the slot its reel sibling needed, + # so the window would come up short for a reason nothing on screen explains. + # ⚠ NO `ref` REACHES HERE — `_clean_rollup` refuses a set-statistic threshold in this + # list, because at this point there is no fixed set for a statistic to be about. + where = list(bag.get("where") or []) + if where: + where_matches = lambda pair: [ + _rollup_condition_matches(pair[1], condition, + linked_types.get(lk_key) or {}, ref_value=None) + for condition in where] + if str(bag.get("whereConj") or "and") == "or": + hits = [pair for pair in hits if any(where_matches(pair))] + else: + hits = [pair for pair in hits if all(where_matches(pair))] + sort_by = str(bag.get("sortBy") or "") + if sort_by: + ftype = (linked_types.get(lk_key) or {}).get(sort_by, "text") + # ⛔ PARTITION, THEN SORT. A row whose sort cell is blank or unparseable is not + # rankable, and it must land at the END whichever direction is asked for — which + # a sentinel inside the sort key cannot do, because `reverse` flips the sentinel + # too (see `_sort_key`). Unrankable rows are appended, so a `limit` spends its + # window on rows that HAVE the value before it falls back to ones that do not. + keyed = [(pair, _sort_key(pair[1].get(sort_by), ftype)) for pair in hits] + rankable = [(p, k) for p, k in keyed if k is not None] + rankable.sort(key=lambda pk: pk[1], + reverse=str(bag.get("sortDir") or "desc") == "desc") + hits = [p for p, _k in rankable] + [p for p, k in keyed if k is None] + distinct_by = str(bag.get("distinctBy") or "") + if distinct_by: + seen, unique = set(), [] + for pair in hits: + identity = str(pair[1].get(distinct_by) or "").strip().lower() + # A blank is not an identity. Keep it rather than collapsing every unknown + # record into one synthetic duplicate. + if identity and identity in seen: + continue + if identity: + seen.add(identity) + unique.append(pair) + hits = unique + limit = int(bag.get("limit") or 0) + if limit: + hits = hits[:limit] + # --- the window is now FIXED, so a set-statistic threshold has a set to be about. + conditions = list(bag.get("conditions") or []) + if conditions: + # ⚠ RESOLVED ONCE PER LEAF, NOT ONCE PER ROW. The threshold is a property of the + # window; computing it inside `matches` would recompute the same mean for every + # candidate and — worse — would invite computing it over a set that the filter is + # already shrinking underneath it. + # ⚠ `.get("sigmas", 0.0)`, never `... or 0.0` — a legitimate `sigmas: 0` ("beyond + # the mean") is falsy, and the `or` spelling would silently rewrite it to the same + # number by accident. It reads identically and is right for the wrong reason, + # which is how it survives a review. + refs = [_rollup_ref_threshold( + [r for _i, r in hits], str(c.get("field") or ""), + (c.get("ref") or {}).get("sigmas", 0.0)) + if isinstance(c, dict) and c.get("ref") is not None else None + for c in conditions] + matches = lambda pair: [ + _rollup_condition_matches(pair[1], condition, + linked_types.get(lk_key) or {}, ref_value=ref) + for condition, ref in zip(conditions, refs)] + if str(bag.get("conditionConj") or "and") == "or": + hits = [pair for pair in hits if any(matches(pair))] + else: + hits = [pair for pair in hits if all(matches(pair))] + src = str(bag.get("field") or "") + # D-92 — the SOURCE column's declared type, read from the same `linked_types` map the + # sort path above uses. `countall` folds a synthetic `[1]*n` with no source column at + # all, so the default stands for it. + want = _rollup_fold(str(bag.get("fn") or ""), + [r.get(src) for _i, r in hits] if src else [1] * len(hits), + (linked_types.get(lk_key) or {}).get(src, "text")) + if str(row.get(fk, "")) != want: + changes.setdefault(rid, {})[fk] = want + if not changes: + return 0 + + # A run that just wrote linked rows passes its in-flight user_tables bucket here so the + # relation refresh joins the SAME coalesced commit. The standalone/tick path below keeps + # the public helper's old persist-on-change behaviour. + if tables is not None: + tt = tables.get(table_key) + if tt is not None: + for r, vals in changes.items(): + tt.setdefault("rows", {}).setdefault(r, {}).update(vals) + return len(changes) + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + tt = cur.get(table_key) + if tt is not None: + for r, vals in changes.items(): + tt.setdefault("rows", {}).setdefault(r, {}).update(vals) + return cur + + rt.update(UT_STORE_KEY, _up, flush="sync") + return len(changes) + + +def _refresh_relations_inplace(tables, log=print): + """Refresh every relation against one mutable user_tables bucket; perform no store write.""" + touched = 0 + for tk, table in list((tables or {}).items()): + fields = (table or {}).get("fields") or [] + if not any(f.get("type") == "rollup" or _ut().is_derived_link(f) for f in fields): + continue + try: + touched += compute_relation_cells(None, tk, tables=tables) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] relation refresh {tk} failed: {type(e).__name__}: {e}") + return touched + + +def refresh_relations(rt, log=print): + """The tick half of the relational pass — the twin of `refresh_metrics`. + + ⚠ Runs for EVERY table, because a link can point anywhere: a rollup on table A goes stale + when table B gains a row, and A has no way to know that happened. Cheap by construction — a + table declaring neither kind costs one dict scan. + """ + snapshot = { + str(key): {**(table or {}), + "rows": {str(rid): dict(row or {}) + for rid, row in ((table or {}).get("rows") or {}).items()}} + for key, table in (ut_all(rt) or {}).items() + } + if not _refresh_relations_inplace(snapshot, log=log): + return 0 + actual = [0] + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + actual[0] = _refresh_relations_inplace(cur, log=log) + return cur + + # ⭐ ASYNC, and for TWO independent reasons (2026-08-09, the lost-record bug). + # + # 1. COST, which was wave 27 item 2's whole point: this pass runs after every row write, and + # `flush="sync"` made each one a blocking HF commit against the 256-commits/hr budget. + # A derived-cell recompute has no business forcing a commit on someone typing. + # 2. It was the deterministic TRIGGER of the bug: `add_row` writes `flush='async'`, so a new + # row lives only in the store cache for 2-20s, and this call — on the SAME key + # (`UT_STORE_KEY == user_tables`) — used to `_read_strict` that cache away and upload the + # result. `POST /rows` answered 201 and the row was gone. + # + # ⚠ THE ROOT FIX IS IN `core/store.py` (a sync RMW no longer discards a dirty cache) and it + # is what makes the other ~39 sync writers of this key safe. This line is not that fix and + # must not be mistaken for it — it removes the trigger and the cost, nothing more. Both + # landed together on purpose: one is correctness, one is the hot path. + rt.update(UT_STORE_KEY, _up, flush="async") + return actual[0] + + +def refresh_metrics(rt, today=None, log=print): + """The tick half of the C7 amendment: `today` advances at tick cadence, so a date-window + metric can never go staler than one tick while a scheduler exists. Cheap by construction — + a table without metric fields costs a dict scan and nothing else.""" + touched = 0 + for tk, t in ut_all(rt).items(): + if metric_fields_of_table(t): + try: + touched += compute_metric_cells(rt, tk, today=today) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] metric refresh {tk} failed: {type(e).__name__}: {e}") + return touched + + +def purge_subject(rt, handle): + """D-24: right-to-erasure for ONE Instagram subject — every row about them leaves the + tenant's four `ut_ig_*` tables AND the platform master (R2 made the master half + non-optional: a purge that missed the pooled copy would not be erasure). Returns + `{table: removed}` counts, master rows prefixed `master:` — every count drills to what is + now ABSENT, which is the one aggregate whose drill is emptiness.""" + subject = str(handle or "").strip().lstrip("@").lower() + if not subject: + return {} + counts = {} + tables = ut_all(rt) + post_rows = (tables.get("ut_ig_posts") or {}).get("rows") or {} + codes = {str(r.get("shortcode") or "") for r in post_rows.values() + if str((r or {}).get("influencer_key") or "").strip().lower() == subject} + + keeps = { + "ut_ig_snapshots": lambda r: str((r or {}).get("influencer_key") + or "").strip().lower() != subject, + "ut_ig_posts": lambda r: str((r or {}).get("influencer_key") + or "").strip().lower() != subject, + "ut_ig_post_snapshots": lambda r: str((r or {}).get("shortcode") or "") not in codes, + DISCOVER_TABLE: lambda r: str((r or {}).get("handle") + or "").strip().lower() != subject, + } + + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + for tk, keep in keeps.items(): + t = cur.get(tk) + if t is None: + continue + rows = t.get("rows") or {} + nxt = {rid: r for rid, r in rows.items() if keep(r)} + counts[tk] = len(rows) - len(nxt) + t["rows"] = nxt + return cur + + rt.update(UT_STORE_KEY, _up, flush="sync") + import ig_master + for bucket, n in (ig_master.purge_handle(subject) or {}).items(): + counts[f"master:{bucket}"] = n + return counts + + +# --------------------------------------------------------------------------------------------- +# TRIGGERS (wave 22, contract C3 + amendment A2 — owner ruling R4; closes D-33) +# --------------------------------------------------------------------------------------------- +# Six ways an automation starts, exactly: manual | schedule | event_field | record_created | +# webhook | email. The first two are what always existed (Run now; cron via the tick). The four +# new ones are EVENTS, and A2 makes their discipline LAW rather than taste: +# +# * **EDGE, NEVER LEVEL (A2(1)).** A condition trigger fires on entering the matching state, +# not for being in it. Implemented as per-record ARMED state over successive evaluations +# (`state.eventDisarmed`): a record fires when it matches while armed, DISARMS, and re-arms +# only by evaluating False — Airtable's documented leave-and-re-enter rule, without needing +# a before-image of a row whose truth is spread over strata. Enabling a trigger SEEDS the +# disarmed set with everything currently matching, so already-matching records do not fire +# (`_seed_event_state`). A settle window coalesces write bursts (the per-keystroke scar). +# * **LOOP PREVENTION IS STRUCTURAL (A2(2)).** The hooks live on the HUMAN doors only +# (`grid_events.overlay_patch`, `user_tables.add_row`); the engine's own writers +# (`ut_write_rows`, the runners' coalesced updates, `patch_cells` from `move_card`) never +# emit — so an automation's write cannot fire event triggers, its own or a sibling's, by +# construction. The circuit breaker on top (>60 fires/5 min auto-pauses with the reason as +# a statusNote) catches whatever construction did not foresee. +# * **FLOOD HOLD (A2(3)).** One evaluation yielding more than 100 candidate records holds +# instead of running — a partial run entry names the count and the deliberate way through +# (Run now). C4's discovery guard is this rule's special case. +# * **REFIRE DEFAULTS (A2(4)), hard-coded this wave:** record_created fires once per record +# EVER (a high-water mark over row ids, so an undo-restored row cannot re-fire); +# event_field fires every transition. + +# ── WAVE 23 · C3 — the trigger vocabulary v2 (owner ruling R2). ─────────────────────────────── +# Airtable's phrasing, because the owner asked for Airtable's builder and a trigger list that +# renames the same events is a second vocabulary to learn for no gain. +# +# ⚠ `event_field` KEPT ITS KEY and changed its LABEL to "When a record matches conditions". +# Renaming the key would have orphaned every stored trigger in production for a caption; the key +# is the contract with the store, the label is the contract with the reader, and they are allowed +# to disagree. What genuinely widened is its SHAPE: the watched field is now OPTIONAL, so the +# trigger covers Airtable's condition-only form (any write to the table, evaluated against a C4 +# tree) as well as wave 22's watch-one-field form. Both are the same edge rule underneath. +# +# ⛔ PLANNED ≠ STORABLE. `button_clicked` / `comment_added` ride the wire so the picker can show +# them faded with a reason (R2: "never a dead control") — and `clean_trigger` REFUSES them with a +# sentence. A vocabulary that renders an option the validator rejects is the wave-9 silent-drop +# class wearing a friendlier face; here the two lists are separate on purpose and the refusal +# names the state rather than pretending the key is unknown. +# ── WAVE 24 · C-TRIG (owner ruling R6). ─────────────────────────────────────────────────────── +# ⭐ INSTAGRAM DISCOVERY BECOMES A TRIGGER. It was a KIND you picked in a create wizard; the +# wizard is deleted, and "when an Instagram profile fits a criteria" is the honest shape anyway — +# it is the event this flow starts from. Picking it sets the definition's kind to +# `discover_instagram` (law 1), which is the ONLY way that kind is reachable now. +# +# ⛔ IT IS NOT A TABLE TRIGGER AND NOT A ROW TRIGGER. It watches nothing: it MAKES rows, on the +# schedule (or on Run now), so it stays out of both lists below and its node switch flips the +# CRON — see `TRIGGER_SCHEDULE_KEYS`. +# +# ⭐⭐ WAVE 29 (item 7 · D-9 · R1) — `tiktok_profile_match` MOVED HERE FROM `TRIGGER_PLANNED`, and +# that move is the whole of "TikTok is a real trigger now". The label was written a wave early in +# the owner's own words and has not changed; what changed is which tuple it sits in, because +# `clean_trigger` refuses the planned list with a sentence and accepts this one. +TRIGGER_KEYS = ("manual", "schedule", "event_field", "record_updated", "record_created", + "enters_view", "webhook", "email", "form_submitted", "ig_profile_match", + "tiktok_profile_match") +#: ⭐ WAVE 25 · C2 / owner ruling R9 — `web_page_changed` JOINS THE PLANNED LIST, and joining THIS +#: tuple rather than `TRIGGER_KEYS` is the whole of its implementation. `clean_trigger` refuses +#: everything here with a sentence, so the faded row is a wall; the picker shows it so the Scraper +#: section is not a section of one. +#: ⚠ `web_page_changed` IS NOT THE WEB ACTION (D-51). A trigger that notices a page changed and an +#: action that drives a browser are different builds; this row must not be read as progress on D-51. +TRIGGER_PLANNED = ("button_clicked", "comment_added", "web_page_changed") +TRIGGER_LABELS = { + "manual": "Manual", + "schedule": "At a scheduled time", + "event_field": "When a record matches conditions", + "record_updated": "When a record is updated", + "record_created": "When a record is created", + "enters_view": "When a record enters a view", + "webhook": "When a webhook is received", + "email": "When an email arrives", + "form_submitted": "When a form is submitted", + "ig_profile_match": "When an Instagram profile fits a criteria", + "button_clicked": "When a button is clicked", + "comment_added": "When a comment is added", + "web_page_changed": "When a website page changes", + "tiktok_profile_match": "When a TikTok profile fits a criteria", +} + +# ── WAVE 25 · C2 — THE PICKER TAXONOMY, and it lives HERE beside the vocabulary it describes. ── +# `group` already rode the wire as "Standard"/"Sources" (`routes_automation`), which is a +# distinction about where a trigger came FROM rather than about what a person is choosing. The +# question the picker actually asks is: does this fire on TIME, on your own DATA, or because +# something OUTSIDE said so. Three answers, and every trigger has exactly one. +# +# ⛔ TWO CONTROLS, NOT ONE, AND THE FIRST DRAFT HAD ONLY THE WRONG HALF. Indexing this map +# directly (`TRIGGER_GROUP_OF[k]`) makes an unclassified trigger a KeyError — which is exactly the +# incident `_triggers_vocab`'s `per.get(k, ...)` comment records: a key added to `TRIGGER_KEYS` +# without remembering a dict beside it 500'd `GET /automations`, the payload the whole automation +# surface polls every 2.5 s, with every gate green. A mis-grouped row is a cosmetic bug; a 500 is +# the surface. So: +# * RUNTIME fails SOFT — an unclassified trigger falls into `other`, which sorts LAST (the rule +# `ACTION_GROUP_ORDER` already uses: an unordered group sorts last, never first, because +# appearing at the top looks deliberate) and is honestly captioned rather than smuggled into +# Database. +# * THE GATE fails HARD — `verify_automation` asserts that NO shipped trigger lands in `other`, +# so the fallback is provably dead code in production and the classification is still +# mandatory. The fallback catches the accident; the gate stops it shipping. +# ⭐ WAVE 34 · R19 — CONNECTOR SITS ABOVE DATABASE, DIRECTLY UNDER TIME. Owner, verbatim: +# *"In the Trigger picker, the Connector section sits directly above the Database section, just +# under the Time trigger types."* So the two orders below are SWAPPED against wave 24's, and +# nothing else moved: same keys, same labels, same fallback. +# ⛔ THIS IS THE WHOLE OF R19 AND IT IS DELIBERATELY NOT A CLIENT CHANGE. `steps.ts::groupTriggers` +# sorts by each option's `groupOrder` and by nothing else, so re-ordering an array on the client +# would look right in a fixture and be wrong in production the moment the server re-sorted. +# ⚠ `verify_steps.py` CANNOT WITNESS THIS EDIT: its C2 leg supplies its OWN `groupOrder` in a +# fixture and asserts the client honours it, which stays true whatever these numbers say. The +# check that binds R19 to this table lives in `verify_automation` beside the vocab section. +TRIGGER_GROUPS = {"time": {"label": "Time", "order": 1}, + "connector": {"label": "Connector", "order": 2}, + "database": {"label": "Database", "order": 3}, + "other": {"label": "Other", "order": 99}} +#: Where an unclassified trigger goes. ⚠ Reaching this in production is a BUG the gate exists to +#: prevent — it is the soft landing, not a category anybody should be adding triggers to. +TRIGGER_GROUP_FALLBACK = "other" +TRIGGER_GROUP_OF = { + "manual": "time", "schedule": "time", + "event_field": "database", "record_updated": "database", "record_created": "database", + "enters_view": "database", "form_submitted": "database", + "button_clicked": "database", "comment_added": "database", + "email": "connector", "webhook": "connector", "ig_profile_match": "connector", + "web_page_changed": "connector", "tiktok_profile_match": "connector", +} +#: The SUB-group inside "Connector" — which connected thing this trigger comes through. +#: ⚠ THESE KEYS ARE GROUPING HANDLES FOR THE PICKER, NOT connector-directory slugs, and the two +#: genuinely differ: the directory's OAuth row for Gmail is `google` (the provider), while a +#: person choosing a trigger is picking *Gmail* (the product). A client that joined this key +#: against `/connectors/directory` would match `scraper` and `webhooks` and miss `gmail` — so it +#: must group by it and render `label`, never look it up. Said here because the miss would be +#: silent and partial, which is the worst shape. +#: (⚠ that example USED to read "`scraper` and `tiktok`" — R3 retired `tiktok` as a handle, and +#: the sentence is corrected here rather than left to rot into a lie about a key that is gone.) +TRIGGER_CONNECTOR = { + "email": {"key": "gmail", "label": "Gmail"}, + "webhook": {"key": "webhooks", "label": "Webhooks"}, + # ⭐ WAVE 30 · R3 — ONE "SCRAPER" BUCKET, AND IT HOLDS BOTH PLATFORMS. + # The owner, verbatim and for the third wave running: *"I say this multiple times already the + # damn Tiktok and Instagram belongs in the same bucket when creating the automation its under + # Scraper … Only when I click 'Scraper' under each automation trigger and actions would I see + # the option to choose either Instagram OR TikTok. That's it."* + # + # ⛔ THIS SUPERSEDES WAVE 29's RULE, and the old rule was not a typo — it was an argument: + # *"TikTok is its own connector, not the Scraper's … because the sub-group answers WHICH + # PRODUCT and never HOW BUILT."* Coherent, and not what was asked for. Instagram and TikTok are + # two PRODUCTS of one CAPABILITY (a social scraper bought from one vendor); a person opening + # this picker is choosing the capability first and the platform second. `verify_automation` + # asserted the old rule as an assertion AND as prose — a shipped gate forbidding the owner's + # ruling is most of why this complaint survived two waves — so it is INVERTED in this same + # change, comment included. + # + # ⚠ The Scraper sub-group now holds THREE rows: two built (Instagram, TikTok) and one faded + # (the page-change trigger). No display ORDER is emitted here — the client groups on `key` and + # owns its own ordering (contract C1). `tiktok` ceases to exist as a grouping handle. + "ig_profile_match": {"key": "scraper", "label": "Scraper"}, + "web_page_changed": {"key": "scraper", "label": "Scraper"}, + "tiktok_profile_match": {"key": "scraper", "label": "Scraper"}, +} +#: Triggers that watch a database and therefore need one named before they can fire. +TRIGGER_TABLE_KEYS = ("event_field", "record_updated", "record_created", "enters_view", + "form_submitted") +#: ⭐ WAVE 24 — triggers whose NODE SWITCH means the CRON rather than the trigger itself. +#: `manual`/`schedule` are not stored at all; `ig_profile_match` is stored and IS schedule-driven, +#: so flipping its node must flip the schedule. +#: +#: ⚠ THIS REPLACES A HAND-LISTED TUPLE IN `toggle_node` THAT WAS ALREADY WRONG. It read +#: `("event_field", "record_created", "webhook", "email")` — omitting `record_updated`, +#: `enters_view` and `form_submitted`, all three of which have been storable since wave 23. For +#: those, clicking the trigger node's switch flipped the CRON under a node labelled "When a +#: record is updated": a switch that lies, which is exactly what the tuple at `graph()` warns +#: about eight lines into its own comment. Derived from one named set now, so a trigger added to +#: `TRIGGER_KEYS` cannot silently join the wrong side of it. +#: ⚠ WAVE 29 — `tiktok_profile_match` BELONGS HERE FOR THE SAME REASON `ig_profile_match` DOES, +#: and forgetting it is precisely the failure this constant's own note describes: it watches no +#: table and MAKES rows on the schedule, so its node switch has nothing to flip but the cron. Left +#: out, a person clicking the TikTok trigger node's switch would toggle the trigger itself while +#: the schedule kept firing — a switch that lies. +TRIGGER_SCHEDULE_KEYS = ("manual", "schedule", "ig_profile_match", "tiktok_profile_match") +#: ⭐ WAVE 25 — DEBT D-55: "the cron drives this one", on the wire at last. +#: +#: ⛔ `TRIGGER_SCHEDULE_KEYS` MUST NOT SHIP VERBATIM, and the one-element difference is the entire +#: reason this constant exists rather than the tuple above being sent. That set answers "which +#: way does this trigger's NODE SWITCH flip" — and `manual` is in it only because a manual +#: automation's switch has nothing else to flip. Shipping it as "the cron drives this" would draw +#: a schedule face on the one trigger whose whole sentence is "It runs only when you press Run +#: now": a control contradicting its own description. +#: +#: D-55's history is why it is DERIVED rather than listed: the client carried +#: `CRON_DRIVEN_TRIGGERS = ["schedule", "ig_profile_match"]` — a hand-kept copy of a server fact +#: that fails VISIBLY but silently (a new cron-driven trigger simply shows no schedule face). +#: Subtracting from the engine's own set means a trigger added there cannot be forgotten here. +TRIGGER_CRON_KEYS = frozenset(TRIGGER_SCHEDULE_KEYS) - {"manual"} +#: Triggers the ROW HOOKS drive (as opposed to the tick, or an inbound HTTP call). Named once so +#: `grid_hook` and the gates read the same list instead of two matching `in (...)` tuples. +TRIGGER_ROW_KEYS = ("event_field", "record_updated", "record_created", "enters_view") +MAX_WATCH_FIELDS = 12 +#: The settle window for field-change bursts (A2(1)). 0 evaluates INLINE — the gates run there, +#: and so would a deployment that prefers immediacy over coalescing. +EVENT_SETTLE_SECONDS = float(os.environ.get("AIOS_EVENT_SETTLE_SECONDS") or 15) +FIRE_LIMIT = 60 # A2(2): fires per window before the breaker pauses +FIRE_WINDOW_SECONDS = 300 +FLOOD_LIMIT = 100 # A2(3): candidate records one evaluation may act on +EMAIL_SEEN_CAP = 500 # message-id dedupe memory per automation +EMAIL_MAX_PER_POLL = 25 # bounded by construction — a poll is a tick guest +CONSECUTIVE_FAILURE_PAUSE = 5 # airtable-brief rec 6: a dead credential must not burn quota + +EMAIL_FIELDS = [ + field_def("email_id", "Email id"), field_def("email_from", "From"), + field_def("email_subject", "Subject"), field_def("email_date", "Date"), + field_def("email_snippet", "Snippet"), field_def("email_seen_at", "Seen at"), +] + + +def clean_trigger(raw, previous=None): + """Validate a definition's `trigger`. Returns `(trigger|None, error)` — None is legal and + means what it always meant: manual + whatever `schedule` says. + + ⚠ A3 (2026-08-05): the stored/wire name is `key` (`kind` accepted on input for symmetry + with the definition's own vocabulary). And an INCOMPLETE event trigger is STORED INERT + rather than refused — the picker writes `{key}` first and the table/field after, the + wave-18 unconfigured-automation-column precedent exactly; `configured: false` rides the + wire so the surface says "finish setting this up" instead of snapping back to Manual. It + cannot fire while incomplete (the hooks match on the table it does not name), which is the + fail-closed direction. MALFORMED parts (an unknown comparison, a valueless compare, a + condition on a field the trigger does not watch) are still refused with the sentence — + incomplete is a state, wrong is not. + """ + if raw in (None, "", {}): + return (dict(previous) if isinstance(previous, dict) and previous else None), None + if not isinstance(raw, dict): + return None, "the trigger must be an object" + prev = previous if isinstance(previous, dict) else {} + key = _s(raw.get("key") or raw.get("kind") or prev.get("key") or prev.get("kind"), + 30).strip() + if key in TRIGGER_PLANNED: + # Declared on the wire, refused at the door — see the TRIGGER_PLANNED note. The sentence + # says WHY rather than "unknown trigger", because the picker legitimately showed it. + return None, (f"{TRIGGER_LABELS[key]!r} is on the list but not built yet. " + f"it renders so you can see it is coming, and it cannot be saved") + if key not in TRIGGER_KEYS: + return None, (f"{key or 'that trigger'!r} is not one of: " + ", ".join(TRIGGER_KEYS)) + if key == "schedule": + # ⛔ STILL NOT STORED, and the original reasoning holds for THIS key alone: `schedule` + # already owns the cron (`defn['schedule']` = `{cron, enabled}`), so a stored + # `{key:'schedule'}` would be a second copy of that fact, free to disagree with it. + return None, None + if key == "manual": + # ⭐⭐ 2026-08-07 (owner ruling) — **MANUAL IS A REAL, STORED CHOICE NOW.** + # Owner: *"Make it so that when you choose Manual, it IS a manual automation that the user + # can just press Run to make the full flow work."* + # + # ⛔ THIS SPLITS A PAIR THAT SHOULD NEVER HAVE BEEN ONE. The old line refused both keys + # together with one argument — *"storing a no-op trigger would be a second copy of that + # fact"* — and that argument is TRUE OF `schedule` AND FALSE OF `manual`. A schedule has + # another home; **manual has none.** Nothing anywhere recorded "this automation is + # manual", so storing it is not a duplicate: it is the only record there has ever been. + # + # ⚠ WHAT THE CONFLATION COST, measured live: picking Manual wrote nothing, so + # `chosen` (`!!trigger || schedule.enabled`) stayed false, the Builder kept showing the + # "nobody has decided yet" empty state, and Configuration — including the Database picker + # a plain automation cannot do without — never rendered. The owner reported it twice. The + # previous note reasoned that a manual option *"would bounce straight back to this state + # on the next reload"* and concluded the option should be HIDDEN; the honest conclusion + # was that it should be STORED. + # + # ⚠ DELIBERATELY BARE. No `enabled`, no `paused`: a manual trigger cannot be switched off + # (Run now always works, which is the whole of what it means) and a switch that governs + # nothing is worse than no switch. `graph()` keeps this node on the SCHEDULE panel so the + # cron stays reachable — picking Manual says how it fires today, never that it may not be + # scheduled tomorrow. + return {"key": "manual"}, None + out = {"key": key, + "enabled": bool(raw["enabled"]) if "enabled" in raw else + bool(prev.get("enabled", True)), + "paused": bool(raw["paused"]) if "paused" in raw else bool(prev.get("paused"))} + if key in TRIGGER_TABLE_KEYS: + table = _s(raw.get("table") if "table" in raw else prev.get("table"), 60).strip() + if table and not table.startswith(UT_PREFIX): + return None, ("event triggers watch blank databases (ut_*) this wave. " + f"{table!r} is not one") + out["table"] = table + if key == "event_field": + # ⭐ WAVE 24 · C-TRIG LAW 4 (owner item 7) — THE WATCHED FIELD IS GONE. "When a record + # matches conditions" is a CONDITION trigger and nothing else: the field picker made it a + # second, quieter way to express the same narrowing, and the owner asked for one. + # ⚠ MIGRATION, NEVER A REFUSAL (law 6). A stored `field` is simply not read, so it is + # dropped on this definition's next clean — silently, and exactly once, because nothing + # writes the key back. A refusal here would have 400'd the live automations that carry it. + cond, cerr = clean_cond(raw.get("when") if "when" in raw else prev.get("when"), + where="the trigger") + if cerr: + return None, cerr + out["when"] = cond + if key == "record_updated": + # Airtable's shape: watch named fields, or leave the list empty for "any field". Empty + # is the WIDER reading and it is the default there too, so it stays the default here. + watch_raw = raw.get("fields") if "fields" in raw else prev.get("fields") + if watch_raw in (None, ""): + watch = [] + elif not isinstance(watch_raw, list): + return None, "the watched-field list must be a list of field keys" + else: + watch = [_s(f, 80).strip() for f in watch_raw if _s(f, 80).strip()] + if len(watch) > MAX_WATCH_FIELDS: + return None, (f"a record-updated trigger watches at most {MAX_WATCH_FIELDS} " + f"fields. Leave the list empty to watch every field") + out["fields"] = watch + # ⭐ WAVE 24 · C-TRIG LAW 5 (owner item 7) — THE CONDITION IS GONE, and this REMOVES A + # SHIPPED CAPABILITY. Watched `fields` is now the whole of this trigger's configuration: + # "a record was updated" is an event, and asking it to also be a filter was the overlap + # with `event_field` the owner asked to end. Stated loudly in the contract AND here so + # nobody restores it as a bug fix. + # ⚠ Same migration shape as law 4: a stored `when` stops being read, so `_row_gate` + # naturally returns "no gate" for it — the write itself becomes the event — rather than + # this needing a second removal anywhere. + if key == "enters_view": + out["viewId"] = _s(raw.get("viewId") if "viewId" in raw else prev.get("viewId"), + 80).strip() + if key == "form_submitted": + # Blank = any form on that database. Naming one narrows to it, which is what a table + # carrying an intake form AND a correction form needs. + out["formToken"] = _s(raw.get("formToken") if "formToken" in raw + else prev.get("formToken"), 64).strip() + if key == "webhook": + # The token is MINTED here, once, and survives every later patch — rotating it on + # every Save would silently break the external caller the URL was given to. + out["token"] = _s(prev.get("token"), 64) or _secrets_token() + # ⭐ WAVE 24 — DEBT D-41: the request BODY, mapped onto record fields by config. + # ⚠ BOTH HALVES ARE OPTIONAL, and that is what keeps this additive: a webhook with no + # map behaves exactly as it did — it fires the flow and reads nothing — so the live + # webhook automations are untouched. `webhook` deliberately stays OUT of + # `TRIGGER_TABLE_KEYS`: joining it would make a table REQUIRED for `configured`, and + # every existing webhook trigger would go unconfigured and stop firing. + table = _s(raw.get("table") if "table" in raw else prev.get("table"), 60).strip() + if table and not table.startswith(UT_PREFIX): + return None, ("a webhook writes into a blank database (ut_*). " + f"{table!r} is not one") + out["table"] = table + fmap, ferr = clean_body_map(raw.get("fieldMap") if "fieldMap" in raw + else prev.get("fieldMap")) + if ferr: + return None, ferr + out["fieldMap"] = fmap + if key == "email": + out["query"] = _s(raw.get("query") if "query" in raw else prev.get("query"), + 200).strip() or "in:inbox is:unread" + out["configured"] = _trigger_configured(out) + return out, None + + +#: D-41 ceilings. 40 mapped cells is `MAX_ACTION_VALUES` doubled — a webhook payload is somebody +#: else's schema and is legitimately wider than an action's hand-written value list. +MAX_BODY_FIELDS = 40 +MAX_BODY_DEPTH = 5 + + +def clean_body_map(raw): + """D-41: `{"": ""}` for a webhook. Returns `(map, error)`. + + Paths are DOTTED into nested objects (`customer.email`). ⛔ NO ARRAY INDEXING in v1, stated + rather than half-supported: `items.0.sku` would read as working for the first element and + silently write nothing the day a payload arrives with the list empty, which is the shape of + bug this module keeps paying for. A path that resolves to nothing writes nothing. + """ + if raw in (None, ""): + return {}, None + if not isinstance(raw, dict): + return None, "the webhook field map must be an object of {body path: field key}" + if len(raw) > MAX_BODY_FIELDS: + return None, f"a webhook maps at most {MAX_BODY_FIELDS} values onto a record" + out = {} + for path, field in raw.items(): + p = _s(path, 200).strip() + if not p: + return None, "a webhook mapping has an empty body path" + if len(p.split(".")) > MAX_BODY_DEPTH: + return None, (f"{p!r} reaches more than {MAX_BODY_DEPTH} levels into the payload. " + f"map a shallower value") + fk = re.sub(r"[^a-z0-9_]+", "_", _s(field, 60).strip().lower()).strip("_") + if not fk: + return None, f"the value at {p!r} is not mapped to a field" + out[p] = fk[:60] + return out, None + + +def body_value(body, path): + """One dotted path into a decoded JSON body, or None. Scalars only — a mapped value that is + an object or a list answers None rather than a stringified `{...}` in a cell, because the + Row contract is scalar and a serialised dict in a grid cell is unreadable and unfilterable.""" + cur = body + for part in str(path or "").split("."): + if not isinstance(cur, dict): + return None + cur = cur.get(part) + return cur if isinstance(cur, (str, int, float, bool)) else None + + +def webhook_row(rt, defn, body): + """D-41: write ONE record from a webhook payload. Returns `(row_id, mapped_count, note)`. + + ⚠ `note` EXISTS BECAUSE THE CAP WAS SILENT. A table at its row ceiling returned the same + `("", 0)` as "no map configured" and the door answered a cheerful 200 — indistinguishable, + from the only side the caller is on, from a payload whose paths did not resolve. That is the + D-11 class (a table that quietly stops growing), and the caller here is a machine that will + keep posting. The note rides the 200: the flow still fires, and the answer says why no row + was written. + + ⛔ THROUGH THE ENGINE'S OWN WRITER, so it emits no row events — the structural loop + prevention law (A2(2)). A sibling automation watching this table does NOT fire on a + webhook-written row, exactly as it does not fire on a scrape's rows. The webhook's OWN flow + fires, because `hook_fire` fires it explicitly, which is the difference between "this + trigger fired" and "a write happened". + """ + trg = (defn or {}).get("trigger") or {} + table, fmap = str(trg.get("table") or ""), dict(trg.get("fieldMap") or {}) + if not table or not fmap or not isinstance(body, dict): + return "", 0, "" # no map configured — nothing to report + t = ut_get(rt, table) + if t is None: + return "", 0, f"{table} no longer exists, so nothing was written" + values = {} + for path, fkey in fmap.items(): + v = body_value(body, path) + if v is not None: + values[fkey] = _s(v, 500) if isinstance(v, str) else v + if not values: + return "", 0, ("none of the mapped paths resolved to a value in this payload. " + "check the paths against what you are sending") + rows = dict((t.get("rows") or {})) + if len(rows) >= row_cap(table): + return "", 0, (f"{table} is at its {row_cap(table)}-row limit, so no record was " + f"written (the flow still ran)") + rid = str(max([int(r) for r in rows if str(r).isdigit()] or [0]) + 1) + rows[rid] = values + ut_write_rows(rt, table, rows) + return rid, len(values), "" + + +def _trigger_configured(trg, config=None): + """Is this trigger complete enough to fire? One reader, because "configured" is asserted in + three places (the wire, the graph node, the hooks) and three copies of a boolean is how a + surface says "ready" about a trigger the engine skips. + + ⭐ WAVE 24 (A2) — `config` is OPTIONAL and only `ig_profile_match` reads it, because that is + the one trigger whose configuration lives in the DEFINITION's config (the discovery filters) + rather than on the trigger. `clean_trigger` calls this without it and so answers + conservatively (False); `clean_definition` calls it again with the validated config and + refines. Conservative-then-refined is the fail-closed order — the reverse would flash + "ready" on a trigger with nothing to search for. + """ + key = str((trg or {}).get("key") or "") + # ⭐ WAVE 29 — BOTH discovery triggers, and they answer identically: a corpus search with no + # filter is not a search, it is a request for the whole index. Named as a pair rather than + # `or`-ed onto the Instagram line so a third network joins by adding a key, not by editing a + # boolean expression. + if key in ("ig_profile_match", "tiktok_profile_match"): + return bool((config or {}).get("predicates")) + if key in TRIGGER_TABLE_KEYS and not trg.get("table"): + return False + if key == "event_field": + # C-TRIG law 4: the CONDITION is now the whole of it. No condition = "fire on anything, + # ever" — which is not a trigger, it is a description of the table. + # ⚠ STATED CONSEQUENCE OF THE MIGRATION: a live automation that narrowed by FIELD alone + # and carried no condition becomes `configured: false` on its next clean. It stops + # firing, and it SAYS SO — the graph node reads "Finish setting this trigger up before it + # can fire" and `configured` rides the wire. Visibly unfinished, never silently inert. + return bool(trg.get("when")) + if key == "enters_view": + return bool(trg.get("viewId")) + return True + + +def _secrets_token(): + import secrets as _sec + return _sec.token_urlsafe(24) + + +# --- the circuit breaker (A2(2)) — process memory, like _RUNNING: a counter that outlives the +# process would keep punishing an automation for a storm that ended with the restart. +_FIRES = {} +_FIRES_LOCK = threading.Lock() + + +def _breaker_trips(tenant, auto_id, now=None): + now = now if now is not None else time.time() + key = (tenant, str(auto_id)) + with _FIRES_LOCK: + log = [t for t in _FIRES.get(key, []) if now - t < FIRE_WINDOW_SECONDS] + log.append(now) + _FIRES[key] = log + return len(log) > FIRE_LIMIT + + +def _pause_trigger(rt, auto_id, note): + def _up(cur): + cur = cur if isinstance(cur, dict) else {} + d = cur.get(str(auto_id)) + if d is not None: + trg = d.get("trigger") + if isinstance(trg, dict): + trg["paused"] = True + d["statusNote"] = _s(note, 200) + return cur + _store_update(rt, _up, flush="sync") + + +def trigger_fire(rt, tenant, auto_id, log=print, rows=None): + """One trigger firing — breaker first, then the ordinary async run. False when it did not + start (breaker, or already running — both are answers, not errors).""" + if _breaker_trips(tenant, auto_id): + note = (f"auto-paused: more than {FIRE_LIMIT} trigger fires in " + f"{FIRE_WINDOW_SECONDS // 60} minutes. Something is writing this trigger's " + f"subject in a loop") + _pause_trigger(rt, auto_id, note) + log(f"[aios-auto] breaker: {auto_id} {note}") + return False + return run_async(rt, tenant, auto_id, username="automation", log=log, rows=rows) + + +# --- the settle buffer (A2(1)) — per (tenant, automation), coalescing a burst into ONE +# evaluation. With EVENT_SETTLE_SECONDS == 0 the evaluation is INLINE (deterministic for gates). +_SETTLE = {} +_SETTLE_LOCK = threading.Lock() + + +def _settle_buffer(rt, tenant, auto_id, row_id, field="", after=None, log=print): + """Buffer ONE touched row for a coalesced evaluation, carrying the written cell. + + ⚠ WAVE 23 — THE WRITTEN VALUE IS STILL LOAD-BEARING, and an earlier draft of this wave + dropped it on the theory that `_settle_eval` could just read the row. It cannot, and the + reason is worth stating because it is invisible from this file: an ordinary ut cell typed at + the grid door lands in the editor's PER-USER OVERLAY stratum + (`grid_events.overlay_patch` → `table_store.patch_overlay`), not in the `user_tables` + definition rows. Only stage-field writes go through `patch_cells` to the shared rows. So for + the common case the value that just changed exists ONLY in this event, and a definition-row + read sees the pre-write value — the trigger would evaluate stale and never fire. + `_settle_eval` therefore MERGES: the definition row underneath (which is what lets a C4 tree + read the record's other columns) with the written cells on top. + """ + key = (tenant, str(auto_id)) + cell = {str(field): after} if field else {} + if EVENT_SETTLE_SECONDS <= 0: + with _SETTLE_LOCK: + buf = _SETTLE.setdefault(key, {"rows": {}}) + buf["rows"].setdefault(str(row_id), {}).update(cell) + _settle_eval(rt, tenant, auto_id, log=log) + return + with _SETTLE_LOCK: + buf = _SETTLE.setdefault(key, {"rows": {}}) + buf["rows"].setdefault(str(row_id), {}).update(cell) + timer = buf.get("timer") + if timer is not None: + timer.cancel() # the burst continues — push the window out + timer = threading.Timer(EVENT_SETTLE_SECONDS, _settle_eval, + args=(rt, tenant, auto_id), kwargs={"log": log}) + timer.daemon = True + buf["timer"] = timer + timer.start() + + +def view_filter(rt, table_key, view_id): + """`(tree, fields, problem)` for one saved view on a user table — the substrate the + `enters_view` trigger tests membership against (C3-v2 / owner R2). + + Personal strata first (`find_view`), then the shared bucket, because a view somebody shared + is exactly the kind an automation gets pointed at. A view that has been deleted answers a + PROBLEM rather than an empty tree: an empty tree matches everything, so degrading to one + would turn "when a record enters Overdue" into "on every write", which is the widening this + module refuses everywhere else. + """ + key = str(table_key or "") + vid = str(view_id or "").strip() + if not key or not vid: + return None, [], "the trigger names no view" + try: + import core.table_store as table_store + tops = table_store.make(f"{key}_table_workspace", st=rt) + found = tops.find_view(vid) + view = (found[1] if found else None) or tops.shared_view(vid) + except Exception as e: # noqa: BLE001 + return None, [], f"the view could not be read ({type(e).__name__})" + if not isinstance(view, dict): + return None, [], f"view {vid!r} no longer exists on {key}" + cfg = view.get("config") or {} + tree = {"nodes": cfg.get("filters") or [], "conj": cfg.get("filterConj") or "and"} + return tree, list((ut_get(rt, key) or {}).get("fields") or []), "" + + +def _row_gate(rt, defn, trg): + """The MATCH gate for a row-event trigger: `(row -> bool) | None`, plus a problem string. + + None means the trigger has NO match gate — the write itself is the event (wave 22's "the + field changed", and `record_updated` over any field). A problem means the gate cannot be + built, and the caller must then fire NOTHING: a gate we cannot evaluate is not a gate that + passes. + """ + key = trg.get("key") + if key == "enters_view": + tree, fields, problem = view_filter(rt, trg.get("table"), trg.get("viewId")) + if problem: + return None, problem + import harness.filter_eval as filter_eval + return (lambda row: filter_eval.matches(tree, row, fields)), "" + when = trg.get("when") + if when: + return (lambda row: lane_match(when, row)), "" + return None, "" + + +def _settle_eval(rt, tenant, auto_id, log=print): + """Evaluate one settled burst: edge over per-record armed state, flood hold, then fire.""" + with _SETTLE_LOCK: + buf = _SETTLE.pop((tenant, str(auto_id)), None) + written = dict((buf or {}).get("rows") or {}) + touched = list(written) + if not touched: + return + d = all_definitions(rt).get(str(auto_id)) + trg = (d or {}).get("trigger") or {} + if trg.get("key") not in ("event_field", "record_updated", "enters_view") \ + or trg.get("paused") or not trg.get("enabled", True) \ + or not trg.get("configured", True): + return + gate, problem = _row_gate(rt, d, trg) + if problem: + # LOUD, once, and it does not fire. A trigger pointed at a deleted view is a broken + # automation, not a quiet no-op — the note is what the surface shows instead of "On". + if (d.get("statusNote") or "") != problem: + _pause_trigger(rt, auto_id, problem) + log(f"[aios-auto] trigger gate: {auto_id} {problem}") + return + fired = [] + if gate is not None: + rows = (ut_get(rt, trg.get("table") or "") or {}).get("rows") or {} + disarmed = set((d.get("state") or {}).get("eventDisarmed") or []) + nxt = set(disarmed) + for rid in touched: + # The merge (see `_settle_buffer`): the shared definition row underneath so a C4 + # tree can read the record's other columns, the just-written cells on top because + # for an ordinary ut column this event is the ONLY place that value exists yet. + if gate({**(rows.get(rid) or rows.get(str(rid)) or {}), + **(written.get(rid) or {})}): + if rid not in disarmed: + fired.append(rid) # false→true THIS evaluation: the edge + nxt.add(rid) + else: + nxt.discard(rid) # left the state — re-armed (Airtable's rule) + if nxt != disarmed: + set_state(rt, auto_id, {"eventDisarmed": sorted(nxt)[:5000]}) + if not fired: + return + else: + fired = list(touched) # "the field changed" — the burst is the edge + if len(fired) > FLOOD_LIMIT: + _commit_run(rt, auto_id, "partial", + f"the trigger matched {len(fired)} records in one evaluation. More than " + f"the {FLOOD_LIMIT}-record flood hold, so nothing ran. Press Run now to " + f"process them deliberately", {"held": len(fired)}, True) + return + trigger_fire(rt, tenant, auto_id, log=log, rows=fired) + + +def _seed_event_state(rt, defn): + """A2(1)'s enable rule: records ALREADY matching when the trigger is set start DISARMED, so + turning the trigger on fires nothing — the first fire needs a real false→true transition. + Evaluated over the definition rows (the machine-written truth this trigger class watches). + + Wave 23: one seeder for all three gated triggers, reading the SAME `_row_gate` the evaluation + reads. Two implementations of "does this row match" is how a seed disagrees with the edge it + is supposed to arm, and the symptom would be a flood of fires the moment somebody enables it. + """ + trg = (defn or {}).get("trigger") or {} + if trg.get("key") not in ("event_field", "record_updated", "enters_view") \ + or not trg.get("configured"): + return + gate, problem = _row_gate(rt, defn, trg) + if gate is None or problem: + return # no match gate ⇒ nothing to arm; a broken gate seeds nothing + rows = (ut_get(rt, trg.get("table") or "") or {}).get("rows") or {} + matching = sorted(str(rid) for rid, row in rows.items() if gate(row or {})) + set_state(rt, defn.get("id"), {"eventDisarmed": matching[:5000]}) + + +def grid_hook(evt): + """THE listener the human write doors emit into (registered onto + `core.user_tables.ROW_HOOKS` by `routes_automation` at import — the one place that may + import both sides). Never raises into a write path; a broken trigger listener must not + break typing into a cell.""" + try: + st = evt.get("st") + if st is None: + return + tenant = str(getattr(st, "key", "") or "royal-imports") + table = str(evt.get("table") or "") + kind = str(evt.get("type") or "") + # ⭐⭐ WAVE 31 · T35 (D-134) — `cached=True`, and this is the ONLY caller that passes it. + # This function runs once per ROW EVENT, so a 20,000-row import used to perform 20,000 + # whole-document deep copies of the automations bucket, under the store lock, to re-read a + # trigger set that had not changed. See `all_definitions` for why the memo is safe here + # and nowhere else. + defs = all_definitions(st, cached=True) + field = str(evt.get("field") or "") + rid = str(evt.get("rowId") or "") + for aid, d in defs.items(): + trg = (d or {}).get("trigger") or {} + key = str(trg.get("key") or "") + if trg.get("paused") or not trg.get("enabled", True) \ + or not trg.get("configured", True) \ + or key not in TRIGGER_ROW_KEYS \ + or str(trg.get("table") or "") != table: + continue + if kind == "record_created" and key == "record_created": + hw = _ig_int((d.get("state") or {}).get("rcHighwater")) or 0 + ridn = int(rid) if rid.isdigit() else None + if ridn is None or ridn <= hw: + continue # once per record EVER (A2(4)) — undo-proof + set_state(st, aid, {"rcHighwater": ridn}) + # ⛔⛔ W31-T35 — MIRROR THE WRITE INTO THE MEMO'S OWN COPY, IN THE SAME STATEMENT. + # This is the hazard a definitions memo creates and the reason D-134 is not a + # one-line change: this branch READS `rcHighwater` and WRITES it, so within one + # import burst the second row would compare against the highwater the FIRST row + # set — and read the pre-write value out of the memo, fire again, and break + # A2(4)'s *"once per record EVER — undo-proof"*. `set_state` goes through + # `_store_update`, which drops the memo, but `defs` is the object already in hand + # for the rest of THIS event; keeping the two in step is what makes the memo safe + # rather than merely fast [[read-path-cannot-witness-write-path]]. + (d.setdefault("state", {}))["rcHighwater"] = ridn + trigger_fire(st, tenant, aid, rows=[rid]) + elif kind == "event_field" and key == "event_field": + # ⭐ WAVE 24 · law 4 — the per-FIELD narrowing is gone with the stored key. Every + # write on the watched table settles, and the CONDITION decides whether it fires + # (`_row_gate`). The old `not trg.get("field") or str(...) == field` test would + # now always take its first arm anyway; leaving a read of a key the validator no + # longer writes is the drift seat this migration exists to close. + _settle_buffer(st, tenant, aid, rid, field, evt.get("after")) + elif kind == "event_field" and key == "record_updated" and ( + not (trg.get("fields") or []) or field in (trg.get("fields") or [])): + _settle_buffer(st, tenant, aid, rid, field, evt.get("after")) + elif key == "enters_view": + # BOTH kinds feed it: a row can enter a view by being edited into its filter or + # by being CREATED already inside it. Listening only to edits would silently miss + # every new record — the half of the definition a reader assumes is covered. + _settle_buffer(st, tenant, aid, rid, field, evt.get("after")) + except Exception as e: # noqa: BLE001 + print(f"[aios-auto] trigger hook failed: {type(e).__name__}: {e}") + + +def form_fired(rt, table_key, row_id, values=None, form_token=""): + """⭐ THE FROZEN SIGNATURE session D calls from the public form door (contract C9/W23-W7). + + One submitted form row → every `form_submitted` automation watching that database fires. + Returns the list of automation ids that started, so the door can log what it set off (and so + the gate can assert it, rather than asserting a side effect nobody can see). + + ⛔ THIS IS A HUMAN DOOR, deliberately: an anonymous submission is a person filling in a form, + so it fires triggers exactly like typing into a cell does. The loop-prevention law is not + weakened by that — the engine's own writers still never reach here (only `routes_forms` calls + it), so an automation cannot create a form row and re-fire itself. + + `values` is accepted and unused today: the row is already written when this is called, and + the flow reads it from the table. It stays in the signature because the caller HAS it and a + later refire policy ("only when field X was submitted") needs it — a parameter added later + would mean changing D's call site in a wave that does not own it. + """ + started, table = [], str(table_key or "") + if not table: + return started + tenant = str(getattr(rt, "key", "") or "royal-imports") + for aid, d in all_definitions(rt).items(): + trg = (d or {}).get("trigger") or {} + if trg.get("key") != "form_submitted" or trg.get("paused") \ + or not trg.get("enabled", True) or not trg.get("configured", True): + continue + if str(trg.get("table") or "") != table: + continue + want = str(trg.get("formToken") or "") + if want and not hmac.compare_digest(want, str(form_token or "")): + continue # this automation watches a DIFFERENT form on that table + if trigger_fire(rt, tenant, aid, rows=[str(row_id)] if row_id else None): + started.append(aid) + return started + + +def hook_fire(rt, tenant, auto_id, token, body=None): + """The webhook trigger's decision, separated from FastAPI so the gate can drive it. + Returns `(status, payload)` — 404 unknown, 403 wrong/missing token or wrong trigger kind, + 409 already running, 200 started. + + ⭐ WAVE 24 (D-41): `body` is the decoded JSON payload, or None when the caller sent none or + sent something that is not JSON. It is written onto a record BEFORE the flow fires — the + flow's actions walk the record the webhook just created, which is the whole point of mapping + it. `body=None` is the pre-wave behaviour exactly, so an existing caller is unaffected. + """ + import hmac as _hmac + defn = all_definitions(rt).get(str(auto_id)) + if defn is None: + return 404, {"error": "unknown_automation"} + trg = defn.get("trigger") or {} + want = str(trg.get("token") or "") + if trg.get("key") != "webhook" or not want: + return 403, {"error": "no_webhook", "message": + "this automation has no webhook trigger"} + if trg.get("paused") or not trg.get("enabled", True): + return 403, {"error": "webhook_off", "message": "the webhook trigger is turned off"} + if not _hmac.compare_digest(want, str(token or "")): + return 403, {"error": "bad_token", "message": "that token is not valid"} + # D-41: map the payload onto a record FIRST, so the flow that fires next walks it. + row_id, mapped, note = webhook_row(rt, defn, body) + started = trigger_fire(rt, tenant, auto_id, rows=[row_id] if row_id else None) + out = {"started": bool(started), "at": _iso(), + # Answered even when zero, so a caller wiring a map up can see whether their paths + # resolved. Silence here would make "my JSON is not landing" undebuggable from the + # outside, which is the only side the caller is on. + "rowId": row_id or None, "mapped": mapped} + if note: + out["note"] = note # a 200 that wrote no row SAYS which reason it was + return 200, out + + +def email_poll(rt, tenant, auto_id, defn, log=print, _list=None, _read=None): + """The email trigger's tick half (C3): poll the CREATOR's Gmail through C5's seam, write a + row per NEW matching message, fire the flow. `_list`/`_read` are injection points so the + gate drives this without a network; production leaves them None. + + Fail-closed and QUIET when unconnected: the statusNote says so ONCE (not a run entry per + tick — 96 identical failures a day is a klaxon, not a status). Bounded everywhere: at most + `EMAIL_MAX_PER_POLL` new messages per tick, the flood hold above that, one coalesced write. + """ + trg = defn.get("trigger") or {} + if trg.get("key") != "email" or trg.get("paused") or not trg.get("enabled", True): + return None + import oauth_connect + creator = str(defn.get("createdBy") or "").strip() or "admin" + token, err = oauth_connect.google_creds(rt, creator) + if err: + if (defn.get("statusNote") or "") != err: + def _note(cur): + cur = cur if isinstance(cur, dict) else {} + dd = cur.get(str(auto_id)) + if dd is not None: + dd["statusNote"] = _s(err, 200) + return cur + _store_update(rt, _note, flush="sync") + return None + lister = _list or (lambda q, n: oauth_connect.gmail_list(token, q, n)) + reader = _read or (lambda mid: oauth_connect.gmail_message(token, mid)) + ids, lerr = lister(trg.get("query") or "", EMAIL_MAX_PER_POLL + FLOOD_LIMIT) + if lerr: + return _commit_run(rt, auto_id, "partial", f"the Gmail poll did not answer. {lerr}", + {}, True) + seen = set((defn.get("state") or {}).get("emailSeen") or []) + fresh = [m for m in ids if m not in seen] + if not fresh: + return None # nothing new is not a run — no history spam + if len(fresh) > FLOOD_LIMIT: + return _commit_run(rt, auto_id, "partial", + f"{len(fresh)} new emails matched in one poll. More than the " + f"{FLOOD_LIMIT}-record flood hold, so nothing was written. Narrow " + f"the query, or press Run now after adjusting it", + {"held": len(fresh)}, True) + fresh = fresh[:EMAIL_MAX_PER_POLL] + rows_in, notes = [], [] + for mid in fresh: + row, rerr = reader(mid) + if row: + rows_in.append(row) + elif rerr: + notes.append(rerr) + table_key = (defn.get("config") or {}).get("targetTable") or "" + if not table_key: + return _commit_run(rt, auto_id, "error", + "the email trigger has nowhere to write. The automation names no " + "target database", {}, False) + ut_ensure(rt, (defn.get("config") or {}).get("targetLabel") or defn.get("name") or "Inbox", + EMAIL_FIELDS, username=str(defn.get("createdBy") or "automation"), key=table_key) + existing = dict((ut_get(rt, table_key) or {}).get("rows") or {}) + merged, counts = upsert_rows(existing, rows_in, "email_id", cap=row_cap(table_key)) + ut_write_rows(rt, table_key, merged) + new_seen = (list(seen) + fresh)[-EMAIL_SEEN_CAP:] + set_state(rt, auto_id, {"emailSeen": new_seen}) + summary = (f"{len(fresh)} new email{'' if len(fresh) == 1 else 's'} matched. " + f"{counts['inserted']} row{'' if counts['inserted'] == 1 else 's'} written") + if notes: + summary += f". {notes[0][:100]}" + entry = _commit_run(rt, auto_id, "ok" if not notes else "partial", summary, counts, + True, affected=list(merged)[:200]) + trigger_fire(rt, tenant, auto_id, log=log) + return entry + + +def compose_sentence(defn): + """The one-sentence server-composed summary (airtable-brief rec 7): rendered from the + definition so it cannot lie about what runs.""" + cfg = defn.get("config") or {} + trg = defn.get("trigger") or {} + sched = defn.get("schedule") or {} + kind = defn.get("kind") + if trg.get("key") == "event_field": + # Law 4: no watched field any more, so the sentence stops naming one. It said + # "When None changes on ut_x" the moment the key stopped being stored. + head = f"When a record in {trg.get('table')} matches conditions" + elif trg.get("key") == "record_updated": + head = f"When a record in {trg.get('table')} is updated" + elif trg.get("key") == "ig_profile_match": + head = "When an Instagram profile fits the criteria" + elif trg.get("key") == "tiktok_profile_match": + head = "When a TikTok profile fits the criteria" + elif trg.get("key") == "record_created": + head = f"When a record is created in {trg.get('table')}" + elif trg.get("key") == "webhook": + head = "When the webhook is called" + elif trg.get("key") == "email": + head = f"When an email matches {trg.get('query')}" + elif sched.get("enabled"): + head = f"{_cron_label(sched.get('cron'))}" + else: + head = "When you press Run now" + if kind == "scrape_db": + host = urlparse(str(cfg.get("url") or "")).hostname or "the page" + body = f"read {host} and upsert rows into {cfg.get('targetTable') or 'a new database'}" + elif kind == "field_instagram": + # ⚠ NO RUNG CLAUSE (R5). There is one way to capture a profile now, so ", exact counts + # first" / ", anonymous only" described a choice that no longer exists. + body = f"capture Instagram profiles for {cfg.get('targetTable') or 'the database'}" + elif kind in DISCOVERY_KINDS: + # ⭐⭐ WAVE 30 · T05 — the arm covers both kinds, and the network is NAMED from the kind + # rather than hard-coded into the sentence. Instagram-only, a TikTok search fell into the + # `plain` branch below and introduced itself as *"do nothing yet — this automation has no + # actions"*: a flatly false sentence, on the one surface whose stated promise is that it + # cannot lie about what runs. (The `plain` branch's own comment records the mirror-image + # incident — it USED to be this arm, and described every plain automation as an Instagram + # search for 0 profiles. The same two branches have now mis-described each other's + # automations in both directions, which is why neither may be a fallthrough.) + _dplatform, _dtable, _, _ = discovery_facts(kind) + body = (f"search {_dplatform} for up to {cfg.get('recordsLimit') or 0} profiles into " + f"{cfg.get('targetTable') or _dtable}") + else: + # ⭐ WAVE 24 — `plain` describes itself from its FLOW, because the flow is all it has. + # ⛔ THIS BRANCH USED TO BE `discover_instagram`'s, so before the arm above existed every + # plain automation would have introduced itself as "search Instagram for up to 0 profiles + # into ut_ig_candidates" — a sentence composed from a definition that says none of it, + # on the one surface whose whole promise is that it "cannot lie about what runs". + n = sum(1 for _ in walk_actions((defn.get("flow") or {}).get("actions"))) + tbl = cfg.get("targetTable") or trg.get("table") or "" + body = ((f"run {n} action{'' if n == 1 else 's'}" + (f" on {tbl}" if tbl else "")) + if n else "do nothing yet. This automation has no actions") + lanes = cfg.get("lanes") or [] + tail = f", then route each record across {len(lanes)} lanes" if lanes else "" + return f"{head}, {body}{tail}." + + +def run_now(rt, tenant, auto_id, username="automation", log=print, rows=None): + """Execute one automation SYNCHRONOUSLY. The route wraps this in a thread; the tick calls it + directly. Returns the run entry, or None when it was already running (the 409).""" + defn = all_definitions(rt).get(str(auto_id)) + if defn is None: + return None + # ⛔⛔ WAVE 32 · T45 (owner item 10) — AN UNCONFIGURED ACTION BLOCKS THE RUN, HERE, WHERE EVERY + # DOOR PASSES. The route checks too so a person gets a 400 rather than a silent no-op, but the + # tick and the webhook do not go through the route; a client-only block is not a block (D-112). + # ⚠ BEFORE `_claim`, deliberately: claiming and then refusing would leave the automation + # marked running until the release, i.e. a refusal that also produces a phantom 409 for the + # next honest attempt. + _refusal = run_refusal(defn) + if _refusal: + log(f"[aios-auto] refused: {_refusal}") + return None + if not _claim(tenant, auto_id): + return None + # ⛔ THE TABLES AN AUTOMATION MAKES BELONG TO THE AUTOMATION'S CREATOR, not to whoever + # happened to press Run — and above all not to the scheduler, which is not a person and + # cannot own anything (see `ut_ensure`). Without this the owner of a database was decided by + # whether a human or a cron got to the first run first. + owner = str(defn.get("createdBy") or "").strip() + if owner and username in MACHINE_OWNERS: + username = owner + try: + _step(tenant, auto_id, "running") + # A deferred metric snapshot is paid work already in Bright Data's queue. Collect it + # first and do not start another profile scrape while it is outstanding: re-running the + # action would buy duplicate engagement reads and reintroduce the timeout this handoff + # exists to remove. The normal scheduler calls this path too via `pending_collect_ids`. + # ⭐ 2026-08-09 — THE PROFILE HANDOFF IS COLLECTED FIRST, for the same reason and one + # rung earlier: a profile snapshot the vendor is still building is paid work, and + # starting a fresh scrape for the same handle would buy the identical row a second time. + # Ahead of the metric collector because the profile IS the thing the run was asked for; + # the engagement batches hang off it. + if _pending_profile_tasks(defn): + state, summary, counts, affected, steps = collect_pending_profile_snapshots( + rt, defn, username=username, log=log, + step=lambda text: _step(tenant, auto_id, text)) + return _commit_run(rt, auto_id, state, summary, + {k: v for k, v in counts.items() if k != RUN_NOTES_KEY}, + state != "error", affected, steps, + notes=counts.get(RUN_NOTES_KEY)) + if _pending_metric_tasks(defn): + state, summary, counts, affected, steps = collect_pending_metric_snapshots( + rt, defn, username=username, log=log, + step=lambda text: _step(tenant, auto_id, text)) + return _commit_run(rt, auto_id, state, summary, + {k: v for k, v in counts.items() if k != RUN_NOTES_KEY}, + state != "error", affected, steps, + notes=counts.get(RUN_NOTES_KEY)) + runner = RUNNERS.get(defn.get("kind")) + if runner is None: + return _commit_run(rt, auto_id, "error", + f"unknown automation kind {defn.get('kind')!r}", {}, False) + try: + # ⭐ WAVE 24 (item 6, on D's measurement) — THE LIVE STEP, closed over this run. + # `status.step` was already on the wire and D's half renders it; measured against the + # code, it was set exactly ONCE ("running") and never again, so the word would have + # been identical whether a run was mid-vendor-wait or genuinely hung. Rendering a + # constant as a progress indicator is worse than rendering nothing: it looks like an + # answer. The runners move it now, and the 120 s Bright Data wait counts out loud. + state, summary, counts, affected, steps = runner( + rt, defn, username=username, log=log, + step=lambda text: _step(tenant, auto_id, text), rows=rows) + except Refused as e: + return _commit_run(rt, auto_id, "error", f"refused: {e}", {}, False) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] run {auto_id} failed: {type(e).__name__}: {e}") + return _commit_run(rt, auto_id, "error", + f"{type(e).__name__}: {str(e)[:200]}", {}, False) + # ⭐ WAVE 23 (C4/C5) — THE FLOW RUNS HERE, after the machine steps and before the run is + # committed, over the records this run actually touched. ONE call site rather than three + # inside the runners: every kind gets actions and endings for free, and a fourth runner + # cannot forget to opt in. + # + # ⚠ Its absence was the wave's most expensive near-miss: actions were stored, validated, + # wired to the wire and covered by twelve gate checks that all called `apply_actions` + # DIRECTLY — so the whole feature was green and unreachable. A person would have built a + # flow, pressed Run now, and watched nothing happen. The gate now drives `run_now`. + # + # A failing action must not fail the RUN: the machine steps already wrote their rows and + # reporting that as an error would misdescribe what happened. It degrades to `partial` + # with the reason in the summary — the cap_note discipline. + # ⚠ BOUND BEFORE THE `try`. The `except` below falls through to the same `_commit_run`, + # which now reads this name — an assignment only on the success path would turn any + # action failure into a NameError inside the handler that exists to prevent exactly that. + # ⚠ THE RUNNER PRODUCES NOTES TOO, and its are the ones that survive a run which walked + # NOTHING — the branch where every candidate was a known-dead handle, i.e. exactly the + # run a person stares at wondering why the automation stopped doing anything. + run_notes = list((counts or {}).pop(RUN_NOTES_KEY, None) or []) + try: + a_counts = apply_actions(rt, defn, _flow_table(defn), affected or [], + username=username, log=log, + step=lambda text: _step(tenant, auto_id, text)) + # ⭐⭐ D-103 — POPPED BEFORE THE MERGE. The per-record reasons ride inside `counts` so + # the runner contract keeps its shape, and they must leave before the merge or they + # would be a "count" everywhere downstream. + run_notes += list(a_counts.pop(RUN_NOTES_KEY, None) or []) + counts = {**(counts or {}), **{k: v for k, v in a_counts.items() if v}} + if a_counts.get("enrichMetricBatchesPending"): + batches = int(a_counts["enrichMetricBatchesPending"]) + state = "partial" if state != "error" else state + summary += (f". {batches} post-engagement batch" + f"{'' if batches == 1 else 'es'} still building; Views and other " + "metrics will be collected automatically without another paid read") + if a_counts.get("enrichUnbound"): + # ⛔ D-79(2): AND THE FIX GOES IN THE SUMMARY, not only in the log. The run is + # `partial` because it genuinely did part of its job — it walked the records — and + # the sentence names the ONE thing that has to change, in the two places a person + # can change it. The old behaviour was `ok` with an empty table. + state = "partial" if state != "error" else state + summary += (". The Instagram step did not run: this database has no profile " + "column. Name one on the step, or mark a text column as the " + "Instagram profile" + + _unbound_hint(rt, _flow_table(defn))) + if a_counts.get("ttEnrichUnbound"): + # ⛔ WAVE 30 · T08 — ITS OWN SENTENCE, not the one above with a word swapped by a + # variable. A flow may carry BOTH steps, and the fix a person has to apply is + # per-column: naming an Instagram profile column does nothing for a TikTok step, + # so a single sentence covering "the enrich step" would send them to the wrong + # place half the time. Both may appear on one run, which is correct. + state = "partial" if state != "error" else state + summary += (". The TikTok step did not run: this database has no TikTok profile " + "column. Name one on the step, or mark a text column as a TikTok " + "profile" + + _unbound_hint(rt, _flow_table(defn))) + if a_counts.get("ttEnrichBlocked"): + state = "partial" if state != "error" else state + tt_note = next((n for n in run_notes if "(TikTok): " in n), "") + summary += (f". {int(a_counts['ttEnrichBlocked'])} TikTok profile read(s) were " + "blocked" + (f". {_s(tt_note, 220)}" if tt_note else "")) + if a_counts.get("enrichProfileBatchesPending"): + # ⭐ The paid profile the vendor is still building. Said out loud so a run that + # looks like a failure is read as the handoff it is — the tick finishes it. + n = int(a_counts["enrichProfileBatchesPending"]) + state = "partial" if state != "error" else state + summary += (f". {n} profile read{'' if n == 1 else 's'} took longer than the " + "wait allows and will be collected automatically, at no extra cost") + if a_counts.get("enrichBlocked"): + state = "partial" if state != "error" else state + # ⭐⭐ D-103 — THE REASON IS IN THE SENTENCE, not only behind a click. "1 profile + # read(s) were blocked" is the exact string the owner read three mornings running + # before asking "wtf is going on"; it names a quantity and withholds the one + # thing that would let anybody act. The first note is the vendor's own words. + # ⚠ ...and the Instagram selector SKIPS the tagged TikTok lines for the same + # reason. Two sentences quoting each other's vendor reason is worse than one. + blocked_note = next((n for n in run_notes + if ": " in n and "(TikTok): " not in n), "") + summary += (f". {int(a_counts['enrichBlocked'])} profile read(s) were blocked" + + (f". {_s(blocked_note, 220)}" if blocked_note else "")) + # ⭐ WAVE 25 · C5 — A FULL TARGET IS A `partial` RUN THAT SAYS SO. D-11 made this the + # law for the runners' OWN writes (`cap_note`), and `create_record` never joined: it + # logged the cap and rolled up `ok`, so a flow that had silently stopped writing + # looked exactly like one that had nothing to write. Same rule, same sentence shape. + if a_counts.get("createCapped"): + state = "partial" if state != "error" else state + summary = (f"{summary}. {a_counts['createCapped']} row(s) NOT created: a target " + f"database is at its row cap") + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] actions on {auto_id} failed: {type(e).__name__}: {e}") + state = "partial" if state != "error" else state + summary = f"{summary}. The actions did not finish ({type(e).__name__})" + return _commit_run(rt, auto_id, state, summary, counts, state != "error", affected, + steps, notes=run_notes) + finally: + _release(tenant, auto_id) + + +def run_async(rt, tenant, auto_id, username="automation", log=print, rows=None): + """Start a run on a background thread. False when one is already in flight (→ 409).""" + if running(tenant, auto_id): + return False + th = threading.Thread(target=run_now, args=(rt, tenant, auto_id, username, log, rows), + daemon=True, name=f"automation-{tenant}-{auto_id}") + th.start() + return True + + +# --------------------------------------------------------------------------------------------- +# THE TICK + the in-process scheduler +# --------------------------------------------------------------------------------------------- + +def pending_collect_ids(rt): + """⭐ 2026-08-06 — automations holding a snapshot the vendor is still building. + + ⛔ THE DEFECT THIS CLOSES, reported live: *"why is the SMALL 10 records test search taking + forever, its not populating"*. A corpus search takes ~20 minutes, so the run hands off and + stores `pendingSnapshot` with the summary *"The next run picks up the results"* — which is + true only if there IS a next run. The owner's automation is MANUAL (`schedule.enabled` false), + so `due_ids` never returns it, nothing ever collected it, and the results the account had + already been charged for sat ready at the vendor forever. The sentence was not wrong; it was + describing a run nobody had scheduled. + + A pending snapshot is unfinished work the tenant has already paid for, so the tick finishes it + regardless of schedule. Independent of `due_ids` on purpose — a schedule says *start something + new*, this says *collect what is already running*, and folding the second into the first would + make an unscheduled automation's paid result depend on someone remembering to press a button. + """ + out = [] + for aid, d in all_definitions(rt).items(): + if not isinstance(d, dict): + continue + if (d.get("trigger") or {}).get("paused"): + continue + # ⭐⭐ WAVE 30 · T05 — BOTH discovery kinds, and this one is a MONEY defect rather than a + # cosmetic one. It tested `== "discover_instagram"`, so a TikTok corpus search stored its + # `pendingSnapshot`, told the person *"The next run picks up the results"*, and was then + # never returned by this function — the tick collected nothing, forever. That is EXACTLY + # the live incident quoted in the docstring above (*"why is the SMALL 10 records test + # search taking forever, its not populating"*), reproduced for the second platform by the + # wave that added it: a result the tenant has already been charged for, stranded. + # ⚠ NOT ON THE SCOUT'S LIST OF FIVE. Found by reading this function for a different + # ticket, which is the argument for `DISCOVERY_KINDS` in one line — the sites that test a + # kind string are not enumerable by memory, and this one is three thousand lines from the + # others. Pressing Run again does still collect (the runner's own branch reads the same + # field), so the money was recoverable BY HAND and only ever silently lost on a schedule. + pending_discovery = (d.get("kind") in DISCOVERY_KINDS + and str((d.get("state") or {}).get("pendingSnapshot") or "").strip()) + # ⭐ 2026-08-09 — PROFILE handoffs join the other two. Without this line the profile + # deferral would be stored and never collected, which is the same defect it fixes wearing + # a queue: `due_ids` only returns SCHEDULED automations, and the automation this was + # measured on is `trigger: manual`. A capability written down but never walked is what + # this whole change is about, so it must not be reintroduced one function later. + if pending_discovery or _pending_metric_tasks(d) or _pending_profile_tasks(d): + out.append(aid) + return sorted(out) + + +def due_ids(rt, now=None): + """Which of this tenant's automations a tick at `now` should start. Pure over the store.""" + return sorted(aid for aid, d in all_definitions(rt).items() if is_due(d, now)) + + +def tick(rt, tenant, now=None, log=print): + """Fire every due automation for ONE tenant + poll every email trigger (C3). Returns the + ids started. The email polls are bounded and fail-quiet per automation — one broken + mailbox connection must not stop the tenant's schedules.""" + started = [] + # ⭐ COLLECT-FIRST (2026-08-06). A snapshot the vendor has finished building is a result the + # tenant has already been charged for; it is collected whether or not this automation is on a + # schedule. `_claim` makes the union safe — an id in both lists starts once. + for aid in dict.fromkeys(list(pending_collect_ids(rt)) + list(due_ids(rt, now))): + if run_async(rt, tenant, aid, username="scheduler", log=log): + started.append(aid) + for aid, d in all_definitions(rt).items(): + if (d.get("trigger") or {}).get("key") == "email": + try: + if email_poll(rt, tenant, aid, d, log=log) is not None: + started.append(aid) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] email poll {tenant}/{aid} failed: {type(e).__name__}: {e}") + # C7's amendment: date-window metrics advance with the tick, so `today` is never staler + # than one tick while a scheduler exists. A tenant without metric fields pays a dict scan. + try: + refresh_metrics(rt, log=log) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] metric refresh {tenant} failed: {type(e).__name__}: {e}") + # ⭐ 2026-08-07 — the relational pass, on the same tick and for the same reason. A rollup on + # table A goes stale when table B gains a row, and A cannot know that happened; the tick is + # the only place that sees both. ⚠ SEPARATE try/except from the metrics above deliberately — + # one pass failing must not silently cancel the other, which a shared block would do. + try: + refresh_relations(rt, log=log) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] relation refresh {tenant} failed: {type(e).__name__}: {e}") + # ⭐ THE READ-THROUGH ROLLUPS, on the same tick and for a stronger version of the same reason. + # A linked rollup goes stale when the LINKED TABLE gains a row; a source-backed one goes stale + # when ODOO does — and its window moves on its own besides (a `ytd` column is wrong on 1 + # January without anybody writing anything). The tick is the only place that sees either. + # + # ⛔ ITS OWN try/except, like the two passes above, and the reason is the same one stated + # there: this pass REFUSES loudly on a truncated group set, and a shared block would let that + # honest refusal silently cancel the relational pass that had already succeeded. + # + # ⚠ IT WRITES NOTHING WHEN IT REFUSES — see `rollup_sql`'s header. A half-applied rollup mixes + # two vintages of one column and looks completely normal, which is why the refusal is total. + try: + import rollup_sql + for _tk in list((rt.get(UT_STORE_KEY) or {})): + if rollup_sql.source_fields((rt.get(UT_STORE_KEY) or {}).get(_tk) or {}): + # ⚠ `today` is DEFAULTED INSIDE `compute`, not passed: this scope has no + # such local, and `refresh_metrics` above resolves it the same way. + n = rollup_sql.compute(rt, _tk) + if n: + log(f"[aios-auto] source rollups {tenant}/{_tk}: {n}") + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] source rollup {tenant} failed: {type(e).__name__}: {e}") + if started: + log(f"[aios-auto] tick {tenant}: started {', '.join(started)}") + return started + + +def tick_all(now=None, log=print): + """Every registered tenant. Fail-quiet per tenant: one tenant's broken store must not stop + the others' schedules.""" + from harness import runtime as _rt + out = {} + for slug in _rt.known_tenants(): + try: + out[slug] = tick(_rt.get_runtime(slug), slug, now=now, log=log) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] tick {slug} skipped: {type(e).__name__}: {e}") + return out + + +#: How often the in-process scheduler wakes. A minute is the cron resolution; anything finer +#: would be a busy loop against a vocabulary that cannot express it. +TICK_SECONDS = int(os.environ.get("AIOS_AUTOMATION_TICK_SECONDS") or 60) +_SCHEDULER = [None] + + +def scheduler_loop(log=print): + """The resync-daemon pattern (`api/main.py:313`): sleep FIRST, then work. + + Sleeping first is deliberate and load-bearing for the gate battery — `verify_api` and + `verify_seam` import this module through `main.py`, run against fake stores in seconds and + exit. A loop that ticked on entry would fire inside them. + """ + while True: + time.sleep(TICK_SECONDS) + try: + tick_all(log=log) + except Exception as e: # noqa: BLE001 + log(f"[aios-auto] scheduler tick failed: {type(e).__name__}: {e}") + + +def start_scheduler(log=print): + """Start the loop once per process. **OPT-IN: `AIOS_AUTOMATIONS=1`.** + + ⚠ AMENDED 2026-08-03 (D), and the reason is a measurement rather than a preference. The wave + brief specified this thread DEFAULT-ON for the API. Then `verify_api.py` was timed: **196 + seconds**, i.e. more than three 60 s tick intervals. A default-on scheduler therefore fires + two or three times inside a gate that imports `main.py` — and while a tick over a fake store + finds nothing due, `tick_all` reaches `runtime.get_runtime()`, which BUILDS tenants and + mutates the LRU cache that `verify_api`'s own isolation assertions read. A background thread + that can move a gate's subject is a flaky suite waiting to happen. + + So it takes the convention `main.py:353` already established for exactly this hazard — + `AIOS_PREWARM=1` — for exactly the reason stated there: env-gated rather than a startup + event, so importing this module in a gate can never fire anything. The DEPLOY sets it; the + external EventBridge tick (R5) does not depend on it either way, since that POSTs the + endpoint rather than riding this thread. + """ + if os.environ.get("AIOS_AUTOMATIONS") != "1": + return False + if _SCHEDULER[0] is not None: + return False + th = threading.Thread(target=scheduler_loop, kwargs={"log": log}, + daemon=True, name="automation-scheduler") + _SCHEDULER[0] = th + th.start() + return True diff --git a/api/main.py b/api/main.py index e01231067a846642502a3d0d0b08bb989a418e81..7a4d6b0d75cff01cda1ebb67299da4d88a4ae9f4 100644 --- a/api/main.py +++ b/api/main.py @@ -1,921 +1,948 @@ -"""AIOS web API — the ONE shared, stateless process the Streamlit exit is aimed at. - -It REUSES the `platform/` data layer verbatim (nothing re-implemented): the canonical, -reconciled Odoo model stays server-side and the browser only ever sees derived JSON. Serves the -JSON API under `/api/*` and the built React bundle (`aios-web/web/dist`) at `/`. - -WHAT CHANGED IN EXIT WAVE 1 (2026-07-30) — three things, all of them load-bearing: - - * **Real sessions replace HTTP Basic.** The whole app used to sit behind one shared - `APP_PASSWORD` with the browser's native Basic prompt, which means every caller was the same - anonymous principal and no route could scope anything. Now a signed stateless cookie carries - a real `core/users` identity (X3), and every v1 route resolves BU + own-book scope from the - user RECORD. `/api/health` stays unauthenticated (liveness only). - * **The overlay fork is deleted.** `aios-web/api/data/overlay.json` was a second writable home - for user-owned fields the Streamlit app keeps in the tenant store. ONE store now (C1c). - * **The SSL shim is env-gated.** It used to run at import, unconditionally. - -⚠ TENANT RESOLUTION IS PER REQUEST (X7). Nothing tenant-shaped is held at module level; the -session's tenant claim resolves through `harness.runtime.get_runtime`, which is LRU-bounded. This -is the rule the ~30–80 MB/tenant target depends on — EXIT-0 measured the alternative at a ~0.6 GB -commit FLOOR per tenant, duplicated within 2% per tenant, nothing shared. -""" -import os -import sys -from pathlib import Path - -# --- the local Odoo SSL quirk, now BEHIND A GATE ------------------------------------------------- -# The Windows trust store reports the (valid) Odoo cert as expired, so local runs need an -# unverified default context; the HF Space and the container are unaffected. This used to run -# unconditionally at import — i.e. the shipped container disabled TLS verification for every -# outbound HTTPS call it ever made, to Odoo and to everything else, forever. That is a -# man-in-the-middle away from being someone else's data. It is now opt-in, must be set -# deliberately, and is never on by default. -# ⛔ NEVER set AIOS_INSECURE_SSL in a deployed environment. It exists for one developer laptop. -if os.environ.get("AIOS_INSECURE_SSL") == "1": - import ssl - ssl._create_default_https_context = ssl._create_unverified_context # noqa: S323 - -# --- reuse the tenant #0 data layer verbatim. RI_DIR overrides the location in the container -# (where the layout differs from the local sibling-dir default). --- -_HERE = Path(__file__).resolve() -_RI = Path(os.environ.get("RI_DIR") or (_HERE.parents[2] / "platform")) -for _p in (str(_RI), str(_HERE.parent)): - if _p not in sys.path: - sys.path.insert(0, _p) - -from dotenv import load_dotenv # noqa: E402 -load_dotenv(_RI / ".env") # Odoo creds + APP_PASSWORD, git-ignored, never committed/printed - -from fastapi import Body, Depends, FastAPI, Request # noqa: E402 -from fastapi.middleware.gzip import GZipMiddleware # noqa: E402 -from fastapi.responses import JSONResponse # noqa: E402 -from fastapi.staticfiles import StaticFiles # noqa: E402 -from starlette.exceptions import HTTPException as StarletteHTTPException # noqa: E402 - -import aios_session # noqa: E402 -import routes_admin # noqa: E402 -import routes_alerts # noqa: E402 (wave 20 item 25 — the Alerts inbox) -import routes_assets # noqa: E402 (wave 18 C2-ASSET — catalog product imagery) -import routes_auth # noqa: E402 -import routes_automation # noqa: E402 (wave 18 C4-AUTO — SESSION D's router, mounted by A) -import routes_changes # noqa: E402 (wave 29 item 20 / C6 — F's change token, mounted by A) -import routes_customers # noqa: E402 -import routes_grid # noqa: E402 -import routes_keychain # noqa: E402 (wave 18 C7 — keychain + connectors admin surfaces) -import routes_statements # noqa: E402 (EXIT-6 — the statement sender, off Streamlit) -import routes_nav # noqa: E402 -import routes_pages # noqa: E402 -import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane) -import routes_products # noqa: E402 (wave 15 C-TOPIC — gated, not yet in the nav) -import routes_records # noqa: E402 -import routes_shares # noqa: E402 (wave 20 R10 — grants for views, folders and databases) -import routes_uploads # noqa: E402 (wave 21 C5 — tabular preview for Select-from-file) -import routes_tables # noqa: E402 (wave 18 C3-UT — user-created databases over the wire) -import routes_connectors # noqa: E402 (wave 23 C11 — the connectors directory; SESSION A's router) -import routes_forms # noqa: E402 (wave 23 C9 — the PUBLIC form door; SESSION D's router) -import routes_templates # noqa: E402 (wave 23 C12 — template apply doors; SESSION E's router) -import routes_odoo_tables # noqa: E402 (wave 27 item 17 — Odoo relational; SESSION E's router) -import routes_connected_tables # noqa: E402 (wave 31 T49/C4 — the source-neutral grid door; D's) -import routes_web_agent # noqa: E402 (wave 31 R10/C5 — the web-browsing agent; E's router, A's line) -import routes_query # noqa: E402 (wave 32 R1/C5 — the Query module; E's router, A's line) -import routes_publish # noqa: E402 (wave 33 R5/C2 — the publish door; C's router, A's line) -import routes_brand # noqa: E402 (wave 33 R4/C2/C6 — connector brand marks; G's router, A's line) -import routes_slack # noqa: E402 (wave 33 R4/C2 — Manage agent + the Slack door; D's router, A's line) -from core import grid_events # noqa: E402 -from deps import Session, module_gate # noqa: E402 - -_WEB_DIST = _HERE.parents[1] / "web" / "dist" - -# --- THE CANONICAL FIELD CONTRACT: a startup assertion, and the referee gate's subject ---------- -# Every field tags its semantic TYPE and its SOURCE. `source='odoo'` is READ-ONLY (Odoo is never -# written); `source='overlay'` is the editable stratum that lives OUTSIDE Odoo. Loaded from the -# ONE canonical file shared with the embedded host (`platform/aios_grid_fields.json`), so -# embed == standalone by construction. -# -# TWO REASONS THIS IS HERE and not folded into the routes: -# 1. It FAILS LOUDLY at import when the file is missing — which means the deploy or the RI_DIR -# layout is broken, and discovering that from a 500 on the first customer request instead of -# at startup costs a debugging session. (The pre-wave main.py had this guard; the EXIT-2a -# rewrite dropped it and this restores it.) -# 2. `aios-web/verify_fields_contract.py` — the cross-side REFEREE — reads `FIELDS` and -# `_PASSTHROUGH_KEYS` from this module to prove the canonical file, `aios_grid.py` and this -# API have not drifted. The rewrite removed them and the referee went red; retargeting the -# gate would have been the wrong repair ([[gate-can-report-green-on-nothing]]: retarget, do -# not delete — but only when the subject genuinely moved. Here it should not have moved). -# -# ⚠ THE ROUTES SERVE A SUPERSET OF THIS. `routes_customers._payload` derives its field list from -# `aios_grid.fields_from_workspace(ws)`, which is `FIELDS` PLUS the session user's own custom_ and -# measure_ columns — the same list the Streamlit host renders. That is the point: the standalone -# shell now sees the user's own columns instead of the bare base contract. `FIELDS` is the -# canonical FLOOR, asserted below to be exactly what `aios_grid` starts from. -import json as _json_contract # noqa: E402 -_FIELDS_PATH = _RI / "aios_grid_fields.json" -if not _FIELDS_PATH.is_file(): - raise FileNotFoundError( - f"AIOS web API: canonical field contract missing at {_FIELDS_PATH}. Set RI_DIR to the " - "platform root (it also carries the data layer this API imports)." - ) -_contract = _json_contract.loads(_FIELDS_PATH.read_text(encoding="utf-8")) -FIELDS = _contract["fields"] if isinstance(_contract, dict) else _contract -# text/status/select/date pass through untouched; every OTHER odoo field is numeric -> rounded. -# Derived from field TYPE (not a hand-kept key list) so a new text/date field can never be -# wrongly rounded. `select` joined 2026-08-02 (dba) — a choice label rounded would be garbage. -_PASSTHROUGH_KEYS = {f["key"] for f in FIELDS - if f["source"] == "odoo" and f["type"] in ("text", "status", "select", - "date")} - -app = FastAPI(title="AIOS web API") - -# The customers payload measured 1.15 MB of JSON on the live Space, shipped UNCOMPRESSED — with -# the 754 KB bundle behind it, most of "the app is slow" was bytes on the wire. gzip takes the -# payload to ~10–15% of that. minimum_size spares the tiny acks the overhead. -app.add_middleware(GZipMiddleware, minimum_size=1024) - - -@app.exception_handler(StarletteHTTPException) -def _error_shape(request: Request, exc: StarletteHTTPException): - """ONE error shape for every non-2xx (X2): `{"error": {"code", "message"}}`. - - `deps.err` already raises detail in that shape; anything FastAPI raises on its own (a 404, a - 422 from a malformed path param) is wrapped here so a client never has to branch on two - different error bodies. - """ - detail = exc.detail - if isinstance(detail, dict) and "error" in detail: - body = detail - else: - body = {"error": {"code": f"http_{exc.status_code}", "message": str(detail)}} - return JSONResponse(body, status_code=exc.status_code, - headers=getattr(exc, "headers", None)) - - -@app.exception_handler(grid_events.StoreUnavailable) -def _store_unavailable(request: Request, exc: grid_events.StoreUnavailable): - """THE SAFETY NET for "the store is down" → 503, from anywhere. - - ⚠ WHY THIS IS APP-LEVEL AND NOT A `try` PER ROUTE. It was a try per route first, and a - `StoreUnavailable` raised while BUILDING THE PAYLOAD — before the route reached its own - try/except — surfaced as a 500. A store outage is a normal operational state and every route - here touches the store at least twice (the workspace read, then the write), so "remember to - wrap it" is a rule that gets forgotten once and then reports the wrong thing. One handler - means a store outage can only ever be a 503, whichever call raised it. - - The seam raises this only when the caller passed no `fallback_ws` — i.e. exactly on this - adapter, which has no durable session dict to degrade into. A 200 over a write that - evaporated is the failure the whole rule exists to prevent. - """ - return JSONResponse( - {"error": {"code": "store_unavailable", - "message": "the tenant store is unavailable — no change was saved"}}, - status_code=503) - - -@app.get("/api/health") -def health(): - """Unauthenticated LIVENESS only — it must answer before anyone can sign in, so it may not - reveal anything about the deployment beyond "the process is up". No version, no tenant list, - no config: a health endpoint is the one URL every scanner finds first. - - ⛔ THE VERSION DOES NOT GO HERE, and it was asked to (2026-08-04, when LIVE became a pinned - release and "what is LIVE running" needed an answer). A build identifier tells an unauthenticated - caller exactly which commit's known issues apply. It rides `GET /api/v1/settings` instead, behind - a session — and the authoritative copy is the `VERSION` file in the Space repo, which - `deploy_web.space_version()` reads without needing the app to be up at all.""" - return {"ok": True} - - -app.include_router(routes_auth.router) -app.include_router(routes_nav.router) -app.include_router(routes_customers.router) -app.include_router(routes_products.router) -app.include_router(routes_assets.router) -app.include_router(routes_tables.router) -app.include_router(routes_grid.router) -app.include_router(routes_records.router) -# EXIT wave 2: the Y1 page-data envelope (one route for every ported dashboard) and Y4's user -# administration. `routes_pages` imports `pages`, which lazily imports each `pages_*` builder — so -# a new page is a builder module plus one registry line, and nothing here changes. -app.include_router(routes_pages.router) -app.include_router(routes_admin.router) -app.include_router(routes_automation.router) -app.include_router(routes_keychain.router) -app.include_router(routes_statements.router) -# Wave 19 (owner item 13, R3): the LOOPABLE admin plane — the platform's own cross-tenant view. -# Its own prefix (`/api/v1/platform-admin`), never nested under `/admin`, so there is no path -# ambiguity with `routes_admin`'s `{username}` params and no chance of a tenant-admin route and a -# platform-operator route ever shadowing each other. Every path it declares is gated by -# `core.platform_admin.is_platform_admin`, and `verify_api` enumerates this router to prove it. -app.include_router(routes_platform_admin.router) -# Wave 20 (owner items 25 + 18/23/26): the Alerts inbox and the manage-access surface. Both are -# session-gated rather than admin-gated — an alert is a person's own subscription, and sharing is -# something every user does with their own views/folders/databases. -app.include_router(routes_alerts.router) -app.include_router(routes_shares.router) -app.include_router(routes_uploads.router) -# ⭐ WAVE 23 — THE THREE NEW ROUTERS. Mounted here, above the static catch-all at the bottom of this -# file, because `app.mount("/", _AppStatic(...), html=True)` swallows everything it is reached by: -# a router included AFTER it answers 404 forever while importing fine, type-checking fine and -# passing its own gate. That is the wave-20 declared-but-unmounted shape with a different cause. -# -# ⛔ ALL THREE EXISTED, COMPLETE AND GATED, WITH NO MOUNT until the close-out audit — three finished -# features that would have shipped dead. The workers each posted a mount ask and said they would -# signal "ready" first; C waited for a signal that never came while the files landed anyway. **The -# lesson is not "read the mailbox harder": `verify_api`'s enumeration is what caught it, so the -# control is that the enumeration must NAME every router in this file.** -app.include_router(routes_templates.router) # item 7 / C12 — template registry, session-gated -app.include_router(routes_connectors.router) # item 10 / C11 — the connectors directory -# ⚠ routes_forms is the FIRST NEW PUBLIC DOOR since the `_DEV_FIXTURES` scar (see :257 below). Its -# two paths are DELIBERATELY unauthenticated — a form is filled in by someone with no account — so -# it joins `/api/health`, the automation hook and the tick on the exempt list. It resolves a token -# by scanning tenants with a constant-time compare and answers a uniform 403, so a bad token cannot -# distinguish "no such form" from "not yours", and it never echoes a tenant, table or slug. -app.include_router(routes_forms.router) # item 8 / C9 — the PUBLIC form door -# WAVE 27 item 17 — E's Odoo relational doors. Mounted in the SAME change that E's module landed, -# because the wave-23 scar is exactly this: three finished routers shipped with no include_router -# line — complete, gated, type-clean and 404 for every caller. `verify_api.section_mounts` walks -# `main.app.routes` and pins these two paths by NAME, so an unmounted router now goes RED. -app.include_router(routes_odoo_tables.router) -# ⭐⭐ WAVE 31 · T49 / C4 — THE SOURCE-NEUTRAL DOOR TO THE SAME CAPABILITY. -# `/api/v1/connected-tables/{key}/rows` is an ALIAS: every request lands in -# `routes_odoo_tables.odoo_table_rows`, so "the Odoo path behaves byte-identically" holds by -# CONSTRUCTION rather than by two implementations that agree on the day they were written. R2 puts -# Meta Ads on the same mirror, and a Meta campaign served from a URL with `odoo` in it is a name -# that lies to every network tab, log line and bug report. -# ⚠ MOUNTED IN THE SAME CHANGE AS THE MODULE, per the line above and for the same wave-23 scar. -app.include_router(routes_connected_tables.router) -# ⭐⭐ WAVE 29, item 20 / R11 / contract C6 — F's CHANGE TOKEN. `GET /api/v1/changes?scope=` -# answers "did this bucket change" for ~zero cost (an in-memory counter; ZERO `store.get()` deep -# copies), which is what lets a filtered view pick up a row created in another tab, by an automation -# or by a connector sync without re-downloading the world. -# ⛔ THIS LINE IS THE ARTIFACT THIS PROTOCOL LOSES MOST RELIABLY, AND IT WAS ALREADY LOST ONCE HERE: -# F's router and the CLIENT half both shipped complete, so the poller was calling `/api/v1/changes` -# six times a minute and taking a 404 while every one of F's own gates was green. `verify_api`'s D-48 -# leg caught it (`unmatched: [('/api/v1/changes', 'apiBridge.ts')]`) — a client fetch path with no -# mounted route — which is precisely the control the wave-23 scar above was written to install. -# ⚠ Mounted is NOT callable: `section_changes_callable` in `verify_api.py` SIGNS IN and CALLS this -# route, because a route can be mounted and still raise before its own `try:` (D-107's plain-text 500). -app.include_router(routes_changes.router) # item 20 / C6 — F's router, A's line -# ⛔ WAVE 31 (R10 / C5) — E's router, taken by the INTEGRATOR under W31-T08's own done-when -# ("no router ships unmounted") because `main.py` is D's fence and D's queue did not reach it. -# It was written, gated and 404-dead: `verify_web_agent.py` asserts THIS LINE and was red at -# 60/61 for it. Four waves of the same defect — three routers in wave 23, four features in -# wave 29 — is why the assertion exists and why the line is not left for later. -app.include_router(routes_web_agent.router) # R10 / C5 — E's router, A's line -# ⭐⭐ WAVE 32 (R1 / C5, cross-fence wiring 6) — THE QUERY MODULE. E's router, A's line, and the -# FIFTH consecutive wave in which this exact line is the artifact the protocol nearly loses. -# ⛔ MOUNTED HERE, IN THIS BLOCK, AND NOT AT THE END OF THE FILE — measured by SESSION E in its own -# gate before it front-inserted: `include_router` APPENDS, and `app.mount("/", _AppStatic(...), -# html=True)` swallows everything reached after it, so a router added below that mount answers -# **405 on POST and 404 on GET** while every one of its own tests passes. The comment at :213 states -# the rule; E's measurement is what turns it from advice into a number. -# ⚠ `verify_api` asserts `/api/v1/query` in `app.openapi()["paths"]` — NEVER `{r.path for r in -# app.routes}`, which finds nothing in this app because FastAPI wraps included routers (W31). -app.include_router(routes_query.router) # R1 / C5 — E's router, A's line -# ⭐⭐ WAVE 33 (C2) — THE SIXTH CONSECUTIVE WAVE IN WHICH THIS BLOCK IS THE THING THE PROTOCOL LOSES. -# Contract C2 is written for exactly that: a lane creating a `routes_*.py` posts an ASK, and the -# INTEGRATOR adds the line in the SAME wave. Both of these arrived that way (C's `ASK C-1`, G's -# `ASK G-1`), and `verify_api::section_w23_mounts` asserts each path in `app.openapi()["paths"]` -# with an NC that comments a mount out and goes RED. -# ⚠ Same placement rule as the line above — ABOVE the `app.mount("/", _AppStatic(...), html=True)` -# at the end of the file, never after it. -# ⭐⭐ THE PUBLISH DOOR IS MOUNTED AGAIN (2026-08-15). It was withheld for the wave-33 deploy -# because QA reproduced three HIGH defects that are armed ONLY by mounting it. All three are fixed -# in `routes_publish.py`, each at its cause rather than at its symptom: -# 1. W33-T68 — `form` LEFT `PUBLISHABLE_MODES`. A form view's rows ARE the submissions people -# sent it, so publishing one served other people's answers to anyone holding the link. A form -# still has its own public door (`#/form/`) which serves the BLANK form and never rows. -# 2. W33-T69 — `_visible_keys` returns `[]` when a view STORED a `visible` list and none of its -# keys survive, instead of falling back to the table default. The fallback still applies to a -# view that never stored one, which is what it was written for. -# 3. W33-T70 — three separate leaks closed: the rate limit no longer keys on the caller-supplied -# `x-forwarded-for` (it keys on the socket peer and counts FAILURES only, so a shared proxy -# peer cannot become one global bucket); every failing path now spends the same PBKDF2 the -# success path spends, closing the 206x timing gap; and an unknown token on the sibling GET -# answers the LOCKED shape rather than a 403, so that route stops sorting real tokens from -# fake ones for free. -# ⚠ `verify_api::section_w23_mounts` asserts this path in `app.openapi()["paths"]`, so its four -# EXPECTED reds should now go GREEN. A red here after this line means the mount broke, not the gate. -app.include_router(routes_publish.router) # R5 / C2 — C's router, A's line (the publish door) -# ⛔ NOT BEHIND `module_gate("product_data")`, WHICH IS THE WHOLE REASON IT IS A SECOND ASSET DOOR. -# `routes_assets.py` gates EVERY one of its routes on that module, so a connector logo served from -# there would 403 for any account without the product-data grant — i.e. the Connectors directory -# would lose its logos for exactly the accounts most likely to be setting a connector up (C6). -app.include_router(routes_brand.router) # R4 / C2 / C6 — G's router, A's line (brand marks) -# ⛔ TWO OF ITS SIX PATHS ARE UNAUTHENTICATED BY DESIGN (`/slack/events`, `/slack/interact`) and -# that is the DOOR, not an omission — Slack posts to them with no session and could not carry one. -# They are built on `routes_forms`' proven public shape: signing-secret verification, a sliding-window -# rate limit, a body cap and ONE non-oracle 403, so a bad signature cannot distinguish "no such -# workspace" from "not yours". ⚠ There is no auth middleware and no exempt-path allow-list in this -# file to register them in: "public" here IS the absence of `Depends(require_session)`, which is why -# `verify_api` asserts the absence rather than an entry in a list that does not exist. -app.include_router(routes_slack.router) # R4 / C2 — D's router, A's line (Manage agent + Slack) - - -# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working) -# They are the v1 handlers with the v1 session requirement — NOT the old unauthenticated Basic -# behavior. An alias that kept the old auth would be a bypass of everything above it. -_GATE = module_gate(routes_customers.MODULE) - - -@app.get("/api/customers", deprecated=True) -def _customers_alias(session: Session = Depends(_GATE)): - return routes_customers._payload(session) - - -@app.patch("/api/customers/{pid}", deprecated=True) -def _patch_alias(pid: int, body: dict = Body(default=None), - session: Session = Depends(_GATE)): - return routes_customers.patch_customer(pid, body, session) - - -def _assert_contract_floor(): - """The canonical FIELDS must be exactly what `aios_grid` starts an empty workspace from. - - This is what stops `FIELDS` becoming a constant that exists only to satisfy a gate. If the - canonical JSON and `aios_grid.fields_from_workspace({})` ever disagree, the standalone API and - the embedded host are serving two different schemas and the grid's own contract has forked — - the exact drift `verify_fields_contract.py` was written to catch, now also caught at startup - on whatever machine is actually running. - """ - import aios_grid - base = [f for f in aios_grid.fields_from_workspace({}) if not f.get("custom")] - if [f["key"] for f in base] != [f["key"] for f in FIELDS]: - raise RuntimeError( - "AIOS web API: the canonical field contract and aios_grid.fields_from_workspace({}) " - "disagree on the base field set — embed and standalone would serve different schemas. " - "Run aios-web/verify_fields_contract.py.") - - -def _startup_notes(): - """Say the two things an operator must know, once, at import — never in a response body.""" - if aios_session.EPHEMERAL_SECRET: - print("[aios-api] AIOS_SESSION_SECRET is not set — signing with a random per-process " - "key. Sessions will not survive a restart and will not work across workers. " - "Set it in production.") - if os.environ.get("AIOS_INSECURE_SSL") == "1": - print("[aios-api] AIOS_INSECURE_SSL=1 — TLS verification is DISABLED for outbound " - "requests. Local development only; never in a deployed environment.") - - -_assert_contract_floor() -_startup_notes() - - -# ⛔ THE STATIC MOUNT IS NOW UNAUTHENTICATED, and that is a change this wave made on purpose. -# Before EXIT-3a, `BasicAuth` middleware gated the WHOLE app including this mount. A branded login -# page cannot live behind a password prompt, so the shell's own assets must be public — which they -# are: `index.html`, the JS/CSS bundle and the favicon reveal nothing. -# -# ⚠ WHAT THAT SILENTLY DE-GATED, caught in review rather than in production. `web/dist/` also -# carries `sample_customers.json`, a DEV FIXTURE copied from `web/public/`. Its 8 customers are -# synthetic, but the `agent` column holds REAL EMPLOYEE NAMES, and it went from Basic-gated to -# publicly fetchable in this commit. Nothing needs it: both the API bridge and `useCustomerData` -# deleted their sample fallback on purpose ("NOTHING HERE FALLS BACK TO sample_customers.json"), -# and it survives only because it sits in `web/public/`. So it is refused here — 404, the same -# answer as any other path that is not part of the app. -# -# The right long-term fix is deleting it from `web/public/` (S2's lane — flagged in the mailbox); -# this guard is what makes the API safe regardless of what the bundle happens to contain. -_DEV_FIXTURES = {"sample_customers.json"} - - -class _AppStatic(StaticFiles): - async def get_response(self, path, scope): - if Path(path).name in _DEV_FIXTURES: - raise StarletteHTTPException(status_code=404, detail="Not Found") - resp = await super().get_response(path, scope) - # Vite content-hashes everything under assets/ (a change is a NEW url), so those are - # immutable — a repeat visit re-downloads zero bytes instead of the whole 750 KB bundle. - # index.html must stay revalidated or a deploy would strand returning browsers on the old - # bundle; ETag/304 makes that revalidation a header exchange, not a transfer. - # ⚠ Normalised first: on a Windows host StaticFiles hands this path with backslashes, - # and `startswith("assets/")` silently skipped every asset (measured on the local probe). - if path.replace("\\", "/").lstrip("/").startswith("assets/"): - resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" - else: - resp.headers["Cache-Control"] = "no-cache" - return resp - - -# static bundle LAST so /api/* wins; html=True serves index.html at / plus the built assets -if _WEB_DIST.is_dir(): - app.mount("/", _AppStatic(directory=str(_WEB_DIST), html=True), name="web") - - -# --- boot prewarm (AIOS_PREWARM=1 — set by the Dockerfile, never by tests) ----------------------- -# Without this, the first visitor after every deploy/restart pays the full Odoo pool build in -# their request. The thread warms the CONSOLIDATED scope (None, None) + every registered page's -# default envelope; scoped users still pay their own scope's first build, once. -# Env-gated rather than a startup event so importing `api.main` in a gate (verify_api and friends -# run against fakes) can never fire a live Odoo pull. -def _seed_and_sync_store(): - """Bring the analytical store (harness.datastore) LIVE for this container — the app.py - bootstrap, mirrored (2026-07-31, owner item 1). - - `harness.datastore` powers every measure column/condition, and ONLY app.py used to call - `ensure_seed()` — so on a fresh Space disk this container resolved measures against a store - that never existed and every measure cell served blank. Seeding alone was NOT enough either, - and that was measured live the same day: "datastore seeded in 1.7s" followed by an endless - `api:measure-column: ModelError the data cache is still warming up` — `ready()` demands - EVERY entity at phase 'live', and a seed that predates a newer entity leaves it un-synced - forever in a process with no sync loop. The SYNC SPRINT after the seed is what closes the - write_date gap and backfills anything the seed lacks (app.py:7779's exact pattern, bounded - passes). Fail-quiet throughout: no seed/token/Odoo → the columns stay blank, the rows still - serve. - """ - import time as _t - t0 = _t.time() - try: - from harness import datastore as _ds - if _ds.ensure_seed(): - print(f"[aios-api] datastore seeded in {_t.time() - t0:.1f}s") - # DEDICATED Odoo connection for this thread (the W6 postmortem rule): the sync's - # search_reads must never interleave on the shared client's xmlrpc transport. - import core.odoo as _odoo - try: - _odoo._tlocal.client = _odoo.OdooClient() - except Exception: - pass - res = {} - for i in range(12): - res = _ds.sync_all(log=lambda *a, **k: None) - print(f"[aios-api] datastore sync pass {i + 1}: " - + ", ".join(f"{k}={v.get('phase')}" for k, v in sorted(res.items()))) - if all(v.get("phase") == "live" for v in res.values()): - break - # ⭐ Wave 21 (item 2, "make sure the metrics are correct"): a cursor sync can never see - # a HARD DELETE, and the downloaded seed carries whatever was deleted since it was cut — - # one reconcile pass at boot removes both classes of phantom row before the first - # measure is served. MEASURED 2026-08-05: five deleted sale_order_line rows = $284.25 of - # phantom YTD revenue, stable across re-syncs, zero the moment reconcile ran. - try: - _ds.reconcile_deletes(log=lambda *a, **k: None) - except Exception: - pass - print(f"[aios-api] datastore sync done in {_t.time() - t0:.1f}s " - f"(ready={_ds.ready()})") - # ⭐⭐ 2026-08-09 (wave 28, D-107) — THE RELATIONAL REBUILD RUNS AT BOOT, HERE. - # - # ⛔ IT DID NOT BEFORE, AND NOTHING SAID SO. The rebuild lived only inside - # `_store_resync_loop`, whose very first statement is `sleep(1800)` — so the earliest a - # freshly booted container could spawn the four locked databases was T+30 MINUTES. The - # symptom was read as "the boot path is silent": `/odoo-tables/status` polled every 30 s - # across a ~20-minute window over two boots returned the pre-wave schema on all 20 - # samples. It was not silent, it had not been asked yet. Both halves of D-107 were like - # this — a thing that never ran, mistaken for a thing that ran and failed. - # - # ⚠ WHY HERE AND NOT IN `_prewarm`: the tables are DERIVED FROM THE MIRROR, and this is - # the exact line where the mirror has finished advancing — seed, up to twelve sync passes, - # then the delete reconcile. Calling it from the other thread would race the seed and hit - # either `ro_con()`'s "still warming" RuntimeError or, worse, a HALF-SYNCED mirror, which - # is D-107's own hypothesis 3: a partial population trips `MAX_SHRINK` and the rebuild - # refuses — correctly, but for a reason that reads like a data loss scare. - # ⚠ Same thread on purpose: it is already a daemon and nothing serves requests behind it. - _pull_meta("boot") - _rebuild_odoo_relational("boot") - # ⭐⭐ W32-T07 — owner items 13 and 15, DELIVERED. See `_sweep_automation_schemas`. - # ⚠ AFTER the two above and not before: those advance the mirror and can take minutes, and - # this sweep is unrelated to it — putting it last means a slow Odoo sync cannot delay the - # one thing on this path that fixes a grid the owner has asked about twice. - _sweep_automation_schemas("boot") - except Exception as e: # noqa: BLE001 - print(f"[aios-api] datastore seed/sync skipped: {e}") - - -def _pull_meta(why): - """Pull Meta Ads into THIS container's mirror, before the relational rebuild reads it. - - ⛔ WHY IT HAS TO HAPPEN HERE AND NOT ON A LAPTOP. The mirror is a FILE that lives beside the - process; the Space's copy is seeded from the HF dataset and knows nothing about a DuckDB on a - developer's box. Running `meta_store --sync` locally populates the local mirror and the LIVE - product stays empty — which is the whole difference between "the loader works" and "the - product has the data". Odoo is already arranged this way (`sync_all` runs in the container); - this is the same arrangement for the second connector. - - ⚠ FAIL-QUIET AND SILENT WHEN THERE IS NOTHING TO DO. No token => no Meta => one line, no - error: a tenant that has not connected Meta is a normal state, and this runs on every boot. - ⚠ The window is deliberately SHORT here (`META_INSIGHTS_DAYS`, default 7 at boot) because boot - is not the place for a 90-day backfill — the resync pass widens it. - """ - try: - from harness import meta_store as _meta - if not _meta.token(): - print(f"[aios-api] meta sync skipped ({why}): no META_ADS_ACCESS_TOKEN in this " - f"deployment - the connector is idle, not broken") - return - # ⛔ PASSED, NOT SET IN THE ENVIRONMENT. `meta_store.INSIGHTS_DAYS` binds at - # IMPORT, so an `os.environ.setdefault` here executed after the module was - # already loaded and changed NOTHING: every boot pulled 90 days instead of 7, - # which is the slow path that trips the per-ad-account rate limit and never - # finishes. A knob read at import cannot be turned by a caller at runtime. - rep = _meta.sync("royal-imports", log=lambda *_a: None, insights_days=7) - for p in rep.get("problems") or []: - print(f"[aios-api] meta sync PROBLEM ({why}): {p}") - print(f"[aios-api] meta sync done ({why}): " - + ", ".join(f"{k}={v['in_mirror']}" for k, v in sorted(rep["tables"].items()))) - except Exception as e: # noqa: BLE001 - print(f"[aios-api] meta sync FAILED ({why}): {type(e).__name__}: {e}") - - -def _rebuild_meta_relational(why, rt): - """Spawn/refresh the `ut_meta_*` locked databases off the SAME mirror the Odoo half just used. - - ⭐ CALLED FROM INSIDE `_rebuild_odoo_relational`, ON PURPOSE, and the reason is D-29 rather than - tidiness: `harness/datastore` is a ONE-FILE-AT-A-TIME process global, and that caller has just - established which tenant's file this process holds open. Spawning Meta here inherits that - binding instead of rebinding it under live readers — which is the documented way to serve one - tenant's rows to another with nothing raised. - - ⚠ SILENT WHEN THERE IS NOTHING TO DO. A tenant that never connected Meta has no `meta_*` tables - in its mirror; `refresh` returns `{}` and says so once. That is a normal state, not a failure, - and it must not print an error every 30 minutes for every tenant that does not use Meta. - ⛔ Its own try/except for the reason the two passes above have theirs: a Meta refusal must not - cancel an Odoo rebuild that already succeeded. - """ - try: - import meta_relational as _meta - counts = _meta.refresh(rt, why) - if counts: - print(f"[aios-api] meta relational rebuild done ({why}): " - + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))) - try: - import automation_engine as _engine - _engine.refresh_relations(rt, log=lambda *_a: None) - except Exception as e: # noqa: BLE001 - print(f"[aios-api] meta relation cells failed ({why}): {type(e).__name__}: {e}") - except Exception as e: # noqa: BLE001 - print(f"[aios-api] meta relational rebuild FAILED ({why}): {type(e).__name__}: {e}") - - -def _sweep_automation_schemas(why): - """⛔⛔ WAVE 32 · `W32-T07` — MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE THE TABLES. - - Owner items 13 and 15 — the TikTok comments lock, and the comment CONTENT column he says he has - asked for *"many times"*. **Both were already correct in the source.** `TT_COMMENT_FIELDS` - carries `field_def("text", "Comment")` and `TT_LOCKED_TABLES` already contains the comments - table, both since wave 31, with his words quoted in the comment beside them. So this ticket is - not a schema change and there is nothing to design: it is DELIVERY. - - ⛔ THE MECHANISM, WHICH IS THE WHOLE OF IT. `ut_ensure` MERGES fields into an existing table and - stamps `recordMode` — but only **when something calls it**, and the only callers are automation - runs. A tenant whose `ut_tt_comments` was spawned before the declaration changed keeps the old - shape until an automation happens to run against it. Nothing sweeps existing tenants. That is - [[a-migration-that-runs-on-the-next-write]], and it is this wave's stated thesis: a declaration - that never reaches a tenant is indistinguishable from one that was never written. - - ⛔⛔ AND IT MUST RUN **IN THE CONTAINER**, WHICH IS WHY THIS IS IN `main.py` AND NOT A SCRIPT. - D-195, measured three times: a developer's CLI write to the tenant store is reverted by the - running Space within a minute (download-modify-upload, last-write-wins) — and **the write - reports success every time**, then a FRESH read confirms it, and it is gone by the next poll. - A connector's tables must be spawned BY THE CONTAINER; a CLI spawn is a dry run that lies. - - ⚠ EVERY TENANT, unlike `_rebuild_odoo_relational` below — and the asymmetry is deliberate - rather than an oversight. That function is scoped to royal because it derives from the DuckDB - mirror, and `harness/datastore` is a ONE-FILE-AT-A-TIME process global (D-29): rebinding it per - tenant in a daemon thread can serve one tenant's rows to another with nothing raised. This - sweep touches only `user_tables` through each tenant's own `rt`, which has no such global — so - the hazard that scopes that one does not exist here, and TikTok automations run in tenants - other than #0. - - ⚠ CHEAP ON A CORRECT TENANT: `ut_ensure` short-circuits when nothing changed, so this is a read - per child table on a tenant that is already right, and the whole delivery on one that is not. - ⚠ ONE TENANT'S FAILURE MUST NOT STOP THE NEXT. Each is wrapped: a tenant whose store is - unreachable at boot is reported and skipped, never allowed to abort the sweep for everyone. - """ - try: - import automation_engine as _eng - from harness import runtime as _runtime - except Exception as e: # noqa: BLE001 - print(f"[aios-api] schema sweep ({why}) SKIPPED — import failed: {e}") - return - try: - schemas = _eng.platform_schemas() - except Exception as e: # noqa: BLE001 - print(f"[aios-api] schema sweep ({why}) SKIPPED — no declarations: {e}") - return - tenants = [] - try: - tenants = _runtime.known_tenants() - except Exception as e: # noqa: BLE001 - print(f"[aios-api] schema sweep ({why}) SKIPPED — tenant list unreadable: {e}") - return - for slug in tenants: - try: - rt = _runtime.get_runtime(slug) - # ⛔⛔ FIXED 2026-08-13 — THIS SWEEP WAS SPAWNING EIGHT DATABASES IN EVERY TENANT. - # Owner: *"Database for Royal Imports, why we have fucking IG and TIktok databases."* - # `ut_ensure`'s first line is *"Create the table if it is missing"*, and this loop fed - # it every child of every platform schema for every tenant — so tenant #0, a floral and - # giftware importer with ZERO automations, woke up on 2026-08-13 at 12:05 UTC owning - # `ut_ig_posts`, `ut_ig_comments`, `ut_ig_snapshots`, `ut_ig_post_snapshots` and the - # four TikTok twins, all empty, all in his nav flyout. **The bug is one word wide:** - # this function's own title says *"MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE - # THE TABLES"* and its body called a CREATE-OR-MERGE function to do a MERGE-ONLY job. - # [[reuse-and-delete-are-hypotheses]] — `ut_ensure` was the right function for the - # merge and brought a second behaviour nobody wanted with it. - # - # ⭐ THE PREDICATE IS "DOES THIS TENANT ALREADY HAVE THE TABLE", read ONCE per tenant - # rather than per child — `rt.get` deep-copies the whole tenant document (28.6 MB on - # tenant #0), so asking eight times is eight copies to answer one question. - # ⚠ AND IT MUST NOT WEAKEN THE DELIVERY: a tenant that HAS `ut_tt_comments` still gets - # the `text` column and the `recordMode` stamp, which is the entire point of T07. The - # sweep now delivers to tables that exist and mints none, which is what it always - # claimed to do. - have = set(rt.get("user_tables") or {}) - ensured, skipped = [], [] - for s in schemas: - for key, child in (s.get("children") or {}).items(): - if key not in have: - skipped.append(key) - continue - got = _eng.ut_ensure(rt, child["label"], child["fields"], "automation", - key=key, lock_fields=True, - record_mode=child["record_mode"]) - if got: - ensured.append(got) - if skipped: - # ⭐ SAID OUT LOUD, never silently skipped — this repo's "no silent caps" rule. A - # sweep that quietly does nothing looks identical to a sweep that is not running, - # which is how the previous behaviour survived review in the first place. - print(f"[aios-api] schema sweep ({why}) {slug}: {len(skipped)} child table(s) not " - f"present in this tenant, so nothing was created for them " - f"({', '.join(sorted(skipped))}) — they are spawned by an automation that " - f"needs them, never by this sweep") - # ⭐ THE RETRACTION (D's `retract_foreign_presets`, D-152), AFTER the children loop. - # ⛔ THE SWEEP ABOVE MAKES COLUMNS **ARRIVE** AND CANNOT MAKE STALE ONES **LEAVE**, and - # T07's `done-when` asserts both ("no `ut_tt_*` grid carries an Instagram column"). On a - # tenant that ran TikTok before W30-T08 the 26 machine-authored IG columns are still - # there — the detector was fixed, the damage never was. - # ⚠ `kept` IS THE HONEST HALF: a foreign column that HOLDS DATA is REPORTED, never - # deleted. If it is non-empty, the screenshot shows a column and the report is the - # answer (W30/R6's second sentence). - st = {} - try: - st = _eng.retract_foreign_presets(rt, log=lambda *a, **k: None) or {} - except Exception as e: # noqa: BLE001 - print(f"[aios-api] schema sweep ({why}) {slug}: retraction FAILED: {e}") - print(f"[aios-api] schema sweep ({why}) {slug}: ensured={len(ensured)} " - f"tables={st.get('tables', 0)} columns={st.get('columns', 0)} " - f"cells={st.get('cells', 0)} flags={st.get('flags', 0)} " - f"kept={st.get('kept') or []}") - except Exception as e: # noqa: BLE001 - print(f"[aios-api] schema sweep ({why}) {slug}: FAILED: {e}") - # ⭐ A SUCCESS MARKER, for `_rebuild_odoo_relational`'s stated reason: D-107 was chased for a - # day on ABSENT log markers, which cannot tell "it ran and was fine" from "it was never - # reached". Three failure markers and no success marker makes silence ambiguous. - print(f"[aios-api] schema sweep ({why}) done over {len(tenants)} tenant(s)") - - -def _rebuild_odoo_relational(why): - """Spawn/refresh the four locked Odoo databases, then compute their cells. `why` is 'boot' or - 'resync' and rides every log line, because "it failed" and "it failed at boot, before anyone - could have asked" are different diagnoses. - - ⭐ THE SUCCESS LINE IS NOT DECORATION — it is the control this path lacked. D-107 was chased - for a day on the strength of *absent* log markers, which cannot distinguish "the rebuild ran - and was fine" from "the rebuild was never reached". Three failure markers and no success - marker means silence is ambiguous; now it is not. - - ⚠ SCOPED TO ROYAL-IMPORTS, deliberately, and it is NOT the D-29 shortcut it resembles. The - caller has just advanced whichever DuckDB file this process holds open — tenant #0's — and - royal is the only tenant with an Odoo mirror to derive from (R1). Iterating tenants here walks - straight into D-29's documented hazard: `harness/datastore` is a ONE-FILE-AT-A-TIME - process-global, and rebinding it in a daemon thread under live readers can serve one tenant's - rows to another with nothing raised. `is_royal` stays the authority on which slugs qualify. - """ - try: - import odoo_relational as _rel - from harness import runtime as _runtime - if not _rel.is_royal("royal-imports"): - return - _rt = _runtime.get_runtime("royal-imports") - counts = _rel.refresh(_rt, "royal-imports") - # ⭐⭐ AND THEN COMPUTE THE CELLS, which `refresh` does NOT do. - # - # ⛔ THE FAILURE THIS CLOSES IS THE WORST-LOOKING KIND. `refresh` writes rows and field - # DEFINITIONS; every Link and Rollup cell comes from a separate pass. Those passes used to - # live only on the automation `tick`, which fires from an EXTERNAL EventBridge cron — so a - # fresh boot landed 71,954 rows with eleven fully-configured relational columns and every - # one of them BLANK until an unrelated scheduler happened to run. Nothing errors; the - # tables simply look finished and answer nothing. - # - # ⚠ `refresh_relations`, NOT `compute_relation_cells`. The latter computes a change count - # over a blob it was handed and PERSISTS NOTHING; the former walks the tenant and writes. - # Calling the inner one here would return a plausible number and change no cell. - # - # ⚠ ONE try/except PER PASS, for the reason `tick` states at its own copies: a source - # rollup REFUSES loudly on a truncated group set, and a shared block would let that honest - # refusal silently cancel a relational pass that had already succeeded. - try: - import automation_engine as _engine - _engine.refresh_relations(_rt, log=lambda *_a: None) - except Exception as e: # noqa: BLE001 - print(f"[aios-api] odoo relation cells failed ({why}): {type(e).__name__}: {e}") - try: - import rollup_sql as _rollup - for _b, _key, _l, _f in _rel.TABLES: - _rollup.compute(_rt, _key) - except Exception as e: # noqa: BLE001 - print(f"[aios-api] odoo source rollups failed ({why}): {type(e).__name__}: {e}") - print(f"[aios-api] odoo relational rebuild done ({why}): " - + ", ".join(f"{k}={v}" for k, v in sorted((counts or {}).items()))) - _rebuild_meta_relational(why, _rt) - except Exception as e: # noqa: BLE001 - # ⚠ The TYPE is named here as it is in the two inner handlers. The original printed only - # `{e}`, and a bare message is exactly what made D-107 unreadable from the outside: a - # `BinderException` about a missing column is a different action from a timeout or an auth - # failure, and the text alone often does not say which it was. - print(f"[aios-api] odoo relational rebuild failed ({why}): {type(e).__name__}: {e}") - - -def _store_resync_loop(): - """Keep the analytical mirror current — the API-process stand-in for the re-sync the - Streamlit app piggybacks on page renders. Measure memos key on the pool stamp, so a - refreshed pool re-reads the freshly synced store.""" - import time as _t - passes = 0 - while True: - # ⭐⭐ WAVE 32 · OWNER ITEM 11 / R11 — THE TENANT'S OWN CADENCE, NOT A HARDCODED 1800. - # - # ⛔ THIS LINE IS WHY THE SETTING WAS NOT A SETTING. SESSION B built the whole of item 11 — - # the 30m/1h/4h/daily/manual presets, the server-side clamp, the config door and - # `odoo_relational.sync_seconds()` which turns the stored preset into seconds — and its own - # ticket said the one-line change belonged to A because `main.py` is A's file. That ASK was - # never sent, so the value was stored, displayed, clamped and IGNORED: a person could pick - # "every 4 hours" and the loop would keep resyncing every 30 minutes with nothing anywhere - # reporting the disagreement. Found by `verify_reachability` naming `sync_seconds` as a - # function whose ONLY caller was its own gate [[artifact-with-no-importer]]. - # - # ⚠ `None` MEANS MANUAL AND MUST NOT MEAN ZERO. R11's `manual` preset returns None from - # `sync_seconds`; treating that as a falsy interval would spin this loop with no sleep at - # all. It parks at the default cadence instead and simply does no work — the tenant asked - # not to be synced automatically, not for the server to stop breathing. - # ⚠ RE-READ EVERY PASS, deliberately: a cadence changed in Settings takes effect on the - # next cycle rather than at the next container restart, which is what makes it a setting. - # ⚠ FAIL-SAFE TO 1800 — an unreadable config must not become a tight loop. The floor is - # enforced in `sync_seconds` as well as at the write door, for the same reason. - _every = 1800 - try: - import odoo_relational as _rel_cad - from harness import runtime as _rt_cad - _secs = _rel_cad.sync_seconds(_rt_cad.get_runtime("royal-imports")) - _every = 1800 if _secs is None else max(int(_secs), 60) - except Exception: # noqa: BLE001 - pass - _t.sleep(_every) - passes += 1 - try: - from harness import datastore as _ds - import core.odoo as _odoo - try: - _odoo._tlocal.client = _odoo.OdooClient() - except Exception: - pass - _ds.sync_all(log=lambda *a, **k: None) - # Wave 21 — every 4th pass (~2h): purge hard-deleted rows the cursor sync cannot - # see (the boot pass's comment has the measured case). Cheap id-sweep per entity; - # without it deleted Odoo lines inflate every sum on the mirror FOREVER. - if passes % 4 == 0: - _ds.reconcile_deletes(log=lambda *a, **k: None) - except Exception as e: # noqa: BLE001 - print(f"[aios-api] store resync failed: {e}") - # ⭐ WAVE 27 item 17 (E's ASK ->A, resolved): the Odoo RELATIONAL tables are rebuilt - # AFTER the mirror they are derived from, in the same pass and in that order — deriving - # from a mirror this loop is about to advance would publish a worklist one cycle stale - # every single time. - # - # ⛔ OUTSIDE the try/except above, NOT folded into it. A failing relational rebuild must - # not swallow the sync's error message, and — worse the other way — a sync failure must - # not skip a rebuild that had nothing wrong with it. Two independent failures, two - # independent logs. (`_rebuild_odoo_relational` carries its own handlers.) - # - # ⚠ THE REBUILD IS NOT THE FRESHNESS GUARANTEE — the rows carry a visible `_refreshed` - # stamp for that. This loop is a daemon thread whose failure path is a `print` (D-29), so - # "the wiring exists" and "the data is current" are different claims and only the stamp - # can tell a user which one they are looking at. - # - # ⭐ ONE IMPLEMENTATION, TWO CALLERS (wave 28). This block used to be the only copy, which - # is what made the boot path silent for 30 minutes after every restart; it is now the - # SECOND caller of the same function `_seed_and_sync_store` calls at boot. A copy here - # would be a second thing to keep in step, and the two would answer differently on the - # next ruling — the exact shape the Views top-up was just fixed for on the other side of - # this wave. - # ⛔ THE META PULL RIDES THE RESYNC TOO, AND LEAVING IT OUT WAS A REAL GAP — caught by - # reading the deploy's own boot log rather than by any gate. `_pull_meta` was wired into - # `_seed_and_sync_store` ALONE, i.e. it ran exactly once per container, at boot, behind a - # full Odoo sync. So a boot where the Graph call was rate-limited, slow or simply after the - # thread died left the mirror empty with NOTHING to retry it: the relational rebuild below - # would then find no `meta_*` tables every 30 minutes forever and skip, silently and - # correctly. A connector that can only ever be established at boot is one bad boot away - # from being permanently absent. - # ⚠ Cheap when there is nothing to do: no token => one line and return. - _pull_meta("resync") - _rebuild_odoo_relational("resync") - - -def _prewarm(): - import time as _t - t0 = _t.time() - # ⚠ The store sync runs in its OWN thread, never ahead of the pool warm: a stale seed can - # take many minutes of XML-RPC to close, and the first live probe of this arrangement - # showed the pool warm (42s) silently queued behind it — the whole app cold for every - # visitor while a background column channel caught up. Measure cells retry via the - # transient rule until the sync lands; nothing else waits on it. - import threading as _th - _th.Thread(target=_seed_and_sync_store, daemon=True, name="store-seed-sync").start() - try: - from harness import runtime as _runtime - rt = _runtime.get_runtime("royal-imports") - routes_customers.warm_default(rt) - import pages as _pages - _pages.warm_default(rt) - # ⭐⭐ WAVE 30 · T12, THE COLD PATH (owner items 4/5). Automation was the ONE module absent - # from this list, so its memo was always filled by a visitor rather than by boot: call 1 of - # `GET /automations` after every deploy downloaded the whole `user_tables` document (35.8 MB - # ceiling, under the store lock) onto whoever clicked first. Memoising the WARM path — two - # waves of it — could not touch that, because the cold call is the one that fills the memo. - # ⚠ It elects a STRING and keeps no document; see `warm_default`'s own note on why caching - # the bucket would trade a latency for memory this tier does not have. - import routes_automation as _rauto - _rauto.warm_default(rt) - print(f"[aios-api] prewarm done in {_t.time() - t0:.1f}s") - except Exception as e: # noqa: BLE001 — boot must not die on a warm-up - print(f"[aios-api] prewarm skipped: {e}") - - -# ═════════════════════════════════════════════════════════════════════════════════════════════ -# ⭐⭐ W31-T46 / D-160 — THE MIRROR IS SEEDED WHETHER OR NOT `AIOS_PREWARM` IS SET. -# ═════════════════════════════════════════════════════════════════════════════════════════════ -# -# THE DEFECT, and it has cost a release once already (owner item 12, wave 20). `_prewarm()` was -# the ONLY caller of `_seed_and_sync_store()`, which is the ONLY caller of -# `datastore.ensure_seed()`. So on a fresh Space disk with `AIOS_PREWARM` anything but `1`: -# no `royal.duckdb` ⇒ `datastore.ready()` False forever ⇒ `ro_con()` refuses ⇒ every measure -# column blank AND — since wave 30 put the Odoo grids on the mirror — **two user-facing grids -# serve nothing**, with the app RUNNING, the deploy green and the tag correct. Last time the -# symptom was read as a pinned tag and a store problem for days. -# -# ⛔ AND THE FLAG IS EASIER TO LOSE THAN IT LOOKS: `deploy_web.py` pushes `AIOS_PREWARM=1` -# EXPLICITLY (to overwrite a stale `0`, because a Space secret survives a redeploy) — but that -# push sits under `if TARGET:`, so an ordinary bare `python deploy_web.py` skips it. A guard -# written for the dangerous case that the ordinary case walks straight past. -# -# ⭐ SO THE SEED IS SPLIT OFF AND MADE UNCONDITIONAL, which is T46's first branch rather than its -# fallback. It is the right half to move because it is the CHEAP, SAFE one: `ensure_seed()` -# touches no Odoo (it is one `hf_hub_download` of a snapshot), returns immediately when the file -# is already there, and returns immediately without `HF_TOKEN`. The EXPENSIVE, live half — -# `sync_all()`'s XML-RPC passes, the pool warm, the resync loop — stays exactly where it was, -# because the env gate's stated reason is still true: importing `api.main` in a gate must never -# fire a live Odoo pull. -# -# ⚠ THE `DB_PATH.exists()` PRE-CHECK IS WHAT KEEPS THIS FREE. It is a stat, and it is False only -# on a genuinely fresh disk — so on every developer box and in every gate run the thread is never -# started at all, and on a fresh Space it does exactly the thing whose absence blanks the grids. -#: What the boot seed did, so a surface can REPORT it instead of an operator inferring it from a -#: blank grid. R6's second sentence: a limit that cannot be removed is reported with its cause. -MIRROR_SEED = {"attempted": False, "seeded": False, "cause": "", "recommendation": ""} - - -def _seed_mirror_if_absent(): - """Hydrate the analytical mirror when this container has none — INDEPENDENT of `AIOS_PREWARM`. - - Fail-quiet by design, and it records WHY rather than only whether: "there is no mirror and no - HF_TOKEN to fetch one" and "there is no mirror and the fetch failed" are different operator - actions, and a blank grid cannot tell them apart. - """ - from harness import datastore as _ds - MIRROR_SEED["attempted"] = True - try: - if _ds.ensure_seed(): - MIRROR_SEED["seeded"] = True - print("[aios-api] analytical mirror seeded at boot (independent of AIOS_PREWARM)") - return True - if not _ds.DB_PATH.exists(): - MIRROR_SEED["cause"] = ( - "this container has no analytical mirror and the seed snapshot could not be " - "fetched (no HF_TOKEN, or the dataset was unreachable)") - MIRROR_SEED["recommendation"] = ( - "set HF_TOKEN on the deployment; until then every connected grid and every " - "measure column served from the mirror is empty") - print(f"[aios-api] NO ANALYTICAL MIRROR: {MIRROR_SEED['cause']}") - except Exception as e: # noqa: BLE001 — boot must not die - MIRROR_SEED["cause"] = f"the boot seed raised {type(e).__name__}: {e}" - MIRROR_SEED["recommendation"] = "check HF_TOKEN and the seed dataset's availability" - print(f"[aios-api] mirror seed skipped: {e}") - return False - - -try: - from harness import datastore as _ds_boot - if not _ds_boot.DB_PATH.exists(): - import threading as _threading_seed - _threading_seed.Thread(target=_seed_mirror_if_absent, daemon=True, - name="mirror-seed").start() -except Exception as e: # noqa: BLE001 - print(f"[aios-api] mirror seed not scheduled: {e}") - -if os.environ.get("AIOS_PREWARM") == "1": - import threading as _threading - _threading.Thread(target=_prewarm, daemon=True, name="prewarm").start() - _threading.Thread(target=_store_resync_loop, daemon=True, name="store-resync").start() +"""AIOS web API — the ONE shared, stateless process the Streamlit exit is aimed at. + +It REUSES the `platform/` data layer verbatim (nothing re-implemented): the canonical, +reconciled Odoo model stays server-side and the browser only ever sees derived JSON. Serves the +JSON API under `/api/*` and the built React bundle (`aios-web/web/dist`) at `/`. + +WHAT CHANGED IN EXIT WAVE 1 (2026-07-30) — three things, all of them load-bearing: + + * **Real sessions replace HTTP Basic.** The whole app used to sit behind one shared + `APP_PASSWORD` with the browser's native Basic prompt, which means every caller was the same + anonymous principal and no route could scope anything. Now a signed stateless cookie carries + a real `core/users` identity (X3), and every v1 route resolves BU + own-book scope from the + user RECORD. `/api/health` stays unauthenticated (liveness only). + * **The overlay fork is deleted.** `aios-web/api/data/overlay.json` was a second writable home + for user-owned fields the Streamlit app keeps in the tenant store. ONE store now (C1c). + * **The SSL shim is env-gated.** It used to run at import, unconditionally. + +⚠ TENANT RESOLUTION IS PER REQUEST (X7). Nothing tenant-shaped is held at module level; the +session's tenant claim resolves through `harness.runtime.get_runtime`, which is LRU-bounded. This +is the rule the ~30–80 MB/tenant target depends on — EXIT-0 measured the alternative at a ~0.6 GB +commit FLOOR per tenant, duplicated within 2% per tenant, nothing shared. +""" +import os +import sys +from pathlib import Path + +# --- the local Odoo SSL quirk, now BEHIND A GATE ------------------------------------------------- +# The Windows trust store reports the (valid) Odoo cert as expired, so local runs need an +# unverified default context; the HF Space and the container are unaffected. This used to run +# unconditionally at import — i.e. the shipped container disabled TLS verification for every +# outbound HTTPS call it ever made, to Odoo and to everything else, forever. That is a +# man-in-the-middle away from being someone else's data. It is now opt-in, must be set +# deliberately, and is never on by default. +# ⛔ NEVER set AIOS_INSECURE_SSL in a deployed environment. It exists for one developer laptop. +if os.environ.get("AIOS_INSECURE_SSL") == "1": + import ssl + ssl._create_default_https_context = ssl._create_unverified_context # noqa: S323 + +# --- reuse the tenant #0 data layer verbatim. RI_DIR overrides the location in the container +# (where the layout differs from the local sibling-dir default). --- +_HERE = Path(__file__).resolve() +_RI = Path(os.environ.get("RI_DIR") or (_HERE.parents[2] / "platform")) +for _p in (str(_RI), str(_HERE.parent)): + if _p not in sys.path: + sys.path.insert(0, _p) + +from dotenv import load_dotenv # noqa: E402 +load_dotenv(_RI / ".env") # Odoo creds + APP_PASSWORD, git-ignored, never committed/printed + +from fastapi import Body, Depends, FastAPI, Request # noqa: E402 +from fastapi.middleware.gzip import GZipMiddleware # noqa: E402 +from fastapi.responses import JSONResponse # noqa: E402 +from fastapi.staticfiles import StaticFiles # noqa: E402 +from starlette.exceptions import HTTPException as StarletteHTTPException # noqa: E402 + +import aios_session # noqa: E402 +import routes_admin # noqa: E402 +import routes_alerts # noqa: E402 (wave 20 item 25 — the Alerts inbox) +import routes_assets # noqa: E402 (wave 18 C2-ASSET — catalog product imagery) +import routes_auth # noqa: E402 +import routes_automation # noqa: E402 (wave 18 C4-AUTO — SESSION D's router, mounted by A) +import routes_changes # noqa: E402 (wave 29 item 20 / C6 — F's change token, mounted by A) +import routes_customers # noqa: E402 +import routes_grid # noqa: E402 +import routes_keychain # noqa: E402 (wave 18 C7 — keychain + connectors admin surfaces) +import routes_statements # noqa: E402 (EXIT-6 — the statement sender, off Streamlit) +import routes_nav # noqa: E402 +import routes_pages # noqa: E402 +import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane) +import routes_products # noqa: E402 (wave 15 C-TOPIC — gated, not yet in the nav) +import routes_records # noqa: E402 +import routes_shares # noqa: E402 (wave 20 R10 — grants for views, folders and databases) +import routes_uploads # noqa: E402 (wave 21 C5 — tabular preview for Select-from-file) +import routes_tables # noqa: E402 (wave 18 C3-UT — user-created databases over the wire) +import routes_connectors # noqa: E402 (wave 23 C11 — the connectors directory; SESSION A's router) +import routes_forms # noqa: E402 (wave 23 C9 — the PUBLIC form door; SESSION D's router) +import routes_templates # noqa: E402 (wave 23 C12 — template apply doors; SESSION E's router) +import routes_odoo_tables # noqa: E402 (wave 27 item 17 — Odoo relational; SESSION E's router) +import routes_connected_tables # noqa: E402 (wave 31 T49/C4 — the source-neutral grid door; D's) +import routes_web_agent # noqa: E402 (wave 31 R10/C5 — the web-browsing agent; E's router, A's line) +import routes_query # noqa: E402 (wave 32 R1/C5 — the Query module; E's router, A's line) +import routes_publish # noqa: E402 (wave 33 R5/C2 — the publish door; C's router, A's line) +import routes_brand # noqa: E402 (wave 33 R4/C2/C6 — connector brand marks; G's router, A's line) +import routes_slack # noqa: E402 (wave 33 R4/C2 — Manage agent + the Slack door; D's router, A's line) +from core import grid_events # noqa: E402 +from deps import Session, module_gate # noqa: E402 + +_WEB_DIST = _HERE.parents[1] / "web" / "dist" + +# --- THE CANONICAL FIELD CONTRACT: a startup assertion, and the referee gate's subject ---------- +# Every field tags its semantic TYPE and its SOURCE. `source='odoo'` is READ-ONLY (Odoo is never +# written); `source='overlay'` is the editable stratum that lives OUTSIDE Odoo. Loaded from the +# ONE canonical file shared with the embedded host (`platform/aios_grid_fields.json`), so +# embed == standalone by construction. +# +# TWO REASONS THIS IS HERE and not folded into the routes: +# 1. It FAILS LOUDLY at import when the file is missing — which means the deploy or the RI_DIR +# layout is broken, and discovering that from a 500 on the first customer request instead of +# at startup costs a debugging session. (The pre-wave main.py had this guard; the EXIT-2a +# rewrite dropped it and this restores it.) +# 2. `aios-web/verify_fields_contract.py` — the cross-side REFEREE — reads `FIELDS` and +# `_PASSTHROUGH_KEYS` from this module to prove the canonical file, `aios_grid.py` and this +# API have not drifted. The rewrite removed them and the referee went red; retargeting the +# gate would have been the wrong repair ([[gate-can-report-green-on-nothing]]: retarget, do +# not delete — but only when the subject genuinely moved. Here it should not have moved). +# +# ⚠ THE ROUTES SERVE A SUPERSET OF THIS. `routes_customers._payload` derives its field list from +# `aios_grid.fields_from_workspace(ws)`, which is `FIELDS` PLUS the session user's own custom_ and +# measure_ columns — the same list the Streamlit host renders. That is the point: the standalone +# shell now sees the user's own columns instead of the bare base contract. `FIELDS` is the +# canonical FLOOR, asserted below to be exactly what `aios_grid` starts from. +import json as _json_contract # noqa: E402 +_FIELDS_PATH = _RI / "aios_grid_fields.json" +if not _FIELDS_PATH.is_file(): + raise FileNotFoundError( + f"AIOS web API: canonical field contract missing at {_FIELDS_PATH}. Set RI_DIR to the " + "platform root (it also carries the data layer this API imports)." + ) +_contract = _json_contract.loads(_FIELDS_PATH.read_text(encoding="utf-8")) +FIELDS = _contract["fields"] if isinstance(_contract, dict) else _contract +# text/status/select/date pass through untouched; every OTHER odoo field is numeric -> rounded. +# Derived from field TYPE (not a hand-kept key list) so a new text/date field can never be +# wrongly rounded. `select` joined 2026-08-02 (dba) — a choice label rounded would be garbage. +_PASSTHROUGH_KEYS = {f["key"] for f in FIELDS + if f["source"] == "odoo" and f["type"] in ("text", "status", "select", + "date")} + +app = FastAPI(title="AIOS web API") + +# The customers payload measured 1.15 MB of JSON on the live Space, shipped UNCOMPRESSED — with +# the 754 KB bundle behind it, most of "the app is slow" was bytes on the wire. gzip takes the +# payload to ~10–15% of that. minimum_size spares the tiny acks the overhead. +app.add_middleware(GZipMiddleware, minimum_size=1024) + + +@app.exception_handler(StarletteHTTPException) +def _error_shape(request: Request, exc: StarletteHTTPException): + """ONE error shape for every non-2xx (X2): `{"error": {"code", "message"}}`. + + `deps.err` already raises detail in that shape; anything FastAPI raises on its own (a 404, a + 422 from a malformed path param) is wrapped here so a client never has to branch on two + different error bodies. + """ + detail = exc.detail + if isinstance(detail, dict) and "error" in detail: + body = detail + else: + body = {"error": {"code": f"http_{exc.status_code}", "message": str(detail)}} + return JSONResponse(body, status_code=exc.status_code, + headers=getattr(exc, "headers", None)) + + +@app.exception_handler(grid_events.StoreUnavailable) +def _store_unavailable(request: Request, exc: grid_events.StoreUnavailable): + """THE SAFETY NET for "the store is down" → 503, from anywhere. + + ⚠ WHY THIS IS APP-LEVEL AND NOT A `try` PER ROUTE. It was a try per route first, and a + `StoreUnavailable` raised while BUILDING THE PAYLOAD — before the route reached its own + try/except — surfaced as a 500. A store outage is a normal operational state and every route + here touches the store at least twice (the workspace read, then the write), so "remember to + wrap it" is a rule that gets forgotten once and then reports the wrong thing. One handler + means a store outage can only ever be a 503, whichever call raised it. + + The seam raises this only when the caller passed no `fallback_ws` — i.e. exactly on this + adapter, which has no durable session dict to degrade into. A 200 over a write that + evaporated is the failure the whole rule exists to prevent. + """ + return JSONResponse( + {"error": {"code": "store_unavailable", + "message": "the tenant store is unavailable. No change was saved"}}, + status_code=503) + + +@app.get("/api/health") +def health(): + """Unauthenticated LIVENESS only — it must answer before anyone can sign in, so it may not + reveal anything about the deployment beyond "the process is up". No version, no tenant list, + no config: a health endpoint is the one URL every scanner finds first. + + ⛔ THE VERSION DOES NOT GO HERE, and it was asked to (2026-08-04, when LIVE became a pinned + release and "what is LIVE running" needed an answer). A build identifier tells an unauthenticated + caller exactly which commit's known issues apply. It rides `GET /api/v1/settings` instead, behind + a session — and the authoritative copy is the `VERSION` file in the Space repo, which + `deploy_web.space_version()` reads without needing the app to be up at all.""" + return {"ok": True} + + +app.include_router(routes_auth.router) +app.include_router(routes_nav.router) +app.include_router(routes_customers.router) +app.include_router(routes_products.router) +app.include_router(routes_assets.router) +app.include_router(routes_tables.router) +app.include_router(routes_grid.router) +app.include_router(routes_records.router) +# EXIT wave 2: the Y1 page-data envelope (one route for every ported dashboard) and Y4's user +# administration. `routes_pages` imports `pages`, which lazily imports each `pages_*` builder — so +# a new page is a builder module plus one registry line, and nothing here changes. +app.include_router(routes_pages.router) +app.include_router(routes_admin.router) +app.include_router(routes_automation.router) +app.include_router(routes_keychain.router) +app.include_router(routes_statements.router) +# Wave 19 (owner item 13, R3): the LOOPABLE admin plane — the platform's own cross-tenant view. +# Its own prefix (`/api/v1/platform-admin`), never nested under `/admin`, so there is no path +# ambiguity with `routes_admin`'s `{username}` params and no chance of a tenant-admin route and a +# platform-operator route ever shadowing each other. Every path it declares is gated by +# `core.platform_admin.is_platform_admin`, and `verify_api` enumerates this router to prove it. +app.include_router(routes_platform_admin.router) +# Wave 20 (owner items 25 + 18/23/26): the Alerts inbox and the manage-access surface. Both are +# session-gated rather than admin-gated — an alert is a person's own subscription, and sharing is +# something every user does with their own views/folders/databases. +app.include_router(routes_alerts.router) +app.include_router(routes_shares.router) +app.include_router(routes_uploads.router) +# ⭐ WAVE 23 — THE THREE NEW ROUTERS. Mounted here, above the static catch-all at the bottom of this +# file, because `app.mount("/", _AppStatic(...), html=True)` swallows everything it is reached by: +# a router included AFTER it answers 404 forever while importing fine, type-checking fine and +# passing its own gate. That is the wave-20 declared-but-unmounted shape with a different cause. +# +# ⛔ ALL THREE EXISTED, COMPLETE AND GATED, WITH NO MOUNT until the close-out audit — three finished +# features that would have shipped dead. The workers each posted a mount ask and said they would +# signal "ready" first; C waited for a signal that never came while the files landed anyway. **The +# lesson is not "read the mailbox harder": `verify_api`'s enumeration is what caught it, so the +# control is that the enumeration must NAME every router in this file.** +app.include_router(routes_templates.router) # item 7 / C12 — template registry, session-gated +app.include_router(routes_connectors.router) # item 10 / C11 — the connectors directory +# ⚠ routes_forms is the FIRST NEW PUBLIC DOOR since the `_DEV_FIXTURES` scar (see :257 below). Its +# two paths are DELIBERATELY unauthenticated — a form is filled in by someone with no account — so +# it joins `/api/health`, the automation hook and the tick on the exempt list. It resolves a token +# by scanning tenants with a constant-time compare and answers a uniform 403, so a bad token cannot +# distinguish "no such form" from "not yours", and it never echoes a tenant, table or slug. +app.include_router(routes_forms.router) # item 8 / C9 — the PUBLIC form door +# WAVE 27 item 17 — E's Odoo relational doors. Mounted in the SAME change that E's module landed, +# because the wave-23 scar is exactly this: three finished routers shipped with no include_router +# line — complete, gated, type-clean and 404 for every caller. `verify_api.section_mounts` walks +# `main.app.routes` and pins these two paths by NAME, so an unmounted router now goes RED. +app.include_router(routes_odoo_tables.router) +# ⭐⭐ WAVE 31 · T49 / C4 — THE SOURCE-NEUTRAL DOOR TO THE SAME CAPABILITY. +# `/api/v1/connected-tables/{key}/rows` is an ALIAS: every request lands in +# `routes_odoo_tables.odoo_table_rows`, so "the Odoo path behaves byte-identically" holds by +# CONSTRUCTION rather than by two implementations that agree on the day they were written. R2 puts +# Meta Ads on the same mirror, and a Meta campaign served from a URL with `odoo` in it is a name +# that lies to every network tab, log line and bug report. +# ⚠ MOUNTED IN THE SAME CHANGE AS THE MODULE, per the line above and for the same wave-23 scar. +app.include_router(routes_connected_tables.router) +# ⭐⭐ WAVE 29, item 20 / R11 / contract C6 — F's CHANGE TOKEN. `GET /api/v1/changes?scope=` +# answers "did this bucket change" for ~zero cost (an in-memory counter; ZERO `store.get()` deep +# copies), which is what lets a filtered view pick up a row created in another tab, by an automation +# or by a connector sync without re-downloading the world. +# ⛔ THIS LINE IS THE ARTIFACT THIS PROTOCOL LOSES MOST RELIABLY, AND IT WAS ALREADY LOST ONCE HERE: +# F's router and the CLIENT half both shipped complete, so the poller was calling `/api/v1/changes` +# six times a minute and taking a 404 while every one of F's own gates was green. `verify_api`'s D-48 +# leg caught it (`unmatched: [('/api/v1/changes', 'apiBridge.ts')]`) — a client fetch path with no +# mounted route — which is precisely the control the wave-23 scar above was written to install. +# ⚠ Mounted is NOT callable: `section_changes_callable` in `verify_api.py` SIGNS IN and CALLS this +# route, because a route can be mounted and still raise before its own `try:` (D-107's plain-text 500). +app.include_router(routes_changes.router) # item 20 / C6 — F's router, A's line +# ⛔ WAVE 31 (R10 / C5) — E's router, taken by the INTEGRATOR under W31-T08's own done-when +# ("no router ships unmounted") because `main.py` is D's fence and D's queue did not reach it. +# It was written, gated and 404-dead: `verify_web_agent.py` asserts THIS LINE and was red at +# 60/61 for it. Four waves of the same defect — three routers in wave 23, four features in +# wave 29 — is why the assertion exists and why the line is not left for later. +app.include_router(routes_web_agent.router) # R10 / C5 — E's router, A's line +# ⭐⭐ WAVE 32 (R1 / C5, cross-fence wiring 6) — THE QUERY MODULE. E's router, A's line, and the +# FIFTH consecutive wave in which this exact line is the artifact the protocol nearly loses. +# ⛔ MOUNTED HERE, IN THIS BLOCK, AND NOT AT THE END OF THE FILE — measured by SESSION E in its own +# gate before it front-inserted: `include_router` APPENDS, and `app.mount("/", _AppStatic(...), +# html=True)` swallows everything reached after it, so a router added below that mount answers +# **405 on POST and 404 on GET** while every one of its own tests passes. The comment at :213 states +# the rule; E's measurement is what turns it from advice into a number. +# ⚠ `verify_api` asserts `/api/v1/query` in `app.openapi()["paths"]` — NEVER `{r.path for r in +# app.routes}`, which finds nothing in this app because FastAPI wraps included routers (W31). +app.include_router(routes_query.router) # R1 / C5 — E's router, A's line +# ⭐⭐ WAVE 33 (C2) — THE SIXTH CONSECUTIVE WAVE IN WHICH THIS BLOCK IS THE THING THE PROTOCOL LOSES. +# Contract C2 is written for exactly that: a lane creating a `routes_*.py` posts an ASK, and the +# INTEGRATOR adds the line in the SAME wave. Both of these arrived that way (C's `ASK C-1`, G's +# `ASK G-1`), and `verify_api::section_w23_mounts` asserts each path in `app.openapi()["paths"]` +# with an NC that comments a mount out and goes RED. +# ⚠ Same placement rule as the line above — ABOVE the `app.mount("/", _AppStatic(...), html=True)` +# at the end of the file, never after it. +# ⭐⭐ THE PUBLISH DOOR IS MOUNTED AGAIN (2026-08-15). It was withheld for the wave-33 deploy +# because QA reproduced three HIGH defects that are armed ONLY by mounting it. All three are fixed +# in `routes_publish.py`, each at its cause rather than at its symptom: +# 1. W33-T68 — `form` LEFT `PUBLISHABLE_MODES`. A form view's rows ARE the submissions people +# sent it, so publishing one served other people's answers to anyone holding the link. A form +# still has its own public door (`#/form/`) which serves the BLANK form and never rows. +# 2. W33-T69 — `_visible_keys` returns `[]` when a view STORED a `visible` list and none of its +# keys survive, instead of falling back to the table default. The fallback still applies to a +# view that never stored one, which is what it was written for. +# 3. W33-T70 — three separate leaks closed: the rate limit no longer keys on the caller-supplied +# `x-forwarded-for` (it keys on the socket peer and counts FAILURES only, so a shared proxy +# peer cannot become one global bucket); every failing path now spends the same PBKDF2 the +# success path spends, closing the 206x timing gap; and an unknown token on the sibling GET +# answers the LOCKED shape rather than a 403, so that route stops sorting real tokens from +# fake ones for free. +# ⚠ `verify_api::section_w23_mounts` asserts this path in `app.openapi()["paths"]`, so its four +# EXPECTED reds should now go GREEN. A red here after this line means the mount broke, not the gate. +app.include_router(routes_publish.router) # R5 / C2 — C's router, A's line (the publish door) +# ⛔ NOT BEHIND `module_gate("product_data")`, WHICH IS THE WHOLE REASON IT IS A SECOND ASSET DOOR. +# `routes_assets.py` gates EVERY one of its routes on that module, so a connector logo served from +# there would 403 for any account without the product-data grant — i.e. the Connectors directory +# would lose its logos for exactly the accounts most likely to be setting a connector up (C6). +app.include_router(routes_brand.router) # R4 / C2 / C6 — G's router, A's line (brand marks) +# ⛔ TWO OF ITS SIX PATHS ARE UNAUTHENTICATED BY DESIGN (`/slack/events`, `/slack/interact`) and +# that is the DOOR, not an omission — Slack posts to them with no session and could not carry one. +# They are built on `routes_forms`' proven public shape: signing-secret verification, a sliding-window +# rate limit, a body cap and ONE non-oracle 403, so a bad signature cannot distinguish "no such +# workspace" from "not yours". ⚠ There is no auth middleware and no exempt-path allow-list in this +# file to register them in: "public" here IS the absence of `Depends(require_session)`, which is why +# `verify_api` asserts the absence rather than an entry in a list that does not exist. +app.include_router(routes_slack.router) # R4 / C2 — D's router, A's line (Manage agent + Slack) + + +# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working) +# They are the v1 handlers with the v1 session requirement — NOT the old unauthenticated Basic +# behavior. An alias that kept the old auth would be a bypass of everything above it. +_GATE = module_gate(routes_customers.MODULE) + + +@app.get("/api/customers", deprecated=True) +def _customers_alias(session: Session = Depends(_GATE)): + return routes_customers._payload(session) + + +@app.patch("/api/customers/{pid}", deprecated=True) +def _patch_alias(pid: int, body: dict = Body(default=None), + session: Session = Depends(_GATE)): + return routes_customers.patch_customer(pid, body, session) + + +def _assert_contract_floor(): + """The canonical FIELDS must be exactly what `aios_grid` starts an empty workspace from. + + This is what stops `FIELDS` becoming a constant that exists only to satisfy a gate. If the + canonical JSON and `aios_grid.fields_from_workspace({})` ever disagree, the standalone API and + the embedded host are serving two different schemas and the grid's own contract has forked — + the exact drift `verify_fields_contract.py` was written to catch, now also caught at startup + on whatever machine is actually running. + """ + import aios_grid + base = [f for f in aios_grid.fields_from_workspace({}) if not f.get("custom")] + if [f["key"] for f in base] != [f["key"] for f in FIELDS]: + raise RuntimeError( + "AIOS web API: the canonical field contract and aios_grid.fields_from_workspace({}) " + "disagree on the base field set. Embed and standalone would serve different schemas. " + "Run aios-web/verify_fields_contract.py.") + + +def _startup_notes(): + """Say the two things an operator must know, once, at import — never in a response body.""" + if aios_session.EPHEMERAL_SECRET: + print("[aios-api] AIOS_SESSION_SECRET is not set. Signing with a random per-process " + "key. Sessions will not survive a restart and will not work across workers. " + "Set it in production.") + if os.environ.get("AIOS_INSECURE_SSL") == "1": + print("[aios-api] AIOS_INSECURE_SSL=1. TLS verification is DISABLED for outbound " + "requests. Local development only; never in a deployed environment.") + + +_assert_contract_floor() +_startup_notes() + + +# ⛔ THE STATIC MOUNT IS NOW UNAUTHENTICATED, and that is a change this wave made on purpose. +# Before EXIT-3a, `BasicAuth` middleware gated the WHOLE app including this mount. A branded login +# page cannot live behind a password prompt, so the shell's own assets must be public — which they +# are: `index.html`, the JS/CSS bundle and the favicon reveal nothing. +# +# ⚠ WHAT THAT SILENTLY DE-GATED, caught in review rather than in production. `web/dist/` also +# carries `sample_customers.json`, a DEV FIXTURE copied from `web/public/`. Its 8 customers are +# synthetic, but the `agent` column holds REAL EMPLOYEE NAMES, and it went from Basic-gated to +# publicly fetchable in this commit. Nothing needs it: both the API bridge and `useCustomerData` +# deleted their sample fallback on purpose ("NOTHING HERE FALLS BACK TO sample_customers.json"), +# and it survives only because it sits in `web/public/`. So it is refused here — 404, the same +# answer as any other path that is not part of the app. +# +# The right long-term fix is deleting it from `web/public/` (S2's lane — flagged in the mailbox); +# this guard is what makes the API safe regardless of what the bundle happens to contain. +_DEV_FIXTURES = {"sample_customers.json"} + + +class _AppStatic(StaticFiles): + async def get_response(self, path, scope): + if Path(path).name in _DEV_FIXTURES: + raise StarletteHTTPException(status_code=404, detail="Not Found") + resp = await super().get_response(path, scope) + # Vite content-hashes everything under assets/ (a change is a NEW url), so those are + # immutable — a repeat visit re-downloads zero bytes instead of the whole 750 KB bundle. + # index.html must stay revalidated or a deploy would strand returning browsers on the old + # bundle; ETag/304 makes that revalidation a header exchange, not a transfer. + # ⚠ Normalised first: on a Windows host StaticFiles hands this path with backslashes, + # and `startswith("assets/")` silently skipped every asset (measured on the local probe). + if path.replace("\\", "/").lstrip("/").startswith("assets/"): + resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" + else: + resp.headers["Cache-Control"] = "no-cache" + return resp + + +# static bundle LAST so /api/* wins; html=True serves index.html at / plus the built assets +if _WEB_DIST.is_dir(): + app.mount("/", _AppStatic(directory=str(_WEB_DIST), html=True), name="web") + + +# --- boot prewarm (AIOS_PREWARM=1 — set by the Dockerfile, never by tests) ----------------------- +# Without this, the first visitor after every deploy/restart pays the full Odoo pool build in +# their request. The thread warms the CONSOLIDATED scope (None, None) + every registered page's +# default envelope; scoped users still pay their own scope's first build, once. +# Env-gated rather than a startup event so importing `api.main` in a gate (verify_api and friends +# run against fakes) can never fire a live Odoo pull. +def _seed_and_sync_store(): + """Bring the analytical store (harness.datastore) LIVE for this container — the app.py + bootstrap, mirrored (2026-07-31, owner item 1). + + `harness.datastore` powers every measure column/condition, and ONLY app.py used to call + `ensure_seed()` — so on a fresh Space disk this container resolved measures against a store + that never existed and every measure cell served blank. Seeding alone was NOT enough either, + and that was measured live the same day: "datastore seeded in 1.7s" followed by an endless + `api:measure-column: ModelError the data cache is still warming up` — `ready()` demands + EVERY entity at phase 'live', and a seed that predates a newer entity leaves it un-synced + forever in a process with no sync loop. The SYNC SPRINT after the seed is what closes the + write_date gap and backfills anything the seed lacks (app.py:7779's exact pattern, bounded + passes). Fail-quiet throughout: no seed/token/Odoo → the columns stay blank, the rows still + serve. + """ + import time as _t + t0 = _t.time() + try: + from harness import datastore as _ds + if _ds.ensure_seed(): + print(f"[aios-api] datastore seeded in {_t.time() - t0:.1f}s") + # DEDICATED Odoo connection for this thread (the W6 postmortem rule): the sync's + # search_reads must never interleave on the shared client's xmlrpc transport. + import core.odoo as _odoo + try: + _odoo._tlocal.client = _odoo.OdooClient() + except Exception: + pass + res = {} + for i in range(12): + res = _ds.sync_all(log=lambda *a, **k: None) + print(f"[aios-api] datastore sync pass {i + 1}: " + + ", ".join(f"{k}={v.get('phase')}" for k, v in sorted(res.items()))) + if all(v.get("phase") == "live" for v in res.values()): + break + # ⭐ Wave 21 (item 2, "make sure the metrics are correct"): a cursor sync can never see + # a HARD DELETE, and the downloaded seed carries whatever was deleted since it was cut — + # one reconcile pass at boot removes both classes of phantom row before the first + # measure is served. MEASURED 2026-08-05: five deleted sale_order_line rows = $284.25 of + # phantom YTD revenue, stable across re-syncs, zero the moment reconcile ran. + try: + _ds.reconcile_deletes(log=lambda *a, **k: None) + except Exception: + pass + print(f"[aios-api] datastore sync done in {_t.time() - t0:.1f}s " + f"(ready={_ds.ready()})") + # ⭐⭐ 2026-08-09 (wave 28, D-107) — THE RELATIONAL REBUILD RUNS AT BOOT, HERE. + # + # ⛔ IT DID NOT BEFORE, AND NOTHING SAID SO. The rebuild lived only inside + # `_store_resync_loop`, whose very first statement is `sleep(1800)` — so the earliest a + # freshly booted container could spawn the four locked databases was T+30 MINUTES. The + # symptom was read as "the boot path is silent": `/odoo-tables/status` polled every 30 s + # across a ~20-minute window over two boots returned the pre-wave schema on all 20 + # samples. It was not silent, it had not been asked yet. Both halves of D-107 were like + # this — a thing that never ran, mistaken for a thing that ran and failed. + # + # ⚠ WHY HERE AND NOT IN `_prewarm`: the tables are DERIVED FROM THE MIRROR, and this is + # the exact line where the mirror has finished advancing — seed, up to twelve sync passes, + # then the delete reconcile. Calling it from the other thread would race the seed and hit + # either `ro_con()`'s "still warming" RuntimeError or, worse, a HALF-SYNCED mirror, which + # is D-107's own hypothesis 3: a partial population trips `MAX_SHRINK` and the rebuild + # refuses — correctly, but for a reason that reads like a data loss scare. + # ⚠ Same thread on purpose: it is already a daemon and nothing serves requests behind it. + _pull_meta("boot") + _rebuild_odoo_relational("boot") + # ⭐⭐ W32-T07 — owner items 13 and 15, DELIVERED. See `_sweep_automation_schemas`. + # ⚠ AFTER the two above and not before: those advance the mirror and can take minutes, and + # this sweep is unrelated to it — putting it last means a slow Odoo sync cannot delay the + # one thing on this path that fixes a grid the owner has asked about twice. + _sweep_automation_schemas("boot") + except Exception as e: # noqa: BLE001 + print(f"[aios-api] datastore seed/sync skipped: {e}") + + +def _pull_meta(why): + """Pull Meta Ads into THIS container's mirror, before the relational rebuild reads it. + + ⛔ WHY IT HAS TO HAPPEN HERE AND NOT ON A LAPTOP. The mirror is a FILE that lives beside the + process; the Space's copy is seeded from the HF dataset and knows nothing about a DuckDB on a + developer's box. Running `meta_store --sync` locally populates the local mirror and the LIVE + product stays empty — which is the whole difference between "the loader works" and "the + product has the data". Odoo is already arranged this way (`sync_all` runs in the container); + this is the same arrangement for the second connector. + + ⚠ FAIL-QUIET AND SILENT WHEN THERE IS NOTHING TO DO. No token => no Meta => one line, no + error: a tenant that has not connected Meta is a normal state, and this runs on every boot. + ⚠ The window is deliberately SHORT here (`META_INSIGHTS_DAYS`, default 7 at boot) because boot + is not the place for a 90-day backfill — the resync pass widens it. + """ + try: + from harness import meta_store as _meta + if not _meta.token(): + print(f"[aios-api] meta sync skipped ({why}): no META_ADS_ACCESS_TOKEN in this " + f"deployment - the connector is idle, not broken") + return + # ⛔ PASSED, NOT SET IN THE ENVIRONMENT. `meta_store.INSIGHTS_DAYS` binds at + # IMPORT, so an `os.environ.setdefault` here executed after the module was + # already loaded and changed NOTHING: every boot pulled 90 days instead of 7, + # which is the slow path that trips the per-ad-account rate limit and never + # finishes. A knob read at import cannot be turned by a caller at runtime. + rep = _meta.sync("royal-imports", log=lambda *_a: None, insights_days=7) + for p in rep.get("problems") or []: + print(f"[aios-api] meta sync PROBLEM ({why}): {p}") + print(f"[aios-api] meta sync done ({why}): " + + ", ".join(f"{k}={v['in_mirror']}" for k, v in sorted(rep["tables"].items()))) + except Exception as e: # noqa: BLE001 + print(f"[aios-api] meta sync FAILED ({why}): {type(e).__name__}: {e}") + + +def _rebuild_meta_relational(why, rt): + """Spawn/refresh the `ut_meta_*` locked databases off the SAME mirror the Odoo half just used. + + ⭐ CALLED FROM INSIDE `_rebuild_odoo_relational`, ON PURPOSE, and the reason is D-29 rather than + tidiness: `harness/datastore` is a ONE-FILE-AT-A-TIME process global, and that caller has just + established which tenant's file this process holds open. Spawning Meta here inherits that + binding instead of rebinding it under live readers — which is the documented way to serve one + tenant's rows to another with nothing raised. + + ⚠ SILENT WHEN THERE IS NOTHING TO DO. A tenant that never connected Meta has no `meta_*` tables + in its mirror; `refresh` returns `{}` and says so once. That is a normal state, not a failure, + and it must not print an error every 30 minutes for every tenant that does not use Meta. + ⛔ Its own try/except for the reason the two passes above have theirs: a Meta refusal must not + cancel an Odoo rebuild that already succeeded. + """ + try: + import meta_relational as _meta + counts = _meta.refresh(rt, why) + if counts: + print(f"[aios-api] meta relational rebuild done ({why}): " + + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))) + try: + import automation_engine as _engine + _engine.refresh_relations(rt, log=lambda *_a: None) + except Exception as e: # noqa: BLE001 + print(f"[aios-api] meta relation cells failed ({why}): {type(e).__name__}: {e}") + except Exception as e: # noqa: BLE001 + print(f"[aios-api] meta relational rebuild FAILED ({why}): {type(e).__name__}: {e}") + + +def _sweep_automation_schemas(why): + """⛔⛔ WAVE 32 · `W32-T07` — MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE THE TABLES. + + Owner items 13 and 15 — the TikTok comments lock, and the comment CONTENT column he says he has + asked for *"many times"*. **Both were already correct in the source.** `TT_COMMENT_FIELDS` + carries `field_def("text", "Comment")` and `TT_LOCKED_TABLES` already contains the comments + table, both since wave 31, with his words quoted in the comment beside them. So this ticket is + not a schema change and there is nothing to design: it is DELIVERY. + + ⛔ THE MECHANISM, WHICH IS THE WHOLE OF IT. `ut_ensure` MERGES fields into an existing table and + stamps `recordMode` — but only **when something calls it**, and the only callers are automation + runs. A tenant whose `ut_tt_comments` was spawned before the declaration changed keeps the old + shape until an automation happens to run against it. Nothing sweeps existing tenants. That is + [[a-migration-that-runs-on-the-next-write]], and it is this wave's stated thesis: a declaration + that never reaches a tenant is indistinguishable from one that was never written. + + ⛔⛔ AND IT MUST RUN **IN THE CONTAINER**, WHICH IS WHY THIS IS IN `main.py` AND NOT A SCRIPT. + D-195, measured three times: a developer's CLI write to the tenant store is reverted by the + running Space within a minute (download-modify-upload, last-write-wins) — and **the write + reports success every time**, then a FRESH read confirms it, and it is gone by the next poll. + A connector's tables must be spawned BY THE CONTAINER; a CLI spawn is a dry run that lies. + + ⚠ EVERY TENANT, unlike `_rebuild_odoo_relational` below — and the asymmetry is deliberate + rather than an oversight. That function is scoped to royal because it derives from the DuckDB + mirror, and `harness/datastore` is a ONE-FILE-AT-A-TIME process global (D-29): rebinding it per + tenant in a daemon thread can serve one tenant's rows to another with nothing raised. This + sweep touches only `user_tables` through each tenant's own `rt`, which has no such global — so + the hazard that scopes that one does not exist here, and TikTok automations run in tenants + other than #0. + + ⚠ CHEAP ON A CORRECT TENANT: `ut_ensure` short-circuits when nothing changed, so this is a read + per child table on a tenant that is already right, and the whole delivery on one that is not. + ⚠ ONE TENANT'S FAILURE MUST NOT STOP THE NEXT. Each is wrapped: a tenant whose store is + unreachable at boot is reported and skipped, never allowed to abort the sweep for everyone. + """ + try: + import automation_engine as _eng + from harness import runtime as _runtime + except Exception as e: # noqa: BLE001 + print(f"[aios-api] schema sweep ({why}) SKIPPED. Import failed: {e}") + return + try: + schemas = _eng.platform_schemas() + except Exception as e: # noqa: BLE001 + print(f"[aios-api] schema sweep ({why}) SKIPPED. No declarations: {e}") + return + tenants = [] + try: + tenants = _runtime.known_tenants() + except Exception as e: # noqa: BLE001 + print(f"[aios-api] schema sweep ({why}) SKIPPED. Tenant list unreadable: {e}") + return + for slug in tenants: + try: + rt = _runtime.get_runtime(slug) + # ⛔⛔ FIXED 2026-08-13 — THIS SWEEP WAS SPAWNING EIGHT DATABASES IN EVERY TENANT. + # Owner: *"Database for Royal Imports, why we have fucking IG and TIktok databases."* + # `ut_ensure`'s first line is *"Create the table if it is missing"*, and this loop fed + # it every child of every platform schema for every tenant — so tenant #0, a floral and + # giftware importer with ZERO automations, woke up on 2026-08-13 at 12:05 UTC owning + # `ut_ig_posts`, `ut_ig_comments`, `ut_ig_snapshots`, `ut_ig_post_snapshots` and the + # four TikTok twins, all empty, all in his nav flyout. **The bug is one word wide:** + # this function's own title says *"MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE + # THE TABLES"* and its body called a CREATE-OR-MERGE function to do a MERGE-ONLY job. + # [[reuse-and-delete-are-hypotheses]] — `ut_ensure` was the right function for the + # merge and brought a second behaviour nobody wanted with it. + # + # ⭐ THE PREDICATE IS "DOES THIS TENANT ALREADY HAVE THE TABLE", read ONCE per tenant + # rather than per child — `rt.get` deep-copies the whole tenant document (28.6 MB on + # tenant #0), so asking eight times is eight copies to answer one question. + # ⚠ AND IT MUST NOT WEAKEN THE DELIVERY: a tenant that HAS `ut_tt_comments` still gets + # the `text` column and the `recordMode` stamp, which is the entire point of T07. The + # sweep now delivers to tables that exist and mints none, which is what it always + # claimed to do. + have = set(rt.get("user_tables") or {}) + ensured, skipped = [], [] + for s in schemas: + for key, child in (s.get("children") or {}).items(): + if key not in have: + skipped.append(key) + continue + got = _eng.ut_ensure(rt, child["label"], child["fields"], "automation", + key=key, lock_fields=True, + record_mode=child["record_mode"]) + if got: + ensured.append(got) + if skipped: + # ⭐ SAID OUT LOUD, never silently skipped — this repo's "no silent caps" rule. A + # sweep that quietly does nothing looks identical to a sweep that is not running, + # which is how the previous behaviour survived review in the first place. + print(f"[aios-api] schema sweep ({why}) {slug}: {len(skipped)} child table(s) not " + f"present in this tenant, so nothing was created for them " + f"({', '.join(sorted(skipped))}). They are spawned by an automation that " + f"needs them, never by this sweep") + # ⭐ THE RETRACTION (D's `retract_foreign_presets`, D-152), AFTER the children loop. + # ⛔ THE SWEEP ABOVE MAKES COLUMNS **ARRIVE** AND CANNOT MAKE STALE ONES **LEAVE**, and + # T07's `done-when` asserts both ("no `ut_tt_*` grid carries an Instagram column"). On a + # tenant that ran TikTok before W30-T08 the 26 machine-authored IG columns are still + # there — the detector was fixed, the damage never was. + # ⚠ `kept` IS THE HONEST HALF: a foreign column that HOLDS DATA is REPORTED, never + # deleted. If it is non-empty, the screenshot shows a column and the report is the + # answer (W30/R6's second sentence). + st = {} + try: + st = _eng.retract_foreign_presets(rt, log=lambda *a, **k: None) or {} + except Exception as e: # noqa: BLE001 + print(f"[aios-api] schema sweep ({why}) {slug}: retraction FAILED: {e}") + print(f"[aios-api] schema sweep ({why}) {slug}: ensured={len(ensured)} " + f"tables={st.get('tables', 0)} columns={st.get('columns', 0)} " + f"cells={st.get('cells', 0)} flags={st.get('flags', 0)} " + f"kept={st.get('kept') or []}") + except Exception as e: # noqa: BLE001 + print(f"[aios-api] schema sweep ({why}) {slug}: FAILED: {e}") + # ⭐ A SUCCESS MARKER, for `_rebuild_odoo_relational`'s stated reason: D-107 was chased for a + # day on ABSENT log markers, which cannot tell "it ran and was fine" from "it was never + # reached". Three failure markers and no success marker makes silence ambiguous. + print(f"[aios-api] schema sweep ({why}) done over {len(tenants)} tenant(s)") + + +def _rebuild_odoo_relational(why): + """Spawn/refresh the four locked Odoo databases, then compute their cells. `why` is 'boot' or + 'resync' and rides every log line, because "it failed" and "it failed at boot, before anyone + could have asked" are different diagnoses. + + ⭐ THE SUCCESS LINE IS NOT DECORATION — it is the control this path lacked. D-107 was chased + for a day on the strength of *absent* log markers, which cannot distinguish "the rebuild ran + and was fine" from "the rebuild was never reached". Three failure markers and no success + marker means silence is ambiguous; now it is not. + + ⚠ SCOPED TO ROYAL-IMPORTS, deliberately, and it is NOT the D-29 shortcut it resembles. The + caller has just advanced whichever DuckDB file this process holds open — tenant #0's — and + royal is the only tenant with an Odoo mirror to derive from (R1). Iterating tenants here walks + straight into D-29's documented hazard: `harness/datastore` is a ONE-FILE-AT-A-TIME + process-global, and rebinding it in a daemon thread under live readers can serve one tenant's + rows to another with nothing raised. `is_royal` stays the authority on which slugs qualify. + """ + try: + import odoo_relational as _rel + from harness import runtime as _runtime + if not _rel.is_royal("royal-imports"): + return + _rt = _runtime.get_runtime("royal-imports") + counts = _rel.refresh(_rt, "royal-imports") + # ⭐⭐ AND THEN COMPUTE THE CELLS, which `refresh` does NOT do. + # + # ⛔ THE FAILURE THIS CLOSES IS THE WORST-LOOKING KIND. `refresh` writes rows and field + # DEFINITIONS; every Link and Rollup cell comes from a separate pass. Those passes used to + # live only on the automation `tick`, which fires from an EXTERNAL EventBridge cron — so a + # fresh boot landed 71,954 rows with eleven fully-configured relational columns and every + # one of them BLANK until an unrelated scheduler happened to run. Nothing errors; the + # tables simply look finished and answer nothing. + # + # ⚠ `refresh_relations`, NOT `compute_relation_cells`. The latter computes a change count + # over a blob it was handed and PERSISTS NOTHING; the former walks the tenant and writes. + # Calling the inner one here would return a plausible number and change no cell. + # + # ⚠ ONE try/except PER PASS, for the reason `tick` states at its own copies: a source + # rollup REFUSES loudly on a truncated group set, and a shared block would let that honest + # refusal silently cancel a relational pass that had already succeeded. + try: + import automation_engine as _engine + _engine.refresh_relations(_rt, log=lambda *_a: None) + except Exception as e: # noqa: BLE001 + print(f"[aios-api] odoo relation cells failed ({why}): {type(e).__name__}: {e}") + try: + import rollup_sql as _rollup + for _b, _key, _l, _f in _rel.TABLES: + _rollup.compute(_rt, _key) + except Exception as e: # noqa: BLE001 + print(f"[aios-api] odoo source rollups failed ({why}): {type(e).__name__}: {e}") + print(f"[aios-api] odoo relational rebuild done ({why}): " + + ", ".join(f"{k}={v}" for k, v in sorted((counts or {}).items()))) + _rebuild_meta_relational(why, _rt) + except Exception as e: # noqa: BLE001 + # ⚠ The TYPE is named here as it is in the two inner handlers. The original printed only + # `{e}`, and a bare message is exactly what made D-107 unreadable from the outside: a + # `BinderException` about a missing column is a different action from a timeout or an auth + # failure, and the text alone often does not say which it was. + print(f"[aios-api] odoo relational rebuild failed ({why}): {type(e).__name__}: {e}") + + +def _store_resync_loop(): + """Keep the analytical mirror current — the API-process stand-in for the re-sync the + Streamlit app piggybacks on page renders. Measure memos key on the pool stamp, so a + refreshed pool re-reads the freshly synced store.""" + import time as _t + passes = 0 + while True: + # ⭐⭐ WAVE 32 · OWNER ITEM 11 / R11 — THE TENANT'S OWN CADENCE, NOT A HARDCODED 1800. + # + # ⛔ THIS LINE IS WHY THE SETTING WAS NOT A SETTING. SESSION B built the whole of item 11 — + # the 30m/1h/4h/daily/manual presets, the server-side clamp, the config door and + # `odoo_relational.sync_seconds()` which turns the stored preset into seconds — and its own + # ticket said the one-line change belonged to A because `main.py` is A's file. That ASK was + # never sent, so the value was stored, displayed, clamped and IGNORED: a person could pick + # "every 4 hours" and the loop would keep resyncing every 30 minutes with nothing anywhere + # reporting the disagreement. Found by `verify_reachability` naming `sync_seconds` as a + # function whose ONLY caller was its own gate [[artifact-with-no-importer]]. + # + # ⚠ `None` MEANS MANUAL AND MUST NOT MEAN ZERO. R11's `manual` preset returns None from + # `sync_seconds`; treating that as a falsy interval would spin this loop with no sleep at + # all. It parks at the default cadence instead and simply does no work — the tenant asked + # not to be synced automatically, not for the server to stop breathing. + # ⚠ RE-READ EVERY PASS, deliberately: a cadence changed in Settings takes effect on the + # next cycle rather than at the next container restart, which is what makes it a setting. + # ⚠ FAIL-SAFE TO 1800 — an unreadable config must not become a tight loop. The floor is + # enforced in `sync_seconds` as well as at the write door, for the same reason. + # ⛔⛔ WAVE 34 · W34-T46 / D-246 — THE PARAGRAPH ABOVE DESCRIBED A BRANCH THAT DID NOT + # EXIST, AND THAT IS WHY NOBODY LOOKED. It said `manual` "parks at the default cadence + # instead and simply DOES NO WORK". The first half was true; the second half was not + # implemented anywhere: `_secs is None` set the sleep to 1800 and then fell into an + # UNCONDITIONAL `sync_all()`. So a tenant who chose "manual" was synced every thirty + # minutes exactly like everyone else, with the setting stored, displayed, clamped and + # obeyed by nothing — the identical failure this block's own W32/R11 note is about, one + # layer further in. A comment asserting a fix is why the second reader stops reading. + # ⚠ FIXED, AND THE FIX IS NARROW ON PURPOSE. `manual` is an ODOO connector setting, so it + # skips the Odoo mirror sync and its reconcile and NOTHING ELSE: the Meta pull and the + # relational rebuild below still run, because a tenant asking not to have Odoo polled has + # not asked for the other connector to stop. + # ⚠ AND IT STILL SLEEPS AND STILL LOOPS. The config is re-read every pass, so switching + # back off `manual` takes effect on the next cycle rather than at the next restart. + _every = 1800 + _manual = False + try: + import odoo_relational as _rel_cad + from harness import runtime as _rt_cad + # ⚠ D-245, KNOWN AND NOT FIXED HERE: the slug is hardcoded, so this reads TENANT #0's + # cadence and applies it to a loop that syncs the shared mirror. Correct while one + # tenant has Odoo; wrong the moment a second one does. Out of this ticket's scope and + # named rather than silently inherited. + _secs = _rel_cad.sync_seconds(_rt_cad.get_runtime("royal-imports")) + _manual = _secs is None + _every = 1800 if _secs is None else max(int(_secs), 60) + except Exception: # noqa: BLE001 + pass + _t.sleep(_every) + passes += 1 + try: + from harness import datastore as _ds + import core.odoo as _odoo + try: + _odoo._tlocal.client = _odoo.OdooClient() + except Exception: + pass + # ⚠ A PLAIN BRANCH, NOT AN EARLY EXIT AND NOT AN EXCEPTION. A `raise` here would be + # caught by this block's own `except` and printed as "store resync failed", turning a + # setting a person chose into a recurring error in the log; a `continue` would skip + # the Meta pull and the relational rebuild below, which `manual` says nothing about. + if not _manual: + _ds.sync_all(log=lambda *a, **k: None) + # Wave 21 — every 4th pass (~2h): purge hard-deleted rows the cursor sync cannot + # see (the boot pass's comment has the measured case). Cheap id-sweep per entity; + # without it deleted Odoo lines inflate every sum on the mirror FOREVER. + # ⚠ INSIDE the branch: a reconcile is a pass over the mirror this loop was just + # told not to advance. + if passes % 4 == 0: + _ds.reconcile_deletes(log=lambda *a, **k: None) + except Exception as e: # noqa: BLE001 + print(f"[aios-api] store resync failed: {e}") + # ⭐ WAVE 27 item 17 (E's ASK ->A, resolved): the Odoo RELATIONAL tables are rebuilt + # AFTER the mirror they are derived from, in the same pass and in that order — deriving + # from a mirror this loop is about to advance would publish a worklist one cycle stale + # every single time. + # + # ⛔ OUTSIDE the try/except above, NOT folded into it. A failing relational rebuild must + # not swallow the sync's error message, and — worse the other way — a sync failure must + # not skip a rebuild that had nothing wrong with it. Two independent failures, two + # independent logs. (`_rebuild_odoo_relational` carries its own handlers.) + # + # ⚠ THE REBUILD IS NOT THE FRESHNESS GUARANTEE — the rows carry a visible `_refreshed` + # stamp for that. This loop is a daemon thread whose failure path is a `print` (D-29), so + # "the wiring exists" and "the data is current" are different claims and only the stamp + # can tell a user which one they are looking at. + # + # ⭐ ONE IMPLEMENTATION, TWO CALLERS (wave 28). This block used to be the only copy, which + # is what made the boot path silent for 30 minutes after every restart; it is now the + # SECOND caller of the same function `_seed_and_sync_store` calls at boot. A copy here + # would be a second thing to keep in step, and the two would answer differently on the + # next ruling — the exact shape the Views top-up was just fixed for on the other side of + # this wave. + # ⛔ THE META PULL RIDES THE RESYNC TOO, AND LEAVING IT OUT WAS A REAL GAP — caught by + # reading the deploy's own boot log rather than by any gate. `_pull_meta` was wired into + # `_seed_and_sync_store` ALONE, i.e. it ran exactly once per container, at boot, behind a + # full Odoo sync. So a boot where the Graph call was rate-limited, slow or simply after the + # thread died left the mirror empty with NOTHING to retry it: the relational rebuild below + # would then find no `meta_*` tables every 30 minutes forever and skip, silently and + # correctly. A connector that can only ever be established at boot is one bad boot away + # from being permanently absent. + # ⚠ Cheap when there is nothing to do: no token => one line and return. + _pull_meta("resync") + _rebuild_odoo_relational("resync") + + +def _prewarm(): + import time as _t + t0 = _t.time() + # ⚠ The store sync runs in its OWN thread, never ahead of the pool warm: a stale seed can + # take many minutes of XML-RPC to close, and the first live probe of this arrangement + # showed the pool warm (42s) silently queued behind it — the whole app cold for every + # visitor while a background column channel caught up. Measure cells retry via the + # transient rule until the sync lands; nothing else waits on it. + import threading as _th + _th.Thread(target=_seed_and_sync_store, daemon=True, name="store-seed-sync").start() + try: + from harness import runtime as _runtime + rt = _runtime.get_runtime("royal-imports") + routes_customers.warm_default(rt) + import pages as _pages + _pages.warm_default(rt) + # ⭐⭐ WAVE 30 · T12, THE COLD PATH (owner items 4/5). Automation was the ONE module absent + # from this list, so its memo was always filled by a visitor rather than by boot: call 1 of + # `GET /automations` after every deploy downloaded the whole `user_tables` document (35.8 MB + # ceiling, under the store lock) onto whoever clicked first. Memoising the WARM path — two + # waves of it — could not touch that, because the cold call is the one that fills the memo. + # ⚠ It elects a STRING and keeps no document; see `warm_default`'s own note on why caching + # the bucket would trade a latency for memory this tier does not have. + import routes_automation as _rauto + _rauto.warm_default(rt) + print(f"[aios-api] prewarm done in {_t.time() - t0:.1f}s") + except Exception as e: # noqa: BLE001 — boot must not die on a warm-up + print(f"[aios-api] prewarm skipped: {e}") + + +# ═════════════════════════════════════════════════════════════════════════════════════════════ +# ⭐⭐ W31-T46 / D-160 — THE MIRROR IS SEEDED WHETHER OR NOT `AIOS_PREWARM` IS SET. +# ═════════════════════════════════════════════════════════════════════════════════════════════ +# +# THE DEFECT, and it has cost a release once already (owner item 12, wave 20). `_prewarm()` was +# the ONLY caller of `_seed_and_sync_store()`, which is the ONLY caller of +# `datastore.ensure_seed()`. So on a fresh Space disk with `AIOS_PREWARM` anything but `1`: +# no `royal.duckdb` ⇒ `datastore.ready()` False forever ⇒ `ro_con()` refuses ⇒ every measure +# column blank AND — since wave 30 put the Odoo grids on the mirror — **two user-facing grids +# serve nothing**, with the app RUNNING, the deploy green and the tag correct. Last time the +# symptom was read as a pinned tag and a store problem for days. +# +# ⛔ AND THE FLAG IS EASIER TO LOSE THAN IT LOOKS: `deploy_web.py` pushes `AIOS_PREWARM=1` +# EXPLICITLY (to overwrite a stale `0`, because a Space secret survives a redeploy) — but that +# push sits under `if TARGET:`, so an ordinary bare `python deploy_web.py` skips it. A guard +# written for the dangerous case that the ordinary case walks straight past. +# +# ⭐ SO THE SEED IS SPLIT OFF AND MADE UNCONDITIONAL, which is T46's first branch rather than its +# fallback. It is the right half to move because it is the CHEAP, SAFE one: `ensure_seed()` +# touches no Odoo (it is one `hf_hub_download` of a snapshot), returns immediately when the file +# is already there, and returns immediately without `HF_TOKEN`. The EXPENSIVE, live half — +# `sync_all()`'s XML-RPC passes, the pool warm, the resync loop — stays exactly where it was, +# because the env gate's stated reason is still true: importing `api.main` in a gate must never +# fire a live Odoo pull. +# +# ⚠ THE `DB_PATH.exists()` PRE-CHECK IS WHAT KEEPS THIS FREE. It is a stat, and it is False only +# on a genuinely fresh disk — so on every developer box and in every gate run the thread is never +# started at all, and on a fresh Space it does exactly the thing whose absence blanks the grids. +#: What the boot seed did, so a surface can REPORT it instead of an operator inferring it from a +#: blank grid. R6's second sentence: a limit that cannot be removed is reported with its cause. +MIRROR_SEED = {"attempted": False, "seeded": False, "cause": "", "recommendation": ""} + + +def _seed_mirror_if_absent(): + """Hydrate the analytical mirror when this container has none — INDEPENDENT of `AIOS_PREWARM`. + + Fail-quiet by design, and it records WHY rather than only whether: "there is no mirror and no + HF_TOKEN to fetch one" and "there is no mirror and the fetch failed" are different operator + actions, and a blank grid cannot tell them apart. + """ + from harness import datastore as _ds + MIRROR_SEED["attempted"] = True + try: + if _ds.ensure_seed(): + MIRROR_SEED["seeded"] = True + print("[aios-api] analytical mirror seeded at boot (independent of AIOS_PREWARM)") + return True + if not _ds.DB_PATH.exists(): + MIRROR_SEED["cause"] = ( + "this container has no analytical mirror and the seed snapshot could not be " + "fetched (no HF_TOKEN, or the dataset was unreachable)") + MIRROR_SEED["recommendation"] = ( + "set HF_TOKEN on the deployment; until then every connected grid and every " + "measure column served from the mirror is empty") + print(f"[aios-api] NO ANALYTICAL MIRROR: {MIRROR_SEED['cause']}") + except Exception as e: # noqa: BLE001 — boot must not die + MIRROR_SEED["cause"] = f"the boot seed raised {type(e).__name__}: {e}" + MIRROR_SEED["recommendation"] = "check HF_TOKEN and the seed dataset's availability" + print(f"[aios-api] mirror seed skipped: {e}") + return False + + +try: + from harness import datastore as _ds_boot + if not _ds_boot.DB_PATH.exists(): + import threading as _threading_seed + _threading_seed.Thread(target=_seed_mirror_if_absent, daemon=True, + name="mirror-seed").start() +except Exception as e: # noqa: BLE001 + print(f"[aios-api] mirror seed not scheduled: {e}") + +if os.environ.get("AIOS_PREWARM") == "1": + import threading as _threading + _threading.Thread(target=_prewarm, daemon=True, name="prewarm").start() + _threading.Thread(target=_store_resync_loop, daemon=True, name="store-resync").start() diff --git a/api/routes_automation.py b/api/routes_automation.py index 6de4ef3aa25c8bce0e14abbb702d192815b380d7..50c1df8d22f4db1ebec63ffa0f123ca4db35ff29 100644 --- a/api/routes_automation.py +++ b/api/routes_automation.py @@ -1,1155 +1,1569 @@ -"""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 time - -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): - """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. - "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), - } - - -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 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) - defs = engine.all_definitions(session.runtime) - items = [_wire(d, session.tenant) for _, d in - sorted(defs.items(), key=lambda kv: (kv[1].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. - "actionsCatalog": engine.action_catalog(), - # 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") - # ⚠ 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 [] - draft, refusal, provider = ai_review.draft_flow( - prompt=prompt, - catalog=engine.action_catalog(), - required=engine.ACTION_REQUIRED, - triggers=_triggers_vocab(session), - tables=tables, - 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 [] - cleaned, why = engine.clean_actions([{"kind": a.get("kind"), "config": a.get("config") or {}} - for a in wrote]) - 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.") - return {"draft": {"name": 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} - - -@router.get("/automations/{auto_id}") -def get_automation(auto_id: str, session: Session = Depends(_GATE)): - 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)} - - -@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), "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") - # 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. - _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) - return {"automation": _wire(defn, session.tenant), "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).""" - if engine.running(session.tenant, auto_id): - raise err(409, "automation_running", "it is running — wait for it to finish") - engine.remove(session.runtime, 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)} - - -@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) +"""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 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): + """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. + "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 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) + 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) + 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 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) + defs = engine.all_definitions(session.runtime) + items = [_wire(d, session.tenant) 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. + "actionsCatalog": engine.action_catalog(), + # 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") + # ⚠ 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. + menu_catalog = [r for r in engine.action_catalog() 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, + 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 [] + cleaned, why = engine.clean_actions([{"kind": a.get("kind"), "config": a.get("config") or {}} + for a in wrote]) + 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} + + +@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)} + + +@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), "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. + _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) + return {"automation": _wire(defn, session.tenant), "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) + 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)} + + +@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) diff --git a/api/routes_nav.py b/api/routes_nav.py index a0fc896c047d2a2912bcd7c178fabe71a73cc371..b376a5f2e3f95e934da77c1d8589a9dd02ae3f9c 100644 --- a/api/routes_nav.py +++ b/api/routes_nav.py @@ -248,6 +248,191 @@ def _read_recents(runtime, uname, allowed): return out[:_MAX_RECENTS] +# ── W34-T10 (ruling R1, contract C1): the "mark important" counts, per database ────────────── + +#: A wall-clock ceiling on the WHOLE counting block, checked between databases. +#: +#: ⛔ IT IS A COLD-START BOUND, NOT A PERFORMANCE BUDGET, and the measurement is the reason it +#: exists at all. Every database's views live in its OWN store bucket (`_table_workspace`), +#: never in the `user_tables` document this route already holds — so this block is `N` reads on a +#: route D-175 spent a wave reducing to one. What makes it affordable is that those buckets are +#: TINY. Measured on tenant #0, 2026-08-16: twelve of them cost **6.2 ms WARM in total**, and the +#: largest (`customer_data`) is 45,035 bytes against the 28.6 MB `user_tables` document; ten of the +#: twelve are under 1 KB. COLD is the other half of the truth: the same twelve cost 7,331 ms of +#: first fetch. So this budget bounds the FIRST request after a container starts, and a database it +#: does not reach is reported through `degraded` rather than quietly carrying no number. +#: +#: ⚠ THE NUMBER IS CHOSEN AGAINST THE CLIENT'S DEADLINE, NOT AGAINST A FEELING. `nav.ts`'s +#: `NAV_TIMEOUT_MS` is 20 s and a cold `/nav` already spends most of that on the `user_tables` +#: download; letting this block run unbounded (measured: 7,331 ms for twelve cold buckets) would +#: convert a slow success into a manufactured failure, which is the exact mistake that deadline's +#: own comment warns about. 2.5 s is a bound the route can afford to lose. +_IMPORTANT_BUDGET_S = 2.5 + +#: Suffix `core.view_templates.workspace_key` appends. Stripping it is how a page key becomes the +#: TOPIC key the cohort bucket is named from — `customer_data` -> `customer_table_workspace` -> +#: `customer` -> `customer_cohorts`. Derived rather than re-listed on purpose: `_WS_KEYS` is +#: already the one place `customer_data`/`product_data` are mapped to their topic, and a second +#: copy here would be free to drift from it ([[one-question-two-normalizers]]). +_WS_SUFFIX = "_table_workspace" + + +def _visible_views(doc, uname, is_admin, granted_ids): + """Every view on ONE database that THIS caller can see, from an already-read workspace doc. + + ⛔ PER-CALLER, NOT TENANT-WIDE, and this is the half a server-side count is most likely to get + wrong. `_table_workspace` is `{username: {views, fields, overlays}, '__shared__': {...}}` + — one home per view, never both — so "how many views are marked important here" has a + DIFFERENT answer for every account. Measured on tenant #0: `leadership` has one marked view and + `admin` has none, on the same database. A tenant-wide count would put a number in the owner's + rail that no view sidebar they can open would ever add up to. + + ⚠ `table_store._may_see` is imported rather than re-expressed. It is private, and reaching for + it is still the right call: the alternative is `TableOps.shared_views`, which re-reads the whole + bucket per database (the N whole reads this block exists to avoid), and the only other option is + a second copy of a permission predicate. A wrong copy of `_may_see` widens what a user is told + exists; a private import cannot. + """ + import core.table_store as table_store + out = {} + for vid, view in ((doc.get(uname) or {}).get("views") or {}).items(): + if isinstance(view, dict): + out[str(vid)] = view + for vid, view in ((doc.get(table_store.SHARED_KEY) or {}).get("views") or {}).items(): + if isinstance(view, dict) and table_store._may_see(view, uname, is_admin): + out.setdefault(str(vid), view) + # The wave-21 named-user grants. The ids come from ONE tenant-wide bucket read once for the + # whole request; the RECORD is already in hand, in whichever stratum its owner keeps it. + if granted_ids: + for stratum, blob in doc.items(): + if stratum == uname or not isinstance(blob, dict): + continue + for vid, view in (blob.get("views") or {}).items(): + if str(vid) in granted_ids and isinstance(view, dict): + out.setdefault(str(vid), view) + return out + + +def _view_record_count(cfg, cohorts): + """How many RECORDS this view resolves to, or None when that cannot be answered for free. + + ⛔ `None` IS AN ANSWER AND IT IS THE IMPORTANT ONE. Two of the three shapes below are exact + because the view CARRIES its row set; the third — an ordinary filtered view — can only be + counted by running its filters over the records, and the records are the one thing this route + must never read (`D-175`/`D-185`: `/nav` is a rows-free projection, and reaching for `rows` + here raises by that projection's own contract). So a filtered view is reported as UNCOUNTED and + the database's `partial` flag says so, which is the whole of R6's second sentence applied to a + badge: a limit that cannot be removed is REPORTED with its cause, never papered over with a + number that is short by an unknown amount. + + ⚠ THIS IS ALSO WHY THE SERVER DOES NOT SIMPLY MIRROR THE CLIENT. `CustomerGrid::alertCounts` + counts a filtered view fine and gives up on a SERVER-WINDOWED one (`D-205`'s `Important 0+`); + this end is the exact inverse — it has no rows at all and no window either. The two are honest + about different halves, which is why `partial` had to be on the wire rather than derived. + """ + if not isinstance(cfg, dict): + return None + # A cohort-locked view IS its cohort: the lock and the id are the same fact (`grid_events` + # re-stamps it on every write), and a cohort's membership is a stored pid LIST, not a query. + lock = str(cfg.get("cohortLock") or "").strip() + if lock: + n = cohorts.get(lock) + return int(n) if isinstance(n, int) else None + # A curated row set carries its own count. + pids = cfg.get("memberPids") + if isinstance(pids, list) and pids: + return len(pids) + return None + + +def _important_counts(session, keys): + """`({key: {marked, counted, partial}}, unread)` for the databases in `keys`. + + `marked` = views this caller can see on that database whose `config.important is True`. + `counted` = the SUM of those views' record counts — a record matching two marked views + contributes twice, because that is what the badge the owner is moving has always + meant (`CustomerGrid::importantTotal`: *"a sum of per-view counts, which is what + was asked"*), and a distinct-record total would disagree with the per-view numbers + a user can read off the sidebar and add up themselves. + `partial` = at least one marked view could not be counted. + + `unread` is the set of keys whose bucket did not answer — a store blip or the budget above. + They are reported through `degraded`, never as a confident zero. + """ + import core.shares as shares + import core.view_templates as view_templates + import modules.cohort as cohort_mod + + uname, is_admin = session.uname, session.admin + out, unread = {}, set() + try: + # ⚠ THE ROLE FILTER IS NOT BELT-AND-BRACES. `shared_with` answers "is there an entry naming + # me", and `grid_events._granted_views` — the reader whose answer this badge has to agree + # with — then requires `role_for(...) in ('view','edit')`. Dropping that second test would + # count a view the sidebar does not list, i.e. a badge one higher than anything a person can + # add up. One tenant-wide bucket, read once and cached, so it costs a dict lookup per id. + granted = {str(v) for v in + ((shares.shared_with(uname, kind="view", st=session.runtime) or {}) + .get("view") or []) + if shares.role_for("view", str(v), uname, + st=session.runtime) in ("view", "edit")} + except Exception: + granted = set() # no grants is the fail-closed answer: a narrower count, never wider + cohort_cache = {} + started = time.perf_counter() + for key in keys: + if key in out or key in unread: + continue # the caller's order may repeat a key; a repeat must not respend + ws_key = view_templates.workspace_key(key) + if not ws_key: + continue # not a table at all (a module surface, a folder head) + if time.perf_counter() - started > _IMPORTANT_BUDGET_S: + unread.add(key) + continue + try: + # ⛔ PROJECTED, and the two dropped keys are the whole reason this is affordable. + # `overlays` is per-record field values and `fields` is the schema stratum; neither + # says anything about a view. On `customer_data` they are most of the bucket. + doc = session.runtime.get_projection(ws_key, drop=("overlays", "fields")) or {} + except Exception: + unread.add(key) + continue + marked = [v for v in _visible_views(doc, uname, is_admin, granted).values() + if ((v.get("config") or {}).get("important") is True)] + if not marked: + out[key] = {"marked": 0, "counted": 0, "partial": False} + continue + scope = ws_key[:-len(_WS_SUFFIX)] if ws_key.endswith(_WS_SUFFIX) else key + if scope not in cohort_cache: + try: + # ⚠ Read through `session.runtime`, NOT `modules.cohort`'s own module-level + # helpers: those call `core.store` directly, so they carry no tenant namespace. + # The MODULE is asked for the bucket NAME (it owns that rule) and this route does + # the reading, which is the only tenant-correct combination. + bucket = session.runtime.get(cohort_mod.key_for(scope)) or {} + # ⛔ THIS CALLER'S OWN COHORTS ONLY, and the consequence is deliberate: a SHARED + # view locked to a cohort somebody else owns finds no id here, so it is reported + # UNCOUNTED (`partial`) rather than counted from a stratum this session cannot + # see. Widening the read to every user's cohorts would make the badge disclose the + # SIZE of another person's private list, which is a leak wearing a bug fix. + mine = bucket.get(uname) or {} + cohort_cache[scope] = { + str(cid): len(c.get("members") or []) + for cid, c in mine.items() if isinstance(c, dict) + } + except Exception: + cohort_cache[scope] = {} + counted, partial = 0, False + for view in marked: + n = _view_record_count(view.get("config"), cohort_cache[scope]) + if n is None: + partial = True + else: + counted += n + out[key] = {"marked": len(marked), "counted": counted, "partial": partial} + return out, unread + + @router.get("/nav") def nav(session: Session = Depends(require_session)): """`{pages: [{key,label,source?,chrome}], landing}`. @@ -336,7 +521,7 @@ def nav(session: Session = Depends(require_session)): # empty rather than leaving 200-[] ambiguous. return {"pages": [], "landing": None, "empty": "no_databases"} raise err(403, "no_surfaces", - "your account has no dashboards assigned — ask an administrator") + "your account has no dashboards assigned. Ask an administrator.") # WAVE 19 (R8 / C1): the tenant's name + icon overrides, merged LAST — after the registry # rows, after the tenant module filter, after the user tables. Merged HERE rather than # applied by the client for one reason: `label` is what every reader of this payload shows, @@ -350,6 +535,38 @@ def nav(session: Session = Depends(require_session)): # leak with no symptom until two tenants rename the same registry key. Copy the rows before # merging on the day that invariant changes. meta = _read_nav_meta(session.runtime) + # ⭐⭐ W34-T10 (ruling R1, contract C1) — THE MARK-IMPORTANT NUMBERS, COMPUTED ONCE. + # + # ⛔ COMPUTED HERE RATHER THAN INSIDE THE LOOP BELOW, AND THAT PLACEMENT IS THE SAFETY + # ARGUMENT, not tidiness. The `for p in pages:` loop sits OUTSIDE the try/except that wraps + # the `all_defs()` read, so anything raising inside it takes the whole nav down for every + # user — a 500 where the store-blip path is careful to answer 200 with `degraded`. One call, + # one guard, and the loop stays a dict lookup. + # + # ⛔⛔ AND THE ORDER IS LOAD-BEARING, WHICH IS THE ONE THING THE FIRST DRAFT GOT WRONG. + # A budget spent in whatever order the pages happen to arrive is a lottery: measured cold on + # tenant #0, the block spent all 1.5 s of its first draft on ten EMPTY Odoo buckets and was cut + # off two rows before `customer_data`, which holds the only marked view in the tenant. The + # feature would have shipped, been correct, and shown nothing on a cold container. Counting in + # the user's own RECENTS order first fixes that with a fact this route already holds: a mark + # lives on a database somebody works in, and `nav_recents` is exactly the list of those, + # newest first. + _page_keys = {str(p.get("key", "")) for p in pages} + recents = _read_recents(session.runtime, session.uname, _page_keys) + _recent_first = [r["key"] for r in recents] + _seen_first = set(_recent_first) + _recent_first += [k for k in (str(p.get("key", "")) for p in pages) + if k not in _seen_first] + _important, _imp_unread = {}, set() + try: + _important, _imp_unread = _important_counts(session, _recent_first) + except Exception: + _imp_unread = set(_page_keys) + if _imp_unread: + # The SAME honest-absence channel W31-T11 built for the `ut_*` merge. A database whose + # count could not be read must not be indistinguishable from one with nothing marked: + # both would render as no badge, and only one of them is true. + _degraded.append("important") # Wave 21 (C3): the definitions, once — `manage`/`canDelete` below answer from `createdBy`. # WAVE 27 (C9): `locked` answers from `recordMode`, off the same one read. # ⭐ W31-T10: that read is now the SAME one the merge above did — it used to be a second, @@ -398,6 +615,13 @@ def nav(session: Session = Depends(require_session)): p["locked"] = True elif session.admin: p["manage"] = True + # ⭐ W34-T10 / C1 — always emitted for a database this route could READ, including when + # nothing is marked (`{marked: 0, counted: 0, partial: false}`). That is the same rule + # `omitted`/`degraded` follow at the bottom of this function and for the same reason: a key + # a consumer has to test for is a key a consumer forgets to test for. ABSENT here means + # "not a database, or we could not read it" — the second case is named in `degraded`. + if key in _important: + p["important"] = _important[key] entry = meta.get(p.get("key")) if meta else None if not entry: continue @@ -422,8 +646,11 @@ def nav(session: Session = Depends(require_session)): # both doors finally agree on what a placeable key is. This block keeps using the assembled # list because it already holds it: re-enumerating here would be a second store read for an # answer sitting in a local variable. - recents = _read_recents(session.runtime, session.uname, - {str(p.get("key", "")) for p in pages}) + # + # ⚠ W34-T10 MOVED THE READ, NOT THE RULE. `recents` is now computed ABOVE the enrichment loop, + # because the important-count block spends its budget in RECENTS ORDER (see there). It is still + # ONE read of `nav_recents`, still pruned against the assembled page list, and it is used here + # unchanged — a second `_read_recents` call would be the extra store read this comment forbids. # ⭐ W31-T11 — TWO KINDS OF ABSENCE, NAMED SEPARATELY, and both keys are ALWAYS PRESENT. # `omitted` — this workspace's catalogue does not include these modules. Deliberate. # `degraded` — a part of this payload could not be read. NOT deliberate, and the shell says @@ -460,7 +687,7 @@ def nav_opened(body: dict = Body(default=None), raise err(400, "bad_request", "no page was named") if not session.runtime.available(): raise err(503, "store_unavailable", - "the tenant store is unavailable — nothing was recorded") + "the tenant store is unavailable. Nothing was recorded.") stamp, uname = _now(), session.uname def _up(data): @@ -484,7 +711,7 @@ def nav_opened(body: dict = Body(default=None), session.runtime.update(_NAV_RECENTS_KEY, _up, flush='async') except Exception: raise err(503, "store_unavailable", - "the tenant store refused the write — nothing was recorded") + "the tenant store refused the write. Nothing was recorded.") return {"key": key, "at": stamp} @@ -549,7 +776,7 @@ def save_nav_meta(body: dict = Body(default=None), if "name" in body: if not key.startswith("ut_"): raise err(400, "name_not_allowed", - "only a database you created can be renamed — this one's name comes " + "only a database you created can be renamed. This one's name comes " "from the module registry") name = " ".join(str(body.get("name") or "").split())[:_MAX_NAV_NAME] if not name: @@ -560,7 +787,7 @@ def save_nav_meta(body: dict = Body(default=None), if not session.runtime.available(): raise err(503, "store_unavailable", - "the tenant store is unavailable — nothing was saved") + "the tenant store is unavailable. Nothing was saved.") def _up(data): data = data if isinstance(data, dict) else {} @@ -583,7 +810,7 @@ def save_nav_meta(body: dict = Body(default=None), session.runtime.update(_NAV_META_KEY, _up) except Exception: raise err(503, "store_unavailable", - "the change was not saved — the store refused the write") + "the change was not saved: the store refused the write.") return {"key": key, "meta": _read_nav_meta(session.runtime).get(key, {})} @@ -608,7 +835,7 @@ def save_nav_prefs(body: dict = Body(default=None), clean = _clean_nav_prefs(body or {}, keys, keep_unknown_ut=not enumerated) if not session.runtime.available(): raise err(503, "store_unavailable", - "the tenant store is unavailable — nothing was saved") + "the tenant store is unavailable. Nothing was saved.") def _up(data): data = data if isinstance(data, dict) else {} @@ -622,7 +849,7 @@ def save_nav_prefs(body: dict = Body(default=None), session.runtime.update(_NAV_PREFS_KEY, _up) except Exception: raise err(503, "store_unavailable", - "the folder change was not saved — the store refused the write") + "the folder change was not saved: the store refused the write.") return {"prefs": clean} diff --git a/api/routes_query.py b/api/routes_query.py index fb54bb206b78d33478163c91d1efdd6455834643..0323ba425d55fb98d96db93494d65130f9b945db 100644 --- a/api/routes_query.py +++ b/api/routes_query.py @@ -1,725 +1,1261 @@ -"""Assistant chat and Query-owned virtual artefacts (W33-T72 / C8). - -Query reads only the detached, permission-filtered snapshot from -``deps.assistant_read_scope``. It owns a separate per-user namespace for threads, messages, -citations and virtual views; it never opens or mutates a source workspace. -""" -import copy -import datetime as _dt -import hashlib -import json -import os -import re -import uuid - -from fastapi import APIRouter, Body, Depends - -from deps import (Session, assistant_read_scope, assistant_source_status, err, - require_session) - -router = APIRouter(prefix="/api/v1") - -MAX_QUESTION = 500 -MAX_VISIBLE = 24 -MAX_ARTIFACTS = 200 -MODEL_AUTO = "auto" -QUERY_PROVIDER_ORDER = ("cerebras", "groq", "openrouter") -QUERY_KINDS = ("grid", "chart", "calendar", "kanban", "timeseries", "map", "list") -QUERY_WORKSPACE_EVENTS = {"view_create", "view_upsert", "view_delete"} -QUERY_EXCLUDED = { - "form": "Forms collect new data and are not a read-only Query artefact.", - "catalog": "Catalog is a source-native presentation that Query cannot safely mutate.", - "swipe": "Swipe is an interactive source-native presentation, not an Assistant output.", -} -_MODE_REFS = { - "kanban": [("stackField", ("select",), True)], - "calendar": [("dateField", ("date",), True)], - "timeseries": [("dateField", ("date",), True)], - "map": [("colorField", ("select",), False), ("sizeField", ("int", "currency", "pct"), False)], -} - - -def _now_iso(): - return _dt.datetime.now(_dt.timezone.utc).isoformat() - - -def _grid(): - import aios_grid - return aios_grid - - -def _safe(value): - """Detach values before they enter a durable Query object or an API reply.""" - return json.loads(json.dumps(value, default=str)) - - -def _namespace_key(session): - """A tenant runtime has one isolated key for each user's Query-owned objects.""" - principal = f"{session.tenant}:{session.uname}".encode("utf-8") - return "query_user_" + hashlib.sha256(principal).hexdigest()[:24] - - -def _blank_state(): - return {"version": 1, "threads": {}, "messages": {}, "citations": {}, "views": {}} - - -def _state(session): - try: - raw = session.runtime.get(_namespace_key(session)) or {} - except Exception: - raw = {} - if not isinstance(raw, dict): - return _blank_state() - out = _blank_state() - for key in out: - if key == "version": - continue - if isinstance(raw.get(key), dict): - out[key] = copy.deepcopy(raw[key]) - return out - - -def _new_id(prefix): - return f"{prefix}_{uuid.uuid4().hex[:16]}" - - -def model_choices(): - """Choices are stable even when one is not configured, so explicit means explicit.""" - return [MODEL_AUTO, *QUERY_PROVIDER_ORDER] - - -def _providers(model=MODEL_AUTO): - """Auto returns the permitted ladder; an explicit choice returns at most one provider.""" - import harness.analyst as analyst - - requested = str(model or MODEL_AUTO).strip().lower() - by_name = {p["name"]: p for p in analyst.PROVIDERS} - names = list(QUERY_PROVIDER_ORDER) if requested == MODEL_AUTO else [requested] - return [by_name[name] for name in names - if name in by_name and os.environ.get(by_name[name]["env"])] - - -def _spec_schema(field_keys): - col = {"type": "string", "enum": sorted(field_keys)} - return { - "type": "object", - "properties": { - "kind": {"type": "string", "enum": [*QUERY_KINDS, "refused"]}, - "name": {"type": "string"}, - "refusal": {"type": "string"}, - "visible": {"type": "array", "items": col}, - # ⭐⭐ 2026-08-15 (owner: *"if you can build the view, the AI assistant should also be - # able to do it by using our tools on the backend"*). `rhs` and `important` were the - # only two things a person could express through `view_upsert` and this tool could not. - # - # ⛔ WITHOUT `rhs` THE ASSISTANT CANNOT STATE AN ERROR-CATCHER AT ALL — the one shape - # item 8a named by hand (*"price and COGS not matching"*). Every one of the 20 - # `FILTER_OPS` was already reachable, because they all read `value`; comparing a column - # against ANOTHER COLUMN is a different member (`aios_grid._clean_rhs`, CG-9) and it was - # simply absent here, so the model had no way to ask for it and no way to be told why. - # `kind` is `field` ONLY: `measure` needs a window and `stat` needs the population - # vocabulary, neither of which this snapshot-shaped reader carries — offering them - # would be a control that lies, which is the same rule the source chips follow. - "filters": {"type": "array", "items": {"type": "object", "properties": { - "colId": col, "op": {"type": "string", "enum": sorted(_grid().FILTER_OPS)}, - "value": {"type": "string"}, - "rhs": {"type": "object", "properties": { - "kind": {"type": "string", "enum": ["field"]}, "colId": col, - }, "required": ["kind", "colId"]}, - }, "required": ["colId", "op"]}}, - # A personal legibility mark, exactly as `grid_events.view_upsert` treats it (wave 32 - # R5/C4) — not a lock, and no second permission wall. - "important": {"type": "boolean"}, - "filterConj": {"type": "string", "enum": ["and", "or"]}, - "sorts": {"type": "array", "items": {"type": "object", "properties": { - "colId": col, "dir": {"type": "string", "enum": ["asc", "desc"]}, - }, "required": ["colId", "dir"]}}, - "groupBy": col, - "aggregation": {"type": "object", "properties": { - "op": {"type": "string", "enum": ["count", "sum", "avg", "min", "max"]}, - "field": col, - }, "required": ["op"]}, - "stackField": col, - "dateField": col, - "colorField": col, - "sizeField": col, - }, - "required": ["kind"], - } - - -def _system_prompt(snapshot): - fields = snapshot["fields"] - cols = json.dumps([{"key": field["key"], "label": field.get("label", field["key"]), - "type": field.get("type", "text")} for field in fields], separators=(",", ":")) - return f"""You turn a question about exactly one permitted database into a virtual view. -Never write SQL, invent fields, or name another database. - -DATABASE: {snapshot['database']} -SNAPSHOT VERSION: {json.dumps(snapshot['source_version'], default=str)} -PERMISSION-FILTERED RECORD COUNT: {len(snapshot['records'])} -FIELDS: {cols} -VIEW KINDS: {", ".join(QUERY_KINDS)} - -Choose visible fields, supported filters and an optional aggregation. Use count for record counts; -sum, avg, min and max require one numeric field. The server computes and cites every number from -this exact snapshot. Return kind=refused with one plain sentence if the database cannot answer. - -To compare one column against ANOTHER column rather than a typed value, give the filter an rhs of -{{"kind":"field","colId":""}} and omit value — that is how you express questions like -"priced below what it costs us". Both columns must be in the FIELDS list above. -Set important=true when the view is one somebody should be chased about: an error, a mismatch, or -money at risk. Leave it out otherwise.""" - - -_FAILED_GEN = re.compile(r"(\{.*?\})\s*", re.S) - - -def _spec_from_400(body): - try: - value = ((json.loads(body) or {}).get("error") or {}).get("failed_generation") or "" - raw = (_FAILED_GEN.search(str(value)) or [str(value).strip()])[1] - result = json.loads(raw) - return result if isinstance(result, dict) else None - except Exception: - return None - - -def _call_model(question, snapshot, model=MODEL_AUTO, chat=None): - """Return ``(spec, sentence, provider, reason)`` without any source-data fallback.""" - requested = str(model or MODEL_AUTO).strip().lower() - if requested not in model_choices(): - return None, f"the selected model ({requested or model}) is unavailable", None, "model_unavailable" - tools = [{"type": "function", "function": { - "name": "build_view", "description": "Emit a virtual-view spec or a refusal.", - "parameters": _spec_schema([field["key"] for field in snapshot["fields"]]), - }}] - messages = [{"role": "system", "content": _system_prompt(snapshot)}, - {"role": "user", "content": question}] - if chat is not None: - provider = requested if requested != MODEL_AUTO else "injected" - return chat(messages, tools), None, provider, None - - providers = _providers(requested) - if not providers: - sentence = (f"the selected model ({requested}) is unavailable" if requested != MODEL_AUTO - else "the assistant is not configured on this deployment") - return None, sentence, None, "model_unavailable" if requested != MODEL_AUTO else None - - import requests - last = None - for provider in providers: - try: - response = requests.post( - provider["url"], timeout=60, - headers={"Authorization": f"Bearer {os.environ[provider['env']]}"}, - json={"model": provider["model"], "messages": messages, "tools": tools, - "tool_choice": "required", "temperature": 0.1, "max_tokens": 1200}, - ) - except Exception as exc: - last = f"{provider['name']}: {type(exc).__name__}" - continue - if response.status_code == 400: - spec = _spec_from_400(response.text) - if spec is not None: - return spec, None, provider["name"], None - last = f"{provider['name']}: 400" - continue - if response.status_code != 200: - last = f"{provider['name']}: HTTP {response.status_code}" - continue - try: - call = (response.json()["choices"][0]["message"].get("tool_calls") or [])[0] - spec = json.loads(call["function"].get("arguments") or "{}") - return spec, None, provider["name"], None - except Exception as exc: - last = f"{provider['name']}: unreadable answer ({type(exc).__name__})" - if requested != MODEL_AUTO: - return None, f"the selected model ({requested}) is unavailable", None, "model_unavailable" - return None, "the assistant could not be reached just now (" + (last or "no provider") + ")", None, None - - -def _validate(spec, fields): - """Return a cleaned, source-independent virtual-view config or a named refusal.""" - if not isinstance(spec, dict): - return None, "the assistant did not answer with a view", "no_spec" - by_key = {str(field.get("key")): str(field.get("type") or "text") for field in fields} - keys = set(by_key) - kind = spec.get("kind") - if kind == "refused": - return None, str(spec.get("refusal") or "this database cannot answer that question"), "model_refused" - if kind in QUERY_EXCLUDED or kind not in QUERY_KINDS: - return None, "that kind of view cannot be built from this question", "unsupported_kind" - - named = set(spec.get("visible") or ()) - for item in spec.get("filters") or (): - if isinstance(item, dict) and item.get("colId"): - named.add(str(item["colId"])) - # ⛔ THE RIGHT-HAND COLUMN IS A COLUMN AND MUST FACE THE SAME `missing` CHECK. Collecting - # only the left side is what makes D-229 possible one layer down: `clean_filter_tree` does - # NOT drop a leaf whose field-rhs names a column that does not exist — it keeps the leaf, - # strips the `rhs`, blanks the value, and `filter_sql.is_rule_active` then reports the rule - # INACTIVE. An inactive rule narrows nothing, so "margin under 10%" would come back as a - # view listing the ENTIRE catalogue under an error-catcher's name, with nothing red. - # Naming it here turns that into the ordinary "this database does not have: X" refusal. - rhs = item.get("rhs") if isinstance(item, dict) else None - if isinstance(rhs, dict) and rhs.get("colId"): - named.add(str(rhs["colId"])) - for item in spec.get("sorts") or (): - if isinstance(item, dict) and item.get("colId"): - named.add(str(item["colId"])) - for key in ("groupBy", "stackField", "dateField", "colorField", "sizeField"): - if spec.get(key): - named.add(str(spec[key])) - raw_aggregation = spec.get("aggregation") or {"op": "count"} - if isinstance(raw_aggregation, dict) and raw_aggregation.get("field"): - named.add(str(raw_aggregation["field"])) - missing = sorted(named - keys) - if missing: - return None, "this database does not have: " + ", ".join(missing), "unknown_columns" - - visible = [str(key) for key in (spec.get("visible") or ()) if str(key) in keys][:MAX_VISIBLE] - if not visible: - return None, "that question did not name any fields to show", "no_columns" - raw_filters = [item for item in (spec.get("filters") or ()) if isinstance(item, dict)] - filters = _grid().clean_filter_tree(raw_filters, keys) - if len(filters) != len(raw_filters): - return None, "part of that filter is unsupported", "filter_dropped" - # ⛔⛔ A SECOND, NARROWER CHECK, AND THE LENGTH CHECK ABOVE CANNOT DO ITS JOB (D-229). - # A dropped leaf changes the COUNT; a stripped `rhs` does not — the leaf survives, so - # `len(filters) == len(raw_filters)` and the refusal above never fires. The failure is - # therefore silent in exactly the direction that matters: the condition stops narrowing and - # the view answers with every record. Assert the member survived, per leaf. - # ⚠ The `named` pass above already refuses an rhs naming a column this database lacks, so - # reaching here means something ELSE stripped it (a type the comparand cannot take, a future - # `_clean_rhs` rule). Both doors, because the two catch different causes and the cost of - # missing this one is a wrong answer that looks right. - for sent, kept in zip(raw_filters, filters): - if sent.get("rhs") and not kept.get("rhs"): - return None, "that column cannot be compared against another column", "rhs_dropped" - - aggregation = raw_aggregation if isinstance(raw_aggregation, dict) else {} - op = str(aggregation.get("op") or "").lower() - field = aggregation.get("field") - if op not in {"count", "sum", "avg", "min", "max"}: - return None, "the assistant gave an unsupported aggregation", "bad_aggregation" - if op == "count": - field = None - elif field not in keys or by_key.get(field) not in {"int", "currency", "pct"}: - return None, "that aggregation needs one visible numeric field", "bad_aggregation" - - display = {"mode": kind} - for ref, families, required in _MODE_REFS.get(kind, ()): - value = spec.get(ref) - if required and not value: - return None, f"a {kind} view needs {ref}", "missing_ref" - if value and by_key.get(value) not in families: - return None, f"{ref} has the wrong field type", "wrong_ref_type" - if value: - display[ref] = value - cleaned_display = _grid()._clean_display(display, keys) if kind != "grid" else None - if kind != "grid" and not cleaned_display: - return None, f"this product could not build a {kind} view", "display_dropped" - - return { - "kind": kind, - "name": " ".join(str(spec.get("name") or "Query").split())[:60] or "Query", - "visible": visible, - "filters": filters, - "filterConj": "or" if spec.get("filterConj") == "or" else "and", - "sorts": [{"colId": item["colId"], "dir": "desc" if item.get("dir") == "desc" else "asc"} - for item in (spec.get("sorts") or []) if isinstance(item, dict) - and item.get("colId") in keys][:3], - "groupBy": spec.get("groupBy") if spec.get("groupBy") in keys else None, - "aggregation": {"op": op, "field": field}, - "display": cleaned_display, - # ⚠ `is True`, not truthy, and UNCONDITIONAL — the same two rules `grid_events.view_upsert` - # follows for this key. `is True` so a model emitting the string "false" does not mark a - # view; unconditional so the mark is REMOVABLE rather than a flag that can be set and never - # cleared (a key written only when present leaves a stored `true` alive forever). - "important": spec.get("important") is True, - }, None, None - - -def _explain(view, fields): - label = {field["key"]: str(field.get("label") or field["key"]) for field in fields} - visible = ", ".join(label.get(key, key) for key in view["visible"][:6]) - result = f"{view['kind']} view of {visible}" - if view["filters"]: - result += "; filtered records only" - if view.get("groupBy"): - result += f"; grouped by {label.get(view['groupBy'], view['groupBy'])}" - agg = view["aggregation"] - if agg["op"] != "count": - result += f"; {agg['op']} of {label.get(agg['field'], agg['field'])}" - return result + "." - - -def _numeric_result(view, records): - aggregation = view["aggregation"] - if aggregation["op"] == "count": - return {"label": "Matching records", "value": len(records), "contributing_record_count": len(records)} - values = [] - for record in records: - try: - value = record.get(aggregation["field"]) - if value is not None and not isinstance(value, bool): - values.append(float(value)) - except (TypeError, ValueError): - continue - if not values: - return {"label": aggregation["op"], "value": None, "contributing_record_count": 0} - op = aggregation["op"] - value = {"sum": sum(values), "avg": sum(values) / len(values), "min": min(values), "max": max(values)}[op] - return {"label": f"{op.title()} of {aggregation['field']}", "value": value, - "contributing_record_count": len(values)} - - -def _referenced_fields(view): - """The citation names every source field that affected the displayed result.""" - out = list(view.get("visible") or ()) - for node in view.get("filters") or (): - if isinstance(node, dict) and node.get("colId"): - out.append(str(node["colId"])) - for node in view.get("sorts") or (): - if isinstance(node, dict) and node.get("colId"): - out.append(str(node["colId"])) - for key in ("groupBy",): - if view.get(key): - out.append(str(view[key])) - aggregation = view.get("aggregation") or {} - if aggregation.get("field"): - out.append(str(aggregation["field"])) - return list(dict.fromkeys(out)) - - -def _effective_filters(snapshot, view): - """Keep the source request and generated-view predicates distinct in provenance.""" - return { - "source": _safe(snapshot.get("filters")), - "view": {"conj": view.get("filterConj", "and"), - "nodes": _safe(view.get("filters") or [])}, - } - - -def _view_records(snapshot, view): - """Apply the exact validated virtual-view filter before calculating a cited number.""" - nodes = view.get("filters") or [] - if not nodes: - return list(snapshot["records"]) - from harness import filter_eval - tree = {"conj": view.get("filterConj", "and"), "nodes": nodes} - return [row for row in snapshot["records"] - if filter_eval.matches(tree, row, snapshot["fields"])] - - -def _citation(citation_id, snapshot, view, numeric, view_id): - return { - "id": citation_id, - "href": f"#/query?view={view_id}&citation={citation_id}", - "database": snapshot["database"], - "snapshot": {"kind": snapshot["source_kind"], "version": _safe(snapshot["source_version"])}, - "fields": [field["key"] for field in snapshot["fields"] - if field.get("key") in set(_referenced_fields(view))], - "filters": _effective_filters(snapshot, view), - "permission_scope_applied": snapshot["permission_scope_applied"], - "aggregation": _safe(view["aggregation"]), - "contributing_record_count": numeric["contributing_record_count"], - "retrieved_at": snapshot["retrieved_at"], - } - - -def _citation_complete(citation): - """A numeric result is not publishable without complete provenance.""" - required = {"database", "snapshot", "fields", "filters", "aggregation", - "contributing_record_count", "retrieved_at", "href"} - snapshot = citation.get("snapshot") if isinstance(citation, dict) else None - return (isinstance(citation, dict) and required <= set(citation) - and bool(citation["database"]) and isinstance(snapshot, dict) - and "version" in snapshot and bool(citation["retrieved_at"]) - and isinstance(citation["fields"], list) - and isinstance(citation["aggregation"], dict)) - - -def _public_view(view): - source = view["source"] - return { - "id": view["id"], "viewId": view["id"], "scope": source["database"], - "name": view["name"], "kind": view["view"]["kind"], "question": view["question"], - "explain": view["explain"], "threadId": view["threadId"], "createdAt": view["createdAt"], - "virtual": True, "source": _safe(source), "view": _safe(view["view"]), - "citationIds": list(view["citationIds"]), "numeric": _safe(view["numeric"]), - } - - -def _source_still_permitted(session, source, permitted=None): - """A persisted artefact never outlives the caller's current data permission. - - ⚠ `permitted` is the BATCH answer from `deps.assistant_source_status` — one rows-free - resolution for every source at once, rather than one whole-document read per saved view. That - function's docstring carries the measurement. The single-source path below stays for callers - holding exactly one artefact (the workspace-event door), and asks the same helper. - - ⛔ `permitted`, NEVER `answerable`. A source whose rows are served through the connector mirror - cannot be ASKED and can still be SEEN: the artefact was built from a snapshot that was legal - when it was taken, and hiding it because the reader can no longer make a NEW one would read as - deletion. The two verdicts are separate for that reason. - """ - database = (source or {}).get("database") if isinstance(source, dict) else None - if not database: - return False - if permitted is None: - permitted = {key for key, row in assistant_source_status(session, [database]).items() - if row["permitted"]} - return database in permitted - - -def _public_state(state, session): - # ONE rows-free batch answers both questions: which sources this caller may still see (which - # artefacts stay listed) and which of them can actually be asked (which chips are live). - status = assistant_source_status(session) - for row in state["views"].values(): - key = (row.get("source") or {}).get("database") if isinstance(row.get("source"), dict) else None - if key and key not in status: - # An artefact whose source is no longer enumerable still gets a verdict rather than a - # KeyError — it resolves to "not permitted" and the artefact drops out, which is the - # same answer the per-view wall gave. - status.update(assistant_source_status(session, [key])) - permitted = {key for key, row in status.items() if row["permitted"]} - allowed_views = [row for row in state["views"].values() - if _source_still_permitted(session, row.get("source"), permitted)] - allowed_ids = {row["id"] for row in allowed_views} - allowed_citations = {citation_id for row in allowed_views - for citation_id in row.get("citationIds") or []} - threads = sorted(state["threads"].values(), key=lambda row: row.get("updatedAt", ""), reverse=True) - messages = sorted(state["messages"].values(), key=lambda row: row.get("createdAt", "")) - views = sorted((_public_view(row) for row in allowed_views), key=lambda row: row["createdAt"], reverse=True) - messages = [row for row in messages if not row.get("viewId") or row.get("viewId") in allowed_ids] - return {"threads": _safe(threads), "messages": _safe(messages), "views": views, - "citations": _safe([row for key, row in state["citations"].items() - if key in allowed_citations]), "models": model_choices(), - # The chip row's own data: a source this caller holds but cannot ask, and WHY. - # ⭐⭐ D-276 — `or row.get("visible")`. A source the caller can open elsewhere in the - # product but the assistant cannot read is now LISTED with its cause, instead of - # vanishing from a picker that shows every other database they hold. It arrives with - # `answerable: False` and a `reason`, which is the SAME shape the mirror-served grids - # already use, so the chip row needs no new state to render it. - # ⛔ `permitted` STILL GATES ARTEFACTS — `allowed_views` above is unchanged. This widens - # what is DESCRIBED, never what can be read or kept. - "sources": _safe([{"database": key, "answerable": row["answerable"], - "reason": row["reason"]} - for key, row in sorted(status.items()) - if row["permitted"] or row.get("visible")])} - - -@router.get("/query") -def list_queries(session: Session = Depends(require_session)): - return _public_state(_state(session), session) - - -@router.post("/query/{qid}/events") -def mutate_query_workspace(qid: str, body: dict = Body(default=None), - session: Session = Depends(require_session)): - """The only grid-mutation transport for a virtual Query workspace. - - Query artefacts are immutable snapshots: creation and changes belong to an Assistant prompt, - never a source-native grid workspace. Deleting the caller's personal artefact is the one - permitted mutation. The binding key, not a client-supplied source scope, identifies it. - """ - qid = str(qid) - body = body if isinstance(body, dict) else {} - binding = body.get("workspaceBinding") - event = body.get("event") - if not (isinstance(binding, dict) and binding.get("kind") == "query" - and str(binding.get("key") or "") == qid): - raise err(400, "query_workspace_mismatch", - "the Query workspace binding must name this virtual artefact") - if not isinstance(event, dict) or str(event.get("type") or "") not in QUERY_WORKSPACE_EVENTS: - raise err(400, "unsupported_query_workspace_event", - "Query accepts only view_create, view_upsert, or view_delete events") - - state = _state(session) - view = state["views"].get(qid) - if view is None or not _source_still_permitted(session, view.get("source")): - # A caller cannot use an opaque Query key to learn about a revoked artefact. - raise err(404, "unknown_query", "that Query artefact does not exist") - - event_type = str(event["type"]) - if event_type != "view_delete": - raise err(409, "query_workspace_immutable", - "AI-created Query views change only through a new Assistant prompt") - if str(event.get("viewId") or "") != qid: - raise err(400, "query_workspace_mismatch", - "a Query delete must name the same virtual artefact as its binding") - - deleted = delete_query(qid, session) - return {"workspaceBinding": {"kind": "query", "key": qid}, - "event": event_type, **deleted} - - -def _sources(body, target, session): - raw = body.get("sources") if isinstance(body, dict) else None - sources = [str(item).strip() for item in raw] if isinstance(raw, list) else [] - sources = list(dict.fromkeys(item for item in sources if item)) - if target not in sources: - sources.insert(0, target) - # Source chips are a permissioned selection, not untrusted labels remembered in a thread. - # The model still receives ONLY `target`'s snapshot below; these calls do not pass a second - # source to it and D's helper refuses cache misses, mirrors and unshared databases. - for source in sources: - assistant_read_scope(session, source, fields=[], filters=None) - return sources - - -def _submit(body, session, chat=None): - body = body if isinstance(body, dict) else {} - question = " ".join(str(body.get("question") or "").split()) - target = str(body.get("database") or body.get("scope") or "").strip() - selected_model = str(body.get("model") or MODEL_AUTO).strip().lower() - if not question: - raise err(400, "bad_request", "no question was asked") - if len(question) > MAX_QUESTION: - raise err(400, "question_too_long", f"a question must be under {MAX_QUESTION} characters") - if not target: - raise err(400, "bad_request", "one target database must be selected") - if selected_model not in model_choices(): - raise err(400, "unknown_model", "that model is not available in Query") - - # This call happens before model selection and is the only permitted source-data read. - snapshot = assistant_read_scope(session, target, fields=None, filters=body.get("filters")) - sources = _sources(body, target, session) - state = _state(session) - thread_id = str(body.get("threadId") or "").strip() - if thread_id and thread_id not in state["threads"]: - raise err(404, "unknown_thread", "that chat does not exist") - if not thread_id: - thread_id = _new_id("thread") - now = _now_iso() - user_message_id = _new_id("message") - assistant_message_id = _new_id("message") - spec, sentence, provider, reason = _call_model(question, snapshot, model=selected_model, chat=chat) - view, refusal, refusal_code = _validate(spec, snapshot["fields"]) if spec is not None else (None, sentence, reason) - - artifact = None - citation = None - if view is not None: - if len(state["views"]) >= MAX_ARTIFACTS: - raise err(400, "query_limit", f"you already have {len(state['views'])} Query artefacts") - view_id = _new_id("query") - citation_id = _new_id("citation") - numeric = _numeric_result(view, _view_records(snapshot, view)) - citation = _citation(citation_id, snapshot, view, numeric, view_id) - if not _citation_complete(citation): - raise RuntimeError("Query refused to persist an incomplete numeric citation") - source = {"database": snapshot["database"], "label": snapshot["label"], - "source_kind": snapshot["source_kind"], "source_version": _safe(snapshot["source_version"]), - "retrieved_at": snapshot["retrieved_at"], - "fields": _safe(snapshot["fields"]), "filters": _safe(snapshot["filters"]), - "permission_scope_applied": snapshot["permission_scope_applied"]} - artifact = {"id": view_id, "threadId": thread_id, "name": view["name"], "question": question, - "explain": _explain(view, snapshot["fields"]), "createdAt": now, "source": source, - "view": _safe(view), "citationIds": [citation_id], "numeric": numeric, - "model": provider, "requestedModel": selected_model} - - assistant_message = { - "id": assistant_message_id, "threadId": thread_id, "role": "assistant", "createdAt": now, - "content": artifact["explain"] if artifact else (refusal or "the assistant could not answer"), - "targetDatabase": target, "requestedModel": selected_model, "model": provider, - "reason": refusal_code, "viewId": artifact["id"] if artifact else None, - "citationIds": artifact["citationIds"] if artifact else [], "numeric": artifact["numeric"] if artifact else None, - } - user_message = {"id": user_message_id, "threadId": thread_id, "role": "user", "createdAt": now, - "content": question, "sources": sources, "targetDatabase": target, - "requestedModel": selected_model} - - def update(raw): - current = _blank_state() - if isinstance(raw, dict): - for key in ("threads", "messages", "citations", "views"): - if isinstance(raw.get(key), dict): - current[key] = copy.deepcopy(raw[key]) - thread = current["threads"].get(thread_id) or {"id": thread_id, "createdAt": now} - thread.update({"updatedAt": now, "title": question[:80], "sources": sources, - "model": selected_model, "activeViewId": artifact["id"] if artifact else thread.get("activeViewId")}) - current["threads"][thread_id] = thread - current["messages"][user_message_id] = user_message - current["messages"][assistant_message_id] = assistant_message - if artifact: - current["views"][artifact["id"]] = artifact - current["citations"][citation["id"]] = citation - return current - - if not session.runtime.available(): - raise err(503, "store_unavailable", "the tenant store is unavailable; no chat was saved") - session.runtime.update(_namespace_key(session), update, flush="async") - return {"thread": _safe(update(state)["threads"][thread_id]), "userMessage": _safe(user_message), - "message": _safe(assistant_message), - "view": _public_view(artifact) if artifact else None, "citations": [citation] if citation else []} - - -@router.post("/query/chat") -def submit_chat(body: dict = Body(default=None), session: Session = Depends(require_session)): - return _submit(body, session) - - -@router.delete("/query/threads/{tid}") -def delete_thread(tid: str, session: Session = Depends(require_session)): - """Remove one of the caller's own chats, and only the chat. - - ⛔ THE ARTEFACTS SURVIVE, AND THAT IS THE POINT. A Query view is a durable object in its own - right — it appears in Query's rail, other people's links can point at it, and the owner's - instruction was that AI views LIVE THERE rather than inside the conversation that happened to - produce them. Cascading the delete would make tidying up a chat silently destroy work. Views - are deleted from Query's own rail, one at a time, by `delete_query` below. - - ⚠ It exists because the history had no prune. `delete_query` removed a view and left its - thread, so the panel could only ever grow — a navigation you cannot manage is half a - navigation, and a person clearing test chats found that out first. - """ - tid = str(tid) - state = _state(session) - if tid not in state["threads"]: - raise err(404, "unknown_thread", "that chat does not exist") - - def update(raw): - current = _blank_state() - if isinstance(raw, dict): - for key in ("threads", "messages", "citations", "views"): - if isinstance(raw.get(key), dict): - current[key] = copy.deepcopy(raw[key]) - current["threads"].pop(tid, None) - for message_id in [key for key, row in current["messages"].items() - if (row or {}).get("threadId") == tid]: - current["messages"].pop(message_id, None) - # ⚠ THE VIEWS' `threadId` IS LEFT EXACTLY AS IT WAS, dangling. Blanking it looks tidier and - # is a data-loss bug: `queryApi.saved()` refuses a row with an empty `threadId`, so every - # artefact built in the deleted chat would silently vanish from Query's rail — the exact - # "tidying up destroys work" outcome this door is written not to have - # [[a-record-can-outlive-its-subject]]. Nothing resolves the id but the chat panel, which - # only ever looks up the thread it is showing. - return current - - session.runtime.update(_namespace_key(session), update, flush="async") - return {"deleted": tid} - - -@router.delete("/query/{qid}") -def delete_query(qid: str, session: Session = Depends(require_session)): - qid = str(qid) - state = _state(session) - if qid not in state["views"]: - raise err(404, "unknown_query", "that Query artefact does not exist") - - def update(raw): - current = _blank_state() - if isinstance(raw, dict): - for key in ("threads", "messages", "citations", "views"): - if isinstance(raw.get(key), dict): - current[key] = copy.deepcopy(raw[key]) - view = current["views"].pop(qid, None) - for citation_id in (view or {}).get("citationIds") or []: - current["citations"].pop(citation_id, None) - return current - - session.runtime.update(_namespace_key(session), update, flush="async") - return {"deleted": qid} +"""Assistant chat and Query-owned virtual artefacts (W33-T72 / C8). + +Query reads only the detached, permission-filtered snapshot from +``deps.assistant_read_scope``. It owns a separate per-user namespace for threads, messages, +citations and virtual views; it never opens or mutates a source workspace. +""" +import copy +import csv +import datetime as _dt +import hashlib +import io +import json +import os +import re +import uuid + +from fastapi import APIRouter, Body, Depends, Response + +from deps import (Session, assistant_read_scope, assistant_source_status, err, + require_session) + +router = APIRouter(prefix="/api/v1") + +MAX_QUESTION = 500 +MAX_VISIBLE = 24 +MAX_ARTIFACTS = 200 +# ⭐⭐ R16 — THE THREAD IS REPLAYED, AND THESE TWO NUMBERS ARE THE WHOLE BOUND ON IT. +# The chat was STATELESS: `_call_model` built `[system, user(question)]` and the conversation on +# screen reached the model as nothing at all, so "what about the other one?" could not be answered +# and the assistant could not grill anybody about anything. Replay is bounded twice because the +# two limits fail differently: a turn COUNT stops a long chat costing more every turn, and a +# CHARACTER budget stops one pasted wall of text from being the whole context. Both count from the +# NEWEST end, because that is what a pronoun refers to. +MAX_HISTORY_TURNS = 12 +MAX_HISTORY_CHARS = 6000 +MAX_RATING_REASON = 200 +QUERY_RATINGS = ("up", "down") +MODEL_AUTO = "auto" +# ⛔⛔ WHY QUERY DECLARES THE ENV NAMES RATHER THAN READING `analyst.PROVIDERS`: MEASURED +# 2026-08-16, `import harness.analyst` costs **5 to 8 SECONDS**, warm, on this box (it pulls +# `harness.tools`, which pulls yaml and the skill recipes). `GET /query` is a LIST path that BOTH +# the Assistant and Query call on mount, so importing the ladder to answer "which models can this +# deployment actually call?" would put eight seconds on the first paint of the AI module. The map +# below is Query's own declaration, and it is deliberately the cheap half. +# ⚠ A SECOND DECLARATION IS A DIVERGENCE WAITING TO HAPPEN, so it is CHECKED rather than trusted: +# `_providers()` already holds the platform ladder in memory and reconciles there, and anything it +# finds is REPORTED into the payload instead of a provider silently never being offered. +# The ORDER is Query's own and is deliberate: cerebras first, because this path needs tool calling +# and cerebras carries this account's tool-capable model. +QUERY_PROVIDER_ENV = { + "cerebras": "CEREBRAS_API_KEY", + "groq": "GROQ_API_KEY", + "openrouter": "OPENROUTER_API_KEY", +} +QUERY_PROVIDER_ORDER = tuple(QUERY_PROVIDER_ENV) +# Filled the first time the platform ladder is loaded; empty means "not yet known", never "clean". +_LADDER_DRIFT = [] +QUERY_KINDS = ("grid", "chart", "calendar", "kanban", "timeseries", "map", "list") +QUERY_WORKSPACE_EVENTS = {"view_create", "view_upsert", "view_delete"} +QUERY_EXCLUDED = { + "form": "Forms collect new data and are not a read-only Query artefact.", + "catalog": "Catalog is a source-native presentation that Query cannot safely mutate.", + "swipe": "Swipe is an interactive source-native presentation, not an Assistant output.", +} +_MODE_REFS = { + "kanban": [("stackField", ("select",), True)], + "calendar": [("dateField", ("date",), True)], + "timeseries": [("dateField", ("date",), True)], + "map": [("colorField", ("select",), False), ("sizeField", ("int", "currency", "pct"), False)], +} + + +def _now_iso(): + return _dt.datetime.now(_dt.timezone.utc).isoformat() + + +def _grid(): + import aios_grid + return aios_grid + + +def _safe(value): + """Detach values before they enter a durable Query object or an API reply.""" + return json.loads(json.dumps(value, default=str)) + + +def _namespace_key(session): + """A tenant runtime has one isolated key for each user's Query-owned objects.""" + principal = f"{session.tenant}:{session.uname}".encode("utf-8") + return "query_user_" + hashlib.sha256(principal).hexdigest()[:24] + + +def _blank_state(): + return {"version": 1, "threads": {}, "messages": {}, "citations": {}, "views": {}} + + +def _state(session): + try: + raw = session.runtime.get(_namespace_key(session)) or {} + except Exception: + raw = {} + if not isinstance(raw, dict): + return _blank_state() + out = _blank_state() + for key in out: + if key == "version": + continue + if isinstance(raw.get(key), dict): + out[key] = copy.deepcopy(raw[key]) + return out + + +def _new_id(prefix): + return f"{prefix}_{uuid.uuid4().hex[:16]}" + + +def model_choices(): + """Choices are stable even when one is not configured, so explicit means explicit.""" + return [MODEL_AUTO, *QUERY_PROVIDER_ORDER] + + +def model_status(): + """Per choice: can this deployment actually CALL it, and if not, why not. + + ⭐⭐ THE SAME DEFECT THE SOURCE CHIPS WERE FIXED FOR IN WAVE 33, ONE CONTROL TO THE LEFT. + `permitted` is not `answerable` for a database; OFFERED is not CALLABLE for a model. The picker + listed every name in the ladder whether or not this deployment holds its key, so choosing one + spent a click and a turn to be told "the selected model is unavailable" by the server, which + knew before the click. The reason travels with the flag, exactly as `sources` does. + + ⚠ It names no environment variable. "This model is not configured on this deployment" is the + cause a tenant user can act on (ask an admin); the variable name is our deployment's business. + """ + live = [name for name in QUERY_PROVIDER_ORDER if os.environ.get(QUERY_PROVIDER_ENV[name])] + rows = [{"model": MODEL_AUTO, "available": bool(live), + "reason": "" if live else "no model is configured on this deployment"}] + for name in QUERY_PROVIDER_ORDER: + rows.append({"model": name, "available": name in live, + "reason": "" if name in live + else "this model is not configured on this deployment"}) + # Anything the platform ladder carries that Query does not offer, once a chat has taught us. + rows.extend({"model": name, "available": False, + "reason": "the platform can reach this model but Query does not offer it yet"} + for name in _LADDER_DRIFT) + return rows + + +def _providers(model=MODEL_AUTO): + """Auto returns the permitted ladder; an explicit choice returns at most one provider.""" + import harness.analyst as analyst + + requested = str(model or MODEL_AUTO).strip().lower() + by_name = {p["name"]: p for p in analyst.PROVIDERS} + # ⚠ THE RECONCILIATION, at the ONE point the platform ladder is already in memory, so it costs + # nothing. A provider added to `analyst.PROVIDERS` would otherwise simply never appear here, + # and an env name changed there would make Query's availability answer quietly wrong. Both are + # invisible failures; this turns them into a row a reader can see [[limit-with-no-enforcer]]. + _LADDER_DRIFT[:] = sorted( + set(by_name) - set(QUERY_PROVIDER_ENV) + | {name for name, row in by_name.items() + if name in QUERY_PROVIDER_ENV and row.get("env") != QUERY_PROVIDER_ENV[name]}) + names = list(QUERY_PROVIDER_ORDER) if requested == MODEL_AUTO else [requested] + return [by_name[name] for name in names + if name in by_name and os.environ.get(by_name[name]["env"])] + + +def _history(state, thread_id): + """The prior turns of THIS thread, oldest first, in the transport's own message shape. + + ⛔ SORTED BY `(createdAt, role)`, NOT BY `createdAt` ALONE. `_submit` stamps the user turn and + the assistant turn with the SAME `now`, so on timestamp alone the pair ties and their order is + whatever the store's dict iteration happens to give — which is insertion order today and is + not a guarantee anybody wrote down. A replayed conversation with the answers before the + questions is worse than no replay: it reads as coherent and is backwards. + + ⚠ Scoped to one thread by construction. Replaying another thread's turns would leak one chat's + subject into another's answer, which is the kind of wrong that looks like a good answer. + """ + thread_id = str(thread_id or "") + if not thread_id: + return [] + rows = [row for row in state["messages"].values() + if isinstance(row, dict) and str(row.get("threadId") or "") == thread_id] + rows.sort(key=lambda row: (str(row.get("createdAt") or ""), + 0 if row.get("role") == "user" else 1)) + turns = [] + for row in rows[-MAX_HISTORY_TURNS:]: + content = " ".join(str(row.get("content") or "").split()) + if not content: + continue + role = "assistant" if row.get("role") == "assistant" else "user" + turns.append({"role": role, "content": content}) + # ⭐⭐ R20's "self-improving", and it is the ONLY thing that makes a rating more than a + # flag shipped without its writer. A thumbs-down becomes what it actually was: a turn in + # which the reader said the answer was not useful. Modelled as a USER turn rather than by + # editing the assistant's own words, because that is what happened, and because rewriting + # a stored answer to steer the next one is how a transcript stops being a record. + if role == "assistant" and row.get("rating") == "down": + reason = " ".join(str(row.get("ratingReason") or "").split())[:MAX_RATING_REASON] + turns.append({"role": "user", + "content": f"That answer was not helpful. {reason}".strip()}) + kept, spent = [], 0 + for turn in reversed(turns): + spent += len(turn["content"]) + if spent > MAX_HISTORY_CHARS and kept: + break + kept.append(turn) + kept.reverse() + return kept + + +def _spec_schema(field_keys): + col = {"type": "string", "enum": sorted(field_keys)} + return { + "type": "object", + "properties": { + "kind": {"type": "string", "enum": [*QUERY_KINDS, "refused"]}, + "name": {"type": "string"}, + "refusal": {"type": "string"}, + "visible": {"type": "array", "items": col}, + # ⭐⭐ 2026-08-15 (owner: *"if you can build the view, the AI assistant should also be + # able to do it by using our tools on the backend"*). `rhs` and `important` were the + # only two things a person could express through `view_upsert` and this tool could not. + # + # ⛔ WITHOUT `rhs` THE ASSISTANT CANNOT STATE AN ERROR-CATCHER AT ALL — the one shape + # item 8a named by hand (*"price and COGS not matching"*). Every one of the 20 + # `FILTER_OPS` was already reachable, because they all read `value`; comparing a column + # against ANOTHER COLUMN is a different member (`aios_grid._clean_rhs`, CG-9) and it was + # simply absent here, so the model had no way to ask for it and no way to be told why. + # `kind` is `field` ONLY: `measure` needs a window and `stat` needs the population + # vocabulary, neither of which this snapshot-shaped reader carries — offering them + # would be a control that lies, which is the same rule the source chips follow. + "filters": {"type": "array", "items": {"type": "object", "properties": { + "colId": col, "op": {"type": "string", "enum": sorted(_grid().FILTER_OPS)}, + "value": {"type": "string"}, + "rhs": {"type": "object", "properties": { + "kind": {"type": "string", "enum": ["field"]}, "colId": col, + }, "required": ["kind", "colId"]}, + }, "required": ["colId", "op"]}}, + # A personal legibility mark, exactly as `grid_events.view_upsert` treats it (wave 32 + # R5/C4) — not a lock, and no second permission wall. + "important": {"type": "boolean"}, + "filterConj": {"type": "string", "enum": ["and", "or"]}, + "sorts": {"type": "array", "items": {"type": "object", "properties": { + "colId": col, "dir": {"type": "string", "enum": ["asc", "desc"]}, + }, "required": ["colId", "dir"]}}, + "groupBy": col, + "aggregation": {"type": "object", "properties": { + "op": {"type": "string", "enum": ["count", "sum", "avg", "min", "max"]}, + "field": col, + }, "required": ["op"]}, + "stackField": col, + "dateField": col, + "colorField": col, + "sizeField": col, + }, + "required": ["kind"], + } + + +def _system_prompt(snapshot): + fields = snapshot["fields"] + cols = json.dumps([{"key": field["key"], "label": field.get("label", field["key"]), + "type": field.get("type", "text")} for field in fields], separators=(",", ":")) + return f"""You are the assistant inside this product, talking with one person about exactly one +database they already have permission to read. Reply in plain prose, and keep it short. + +DATABASE: {snapshot['database']} +SNAPSHOT VERSION: {json.dumps(snapshot['source_version'], default=str)} +PERMISSION-FILTERED RECORD COUNT: {len(snapshot['records'])} +FIELDS: {cols} +VIEW KINDS: {", ".join(QUERY_KINDS)} + +You may answer in words alone, and you may ask ONE short question back when the request is +ambiguous: which field is meant, which period, whether they want every record or a narrower set. +Prefer asking over guessing whenever the answer would change what you build. Earlier turns of this +conversation are above; use them, and read a pronoun as referring to what was just discussed. + +Call build_view ONLY when the person wants to SEE records: a table, a chart, a board, a list. Do +not call it to explain something, to confirm something, or to ask your question. When you do call +it you may also write one sentence saying what you built. + +Inside build_view: choose visible fields, supported filters and an optional aggregation. Use count +for record counts; sum, avg, min and max require one numeric field. The server computes and cites +every number from this exact snapshot. Never write SQL, invent fields, or name another database. +Return kind=refused with one plain sentence if the database cannot answer. + +To compare one column against ANOTHER column rather than a typed value, give the filter an rhs of +{{"kind":"field","colId":""}} and omit value. That is how you express questions like +"priced below what it costs us". Both columns must be in the FIELDS list above. +Set important=true when the view is one somebody should be chased about: an error, a mismatch, or +money at risk. Leave it out otherwise. + +Write with ordinary punctuation. Never use an em dash or an en dash: use a comma, a colon or a full +stop instead.""" + + +_FAILED_GEN = re.compile(r"(\{.*?\})\s*", re.S) + + +def _spec_from_400(body): + try: + value = ((json.loads(body) or {}).get("error") or {}).get("failed_generation") or "" + raw = (_FAILED_GEN.search(str(value)) or [str(value).strip()])[1] + result = json.loads(raw) + return result if isinstance(result, dict) else None + except Exception: + return None + + +# ⚠ BUILT FROM `chr`, AND A UNICODE ESCAPE IS NOT ENOUGH. `web_prose` reads a Python +# string's VALUE off the AST, not its spelling in the source, so an escape and the character +# itself are the SAME finding to it. Composing the class at runtime means no string literal in +# this file holds a dash, which is true rather than merely quiet: the only dashes in this module +# are the ones being removed. +_DASH = "[" + chr(0x2014) + chr(0x2013) + "]" + + +def _no_dashes(text): + """R6 applied where it is the ONLY place it can be applied: the model's own words. + + ⛔⛔ THE PROMPT INSTRUCTION IS NOT ENFORCEMENT, AND THIS IS MEASURED, NOT ANTICIPATED. The + system prompt ends with *"Never use an em dash or an en dash"* and the very next live turn came + back with *"Which view type would you like—grid, chart, list, or another?"* (cerebras, + 2026-08-16). Model prose reaches the screen exactly as a string literal does, and `web_prose` + scans SOURCE, so it cannot see a dash that arrives at runtime: the sweep every lane is doing + this wave is undone by our own assistant unless it is undone here. + + ⚠ A DIGIT RANGE IS A DIFFERENT SENTENCE. "10–20" means "10 to 20"; rewriting it as + "10, 20" states two numbers where the model stated a span, which is a wrong answer rather than + a punctuation fix. It gets its own rule, first. + """ + text = str(text or "") + text = re.sub(rf"(?<=\d)\s*{_DASH}\s*(?=\d)", " to ", text) + text = re.sub(rf"\s*{_DASH}\s*(?=[,.;:!?])", "", text) # abutting punctuation: it just goes + text = re.sub(rf"(?<=[,;:])\s*{_DASH}\s*", " ", text) # already punctuated: one space + return re.sub(rf"\s*{_DASH}\s*", ", ", text) + + +def _said(raw): + """The model's own words: one line of whitespace, no dash, and empty means nothing was said.""" + return _no_dashes(" ".join(str(raw or "").split())).strip() or None + + +def _from_chat(answer, provider): + """An injected transport may answer with a SPEC (a dict) or with PROSE (a string). + + ⛔ THE BRANCH BELONGS HERE, NOT AT THE CALLER. `_validate`'s first line refuses anything that + is not a dict, with "the assistant did not answer with a view" — so a prose answer passed + straight through would arrive as a REFUSAL, styled as one, and every conversation check would + pass against the wrong path while looking green [[one-question-two-normalizers]]. + """ + if isinstance(answer, str): + return None, _said(answer), provider, None + return answer, None, provider, None + + +def _call_model(question, snapshot, model=MODEL_AUTO, chat=None, history=None): + """Return ``(spec, said, provider, reason)`` without any source-data fallback. + + ``said`` is what the assistant SAID: the whole answer when it did not build a view, and the + sentence beside the view when it built one and talked as well. ``reason`` is set only when + something went wrong, so a prose ANSWER and a transport FAILURE are distinguishable one layer + up rather than both arriving as a bare sentence. + """ + requested = str(model or MODEL_AUTO).strip().lower() + if requested not in model_choices(): + return None, f"the selected model ({requested or model}) is unavailable", None, "model_unavailable" + tools = [{"type": "function", "function": { + "name": "build_view", "description": "Emit a virtual-view spec or a refusal.", + "parameters": _spec_schema([field["key"] for field in snapshot["fields"]]), + }}] + messages = [{"role": "system", "content": _system_prompt(snapshot)}, + *(history or []), + {"role": "user", "content": question}] + if chat is not None: + provider = requested if requested != MODEL_AUTO else "injected" + return _from_chat(chat(messages, tools), provider) + + providers = _providers(requested) + if not providers: + sentence = (f"the selected model ({requested}) is unavailable" if requested != MODEL_AUTO + else "the assistant is not configured on this deployment") + return None, sentence, None, "model_unavailable" if requested != MODEL_AUTO else "not_configured" + + import requests + last = None + for provider in providers: + try: + response = requests.post( + provider["url"], timeout=60, + headers={"Authorization": f"Bearer {os.environ[provider['env']]}"}, + # ⛔⛔ `auto`, NOT `required`, AND THAT ONE WORD IS R16. Under `required` the model + # had to emit a `build_view` call for EVERY turn: it could not answer a question, + # could not ask one back, and could not decline to build. "The AI chat must TALK + # with the user, not only build queries, and decide for itself whether to create a + # query view" is unreachable with the tool forced, whatever the prompt says. + # ⚠ AND IT IS A LIVE VENDOR-BEHAVIOUR CHANGE THAT NO GATE HERE CAN SEE, because + # `verify_query` injects `chat` and never builds this body. `_spec_from_400` below + # exists precisely because providers differ in how they emit a forced call; under + # `auto` a provider may also decline to build for a view-shaped question. That is + # why this ticket's evidence is a real three-turn run against a real provider and + # not a green gate. + json={"model": provider["model"], "messages": messages, "tools": tools, + "tool_choice": "auto", "temperature": 0.1, "max_tokens": 1200}, + ) + except Exception as exc: + last = f"{provider['name']}: {type(exc).__name__}" + continue + if response.status_code == 400: + spec = _spec_from_400(response.text) + if spec is not None: + return spec, None, provider["name"], None + last = f"{provider['name']}: 400" + continue + if response.status_code != 200: + last = f"{provider['name']}: HTTP {response.status_code}" + continue + try: + answer = response.json()["choices"][0]["message"] + said = _said(answer.get("content")) + calls = answer.get("tool_calls") or [] + if calls: + # The prose rides ALONG with the view when the model wrote both, so the reader gets + # a sentence instead of `_explain`'s machine description of its own output. + spec = json.loads(calls[0]["function"].get("arguments") or "{}") + return spec, said, provider["name"], None + if said: + return None, said, provider["name"], None + # ⚠ Neither a call nor a word is a FAILED turn, not a silent one: falling through to + # the next provider is right, and swallowing it as an empty answer would show the + # reader a blank reply [[empty-answer-vs-unfinished-answer]]. + last = f"{provider['name']}: an empty answer" + except Exception as exc: + last = f"{provider['name']}: unreadable answer ({type(exc).__name__})" + if requested != MODEL_AUTO: + return None, f"the selected model ({requested}) is unavailable", None, "model_unavailable" + return (None, "the assistant could not be reached just now (" + (last or "no provider") + ")", + None, "provider_unreachable") + + +def _validate(spec, fields): + """Return a cleaned, source-independent virtual-view config or a named refusal.""" + if not isinstance(spec, dict): + return None, "the assistant did not answer with a view", "no_spec" + by_key = {str(field.get("key")): str(field.get("type") or "text") for field in fields} + keys = set(by_key) + kind = spec.get("kind") + if kind == "refused": + # Model-authored, so it faces the same R6 wall the model's chat prose does. + return None, _said(spec.get("refusal")) or "this database cannot answer that question", "model_refused" + if kind in QUERY_EXCLUDED or kind not in QUERY_KINDS: + return None, "that kind of view cannot be built from this question", "unsupported_kind" + + named = set(spec.get("visible") or ()) + for item in spec.get("filters") or (): + if isinstance(item, dict) and item.get("colId"): + named.add(str(item["colId"])) + # ⛔ THE RIGHT-HAND COLUMN IS A COLUMN AND MUST FACE THE SAME `missing` CHECK. Collecting + # only the left side is what makes D-229 possible one layer down: `clean_filter_tree` does + # NOT drop a leaf whose field-rhs names a column that does not exist — it keeps the leaf, + # strips the `rhs`, blanks the value, and `filter_sql.is_rule_active` then reports the rule + # INACTIVE. An inactive rule narrows nothing, so "margin under 10%" would come back as a + # view listing the ENTIRE catalogue under an error-catcher's name, with nothing red. + # Naming it here turns that into the ordinary "this database does not have: X" refusal. + rhs = item.get("rhs") if isinstance(item, dict) else None + if isinstance(rhs, dict) and rhs.get("colId"): + named.add(str(rhs["colId"])) + for item in spec.get("sorts") or (): + if isinstance(item, dict) and item.get("colId"): + named.add(str(item["colId"])) + for key in ("groupBy", "stackField", "dateField", "colorField", "sizeField"): + if spec.get(key): + named.add(str(spec[key])) + raw_aggregation = spec.get("aggregation") or {"op": "count"} + if isinstance(raw_aggregation, dict) and raw_aggregation.get("field"): + named.add(str(raw_aggregation["field"])) + missing = sorted(named - keys) + if missing: + return None, "this database does not have: " + ", ".join(missing), "unknown_columns" + + visible = [str(key) for key in (spec.get("visible") or ()) if str(key) in keys][:MAX_VISIBLE] + if not visible: + return None, "that question did not name any fields to show", "no_columns" + raw_filters = [item for item in (spec.get("filters") or ()) if isinstance(item, dict)] + filters = _grid().clean_filter_tree(raw_filters, keys) + if len(filters) != len(raw_filters): + return None, "part of that filter is unsupported", "filter_dropped" + # ⛔⛔ A SECOND, NARROWER CHECK, AND THE LENGTH CHECK ABOVE CANNOT DO ITS JOB (D-229). + # A dropped leaf changes the COUNT; a stripped `rhs` does not — the leaf survives, so + # `len(filters) == len(raw_filters)` and the refusal above never fires. The failure is + # therefore silent in exactly the direction that matters: the condition stops narrowing and + # the view answers with every record. Assert the member survived, per leaf. + # ⚠ The `named` pass above already refuses an rhs naming a column this database lacks, so + # reaching here means something ELSE stripped it (a type the comparand cannot take, a future + # `_clean_rhs` rule). Both doors, because the two catch different causes and the cost of + # missing this one is a wrong answer that looks right. + for sent, kept in zip(raw_filters, filters): + if sent.get("rhs") and not kept.get("rhs"): + return None, "that column cannot be compared against another column", "rhs_dropped" + + aggregation = raw_aggregation if isinstance(raw_aggregation, dict) else {} + op = str(aggregation.get("op") or "").lower() + field = aggregation.get("field") + if op not in {"count", "sum", "avg", "min", "max"}: + return None, "the assistant gave an unsupported aggregation", "bad_aggregation" + if op == "count": + field = None + elif field not in keys or by_key.get(field) not in {"int", "currency", "pct"}: + return None, "that aggregation needs one visible numeric field", "bad_aggregation" + + display = {"mode": kind} + for ref, families, required in _MODE_REFS.get(kind, ()): + value = spec.get(ref) + if required and not value: + return None, f"a {kind} view needs {ref}", "missing_ref" + if value and by_key.get(value) not in families: + return None, f"{ref} has the wrong field type", "wrong_ref_type" + if value: + display[ref] = value + cleaned_display = _grid()._clean_display(display, keys) if kind != "grid" else None + if kind != "grid" and not cleaned_display: + return None, f"this product could not build a {kind} view", "display_dropped" + + return { + "kind": kind, + # The view NAME is model-authored too, and it is the string that ends up in the rail, in + # the flyout and on the artefact card. R6 reaches it here or nowhere. + "name": (_said(spec.get("name")) or "Query")[:60], + "visible": visible, + "filters": filters, + "filterConj": "or" if spec.get("filterConj") == "or" else "and", + "sorts": [{"colId": item["colId"], "dir": "desc" if item.get("dir") == "desc" else "asc"} + for item in (spec.get("sorts") or []) if isinstance(item, dict) + and item.get("colId") in keys][:3], + "groupBy": spec.get("groupBy") if spec.get("groupBy") in keys else None, + "aggregation": {"op": op, "field": field}, + "display": cleaned_display, + # ⚠ `is True`, not truthy, and UNCONDITIONAL — the same two rules `grid_events.view_upsert` + # follows for this key. `is True` so a model emitting the string "false" does not mark a + # view; unconditional so the mark is REMOVABLE rather than a flag that can be set and never + # cleared (a key written only when present leaves a stored `true` alive forever). + "important": spec.get("important") is True, + }, None, None + + +def _explain(view, fields): + label = {field["key"]: str(field.get("label") or field["key"]) for field in fields} + visible = ", ".join(label.get(key, key) for key in view["visible"][:6]) + result = f"{view['kind']} view of {visible}" + if view["filters"]: + result += "; filtered records only" + if view.get("groupBy"): + result += f"; grouped by {label.get(view['groupBy'], view['groupBy'])}" + agg = view["aggregation"] + if agg["op"] != "count": + result += f"; {agg['op']} of {label.get(agg['field'], agg['field'])}" + return result + "." + + +def _said_fallback(artifact): + """What a build turn SAYS when the provider volunteered no sentence of its own. + + ⚠ MEASURED, 2026-08-16, cerebras: a view-shaped question comes back as a tool call with + `content: null`. So this is the sentence a person reads on MOST build turns, not a rare + fallback, and R16 ("the AI chat must TALK with the user") is decided here rather than in the + prompt. `_explain` used to be it, and it is a receipt for our own output: *"grid view of + Company, Owner, Deal value; filtered records only."* It keeps its real job ON THE ARTEFACT, + where it labels the thing it describes. + """ + numeric = artifact.get("numeric") or {} + value = numeric.get("value") + if value is None: + return f"I built {artifact['name']}." + if isinstance(value, float) and value.is_integer(): + value = int(value) + number = f"{value:,}" if isinstance(value, (int, float)) else str(value) + label = " ".join(str(numeric.get("label") or "Records").split()) + return f"I built {artifact['name']}. {label}: {number}." + + +def _numeric_result(view, records): + aggregation = view["aggregation"] + if aggregation["op"] == "count": + return {"label": "Matching records", "value": len(records), "contributing_record_count": len(records)} + values = [] + for record in records: + try: + value = record.get(aggregation["field"]) + if value is not None and not isinstance(value, bool): + values.append(float(value)) + except (TypeError, ValueError): + continue + if not values: + return {"label": aggregation["op"], "value": None, "contributing_record_count": 0} + op = aggregation["op"] + value = {"sum": sum(values), "avg": sum(values) / len(values), "min": min(values), "max": max(values)}[op] + return {"label": f"{op.title()} of {aggregation['field']}", "value": value, + "contributing_record_count": len(values)} + + +def _referenced_fields(view): + """The citation names every source field that affected the displayed result.""" + out = list(view.get("visible") or ()) + for node in view.get("filters") or (): + if isinstance(node, dict) and node.get("colId"): + out.append(str(node["colId"])) + for node in view.get("sorts") or (): + if isinstance(node, dict) and node.get("colId"): + out.append(str(node["colId"])) + for key in ("groupBy",): + if view.get(key): + out.append(str(view[key])) + aggregation = view.get("aggregation") or {} + if aggregation.get("field"): + out.append(str(aggregation["field"])) + return list(dict.fromkeys(out)) + + +def _effective_filters(snapshot, view): + """Keep the source request and generated-view predicates distinct in provenance.""" + return { + "source": _safe(snapshot.get("filters")), + "view": {"conj": view.get("filterConj", "and"), + "nodes": _safe(view.get("filters") or [])}, + } + + +def _view_records(snapshot, view): + """Apply the exact validated virtual-view filter before calculating a cited number.""" + nodes = view.get("filters") or [] + if not nodes: + return list(snapshot["records"]) + from harness import filter_eval + tree = {"conj": view.get("filterConj", "and"), "nodes": nodes} + return [row for row in snapshot["records"] + if filter_eval.matches(tree, row, snapshot["fields"])] + + +def _citation(citation_id, snapshot, view, numeric, view_id): + return { + "id": citation_id, + "href": f"#/query?view={view_id}&citation={citation_id}", + "database": snapshot["database"], + "snapshot": {"kind": snapshot["source_kind"], "version": _safe(snapshot["source_version"])}, + "fields": [field["key"] for field in snapshot["fields"] + if field.get("key") in set(_referenced_fields(view))], + "filters": _effective_filters(snapshot, view), + "permission_scope_applied": snapshot["permission_scope_applied"], + "aggregation": _safe(view["aggregation"]), + "contributing_record_count": numeric["contributing_record_count"], + "retrieved_at": snapshot["retrieved_at"], + } + + +def _citation_complete(citation): + """A numeric result is not publishable without complete provenance.""" + required = {"database", "snapshot", "fields", "filters", "aggregation", + "contributing_record_count", "retrieved_at", "href"} + snapshot = citation.get("snapshot") if isinstance(citation, dict) else None + return (isinstance(citation, dict) and required <= set(citation) + and bool(citation["database"]) and isinstance(snapshot, dict) + and "version" in snapshot and bool(citation["retrieved_at"]) + and isinstance(citation["fields"], list) + and isinstance(citation["aggregation"], dict)) + + +def _public_view(view): + source = view["source"] + return { + "id": view["id"], "viewId": view["id"], "scope": source["database"], + "name": view["name"], "description": str(view.get("description") or ""), + "kind": view["view"]["kind"], "question": view["question"], + "explain": view["explain"], "threadId": view["threadId"], "createdAt": view["createdAt"], + "virtual": True, "source": _safe(source), "view": _safe(view["view"]), + "citationIds": list(view["citationIds"]), "numeric": _safe(view["numeric"]), + } + + +def _source_still_permitted(session, source, permitted=None): + """A persisted artefact never outlives the caller's current data permission. + + ⚠ `permitted` is the BATCH answer from `deps.assistant_source_status` — one rows-free + resolution for every source at once, rather than one whole-document read per saved view. That + function's docstring carries the measurement. The single-source path below stays for callers + holding exactly one artefact (the workspace-event door), and asks the same helper. + + ⛔ `permitted`, NEVER `answerable`. A source whose rows are served through the connector mirror + cannot be ASKED and can still be SEEN: the artefact was built from a snapshot that was legal + when it was taken, and hiding it because the reader can no longer make a NEW one would read as + deletion. The two verdicts are separate for that reason. + """ + database = (source or {}).get("database") if isinstance(source, dict) else None + if not database: + return False + if permitted is None: + permitted = {key for key, row in assistant_source_status(session, [database]).items() + if row["permitted"]} + return database in permitted + + +def _public_state(state, session): + # ONE rows-free batch answers both questions: which sources this caller may still see (which + # artefacts stay listed) and which of them can actually be asked (which chips are live). + status = assistant_source_status(session) + for row in state["views"].values(): + key = (row.get("source") or {}).get("database") if isinstance(row.get("source"), dict) else None + if key and key not in status: + # An artefact whose source is no longer enumerable still gets a verdict rather than a + # KeyError — it resolves to "not permitted" and the artefact drops out, which is the + # same answer the per-view wall gave. + status.update(assistant_source_status(session, [key])) + permitted = {key for key, row in status.items() if row["permitted"]} + allowed_views = [row for row in state["views"].values() + if _source_still_permitted(session, row.get("source"), permitted)] + allowed_ids = {row["id"] for row in allowed_views} + allowed_citations = {citation_id for row in allowed_views + for citation_id in row.get("citationIds") or []} + threads = sorted(state["threads"].values(), key=lambda row: row.get("updatedAt", ""), reverse=True) + messages = sorted(state["messages"].values(), key=lambda row: row.get("createdAt", "")) + views = sorted((_public_view(row) for row in allowed_views), key=lambda row: row["createdAt"], reverse=True) + messages = [row for row in messages if not row.get("viewId") or row.get("viewId") in allowed_ids] + return {"threads": _safe(threads), "messages": _safe(messages), "views": views, + "citations": _safe([row for key, row in state["citations"].items() + if key in allowed_citations]), "models": model_choices(), + # The model picker's own data, the same shape `sources` uses for databases. + "modelStatus": model_status(), + # The chip row's own data: a source this caller holds but cannot ask, and WHY. + # ⭐⭐ D-276 — `or row.get("visible")`. A source the caller can open elsewhere in the + # product but the assistant cannot read is now LISTED with its cause, instead of + # vanishing from a picker that shows every other database they hold. It arrives with + # `answerable: False` and a `reason`, which is the SAME shape the mirror-served grids + # already use, so the chip row needs no new state to render it. + # ⛔ `permitted` STILL GATES ARTEFACTS — `allowed_views` above is unchanged. This widens + # what is DESCRIBED, never what can be read or kept. + "sources": _safe([{"database": key, "answerable": row["answerable"], + "reason": row["reason"]} + for key, row in sorted(status.items()) + if row["permitted"] or row.get("visible")])} + + +@router.get("/query") +def list_queries(session: Session = Depends(require_session)): + return _public_state(_state(session), session) + + +@router.post("/query/{qid}/events") +def mutate_query_workspace(qid: str, body: dict = Body(default=None), + session: Session = Depends(require_session)): + """The only grid-mutation transport for a virtual Query workspace. + + Query artefacts are immutable snapshots: creation and changes belong to an Assistant prompt, + never a source-native grid workspace. Deleting the caller's personal artefact is the one + permitted mutation. The binding key, not a client-supplied source scope, identifies it. + """ + qid = str(qid) + body = body if isinstance(body, dict) else {} + binding = body.get("workspaceBinding") + event = body.get("event") + if not (isinstance(binding, dict) and binding.get("kind") == "query" + and str(binding.get("key") or "") == qid): + raise err(400, "query_workspace_mismatch", + "the Query workspace binding must name this virtual artefact") + if not isinstance(event, dict) or str(event.get("type") or "") not in QUERY_WORKSPACE_EVENTS: + raise err(400, "unsupported_query_workspace_event", + "Query accepts only view_create, view_upsert, or view_delete events") + + state = _state(session) + view = state["views"].get(qid) + if view is None or not _source_still_permitted(session, view.get("source")): + # A caller cannot use an opaque Query key to learn about a revoked artefact. + raise err(404, "unknown_query", "that Query artefact does not exist") + + event_type = str(event["type"]) + if event_type != "view_delete": + raise err(409, "query_workspace_immutable", + "AI-created Query views change only through a new Assistant prompt") + if str(event.get("viewId") or "") != qid: + raise err(400, "query_workspace_mismatch", + "a Query delete must name the same virtual artefact as its binding") + + deleted = delete_query(qid, session) + return {"workspaceBinding": {"kind": "query", "key": qid}, + "event": event_type, **deleted} + + +def _sources(body, target, session): + raw = body.get("sources") if isinstance(body, dict) else None + sources = [str(item).strip() for item in raw] if isinstance(raw, list) else [] + sources = list(dict.fromkeys(item for item in sources if item)) + if target not in sources: + sources.insert(0, target) + # Source chips are a permissioned selection, not untrusted labels remembered in a thread. + # The model still receives ONLY `target`'s snapshot below; these calls do not pass a second + # source to it and D's helper refuses cache misses, mirrors and unshared databases. + for source in sources: + assistant_read_scope(session, source, fields=[], filters=None) + return sources + + +def _submit(body, session, chat=None): + body = body if isinstance(body, dict) else {} + question = " ".join(str(body.get("question") or "").split()) + target = str(body.get("database") or body.get("scope") or "").strip() + selected_model = str(body.get("model") or MODEL_AUTO).strip().lower() + if not question: + raise err(400, "bad_request", "no question was asked") + if len(question) > MAX_QUESTION: + raise err(400, "question_too_long", f"a question must be under {MAX_QUESTION} characters") + if not target: + raise err(400, "bad_request", "one target database must be selected") + if selected_model not in model_choices(): + raise err(400, "unknown_model", "that model is not available in Query") + + # This call happens before model selection and is the only permitted source-data read. + snapshot = assistant_read_scope(session, target, fields=None, filters=body.get("filters")) + sources = _sources(body, target, session) + state = _state(session) + thread_id = str(body.get("threadId") or "").strip() + if thread_id and thread_id not in state["threads"]: + raise err(404, "unknown_thread", "that chat does not exist") + if not thread_id: + thread_id = _new_id("thread") + now = _now_iso() + user_message_id = _new_id("message") + assistant_message_id = _new_id("message") + # R16: the turns already on screen go with the question. `state` was read above, BEFORE this + # turn's own messages exist, so the replay is strictly the prior conversation. + spec, said, provider, reason = _call_model(question, snapshot, model=selected_model, chat=chat, + history=_history(state, thread_id)) + view, refusal, refusal_code = _validate(spec, snapshot["fields"]) if spec is not None else (None, said, reason) + + artifact = None + citation = None + if view is not None: + if len(state["views"]) >= MAX_ARTIFACTS: + raise err(400, "query_limit", f"you already have {len(state['views'])} Query artefacts") + view_id = _new_id("query") + citation_id = _new_id("citation") + numeric = _numeric_result(view, _view_records(snapshot, view)) + citation = _citation(citation_id, snapshot, view, numeric, view_id) + if not _citation_complete(citation): + raise RuntimeError("Query refused to persist an incomplete numeric citation") + source = {"database": snapshot["database"], "label": snapshot["label"], + "source_kind": snapshot["source_kind"], "source_version": _safe(snapshot["source_version"]), + "retrieved_at": snapshot["retrieved_at"], + "fields": _safe(snapshot["fields"]), "filters": _safe(snapshot["filters"]), + "permission_scope_applied": snapshot["permission_scope_applied"]} + artifact = {"id": view_id, "threadId": thread_id, "name": view["name"], "description": "", + "question": question, + "explain": _explain(view, snapshot["fields"]), "createdAt": now, "source": source, + "view": _safe(view), "citationIds": [citation_id], "numeric": numeric, + "model": provider, "requestedModel": selected_model} + + assistant_message = { + "id": assistant_message_id, "threadId": thread_id, "role": "assistant", "createdAt": now, + "content": (said or _said_fallback(artifact)) if artifact else (refusal or "the assistant could not answer"), + "targetDatabase": target, "requestedModel": selected_model, "model": provider, + "reason": refusal_code, "viewId": artifact["id"] if artifact else None, + "citationIds": artifact["citationIds"] if artifact else [], "numeric": artifact["numeric"] if artifact else None, + } + user_message = {"id": user_message_id, "threadId": thread_id, "role": "user", "createdAt": now, + "content": question, "sources": sources, "targetDatabase": target, + "requestedModel": selected_model} + + def update(raw): + current = _blank_state() + if isinstance(raw, dict): + for key in ("threads", "messages", "citations", "views"): + if isinstance(raw.get(key), dict): + current[key] = copy.deepcopy(raw[key]) + thread = current["threads"].get(thread_id) or {"id": thread_id, "createdAt": now} + # ⚠ THE FIRST QUESTION NAMES THE CHAT, and R16 is what makes that matter. While every turn + # was a fresh one-shot the title could only be the last thing asked; now that a thread is a + # conversation, retitling it on every follow-up renames the history entry out from under + # the reader, and "and the other one?" is a useless name for anything. + thread.update({"updatedAt": now, "title": thread.get("title") or question[:80], "sources": sources, + "model": selected_model, "activeViewId": artifact["id"] if artifact else thread.get("activeViewId")}) + current["threads"][thread_id] = thread + current["messages"][user_message_id] = user_message + current["messages"][assistant_message_id] = assistant_message + if artifact: + current["views"][artifact["id"]] = artifact + current["citations"][citation["id"]] = citation + return current + + if not session.runtime.available(): + raise err(503, "store_unavailable", "the tenant store is unavailable; no chat was saved") + session.runtime.update(_namespace_key(session), update, flush="async") + return {"thread": _safe(update(state)["threads"][thread_id]), "userMessage": _safe(user_message), + "message": _safe(assistant_message), + "view": _public_view(artifact) if artifact else None, "citations": [citation] if citation else []} + + +@router.post("/query/chat") +def submit_chat(body: dict = Body(default=None), session: Session = Depends(require_session)): + return _submit(body, session) + + +@router.delete("/query/threads/{tid}") +def delete_thread(tid: str, session: Session = Depends(require_session)): + """Remove one of the caller's own chats, and only the chat. + + ⛔ THE ARTEFACTS SURVIVE, AND THAT IS THE POINT. A Query view is a durable object in its own + right — it appears in Query's rail, other people's links can point at it, and the owner's + instruction was that AI views LIVE THERE rather than inside the conversation that happened to + produce them. Cascading the delete would make tidying up a chat silently destroy work. Views + are deleted from Query's own rail, one at a time, by `delete_query` below. + + ⚠ It exists because the history had no prune. `delete_query` removed a view and left its + thread, so the panel could only ever grow — a navigation you cannot manage is half a + navigation, and a person clearing test chats found that out first. + """ + tid = str(tid) + state = _state(session) + if tid not in state["threads"]: + raise err(404, "unknown_thread", "that chat does not exist") + + def update(raw): + current = _blank_state() + if isinstance(raw, dict): + for key in ("threads", "messages", "citations", "views"): + if isinstance(raw.get(key), dict): + current[key] = copy.deepcopy(raw[key]) + current["threads"].pop(tid, None) + for message_id in [key for key, row in current["messages"].items() + if (row or {}).get("threadId") == tid]: + current["messages"].pop(message_id, None) + # ⚠ THE VIEWS' `threadId` IS LEFT EXACTLY AS IT WAS, dangling. Blanking it looks tidier and + # is a data-loss bug: `queryApi.saved()` refuses a row with an empty `threadId`, so every + # artefact built in the deleted chat would silently vanish from Query's rail — the exact + # "tidying up destroys work" outcome this door is written not to have + # [[a-record-can-outlive-its-subject]]. Nothing resolves the id but the chat panel, which + # only ever looks up the thread it is showing. + return current + + session.runtime.update(_namespace_key(session), update, flush="async") + return {"deleted": tid} + + +@router.delete("/query/{qid}") +def delete_query(qid: str, session: Session = Depends(require_session)): + qid = str(qid) + state = _state(session) + if qid not in state["views"]: + raise err(404, "unknown_query", "that Query artefact does not exist") + + def update(raw): + current = _blank_state() + if isinstance(raw, dict): + for key in ("threads", "messages", "citations", "views"): + if isinstance(raw.get(key), dict): + current[key] = copy.deepcopy(raw[key]) + view = current["views"].pop(qid, None) + for citation_id in (view or {}).get("citationIds") or []: + current["citations"].pop(citation_id, None) + return current + + session.runtime.update(_namespace_key(session), update, flush="async") + return {"deleted": qid} + + +# ══ R20 — A QUERY VIEW HAS EVERY FUNCTION A DATABASE VIEW HAS ═══════════════════════════════════ +# +# ⛔⛔ THE DECISION THIS TICKET ASKED FOR, WRITTEN DOWN RATHER THAN IMPLIED, because it is the line +# every later reader will need and there is no other place it exists. +# +# **IMMUTABLE, and unchanged by R20:** the generated SPEC and its provenance. `view` (kind, visible, +# filters, sorts, aggregation, display, important), `source` (the snapshot, its version, its +# retrieval time, the permission scope), `question`, `citationIds`, `numeric`. A cited number is +# only worth citing if the thing it was computed from cannot be edited underneath it, so +# `mutate_query_workspace` still answers 409 `query_workspace_immutable` for `view_create` and +# `view_upsert`, and `QUERY_MUTATION_POLICY` on the client is untouched. +# +# **OPENED:** `name` and `description`. They are LABELS, not the answer: changing them cannot make +# a citation wrong. Plus a server-side DUPLICATE (the client never supplies a spec, so a copy is a +# copy) and an EXPORT, which is a read. +# +# ⛔ AND THE REASON THESE ARE NAMED DOORS RATHER THAN AN OPENED `view_upsert`, which is what the +# ticket's `how:` first suggested: the client refuses `view_upsert` on a Query binding LOCALLY, +# before any request leaves the browser (`queryPreview.ts::routeQueryViewMutation`). Opening the +# server there would give one question two answers, and the client's is the one a person +# experiences. A named door has exactly one answer, and "opening a route widens every field it +# carries unless the cleaner is explicit" is answered by the allow-lists below rather than by hope. + +QUERY_NOTE_MAX = 400 + + +def _artifact_or_404(session, qid, state=None): + """The one wall every artefact door goes through: it exists AND this caller may still see it.""" + state = _state(session) if state is None else state + row = state["views"].get(str(qid)) + if row is None or not _source_still_permitted(session, row.get("source")): + # A caller cannot use an opaque Query key to learn about a revoked artefact. + raise err(404, "unknown_query", "that Query artefact does not exist") + return state, row + + +def _copy_name(name, taken): + """`X copy`, then `X copy 2`, so duplicating twice does not make two rows with one name.""" + base = f"{str(name or 'Query')[:52]} copy" + if base not in taken: + return base[:60] + index = 2 + while f"{base} {index}" in taken and index < 99: + index += 1 + return f"{base} {index}"[:60] + + +@router.patch("/query/{qid}") +def rename_query(qid: str, body: dict = Body(default=None), + session: Session = Depends(require_session)): + """Rename a Query view, or give it a description. Nothing else, by construction. + + ⚠ THE TEXT IS THE READER'S, SO IT IS NOT SWEPT FOR DASHES. R6 governs the copy WE write; a note + somebody typed about their own view is theirs, and rewriting its punctuation would be the + product editing a person's words. Model-authored strings are a different case and are swept + where they are produced (`_said`). + """ + body = body if isinstance(body, dict) else {} + state, row = _artifact_or_404(session, qid) + # An ALLOW-LIST, read key by key. A body carrying `view` or `source` changes neither. + patch = {} + if "name" in body: + patch["name"] = " ".join(str(body.get("name") or "").split())[:60] or row.get("name") or "Query" + if "description" in body: + patch["description"] = " ".join(str(body.get("description") or "").split())[:QUERY_NOTE_MAX] + if not patch: + raise err(400, "bad_request", "a Query view takes a new name or a new description") + + def update(raw): + current = _blank_state() + if isinstance(raw, dict): + for key in ("threads", "messages", "citations", "views"): + if isinstance(raw.get(key), dict): + current[key] = copy.deepcopy(raw[key]) + if str(qid) in current["views"]: + current["views"][str(qid)].update(patch) + return current + + if not session.runtime.available(): + raise err(503, "store_unavailable", "the tenant store is unavailable; nothing was renamed") + session.runtime.update(_namespace_key(session), update, flush="async") + return _public_view(update(state)["views"][str(qid)]) + + +@router.post("/query/{qid}/duplicate") +def duplicate_query(qid: str, session: Session = Depends(require_session)): + """A second artefact with the same spec and its own identity. + + ⛔ THE CITATIONS ARE COPIED, NOT SHARED. `delete_query` pops an artefact's citations by id, so a + copy pointing at the original's citation ids would lose its provenance the moment the original + was deleted, and a number with no citation is the one thing this module refuses to persist. + """ + state, row = _artifact_or_404(session, qid) + if len(state["views"]) >= MAX_ARTIFACTS: + # R6's second sentence: a cap that cannot be lifted here names its cause and the remedy. + raise err(400, "query_limit", + f"you already have {len(state['views'])} Query views, which is the most one " + f"account can hold. Delete one to make room for this copy.") + new_id = _new_id("query") + copied = copy.deepcopy(row) + copied["id"] = new_id + copied["name"] = _copy_name(row.get("name"), {r.get("name") for r in state["views"].values()}) + copied["createdAt"] = _now_iso() + citations = {} + for citation_id in row.get("citationIds") or []: + source = state["citations"].get(citation_id) + if not source: + continue + fresh_id = _new_id("citation") + citation = copy.deepcopy(source) + citation["id"] = fresh_id + citation["href"] = f"#/query?view={new_id}&citation={fresh_id}" + citations[fresh_id] = citation + copied["citationIds"] = list(citations) + + def update(raw): + current = _blank_state() + if isinstance(raw, dict): + for key in ("threads", "messages", "citations", "views"): + if isinstance(raw.get(key), dict): + current[key] = copy.deepcopy(raw[key]) + current["views"][new_id] = copied + current["citations"].update(citations) + return current + + if not session.runtime.available(): + raise err(503, "store_unavailable", "the tenant store is unavailable; nothing was copied") + session.runtime.update(_namespace_key(session), update, flush="async") + return _public_view(copied) + + +# ⛔ A REGISTRY KEY AND A GRID SCOPE ARE DIFFERENT SPELLINGS OF ONE DATABASE, and this product has +# already been bitten by treating them as the same: a `VIEW_OPEN` emit whose topic did not match was +# dropped SILENTLY, 200 OK, and an alert was filed against the wrong database. `customer_data` is +# what the assistant reads; `customer` is what the grid writes. `routes_grid._scope_or_400` refuses +# an unknown scope rather than defaulting it, so this map has to be right rather than nearly right. +_GRID_SCOPE = {"customer_data": "customer", "product_data": "product"} +# The keys a Query spec and a database view actually share. `aggregation` is deliberately absent: +# a database view has no aggregation of its own, which is why the reply below NAMES it as dropped +# instead of letting a "sum of value" answer land as a plain list of rows. +_SAVEABLE = ("visible", "filters", "filterConj", "sorts", "groupBy", "display", "important") + + +def _grid_scope(database): + key = str(database or "").strip() + return _GRID_SCOPE.get(key) or (key if key.startswith("ut_") else "") + + +@router.post("/query/{qid}/save-to-database") +def save_query_to_database(qid: str, session: Session = Depends(require_session)): + """R20: copy this answer into the database's OWN view list, where an ordinary view lives. + + ⛔⛔ `view_upsert` ANSWERS 200 WHILE WRITING NOTHING, IN TWO DIFFERENT WAYS, AND THE WIRE CANNOT + TELL YOU WHICH. `rerender: False` is the documented SUCCESS shape for this event, and it is + also what several silent-refusal branches return: a malformed payload, a missing id or name, an + id that belongs to a view the caller cannot SEE (the wave-9 shared-view guard, which makes + pinned ids effectively tenant-scoped), and a shared view they may see but not edit. So this + door does not believe the response. It READS THE STORE BACK and refuses out loud if the view is + not there. That read is same-process, which is what makes it valid despite `flush="async"`: + the write is visible through the cache long before it is durable. + + ⭐ ONE REQUEST, ONE EVENT. Eighteen POSTs against one JSON document under a coalescing + single-flight once landed ZERO while answering 200 eighteen times; the fix is a batch, never a + retry loop, which treats the symptom and doubles the races. + + ⚠ A FRESH ID, NEVER THE ARTEFACT'S. Reusing the Query id would be a guessable id in a shared + bucket, which is the exact door the wave-9 guard exists to shut. + """ + from routes_grid import grid_events_route + + state, row = _artifact_or_404(session, qid) + scope = _grid_scope((row.get("source") or {}).get("database")) + if not scope: + raise err(409, "unsaveable_source", + "this answer's database does not have a view list to save into.") + spec = row.get("view") or {} + config = {key: copy.deepcopy(spec[key]) for key in _SAVEABLE if spec.get(key) is not None} + config["important"] = spec.get("important") is True + view_id = _new_id("view") + name = " ".join(str(row.get("name") or "Query").split())[:120] or "Query" + + grid_events_route({"scopeKey": scope, "events": [{ + "id": _new_id("event"), "type": "view_upsert", + "view": {"id": view_id, "name": name, "config": config}, + }]}, session) + + document = session.runtime.get(f"{scope}_table_workspace") or {} + landed = (((document.get(session.uname) or {}).get("views") or {}).get(view_id) + if isinstance(document, dict) else None) + if not isinstance(landed, dict): + raise err(409, "view_not_saved", + "the database did not accept this view, so nothing was saved. Open the database " + "and check you can still add a view there.") + + # R6's second sentence, in the payload: what the database's own validator would not take is + # NAMED rather than quietly missing from a view that then looks complete. + stored = landed.get("config") or {} + dropped = [] + if str((spec.get("aggregation") or {}).get("op") or "count") != "count": + dropped.append("the aggregation, which a database view does not carry") + if len(stored.get("filters") or []) != len(config.get("filters") or []): + dropped.append("some of the conditions") + for key in ("visible", "sorts", "groupBy", "display"): + if config.get(key) and not stored.get(key): + dropped.append(key) + return {"scope": scope, "viewId": view_id, "name": landed.get("name") or name, + "dropped": dropped} + + +@router.post("/query/messages/{mid}/rating") +def rate_message(mid: str, body: dict = Body(default=None), + session: Session = Depends(require_session)): + """R20's thumbs, and the ONE thing that stops them being decoration. + + ⛔ A RATING THAT IS WRITTEN AND NEVER READ IS A FLAG SHIPPED WITHOUT ITS WRITER. This one is + read: `_history` turns a thumbs-down into a user turn saying the answer was not helpful, with + the reason if one was given, so the next question in the same thread is answered against that. + The scope is honest and narrow: it steers THIS conversation. It does not train anything, does + not cross threads, and does not cross accounts. + + ⚠ Rating is idempotent and CLEARABLE (`rating: null`), because a mis-click that cannot be taken + back is worse than no control at all, and a flag that can be set and never cleared is the same + defect `important` was fixed for. + """ + body = body if isinstance(body, dict) else {} + mid = str(mid) + raw = body.get("rating") + rating = str(raw or "").strip().lower() + if raw is not None and rating not in QUERY_RATINGS: + raise err(400, "bad_rating", "a rating is up, down, or nothing at all") + state = _state(session) + row = state["messages"].get(mid) + if row is None: + raise err(404, "unknown_message", "that message does not exist") + if row.get("role") != "assistant": + raise err(400, "bad_rating", "only an answer can be rated") + reason = " ".join(str(body.get("reason") or "").split())[:MAX_RATING_REASON] + + def update(raw_state): + current = _blank_state() + if isinstance(raw_state, dict): + for key in ("threads", "messages", "citations", "views"): + if isinstance(raw_state.get(key), dict): + current[key] = copy.deepcopy(raw_state[key]) + message = current["messages"].get(mid) + if message is not None: + message["rating"] = rating if raw is not None else None + # A reason belongs to a thumbs-down; clearing the rating clears it with them. + message["ratingReason"] = reason if (raw is not None and rating == "down") else "" + return current + + if not session.runtime.available(): + raise err(503, "store_unavailable", "the tenant store is unavailable; nothing was recorded") + session.runtime.update(_namespace_key(session), update, flush="async") + return _safe(update(state)["messages"][mid]) + + +@router.get("/query/{qid}/export") +def export_query(qid: str, session: Session = Depends(require_session)): + """The artefact's own rows and columns, as CSV. + + ⚠ THE ARTEFACT IS FROZEN; ITS DATA IS NOT, and the difference is worth stating because the two + are easy to conflate. This re-reads the source through the SAME permission wall that built the + artefact and applies the view's own stored predicate, so the file matches what the grid is + showing right now, not the snapshot the stored `numeric` was computed from. That is the useful + answer: a person exports what they are looking at. + """ + state, row = _artifact_or_404(session, qid) + source = row.get("source") or {} + # ⛔ THIS PASSES A STORED OUTPUT BACK INTO AN INPUT SLOT, WHICH IS WORTH STATING RATHER THAN + # HIDING. `source["filters"]` is `deps.assistant_read_scope`'s OWN return value, and that value + # is `validate_assistant_filter(filters, visible)` — the VALIDATED form of whatever the caller + # asked for. MEASURED: `queryApi.submitChat` sends no `filters` key at all, so in production + # this is `None` on every artefact and the round trip never happens. + # ⚠ It is sent anyway, and deliberately, because the alternative FAILS OPEN. If a caller ever + # does narrow a source, dropping the filter here would export MORE rows than the artefact was + # built from, silently. Sending it means a stored filter that cannot be re-validated comes back + # as a NAMED 400 (`assistant_bad_filter`) instead — the fail-closed direction. The gate asserts + # what this door PASSES rather than what the fixture echoes back, because a double built from + # the producer would make this argument valid by construction. + snapshot = assistant_read_scope(session, source.get("database"), fields=None, + filters=source.get("filters") or None) + labels = {field["key"]: str(field.get("label") or field["key"]) for field in snapshot["fields"]} + columns = [key for key in (row["view"].get("visible") or ()) if key in labels] + if not columns: + raise err(409, "nothing_to_export", + "none of this view's columns exist in the database any more, so there is " + "nothing to export. Ask the assistant to build it again.") + buffer = io.StringIO() + writer = csv.writer(buffer, lineterminator="\n") + writer.writerow([labels[key] for key in columns]) + for record in _view_records(snapshot, row["view"]): + writer.writerow(["" if record.get(key) is None else record.get(key) for key in columns]) + stem = re.sub(r"[^A-Za-z0-9]+", "_", str(row.get("name") or "query")).strip("_") or "query" + return Response( + content=buffer.getvalue(), media_type="text/csv; charset=utf-8", + # ⚠ ASCII-only filename: a header carrying a non-ASCII byte is refused by some servers and + # silently mangled by others, and the stem above comes from a model-authored name. + headers={"Content-Disposition": f'attachment; filename="{stem[:60]}.csv"'}) diff --git a/api/routes_tables.py b/api/routes_tables.py index fae644151f61e70702cdae1a93e5266b7300b136..9be38e6e6c95d32ed698178ac95222089ae4e1e0 100644 --- a/api/routes_tables.py +++ b/api/routes_tables.py @@ -165,7 +165,7 @@ def _records_or_refuse(session, table_key, st=None): defn = _defn_or_refuse(session, table_key, st=st) if not _ut().records_mutable(table_key, st=st): raise err(403, "records_read_only", - "records in this automation-owned database are read-only — add Instagram " + "records in this automation-owned database are read-only. Add Instagram " "handles in a Profile database and let enrichment populate this database") return defn @@ -376,7 +376,7 @@ def delete_shared_field(table_key: str, field_key: str, owner = str(defn.get("createdBy") or "") if not session.admin and owner != session.uname: raise err(403, "forbidden", - f"a tenant-wide column can be removed by its creator or an admin — this one " + f"a tenant-wide column can be removed by its creator or an admin. This one " f"was added by {owner or 'somebody else'}, and dropping it would delete the " f"value for every account") dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime) @@ -735,17 +735,17 @@ def create_table(body: dict = Body(default=None), raise err(400, "bad_label", "give the database a name") if not session.runtime.available(): raise err(503, "store_unavailable", - "the tenant store is unavailable — nothing was created") + "the tenant store is unavailable. Nothing was created") source = body.get("source") try: key = ut.create(label, session.uname, fields=body.get("fields"), source=source, st=session.runtime) except Exception: raise err(503, "store_unavailable", - "the tenant store refused the write — nothing was created") + "the tenant store refused the write. Nothing was created") if not key: raise err(400, "refused", - f"could not create it — the name may be empty or this tenant already has " + f"could not create it. The name may be empty or this tenant already has " f"{ut.MAX_TABLES} databases") return {"key": key} @@ -845,7 +845,7 @@ def delete_table(table_key: str, session: Session = Depends(require_session)): try: _ut().delete(table_key, st=session.runtime) except Exception: - raise err(503, "store_unavailable", "the delete did not land — try again") + raise err(503, "store_unavailable", "the delete did not land. Try again") return {"ok": True} @@ -993,6 +993,9 @@ def table_rows(table_key: str, session: Session = Depends(require_session)): # so a tenant using `json` for a short config sees no change at all. rows = aios_grid.rows_from_pool( g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"]) + # ⭐⭐ WAVE-34 (R13) — the per-cell enrichment STATE rides the row, beside `_created`/`lat`/ + # `lon`. See `_stamp_ai_states` for why it is a row key rather than a map beside `rows`. + _stamp_ai_states(table_key, g["fields"], rows, st=_lent) # ⭐ R6's SECOND SENTENCE, ON THE WIRE (W30-T29). *"if there is lag or it can't be done, you # need to explicitly tell me why and recommend a fix."* A ceiling that still applies to this # database says so here, with its cause and the recommendation, rather than waiting to be @@ -1016,6 +1019,48 @@ def table_rows(table_key: str, session: Session = Depends(require_session)): "recordsMutable": _ut().records_mutable(table_key, st=_lent)} +#: The per-cell provenance a row carries on the wire, one key per enrichment column. +#: ⛔ A ROW KEY RATHER THAN A SIBLING MAP, and the choice is load-bearing rather than cosmetic. +#: A `{colId: {pid: state}}` map beside `rows` would need a new PROP on `RecordDetail` and a new +#: argument at `CustomerGrid`'s call site, both in another lane's fence, to reach the two surfaces +#: that must paint it. The row already carries `_created`, `lat` and `lon` for exactly this +#: reason, so every reader already tolerates keys that are not columns, and both surfaces hold the +#: row already. ⚠ COLLISION-PROOF BY CONSTRUCTION: `_clean_field` strips leading underscores off +#: every field key, so no column can ever be called `_ai_*`. +AI_STATE_PREFIX = "_ai_" + + +def _stamp_ai_states(table_key, fields, rows, st=None): + """Add `_ai_` to each row for every `ai_enrich` column. Mutates and returns `rows`. + + ⛔ A PROJECTION, NOT THE MARK SET. The stratum holds a hash, a model, a timestamp, a token + count and an error per cell; a browser needs ONE WORD to paint a state, and shipping the rest + would grow this payload by a dict per enriched cell for data no reader reads. The vocabulary + is `agent`/`human`/`stale`/`error` (`api/ai_enrich.py::cell_state`), which is also what the + RUNNER obeys, so the badge and the behaviour cannot disagree about whose cell it is. + + ⚠ AN ABSENT KEY MEANS `empty`, and only non-empty states are stamped: a table with no + enrichment column is untouched, and a freshly created column adds nothing until something + runs. ⛔ `stale` is DERIVED here rather than stored, so it is computed against TODAY'S row + instead of against whatever was true when the value was written. + """ + cols = [f for f in (fields or []) if str(f.get("type") or "") == "ai_enrich"] + if not cols: + return rows + import ai_enrich as _ae + for field in cols: + col = str(field.get("key") or "") + marks = _ut().ai_enrich_marks(table_key, col, st=st) + cfg = field.get("aiEnrich") if isinstance(field.get("aiEnrich"), dict) else {} + for row in (rows or []): + if not isinstance(row, dict): + continue + state = _ae.cell_state(marks.get(str(row.get("pid"))), cfg, row, col) + if state != "empty": + row[AI_STATE_PREFIX + col] = state + return rows + + @router.get("/tables/{table_key}/rows/{pid}/fields/{fkey}") def table_cell(table_key: str, pid: str, fkey: str, session: Session = Depends(require_session)): @@ -1062,7 +1107,7 @@ def add_row(table_key: str, body: dict = Body(default=None), rid = ut.add_row(table_key, values, session.uname, st=session.runtime, rid=(body or {}).get("rid")) except Exception: - raise err(503, "store_unavailable", "the row was not saved — the store refused") + raise err(503, "store_unavailable", "the row was not saved. The store refused") if rid is None: # C3 (wave 25): `add_row` also refuses a profile cell that is not a handle, so the cap # sentence alone would misdirect — the reader would go and count rows. Ask the same @@ -1072,11 +1117,11 @@ def add_row(table_key: str, body: dict = Body(default=None), _h, ok = ut.normalize_profile(values[pf["key"]], pf["profile"].get("source")) if not ok: raise err(400, "refused", - f"{str(values[pf['key']])[:80]!r} is not an Instagram profile — " + f"{str(values[pf['key']])[:80]!r} is not an Instagram profile. " f"{pf.get('label') or pf['key']!r} takes a handle (@name) or a " f"profile link (instagram.com/name)") raise err(400, "refused", - f"row refused — the table may be at its {ut.MAX_ROWS}-row cap") + f"row refused. The table may be at its {ut.MAX_ROWS}-row cap") _refresh_relations(session) return {"rid": rid, "pid": int(rid)} @@ -1130,14 +1175,14 @@ def import_rows(table_key: str, body: dict = Body(default=None), for key, value in row.items(): why = ut.cell_type_refusal(by_key[key], value) if why: - raise err(400, "bad_value", f"row {index + 1}: {why} — nothing was imported") + raise err(400, "bad_value", f"row {index + 1}: {why}. Nothing was imported") try: made = ut.add_rows(table_key, rows_in, session.uname, st=session.runtime) except Exception: - raise err(503, "store_unavailable", "nothing was imported — the store refused") + raise err(503, "store_unavailable", "nothing was imported. The store refused") if made is None: raise err(400, "refused", - f"nothing was imported — {len(rows_in)} rows would take this database past " + f"nothing was imported. {len(rows_in)} rows would take this database past " f"its {ut.MAX_ROWS}-row cap, or a profile column rejected a value") _refresh_relations(session) return {"imported": len(made), "pids": [int(r) for r in made]} @@ -1168,7 +1213,7 @@ def _field_or_refuse(session, table_key, fkey=""): ut = _ut() if not ut.is_user_table(table_key, st=session.runtime): raise err(400, "not_a_user_table", - "only a user-created database has an editable schema — a connected source " + "only a user-created database has an editable schema. A connected source " "owns its own columns") if fkey and not ut.may_edit_field(table_key, fkey, session.uname, session.admin, st=session.runtime): @@ -1221,12 +1266,41 @@ def add_field(table_key: str, body: dict = Body(default=None), # field relational", which is the question that was always meant. if field.get("type") in ("link", "rollup"): _refresh_relations(session) - return {"field": field} + return _with_dropped(ut, {"field": field}, body) + + +def _with_dropped(ut, out, body): + """Attach the NAMED list of config keys the validator did not keep (wave 34, R13 / W34-T51). + + ⛔⛔ THIS EXISTS BECAUSE THE VALIDATOR HAS NO ERROR CHANNEL AND CANNOT GROW ONE. Every bag + cleaner in `core/user_tables.py` returns `dict | None` and drops unknown keys in silence, and + `verify_fields_contract` asserts that they do -- so the drop is correct and the SILENCE is the + defect. T51's contract is that an unknown config key is dropped **and named**, so the naming + rides the response beside the accepted field rather than inside the validator. + + ⚠ OMITTED WHEN EMPTY, deliberately: an always-present `dropped: []` teaches every reader to + ignore the key, which is how a report stops being read before it stops being true. + """ + dropped = ut.ai_enrich_dropped_keys((body or {}).get("aiEnrich")) + if dropped: + out = dict(out) + out["dropped"] = dropped + return out def _refusal_sentence(ut, session, body, table_key="", fkey=""): """Why was this column refused? The specific reason when we can name one, the general list otherwise — never a specific-sounding guess.""" + # ⭐ WAVE-34 (R13): the enrichment column's own sentence, named BEFORE the automation bag + # below. `_clean_field` DERIVES `field.automation` for this kind, so a refused enrichment + # column would otherwise be explained by the flow law -- "pick a flow, or make this an + # ordinary column" -- which is the D-46 misdirection exactly, pointing at a control the user + # never touched. + if str((body or {}).get("type") or "").strip().lower() == "ai_enrich": + bag = (body or {}).get("aiEnrich") + if not isinstance(bag, dict) or not str(bag.get("prompt") or "").strip(): + return ("an AI enrichment column needs a prompt. It is the only thing that can " + "produce a value here, so a column without one would stay empty forever") # ⭐ C3 (wave 25, R7): the one-profile-per-table refusal NAMES THE EXISTING COLUMN, which is # what the contract asks for and what makes it actionable — "at most one" sends the reader # hunting through a 40-column schema for a flag they cannot see from the header. @@ -1238,11 +1312,11 @@ def _refusal_sentence(ut, session, body, table_key="", fkey=""): # real mistake was the column type sends them to fix the wrong thing, which is the # misdirection D-46 closed one door over. if str((body or {}).get("type") or "text").strip().lower() != "text": - return ("a profile column is a flag on an ordinary TEXT column — it validates what " + return ("a profile column is a flag on an ordinary TEXT column. It validates what " "is typed into it, which it can only do for text") existing = ut.profile_field(table_key, st=session.runtime) if table_key else None if existing and existing.get("key") != str(fkey): - return (f"this database already has a profile column — " + return (f"this database already has a profile column: " f"{existing.get('label') or existing.get('key')!r}. A database has at most " f"one, so the automation knows which handle to enrich; edit that column, or " f"take the flag off it first") @@ -1250,16 +1324,16 @@ def _refusal_sentence(ut, session, body, table_key="", fkey=""): if isinstance(bag, dict): flow = str(bag.get("flowId") or "").strip() if not flow: - return ("an automation column has to name the automation that fills it — pick a " + return ("an automation column has to name the automation that fills it. Pick a " "flow, or make this an ordinary column") if not ut.flow_bound(bag, st=session.runtime): return (f"this column names automation {flow!r}, which does not exist in this " - f"workspace — it may have been deleted; pick a flow that is still there") + f"workspace. It may have been deleted; pick a flow that is still there") kind = str((body or {}).get("type") or "").strip() if kind and kind not in ut.UT_FIELD_TYPES: return (f"{kind!r} is not a column type here (types: " f"{', '.join(sorted(ut.UT_FIELD_TYPES))})") - return (f"the column was refused — check the name and type, or the table may be at its " + return (f"the column was refused. Check the name and type, or the table may be at its " f"{ut.MAX_FIELDS}-column cap (types: {', '.join(sorted(ut.UT_FIELD_TYPES))})") @@ -1282,7 +1356,7 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None), try: migrated = ut.rename_choice_values(table_key, fkey, renames, st=session.runtime) except Exception: - raise err(503, "store_unavailable", "the rename did not land — try again") + raise err(503, "store_unavailable", "the rename did not land. Try again") # The per-user workspace strata and any view filter naming the old value are the OTHER # half of C-RENAME and belong to `core.table_store`. Called only if it is there: an # enumerator's mirror waits for its counterpart rather than guessing at its shape, and a @@ -1309,7 +1383,79 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None), out = {"field": field} if migrated is not None: out["migrated"] = migrated - return out + return _with_dropped(ut, out, body) + + +def _fire_on_change(table_key, pid, changed, session): + """Run any `on_change` enrichment column whose prompt names a cell that just moved. + + ⛔ ONE DEFINITION READ FOR THE WHOLE WRITE, and that is the point rather than an optimisation. + `automation_engine.grid_hook` calls `all_definitions(st)` once PER EVENT, which turns a + 20,000-row import into 20,000 whole-document reads on the single process this product runs + (`D-134`, and `W34-T54`'s own `how:` says not to rebuild it). `on_change_fields` is pure and + takes the definition, so this reads once and asks about every column. + + ⛔ AND IT NEVER FAILS THE WRITE. The cell edit has already succeeded and been acknowledged; + an enrichment that could not run is a missing value, not a lost edit, and the run's own report + carries the reason. ⚠ It is also deliberately SYNCHRONOUS and bounded to this one row: a + fan-out here would put a vendor call on the critical path of every keystroke-commit. + """ + import ai_enrich as _ae + + try: + defn = _ut().get(table_key, st=session.runtime) or {} + wanted = _ae.on_change_fields(defn, changed.keys()) + for field in wanted: + _ae.run_field(table_key, field["key"], st=session.runtime, rows=[str(pid)]) + except Exception: # noqa: BLE001 + pass + + +@router.post("/tables/{table_key}/fields/{fkey}/enrich") +def enrich_field(table_key: str, fkey: str, body: dict = Body(default=None), + session: Session = Depends(require_session)): + """Run an AI enrichment column (wave 34, owner ruling R13). Returns the run's own REPORT. + + ⛔ THIS DOOR SPENDS MONEY, so it rides the same wall every other schema write rides + (`_field_or_refuse`) rather than a looser one of its own. A read-only viewer cannot bill the + tenant by opening a grid. + + `{"rows": ["3"]}` is a MANUAL run of exactly those records: a person asked, in front of the + value being replaced, so it skips the `overwrite` policy. An ABSENT `rows` is the automatic + plan, where the policy and the never-overwrite-a-human law both apply. The two are one + function with one flag, not two runners. + + ⚠ THE REPORT IS THE PRODUCT, not a status code. It carries `filled`, `failed`, `skipped` by + reason, `tokens` spent, the provider, per-row errors, and `limit` (R6's second sentence: a + ceiling that stopped the run names its cause and a remedy). A 200 with `filled: 0` and a + populated `skipped` is a correct, informative answer, and the client must render it rather + than treat it as success. + """ + _field_or_refuse(session, table_key, fkey) + import ai_enrich as _ae + + rows = (body or {}).get("rows") + if rows is not None and not isinstance(rows, list): + raise err(400, "bad_rows", "`rows` must be a list of record ids, or absent to run the " + "rows this column's own settings choose") + # The caller's permitted pool, the same one the row doors use. A named row outside it is + # dropped rather than refused: a stale client naming a record that has been deleted or moved + # out of scope should not fail a run over the rows it can legitimately fill. + if rows is not None: + allowed = {str(p) for p in scoped_pids(session, table_key)[0]} + rows = [str(r) for r in rows if str(r) in allowed] + # ⭐ `W34-T54`'s bulk menu is this one field: "Rows never filled" sends `blank`, "All rows" + # sends `always`. Anything else falls back to the column's own saved policy rather than to a + # default, so a typo cannot quietly widen what a run touches. + report = _ae.run_field(table_key, fkey, st=session.runtime, rows=rows, + policy=str((body or {}).get("scope") or "") or None, + # A named row set through THIS door is a person asking. + manual=rows is not None) + if report.get("problem"): + # A run that could not start at all is not a 200: nothing was attempted, nothing was + # spent, and the reason is actionable (no provider configured, or the wrong column). + raise err(400, "enrich_refused", str(report["problem"])) + return report @router.delete("/tables/{table_key}/fields/{fkey}") @@ -1317,7 +1463,7 @@ def delete_field(table_key: str, fkey: str, session: Session = Depends(require_s _field_or_refuse(session, table_key, fkey) if not _ut().delete_field(table_key, fkey, st=session.runtime): raise err(400, "refused", - "that column could not be removed — a database must keep at least one") + "that column could not be removed. A database must keep at least one") _refresh_relations(session) return {"deleted": fkey} @@ -1342,7 +1488,7 @@ def delete_row(table_key: str, rid: str, session: Session = Depends(require_sess try: ok = _ut().delete_row(table_key, rid, st=lent) except Exception: - raise err(503, "store_unavailable", "the delete did not land — try again") + raise err(503, "store_unavailable", "the delete did not land. Try again") if not ok: raise err(400, "refused", "rows can only be deleted from user-created databases") _refresh_relations(session) @@ -1375,7 +1521,7 @@ def patch_row(table_key: str, pid: int, body: dict = Body(default=None), "pid": pid, "updates": updates}, ctx) except grid_events.StoreUnavailable: raise err(503, "store_unavailable", - "the tenant store is unavailable — your change was not saved") + "the tenant store is unavailable. Your change was not saved") # ⚠ THE READ-BACK IS THE DEFINITION ROW, AND ON A `ut_` SCOPE THAT IS THE WHOLE OF IT. # # ⛔ CORRECTED, wave-29 T22 (owner item 2a): this note used to say "THE READ-BACK SPANS BOTH @@ -1414,6 +1560,22 @@ def patch_row(table_key: str, pid: int, body: dict = Body(default=None), return False refused = sorted(k for k in updates if not _took(k)) + # ⭐⭐ WAVE-34 (R13) — THE HUMAN-EDIT STAMP, AND IT HAS TO HAPPEN HERE. "Did a person write + # this cell?" is not recoverable from the value afterwards, so the only place to record it is + # the door a person writes through. `ai_enrich_may_write` then refuses to let any automatic + # run overwrite it, whatever the column's `overwrite` policy says. + # ⚠ STAMPED FROM THE CELLS THAT ACTUALLY TOOK, never from what was asked: marking a refused + # write `human` would freeze a cell against the agent on the strength of an edit that never + # landed. `note_human_edit` filters to the enrichment columns itself and is a no-op otherwise. + took = {k: v for k, v in accepted.items() if k not in refused} + if took: + try: + _ut().note_human_edit(table_key, took, pid, st=session.runtime) + except Exception: # noqa: BLE001 + # Provenance is metadata about a write that has already succeeded. Failing the + # request here would tell the user their edit was lost when it was not. + pass + _fire_on_change(table_key, pid, took, session) out = {"pid": pid, "updates": accepted} if refused: out["refused"] = refused diff --git a/api/web_agent.py b/api/web_agent.py index 1de71471fbc7c97fc3ddae43d39529794cf39253..00d58c51c0871457876ccf81c697deac757729fe 100644 --- a/api/web_agent.py +++ b/api/web_agent.py @@ -1,9 +1,10 @@ """web_agent.py — THE SEAM between an automation step and a browser that runs somewhere else. -CONTRACT C5 (wave 31, ruling R10 / D-51). E ships this; **C mounts it** in -`automation_engine.py`'s action dispatch for `web_read` and adds the `verify_wiring` row — -⛔ E cannot verify its own mounting, and an unmounted runner is a whole, correct, UNREACHABLE -feature, which is exactly how five wave-29 features shipped behind green gates. +CONTRACT C5 (wave 31, ruling R10 / D-51). E shipped this and C mounted it in +`automation_engine.py`'s action dispatch; both halves landed and the `verify_wiring` row exists. +⚠ That sentence was written in the FUTURE TENSE as a wave-31 hand-off and stayed that way for +three waves, which is the smaller cousin of the correction below: a fence note describes one +wave's division of labour and reads, afterwards, as a description of the code. result, error = web_agent.run_step(step, ctx) # -> (dict|None, str) @@ -30,22 +31,39 @@ foreground step of an automation RUN and far too slow for a route a person is wa cost is per JOB, not per step — `run_plan` sends a whole flow's steps in ONE job for that reason. ──────────────────────────────────────────────────────────────────────────────────────────────── -⛔ THREE THINGS ARE TRUE OF PRODUCTION TODAY AND ARE NOT DEFECTS IN THIS FILE. `capability()` -reports each as a sentence, and `verify_web_agent.py` holds each to a check, so none of them can -be discovered by a customer instead of by us: - - 1. THE SPACE'S TOKEN CANNOT PAY FOR A JOB. `deploy_web.py` pushes `HF_TOKEN`, which is scoped to - the org `royal-imports`; HF answers **402 `Pre-paid credit balance is insufficient`** there - and 0 jobs have ever run under it. `AIOS_HF_TOKEN` (user `fsanyoto`, 100 jobs) works and is - NOT pushed. Measured 2026-08-12. - 2. THE SPACE DOES NOT SHIP THE JOB SCRIPT. `deploy_web.py` uploads `api/*.py` and - `platform/{core,modules,harness}`; `jobs/` is in no manifest. And `check_upload()` walks - IMPORTS, so it structurally cannot see a file opened by PATH — its own comments say so, about - `aios_grid_fields.json`, which crashed a Space for exactly this reason. - 3. `routes_web_agent.py` IS NOT MOUNTED. `main.py` is another lane's file this wave. - -Each needs one line somebody else owns. Until then this module answers with a sentence rather -than a silence, which is the whole of R6's second half. +⛔⛔ WAVE 34 · W34-T44 — THIS BLOCK LISTED "THREE THINGS TRUE OF PRODUCTION TODAY". ALL THREE WERE +FALSE BY THE TIME ANYBODY READ THEM, AND THIS DOCSTRING IS WHERE THE ERROR CAME FROM. + +It said the Space token 402s, that `deploy_web.py` never uploads `jobs/`, and that +`routes_web_agent.py` is not mounted — so the whole web-action family was inert in production. +**Four later documents repeated it** (the wave-34 PRD's scouted index, the plan-time scout's +report, `W34-T44`'s own `how:`, and `run_gates.ps1`'s annotation telling a deploy to expect +`web_agent` at 48/50), and every one of them traced back here. Re-checked line by line, against +the files themselves, on 2026-08-16: + + 1. THE TOKEN IS PUSHED. `deploy_web.py:1128` adds `AIOS_HF_TOKEN` to the Space's secrets when it + is set, and `ENV_TOKEN` below prefers it over `HF_TOKEN`, so the org token that 402s is never + the one reached. The 402 itself is still true OF THAT TOKEN and is why the order matters. + 2. THE JOB SCRIPT SHIPS. `deploy_web.py::_jobs_files` (:413) collects `jobs/` and :867 uploads + it. The `check_upload()` caveat stands as a caveat: it walks IMPORTS and cannot see a file + opened by PATH, so this upload is covered by the manifest, never by that check. + 3. IT IS MOUNTED. `main.py:264`, `app.include_router(routes_web_agent.router)` — and + `verify_web_agent.py` asserts the route is in the SERVED set (`app.openapi()["paths"]`), + which is green. + +⭐ MEASURED END TO END the same day, from a dev box: `capability()` = ready True / "configured" / +`AIOS_HF_TOKEN` / playwright 1.62.0, and a real `web_read` of `https://example.com h1` submitted in +3.8 s, was alive at 21.4 s and returned `value "Example Domain"`, status 200, 106 ms in-browser, +24.5 s wall clock cold. +⚠ STILL UNPROVEN, so nobody over-corrects in the other direction: that run was NOT on the deployed +Space, which carries the secret only if the last deploy read this `.env`. Proving it needs a click +on staging, and until then "it works" means "it works from a box with the token". + +⛔ THE STANDING LESSON, kept here because this file is where it started: **a docstring stating +facts about PRODUCTION has no expiry and nothing re-reads it when the world changes.** Three +sentences about somebody else's file outlived all three of those files' fixes, and were quoted as +current by four documents and a ticket. `capability()` answers the same question in under a second +and returns TODAY's answer; prefer calling it to reading this. """ import base64 import hashlib @@ -223,7 +241,7 @@ def _sentence_for(exc, namespace): f"point this deployment at one that has it ({ENV_NAMESPACE} / {ENV_TOKEN[0]}). " f"Nothing was read.") if status in (401, 403): - return (f"Hugging Face refused the browser job as unauthorised for {where} — this " + return (f"Hugging Face refused the browser job as unauthorised for {where}. This " f"deployment's token does not carry `job.write` on that account. Point it at an " f"account the token owns ({ENV_NAMESPACE}), or configure a token that owns this " f"one ({ENV_TOKEN[0]}). Nothing was read.") @@ -524,7 +542,7 @@ def _await(api, job_id, namespace, started, log): break if state in ("COMPLETED", "ERROR", "CANCELED"): return None, (f"The browser job {job_id} ended {state} without returning a result" - f"{' — it never started printing' if not booted else ''}. " + f"{'. It never started printing' if not booted else ''}. " f"Nothing was read.") if not booted and waited > START_TIMEOUT: # The submitted-but-never-started case, named as itself. @@ -534,7 +552,7 @@ def _await(api, job_id, namespace, started, log): pass return None, (f"The browser job {job_id} was accepted but had not started after " f"{int(START_TIMEOUT)}s (it stayed {state or 'queued'}), so it was " - f"cancelled. Nothing was read — try again, or the platform is busy.") + f"cancelled. Nothing was read. Try again, or the platform is busy.") if waited > RUN_TIMEOUT: try: api.cancel_job(job_id=job_id, namespace=namespace) diff --git a/platform/aios_grid.py b/platform/aios_grid.py index 03a7d98d3137781d6895f5af1804d8dfd144fa8c..8fb2db4f0aa9d0331ead406ab80a14bf146011f7 100644 --- a/platform/aios_grid.py +++ b/platform/aios_grid.py @@ -120,9 +120,20 @@ def _round(v): #: surfaces that CREATE them are the user-table databases, where a relation between two tables is #: a thing that exists. On the Odoo-backed Customer/Product grids there is no second user table to #: point at, so the column menu there simply never offers one. +#: ⭐⭐ WAVE-34 (owner ruling R13) — `ai_enrich` JOINS, and it had to join HERE in the same change +#: that put it in `UT_FIELD_TYPES`, not a ticket later. The wave planned a SERVER-FIRST landing on +#: the reasoning that a kind the client does not offer is invisible while a kind the server refuses +#: deletes a column. That reasoning is sound and the conclusion was still wrong, because THREE +#: parity gates chain over these sets and none of them permits a partial landing: +#: `verify_api` W18-UT `UT_FIELD_TYPES - CUSTOM_FIELD_TYPES == set()` (this line) +#: `aw_fields_contract` §5 `CREATABLE_TYPES == CUSTOM_FIELD_TYPES`, EXACT set equality +#: `types.ts` `CREATABLE_TYPES: readonly FieldType[]`, so the union must carry it too +#: Measured live by lane B at 17:09: `api_api` went 969/969 to 968/969 the moment the kind entered +#: `core/user_tables.py` alone, printing `got {'ai_enrich'} want set()`. The comments on +#: `json`, `link`/`rollup` and `code` below all say the same thing in their own words. CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date", "checkbox", "phone", "email", "url", "rating", "created_time", "formula", - "automation", "image", "json", "link", "rollup", "code"} + "automation", "image", "json", "link", "rollup", "code", "ai_enrich"} #: ⭐ WAVE-27 item 13 (owner ruling R13) — the `code` field's LANGUAGES. #: diff --git a/platform/core/registry.py b/platform/core/registry.py index e65062743bf8cf7a90c80b0accc86b5b7731812c..d038770a0f7833ea467cd48949d4f6248a8bc65f 100644 --- a/platform/core/registry.py +++ b/platform/core/registry.py @@ -57,7 +57,7 @@ REGISTRY = [ # briefing still read it, and pages_sales.py survives UNREGISTERED as the Y1 envelope's # template + verify_api's fixture. {'key': 'sales', 'label': 'Sales', 'brand': True, 'hq': False, 'validate': True, 'source': 'Odoo', 'archived': True, - 'note': 'Revenue, YoY, seasonality, reps, customers, SKUs — by BU. Retired wave 16: ' + 'note': 'Revenue, YoY, seasonality, reps, customers, SKUs. By BU. Retired wave 16: ' 'rebuild as grid chart/dashboard views (compare series, KPI deltas, tables).'}, # ARCHIVED AS A PAGE (owner item 14, wave 8): "archive the current form of the Customer # dashboard completely, i want to redesign it, exactly with the backend we have". So the @@ -104,7 +104,7 @@ REGISTRY = [ # _LEGACY_KEYS maps cohort→customer_data so grants + ?page= deep links land on the grid # that now hosts the cohorts. {'key': 'cohort', 'label': 'Cohort', 'brand': True, 'hq': False, 'validate': False, 'source': 'Odoo', 'parent': 'customers', 'archived': True, - 'note': 'Hand-curated, unchanging customer lists — folded into the Customer rail as ' + 'note': 'Hand-curated, unchanging customer lists. Folded into the Customer rail as ' 'locked views (wave 16). The set is fixed: it changes only when someone adds ' 'or removes a member. Open them from Customer > Views > Cohorts.'}, # ARCHIVED wave 16 (owner item 7, R11, 2026-08-02) beside Sales. The agent DRAWER and the @@ -131,9 +131,9 @@ REGISTRY = [ 'note': 'The SKU catalogue as a grid: per-product revenue, units and (consolidated) ' 'stock columns, with saved views and custom fields. Identity = the SKU code.'}, {'key': 'products', 'label': 'SKU', 'brand': True, 'hq': False, 'validate': True, 'archived': True, - 'note': 'SKU movers, zombies, velocity, coverage, drawers — by BU (sales-derived).'}, + 'note': 'SKU movers, zombies, velocity, coverage, drawers. By BU (sales-derived).'}, {'key': 'assortment', 'label': 'Assortment', 'brand': True, 'hq': False, 'validate': True, 'archived': True, - 'note': 'Facet-level performance (category/color/occasion/collection/season) + season readiness — by BU.'}, + 'note': 'Facet-level performance (category/color/occasion/collection/season) + season readiness. By BU.'}, {'key': 'financial', 'label': 'Financial', 'brand': True, 'hq': False, 'validate': True, 'archived': True, 'note': 'Gross margin by BU/category/SKU. Cash-conversion cycle is HQ-consolidated.'}, {'key': 'pricing', 'label': 'Pricing', 'brand': True, 'hq': False, 'validate': True, 'archived': True, @@ -148,7 +148,7 @@ REGISTRY = [ 'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 13: "We should be able to replace ' 'Procurement completely, and add it as part of the Product database"). The buy ' 'list is now a saved VIEW on the Product grid, filtered on a FORMULA field over ' - 'the supplier/lead-time columns and the demand measure — the owner\'s ruling R3: ' + 'the supplier/lead-time columns and the demand measure. The owner\'s ruling R3: ' '"Buy list is just a View, with a Filter from a Formula field that taps into ' 'Metrics Fields... the 8-month demand baseline etc. is just math in a Formula ' 'field." The ROW STAYS validate_only: `modules/procurement.py` still owns the ' @@ -162,7 +162,7 @@ REGISTRY = [ 'source': 'Odoo', 'api_surface': False, 'admin_only': True, 'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 15: "Collections should also be ' 'entirely replicable as just a View under Customer"). The worklist is the shared ' - '"Collections" view on the Customer grid — same numbers, from this module\'s own ' + '"Collections" view on the Customer grid. Same numbers, from this module\'s own ' 'reconciled blocks (ar_open/ar_overdue/ar_exposure/days_to_pay + the four aging ' 'buckets). The ROW STAYS validate_only so validate.py keeps running ar.validate(), ' 'which is the proof those columns rest on; archiving it would have skipped the ' @@ -171,7 +171,7 @@ REGISTRY = [ 'sender (app._collections_statements -> modules/collections_send), the ONE ' 'sanctioned Odoo writer, which the same ruling says stays untouched. So the row ' 'keeps a Streamlit page for ADMINS ONLY (`admin_only`) and leaves the API payload ' - 'entirely (`api_surface: False`) — no "Collections" in the React nav, no second ' + 'entirely (`api_surface: False`). No "Collections" in the React nav, no second ' 'worklist, and the biweekly send keeps its door.'}, {'key': 'returns', 'label': 'Returns', 'brand': False, 'hq': True, 'validate': True, 'archived': True, 'note': 'Credit-note lens: refund concentration by SKU (quality) and customer (behavior). Company-level.'}, @@ -180,7 +180,7 @@ REGISTRY = [ {'key': 'backorders', 'label': 'Backorders', 'brand': True, 'hq': False, 'validate': True, 'archived': True, 'note': 'Confirmed-undelivered order lines aged vs promise date, valued, with a supply-aware next action per row (ship / expedite / call). Wholesale scope.'}, {'key': 'pricecomp', 'label': 'Price Compliance', 'brand': True, 'hq': False, 'validate': True, 'archived': True, - 'note': 'Selling below the customer pricelist tier (LTM, per customer x SKU): the pocket-price floor worklist with annualized leak $. Sub-30% ratios usually mean a stale or pack-basis RULE — fix the rule, not the rep.'}, + 'note': 'Selling below the customer pricelist tier (LTM, per customer x SKU): the pocket-price floor worklist with annualized leak $. Sub-30% ratios usually mean a stale or pack-basis RULE. Fix the rule, not the rep.'}, {'key': 'o2c', 'label': 'Cash Timing', 'brand': True, 'hq': False, 'validate': True, 'archived': True, 'note': 'Order-to-cash stage decomposition (order-to-ship / ship-to-invoice / invoice-to-paid, each with its owner) + the terms-gap rollup: contractual vs actual days per payment term with the free-credit $ it strands.'}, {'key': 'bookings', 'label': 'Order Book', 'brand': True, 'hq': False, 'validate': True, 'archived': True, @@ -193,29 +193,37 @@ REGISTRY = [ 'note': 'Operating expense from the GL (all expense-type accounts; COGS excluded): trend, operating leverage (opex % of revenue), the YoY cost bridge, XmR control-limit spike watch list, fixed/variable split and a drill-to-ledger category directory. Company-level.'}, {'key': 'health', 'label': 'Data Health', 'brand': False, 'hq': True, 'validate': True, 'archived': True, 'note': 'Close/reconciliation scan; period-filtered, mostly company-level. ARCHITECTURE ' - '(owner 2026-07-23): every discrepancy / potential-error marker lives here — ' + '(owner 2026-07-23): every discrepancy / potential-error marker lives here. ' 'procurement mapping gaps, count-trust (unverified on-hand counts), untracked-on-' - 'order — so operational workflows stay clean for doing the work.'}, + 'order. So operational workflows stay clean for doing the work.'}, {'key': 'dictionary', 'label': 'Metric Dictionary', 'brand': False, 'hq': True, 'validate': True, 'nav': False, 'validate_only': True, - 'note': 'PAGE RETIRED 2026-07-23 (owner: broken/not customer-facing) — row kept ONLY so ' + 'note': 'PAGE RETIRED 2026-07-23 (owner: broken/not customer-facing). Row kept ONLY so ' 'validate.py keeps running the semantic-layer contracts (the Analyst grounding ' - 'proof). No nav, no page. Wave 17 R8 ("I don\'t even know what it does — delete ' + 'proof). No nav, no page. Wave 17 R8 ("I don\'t even know what it does. Delete ' 'them") made that literal: `validate_only` takes it out of the account menu too, ' 'which is the last place it was still visible. The proof survives; the door does not.'}, - {'key': 'automation', 'label': 'Automation', 'brand': False, 'hq': True, 'validate': False, + {'key': 'automation', 'label': 'Agents', 'brand': False, 'hq': True, 'validate': False, 'nav': True, - 'note': 'Wave 18 (C-AUTONAV): the Automation surface — scheduled jobs that create and ' - 'refresh user databases (website scrape-to-DB, the Instagram field). React-only ' - 'surface (no PAGE_FUNCS entry, the no-new-Streamlit rule); admins hold it via ' - '"all", other users need the explicit grant — fail-closed default.'}, + 'note': 'Wave 18 (C-AUTONAV): scheduled jobs that create and refresh user databases ' + '(website scrape-to-DB, the Instagram field). React-only surface (no PAGE_FUNCS ' + 'entry, the no-new-Streamlit rule); admins hold it via "all", other users need the ' + 'explicit grant, fail-closed default. ' + '⭐ WAVE 34 · R12. THE LABEL IS "Agents" AND THE KEY IS STILL `automation`. The ' + 'owner ruled the module becomes Agent: this is our agent deployment surface going ' + 'forward. Only the LABEL moved, because the key is a permission scope, a nav ' + 'placement key, a grant name and the `#/automation` route; renaming it would revoke ' + 'every stored grant and 404 every bookmark to buy a caption. ⚠ The plural is not a ' + 'slip: R15 and the wave PRD both spell the rail row "Agents", and the degraded ' + 'fallback row in Shell.tsx hard-codes that spelling, so a singular here would paint ' + 'two different words in one list depending on whether /nav answered.'}, {'key': 'settings', 'label': 'Settings', 'brand': False, 'hq': True, 'validate': False, 'nav': False, # sidebar sentinel (account group) — owner IA 2026-07-12 'note': 'User scope settings: the Business Unit toggle (strict isolation) and the Data basis ' 'toggle (Orders vs Invoiced) moved here from the sidebar.'}, {'key': 'analyst', 'label': 'AIOS Analyst', 'brand': False, 'hq': True, 'validate': False, 'nav': False, # sidebar sentinel button; eval gate = harness/evals.py (run pre-ship, NOT in validate.py — live LLM cost) - 'note': 'Ask the business a question in plain language: a small AI model calls governed tools over the semantic layer — answers carry their query trace and drill links. AI-generated output (Art. 50 labeled).'}, + 'note': 'Ask the business a question in plain language: a small AI model calls governed tools over the semantic layer. Answers carry their query trace and drill links. AI-generated output (Art. 50 labeled).'}, ] BY_KEY = {m['key']: m for m in REGISTRY} diff --git a/platform/core/user_tables.py b/platform/core/user_tables.py index 0b48b615762f56360bd9dad54e5537c43465bcd8..b3273b8295abfdbaf8b09fbdea2b180bf7932f08 100644 --- a/platform/core/user_tables.py +++ b/platform/core/user_tables.py @@ -336,7 +336,7 @@ def limit_report(table_key, st=None): 'cause': f'this database is EDITABLE, so its rows live in the shared `{STORE_KEY}` ' f'document that every request copies; at the widest shipped row (0.318 KB) ' f'{cap:,} rows is 19.1 MB against a 32 MB per-table budget', - 'recommendation': 'connect it to a source instead — connected data is served ' + 'recommendation': 'connect it to a source instead. Connected data is served ' 'read-through from the mirror and is not bounded (R6); for typed data ' 'the increment is D-87, a per-table row key, not a bigger number', } @@ -439,9 +439,15 @@ def records_mutable(table_key, st=None): #: waves because `verify_fields_contract` diffed the client's offer against the ODOO grid's #: acceptance and never against this set. Wave-27 item 13 adds that derived leg, which is what #: found it (measured, not reviewed: `_clean_field({'type':'image'})` returned None). +#: ⭐ WAVE-34 (owner ruling R13) — `ai_enrich` JOINS, and it lands HERE FIRST rather than last. +#: The block above records that `image` reached four of the five surfaces and missed this one, so +#: the client offered a column the server deleted on the next read. `ai_enrich` is added to this +#: set in `W34-T51` (server) while the CLIENT half waits for `W34-T53`, which is the safe order: +#: a kind the server accepts and the client does not offer is invisible, whereas a kind the client +#: offers and the server refuses is a column created, named, configured and gone. UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date', 'checkbox', 'phone', 'email', 'url', 'rating', 'automation', 'json', - 'link', 'rollup', 'code', 'image', 'formula'} + 'link', 'rollup', 'code', 'image', 'formula', 'ai_enrich'} #: A cell is still a scalar on the wire (`Row` values are strings/numbers) — what this bounds is #: the DOCUMENT inside it. Source data is one already-paid provider response; keep it whole so a #: provider field is never silently discarded merely because the response is rich. 32 MiB matches @@ -902,6 +908,19 @@ def clean_fields(raw): entry = None elif fx is not None: entry['formula'] = fx + # ⭐⭐ WAVE-34 (R13) — the AI ENRICHMENT bag rides THIS door too, and it is the SIXTH time + # this seam has been fixed after the fact (`pinned`, `code`, `link`/`rollup`, `formula`, + # `agg`). It is written in the same change as `_clean_field`'s arm rather than a wave + # later, which is the only difference between this entry and the five above it. + if entry is not None: + ae = (_clean_ai_enrich(f.get('aiEnrich')) + if f.get('aiEnrich') is not None else None) + if (ftype == 'ai_enrich') != (ae is not None): + entry = None + elif ae is not None: + entry['aiEnrich'] = ae + # C3: derived, never typed beside the config. Same call as the other door. + entry['automation'] = _field_agent_binding(entry) if entry is None: seen.discard(key) continue @@ -1664,6 +1683,308 @@ def _clean_rollup(raw): return out +# --------------------------------------------------------------------------------------------- +# THE AI ENRICHMENT COLUMN (wave 34, owner ruling R13) +# --------------------------------------------------------------------------------------------- +# R13: *"a field kind called AI enrichment: a prompt per row that populates text. Detailed +# configuration in the field's own config, including a token-usage limit."* +# +# ⛔ POSTURE: `link`/`rollup`/`formula`'s, NOT `code`'s. The bag DEFINES the column — a prompt is +# the only thing that can ever produce a value here — so the type/bag pairing is enforced BOTH +# WAYS and either half alone refuses the field. An `ai_enrich` column with no prompt is a column +# that renders, sorts, filters and can never hold anything, which is the silent kind of broken +# every other bag on this door is paired to prevent. +# +# ⚠ AND THE VALUE IS STORED, NOT COMPUTED. `formula` is client-recomputed on every paint and +# `rollup` is read-through; neither persists. An enrichment that vanished on reload would not be +# the feature (PRD assumption A6), so an `ai_enrich` cell is an ordinary stored string, a human +# may type over it, and it is deliberately NOT in `is_computed_cell`. + +#: The bag's own key set, read by BOTH the cleaner and the reporter below so they cannot disagree +#: about what "unknown" means. ⛔ ONE constant, because T51's contract is that an unknown key is +#: DROPPED **and NAMED** — two hand-maintained lists would eventually drop a key the report did +#: not mention, which reads to a user as the config silently not saving. +AI_ENRICH_KEYS = ('prompt', 'model', 'maxTokens', 'trigger', 'overwrite') +#: When the column runs. `manual` = only when a person asks; `on_change` = when a referenced cell +#: moves; `schedule` = on a cadence. The community ask R13 cites ("enrichment on a schedule") is +#: the third of these, so it is in the vocabulary from the start rather than added later. +AI_ENRICH_TRIGGER_MODES = ('manual', 'on_change', 'schedule') +#: What an AUTOMATIC run may replace. ⛔ A HUMAN-EDITED CELL IS NEVER OVERWRITTEN AND THAT IS NOT +#: IN THIS VOCABULARY — it is a law (`ai_enrich_may_write`), not a policy, so no configuration can +#: turn it off. These three decide only what happens to cells the AGENT itself wrote. +AI_ENRICH_OVERWRITE = ('blank', 'stale', 'always') +#: Bounded because the ceiling is the feature (R13). The floor is one useful sentence; the roof is +#: what one cell can cost before the run has to say so out loud. +AI_ENRICH_MIN_TOKENS, AI_ENRICH_MAX_TOKENS = 16, 4000 +AI_ENRICH_DEFAULT_TOKENS = 300 +#: A prompt is a template, not an essay. Long enough for real instructions plus a few `{field}` +#: tokens, short enough that the per-cell input cost is predictable. +AI_ENRICH_PROMPT_MAX = 2000 +#: `cron` is stored as an opaque bounded string. ⚠ It is NOT parsed here on purpose: this module +#: owns the field contract, and the scheduler that reads it owns the cadence vocabulary. Parsing +#: it in two places is how one door starts refusing a cadence the other accepts. +AI_ENRICH_CRON_MAX = 120 + + +def _clean_ai_enrich(raw): + """The `ai_enrich` config bag → the stored shape, or None if the column cannot run. + + ⚠ Every key is normalised to a canonical form, so a definition saved and re-read is + byte-identical (`_clean_ai_enrich(_clean_ai_enrich(x)) == _clean_ai_enrich(x)`). That + idempotence is what makes the round-trip clause testable rather than a claim. + """ + if not isinstance(raw, dict): + return None + prompt = str(raw.get('prompt') or '').strip()[:AI_ENRICH_PROMPT_MAX] + if not prompt: + return None # the prompt IS the column; see the posture note above + out = {'prompt': prompt} + # The model is OPTIONAL and an unknown name is DROPPED rather than refused: it selects a + # PROVIDER for a column that already knows what to ask, so a stale name should fall back to + # the ladder's own order, never destroy the column. (`code`'s language argument, same shape.) + model = str(raw.get('model') or '').strip().lower()[:60] + if model: + out['model'] = model + tokens = raw.get('maxTokens') + # `bool` is an `int` subclass — `True` would otherwise validate as a 1-token ceiling. + if isinstance(tokens, bool) or not isinstance(tokens, (int, float)): + tokens = AI_ENRICH_DEFAULT_TOKENS + out['maxTokens'] = max(AI_ENRICH_MIN_TOKENS, min(int(tokens), AI_ENRICH_MAX_TOKENS)) + trig = raw.get('trigger') if isinstance(raw.get('trigger'), dict) else {} + mode = str(trig.get('mode') or '').strip().lower() + trigger = {'mode': mode if mode in AI_ENRICH_TRIGGER_MODES else 'manual'} + # ⛔ `cron` is kept ONLY on a scheduled column. A cadence stored under `manual` is a setting + # the user can see and nothing will ever read — the shape D-246 wears on the Odoo sync, where + # a `manual` preset does nothing because no branch reads it. + cron = str(trig.get('cron') or '').strip()[:AI_ENRICH_CRON_MAX] + if trigger['mode'] == 'schedule' and cron: + trigger['cron'] = cron + out['trigger'] = trigger + overwrite = str(raw.get('overwrite') or '').strip().lower() + out['overwrite'] = overwrite if overwrite in AI_ENRICH_OVERWRITE else 'blank' + return out + + +def ai_enrich_dropped_keys(raw): + """The config keys `_clean_ai_enrich` will NOT keep, sorted — the NAMING half of T51's + "an unknown config key is DROPPED and NAMED, never silently eaten". + + ⛔ IT EXISTS BECAUSE EVERY OTHER BAG CLEANER IN THIS FILE HAS NO ERROR CHANNEL. `_clean_link`, + `_clean_rollup`, `_clean_profile` and `_clean_format` all return `dict | None` and drop + unknown keys in silence — `verify_fields_contract` even asserts that they do. Copying one of + them as a template gets the DROP right and the NAMING wrong by construction, so the report is + a separate function reading the SAME `AI_ENRICH_KEYS` constant rather than a second list. + + ⚠ It reports UNKNOWN keys only. A known key whose VALUE was normalised (an out-of-range + ceiling clamped, an unknown model dropped) is not a surprise worth a sentence — the stored + definition comes straight back to the editor, so the user sees the accepted value itself. + """ + if not isinstance(raw, dict): + return [] + return sorted(str(k) for k in raw if k not in AI_ENRICH_KEYS) + + +def ai_enrich_fields(defn): + """Every `ai_enrich` column in this table definition, in declaration order. + + One reader for "which columns are field agents", so the Agents module (contract C3), the + runner and the scheduler cannot disagree about the set ([[one-evaluator-per-question]]). + """ + return [f for f in ((defn or {}).get('fields') or []) + if isinstance(f, dict) and f.get('type') == 'ai_enrich' + and isinstance(f.get('aiEnrich'), dict)] + + +def _field_agent_binding(field): + """Contract C3's `field.automation` bag, DERIVED from the column rather than typed beside it. + + ⛔⛔ THIS IS DERIVED ON PURPOSE AND IT IS THE ONE DESIGN CALL IN T51 WORTH ARGUING WITH. + C3 as drafted has F *store* `{kind, table, field, trigger}` — but `field` is already + `field['key']` and `trigger` is already `aiEnrich['trigger']`, so two of its four keys would + be a second copy of a fact this same dict already carries. Two copies of one fact is + [[one-question-two-normalizers]] with a guaranteed drift date: the first PATCH that changes + the trigger through the enrichment editor and not through the automation editor. + Computing it inside the SINGLE validator both write doors run means they cannot diverge. + + ⚠ `table` IS ABSENT, and that is the deviation E must know about: this function is handed one + field dict and no table context, while `_clean_field(f) == f` is asserted by two gates over + engine-seeded field lists — stamping the table key from a caller that happens to know it would + make a stored field unequal to its own validator's output. E iterates tables to build the + synthetic rows, so E already holds the key that would go here. + """ + bag = (field or {}).get('aiEnrich') + if not isinstance(bag, dict): + return None + return {'kind': 'field_agent', 'field': str((field or {}).get('key') or ''), + 'trigger': dict(bag.get('trigger') or {'mode': 'manual'})} + + +# --------------------------------------------------------------------------------------------- +# PER-CELL PROVENANCE (wave 34, W34-T51 — "stamp it FROM DAY ONE") +# --------------------------------------------------------------------------------------------- +# ⛔⛔ IT CANNOT LIVE IN THE ROW, AND THAT IS MEASURED RATHER THAN ASSUMED. `add_row` builds its +# row as `{k: str(v) for k, v in values.items() if k in valid}` where `valid` is the declared +# field keys — a row dict is field-keys-only by construction, so a reserved `__ai__` key inside +# one would be stripped on the way in. Widening that filter to admit a private key would weaken +# the wall that stops a client inventing columns. So provenance is a SIBLING STRATUM of `rows`, +# exactly as `fields` and `rows` are siblings today. +# +# ⛔ AND `stale` IS DERIVED, NEVER STORED. A stored `stale` flag needs a sweep to stay true, and a +# sweep nobody runs is [[flag-shipped-without-its-writer]]. What IS stored is the INPUT HASH the +# value was produced from; `ai_enrich_is_stale` compares it to today's inputs, so staleness is a +# question asked at read time and can never be out of date. + +#: The stratum's key inside a table document, beside `fields` and `rows`. +AI_ENRICH_MARK_KEY = 'aiEnrich' +#: What a mark can say. ⚠ `stale` is absent ON PURPOSE (see above) and so is `generating`: an +#: in-flight run is a fact about a REQUEST, not about stored data, and persisting it would leave +#: a cell stuck on "generating" forever the first time a process dies mid-run. +AI_ENRICH_STATES = ('agent', 'human', 'error') +#: A bound on the stratum, in the shape `row_limit` uses: this document is copied whole on every +#: read, so a mark set that can outgrow the rows it describes is a store problem wearing a +#: feature's name. One mark per (row, column) is the natural size; the cap is the guard against a +#: column deleted without its marks. +AI_ENRICH_MAX_MARKS = MAX_ROWS +# +# ⚠ THERE IS DELIBERATELY NO `drop_marks(column)`, AND THE ABSENCE IS THE DECISION. `delete_field` +# leaves a deleted column's CELLS in the rows on purpose — an accidental delete stays recoverable, +# and re-adding the column under the same key brings the values back. Marks must follow the same +# rule or a restored column's agent-written values would come back looking human-authored, which +# is the one distinction this whole stratum exists to make. A table that is deleted takes its +# document (and therefore its marks) with it, so neither lifecycle leaks. + + +def ai_enrich_input_hash(prompt, row, refs): + """The fingerprint a stored value was produced FROM: the prompt plus the referenced cells. + + ⚠ `refs` is passed in rather than re-parsed here, so the token vocabulary is owned by one + module (`api/ai_enrich.py::prompt_refs`) instead of two. A hash built from a second parse + would disagree with the runner's the first time the token syntax gains a form. + """ + payload = [str(prompt or '')] + for key in sorted(str(r) for r in (refs or ())): + payload.append(f'{key}={(row or {}).get(key, "")}') + return hashlib.sha256('\x1f'.join(payload).encode('utf-8')).hexdigest()[:32] + + +def ai_enrich_marks(table_key, col_id, st=None): + """`{row_id: mark}` for ONE column. Absent = a cell nobody has written yet.""" + stratum = (get(table_key, st) or {}).get(AI_ENRICH_MARK_KEY) or {} + got = stratum.get(str(col_id)) + return dict(got) if isinstance(got, dict) else {} + + +def ai_enrich_is_stale(mark, fresh_hash): + """Was this value produced from inputs that have since changed? + + A human edit is never stale — a person's own words do not go out of date because a + neighbouring cell moved. Only an agent-written value can be. + """ + if not isinstance(mark, dict) or mark.get('state') != 'agent': + return False + return bool(fresh_hash) and str(mark.get('hash') or '') != str(fresh_hash) + + +def ai_enrich_human_authored(mark, has_value=False): + """Did somebody other than the agent put this value here? + + ⛔ TWO CASES, AND THE SECOND IS THE ONE THAT GETS MISSED. An explicit `human` mark is obvious. + The other is a cell that holds a value with NO mark at all: a value typed, pasted or imported + before the column became an enrichment column. The agent did not write it, so the agent does + not own it. Without this arm, `overwrite: 'always'` would eat exactly the pre-existing work + R8's *"we are helping the team put their work into the system"* is about. + + ⚠ ONE PREDICATE, because `ai_enrich_may_write` and `api/ai_enrich.py::cell_state` both answer + "is this the human's?" and a cell the UI labels human-written while the runner overwrites it + is the worst of both ([[one-question-two-normalizers]]). + """ + if isinstance(mark, dict) and mark.get('state') == 'human': + return True + return bool(has_value) and not (isinstance(mark, dict) and mark.get('state') == 'agent') + + +def ai_enrich_may_write(mark, policy, fresh_hash, has_value=False): + """May an AUTOMATIC run write this cell? The law, in one place. + + ⛔ THE FIRST CLAUSE IS NOT CONFIGURABLE. A cell a person typed into is never overwritten by an + agent, whatever `overwrite` says — that is why `human` is not one of the `AI_ENRICH_OVERWRITE` + values, and why `always` means "always refresh what the AGENT owns" rather than "overwrite + everything". A policy that could turn it off would make the column unsafe to hand to a team. + ⚠ A MANUAL, per-cell run is a different act with a different door; this predicate governs the + automatic paths (`on_change`, `schedule`, and a bulk "all rows" sweep). + """ + if ai_enrich_human_authored(mark, has_value): + return False + if not has_value: + return True # a blank cell is fillable under every policy + if policy == 'always': + return True + if policy == 'stale': + return ai_enrich_is_stale(mark, fresh_hash) + return False # 'blank': an agent never replaces a value it wrote + + +def stamp_ai_enrich(table_key, col_id, marks, st=None): + """Write MANY marks for one column in ONE store update. Returns the number stored. + + ⛔ BULK BY CONSTRUCTION, and the signature is what enforces it. Every write here is a + read-modify-write of the whole tenant document, so a per-row stamp inside a run loop is the + same shape as the 2,000-full-document-copies problem `add_rows` exists to avoid. There is + deliberately no `stamp_one`. + """ + col_id = str(col_id) + clean = {} + for row_id, mark in (marks or {}).items(): + if not isinstance(mark, dict): + continue + state = str(mark.get('state') or '') + if state not in AI_ENRICH_STATES: + continue + entry = {'state': state, 'at': _dt.datetime.now(_dt.timezone.utc) + .replace(microsecond=0).isoformat()} + for key, cap in (('hash', 64), ('model', 60), ('error', 300)): + got = str(mark.get(key) or '').strip()[:cap] + if got: + entry[key] = got + tokens = mark.get('tokens') + if isinstance(tokens, int) and not isinstance(tokens, bool) and tokens >= 0: + entry['tokens'] = tokens + clean[str(row_id)] = entry + if not clean: + return 0 + + def _set(cur): + t = cur.get(str(table_key)) + if t is not None: + col = t.setdefault(AI_ENRICH_MARK_KEY, {}).setdefault(col_id, {}) + col.update(clean) + # The cap is enforced by DROPPING THE OLDEST, never by refusing the write: a run that + # produced values and could not record where they came from would leave the cells + # looking human-authored, which is the one state this stratum exists to distinguish. + if len(col) > AI_ENRICH_MAX_MARKS: + for rid in sorted(col, key=lambda r: str(col[r].get('at') or ''))[ + :len(col) - AI_ENRICH_MAX_MARKS]: + col.pop(rid, None) + return cur + + _st(st).update(STORE_KEY, _set, flush='async') + return len(clean) + + +def note_human_edit(table_key, values, row_id, st=None): + """A person typed into these cells: mark any `ai_enrich` column among them `human`. + + ⛔ THE STAMP HAS TO HAPPEN AT THE WRITE DOOR, not at read time, because "did a human write + this" is not recoverable from the value afterwards. Called by `routes_tables.patch_row`; + a no-op (and cheap) for the overwhelming case where the edited columns are ordinary. + """ + defn = get(table_key, st) or {} + touched = {f['key'] for f in ai_enrich_fields(defn)} & {str(k) for k in (values or {})} + for col_id in touched: + stamp_ai_enrich(table_key, col_id, {str(row_id): {'state': 'human'}}, st=st) + return sorted(touched) + + def _clean_field(raw, previous=None): """One field dict → the stored shape, or None. The single validator for create AND patch, so a column cannot be typed one way on the way in and another on the way back.""" @@ -1899,6 +2220,28 @@ def _clean_field(raw, previous=None): out['formula'] = fx elif ftype == 'formula': return None + # ⭐⭐ WAVE-34 (owner ruling R13) — THE AI ENRICHMENT BAG. Posture and inheritance copied from + # `formula` two lines up, deliberately and for the same reason: the bag defines the column, so + # the pairing is enforced both ways, and an inherited bag is only inherited while the type + # still wants it (retyping an enrichment column to text must not resurrect its prompt through + # `prev` and then refuse the whole write, which is the bug the `prev_type == ftype` guard on + # `link`/`rollup`/`formula` exists to record). + enrich_raw = (raw.get('aiEnrich') if 'aiEnrich' in raw + else (prev.get('aiEnrich') if prev_type == ftype else None)) + if enrich_raw is not None: + ae = _clean_ai_enrich(enrich_raw) + if ae is None or ftype != 'ai_enrich': + return None + out['aiEnrich'] = ae + elif ftype == 'ai_enrich': + return None + # C3 (wave 34): the Agents module reads field agents off this bag. DERIVED here, inside the + # one validator both write doors run, so the binding can never drift from the config it + # describes — see `_field_agent_binding` for why it is computed rather than stored beside it. + # ⛔ It is written AFTER the generic `automation` passthrough above, so a caller cannot hand us + # a `field_agent` bag that disagrees with the column. Every OTHER automation bag is untouched. + if ftype == 'ai_enrich': + out['automation'] = _field_agent_binding(out) return out @@ -2094,6 +2437,16 @@ def flow_bound(bag, st=None): lives in `aios_grid._clean_automation` — this guards the WRITE doors only.)""" if not isinstance(bag, dict): return True # no bag, no law — an ordinary column + # ⭐⭐ WAVE-34 (R13 / contract C3) — A FIELD AGENT IS BOUND BY ITS OWN COLUMN, NOT BY A FLOW, + # and without this arm the whole feature is unsavable. `_clean_field` stamps + # `{kind:'field_agent', ...}` on every `ai_enrich` column; `add_field` then asks this + # predicate, which read `flowId` and only `flowId` — so every AI enrichment column would have + # been refused at the create door by the guard for a different feature that happens to share + # the `automation` key. ⚠ The binding is REAL, not waived: the bag names the column it belongs + # to, and `_clean_field` derives that name from the field itself, so it cannot name a column + # that does not exist the way a stale `flowId` can name a deleted flow. + if bag.get('kind') == 'field_agent': + return bool(str(bag.get('field') or '').strip()) flow = str(bag.get('flowId') or '').strip() if not flow: return False @@ -2399,6 +2752,41 @@ def patch_cells(table_key, row_id, values, st=None): return True +def patch_many_cells(table_key, updates, st=None): + """Write cells across MANY rows in ONE store update. Returns the number of rows touched. + + ⛔ WHY THIS EXISTS RATHER THAN A LOOP OVER `patch_cells`, which is the same argument + `add_rows` makes one screen up: every write here is a read-modify-write of the WHOLE tenant + document, so filling a 1,000-row enrichment column row by row is 1,000 full-document copies + under one lock on the single uvicorn process this product runs. One `update()` writes them + all. + + ⚠ `updates` is `{row_id: {field_key: value}}`, and a row id that does not exist is SKIPPED + rather than created: this is a cell writer, not an insert door, and inventing a row here would + let a stale plan resurrect a record somebody deleted mid-run. + """ + if not is_user_table(table_key, st): + return 0 + have = set((get(table_key, st) or {}).get('rows') or {}) + clean = {str(r): {str(k): str(v) for k, v in (cells or {}).items()} + for r, cells in (updates or {}).items() + if str(r) in have and isinstance(cells, dict) and cells} + if not clean: + return 0 + + def _set(cur): + t = cur.get(str(table_key)) + if t is not None: + rows = t.setdefault('rows', {}) + for rid, cells in clean.items(): + if rid in rows: + rows[rid].update(cells) + return cur + + _st(st).update(STORE_KEY, _set, flush='sync') + return len(clean) + + def patch_link_cell(table_key, row_id, fkey, value, st=None): """Persist one user-picked Link cell in the shared row and return its canonical id string. diff --git a/web/src/assistant/AssistantPage.tsx b/web/src/assistant/AssistantPage.tsx index 4f8fa4524d03443b2ee4752dab287cae207f68a2..a5242cca8a27ae51e261b0a0f2c9ad4cc50c5862 100644 --- a/web/src/assistant/AssistantPage.tsx +++ b/web/src/assistant/AssistantPage.tsx @@ -1,20 +1,81 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { QUERY_OPEN_EVENT, signal } from "../apiContract"; import { FolderMark } from "../customer-grid/icons"; import { queryCitationLabel } from "../customer-grid/queryPreview"; import { retryEmit } from "../inbox/inboxModel"; +import { queryGroups, QueryRail } from "../query/queryParts"; import { DbIcon } from "../shell/dbFrame"; -import { databaseEntries, QUERY_ROUTE } from "../shell/nav"; +import { ASSISTANT_ROUTE, databaseEntries, QUERY_ROUTE, routeKeyOf } from "../shell/nav"; import type { NavEntry } from "../shell/nav"; import { - deleteThread, fetchQueries, submitChat, type QueryCitation, type QueryIndex, - type QueryMessage, type SavedQuery, + deleteQuery, deleteThread, fetchQueries, rateMessage, submitChat, type QueryCitation, + type QueryIndex, type QueryMessage, type SavedQuery, } from "../query/queryApi"; import "./assistant.css"; +/** + * ⭐ LAZY ON PURPOSE. `QueryPage` imports `CustomerGrid`, the largest module in the client; a + * static import here would put the whole grid in the chat bundle, so opening the Assistant to ask + * one question would download the surface it is merged WITH but not showing. The list itself + * comes from `queryParts`, which imports no grid, so the left panel renders in Query mode before + * this chunk arrives. + */ +const QueryWorkspace = lazy(() => import("../query/QueryPage")); + export interface AssistantPageProps { granted: NavEntry[]; } +/** + * ⭐⭐ R14 / CONTRACT C2 — ONE MODULE, ONE ROUTE, TWO LISTS. Owner, 2026-08-16: *"Combine the AI + * assistant module AND the Query module. In the secondary navigation at the top, just above + * '+ New chat', an easily accessible toggle between Chat / Query; the list below then shows either + * Chats or Queries."* + * + * The toggle changes WHICH LIST the left panel shows, never which route you are on: `#/assistant` + * serves both, and `#/query` redirects here carrying `?mode=query`. + */ +export type AssistantMode = "chat" | "query"; +const MODE_KEY = "aios-assistant-mode"; +const isMode = (value: unknown): value is AssistantMode => value === "chat" || value === "query"; + +/** + * The hash wins on arrival, the stored choice answers a reload. + * + * `?mode=query` is the REDIRECT'S INSTRUCTION — somebody followed a `#/query` link or bookmark — + * and a preference stored days ago must not override the link just clicked. Everything else reads + * `localStorage`, which is what makes the choice survive a reload. + * + * ⚠ THE TOGGLE STILL DOES NOT WRITE THE HASH, AND THE REASON HAS CHANGED — corrected here rather + * than left, because a stale comment that later readers quote is this wave's own worst defect. + * It USED to be a hard constraint: `Shell.tsx::read` took the whole remainder of the hash as the + * route key, so writing `#/assistant?mode=query` resolved to no route and landed on Home. B's + * `routeKeyOf` splits on `?` now (NOTE B-8), so writing it would be safe. It is still not written, + * for a smaller reason: which LIST a panel shows is a preference, not a location, and the hash is + * B's to own. The arrival parameter is consumed once, below, and stored. + * Pure and exported so the decision can be checked without a browser. + */ +export function initialMode(hash: string, stored: string | null): AssistantMode { + const mark = hash.indexOf("?"); + if (mark >= 0) { + const asked = new URLSearchParams(hash.slice(mark + 1)).get("mode"); + if (isMode(asked)) return asked; + } + return isMode(stored) ? stored : "chat"; +} + +/** A private window refuses storage; that is a lost preference, never an error the reader sees. */ +function readMode(): AssistantMode { + if (typeof window === "undefined") return "chat"; + let stored: string | null = null; + try { stored = window.localStorage.getItem(MODE_KEY); } catch { stored = null; } + return initialMode(window.location.hash, stored); +} + +function storeMode(mode: AssistantMode): void { + if (typeof window === "undefined") return; + try { window.localStorage.setItem(MODE_KEY, mode); } catch { /* see readMode */ } +} + const KIND_LABEL: Record = { grid: "table", list: "list", chart: "chart", kanban: "board", calendar: "calendar", timeseries: "time series", map: "map", @@ -30,17 +91,42 @@ const KIND_LABEL: Record = { * whatever the server sent — an unknown key falls through to itself rather than being hidden, so a * provider added server-side appears here the day it is added instead of the day someone * remembers to edit this file [[a-flag-can-ship-without-its-writer]]. + * + * ⛔ `anthropic: "Claude"` AND `openai: "OpenAI"` ARE GONE, and the reason is worth keeping because + * the ticket that removed them described the defect backwards. They were never OFFERED: the picker + * renders `index.models`, which is `model_choices()` = `[auto, *QUERY_PROVIDER_ORDER]`, so no + * reader ever saw "Claude" in this control. They were labels for providers that do not exist in + * the platform ladder, i.e. dead weight, and the fall-through above already covers a provider + * added server-side. The REAL defect the ticket named lives one layer down and is fixed by + * `modelStatus`: a model that IS offered and whose key this deployment does not hold. */ const MODEL_LABEL: Record = { auto: "Auto", cerebras: "Cerebras", groq: "Groq", openrouter: "OpenRouter", - anthropic: "Claude", openai: "OpenAI", }; const modelLabel = (key: string) => MODEL_LABEL[key] ?? key; -/** Route to the exact Query-owned artefact. C owns the destination wiring, not this page. */ -export function openBuiltView(qid: string): () => void { +/** + * Route to the exact Query-owned artefact. C owns the destination wiring, not this page. + * + * ⭐ `hosted` is R14's one change and it is a NAVIGATION suppressor, not a second path: when this + * surface is itself showing Query, there is nowhere to route TO — the workspace is already mounted + * in the main column — and setting the hash would send the reader to the standalone route we just + * merged away from. The qid contract is unchanged either way, which is the point: the same public + * hand-off answers an external link and an in-page click, so Query keeps ONE selection door. + * ⚠ The retry ladder still earns its keep here: the hosted workspace is lazy, so the event can + * fire before its chunk has finished loading and its listener exists. + */ +export function openBuiltView(qid: string, hosted = false): () => void { if (typeof window === "undefined") return () => {}; - if (window.location.hash.replace(/^#\/?/, "") !== QUERY_ROUTE) window.location.hash = `#/${QUERY_ROUTE}`; + // ⛔ `routeKeyOf`, NOT `hash.replace(...)` (B's NOTE B-14). Since T15 the hash is + // `#/assistant?mode=query`, so the whole-remainder idiom yields the key `assistant?mode=query`, + // which is never `query` — the guard was permanently true and its intent ("do not navigate, we + // are already there") had become unsatisfiable by any hash. Latent rather than live, because the + // one caller passes `hosted`, and fixed rather than left because a guard that cannot be + // satisfied is the kind a later reader trusts. + if (!hosted && routeKeyOf(window.location.hash) !== QUERY_ROUTE) { + window.location.hash = `#/${QUERY_ROUTE}`; + } return retryEmit(() => signal(QUERY_OPEN_EVENT, { qid })); } @@ -54,10 +140,25 @@ export function CitationLine({ citation }: { citation: QueryCitation }) { return {queryCitationLabel(citation)}; } -export function AssistantMessage({ message, view, citations, onPreview }: { +/** One glyph, drawn once and turned over for the other. R20 asked for thumbs; these are thumbs. */ +function ThumbIcon({ down = false }: { down?: boolean }) { + return ( + + ); +} + +export function AssistantMessage({ message, view, citations, onPreview, onRate }: { message: QueryMessage; view?: SavedQuery; citations: QueryCitation[]; onPreview: (id: string) => void; + /** Optional so the message can be rendered outside a live conversation without a rating door. */ + onRate?: (id: string, rating: "up" | "down" | null, reason?: string) => void; }) { const [openSources, setOpenSources] = useState(false); + const [askWhy, setAskWhy] = useState(false); + const [why, setWhy] = useState(""); const matching = citations.filter((citation) => (message.citationIds || []).includes(citation.id)); if (message.role === "user") { return ( @@ -85,6 +186,38 @@ export function AssistantMessage({ message, view, citations, onPreview }: { {`Open the ${KIND_LABEL[view.kind] ?? view.kind} in Query`} ) : null} + {/* ⚠ The thumbs are NOT a survey: the server reads a thumbs-down back into the next turn of + this thread. That is why a down asks for one short reason and why both are clearable. */} + {onRate ? ( +
+ + + {message.rating === "down" && !askWhy && message.ratingReason ? ( + {message.ratingReason} + ) : null} +
+ ) : null} + {onRate && askWhy && message.rating === "down" ? ( +
{ + event.preventDefault(); setAskWhy(false); onRate(message.id, "down", why.trim()); + }}> + setWhy(event.currentTarget.value)} /> + +
+ ) : null} {matching.length ? (
+ ))} +
+ {mode === "query" ? ( + /* The SAME rail the standalone page renders; only its host changed, so the differences + are CSS in assistant.css and nothing here. */ + void removeQuery(id)} onChanged={upsertQuery} /> + ) : ( + <> - ); - - // ── the centre column ──────────────────────────────────────────────────────────────────── - const chip = () => { - if (!chosen) return null; - if (!configured) - return ( - - Finish configuration - - ); - if (trigger?.paused) - return Paused; - if (picked && picked.ready === false) - return Not set up; - const last = automation.runs?.[0]; - if (last) - return ( - - {last.ok ? "Last run succeeded" : "Last run failed"} - - ); - return null; - }; - - const actionCard = (a: Action, depth: number, hasNext = false): ReactNode => { - const row = catalog.find((c) => c.kind === a.kind); - const isGroup = a.kind === "group"; - const branches = isGroup ? groupBranches(a) : []; - /** - * ⭐ THE PERMANENT FIRST STEP (owner ruling 2026-08-06). An Instagram search produces rows - * and has nowhere to put them until something writes them, so Create record is step 1 and - * stays. DERIVED from the same rule the server enforces (`ig_action_pinned`) rather than - * from a stored flag — and the control is HIDDEN rather than disabled, because the server - * re-inserts the action on the next save, so a delete button here would appear to work and - * then silently undo itself. - */ - const pinned = - automation.kind === "discover_instagram" && depth === 0 && a.id === actions[0]?.id; - /** - * ⭐⭐ WAVE 32 · T45 (owner item 10) — CONFIGURED / UNCONFIGURED, and the SERVER decides. - * - * ⛔ NOT COMPUTED HERE. The same `engine.action_needs` that fills this list also refuses the - * run (`400 action_unconfigured`, and `run_now` for the tick and the webhook), so a card - * cannot claim Configured over an action the run will reject. A client-side "does it look - * filled in" test would be a second rule that agrees until one of them learns a new key. - * ⚠ Nested cards get it too: the list is keyed by ACTION ID over the whole flow, branches - * included, and a step inside an If / then is the one a person is least able to see. - */ - const needs = (automation.unconfigured || []).find((u) => u.id === a.id)?.needs || []; - /** Only TOP-LEVEL cards reorder (see `moveAction`) — depth 0, and never while a write is up. */ - const canDrag = depth === 0 && !busy && !pinned; - return ( -
-
{ - draggedRef.current = true; - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData(ACTION_DRAG_TYPE, a.id); - } - : undefined - } - onDragOver={ - canDrag - ? (e) => { - // Somebody else's drag — a file, a nav row, selected text — is not ours to - // accept. Without this test the card would light up for anything dragged over - // it and then swallow the drop. - if (!e.dataTransfer.types.includes(ACTION_DRAG_TYPE)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - setDragOver(a.id); - } - : undefined - } - onDragLeave={ - canDrag - ? (e) => { - // Moving onto a CHILD of this card is not leaving it; without this the - // highlight flickers off every time the pointer crosses the title. - if (e.currentTarget.contains(e.relatedTarget as Node)) return; - setDragOver((cur) => (cur === a.id ? "" : cur)); - } - : undefined - } - onDrop={ - canDrag - ? (e) => { - const dragId = e.dataTransfer.getData(ACTION_DRAG_TYPE); - setDragOver(""); - if (!dragId) return; - e.preventDefault(); - moveAction(dragId, a.id); - } - : undefined - } - onDragEnd={() => { - setDragOver(""); - // Cleared a tick later: the post-drag click arrives BEFORE this would run. - window.setTimeout(() => { - draggedRef.current = false; - }, 0); - }} - > - - {pinned ? ( - - Always first - - ) : ( - - )} -
- - {/* - ⭐ THE FORK (owner item 12a, ruling R8, contract C-FORK). A group used to hold ONE - nested column; it now holds a LETTERED LANE PER BRANCH, because R8 rules that a fork - occupies one step number and its legs are alternatives rather than later steps. - - THE LETTER AND THE "Otherwise" ARE THE SERVER'S WORDS — `br.label`, printed, never - composed. `clean_actions` letters by index and names a null-cond last leg "Otherwise", - so the lanes re-letter themselves correctly when one is deleted. - The nesting ceiling is still the SERVER's `maxGroupDepth`, so the add-here affordance - disappears at exactly the depth `clean_actions` refuses. - */} - {isGroup ? ( -
- {branches.map((br) => ( -
-
- {br.label} - - {/* ⚠ NOT A RENDERED CONDITION — the tree is edited in Properties, and a - second editable copy here would be two controls for one fact. This says - only WHICH leg you are looking at. A null cond is the catch-all, and the - server has already labelled it, so this says nothing twice. */} - {br.cond ? "when its conditions match" : "everything else"} - - {branches.length > 1 ? ( - - ) : null} -
-
- {(br.actions || []).map((k) => actionCard(k, depth + 1))} - {depth + 1 < (vocab?.maxGroupDepth ?? 2) || !(br.actions || []).length ? ( - - ) : null} -
-
- ))} - -
- ) : null} - - {/* - THE ARROW BACK INTO THE FLOW (item 12a). The lanes are alternatives and exactly one of - them runs — the engine takes the first matching branch and breaks — so they REJOIN, and - without a mark saying so a fork at the end of a column reads as several parallel endings. - Drawn only when there IS a next step to rejoin: an arrow into nothing is a promise the - flow does not keep. - */} - {isGroup && hasNext ? ( - - ) : null} -
- ); - }; - - return ( - <> -
- {/* ── TRIGGER ─────────────────────────────────────────────────────────────────── */} -
-
- Trigger - {chip()} -
-
- {chosen && options.length ? ( -
- -
- ) : ( - /* - THE EMPTY STATE (image 1): a dashed add-box and the server's own suggested - triggers. One line of chrome, no tour (R13) — the list IS the explanation, and - it is the server's list so it cannot describe a trigger we removed. - */ - <> - {/* THE BOX AND ITS MENU ARE ONE UNIT (see `addWrap`). The menu USED to render at - the bottom of this component, outside `.autox-flow` entirely, and became a - third column of the flex row that holds the canvas and Properties — the - owner's ERROR 4. */} -
- - {/* ⭐ WAVE 25 item 5b (C2) — `TriggerPicker`, the SAME component Properties → - Trigger details renders in its compact form. Category first (Time / - Database / Connector) with connector rows nested under their product. */} - {picking ? ( - setPicking(false)} - /> - ) : null} -
- {options.length ? ( -
-

Suggested triggers

- {options - // ⭐ 2026-08-07 (owner ruling) — MANUAL IS OFFERED AGAIN. It was excluded - // (`t.key !== "manual"`) because picking it stored NOTHING and bounced - // straight back to this empty state — "a control that undoes itself is - // worse than no control", correct while that was true. `clean_trigger` now - // STORES `{key:'manual'}`, so the pick sticks, `chosen` goes true and - // Configuration opens. Hiding it now would leave the one trigger a person - // most expects to find as the only one they cannot choose. - .filter((t) => t.ready !== false && !t.planned) - .slice(0, SUGGESTED) - .map((t) => ( - - ))} -
- ) : ( -

This server did not offer a trigger list.

- )} - {/* ONE LINE (R13), and it is what is TRUE of this state rather than a caption - for the box above it: with nothing chosen, Run now is the only thing that - starts this automation. Airtable's empty state means "it cannot run"; ours - does not, and saying so is the difference between a screen that is honest - and one that merely looks the same. */} -

Until then, only Run now starts it.

- - )} -
-
- - {/* ── ACTIONS ─────────────────────────────────────────────────────────────────── */} -
-
- Actions -
-
- {actions.map((a, i) => actionCard(a, 0, i < actions.length - 1))} - - {/* - ⛔ THE MACHINE STEPS ARE NOT CARDS ANY MORE (owner item 5, contract C-CFG). They - were engine-derived read-only cards sitting under the owner's own actions — "fetch - the page", "Bright Data", "Anonymous", "Write" — so the centre column mixed two - different things: what the AUTOMATION is, which nobody chose and nobody can reorder, - and what the OWNER added, which is the whole subject of this builder. Their - configuration and their switches moved to Properties → Configuration → - "How this fetches", one section per panel. - - ⚠ THE ENGINE STILL RUNS THEM. `graph()` emits the same nodes; this surface simply - stops DRAWING them, which is why the switches had to move rather than go — R7 names - Bright Data (money) and Write's dry run (reads and reports without writing a row) as - controls that must survive the cards that carried them. - */} - - - - {/* THE ACTION MENU (images 7/8): the server's catalog, grouped by its own `group` - key, with `ready:false` rows faded and carrying the server's reason. A shorter - menu would imply those actions do not exist — the owner asked to see all of - them, and `clean_actions` refuses the unready ones at the door, so faded is a - wall rather than a decoration. */} - {adding ? ( -
- {menuGroups.map((g) => ( -
-

{g.key}

- {/* ⭐ WAVE 27 · ITEM 33 / C4 — the group's OWN rows first, then one nest per - connector. `
` rather than a hand-built disclosure: it IS the - chevron-and-submenu the reference shows, it opens on Enter and Space - without a keydown handler, and it needs no open/closed state of its own - to get wrong. Closed by default — the reference shows collapsed rows, - and a connector nobody is using should cost one line, not six. */} - {[...g.rows, ...g.sub].map((entry) => ("rows" in entry ? ( -
- - {/* ⭐ WAVE 30 · ITEM 7 / R4 — THE COLLAPSED ROW GETS AN IDENTITY. - This is the row the owner clicks before choosing Instagram or - TikTok, and until now it was the only row in the menu with - nothing on its left: the chevron, then a bare word. The mark is - resolved from the connector KEY, so a connector the server adds - tomorrow gets a slot for free and simply renders no logo. */} - {brandForConnector(entry.key) ? ( - - {brandForConnector(entry.key)} - - ) : null} - - {entry.label} - - {entry.rows.length} action{entry.rows.length === 1 ? "" : "s"} - - - - {entry.rows.map((c) => actionRow(c))} -
- ) : actionRow(entry)))} -
- ))} -
- ) : null} -
-
-
- - {/* ── PROPERTIES ──────────────────────────────────────────────────────────────────── */} - - - {/* THE CONFIRM (image 6). A trigger change can invalidate the configuration under it, so - it ASKS — and it says what it will cost rather than "are you sure". */} - {confirmKey ? ( -
-

Change the trigger?

-

- Anything configured for {picked?.label || triggerKey} is dropped. -

-
- - -
-
- ) : null} - - {/* ⛔ THE PICKER USED TO RENDER HERE, and here is outside both columns. `.auto-work` is a - flex row of `.autox-flow` + `.autox-props`, so a third child was laid out as a third - COLUMN — the menu appeared past the right edge of the Properties panel and squeezed the - canvas until the suggested triggers wrapped one word per line (owner's ERROR 4). It is - rendered beside its own "+ Add trigger" box now; see `addWrap`. */} - - ); -} - -/** One action's subtitle — what it will DO, composed from its own config. */ -function actionSub(a: Action, tables: UserTable[]): string { - const cfg = a.config || {}; - if (a.kind === "update_record") { - const keys = Object.keys((cfg as { values?: Record }).values || {}) - .filter(Boolean); - return keys.length ? `Sets ${keys.join(", ")}` : "No values yet"; - } - if (a.kind === "create_record") { - const t = String((cfg as { table?: string }).table || ""); - return t ? `Into ${tableLabel(tables, t)}` : "No database yet"; - } - if (a.kind === "find_records") { - const t = String((cfg as { table?: string }).table || ""); - return t ? `In ${tableLabel(tables, t)}` : "No database yet"; - } - return ""; -} - -function findAction(list: Action[], id: string): Action | null { - for (const a of list) { - if (a.id === id) return a; - // ⚠ THROUGH THE BRANCHES (C-FORK). This read `config.actions`, which a fork no longer has — - // so selecting any action inside one would have found nothing and the Properties panel would - // have said "that action is no longer part of this flow" about a card visibly on screen. - if (a.kind === "group") - for (const br of groupBranches(a)) { - const hit = findAction(br.actions || [], id); - if (hit) return hit; - } - } - return null; -} - -/* - * ⛔ `nodePanel()` IS GONE (item 5). It rendered ONE machine node's panel, chosen by the card the - * reader had clicked, and included a "that step is no longer part of this automation" branch for - * a selection the graph had since dropped. Both facts died with the cards: the panels are now - * rendered together under "How this fetches", keyed by PANEL rather than by node id, so there is - * no selection left to go stale. - */ - -// ── the Properties panel's two faces ───────────────────────────────────────────────────────── - -function TriggerProps({ - automation, - triggerKey, - options, - picked, - table, - tables, - ops, - nullaryOps, - vocab, - oauth, - busy, - nodes, - onToggleNode, - renderNodeBody, - onAskChange, - onPatchTrigger, - onPatchConfig, - onPickTrigger, - onRunNow, - scheduleFace, - schedules, - chosen, -}: { - automation: Automation; - triggerKey: string; - options: TriggerOption[]; - picked: TriggerOption | null; - table: UserTable | null; - tables: UserTable[]; - ops: string[]; - nullaryOps: string[]; - vocab?: FlowVocab; - oauth: OAuthStatus | null; - busy: boolean; - /** The engine's machine steps (item 5) — their switches and bodies live under Configuration. */ - nodes: GraphNode[]; - onToggleNode: (nodeId: string) => void; - renderNodeBody: (panel: string) => ReactNode; - /** Ask before a trigger change that can invalidate the config under it (image 6). */ - onAskChange: (key: string) => void; - onPatchTrigger: (patch: Record) => void; - /** Writes the automation's OWN config — the database a table-less trigger has nowhere - * else to name (owner report 2026-08-07). Spreads on the way in; see `patchConfig`. */ - onPatchConfig: (patch: Record) => void; - onPickTrigger: (key: string) => void; - onRunNow: () => void; - scheduleFace: ReactNode; - schedules: boolean; - /** - * ⭐ WAVE 25 item 5b (ruling R14) — HAS A TRIGGER BEEN PICKED AT ALL? - * - * REQUIRED and passed IN, never re-derived: it is the same `chosen` the centre column gates its - * empty state on, and the honest test is what the DEFINITION holds rather than what the picker - * displays (`triggerKey` derives "manual" whenever nothing is stored, so anything computed from - * it is true for every automation ever — a mistake this file already made once and only a - * screenshot caught). - */ - chosen: boolean; -}) { - const trigger = automation.trigger || null; - const provider = picked?.connect?.provider || ""; - const connected = !!(provider && oauth?.[provider]?.connected); - const startUrl = picked?.connect?.startUrl || ""; - const needsTable = !!trigger && "table" in trigger; - /** - * ⭐⭐ 2026-08-07 — DOES THIS AUTOMATION HAVE TO NAME ITS OWN DATABASE? - * - * True for a `plain` automation whose trigger carries no table — which INCLUDES the state where - * no trigger is stored at all, and that inclusion is the whole fix. `clean_trigger` stores - * nothing for `manual`/`schedule` by design, so those automations hold `trigger: null`, `chosen` - * is false, and W25/R14's "Configuration does not render until a trigger is picked" hid the - * Database picker below from the exact automations that cannot get a database any other way. - * The owner hit it twice: *"when the Trigger is Manual, it says that I need to bound it to a - * database, how?"* — and the honest answer was that there was no how. - * - * ⚠ It is the SAME condition the picker itself renders on, named once and used twice, so the - * section cannot open without the control or the control appear without its section. - */ - const needsOwnTable = automation.kind === "plain" && !needsTable; - const last = automation.runs?.[0]; - /** ITEM 22 / D-70 / R12 — the shared Run guard (see `runBlock`). */ - const runState = runBlock(automation); - - return ( - <> -

Trigger details

-
- {/* - ⭐ WAVE 25 item 5b (C2) — THE NATIVE ` onPatchTrigger({ table: e.target.value })} - data-role="trigger-table" - > - - {trigger?.table && !tables.some((t) => t.key === trigger.table) ? ( - - ) : null} - {tables.map((t) => ( - - ))} - -
- ) : null} - - {/* - ⭐⭐ 2026-08-07 (owner report) — THE DATABASE A TABLE-LESS TRIGGER HAS NOWHERE ELSE TO NAME. - Owner: *"how come, enrich instagram when standalone 'manual' trigger, doesn't have a - database that it should point to? dont make this mistake again."* - - MEASURED across the trigger vocabulary: `manual`, `schedule` and `email` carry NO `table`, - so `needsTable` is false and the picker above never rendered — while `AutomationDetail`'s - Properties picker is gated to `scrape_db` / `field_instagram`, BOTH RETIRED KINDS. So - **nothing in the client wrote `config.targetTable` for a `plain` automation**, and - `run_plain` answered *"no database is bound yet — pick one on the trigger, or in - Properties"*: a refusal naming two doors, neither of which existed. Every scheduled or - manual flow — the owner's *"on a schedule, enrich these sets of influencer names"* — was - unbuildable. - - ⛔ `plain` ONLY, and that is what keeps it from being a second control for one fact. Every - other kind names its database somewhere of its own: an Instagram search through the pinned - Create record (W25/R2 made that action authoritative and `targetTable` follows it), and the - two retired kinds through their own Properties panels. Widening this would put two pickers - on one key and let them disagree, which is the exact defect R2 was written to end. - */} - {needsOwnTable ? ( -
- - -

- The records this automation’s steps walk. A trigger like Manual or a schedule does - not name one on its own. -

-
- ) : null} - - {triggerKey === "event_field" ? ( - <> - {/* - ⛔ "WATCHED COLUMN" IS GONE (owner item 7, C-TRIG law 4). "When a record matches - conditions" is a FILTER, and a watched column was a second, different question - answered in the same box — the trigger fired on a column changing AND on the - conditions holding, which is two triggers wearing one name. - ⚠ THE SERVER WENT FIRST, and that ordering was the point: `clean_trigger` stopped - reading `field` (C posted law 4 done) BEFORE this control came out. Deleting the - control while the validator still read the key would have left a stored watched - column that nothing displays and nothing can clear — and deleting the key while the - control still showed it would have made every Save silently drop what the user - picked. The CONDITION is what completes this trigger now: with none, it rides - `configured:false` and says so. - */} -

- * Conditions -

- onPatchTrigger({ when: next })} - fields={table?.fields || []} - ops={ops} - nullaryOps={nullaryOps} - maxDepth={vocab?.maxCondDepth ?? 3} - maxChildren={vocab?.maxCondChildren ?? 12} - disabled={busy} - /> - - ) : null} - - {triggerKey === "record_updated" ? ( -
- - -

Select none to fire on any column.

-
- ) : null} - - {triggerKey === "enters_view" ? ( -
- - {/* - ⭐ FOUR STATES, AND THREE OF THEM ARE SENTENCES (owner item 8, C-TYPES). - `views` now rides `GET /automations/tables`, so this control is live — but "drop the - note" would have collapsed it to two states and rebuilt the exact defect the contract - forbids. Absent and empty are DIFFERENT FACTS: - · no database chosen -> choose one first - · `views` ABSENT -> the server offered nothing (a server older than this - client). Saying "no views" here would be a claim - nobody measured. - · `views` present and [] -> this database genuinely has none. That IS measured, - and it is the one that tells the reader to go and - make a view rather than to go and find an admin. - · non-empty -> the picker. - */} - {!table ? ( -

Choose a database first.

- ) : !table.views ? ( -

This server did not offer a view list for that database.

- ) : !table.views.length ? ( -

- That database has no saved views yet — make one on its grid and it appears here. -

- ) : ( - whose `value` matches no - ) : null} - {table.views.map((v) => ( - - ))} - - )} -
- ) : null} - - {triggerKey === "email" ? ( -
- - onPatchTrigger({ query: e.target.value })} - /> -
- ) : null} - - {triggerKey === "webhook" && trigger?.token ? ( -
- - -

Minted once. It survives every other change to this trigger.

-
- ) : null} - - {/* THE SCHEDULE, rendered by the one component that owns the cron round-trip. - ⚠ NOT `triggerKey === "schedule"` ANY MORE (item 7, C-TRIG). `ig_profile_match` watches - nothing — it MAKES rows, on the cron — so its Configuration carries these controls - "always", and the caller says which keys those are. */} - {schedules ? scheduleFace : null} - {triggerKey === "manual" ? ( -

- It runs when you press Run once now, and nothing else starts it. -

- ) : null} - - )} - - {/* - ⭐ HOW THIS FETCHES (owner item 5, contract C-CFG) — the machine steps' switches and - config bodies, below the trigger's own fields, under ONE sub-heading. - - ⛔ ONE SECTION PER PANEL, NOT PER NODE (`groupByPanel`, and its note is the reason). Four - of `field_instagram`'s nodes share `panel: "capture"` and both of `discover_instagram`'s - share `panel: "find"`, so a section per node would print one body four times and mount the - discovery filter twice against a single `preds` array. - - ⛔ AND THE SWITCHES ARE THE POINT, not a leftover. R7: Bright Data buys exact counts with - MONEY and Write's switch is the difference between a run that writes rows and one that - reads and reports. They were on the cards this wave deleted, so deleting the cards without - moving them would have retired two shipped controls silently. Each still posts to the - node-toggle door — the server decides what a switch means, this only says which node. - - `plain` automations have no machine nodes at all, so the whole section is absent for them - rather than an empty heading (R6 makes that the common case from now on). - - ⭐ WAVE 26 · ITEM 17 / R15 — THE PROSE IS GONE AND THE SWITCHES STAYED, which is the whole - ruling. The owner quoted this section back verbatim — *"How this fetches / Find profiles / - Bio contains skincare… / Collect results / Takes about 20 minutes / Up to 10 profiles · - about $0.025"* — and asked for it gone. Every line of that quote is server-composed - (`automation_engine.graph`), and it maps EXACTLY onto three renderers, which is why the - deletion could be surgical rather than a section removal: - · `n.subtitle` -> "Bio contains skincare…" and "Takes about 20 minutes" (`:5030`, `:5036`) - · `g.nodes[0].detail` -> "Up to 10 profiles · about $0.025" (`:5032`) — the ESTIMATE LINE - · `n.title`/`

` -> "How this fetches", "Find profiles", "Collect results" — KEPT - ⛔ THE HEADING WAS A SYMPTOM, NOT THE SUBJECT. Deleting the section would have retired the - Bright Data MONEY switch and the write node's DRY RUN with it — the two controls that were - moved here precisely so deleting the cards would not retire them silently. They are the - `n.toggle` block below and they are untouched. - ⚠ `AutomationFind`'s "What it costs" estimate is NOT this line and is NOT in scope: it is - the ANSWER to a button a person pressed, and it is the only surface carrying D-23's - SPEC-basis caveat. The read-only paragraph went; the control did not. - */} - {nodes.length ? ( - <> -

How this fetches

- {groupByPanel(nodes).map((g) => ( -
- {g.nodes.map((n) => ( -
- - - - - {n.title} - - {/* ⭐ ITEM 18 / R14 — the per-node status dot stood here and is deleted. The - node already carries its own `ActionMark` above; a coloured dot beside it was - the second thing on one row claiming to say what this step is. */} - {n.toggle ? ( - - ) : null} -
- ))} - {/* ⭐ ITEM 17 / R15 — THE ESTIMATE LINE STOOD HERE. `g.nodes[0].detail` is where - "Up to 10 profiles · about $0.025" reached the screen (composed at - `automation_engine.py:5032`), and the owner named it. It was the engine's own - sentence rather than a client paraphrase, which is why it was defensible and - why it still had to go: an accurate paragraph nobody asked for is still the - architecture explaining itself (DESIGN.md §4). - ⚠ The COST question keeps a home — "What it costs" in the Find panel below, - behind a press, carrying its own SPEC caveat. This line stated a price nobody - had asked for beside a control that was not about price. */} - {renderNodeBody(g.panel)} -
- ))} - - ) : null} - -

Test step

- {/* - ⛔ HONEST TO OUR SEMANTICS, not to Airtable's. Airtable's "Test step" replays one step - against a chosen record. Ours has no step-replay: the engine runs a whole automation. So - the control says what it actually does — RUN IT — and the results below are the last - real run's, from the run log, rather than a rehearsal nobody performed. - */} - {/* ⭐ ITEM 22 / D-70 / R12 — THE SECOND DOOR ONTO THE SAME MONEY, guarded by the SAME - function. It was `!!automation.running`, which is process state and therefore false for - the entire 20-30 minute vendor wait; `runBlock` is the one definition both buttons read, - so a fix cannot land on one and miss the other. */} - - {runState.blocked && !automation.running ? ( -

{runState.why}

- ) : null} - {last ? ( - <> -

- {last.ok ? "Last run succeeded" : "Last run failed"} -

-

- {last.ts.replace("T", " ").slice(0, 16)} — {last.summary} -

- - ) : ( -

It has not run yet.

- )} - - ); -} - -/** - * ⭐ WAVE 26 · ITEM 9 / R11 — "+ New database", INSIDE the action's own database picker. - * - * Owner ruling R11, verbatim: *"name it and go. No dialog stack; columns follow what the action - * writes (how the IG presets already work)."* So this is a name box and a button, in place, and - * it selects what it creates — never a modal over a modal, and never a trip to another surface - * that loses the action being configured. - * - * ⛔ IT DOES NOT INVENT THE ROW IT JUST MADE. `POST /tables` answers `{key}` alone, so the - * component asks the caller to RE-LIST and only then selects the key. Splicing a client-built - * `UserTable` in would put a guessed `rowCount` and an empty `fields` on screen beside real ones, - * and the picker renders `rowCount` — a fabricated measurement, which is the one thing this - * codebase refuses everywhere else. - * - * ⚠ THE REFUSAL IS THE SERVER'S, PRINTED VERBATIM. `POST /tables` has real refusals a person can - * hit — an empty name, the `MAX_TABLES` ceiling, an unavailable store — and each answers with its - * own sentence. Re-deriving "you have too many databases" here would be a second copy of a rule - * that lives on the other side of the wall ([[schema-role-is-not-a-value-wall]]: find the line - * that ENFORCES it, and let that line do the talking). - */ -function NewDatabase({ - disabled, - onCreated, -}: { - disabled: boolean; - /** - * Called with the new key AFTER the caller's table list has been re-read. - * ⚠ IT MAY RETURN A PROMISE AND THE CALLER MUST AWAIT IT. Selecting the key before the list - * contains it makes the picker's own "stored value is always an option" guard paint - * "(not visible to you)" about the database the user just created — the guard doing exactly its - * job, on a race, and saying something alarming and false. - */ - onCreated: (key: string) => Promise | void; -}) { - const [open, setOpen] = useState(false); - const [name, setName] = useState(""); - const [busy, setBusy] = useState(false); - const [err, setErr] = useState(""); - - const create = () => { - const label = name.trim(); - if (!label || busy) return; - setBusy(true); - setErr(""); - createTable(label) - .then(async (r) => { - setOpen(false); - setName(""); - // AWAITED: the caller re-reads the table list, and only then may the key be selected. - await onCreated(r.key); - }) - .catch((e) => { - // The server's own words. `AutomationError` carries the composed sentence; anything else - // is a transport failure and says so rather than blaming the name. - setErr( - e instanceof AutomationError && e.message - ? e.message - : "could not create it — the workspace did not answer" - ); - }) - .finally(() => setBusy(false)); - }; - - if (!open) { - return ( - - ); - } - return ( -
- setName(e.target.value)} - // Enter creates, Escape backs out — the two keys a one-field form owes its user. - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - create(); - } else if (e.key === "Escape") { - setOpen(false); - setErr(""); - } - }} - /> - - - {err ?

{err}

: null} -
- ); -} - -/** - * ⭐⭐ WAVE 33 · W33-T52 (owner item 16) — THE WEB ACTION'S CONFIGURATION, which did not exist. - * - * One component for all five kinds rather than five arms, because the kinds differ ONLY in which - * keys they carry — and that list already exists, once, in `WEB_SEEDS`. A per-kind arm would have - * been a fifth place the same key set is written down, and the previous four copies are why this - * ticket exists at all. - * - * ⛔ THE ORDER OF THE CONTROLS IS `WEB_SEEDS`' OWN KEY ORDER. Not a second list: an object literal - * preserves insertion order in every engine this ships to, so the table that says which keys a - * kind has also says what a person reads first. Adding a key to the seed adds its control. - * ⛔ THE RED MARK IS THE SERVER'S, not this file's. `vocab.actionRequired[kind]` is - * `ACTION_REQUIRED` on the wire — the same table `unconfigured_actions` and the run refusal read — - * so a control marked optional here is a control the runner will start on. With no vocabulary - * (a server older than this client) NOTHING is marked, which is the honest degradation: inventing - * a requirement the save door does not have would refuse a step that would have run. - * ⚠ EXPORTED for the same reason `ActionProps` is: a panel's defects (a control bound to no key, - * two controls bound to one key, a checkbox that paints unchecked over a stored `true`) are - * invisible to a source grep and need the real thing mounted. - */ -export function WebActionConfig({ - kind, - cfg, - required, - walkFields, - walkTable, - busy, - setCfg, -}: { - kind: string; - cfg: Record; - /** `ACTION_REQUIRED[kind]` off the wire — `[]` when the server did not send it. */ - required: { phrase: string; key: string }[]; - walkFields: { key: string; label: string; type: string }[]; - /** `""` when no record walks this flow — the R10 sentence, not an empty picker. */ - walkTable: string; - busy: boolean; - setCfg: (patch: Record) => void; -}) { - const shape = WEB_SEEDS[kind]; - if (!shape) return null; - const req = new Set(required.map((r) => r.key)); - const text = (v: unknown) => (v === undefined || v === null ? "" : String(v)); - const mark = (key: string) => (req.has(key) ? * : null); - - /* Every control is UNCONTROLLED (`defaultValue` + `onBlur`), which is this file's text idiom — - see the `find_records` limit box. It matters more here than there: `setCfg` merges into the - action and re-renders the whole builder, so a controlled input would round-trip the tree on - every keystroke of a CSS selector. */ - const line = (key: string, label: string, hint = "", mono = false) => ( -
- - setCfg({ [key]: e.target.value })} - /> - {hint ?

{hint}

: null} -
- ); - const flag = (key: string, label: string) => ( - - ); - - return ( - <> - {Object.keys(shape).map((key) => { - if (key === "url") - /* ⛔ THE HINT IS PER KIND, because the answer is. `web_goto` REQUIRES an address (the - server marks it), while the other four act on the page the journey is already on and - an address is how you START one — leaving it blank is the normal case for a step that - follows another. The first draft said "The page this step opens" on all five, which - reads as mandatory on the four where it is not, and invites a person to paste the same - URL into every step of a journey (found by this ticket's own verifier). */ - return line("url", "Web address", - (kind === "web_goto" - ? "The page this step opens." - : "Leave this blank to act on the page the flow is already on. Fill it in only to " - + "start somewhere new.") - + " Braces read a column off the record this flow is walking, so {{Website}} visits a " - + "different page for every row."); - if (key === "waitFor") - return line("waitFor", "Wait for this to appear first", - "A CSS selector. The step waits for it before reporting success — useful on a page " - + "that fills itself in after it loads. Leave blank to not wait.", true); - if (key === "selector") - return line("selector", "CSS selector", - "What on the page to act on — for example h1, .price, or #email.", true); - if (key === "value") - return line("value", "Value to type", - "Braces work here too, so {{Email}} types each row's own address."); - if (key === "hint") - return line("hint", "What to look for", - "Describe the thing in words. This step proposes a selector for it and writes the " - + "proposal to the run log; it changes nothing on the page."); - if (key === "attr") - return line("attr", "What to take", - "Leave this as `text` to take what a person would read. Name an attribute instead — " - + "href, src — to take that."); - if (key === "all") return flag("all", "Take every match, not just the first"); - if (key === "secret") - return flag("secret", "Hide this value in the run log (for a password or a token)"); - if (key === "dryRun") - return flag("dryRun", "Rehearse only — find the element and report what it would do"); - if (key === "field") { - /* ⛔ R10's RULE, and this key is the one place on the panel it applies. The value is a - column on the database the flow WALKS — `apply_actions` writes it back onto that row — - so with no walking record there is no column to choose and a picker with no options - would read as "this database has no columns". The sentence points at the surface that - can fix it, exactly as the condition editor below does. */ - if (!walkTable) - return ( -
- -

- This flow has no record to write to — give it a database on the trigger - first. -

-
- ); - const cur = text(cfg.field); - return ( -
- - ` whose `value` matches no - ` - ) : null} - {walkFields.map((f) => ( - - ))} - -
- ); - } - if (key === "timeoutMs") - return ( -
- - { - const n = Math.min(WEB_TIMEOUT_MAX, - Math.max(WEB_TIMEOUT_MIN, Number(e.target.value) || 20000)); - e.target.value = String(n); - setCfg({ timeoutMs: n }); - }} - /> -

- Between {WEB_TIMEOUT_MIN.toLocaleString()} and {WEB_TIMEOUT_MAX.toLocaleString()}. - Anything outside is pulled in. -

-
- ); - return null; - })} -

- This step runs in a browser job of its own, which takes about 10 to 30 seconds. A run stops - after 50 of them. -

- - ); -} - -/** - * ⭐ WAVE 28 — EXPORTED so the include-row render suite can mount the real panel. - * - * ⛔ THE EXPORT IS THE POINT, not a convenience. R6's three include rows are markup whose defect - * modes are invisible to a source grep: a row that renders with no key attached, a Profile - * checkbox that paints unchecked, two rows bound to the same key. `ReviewProps` below is already - * exported for the same reason, so this is the file's existing shape rather than a new one. - * `_test/` never ships (`deploy_web.py` excludes `_test/` and `_`-prefixed files both). - */ -export function ActionProps({ - action, - pinned, - catalog, - tables, - onTablesChanged, - walkFields, - walkTable, - ops, - nullaryOps, - vocab, - busy, - onEdit, -}: { - action: Action | null; - /** - * ⭐ THE PERMANENT STEP 1 OF AN INSTAGRAM SEARCH (owner report, 2026-08-06). It is the ONE - * action whose `values` and `uniqueOn` the engine never reads: `apply_actions` drops - * `actions[0]` for `discover_instagram` and the runner does the writing itself, upserting the - * profile columns on `(handle, created_by)`. - * - * The card stays — "this search puts what it finds in a database" is true and worth showing — - * but a `* Values` editor above `{{column_key}} reads that column off the record walking the - * flow` is a required-looking control over a step nothing walks, and the owner spent a session - * trying to make sense of it. Worse, it was ANSWERABLE: remapping it to `name: {{handle}}` - * saved cleanly, changed nothing, and left the panel describing a write the engine does not - * perform. A picture of the engine that disagrees with the engine is the defect this file - * refuses in five other places. - */ - pinned: boolean; - catalog: ActionCatalogRow[]; - tables: UserTable[]; - /** ITEM 9 / R11 — re-read the list after this panel's picker creates one. REQUIRED; see Props. */ - onTablesChanged: () => Promise | void; - /** - * ⛔ THE WALKING RECORD'S COLUMNS — the trigger's database, resolved by the builder. - * - * These three surfaces were built with `fields={[]}` and it made R3's headline feature - * unauthorable: a conditional group's condition, an action's `when`, and `update_record`'s - * column picker all offered "Choose a field…" and nothing else. An empty list is not a - * neutral default here — it is a picker with no options, which reads as "this database has - * no columns". - */ - walkFields: { key: string; label: string; type: string }[]; - /** - * ⭐ WAVE 26 · ITEM 5 / R10 — DOES A RECORD WALK THIS FLOW AT ALL? The key of the database it - * walks, or `""`. - * - * ⛔ REQUIRED, and it answers a question `walkFields` structurally cannot. Both a flow with no - * walking record and a flow walking an empty database hand this panel `[]`, and R10 wants - * OPPOSITE renderings for them: a sentence pointing at the trigger for the first, an ordinary - * (empty) picker for the second. - * ⚠ VERIFIED AGAINST THE RUNNER, not inferred from the trigger list — which C4 forbids by name - * ([[loopable-wave24]]: `CRON_DRIVEN_TRIGGERS` was exactly that mistake and became D-55). - * `run_flow` returns immediately on `if not table_key` and otherwise walks that table's rows - * evaluating `lane_match(act["when"], row)`, so a non-empty table key is EXACTLY the condition - * under which `when` is ever consulted. The builder derives it from `_flow_table`'s own - * precedence (`AutomationDetail.tsx`'s `walkTable`, whose comment carries that rule). - */ - walkTable: string; - ops: string[]; - nullaryOps: string[]; - vocab?: FlowVocab; - busy: boolean; - onEdit: (change: (a: Action) => Action) => void; -}) { - if (!action) return

That action is no longer part of this flow.

; - const row = catalog.find((c) => c.kind === action.kind); - const cfg = action.config || {}; - const setCfg = (patch: Record) => - onEdit((a) => ({ ...a, config: { ...a.config, ...patch } })); - const values = (cfg as { values?: Record }).values || {}; - const target = tables.find((t) => t.key === String((cfg as { table?: string }).table || "")); - const valueFields = action.kind === "create_record" ? target?.fields || [] : walkFields; - /** - * The saved views of the database the flow's records WALK — the enrich step's optional filter. - * - * ⚠ Read off `walkTable`, never off `target`: `target` is the Create-record action's own - * destination and answers a different question, and on an Instagram discovery flow the two are - * routinely different tables. Same trap `walkFields` carries its own note about. - * ⚠ `undefined` is a THIRD STATE and is preserved as one (`UserTable.views` is absent until the - * server sends it) — the panel says "no view list was offered" rather than drawing an empty - * picker that reads as "this database has no views". - */ - const walkViews = (tables.find((t) => t.key === walkTable) || null)?.views; - - return ( - <> -

{row?.label || action.kind}

- {row?.detail ?

{row.detail}

: null} - -

Configuration

- - {action.kind === "group" ? ( - <> - {/* - ⭐ ONE CONDITION EDITOR PER BRANCH (C-FORK). The group's own `config.cond` is GONE — - a fork has a condition per leg, not one for the whole thing — so an editor bound to - `cfg.cond` would now write a key `clean_actions` drops on the floor: the tree would - look saved, survive a reload from local state, and be absent the next time anyone - opened the automation. - THE HEADING IS THE SERVER'S LABEL. The last leg may carry no condition, and the - server has already named it "Otherwise" — giving it one here turns it into a lettered - branch, which is a real thing to want and needs no separate control. - */} - {groupBranches(action).map((br, i) => ( -
-

- {br.label} - {br.cond ? " — run these actions if…" : " — everything that reaches here"} -

- - onEdit((a) => ({ - ...a, - config: { - ...a.config, - branches: groupBranches(a).map((x) => - x.id === br.id ? { ...x, cond: next } : x - ), - }, - })) - } - fields={walkFields} - ops={ops} - nullaryOps={nullaryOps} - maxDepth={vocab?.maxCondDepth ?? 3} - maxChildren={vocab?.maxCondChildren ?? 12} - lead="" - disabled={busy} - /> -
- ))} -

- The first branch whose conditions match is the one that runs — the record does not go - down two of them. -

- - ) : null} - - {action.kind === "create_record" ? ( -
- - ` whose `value` matches no ` - ) : null} - {tables.map((t) => ( - - ))} - - {/* ITEM 9 / R11 — mint one without leaving the action being configured. */} - { - await onTablesChanged(); - setCfg({ table: key }); - }} - /> - {/* - ⭐ WAVE 25 item 4 (ruling R2b, contract C1, wiring W25-4) — WHAT POINTING IT HERE DOES. - The two lists are the SERVER's (`GET /automations/presets`), diffed against whatever - database this action names — R2 makes `config.table` authoritative, so a hand-made one - answers exactly like a spawned one. - */} - -
- ) : null} - - {/* WHAT THE SEARCH ACTUALLY WRITES, in place of the two controls below that do not apply to - it. ⚠ THE LIST IS `_candidate_row`'s, field for field: a shorter "the profile columns" - would be safe and useless, and a list that drifts from what the engine writes would be - the same lie the value map was. */} - {pinned ? ( -

- The search writes one row per profile it finds — handle, profile link, name, followers, - following, average engagement, bio, link in bio, verified and category — into that - database. It matches on the handle, so a profile found again updates its row instead of - adding another. There is nothing to map: the columns come from the search. -

- ) : null} - - {/* - ⭐ WAVE 25 item 2 (ruling R1a, contract C5, wiring W25-5) — KEEP RECORDS UNIQUE. - Today's Create record APPENDS FOREVER: `_commit_action_writes` mints `max(id)+1`, so any - scheduled flow using it duplicates a row per run — every night, silently, until somebody - looks at the table. `uniqueOn` names the column to upsert on instead. - - ⛔ `""` IS A REAL CHOICE AND IT IS THE DEFAULT, so no stored automation changes meaning the - day this ships. That is why the empty option is worded as a behaviour ("Add a new record - every time") rather than as an absence ("None"): it describes what will happen, which is - the thing the reader is choosing between. - - ⚠ THE COLUMN LIST IS THE TARGET DATABASE'S, not the walking record's — `valueFields` - already resolves that for `create_record`. And the server REFUSES a `uniqueOn` this action - does not write ("it writes: …"), so the honest client move is to offer the columns it - writes FIRST while still allowing the rest: pre-empting the refusal here would be a second - copy of a server rule, and this file's law is that the server owns legality. - */} - {action.kind === "create_record" && !pinned ? ( -
- - -

- {String((cfg as { uniqueOn?: string }).uniqueOn || "") - ? "A run that finds a matching record updates it instead of adding another." - : "Every run adds a row, even if the same one is already there."} -

-
- ) : null} - - {/* - ⭐ WAVE 25 item 1 (rulings R3/R4, contract C4) — THE ENRICH ACTION'S CONFIGURATION. - It replaces the whole `field_instagram` KIND: enriching a profile is something you do TO a - record, not a species of automation. The switches are that kind's, unchanged in meaning, - because they are the ones that decide what a run COSTS. - - ⛔ NO `profileField` PICKER HERE, DELIBERATELY, and it is not an omission. C3's profile - FLAG on the column is what binds this action (R7 — one flagged text field per table), and - the server resolves it at run time; an empty `profileField` is the A3 stored-inert state, - not an error. A second picker would let a reader choose a column the flag does not name, - which is two sources of truth for one binding — and the flag is the one the engine reads. - */} - {isEnrich(action.kind) ? ( - <> - {/* - ⭐⭐ 2026-08-07 (owner ruling) — WHICH RECORDS THIS STEP SPENDS ON. - Owner: *"how many records with what sort, based on a Filtered view or maybe top N - enrichment sorted by date"*. Every enrichment is a vendor call, so an enrich step - without this panel bills for EVERY record in the database on EVERY run — which is why - these controls shipped in the same change as the fix that made the action clickable at - all, rather than after it. - - ⛔ THE LIMIT IS A QUOTA OF WORK DONE, NOT A WINDOW OF ROWS READ — the owner's own - clarification: *"if a user choose to enrich 30 and from that sorted list of 30, 20 is - enriched last 30 days, then it goes to next list"*. The server walks PAST every skipped - record until the quota is filled, so the number in this box is a cost ceiling that - means the same thing every run. `enrich_selection` owns the rule; this panel never - re-implements it. - */} -

Records to enrich

-
- - ` whose - `value` matches no ` - ) : null} - {(walkViews || []).map((v) => ( - - ))} - - {/* ⛔ AND IT SAYS SO IN WORDS, not only as an option label. The option is what stops - the picker lying; the sentence is what tells somebody the automation is broken and - what to do — an option nobody opens the dropdown to read is a fix for the code and - not for the person. */} - {(cfg as { fromView?: string }).fromView && walkViews - && !walkViews.some((v) => v.id === (cfg as { fromView?: string }).fromView) ? ( -

- This step is bound to a view that no longer exists, so its runs walk no records. - Pick another view, or choose every record. -

- ) : null} - {/* ⚠ ABSENT vs EMPTY, the `UserTable.views` third state. "This server did not offer a - view list" is a different fact from "this database has no views", and rendering an - empty picker for the first one is the silent-empty defect wave 23 gated. */} - {!walkViews ? ( -

No view list was offered for that database.

- ) : null} -
-
- - -
-
- - -
-
- - setCfg({ limit: Number(e.target.value) || 25 })} - /> -

- Records enriched per run, at most 100. Skipped records do not use up the limit — - the run keeps going down the list until it has enriched this many. -

-
- - {/* ⛔ THE DAYS BOX ONLY EXISTS WHILE THE RULE IS ON. A number that configures nothing is - worse than a missing one ([[wrong-parent-not-broken-control]]) — and this one would - read as a cost guard that is running while it is switched off. */} - {(cfg as { skipRecent?: boolean }).skipRecent ? ( -
- - setCfg({ skipRecentDays: Number(e.target.value) || 30 })} - /> -

- Days. A profile enriched inside this window is passed over, and the run moves on to - the next one — so you never pay twice for the same profile, and the history - still gains a point once the window closes. -

-
- ) : null} - - {/* - ⭐⭐ WAVE 28 · R5 / R6 / R7 — THE SOURCE QUESTION IS GONE, AND THREE INCLUDE AXES - REPLACE IT. What stood here was a `Source` select writing `config.tier` - (Anonymous / Paid provider) plus a sibling `noFallback` checkbox — ONE control in two - pieces, asking the user to choose a VENDOR STRATEGY. - - ⛔ R5 RETIRED THE QUESTION, not just the control. Enrichment routes per capability to - the paid providers and reports blocked on a refusal; there is no thin anonymous row to - fall back to, so "which source" and "stop if it fails" no longer have answers a person - could give. `tier` and `noFallback` are accepted-and-ignored in stored configs (C2, - and D-65's law: never 400 a definition that was legal when it was written) — which is - why this panel simply stops writing them rather than migrating anything. - - ⛔ THE ROWS BELOW ARE A TRANSFORM, NOT AN ADDITION, and that distinction is the whole - defect risk in this change. `postMetrics` and `commentMetrics` ALREADY had checkboxes - 34 lines below this point ("Also capture per-post engagement" / "…per-comment…"). - Building three NEW rows and leaving those would have put TWO controls on each key — - which compiles, renders, and satisfies any check asking whether a Post-data switch - exists. The old pair is deleted; these carry their keys. - - ⚠ KEYS UNCHANGED ON PURPOSE (C2). Every stored enrich action round-trips untouched; - only the labels move. R7: no cost sentence anywhere in this panel — the run log keeps - honest spend reporting, and a warning printed permanently is chrome the eye stops - reading (DESIGN.md §4). - */} - {/* ⚠ A GROUP HEADING, NOT A `