Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/deps.py +28 -3
- api/main.py +18 -22
- api/routes_publish.py +117 -12
- api/routes_query.py +61 -2
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "8a1a564",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
8a1a564
|
api/deps.py
CHANGED
|
@@ -406,11 +406,36 @@ def assistant_source_status(session: Session, databases=None):
|
|
| 406 |
|
| 407 |
out = {}
|
| 408 |
for key in wanted:
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
| 410 |
out[key] = row
|
| 411 |
if key in _ASSISTANT_BUILT_INS:
|
| 412 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
continue
|
| 415 |
row["permitted"] = True
|
| 416 |
# β A CACHE MISS IS A DATA-AVAILABILITY FACT, NOT A PERMISSION ONE, and it must not
|
|
|
|
| 406 |
|
| 407 |
out = {}
|
| 408 |
for key in wanted:
|
| 409 |
+
# `visible` = "the caller can open this database elsewhere in the product". D-276 uses it
|
| 410 |
+
# to decide whether an unusable source is REPORTED or stays absent; default False, so a
|
| 411 |
+
# branch that never sets it keeps today's silence rather than inheriting a disclosure.
|
| 412 |
+
row = {"permitted": False, "answerable": False, "reason": "", "visible": False}
|
| 413 |
out[key] = row
|
| 414 |
if key in _ASSISTANT_BUILT_INS:
|
| 415 |
+
# ββ D-276 (2026-08-15) β AN OMISSION MUST STATE ITS CAUSE, and this branch used to
|
| 416 |
+
# `continue` in silence. MEASURED on tenant #0, both `admin` and `leadership`:
|
| 417 |
+
# `GET /query` listed nine sources and `product_data` was not among them, while
|
| 418 |
+
# `POST /query/chat {database:"product_data"}` answered 403 `assistant_forbidden` β
|
| 419 |
+
# for a database the SAME account opens from the nav, holds saved views on, and can
|
| 420 |
+
# see in the Database flyout. The two mirror-served grids are also un-askable and they
|
| 421 |
+
# ARE listed, with their cause in the chip; this one said nothing at all.
|
| 422 |
+
# β THE ASYMMETRY IS THE DEFECT, NOT THE REFUSAL. R6's second sentence: a limit that
|
| 423 |
+
# cannot be removed must be REPORTED with its cause, never silently enforced.
|
| 424 |
+
# β `visible` IS WHAT KEEPS THIS FROM BEING A DISCLOSURE. A source the caller cannot
|
| 425 |
+
# open ANYWHERE stays absent β naming it would tell them a database exists that they
|
| 426 |
+
# were never allowed to know about. `visible` is true only when the ordinary product
|
| 427 |
+
# wall already lets them open it, i.e. when the silence was incoherent rather than
|
| 428 |
+
# protective. `permitted` is untouched: it gates which stored artefacts survive, and
|
| 429 |
+
# widening it here would resurrect views the read boundary refuses.
|
| 430 |
+
if not _assistant_tenant_allows(session, key):
|
| 431 |
+
row["visible"] = bool(perm_scope.may_access(session.user, key))
|
| 432 |
+
row["reason"] = ("this workspace's catalogue does not list it as an assistant "
|
| 433 |
+
"source β an administrator can add it")
|
| 434 |
+
continue
|
| 435 |
+
if perm_scope.assistant_entry(session.user, key) is None:
|
| 436 |
+
row["visible"] = bool(perm_scope.may_access(session.user, key))
|
| 437 |
+
row["reason"] = ("your permissions for it have not been migrated to the grant the "
|
| 438 |
+
"assistant reads β an administrator can re-save them")
|
| 439 |
continue
|
| 440 |
row["permitted"] = True
|
| 441 |
# β A CACHE MISS IS A DATA-AVAILABILITY FACT, NOT A PERMISSION ONE, and it must not
|
api/main.py
CHANGED
|
@@ -279,28 +279,24 @@ app.include_router(routes_query.router) # R1 / C5 β E's router, A's line
|
|
| 279 |
# with an NC that comments a mount out and goes RED.
|
| 280 |
# β Same placement rule as the line above β ABOVE the `app.mount("/", _AppStatic(...), html=True)`
|
| 281 |
# at the end of the file, never after it.
|
| 282 |
-
#
|
| 283 |
-
#
|
| 284 |
-
#
|
| 285 |
-
#
|
| 286 |
-
#
|
| 287 |
-
#
|
| 288 |
-
#
|
| 289 |
-
#
|
| 290 |
-
#
|
| 291 |
-
#
|
| 292 |
-
# `
|
| 293 |
-
#
|
| 294 |
-
#
|
| 295 |
-
#
|
| 296 |
-
#
|
| 297 |
-
#
|
| 298 |
-
#
|
| 299 |
-
#
|
| 300 |
-
# β `verify_api::section_w23_mounts` asserts this path in `app.openapi()["paths"]`, so that gate is
|
| 301 |
-
# EXPECTED RED on the publish rows until T68-T70 land. That is the alarm doing its job, not a
|
| 302 |
-
# regression β do not "fix" it by weakening the assertion.
|
| 303 |
-
# app.include_router(routes_publish.router) # R5 / C2 β C's router, A's line (the publish door)
|
| 304 |
# β NOT BEHIND `module_gate("product_data")`, WHICH IS THE WHOLE REASON IT IS A SECOND ASSET DOOR.
|
| 305 |
# `routes_assets.py` gates EVERY one of its routes on that module, so a connector logo served from
|
| 306 |
# there would 403 for any account without the product-data grant β i.e. the Connectors directory
|
|
|
|
| 279 |
# with an NC that comments a mount out and goes RED.
|
| 280 |
# β Same placement rule as the line above β ABOVE the `app.mount("/", _AppStatic(...), html=True)`
|
| 281 |
# at the end of the file, never after it.
|
| 282 |
+
# ββ THE PUBLISH DOOR IS MOUNTED AGAIN (2026-08-15). It was withheld for the wave-33 deploy
|
| 283 |
+
# because QA reproduced three HIGH defects that are armed ONLY by mounting it. All three are fixed
|
| 284 |
+
# in `routes_publish.py`, each at its cause rather than at its symptom:
|
| 285 |
+
# 1. W33-T68 β `form` LEFT `PUBLISHABLE_MODES`. A form view's rows ARE the submissions people
|
| 286 |
+
# sent it, so publishing one served other people's answers to anyone holding the link. A form
|
| 287 |
+
# still has its own public door (`#/form/<token>`) which serves the BLANK form and never rows.
|
| 288 |
+
# 2. W33-T69 β `_visible_keys` returns `[]` when a view STORED a `visible` list and none of its
|
| 289 |
+
# keys survive, instead of falling back to the table default. The fallback still applies to a
|
| 290 |
+
# view that never stored one, which is what it was written for.
|
| 291 |
+
# 3. W33-T70 β three separate leaks closed: the rate limit no longer keys on the caller-supplied
|
| 292 |
+
# `x-forwarded-for` (it keys on the socket peer and counts FAILURES only, so a shared proxy
|
| 293 |
+
# peer cannot become one global bucket); every failing path now spends the same PBKDF2 the
|
| 294 |
+
# success path spends, closing the 206x timing gap; and an unknown token on the sibling GET
|
| 295 |
+
# answers the LOCKED shape rather than a 403, so that route stops sorting real tokens from
|
| 296 |
+
# fake ones for free.
|
| 297 |
+
# β `verify_api::section_w23_mounts` asserts this path in `app.openapi()["paths"]`, so its four
|
| 298 |
+
# EXPECTED reds should now go GREEN. A red here after this line means the mount broke, not the gate.
|
| 299 |
+
app.include_router(routes_publish.router) # R5 / C2 β C's router, A's line (the publish door)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
# β NOT BEHIND `module_gate("product_data")`, WHICH IS THE WHOLE REASON IT IS A SECOND ASSET DOOR.
|
| 301 |
# `routes_assets.py` gates EVERY one of its routes on that module, so a connector logo served from
|
| 302 |
# there would 403 for any account without the product-data grant β i.e. the Connectors directory
|
api/routes_publish.py
CHANGED
|
@@ -78,7 +78,19 @@ _HITS: dict = {}
|
|
| 78 |
#: publish, with nothing anywhere going red. A `grid` or `kanban` view is a re-shaping of a row
|
| 79 |
#: set; publishing one would be publishing the table, which is exactly what R5's projection clause
|
| 80 |
#: exists to prevent.
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
def _refuse():
|
|
@@ -99,23 +111,76 @@ def _same(a: str, b: str) -> bool:
|
|
| 99 |
|
| 100 |
|
| 101 |
def _client_ip(request: Request) -> str:
|
| 102 |
-
"""
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
|
| 109 |
-
"""A SLIDING window. A fixed bucket lets a caller spend a whole allowance at 11:59:59 and the
|
| 110 |
whole next one at 12:00:00 β i.e. double the limit, back to back, against a door whose entire
|
| 111 |
-
protection is that guessing a 24-byte token is slow.
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
seen.append(now)
|
| 114 |
-
_HITS[
|
| 115 |
if len(_HITS) > 4096:
|
| 116 |
for k in [k for k, v in _HITS.items() if not v or now - v[-1] > RATE_WINDOW_S]:
|
| 117 |
_HITS.pop(k, None)
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
|
| 121 |
def _public_base() -> str:
|
|
@@ -443,6 +508,20 @@ def _visible_keys(view: dict, fields: list) -> list:
|
|
| 443 |
keep = [k for k in stored if k in by_key]
|
| 444 |
if keep:
|
| 445 |
return keep
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
try:
|
| 447 |
default_visible = (aios_grid._default_view_config(fields) or {}).get("visible") or []
|
| 448 |
except Exception: # noqa: BLE001
|
|
@@ -776,7 +855,21 @@ def get_published(token: str, request: Request):
|
|
| 776 |
raise err(429, "too_many_requests", "too many requests β wait a moment and try again")
|
| 777 |
found = _resolve(token)
|
| 778 |
if not found:
|
| 779 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
rt, _slug, table_key, view, entry = found
|
| 781 |
if entry.get("access") == "password":
|
| 782 |
# β THE SHAPE OF THE PASSWORD ANSWER, and it is not a refusal. A locked link must render
|
|
@@ -805,8 +898,20 @@ async def open_published(token: str, request: Request):
|
|
| 805 |
body = await _bounded_body(request)
|
| 806 |
found = _resolve(token)
|
| 807 |
if not found:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 808 |
raise _refuse()
|
| 809 |
rt, _slug, table_key, view, entry = found
|
| 810 |
if entry.get("access") == "password" and not _pw_ok(entry, str(body.get("passphrase") or "")):
|
|
|
|
| 811 |
raise _refuse()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 812 |
return _public_view(rt, table_key, view)
|
|
|
|
| 78 |
#: publish, with nothing anywhere going red. A `grid` or `kanban` view is a re-shaping of a row
|
| 79 |
#: set; publishing one would be publishing the table, which is exactly what R5's projection clause
|
| 80 |
#: exists to prevent.
|
| 81 |
+
#: ββ W33-T68 β `form` IS DELIBERATELY ABSENT, AND ITS ABSENCE IS THE FIX.
|
| 82 |
+
#: A `form` view's rows ARE the submissions people have sent it. Every other mode here re-shapes a
|
| 83 |
+
#: row set the publisher already curated; a form's row set is other people's answers, gathered under
|
| 84 |
+
#: an implicit promise that they go to the owner. Publishing one turned "share this interface" into
|
| 85 |
+
#: "serve the responses to anyone holding the link" β on the one unauthenticated door in the
|
| 86 |
+
#: product, with no wall between the link and the data.
|
| 87 |
+
#: β AND A FORM ALREADY HAS ITS OWN PUBLIC DOOR: `#/form/<token>` through `routes_forms.py`, which
|
| 88 |
+
#: serves the BLANK form for submitting and never the stored rows. So this is not a capability
|
| 89 |
+
#: removed, it is a second door onto the same object that should never have existed beside the
|
| 90 |
+
#: first. Publishing a form to be filled in still works, at the URL that was always for it.
|
| 91 |
+
#: β `verify_forms.py` holds this list in step with the client's `MODE_GROUP`; the client must not
|
| 92 |
+
#: offer Publish on a form view, or the picker promises what this refuses.
|
| 93 |
+
PUBLISHABLE_MODES = ("map", "catalog", "swipe", "timeseries")
|
| 94 |
|
| 95 |
|
| 96 |
def _refuse():
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
def _client_ip(request: Request) -> str:
|
| 114 |
+
"""ββ W33-T70 β `x-forwarded-for` IS GONE FROM THIS FUNCTION, AND THAT WAS THE WHOLE HOLE.
|
| 115 |
+
|
| 116 |
+
The header is chosen by the caller. Keying a rate limit on it means an enumerator writes a new
|
| 117 |
+
value per request and every request lands in a fresh bucket: MEASURED at **100 of 100 admitted
|
| 118 |
+
through a 30-per-window ceiling**. A limiter with a caller-chosen key is not a limiter, and its
|
| 119 |
+
old docstring said the header was "a rate-limit key and NOTHING else" β which was exactly the
|
| 120 |
+
use it could not support. It is safe as a LOG field and as nothing else.
|
| 121 |
+
|
| 122 |
+
The socket peer is the only thing here the caller cannot choose, so it is the key.
|
| 123 |
+
β AND BEHIND HF'S PROXY EVERY CALLER SHARES ONE PEER, which is why `_rate_ok` counts FAILURES
|
| 124 |
+
ONLY (see there). A per-peer ceiling over ALL traffic would be one global bucket, i.e. an alarm
|
| 125 |
+
that fires for everyone [[alarm-that-fires-for-everyone]] β the honest reading of a shared peer
|
| 126 |
+
is that we cannot separate callers, not that we should throttle them together.
|
| 127 |
+
"""
|
| 128 |
+
return request.client.host if request.client else "?"
|
| 129 |
+
|
| 130 |
|
| 131 |
+
def _rate_ok(key: str, now: float) -> bool:
|
| 132 |
+
"""Is this caller UNDER the failure ceiling? Pure check β call `_note_failure` to count.
|
| 133 |
|
| 134 |
+
A SLIDING window. A fixed bucket lets a caller spend a whole allowance at 11:59:59 and the
|
|
|
|
| 135 |
whole next one at 12:00:00 β i.e. double the limit, back to back, against a door whose entire
|
| 136 |
+
protection is that guessing a 24-byte token is slow.
|
| 137 |
+
|
| 138 |
+
β W33-T70 β IT COUNTS FAILURES, NOT REQUESTS, and the split is what makes a shared proxy peer
|
| 139 |
+
survivable. A legitimate reader opens a link that RESOLVES, so they never touch the counter at
|
| 140 |
+
all; an enumerator produces nothing but misses. Counting every request under one shared peer
|
| 141 |
+
would have denied service to everybody the moment one guesser showed up β trading a token
|
| 142 |
+
oracle for an outage is not a fix.
|
| 143 |
+
"""
|
| 144 |
+
seen = [t for t in _HITS.get(key, ()) if now - t < RATE_WINDOW_S]
|
| 145 |
+
_HITS[key] = seen
|
| 146 |
+
return len(seen) < RATE_PER_WINDOW
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _note_failure(key: str, now: float) -> None:
|
| 150 |
+
"""Record one failed resolution against this peer, and keep the table bounded."""
|
| 151 |
+
seen = [t for t in _HITS.get(key, ()) if now - t < RATE_WINDOW_S]
|
| 152 |
seen.append(now)
|
| 153 |
+
_HITS[key] = seen
|
| 154 |
if len(_HITS) > 4096:
|
| 155 |
for k in [k for k, v in _HITS.items() if not v or now - v[-1] > RATE_WINDOW_S]:
|
| 156 |
_HITS.pop(k, None)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
#: A salt used ONLY to burn the same PBKDF2 a real verification would, when there is no stored hash
|
| 160 |
+
#: to check against. Its value is irrelevant; its COST is the point.
|
| 161 |
+
_DUMMY_SALT = b"\x00" * 16
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _equalise_pw_cost(entry) -> None:
|
| 165 |
+
"""ββ W33-T70 β SPEND THE PBKDF2 EVEN WHEN THERE IS NOTHING TO VERIFY.
|
| 166 |
+
|
| 167 |
+
MEASURED before this existed: a wrong TOKEN answered in ~0.4 ms and a wrong PASSPHRASE in
|
| 168 |
+
~82 ms β a **206x gap with zero overlap across 24 samples**. So the refusal's careful wording
|
| 169 |
+
("that link is not valid", identical for both) was undone by the clock: anyone could sort real
|
| 170 |
+
tokens from fake ones by timing alone, then spend their guesses only on the real ones. The
|
| 171 |
+
docstring on `_refuse` describes exactly the leak the code then handed over for free.
|
| 172 |
+
|
| 173 |
+
Called on every path that fails BEFORE a passphrase check would have happened, so the cheap
|
| 174 |
+
branch costs what the expensive one costs. `iter` is taken from the entry when there is one, so
|
| 175 |
+
a link minted under a different iteration count stays indistinguishable too.
|
| 176 |
+
"""
|
| 177 |
+
iterations = PW_ITERATIONS
|
| 178 |
+
if isinstance(entry, dict):
|
| 179 |
+
try:
|
| 180 |
+
iterations = int(entry.get("iter") or PW_ITERATIONS)
|
| 181 |
+
except (TypeError, ValueError):
|
| 182 |
+
iterations = PW_ITERATIONS
|
| 183 |
+
_hash_pw("", _DUMMY_SALT, iterations)
|
| 184 |
|
| 185 |
|
| 186 |
def _public_base() -> str:
|
|
|
|
| 508 |
keep = [k for k in stored if k in by_key]
|
| 509 |
if keep:
|
| 510 |
return keep
|
| 511 |
+
# ββ W33-T69 β A STALE `visible` MUST SERVE NOTHING, NOT THE TABLE DEFAULT.
|
| 512 |
+
# `delete_field` never prunes a view's stored `visible`, so a published view whose columns were
|
| 513 |
+
# later deleted and replaced arrives here with a NON-EMPTY `stored` of which nothing survives β
|
| 514 |
+
# and the fallback below then WIDENS the public payload to whatever the table declares by
|
| 515 |
+
# default. The publisher chose five columns; the anonymous reader gets the table's idea of
|
| 516 |
+
# sensible. That is a widening on the one unauthenticated door in the product.
|
| 517 |
+
# β THE DISTINCTION IS `stored` NON-EMPTY, NOT `keep` EMPTY. A view that never stored `visible`
|
| 518 |
+
# at all (a legacy publish, a view saved before the key existed) has no intent to honour and
|
| 519 |
+
# the default IS the right answer for it β that is what the fallback was written for. A view
|
| 520 |
+
# that stored five keys and has none left DID state an intent, and every column it named is
|
| 521 |
+
# gone: the honest answer is no columns, which renders as an empty published view rather than
|
| 522 |
+
# somebody else's data.
|
| 523 |
+
if stored:
|
| 524 |
+
return []
|
| 525 |
try:
|
| 526 |
default_visible = (aios_grid._default_view_config(fields) or {}).get("visible") or []
|
| 527 |
except Exception: # noqa: BLE001
|
|
|
|
| 855 |
raise err(429, "too_many_requests", "too many requests β wait a moment and try again")
|
| 856 |
found = _resolve(token)
|
| 857 |
if not found:
|
| 858 |
+
# ββ W33-T70 β AN UNKNOWN TOKEN ANSWERS EXACTLY WHAT A LOCKED ONE ANSWERS.
|
| 859 |
+
# This used to `raise _refuse()`, and that 403 was a free oracle: one unauthenticated GET,
|
| 860 |
+
# no passphrase, no cost, told an enumerator whether a token was REAL. `_refuse`'s own
|
| 861 |
+
# docstring says the difference between "no such link" and "that link was revoked" must
|
| 862 |
+
# never be observable β and the route beside it published that difference in its status
|
| 863 |
+
# code. Sorting real tokens from fake ones is the whole of the work; once it is free, the
|
| 864 |
+
# passphrase is all that is left and it can be attacked offline-cheap.
|
| 865 |
+
# β SO THE UNKNOWN TOKEN GETS THE LOCKED SHAPE: `{"locked": true}` and nothing else β no
|
| 866 |
+
# title, no columns, no count, the same bytes a real password link returns before anyone
|
| 867 |
+
# has tried to open it. The guess then costs a POST with a passphrase, which is rate
|
| 868 |
+
# limited and PBKDF2-priced. A PUBLIC link still opens on this GET, which is what a public
|
| 869 |
+
# link is for; what stops being visible is which PASSWORD tokens exist.
|
| 870 |
+
_note_failure(_client_ip(request), time.time())
|
| 871 |
+
_equalise_pw_cost(None)
|
| 872 |
+
return {"locked": True}
|
| 873 |
rt, _slug, table_key, view, entry = found
|
| 874 |
if entry.get("access") == "password":
|
| 875 |
# β THE SHAPE OF THE PASSWORD ANSWER, and it is not a refusal. A locked link must render
|
|
|
|
| 898 |
body = await _bounded_body(request)
|
| 899 |
found = _resolve(token)
|
| 900 |
if not found:
|
| 901 |
+
# ββ W33-T70 β SPEND THE PBKDF2 ANYWAY. A wrong token skipped the hash entirely and
|
| 902 |
+
# answered in ~0.4 ms while a wrong passphrase paid ~82 ms: a 206x gap, zero overlap in 24
|
| 903 |
+
# samples, and a clean separation of real tokens from fake ones for anyone with a stopwatch.
|
| 904 |
+
# Both arms now cost the same, so the identical 403 above is finally identical in practice
|
| 905 |
+
# rather than only in wording.
|
| 906 |
+
_note_failure(_client_ip(request), now)
|
| 907 |
+
_equalise_pw_cost(None)
|
| 908 |
raise _refuse()
|
| 909 |
rt, _slug, table_key, view, entry = found
|
| 910 |
if entry.get("access") == "password" and not _pw_ok(entry, str(body.get("passphrase") or "")):
|
| 911 |
+
_note_failure(_client_ip(request), now)
|
| 912 |
raise _refuse()
|
| 913 |
+
# β A link with NO passphrase must still pay, or "this token is public" is readable from the
|
| 914 |
+
# clock on a route whose whole job is to be uninformative.
|
| 915 |
+
if entry.get("access") != "password":
|
| 916 |
+
_equalise_pw_cost(entry)
|
| 917 |
return _public_view(rt, table_key, view)
|
api/routes_query.py
CHANGED
|
@@ -108,10 +108,28 @@ def _spec_schema(field_keys):
|
|
| 108 |
"name": {"type": "string"},
|
| 109 |
"refusal": {"type": "string"},
|
| 110 |
"visible": {"type": "array", "items": col},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
"filters": {"type": "array", "items": {"type": "object", "properties": {
|
| 112 |
"colId": col, "op": {"type": "string", "enum": sorted(_grid().FILTER_OPS)},
|
| 113 |
"value": {"type": "string"},
|
|
|
|
|
|
|
|
|
|
| 114 |
}, "required": ["colId", "op"]}},
|
|
|
|
|
|
|
|
|
|
| 115 |
"filterConj": {"type": "string", "enum": ["and", "or"]},
|
| 116 |
"sorts": {"type": "array", "items": {"type": "object", "properties": {
|
| 117 |
"colId": col, "dir": {"type": "string", "enum": ["asc", "desc"]},
|
|
@@ -145,7 +163,13 @@ VIEW KINDS: {", ".join(QUERY_KINDS)}
|
|
| 145 |
|
| 146 |
Choose visible fields, supported filters and an optional aggregation. Use count for record counts;
|
| 147 |
sum, avg, min and max require one numeric field. The server computes and cites every number from
|
| 148 |
-
this exact snapshot. Return kind=refused with one plain sentence if the database cannot answer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
|
| 151 |
_FAILED_GEN = re.compile(r"<function=build_view>(\{.*?\})\s*</function>", re.S)
|
|
@@ -231,6 +255,16 @@ def _validate(spec, fields):
|
|
| 231 |
for item in spec.get("filters") or ():
|
| 232 |
if isinstance(item, dict) and item.get("colId"):
|
| 233 |
named.add(str(item["colId"]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
for item in spec.get("sorts") or ():
|
| 235 |
if isinstance(item, dict) and item.get("colId"):
|
| 236 |
named.add(str(item["colId"]))
|
|
@@ -251,6 +285,18 @@ def _validate(spec, fields):
|
|
| 251 |
filters = _grid().clean_filter_tree(raw_filters, keys)
|
| 252 |
if len(filters) != len(raw_filters):
|
| 253 |
return None, "part of that filter is unsupported", "filter_dropped"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
aggregation = raw_aggregation if isinstance(raw_aggregation, dict) else {}
|
| 256 |
op = str(aggregation.get("op") or "").lower()
|
|
@@ -287,6 +333,11 @@ def _validate(spec, fields):
|
|
| 287 |
"groupBy": spec.get("groupBy") if spec.get("groupBy") in keys else None,
|
| 288 |
"aggregation": {"op": op, "field": field},
|
| 289 |
"display": cleaned_display,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
}, None, None
|
| 291 |
|
| 292 |
|
|
@@ -448,9 +499,17 @@ def _public_state(state, session):
|
|
| 448 |
"citations": _safe([row for key, row in state["citations"].items()
|
| 449 |
if key in allowed_citations]), "models": model_choices(),
|
| 450 |
# The chip row's own data: a source this caller holds but cannot ask, and WHY.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
"sources": _safe([{"database": key, "answerable": row["answerable"],
|
| 452 |
"reason": row["reason"]}
|
| 453 |
-
for key, row in sorted(status.items())
|
|
|
|
| 454 |
|
| 455 |
|
| 456 |
@router.get("/query")
|
|
|
|
| 108 |
"name": {"type": "string"},
|
| 109 |
"refusal": {"type": "string"},
|
| 110 |
"visible": {"type": "array", "items": col},
|
| 111 |
+
# ββ 2026-08-15 (owner: *"if you can build the view, the AI assistant should also be
|
| 112 |
+
# able to do it by using our tools on the backend"*). `rhs` and `important` were the
|
| 113 |
+
# only two things a person could express through `view_upsert` and this tool could not.
|
| 114 |
+
#
|
| 115 |
+
# β WITHOUT `rhs` THE ASSISTANT CANNOT STATE AN ERROR-CATCHER AT ALL β the one shape
|
| 116 |
+
# item 8a named by hand (*"price and COGS not matching"*). Every one of the 20
|
| 117 |
+
# `FILTER_OPS` was already reachable, because they all read `value`; comparing a column
|
| 118 |
+
# against ANOTHER COLUMN is a different member (`aios_grid._clean_rhs`, CG-9) and it was
|
| 119 |
+
# simply absent here, so the model had no way to ask for it and no way to be told why.
|
| 120 |
+
# `kind` is `field` ONLY: `measure` needs a window and `stat` needs the population
|
| 121 |
+
# vocabulary, neither of which this snapshot-shaped reader carries β offering them
|
| 122 |
+
# would be a control that lies, which is the same rule the source chips follow.
|
| 123 |
"filters": {"type": "array", "items": {"type": "object", "properties": {
|
| 124 |
"colId": col, "op": {"type": "string", "enum": sorted(_grid().FILTER_OPS)},
|
| 125 |
"value": {"type": "string"},
|
| 126 |
+
"rhs": {"type": "object", "properties": {
|
| 127 |
+
"kind": {"type": "string", "enum": ["field"]}, "colId": col,
|
| 128 |
+
}, "required": ["kind", "colId"]},
|
| 129 |
}, "required": ["colId", "op"]}},
|
| 130 |
+
# A personal legibility mark, exactly as `grid_events.view_upsert` treats it (wave 32
|
| 131 |
+
# R5/C4) β not a lock, and no second permission wall.
|
| 132 |
+
"important": {"type": "boolean"},
|
| 133 |
"filterConj": {"type": "string", "enum": ["and", "or"]},
|
| 134 |
"sorts": {"type": "array", "items": {"type": "object", "properties": {
|
| 135 |
"colId": col, "dir": {"type": "string", "enum": ["asc", "desc"]},
|
|
|
|
| 163 |
|
| 164 |
Choose visible fields, supported filters and an optional aggregation. Use count for record counts;
|
| 165 |
sum, avg, min and max require one numeric field. The server computes and cites every number from
|
| 166 |
+
this exact snapshot. Return kind=refused with one plain sentence if the database cannot answer.
|
| 167 |
+
|
| 168 |
+
To compare one column against ANOTHER column rather than a typed value, give the filter an rhs of
|
| 169 |
+
{{"kind":"field","colId":"<other field>"}} and omit value β that is how you express questions like
|
| 170 |
+
"priced below what it costs us". Both columns must be in the FIELDS list above.
|
| 171 |
+
Set important=true when the view is one somebody should be chased about: an error, a mismatch, or
|
| 172 |
+
money at risk. Leave it out otherwise."""
|
| 173 |
|
| 174 |
|
| 175 |
_FAILED_GEN = re.compile(r"<function=build_view>(\{.*?\})\s*</function>", re.S)
|
|
|
|
| 255 |
for item in spec.get("filters") or ():
|
| 256 |
if isinstance(item, dict) and item.get("colId"):
|
| 257 |
named.add(str(item["colId"]))
|
| 258 |
+
# β THE RIGHT-HAND COLUMN IS A COLUMN AND MUST FACE THE SAME `missing` CHECK. Collecting
|
| 259 |
+
# only the left side is what makes D-229 possible one layer down: `clean_filter_tree` does
|
| 260 |
+
# NOT drop a leaf whose field-rhs names a column that does not exist β it keeps the leaf,
|
| 261 |
+
# strips the `rhs`, blanks the value, and `filter_sql.is_rule_active` then reports the rule
|
| 262 |
+
# INACTIVE. An inactive rule narrows nothing, so "margin under 10%" would come back as a
|
| 263 |
+
# view listing the ENTIRE catalogue under an error-catcher's name, with nothing red.
|
| 264 |
+
# Naming it here turns that into the ordinary "this database does not have: X" refusal.
|
| 265 |
+
rhs = item.get("rhs") if isinstance(item, dict) else None
|
| 266 |
+
if isinstance(rhs, dict) and rhs.get("colId"):
|
| 267 |
+
named.add(str(rhs["colId"]))
|
| 268 |
for item in spec.get("sorts") or ():
|
| 269 |
if isinstance(item, dict) and item.get("colId"):
|
| 270 |
named.add(str(item["colId"]))
|
|
|
|
| 285 |
filters = _grid().clean_filter_tree(raw_filters, keys)
|
| 286 |
if len(filters) != len(raw_filters):
|
| 287 |
return None, "part of that filter is unsupported", "filter_dropped"
|
| 288 |
+
# ββ A SECOND, NARROWER CHECK, AND THE LENGTH CHECK ABOVE CANNOT DO ITS JOB (D-229).
|
| 289 |
+
# A dropped leaf changes the COUNT; a stripped `rhs` does not β the leaf survives, so
|
| 290 |
+
# `len(filters) == len(raw_filters)` and the refusal above never fires. The failure is
|
| 291 |
+
# therefore silent in exactly the direction that matters: the condition stops narrowing and
|
| 292 |
+
# the view answers with every record. Assert the member survived, per leaf.
|
| 293 |
+
# β The `named` pass above already refuses an rhs naming a column this database lacks, so
|
| 294 |
+
# reaching here means something ELSE stripped it (a type the comparand cannot take, a future
|
| 295 |
+
# `_clean_rhs` rule). Both doors, because the two catch different causes and the cost of
|
| 296 |
+
# missing this one is a wrong answer that looks right.
|
| 297 |
+
for sent, kept in zip(raw_filters, filters):
|
| 298 |
+
if sent.get("rhs") and not kept.get("rhs"):
|
| 299 |
+
return None, "that column cannot be compared against another column", "rhs_dropped"
|
| 300 |
|
| 301 |
aggregation = raw_aggregation if isinstance(raw_aggregation, dict) else {}
|
| 302 |
op = str(aggregation.get("op") or "").lower()
|
|
|
|
| 333 |
"groupBy": spec.get("groupBy") if spec.get("groupBy") in keys else None,
|
| 334 |
"aggregation": {"op": op, "field": field},
|
| 335 |
"display": cleaned_display,
|
| 336 |
+
# β `is True`, not truthy, and UNCONDITIONAL β the same two rules `grid_events.view_upsert`
|
| 337 |
+
# follows for this key. `is True` so a model emitting the string "false" does not mark a
|
| 338 |
+
# view; unconditional so the mark is REMOVABLE rather than a flag that can be set and never
|
| 339 |
+
# cleared (a key written only when present leaves a stored `true` alive forever).
|
| 340 |
+
"important": spec.get("important") is True,
|
| 341 |
}, None, None
|
| 342 |
|
| 343 |
|
|
|
|
| 499 |
"citations": _safe([row for key, row in state["citations"].items()
|
| 500 |
if key in allowed_citations]), "models": model_choices(),
|
| 501 |
# The chip row's own data: a source this caller holds but cannot ask, and WHY.
|
| 502 |
+
# ββ D-276 β `or row.get("visible")`. A source the caller can open elsewhere in the
|
| 503 |
+
# product but the assistant cannot read is now LISTED with its cause, instead of
|
| 504 |
+
# vanishing from a picker that shows every other database they hold. It arrives with
|
| 505 |
+
# `answerable: False` and a `reason`, which is the SAME shape the mirror-served grids
|
| 506 |
+
# already use, so the chip row needs no new state to render it.
|
| 507 |
+
# β `permitted` STILL GATES ARTEFACTS β `allowed_views` above is unchanged. This widens
|
| 508 |
+
# what is DESCRIBED, never what can be read or kept.
|
| 509 |
"sources": _safe([{"database": key, "answerable": row["answerable"],
|
| 510 |
"reason": row["reason"]}
|
| 511 |
+
for key, row in sorted(status.items())
|
| 512 |
+
if row["permitted"] or row.get("visible")])}
|
| 513 |
|
| 514 |
|
| 515 |
@router.get("/query")
|