NAOMS Devlog

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

One Source of Truth, No Second Database to Drift From

Killing 879 raw SQL calls and Neo4j by making the signed chain its own graph

Technology Architect March 28, 2026ยท6 min readยทstorage-sync
TL;DR Your data now lives in exactly one place that can't quietly disagree with itself. We deleted two separate databases' worth of habits โ€” Neo4j and 879 scattered raw queries โ€” and made the one signed record of truth answer every query directly, with guardrails so the old habits can't creep back.

A system's data layer is a confession of what its authors believed when they wrote it. NAOMS, by this week, had accumulated two beliefs it no longer held: a Neo4j graph database off to one side, and raw SQL queries scattered through the application code. This week it deleted both โ€” and the more interesting story is what it did instead.

flowchart LR
  E[signed event] --> R[reducer] --> N[graph node] --> Q[your query]

The headline numbers are real and we'll hold ourselves to them. Neo4j removal deleted 3,225 lines and shipped on 2026-03-28. The raw-SQL removal sized its own target precisely: 879 calls across 97 files (2026-03-28). By the 28th the migration was well underway โ€” the API-routes file alone went from 83 raw SQL calls to zero, replaced by 78 graph-API calls โ€” and the final death condition, zero raw SQL in application code, lands the next day. We'll be careful about that boundary later; the architecture is what we want to build first.

Two databases, one too many

Start with the shape of the problem. NAOMS stored graph-ish data โ€” trust edges, relationships, materialized memory nodes โ€” and it had reached for the obvious tool: Neo4j, a real graph database, with Cypher as its query language. Alongside it, plain SQL for everything tabular. Two storage engines, two query languages, two operational dependencies.

The trouble isn't that either tool is bad. Neo4j is excellent at what it does. The trouble is what two of them cost an architecture that has a third thing โ€” the chain โ€” which is the actual source of truth. Every fact in NAOMS originates as a signed event appended to a chain. Neo4j and SQL were both, fundamentally, caches of that truth, shaped for querying. And maintaining two query-shaped caches of a single signed source means two places to keep in sync, two consistency stories, two things that can silently diverge from the chain that's supposed to be authoritative.

The replacement names itself directly: a graph-native database โ€” a unified chain primitive with a graph API โ€” and it shipped on 2026-03-28 with 72 tests and production migrated. The bet is that you don't need a separate graph database if your chain primitive can be the graph.

The replacement: a chain primitive with a graph API on top

Here's the architecture the week converged on. Instead of "chain โ†’ Neo4j + SQL โ†’ queries," the stack became "chain โ†’ graph nodes โ†’ graph API โ†’ queries," with no second engine.

The flow is:

  1. A signed event is appended to a chain (the only way anything becomes true).
  2. A reducer for that event type materializes it into graph nodes โ€” the queryable projection of the event.
  3. Application code reads through a graph API (graphQuery / graphPut), never through raw SQL.

This is why the migration was a graph migration and not just a "swap one SQL dialect for another." The calls weren't rewritten to different SQL โ€” they were rewritten to graphQuery and graphPut against materialized graph nodes. The api-routes rewrite captures the pattern exactly: 83 raw SQL calls became 78 graph-API calls (2026-03-28). Reads and writes now go through a single typed surface that sits on top of the chain, not beside it.

The materialization itself got its own home this week: the Materializer Registry, whose guiding question is the whole idea in one line โ€” "How can chain event types declare their own graph materialization without editing reducer source code?" Each event type registers how it projects into graph nodes; the registry grew to 78 materializers (from an intermediate 66). That registry is what makes graph-native sustainable: adding a new queryable thing means registering a materializer, not hand-writing SQL in 97 files again.

A caveat on status: the materializer registry was still in progress โ€” not closed out โ€” at this point. So we're describing it as the in-progress machinery this migration leaned on, landing alongside the SQL kill, not as a finished item. The raw-SQL kill, the graph-native database, and the Neo4j removal all reached their closeout; the registry that backs their long-term sustainability was still being finished. That's the honest state.


Structural enforcement: making the old habit impossible, not just absent

Deleting 879 SQL calls is a migration. Keeping them deleted is an architecture problem, and this is the part we find most instructive.

A one-time migration decays. Someone, three weeks later, reaches for a raw query because it's faster in the moment, and the count creeps from zero back up. The honest way to prevent that isn't a code-review reminder โ€” it's to make the wrong thing structurally unavailable. The raw-SQL kill did this in three composing moves:

  • A guarded query primitive. A single safe graph-query call to use, and a pre-commit hook โ€” a static check that bans raw SQL โ€” that fails the commit if raw SQL reappears in application code. The guard runs before the bad code can land, not after.

  • Writes restricted to reducers. Application code can read the graph, but only a reducer โ€” the thing that materializes a signed event โ€” may write a graph node. This is the load-bearing constraint: it means a graph node can't exist unless a signed chain event caused it. The cache can't lie about the chain because the cache can only be written by replaying the chain.

  • Writes restricted to chain append at the MCP boundary. Agents reaching in through the tool surface get exactly one write verb: append a signed event. They cannot poke the graph directly at all.

Stack those three and the result is a closed loop with no side doors. The only way to create truth is to append a signed event; the only thing that writes graph nodes is a reducer replaying those events; raw SQL can't even be committed. The "no raw SQL" property stops being a thing you maintain and becomes a thing the system is. That's the same move NAOMS makes everywhere worth trusting: authentication that's structural rather than remembered, public-read that's a branch policy rather than a special case, and here, graph-native storage that's enforced by what's possible rather than by discipline.

The honest boundary: 879 to zero spanned two days

We promised to be careful about the timeline, so here it is straight.

On 2026-03-28 โ€” this article's date โ€” the migration was substantially done but not complete. The plan was written, Neo4j was gone, the graph-native database had shipped, the api-routes file was at zero, and the pre-commit guard was in place. But the application-wide death condition โ€” zero raw SQL โ€” was met the next day, when the codebase formally hit zero raw SQL in application code (2026-03-29). And there's an honest scar in between: the day before the finish, an audit deliberately published "31% death conditions pass, gaps documented" (2026-03-28) โ€” the team shipped the unflattering true number mid-flight rather than waiting for green.

So if you take one factual claim from this piece, take the careful one: as of 2026-03-28, raw-SQL removal was in-progress and near-complete; the zero-SQL death condition formally landed on 2026-03-29. Neo4j removal, the graph-native database, and the raw-SQL kill are all closed out; the materializer registry that sustains them was still in progress.

Why it's built this way

The deepest reason to delete two databases isn't simplicity for its own sake. It's that NAOMS has exactly one source of truth โ€” the signed chain โ€” and every additional query engine is another thing that can quietly drift from it. A graph that can only be written by a reducer replaying signed events can never disagree with the chain, because it has no independent existence. Two databases were two opportunities for the cache to lie. One graph API, fed only by reducers, fed only by signed events, has none.

The hard part of this migration was never the find-and-replace. It was building the walls โ€” the pre-commit hook, the reducer-only writes, the chain-append-only MCP boundary โ€” so that "zero raw SQL" is a property the system enforces on itself rather than a number that decays the moment attention moves on. What's next is finishing the materializer registry so that every new queryable concept arrives by registration, not by reopening the door this week worked so hard to weld shut.


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

โ† more in Technology   home โœฆ   all โ†’