The LLM never writes the query: declarative search layer over sensitive records

alechash1 pts0 comments

The LLM never writes the query: a declarative search layer over sensitive records | Alec Jude Wilson

We have an internal assistant. One of the things it does is find people.

By people I mean records in a system of record — names, contacts, home addresses, current assignments, and other personal details that exist in exactly one place and actually matter. This is the most sensitive data we hold. Staff can search it by typing a request in plain language, such as “find translators in France who speak Spanish,” and getting an answer back.

The model handles the request itself without much trouble. This post is about what happens between the request and the answer.

The setup

First, some background on what the assistant is.

It’s an internal chat tool. A staff member opens it, types a request in plain language, and gets an answer. Under the hood it’s an LLM with a set of tools, which are small functions it’s allowed to call. The model has no direct connection to a database. When it needs data, it calls a tool, and the tool is the only code that touches a real record.

Finding people is one of those tools. When someone types “find translators in France who speak Spanish,” the model reads the request, works out the search criteria, and calls the person-search tool with them. The tool runs the query and returns the matches, and the model presents them.

So the model turns language into criteria, and the tool turns criteria into people. The rest of this post is about the tool: the interface it exposes and how it runs a query.

The model doesn’t get a query language

The obvious way to build this is to give the model a flexible search tool and let it improvise — hand it something query-shaped and let it filter however it likes.

For data like this, that’s a bad idea. The records are read-only to the assistant by construction; it can’t write to them, and the whole feature is gated behind a permission claim. But read-only access still leaves room to read more than you should. If the model improvises its own queries, it can request data in shapes nobody reviewed beforehand.

So instead of a query language, the model gets a fixed vocabulary. Every criterion it can express is a small declared object with a field, an operator, and a value:

{ "field": "departments", "operator": "current",<br>"value": { "departmentId": "…", "isManager": true } }

The model can’t invent a field or an operator. Whatever it sends is validated against a registry of things we marked as searchable, and that validation runs before any record is read. The model still works out which criteria a request needs, but it doesn’t control the shape of the search.

Not everyone sees the same person

There’s a second reason the model doesn’t get a query language, and it matters more than the first.

Not everyone who uses the assistant sees the same data. Permissions here aren’t a single yes-or-no on whether you can search people; they’re more granular. Two people can run the same search and get back different fields on the same person, because each record is authorized field by field against the claims of whoever is asking. Which details you can see depends on who you are.

A free-form query language has no good way to handle that. It lets any caller name any field, then relies on the backend to drop whatever they weren’t cleared for on every query. The model ends up reasoning in a vocabulary that may not be valid for the person it’s working for.

A declared registry handles it in one place. The fields are finite and known, so “what can this caller search?” has a definite answer. You give each user only the fields their claims allow, and the model can’t form a criterion for a field it was never given. Fields a user isn’t cleared for are never offered to the model, so they don’t have to be filtered out of the results afterward.

One definition, five jobs

The old version of this tool had two parallel systems bolted together. There were about sixteen typed parameters for the fast path, plus a stringly-typed JSON blob for everything else, with the filter logic reflected out of attributes at runtime. Adding one searchable field meant editing five files and making sure they stayed consistent. The two halves didn’t even agree on what “searchable” meant.

The rewrite replaces that with a single concept. A search field is defined once, and that one definition does five separate jobs.

SearchField.ObjectCollectionDepartmentAssignment>("departments")<br>.Operators(Current, Past, Future, Ever, Never)<br>.TemporalRange(d => d.StartDate, d => d.EndDate)<br>.Member("departmentId", …)<br>.Member("isManager", …)<br>.Phase1("departmentIds") // how it narrows the server query<br>.Selects("departments { … }"); // what it fetches back

That one block produces all five things: the description the model reads, the rules its input is validated against, how the criterion gets pushed into the upstream search, what data we fetch back, and how a match is decided. Adding a field means writing one of...

model search query field tool request

Related Articles