This is the fifth time I have attacked my own coach. The first four found something. A prompt that leaked the tool list. A memory I could not poison but learned a lot trying. A paid endpoint with no rate limit. A destructive tool that fired on a two-word message. This one is different. I went looking for the worst bug in the stack and the boundary held.

The boundary is this. Every user’s data lives in a separate Durable Object. One per person. All their workouts, plans, conversations, memory. If that wall has a crack, one user can read or wreck another user’s data. That is the bug that ends an app. So I tried to make the crack.

The Attack I Wanted

The classic version of this is boring and devastating. It has a name, BOLA, broken object level authorization. You ask for object 124 and get it. You ask for 125 and get someone else’s. The server checked that you were logged in. It never checked that the thing you asked for was yours. Most real breaches are some flavor of this. Ask for an id that is not yours and see if the server hands it over.

So I set up two accounts. A and B. A made a conversation, a scheduled session, and a workout. I grabbed their ids. Then as B I walked every door in the app holding A’s ids.

Read A’s conversation. Post a message into A’s conversation. Read A’s planned session. Edit it. Start it. Read A’s workout. Delete A’s workout. Then I dropped the login entirely and tried to read A’s private workout as nobody at all.

Nine attempts. Every one denied. Nothing of A’s came back to B. Nothing came back to the anonymous caller. I checked the scariest one twice, the delete. B asked the server to delete A’s workout and the server said 200, OK. My stomach dropped. Then I logged back in as A and the workout was still there. The delete had hit nothing.

Why It Held

The reason is the part worth keeping. The isolation is not a check. It is an address.

When B logs in, the server reads B’s identity from the signed token. Not from the URL, not from the body, from the token. Then it builds the database handle from that identity.

const id = env.WL_DURABLE_OBJECT.idFromName(user.email);
const stub = env.WL_DURABLE_OBJECT.get(id);

That handle points at B’s Durable Object and only B’s. When B then asks to delete A’s workout id, the delete runs against B’s database, where that id does not exist. It deletes nothing and says so. B never gets a handle to A’s data, because the handle is built from B’s token, and B cannot forge A’s token without the signing secret.

This is isolation by address, not isolation by check. The difference matters. A check is a question you ask on every read. Is this row yours. Is this one yours. Forget the question once, on one route, and that route leaks. The whole class of BOLA bugs is a forgotten check. Address-based isolation has no question to forget. You are handed a room and the room only has your things in it. You could not reach into another room if you tried, because you were never given its door.

It is the same idea I keep landing on in this series. Only code binds. A check is a line a tired engineer can skip. An address is structural. The wall is the architecture, not a rule written on top of it.

The ids help too. They are random UUIDs, not 124 and 125. So even the guessing game that powers most BOLA attacks does not start. But the ids are the smaller reason. Even if B knew A’s exact workout id, and B did, it knew it because I handed it over, the address still pointed at the wrong room.

The One Thing That Was Wrong

The boundary held, but one door reported the denial badly. When B asked for A’s workout, the server returned a 500. A server error. Not a 404.

No data leaked. The 500 was the server tripping over its own feet on the way to denying the request. The workout was not in B’s database, so the lookup came back empty, and the code tried to format an empty result into a response that requires a real one. It threw, and the catch turned the throw into a 500.

That is still a bug. A denial that looks like a crash is its own problem. A 500 tells an attacker the code path is fragile, which is an invitation to push on it. It tells an honest user something is broken when nothing is. And it is the one response on the whole probe I could not tell apart from a real failure at a glance. The fix is small. When the workout is not in your database, say so. Return a 404. Not found is the honest answer, and it is the same answer whether the workout never existed or simply was never yours.

if (!meta) {
  return errorResponse('Workout not found', 404);
}

The delete had the mirror version of the same sin. It denied correctly, deleted nothing, and then said 200 OK. A success code for a no-op. That one is already on my list as a separate issue, so I left it there rather than drag it into an isolation fix. But it is the same lesson twice. The boundary did its job, and then the response lied about what happened. A denial you cannot distinguish from success, or from a crash, undoes some of the value of denying at all.

What I Take From It

I have spent five rounds looking for ways my agent breaks. This is the first where the answer was, it does not, here is why. That is worth writing down as much as a break is. The thing that made it hold was not vigilance. It was that the isolation lives in the address, so there is no per-request check to forget. Pick a primitive where the safe thing is the only thing you can do, and you stop relying on remembering.

The two warts are the smaller half of the story but the more useful half for anyone building this. Get your boundary right and then make sure it tells the truth when it stops someone. A 404 that means both not found and not yours. A clear no on a write that changed nothing. The boundary and the boundary’s honesty are two different jobs.

The worker has the fix and the two-account probe is the regression test. Next I want to point this same probe at the agent’s tool-result payloads, the data the model reads back mid-loop. The address protects the database. I want to know what the model trusts once the data is in its hands.