Development

What an Agent Sees When a Query Returns Null

Antonio Evans10 min read

A close-up of a terminal window on a dark screen.
Key points

Key points

  1. 1To a model, an empty result and a permission denial both arrive as a null or an empty list, and the model cannot tell which one it received.
  2. 2Row-level security hides rows instead of raising an error, so a filtered query and a forbidden query return the same shape of response.
  3. 3Return a structured reason with every empty result: a status field, the filter that was applied, and no detail about rows the caller cannot see.
  4. 4Design the reason codes from the agent's next-step decision, not from the database's internal error taxonomy.

What the model actually receives

Start from the model's side of the wire. An agent calls a tool such as search_orders(status="open", tenant="acme"). The tool runs a query, gets no rows, and returns whatever the serialization layer produces. In most codebases that is None, null, [], or {"ok": true, "rows": []}. The model sees a successful call with nothing in it.

Nothing in that payload distinguishes two very different situations:

  1. The filter matched nothing. There are no open orders for this tenant, and that is true for everyone.
  2. Rows exist, but the caller is not permitted to see them. Another tenant's open orders exist and the current identity cannot read them.

The model has no way to probe. It cannot see the SQL, the execution plan, the row counts before filtering, or the policy that was applied. If the tool returns only data, the two states collapse into one observation. This is why agents behave badly around empty results: they either loop, retrying a query that will never succeed, or they announce to the user that "there are no records" when the truth is "you may not see them."

The distinction matters because the two states call for different next steps. A no_match result invites the agent to broaden the filter, drop a constraint, or try a different field. A denied result tells the agent that widening the filter is pointless and that the user needs different credentials or a different resource. If the payload does not carry that distinction, the agent has to guess, and it will usually guess wrong in the direction of "keep trying."

Where the boundary sits in the database

The reason the model cannot tell is that the database itself often cannot tell, or chooses not to. The two mechanisms are different in kind, and only one of them surfaces as an error.

Object privileges gate whole tables and columns. In PostgreSQL, an object is assigned an owner, and by default only the owner or a superuser can do anything with it until privileges are granted; a role without SELECT on a table gets an error, not an empty list (PostgreSQL privileges). That case produces a hard failure the application layer can catch and translate. If a read-only role queries a table outside its allow-list, the attempt is denied outright rather than silently returning nothing (Simon Willison's read-only role notes).

Row-level security behaves differently. Once row security is enabled on a table, all normal access must be allowed by a policy, and if no policy matches, a default-deny policy applies, meaning no rows are visible (PostgreSQL row security policies). The query still runs. It returns zero rows. There is no error for the application to catch unless the code explicitly checks whether rows were hidden. That is the boundary the agent cannot see: on one side a privilege error, on the other a silent empty set, and the model sees only the latter.

A small worked example makes the asymmetry concrete. Suppose a table holds 311,205 invoices across 100 tenants, with the largest tenant holding 60,000 (now-next.nl's RLS measurements). A query for one tenant's open invoices returns 0 rows because that tenant has none. A query for a different tenant's invoices, run by a session scoped to the first tenant, also returns 0 rows, because the policy on current_setting or current_user filters every row out. Same payload, same status, same empty list. The first query is answering a real question; the second is refusing to answer one, and it looks identical.

Why "just return 404" does not solve it

The instinct from REST design is to return a status code. That helps the HTTP client, and it is a reasonable starting point: status codes set the category of a response so software can make automatic decisions, such as refreshing a token on a 403 (Speakeasy's error design guide). A 403 for a denied read and a 200 with an empty body for a genuine no-match is cleaner than returning empty for both.

But there is a trap specific to agents. If the API returns 403 for a row the caller is not permitted to read, and 200 with an empty body for a row that does not exist, then the API has just built an existence oracle. An agent can probe identifiers one at a time and learn which ones exist in another tenant. The same reasoning applies to a 404 versus 403 distinction on an object endpoint. Returning a distinct denial status confirms that something is there; returning a not-found status for both hides it.

This is the central design tension. The agent needs a reason to choose its next step, and the attacker (which may be the same agent, driven by a prompt injection) must not learn whether rows exist elsewhere. You cannot resolve that tension at the HTTP status layer alone, because the status code is coarse: one 403 covers "you lack the table privilege," "your row policy excluded everything," and "this object belongs to another tenant." The resolution is to encode the reason in the response body, chosen with the visibility question in mind.

A reason vocabulary built for the agent's next step

Design the reason codes from the decision the agent has to make, not from the database's internal error taxonomy. That keeps the vocabulary small and keeps the codes from leaking. Four reasons cover most reads:

Reason codeMeaningWhat the agent may do nextWhat it must never learn
okRows returnedContinue the task, present resultsNothing special
no_matchFilter applied, no rows matchedBroaden or change the filter, or tell the user the result is genuinely emptyNothing; there is no hidden row
deniedThe caller is not permitted to perform this readStop retrying; ask for access or a different resourceWhether any row exists, how many, or which tenant holds them
unknown_scopeThe server cannot confirm the tenant or identity scopeRe-authenticate or fail closedWhether anything exists

Two rules keep this vocabulary honest. First, denied never carries a count, an identifier, or a hint about what might exist. It says the read was refused and nothing more. Second, no_match is only returned when the server is certain the filter is the reason for the empty result, which usually means the query ran under a policy that the caller is allowed to see the full scope of. If the server cannot be certain, it returns denied or unknown_scope, not no_match. This is fail-closed design, and it is the same default-deny posture that row security uses when no policy matches (PostgreSQL row security policies).

The vocabulary is deliberately coarser than what an application developer might want. An engineer debugging a query wants to know "was it the tenant predicate, the status predicate, or the date range?" The agent does not need that granularity to act, and finer codes are harder to keep leak-free across a table with many tenants. Keep the codes few and the semantics strict.

Implementing the structured reason on the server

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:

  1. 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.
  2. 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.
  3. 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).

Testing the boundary

A reason code is easy to declare and hard to keep correct across refactors, so test the boundary the way you test the query itself. Mock or seed three states for a single entity: a row visible to the caller, a row visible to another tenant but not this one, and no row at all. Then assert what the tool returns for each, using the caller's own credentials.

A minimal test matrix:

  • Visible row, matching filter: ok, row returned.
  • No row, matching filter, caller has full scope: no_match, empty rows, filter echoed.
  • Another tenant's row, matching filter: denied, empty rows, no filter echo, no count.
  • Caller with no table privilege at all: denied, and the underlying privilege error is logged.
  • Caller scope undetermined: unknown_scope, fail closed.

Run this against the identity mechanism you actually use. If you scope tenants with a session variable, test that the variable is set per transaction, because a session variable set outside a transaction leaks across pooled connections (now-next.nl's RLS measurements). If you use a role per tenant, test that the role is switched per transaction with SET LOCAL ROLE rather than SET ROLE, for the same reason. The Reason column in the matrix is the thing under test; the identity plumbing is the thing that makes it trustworthy.

One more check belongs in the suite: a test that sends the same request under two tenants with the same filter and asserts the responses are byte-identical up to the point where they diverge for legitimate reasons. If tenant A and tenant B both get denied for a row neither can see, and the responses match, that is the privacy property holding. If one response includes a hint that the other lacks, the classifier is leaking.

A related trap is a query planner that never touches the rows at all. If a policy predicate and a filter together match no rows, an index scan can stop early and return nothing, and a full scan would also find nothing; the empty set is real either way. The reason code must be classified from the scope check, not from whether the query happened to read pages, because a plan that avoids a scan can also avoid exposing that rows might exist (MySQL's notes on scan avoidance).

Handing the reason to the agent

The reason code is only useful if the agent's tool description and its instructions tell it what to do with each value. Two lines in the tool description are worth more than any amount of clever wording in the payload. For example: "If the result status is denied, stop and report that access is unavailable; do not retry with a different filter. If the result status is no_match, the filters you sent matched nothing; you may broaden them."

That instruction is what closes the loop between the database-side decision and the model's behavior. Without it, a capable model may still retry on denied, because the payload does not force it to stop. The code gives the model the information; the instruction gives it the rule.

For teams building agents that call into databases, retrieval systems, or any API behind a policy, the structured reason is a small amount of code with an outsized effect on reliability. If you are wiring an agent to a mix of tools and want a single place to route model calls while you build the rest, Quanta Inference gives you one API key across text, image, and embedding models, so the tool layer you build is not coupled to one provider (Quanta Inference).

FAQ

FAQ

Why does an empty result and a permission denial look the same to a model?

Because the model only sees the tool's return value, and a query that returns zero rows and a query whose rows were filtered out by a policy both serialize to an empty list. The model cannot see the SQL, the execution plan, or the policy, so it has no evidence to separate the two states unless the system encodes the reason in the response.

Does returning a 403 for denied rows not solve the problem?

It helps the HTTP client but creates an existence oracle. If a denied row returns a distinct status and a missing row returns an empty success, an agent can probe identifiers and learn which ones exist in another tenant. The reason belongs in a structured body field whose vocabulary is chosen to avoid confirming existence.

How do I decide whether a zero-row result is a no-match or a denial?

Run a classification check with the same identity and the same policy as the original query. If the caller's scope includes the entity and the filter simply matched nothing, return no_match with the filter echoed. If the scope does not include the entity, return denied with no detail. When the scope cannot be determined, fail closed with unknown_scope.

What should the denial reason include?

As little as possible: a status, the entity name, and nothing about counts, identifiers, or which tenant holds the rows. The value of denied to the agent is that it should stop retrying, not that it should learn what it is being denied. Detailed reasons go to the server's audit log, which the agent cannot read.

Where does this matter most?

Multi-tenant systems that rely on row-level security, because the policy filters rows silently instead of raising an error. Any tool an agent calls that runs behind a per-row policy needs the reason code, or the agent will report "nothing found" for data that exists and is merely out of reach.

Sources

Sources

  1. Documentation: 18: 5.8. Privileges
  2. Granting a PostgreSQL user read-only access to some tables
  3. Documentation: 18: 5.9. Row Security Policies
  4. A role per tenant vs a session variable for PostgreSQL RLS
  5. Errors Best Practices in REST API Design - Speakeasy
  6. Modify and Regenerate SQL AST
  7. 10.2.1.23 Avoiding Full Table Scans
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles