27 KiB
ADR 0010 — Chatbot controlled-query architecture: allowlisted typed tool intents, no generated SQL
Status: Accepted — 2026-07-29
Scope: How the conversational assistant reaches data. Covers the Phase 2 read-only surface and the Phase 4 drafting tools. Does not cover prompt/model versioning (ADR 0011) or the authorization model itself (ADR 0009).
Deciders: Talha Ahmed (the tool registry, intent classification, grounding checks, the injection defences). Ahmed Mujtaba (individual tool implementations against existing facades with their three-case test pattern, the citation-link UI, the refusal and truncation copy, the provenance footer) — each independently demonstrable, each with a Talha review checkpoint.
Context
What the repository contains
js/aiassistant.js is 218 lines that render static markup. There are zero network calls
anywhere in the repository — grep for fetch(, XMLHttpRequest, axios, $.ajax,
WebSocket, EventSource across js/ and index.html returns nothing (findings §C). There
is no model client, no prompt, no tool definition, no retrieval layer. The assistant is a
mockup of a chat dock.
That matters twice over. First, there is nothing to preserve — this is a greenfield design. Second, the mockup already exists in the UI across 23 routes, which creates stakeholder expectation that a working assistant is nearly done. It is not: 15 AI capabilities are visible as interface preview and only AI-1/2/3 are Phase 1 work.
The non-negotiable
The chatbot must never bypass access controls. The repository has no access controls to
bypass yet — the RBAC matrix is a display widget with no can() anywhere
(js/rbac.js:78, js/rbac.js:111-112) — so the assistant and the authorization layer are
being built in the same programme. That is an opportunity: the assistant can be designed to
have no capability the UI does not have, from the first line.
Why the permission model makes generated SQL untenable
ADR 0009 pins an authorization model with eight scope dimensions, three of which resolve
through tstzrange interval tables evaluated per granting assignment, plus a sensitive-field
conjunction that lives in the serialisation layer, plus row-state gates for soft-deleted,
merged and retention-pseudonymised rows. A generated SQL statement would have to reproduce all
of that, correctly, for every phrasing of every question, forever.
Additional context: the input is adversarial by design
Candidate CVs are attacker-supplied and their extracted text is deliberately stored unsanitised so the original is recoverable (Part 2 risk list). A candidate has direct motive, direct control of the input, and the injection technique is public and free. Any design where document text can reach an instruction position or a tool-selection context is a design where a CV can drive the assistant.
Options considered
Option A — Text-to-SQL against a read-only application role
The model writes SQL; the application executes it read-only and formats the result.
| Pros | Cons |
|---|---|
| Unbounded question coverage. This is a genuine advantage and the reason the pattern is popular: no question has to be anticipated, and the assistant answers things nobody designed for. Option C cannot do that | The permission model is not expressible in the generated query. Scope resolution across role_assignment, job_assignment, job_application_assignment, interview_participant and access_grant, per granting assignment, will not be reproduced correctly for every phrasing |
| Zero per-question engineering cost; new reporting needs are answered the day they are asked | A prompt is not a boundary. "Only query candidates assigned to this user" is an instruction, and instructions are overridable by the input — including by text inside a CV that the query results themselves return. The guard and the attack surface are the same channel |
| Extremely fast to demo, which is exactly why it gets adopted | The failure mode is silent and total. A dropped WHERE or a wrong JOIN returns more data, plausibly formatted. Nothing errors. The user sees a confident answer containing 400 candidates they should not see |
Defeats the sensitive-field policy entirely — it lives in the serialiser, and SELECT * bypasses it. Salary expectations, scorecards and offer amounts lose all protection |
|
Availability risk even read-only: cartesian joins, unbounded scans, pg_sleep, on the same instance serving the transactional workload |
|
Unauditable in the way compliance needs. Logging SQL records that a query ran, not what was disclosed under whose authority. audit_event needs entity type, entity ids and fields |
Option B — Text-to-SQL against a dedicated role with RLS and column privileges
Same mechanism, but under ats_ai_reader with RLS keyed to current_setting('app.actor_user_id')
and column privileges excluding every sensitive_personal column.
| Pros | Cons |
|---|---|
| The access boundary is enforced by the database, not by prompt engineering. This is a real answer to the first four objections above and is why the option is deferred rather than dismissed | Requires the RLS helper functions mirroring scopes_for to be correct — a second authorization implementation, which is the divergence ADR 0009 is built to avoid |
Column privileges give a hard field boundary that survives SELECT * |
Inherits the SET LOCAL-on-a-pooled-connection fragility ADR 0009 rejects for Phase 1 |
| Keeps most of Option A's coverage advantage | Does nothing about the availability risk or the auditability gap: pathological SQL is still pathological, and "a query ran" is still not "these entities were disclosed" |
| A defensible Phase 2 posture once the authorization layer is proven | Adopting it in Phase 2 requires that the RLS work land first, so it is not a shortcut |
Option C — Fixed allowlist of typed tool intents over the existing service facades (chosen)
Intent classification maps a question to one of N approved tools. Each tool is a typed, parameter-validated call into the same module service facade the REST API uses.
| Pros | Cons |
|---|---|
Exactly one authorization implementation — identity.can() and identity.scope(), the same code the UI runs (ADR 0009 checkpoint 3) |
Bounded coverage. The assistant can only answer questions someone designed a tool for. Users will ask reasonable things and be refused, and that refusal will read as the product being stupid |
| Field allowlists intersect with the sensitive-field policy, so a tool cannot return more than the UI would | Each new question class is a code change: tool definition, permission mapping, field allowlist, audit event, tests. Roughly 1–2 developer-days per tool |
| Structured audit is natural: tool name, version, parameters, entity ids, row count | Two model calls per turn (classify, then assemble), so latency is higher than a single-shot design |
| No pathological query is possible: every predicate is ours, every parameter is enumerated or bounded, every result set is capped | The allowlist is a maintenance surface that grows |
| A successful prompt injection in a scoring or parsing call has nothing to call, because those capabilities are invoked with no tools at all | Cross-entity analytical questions ("who did we reject in 2024 and later hired elsewhere") need either a tool or a report, not conversation |
Option D — RAG over a pre-built index of candidate documents and records
Embed candidate documents and records into a vector index; retrieve top-k; answer from retrieved chunks.
| Pros | Cons |
|---|---|
| Handles genuinely fuzzy semantic questions over CV prose better than any structured tool | The index is a second copy of the data with its own access model. Filtering retrieved chunks post-hoc is the standard mistake — the first filter bug is a disclosure, and it fails exactly the way Option A fails |
Cheap to prototype; pgvector is already in the Phase 2 plan for hybrid search |
Answers are chunk-grounded, not entity-grounded, so public_id citation and per-row state gates (soft-deleted, merged, purged) are hard to enforce |
| Would compose with Option C as a retrieval step inside a tool | Retention and erasure obligations extend to derived embeddings; a pseudonymised candidate must not remain answerable through a stale index |
| Chunks of CV text in the answer-assembly context is precisely the injection path we are trying to close |
Not rejected as a technique — pgvector hybrid retrieval is planned inside the
search_candidates tool. Rejected as the access architecture.
Option E — No conversational surface; canned parameterised reports only
Drop the assistant; ship a report library and saved segments.
| Pros | Cons |
|---|---|
| Zero new attack surface, zero new authorization path, zero model cost | The assistant is an explicit product requirement, and the mockup is already in front of stakeholders |
Everything it would answer is answerable from analytics read models |
Loses the genuine value: a recruiter asking "which of my pipelines are stalled" in one sentence instead of navigating three screens |
| Cheapest option by a wide margin | Ignores that a natural-language layer over fixed intents (Option C) is only marginally more expensive than the report library itself |
Decision
The assistant has no SQL access. Ever, in any phase, under any role that can read candidate data. It calls a fixed, versioned allowlist of typed tool intents, each of which calls the same authorization-checked module service facade the REST API calls, with the human actor. Text-to-SQL against any application role is prohibited.
Nine mandatory properties
| # | Property | Detail |
|---|---|---|
| 1 | Intent classification before any data access | A model call with no tools and no data maps the question to an approved intent or to "cannot answer". Classification failure is a refusal with a suggestion — never a fallback to free querying |
| 2 | Approved tools only | A fixed, versioned allowlist. A tool not on the list does not exist. Adding one is a code change plus a permission mapping, a field allowlist, an audit-event definition and a review |
| 3 | Typed, validated, enumerated parameters | No free-text parameter ever reaches a database predicate. Filters are enumerated reference values (a ref.pipeline_stage key, a department id) or bounded (date span ≤ 180 days, limit ≤ the tool's cap). Free text is permitted only into the FTS/trigram path, and even there it is a parameterised query |
| 4 | Permission-aware query service | Every tool calls the module facade with the human actor: identity.can() for capability, identity.scope() for rows. One authorization implementation (ADR 0009) |
| 5 | Field allowlist per tool | Declared per tool and intersected with that actor's sensitive-field policy, so a tool is a ceiling, not a grant |
| 6 | Entity-level checks on every returned row | A row that survives the scope filter but fails a row-state gate — soft-deleted, merged, retention-purged — is dropped |
| 7 | Read-only first | Phase 2 ships read tools only. Phase 4 adds two drafting tools that produce text and write nothing. No tool ever transitions state, sends a message or creates a record |
| 8 | Record limits and grounding | Hard cap per tool. Every claim cites public_ids, rendered as links the user opens through the normal permission path — so a citation the user cannot open is a caught bug. Facts absent from the tool payload may not appear in the answer; an ungrounded-claim check runs against the payload |
| 9 | Audit every turn | audit_event with actor_kind='ai_agent', on_behalf_of_user_id = the asking user, tool name and version, parameters, entity ids returned, row count. AI-mediated access is a delegated action, not an anonymous system read |
Trust boundaries in one view
flowchart LR
subgraph UNTRUSTED["Untrusted input"]
Q["User question"]
CVTEXT["Candidate document text<br/>(stored unsanitised by design)"]
end
subgraph MODEL["Model calls — no data, no tools"]
CLS["Intent classification"]
ASM["Answer assembly<br/>payload is DATA, never instructions"]
end
subgraph OURCODE["Our code — the only place decisions happen"]
VAL["Typed parameter validation<br/>enumerated values only"]
TOOL["Approved tool<br/>fixed allowlist"]
IDENT["identity.can + identity.scope<br/>with the HUMAN actor"]
FACADE["Module service facade<br/>same code path as the REST API"]
GATE["Row-state gate + field allowlist<br/>INTERSECT sensitive-field policy"]
CHK["Ungrounded-claim check"]
AUD["audit_event<br/>actor_kind=ai_agent<br/>on_behalf_of_user_id=asker"]
end
Q --> CLS
CLS --> VAL
VAL --> TOOL
TOOL --> IDENT
IDENT --> FACADE
FACADE --> GATE
GATE --> ASM
CVTEXT -->|"escaped, length-capped,<br/>never in tool-selection context"| GATE
ASM --> CHK
CHK --> AUD
AUD --> OUT["Streamed answer + public_id citations"]
The shape of that diagram is the decision: no arrow runs from a model call to the database.
The tool allowlist
Read tools, Phase 2. [scope] means the standard scope filter for that entity applies. The
"returned fields" column is a ceiling, intersected with the caller's field policy.
| Tool | Required permission | Cap | Notably excluded |
|---|---|---|---|
search_candidates |
candidate.view [scope] |
25 | email, phone, links, salary expectations, documents, scorecards, offers, ATS score |
get_candidate_summary |
candidate.view on that candidate |
1 candidate / 10 applications | contact details unless the caller has full contact access; salary expectations; document blobs; other users' scorecards; applications outside the caller's scope |
compare_selected_applications |
application.view on every id supplied |
5 | salary expectations, contact details, offer amounts, scorecard free-text notes. Answer carries the advisory disclaimer and must not state or imply a recommendation to reject |
list_upcoming_interviews |
interview.view [scope] |
50, span ≤ 60 days | scorecard content, candidate contact details, meeting join links |
list_pending_offers |
offer.view [scope] |
25 | all monetary amounts and components by default; a band only if the caller's field policy grants it |
list_overdue_jobs |
requisition.view [scope] |
50 | salary range unless granted; candidate identities (counts only) |
search_talent_pool |
talent_pool.view + candidate.view [scope] |
25 | as search_candidates, plus prior rejection reasons — a rejection reason resurfaced out of context is both prejudicial and often personal |
get_department_recruitment_status |
analytics.view [scope] |
20 groups, minimum cell size 5 | every per-candidate field; recruiter-attributable metrics unless the caller has analytics.view [D]+ over that recruiter |
Drafting tools, Phase 4. Both produce inert text.
| Tool | Required permission | Confirmation |
|---|---|---|
draft_job_description |
requisition.create or requisition.edit [scope] |
Draft returned to the composer. No candidate data of any kind enters this tool's context. Saving is a normal requisition write by the human |
draft_candidate_response |
application.edit on that application and notifications.create |
Mandatory human confirmation before send, always. For intent = decline_after_interview the application must already carry a human-recorded terminal-negative decision — the assistant can draft the wording of a rejection, never be the thing that decides it |
Two rules over the whole table:
- Every answer carries a provenance footer: tools invoked, record counts, whether results were truncated, model version. A truncated answer that does not say so is a wrong answer.
- Phase 4 writes execute as the human, through the normal facade, hitting the normal
guards, writing a normal
audit_eventwithactor_kind='user'plus the originating invocation id. There is never a path where a write's actor is the assistant.
Prompt-injection posture specific to this surface
The full eight-layer defence is in 05-security-rbac-ai-governance.md §6.5. The three layers
that are this ADR's responsibility:
- Tool-less invocation for anything that touches document text. Scoring, extraction and
summarisation run with no tools and no data access. A fully successful injection has nothing
to call — it cannot read another candidate, cannot query, cannot write, cannot reach the
network. This is the single most effective layer, and it is why
document_parsingandscoringare separate modules fromassistantin the dependency graph. - Document text never enters the tool-selection context. Intent classification sees the question and screen context only.
match_snippetis escaped and length-capped before it enters an answer, because a snippet is candidate-controlled text arriving through a legitimate channel.
Phase 2 escalation, conditionally
If Phase 2 concludes that ad-hoc querying is genuinely required (see the revisit conditions),
it runs under the dedicated ats_ai_reader role with RLS keyed to
current_setting('app.actor_user_id') and column privileges excluding every
sensitive_personal column — as defence in depth layered on top of the tool architecture,
never as a replacement for it.
Justification
The decision rests on one asymmetry: the cost of Option C is refusals, and the cost of Options A/B/D is silent over-disclosure. A refusal is visible, annoying and fixable in a day. A silent over-disclosure of candidate data is invisible, unbounded and — in an HR system holding compensation, scorecards and CV text across six jurisdictions — not recoverable by apology.
Secondary reasons, in order:
- One authorization implementation. Every alternative that gives the model query freedom requires the permission model to be re-expressed somewhere the model can reach. ADR 0009's whole premise is that two expressions of one policy diverge.
- Audit that means something. Compliance needs "which entities were disclosed to whom, under what authority, at what time". Only a structured tool call produces that.
- Injection containment is structural. Tool-less invocation for document-facing capabilities is not a mitigation that can be forgotten in a prompt edit; it is the absence of a capability.
- It is buildable by two people. Each tool is a small, independently demonstrable unit with a clear test: one authorised case, one out-of-scope case asserting the row is absent, one field-policy case asserting the column is absent. That is a good junior workstream with a senior review checkpoint.
Consequences
Positive
- The chatbot constraint is discharged structurally, not by policy text.
- Every assistant answer is attributable: which tool, which parameters, which entities, whose authority, which model version.
- A citation the user cannot open is a self-reporting bug, which turns the grounding requirement into a test rather than an aspiration.
- Assistant availability failures degrade to "the assistant is unavailable" with the rest of the platform unaffected — the circuit-breaker posture from the degradation ladder.
- Adding a tool is a small, reviewable, testable diff; the surface grows in units the team can estimate.
Negative — the costs being accepted
| Cost | Detail |
|---|---|
| A hard coverage ceiling | Eight read tools answer eight classes of question. Recruiters will ask things that seem obvious and be refused, and they will read that as the assistant being useless. This is the central cost and it should be set as an expectation with the Talent Lead before Phase 2 ships, not discovered in a demo |
| Per-question engineering cost | ~1–2 developer-days per tool including permission mapping, field allowlist, audit definition and tests. The assistant grows at that rate, not at the rate of user imagination |
| Two model calls per turn | Classification then assembly. Higher latency and roughly double the token cost of a single-shot design |
| Refusal quality is now a product surface | "Cannot answer" with a useful suggestion is a design problem the team has to own; a bare refusal makes the ceiling feel arbitrary |
| Classification errors are plausible, not loud | A question mapped to the wrong-but-valid tool returns a correct answer to a question nobody asked. Users may not notice |
| Audit volume | One row per turn plus access events. Real growth on an already-partitioned table; sizing must include it |
| Analytical questions have no home in conversation | Cross-entity historical analysis goes to the analytics report library, and users will not intuit that boundary |
Risks
| # | Risk | Likelihood | Mitigation |
|---|---|---|---|
| R1 | Refusal fatigue produces pressure to "just let it query the database" — the single most likely way this decision is reversed, and it will be framed as pragmatism | High | The refusal log is reviewed monthly and drives the tool backlog, so the pressure has an approved outlet. V1 below is the only sanctioned escalation path, and it goes through RLS, not through the application role |
| R2 | Intent misclassification into a valid tool with plausible output | Medium-high | Classification confidence threshold with an explicit "did you mean" clarification turn; the provenance footer names the tool used so a user can see the misread; misclassification rate tracked per intent |
| R3 | Injection via match_snippet or a summary field reaching the assembly context |
Medium | Escaped and length-capped; assembly context labels payload as data; adversarial regression suite is a blocking CI gate on prompt and model changes (ADR 0011) |
| R4 | Ungrounded-claim check is weaker than it sounds — a fluent answer can restate payload facts with wrong emphasis and still pass | Medium | Checks entity claims against payload public_ids and numeric claims against payload values; flagged turns go to review; this is stated as a detection layer, not a guarantee |
| R5 | compare_selected_applications drifts toward de facto decision-making — recruiters treating a comparison as a recommendation |
Medium-high | The tool must not state or imply a rejection recommendation; the advisory disclaimer is mandatory in the answer; application.transition() refuses terminal-negative transitions unless actor_kind='user' regardless |
| R6 | draft_candidate_response sends a wrong or duplicate message |
Low, high impact | Mandatory human confirmation; send is notifications.send_email() by the human; the outbound idempotency guard (sent_at IS NULL + outbound:{public_id}) is what prevents a duplicate rejection email, the most reputationally damaging duplicate this system can produce |
| R7 | Token cost growth as usage rises, with two calls per turn | Medium | Per-user rate limits; ai_cost_total{capability} metric; alert on daily spend > 2× the 7-day mean |
| R8 | The tool allowlist becomes a de facto reporting API maintained by conversation-shaped accident | Medium | A tool that duplicates an analytics read model must call that read model, not re-derive it; tool count is reviewed against V2 below |
| R9 | Stakeholder expectation from the existing mockup — 15 capabilities are visible in the UI today | High | AI Studio must show true per-capability availability rather than a coming-soon grid, or the team is judged against js/aiassistant.js |
Revisit conditions
| # | Trigger | Threshold | What changes |
|---|---|---|---|
| V1 | Genuine, evidenced coverage gap | Over a rolling 4-week window with ≥ 200 turns: > 30% of turns end in "cannot answer" and a review of the refusal log identifies > 10 distinct legitimate intents not covered by an existing or backlogged tool and ≥ 3 named users have escalated | Evaluate constrained ad-hoc querying under ats_ai_reader with RLS (ADR 0009 V3) as a layer on top of the tool architecture. Precondition: RLS helper functions mirroring scopes_for, with their own test suite, must exist and pass before the first query |
| V2 | Allowlist maintenance cost | Tool count > 15, or > 2 tools added per month for 3 consecutive months | Replace hand-written tools with a typed, permission-aware query DSL that compiles to identity.scope()-filtered querysets — a narrower capability than SQL, generated by us, not by the model |
| V3 | Any confirmed out-of-scope disclosure in an assistant answer | One occurrence | Suspend the assistant surface immediately. Root-cause before re-enabling. If the cause was the tool architecture rather than a scope bug, this ADR is void and the decision reopens from scratch |
| V4 | Latency | Assistant time-to-first-token p95 > 4 s, or classification adding > 1.5 s p95 | Collapse classification and assembly into one constrained call with the tool schema supplied as structured output, keeping every property in the nine above. Do not solve latency by removing the classification step's data isolation |
| V5 | Cost | Monthly assistant model spend exceeds the monthly cost of the web + worker revisions combined (ADR 0012 sizing) |
Cache classification for repeated phrasings; reduce assembly context; consider a smaller model for classification only, versioned per ADR 0011 |
| V6 | Injection detection rate rises | injection_signal positive on > 2% of parsed documents over 30 days, or any injection payload reaching an answer |
Escalate the adversarial suite, and re-audit every path where document text can reach an assembly context |
| V7 | Write tools requested beyond the two drafting tools | Any request for a tool that transitions state, sends a message, or creates a record without human confirmation | Refused at the design level. "AI must never auto-reject" and the human-confirmation pattern are constraints, not defaults; a change requires an explicit, documented business exception and a new ADR |
| V8 | Semantic search demand inside a tool | Recruiter feedback that keyword search misses obvious matches, at a measured miss rate over a labelled query set | Add pgvector hybrid retrieval inside search_candidates — FTS/trigram candidate generation, vector rerank, same scope filter, same field allowlist. This is a retrieval upgrade, not an access-architecture change |
| V9 | special_category data enters scope |
Any decision to store diversity, health or accommodation data | Every tool's field allowlist is re-derived, and aggregate-only access with a raised minimum cell size becomes mandatory before the assistant may touch the affected entities |
Related
_decisions.mdPart 1 —ai_orchestrationas the only holder of a model client; the assistant module;assistantphased 2 read-only / 4 full._decisions.mdPart 2 — "Chatbot and AI query isolation": no SQL access in Phase 1,ats_ai_readerwith RLS as the Phase 2 path,on_behalf_of_user_idon every answer.05-security-rbac-ai-governance.md§6 — the six reasons an LLM must never execute generated SQL, the full per-tool specification, and the eight prompt-injection layers.- ADR 0009 — the authorization model this decision depends on entirely.
- ADR 0011 — model and prompt versioning, the circuit breaker, and the adversarial CI suite.