diff --git a/RELEASES.json b/RELEASES.json index d8599631cca054b9b1035b067e9c08741217e3a1..8547e9e0971501878b103ea790350042e7463b34 100644 --- a/RELEASES.json +++ b/RELEASES.json @@ -1,5 +1,5 @@ { - "current": "5d8aacc", + "current": "b89a27e", "releases": [ { "version": "v53", diff --git a/VERSION b/VERSION index e03c05b51e054a064aea221e718b8c2f8b206f44..1aafb340eb295c078e1be0fadcd8e5111f89bcd2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5d8aacc +b89a27e diff --git a/api/automation_engine.py b/api/automation_engine.py index 92e522efb35c400abb654023a45bee6d1b25a796..be7c7acdc3ee1476bbe013d65bf80ee35f76da2e 100644 --- a/api/automation_engine.py +++ b/api/automation_engine.py @@ -156,6 +156,9 @@ TT_POST_SNAPSHOTS_TABLE = "ut_tt_post_snapshots" TT_COMMENTS_TABLE = "ut_tt_comments" AUTOMATION_RECORD_MODE = "automation" +# C2/R3: a scheduled route snapshots an existing map route. Keep this transport ceiling aligned +# with the map/router ceiling without importing a client module into the automation engine. +ROUTE_STOP_CAP = 100 #: ⚠ 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, @@ -179,7 +182,13 @@ APPEND_TABLES = frozenset({IG_SNAPSHOTS_TABLE, IG_POST_SNAPSHOTS_TABLE, #: `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") +KINDS = ("plain", "scrape_db", "field_instagram", "discover_instagram", "discover_tiktok", + "route_delivery") +#: Kinds exposed by the generic Automation rail. A scheduled route is a real automation, but it +#: has no generic editor: it is created and configured from the Map route index, which owns the +#: immutable View snapshot and Inbox recipient. Showing it here would offer a door that cannot +#: correctly configure it. +PICKER_KINDS = tuple(kind for kind in KINDS if kind != "route_delivery") #: ⭐⭐ 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 @@ -252,7 +261,8 @@ RETIRED_KIND_REPLACEMENT = { KIND_LABELS = {"plain": "Agent", "scrape_db": "Web page to database", "field_instagram": "Instagram profile column", "discover_instagram": "Find Instagram profiles", - "discover_tiktok": "Find TikTok profiles"} + "discover_tiktok": "Find TikTok profiles", + "route_delivery": "Scheduled route"} #: ⭐⭐ WAVE 34 · CONTRACTS C3 + C4 — WHY A SYSTEM AGENT CANNOT BE DELETED, one sentence per slug. #: #: ⛔ ONE PLACE, because two doors ask this question (the DELETE route refuses with it, and the @@ -1643,6 +1653,57 @@ def clean_config(kind, raw, previous=None): raw = raw if isinstance(raw, dict) else {} prev = previous if isinstance(previous, dict) else {} + # W43C-T03 / C2 — a scheduled route is a FROZEN delivery, not a second view + # evaluator. Its source-view identity and revision are provenance; the ordered stops are + # the exact route the recipient will receive when the cron fires. Keeping this in the + # automation definition gives it the existing native schedule, run history and single + # scheduler without teaching the generic field persistence layer about routes. + if kind == "route_delivery": + snap = raw.get("routeSnapshot") if isinstance(raw.get("routeSnapshot"), dict) else {} + source_view = str(snap.get("sourceViewId") or "").strip()[:120] + revision = str(snap.get("sourceViewRevision") or "").strip()[:128] + recipient = str(raw.get("recipient") or "").strip()[:120] + timezone = str(snap.get("timezone") or raw.get("timezone") or "UTC").strip()[:80] + source_name = " ".join(str(snap.get("sourceViewName") or "").split())[:120] + route_field = str(snap.get("routeField") or "").strip()[:120] + try: + links_per_map = int(snap.get("mapsPerLink", raw.get("mapsPerLink", 10))) + except (TypeError, ValueError): + links_per_map = 10 + # R5: Google directions requires at least an origin and destination. The map UI exposes + # the same 2–10 bound; this server normalisation is the wall for hand-written bodies too. + links_per_map = max(2, min(10, links_per_map)) + ordered = [] + seen = set() + for raw_pid in snap.get("orderedStopIds") or []: + try: + pid = int(raw_pid) + except (TypeError, ValueError): + continue + if pid > 0 and pid not in seen: + ordered.append(pid) + seen.add(pid) + if not recipient: + return None, "choose the Inbox recipient for this route" + if not source_view or not revision: + return None, "a scheduled route needs the source View and its frozen revision" + if len(ordered) < 2: + return None, "a scheduled route needs at least two ordered stops" + if len(ordered) > ROUTE_STOP_CAP: + return None, f"a scheduled route may contain at most {ROUTE_STOP_CAP} stops" + return { + "recipient": recipient, + "routeSnapshot": { + "sourceViewId": source_view, + "sourceViewRevision": revision, + "sourceViewName": source_name or source_view, + "timezone": timezone or "UTC", + "routeField": route_field, + "orderedStopIds": ordered, + "mapsPerLink": links_per_map, + }, + }, None + def flag(name): return bool(raw[name]) if name in raw else bool(prev.get(name)) if kind == "plain": @@ -1659,7 +1720,12 @@ def clean_config(kind, raw, previous=None): target = UT_PREFIX + target return {"targetTable": target, "targetLabel": _s(raw.get("targetLabel"), 60).strip()}, None - if kind == "scrape_db": + if kind == "route_delivery": + snap = cfg.get("routeSnapshot") or {} + body = (f"deliver {len(snap.get('orderedStopIds') or [])} frozen route stops from " + f"{snap.get('sourceViewName') or snap.get('sourceViewId') or 'a View'} to " + f"{cfg.get('recipient') or 'an Inbox'}") + elif kind == "scrape_db": url = _s(raw.get("url"), 2000).strip() if not url: return None, "a source URL is required" @@ -8271,6 +8337,38 @@ def seed_statements_agent(rt, username="system"): return defn +def run_route_delivery(rt, defn, username="automation", log=print, step=_no_step, rows=None): + """Deliver one immutable C2 route snapshot to its assignee's Inbox. + + The runner deliberately never reads a View or recalculates stops. A schedule is a promise to + send the route that was saved, not a second evaluator whose answer can drift when the source + View changes overnight. `core.alerts.notify` is the existing tenant-scoped Inbox writer; it + is passed the runtime explicitly so scheduler work cannot land in tenant #0 by accident. + """ + cfg = defn.get("config") or {} + snap = cfg.get("routeSnapshot") if isinstance(cfg.get("routeSnapshot"), dict) else {} + recipient = str(cfg.get("recipient") or "").strip() + stops = [int(pid) for pid in (snap.get("orderedStopIds") or [])] + if not recipient or len(stops) < 2: + return ("error", "the saved route delivery no longer has a recipient and at least two stops", + {}, [], {}) + title = str(defn.get("name") or snap.get("sourceViewName") or "Scheduled route").strip() + source = str(snap.get("sourceViewName") or snap.get("sourceViewId") or "the saved View") + cap = int(snap.get("mapsPerLink") or 10) + detail = (f"{len(stops)} frozen stops from {source}. Google Maps links use up to {cap} stops " + f"per link; timezone {snap.get('timezone') or 'UTC'}.") + step(f"Delivering {len(stops)} frozen stops to {recipient}'s Inbox") + try: + from core import alerts + alerts.notify(recipient, title, topic="automation", key=str(defn.get("id") or ""), + row_id=str((defn.get("schedule") or {}).get("enabledAt") or "route"), + detail=detail, actor=str(defn.get("createdBy") or ""), st=rt) + except Exception as exc: # the run must name a delivery failure; pretending it delivered is worse + return ("error", f"the route could not be delivered to Inbox ({type(exc).__name__})", {}, [], {}) + return ("ok", f"Delivered {len(stops)} frozen route stops to {recipient}'s Inbox.", + {"stops": len(stops), "inboxDelivered": 1}, stops, {}) + + 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 @@ -8408,7 +8506,7 @@ def run_plain(rt, defn, username="automation", log=print, step=_no_step, rows=No counts, ids, {}) -RUNNERS = {"plain": run_plain, "scrape_db": run_scrape_db, +RUNNERS = {"plain": run_plain, "route_delivery": run_route_delivery, "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 diff --git a/api/rollup_sql.py b/api/rollup_sql.py index d4c5f1d653462f312c5f5b1cd561972659115923..21955a30993b5565b1d05741a7b5781c69515d63 100644 --- a/api/rollup_sql.py +++ b/api/rollup_sql.py @@ -39,35 +39,7 @@ for _p in (str(_HERE), str(_PLATFORM)): class RollupSourceError(Exception): - """The rollup could not be computed HONESTLY — no cells are written when this is raised. - - ⛔⛔ THE ORIGINAL EXCEPTION IS CARRIED AS `.cause`, AND THAT ATTRIBUTE IS THE CONTRACT. - `group_values` wraps whatever the semantic layer raises, and it used to keep only - `f"{type(e).__name__}: {e}"` — the TYPE survived as TEXT and nothing else. A caller wanting to - tell "you named a column this topic does not have" (a 400 naming the column) apart from "this - topic has no store binding" (a 503) then had no choice but to match words in a sentence, which - is exactly the discriminator amendment A8 exists to forbid: that sentence is copy that reaches - a screen, so it WILL be reworded, and the branch stops firing without a word. - - ⚠ `__cause__` is set as well (every wrap keeps its `from e`), but it is NOT the contract. - `__cause__` is traceback chaining, dropped silently by the first later `raise - RollupSourceError(...)` written without `from`; a named attribute set at the raise site cannot - go missing that quietly, and `isinstance(err.__cause__, X)` is a DIFFERENT contract from the - one A8 wrote. - - ⚠ `cause` DEFAULTS TO None and the message argument is unchanged, so single-argument - construction and `str(err)` behave exactly as before — `verify_odoo_relational.py`'s - `except R.RollupSourceError` needs no edit. The two refusals raised by this module's own logic - (an unresolvable window, a truncated group set) have no underlying exception, so they leave it - None: `.cause is None` means "this module refused", not "nothing was wrapped". - - Test it as `isinstance(err.cause, semantic.UnknownColumnError)`, NEVER as - `"UnknownColumnError" in str(err)`. - """ - - def __init__(self, message, cause=None): - super().__init__(message) - self.cause = cause + """The rollup could not be computed HONESTLY — no cells are written when this is raised.""" def _ut(): @@ -118,10 +90,7 @@ def group_values(bag, today=None): date_from=date_from, date_to=date_to, limit=sem.MAX_GROUPS, today=today) except Exception as e: # noqa: BLE001 - # ⛔ `cause=e` IS THE POINT OF THIS LINE, not decoration. The message keeps the type name - # for a human reading a log; a CALLER branches on `err.cause`, never on that text. See - # `RollupSourceError`'s docstring for why the text is not a discriminator. - raise RollupSourceError(f"{type(e).__name__}: {e}", cause=e) from e + raise RollupSourceError(f"{type(e).__name__}: {e}") from e if res.get("truncated"): # ⛔ THE REFUSAL THAT MATTERS. See the module header: a truncated GROUP set is a wrong diff --git a/api/routes_automation.py b/api/routes_automation.py index 9e5a2684f67b4b8d871c22e42e83b4422045ccdd..78c12961b6b0907cf7e33ce9de452a209ac68eb4 100644 --- a/api/routes_automation.py +++ b/api/routes_automation.py @@ -844,14 +844,14 @@ def list_automations(session: Session = Depends(_GATE)): 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 + # ⭐ WAVE 24 — DERIVED from the engine's public `PICKER_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], + for k in engine.PICKER_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 diff --git a/api/routes_customers.py b/api/routes_customers.py index 16c95516d07c36bd1d51b5ce99a2cd434c06e367..9f8c043552fa6123492d744425e667cbbd60a2f3 100644 --- a/api/routes_customers.py +++ b/api/routes_customers.py @@ -87,8 +87,8 @@ def _pool_for(rt, team_id, agent): rt.pool_cache[key] = snap # seed, so the memo stamps stay coherent return snap[1] raise err(503, "connector_paused", - "this data source is paused and no snapshot exists for your scope. " - "An admin can resume it under Settings, then Connectors.") + "this data source is paused and no snapshot exists for your scope — " + "an admin can resume it under Settings → Connectors") def _build(): # ⚠ THE SCOPE GOES INTO THE BUILDER. `pool(agent_name, team_id)` is the same reconciled @@ -203,32 +203,7 @@ def shared_cells(pids, st=None): return {} -def _canonical_field_keys(): - """The keys THE CANONICAL CONTRACT declares, read from `aios_grid.FIELDS`. - - ⭐ W42-T46 — this is the discriminator `_merge_shared_fields` narrows its precedence rule on, - and the one `grid_assembly` tells `contract` from `personal` with when it lends C10's strata - map. Derived from the contract list itself rather than from a mark on the definition, because - every mark on a definition survives being copied out of the document it came from (the whole - of `field_permissions.field_stratum`'s docstring is that proof). - - ⛔⛔ THE IMPORT IS LOCAL, LIKE EVERY OTHER `aios_grid` USE IN THIS FILE (nine of them), AND - LEAVING IT OUT WAS A LIVE DEFECT RATHER THAN AN UNTIDINESS. `routes_customers` never imports - `aios_grid` at module scope, so a bare `aios_grid.FIELDS` here raised `NameError` on EVERY call - -- and `_merge_shared_fields` calls this unconditionally, on the main grid read path. - ⚠ WHAT MADE IT INVISIBLE IS THE INTERESTING HALF: `routes_nav.nav_schema` wraps its customer - branch in `except Exception: fields = []`, whose own comment calls that fail-closed and correct. - So the NameError did not surface as an error; it surfaced as a schema drawer serving an EMPTY - field contract, and the only thing that noticed was `verify_api.py:786` indexing `[0]` of it. - A lenient except turned a typo into a silent empty payload. - """ - import aios_grid - - return {str(f.get("key") or "").strip() - for f in (aios_grid.FIELDS or ()) if isinstance(f, dict)} - - -def _merge_shared_fields(fields, defs, session=None, strata=None): +def _merge_shared_fields(fields, defs, session=None): """`fields` PLUS the tenant-wide columns this topic declares — `routes_tables._ut_shared_fields` on the customer topic. @@ -237,74 +212,15 @@ def _merge_shared_fields(fields, defs, session=None, strata=None): closure's reach otherwise and carries the hidden value out wearing a second name. It is also the only order in which `field_grant_hidden` can ever see the `granted` marker at all — merge afterwards and the per-field wall is inert while every test still passes. - - ⭐⭐ W42-T46 / R15 / R16 — THE PRECEDENCE RULE, NARROWED, AND IT IS THE ROOT CAUSE OF - INSTRUCTION 17. The old rule was flat: `if k in have: continue`, so a viewer holding ANY - same-key entry silently discarded the tenant-wide definition and read their own. Their own was - never marked shared, so it filed as private, which is the owner's *"Gabriel created the Route - and it ends up being MY field"* verbatim: a route IS a field (`kind: "route_order"`, key - `route_`) minted tenant-wide, and it landed in every other account's private list. - - The rule is now: - - * a key the canonical contract declares AND the incoming list already carries -> the LOCAL - entry wins, exactly as before. This is the original guard's whole purpose: a shared overlay - may never redefine a built-in column. - * a key the incoming list carries that the contract does NOT declare (a user-created column, - a route among them) -> the SHARED definition wins, in place, carrying its own `createdBy` - so `field_class` reports its real owner. - * a key the incoming list does not carry -> projected and appended, exactly as before. - - ⛔ THE CANONICAL GUARD IS CONDITIONAL ON `k in have`, AND THAT IS DELIBERATE RATHER THAN - SLOPPY. Skipping a canonical key unconditionally would DROP a key that is projected today - (a contract key absent from the incoming list), and which keys are present is a WALL question, - not a precedence one. Under this rule the outgoing key set is `incoming | (defs - have)` for - every input, byte for byte what it was. - - ⚠ SUPERSEDED, NOT DELETED (R16), AND IT IS STRUCTURAL. This helper is a read-time projection: - it writes nothing, and the viewer's own definition stays in their workspace member exactly as - `TableStore.save_field` left it. A caller recovers it by reading the workspace member before - the merge (`aios_grid.workspace_wire`'s output, the `fields` argument here). What rides on the - wire is a THIN marker under `superseded`, never a copy of the local definition: a nested - definition would be copied verbatim into a fork by `grid_events.field_upsert` - (`base = field_by_key[key]` then `{**base, ...}`) and persisted, and a nested `formula` would - sit outside `hidden_keys`' closure, which walks entries and not nested dicts. - - ⭐ `strata` IS AN OUT-PARAMETER, filled per key with C10's stratum for the definition that - WON: `contract` | `shared` | `personal`. `field_permissions.field_class` cannot derive it (see - `field_stratum`), so the assembly that did the reading lends it. Only `grid_assembly` passes - one; the return type is unchanged for every other call site. + ⚠ A key the canonical contract already declares WINS. A shared column is an ADDITION to this + database's contract, never a redefinition of a column it already has. """ - incoming = list(fields or ()) - canonical = _canonical_field_keys() - lent = strata if isinstance(strata, dict) else None - - # Index the incoming list ONCE, and mark each of its keys with the stratum it was read from: - # a key the contract declares is `contract`, anything else came out of this viewer's own - # workspace member and is `personal`. Keys are stripped because `field_classes` strips both - # sides of its lookup, and an unstripped entry would silently fall back instead of failing. - have = {} - for idx, f in enumerate(incoming): - if not isinstance(f, dict): - continue - k = str(f.get("key") or "").strip() - if not k or k in have: - continue - have[k] = idx - if lent is not None: - lent[k] = "contract" if k in canonical else "personal" - if not defs: return fields + have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)} projected = [] for k, f in defs.items(): - k = str(k or "").strip() - if not k: - continue - idx = have.get(k) - if idx is not None and k in canonical: - # THE CANONICAL GUARD, UNCHANGED. A shared column is an ADDITION to this database's - # contract, never a redefinition of a column it already has. + if k in have: continue item = dict(f, source="overlay", shared=True) if session is not None: @@ -314,82 +230,8 @@ def _merge_shared_fields(fields, defs, session=None, strata=None): is_admin=session.admin, st=session.runtime) if role: item["sharedRole"] = role - if idx is None: - projected.append(item) - else: - local = incoming[idx] if isinstance(incoming[idx], dict) else {} - item["superseded"] = { - "stratum": "personal", - "owner": str(local.get("createdBy") or "").strip() or None, - "label": str(local.get("label") or "").strip() or None, - } - incoming[idx] = item - if lent is not None: - lent[k] = "shared" - return incoming + projected - - -def _mark_offerability(fields): - """Every `route_order` column marked NOT offerable in a field picker, and nothing else touched. - - ⭐⭐ W42-T53 / CONTRACT C11 / R20 / R23 — THE NAVIGATION COMPLAINT, MEASURED: ~64 route - columns on a 53-field grid, each carrying ~21 filled stops against ~3,620 blanks (99.4% - empty), every one of them in every field picker on the topic. - - ⛔⛔ THIS IS PRESENTATION. IT IS NOT A PERMISSION AND IT IS NOT A DELETION, AND THE - DIFFERENCE IS THE WHOLE TICKET. The columns are still SERVED: W42-T20's projection reads - them to build its one column, and `route_order_write` still validates a full 1..N - permutation per column. Filtering them out of the payload would leave the projection with - nothing to project and take the route planner's data off the wire, and R20 keeps the 64 as - the store underneath precisely so that nothing has to be migrated irreversibly on live. - So this adds ONE key to an entry and never removes an entry. - - ⛔ KIND-CHECKED, NEVER PREFIX-CHECKED, the rule `_split_route_updates` and - `route_order_delete` both state: `ROUTE_KEY_PREFIX` is a naming convention and a convention - is not a declaration. - - ⭐ AND THE `kind` IS READ OFF THE **SERVED ENTRY**, NOT OFF `shared_fields`. After W42-T46 - narrowed the precedence rule a route key reaches the outgoing list two ways, and `kind` - rides the entry in both: projected from the tenant-wide stratum, and replaced IN PLACE over - a viewer's own same-key entry (the `superseded` branch). Both go through - `dict(f, source="overlay", shared=True)`, which copies the stored definition whole. Reading - the entry rather than the `defs` dict also means this helper is honest about the list it was - actually handed, instead of about a second document that may not describe it. - - ⚠ THE ONE SHAPE IT CANNOT SEE, STATED RATHER THAN GLOSSED: a route key that exists ONLY in a - viewer's own workspace member, with no tenant-wide definition left to supersede it. - `aios_grid.fields_from_workspace` rebuilds a user-created column from a STRICT ALLOWLIST - (`_field_extras` plus a fixed key set) and `kind` is not in it, so such an entry arrives with - no kind at all and is not marked. That is not the ~64 this ticket is about -- those are all - tenant-wide -- and marking by key prefix instead would break the rule this whole file keeps - (a convention is not a declaration). Recorded so the next reader does not assume coverage - that is not there. - - ⭐ THE `routes` KEY PASSES THROUGH (C11). W42-T20's pre-set projection column is the ONE - column a person picks a route with, so withholding it would remove the feature this ticket - exists to deliver. It is exempted by KEY, because that key is what C11 fixes; whether T20 - gives it `kind: "route_order"` or no kind at all (the `cohort_field` precedent carries no - kind), it comes out offerable either way. - - ⚠ ABSENT-MEANS-OFFERABLE IS PRESERVED BY NEVER STAMPING `True`. A non-route column leaves - this function carrying no `offerable` key whatsoever, so the served bytes for every field - that predates the flag are unchanged and no stored fixture moves. - - ⛔ A NEW DICT PER COLUMN, NEVER AN IN-PLACE STAMP, the same rule `class` and `usage` state a - few lines below: these entries are per-request copies today, and writing through one would - make that a dependency rather than a coincidence. - - Returns a list with the SAME keys in the SAME order as the input. - """ - out = [] - for f in (fields or ()): - if (isinstance(f, dict) - and f.get("kind") == ROUTE_KIND - and str(f.get("key") or "").strip() != ROUTE_PROJECTION_KEY): - out.append(dict(f, **{OFFERABLE_KEY: False})) - else: - out.append(f) - return out + projected.append(item) + return list(fields or ()) + projected def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "", @@ -480,13 +322,7 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str = # ⭐⭐ W38-T20 — AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this # position is load-bearing twice, once for the transitive closure and once because it is the # only order in which the per-field grant marker is ever presented to `hidden_keys`. - # ⭐⭐ W42-T46 / C10 — AND IT FILLS THE STRATA MAP ON THE WAY THROUGH. `_strata` is keyed by - # field key and covers EVERY key in the outgoing list by construction: the merge marks each - # incoming key as it indexes it and re-marks any key whose shared definition wins. Nothing - # between this line and the `field_classes` call below adds or renames a column, so lending it - # there is a complete map rather than a partial one. - _strata = {} - fields = _merge_shared_fields(fields, _defs, session=session, strata=_strata) + fields = _merge_shared_fields(fields, _defs, session=session) # ⭐⭐ W41-T01 / RULING R5 / CONTRACTS C1 + C8 — **THE THREE BADGES, STAMPED ONCE, HERE.** # # C1 makes `field_permissions.field_class` the ONE producer of `{origin, audience, sharedBy, @@ -525,24 +361,12 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str = # definition would be one account's answer served to the next reader of the same cached # structure. The copy makes that impossible to reintroduce rather than merely untrue now, which # is the same rule `_payload` states over `rows_src`. - # ⛔ `agg` belongs to another ticket, and C8 is explicit that an absent key means "not built - # yet", never "false". - # ⭐⭐ W42-T14 / C4 — C8's `usage` IS NO LONGER ONE OF THEM; it is stamped a few lines below, - # in this same window between the merge and the wall. Until then this topic sent `class` with - # no `usage` while every `ut_*` grid sent `usage` with no `class`, and - # `filter-kit/fieldClass.ts::deleteImpactTitle(fc, usage)` composes its delete warning as two - # independent halves and DROPS whichever argument is null -- so both surfaces rendered half a - # sentence with every gate on both sides green. + # ⛔ AND ONLY `class`. C8's `usage`, `agg` and `descriptionEdited` belong to other tickets, and + # C8 is explicit that an absent key means "not built yet", never "false". try: from core import field_permissions as _fp - # ⭐⭐ W42-T46 / W42-T47 / R15 / C10 — `strata=_strata` IS THE LENDER T47 WAITED FOR, and - # without this argument T47 is a no-op: `field_stratum`'s fallback cannot see a fork and - # names one `shared`, because `{**base}` copies `shared`/`granted`/`source` into the fork - # verbatim. This assembly is the one place that KNOWS which document each definition came - # out of, so it is the only honest source of the fact. _classes = _fp.field_classes(fields, session.uname, table_key=_shared_key(), - grant_topic=SHARE_TOPIC, values_shared=set(_defs), - strata=_strata, st=rt) + grant_topic=SHARE_TOPIC, values_shared=set(_defs), st=rt) fields = [dict(f, **{"class": _classes[f["key"]]}) if isinstance(f, dict) and _classes.get(f.get("key")) else f for f in fields] @@ -564,79 +388,6 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str = # makes this degrade safe: a client renders NOTHING for a missing `class` rather than a # wrong badge, so a registry that will not open costs the badges and not the grid. pass - # ⭐⭐ W42-T14 / C4 — **C8's `usage`, THE EXPENSIVE DIRECTION, STAMPED ONCE, HERE.** - # - # ⛔ ONE PRODUCER, REUSED, NEVER A SECOND COUNTER FOR THIS TOPIC. `routes_tables._field_usage` - # already answers "what breaks if this column goes" over the WHOLE workspace rather than over - # this caller's stratum, which is the clause the count exists for and the one a second - # implementation would quietly get wrong. It is imported here rather than restated for the same - # reason `_tenant_of` is imported inside it: two spellings of one count is - # [[two-lanes-one-contract-dead-feature]] with a number instead of a key. Imported lazily, - # because `routes_tables` imports this module's siblings at module scope. - # - # ⛔⛔ ONE MEMO KEY SPELLING, AND IT IS `MODULE`. The memo is `{(tenant, table_key): …}` and it - # is invalidated by a change token, never by time, so a second spelling of the topic on this - # door would pin a stale answer under a key nothing invalidates for the life of the process. - # `MODULE` ("customer_data") is the string `apply_row_scope`, `hidden_keys` and - # `routes_admin._clean_perms` already spell for this topic, and it is what the permission - # records inside `_field_usage` are keyed by, so any other choice would also report - # `permRules: 0` for every account. ⚠ NOT `scope`, which is a ROUTE PARAMETER (`"customer"` by - # default) two callers may pass differently, and not `_shared_key()`, which names the DOCUMENT. - # - # ⚠ THE DOCUMENT IS HANDED IN SEPARATELY, and that is the whole re-parameterisation: this - # topic's strata live in `customer_table_workspace`, a name no `f"{table_key}_table_workspace"` - # literal can produce. `rt` carries it, exactly as `_ctx_for` already opens this topic's - # workspace through `_customer_table(session)`. - # - # ⚠ `defn=None` IS NOT A SHORTCUT. That argument is the SHARED CONTRACT leg of - # `_usage_field_corpus`, and on this topic the merge above has already folded both the - # canonical contract (`aios_grid.FIELDS`) and the tenant-wide stratum (`_defs`) into `fields`, - # which the corpus reads as its second source. The third leg -- every OTHER account's `fields` - # stratum -- comes out of the workspace document, which is the half no caller-scoped list can - # contain and the half the count is about. Passing `_defs` too would add nothing but a - # second shape to keep in step, since it is a `{key: field}` dict and not a `{'fields': [...]}` - # definition. - # - # ⚠ AFTER THE MERGE AND BEFORE THE WALL, the same window and the same two reasons as `class` - # above: after, or the tenant-wide columns are neither in the list to stamp nor in the corpus - # to count from; before, so `hidden_keys` deletes a column's usage bag along with the column - # and a reader who may not see a column never learns how much of the workspace depends on it. - # ⚠ A NEW DICT PER COLUMN AND A COPY OF THE BAG -- `_field_usage` MEMOISES, so handing its own - # dict out would let anything downstream write into the number the next request reads. - # ⚠ LENIENT, AND THE ABSENT-KEY POLARITY IS WHAT MAKES THAT SAFE: `_field_usage` returns `{}` - # rather than zeros on a failed read, and this stamps NO key for a column it has no bag for, - # because a confident "0 uses" beside a delete button reads as safe to delete. - try: - from routes_tables import _field_usage as _fu - _usage = _fu(session, MODULE, fields, None, rt, _shared_key(), st=rt) - except Exception: # noqa: BLE001 - _usage = {} - if _usage: - fields = [dict(f, usage=dict(_usage[f["key"]])) - if isinstance(f, dict) and _usage.get(f.get("key")) else f - for f in fields] - # ⭐⭐ W42-T53 / C11 — **ROUTE OFFERABILITY, STAMPED ONCE, HERE.** See `_mark_offerability` - # for what it does and does not do (it adds a key; it removes nothing from any wire). - # - # ⛔ IN `grid_assembly` AND NOT IN `_merge_shared_fields`, for the two reasons already - # written out over `class` above: that helper returns `fields` untouched when `defs` is - # empty and otherwise only builds the PROJECTED additions, so a route column that reached - # the list through the viewer's own stratum would leave it unmarked -- and it is called from - # five places (`_hidden_for`, `_route_defs`, and the three route doors) that want a field - # list purely to ask the WALL a question and then throw it away. `grid_assembly` is the - # single assembly both client doors serve verbatim (`_payload` returns `g["fields"]` on - # `/customers`, `routes_grid` assigns `workspace["fields"] = g["fields"]` on `/workspace`), - # so it is the only position in which the two wires cannot disagree about a column. - # - # ⚠ AFTER THE MERGE AND BEFORE THE WALL, the same window and the same two reasons as `class` - # and `usage`: after, or the tenant-wide route columns are not in the list to mark; before, - # so a column this reader may not see loses the mark along with the column rather than - # leaving a mark behind on nothing. - # ⚠ NOT LENIENT, and deliberately not: this is a pure list transform over data already in - # hand, with no store read, no registry open and nothing to raise. A `try` here would be a - # place for a real defect to hide, which is exactly how a `NameError` in - # `_canonical_field_keys` shipped as an EMPTY field contract earlier in this lane. - fields = _mark_offerability(fields) # THE FIELD WALL — a TRANSITIVE closure (C-PERM amendment 5), so hiding a field also hides # every formula computed FROM it. Formulas evaluate in the browser from `{ref}`s, so # shipping a dependent formula while withholding its input either leaks the input through @@ -669,50 +420,6 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str = ws["overlays"] = {pid: {k: v for k, v in (cells or {}).items() if k not in hidden} for pid, cells in _ov.items()} - # ⭐⭐ W42-T20 / C11 / R9 / R20 / R23 — **THE ONE ROUTE PROJECTION COLUMN, APPENDED HERE.** - # - # ⛔⛔ AFTER THE WALL, WHICH IS THE OPPOSITE POSITION TO `class`, `usage` AND `offerable`, AND - # THE INVERSION IS THE POINT. Those three are marks ON columns the wall then narrows. This is a - # column DERIVED FROM what the wall left standing: `hidden` is exactly the set of route columns - # this session was not granted, so narrowing first and projecting second is what makes the - # projection incapable of naming a route the reader may not see. Projecting before the wall and - # narrowing afterwards would have to re-parse the joined string to take an entry back out, and - # a value that has already been assembled has already leaked. - # - # ⛔ THE SAME PREDICATE AS `_route_defs`, NOT A SECOND OPINION. That helper is - # `shared_fields` filtered by `kind == ROUTE_KIND` minus `perm_scope.hidden_keys(...)`, and - # `_defs` / `hidden` here are those same two reads on this assembly. Calling `_route_defs` - # instead would repeat a whole `_merge_shared_fields` and a second `hidden_keys` per request to - # recompute a set already in hand; what must not diverge is the PREDICATE, and it does not. - # - # ⚠ THE APPEND IS CONDITIONED ON THE TENANT HAVING ROUTES AT ALL, AND THE CELLS ON THE VIEWER - # HAVING GRANTS -- two different questions with two different answers. A tenant that has never - # planned a route gets no column, which is the cost `cohort_field`'s own note gives for staying - # out of the canonical contract (an always-present column nobody can fill). A viewer with no - # route grants on a tenant that HAS routes gets the column BLANK on every row, which is the - # done-when: hiding the column from them instead would make its presence a signal. - # - # ⚠ NOT LENIENT, deliberately, for `_mark_offerability`'s stated reason: this is a pure - # transform over data already in hand, with no store read and nothing to raise, and a `try` - # here would be a place for a real defect to hide -- which is exactly how a `NameError` in - # `_canonical_field_keys` shipped as an EMPTY field contract earlier in this lane. - # - # ⚠ THE DUPLICATE-KEY GUARD IS `fields_from_workspace`'s, for its reason: a person can create a - # column literally called "Routes", whose slug is this key. The client indexes fields by key, so - # a duplicate does not error -- it silently paints one column's values under the other's header. - # The user's own column wins and the derived one steps aside. - # - # ⚠ IT IS APPENDED AFTER `_mark_offerability` RUNS, so T53's `ROUTE_PROJECTION_KEY` exemption - # never actually fires for this column. Absent-means-offerable gives the right answer either - # way; recorded so the next reader does not read that exemption as load-bearing. - _route_vis = {k: v for k, v in (_defs or {}).items() - if isinstance(v, dict) and v.get("kind") == ROUTE_KIND - and k not in (hidden or ())} - if (any(isinstance(v, dict) and v.get("kind") == ROUTE_KIND for v in (_defs or {}).values()) - and not any(isinstance(f, dict) and f.get("key") == ROUTE_PROJECTION_KEY - for f in fields)): - fields = fields + [aios_grid.route_projection_field(ROUTE_PROJECTION_KEY, _route_vis)] - today = time.strftime("%Y-%m-%d") stamp = _pool_stamp(rt, team_id, agent) # ⭐⭐ W38-T19 — THE METRICS CAPABILITY, AND THIS GRAIN NEEDS **THREE** GUARDS WHERE THE @@ -732,20 +439,6 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str = # The derived channel: cohort membership cells + measure column values, ONE dict — the # same read-only channel the embed host hands to rows_from_pool. derived = aios_grid.cohort_cells(lists) - # ⭐⭐ W42-T20 — THE ROUTE PROJECTION'S CELLS, ON THE SAME CHANNEL AND NOT A SECOND ONE. - # - # ⛔ `_shared` IS THE SOURCE, AND IT IS THE ONLY ONE THERE IS. `_patch_route_ranks::_held` - # states the mapping: a record is on a route iff its shared-overlay cell for that route key is - # non-empty. There is no reverse index and `route.stops` is a COUNT, so the projection is a - # scan of the cells this assembly already read once for the rows it already scoped. - # ⚠ `_shared` is STRING-keyed and `derived` is INT-keyed; `route_projection_cells` bridges the - # two and its docstring says why inverting them is invisible (a blank column reads exactly like - # a working grant wall). - # ⛔ NOTHING IS WRITTEN BACK ANYWHERE. The ~64 route columns REMAIN the store underneath (R20); - # this reads them and T53 only stops a picker offering them. - for _pid, _cells in aios_grid.route_projection_cells( - ROUTE_PROJECTION_KEY, _route_vis, _shared, pids).items(): - derived.setdefault(_pid, {}).update(_cells) # ⭐ W33-T43 / owner item 12 ("One unique ID per database always"). The customer grid carried # NO Odoo id column at all, while its retiring twin `ut_odoo_customers` carried `partner_id` # as its join key — so the merge would have lost the one value every Odoo document joins on. @@ -1097,7 +790,7 @@ def patch_customer(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, so your change was not saved") + "the tenant store is unavailable — your change was not saved") route_accepted, route_swaps, route_taken = ({}, [], set()) if route_updates: @@ -1165,28 +858,6 @@ ROUTE_KEY_PREFIX = "route_" #: convention is not a declaration. ROUTE_KIND = "route_order" -#: ⭐⭐ W42-T53 / CONTRACT C11 — **THE OFFERABILITY KEY, AS ONE LITERAL, EXPORTED.** -#: -#: `offerable?: boolean` — ABSENT or `True` means offerable; `False` means a field picker -#: WITHHOLDS the column. Optional and absent-means-offerable, so every field that predates the -#: flag and every stored fixture stays byte-identical (the same reasoning that made C-A2's -#: `strict?` optional: the alternative silently reclassifies everything older than the flag). -#: -#: ⛔⛔ IT IS A MODULE CONSTANT AND NOT A LITERAL TYPED INTO THE STAMP, BECAUSE THE FAILURE C11 -#: WAS WRITTEN ABOUT IS A SPELLING DISAGREEMENT ACROSS TWO LANES, NOT A LOGIC ERROR. This lane -#: was about to emit `notOfferable: true` while the client half (W42-T54) was about to read -#: `offerable === false`: both correct, both sides' gates green, and ~64 route columns still -#: filling every picker on screen. A per-side negative control cannot see that. Exporting the -#: name lets a parity leg DERIVE the server's emitted key from this module instead of retyping -#: it, which is the only shape of assertion that can compare the two literals to each other. -OFFERABLE_KEY = "offerable" - -#: ⭐ CONTRACT C11 — the key of W42-T20's ONE pre-set projection column, which passes THROUGH the -#: filter that withholds the other route columns. Mirrors the `cohort_field` precedent's -#: `{key: 'cohorts'}`: served, special, and offered. Named here so the exemption is a fact about -#: this module rather than a string repeated in two places. -ROUTE_PROJECTION_KEY = "routes" - #: ⭐⭐ W41-T05 / CONTRACT C3 — THE DESCRIPTION EVERY ROUTE COLUMN IS BORN WITH. #: #: `shared_overlay.mint_field` refuses a tenant-wide column that has no description, and the @@ -1356,147 +1027,6 @@ def _own_route_fork(session: Session, key: str): return entry -#: ⭐⭐ W42-T23's FOLDER SURFACE NAME, DECLARED HERE SO THERE IS ONE SPELLING OF IT. -#: -#: T22 carried `folderId` on the wire as an honest `None`, because no store document could hold a -#: route's placement: `aios_grid.FOLDER_SURFACES` was `{"views", "cohorts"}`, `clean_folders` and -#: `clean_item_folders` iterate exactly that set, and the folder write door refused the surface -#: outright. T23 widened that set, and this is where the value comes from now. -#: -#: ⛔⛔ THE PLACEMENT IS PER VIEWER, AND THE ROUTE ITSELF IS TENANT-WIDE. Those two facts sit -#: badly together until you look at where a folder ROW lives: `TableStore.workspace` is keyed by -#: USERNAME, so the routes rail's folders are this account's folders and nobody else's. A -#: tenant-wide placement could therefore only ever name a folder that no other viewer has, and -#: every other account would watch the route fall back to the root anyway — a shared fact with an -#: unshareable referent. `aios_grid.clean_folders` states the same rule from the other end: -#: filing is an ORGANISING act, not part of what the filed thing IS. So the route is one object -#: every account sees, and where each account keeps it is that account's own business. -#: -#: ⛔ THE HOME IS `itemFolders["routes"]` IN THE PER-USER TABLE WORKSPACE — the stratum views and -#: cohorts already use — and NOT a `folderId` key on the shared definition. A key on the -#: definition would be tenant-wide by construction (the wrong answer above), and it would ride -#: through `shared_overlay.put_field` on a record whose every other key is a fact about the -#: COLUMN rather than about one reader's sidebar. -ROUTE_FOLDER_SURFACE = "routes" - - -def _route_folder_stratum(session: Session): - """`(folders, placements)` for the routes rail: this caller's own, never another's. - - ⚠ `consume_corrections=False`, like `_own_route_fork` and like every folder read in - `grid_events`. A filing read that consumed a pending label-correction acknowledgement would - make the ack's delivery depend on whether somebody had a sidebar open. - """ - try: - ws = _customer_table(session).workspace(session.uname, consume_corrections=False) - except Exception: # noqa: BLE001 - return [], {} - folders = list((ws.get("folders") or {}).get(ROUTE_FOLDER_SURFACE) or []) - placed = dict((ws.get("itemFolders") or {}).get(ROUTE_FOLDER_SURFACE) or {}) - return folders, placed - - -def _route_folder_id(placed, folders, key): - """The folder this route sits in for this caller, or None for the top level. - - ⛔ RESOLVED AGAINST THE FOLDERS THAT EXIST, EVERY TIME, and that is not belt-and-braces. A - folder can be deleted through the generic folder door, which knows nothing about routes; the - placement it leaves behind names nothing, and answering with it would put the route in a - section the rail cannot draw. Prune, never invent — `clean_item_folders`' posture, said at - read time so a stale placement can never outlive its folder on screen. - - ⚠ `ROOT_PLACEMENT` SURVIVES THIS, deliberately, and it is not the same as None. Absence means - "never filed", which the client's `groupByFolder` renders into the Shared group for a route - somebody shared with you; the reserved id means "this reader deliberately dragged it out to - the top level". Wave 32 item 20 is the whole account of why one value cannot carry both. - """ - import aios_grid - - fid = placed.get(key) - if not isinstance(fid, str) or not fid: - return None - if fid == aios_grid.ROOT_PLACEMENT: - return fid - return fid if any(f.get("id") == fid for f in folders) else None - - -def _route_share_marks(session: Session, defs): - """Per-route `{owner, shared, sharedOut, hasShares, sharedRole}` from ONE registry read. - - ⭐⭐ CONTRACT C6 — THE SPELLINGS ARE `ViewSidebar.tsx::viewShareMark`'s, NAME FOR NAME. That - function reads `hasShares` / `shared` (line 198), `shared` + `owner` (199), `sharedRole` (203) - and `sharedOut` (209); a key spelled one letter differently here is a route navigation that - lists routes, opens the share dialog, passes every gate and never draws the shared icon - ([[two-lanes-one-contract-dead-feature]]). - - ⛔ THE GRANT IS THE EXISTING GENERIC FIELD DOOR, NOT A SECOND MECHANISM. A route IS a field - (`kind: "route_order"`, key `route_`), so its share state lives at - `shares.field_oid(SHARE_TOPIC, key)` and there is no `route` share kind to invent - (`shares.KINDS` is `view | folder | database | field`). - - ⛔ ONE STORE READ FOR THE WHOLE LISTING, and that is measured rather than tidy. - `field_permissions.grant_records` opens `object_shares` once and returns - `{field_key: {owner, entries}}` through the SAME `shares._clean_entries` normaliser - `shares.grants` uses, so this badge and the wall cannot disagree about a junk-role entry. The - per-key `shares.role_for` in `_merge_shared_fields` is the older shape and it is a fresh deep - copy of the whole registry PER KEY: this tenant carries dozens of route columns, so the loop - would be dozens of serialise/deserialise round trips on a listing that is one read. - - ⚠ NO `try/except` AROUND THE READ. `grant_records` already fails closed on an unreadable - bucket. A second, wider swallow here would turn a missing import into "every route reports - never-shared" behind a clean 200, which is the exact defect this lane shipped once today. - - ⭐ THE ROLE SEMANTICS ARE `SavedView`'s, because `viewShareMark` is `SavedView`'s reader: - `types.ts` declares `sharedRole?: "view" | "edit"` and states that `shared` / `sharedRole` / - `owner` all mean *granted TO me*, while the sharer's own leg carries `hasShares` + `sharedOut` - and never `shared`. So `sharedRole` here is the ENTRY role, absent for the owner. ⚠ The field - surface spells the same member differently (`FieldShareRole` admits `owner`, and - `_merge_shared_fields` stamps `shares.role_for`, which reads an admin as `owner`). Two - surfaces, two declared types; this one follows the consumer it was written for. - """ - from core import field_permissions, shares - - records = field_permissions.grant_records(SHARE_TOPIC, st=session.runtime) or {} - me = str(session.uname or "").strip().lower() - marks = {} - for key, defn in (defs or {}).items(): - rec = records.get(key) or {} - entries = rec.get("entries") or [] - # ⭐ THE OWNER IS RESOLVED ONCE AND OWNERSHIP IS TESTED AGAINST THE RESOLVED VALUE. A route - # minted before the grant door stamped an owner has `entries` and no `owner` on the - # record; testing the raw member would leave its creator reading `shared: true` beside - # their own name, and the mark would tell them the column was shared with them by - # themselves. `createdBy` is already on the definition and is already on the wire as - # `solvedBy`, so the fallback leaks nothing new. - owner = (str(rec.get("owner") or "").strip().lower() - or str((defn or {}).get("createdBy") or "").strip().lower()) - mine = bool(me) and owner == me - granted = None - if me and not mine: - for entry in entries: - if entry.get("user") in (me, shares.EVERYONE): - # The stronger of the two wins, exactly as `shares.role_for` decides it: - # naming somebody explicitly is how you raise them above the room. - if entry.get("role") == "edit": - granted = "edit" - break - granted = granted or "view" - marks[key] = { - "owner": owner, - "hasShares": bool(entries), - "shared": granted is not None, - "sharedRole": granted, - # ⚠ WHAT `sharedOut` CAN TRUTHFULLY MEAN HERE IS NARROWER THAN ON A VIEW, and the - # narrowness is the registry's, not a choice. `field` is excluded from - # `shares.RESHARE_KINDS` and `routes_shares._owns_object`'s field branch requires - # `createdBy == session.uname`, so an `edit` grantee can never hand a column on. The - # only account that can have shared a route out is the one that owns it, which is - # what this says: I own it AND somebody holds a grant on it. - "sharedOut": bool(mine and entries), - } - return marks - - @router.get("/customers/route-order") def route_order_list(session: Session = Depends(module_gate(MODULE))): """The route-order columns this session may see, with the fingerprint each was solved from. @@ -1508,18 +1038,8 @@ def route_order_list(session: Session = Depends(module_gate(MODULE))): about a moment that has already passed. """ out = [] - # ⛔ THE WALL IS READ ONCE AND NOT RE-ASKED. `_route_defs` is still the ONLY source of the key - # set below, and the share marks are looked up BY the keys it returned: a route this session - # may not see is absent from `defs`, so it can have no mark, and its existence is not leaked - # by a share key either. Nothing here widens what `perm_scope.hidden_keys` let through. - defs = _route_defs(session) - marks = _route_share_marks(session, defs) - # ⭐⭐ W42-T23 — ONE workspace read for the whole listing, not one per route. See - # `ROUTE_FOLDER_SURFACE` for why the placement is per viewer and lives in this stratum. - folders, placed = _route_folder_stratum(session) - for key, defn in sorted(defs.items()): + for key, defn in sorted(_route_defs(session).items()): route = defn.get("route") if isinstance(defn.get("route"), dict) else {} - mark = marks.get(key) or {} out.append({ "key": key, "label": defn.get("label") or key, @@ -1530,27 +1050,13 @@ def route_order_list(session: Session = Depends(module_gate(MODULE))): "depot": route.get("depot") if isinstance(route.get("depot"), dict) else None, "solvedAt": route.get("solvedAt") or "", "solvedBy": defn.get("createdBy") or "", + # The route index's gear edits the same named detail the field editor owns. Serving + # it here prevents a rename from accidentally clearing an existing description. + "note": str(defn.get("note") or ""), # Who may re-solve it. The same creator-or-admin wall the write door enforces, said on # the way out so the client can grey the control instead of discovering a 403. "mine": bool(session.admin or str(defn.get("createdBy") or "") == session.uname), - # ⭐⭐ CONTRACT C6 — THE SHARE STATE, SPELLED AS `viewShareMark` READS IT. Every one of - # the five is present on every route, negatives included: a MISSING key and a false - # one are the same thing to `viewShareMark`, but they are not the same thing to the - # next reader, who cannot tell "not shared" from "this door does not know". - # - # ⛔⛔ `shared` HERE IS NOT `shared` ON A FIELD, AND THE TWO LIVE IN THIS ONE FILE. - # `_merge_shared_fields` stamps `shared: True` on every tenant-wide column, meaning - # *this definition came out of the shared stratum* - and EVERY route is that, always. - # `viewShareMark` reads `shared` as *granted to me*, which is FALSE on a tenant-wide - # route the caller owns. Same word, two questions; this row answers the client's. - "owner": mark.get("owner") or "", - "shared": bool(mark.get("shared")), - "sharedOut": bool(mark.get("sharedOut")), - "hasShares": bool(mark.get("hasShares")), - "sharedRole": mark.get("sharedRole"), - # C6's spelling, and it reports a real placement now. See `ROUTE_FOLDER_SURFACE`. - "folderId": _route_folder_id(placed, folders, key), }) # ⭐⭐ OWNER ITEM 5 — THE DEFAULT NAME IS ALLOCATED **HERE**, NEVER ON THE CLIENT. # @@ -1563,12 +1069,7 @@ def route_order_list(session: Session = Depends(module_gate(MODULE))): # # ⚠ IT LEAKS NOTHING. What travels is the first FREE name, which is a fact about absence. import aios_grid - # ⭐ W42-T23 — the rail's own folder ROWS travel with the listing, because a `folderId` with - # no folder list beside it names a section the client cannot draw. Same rows the generic - # folder door writes, read back from the same stratum; `parent` rides along, which is what - # makes the routes rail nest exactly as deep as the views rail does. return {"fields": out, - "folders": [{k: v for k, v in f.items()} for f in folders], "nextLabel": _next_route_label(shared_fields(st=session.runtime) or {}, aios_grid.FIELDS)} @@ -2047,101 +1548,129 @@ def route_order_rename(field_key: str, body: dict = Body(default=None), "note": patch.get("note") or ""} -@router.patch("/customers/route-order/{field_key}/folder") -def route_order_file(field_key: str, body: dict = Body(default=None), - session: Session = Depends(module_gate(MODULE))): - """File a route into one of this caller's route folders. `{folderId: str | null}`. - - ⭐⭐ W42-T23 — A ROUTE'S OWN DOOR, AND THAT IS THE POINT OF IT EXISTING AT ALL. - - ⛔⛔ THE GENERIC DOOR IS NOT AVAILABLE HERE, AND IT IS NOT A STYLE CHOICE. `grid_events`' - `item_move` gates the mover on `_own_ids(surface, ws)`, which is written as - `if sfc == 'views': ...` with an ELSE returning the caller's COHORT ids — a two-way split, - not a lookup. A route key is never a cohort id, so filing a route through that door answers - *"You can file only the items you own"* about a column the person planned themselves. The - matching read-side hazard is answered in `aios_grid.FOLDER_SURFACES_TENANT_WIDE`, which is - what stops an unrelated folder rename from erasing what this door writes. - - ⛔ THE WALL IS "CAN YOU SEE IT", NOT "IS IT YOURS", and the two differ here on purpose. - `route_order_rename` and `route_order_delete` are creator-or-admin because they change what - every account in the workspace reads. A placement changes ONE account's sidebar — this one's - — so requiring ownership would leave a shared route permanently unfileable by the very people - it was shared with. A caller who cannot see the column still gets the answer a nonexistent - key gets, for `route_order_rename`'s reason: a refusal that said "forbidden" would confirm a - route exists here. - - ⚠ THREE INPUTS, NOT TWO. `null` unfiles (no record at all, which is where a new route - starts), the reserved root id records a deliberate move to the top level, and any other value - must name a folder that exists. Wave 32 item 20 is the full account of why the first two - cannot be the same stored state. - """ - from core import shared_overlay - from core.table_store import FolderTreeError - import core.perm_scope as perm_scope - import aios_grid +# --------------------------------------------------------------------------- +# W43C-T03 / C2 — scheduled, frozen route delivery +# --------------------------------------------------------------------------- - body = body if isinstance(body, dict) else {} - key = str(field_key or "").strip() +def _route_delivery_wire(defn): + """The compact C2 record consumed by the Map route index. - all_defs = shared_fields(st=session.runtime) or {} - defn = all_defs.get(key) if isinstance(all_defs.get(key), dict) else None - unknown = err(404, "unknown_field", - "that column is not a route order column on this database") - if not defn or defn.get("kind") != ROUTE_KIND: - raise unknown - if key in perm_scope.hidden_keys( - session.user, MODULE, - _merge_shared_fields(list(aios_grid.FIELDS), all_defs), st=session.runtime): - raise unknown + The generic automation payload deliberately remains generic. This route is the domain seam + that names a frozen View route as such, so the map never has to infer it from an arbitrary + agent's config bag. + """ + cfg = defn.get("config") if isinstance(defn.get("config"), dict) else {} + snap = cfg.get("routeSnapshot") if isinstance(cfg.get("routeSnapshot"), dict) else {} + return { + "id": str(defn.get("id") or ""), + "name": str(defn.get("name") or "Scheduled route"), + "recipient": str(cfg.get("recipient") or ""), + "schedule": dict(defn.get("schedule") or {}), + "snapshot": { + "sourceViewId": str(snap.get("sourceViewId") or ""), + "sourceViewRevision": str(snap.get("sourceViewRevision") or ""), + "sourceViewName": str(snap.get("sourceViewName") or ""), + "timezone": str(snap.get("timezone") or "UTC"), + "routeField": str(snap.get("routeField") or ""), + "orderedStopIds": list(snap.get("orderedStopIds") or []), + "mapsPerLink": int(snap.get("mapsPerLink") or 10), + }, + "createdBy": str(defn.get("createdBy") or ""), + "created": str(defn.get("created") or ""), + } - raw = body.get("folderId") - target = None if raw is None else str(raw).strip()[:80] - store = _customer_table(session) +def _route_delivery_recipient_or_400(raw): + recipient = str(raw or "").strip() try: - ws = store.workspace(session.uname, consume_corrections=False) - except Exception: # noqa: BLE001 - raise err(503, "store_unavailable", - "your folders could not be saved because the workspace store is not reachable " - "right now, so nothing was changed. Try again in a moment") - - # ⛔⛔ `save_folders` REPLACES THE WHOLE STRATUM (`ws['folders'] = tree`), so the picture sent - # in has to be the COMPLETE one. Passing only the routes surface would file this route and - # delete every views and cohorts folder the caller had, with a 200 and no way back. - folders = {s: [dict(f) for f in rows] - for s, rows in (ws.get("folders") or {}).items() if isinstance(rows, list)} - placed = {s: dict(m) for s, m in (ws.get("itemFolders") or {}).items() - if isinstance(m, dict)} - rows = folders.get(ROUTE_FOLDER_SURFACE) or [] - mine = dict(placed.get(ROUTE_FOLDER_SURFACE) or {}) - - if target is None: - mine.pop(key, None) # unfiled: no record is how a route is born - elif target == aios_grid.ROOT_PLACEMENT: - mine[key] = target - else: - if not any(str(f.get("id") or "") == target for f in rows): - raise err(404, "folder_gone", - "that folder is not on this sidebar any more, so the route stayed where it " - "was. Reload the page to see the folders you have, then try again") - mine[key] = target - placed[ROUTE_FOLDER_SURFACE] = mine + import core.users as users + user = (users.registry() or {}).get(recipient) + except Exception: # registry unavailable is not permission to queue a phantom Inbox delivery + user = None + if not recipient or not isinstance(user, dict) or not user.get("active", True): + raise err(400, "unknown_route_recipient", + "choose an active account to receive this route in Inbox") + return recipient + + +@router.get("/customers/route-deliveries") +def route_delivery_list(session: Session = Depends(module_gate(MODULE))): + """Saved C2 route deliveries owned by the caller (or all for an administrator).""" + import automation_engine as engine + out = [] + for defn in (engine.all_definitions(session.runtime) or {}).values(): + if not isinstance(defn, dict) or defn.get("kind") != "route_delivery": + continue + # A recipient can see the delivery arriving in their Inbox; the edit index is the owner's + # configuration surface. Admins can support either one. + if not session.admin and str(defn.get("createdBy") or "") != session.uname: + continue + out.append(_route_delivery_wire(defn)) + return {"deliveries": sorted(out, key=lambda row: (row["name"].casefold(), row["id"]))} + +@router.post("/customers/route-deliveries") +def route_delivery_create(body: dict = Body(default=None), + session: Session = Depends(module_gate(MODULE))): + """Save a daily route delivery as an immutable C2 snapshot and arm its native schedule.""" + import automation_control + import automation_engine as engine + + if not session.runtime.available(): + raise err(503, "store_unavailable", "the tenant store is unavailable. Nothing was saved") + body = body if isinstance(body, dict) else {} + recipient = _route_delivery_recipient_or_400(body.get("recipient")) + snapshot = body.get("routeSnapshot") if isinstance(body.get("routeSnapshot"), dict) else {} + # View IDs are usually supplied by the host. MapView cannot own generic View persistence, so + # its explicit snapshot fingerprint is accepted as the revision when the host has not supplied + # a persisted view id yet; it still makes the scheduled contents immutable and inspectable. + source_view_id = str(snapshot.get("sourceViewId") or "map-current").strip() + source_revision = str(snapshot.get("sourceViewRevision") or body.get("inputsHash") or "").strip() + raw = { + "name": " ".join(str(body.get("name") or snapshot.get("sourceViewName") or "Scheduled route").split())[:120], + "kind": "route_delivery", + "trigger": {"key": "manual"}, + "schedule": body.get("schedule") if isinstance(body.get("schedule"), dict) + else {"cron": "0 8 * * *", "enabled": True}, + "config": { + "recipient": recipient, + "routeSnapshot": { + **snapshot, + "sourceViewId": source_view_id, + "sourceViewRevision": source_revision, + "mapsPerLink": snapshot.get("mapsPerLink", body.get("mapsPerLink", 10)), + }, + }, + "flow": {"actions": []}, + } + defn, problem = engine.create(session.runtime, raw, username=session.uname) + if problem: + raise err(400, "invalid_route_delivery", problem) + try: + automation_control.sync_definition(session.tenant, defn) + except automation_control.ControlPlaneError as exc: + # The definition exists but is not allowed to read as armed if its native schedule did not. + raise err(503, "automation_control_unavailable", + f"the route was saved, but its daily schedule could not be armed: {exc}") + return {"delivery": _route_delivery_wire(defn)} + + +@router.delete("/customers/route-deliveries/{delivery_id}") +def route_delivery_delete(delivery_id: str, + session: Session = Depends(module_gate(MODULE))): + """Remove one scheduled delivery without touching its immutable route field or rows.""" + import automation_control + import automation_engine as engine + + defn = (engine.all_definitions(session.runtime) or {}).get(str(delivery_id)) + if not isinstance(defn, dict) or defn.get("kind") != "route_delivery": + raise err(404, "unknown_route_delivery", "no scheduled route with that id") + if not session.admin and str(defn.get("createdBy") or "") != session.uname: + raise err(403, "forbidden", "only the route creator or an administrator can remove its schedule") + engine.remove(session.runtime, str(delivery_id)) try: - receipt = store.save_folders(session.uname, folders, placed) - except FolderTreeError as e: # noqa: BLE001 - # The validator already words this for a person, and it names the folder. Re-wording it - # here would be a second sentence about one rule, drifting from the first the day the cap - # moves. 400 rather than 500: the tree is illegal, and nothing was written. - raise err(400, "folder_tree_illegal", str(e)) - - saved = (receipt.get("itemFolders") or {}).get(ROUTE_FOLDER_SURFACE) or {} - kept = [dict(f) for f in (receipt.get("folders") or {}).get(ROUTE_FOLDER_SURFACE) or []] - return {"ok": True, "field": key, - "folderId": _route_folder_id(saved, kept, key), - "folders": kept, - # ⚠ The receipt travels. `validate_folder_tree` REPAIRS a placement whose folder was - # deleted rather than refusing it, and a repair the caller never hears about is a - # route that quietly moved. `save_folders`' own docstring makes this the point of its - # return value. - "repairs": list(receipt.get("repairs") or [])} + automation_control.delete_definition(session.tenant, str(delivery_id)) + except automation_control.ControlPlaneError as exc: + raise err(503, "automation_control_unavailable", + f"the route schedule was removed, but its native schedule could not be removed: {exc}") + return {"deleted": str(delivery_id)} diff --git a/api/routes_geo.py b/api/routes_geo.py index a68fde4606262d17205e87db094623057dcf2332..66a0300a17f3315c15c4c530b208a7153cf1fd18 100644 --- a/api/routes_geo.py +++ b/api/routes_geo.py @@ -116,6 +116,113 @@ _TILE_MAX_Z_DEFAULT = 19 _CACHE_MAX = 4096 +# W43C-T03 / C3 — tenant-durable map Zones. This is intentionally a map-domain record, not a +# generic field type: a Zone has geometry, visibility and a membership snapshot that only the map +# can interpret. Keeping it out of generic field persistence lets C consume A's DTO without +# widening unrelated table writes. +ZONES_KEY = "map_zones" +MAX_ZONES = 200 +MAX_ZONE_MEMBERSHIPS = 10_000 +_ZONE_COLORS = ("#9DBFF2", "#A5D8B4", "#F5D989", "#F0A8A0", "#C7B7E8") + + +def _zone_id(raw): + value = re.sub(r"[^a-z0-9_-]+", "_", str(raw or "").strip().lower()).strip("_") + return value[:80] + + +def _finite_coord(raw): + try: + value = float(raw) + except (TypeError, ValueError): + return None + return value if value == value and abs(value) != float("inf") else None + + +def _clean_geometry(raw): + """One visible Polygon/MultiPolygon GeoJSON value, normalized without inventing geometry.""" + geom = raw.get("geometry") if isinstance(raw, dict) and raw.get("type") == "Feature" else raw + if not isinstance(geom, dict): + raise ValueError("a Zone needs GeoJSON Polygon or MultiPolygon geometry") + kind = str(geom.get("type") or "") + coords = geom.get("coordinates") + polys = [coords] if kind == "Polygon" else coords if kind == "MultiPolygon" else None + if not isinstance(polys, list) or not polys: + raise ValueError("a Zone needs GeoJSON Polygon or MultiPolygon geometry") + cleaned_polys = [] + for poly in polys: + if not isinstance(poly, list) or not poly: + raise ValueError("each Zone polygon needs at least one ring") + rings = [] + for ring in poly: + if not isinstance(ring, list) or len(ring) < 4: + raise ValueError("each Zone ring needs at least four longitude/latitude points") + out = [] + for point in ring: + if not isinstance(point, (list, tuple)) or len(point) < 2: + raise ValueError("each Zone point needs longitude and latitude") + lon, lat = _finite_coord(point[0]), _finite_coord(point[1]) + if lon is None or lat is None or not (-180 <= lon <= 180 and -90 <= lat <= 90): + raise ValueError("each Zone point must be a longitude and latitude on Earth") + out.append([round(lon, 7), round(lat, 7)]) + if out[0] != out[-1]: + out.append(list(out[0])) + rings.append(out) + cleaned_polys.append(rings) + return {"type": "Polygon", "coordinates": cleaned_polys[0]} if kind == "Polygon" else { + "type": "MultiPolygon", "coordinates": cleaned_polys} + + +def _clean_zone(raw, previous=None, *, zone_id=""): + raw = raw if isinstance(raw, dict) else {} + previous = previous if isinstance(previous, dict) else {} + name = " ".join(str(raw.get("name") if "name" in raw else previous.get("name") or "").split())[:120] + if not name: + raise ValueError("name this Zone") + color = str(raw.get("color") if "color" in raw else previous.get("color") or _ZONE_COLORS[0]).upper() + if not re.fullmatch(r"#[0-9A-F]{6}", color): + raise ValueError("a Zone color is a six-digit hex color") + geometry = _clean_geometry(raw.get("geometryGeoJson") if "geometryGeoJson" in raw + else previous.get("geometryGeoJson")) + geographic = str(raw.get("geographicField") if "geographicField" in raw + else previous.get("geographicField") or "").strip()[:80] + memberships = raw.get("memberships") if "memberships" in raw else previous.get("memberships") or [] + if not isinstance(memberships, list): + raise ValueError("Zone memberships must be a list of record IDs") + member_ids, seen = [], set() + for value in memberships[:MAX_ZONE_MEMBERSHIPS]: + try: + pid = int(value) + except (TypeError, ValueError): + continue + if pid > 0 and pid not in seen: + member_ids.append(pid) + seen.add(pid) + return { + "id": zone_id or _zone_id(raw.get("id") or name), + "name": name, + "color": color, + "geometryGeoJson": geometry, + "visible": bool(raw.get("visible")) if "visible" in raw else bool(previous.get("visible", True)), + "geographicField": geographic, + "memberships": member_ids, + } + + +def _zone_wire(zone): + return {key: zone.get(key) for key in ("id", "name", "color", "geometryGeoJson", "visible", + "geographicField", "memberships", "createdBy", "createdAt")} + + +def _zone_subject(session, zone_id): + zones = session.runtime.get(ZONES_KEY) or {} + zone = zones.get(str(zone_id)) if isinstance(zones, dict) else None + if not isinstance(zone, dict): + raise err(404, "unknown_zone", "no Zone with that id") + if not session.admin and str(zone.get("createdBy") or "") != session.uname: + raise err(403, "forbidden", "only the Zone creator or an administrator can change it") + return zone + def _env(name, default): return (os.environ.get(name) or "").strip() or default @@ -286,6 +393,92 @@ def geo_providers(session: Session = Depends(require_session)): return provider_config() +@router.get("/geo/zones") +def geo_zones(session: Session = Depends(require_session)): + """The C3 Zone DTOs this tenant may draw and choose on its Map Views.""" + try: + raw = session.runtime.get(ZONES_KEY) or {} + except Exception: + raw = {} + zones = [zone for zone in raw.values() if isinstance(zone, dict)] if isinstance(raw, dict) else [] + # Visibility is a display choice carried by the durable zone itself. Creators and admins keep + # seeing an invisible zone in the chooser so it can be restored or edited rather than orphaned. + visible = [zone for zone in zones if zone.get("visible", True) + or session.admin or str(zone.get("createdBy") or "") == session.uname] + return {"zones": [_zone_wire(zone) for zone in sorted(visible, + key=lambda zone: (str(zone.get("name") or "").casefold(), str(zone.get("id") or "")))]} + + +@router.post("/geo/zones") +def geo_zone_create(body: dict = Body(default=None), session: Session = Depends(require_session)): + """Persist one drawn Zone as C3's tenant record; no table-field writer is involved.""" + body = body if isinstance(body, dict) else {} + try: + zone = _clean_zone(body) + except ValueError as exc: + raise err(400, "invalid_zone", str(exc)) + if not zone["id"]: + raise err(400, "invalid_zone", "name this Zone") + created = {} + + def _write(current): + current = current if isinstance(current, dict) else {} + if zone["id"] in current: + raise ValueError("a Zone with that name already exists") + if len(current) >= MAX_ZONES: + raise ValueError(f"this tenant has reached the {MAX_ZONES}-Zone limit") + item = {**zone, "createdBy": session.uname, "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S")} + current[zone["id"]] = item + created.update(item) + return current + try: + session.runtime.update(ZONES_KEY, _write, flush="async") + except ValueError as exc: + raise err(409, "zone_exists", str(exc)) + return {"zone": _zone_wire(created)} + + +@router.patch("/geo/zones/{zone_id}") +def geo_zone_update(zone_id: str, body: dict = Body(default=None), + session: Session = Depends(require_session)): + """Edit geometry, membership, geographic source or visibility of a stored Zone.""" + previous = _zone_subject(session, zone_id) + try: + zone = _clean_zone(body, previous, zone_id=str(zone_id)) + except ValueError as exc: + raise err(400, "invalid_zone", str(exc)) + updated = {} + + def _write(current): + current = current if isinstance(current, dict) else {} + old = current.get(str(zone_id)) + if not isinstance(old, dict): + raise KeyError(zone_id) + item = {**zone, "createdBy": old.get("createdBy") or session.uname, + "createdAt": old.get("createdAt") or time.strftime("%Y-%m-%dT%H:%M:%S")} + current[str(zone_id)] = item + updated.update(item) + return current + try: + session.runtime.update(ZONES_KEY, _write, flush="async") + except KeyError: + raise err(404, "unknown_zone", "no Zone with that id") + return {"zone": _zone_wire(updated)} + + +@router.delete("/geo/zones/{zone_id}") +def geo_zone_delete(zone_id: str, session: Session = Depends(require_session)): + """Delete exactly one Zone, leaving records and generic fields untouched.""" + _zone_subject(session, zone_id) + + def _write(current): + current = current if isinstance(current, dict) else {} + current.pop(str(zone_id), None) + return current + session.runtime.update(ZONES_KEY, _write, flush="async") + return {"deleted": str(zone_id)} + + @router.post("/geo/geocode") def geo_geocode(body: dict = Body(...), session: Session = Depends(require_session)): """Turn a bounded list of addresses into coordinates. diff --git a/api/routes_grid.py b/api/routes_grid.py index c4cab1ddf5ca59db7da7e3558097894861c137c6..54148608a4d59551ad9416d1c8016b8bdcbd1a18 100644 --- a/api/routes_grid.py +++ b/api/routes_grid.py @@ -24,7 +24,7 @@ back. A 200 over a write that evaporated is the failure this rule exists to prev import datetime as dt import time -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, Body, Depends from deps import Session, err, module_gate, perms, require_session @@ -1029,24 +1029,8 @@ def grid_events_route(body: dict = Body(default=None), # an UNCAPPED `IN (?,…)` over them. Confirmed orders only, the wholesale teams and the GIFTWARE # DEALS exclusion all come from that topic's `scope_sql`, so the ticket's "confirmed orders only" # is enforced by the ONE definition of it rather than by a second copy written here. -# ⛔⛔ `viaField` IS LOAD-BEARING AS OF W42-T05, AND IT USED TO SAY THE OPPOSITE HERE. Until this -# ticket the key rode the wire and NOTHING read it: the edge came from a compiled-in route table -# keyed on `(source, target)`, so the request named a link field and the server answered from -# a different declaration entirely. The table is gone. The edge is now read off the link field the -# request NAMES, out of C1's `via` bag (`field["link"]["via"]`, written and validated by -# `core.user_tables._clean_via`), which is what lets a second pair exist without editing this file. -# ⚠ THE CONSEQUENCE IS STATED RATHER THAN DISCOVERED, AND IT IS BIGGER THAN A MISSING SEED: this -# door REFUSES a pivot whose `viaField` does not resolve, with a 400 naming the field, and will -# keep doing so until a link field can EXIST on a topic grid. `scoped_fields('customer_data')` -# resolves to `aios_grid.FIELDS`, which declares no `link` column, and `user_tables.add_field` -# refuses any key without the `ut_` prefix, so there is nowhere to put one. See `_pivot_route`'s -# docstring for the trace. That refusal is the ticket's own instruction and it is the honest -# answer, because the alternative shape (answer from a table nobody named) is the defect above. -# ⚰ AMENDED BY WAVE 42 AMENDMENT A23 — ONE PAIR IS EXCEPTED, AND ONLY ONE. The paragraph above -# described `(customer_data, product_data)` too, and that took a SHIPPING feature dark on a wave -# that ends on the public live domain. `_PIVOT_ROUTES` below is back as a DATED TOMBSTONE holding -# exactly that one pair, read ONLY after the strict resolution has already refused. Every other -# unresolvable case still refuses with the field named, which is T05's real win and is intact. +# ⚠ `viaField` therefore RIDES THE WIRE AND IS ADVISORY for this pair (C10, and lane D types +# against it). A door that refused an unresolvable `viaField` would refuse R10's headline case. # # ⛔ AND IT IS STILL NOT CHAINING (R12). `path` is TWO databases. `sales_lines` is the EDGE, not a # third stop: none of it reaches the client, no filter over it is accepted, and no caller can name @@ -1060,192 +1044,36 @@ def grid_events_route(body: dict = Body(default=None), #: [[date-window-vocabulary]]. This is a RENAME and nothing else. `all` resolves to `(None, None)`. PIVOT_WINDOWS = {"all": "all_time", "last12m": "ltm", "ytd": "ytd"} -#: The `via` keys THIS DOOR needs before it can ask a question. C1 makes all six required and -#: `core.user_tables._clean_via` refuses a bag missing any of them, so a stored bag always has -#: them; this tuple is the door's own check rather than a second copy of that rule, because a bag -#: can also arrive from an older write or a hand-edited document. `filter`, `filterConj` and -#: `window` are the optional three and are NOT read here (the request's own `window` governs). -#: ⛔ `measure` IS AMONG THE REQUIRED SIX. There is no default: a bag without it is refused, never -#: quietly answered with revenue, because the measure decides WHICH relationship the edge reports. -_PIVOT_VIA_REQUIRED = ("topic", "measure", "scope_key", "source_dim", "target_dim", "target_key") - -#: ⚠ THE SOURCE IDENTITY IS `pid` AND NOTHING ELSE, WHICH IS WHY `via` HAS NO `source_key`. -#: `filter_eval.visible_pids` reads `row['pid']`, so there is exactly one answer and a bag key -#: offering a second one could only ever disagree with the engine. The old route table carried -#: `source_key` purely to assert `== "pid"` against itself; it died with the table rather than -#: being migrated onto `via` (C1, A5). ⭐ W41-T19 measured why the obvious alternative is wrong: -#: `partner_id` is declared `derived` and is on 0 of 3,643 customer rows, while `pid` IS the -#: `res.partner` id, so `from: "partner_id"` resolves 0 rows with nothing red. +#: The DECLARED pivot routes: `(source, target) -> the edge that answers it`. A pair with no entry +#: is a 400, never an empty grid — "there is no way to get there" and "nothing is related" are two +#: different answers and only one of them is true. #: -#: ⚠ AND THE MEASUREMENT BEHIND `target_dim: product_code` OUTLIVES THE TABLE IT WAS WRITTEN ON, so -#: the next author does not re-derive it: the SKU CODE is right and `product_id` is the wrong -#: obvious choice. `product_code` reproduces the product grid's identity exactly, `pid:` -#: fallback included; `product` (`l.product_id`) splits a re-SKUed pair whose archived half has no -#: grid row at all. MEASURED on this store over the wholesale scope: grouping by `product_id` gives -#: 3,360 buckets and by `product_code` gives 3,352 — 8 codes carry two ids each, and it is the -#: archived id of each pair that no `product_data` row claims (that module keeps archived products -#: out, by R12). Joining on the id therefore loses 8 real products while looking fine. - - -#: ⚰⚰ TOMBSTONE — WAVE 42, AMENDMENT A23, 2026-08-25. ONE PAIR. NOT A TABLE. DELETE ME IN WAVE 43. -#: -#: ⛔ WHY A DEAD CONSTANT IS BACK, AND IT IS NOT A HACK BOLTED ONTO A CLEAN DESIGN. W42-T05 -#: dissolved the compiled `(source, target)` route table so the pivot reads its edge off the `via` -#: bag on the link field the request NAMES. That work is correct and is not undone here. But the -#: old table answered THIS pair while ignoring `viaField` entirely, so the wave's headline -#: customer-to-product pivot shipped and worked; after T05 it 400s, and ⛔ SEEDING A `via` BAG -#: CANNOT FIX IT: `aios_grid.FIELDS` is 37 entries with ZERO of type `link`, -#: `scoped_fields('customer_data')` returns `list(aios_grid.FIELDS)` through `_customer_rows`, -#: `aios_grid.fields_from_workspace` is the CLIENT wire's separate path (notes / `custom_` / -#: `measure_` only), and `core.user_tables.add_field` refuses any key without the `ut_` prefix. -#: A LINK FIELD CANNOT EXIST ON A TOPIC GRID AT ALL — no stratum can hold one. -#: -#: ⭐ AND THE LANE BASE PREDICTED THIS IN A COMMENT AT THIS SITE (`648f051:routes_grid.py:1032`): -#: "`viaField` therefore RIDES THE WIRE AND IS ADVISORY for this pair … a door that refused an -#: unresolvable `viaField` would refuse R10's headline case." So this restores an invariant the -#: code documented and the ticket instructed away; it does not invent a new one. -#: -#: ⚰ THIS IS A TOMBSTONE, WITH AN EXPIRY THAT IS PROVABLE RATHER THAN HOPED FOR. Wave 43's -#: storage ticket — the stratum that lets a link field exist on a topic grid — DELETES this -#: constant and the guarded read in `_pivot_route` below. `verify_pivot.py` section 12 holds the -#: leg that makes the deletion safe: once a real `via` resolves for this pair the fallback is -#: UNREACHABLE, so wave 43 can PROVE the tombstone is dead instead of assuming it. +#: ⚠ `source_key` IS ASSERTED, NOT DECORATION. `filter_eval.visible_pids` reads `row['pid']` and +#: nothing else, so a future route whose source identity is NOT `pid` would silently pivot on the +#: wrong column. The route refuses instead. ⭐ For `customer_data` this is exactly right and W41-T19 +#: measured it: `partner_id` is declared `derived` and is on 0 of 3,643 rows, while `pid` IS the +#: `res.partner` id. `from: "partner_id"` resolves 0 rows with nothing red. #: -#: ⛔ `source_key` IS DELIBERATELY NOT CARRIED. The old table's copy only ever asserted `== "pid"` -#: against itself; `_clean_via` refuses it by name as an unknown key, so carrying it here would -#: make this bag illegal as a stored `via` and the two shapes would diverge. The six keys below -#: are exactly `_PIVOT_VIA_REQUIRED`, and the reasoning behind `order_partner`/`product_code` -#: lives in the block above rather than being restated. +#: ⚠ `target_dim` IS THE SKU CODE, NOT `product_id`, AND THE OBVIOUS CHOICE IS THE WRONG ONE. +#: `product_code` is written to reproduce the product grid's identity exactly, `pid:` +#: fallback included; `product` (`l.product_id`) splits a re-SKUed pair whose archived half has no +#: grid row at all. MEASURED on this store: over the wholesale scope, grouping by `product_id` +#: gives 3,360 buckets and by `product_code` gives 3,352 — 8 codes carry two ids each, and it is +#: the archived id of each pair that no `product_data` row claims (that module keeps archived +#: products out, by R12). Joining on the id therefore loses 8 real products while looking fine. _PIVOT_ROUTES = { ("customer_data", "product_data"): { "topic": "sales_lines", "measure": "revenue", - "scope_key": "customer", + "scope_key": "customer", # the cohort bucket the SOURCE tree's cohort leaves name "source_dim": "order_partner", + "source_key": "pid", "target_dim": "product_code", "target_key": "code", }, } -def _pivot_route(fields, source, target, via_field): - """The strict `via` resolution, with A23's ONE tombstoned pair behind it. - - ⛔⛔ THE STRICT DOOR IS TRIED FIRST, ALWAYS, AND THAT ORDER IS THE DESIGN. It is what makes - the fallback UNREACHABLE the moment a real `via` resolves for this pair, by construction - rather than by assertion — which is what lets wave 43 delete the tombstone and prove nothing - was still riding on it. A pre-check on the pair would invert that and is forbidden. - - ⚰ THE ESCAPE HATCH IS GUARDED TWICE: the strict door must have refused with `no_pivot_route`, - AND `(source, target)` must be the one key in `_PIVOT_ROUTES`. Every other unresolvable case - still raises with the field named. See the tombstone's own comment for why it exists and for - what deletes it. - """ - try: - return _pivot_route_via(fields, source, target, via_field) - except HTTPException as exc: - # ⛔ NARROW BY CODE, NOT BY TYPE. Only the "there is no edge" refusal may be excepted; a - # permission or shape failure raised from anywhere under here must still reach the client. - detail = exc.detail if isinstance(exc.detail, dict) else {} - if (detail.get("error") or {}).get("code") != "no_pivot_route": - raise - tomb = _PIVOT_ROUTES.get((source, target)) - if tomb is None: - raise - return dict(tomb) - - -def _pivot_route_via(fields, source, target, via_field): - """C1's `via` bag off the link field the REQUEST NAMED, or a 400 that names that field. - - ⛔⛔ EVERY UNRESOLVABLE CASE REFUSES, AND THAT IS THE WHOLE POINT OF THE FUNCTION. ⚰ Wave 42 - amendment A23 added EXACTLY ONE exception, and it is not in here: `_pivot_route` above catches - this function's `no_pivot_route` for the single tombstoned pair. Nothing else is excepted, and - nothing in this function may soften on account of it. Falling - through with a partial bag lands as `rows = [r for r in tgt_rows if str(r.get(tkey)) in keys]` - matching nothing, which is a 200 carrying `rows: []` and no `refusal` — the silent lie - `_pivot_reply`'s own docstring forbids, and it reads as "these customers ordered nothing". - "There is no way to get there" and "nothing is related" are two different answers and only one - of them is ever true. - - ⚠ `fields` IS THE READER'S SCOPED CONTRACT, NOT THE SCHEMA, and the wording follows from that. - It arrives through `perm_scope.scoped_fields`, so a field wall can hide a link field that does - exist. The refusal therefore says this ACCOUNT cannot resolve the field rather than that the - database has no such field, which would be a claim about the schema made out of a fact about - the reader. Resolving from the scoped list is still right for a `ut_*` source, whose link - fields ARE stored on the table document `scoped_fields` reads. - - ⛔⛔ AND IT IS STRUCTURALLY EMPTY OF LINK FIELDS FOR A TOPIC SOURCE, WHICH IS MEASURED, NOT - FEARED. `scoped_fields('customer_data')` is answered by `_customer_rows` below, which returns - `list(aios_grid.FIELDS)` — the compiled `aios_grid_fields.json` contract, which carries ZERO - entries of type `link`. Nothing merges the per-user `_table_workspace` stratum into that - path (`aios_grid.fields_from_workspace` is a different function, reached only by - `workspace_wire` on the client wire), and `core.user_tables.add_field` refuses a non-`ut_` key - outright, so no link field is written there either. ⚠ SO THE CUSTOMER-TO-PRODUCT PIVOT REFUSES - TODAY NO MATTER WHAT IS SEEDED IN A `via` BAG: that pair needs a link field to exist on a topic - grid at all, which is a stratum question and not this door's. Refusing is still the right - answer here; a second field-assembly door opened inside this route to manufacture one would be - the two-doors-onto-one-vocabulary defect this wave has already paid for twice. - """ - via_field = str(via_field or "").strip() - if not via_field: - raise err(400, "no_pivot_route", - f"this pivot did not name a link field, so there is no edge to answer it with. " - f"Send 'viaField' naming a link field on '{source}' that points at '{target}'") - - match = None - for field in fields or (): - if not isinstance(field, dict): - continue - if str(field.get("key") or "") == via_field and field.get("type") == "link": - match = field - break - if match is None: - raise err(400, "no_pivot_route", - f"this account cannot resolve a link field named '{via_field}' on '{source}', " - f"so there is no declared edge to '{target}'. Pivot from a link field this " - f"account can see on that grid") - - link = match.get("link") if isinstance(match.get("link"), dict) else {} - points_at = str(link.get("table") or "") - if points_at != target: - raise err(400, "no_pivot_route", - f"the link field '{via_field}' points at " - f"'{points_at or 'nothing this door can name'}', not at '{target}'. Refusing " - f"rather than pivoting through an edge nobody asked for") - - via = link.get("via") if isinstance(link.get("via"), dict) else None - if not via: - raise err(400, "no_pivot_route", - f"the link field '{via_field}' joins '{source}' to '{target}' directly and " - f"carries no 'via' declaration, so this door has no fact table to ask. A pivot " - f"needs the edge spelled out: topic, measure, and the two dims it joins on") - - missing = [k for k in _PIVOT_VIA_REQUIRED if not via.get(k)] - if missing: - raise err(400, "no_pivot_route", - f"the 'via' declaration on link field '{via_field}' is missing " - f"{', '.join(missing)}, so the edge is not fully described. Refusing rather " - f"than filling in a default, which would answer a question nobody asked") - return via - - -#: ARM 2 of the catch below: the `ModelError` sentences that are an AUTHORING fault, matched by a -#: needle rather than by type because `harness.semantic` raises the bare class for all of them. -#: ⛔ `unknown metric` IS THE ONE THAT ACTUALLY FIRES ON THIS DOOR. A bad `via.measure` reaches -#: `semantic._expand_measures` and comes back as a plain `ModelError`, NOT `UnknownColumnError` — -#: the subclass covers a bad DIM only. `via.measure` is authored by whoever wrote the link field, -#: so answering it with 503 "the store is unavailable" sends that author looking at the wrong -#: thing. The rest are here so a bag naming a bad topic, a foreign measure or an over-wide filter -#: lands the same way. ⚠ Kept at module level so the set is greppable and can be extended without -#: reading the route. -_PIVOT_AUTHORING = ( - "unknown metric", "unknown topic", "belong to another topic", "is company-level", - "at least one measure required", "filter tree", "filter rule", "must reference", - "grain must be", "is store-only", "unknown dim", "has no name column", -) - - def _pivot_today(): """The tenant's today, ISO. One clock, so the window and any date leaf agree.""" import core.periods as periods @@ -1378,43 +1206,14 @@ def grid_pivot(body: dict = Body(default=None), f"window must be one of {', '.join(sorted(PIVOT_WINDOWS))}. Refusing to guess " f"which period was meant, because guessing 'all time' would widen the answer " f"under a count nobody would doubt") - # ── C10 / R12 — THE WALL THAT WAS BUILT FOR THIS ROUTE, RUN FIRST (W42-T04) ────────────── - # - # ⛔ AHEAD OF EVERYTHING, AND THE ORDER IS THE POINT — MORE SO NOW THAT THE EDGE COMES OFF A - # FIELD. `_pivot_route` cannot run first even in principle: it reads the SOURCE's scoped field - # list, which means fetching a database, and fetching before the verdict is the wrong shape. - # And the two predicates `_pivot_side` has never enforced — `is_linkable_target` on BOTH - # endpoints and R12's `PIVOT_PATH_LEN` — live only here, so a request naming `users` or - # `cohort` gets a permission answer with a reason on the wire (C10) rather than a field lookup. - # W42-T04 wrote this note against a route TABLE that could have shadowed the wall; T05 removed - # the table and the ordering matters for the same reason under `via`. - # - # ⚠ `path` IS NOT READ FROM THE BODY. C1 enumerates the wire keys and `pivot_scope` defaults - # the walk to `[source, target]`; inventing an undeclared request key so the `chained` leg - # could be reached from here would widen the contract to exercise a guard. The leg is in - # force and unreachable from this door by construction while exactly one 2-hop pair is - # declared; `via` is what will start handing it a longer walk. - scope = perm_scope.pivot_scope(session.user, source, target, st=session.runtime) - if not scope.permitted: - # ⛔ A 200 WITH A REASON, NOT A STATUS. `_pivot_reply` stays the one envelope: it already - # carries `refusal` and `limits`, and `PivotScope.envelope()` would only re-emit the same - # `path` this route already writes while raising a key-precedence question nobody needs. - # `limits` is standing rule 1's second sentence carried across the join; it is empty on - # this pair because neither key is a `ut_` database, and it stops being empty for free the - # day one is pivotable. - return _pivot_reply(source, target, [], [], 0, 0, - refusal=scope.refusal(), - limits=scope.limits(st=session.runtime)) - - # ── THE ROW AND FIELD WALLS, ON BOTH SIDES OF THE JOIN (R12) ───────────────────────────── - # ⚠ THE VERDICT IS ASKED ONCE, ABOVE; THIS IS THE FETCH. `pivot_scope` decides WHETHER, and - # `_pivot_side` is what actually gets each side's scoped fields and rows through C1's one door - # (`scoped_fields` / `scoped_table`) plus the tenant/account module gate `session.require`. - # ⛔ W42-T05 DECLINED THE OFFERED COLLAPSE INTO `scope.source_grid`/`scope.target_grid`, on - # purpose: those two NARROW a `(fields, rows)` pair handed to them, they do not fetch one, so - # the collapse would need an UNWALLED read here to feed them and would drop `session.require` - # and the refusal shape `_pivot_side` owns. Two callers of one wall is not the redundancy the - # note warned about; an unwalled fetch to satisfy a shape would be the actual defect. + route = _PIVOT_ROUTES.get((source, target)) + if route is None: + raise err(400, "no_pivot_route", + f"there is no declared way to get from '{source}' to '{target}'. " + f"Declared: " + + "; ".join(f"{a} to {b}" for a, b in sorted(_PIVOT_ROUTES))) + + # ── THE WALL, ON BOTH SIDES OF THE JOIN (R12) ──────────────────────────────────────────── src, refusal = _pivot_side(session, source) if refusal is not None: return _pivot_reply(source, target, [], [], 0, 0, refusal=refusal) @@ -1425,12 +1224,6 @@ def grid_pivot(body: dict = Body(default=None), src_fields, src_rows = src tgt_fields, tgt_rows = tgt - # ⭐⭐ W42-T05 — THE EDGE, READ OFF THE LINK FIELD THE REQUEST NAMED. Deliberately AFTER both - # sides are walled, so a reader who may not open either one gets the permission answer C10 - # specifies rather than a lecture about link fields; and deliberately against `src_fields`, - # which is the reader's scoped contract and the same list the client chose `viaField` from. - route = _pivot_route(src_fields, source, target, body.get("viaField")) - today = _pivot_today() columns = filter_eval._columns_map(src_fields) unanswerable = _pivot_unanswerable(filters, columns) @@ -1455,9 +1248,10 @@ def grid_pivot(body: dict = Body(default=None), for cid, c in cohort_mod.scoped(route["scope_key"]).visible( session.uname, pool_pids).items()} - # ⛔ NO `source_key` CHECK ANY MORE, AND ITS ABSENCE IS THE POINT (C1, A5). It only ever - # compared the old table against itself; `via` declares no such key, so there is nothing left - # that could disagree with `visible_pids`' one reading of `row['pid']`. + if route["source_key"] != "pid": + raise err(500, "pivot_source_key", + f"this pivot names '{route['source_key']}' as the source identity and the row " + f"engine reads 'pid'. Refusing rather than pivoting on the wrong column") member = sorted({p for p in filter_eval.visible_pids( {"conj": filter_conj, "nodes": filters}, src_rows, src_fields, ctx=filter_eval.EvalCtx(cohort_sets=cohort_sets, today=today)) @@ -1479,45 +1273,8 @@ def grid_pivot(body: dict = Body(default=None), filters={route["source_dim"]: member}, date_from=date_from, date_to=date_to, team_id=team_id, today=today, limit=semantic.MAX_GROUPS) - # ⛔⛔ THREE ARMS, AND THE CATCH STAYS STRUCTURALLY A CATCH-ALL (W42-T05, amendment A8b). Until - # this ticket every `ModelError` off this one call became 503 "pivot_unavailable", which told - # an author whose `via.measure` was a typo that the STORE was down. Narrowing the catch would - # have been the worse fix: `except semantic.ModelError` still wraps everything, and a future - # raise site added inside `store_query` lands in arm 3 by DEFAULT rather than escaping as an - # uncaught 500. Nothing here re-words `harness.semantic`'s sentences; the arms only choose the - # status that makes the sentence actionable. - except semantic.UnknownColumnError as exc: - # ARM 1 — A COLUMN THIS TOPIC DOES NOT HAVE. 400, and the body NAMES IT: a 400 carrying a - # generic sentence is the same authoring dead end as the 503 was, one status politer. - # `.column` and `.columns` are attributes of the class, not text to be parsed back out. - # ⚠ THE REST OF `.columns` IS EMITTED ONLY WHEN THERE IS A REST. It defaults to - # `[column]`, so an unconditional parenthetical would read "is 'product_kode' (named: - # product_kode)" on the common single-column case, which is words that say nothing. - others = [c for c in (exc.columns or ()) if c != exc.column] - also = f" It also names {', '.join(others)}." if others else "" - raise err(400, "pivot_unknown_column", - f"{str(exc).rstrip('. ')}. The column this pivot could not resolve is " - f"'{exc.column}'.{also} Fix the link field's 'via' declaration, or pivot " - f"through a link field whose dims this topic actually carries") except semantic.ModelError as exc: - sentence = str(exc) - low = sentence.lower() - if any(needle in low for needle in _PIVOT_AUTHORING): - # ARM 2 — AUTHORING, BUT NOT A COLUMN. An unknown metric (the one that fires on a bad - # `via.measure`, because `_expand_measures` raises the BARE class), a foreign measure, - # an unknown or company-level topic, a filter tree over the width, depth or size - # limits, a sort that names nothing selected, and T06's invalid-operator refusal. - # Every one of them is a fault in what was ASKED, so the asker is who can fix it. - raise err(400, "pivot_bad_edge", - f"{sentence.rstrip('. ')}. This pivot's edge comes from the link " - f"field's 'via' declaration, so that declaration is what needs correcting") - # ARM 3 — A GENUINE OUTAGE, 503, UNCHANGED, AND THE DEFAULT. The store is locked, cold or - # missing, or the topic has no store binding. ⚠ `harness.semantic`'s data-cache sentence - # says the cache is warming up after a restart; that is TRUE about the store and often - # FALSE about the cause, which is usually another process holding it. It is not reworded - # here: that file belongs to W42-T06, and a second copy of the sentence is how one fact - # gets two definitions. - raise err(503, "pivot_unavailable", sentence) + raise err(503, "pivot_unavailable", str(exc)) # ⛔ `truncated` IS READ, NOT ASSUMED FALSE. `store_query` returns groups plus a flag, and a # grouped query's groups ARE the answer, so a dropped one is missing data with nothing to @@ -1617,200 +1374,3 @@ def _register_topic_rows(): _PRODUCT_MODULE = "product_data" _C1_ROW_SOURCES = _register_topic_rows() - - -# ----------------------------------------------------------------------------------------------- -# W42-T07 / contract C2 + amendment A5 - THE HOP OPERAND OFFER. -# -# `GET /grid/hop-topics` is the picker behind C1's `via` bag: the filter builder needs a topic's -# DIMS as operands, the Measure picker needs that topic's MEASURES, and the window select needs -# the window vocabulary the SAVE DOOR accepts. All three ride one payload because they are one -# feature - A5's whole point is that dims alone leave the Measure picker permanently empty, -# `_clean_via` refuses a bag with no `measure`, and no user can ever complete a hop while every -# key-set parity gate stays green. -# -# It lives here and not in `routes_tables`, deliberately: this is pivot machinery. -# `routes_tables`' `brief=1` branch answers a different question (which DATABASES a link may -# target), and `_rollup_source_offer` answers a third (which (topic, measure, dim) triples a -# ROLLUP column may bind). This one is MIRRORED from that offer's logic and calls none of it. -# -# MODEL FILES ONLY - NO STORE READ, AND THAT IS A DECISION, NOT AN OMISSION. -# `_rollup_source_offer` additionally gates each topic on `sem._source_ready(table)`, and this -# endpoint deliberately does NOT. `core.user_tables._topic_dims` - the VALIDATOR half of this -# very feature - already ruled on the same question in its own docstring: "THE MODEL FILES, -# NEVER THE STORE ... a validator that needed the store to say yes would refuse every legal link -# the moment the file was locked by a `validate.py` run or cold after a restart." An OFFER that -# failed closed on a locked store is strictly worse than a validator that does: the validator -# refuses one link out loud, the offer empties the picker for everybody with nothing on screen to -# explain it - A5's exact catastrophe, reached by a different road. It would also make the C2 -# parity gate flip red or green depending on who holds the duckdb lock. -# THE COST IS STATED RATHER THAN HIDDEN: a topic with dims and measures but no live table -# (`stock_moves` is the standing example) IS offered here and refuses at hop time. That is the -# loud failure `_pivot_route_via` is built around, not the silent blank-column failure W37-T13 -# found on the rollup door. A future ticket may add readiness as a THIRD omission cause; it must -# then also decide what an INDETERMINATE store answer means for the cache. -# -# Because the offer is a pure function of the model files, the cache is unconditionally safe - -# none of `_ROLLUP_CACHE`'s "never memoise an indeterminate answer" reasoning applies here. -_HOP_CACHE = {} - -#: C2's `Field.source`. Every operand this endpoint offers comes from the TOPIC, never from the -#: grid's own field contract, and the client needs to tell them apart when it merges the two -#: lists into one filter builder. -_HOP_FIELD_SOURCE = "topic" - -#: The `FieldType` names this offer may emit - the intersection of `harness.semantic`'s column -#: types and `customer-grid/types.ts::FieldType`. A type outside this set would reach a renderer -#: with no branch for it. -_HOP_FIELD_TYPES = ("text", "int", "date", "currency", "pct") - - -def _hop_dim_type(cols, dim_key): - """`Field.type` for one dim, READ OUT of `harness.semantic.store_columns`, never mapped here. - - C2 asks for a type "from the dim's store column" and this is literally that: `store_columns` - publishes `{sql, type, aggregate}` per column, and the dim's entry is its NAME column - the - text a user types "contains" against - so every dim resolves to `text` today, while the - `_id` companion column it also publishes is `int`. That is DERIVED, not a mapping - somebody wrote down: give a future dim a numeric name column and the type follows with no - edit here. - - `store_columns` reads `_model()` (the cached `platform/model/topics/*.yml`) and opens no - DuckDB connection, so asking it costs nothing and cannot make this endpoint store-dependent. - - The `text` fallback is reachable only if `store_columns` and `store.dims` ever disagree about - which keys exist, which they cannot today (one iterates the other). It is a floor, not a - guess: it keeps an unknown operand FILTERABLE as text rather than emitting a type no renderer - has a branch for. - """ - ftype = ((cols or {}).get(dim_key) or {}).get("type") - return ftype if ftype in _HOP_FIELD_TYPES else "text" - - -def _hop_topic_offer(): - """`{topics, windows, omissions}` - every topic a `via` hop can actually be completed through. - - A DIMLESS TOPIC IS OMITTED, NOT OFFERED EMPTY (amendment A12). The ticket's own done-when - asked for it to be "offered with an empty operand list rather than omitted silently", and - that is the LETTER of a rule whose operative word is *silently*: `user_tables._clean_via` - refuses a `via` whose topic has no dims (`if not dims: return None`), because a hop REQUIRES - `source_dim` and `target_dim` to BE dims of that topic. A dimless topic can therefore never - be a legal hop, and offering it with an empty operand list ships a dead option that spends - the user's time and blanks the column. So it is omitted AND the omission is REPORTED, with a - machine-readable cause, under `omissions` - the client can say the topic cannot be hopped - through, and nothing offered is unanswerable. - - A TOPIC WITH NO USABLE MEASURE IS OMITTED FOR ITS OWN, DIFFERENT REASON, and the two causes - are reported separately because seven of the eight topics omitted today are omitted for the - SECOND one. Reporting them all as "declares no dims" would be false for seven of eight, and a - client renders a cause verbatim to a user. `odoo_agents` alone carries both codes. - - THE MEASURE EXCLUSION IS A PREDICATE, NEVER A NAME LIST. `store_query` refuses a - `ratio`/`derived` metric the moment a `group_by` is present (cross-topic measures are - scalar-only), and a hop ALWAYS has a `group_by` - its `target_dim`. Enumerating the excluded - metrics by NAME instead would be [[two-enumerations-from-different-anchors]]: the names most - often quoted for this rule are `aov`/`margin_pct`/`returns_pct`, and the live predicate - catches seven, so a name list would offer four hops that can only ever error, plus every - ratio metric added after it was written. - - ONLY THE DECLARED DIMS ARE OFFERED. `store_columns` also publishes a `_id` companion and - a `date` column, and `_clean_tree` would happily validate a filter on either - but C2's - operand list is the topic's DIMS, and `_clean_via` matches `source_dim`/`target_dim` against - exactly `store.dims`. Offering `_id` here would put two names for one operand in one - picker. A future ticket may add them under their own payload key. - """ - if _HOP_CACHE.get("offer"): - return _HOP_CACHE["offer"] - from harness import semantic as sem - from harness import windows as W - import core.user_tables as user_tables - - topics, metrics = sem.topics(), sem.metrics() - - by_topic = {} - for key, m in metrics.items(): - # See the docstring: the predicate IS the enumeration. A ratio/derived metric cannot - # survive the `group_by` a hop always carries. - if m.get("agg") in ("ratio", "derived"): - continue - by_topic.setdefault(m.get("topic"), []).append( - {"key": key, "label": m.get("label") or key, - "format": m.get("format") or "usd", - "description": m.get("description") or ""}) - - out, omissions = [], [] - for tkey, t in sorted(topics.items()): - store = t.get("store") or {} - dims = store.get("dims") or {} - measures = by_topic.get(tkey) or [] - label = t.get("label") or tkey - if not dims or not measures: - # Machine-readable codes, and BOTH when both apply. No prose sentence lives in this - # payload: the wording belongs to the client, which is the surface that knows what - # the user was trying to do. - reasons = [] - if not dims: - reasons.append("no_dims") - if not measures: - reasons.append("no_measures") - omissions.append({"topic": tkey, "label": label, "reasons": reasons}) - continue - try: - cols = sem.store_columns(tkey, include_measures=False) - except Exception: # noqa: BLE001 - # A topic can declare `store.dims` only inside a `store` block, so this is - # unreachable today; it is here so a malformed model file drops ONE topic with a - # stated cause instead of 500-ing the whole picker. - omissions.append({"topic": tkey, "label": label, - "reasons": ["no_store_columns"]}) - continue - out.append({ - "key": tkey, - "label": label, - "grain": t.get("grain") or "", - # C2's `Field[]`. SUPERSET, DECLARED: C2 enumerates `key`/`label`/`type`/`source`, - # and `keyedBy` rides along because C1's `target_key` is the TARGET GRID's join - # column and the user cannot pick it without knowing whether this dim keys by an - # Odoo id or by its own value - the same fact `RollupSourceTopic.dims` carries. - "dims": [{"key": dkey, - "label": d.get("label") or dkey, - "type": _hop_dim_type(cols, dkey), - "source": _HOP_FIELD_SOURCE, - "keyedBy": "id" if d.get("name_col") else "value"} - for dkey, d in dims.items()], - "measures": sorted(measures, key=lambda m: m["label"].lower()), - }) - - # `core.user_tables.LINK_VIA_WINDOWS` IS THE AUTHORITY, not `harness.windows`, because it is - # the set `_clean_via` accepts (`window not in LINK_VIA_WINDOWS` refuses the field). It - # aliases `ROLLUP_SOURCE_WINDOWS` and deliberately omits the parameterised kinds - # (`last_n_days`, `custom`), which have nowhere in the `via` bag to carry their `n`. Offering - # a kind the save door refuses would let the editor build a field that cannot be stored. - # The all-time key is `all_time`; `all` is not a window and is refused. - windows = [{"key": k, "label": W.WINDOW_LABELS.get(k, k).format(n="N")} - for k in user_tables.LINK_VIA_WINDOWS] - - offer = {"topics": out, "windows": windows, "omissions": omissions} - _HOP_CACHE["offer"] = offer - return offer - - -@router.get("/grid/hop-topics") -def grid_hop_topics(session: Session = Depends(require_session)): - """C2's operand offer for the hop editor: topics, their dims and measures, and the windows. - - TENANT-SCOPED, for the reason `rollup_sources` records: `sem.topics()` reads the GLOBAL model - files, so a tenant with no Odoo mirror behind it would be offered a hop it can never resolve. - `odoo_relational.is_royal` is the one authority on that and is reused, not re-decided. - - `omissions` IS PRESENT ON EVERY RESPONSE, including this early return, so the payload shape is - unconditional and no reader needs a branch for its absence. - - Declared as a LITERAL path under `/grid/`, and there is no `GET /grid/{something}` route in - this file for it to be swallowed by. If one is ever added it must go BELOW this line - FastAPI - matches in declaration order. - """ - import odoo_relational - if not odoo_relational.is_royal(session.tenant): - return {"topics": [], "windows": [], "omissions": []} - return _hop_topic_offer() diff --git a/api/routes_odoo_tables.py b/api/routes_odoo_tables.py index 6ef9efe309924fb5cfd1575322b2aad5a309f021..8150c7e63978d26217a8ea3d62c727f757b2bf7b 100644 --- a/api/routes_odoo_tables.py +++ b/api/routes_odoo_tables.py @@ -831,24 +831,6 @@ def _json_arg(raw, what): return val -def _clean_include_pids(raw): - """Bounded existing Link choices, or a named bad-argument refusal. - - A picker condition narrows what a person may add. It never rewrites existing links, so a - read-through window carries those ids as an explicit exception. The normal table and row wall - remains outside that exception in `odoo_table_rows` below. - """ - if raw is None: - return [] - if not isinstance(raw, list): - raise ValueError("includePids must be a JSON list of record ids") - if len(raw) > 500: - raise ValueError("includePids may name at most 500 existing linked records") - if any(type(pid) is not int or pid <= 0 for pid in raw): - raise ValueError("includePids must contain positive integer record ids") - return sorted(set(raw)) - - @router.get("/odoo-tables/{table_key}/rows") def odoo_table_rows(table_key: str, offset: int = Query(default=0, ge=0), @@ -857,7 +839,6 @@ def odoo_table_rows(table_key: str, filterConj: str = Query(default="and"), sorts: str = Query(default=None), search: str = Query(default=None), - includePids: str = Query(default=None), session: Session = Depends(require_session)): """ONE WINDOW over a connected grid, read straight from the mirror (contract C2). @@ -1004,10 +985,6 @@ def odoo_table_rows(table_key: str, base_where = (f'({spec["where"]}) AND {wall_sql}' if (spec["where"] and wall_sql) else (wall_sql or spec["where"])) limits, tree = [], _json_arg(filters, "filters") - try: - include_pids = _clean_include_pids(_json_arg(includePids, "includePids")) - except ValueError as e: - raise err(400, "bad_argument", str(e)) sort_spec = _json_arg(sorts, "sorts") or [] # R6's SECOND SENTENCE, ON THE WALL ITSELF. The precision note further down covers the @@ -1069,7 +1046,7 @@ def odoo_table_rows(table_key: str, "recommendation": "compare against a whole number, or use a range that does not sit " "on a fractional boundary"}) - predicate_sql, predicate_params = None, [] + where, params = base_where, list(wall_params) try: pred = _fs().compile_filter_tree( tree, conj=(filterConj if filterConj in ("and", "or") else "and"), columns=cols, @@ -1083,25 +1060,13 @@ def odoo_table_rows(table_key: str, raise err(400, "filter_unsupported", "a condition on an aggregate column belongs in HAVING, and that path is " "deliberately not built for windowed grids") - predicate_sql = pred.sql - predicate_params.extend(pred.params) + where = f"({where}) AND {pred.sql}" if where else pred.sql + params.extend(pred.params) if str(search or "").strip(): got = _fs().compile_search(search.strip(), cols) if got is not None: - predicate_sql = (f"({predicate_sql}) AND {got.sql}" - if predicate_sql else got.sql) - predicate_params.extend(got.params) - - # Existing choices survive a new picker condition, but the table's permanent wall stays - # outside this OR. A caller cannot use an old link id to reveal a record the database wall - # would otherwise deny. - if include_pids and predicate_sql: - slots = ", ".join("?" for _ in include_pids) - predicate_sql = f"(({predicate_sql}) OR ({spec['id']} IN ({slots})))" - predicate_params.extend(include_pids) - where = (f"({base_where}) AND ({predicate_sql})" if base_where and predicate_sql - else (predicate_sql or base_where)) - params = [*wall_params, *predicate_params] + where = f"({where}) AND {got.sql}" if where else got.sql + params.extend(got.params) # ── the order, made TOTAL ───────────────────────────────────────────────────────────────── # ⛔ `tiebreak_sql` is not optional here: without a total order, LIMIT/OFFSET may return one diff --git a/api/routes_shares.py b/api/routes_shares.py index 81fc0bb0c93362161a45ec7d0afe23e9afff7202..1e5ce725086207058c8f8cd889073365623ca522 100644 --- a/api/routes_shares.py +++ b/api/routes_shares.py @@ -202,6 +202,10 @@ def _owns_object(session, kind, oid): where a caller is about to be stamped OWNER of something nobody owns — so the resolution runs exactly there, and the common path (a record exists, `may_administer` decides) is untouched. """ + if kind == "cohort": + scope, cohort_id = shares.split_cohort_oid(oid) + source = shares.cohort_source(scope, cohort_id, st=session.runtime) if scope else None + return bool(source and source["owner"] == str(session.uname or "").strip().lower()) if session.admin: return True if kind == "field": @@ -411,422 +415,43 @@ def _unremovable(held_rows, entries, uname): stuck.add(user) return stuck -# ── ⭐⭐ WAVE 42 · T25 — SHARING A VIEW SHARES THE FIELDS IT NAMES ────────────────────────────── -# -# A saved view is a SELECTION expressed in columns: an order, a visible set, a group, a colour, -# a sort and a filter tree, every one of which names a field key. Granting the view alone hands -# the receiver a rail row whose columns resolve to nothing they may read, so the feature that -# looked shipped puts an empty grid on their screen. This block closes that, and it refuses -# LOUDLY in the three places where a grant would otherwise be a lie. -# -# ⛔⛔ TRAP 1 — A FIELD GRANT IS TOPIC-WIDE, NOT VIEW-SCOPED. It is keyed -# `shares.field_oid(topic, key)`, so it hands the receiver that column on EVERY view of that -# database, for as long as the grant stands. That is the existing grant primitive and this -# ticket does not narrow it; what it does is refuse to let the response PRETEND otherwise, which -# is why `sharedFieldScope` rides back beside the list. -# -# ⛔⛔ TRAP 2 — THE SHARER CANNOT GRANT A COLUMN THEY DO NOT OWN. `field` is absent from -# `shares.RESHARE_KINDS` and `_owns_object`'s field branch demands `createdBy == session.uname`, -# so a view built over a colleague's column simply cannot carry that column with it. Silently -# skipping it ships an empty column; silently granting it is a permission hole. It is REFUSED -# BY NAME, inside the 200 response, with the sentence that says who to ask. -# -# ⛔⛔ TRAP 3 — A GRANT CANNOT UN-HIDE AN ADMIN-WALLED COLUMN. `perm_scope.hidden_keys` UNIONS -# the administrator's `hiddenFields` with the field-grant source, so granting a walled column -# answers 200, writes the record, and the receiver still sees nothing: granted with no effect, -# which is the failure this ticket exists to end. Detected BEFORE the grant, refused by name, -# and the receiver it is walled from is named too. -# -# ⚠ THE WALL IS READ FROM THE ADMIN SOURCE DIRECTLY AND NEVER THROUGH `hidden_keys`, AND THAT IS -# NOT AN OPTIMISATION. `hidden_keys` unions `field_grant_hidden` — "hidden because you were not -# granted it" — which is true of every column this route is ABOUT to grant. Asking it here would -# refuse the whole set on the grounds that the grant has not happened yet. - -def _view_configs(view): - """The dicts a saved view keeps its column state in. +@router.get("/share/mine") +def my_shares(session: Session = Depends(require_session)): + """Everything shared WITH me, by kind — the "Shared with me" rail section (R10). - `SavedView.config` is the contract (`types.ts::ViewConfig`) and is where every key below - actually lives. The record ITSELF is read as well because a legacy row that stored the same - members flat costs one dict lookup to cover and would otherwise hand back an empty key set, - which is the shape that grants nothing and reports nothing wrong. + Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves + in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL + that is not malformed at all. """ - out = [] - if not isinstance(view, dict): - return out - cfg = view.get("config") - if isinstance(cfg, dict): - out.append(cfg) - out.append(view) - return out - + return shares.shared_with(session.uname, st=session.runtime) -def _view_field_keys(view): - """Every field key this view NAMES, in the order a reader would meet them. - ⚠ `filters[].rhs` IS ONE OF THEM AND IS THE ONE THAT GETS DROPPED. A field-vs-field leaf - (`{colId: price, op: lt, rhs: {kind: field, colId: cost}}`) names TWO columns, and a share - carrying only the left one leaves the receiver a filter that cannot evaluate. `kind` `stat` - is not a column reference and is skipped, matching `perm_scope`'s own reader of the tree. +@router.get("/cohorts/{scope}") +def list_cohorts(scope: str, session: Session = Depends(require_session)): + """The authorized Cohort list for one database. - ⚠ AND `filters` IS A TREE, NOT A LIST. Condition groups nest under `children`, so the walk - recurses rather than iterating once: a column named only inside a group is still named. - """ - keys, seen = [], set() - - def add(raw): - key = str(raw or "").strip() - if key and key not in seen: - seen.add(key) - keys.append(key) - - def walk(node): - if isinstance(node, list): - for child in node: - walk(child) - return - if not isinstance(node, dict): - return - kids = node.get("children") - if isinstance(kids, list): - walk(kids) - add(node.get("colId")) - rhs = node.get("rhs") - if isinstance(rhs, dict) and rhs.get("kind") != "stat": - add(rhs.get("colId")) - - for cfg in _view_configs(view): - for key in (cfg.get("order") or ()): - add(key) - for key in (cfg.get("visible") or ()): - add(key) - walk(cfg.get("filters")) - add(cfg.get("groupBy")) - colour = cfg.get("colorBy") - add(colour.get("colId") if isinstance(colour, dict) else colour) - sorts = cfg.get("sorts") - if isinstance(sorts, dict): - sorts = [sorts] - for spec in (sorts or ()): - if isinstance(spec, dict): - add(spec.get("colId")) - return keys - - -def _find_view_topic(session, oid): - """`(topic, view)` for a saved view living in any personal stratum of any topic, else - `(None, None)`. - - ⚠ THE TOPIC IS HALF THE ANSWER. A field grant is keyed by TOPIC, so resolving the view - without remembering which workspace answered would leave the caller guessing at the oid, - which is exactly how two arms of one route come to write two different keys for one column. - Same loop and same finder as `_owns_object`, which answers a different question about the - same lookup and is deliberately left alone. + Unlike the former owner-bucket projection, this calls the same predicate as the Cohort + detail below. An account without a grant receives no entry at all; it never receives an + id/name shell whose membership has been stripped. """ try: - import core.table_store as table_store - except Exception: # noqa: BLE001 - return None, None - for topic in _topics(session): - try: - hit = table_store.make(f"{topic}_table_workspace", st=session.runtime).find_view(oid) - except Exception: # noqa: BLE001 - continue - if hit: - return str(topic), (hit[1] if len(hit) > 1 else {}) - return None, None + records = shares.visible_cohorts(scope, session.uname, st=session.runtime) + except ValueError as exc: + raise err(400, "bad_cohort_scope", str(exc)) + return {"cohorts": [{"id": cid, **record} for cid, record in sorted(records.items())]} -def _promote_field_for_grant(session, table_key, field_key): - """Promote a private column exactly once, stamp it shared, and answer its GRANT OID. - - ⛔⛔ ONE SEQUENCE, TWO ARMS, AND THAT IS THE WHOLE REASON THIS IS A FUNCTION. `put_share`'s - `field` arm and its `view` arm (T25) both hand a column to somebody. Two spellings of - "promote, stamp, key the oid" is how two surfaces start disagreeing about what a grant - means, and the oid is the exact member that would drift: it is normalised through - `_field_storage_keys(...)[2]`, never built from whatever key the caller happened to hold. - - Answers `None` for a key with no definition on that database, so the caller decides whether - that is a 404 (the `field` arm, where the column IS the request) or a named refusal (the - `view` arm, where it is one entry in a list). - """ - from core import field_permissions, shared_overlay - defn, already_shared, workspace_key, shared_key, grant_topic = _field_definition( - session, table_key, field_key) - if not isinstance(defn, dict): - return None - if not already_shared: - defn = field_permissions.promote_field( - workspace_key, shared_key, grant_topic, - session.uname, defn, st=session.runtime) - stamped = dict(defn) - stamped["shared"] = True - stamped["granted"] = True - shared_overlay.put_field(shared_key, field_key, stamped, st=session.runtime) - return shares.field_oid(grant_topic, field_key) - - -def _grant_receivers(session, entries): - """`{username: display name}` for everybody a grant list actually reaches. - - `shares.EVERYONE` is a real audience, not a placeholder, so it expands to the tenant roster - `_people` already computes for the picker. Reading it from a second place would be a second - answer to "who is in this workspace". - """ - people = None - out = {} - for entry in shares._clean_entries(entries): - who = str(entry.get("user") or "").strip().lower() - if not who: - continue - if who == shares.EVERYONE: - if people is None: - people = _people(session.tenant) - for person in people: - out.setdefault(str(person["username"]).strip().lower(), person["name"]) - continue - out.setdefault(who, who) - if out: - try: - reg = users.registry() or {} - except Exception: # noqa: BLE001 - reg = {} - for uname in list(out): - rec = reg.get(uname) - if isinstance(rec, dict) and rec.get("name"): - out[uname] = str(rec["name"]) - return out - - -def _admin_walled(session, grant_topic, defs, receivers): - """`{field key: [display names]}` for receivers an ADMINISTRATOR has already walled off. - - ⛔ THE ADMIN SOURCE ONLY. `perm_scope.entry(rec, module)['hiddenFields']` is the half of - `hidden_keys` a field grant cannot overrule; the other half IS the field grant, so consulting - the closure here would report every column this route is about to grant as hidden and refuse - the lot (see the block note above). - - ⭐ THE MEASURE BINDING IS BORROWED, NEVER RESPELLED. An administrator ticking - "Metric - Gross margin" stores `measure_margin`, while the COLUMNS carrying that number are - keyed `measure_margin_`; `perm_scope._measure_bound_keys` is the one evaluator that - joins the two, and a second copy here would be the first to drift. - - ⚠ AN ADMINISTRATOR RECEIVER IS NEVER WALLED — `hidden_keys` answers an empty set for one - before it reads anything — so the same short circuit runs here, or this function would refuse - a grant to somebody who can already see the column. - - ⚠ A RECEIVER WITH NO REGISTRY RECORD IS SKIPPED RATHER THAN TREATED AS WALLED: `perm_scope` - reads a permission block off the RECORD (`_rec` answers `{}` for a bare username), so a name - this tenant cannot resolve carries no admin wall to find. `_entries_or_400` has already - refused a grant list naming an account that does not exist. - """ - import core.perm_scope as perm_scope - import core.perms as perms +@router.get("/cohorts/{scope}/{cohort_id}") +def get_cohort(scope: str, cohort_id: str, session: Session = Depends(require_session)): + """One authorized Cohort, with an absent-permission answer indistinguishable from absence.""" try: - reg = users.registry() or {} - except Exception: # noqa: BLE001 - reg = {} - field_list = [dict(defn or {}, key=key) for key, defn in defs.items() - if isinstance(defn, dict)] - walled = {} - for uname, display in sorted(receivers.items()): - rec = reg.get(uname) - if not isinstance(rec, dict) or perms.is_admin(rec): - continue - try: - entry = perm_scope.entry(rec, grant_topic) or {} - except Exception: # noqa: BLE001 - continue - hidden = {str(k) for k in (entry.get("hiddenFields") or ()) if k} - if not hidden: - continue - try: - hidden |= set(perm_scope._measure_bound_keys(hidden, field_list)) - except Exception: # noqa: BLE001 - pass - for key in defs: - if key in hidden: - walled.setdefault(key, []).append(display) - return walled - - -#: ⭐ THE THREE REASONS A VIEW'S COLUMN IS NOT GRANTED, EACH ITS OWN CODE. One fused -#: "could not share" would make the three walls unprovable apart, which is the -#: [[a-declared-gate-is-an-unchecked-claim]] failure `verify_field_doors` section 8 exists to -#: prevent: every cause fails by its own name, or it is not gated at all. -#: ⛔ STANDING RULE 2 — no em dash and no en dash in any sentence below; these reach a screen. -FIELD_REFUSAL_CODES = ("not_a_shared_column", "not_owned", "admin_hidden") - -#: ⭐ TRAP 1, SAID OUT LOUD. A column grant is keyed by DATABASE, so it is not scoped to the view -#: that occasioned it. The response carries this sentence rather than letting a caller infer a -#: narrower promise than the store actually makes. -FIELD_GRANT_SCOPE = ("A column shared this way is shared on the whole database, so the people " - "you named can see it on every view of it, not only on this one.") - - -def _refusal(key, code, names=()): - """One refused column, with the sentence a person reads.""" - if code == "not_a_shared_column": - why = ('"{k}" belongs to the database itself rather than to a person, so there was ' - 'nothing to grant. Anybody who can open the database already sees it.') - elif code == "not_owned": - why = ('"{k}" is somebody else\'s column, and only its owner (or an administrator) can ' - 'share it. Ask them to share it, then share this view again.') - else: - why = ('"{k}" is hidden from {who} by an administrator, so sharing it would change ' - 'nothing. Ask an administrator to unhide it first.') - return {"field": key, "reason": code, - "message": why.format(k=key, who=_named(names) if names else "them")} - - -def _classify_view_fields(keys, owned, defined, walled): - """`(sharedFieldKeys, refusedFieldKeys)` for the columns a view names. PURE. - - ⛔ EVERY KEY IS DECIDED BEFORE ANY OF THEM IS WRITTEN, which is the same property the ceiling - block above claims for the three role walls: a refusal must not be able to leave part of the - set promoted and part of it untouched. - - ⚠ THE ORDER OF THE CAUSES IS FIXED AND IT DECIDES WHICH SENTENCE A PERSON READS. A built-in - column is owned by nobody, so "there was nothing to grant" has to be asked before "you do not - own it", or every database column in `visible` would be reported as somebody else's. - """ - granted, refused = [], [] - for key in keys: - if key not in defined: - refused.append(_refusal(key, "not_a_shared_column")) - elif key not in owned: - refused.append(_refusal(key, "not_owned")) - elif walled.get(key): - refused.append(_refusal(key, "admin_hidden", walled.get(key))) - else: - granted.append(key) - return granted, refused - -def _merge_field_entries(existing, incoming): - """A column's grant list with `incoming` ADDED to it. Nobody is removed, nobody is lowered. - - ⛔⛔ THE SHARE PUT REPLACES, AND A COLUMN'S AUDIENCE IS NOT THE VIEW'S AUDIENCE. This route's - contract is a REPLACING set (`PUT ... entries` at the top of the file), which is right for the - object the caller is looking at and catastrophic for the columns underneath it: a column grant - is TOPIC-WIDE, so handing the view's list straight to `set_grants` would rewrite the column's - audience to whoever this view happens to be shared with. A colleague granted that column - through the `field` door would lose it, on every view of the database, silently, inside a 200. - That is exactly the harm D-474 closed at the view door, arriving one door over. - - ⚠ AND A LENGTH CHECK DOES NOT SEE IT. Replacing a one-person list with a different one-person - list keeps the count identical, so the gate asserts the HELD USER IS STILL THERE AT THEIR OWN - ROLE rather than counting rows. - - ⭐ RAISED, NEVER LOWERED. A receiver already held at `view` who arrives on an `edit` share is - raised, because only the column's owner (or an administrator) reaches this path at all and - that is the access they just asked to hand over. An `edit` holder meeting a `view` share keeps - `edit`: a share is an addition, and nothing here is a revocation. - - ⚠ `by` RIDES ON EXISTING ROWS AND IS NEVER TAKEN FROM AN INCOMING ONE. Provenance decides who - may revoke a grant (`_unremovable`), so an incoming payload must not be able to claim it; - `set_grants` stamps a genuinely new row from `granter`. - """ - rank = {"view": 0, "edit": 1} - order, merged = [], {} - for row in (existing or ()): - if not isinstance(row, dict): - continue - who = str(row.get("user") or "").strip().lower() - role = str(row.get("role") or "").strip().lower() - if not who or role not in rank or who in merged: - continue - kept = {"user": who, "role": role} - stamp = str(row.get("by") or "").strip().lower() - if stamp: - kept["by"] = stamp - merged[who] = kept - order.append(who) - for row in (incoming or ()): - if not isinstance(row, dict): - continue - who = str(row.get("user") or "").strip().lower() - role = str(row.get("role") or "").strip().lower() - if not who or role not in rank: - continue - if who not in merged: - merged[who] = {"user": who, "role": role} - order.append(who) - elif rank[role] > rank[merged[who]["role"]]: - merged[who]["role"] = role - return [merged[who] for who in order] - -def _share_view_fields(session, oid, entries): - """The `view` arm's field half: classify every column the view names, then grant the ones - that survive. `(sharedFieldKeys, refusedFieldKeys)`. - - ⛔ CLASSIFY EVERYTHING, THEN WRITE. `_classify_view_fields` sees the whole key set before - `_promote_field_for_grant` touches a single definition, so a refusal cannot leave half the - view's columns promoted. - - ⚠ THE VIEW'S OWN GRANT HAS ALREADY BEEN WRITTEN when this runs, and that order is the safe - one: a `409 grants_changed` on the view must not be preceded by a set of column promotions - for a share that then did not happen. - """ - topic, view = _find_view_topic(session, oid) - if not topic or not isinstance(view, dict): - return [], [] - keys = _view_field_keys(view) - if not keys: - return [], [] - table_key = f"{topic}_table_workspace" - grant_topic = _field_storage_keys(table_key)[2] - defs, defined, owned, owners = {}, set(), set(), {} - for key in keys: - defn, already_shared = _field_definition(session, table_key, key)[:2] - if not isinstance(defn, dict): - continue - defs[key] = defn - defined.add(key) - # ⚠ THE COLUMN'S OWNER, NOT THIS SESSION. `set_grants`' owner is sticky, so stamping the - # SHARER here would hand an administrator sharing somebody else's column the ownership - # of it as a side effect of sharing a view. Same resolver the claim wall uses. - owners[key] = _field_owner(session, defn, already_shared) - # ⛔ ONE OWNERSHIP PREDICATE. `_owns_object` is the wall the `field` arm claims through - # and the DELETE door reads; a second "does this person own this column" here is how the - # two answers come apart. It short circuits True for an administrator, which is right: - # an administrator may already share any column through the `field` door. - if _owns_object(session, "field", shares.field_oid(grant_topic, key)): - owned.add(key) - walled = _admin_walled(session, grant_topic, defs, _grant_receivers(session, entries)) - granted, refused = _classify_view_fields(keys, owned, defined, walled) - written = [] - for key in granted: - field_oid = _promote_field_for_grant(session, table_key, key) - if not field_oid: - refused.append(_refusal(key, "not_a_shared_column")) - continue - # ⛔⛔ ADDED TO, NEVER REPLACED. `set_grants` writes the list it is given, and this route - # PUTs a REPLACING set, so handing it the view's `entries` would make the view's audience - # the COLUMN's audience: a colleague granted this column through the `field` door would - # lose it, on every view of the database, inside a 200. `_merge_field_entries` says what - # that sentence means once, and it is the only writer of a column list on this path. - held = shares.grants("field", field_oid, st=session.runtime) - # ⚠ NO `expect` HERE, AND THAT IS NOT AN OVERSIGHT. The compare-and-set on the view's own - # grant guards a record TWO PEOPLE ARE EDITING through the same dialog. A column's record - # was just read (and possibly created) inside this call, nobody is looking at it in a - # dialog, and a `409` raised at this point would fail a PUT whose view grant has ALREADY - # been written, which is the half-applied state the refusals above exist to avoid. - shares.set_grants("field", field_oid, - _merge_field_entries(held.get("entries"), entries), - owner=held.get("owner") or owners.get(key) or session.uname, - granter=session.uname, st=session.runtime) - written.append(key) - return written, refused - -@router.get("/share/mine") -def my_shares(session: Session = Depends(require_session)): - """Everything shared WITH me, by kind — the "Shared with me" rail section (R10). - - Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves - in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL - that is not malformed at all. - """ - return shares.shared_with(session.uname, st=session.runtime) + record = shares.cohort_record(scope, cohort_id, session.uname, st=session.runtime) + except ValueError as exc: + raise err(400, "bad_cohort_scope", str(exc)) + if record is None: + raise err(404, "no_cohort", "no such cohort, or it is not shared with this account") + return {"id": record["id"], **record["cohort"]} @router.get("/share/{kind}/{oid}") @@ -837,8 +462,12 @@ def get_share(kind: str, oid: str, session: Session = Depends(require_session)): if table_key and field_key: oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key) rec = shares.grants(kind, oid, st=session.runtime) - role = shares.role_for(kind, oid, session.uname, is_admin=session.admin, st=session.runtime) - may_admin = shares.may_administer(kind, oid, session.uname, is_admin=session.admin, + # A Cohort is visible only to its source owner or an explicit grant. In particular, an + # administrator-shaped Farhan session must not turn Shantal's absent grant into a record. + # Other share kinds retain their established administrator behaviour. + _admin_share = bool(session.admin) and kind != "cohort" + role = shares.role_for(kind, oid, session.uname, is_admin=_admin_share, st=session.runtime) + may_admin = shares.may_administer(kind, oid, session.uname, is_admin=_admin_share, st=session.runtime) # W39-T29 — the dialog opens with GET before its first PUT. Until that PUT exists the grant # registry has no owner to return from `may_administer`, even though the same caller may safely @@ -878,39 +507,6 @@ def get_share(kind: str, oid: str, session: Session = Depends(require_session)): } -@router.delete("/share/field/{oid}/mine") -def remove_my_field_share(oid: str, session: Session = Depends(require_session)): - """Remove only this session's direct grant on one shared field. - - This is deliberately not a shortened form of the replacing PUT route. A grantee may leave - a column shared to them, but may not submit a list that can revoke anybody else. A wildcard - grant is also not removable here: deleting it would remove the column from everybody. - """ - table_key, field_key = shares.split_field_oid(oid) - if not table_key or not field_key: - raise err(404, "no_object", "no such column, or it is not shared with this account") - oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key) - rec = shares.grants("field", oid, st=session.runtime) - uname = str(session.uname or "").strip().lower() - direct = [entry for entry in rec["entries"] if entry.get("user") == uname] - if not direct: - if shares.role_for("field", oid, uname, is_admin=session.admin, st=session.runtime): - raise err(409, "not_direct_grant", - "this column is shared with everyone in this workspace, so there is no " - "personal grant to remove. Nothing was changed.") - raise err(404, "no_object", "no such column, or it is not shared with this account") - if shares.role_for("field", oid, uname, is_admin=session.admin, st=session.runtime) == "owner": - raise err(409, "owner_cannot_leave", - "you own this column. Reassign or delete it instead of removing yourself from it.") - kept = [entry for entry in rec["entries"] if entry.get("user") != uname] - try: - shares.set_grants("field", oid, kept, owner=rec["owner"], st=session.runtime, - expect=rec["entries"]) - except shares.GrantsChanged as exc: - raise err(409, "grants_changed", str(exc)) - return {"removed": True, "field": field_key} - - def _people(tenant): """[{username, name}] for this tenant — same population as `assignable_people`, with the BINDING identity alongside the display one.""" @@ -948,7 +544,8 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None), # the alternative (refusing until somebody seeds an owner) would make a brand-new folder # unshareable by the person who just made it. if rec["owner"]: - if not shares.may_administer(kind, oid, session.uname, is_admin=session.admin, + if not shares.may_administer(kind, oid, session.uname, + is_admin=bool(session.admin) and kind != "cohort", st=session.runtime): # ⭐ R4 / W40-T02 — `may_administer` now also admits an `edit` grantee on a VIEW, so # the population refused here is narrower than the code word `not_owner` suggests: a @@ -1065,15 +662,21 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None), # A field grant is a visibility and edit wall. Promote a private custom # field exactly once, then keep the requested Share field role as the # authoritative override for the legacy permissions bag. - # ⭐⭐ W42-T25 — THE SEQUENCE MOVED INTO `_promote_field_for_grant` AND DID NOT CHANGE. - # The `view` arm below now hands columns to people as well, and one function answering - # "promote, stamp, key the oid" is what keeps the two arms writing the SAME oid for the - # same column. `None` here still means the column does not exist on that database. + from core import field_permissions, shared_overlay table_key, field_key = shares.split_field_oid(oid) - promoted = _promote_field_for_grant(session, table_key, field_key) - if promoted is None: + defn, already_shared, workspace_key, shared_key, grant_topic = _field_definition( + session, table_key, field_key) + if not isinstance(defn, dict): raise err(404, "no_object", "no such field, or it is not shared with this account") - oid = promoted + if not already_shared: + defn = field_permissions.promote_field( + workspace_key, shared_key, grant_topic, + session.uname, defn, st=session.runtime) + stamped = dict(defn) + stamped["shared"] = True + stamped["granted"] = True + shared_overlay.put_field(shared_key, field_key, stamped, st=session.runtime) + oid = shares.field_oid(grant_topic, field_key) # ⭐⭐ R4 / W40-T02 (D3) — OWNERSHIP NEVER MOVES ON A RE-SHARE, AND IT IS SAID HERE RATHER # THAN LEFT TO FALL OUT. `set_grants`' owner is sticky, so the old `rec["owner"] or # session.uname` already happened not to transfer ownership — incidentally, as a property of @@ -1103,20 +706,6 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None), except shares.GrantsChanged as exc: raise err(409, "grants_changed", str(exc)) _notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or []) - # ⭐⭐ W42-T25 — SHARING A VIEW SHARES THE FIELDS IT NAMES. A view is a selection expressed - # in columns, so a grant that carries the view and none of its columns puts a rail row on - # the receiver's screen over an empty grid. The three refusals are named rather than thrown: - # this PUT succeeded, the view IS shared, and what could not follow it is reported by name - # with the reason. Throwing instead would make the ordinary case (a view over one built-in - # column) unshareable. - # ⛔ THE EXISTING KEYS ARE UNTOUCHED. `{owner, entries}` is what every current client reads - # off this route; the field report is additive, and a caller that ignores it is unaffected. - if kind == "view": - shared_fields, refused_fields = _share_view_fields(session, oid, entries) - return {**out, - "sharedFieldKeys": shared_fields, - "refusedFieldKeys": refused_fields, - "sharedFieldScope": FIELD_GRANT_SCOPE} return out @@ -1321,6 +910,14 @@ def _object_ref(session, kind, oid): a row that opens nowhere. """ try: + if kind == "cohort": + scope, cohort_id = shares.split_cohort_oid(oid) + source = shares.cohort_source(scope, cohort_id, st=session.runtime) if scope else None + if source is None: + return ("A cohort", None, "") + from routes_alerts import route_for_topic + label = str(source["cohort"].get("name") or "").strip() or "A cohort" + return (label, route_for_topic(source["scope"]) or None, source["id"]) if kind == "field": # ⭐⭐ W38-T16 — A COLUMN IS NOT ADDRESSABLE ON ITS OWN, exactly as a view is not: it # is a column INSIDE a database, so the target that travels is the DATABASE. Without @@ -1373,4 +970,43 @@ def _object_ref(session, kind, oid): except Exception: # noqa: BLE001 pass return ({"view": "A view", "folder": "A folder", - "field": "A column"}.get(kind, "An item"), None, "") + "field": "A column", "cohort": "A cohort"}.get(kind, "An item"), None, "") + + +def _cohort_visible(scope, username, allowed_pids=None): + """Adapt the Cohort module's existing list projection to the one registry predicate. + + ``workspace_wire`` is the live Cohort list/open path. It intentionally receives only a + username and a pid envelope, so the adapter remains at this API boundary and reuses the + module's membership cleaner rather than creating a second Cohort representation in + ``core.shares``. ``visible_cohorts`` returns no unauthorized id, which prevents the blank + locked-view placeholder the old per-user read created. + """ + import modules.cohort as cohort_mod + + records = shares.visible_cohorts(scope, username) + return {cid: {**record, + "members": cohort_mod._clean_members(record.get("members"), allowed_pids)} + for cid, record in records.items()} + + +def _install_cohort_visibility(): + """Route all API Cohort list/detail reads through the normal share predicate once.""" + try: + import modules.cohort as cohort_mod + + def _customer_visible(username, allowed_pids=None): + return _cohort_visible("customer", username, allowed_pids) + + def _scoped_visible(self, username, allowed_pids=None): + return _cohort_visible(self.scope, username, allowed_pids) + + cohort_mod.visible = _customer_visible + cohort_mod.CohortStore.visible = _scoped_visible + except Exception: + # The API route still has its direct, fail-closed list/detail paths if the retired module + # is unavailable during an import-only tool invocation. + return + + +_install_cohort_visibility() diff --git a/api/routes_tables.py b/api/routes_tables.py index 02a92f9bafa82c403e31dfd7ef50a1e2b87e51f7..68ff1eb1bd27ace259dbc3603565d1c9dcfa2b4c 100644 --- a/api/routes_tables.py +++ b/api/routes_tables.py @@ -1050,32 +1050,10 @@ def _usage_column_refs(field): return rollup, automation -def _field_usage(session, table_key, fields, defn, ws_st, ws_key, st=None): +def _field_usage(session, table_key, fields, defn, ops, st=None): """`{field_key: {views, filters, rollups, automations, permRules}}` — C8's `usage`, computed over the WHOLE workspace, or `{}` when it cannot be established. - ⭐⭐ W42-T14 / C4 — **PARAMETERISED OFF `(ws_st, ws_key)`, NOT OFF A `user_tables` OPS OBJECT.** - The only two things the old `ops` argument was ever asked for were `ops.st` (the bound store - handle) and `ops.table_key` (the name of the document every account's strata live in), so - taking those two directly is what lets a SECOND surface call this. It matters because the - document is NOT named `_table_workspace` everywhere: on the customer topic it is - `modules.customer_data.TABLE_KEY` (`customer_table_workspace`), a name no `f"{table_key}_…"` - literal can produce. Rebuilding the string here rather than being handed it is exactly the - second spelling `_ops`' own note refuses. - - ⛔⛔ `table_key` IS THE MEMO KEY AND IT IS THE TOPIC, NEVER THE DOCUMENT. `(tenant, table_key)` - addresses the memo, `rec['perms'][table_key]` addresses the permission record and - `config.targetTable == table_key` addresses the flow binding — three reads that must all name - the SAME thing the registry calls this database. `ut_assembly` passes its `table_key`; - `routes_customers.grid_assembly` passes its module constant `customer_data`, which is the key - `apply_row_scope`, `hidden_keys` and `routes_admin._clean_perms` already spell there. A caller - that spelled it differently (the topic on one door, the bucket on another) would pin a stale - answer under a key nothing ever invalidates, for the life of the process. - ⚠ ON THE CUSTOMER TOPIC THE FLOW LEG IS A TRUE ZERO, not a missed read: `automation_engine` - resolves `targetTable` out of `user_tables` only, so no flow can bind to that topic at all. - The COLUMN leg (`field['automation']['urlField']`) still counts there, which is the half a - customer column can actually carry. - ⭐⭐ W41-T10 / R3 / C8. The `done-when`'s load-bearing clause is *"over the whole workspace, not from a partial client list"*: the assembly's own `ws` holds this caller's stratum merged with the views shared TO them, so a count taken from it would miss every private view every @@ -1121,18 +1099,18 @@ def _field_usage(session, table_key, fields, defn, ws_st, ws_key, st=None): return {} lent = st if st is not None else session.runtime memo_key = (str(getattr(session, "tenant", "") or ""), str(table_key)) - token = _usage_token(lent, ws_key) + token = _usage_token(lent, ops.table_key) if token is not None: hit = _USAGE_MEMO.get(memo_key) if hit and hit[0] == token: return hit[1] try: - # ⚠ THE HANDLE AND THE NAME ARE BOTH HANDED IN, never rebuilt from `table_key`: the store - # key the calling assembly closed over is the one this database's strata actually live - # under, and a second spelling of it is a second thing to keep in step. A lend passes a - # workspace document straight through to the runtime (a lend serves only `user_tables` and - # `object_shares`), so this is the tenant's own document either way. - ws_doc = ws_st.get(ws_key) or {} + # ⚠ `ops.st` and `ops.table_key`, not a rebuilt literal: the store key `_ops` closed over + # is the one this database's strata actually live under, and a second spelling of the + # suffix is a second thing to keep in step. A lend passes `_table_workspace` straight + # through to the runtime (it serves only `user_tables` and `object_shares`), so this is + # the tenant's own document. + ws_doc = ops.st.get(ops.table_key) or {} except Exception: # noqa: BLE001 return {} try: @@ -1285,6 +1263,17 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "", # lines and for the same reason: the closure must be recomputed on the MERGED contract or a # formula in a shared column reaches past it. fields = _ut_shared_fields(session, table_key, fields) + # W43C C2: store compact recurrence configuration, then derive next/preview under the + # request's server day only after every field stratum is merged. + _recurrence_today = time.strftime("%Y-%m-%d") + + def _wire_recurrence(field): + if not isinstance(field, dict) or field.get("type") != "date": + return field + payload = aios_grid.recurrence_payload(field.get("recurrence"), today=_recurrence_today) + return dict(field, recurrence=payload) if payload else field + + fields = [_wire_recurrence(field) for field in fields] # ⭐⭐ W41-T10 / RULING R3 / CONTRACT C8 — **`usage`, STAMPED ONCE, HERE.** # # ⛔ IN THE ASSEMBLY AND ON BOTH ARMS, WHICH IS NOT THE CHEAP CHOICE AND IS THE ONLY CORRECT @@ -1307,14 +1296,8 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "", # `dict(f, source=…)` copies for the shared stratum but passes the rest through by reference, # and `_usage_field_corpus` hands those same objects back. Mutating one would write a count # into a definition the next reader shares. - # ⛔ `agg` belongs to lanes B and D; C8 is explicit that an absent key means "not built yet". - # ⭐⭐ W42-T14 / C4 — `class` NOW RIDES BESIDE IT, and the pair is the contract. `class` shipped - # on `customer_data` with W41-T01 and stopped at that fence, so a `ut_*` grid sent `usage` with - # no `class` while the customer grid sent `class` with no `usage` — and - # `filter-kit/fieldClass.ts::deleteImpactTitle(fc, usage)` composes its warning as two - # independent halves and DROPS whichever argument is null. Both live readers (`ColumnMenu`, - # `FieldsHidePanel`) pass both. So each surface was rendering half a sentence with every gate on - # both sides green, which is [[two-lanes-one-contract-dead-feature]] wearing a third face. + # ⛔ AND ONLY `usage`. `class` shipped with W41-T01, `agg` belongs to lanes B and D and + # `descriptionEdited` to W41-T11; C8 is explicit that an absent key means "not built yet". # # ⚠ LENIENT, LIKE EVERY OTHER DISPLAY READ ON THIS ASSEMBLY, and C8's absent-key polarity is # what makes degrading safe: a client renders nothing for a missing `usage` rather than a @@ -1323,59 +1306,13 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "", # ⚠ A COPY OF THE BAG, because `_field_usage` MEMOISES its answer: handing the memo's own dict # out would let anything downstream write into the number the next request reads. try: - _usage = _field_usage(session, table_key, fields, defn, - ops.st, ops.table_key, st=st) + _usage = _field_usage(session, table_key, fields, defn, ops, st=st) except Exception: # noqa: BLE001 _usage = {} if _usage: fields = [dict(f, usage=dict(_usage[f["key"]])) if isinstance(f, dict) and _usage.get(f.get("key")) else f for f in fields] - # ⭐⭐ W42-T14 / C4 — **THE THREE BADGES, THE CHEAP DIRECTION.** `field_classes` is O(1) in - # store reads for the whole list (one `object_shares` read via `grant_records`, one - # `__shared` read for the value stratum) and this is the only line on a `ut_*` grid that - # calls it. - # - # ⚠ THE TWO KEYS ARE THE SAME STRING HERE AND THAT IS NOT A TYPO. On the customer topic the - # bucket (`customer_table_workspace`) and the grant topic (`customer_data`) are different - # names; on a user table both are `table_key` itself, which is what `delete_shared_field` - # already spells three lines apart (`shared_overlay.fields(table_key)` and - # `grant_topic=table_key`) and what `_ut_shared_fields` passes as `shared_key=table_key`. - # - # ⚠ `st=st` IS THE LEND, AND IT IS SAFE ON A NON-LENDABLE BUCKET. `grant_records` reads - # `object_shares`, which IS lendable, so the badges ride the document this pass already holds - # (the D-214 shape). `_shared_value_keys` then reads `__shared`, which is NOT in - # `_LENDABLE` -- and `_Lent.get`'s first branch forwards exactly that case straight to the - # runtime rather than answering empty, which is checked in `verify_field_row` rather than - # assumed: a lend that swallowed it would make every `values` badge read `personal` under a - # lenient try, which is this ticket's own failure class. - # - # ⛔ NO `strata` MAP IS LENT, DELIBERATELY, AND THE ABSENCE IS THE HONEST ANSWER. W42-T47 gates - # its behaviour change on the stratum being ASSERTED by the caller that did the reading, and - # a guessed `personal` would MOVE A PERMISSION — `user_tables.delete_field_refusal` reads - # `bag['owner']` to decide DELETE. This assembly cannot assert one: `workspace_wire` returns - # the contract and this account's fork already merged into one list with no record of which - # document each entry came out of. What it CAN say is not needed either: `_ut_shared_fields` - # only ADDS keys the list does not already have, never replaces one, so no fork with a copied - # `shared` mark is created here and `field_stratum`'s per-key fallback answers correctly. An - # unlent map is today's answer plus the new member, which is exactly T47's designed fallback. - # - # ⚠ A NEW DICT PER COLUMN, NEVER AN IN-PLACE STAMP — the same rule as `usage` above and for a - # sharper reason: `class` carries THIS viewer's `sharedBy` and `owner`, and `_ut_shared_fields` - # passes non-shared entries through BY REFERENCE, so an in-place stamp would write one - # account's answer into a definition the next reader shares. - # ⚠ LENIENT AND ABSENT-KEY, like every other display read on this assembly: a registry that - # will not open costs the badges and not the grid, and a client renders nothing for a missing - # `class` rather than a wrong badge. - try: - from core import field_permissions as _fp - _classes = _fp.field_classes(fields, session.uname, table_key=table_key, - grant_topic=table_key, st=st) - fields = [dict(f, **{"class": _classes[f["key"]]}) - if isinstance(f, dict) and _classes.get(f.get("key")) else f - for f in fields] - except Exception: # noqa: BLE001 - pass # ⭐⭐ C8's `descriptionEdited`, AND IT CLOSES A CONSUMER THAT WAS ALREADY WRITTEN. # W41-T11 built the producer (`user_tables.description_edited`, R19's custody mark scoped to the # description) and correctly stopped at its own fence, which is one core file. Measured across @@ -1414,7 +1351,7 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "", # whose entity topic declares a `measures:` binding (today `ut_odoo_agents`) serves a real # lookback catalogue AND the cells to go with it; every other user table still serves `[]`, # which is the honest answer for a hand-typed one rather than a descope. - today = time.strftime("%Y-%m-%d") + today = _recurrence_today measures = ut_measures(table_key, session) derived = aios_grid.cohort_cells(lists) # R9: this table's own lists for _pid, _cells in _ut_measure_cells(table_key, fields, pids, today, measures, @@ -2190,7 +2127,14 @@ def table_rows(table_key: str, session: Session = Depends(require_session)): # missing key has been silently switching the feature off. ⛔ ONE shared serialiser with the # customer scope — `grid_events.docs_for` — never a twin here. from core import grid_events as _ge + # Zone memberships are row identities and therefore pass through the same row wall as the + # rows themselves. A map reader cannot infer a hidden record from a saved zone boundary. + _zone_pids = {str(pid) for pid in g["pids"]} + _zones = [dict(zone, memberships=[pid for pid in zone.get("memberships", []) + if str(pid) in _zone_pids]) + for zone in _ut().zones(table_key, st=_lent)] return {"fields": g["fields"], "rows": rows, "today": g["today"], + "zones": _zones, "docs": _ge.docs_for(g["pids"], scope_key=table_key, uname=session.uname, admin=session.admin, st=session.runtime), "pulled_at": time.strftime("%Y-%m-%d %H:%M"), @@ -2491,11 +2435,6 @@ def _with_dropped(ut, out, body): 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.""" - # W42-T36: the validator returns only ``None``. Keep the option-cap wording in its - # single owner so both add and patch tell the person exactly what was refused. - option_refusal = ut.option_cap_refusal((body or {}).get("options")) - if option_refusal: - return option_refusal # ⭐ 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 @@ -2574,11 +2513,7 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None), migrated = dict(migrated or {}, workspace=True) except Exception: # noqa: BLE001 migrated = dict(migrated or {}, workspace=False) - # A type change can clear values that no longer have a meaning in the target column. Keep - # that report separate from the field itself: callers that only need the field retain the - # established shape, while a future editor can state the consequence from the same response. - retype = {} - field = ut.patch_field(table_key, fkey, body, st=session.runtime, preview=retype) + field = ut.patch_field(table_key, fkey, body, st=session.runtime) if not field: # C3: the same narrator as `add_field`. Flagging a SECOND column is the same act as # adding one, so it must get the same sentence naming the column that already holds the @@ -2590,8 +2525,6 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None), field = synced.get("field") or field _refresh_relations(session) out = {"field": field} - if retype: - out["retype"] = retype # ⭐⭐ W41-T15 / C5 — the same report `add_field` carries, on the door that RETARGETS a link. # This one matters more: pointing an existing link at `customer_data` deletes the backlink the # old target held (the stale-inverse sweep) and cannot make a new one, so the column silently @@ -2691,179 +2624,6 @@ def delete_field(table_key: str, fkey: str, session: Session = Depends(require_s return {"deleted": fkey} -#: ⭐⭐ W42-T28 — THE SENTENCES THE BULK DOOR SAYS **FOR ITSELF**, and why they are not next door. -#: -#: ⛔ NONE OF THESE IS A RIGHTS REFUSAL, so none of them belongs in `_delete_field_sentence`. That -#: function renders `user_tables.delete_field_refusal`'s stable codes and nothing else, which is -#: C2's *"no surface re-implements either test"* read the other way round: a door that folded its -#: own execution outcomes into the rights renderer would make the two indistinguishable to the next -#: reader, and the first person to add a code there would be writing a second opinion about who may -#: delete what. These four are outcomes of the EXECUTOR or of this door's read wall. -#: -#: ⛔ STANDING RULE 2 — NO EM DASH AND NO EN DASH. Every one of these reaches a toast beside a -#: column name, so they are user-facing copy; they are written to read naturally without the -#: punctuation rather than having it stripped out afterwards. -_BULK_DELETE_SENTENCES = { - "unknown_field": ("that column is not on this database. It may already have been removed, so " - "reload and check before trying again"), - "field_hidden": ("this database has no column by that name that you may remove"), - "last_field": ("a database must keep at least one column, so this one cannot be removed. Add " - "another column first, then remove this one"), - "delete_failed": ("that column could not be removed. Reload the database and try again"), -} - - -def _bulk_delete_refusal(key, code): - """One refused outcome, in the same shape a deleted one has: a key, a status, a machine code - and a sentence. The code is what a client switches on; the sentence is what a person reads.""" - return {"key": key, "status": "refused", "code": code, - "reason": _BULK_DELETE_SENTENCES.get(code, _BULK_DELETE_SENTENCES["delete_failed"])} - - -def _contract_holds_field(ut, table_key, fkey, st): - """Is `fkey` STILL a declared column of this database's contract? Asked only on the failure - path, because it is a whole-document read and it separates two very different refusals: - `user_tables.delete_field` answers False both for *"a database must keep at least one"* and for - a key that is already gone (an ordinary link's reciprocal disappears when its source column - does, so key B can vanish while key A of the same request is being deleted).""" - fields = (ut.get(table_key, st=st) or {}).get("fields") or [] - return any(isinstance(f, dict) and str(f.get("key")) == str(fkey) for f in fields) - - -@router.post("/tables/{table_key}/fields/delete") -@router.delete("/tables/{table_key}/fields") -def delete_fields(table_key: str, body: dict = Body(default=None), - session: Session = Depends(require_session)): - """⭐⭐ W42-T28 / CONTRACT C4 — **REMOVE N COLUMNS IN ONE CALL, AND ANSWER PER KEY.** - - `{"keys": [...]}` in, `{"results": [{key, status, ...}, ...]}` out. Both a POST to - `…/fields/delete` and a DELETE to `…/fields` reach this one handler, because a body on a DELETE - is legal and unevenly supported by clients, and the two spellings must not become two doors. - - ⛔ THIS IS NOT A LOOP OVER `delete_field`, AND THE DIFFERENCE IS THE OWNER'S BUG. Instruction 21 - of wave 41 was a column that *"reappeared three times"*: the singular route below deletes ONE - stratum, the `user_tables` contract, and leaves the shared bucket, every other account's fork - and the grant record alive for `migrate_legacy_fields` to promote straight back on the next - read. W41-T07 fixed that in `field_permissions.delete_field_everywhere`, which is the five-leg - executor, and this door goes through it once per key. Multiplying the broken delete by N would - have multiplied the resurrection by N. - - ⛔ PARTIAL SUCCESS IS THE POINT, NOT A TOLERANCE. A key the caller may not delete is refused - while the rest still go, so the outcomes are per key and there is no transaction around them. - A field manager offers a multi-select over columns a person may own only some of; an - all-or-nothing answer would make the one column they cannot touch block the nine they can, and - a bare 200 would leave them believing all ten went. - - ⛔ THE RIGHT IS NOT DECIDED HERE. `user_tables.delete_field_refusal` is C2's one pair and it - answers both halves in one call (the decision, and the reason a person reads); - `_delete_field_sentence` turns its code into this door's sentence, exactly as - `delete_shared_field` already does. Spelling the owner-or-admin test a second time is how the - two delete doors came apart in the first place. - - ⚠ `deleted` IS EARNED BY A TRUTHY REMOVAL, NEVER BY THE ABSENCE OF A REFUSAL. `tombstoned` is - always True and is deliberately not counted; a key that passed every wall and still moved - nothing is reported refused, because a `deleted` that removed nothing reads exactly like a - working delete and is the failure this ticket exists to end. - - ⚠ NO `fields` LIST COMES BACK, and the omission is the answer. A list assembled here has been - through neither `ut_assembly`'s hidden closure nor C4's `class`/`usage` stamps, so a client - that trusted it would render columns C1 walls away. `rerender: True` is the instruction to - refetch, which is what `delete_shared_field` ships for the same reason. - - ⚠ `st=session.runtime` FOR BOTH EXECUTORS, NEVER THE WALL'S LEND. The last-column rule is - evaluated against the field list as of the PREVIOUS key's deletion; answering it from a - pre-write snapshot would refuse the wrong key of a request that empties a database. - """ - # ⛔ NO `defs_only`, matching every other write wall in this file: a projected snapshot must - # never reach a post-write read (contract C5), and this door writes. - defn = _defn_or_refuse(session, table_key, scope_applied=True) - 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 " - "owns its own columns") - raw = (body or {}).get("keys") - if not isinstance(raw, list): - raise err(400, "bad_keys", "send `keys`, the list of columns to remove") - ordered = [] - for k in raw: - k = str(k or "").strip() - if k and k not in ordered: - ordered.append(k) - if not ordered: - raise err(400, "bad_keys", "name at least one column to remove") - - from core import field_permissions, shared_overlay - declared = [f for f in (defn.get("fields") or []) if isinstance(f, dict)] - contract_keys = {str(f.get("key")) for f in declared} - # ⚠ Read ONCE for the whole request rather than per key: neither the hidden closure nor the - # shared bucket can change under us mid-request, and both are whole-document reads. - hidden = _ut_hidden(session, table_key, declared) - # ⛔ AND THE LENIENT BRANCH REMEMBERS THAT IT FAILED, which the display reads on this - # assembly deliberately do not. An unreadable bucket that silently became `{}` would report a - # tenant-wide column as `unknown_field` — *"that column is not on this database"* — which is a - # confident false statement about a destructive act, and the person would go looking for who - # else deleted it. A read that did not happen answers "try again", not "it is not there". - bucket_read = True - try: - shared_defs = shared_overlay.fields(table_key, st=session.runtime) or {} - except Exception: # noqa: BLE001 - shared_defs, bucket_read = {}, False - - results = [] - landed = [] - for key in ordered: - field = next((f for f in declared if str(f.get("key")) == key), None) - if field is None and isinstance(shared_defs.get(key), dict): - # A tenant-wide column lives in `__shared`, a document the contract cannot see. - field = shared_defs[key] - if field is None: - results.append(_bulk_delete_refusal( - key, "unknown_field" if bucket_read else "delete_failed")) - continue - # ⭐⭐ W36-T21's wall, per key. An account that cannot SEE a column must not be able to - # delete it for the whole tenant. The sentence is delete-flavoured on purpose: the schema - # wall's own says "that you may edit", which would be the server explaining the wrong rule. - if key in hidden: - results.append(_bulk_delete_refusal(key, "field_hidden")) - continue - refusal = ut.delete_field_refusal( - table_key, key, session.uname, session.admin, session.runtime, - field=field, grant_topic=table_key) - if refusal is not None: - results.append({"key": key, "status": "refused", "code": refusal, - "reason": _delete_field_sentence( - refusal, str(field.get("createdBy") or ""))}) - continue - # ⚠ ONE `try` PER KEY, and that is what makes partial success real rather than claimed: a - # store that fails on key 3 must not cost keys 4 and 5 their turn, and must not turn a - # request that already deleted keys 1 and 2 into a bare 500 the client will repeat. - try: - dropped = False - if key in contract_keys: - dropped = bool(ut.delete_field(table_key, key, st=session.runtime)) - if not dropped and _contract_holds_field(ut, table_key, key, session.runtime): - results.append(_bulk_delete_refusal(key, "last_field")) - continue - removed = field_permissions.delete_field_everywhere( - f"{table_key}_table_workspace", table_key, table_key, key, - st=session.runtime) - except Exception: # noqa: BLE001 - results.append(_bulk_delete_refusal(key, "delete_failed")) - continue - if not (dropped or removed.get("shared") or removed.get("personal")): - results.append(_bulk_delete_refusal(key, "delete_failed")) - continue - landed.append(key) - results.append({"key": key, "status": "deleted", "contract": dropped, - "removed": removed}) - - if landed: - _refresh_relations(session) - return {"ok": True, "rerender": True, "results": results, "deleted": landed, - "refused": [r["key"] for r in results if r["status"] == "refused"]} - - @router.delete("/tables/{table_key}/rows/{rid}") def delete_row(table_key: str, rid: str, session: Session = Depends(require_session)): # ⭐⭐ W31 QA — ONE SNAPSHOT FOR THE WHOLE DELETE, and the owner reported what it cost. diff --git a/platform/aios_grid.py b/platform/aios_grid.py index a9742052b941f9007e341f1164e094712cf8d06b..8ed3dadc96d1261de20e2b9457da19b9b851387f 100644 --- a/platform/aios_grid.py +++ b/platform/aios_grid.py @@ -23,6 +23,7 @@ Design notes: into the React app and returns guarded events for persistence in the tenant store. The old injected-HTML path remains a read/local-write fallback when component assets are absent. """ +import datetime as _dt import json import math import re @@ -339,92 +340,9 @@ def _clean_permissions(raw): #: drift risk this file's docstring names. The charset says which characters may appear; the #: engine says what they mean. `_quotes_balanced` below is the one structural rule the quote #: character brings with it. -#: ⭐⭐ WAVE 42 G7 (2026-08-25) — THE SAME SCAR, WIDENED IN THE SAME CHANGE THIS TIME. -#: -#: The client engine gained REGEX_EXTRACT, REGEX_REPLACE, SUBSTITUTE, FIND, SWITCH, DATEADD, -#: DATETIME_DIFF and DATETIME_FORMAT. Each added character below is admitted because a token -#: the client's tokenizer parses would otherwise die here: -#: -#: ' a SINGLE-QUOTED literal. Airtable's own documentation spells every unit and date -#: format that way -- DATETIME_DIFF({a}, {b}, 'days') -- and a user pastes what the -#: documentation prints. Without this, EVERY date function is refused on save. -#: [ ] a regex character class, the commonest construct there is: "[0-9]+". -#: | regex alternation. -#: ? regex optional, and the non-capturing group "(?:...)". -#: \ regex escapes: \d \s \. \w. -#: : "(?:...)", and a class such as "[a-z:]". -#: @ SUBSTITUTE/FIND over an email or a URL, the canonical use of both. -#: -#: ⚠ AND THREE THAT THE EIGHT DO NOT NEED, admitted because they are the SAME BUG already -#: sitting here unnoticed: `textFormat` in the client accepts "#,##0", "$#,##0.00" and "0%", -#: so `TEXT({x}, "0%")` parses client-side and is refused here TODAY. That is the 2026-08-03 -#: failure still live for three format shapes, so: -#: # $ % the TEXT() number formats the client already implements. ($ doubles as the regex -#: end-anchor and % as a literal in text, so all three earn their place twice.) -#: -#: ⛔ `;` STAYS REFUSED, and the fields-contract gate asserts it. Nothing in the grammar needs -#: a statement separator, and it is the shape an injection attempt takes. -_FORMULA_CHARS = re.compile(r"^[\w\s{}()+\-*/.,<>=!^&\"'\[\]|?:\\#$%@]*$") +_FORMULA_CHARS = re.compile(r"^[\w\s{}()+\-*/.,<>=!^&\"]*$") _FORMULA_REF = re.compile(r"\{([^{}]*)\}") -#: Longest single string literal a formula may carry. A regex pattern is a string literal, and -#: an unbounded pattern reaching a regex engine in the tenant's ONE process is a denial of -#: service rather than a slow cell. 200 inside a 500-character formula is far above honest use. -MAX_FORMULA_LITERAL_LEN = 200 - -#: The catastrophic-backtracking SHAPE: a group whose body already carries an unbounded -#: quantifier, quantified again -- `(a+)+`, `(a*)*`, `(?:\d+)*`. -#: -#: ⛔ IT IS APPLIED TO STRING LITERALS ONLY, NEVER TO THE WHOLE EXPRESSION. `({a} + {b}) * 2` -#: has exactly this character shape and is ordinary arithmetic; scanning the whole source would -#: refuse it. -#: -#: ⚠ AND IT IS A STATIC, EARLY refusal, not the guarantee. A pattern assembled at run time -- -#: `REGEX_EXTRACT({a}, "(x" & "+)+")`, or a pattern read out of another field -- is invisible -#: to any check here. The LOAD-BEARING bound is the client's `safeRegex`, which sees the actual -#: pattern at evaluation time and caps the SUBJECT too. This wall stops the obvious one early -#: and cheaply; it does not pretend to be the wall. -_NESTED_QUANT = re.compile(r"\((?:[^()\\]|\\.)*[*+](?:[^()\\]|\\.)*\)\??\s*[*+]") - - -def _string_literals(s): - """Every string literal's BODY, in order, or None if one is unterminated. - - Mirrors the client tokenizer exactly: `"..."` and `'...'` are the same literal in two - spellings, a DOUBLED quote of the opening kind is one character of body, and a quote of the - OTHER kind inside is ordinary text. That last part is why this is a scanner and not a parity - count: `"Owner's report"` is one literal containing an apostrophe, and counting `'` would - refuse it. - - ⚠ This is NOT the second grammar the module refuses to write. It answers one question -- - where does a literal begin and end -- which is the same structural question `_quotes_balanced` - and the `{ref}` scan already answer. It assigns no MEANING to anything it finds. - """ - out = [] - i = 0 - n = len(s) - while i < n: - ch = s[i] - if ch not in ('"', "'"): - i += 1 - continue - j = i + 1 - body = [] - while True: - if j >= n: - return None # unterminated - if s[j] == ch: - if j + 1 < n and s[j + 1] == ch: - body.append(ch) - j += 2 - continue - break - body.append(s[j]) - j += 1 - out.append("".join(body)) - i = j + 1 - return out - def _quotes_balanced(s): """An even number of `"` — the structural half of string support. @@ -519,32 +437,6 @@ def _clean_formula(raw, valid_keys=None): return None if not _quotes_balanced(s): return None - # ⭐⭐ WAVE 42 G7 — the regex bound, and it runs at READ TIME TOO, deliberately. - # - # Every other refusal in this function is split write/read because a `{ref}`'s validity - # CHANGES with the world: a column referenced today can be deleted tomorrow, and refusing - # then would vaporise a stored column. A string literal's SHAPE is intrinsic to the - # expression and cannot become invalid later, so there is no stored-and-later-invalid case - # for the split to protect. Applying it both ways is what makes it reach the user-tables - # door, which calls this validator with `valid_keys=None` on WRITE as well as on read - # (core/user_tables.py::_clean_field) -- a write-only gate would simply miss `ut_*`. - # - # ⚠ THE TWO LEGS ARE SCOPED DIFFERENTLY, and the difference is data loss. - # The SHAPE test is safe everywhere: no formula written before today contains `"(a+)+"`, - # so it can only fire on something new. The LENGTH cap is not: a formula stored yesterday - # could carry a 250-character message literal and still sit under MAX_FORMULA_LEN, and - # refusing it at read time does not blank a cell -- `user_tables` drops the whole COLUMN. - # So the cap applies only where a literal can BE a pattern, which is a formula that names - # a regex function at all. That is a substring test, not a second grammar. - literals = _string_literals(s) - if literals is None: - return None - regexy = 'REGEX_' in s.upper() - for lit in literals: - if regexy and len(lit) > MAX_FORMULA_LITERAL_LEN: - return None - if _NESTED_QUANT.search(lit): - return None refs = _FORMULA_REF.findall(s) leftover = _FORMULA_REF.sub("", s) if "{" in leftover or "}" in leftover: # unbalanced / nested braces @@ -602,6 +494,198 @@ def _clean_geocode(raw): return out +# --- DATE RECURRENCE --------------------------------------------------------- +# A date cell remains an ISO scalar. Its optional field recurrence is a separate, explicit +# configuration bag: persisting a JavaScript Date, locale label, or a client-computed next value +# would make an identical record mean different things to two hosts. The host computes the +# next occurrence and preview at projection time from this compact, durable shape. +_RECURRENCE_KINDS = frozenset({'daily', 'weekly', 'monthly', 'yearly', 'holiday'}) +_PUBLIC_HOLIDAY_CALENDARS = frozenset({'US'}) + + +def _iso_day(value): + if isinstance(value, _dt.datetime): + value = value.date() + if isinstance(value, _dt.date): + return value + if not isinstance(value, str): + return None + try: + return _dt.date.fromisoformat(value.strip()[:10]) + except ValueError: + return None + + +def _observed(day): + """US federal-observed day: Saturday -> Friday, Sunday -> Monday.""" + if day.weekday() == 5: + return day - _dt.timedelta(days=1) + if day.weekday() == 6: + return day + _dt.timedelta(days=1) + return day + + +def _nth_weekday(year, month, weekday, nth): + day = _dt.date(year, month, 1) + shift = (weekday - day.weekday()) % 7 + return day + _dt.timedelta(days=shift + 7 * (nth - 1)) + + +def _last_weekday(year, month, weekday): + if month == 12: + day = _dt.date(year + 1, 1, 1) - _dt.timedelta(days=1) + else: + day = _dt.date(year, month + 1, 1) - _dt.timedelta(days=1) + return day - _dt.timedelta(days=(day.weekday() - weekday) % 7) + + +def public_holidays(calendar, year): + """The supported public holiday calendar, returned as deterministic ISO-day objects. + + US federal holidays are intentionally calculated locally rather than fetched from a public + endpoint during a render: a recurrence must keep its answer during an outage and historical + previews must not move when a third party updates a feed. The field accepts other ISO + country codes for forward compatibility, but an unavailable calendar yields no candidates + rather than pretending it is the US calendar. + """ + if str(calendar or '').upper() not in _PUBLIC_HOLIDAY_CALENDARS: + return frozenset() + days = { + _observed(_dt.date(year, 1, 1)), + _nth_weekday(year, 1, 0, 3), # Martin Luther King Jr. Day + _nth_weekday(year, 2, 0, 3), # Washington's Birthday + _last_weekday(year, 5, 0), # Memorial Day + _observed(_dt.date(year, 7, 4)), + _nth_weekday(year, 9, 0, 1), # Labor Day + _nth_weekday(year, 10, 0, 2), # Columbus Day + _observed(_dt.date(year, 11, 11)), + _nth_weekday(year, 11, 3, 4), # Thanksgiving + _observed(_dt.date(year, 12, 25)), + } + # Juneteenth became a federal holiday in 2021; earlier recurrence previews must not + # retroactively invent it. + if year >= 2021: + days.add(_observed(_dt.date(year, 6, 19))) + return frozenset(days) + + +def clean_date_recurrence(raw): + """One date field's recurrence config, or ``None`` for absent/malformed input. + + ``rules`` are an intentional union: selecting weekday + public holiday means either makes + a valid next date. The stored config carries neither a process clock nor generated preview; + those derived values belong to ``recurrence_payload`` and are evaluated by the server. + """ + if not isinstance(raw, dict): + return None + start = _iso_day(raw.get('startDate', raw.get('anchor'))) + if start is None: + return None + out_rules = [] + for raw_rule in list(raw.get('rules') or [])[:12]: + if not isinstance(raw_rule, dict): + continue + kind = str(raw_rule.get('kind') or '').lower() + if kind not in _RECURRENCE_KINDS: + continue + interval = raw_rule.get('interval', 1) + if not isinstance(interval, int) or isinstance(interval, bool) or not 1 <= interval <= 366: + continue + rule = {'kind': kind, 'interval': interval} + if kind == 'weekly': + weekdays = sorted({day for day in (raw_rule.get('weekdays') or []) + if isinstance(day, int) and not isinstance(day, bool) + and 0 <= day <= 6}) + rule['weekdays'] = weekdays or [start.weekday()] + elif kind == 'monthly': + day = raw_rule.get('day', start.day) + if not isinstance(day, int) or isinstance(day, bool) or not 1 <= day <= 31: + continue + rule['day'] = day + elif kind == 'yearly': + month, day = raw_rule.get('month', start.month), raw_rule.get('day', start.day) + if (not isinstance(month, int) or isinstance(month, bool) or not 1 <= month <= 12 + or not isinstance(day, int) or isinstance(day, bool) or not 1 <= day <= 31): + continue + # Validate the month/day without reinterpreting 29 February. + try: + _dt.date(2024, month, day) + except ValueError: + continue + rule.update({'month': month, 'day': day}) + elif kind == 'holiday': + calendar = str(raw_rule.get('calendar') or 'US').strip().upper() + if not re.fullmatch(r'[A-Z]{2}', calendar): + continue + offset = raw_rule.get('offset', 0) + if not isinstance(offset, int) or isinstance(offset, bool) or offset not in (-1, 0, 1): + continue + rule.update({'calendar': calendar, 'offset': offset}) + if rule not in out_rules: + out_rules.append(rule) + if not out_rules: + return None + preview_count = raw.get('previewCount', raw.get('preview', 10)) + if not isinstance(preview_count, int) or isinstance(preview_count, bool): + preview_count = 10 + return {'startDate': start.isoformat(), 'rules': out_rules, + 'previewCount': max(1, min(preview_count, 50))} + + +def _recurrence_matches(day, start, rule): + kind, interval = rule['kind'], rule['interval'] + if day < start: + return False + if kind == 'daily': + return (day - start).days % interval == 0 + if kind == 'weekly': + return ((day - start).days // 7) % interval == 0 and day.weekday() in rule['weekdays'] + if kind == 'monthly': + months = (day.year - start.year) * 12 + day.month - start.month + return months >= 0 and months % interval == 0 and day.day == rule['day'] + if kind == 'yearly': + return (day.year - start.year) % interval == 0 and day.month == rule['month'] and day.day == rule['day'] + return False + + +def recurrence_preview(raw, today=None, count=None): + """The next distinct ISO dates, anchored to the server's supplied/current day.""" + config = clean_date_recurrence(raw) + if config is None: + return [] + now = _iso_day(today) or _dt.date.today() + start = _iso_day(config['startDate']) + want = config['previewCount'] if count is None else max(1, min(int(count), 50)) + lower = max(start, now) + # A preview of at most fifty values needs no unbounded search. Ten years also makes an + # unavailable public calendar visibly empty instead of spinning a request forever. + horizon = lower + _dt.timedelta(days=3660) + found, day = set(), lower + holiday_rules = [rule for rule in config['rules'] if rule['kind'] == 'holiday'] + ordinary_rules = [rule for rule in config['rules'] if rule['kind'] != 'holiday'] + while day <= horizon and len(found) < want: + if any(_recurrence_matches(day, start, rule) for rule in ordinary_rules): + found.add(day) + for rule in holiday_rules: + if (day.year - start.year) % rule['interval']: + continue + for holiday in public_holidays(rule['calendar'], day.year): + occurrence = holiday + _dt.timedelta(days=rule['offset']) + if occurrence == day and occurrence >= start: + found.add(day) + day += _dt.timedelta(days=1) + return [item.isoformat() for item in sorted(found)[:want]] + + +def recurrence_payload(raw, today=None): + """Durable config plus server-authoritative ``nextDate`` and finite ``preview``.""" + config = clean_date_recurrence(raw) + if config is None: + return None + preview = recurrence_preview(config, today=today) + return {**config, 'nextDate': preview[0] if preview else None, 'preview': preview} + + def _field_extras(saved, ftype): """createdBy / permissions / format / scope — the validated passthrough the created strata share (wave 5). `createdBy` is only ever WRITTEN host-side (the handler stamps it); here it @@ -642,6 +726,12 @@ def _field_extras(saved, ftype): geo = _clean_geocode(saved.get("geocode")) if geo: out["geocode"] = geo + # W43C C2 — the ISO date remains scalar in each row; recurrence is field metadata. Serve + # the compact persisted config together with a server-derived next date and preview so the + # client never substitutes its browser clock or holiday library for the tenant contract. + recurrence = recurrence_payload(saved.get("recurrence")) if ftype == "date" else None + if recurrence: + out["recurrence"] = recurrence corrected_from = saved.get("labelCorrectedFrom") correction_id = saved.get("labelCorrectionId") if (isinstance(corrected_from, str) and corrected_from.strip() @@ -715,152 +805,6 @@ def cohort_cells(cohorts, allowed_pids=None): return {pid: {COHORT_COLUMN: ', '.join(names)} for pid, names in cells.items()} -# --- W42-T20: THE ROUTE PROJECTION COLUMN (owner instructions 7 + 19, R9/R20/R23, C11) ------- -# -# ⭐⭐ THE SHAPE IS `cohort_field` / `cohort_cells`, FLAG FOR FLAG, AND THAT IS DELIBERATE: the -# grouping behaviour this column exists for is FREE only in that shape. `useVisibleRows::groupRows` -# fans a row out per element for a `multi` (or `multiselect`) field and it does so with -# `cell.split(",")`, so the value is a COMMA-JOINED STRING and nothing else can be substituted. -# -# ⛔⛔ THE KEY IS NOT DECLARED HERE, IT IS PASSED IN. C11 fixes the literal at -# `routes_customers.ROUTE_PROJECTION_KEY`, because `_mark_offerability` exempts this column BY KEY -# and two spellings of one key is the failure C11 was written about. `platform/` cannot import the -# API layer (layers point one way), so the caller hands the key down rather than this module -# holding a second copy of it. -# -# ⛔⛔ AND THE APPEND IS NOT HERE EITHER. `fields_from_workspace` appends `cohort_field()` for every -# grain that calls it, PRODUCT GRIDS INCLUDED; this column is appended by exactly one caller, -# `routes_customers.grid_assembly`. That absence of a call is the mechanism by which `product_data` -# does not offer this column at all: nothing on that grain builds route definitions and nothing on -# that grain calls either of these two functions. - - -def _route_label(fkey, defn): - """The route's name AS THE CELL SHOWS IT, which is the name minus any comma. - - ⚠ The COMMA is the separator the client splits on to group a record into every route it is - on, so it cannot also occur inside a name. Route labels are free text ("North loop, Tuesday" - is a name somebody will type), so a comma is replaced here rather than left to break the split - silently: one group called "North loop" and another called "Tuesday" would be two routes that - do not exist. The cost is cosmetic and confined to the cell; the route listing still shows the - name the person typed. - """ - return str((defn or {}).get('label') or fkey or '').replace(',', ' ').strip() - - -def route_projection_field(key, defs=None, label='Routes'): - """The derived column listing the routes a record is on, one descriptor per request. - - ⭐ `source: 'odoo'` is doing the same ONE job it does on `cohort_field` and it is not - provenance: the client keys editability off `source == 'overlay'` and `_cl_handle_grid_event` - accepts cell writes only for overlay keys, so 'odoo' is what makes this column READ-ONLY at - both ends. It is a mechanism and not a UI decision, which is why it is stated here rather than - left to a renderer. `derived: True` stops the column menu calling it a source field. - - ⚠ IT IS PRE-SET IN THE SENSE THAT NO PERSON MADE IT, AND THAT IS NOT A BADGE ON THE WIRE. - The descriptor carries no `createdBy`, which is what `field_permissions.field_origin` WOULD - answer `"preset"` from -- and it is never asked, because the one caller appends this column - AFTER the badge pass (`class`), after `usage` and after `offerable`, so the served entry - carries none of those three keys. Each of them is absent-means-nothing by design, so a client - renders no badge rather than a wrong one. The clause a reader should observe for read-only-ness - is `source != 'overlay'`, above, which is a mechanism both ends already enforce. - - ⚠ `filterable: False`, for `cohort_field`'s reason exactly. Text ops over a joined string - almost work and disagree at the edges (`contains "North"` also matches "North loop extra"), - and a filter that is nearly right is worse than one that is absent. ⛔ THAT DOES NOT CONTRADICT - `routeOwners`: the replacement is a CONDITION (`Where [Route author] ...`) reading the map - below, exactly as `cohort_field`'s replacement is the `Where [Cohort]` condition and not a text - op on its own joined string. - - ⭐⭐ `routeOwners` IS THE SIDE CHANNEL FOR `createdBy`, AND ITS DERIVATION RULE IS PART OF THE - CONTRACT. Each cell entry is `