Repository navigation
fix(chat): stop rejecting compound WHERE clauses; stop inviting a fabricated customer_id - #26
Merged
Conversation
… model into inventing a customer_id
Two compounding bugs found by QA testing caused all 6 of the chat feature's
starter questions to fail.
sql_tool_validator.validate_query() walked every exp.Func node to check it
against an allow-list, but in the installed sqlglot==30.18.0, AND/OR/XOR
are modeled as Func subclasses (Connector -> Binary -> Func) -- so any
compound WHERE clause was rejected as calling a disallowed function 'and'.
Fixed by skipping exp.Connector before the allow-list check; find_all
still recurses into a connector's operands, so `... AND pg_sleep(1)` is
still rejected (covered by a new test -- the load-bearing property of this
fix). Every other Func-typed operator (->, @>, ~, ^, TRY_CAST) stays
subject to the allow-list, also covered.
Separately, describe_schema() listed customer_id as a plain column on
every curated view. Every view already scopes itself to the current
customer via its own WHERE clause (ADR 19 §4.3), so the model never needs
to filter on it -- but seeing it listed, it invented placeholder values
('your_customer_id') that failed as invalid UUID literals. Fixed
structurally: customer_id is hidden from the schema description (still
selectable in actual results -- harmless, it's always the caller's own
id), with a one-line scope note in the tool output plus a matching system
prompt rule as defense-in-depth.
No migration; neither fix touches views, grants, or the allow-list's
shape. New regression tests in test_sql_tool_validator.py and
test_chat_security_perimeter.py, including a live compound-WHERE query
against the real curated views.
This was referenced Sep 7, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A QA pass (Playwright + Postman) found that all 6 of the chat feature's own starter questions
fail to return real answers, for two compounding backend bugs in the natural-language SQL
assistant (S11, ADR 18/19).
Root causes and implementation
Bug 1 (Critical):
sql_tool_validator.validate_query()walks everyexp.Funcnode andrejects any name off the allow-list. In the installed
sqlglot==30.18.0,AND/OR/XORaremodeled as
Funcsubclasses (Connector -> Binary -> Func), soWHERE a > 0 AND b = 'x'wasrejected as calling a disallowed function
'and'— any compound WHERE clause was impossible.Confirmed narrow: no comparison operator (
EQ,LT,Like,In,Is,Between,Not) is aFuncsubclass. Fixed by skippingexp.Connectorbefore the allow-list check — not a broaderchange;
find_allstill recurses into a connector's operands, so... AND pg_sleep(1)is stillrejected (its own regression test). Considered and rejected an
isinstance-based function-classallowlist instead of the name-based denylist:
exp.TryCast/exp.JSONCastsubclassexp.Castandwould have been silently admitted.
Bug 2 (Critical, compounding):
describe_schema()listedcustomer_idas a plain column onevery one of the 8 curated views. Every view already scopes itself to the current customer via
its own
WHERE(ADR 19 §4.3) — the model never needs to filter on it — but seeing it listed, itinvented placeholder values (
'your_customer_id') that failed as invalid UUID literals. Fixedstructurally:
customer_idis hidden from the schema description (still selectable in actualquery results — harmless, it's always the caller's own id), with a one-line scope note prepended
to the tool output plus a matching system-prompt rule as defense-in-depth, not a substitute.
Explicitly out of scope (with reasoning)
treats a validator rejection as a normal retryable tool result; "doesn't retry" under the old
code was the natural symptom of a systemic block (any natural retry also contained
AND).and the assistant already declines correctly rather than fabricating. Adding a 9th curated view
is a genuine ADR-19-amending perimeter expansion (new migration, new grant), not a bug fix —
deferred as a separate decision.
already an asserted convention identical across every POST route in this codebase (funding,
fees, etc.) — not chat-specific, no code change.
Validation evidence
tests/unit/test_sql_tool_validator.py(compoundAND/OR/BETWEEN/NOTqueries now accepted; a disallowed function nested inside anANDstill rejected — theload-bearing proof the
Connectorskip isn't a bypass;TRY_CAST/->/^/~still rejected).tests/integration/test_chat_security_perimeter.py(three new tests:describe_schemaneveradvertises
customer_id; a real compound-WHEREstarter-style query executes end-to-endagainst the live curated views; a validator rejection is a structured outcome, never an
exception).
uv run pytest tests/unit/test_sql_tool_validator.py tests/integration/test_chat_security_perimeter.py tests/api/test_chat.py— 58 passed.uv run ruff check,uv run mypy --strict app,uv run lint-imports— clean on every touchedfile, zero new baseline noise.
uv run pytestshows unrelated pre-existing failures (test_portfolios_holdings.py,test_valuation_api.py) traced to stray Postgres enum types left by an earlier, unrelated localenvironment collision — confirmed identical and passing in isolation, unrelated to this change;
CI runs against a fresh database.
Security / DB / API impact
the existing ADR 19 safety perimeter — the perimeter itself (curated views,
chat_readonlyrole, statement timeout, row cap) is unchanged.
Follow-on (not in this PR)
explicit scope decision this session).