← Conocimiento
Arquitectura Reseña

Bob’s memory system

A reference design for multi-user memory: recorded turns, curated decisions, scoped access and the shared-state exceptions.

Available in English · this article has not been translated yet.

Reference design, not a release checklist. This is a site edition of the already-published BoB memory architecture. The deployment described is a dated reference, not a guarantee about the default public installer.

A write-up of how BoBClaw’s memory actually works on this box (verified from the live code and live Qdrant), written for someone building RAG where several people must not see each other’s memories. Every number below was read off the running system, not estimated.

Scope note: this is a reference design, not a vendor brochure. Where Bob does something unusual because of its history (family file memory, the federation write fence), that is marked — you probably do not need to copy it.


BoBClaw memory architecture

1. The core idea: two surfaces, not one index

Most multi-user RAG failures come from putting everything into one collection and filtering by user_id at query time. One missing filter is a leak, and the failure is silent.

Bob instead splits memory into two independent surfaces with different authority:

SurfaceWhat it holdsRetrievalWho writes
Turn corpus (vector)Verbatim assistant turns, chunked and embeddedSemantic, top-kThe lane’s own core, automatically
Central file memory (flat)Curated paragraphs in per-owner MEMORY.mdLiteral substring, scope-gatedAgents, explicitly, via a /save call

They are deliberately not merged. The vector corpus is reconstructive — “what was said”, with provenance back to an event id, and it decays. The file memory is declarative — “what we decided”, with no embeddings at all, and it is searchable only by literal text.

That split means the curated memory stays auditable by a human (grep works), and the vector corpus can be rebuilt, re-embedded, or dropped per lane without touching the facts.

There is a third surface, the work/corpus lane (for a shared document corpus such as a company workstream). It has its own router, its own token, and a standing rule: personal memory may read the work lane; the work lane may never read personal memory. Enforced in the token scope, in gateway middleware, and again per call in the router.

2. The write path (zero LLM)

Every agent turn is projected into the lane’s vector store deterministically:

assistant turn
  → append event to hash-chained SQLite event log (L0)
  → CLAIM in a completion ledger keyed by (normalized_content_hash, task, task_version)
  → fixed-window chunker: 512 chars, 100 char overlap, header "[log <evt8> <date>] "
  → delete this event's old points, then embed + upsert  (delete-before-upsert)
  → ledger COMPLETE, storing the resulting point ids

Four things in that pipeline are worth stealing:

The T0 projection is enabled per lane with MEMORY_T0_RECALL_ENABLED=1. It indexes the assistant output, so a marker that only appears in the user prompt will not appear in the vector store — a classic false-negative in smoke tests.

3. The read path

last user message → truncate to 3072 chars (head + tail, marker in the middle)
  → embed with slot embed_text
  → query_vector, over-fetch (max(top_k*4, top_k+10), hard cap 200 candidates)
  → drop score < 0.35, assign RRF score 1/(60+rank+1)
  → validate each hit against the fact store; skip dangling and deprecated
  → multiply by read-time credibility decay  → sort → top_k (default 5) into the prompt

Design notes that matter in production:

4. Tenancy: the lane, and what is actually shared

A “lane” is the unit of isolation. Each lane gets all of:

What is shared, on purpose:

Rule: the write fence. A lane may only write a collection whose federation registry record declares that lane as writer with a write-capable mode (rw/w/…). Unknown collection, missing ACL, garbled ACL, or a different writer ⇒ refused, fail-closed. That is what stops two cores from co-writing one collection and corrupting each other’s ids. The same registry stamps an embed fingerprint (model + dimension), and a query against a stamp-mismatched instance fails closed rather than silently comparing incomparable vectors.

5. Authorization: scope, not client claims

The gateway authenticates a human (password + TOTP), then mints an agent token whose claims include token_type: "agent" and a signed Scope:

{
  "may_touch":     ["/srv/work/<repo>/**", "/srv/family/<owner>/MEMORY.md", "/srv/family/shared/SHARED_MEMORY.md"],
  "may_not_touch": ["/srv/family/<other-owner>/**", "…/.secrets/**"],
  "auto_actions": [], "escalate_actions": [], "budget_usd": null
}

evaluate_path(path, scope) returns auto / gate / human, fail-closed on no match. The memory router then does three things no client can influence:

  1. Derives the owner from the filesystem scope, never from a request field. A save is accepted only if exactly one private owner path evaluates auto; otherwise single_private_owner_required. There is no owner parameter to lie about.
  2. Enumerates sources per caller. status/search only iterate owners whose MEMORY.md the token can touch, so one lane’s token never sees another lane’s paragraph even in a count.
  3. 404s foreign ids. read(source_id) looks the id up inside the already-filtered source set — an id belonging to another owner is indistinguishable from a nonexistent one.

The gateway then HMAC-attests the scope to the core, bound to the exact scope bytes, so a direct caller to the core cannot self-assert a fatter scope. Treat retrieved memory text as untrusted evidence, not instructions — it is another user’s prose arriving in your prompt.

6. Shape of a live deployment

Numbers are given as magnitudes on purpose — the point is the shape, not this host’s activity level.

7. Failure modes this design actually hit

8. Build checklist for your own multi-tenant RAG

  1. Pick the isolation boundary before the vector DB: one store per tenant, not a payload filter. If you must share a collection, make the filter mandatory in one chokepoint that fails closed.
  2. Give every tenant its own append log, its own write ledger, its own config file with a unique collection prefix. Derive all three from one tenant id so they cannot drift.
  3. Write via a deterministic projection with a content hash and a claim row. No LLM in the write path until the deterministic version is boring and correct.
  4. Store task_version, prompt_version, embed_model and dim on every point. Reject queries whose embed fingerprint differs from the store’s.
  5. Enforce one writer per collection, asserted before every upsert.
  6. Derive the tenant from the credential, never from the request body, and make cross-tenant ids return 404 rather than 403.
  7. Return provenance and an explicit coverage statement (complete_corpus, excluded classes, counts by kind) with every search response. It is the difference between a demo and a tool.
  8. Keep a separate, human-readable, non-embedded surface for decisions your users will need to audit. grep is a feature.
  9. Test with a control, always: write a marker in tenant A, assert it is in A’s collection, assert it is absent from B’s, assert B’s point count did not move, then recall the marker in A without including it in the prompt.
  10. Log recall metrics (candidates scanned, dangling skipped, results returned) — counts only, never content. Dangling-vector rate is your earliest signal of index drift.

License

Text and diagram: CC BY 4.0 © 2026 Canopy Seed. Share and adapt it freely, including commercially, with credit. A Canopy Seed project.