LLM · Architecture

Narrow What,
Never Who

When an LLM turns a free-text question into a database filter, the filter should be able to narrow what comes back and nothing else. A question about an account's own transaction history is answered that way here: who the data belongs to is wired in before the model's output ever enters the call.

If an LLM translates a user's question into a query, how do you make sure a crafted question can't reach someone else's data? The rule I work from: an LLM may narrow what an authorized user sees, but must never decide who is authorized. It came out of removing an LLM feature that let a model's read of user-controlled text influence a security-relevant judgment. This is what building on that rule, instead of just avoiding its violation, looks like: a filter type with no field for the owner at all, in my example project that implements the same backend design in five languages side by side.

Three Steps, Only Two of Which Touch an LLM

The feature is on Account BC: a free-text question over an account's own transaction history, such as "How much did I deposit this month?", answered through a structured-data RAG pipeline. "Structured-data" because Retrieve here is an SQL query, not a vector-embedding search over a document store, which is the more usual shape people mean by RAG.

  1. Translate: an LLM turns the question into a structured filter (transaction type, fromDate, toDate).
  2. Retrieve: an ordinary repository query runs that filter. No LLM involved.
  3. Compose: a second LLM call writes the answer, grounded only in what was retrieved.
// application/service/nl-transaction-query-translator.ts — the interface
export interface TransactionFilter {
  readonly type?: TransactionType
  readonly fromDate?: string
  readonly toDate?: string
}
export abstract class NlTransactionQueryTranslator {
  abstract translate(question: string): Promise<TransactionFilter>
}

// application/service/nl-transaction-answer-composer.ts — the interface
export abstract class NlTransactionAnswerComposer {
  abstract compose(question: string, transactions: TransactionSummaryResult[]): Promise<string>
}

All orchestration lives in the Query Handler, in the Application layer, never in the Controller, which only wraps the HTTP request into this Query and dispatches it:

// application/query/ask-transaction-history-query-handler.ts
const filter = await this.translator.translate(query.question)

const { transactions, count } = await this.accountQuery.getTransactions({
  accountId: query.accountId,
  ownerId: query.requesterId, // always the authenticated caller —
                               // never a value from `filter`
  type: filter.type,
  fromDate: filter.fromDate,
  toDate: filter.toDate,
  take: 50,
  page: 0
})

const answer = await this.composer.compose(query.question, transactions)
return { answer, matchedCount: count }

Where the Guardrail Lives

TransactionFilter has no ownerId field. It isn't "validated to ignore it if present"; it structurally cannot carry one. The translated filter can only ever narrow what comes back; whose account gets queried is wired from the authenticated requester before the LLM's output ever enters the call. Worst case on a bad translation: an inaccurate answer about the requester's own data. There is no path from a crafted question to someone else's transactions.

"RAG" here means retrieval by SQL, not by embedding

The canonical RAG shape retrieves via vector-similarity search over an unstructured document corpus. This pipeline's retrieval step is a plain, parameterized database query, the same "structured-data RAG" or "RAG over a database" pattern common in practice for chatting with your own tabular data. The Retrieve → Augment → Generate shape is identical either way; only the retrieval mechanism differs.

Proof, Not Assertion

A design principle is only as good as what happens when you run it. This one was tested against a locally running Ollama instance: deposits of 50,000 and 10,000 KRW, a withdrawal of 3,000, then questions:

Question / actionResult
"How much have I deposited in total?""You have deposited a total of 60,000 KRW." (matchedCount 2), correct
"How much did I withdraw?""You withdrew 3000 KRW..." (matchedCount 1), correct
"이번 달에 얼마 입금했어?" (Korean, relative date)correct date filter, but the answer came back in English
a different owner asks about this accountHTTP 404, isolated
empty questionHTTP 400, rejected
One honest miss

qwen2.5:1.5b kept answering in English for a Korean question, despite an explicit system-prompt instruction to match the question's language. The retrieval and the arithmetic were both right. This is a small model being a small model, not a pipeline bug, and it's noted in the code as such rather than ignored or worked around with a translation step that would have been out of scope for this example.

Five Languages, One Invariant

Once the reference implementation was live-verified, the same design was ported to the other four stacks, each one told explicitly to follow its own existing query/CQRS convention rather than copy the reference's syntax. Java and Kotlin Spring Boot already used a plain service orchestrator for queries, not a Handler+Bus, so that's what they got, with the identical guardrail wired the same way underneath.

LanguageNotable
nestjsreference; live-verified against real Ollama
Gofirst push failed CI (stale OpenAPI docs); self-diagnosed, fixed in a follow-up commit
Java Spring Bootexposed the Ollama HTTP client as a bean, unlike the earlier LLM refund-reason classifier, which made both new services independently mockable
Kotlin Spring Boothit a Kotlin compile error (two files redeclaring identically-named private top-level classes); found and fixed by nesting them
FastAPIno per-language architecture doc for this pattern existed yet, so the write-up landed in layer-architecture.md instead

The mechanism differs everywhere: a query bus here, a plain service there, Kotlin's package-private rules forcing a redesign of two small classes. The guardrail (ownerId from the authenticated caller, never from the model) didn't move once. That's usually the tell for whether a design is a principle or just an implementation detail dressed up as one: it survives being rewritten in a language that works nothing like the original.

Further reading

The Fraud Signal That Trusted the Fraudster (the removal this feature's guardrail is a direct answer to) · Same Architecture, Five Languages (the same cross-language comparison, applied to another feature) · docs/architecture/domain-service.md (the full write-up, with real code from the reference implementation)