My Architect, part 2: an architecture with no database
This is the second post in the series about My Architect. In part 1 I explained why an agent needs memory between sessions and how one project serves two interfaces: a canvas for the human and MCP for the agent. Now, about what all of this stands on. Spoiler: there is no database, and that's a deliberate decision I once paid for with lost data.
Storage: just files
The whole project on disk looks like this:
workspace.yaml
projects/<id>/
project.yaml # metadata, releases
hierarchy/hierarchy.yaml # node tree
requirements/ # FR, NFR, SAR, CON
diagrams/<id>.yaml + .dsl # metadata + text DSL
docs/<id>.md # node documents
The hierarchy and requirements live in YAML, documentation in Markdown, diagrams are stored as YAML metadata plus a text DSL. Everything is readable by eye and editable with any editor. You can open hierarchy.yaml, rename a node by hand, and the system will pick it up. You can put the workspace in git and get the history of every decision for free: each change to the plan shows up in a diff. And no lock-in: if My Architect disappears tomorrow, your data remains plain text files.
The stack around the files is modest. A Fastify and TypeScript backend listens on localhost:3100, the frontend is React 19, and the MCP server talks to the agent over stdio. One important detail: the MCP tools don't touch the files directly — they go through the same HTTP API the browser uses. One domain logic, one set of validation rules, two clients.
The bug that forced me to write a mutex
Files instead of a database mean transactions are now my problem. I knew this in theory. In practice it caught up with me on the plan_release tool.
The scenario: the agent plans a release and assigns fifty-odd nodes to it. The first version of plan_release dutifully issued one PUT request per node, all in parallel. Each request did the classic read-modify-write: read hierarchy.yaml in full, change one node, write the file back. Fifty requests read the same snapshot of the file almost simultaneously, and each wrote its own version over everyone else's. Last-writer-wins in its purest form: only the assignments of the nodes whose write landed last survived. About 85% of the assignments simply evaporated. The agent reported success, the human opened the User Story Map and saw a half-empty release.
The fix ended up two-layered. First, a bulk endpoint PUT /hierarchy/nodes/bulk appeared: plan_release now sends all assignments in a single request, and that's one read-modify-write instead of fifty. Second — and this is the main part — project-mutex appeared: an async lock keyed by project, through which all mutations pass.
const chains = new Map<string, Promise<unknown>>();
const owners = new AsyncLocalStorage<Set<string>>();
export async function withProjectLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const held = owners.getStore();
if (held?.has(key)) return fn(); // re-entrant: we already hold the lock
const prev = chains.get(key) ?? Promise.resolve();
// ... join the FIFO queue behind prev
}
The whole file is 33 lines. Operations on a single project line up in a FIFO queue through a chain of promises; different projects run in parallel and don't wait on each other. The lock is re-entrant: the owner is tracked via AsyncLocalStorage, so a domain function holding the lock can call another domain function of the same project without hanging in a deadlock. Without this I would have had to spawn "internal" lock-free versions of every function — a sure path to someone eventually calling the wrong one.
File storage doesn't free you from concurrency. It only makes the consequences visible: here's the file, here are two writers, here are the missing lines in the diff.
Atomic writes and the file watcher
The second trap of file storage: a reader can see a half-written file. The solution is as old as POSIX: write to a temporary file and rename.
const tmpPath = `${filePath}.tmp.${Date.now()}.${random}`;
await fs.writeFile(tmpPath, content, 'utf-8');
await fs.rename(tmpPath, filePath);
rename on POSIX systems is atomic: the file at the target path is either entirely the old one or entirely the new one — no intermediate state exists. All writes go through this atomicWrite function: hierarchy, requirements, docs, diagrams. And the watcher is configured to ignore the */.tmp.* mask, so temporary files don't generate spurious events.
Live sync
The watcher is built on chokidar and observes the entire workspace. When a file changes, the event is classified by path: hierarchy/ means "re-read the tree", requirements/ means "re-read the requirements", and so on. Then a 100 ms debounce, a fresh read, and a broadcast to the browser over WebSocket. The canvas updates without a page reload.
This is the scheme that produces the effect from part one: the agent closes a task via MCP, the file changes, the human sees the node turn gray right during the session. It works the other way too: editing a file by hand or a git checkout of another branch also lands in the browser.
One subtlety: the server writes the files itself and watches them itself, so without protection every one of its own writes would echo back. The markOwnWrite hook tags a file before writing, and the watcher ignores events for it for 200 ms. External edits get through, the server's own echo is suppressed.
Files are agent-native design
The old-school reflex says: a tool stores projects, tasks and requirements, therefore there's a database underneath. My Architect was designed from a different starting point: its primary user is an agent, and an agent's native habitat is text files. Claude Code and any MCP agent can read, grep and edit text directly; YAML with the hierarchy and Markdown with the docs need no intermediary at all. A plain git diff of the project plan is ready-made review material the agent reads as freely as a human does.
A database adds nothing to this picture and takes plenty away. The data moves behind SQL and a schema, and a maintenance layer grows around it: migrations, connections, docker-compose, backups, an admin panel. To the agent all of that is opaque — instead of "open the file and read it" you get "go through an API someone has to maintain". Extra load on the system with zero profit: there isn't a single job a database would do here that files do worse.
- The concurrency guarantees people usually reach for a database for are provided by a 33-line mutex: one server, one process.
- The data is local and small: even a big project is hundreds of nodes, megabytes of YAML. Reading the whole hierarchy is cheaper than a network round-trip to a database.
- History, backup and review of plan changes are just git, for free. With a database the same things would have to be built by hand.
- Installation is trivial: no daemon, no migrations. Clone it, run it, it works.
The price of the decision is discipline: every mutation must go through withProjectLock and atomicWrite. That discipline is guarded by tests — 746 of them at the moment — including the exact scenario that bit me: 50 parallel updateNode calls assign a release, and the test checks that all 50 survive. It used to be seven or eight.
If tomorrow I need a multi-user server with thousands of projects, I'll take Postgres without regret — at that scale a database has actual work to do. But for a tool that lives next to the code and whose primary user is an agent, a database would solve problems that don't exist and wall the data off from the one who works with it.
Up next
In part 3 I'll break down the planning model: the node hierarchy, requirements inherited down the tree, and the User Story Map with releases. That is, the thing all this file machinery exists for in the first place. You can try My Architect at my-architect.app.