HR-ATS-Portal/docs/architecture/adr/0010-chatbot-controlled-que...

27 KiB
Raw Blame History

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 12 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_event with actor_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:

  1. 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_parsing and scoring are separate modules from assistant in the dependency graph.
  2. Document text never enters the tool-selection context. Intent classification sees the question and screen context only.
  3. match_snippet is 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 ~12 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

  • _decisions.md Part 1 — ai_orchestration as the only holder of a model client; the assistant module; assistant phased 2 read-only / 4 full.
  • _decisions.md Part 2 — "Chatbot and AI query isolation": no SQL access in Phase 1, ats_ai_reader with RLS as the Phase 2 path, on_behalf_of_user_id on 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.