SAP SuccessFactors OData v2, under Role-Based Permissions, does not return 403 when your scope excludes a record. It returns this:
HTTP 200
{"d":{"results":[]}}
Which is byte-identical to there genuinely are no records.
Not-permitted and no-such-data are the same artifact. No amount of careful handling upstream separates them, because there is nothing to separate — the two states serialise the same.
A human logged in as themselves, saw an empty list, and supplied the missing bit out of their own head: huh, that can't be right, I know I booked leave in June.
The human was the discriminator. Nobody wrote that down, because nobody had to.
Agents are being wired to SuccessFactors this year, and they are being wired with service credentials — because per-user auth is the thing that doesn't work. SAML Bearer Assertion needs every employee to hold the API permission role in RBP, and most tenants don't have that and won't be redesigning their permission model to get it.
So the loop becomes:
200Silent, plausible, and wrong. The shape that cannot be caught by looking.
I read the bytes of aiadiguru2025/sf-mcp — 11 stars, 43 HR tools, the most-installed SuccessFactors MCP server I could find. Its HTTP client has three guard clauses, and they are all reasonable:
| line | catches | catches the empty 200? |
|---|---|---|
client.py:183 | status code != 200 | no — this is a 200 |
client.py:201 | empty response body | no — the body is well-formed |
client.py:217 | — | returns the empty result straight through |
Then every one of the 43 tools does .get("d",{}).get("results",[]) — and so does permissions.py, which is the file whose entire job is knowing what you're allowed to see.
Nothing here is careless. Each guard is correct about the thing it was written for. The failure is that the interesting state doesn't look like a failure at any layer.
Treat “one fixed scope for all callers” as a permanent constraint. Fighting it is an RBP redesign, not a ticket. Move the fix down a layer instead, and manufacture the difference that the API refuses to give you:
For each entity type, hold one record you know exists and know is inside the service account's scope. Query it alongside the real request.
That's a third state — couldn't tell you — bolted onto an API that only has two. It costs one extra request per entity type, needs no admin involvement, and it is the only thing in this post that doesn't require somebody else's permission to do.
An empty 200 is one instance. The class is: two different states of the world producing one artifact, where the system used to have a human standing in the gap supplying the missing bit — and then the human was removed and nobody noticed she was load-bearing.
Every integration built before agents has one of these somewhere. It was fine, because it was never the API's job to be unambiguous; it was the operator's job to be suspicious. Agents inherit the API and not the suspicion.
The question worth asking about any tool you've handed to a model: what does it return when it is not allowed to answer, and is that different from what it returns when the answer is nothing?
Verified: the RBP behaviour, and the three guard clauses in that repo at those lines, by reading them. The canary approach is sound but I have not run it in a production SuccessFactors tenant — I don't have one. Treat it as a design I'd stake my name on, not a measurement.
Finding this class of thing in code I didn't write is what I do for money — unreached.dev. Happy to quote.
The finding above stands whether or not you ever talk to me, which is the point of publishing it.