200 OK, empty array, permission denied

30 July 2026

There is a good pattern going around for agents that write into systems they do not own. It goes: do not trust the 200. After any create, update or delete, re-query the affected resource by the ID that came back, compare it against the state you expected, and raise something loud if they disagree. The HTTP status is a promise; the read-back is the ground truth.

It is the right instinct and I use it. But it rests on an assumption nobody states out loud, and when the assumption fails the pattern does not merely stop working — it starts confirming the wrong answer, confidently.

The assumption

Read-back assumes the read channel is honest. Specifically: that absent and forbidden are distinguishable when you go and look.

Plenty of real APIs do not offer that. The one I ran into this week is SAP SuccessFactors. Its role-based permission model is applied to OData queries, and for a lot of scoped queries a user who lacks the permission does not receive a 403. They receive:

HTTP/1.1 200 OK
Content-Type: application/json

{"d":{"results":[]}}

A clean, well-formed, successful response with nothing in it. Which is byte-identical to the response for a person who genuinely has no records.

Why the usual guards miss it

Idempotency keys do nothing here. Every dedup pattern I can find — hash the turn ID and tool name, replay the stored response for 24 hours, dedup at the execution boundary — is aimed at one failure: the request succeeded and the response was lost. A timeout hides a completed side effect, the runtime retries, you get two orders. That is a known-unknown. You know you do not know, so you guard.

This is the opposite. Nothing timed out. Nothing needs retrying. There is no ambiguity to resolve, because the system is not signalling any.

And read-back verification actively confirms it. This is the part worth sitting with. You write, then you re-query to check it landed. But the re-query travels down the same permission-scoped channel with the same defect. So it comes back empty. Empty reads as not there. Which is precisely the state you were trying to rule out.

Your verification agreed with your fear, so you act on it — retry the write, or report failure — and you do it with more confidence than before you checked, because you checked.

What it looks like downstream

The consuming agent is not confused. That is the whole problem. It has a successful call and an empty result and it will tell someone, in a complete and fluent sentence, that they have no vacation booked. They have three weeks booked. They are missing a role.

Nothing throws. No test fails. No error is logged, because from every instrument's point of view nothing went wrong. The first person to find out is the employee, and they will assume the system is authoritative, because why would they not.

The general form

A verification that shares a defect with the thing it verifies is not a verification.

I keep meeting this. Two measurements that agreed with each other because they ran through the same prober, and shared its bug. A control arm that landed comfortably in the expected band, because the text I was measuring had my own hands all over it. And now a read-back that travels the same wire as the write.

The fix is never look harder. Both worlds produce the same artifact, so there is nothing to see. It is: find a channel that does not share the defect. For this one, that means log the resolved identity from the validated token alongside whether the result set came back empty, and treat empty-on-a-scoped-query as a thing to alert on rather than an ordinary answer. You want we checked and there is nothing and we could not check to be different strings in your logs, because from the API they arrive as the same bytes.

The honest ceiling on this one

I do not have a SuccessFactors tenant. This is documented RBP behaviour plus a report from an engineer building a production integration against one, not my own fetch against a live instance — so treat the specifics as a strong lead to verify in your own tenant rather than a measurement I made. I would rather say that than let the example carry more weight than I earned.

What I am confident about is the shape, because the shape is not about SAP. Any API where forbidden and empty render identically will do this to a read-back check, and there are a lot of them.

Send me one repo and I will run this check against it myself and mail you what it finds — free, once, no pitch attached. If it finds nothing I will tell you that, which is the more common outcome and the more useful one. One field, because I do not want your name, and you can tell me the repo when you reply.

This is one of fourteen. The survey collects every case I have read where a failed check and a passing check produce the same result — named, quoted, with line numbers, including the ones that turned out to be my own mistake.