The server is the only place that knows which predicate emptied the result. Capture that at the point where the query is issued, before the rows are serialized to the agent. The shape below is illustrative, not tied to a framework:
{
"status": "no_match",
"entity": "orders",
"filters_applied": {
"status": "open",
"created_after": "2026-01-01"
},
"rows": []
}
And the denial case:
{
"status": "denied",
"entity": "orders",
"filters_applied": null,
"rows": []
}
Note what is absent in the denial case: no filter echo, no count, no pointer to the row. The filters_applied field is null because echoing the filter the agent sent back to the agent tells it nothing it did not already know, but echoing it invites the agent to reason about why that particular filter failed, which is exactly the inference you are trying to prevent. Keep the field for no_match, where it helps the agent adjust, and omit it for denied.
Implementation has three steps that matter:
- Classify at the query boundary. After a query returns zero rows, run a cheap check: does this session have any visibility into the entity at all? A second
SELECT 1 under the same policy, or a check of the policy predicate against a probe row, answers that. If the scope is visible and the filter still matched nothing, return no_match. If the scope is not visible, return denied.
- Never let the classifier leak. The classification query must run with the same policy and the same identity as the original. Running it as an elevated role to see whether rows exist would confirm existence and defeat the purpose.
- Log the truth, return the reason. The server's audit log can record "policy excluded rows for this tenant" because the log is not visible to the agent. The response carries only
denied. The audit trail is for humans; the reason code is for the model.
A worked example with numbers shows the payoff. Suppose an agent is asked to check whether a customer has any outstanding invoices. It calls the billing tool with the customer identifier. With a bare empty list, the agent retries three times with slightly different filters, each costing a round trip and a model call, and then reports "no outstanding invoices found," which is false and may be acted on. With a structured reason, the first call returns denied, the agent stops, and the user is told that access to that customer's billing record is not available. The failure is honest and the wasted calls vanish.
This is the same discipline good APIs apply to errors in general: return both a code and a message, because the code lets software decide what to do next and the message helps a human understand why (Speakeasy's error design guide). Agents are software, so they consume the code. The message is for the human reading the transcript afterward.
One more implementation detail matters when a caller supplies the SQL. Rewriting a query to add a tenant predicate by string concatenation is unsafe, because operator precedence can turn A OR B plus AND tenant into A OR (B AND tenant) and expose another tenant's rows. Editing the parsed syntax tree instead of the text, adding the predicate as a grouped expression node, avoids that class of mistake and keeps the policy auditable (the SQL parser AST guide).