NAOMS Devlog

Building a sovereign, local-first memory & identity system β€” in the open, honestly.

The Governance Socket Is Back

Every action an AI agent takes inside NAOMS travels one standard wire: MCP. Here is what that wire carries β€” the entitlement gate, the trust tiers, the signed approval receipt, the boot vocabulary β€” and four places where it had frayed.

Technology Architect free June 27, 2026Β·10 min readΒ·agents
TL;DR Our AI agents don't reach NAOMS's rules and memory through a private back door. They use MCP, an open standard interface, and everything an agent does β€” booting, checking what it is permitted to do, calling a tool β€” travels that one wire. This piece explains what the wire carries: an entitlement gate on every tool call, a trust tier that decides how loudly you are asked to approve, a signed receipt that proves a human said yes, and a boot vocabulary that tells an agent how the lifecycle works. Then it walks the four places that had frayed, and how each was repaired.

How the governance socket is supposed to work

Here is a piece of NAOMS that no end user will ever name, and that nearly everything else quietly stands on.

When an AI agent works inside NAOMS β€” checks what it is allowed to do, reads the procedure it should follow next, reaches into memory β€” it does not do that through a bespoke private channel. It goes through MCP, the Model Context Protocol: an open, published standard for connecting a model to tools and context, spoken by many systems and owned by none of them. MCP is the socket the agents plug into to reach the system's governance. Session boot, policy checks, every tool call an agent makes β€” all of it travels this one wire, into the daemon: the long-running NAOMS process on your machine that holds your data and serves every client talking to it.

That choice is deliberate, and it has a cost. We could have wired our agents to the daemon with something proprietary. We didn't, for the same adopt-the-protocol, don't-reinvent-it reason that runs through the whole project, and which we wrote about at length when we first exposed the daemon's real controls over MCP. The bill for that choice is maintenance: a standard you adopt is a standard you have to keep actually working, because it is load-bearing for every agent action.

Four things travel that wire, and each is worth understanding on its own terms.

An entitlement gate on every tool call. MCP distinguishes asking what tools exist (tools/list) from running one (tools/call). Every tools/call passes through an agent-trust capability gate whose job is exactly the trust-the-agent, verify-at-the-server discipline: before a tool runs, check that this caller is actually entitled to that capability. A read-only member should be denied a write tool; a properly scoped agent should sail through. The gate answers that question by running a query against the graph β€” NAOMS's local store of who holds what β€” and the whole point of the gate is that it is supposed to sometimes say no, on the merits. Listing tools carries no such gate, because naming a tool is not using one.

A trust tier that decides how loudly you are asked. When the daemon needs the owner to approve something, it classifies the request by trust tier, which is simply how much interruption the approval deserves. A high tier means "you already trust this deeply β€” show a quiet banner and an approvals card, don't interrupt with a modal dialog." Lower tiers are allowed to be louder. Approvals are not only broadcast to whatever screen you have open; they are also written to the chain, NAOMS's append-only record of things that happened. That matters because of reconnection: when a client comes back after a drop, the daemon does not have the original live moment to replay. It reads the stored record back and re-broadcasts from that. So the record has to say exactly what the live moment said β€” anything the live path knows only implicitly is knowledge the replay path does not have.

A signed receipt that proves the human said yes. Authorising an external client over MCP runs on OAuth, the standard delegation dance you have met as "sign in with…". The shape that matters here: the client is waiting for an authorization code to be delivered to a small loopback callback β€” a tiny web server the client runs on its own machine, reachable only from that machine β€” which it then exchanges for a token. NAOMS adds a step the plain protocol does not have. Consent is not just a flag flipped somewhere; the daemon mints a signed approval receipt, a cryptographic artefact proving that the owner genuinely approved, and the token endpoint requires it. The design principle that follows is worth stating on its own: which surface the human pressed yes on β€” popup, banner, card, command line β€” is cosmetic, and the receipt is load-bearing. The daemon, not the widget, owns delivery.

A boot vocabulary that tells an agent how the lifecycle works. When an agent starts a session over MCP, it is handed a vocabulary and a conductor procedure: named, numbered instructions describing how work moves from planning to build to close-out. Those names come from a governance seed β€” a set of definitions written into a daemon once, at its genesis, and read from there afterwards. Every name on that menu is a promise that something answers to it.

What was wrong

Four seams had come loose. None was a new feature. All four were the kind of small, specific rot that degrades everything downstream without ever announcing itself. These were post-ship bugs, filed against the MCP server's own bug register, fixed one at a time β€” and the root causes are more interesting than the headline.

Seam one: the entitlement gate had learned to always say no. Every tools/call came back with the same flat error: capability lookup failed. Curiously, tools/list still worked fine, which is the clue that cracks the case: tools/list has no capability gate, and every path that did have the gate failed.

The root cause is a beautiful little trap. The gate ran the synchronous version of its graph query. But synchronous graph queries reach into the signer's native code β€” the FFI layer, where the runtime calls out to compiled code that blocks until it returns β€” and on the daemon thread that is busy serving the live WebSocket, blocking calls like that are forbidden. A guard introduced a couple of weeks earlier throws the moment one is attempted there, precisely to stop that class of stall. So the gate called the query, the guard threw, and the throw was caught and dressed up as the generic capability lookup failed. The entitlement check never actually ran. It just looked like a denial.

That is the dangerous shape. A masked exception inside a security gate does not fail loud and obvious. It fails as a polite, plausible "no" β€” and a gate that denies everything looks, from the outside, almost like a gate working very strictly. It took reading the gate's own code path to see that the "no" was not a verdict at all. It was an error wearing a verdict's clothes.

Seam two: the live path and the replay path disagreed. The MCP OAuth flow is supposed to be high tier, and on a fresh connection it behaved exactly that way. But every reconnect popped a full authorization dialog instead of the quiet banner. The flow profile that drives this had no tier declared at all. The live broadcast path happened to announce "high" anyway β€” but when the approval request was persisted to the chain, it wrote the profile's tier field, which was undefined, so the stored record carried no tier. On reconnect the daemon read the stored record back, found no tier, and defaulted to medium β€” and medium is not suppressed from popping a dialog the way high is. The live path said "quiet," the record said nothing, and the replay path filled the silence with "medium."

Seam three: the receipt depended on the widget. Approving the MCP authorization on a generic surface β€” a banner, a card, a command-line prompt, anything other than the one canonical popup β€” resolved the approval on screen, and then nothing happened. The client never got its key. The generic path marked the request approved, but never delivered the authorization code to the client's loopback callback, and the token endpoint kept rejecting the exchange, because that path never minted the signed approval receipt the verifier requires. The human said yes; the cryptographic proof that the human said yes was never produced; the agent waited forever.

Seam four: the boot menu advertised a dead item. The vocabulary and conductor procedure handed to a booting agent still referred to a procedure by an old numbered id that had been renamed some time earlier. Twelve user-facing strings on the boot surface pointed at a name that, as a primary id, no longer existed. One of them even cited a supporting doctrine document under the old name, and that document genuinely was not there. So agents booted against a menu with a dead item on it.

How they were fixed, and what is still not fixed

flowchart TD
    A[Agent: tools/call policies.check] --> B{Agent-trust capability gate}
    B -->|asks entitlement question| C[Graph query]
    C -.->|BEFORE: synchronous query| D[Synchronous FFI into signer]
    D --> E[Serving-isolate guard:\nsync FFI forbidden here -> throws]
    E --> F[Throw caught and masked\nas 'capability lookup failed']
    F --> G[Every tools/call denied\nentitlement never evaluated]
    C ==>|AFTER: await async query| H[graphQueryAsync\nno sync-FFI guard tripped]
    H ==> I[Real entitlement answer:\nallow or genuinely deny]
    style F fill:#f8d7da
    style G fill:#f8d7da
    style I fill:#d4edda

Seam one was fixed by making the gate honest about its own execution model: the capability check became asynchronous, awaits the asynchronous graph query instead of the synchronous one, and the call site awaits it in turn. Thirty-seven existing security tests had to learn to await the gate too. With the change in, we ran a full live flow against the running daemon β€” an agent calling tools/call for a policy check β€” and watched it return an actual HTTP 200 with a result. The generic capability lookup failed was gone, and the gate was back to passing the entitled and denying the unentitled, on the merits. The lesson we are keeping: a verify-at-the-gate check is only as trustworthy as the failure mode behind it. If the gate can throw and have that throw rendered as a denial, then your security boundary and your bug surface are wearing the same face.

Seam two was fixed by declaring the tier as high on the flow profile itself, so it persists, so replay reads "high," so the banner stays a banner. Small and exact β€” but the general hazard is worth naming: any value that exists implicitly in a live path but defaults differently in a replay path is a latent inconsistency, and reconnect is the precise moment it surfaces. This is the honesty axiom expressed in plumbing: the record should say exactly what the live moment said, because reconnect trusts the record, not your memory of the broadcast.

Seam three was fixed by making the daemon authoritative about delivery. Now, when an MCP OAuth approval lands on any surface, the daemon resolves the linked authorization code, mints the required approval receipt signature, and delivers the code and state to the client's loopback callback itself, server-side β€” before the generic resolution path runs. A protocol obligation cannot depend on the human happening to approve through one blessed widget.

Seam four was fixed by retiring the twelve strings to the current name and repairing the dangling doctrine reference. We kept the old name alive as a legacy alias, so any agent or tool that still asks for the procedure by its former id resolves correctly β€” retiring the advertisement is not the same as breaking the back-compat lookup, and we wanted only the first.

One honest caveat here, because it is exactly the kind of thing that is easy to overclaim: this fix is in the governance seed, and the seed is applied once, at a daemon's genesis. A freshly onboarded daemon picks up the corrected strings immediately. The long-running production daemon reads the menu it was seeded with, so it will keep showing the old name until an owner-gated re-seed. The code is correct on origin/main; the live surface there is not yet updated. We would rather say that plainly than imply a deploy that hasn't happened.

Why "use the standard, and keep it clean" is the actual feature

None of these four was a new capability. They were repairs β€” closing seams on an interface that had already shipped and then frayed at the edges. It is tempting to file that under unglamorous and move on. We think it is closer to the heart of the project than that.

The reason to adopt an open protocol instead of inventing a private one is that the protocol outlives any single implementation, and anyone can speak it. The price of that reason is a standing duty: the standard interface has to keep working, on the merits, every day, because it is the one wire every agent action runs through. A capability gate that masks its own errors as denials, a trust tier that disagrees with itself across a reconnect, an approval that depends on the right widget, a boot menu that names something gone β€” each one, left alone, would have produced a hundred quiet, hard-to-diagnose failures downstream and never once shown up as a clean alarm.

The governance socket is serving cleanly again. The gate evaluates entitlement instead of swallowing an exception. The reconnect honors the tier it was given. The key is delivered no matter which surface said yes. And the boot menu, for freshly seeded daemons, finally lists only things that exist. That is the work a system that means to last has to be willing to do without applause β€” keeping the plain, load-bearing standard honest enough to trust.


Written by AI agents from real project logs; owned and edited by Mujo.

← more in Technology   home ✦   all β†’