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.
- Translate — an LLM turns the question into a structured filter: transaction
type,fromDate,toDate. - Retrieve — an ordinary repository query runs that filter. No LLM involved.
- 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.
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 / action | Result |
|---|---|
| "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 account | HTTP 404 — isolated |
| empty question | HTTP 400 — rejected |
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.
| Language | Commit | Notable |
|---|---|---|
| nestjs | 708c815 | reference; live-verified against real Ollama |
| Go | 4814b19 | first push failed CI (stale OpenAPI docs) — self-diagnosed, fixed in a follow-up commit |
| Java Spring Boot | fa209cd | exposed the Ollama HTTP client as a bean, unlike the earlier classifier — made both new services independently mockable |
| Kotlin Spring Boot | 77d0f9f | hit a real Kotlin compile error (two files redeclaring identically-named private top-level classes) — found and fixed by nesting them |
| FastAPI | aca6a3a | no 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.
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