Repository navigation
fix(chat): close the daily-cap check-then-insert race - #24
Merged
Merged
Conversation
…y lock Security audit (2026-09-07) found ChatUsageLimiter.check() ran its own SELECT count(...) in a transaction that committed before begin_turn()'s transaction inserted the new message rows. Under READ COMMITTED, two concurrent requests for the same customer could each read a count just under the daily cap and both proceed. begin_turn() now acquires a transaction-scoped pg_advisory_xact_lock keyed on the customer as the first statement of its transaction, so the cap check and the message insert are serialized against any other in-flight begin_turn() for that customer, across sessions. The lock is released automatically on commit/rollback and covers only the fast bookkeeping transaction, never the slow LLM stream, so a second tab still works while the first is streaming. ChatUsageLimiter.check() now takes the caller's already-open unit of work instead of opening its own, so the count read and the message insert share one transaction under one lock. Adds an integration test that proves the serialization directly: one thread holds the lock while inserting the capping message, a second thread's begin_turn() call is shown to genuinely block on that lock and, once unblocked, correctly rejects.
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
An independent security audit of the Ask Trueup chat backend (2026-09-07) found a real
check-then-act race in the per-customer daily query cap (S11 §5.2 step 1, NFR-16):
ChatUsageLimiter.check()ran its ownSELECT count(...)in a transaction that committed andclosed before
ChatOrchestrationService.begin_turn()'s own transaction inserted the newmessage rows. Under Postgres's default READ COMMITTED isolation, two concurrent
begin_turn()calls for the same customer (e.g. two browser tabs, or two chat sessions) could each read a count
just under the cap and both proceed, letting a customer exceed the configured daily cap. Simple
reordering of the count-then-insert sequence cannot close this on its own — the two calls run in
genuinely separate transactions that cannot see each other's uncommitted writes; only a lock that
serializes them does.
Scope note: this audit also surfaced two lower-severity findings (the concurrency lock being
session-scoped rather than customer-scoped, and no upper bound on a single tool-response row's
size). Per explicit scope agreement, this PR fixes only the medium-severity race above; the other
two are left untouched.
Implementation
ChatOrchestrationService.begin_turn()now acquires a transaction-scopedpg_advisory_xact_lock(hashtext(:customer_id)::bigint)as the first statement inside its unitof work, before the cap check.
pg_advisory_xact_lock(not the session-scoped variant) isreleased automatically on commit or rollback, so it can never leak across a pooled connection.
ChatUsageLimiter.check()now takes the caller's already-open unit of work instead of openingits own — the count read and the message insert now share one transaction, serialized by the
lock above, closing the race.
_build_orchestration_service()updated for the newChatUsageLimiter(daily_query_cap=...)constructor signature (no longer takes a
uow_factory).stream in
stream_turn()— so a customer's second tab still works normally while the first isactively streaming; only the "am I allowed to start a turn" decision is serialized.
rather than per-session, it also happens to narrow the session-scoped-lock finding noted above,
since two sessions for the same customer now serialize through this same lock. No additional
change was made to address that finding directly.
Validation
uv run ruff checkanduv run mypy --strictclean on all touched files (2 pre-existing,unrelated mypy errors in
openai_agent_adapter.pyconfirmed present onorigin/mainbeforethis change, via
git stash).uv run lint-imports: all 5 layering contracts kept.tests/api/test_chat.pytests pass unchanged, including the daily-cap andconcurrent-turn rejection tests.
tests/integration/test_chat_usage_cap_race.py: proves the serialization directly — onethread holds the same advisory lock key while inserting the message that fills the cap; a
second thread's
begin_turn()call is shown to genuinely block on that lock (asserted via waitduration, not scheduling luck) and, once unblocked, correctly sees the just-committed row and
rejects. A second test confirms the lock doesn't wrongly block a second session's turn while
still under the cap.
uv run pytest) run twice on this branch (based onorigin/main) to ruleout flakiness from the new threading-based test: 801 passed, 7 skipped, 2 pre-existing xpassed
(unrelated
test_valuation_api.py, tracked against a separate foundation bug) both times.Security / DB / API impact
Consumption) — a customer could previously exceed the configured daily chat-query cap via
concurrent requests.
(
pg_advisory_xact_lock), no new table or column.daily_chat_query_cap_exceeded429error path is unchanged, just now race-free.