Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Storage backends

Every substrate implements one Store trait over one uniform Record (ADR 0002, ADR 0004). Moving from local disk to git, S3 or a daemon is a configuration change, not a code change. Each substrate must pass the shared conformance suite that gonzalo-core ships (ADR 0006).

The facade

Rust consumers depend on the gonzalo crate and enable what they need (ADR 0009):

featurebrings in
fs (default)gonzalo-store-fs, the filesystem substrate
gitgonzalo-store-git, the git-backed substrate
s3gonzalo-store-s3, the S3-compatible substrate
remotegonzalo-store-server, a client Store over a running gonzalod
vectorgonzalo-vector: the Embedder trait and vector indexes
knowledgegonzalo-knowledge: records plus vector search by RecordKey (ADR 0011); implies vector
graphgonzalo-graph: the tree-sitter code graph
ticket, ticket-github, ticket-jira, ticket-linear, ticket-gitlab, ticket-asanathe ticket layer and its connectors

The core and gonzalo-domain (typed views for memory tiers, topics, sessions, checkpoints, tickets and fleet access-control records) are always included.

The surface is grouped into modules (ADR 0026). The root holds the record and store core — Record, RecordKey, Store, PutResult, the operations (sync, merge, collect, reset, gc_blobs) and the substrates. Every domain type lives under the module naming its domain:

moduleholds
gonzalo::memoryMemoryTier, Topic
gonzalo::sessionSession, Turn
gonzalo::checkpointCheckpoint
gonzalo::codecRecordCodec
gonzalo::ticketthe ticket records, plus the source layer and its connectors
gonzalo::fleetthe fleet access-control records
gonzalo::graph, ::vector, ::knowledgethe feature-gated capability layers

So a program with its own State or Actor keeps them: gonzalo's are gonzalo::ticket::State and gonzalo::ticket::Actor. Up to and including 0.7.0 all of these were exported flat at the root; the grouping lands in the next release, with no deprecation window, and the changelog entry carries the full old→new table. Each ticket-* connector feature implies ticket, and knowledge implies vector.

Choosing a substrate

substrategood fornotes
fsa single machine; the CLI and gonzalo-mcpzero dependencies. Per-record file locks make concurrent writers on one host safe. Two fs stores reconcile with gonzalo sync.
gitsharing state between machines or people through an ordinary git remotecommits every write. GitStore::pull(remote, branch) merges non-fast-forward histories by record content (ADR 0017); push publishes. Not a daemon substrate, and not a blob store.
s3several stateless gonzalod replicas over shared storagethe backend must support atomic If-Match conditional writes. See below.
remotea Rust program using a daemon someone else runsHTTP or gRPC, optional bearer token. See Running gonzalod.

The daemon can serve fs or s3 (GONZALO_STORE). The CLI and gonzalo-mcp work on fs roots.

S3: only atomic conditional writes are safe

Optimistic concurrency over S3 depends on the object store enforcing If-Match atomically. "S3-compatible" does not imply that. A store that checks and then sets lets concurrent writers all commit, and updates are lost without any conflict being reported, which is the one outcome gonzalo exists to prevent (ADR 0019).

backendatomic If-Matchstatus
RustFSyesthe qualified backend for multi-replica HA
MinIOyestechnically sound; not chosen, for project-sustainability reasons
Garagenounsafe: lets several racers commit

For AWS S3 itself, set GONZALO_S3_BUCKET with no endpoint and let the ambient AWS configuration supply region and credentials. ADR 0019's qualification runs did not cover it.

Qualify any other backend by running gonzalo-store-s3's conditional-write conformance case against it, not by reading its compatibility matrix. scripts/rustfs-up.sh starts a pinned single-node RustFS for local testing, and the gonzalo-soak crate runs replicas against it while killing them.

S3: what a listing costs

A tombstone lives at the deleted record's own object key, so a listing can't tell it from a live record by the record alone. A delete therefore also writes a marker beside it, at <object key>.tombstone, and list makes one ListObjectsV2 pass and reads only the keys a marker points at (ADR 0025). The cost is one read per tombstone, which collect bounds — not one per record, which nothing bounds. reset and collect enumerate the same way and get the same reduction.

A bucket written before markers existed upgrades itself. Until a collection carries the flag object <namespace>/<collection>/_tombstone_markers, list reads every key as it always did, writes the markers it finds missing, and then sets the flag. So the first listing of each collection after upgrading costs what it used to, every later one is cheap, and at no point can a deleted record reappear in a listing. There is nothing to run and nothing to remember.

Concurrency and conflicts

Writes are optimistic. put(record, expected) names the revision the caller believes is current (None for "expect no record"). If the store holds something else, the result is PutResult::Conflict carrying the live record. A conflict is a normal, typed, recoverable result, never an error and never a silent overwrite (ADR 0005).

Sync uses the same machinery. Records carry content-addressed ancestry, so two diverged copies merge 3-way against their common ancestor, with the merge rule chosen by record kind; append-only kinds auto-merge (ADR 0016).

Deletion

Store::delete(key, expected) is OCC-aware in the same way as put: with Some(rev) it deletes only if rev is still current, and otherwise returns DeleteResult::Conflict with the live record. Deleting an absent key is an idempotent success.

A delete writes a tombstone that sync and git pull replicate, so a delete made on one store takes effect on its peers. Consumer reads (get, list) hide tombstones; gonzalo collect removes old ones. See Deleting records, resetting namespaces, and collecting tombstones and ADR 0021, which supersedes ADR 0018's local-only deletion. Deletion replicates only once every binary that reads the store or runs sync is on 0.7.

Blobs

The fs and S3 substrates also implement BlobStore, a content-addressed store for large bodies. Blobs are keyed by content hash and written only if absent, so storing the same bytes twice is a no-op. Code-graph slices shared across views are stored this way (ADR 0012).

Because blobs are shared, nothing frees one implicitly — not deleting the record that holds it, and not collecting that record's tombstone, which pins the blob until it is collected. gonzalo gc sweeps what no record references, marking record bodies, tombstone pins and manifest slices alike (ADR 0024). Deletion walks through the order.

Deleting a vector index (or a code-graph view) is recoverable within the collection horizon: the manifest's tombstone keeps the manifest body, so its shards stay marked and gonzalo undelete restores it (ADR 0029). Reopening a deleted index is refused, so the restore window survives; the two ways out (undelete or purge) are in Deletion.

Vector search and embeddings

gonzalo-vector defines the Embedder trait and an exact in-memory index, MemoryVectorIndex. Its hnsw feature adds HnswVectorIndex, an approximate index for larger collections (ADR 0014). gonzalo-embed provides a local CPU embedder built on Candle and all-MiniLM (ADR 0013). It is a separate crate, not a facade feature.

A durable vector index

RecordVectorIndex persists vectors behind the same VectorIndex trait, so recall survives the process that built it — the working case is caller-supplied embeddings (an embedder gonzalo never runs, so it can never rebuild an index by re-embedding) (ADR 0027).

Vectors are bucketed into a fixed number of shards (256 by default), each one a content-addressed blob, named by a vector manifest record. A manifest records the embedding space, the dimension, the shard count, and which blob holds each shard's vectors — the same content-addressing the rest of gonzalo uses for blobs, so an unchanged shard costs nothing extra to rewrite.

open(store, key, space, dim) declares an embedding space and dimension. Opening against a manifest whose stored space or dimension disagrees is an error naming both values: a swapped embedder is caught once, loudly, at startup, rather than producing silently wrong rankings on every query after. This is a declared value, not a verified one — gonzalo never sees the model that produced a vector, so it can catch configuration drift but not a caller that mislabels its vectors on purpose or by mistake.

One writer per index. Optimistic concurrency detects a second writer and retries; it does not merge two writers' changes. A second writer's queries can be served from a shard it hasn't refreshed yet until it reopens, though a concurrent writer's actual commits are never silently lost — a conflict reloads every shard that changed and retries.

gonzalo gc already treats a vector manifest's shard blobs as live, the same way it treats a code-graph manifest's slices, so running GC against a store holding a durable vector index is much safer than it used to be while writers are running, but not risk-free. A sweep skips blobs younger than --min-age (one hour by default), and a writer re-checks the blobs it newly referenced after its manifest commits (ADR 0028). Two windows remain. A writer that crashes between its manifest commit and that re-check can still lose a newly referenced shard to a sweep. And a sweep that has already decided to delete an old shard blob deletes it even if a live writer references it a moment later, because the writer's re-check still sees the blob. Either loss is unrecoverable for vectors, since they cannot be regenerated the way graph slices can — see ADR 0027.

The whole index loads into memory when opened — there is no partial or paged load — so this is the right shape for the thousands-to-~100k-chunk working set, not for scale beyond it; an external backend (#202) is the answer there, still behind the unchanged VectorIndex trait.