fix chatbot limits and test * add tests
21 KiB
Chatbot Backend Guide
This guide covers the chatbot backend that queryAPI exposes for the Aaria
Agent Service integration. It is the operator entry point; design
rationale lives in plans/chatbot_plan_codex.v10.md.
Overview
queryAPI exposes a synchronous HTTP facade over the Aaria FastAPI agent service. Client UIs talk to queryAPI; queryAPI calls FastAPI and persists the conversation in Postgres so future turns inherit the prior context.
Who calls it:
- Client UIs as
client_user: create sessions, post turns, list turns, patch document/folder scope. - Super-admins: list, get, soft-delete sessions across all users for a given client.
What it does NOT do (out of scope per plan v10):
- Full-text search.
- GIN indexes.
- Costing / billing.
- Persisted structured data or result rows from the agent.
- User-initiated session deletes (only super-admin DELETE).
Architecture
Data model (bot_* tables, migration 131)
| Table | Purpose |
|---|---|
bot_sessions |
Owner-scoped chatbot conversations. Soft-deletable via is_deleted. Derives last_terminal_ordinal and last_seen_ordinal from bot_turns. |
bot_session_documents |
Per-session document scope. Empty set means "all documents for the client" (plan §4 fallback). |
bot_session_folders |
Per-session folder scope. Folder ids preserved in the FastAPI payload; expansion to documents happens in M3 payload assembly. |
bot_turns |
Append-only turn log. Status is one of in_flight, completed, errored, abandoned, session_deleted. |
Two CHECK constraints govern bot_turns:
bot_turns_status_valuesenforces the enum set.bot_turns_status_fields_consistentenforces the (status, completion, error) consistency matrix from plan §4 plus a guard clause for unknown statuses. The guard clauseOR status NOT IN (...)letsbot_turns_status_valuesown the rejection of unknown values without_status_fields_consistentfiring first under Postgres's alphabetical-by-name CHECK evaluation order. The plan §4 literal three-clause matrix is preserved verbatim for documented statuses.
The partial unique index bot_turns_one_inflight_per_session enforces
"at most one in-flight turn per session". This is the primary
mechanism for serializing concurrent submissions.
AddTurn flow (plan §6)
AddTurn(input):
validate prompt (1 <= rune count <= MaxTurnChars)
attemptID = uuid.New()
tx1:
LockBotSessionForOwner(...)
AbandonExpiredInflightForOwner(grace = max(2 * RequestTimeoutSeconds, 60s))
re-derive last_seen_ordinal / last_terminal_ordinal
GetBotTurn(input.Ordinal)
if existing exists:
classify by status; reset on errored/abandoned (prompt must match,
ordinal must equal last_terminal_ordinal); reject on others.
else:
check ordinal matches natural target (last_seen+1 if has_inflight,
else last_terminal+1); InsertBotTurnPrompt; partial unique index
may reject -> ErrPriorTurnInFlight.
if input.Ordinal == 1:
SetBotSessionTitle(first 120 runes of prompt) where title=''
end tx1
Build conversation_history (last RecentTurns completed turns x 2 messages, asc).
Build chat_scope (stored docs + folder-expanded docs, dedup; folder ids preserved).
Call FastAPI /agent/chat OUTSIDE any DB tx (network round-trip).
tx2:
LockBotSessionForOwner(...)
if no row -> session was soft-deleted mid-call:
MarkBotTurnSessionDeleted(...) with attempt_id compare-and-set
return ErrSessionDeletedDuringCall (HTTP 410)
if FastAPI returned a typed error:
FailBotTurn(...) with error JSON containing attempt count
return mapped agent_* sentinel (HTTP 502)
if FastAPI succeeded but answer.text is empty/oversize:
FailBotTurn(...) with agent_invalid_response error JSON
return ErrAgentInvalidResponse (HTTP 502)
success path:
CompleteBotTurn(...) with attempt_id compare-and-set
summarize metadata (latency, tokens)
apply state update: null -> preserve prior; non-null -> strip
cached_results, write if under 64KB cap, else log warn + preserve
end tx2
The two-transaction model is deliberate: no DB connection is held
while calling FastAPI (which can take tens of seconds), and tx2's
attempt_id compare-and-set rejects stale callbacks (zero rows affected -> ErrTurnSuperseded). Plan §6 + §11.
State management
bot_sessions.state jsonb mirrors the agent's session state across
turns. On a successful turn:
- FastAPI
updated_session_state == null-> preserve prior state. The service does NOT callUpdateBotSessionState. updated_session_statenon-null ->StripCachedResultsremoves the top-levelcached_resultskey (a hash-keyed MAP, often the largest field). ThenSessionStateOversizechecks the result againstchatbot.StateMaxBytes = 64 * 1024. Under cap -> persist; over cap -> log warn and preserve prior state.
The canonical example of a session_state payload is
plans/chatbot.stuff/sample.response.json. It has 10 top-level keys:
turn, resolved_entities, last_scope, last_intent,
last_query_sql, last_query_plan, last_result_hash,
cached_results, last_scope_signature, validator_retry_count.
After strip, the persisted payload has the remaining 9 top-level keys.
Retry policy (FastAPI client, plan §7)
- Up to 3 attempts per HTTP call.
- Retry on HTTP 429, 500, 502, 503, 504, and transport timeouts.
- Do NOT retry other 4xx, malformed JSON, HTTP 200 with
status:"error", or HTTP 200 with empty/nullanswer.text. - Reuse the same
request_id({sessionID}:{ordinal}) on every attempt. The caller mints it once; the client must not regenerate per-attempt. - One parent context enforces total wall time. Per-attempt timeout =
cfg.RequestTimeoutSecondsviacontext.WithTimeoutderived from the parent. - Header literal:
X-API-Key: <cfg.APIKey verbatim>. Production values are JSON-encoded strings ({"AGENT_SERVICE_API_KEY":"value"}) per plan §11.
Error JSON shape
All bot_turns.error writes use the plan §6 canonical shape:
{
"code": "<sentinel-string>",
"message": "<human-readable>",
"attempt": <int>
}
The attempt field carries the FastAPI client's AttemptCount so
operators can distinguish single-failure from exhausted-retry scenarios.
Configuration reference
Five env vars, plan §8. Validated at startup via
chatbot.ChatbotConfig.Validate (called between
serviceconfig.InitializeConfig and cfg.InitializeAuthConfig).
| Variable | Default | Validation | Notes |
|---|---|---|---|
CHATBOT_SERVICE_URL |
(required, notEmpty) | env-loader rejects empty | FastAPI base URL. |
CHATBOT_API_KEY |
(required, notEmpty) | env-loader rejects empty | Sent verbatim as X-API-Key header. |
CHATBOT_REQUEST_TIMEOUT_SECONDS |
60 | >= 1 |
Per-attempt FastAPI timeout. |
CHATBOT_MAX_TURN_CHARS |
2000 | 1 <= x <= 65536 |
Caps prompt and answer.text rune count. Upper bound (chatbot.MaxTurnCharsCeiling) mirrors the OpenAPI prompt.maxLength. |
CHATBOT_RECENT_TURNS |
5 | 1 <= x <= 5 |
Conversation-history length and session-list preview cap. |
Derived values (constants/helpers, not env vars):
chatbot.StateMaxBytes = 64 * 1024— session-state cap (plan §8).chatbot.MaxTurnCharsCeiling = 65536— upper bound forCHATBOT_MAX_TURN_CHARS, locked to the OpenAPIBotTurnAddRequest.prompt.maxLength. Validate() rejects any value above this.chatbot.ChatbotConfig.InflightGraceSeconds() = 2 * RequestTimeoutSecondsper plan §8 literal. The service useseffectiveGraceSeconds(cfg) = max(2 * RequestTimeoutSeconds, 60)as the actual abandonment threshold to keep smallRequestTimeoutSecondsin test fixtures from prematurely abandoning legitimately in-flight turns. ProductionRequestTimeoutSeconds=60already exceeds the 60-second floor.
API reference
User routes (/client/{clientId}/...)
All routes are singular /client/... per plan §3 so Permit.io's
existing client_user policy applies.
| Method | Path | OperationId | Description |
|---|---|---|---|
| POST | /client/{clientId}/bot/sessions |
createBotSession |
Create a new owner-scoped session. |
| GET | /client/{clientId}/bot/sessions |
listBotSessions |
List the caller's sessions; bounds: limit in [1,200], offset >= 0. |
| GET | /client/{clientId}/bot/sessions/{sessionId} |
getBotSession |
Read one owner-scoped session. |
| PATCH | /client/{clientId}/bot/sessions/{sessionId}/scope |
patchBotSessionScope |
Add/remove documents and folders from session scope. |
| POST | /client/{clientId}/bot/sessions/{sessionId}/turns |
createBotTurn |
Submit a new turn. Plan §6 algorithm. |
| GET | /client/{clientId}/bot/sessions/{sessionId}/turns |
listBotTurns |
List all turns for an owner-scoped session, ASC. |
| GET | /client/{clientId}/documents/{documentId}/schema |
getDocumentSchema |
Return the document's bound JSON Schema or null (omitted in JSON when unbound). |
| GET | /client/{clientId}/documents/{documentId}/all-metadata |
getDocumentAllMetadata |
Bundle of (document, labels, customMetadata) for one doc. |
Super-admin routes
| Method | Path | OperationId | Description |
|---|---|---|---|
| GET | /super-admin/bot/sessions?clientId=... |
listBotSessionsAsSuperAdmin |
List all sessions for a client (cross-user). |
| GET | /super-admin/bot/sessions/{sessionId}?clientId=... |
getBotSessionAsSuperAdmin |
Read one session by id (cross-user). |
| DELETE | /super-admin/bot/sessions/{sessionId}?clientId=... |
deleteBotSessionAsSuperAdmin |
Soft-delete; transitions any in-flight turns to session_deleted. |
BotSessionResponse.lastTurn
lastTurn returns last_terminal_ordinal (plan §4 derived note), NOT
last_seen_ordinal. The latter is computed from bot_turns but used
only internally by AddTurn for ordinal classification. Clients should
display lastTurn as "the last completed turn this session has".
DocumentSchemaResponse.schema
When the document has no custom_schema_id binding, the response is
HTTP 200 with the schema field omitted (the field is *map[...] with
omitempty, so JSON does NOT emit "schema": null; the key is
absent). Clients should treat both "key absent" and "key present and
null" as the unbound case.
BotLabelRecord vs LabelRecord
The all-metadata endpoint uses BotLabelRecord (plain-string
appliedBy) instead of the existing LabelRecord (email-validated
appliedBy). Both shapes wrap the same documentLabels.appliedBy varchar column; the divergence exists because the email-regex Marshal
validator on LabelRecord rejects non-email seed values that pre-date
the email-typing convention. Long-term cleanup is to relax
LabelRecord.AppliedBy to plain string. Until then, the chatbot
endpoints emit BotLabelRecord.
HTTP status mapping
| Status | Sentinels / cases |
|---|---|
| 400 | prompt_empty, prompt_too_long, add_remove_conflict, document_cross_client, folder_cross_client, clientId is required (super-admin), invalid limit/offset |
| 401 | Missing Cognito subject |
| 404 | Cross-client / cross-user / soft-deleted session; document not found in client scope |
| 409 | turn_in_flight, prior_turn_in_flight, already_complete, turn_session_deleted, ordinal_out_of_range, turn_superseded (legacy: prompt_mismatch — service-layer assertion only; not reachable from the wire as of 2026-05-07) |
| 410 | session_deleted_during_call |
| 502 | agent_application_error, agent_missing_answer, agent_invalid_response, agent_transport_error |
| 500 | Unexpected backend errors |
mapBotTurnError dispatch order matters because ErrAlreadyComplete
chains to ErrOrdinalOutOfRange via a custom Is() method. The
sentinels are matched in declaration order; ErrAlreadyComplete must
be checked before ErrOrdinalOutOfRange so the message string
"already_complete" is returned in the HTTP retry-of-completed scenario.
The chain exists so plan §11's concurrency-test loss set
(ErrTurnInFlight | ErrPriorTurnInFlight | ErrOrdinalOutOfRange) covers
the case where a concurrent submitter's ordinal lands on the row the
winner just completed.
Test-pattern note
Test assertions on HTTP error message bodies use substring matches.
turn_in_flight matches both turn_in_flight and prior_turn_in_flight
(the latter contains the former). M4's TestAddTurn_SameOrdinalStillInflight_409
exploits this: the test name says "same ordinal", but the algorithm
returns prior_turn_in_flight because the natural target is last_seen+1
when has_inflight; the substring assertion still passes. Both 409s
have the same operator semantics ("can't submit while a turn is in
flight").
Operator runbook
Failed-turn classifications
When a bot_turns row has a non-completed status, operators consult
bot_turns.error for the canonical error JSON and pick the recovery
path below.
errored
The FastAPI agent returned a typed error or the response failed
validation. The error JSON includes code, message, and attempt.
Common codes:
| Code | Meaning | Recovery |
|---|---|---|
agent_application_error |
FastAPI returned 200 with status:"error". Code/message in the inner error field. |
Client may resubmit the same prompt at the same ordinal; the algorithm reset path runs. |
agent_missing_answer |
FastAPI returned 200 with empty/null/whitespace answer.text. |
Same as above. May indicate a prompt the agent cannot answer; client UX should suggest rephrasing. |
agent_invalid_response |
FastAPI returned 200 but body failed structural validation (malformed JSON or oversize completion). | Same as above. May indicate an upstream agent change; check upstream logs. |
agent_transport_error |
FastAPI exhausted its 3-attempt retry budget on transport-level failures (429/5xx/timeouts). | Same as above. May indicate FastAPI overload; check upstream health and rate-limit. |
To retry: client POSTs to the same /turns route with the same prompt.
The handler picks the errored row's ordinal via prompt-match;
AddTurn runs the existing-row reset path; a new attempt_id is
minted; tx2 records the new outcome.
If the client posts a different prompt instead of retrying, the
handler advances to last_terminal_ordinal+1 (a fresh in_flight at
ordinal N+1) rather than rejecting with 409 prompt_mismatch. The
errored row at ordinal N stays in history. See
plans/chatbot.stuff/fix.chat.bugs.1.md §3 Option A for the rationale.
abandoned
The grace window expired without a terminal status. Most often this
means the queryAPI process died mid-call and the next caller's
AbandonExpiredInflightForOwner swept the stale row. The error JSON
carries:
{"code":"turn_abandoned_grace_expired","message":"no terminal status reached within grace window"}
Recovery is identical to errored: client resubmits the same prompt.
The reset path applies.
session_deleted
A super-admin DELETEd the session while the turn was in flight, OR an older retry path hit the session-deleted-during-call branch. The error JSON carries:
{"code":"session_deleted_by_admin","message":"session deleted while turn in flight","attempt":<int>}
OR (from the AddTurn tx2 path):
{"code":"session_deleted_during_call","message":"session was soft-deleted while FastAPI call was in flight","attempt":<int>}
Recovery: NONE. The session is permanent. The client must create a new
session and resubmit. The historical session_deleted row remains for
audit; LIST turns surfaces it with the original prompt and the error
JSON.
Performance notes
decorateSessionList (the M2 list-sessions handler path) does N+1
scope reads: one loadScope round-trip per session in the list.
Default limit=20, hard cap limit=200. Under default load the
overhead is negligible; if a deployment surfaces latency on the list
endpoint, consider a single batch read of all scope rows for the
listed session ids. Plan v10 marked this as acceptable for v1.
Rate-limit overrides
RATE_LIMIT_ENDPOINT_OVERRIDES is a JSON object keyed by
METHOD /path using Echo's path template form (:clientId,
:sessionId), NOT the OpenAPI {clientId} form. The middleware uses
c.Path() which returns Echo's registered path string; an override
keyed by {clientId} will silently fail to match.
Example for the chatbot turn endpoint:
RATE_LIMIT_ENDPOINT_OVERRIDES='{"POST /client/:clientId/bot/sessions/:sessionId/turns":{"global_rate":50,"global_burst":100,"rate":2,"burst":4}}'
M6 operator-facing behavior change: setting an override here NOW
throttles the actual request rate (per-(IP, route) budget). Before M6,
overrides only affected the RateLimit and Retry-After headers; the
Allow() decision used the default rate/burst. Plan §10 M6 + plan §11.
References
- Plan:
plans/chatbot_plan_codex.v10.md(design rationale and authoritative spec). - FastAPI swagger:
plans/chatbot.stuff/openapi.chatbot.swagger.json. - Sample
session_statefixture:plans/chatbot.stuff/sample.response.json. - Cloud FastAPI deployment (dev): see plan §11 for the URL and the
literal
X-API-Keyvalue.
Known warts
These are not bugs; they are conscious tradeoffs documented for maintainers.
- CHECK guard clause in
bot_turns_status_fields_consistent. The plan §4 literal three-clause matrix is preserved verbatim; the trailingOR status NOT IN (...)is an evaluation-order guard sobot_turns_status_valuesowns the rejection of unknown enum values. ErrAlreadyCompletechain toErrOrdinalOutOfRangevia customIs()so plan §11's concurrency-test loss set covers the case where a loser's ordinal lands on the winner's just-completed row.BotLabelRecordvsLabelRecorddivergence (above).schema=nulluses field omission (omitempty) rather than explicit JSON null. Clients should accept both.M5DocumentIDalias is cosmetically distinct fromDocumentIDin the generated types. Both areopenapi_types.UUID. Cosmetic only.- Rate-limit override
:paramtemplate form, not OpenAPI{param}form (above). - N+1 scope reads in session list (above).
- Grace-window floor of 60s in
effectiveGraceSeconds; the plan §8 literal2 * RequestTimeoutSecondsis preserved on the chatbot config method, but the service applies the floor at the call site so test fixtures withRequestTimeoutSeconds=1(2s grace) do not prematurely abandon legitimately in-flight turns.