LLM · Architecture

Narrow What,
Never Who

AskTransactionHistoryQuery answers a free-text question about an account's own transaction history. The filter an LLM produces can only narrow what comes back — who it belongs to is wired in before the model's output ever enters the call.

The previous post removed an LLM feature that let a model's read of user-controlled text influence a security-relevant judgment, and left behind one rule: an LLM may narrow what an authorized user sees, but must never decide who is authorized. This is what building on that rule, instead of just avoiding its violation, looks like.

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 — "How much did I deposit this month?" — answered through a structured-data RAG pipeline. "Structured-data" because Retrieve here is a real 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 actually 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 Actually Lives

TransactionFilter has no ownerId field. Not "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 actually run it. This one was tested against a real, locally-running Ollama instance — deposits of 50,000 and 10,000 KRW, a withdrawal of 3,000, then real 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 exactly that rather than quietly 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.

LanguageCommitNotable
nestjs708c815reference; live-verified against real Ollama
Go4814b19first push failed CI (stale OpenAPI docs) — self-diagnosed, fixed in a follow-up commit
Java Spring Bootfa209cdexposed the Ollama HTTP client as a bean, unlike the earlier classifier — made both new services independently mockable
Kotlin Spring Boot77d0f9fhit a real Kotlin compile error (two files redeclaring identically-named private top-level classes) — found and fixed by nesting them
FastAPIaca6a3ano per-language architecture doc for this pattern existed yet — 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 real 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 actually 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 an earlier feature · docs/architecture/domain-service.md — the full write-up, with real code from the reference implementation