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):
| feature | brings in |
|---|---|
fs (default) | gonzalo-store-fs, the filesystem substrate |
git | gonzalo-store-git, the git-backed substrate |
s3 | gonzalo-store-s3, the S3-compatible substrate |
remote | gonzalo-store-server, a client Store over a running gonzalod |
vector | gonzalo-vector: the Embedder trait and vector indexes |
knowledge | gonzalo-knowledge: records plus vector search by RecordKey (ADR 0011); implies vector |
graph | gonzalo-graph: the tree-sitter code graph |
ticket, ticket-github, ticket-jira, ticket-linear, ticket-gitlab, ticket-asana | the 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:
| module | holds |
|---|---|
gonzalo::memory | MemoryTier, Topic |
gonzalo::session | Session, Turn |
gonzalo::checkpoint | Checkpoint |
gonzalo::codec | RecordCodec |
gonzalo::ticket | the ticket records, plus the source layer and its connectors |
gonzalo::fleet | the fleet access-control records |
gonzalo::graph, ::vector, ::knowledge | the 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
| substrate | good for | notes |
|---|---|---|
| fs | a single machine; the CLI and gonzalo-mcp | zero dependencies. Per-record file locks make concurrent writers on one host safe. Two fs stores reconcile with gonzalo sync. |
| git | sharing state between machines or people through an ordinary git remote | commits 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. |
| s3 | several stateless gonzalod replicas over shared storage | the backend must support atomic If-Match conditional writes. See below. |
| remote | a Rust program using a daemon someone else runs | HTTP 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).
| backend | atomic If-Match | status |
|---|---|---|
| RustFS | yes | the qualified backend for multi-replica HA |
| MinIO | yes | technically sound; not chosen, for project-sustainability reasons |
| Garage | no | unsafe: 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.