fsanyoto commited on
Commit
ff2297b
Β·
verified Β·
1 Parent(s): 852b0d3

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
Files changed (6) hide show
  1. RELEASES.json +1 -1
  2. VERSION +1 -1
  3. api/deps.py +28 -3
  4. api/main.py +18 -22
  5. api/routes_publish.py +117 -12
  6. api/routes_query.py +61 -2
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "abfbfbe",
3
  "releases": [
4
  {
5
  "version": "v25",
 
1
  {
2
+ "current": "8a1a564",
3
  "releases": [
4
  {
5
  "version": "v25",
VERSION CHANGED
@@ -1 +1 @@
1
- abfbfbe
 
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
- row = {"permitted": False, "answerable": False, "reason": ""}
 
 
 
410
  out[key] = row
411
  if key in _ASSISTANT_BUILT_INS:
412
- if not (_assistant_tenant_allows(session, key)
413
- and perm_scope.assistant_entry(session.user, key) is not None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # β›”β›” THE PUBLISH DOOR IS DELIBERATELY NOT MOUNTED FOR THE WAVE-33 DEPLOY (2026-08-14).
283
- # Owner ruled *"deploy what we can and you can defer any validation later."* Everything else in the
284
- # wave ships; this one router does not, because QA REPRODUCED three HIGH defects on it and **all
285
- # three are armed ONLY by mounting it** β€” the route 404s on the currently-live build while its
286
- # sibling `/api/v1/forms/{token}` answers 403 from the same request in the same second, so leaving
287
- # it unmounted preserves today's behaviour EXACTLY rather than introducing a new one:
288
- # 1. a published FORM view serves its stored SUBMISSIONS to anyone with the link β€” `form` is in
289
- # `PUBLISHABLE_MODES` and `PublishedBody` has no `form` branch, so it falls to the ELSE that
290
- # renders rows. Reproduced end to end. -> W33-T68
291
- # 2. `_visible_keys` falls back to every non-hidden field on an EMPTY intersection, and
292
- # `delete_field` never prunes a view's `visible` β€” so deleting the columns a published view
293
- # showed silently WIDENS its public page to the table default. -> W33-T69
294
- # 3. "a wrong passphrase is indistinguishable from a wrong token" is false twice: 1.66 ms vs
295
- # 342.41 ms medians (206x, ZERO overlap across 24 samples) because a bad token returns before
296
- # PBKDF2, plus a zero-timing oracle on the sibling GET. The rate limit keys on the
297
- # caller-supplied `x-forwarded-for`: 100 of 100 allowed against a 30/window ceiling. -> W33-T70
298
- # ⭐ TO RE-ARM: delete this comment block and uncomment the line below β€” nothing else. The router,
299
- # its client and its 178-check gate are all shipped and unchanged; only the mount is withheld.
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
- PUBLISHABLE_MODES = ("map", "catalog", "form", "swipe", "timeseries")
 
 
 
 
 
 
 
 
 
 
 
 
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
- """⚠ Caller-controlled, therefore a rate-limit key and NOTHING else β€” never a permission."""
103
- fwd = request.headers.get("x-forwarded-for") or ""
104
- first = fwd.split(",")[0].strip()
105
- return first or (request.client.host if request.client else "?")
 
 
 
 
 
 
 
 
 
 
 
 
106
 
 
 
107
 
108
- def _rate_ok(ip: str, now: float) -> bool:
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
- seen = [t for t in _HITS.get(ip, ()) if now - t < RATE_WINDOW_S]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  seen.append(now)
114
- _HITS[ip] = seen
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
- return len(seen) <= RATE_PER_WINDOW
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- raise _refuse()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()) if row["permitted"]])}
 
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")