The first version of access control most people reach for with an AI agent is a sentence in the system prompt. "You are working on project Atlas. Do not discuss other projects." I understand why. It takes ten seconds and it mostly works.
The problem is the word "mostly". A prompt is an instruction to a model, and a model can be talked out of an instruction, can misread it, or can be handed a document that contains a better-sounding one. If the agent can call a tool that returns another project's notes, then the only thing between the agent and those notes is the model's judgment on that turn. That is not a boundary. It is a request.
Willder's rule is that access is decided in the database, before the model sees anything, and that no model takes part in the decision. Here is what that looks like, and where it stops.
I.Scopes are a tree, like folders
Every piece of memory belongs to a scope, and scopes form a tree: an organization, then teams or projects under it, then whatever depth you need. A grant on a parent covers its children. A token on one project never reaches a sibling or a parent.
Figure 1 shows a small one.
The organization acme has two projects, acme.atlas and acme.borealis. acme.atlas has two children, acme.atlas.research and acme.atlas.deploy. A token minted on acme.atlas reads acme.atlas, acme.atlas.research and acme.atlas.deploy. It never reads acme.borealis, a sibling, or acme, its parent.
The tree is stored as Postgres ltree paths [1], so "does this grant cover that scope?" is one operator:
granted_path @> target_pathWritten as a relation, a grant on path covers a target exactly when is or one of its ancestors:
I picked a tree over flat roles (admin, member, viewer) because flat roles cannot say "this agent sees the Atlas project but not Borealis", and real access is shaped like that: nested, and different at each level. The cost is that ltree is obscure, and every memory check runs a SQL query with that operator in it.
II.Permissions look like a file system's
Each actor is a principal of its own. A person has grants. An agent has a capability token, which starts with wcap_, and names exactly one scope, one permission (read, write or admin) and an expiry. The raw token is shown once; only its SHA-256 hash is stored [2].
Tokens are short-lived by default: seven days unless you ask for less or more, and never more than ninety. An admin token is cut to two hours from its mint, by the database itself, whatever was asked [3]. In the code the ninety-day cap is one line:
export const MAX_CAPABILITY_TTL_HOURS = 24 * 90;One token per agent per scope sounds like overhead. In practice it is what lets you answer "what can the research agent see?" by looking at its tokens.
III.One check for every read and write
Every guarded memory operation goes through one function, check() in services/access/check.ts [4]:
export async function check(
ctx: AccessContext,
req: AccessRequest,
): Promise<AccessDecision>It is default deny. It is a database query over grants, the token's scope, expiry and revocation. For an agent's token , a request for permission on scope is allowed only when
and denied otherwise. It runs before any store is searched, so in a guarded read, out-of-scope data never reaches the agent, the model or the prompt. On the hosted MCP endpoint, the workspace and the agent come from the bearer token alone. An organization named in a prompt or a request body does not switch workspaces.
I chose one chokepoint over checks in each route because checks spread across routes drift. When the same logic lives in six places, one of them misses a case. One function gives one boundary to test, and the leak tests, npm run test:leaks, test it with no keys and no network: a token on one scope never receives a sibling's facts, graph nodes, files or briefs, and a revoked or expired token is refused on every memory tool [5].
There is one detail I like here. A token's scope and permission are fixed when it is minted. If the agent's access comes from a grant, check() also re-reads the agent's current grants on every call. Demote or remove the grant and the wider token stops working on its next call, not when it expires.
IV.Revocation that takes effect on the next call
Revoke a token and, on the hosted endpoint, its next call is refused with HTTP 401 [6] Invalid or unknown capability token., before any tool runs. Revoking updates the row rather than deleting it, so the record that the token existed, and when it ended, stays for review.
The local stdio server is a little different, and I want to be precise about it. It resolves its token once, at start. After a revoke the process keeps running, but every later access decision is refused by check(), which only matches tokens that are not revoked and not expired [7].
V.An audit log of every decision
check() writes one append-only audit_log row per decision, allow or deny: the actor, the action, the target scope, the decision and the reason. Reads, writes, listings, change notices, and scope, grant and token management are all recorded. The app itself cannot edit or delete those rows. You see them under Audit in the app or through GET /api/audit [8].
An audit row records a decision, not a truth. It tells you the research agent was allowed to read a fact at 14:02. It does not tell you the fact was right.
VI.Limit 1: credentials that skip the check
check() runs inside the application. Anyone holding a credential that talks to the stores directly reaches memory without it. No row-level security is enabled in Postgres, so the database URL reads every table. The Neo4j password reads the whole graph. The memory service key trusts whatever partition the caller names. The background job keys can forge a job that writes memory into any scope.
The README has a table of every such credential, what it reaches and which file reads it [9], and npm run bypass-credentials:check fails when the code reads a credential that is neither in that table nor recorded as one that does not reach memory. If you self-host, hold those credentials as tightly as the memory itself.
VII.Limit 2: what the client already has
Revocation ends future access. It cannot recall what an agent already read, and this is easy to underestimate.
On 29 October 2026 I ran Cursor's CLI agent (version 2026.10.22) against a local self-hosted Willder. It saved a fact, read it back, found nothing with a token on another scope, and after I revoked its token it could not connect. Willder gave it nothing. Then the agent went looking on its own. It searched Cursor's local transcripts of earlier sessions, under ~/.cursor/projects/, and found the text of those sessions, the fact included. It did not answer with it, but the fact was back in its context, and so on its way to the model.
That is not a Cursor bug. Cursor keeps transcripts of each session on your machine, and its model runs on Cursor's servers, so what an agent reads from Willder goes there with the rest of the conversation. Any client that keeps its own history has the same property. Revoking a token stops Willder answering; it does not reach into the client's files or its provider's logs.
VIII.What I would do with this
Give each agent its own token, on the narrowest scope that does the job, with a short expiry. Put nothing in a prompt that you are relying on as a fence. Check the audit log when something surprises you. And keep the credentials in that README table out of any agent's reach, because they are the doors that do not go through the check.