The query you wrote is not the query we ran
Letting an agent send raw filters to a database shared by 419 tenants, and the three refusals that make it survivable.
This morning I asked our platform for a page record belonging to a tenant called acme-corp. Here is what I sent, and what the database actually received:
{ "name": "blog", "domain": "acme-corp" }
// what executed
{ "name": "blog", "domain": "www" }
No error. No warning. The domain value I supplied was overwritten with the one I am actually entitled to, the query ran against my own tenant, and the response handed me back the filter that had really been used. That last part matters more than it looks, and most of this article is about why.
The uncomfortable question
Every agent platform eventually arrives at the same fork: does the model get to talk to the database directly? Saying no is defensible and costs you most of what makes an agent useful. Saying yes means a language model composes queries that run against production, and the model is not a trusted component. It is a very capable pattern-matcher that will occasionally produce a confident, well-formed, completely wrong filter.
We said yes. What made that decision survivable was accepting one thing early: the agent must never be the component that decides whose data it reads. Not because the model is malicious, but because a system whose safety depends on the model's good judgement has no safety property at all. It has a hope.
So the tenancy fields are not the agent's to set. When a query arrives, the server overwrites domain and app with the values the caller is authorised for, then executes. A cross-tenant read is not blocked, exactly. It is unsayable. There is no phrasing of the request that expresses it, which is a stronger property than a check that has to be right every time.
The part that surprised us: silent is wrong
Our first version filtered quietly, the way row-level security does. Ask for another tenant's rows, get an empty result. Clean, and by conventional security thinking, correct: leak nothing, not even the shape of what you refused.
It was the wrong default, and the reason is specific to who the caller is.
When a human hits an empty result set, they check their filter. When a language model hits an empty result set, it very often concludes that the data does not exist, and then reasons confidently from that conclusion. We watched an agent decide a tenant had no configured pages, and go on to helpfully offer to create them. It had simply been scoped, and had no way to tell the difference between "this is empty" and "this is not yours."
So the response now carries the filter that actually ran. The agent can see that its domain was rewritten, and an empty result becomes interpretable rather than misleading. This is the same instinct behind the structural checks on our write path: tell the caller precisely what happened, including when what happened was a refusal. Security that hides its own operation makes a human feel safe and makes a model hallucinate.
A rewrite is necessary and not sufficient
Rewriting the filter constrains one collection. MongoDB offers several ways to leave that collection while technically honouring the filter you were given, and this is where a naive implementation quietly fails.
$lookup joins into a second collection, and the sub-pipeline that does the joining carries its own match stage that nobody rewrote. $where and $function run arbitrary JavaScript inside the database engine. $out and $merge write the result set into a collection the caller never named, turning a read into a write. Each of these takes a correctly-scoped query and walks straight out the side door.
We refuse them by name, before execution, without trying to analyse intent:
"error": "forbidden_operator",
"message": "Forbidden operator '$where' at $.query.$where.
This operator is blocked because it allows arbitrary JS
execution or bypasses the per-app allowlist."
}
The denylist is deliberately blunt: $where, $function, $accumulator, $lookup, $out, $merge, $graphLookup. Some of those have entirely legitimate uses, and refusing them costs us real capability. We took that trade because a rule you can evaluate by looking at a key name is a rule that holds under adversarial input, and a rule that requires understanding a nested pipeline is a rule that eventually doesn't. Aggregations are simply not exposed yet; reads are find only. We would rather ship the narrow thing that is provably contained.
No ambient authority
Two smaller decisions carry more weight than they should.
First, collections are opt-in. An app declares what it exposes, each with its own row cap, and everything else is invisible. The default is not "the service account can read the database and we simply never write those queries" — a posture that survives exactly until an agent writes one.
Second, the override for platform operators is explicit per call, pinned to a single tenant, and logged. It is a parameter you must pass every time, never a mode you enter and forget. And it only lifts the collection allowlist. It does not lift tenancy. The opening example in this article was run with that override active, which is precisely why it is worth showing: even with the allowlist stood down, acme-corp still came back as www. The two controls are independent by design, because the failure we actually fear is the one where somebody has legitimately elevated for a good reason and forgotten they are still elevated.
What we would tell you to steal
Postgres row-level security enforces at the engine, which is better placement than an application-layer rewrite, and if you are on Postgres you should use it. We are not claiming to have invented tenant scoping. The pieces worth taking are the ones that come from the caller being a model:
- Make the dangerous thing unsayable rather than checked. If the agent cannot express a cross-tenant read, you do not need a check that is right every time.
- Show the caller what actually ran. Silent filtering is a reasonable default for humans and a source of confident fabrication for models.
- Deny by key name, not by intent. Anything requiring you to understand a nested structure will eventually be understood incorrectly.
- Keep elevation explicit and narrow. A mode you enter is a mode you forget.
None of this is about trusting the model more as it gets better. The model will get better. The boundary still belongs in the platform, because a guarantee that depends on the model's competence expires with every model release, and a structural refusal does not.
If you want to see the shape of it, the read tools are on every plan including the free one — pricing is here and you can start in about ten minutes. Point an agent at a collection, ask it for someone else's data, and watch the filter come back rewritten. For a gentler on-ramp with a different database, we wrote up connecting Postgres to an agent without handing it raw SQL.