Inspiration

Two failures, one problem.

Most agent memory appends every turn and retrieves by similarity. After a few thousand entries the same fact is stored eleven times in eleven phrasings, retrieval returns all eleven, the context window fills with restatement, and the answer gets worse as the system "learns" more. Memory that only grows is not memory. It is a log.

The second failure is quieter. One vector table for every user means every search traverses everyone's vectors, and the correctness of isolation rests on remembering a WHERE clause. It also means one physical location for data that different jurisdictions require to sit in different places. That is not a filter problem. It is a storage problem, and no application-level check fixes it.

A naaba is a chief among the Mossi of Burkina Faso: the one who holds and passes on what the community knows. Not everything that was ever said. What is worth keeping, kept in its place.

What it does

Naaba is an MCP server with four tools: remember, recall, forget, curation_state.

Isolation is in the index, not in the query. CockroachDB's vector index accepts prefix columns, so VECTOR INDEX (user_id, kind, embedding) builds one search tree per user and per kind rather than one tree filtered afterwards. A forgotten predicate degrades into no results instead of into someone else's memories.

Location is a property of the row. With REGIONAL BY ROW, a memory written in Frankfurt is stored in Frankfurt and one written in Singapore is stored in Singapore, under one logical database and one query. Residency stops being a second deployment and becomes a column.

The two partitionings compose rather than collide: CockroachDB puts crdb_region ahead of user_id in the vector index prefix, so a search narrows to one region and one user without a line of application code. The documentation says nothing about this. It is measured, not assumed, and the query plans are in the repository.

Forgetting is a policy. Duplicates are merged rather than accumulated, each kind has a ceiling, what gets evicted is chosen by how recently it was useful rather than by age, and session memories expire. Every automatic deletion leaves a journal line recording the kind, the age, the number of recalls and the rule that applied. Never the content: a record of forgetting that kept what was forgotten has not forgotten anything.

None of the four tools takes a user_id. The index bounds a search structurally, but that guarantee evaporates if the caller names the user in an argument, because changing one UUID would read somebody else's memories. Identity arrives with the bearer token and nowhere else. A test defends the absence of that parameter, for the day someone adds it while debugging.

How we built it

CockroachDB Basic for storage: partitioned vector index, row-level residency, and a GLOBAL table for the API keys that every request reads. AWS Bedrock for vectors, with Titan Text Embeddings V2 at 1024 dimensions. AWS App Runner in Frankfurt for the deployment, with the connection string in Secrets Manager rather than an environment variable, and a container role that may invoke exactly two models and read exactly one secret.

Challenges we ran into

The deduplication threshold does not exist. This was going to be a similarity threshold, until I measured the distances. On Titan v2, "call him in the morning" and "call him at the end of the day", two contradictory instructions, sit 0.9016 apart. "The head office is in Ouagadougou." and the same sentence with its accents stripped sit 1.1523 apart.

The contradiction is closer than the restatement. An embedding measures shared subject, not shared claim: negation and opposition barely move the vector. The two populations overlap, so no threshold accepts every restatement without accepting some contradictions, and merging on one would destroy information with no way back.

So similarity search narrows the field to one candidate and a model decides. On the seven pairs where distance failed, Amazon Nova Micro is right seven times out of seven. If the judge is unavailable nothing is merged, because an extra memory is recoverable and an overwritten one is not.

Residency cannot have a safe default. SELECT gateway_region() returned aws-ap-southeast-1 while the cluster's declared primary region was aws-eu-central-1. An implicit fallback would have filed memories according to where the client happened to connect from. The home region is now mandatory and the process refuses to start without it.

A predicate after a vector search cannot rescue anything. It removes rows from the k the index returned; it never reaches past them. Deduplication filtering by kind in Python meant a user with five closer memories of other kinds never had a duplicate detected, silently, on the feature the project is built around. The fix belongs in the index prefix, not the code.

Accomplishments that we're proud of

Nine silent failure modes found and closed, one at a time. None of them raised an exception where it should have: every one produced plausible results, or produced nothing while letting you believe the rule was being applied.

A query vector built by a subquery makes the plan fall back to a full scan with no warning, so a script now reads the plan. A model that ignores normalize gives a slightly wrong ranking everywhere, forever, so the norm is checked on every response. The MCP SDK wraps tool exceptions and forwards the text to the client, and a psycopg failure contains the database host, port and user, so every tool answers with a neutral sentence and logs the rest. A judge that cannot reach Bedrock returns "not a duplicate" and looks exactly like a strict judge, so a check now exercises it against the deployed container in both directions.

And the whole thing is deployed and verified by a real MCP client against the live URL, with tokens issued from my own machine against the same cluster, so the deployment has no way to vouch for itself.

What we learned

That the interesting failures in a memory layer are the ones that return an answer. Anything that crashes gets fixed on the first run. What survives is the code that is confidently wrong, and the only defence is to measure the thing you assumed instead of reasoning about it.

What's next for Naaba

remember reads then writes without a transaction, so two agents storing the same fact for the same user at the same instant can create two rows where one was needed. The result is a duplicate rather than a loss, which is the direction to be wrong in, but the honest fix is a uniqueness constraint on (crdb_region, user_id, kind, content_hash), moving the guarantee from the application into the schema, exactly as the isolation already is.

Built With

  • amazon-bedrock
  • amazon-ecr
  • amazon-nova
  • amazon-web-services
  • anyio
  • aws-app-runner
  • aws-iam
  • aws-secrets-manager
  • boto3
  • cockroachdb
  • docker
  • mcp
  • model-context-protocol
  • psycopg
  • python
  • sql
  • starlette
  • titan-embeddings
  • uvicorn
  • vector-search
Share this project:

Updates