building vodou.

Your entitlement check ships in a file the user can edit

I moved an account gate out of a TypeScript env flag into the compiled engine, at the two places every memory route passes. What mapping the routes found.

Chad Priest / / 11 min read

Most agent stacks have an entitlement check somewhere. Ours did. It was a boolean read out of an environment variable, evaluated in TypeScript, in a file that ships to the user’s disk as readable JavaScript. Anyone with the install could set the variable to 0. Anyone who did not want to bother could open the compiled file and delete the branch.

That check asks honestly, which is worth something. It is not a gate.

The interesting part was not replacing it. The interesting part was what happened when I sat down to list every route that reaches memory, because the list was shorter than the architecture diagram implied, and that is the finding that transfers.

Four ways into memory, and two of them were the same door

I wrote out every caller that can retrieve or write a fact. There were four, and they come from completely different worlds: a web chat in the browser, an IDE hook firing from Cursor or Claude Code, an MCP server that other tools attach to, and a CLI subprocess that another MCP server shells out to.

My assumption going in was that four entry points meant four enforcement points, which meant four places to get it wrong and four places to keep in sync forever. That assumption was wrong, and it was wrong for a reason I could have checked on day one instead of day three.

The gateway (the Node process serving web chat) never opens the memory database for recall. It is a client of the daemon socket. It asks the engine and renders what comes back. Same for the IDE hook: it is a thin binary that talks to the same socket. Same for the shell-out path: it runs a memory subcommand, which is the same engine. Three of the four routes were already funnelling through one process, and nobody had written that down as a property, so nobody had used it.

Gateway web chatIDE hookCursor, Claude CodeAttached MCP clientsCLI shell-outfrom another MCP serverDaemon socket: memoryverbsMCP server startupMemory storeThe gateway never opens the store itself, which is why two enforcement points cover four callers.
Four callers, two doors

Two places. Both compiled. No flag to flip.

The first plan was a sign-in modal, which is a gate on the UI and nothing else

The original scope for this item said the account requirement would be enforced in the browser, via a sign-in modal in front of the console. I wrote that down myself, in a work queue, weeks before I built it.

It is the most common shape of this bug and I had shipped it. A modal in front of a page does not gate the API behind the page. Anyone who talks to the local gateway directly gets everything the modal was standing in front of, and “talks to the local gateway directly” means one line of curl. The client-side check is a UX affordance that looks like a control, which is worse than no control, because it stops you from looking for the real one.

The TypeScript env flag had the same disease one layer down. The decision was data, the data lived on the user’s disk, and the code that read it also lived on the user’s disk in a form you can edit with any text editor. Enforcement that ships as editable data is a preference.

BeforeSign-in modal in the browserBoolean read from an env fileBranch visible in shipped JScurl the local API and it answersAfterVerdict decided inside the engineEnforced at the daemon's memory verbsEnforced at MCP server startupBypass now means patching a binary

A five-second license call in front of every prompt was the second wrong answer

The naive version checks the account on every call. That is correct and unusable.

The CLI in this system is once-per-process: a prompt fires, a process starts, it does its work, it exits. A per-call license check in that pattern is a network round trip to a license server in front of every single prompt, and the measured cost of that round trip was about five seconds. Five seconds of nothing, before memory retrieval even starts, on every keystroke-adjacent action a person takes.

So the gate caches its verdict for fifteen minutes. That is a deliberate hole with a known size: a revoked account keeps working for up to fifteen minutes. I would rather have a hole I can state in one sentence than a product where every prompt pauses.

The general version of that trade, and the thing I now write down before building any of these: classify the gate by what it protects. Does this check stand in front of something that costs money to serve (managed inference, a billed API, someone else’s compute)? Or does it stand in front of local-only work on hardware the user owns? The first kind is a moat and belongs server-side, in the proxy, where the user cannot reach it. The second kind is convenience-ops, and the honest ceiling on a machine its user owns is “you would have to patch the binary.” Pretending otherwise gets you elaborate local enforcement that protects nothing and slows down everything.

The checkable property: enforcement belongs where the data is opened, not where the request arrives

Stated so you can go and check it against your own repo, rather than as advice:

Every process that can open the datastore must pass the same gate, and the gate must live in the code that opens it, not in the code that receives the request.

That is either true or false of a given codebase. It fails the moment a second process opens the same file directly, because now you have two doors and one of them has no lock. It also fails when the check is in a request handler, because a request handler is one of the callers, not the data owner.

The corollary that saved me four enforcement points: before you write a gate, enumerate who opens the store. Not who serves the feature. Who calls open.

Run this against your own stack in five minutes

Three checks. None of them need anything of mine.

1. Ask your own API without a session. Your UI has an auth check. Find out whether your API does.

curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST http://localhost:3000/api/memory/search \
  -H 'content-type: application/json' \
  -d '{"query":"anything"}'

Passing looks like 401. Failing looks like 200, and if it is 200 the modal you shipped is decoration. Do this for every route that reads user data, not just the one you remember.

2. Count how many processes can open the store. In a monorepo, the honest version is a grep for the store’s filename across every language in the tree:

grep -rn "memory.db\|agent.sqlite\|pgbouncer\|DATABASE_URL" \
  --include='*.ts' --include='*.js' --include='*.py' --include='*.go' \
  src/ services/ | grep -v -i test

Or at runtime, if it is a local file:

lsof /path/to/your/store.db

Passing is one process family. Failing is a Node service, a Python worker and a CLI all holding the same file, because that is three gates you now have to write and keep in sync, and you will write two.

3. Find out whether your enforcement is data or code.

grep -rnE "(process\.env|os\.getenv)\[?['\"][A-Z_]*(AUTH|LICENSE|ENTITLE|PREMIUM|GATE)" \
  --include='*.ts' --include='*.js' --include='*.py' . | grep -v -i test

Every hit is a decision your user can change without recompiling anything. Some of those are fine (a dev-mode toggle, a staging switch). For each one, ask whether an ordinary user flipping it costs you money. If yes, it is in the wrong place.

4. Time the check you are about to add.

time curl -s -o /dev/null https://your-license-host/v1/verify -d '{"token":"..."}'

Multiply by how many times your hot path runs. If the product is over a few hundred milliseconds, you need a cached verdict with a stated TTL, and you should write the TTL in the docs as the revocation lag, because that is what it is.

Every “gate” in the agent-memory literature gates relevance, not callers

This is the part that surprised me when I went looking for prior art. The word “gate” is everywhere in agent-memory writing, and it almost never means authorization.

Gated-Memory Routing (September 2026) builds a whole router around gates: a Retrieval Gate that surfaces a compact, step-relevant subset of memory, a Memory Write Gate that commits only high-utility, non-redundant reasoning steps. Excellent work, and the gates are all relevance filters. Portal.ai’s Do Agents Dream of Electric Sheep? defines its Layer 2 as the Gate, and the thing it gates is whether the agent remembers to check its own notes before replying: “a journal is useless if the agent doesn’t open it.” Also right, also not about callers. The agent memory pattern spec gets closest to the security question with “redact PII at write time, not at read time,” and then spends its rules on consolidation, scoring and eviction. LangChain’s memory for agents is about what to remember and for whom.

Read those four together and you have a thorough account of which memories reach the model, and no account at all of which processes may ask.

The two projects that do take the caller seriously both put the gate on a network hop. AgentGate is blunt about the state of play: agents in production “reading emails, writing to databases, calling APIs” with zero authorization infrastructure, long-lived tokens, no least-privilege, no revocation. Its answer is a drop-in authorization gateway between the agent and the API. ThumbGate makes the matching argument for enterprise context: memory alone “does not stop a repeated bad action,” so promote policies and approvals into pre-action gates.

Both designs assume there is a hop you control. In a local-first system there is no such hop. The agent, the memory and the user are on one machine, and a sidecar you can stop with Activity Monitor is not an authorization boundary. The chokepoint has to be inside the process that owns the data, which is exactly why this ended up compiled in.

What is still open, and what the ceiling actually is

One route bothers me: an MCP server that reaches memory by shelling out to CLI subcommands. It funnels into the same engine, which is why it is on the covered list, and it is still the path I would re-check first, because a subprocess boundary is the easiest place for a future refactor to quietly grow a second door. A gate that is true today because of a call graph, rather than because something asserts it, is one commit from being false.

And the ceiling: on hardware you own, running a binary you have, the bar this moves is from “edit a config line” to “patch the binary.” That is a real improvement and it is not a lock. I would rather say that out loud than ship a claim I cannot back.

Two enforcement points exist because the memory only lives in one place

The reason I could map every memory route in an afternoon is that there is one memory. Not a vector store here, a chat history there and a notes file somewhere else: one local database that owns the facts, and everything else is a client of it. That property is what made the security question tractable, and it is the same property that makes the product work at all.

That is Vodou, which I build. Three things in it matter to an engineer standing up their own stack.

Your memory is a database on your machine, and it crosses the browser boundary. The facts live locally, in a file you own, and nothing leaves until you send it. The same memory rides into Cursor, VS Code, Claude Code and Claude Desktop through MCP and hooks, and into ChatGPT, Claude and Gemini in the browser through the Vodou Bridge extension. Most context tools stop at the terminal. The web chats are where most people actually work, and that boundary is the hard one to cross.

It captures without you keeping notes. Facts get extracted from your conversations and sessions automatically, and retrieval is hybrid vector plus keyword with a cross-encoder reranker, with a precision floor that injects nothing rather than noise. When you correct something, the correction supersedes the stale fact instead of sitting next to it arguing.

It runs work while you are gone, and you can extend every part. Skills are guided workflows you write yourself, with stopping points where you decide. MCP servers connect and get routed by intent. There is a scheduler and a background job runner behind it: this post was mined from my own memory, drafted, graded against a rubric, scanned by a redaction gate, deployed and verified on Vodou’s own scheduler, which is a story you can check by reading the open-source half of the repo.

It is for engineers who are tired of re-explaining their stack to a model every morning and who want the thing holding that context to be a file on their own disk. The client side (MCP servers, skills, docs, scripts, the extension) is open source. The engine is not, which is the other half of why this gate went where it went.

If you are building the same thing I am, you will hit the four-doors problem within a month. Vodou already ate it, and you can have the working memory instead of the map: vodou.ai.