DEV Community

Cover image for Storing decisions instead of memory: the design behind kgai
kgaidev
kgaidev

Posted on

Storing decisions instead of memory: the design behind kgai

Coding agents have a specific failure mode on long-lived codebases. Every few sessions the agent re-proposes something the team already evaluated and rejected. It reads the code, sees a slow search query, and suggests Elasticsearch. The reason Elasticsearch was rejected months ago exists only in a chat thread, so from the agent's point of view the question was never settled.

Humans on the team hit the same wall in slower motion. git blame tells you who changed a line and when, the PR tells you what was merged, but neither records why the other approach lost. The reviewer who knew leaves the team, and six months later someone re-litigates the whole thing in a new PR.

This post describes how kgai (https://github.com/kgaidev/kgai, MIT) approaches that as a data problem inside the dev workflow, and which properties fall out of the design. No prior knowledge of knowledge graphs is assumed.

The data model: boxes and arrows

A knowledge graph here means nothing more than a map of things and connections. The things are the parts of a codebase as you'd sketch them in a design review:

Elements of a codebase connected by labeled links, with properties on the side

Each box is an element. Elements carry properties, like search-api.retry_policy = "idempotency-key + backoff". That's the entire vocabulary: elements, links, properties. It lives in <project>/.kgai/store, next to the code it describes, one store per repo.

The graph itself is not the interesting part. The interesting part is where it comes from.

Decisions as the source of truth

A bank doesn't store an account balance as a number that gets edited. It stores a list of transactions, and the balance is derived by summing them. A transaction, once recorded, is never modified. Corrections are new transactions.

kgai applies the same structure to a codebase's architecture. The current shape (the graph above) is the derived value. The recorded facts are decisions, captured during the session in which they were made:

✓ recorded "Search retries use idempotency keys + exponential backoff"
    why: fixed 3x retry double-charged a request during a provider brownout
    sets search-api.retry_policy = "idempotency-key + backoff"
    supersedes "Simple 3x retry is enough"
Enter fullscreen mode Exit fullscreen mode

A decision states what changed, why, and which elements it touches. The queryable graph is computed by replaying the decision log from the beginning. Deleting the graph and replaying the log yields byte-identical results, so the log is the truth and the graph is a disposable cache.

An append-only decision log replayed into a derived graph

This is event sourcing, the same pattern financial systems use. Applied here, the "account" is the reasoning behind a repo.

Supersession instead of edits
A decision is never updated. A change is expressed as a new decision that supersedes the old one. The old decision remains in the log, marked superseded:

$ kg history "feature:search-api"
feature:search-api — 2 decisions, oldest first

  2026-05-02  Simple 3x retry is enough                                superseded
      why: provider timeouts are rare, simplest thing that works

  2026-07-16  Search retries use idempotency keys + backoff            ● current
      why: fixed 3x retry double-charged a request during a brownout
Enter fullscreen mode Exit fullscreen mode

A superseded decision pointing to the current one, history keeps both, context serves one

Two properties follow.

Rejected paths stay recorded. When a teammate opens a PR that swaps the retry logic back to something simpler, the review comment writes itself: the graph holds the incident that killed that approach. The same applies to QA reading unexpected behavior, or to the new hire asking why the API boundary sits where it sits. The answer is one query away instead of one departed colleague away.

Recall stays clean. When an agent asks what's decided about an area, it receives only the current head decisions. Superseded decisions are reachable through history but are not injected into the model's context. This matters in practice: a language model given two contradictory rationales will sometimes follow the outdated one. Serving only the current state removes that failure mode while keeping the full record.

Idempotence and what it means for a dev team

An operation is idempotent when performing it twice has the same effect as performing it once. An elevator button is idempotent.

In kgai a decision's identity is a hash of its content. Recording the same decision twice, from two machines, in any order, converges to a single record, because identical content produces an identical id. There is no sequence counter to coordinate and no last-writer-wins overwrite.

The consequence for a team: every dev's agent appends to its own log file, and sync is a union of logs over plain git or an S3 bucket. Textual merge conflicts cannot occur, because nothing is ever edited in place. Every machine replays the combined log and arrives at the same graph deterministically. Nobody maintains a wiki, nobody merges anybody's notes.

Two developers' logs synced through git or S3 into byte-identical graphs

The only conflict that can exist is a semantic one. Two devs on two branches genuinely decided the same thing differently, say Alice put session state in Redis while Bob kept it in-process. Both decisions stand in the log and the element then has two current heads, which kg conflicts reports, typically surfacing at review time. Resolution is one more decision that supersedes both. The disagreement and its resolution both remain in history, which is occasionally more useful than the resolution itself.

Performance characteristics

An append-only log grows forever, which sounds like a scaling problem. It isn't, because queries never touch the log. Reads go to the derived graph, which lives in an embedded property graph database (Kuzu) inside the project directory. Writes are file appends. Rebuilds stream the log through a bulk loader rather than applying events one at a time.

Measured on stores larger than a real team produces, one million decisions across fifty thousand elements written by thirty concurrent users:

store size everyday sync cold clone full rebuild lookup
20k decisions < 0.5 s ~3 s ~1.5 s ~60 ms
100k decisions < 0.5 s ~12 s ~6 s ~60 ms
1M decisions ~2 s < 2 min < 1 min ~100 ms

Lookup latency stays flat from 20k to 1M decisions

Cold clone is the onboarding path: a new teammate pulls the full decision history and has a queryable graph in seconds on a normal-sized store. These are single-run wall-clock numbers from our harness on our hardware, not percentiles. The raw runs are archived in the repo. Query latency stays flat with history size because the graph holds only current state.

The read path contains no vector search and no LLM call. Retrieval is scoped by element and path identifiers, so recall is deterministic and adds no token cost beyond the decisions themselves.

Capture is enforced, not requested

Decisions enter the log during normal coding sessions. Today that's wired into Claude Code as a plugin, and the engine is a single CLI, so any agent environment that can run a command can use it. When a session that edited code ends, a hook blocks the agent until it either records the structural decision it made or explicitly states there was none. This is deterministic by design. In testing, unprompted capture by the model alone proved unreliable, so the capture step is enforced at the hook level. Trivial changes (renames, formatting, bug fixes) record nothing, which keeps the log at decision granularity rather than becoming a diary.

Before modifying an area, the agent queries the graph for decisions relevant to the files it's about to touch. The Elasticsearch case from the beginning then resolves differently: the agent finds the rejection and its reasoning, and works within it. So does the next dev who asks.

Boundaries

The design has edges worth knowing. Free-text search over a very large store scans the corpus (about 1.4 s at 100k decisions), an index for that is planned. A 1M-decision log is roughly 790 MB on disk. Prebuilt engine binaries exist for Linux and macOS, not Windows.

Everything runs locally. Sync targets are a git remote or an S3 bucket the team owns. The engine, plugin and benchmark harness are MIT: https://github.com/kgaidev/kgai

Top comments (0)