All posts
Aug 7, 2026·9 min read

Four things were wrong with my own permission layer

I build Threadline, a permission layer for agent memory. An agent asks for scoped access to a user's context the way an app asks for OAuth scopes, and the service decides what it is allowed to read and write.

Last week I stopped adding features and spent five days trying to break it instead. I found four things. Two of them defeated the product's central claim. All four are fixed now, and I want to write them down while the logs are still in front of me, because a governance product that only publishes its passing tests is not telling you very much.

1. Any API key could delete any user's context

The DELETE endpoint authenticated the caller and then deleted. It never checked whether the calling agent had a grant for that user.

That alone would be bad. What made it worse was the cleanup that followed the delete. It revoked grants with a filter on the user id and nothing else, so a single call by any key holder destroyed every agent's relationship with that user, including agents belonging to other developers on the platform.

I proved it on production rather than reasoning about it. Three keys, three separate agents. A and C each established a grant. B never touched the user at all. Then B called DELETE:

text
DELETE /api/context/{userId}   (key B, no grant)

200 {"deleted": true}

The context was gone. Both A's and C's grants came back revoked in the database. A's own delete afterwards returned 404, because B had already destroyed everything.

The fix requires an active grant before any deletion, scopes the revocation to the calling agent, and writes an audit entry per revoked grant. Re-run against a fresh user:

text
DELETE (key B, no grant)  → 403 FORBIDDEN

GET    (key A)            → 200, context intact

DELETE (key A)            → 200, deleted

grants table              → A revoked, C still active

That last line is the one that matters. It is the difference between the auth gate being added and the blast radius actually being contained.

2. Revoking access did not revoke access

This is the one I would not have found by reading the code, and it is the one that should worry you most if you are building something similar.

Threadline auto-provisions a grant on an agent's first call. That was a deliberate onboarding decision: a developer should get working memory without first learning a permissions model. It is the reason integration is two lines instead of five.

The lookup for an existing grant filtered on `revoked = false`. So when a user revoked an agent's access, the next call found no active grant, concluded that none had ever existed, and helpfully created a fresh one.

text
inject + update           → grant created, context stored

revoke the grant          → row marked revoked

update again (same key)   → 200, writes normally

grants table              → two rows: the revoked one, and a new active one

inject again              → returns the stored context

Revocation was advisory. The user could withdraw access and the agent would silently get it back on its next request. Meanwhile the site told them they could revoke.

The fix is small. Before auto-creating, check whether any revoked grant exists for that pair, and if one does, refuse. Auto-grant now applies only to a relationship that has never existed. Update returns 403, inject returns a distinct `revoked` reason rather than the generic no-grant path, and the blocked auto-create is itself audited.

The interesting part is not the bug. It is that a convenience feature quietly ate a governance feature, and nothing in either feature's own tests would ever have caught it. Auto-grant worked correctly. Revoke worked correctly. The interaction did not, and the interaction is where the promise lived.

3. Facts were laundered into scopes by nesting

Enforcement checked the top-level scope of every extracted fact. But two of the seven scopes are objects with free-form keys, and nothing looked inside them.

So an agent granted only `preferences` and `goals` produced this:

json
{"preferences": {"communication_style": {"value": "prefers bullet points"}}}

A communication-style fact, stored under a sub-key named after the scope it belonged to, by an agent that was never granted that scope. The top-level `communication_style` column sat empty the whole time, which is exactly why it looked clean.

Enforcement now inspects one level inside object-shaped scopes. A sub-key that resolves to a different spec scope is routed there if the agent holds it, and denied if it does not. Deterministic name comparison, no model judgement involved.

4. Refusals were happening, but silently

The write pipeline told the extraction model which scopes were granted and asked it to stay inside them. Sensible on the surface. In practice the model simply omitted out-of-scope facts, so nothing reached the enforcement layer, so nothing was refused, so nothing was logged.

Nothing was stored, which is correct. But my published claim was that a fact outside a grant is refused and logged as refused rather than quietly kept, and the second half was not happening. A denial you cannot prove did not happen.

The fix inverts it. Extraction now runs unrestricted across all seven scopes and enforcement is entirely deterministic. Out-of-scope facts are dropped, their scope names reported, and an audit row written. Sending "My wife Sarah works at Acme" to an agent without the relationships scope now produces:

json
{"updated": false, "delta": {}, "denied_scopes": ["relationships"]}

and an audit entry whose metadata is `{"scope": "relationships"}` and nothing more. The refusal is recorded. The refused value is not, anywhere, which matters just as much.

What I would tell anyone building this

Three things, and the third took me longest.

Test the interactions, not the features. Every one of these bugs sat in the gap between two things that each worked correctly on their own. Auto-grant and revoke. Extraction and enforcement. Top-level scopes and nested objects.

Do not ask a model to enforce a boundary. Ask it to find things, then let rules decide what happens to them. A model asked to classify will reason its way into a defensible wrong answer and explain itself convincingly on the way. Every enforcement decision in Threadline is now a deterministic comparison, and the model has no vote.

Convenience is where governance goes to die. Auto-grant exists because friction kills adoption, and I would build it again. But every feature that removes a step for the developer is a feature that can remove a step the user was relying on. That is not an argument against convenience. It is an argument for going looking, on purpose, at every place the two touch.

All four are fixed and verified on production, and the permission model behind them is being written up as an open spec with these findings in it.

If you are building on agent memory and want to compare notes on where your own boundaries leak, I would like to hear from you. Threadline is at threadline.to.