gonzalo
Robust, shareable persistence layer for caliban.
This guide is under construction. See the architecture decisions for design rationale, and the API reference for crate docs.
Guiding Principles & Invariants
Gonzalo's design philosophy is otherwise recoverable only by reading the full ADR log and the founding design spec. This page synthesizes that philosophy into one place: the guiding principles that shape the system and the inviolable invariants it must never break. It complements — it does not replace — the ADRs; each item cites the ADR(s) it derives from, and should be kept in sync when those are superseded.
Guiding principles
- One uniform
Record, one genericStore("Approach A"). Every domain type is a serde view overRecord { key, kind, revision, parent, body, meta, links }; versioning, concurrency, conflict, and sync are written once in the core (ADR 0002). - Substrate pluggability — the backend is configuration, not API.
fs/git/S3/daemon-client each implement only
Store; moving from local to git/S3/daemon is a config change, not a code change; fs is the zero-dependency default (ADR 0004, 0009). - Optimistic concurrency with explicit, typed conflict surfacing.
put(record, expected_parent_rev); a stale parent yieldsPutResult::Conflict(recoverable, not an error); merge is keyed byRecordKind, andSyncreuses the exact same machinery (ADR 0005). - Layering discipline — capabilities compose over a storage-only core.
Vector, graph, tickets, and knowledge are added as layers, each keyed by
RecordKey; substrates never know about vectors or graphs (ADR 0008, 0010, 0011, 0012). - Minimal-core-touch for new capabilities. A new layer may register a
RecordKindat most — no new core traits or types enter (ADR 0010, 0011). This aligns with the rule of three for promoting anything into the core. - Provider-agnostic boundaries via traits. Embedding goes through
Embedder, tickets throughTicketSource; provider divergence is handled bycapabilities()negotiation, neverif provider == …branches (ADR 0008, 0010). - The conformance suite is the executable contract. One shared conformance
suite that every
Storeimplementation must pass (ADR 0006). - A single canonical schema behind dual transports. gRPC (tonic) and
HTTP/JSON (axum) sit over one service layer;
gonzalo-protois the single schema, so the transports cannot drift (ADR 0007). - Single-facade public surface + workspace discipline. Caliban depends on
one crate — the
gonzalofacade; substrates and layers are toggled by Cargo feature (ADR 0009). - Normalize on the shared spine, preserve the raw losslessly. Tickets
normalize to
State { category, resolution, raw_name, raw_id }plus a boundedfieldsmap; the status signal is configured per connection, not hard-coded per provider (ADR 0010). - Separate storage identity from query identity. The code graph keys slices by content + grammar hash (which dedups) and resolves them through a per-worktree manifest — git's blob/tree split (ADR 0012).
- Retrieval returns first-class records, never bare ids. Vector, graph,
knowledge, and ticket queries resolve back through the
Storeto whole records (ADR 0008, 0011).
Inviolable invariants
- Concurrent edits are never silently lost — the core invariant. A
stale-parent write MUST return
Conflict, never overwrite; ambiguous merges MUST surface (ADR 0005). - Conflict is a typed, recoverable result —
PutResult::Conflict, never collapsed intoGonzaloError(ADR 0005). - All persistence funnels through the one
Record/Store— no parallel typed store re-implements versioning (ADR 0002). - Substrates implement only the generic
Storeand stay type-blind — no substrate-specific escape hatches (ADR 0004, 0008). - Every
Storeimplementation must pass the shared conformance suite (ADR 0006). - Capability layers never bypass or mutate the core — a layer may register
a
RecordKind+ merge class at most (ADR 0008, 0010, 0011). Syncreuses the exact local-write conflict/merge machinery — anyStorecan be a sync peer (ADR 0005).- The daemon's two transports derive from one canonical schema + one service
layer (
gonzalo-proto) (ADR 0007). - The code graph is NEVER keyed by
(repo, path)— two-level keying; slices are content-addressed, path-agnostic, and stored raw, and resolution tolerates missing targets (ADR 0012). - No query engine ever sits under the
Storesubstrate — engines back only regenerable index layers, never the durable source of truth (ADR 0012). unsafe_codeis forbidden workspace-wide (ADR 0009).- License is AGPL-3.0-only (ADR 0003).
- The core does no I/O —
gonzalo-coreis pure logic; all I/O lives in substrates (design spec §3). - Every write carries provenance identity — an
Identity;Metarecordsauthorandorigin_system(design spec §4, §9). - ADRs are an append-only log — superseded, never deleted (ADR 0001).
The MCP server
gonzalo-mcp exposes the code graph to an agent over MCP (stdio). An agent can ask
where a symbol is defined, who calls it, what a view contains, and what changed
structurally between two views.
The two-step model
The single most important thing to know:
gonzalo indexwrites.gonzalo-mcponly reads.
The server never indexes anything. Install and register it without indexing and you get a server that answers every query with "no indexed view" — correctly, but forever. Indexing is not optional and not discoverable from the server.
gonzalo index ──writes──▶ store root ◀──reads── gonzalo-mcp ◀── agent
(~/.gonzalo)
A view is one indexed snapshot of one repo, addressed by (repo, view_id).
repo is any identifier you choose (by convention owner/name); view_id is
typically a branch. Multiple repos and multiple views share one store root cleanly —
each gets its own SQLite graph under <root>/graphs/<encoded-repo>/<view>.db.
Install
cargo install gonzalo-cli gonzalo-mcp gonzalo-parse
gonzalo-parse supplies gonzalo-parse-worker, which isolates tree-sitter parsing in
a subprocess so one bad file skips instead of aborting the whole index. Indexing works
without it — it silently falls back to in-process parsing — so install it explicitly
rather than assuming it is there.
Cargo installs to ~/.cargo/bin. That directory is on PATH for interactive shells
via ~/.zshrc, but an MCP client typically spawns a non-interactive shell, which
reads ~/.zshenv instead. If the server fails to start, this is usually why: either
put the directory on PATH in ~/.zshenv, or register the server with an absolute
path to the binary.
Index
gonzalo index --root /Users/you/.gonzalo --repo acme/widgets --view main /path/to/checkout
Output tells you what happened:
driver: full walk
files: 465
added: 465
modified: 0
deleted: 0
skipped: 0
ignored: 2 files, 9 dirs not descended
driver—full walkthe first time; afterwards a git-diff-driven incremental pass that re-parses only what changed. A no-op re-index is well under a second.skipped— files a parse worker crashed or hung on.driveralso goes back tofull walkon its own when gonzalo's extraction format changes, so a parser improvement reaches files that did not themselves change.ignored— files and directories deliberately left out of the view: dependency and build-output directories, generated bundles (*.min.jsand friends), and anything.gitignored. Use--include <path>to re-admit a vendored path you do want indexed.--includecannot override.gitignore, because a view must stay reproducible from the commit alone.
Keeping a view fresh
Views are snapshots. Re-index after checking out or pulling — it is incremental:
gonzalo index --root /Users/you/.gonzalo --repo acme/widgets --view main /path/to/checkout
gonzalo index --watch keeps a view current continuously, with a debounce and a
periodic full reconcile.
Because views are per-(repo, view_id), git worktrees pair naturally with them: index
each worktree under its own view_id and diff them.
Register
claude mcp add gonzalo --env GONZALO_ROOT=/Users/you/.gonzalo -- gonzalo-mcp
Use an absolute path.
GONZALO_ROOTis passed straight through, and nothing in gonzalo expands a leading~. Neither bash nor zsh expands a tilde in--env KEY=~/pathargument position either, soGONZALO_ROOT=~/.gonzalocreates a directory literally named~in the current working directory and indexes nothing you can find. See #211.
Restarting or reconnecting the MCP client respawns the server process, which is how a newly installed binary's tools become visible — a full client restart is not needed.
Verify
status → {"status":"ok","root":"/Users/you/.gonzalo","views":2}
views → [{"repo":"acme/widgets","view_id":"main","files":465,"base_commit":"9ea0860…"}]
status reports the number of indexed views, so a server pointed at an empty or wrong
store is visibly wrong rather than merely "ok". views lists the valid (repo, view_id) pairs — call it first rather than guessing a selector.
base_commit is the commit the view was last indexed at. Compare it against
git rev-parse HEAD to detect a stale view, which is the quiet failure mode: results
that are plausible but describe code that has moved on.
A selector error is an error
A query naming a view that does not exist returns a tool error that names the selector and lists the views that do exist:
no indexed view 'acme/widgets/mian'. This is a selector error, not an empty result.
Indexed views: acme/widgets/main. Call `views` to list them.
An empty result therefore means what it says: the symbol is not in that view. This was
not always true — see #210 — and it
matters because an agent reads [] as "nothing calls this" and reports it as fact.
The tools
Discovery
| tool | answers |
|---|---|
views | which (repo, view_id) pairs exist, with file counts and indexed commit |
status | is the server up, which root, how many views |
Whole-view — start here in an unfamiliar repo; none need a symbol name.
| tool | answers |
|---|---|
overview | counts, breakdown by kind and language, largest files |
top | most referenced (fan_in), most calls out (fan_out), most ambiguous (definitions) |
list | enumerate symbols filtered by path prefix, kind, name substring |
unreferenced | dead-code candidates — heuristic, see below |
Per-symbol — for a name you already have.
| tool | answers |
|---|---|
search | where is this defined |
node | definitions + callers + callees in one call |
callers / callees | who calls this / what does this call |
explore | every reference, with paths |
impact | transitive caller closure, resolution-gated; takes max_depth |
Across views
| tool | answers |
|---|---|
diff | symbols and references added/removed from view_a to view_b |
diff is the most useful tool here for reviewing work: point it at a branch view and
a main view and it reports what changed structurally, which is a different and often
better question than what a textual diff shows.
Every whole-view result is bounded and reports total and truncated, so a capped
list never masquerades as a complete one.
Capability boundaries
Read this before trusting a result.
The call graph is name-matched, not type-resolved. Two unrelated functions sharing
a name are one node. callees includes enum variants, constructors, and std methods
(Some, ok, from) alongside project functions. top by=definitions is the
ambiguity report: any name scoring above 1 is defined in several places, and every
traversal through it merges unrelated subgraphs.
impact follows only resolvable edges. It used to walk the name-matched graph and
return roughly half the repository for one seed; it now keys on (name, defining path)
and refuses to traverse an ambiguous reference, reporting the count in
ambiguous_edges instead (#207).
Read that count: non-zero means the true set may be larger than what you got. Method calls
(x.foo()) on a receiver whose type is unknown are not attributed at all and are
counted in receiver_unknown_edges, which stops a std method being credited to a
same-named project function
(#223). Treat the closure as a lead
list and confirm the load-bearing edges with callers.
callers on a type is always empty. Only call expressions are edges, so types,
traits, and structs have no inbound edges. Empty there means "not applicable", not
"unused".
unreferenced is a heuristic. A function used only as a value — and_then(f),
map_err(be) — is a path expression rather than a call, so it records no reference and
will be reported as dead when it is not. An unused name is also hidden by any
same-named symbol that is used. Confirm every hit against the source.
Calls inside Rust macro arguments are recorded, including in assert_eq! and
println!, but by a token-level rule: an identifier followed by a parenthesised token
tree. That is deliberately over- rather than under-inclusive — a tuple-struct pattern
like Some(_) reads as a call. In C and C++, calls inside a #define body are still
invisible, because the body is a single opaque preprocessor token.
Test functions are ordinary symbols and rank alongside production code. Check the path before concluding something is only used in tests.
Generated and vendored code is excluded by default, along with .gitignored build
output. If a query returns nothing you expected from a vendored path, that is why —
re-index with --include <path>.
Troubleshooting
| symptom | cause |
|---|---|
| server will not start | ~/.cargo/bin not on PATH for non-interactive shells — set it in ~/.zshenv or register an absolute path |
status shows "views":0 | nothing indexed yet, or GONZALO_ROOT points somewhere unexpected — check the root it reports |
a directory named ~ appeared | GONZALO_ROOT=~/… was not expanded; use an absolute path |
| "no indexed view" error | the selector does not match — call views |
| results describe code that changed | stale view — compare base_commit to HEAD and re-index |
| new tools missing after an upgrade | the client is still running the old binary — reconnect the MCP server |
Changelog
All notable changes to gonzalo are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. While the project is pre-1.0, the minor version is bumped for new features and the patch version for fixes.
Unreleased
0.5.0 - 2026-08-22
Code-graph correctness. impact and reference resolution stop asserting edges
they cannot justify — a closure that returned half the repo for one seed, and a
name-matched resolver that credited std methods to same-named project functions.
Alongside that, the MCP server gains aggregate queries that answer questions about
a whole view rather than about a symbol name the caller already has.
Upgrading: the first index after this release does a one-time full re-walk
of every view (see EXTRACTION_VERSION below) — expected, not a fault. Reinstall
the binary as well as re-indexing: the resolution fixes live in the query path, so
a stale gonzalo-mcp keeps returning the old answers over freshly indexed data.
Fixed
-
A method call no longer claims a same-named free function (#223).
Resolution::UniqueGlobalattributed a reference to the sole definition of that name in the view — including when the name was really a std or dependency method. In gonzalo,.chain(ours_obj.keys())ingonzalo-coreresolved tofn chain(), a test fixture ingonzalo-graph, a crategonzalo-coredoes not depend on.References now record the shape of the call site (
RefKind::{Free, Method}), and a cross-file method call resolves to the newResolution::ReceiverUnknownrather than guessing. Measured over gonzalo's own source: of the 388 cross-file method calls that used to resolveUniqueGlobal, 294 pointed at a different crate —push,filter,send,bytes,nextand friends. Same-file method calls still resolveLocal, and free and path calls (foo(),a::b::foo()) are unaffected.Effect on
impact(#207), same 122-file source both runs:seed before after build_rust185 126 assemble60 23 resolve_references_to6 6 The 24 provably-false
gonzalo-corenodes in thebuild_rustclosure are now 0. Dropped edges are reported asreceiver_unknown_edges, counted separately fromambiguous_edgesbecause the cause differs: not "too many candidates" but "cannot claim any candidate".RefKindis omitted from the serialized slice when free, so a file of plain calls keeps its existing content hash. -
impactno longer merges unrelated code through shared identifiers (#207). The closure walked the name-matched caller graph, so one hop into a name with several definitions absorbed every subgraph sharing that identifier. The walk now keys nodes on(name, defining path)and consults the resolver for every edge: anAmbiguousreference is counted and dropped rather than traversed.On the gonzalo view, seeded at
build_rust: 356 → 178 reached names, with 10 ambiguous edges reported rather than followed. Seeds that were already sharp are unchanged (resolve_references_to: 5 → 5).The result is now a report rather than a name list — every node carries the path defining it,
ambiguous_edgessays how many edges could not be attributed (so a non-zero count means the true set may be larger), andtruncatedreports a walk stopped by the new optionalmax_depth. The daemon's HTTP and gRPC transports keep their existing name-list shape and so get the precision fix without the report fields.Of the remaining 178, 17 are still provably false and trace to a single
UniqueGlobalover-attribution — std'sIterator::chainresolving to a same-named test fixture. That is a distinct defect, filed as #223. -
An incremental re-index now prunes paths a laxer run admitted (#209 follow-up). The filter added in #218 only applied to newly walked or changed files, so an existing view kept its vendored bundles forever: a bundle never changes, so it never appears in the git diff and was never reconsidered — and once a base commit is recorded there is no full walk to clean it up. Upgrading therefore fixed new views only, which is the case least in need of fixing.
The carried-forward set is now re-checked against the current rules, including
.gitignore— necessary becausedocs/guide/book/is build output excluded by ignore rules rather than by any directory-name rule, so a path-only prune left it behind.Re-indexing the existing
caliban-ai/calibanview: 17 162 symbols → 8 549, with vendored symbols going 8 618 → 0 and the largest file becomingcaliban/src/tui/events.rs(142) instead of a copy ofmermaid.min.js. -
An unknown
repo/view_idis now an error, not an empty result (#210). Every graph query returned[]withisError: falsewhen the selector named no indexed view, so a one-character typo inview_idwas indistinguishable from a symbol that genuinely is not there — an agent read it as "nothing calls this" and reported a wrong answer as fact.Service::viewnow fails withNotFound, and the MCP layer turns that into a tool error naming the unresolved selector and listing the views that do exist, so a caller can correct itself in one round trip. A real miss inside a real view still returns[], so the two cases are finally distinguishable.diffgets the same check on bothview_aandview_b. -
Calls inside Rust macro arguments are now recorded as references (#216). Macro arguments parse as a
token_treeof raw tokens rather than expressions, soassert_eq!(f(), 1)contained nocall_expressionand the call tofwas never seen. Because assertions are where much of a codebase is exercised, this silently removed a large share of the call graph:callers,callees,impactandtop by=fan_inall undercounted, andunreferencedreported live functions as dead.Re-indexing gonzalo itself, with an identical file set (1 852 symbols both runs), references go from 10 247 to 12 196 — +1 949 edges, +19.0%.
Language::from_extension, the symbol that exposed the bug, goes from 0 recorded references to 28.Detection is token-level: an identifier whose immediate next sibling is a parenthesised token tree. A nested macro has a
!between the two and is excluded. It is deliberately over- rather than under-inclusive — a tuple-struct pattern likeSome(_)reads as a call — which matches a graph that already records constructors and enum variants as calls.The other 17 grammars were audited for the same opaque-node hole. Only C/C++ has one: a
#definebody is a single opaquepreproc_argtoken with no child nodes to read. It is left in place and pinned by a test so the gap is discoverable rather than silent. -
The indexer no longer walks vendored bundles or gitignored build output (#209).
is_indexableskipped onlytarget,.git, and dotted components, so half of a real repo's graph was not that repo's code. Membership now lives in one place (IndexFilter), shared by the full walk and the git-incremental driver so they cannot disagree: dependency/output directories (node_modules,vendor,dist,build,site-packages,third_party) and generated files (*.min.js,*.min.css,*.bundle.js,*-lock.json) are dropped on both paths, and the full walk additionally honours.gitignore.Re-indexing
caliban-ai/calibandrops it from 16 986 symbols to 8 501 (-50.0%) with zero symbols frombook/**or any*.min.js; the largest file in the view is nowcaliban/src/tui/events.rs(142 symbols) rather than a 4 231-symbol copy ofmermaid.min.js. This also removes a reproducibility hole — indexing gitignored output made the graph depend on whether anyone had run a build.gonzalo indexnow reports what it excluded (ignored: N files, M dirs not descended), and--include <path>re-admits a vendored path that a built-in rule would drop.--includedeliberately cannot override.gitignore, so no flag can make a view irreproducible.
Added
-
EXTRACTION_VERSION, and a full walk when it changes (#223). The incremental driver carries unchanged slices forward untouched, so a parser improvement never reached files that did not change — an existing view stayed permanently half-upgraded.gonzalo indexnow records the extraction format alongside the view and rebuilds in full when it differs, which is what lets #216's and #223's parsing changes actually reach an established view. -
A guide chapter for the MCP server (#208) —
docs/guide/src/mcp.md, covering install → index → register → verify → keep fresh, a tool reference grouped by the question each tool answers, and the capability boundaries. It leads with the thing nothing in the repo stated: the server only reads,gonzalo indexwrites, so an unindexed setup answers every query forever with no indication why. It also records the traps found while wiring the server up for real —GONZALO_ROOTnot expanding~(#211),~/.cargo/binmissing from the non-interactive shells an MCP client spawns, and needing to reconnect the server to pick up a newly installed binary — plus a troubleshooting table keyed by symptom. -
viewsdiscovery tool and a view count instatus(#210).viewslists every indexed(repo, view_id)with its file count and the commit it was indexed at, which makes the server self-describing rather than dependent on out-of-band documentation; comparingbase_commitagainst the checkout's HEAD also surfaces a stale view, the quieter form of the same problem.statusnow reports how many views are indexed, so the natural health-check call actually detects a server pointed at an empty or wrong store. Therepo/view_idschema descriptions now say they must match an indexed view and point atviews. -
Aggregate code-graph queries (#214) — three MCP tools that answer questions about a view rather than about a symbol name the caller already has:
overview(file/symbol/reference counts, a breakdown by kind and language, and the largest files),top(rank byfan_in,fan_out, ordefinitions— adefinitionsscore above 1 marks an ambiguous name), andlist(enumerate symbols filtered bypath_prefix,kind, andname_contains). Backed by new defaultGraphStoremethods, so every store implementation inherits them. Results are bounded and reporttotal+truncatedrather than silently cutting. -
unreferenceddead-code candidates (#214) — a fourth aggregate tool listing symbols with no inbound reference, filtered by the samepath_prefix/kind/name_containsand bounded the same way.exclude_tests(default on) drops members of amod tests/mod testblock by line range and anything under atests/directory; on gonzalo itself that is the difference between 515 hits and 40. Deliberately errs toward silence — a reference from anywhere counts, including from tests and from the symbol itself. Its false positives are documented in the tool description, the rustdoc, and a pinned test: calls inside macro arguments are not recorded at all (assert_eq!(f(), 1)registers nothing), and a function passed as a value is a path expression rather than a call, so both look uncalled.
0.4.0 - 2026-08-01
The remote-parity & backend-qualification release. Deletion and blobs — the two
gaps that kept the daemon substrate behind fs/s3 — close, so a daemon-backed
consumer now gets the full Store + BlobStore surface. Alongside them, an HA
soak harness doubles as a conditional-write qualifier for S3 backends, and its
first finding disqualifies Garage outright.
Added
Store::delete(#183) — OCC-aware record deletion across every substrate and the daemon, propagated bySync. Local-only semantics; see ADR 0018. (#185)- Blobs over the daemon (#184) — the content-addressed
BlobStoreis exposed ongonzalo-server(HTTPGET|PUT|DELETE /v1/blobs/{hash},GET /v1/blobs, plus gRPC), andServerStoreimplementsBlobStoreover both transports, so a daemon-backed consumer gets the fullStore+BlobStoresurface. Blobs previously worked only onfs/s3. Adds theGONZALO_MAX_BLOB_SIZEdaemon knob (default 64 MiB). (#192)
Changed
- S3 backends are now qualified, and Garage is not among them (#52) — atomic
If-Matchis a hard requirement for any S3-compatible backend. Garage does not provide it: gonzalo's conditional-write conformance case expects exactly 1 of 8 concurrent racers to commit, and Garage let 8/8 through on v1.0.1 and 3–8 through non-deterministically on v2.1.0 — the signature of a check-then-set, not an atomic CAS. Deployments running gonzalo over Garage can silently lose concurrent writes. RustFS (Apache-2.0) is the qualified backend; MinIO passes the qualifier but is rejected on project sustainability. See ADR 0019. (#186, #205)
Testing: an HA soak harness for stateless gonzalod replicas over an
S3-compatible store — backend-agnostic, doubling as the conditional-write
qualifier above (#52, #186); cross-crate integration tests extracted into
gonzalo-integration-tests (#190, #191).
Project: a crates.io publishing pipeline for the workspace, triggered on v*
tags (#187, #189).
Docs: competitor capability inventories and parity-gap matrices for mem0, Zep,
and Letta under docs/evaluation/ (#193); ADR 0019 recording the S3 backend
qualification (#205).
0.3.0 - 2026-07-11
The hardening & language-breadth release. A broad correctness and robustness sweep — a 20-finding QA pass turned into fixes across the core merge/OCC model, every storage substrate, the daemon, the ticket connectors, and the knowledge/vector layer — lands alongside eight new code-graph grammars that take language coverage from nine to seventeen.
Added
- Language breadth for the code graph — grammars for Ruby, PHP, Bash
(#87), Kotlin (#87), Swift (#87), Lua (#87), Scala (#87), and
Elixir (#87). Elixir is homoiconic (
def/defp/defmacro/defmoduleparse as ordinarycallnodes), so it uses a value-basedwalk()dispatch on the call target's text rather than a node-kind mapping. Coverage is now 17 languages. (#126, #127, #128, #129, #130, #181)
Changed
- Ticket
RecordKeys are board-scoped — a board-scoped source folds its connection/board discriminator into the key, so the same issue imported from two boards no longer collides onto one thrashing record. Stored keys for board sources change shape and re-import on the next sync. (#159)
Fixed
- core — record-key encoding is now a reversible, injective percent-style
codec, so distinct keys can never collide onto one physical path (silent
cross-key overwrite / OCC bypass); append-only merge preserves blank and
legitimately-repeated committed lines instead of stripping/de-duping them, and
the
Derived/gc semantics are corrected. (#131, #133) - storage substrates — the git substrate locks the
putcritical section (no lost updates under concurrent writers) and detects non-fast-forward push rejection; filesystem writes fsync the temp file and parent directory for crash durability; S3 list-pagination terminates when the continuation token is absent; graph-sqlite view-db paths use the injective encoder. (#132, #134, #144, #145) - daemon — authorization runs before request deserialization, internal backend errors are returned opaquely (no path/bucket/SQLite leakage), the PUT record route validates its URL path against the body key, and the remote client surfaces daemon 403/413 responses instead of masking them as a decode error. (#146, #147)
- code graph — JS/TS arrow-function and function-expression bindings are
extracted, PHP method and static calls are recorded, and Swift/Kotlin
struct/enum/interfacedeclarations get their correctSymbolKind. (#136) - knowledge / vector / domain — a corrupt knowledge-bearing body surfaces an
ingest error (rather than silently not indexing) and removed records are
de-indexed; non-finite vectors score
0.0and rank deterministically; the domain codec rejects aBody::Blobinstead of misparsing its content hash. (#139, #149, #154) - cli —
get/ticket getexit non-zero (message on stderr) when a record is absent,indexadvances the persistent SQLite graph only after the manifest commits, and--gcis honored under--watch. (#152) - ticket connectors — Jira routes a
Canceledmove to a won't-do status rather than Done; a closed GitLab issue is terminal and non-terminal moves no longer report a false success; Linear fails a mutation that returnssuccess: false; the GitHub REST connector followsLinkpagination so all issues import, not just the first 100. (#138, #140, #141, #142)
Testing: de-flaked gonzalo-parse's hung-worker timeout test, whose 300 ms
budget false-timed-out the healthy recovery parse under heavy build load. (#178)
0.2.0 - 2026-07-06
The code-graph release. Gonzalo grows a full code-graph capability —
tree-sitter parsing across nine languages, content-addressed slices with
two-level keying, a persistent SQLite GraphStore, and structural queries over
both the daemon and an MCP server — alongside real semantic search (a local
Candle embedder feeding an approximate ANN index over per-kind-chunked
knowledge), namespace-scoped daemon auth, and correct content-aware 3-way merges
on both store-sync and git pull. Distributed as the gonzalod container image.
Added
- Code-graph capability (EPIC A–K): tree-sitter parsing into path-agnostic,
content-addressed slices with two-level keying (content+grammar hash for
storage, per-view manifest for resolution); a persistent
SqliteGraphStore; assembly-time name resolution; and structural queries — definitions, references, callers, callees, transitive impact, and cross-view diff — served over the daemon and a dedicated code-graph MCP server. Parsing is crash-isolated behind a worker-subprocessParserPool. (ADR 0012; #48, #50, #54, #56, #61, #64, #66, #70, #71, #74, #77, #88, #89, #90, #10, #30) - Language breadth for the code graph — a
Languagedispatch with grammars for Rust, Python, JavaScript/TypeScript/TSX, Go, Java, C#, C, and C++. (#79, #81, #83, #84, #85, #86) - CLI indexing (
gonzalo index): index a source tree into a code-graph view, git-diff-driven incremental re-sync, a--watchfile-watcher for live re-index, and opt-in mark-sweep GC of unreferenced slices. (#74, #93, #94, #100, #104) - Knowledge store (
gonzalo-knowledge): a "what do we know about X" surface composingStore+VectorIndex+Embedder, a vector⋈graph join (semantically similar and structurally near), and per-kind chunking so long records retrieve at turn/section granularity. (ADR 0011; #30, #29) - Real semantic vector search:
gonzalo-embed— a local CPU sentence embedder (Candle + all-MiniLM-L6-v2) — andHnswVectorIndex, an approximate ANN backend, behind the existingEmbedder/VectorIndextraits. (ADR 0013, ADR 0014; #97, #9) - Daemon substrate selection & health: env-driven
fs|s3store selection with a native S3BlobStore, and unauthenticated/healthz+/readyzprobes for k8s. (#62, #63) - Namespace-scoped daemon auth: a token→principal model with per-namespace read/write scoping enforced on both transports, plus unforgeable author stamping. (ADR 0015; #11)
gonzalodcontainer image — the release artifact, published on av*tag. (#51, #65)
Changed
- Store sync — true 3-way merge:
AncestryStoreretains each version's body by revision hash sosynccan merge divergent structured records against their real common ancestor instead of an empty base. (ADR 0016; #2) - Git pull — content-aware non-fast-forward merge: a diverged pull now
reconciles per-record through gonzalo's class-aware
merge()into a two-parent merge commit, surfacing unresolved records instead of erroring. (ADR 0017; #7) - S3 native conditional writes:
If-Match/If-None-Matchclose the optimistic-concurrency TOCTOU window in the S3 substrate. (#5)
Fixed
- CI: serialize GitHub Pages deploys with a concurrency group. (#107)
Internal
- ADRs 0010–0017 added (ticket capability layer, two-level code-graph keying, local embedder, ANN backend, namespace auth, stored-ancestry 3-way merge, content-aware non-FF pull).
- Docs: an mdBook guide publishing the ADR log, changelog, and a synthesized Guiding Principles & Invariants page; README status badges. (#103, #38)
0.1.0 - 2026-07-03
The initial development line — a generic, versioned, conflict-aware persistence layer for caliban, built milestone by milestone (M1–M6).
Added
- Record/Store core (M1):
gonzalo-core— one uniformRecordmodel and a genericStoretrait, with revisions, optimistic-concurrencyparenttracking,PutResult::Conflict, per-RecordKindmerge, and a feature-gated substrate conformance suite. No I/O in the core. (ADR 0002, ADR 0005, ADR 0006) - Filesystem substrate + domain + facade (M1):
gonzalo-store-fs(mirrors caliban's on-disk layout, the zero-dependency default),gonzalo-domain(typedMemoryTier/Topic/Session/Checkpointviews), and thegonzalofacade. (ADR 0004, ADR 0008, ADR 0009) - Git & S3 substrates + Sync (M2):
gonzalo-store-git(commit-per-write, fast-forward pull/push) andgonzalo-store-s3(S3-compatible object store), plus theSyncengine reusing the core conflict/merge machinery. (ADR 0004, ADR 0005) - Daemon + remote substrate (M3):
gonzalo-proto(one canonical schema),gonzalo-server(gonzalod) serving the store over both gRPC (tonic) and HTTP/JSON (axum) on one core service layer with optional bearer auth, andgonzalo-store-serveras the client substrate. (ADR 0007) - Vector layer (M4):
gonzalo-vector—Embedder+VectorIndextraits with a caller-delegating default embedder and an exact in-memory cosine index. (ADR 0008) - Code-graph layer (M5):
gonzalo-graph— a tree-sitter Rust symbol/ref index (build_rust) behind aGraphStoretrait. (ADR 0008) - Admin CLI (M6):
gonzalo-cli(gonzalo) —list,get,status,migrate,sync.
Internal
- Project: established
docs/adr/(MADR-lite) with the initial retrospective ADRs 0001–0009; added CI (fmt/clippy/build/test), a line-coverage gate, the Kanban label taxonomy, and board/triage automation.
Architecture Decision Records
- ADR 0001 · Record architecture decisions
- ADR 0002 · Uniform
Record+ genericStorecore (Approach A) - ADR 0003 · License: AGPL-3.0-only
- ADR 0004 · Pluggable storage substrates behind one
Storetrait - ADR 0005 · Optimistic concurrency with explicit conflict surfacing
- ADR 0006 · Shared substrate conformance suite
- ADR 0007 · Dual-transport daemon: gRPC + HTTP/JSON over one schema
- ADR 0008 · Domain, vector, and graph as capability layers over core
- ADR 0009 · Workspace layout and single-facade public surface
- ADR 0010 · Ticket systems as a normalized work-item capability layer
- ADR 0011 · Knowledge store over the capability layers
- ADR 0012 · Two-level keying for the code graph
- ADR 0013 · Local Candle embedder for real semantic embeddings
- ADR 0014 · Approximate vector index backend (hnsw_rs)
- ADR 0015 · Namespace-scoped daemon auth
- ADR 0016 · 3-way merge with content-addressed stored ancestry
- ADR 0017 · Non-fast-forward git pull via content-aware merge
- ADR 0018 · Record deletion and its sync semantics
- ADR 0019 · Qualified S3 backend for HA: RustFS
ADR 0001 · Record architecture decisions
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo began as a design spec (docs/superpowers/specs/) and a set of
per-milestone plans, with the significant decisions captured there. Now that
milestones M1–M6 are implemented, those decisions are spread across long design
documents and commit history — hard to consult and easy to let drift from the
code. The sibling repos caliban and prospero both keep an ADR log; gonzalo had
none.
Decision
We will keep an Architecture Decision Record log under docs/adr/, in
MADR-lite format, matching caliban (and prospero once
caliban-ai/prospero#30
lands). Each architecturally significant, hard-to-reverse, or future-constraining
decision gets one append-only record with Context, Decision, and
Consequences. Superseded decisions are marked and linked, never deleted.
This first set of ADRs (0002–0009) is retrospective: it documents decisions already embodied in the M1–M6 code, so the rationale is captured before it is lost. Decisions from here on are recorded as they are made.
Consequences
- Positive: One durable, greppable home for "why"; new contributors and sibling-repo readers meet one consistent format across caliban / gonzalo / prospero; design rationale stops drifting from the code.
- Negative: An ongoing discipline cost — a significant decision now means writing an ADR, not just code. Retrospective ADRs also risk rationalizing after the fact rather than capturing the live trade-off.
- Revisit if: the MADR-lite format diverges from the agreed cross-sibling standard (see caliban-ai/prospero#30), or the log proves too heavyweight for the team's cadence.
ADR 0002 · Uniform Record + generic Store core (Approach A)
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo must persist several caliban data types — memory tiers, auto-memory topics, sessions, checkpoints — across several substrates (filesystem, git, S3, remote daemon), with versioning, optimistic concurrency, conflict surfacing, and sync. Three shapes were considered:
- (A) One uniform persisted unit (
Record) and a single genericStoretrait; caliban's types become typed views layered on top. - (B) A typed store per domain type — which re-implements versioning / conflict / sync for every type × substrate combination (combinatorial duplication).
- (C) A schemaless JSON-document store — which discards the type safety that motivates writing the system in Rust.
Decision
Adopt Approach A. gonzalo-core defines one Record (key, kind, revision,
parent, body, meta, links) and a generic Store trait over it. Substrates
implement only the generic Store and never know about caliban's types.
Caliban's types live as typed views in gonzalo-domain, mapped to/from Record
via serde.
The hard parts — versioning, optimistic concurrency, conflict surfacing (ADR 0005), and sync — are written once in the core, independent of both substrate and domain type.
Consequences
- Positive: Versioning / conflict / sync logic exists once, not per
type × substrate. New substrates and new domain types are independent axes —
adding one does not touch the other. The vector and graph layers key off the
same
RecordKey, so their queries return first-class records. - Negative: Everything funnels through one
Recordshape; a domain type that fits the model poorly must still be marshalled into it. An extra mapping layer (domain view ↔Record) sits between caliban and storage. - Revisit if: a domain type cannot be reasonably expressed as a
Record, or the generic core blocks a substrate-specific optimization that materially matters.
ADR 0003 · License: AGPL-3.0-only
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo needs a license. It is a persistence layer for caliban, which is itself
licensed AGPL-3.0-only, and it is intended to be deployable as a network
daemon (gonzalod) — so network-use copyleft is directly relevant rather than
incidental.
Decision
License gonzalo under AGPL-3.0-only, matching caliban. The LICENSE file
carries the full text; license = "AGPL-3.0-only" is set in the workspace
package metadata.
Consequences
- Positive: Consistent with caliban (the primary consumer); strong copyleft including the AGPL network-use provision, which fits a shareable daemon; one license story across the sibling repos.
- Negative: AGPL deters some commercial/proprietary adopters and cannot be linked into closed-source software; operators who modify and serve the daemon take on the network-copyleft obligation.
- Revisit if: a relicensing need arises (e.g. broader ecosystem adoption), which would require consent from all contributors.
ADR 0004 · Pluggable storage substrates behind one Store trait
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo's value proposition is taking caliban's local-first state shared "by configuration, not code." That requires several backends — a local filesystem (today's behavior), git (auditable shared history), S3 (large cheap blobs), and a remote daemon (central server) — without each backend re-deriving the core semantics, and without forcing every build to pull heavy dependencies (git2, aws-sdk, tonic).
Decision
Each backend is a separate crate implementing gonzalo-core::Store:
gonzalo-store-fs[fs]— filesystem, mirrors caliban's on-disk layout; the zero-dependency reference/default.gonzalo-store-git[git]— commit-per-write, fast-forward pull/push.gonzalo-store-s3[s3]— S3-compatible object store.gonzalo-store-server[remote]— proxies to a remote daemon over HTTP or gRPC (client side of ADR 0007).
Heavy dependencies live only in their owning substrate crate and are selected through facade Cargo features (ADR 0009), so a filesystem-only build stays lean. Which substrate backs caliban is configuration, not API.
Consequences
- Positive: Going from local to git / S3 / daemon is a config change, not a
code change. A default build pays for nothing but
fs. New backends slot in by implementing one trait and passing the conformance suite (ADR 0006). - Negative: The
Storetrait is a lowest-common-denominator surface — substrate-specific capabilities must fit it or be abstracted away. Every substrate carries the cost of conforming to the full semantics (versioning, conflicts) even where its native model differs. - Revisit if: a needed backend cannot satisfy the
Storecontract, or the trait starts accreting substrate-specific escape hatches.
ADR 0005 · Optimistic concurrency with explicit conflict surfacing
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo's premise is that multiple systems and contributors share state. Concurrent edits to the same record are therefore expected, and caliban's durable memory must never silently lose a contributor's write. We need a concurrency model that works identically for local writes and cross-replica sync, across every substrate.
Decision
Use optimistic concurrency with explicit, typed conflict surfacing:
- Writes are conditional:
put(record, expected_parent_rev). If the stored revision no longer matches the expected parent, the store returnsPutResult::Conflictrather than overwriting. - The core ships merge strategies keyed by
RecordKind: append-only kinds (auto-memory topics, session transcripts) auto-merge by union/concatenation; structured kinds attempt a field-level 3-way merge against the base; anything ambiguous is surfaced to the caller and togonzalo-clifor resolution. Sync(pull → detect divergence → merge → push) reuses this exact machinery, so reconciliation and local writes share one code path.Conflictis a recoverable result variant, not a generic error.
Consequences
- Positive: Concurrent edits are never silently lost — the core invariant. One conflict/merge implementation serves both local writes and sync. Callers get a typed, recoverable outcome they must handle, not a stringly error.
- Negative: Every writer must handle a
Conflictoutcome — more caller complexity than last-write-wins. Concurrency is optimistic, so a high-contention record can see repeated retry/merge cycles. - Revisit if: a workload needs last-write-wins or server-side locking, or the per-kind merge strategies prove insufficient for a real conflict pattern.
ADR 0006 · Shared substrate conformance suite
- Status: accepted
- Date: 2026-06-13
Context
Four substrates (fs, git, S3, server) implement the same Store contract, with
subtle shared semantics: revision monotonicity, conditional-put conflict
behavior, body round-tripping, key listing. If each substrate were tested only
in isolation, they would inevitably drift in behavior and caliban could not
treat them interchangeably.
Decision
gonzalo-core ships one shared conformance test suite that every Store
implementation must pass. Each substrate crate runs the suite against its own
backend (fs, git, S3, the daemon-backed server substrate). The suite is the
executable definition of what "being a Store" means.
Consequences
- Positive: All substrates are held to one behavioral spec, so they are genuinely interchangeable. A new substrate's correctness bar is simply "pass the suite." Semantics live in one place rather than scattered across per-substrate tests.
- Negative: The suite is a coupling point — tightening it can require work across every substrate at once. Backends that need external services (S3, the daemon) require test infrastructure (wiremock / testcontainers) to run it.
- Revisit if: a legitimate substrate cannot satisfy a suite assertion, and the contract needs capability tiers rather than one flat spec.
ADR 0007 · Dual-transport daemon: gRPC + HTTP/JSON over one schema
- Status: accepted
- Date: 2026-06-13
Context
The optional daemon (gonzalod) lets non-Rust tools and remote systems share a
Gonzalo store. Two audiences pull in opposite directions: Rust clients and
streaming large transfers want strongly-typed gRPC; ad-hoc tooling and
humans want a curl-able HTTP/JSON API. Maintaining two independent
implementations of the same surface would let them drift apart.
Decision
gonzalo-server exposes both transports — a tonic gRPC service and an
axum HTTP/JSON service — over one shared core service layer.
gonzalo-proto holds the single canonical schema both derive from: protobuf for
gRPC, serde types for HTTP/JSON. Payloads are JSON-encoded gonzalo-core
records carried as bytes, so both transports share one serialization and stay in
lockstep. The daemon supports optional bearer-token auth with namespace-scoped
checks. gonzalo-store-server is the client side, speaking either transport.
Consequences
- Positive: Operators pick the transport that fits — Rust clients get typed gRPC, everyone else gets curl. One service layer and one schema mean the two transports cannot drift. Auth is one concern handled at one layer.
- Negative: Two server stacks (tonic + axum) to build and keep running. Carrying JSON-over-bytes inside protobuf forgoes some of gRPC's native typed payloads for the sake of a single shared serialization.
- Revisit if: one transport goes effectively unused (drop it), or performance demands native protobuf payloads instead of JSON-over-bytes.
ADR 0008 · Domain, vector, and graph as capability layers over core
- Status: accepted
- Date: 2026-06-13
Context
Caliban needs more than key-value persistence: typed access to its own data, semantic (vector) retrieval, and structural (code-graph) queries. These could have been built into the core or into each substrate, but that would entangle retrieval concerns with storage and force every substrate to reimplement them.
Decision
Keep gonzalo-core storage-only and add capabilities as layers over it,
each keyed by the shared RecordKey:
gonzalo-domain— typed views (MemoryTier,Topic,Session,Checkpoint) mapped to/fromRecordvia serde.gonzalo-vector—Embedder+VectorIndextraits. Embedding generation delegates to the caller by default (the core stays provider-agnostic). What shipped in M4 is an exact in-memory cosine index; approximate/external indexes (HNSW, sqlite-vec, Qdrant) anticipated by the design spec remain future, feature-gated impls.gonzalo-graph— atree-sitter-based Rust code graph (build_rust) and aGraphStoretrait over symbols / files / references / edges.
Because every layer keys off RecordKey, semantic and structural queries return
first-class records, and the layers compose (vector ⋈ graph) by shared key.
Consequences
- Positive: Storage and retrieval stay decoupled — substrates never know
about vectors or graphs. The shared
RecordKeymakes the layers composable and keeps query results first-class. Provider-agnostic embedding keeps the core free of model dependencies. - Negative: The shipped exact in-memory vector index does not scale to large corpora; production-scale retrieval will need the not-yet-built approximate indexes. Two retrieval layers add API surface beyond plain persistence.
- Revisit if: corpus size outgrows the exact in-memory index (prioritize a real ANN impl), or a capability needs core/substrate support that a pure layer cannot provide.
ADR 0009 · Workspace layout and single-facade public surface
- Status: accepted
- Date: 2026-06-13
Context
Gonzalo is 12 crates (core; the fs/git/s3/server substrates; proto; server; domain; vector; graph; cli; and the facade). Caliban should not have to depend on, and feature-wrangle, all of them. We also want heavy dependencies (git2, aws-sdk, tonic) to be opt-in so a default consumer stays lean.
Decision
Use a single Cargo workspace (edition 2024, rust-version 1.95), with every
crate prefixed gonzalo- except the facade. The gonzalo facade is a thin
re-export crate giving caliban one dependency and a curated public surface;
substrates and capability layers are selected via the facade's Cargo features
(fs, git, s3, remote, vector, graph). Internal crate versions are
centralized in [workspace.dependencies], and unsafe_code is forbidden
workspace-wide. The two binaries are gonzalod (daemon) and gonzalo (CLI).
Consequences
- Positive: Caliban depends on one crate and turns capabilities on by feature; default builds avoid the heavy deps. Single-responsibility crates keep compile units and ownership clear. Centralized workspace deps and lints keep the tree consistent.
- Negative: Twelve crates is real overhead — a change touching the core can ripple through the workspace, and the facade must be kept in sync with what it re-exports. Feature combinations need testing to ensure each builds.
- Revisit if: the crate count becomes a maintenance burden out of proportion to the isolation it buys, or feature combinations prove untestable.
ADR 0010 · Ticket systems as a normalized work-item capability layer
- Status: accepted
- Date: 2026-06-14
Context
Caliban and the sibling repos increasingly reason over tracked work — issues,
Kanban cards, ADR-linked tasks — that today lives entirely outside Gonzalo. We
want that work to be a first-class Record: versioned, synced, conflict-merged,
and composable with the vector and graph layers the same way memory and sessions
are. Gonzalo has no such abstraction today (no Ticket kind, no source trait).
The hazard is the one ADR 0004 flags for Store: a lowest-common-denominator
trait that flattens real differences, or one that accretes provider-specific
escape hatches until it is not an abstraction. Ticket platforms diverge more than
storage backends do, so this risk is acute. We surveyed nine platforms chosen to
cover distinct data-model archetypes, not for breadth's sake:
- GitHub — open/closed + reason; Projects v2 status is a per-project field.
- Jira — custom workflow →
statusCategory; transition-gated writes; ADF body. - Linear — typed states (backlog/started/completed/canceled); direct set.
- GitLab — tier-dependent: free = open/closed +
workflow::*scoped labels; Premium = native categorized Status; custom fields GA in 18.0. - Asana — no intrinsic state (
completedbool / section / enum field); multi-homed across projects; single assignee; plaintext/HTML body. - Azure DevOps — work-item type (process template) drives fields and states; states grouped into categories; any-to-any transitions by default.
- Bugzilla — two-dimensional state:
status×resolution(FIXED / WONTFIX / DUPLICATE / INVALID …). - Monday / Airtable — fully schemaless: title, status, assignee are all user-named columns/fields.
- Zendesk / ServiceNow — support/ITSM: distinct requester/assignee/submitter,
a
pendingstate, cross-type links (incident→problem→change), SLA events.
Two facts fell out of the survey. First, a categorized status model is shared by six of the nine (Jira, Linear, GitLab-Premium, Azure DevOps, ClickUp, Shortcut) — so a normalized state category is a real spine, not a forced fit. Second, the signal that carries status is configured per connection, not fixed per provider (GitLab free vs Premium; Asana completed vs section vs field).
Decision
Model tickets as a capability layer over core (ADR 0008), not a new core
concept (ADR 0002). The only core change is registering two new RecordKind
variants (Ticket, TicketEvent) and their merge classes — the same minimal
touch every domain kind already requires; no new core traits or types enter.
- Record shape. New
RecordKinds — a ticket and an append-only ticket-event/comment stream — with a typedTicketview ingonzalo-domain, mapped to/fromRecordvia serde, exactly asMemoryTier/Sessionare. - Normalized canonical model + lossless raw.
Ticketcarries anitem_type; aState { category, resolution, raw_name, raw_id }(category is the cross-platform spine,resolutionthe second axis Bugzilla/Jira need, raw round- trips); actor roles (Requester | Assignee | Submitter | Follower); normalized common fields (title, body, priority, labels); many-to-manycontainers(Asana multi-home, multi-board); typedlinks(blocks/relates/ parent/duplicate →RecordKeyor external ref); aBody { markdown, raw, format: Markdown|Adf|Html|PlainText }; and a boundedfieldsmap for everything else.StateCategoryincludes aPending/blocked member. TicketSourcetrait is the provider boundary — the ticket analogue ofEmbedder, keeping Gonzalo provider-agnostic about where tickets come from.- Per-connection mapping policy. Because state and fields are instance-
configured, each connection carries a
FieldMapping/StateMapping(the generalization of a fixed per-provider schema) that resolves canonical fields and the normalized state category from the configured signal — intrinsic state, scoped label, native status, section, or custom field. - Capability negotiation, not escape hatches. A
capabilities()descriptor (push,transitions_required,custom_fields,single_assignee,hierarchy,relations,comments) replacesif provider == …branches; available writes may be dynamic (auth/workflow-dependent). - Reuse concurrency + merge (ADR 0005). Ticket state is a structured kind → field-level 3-way merge; the event/comment stream is append-only → union-merge.
- Opaque incremental
Cursorowned by each source (timestamp / JQL bound / GraphQL cursor / event sync token) — not Gonzalo'sRevision. - Read-only import first; capability-gated write-back second.
fetch_changedis uniform across every platform and tier and already delivers the composition value;set_state(category)(which the source resolves to a Jira transition, a GitLab label swap, or an Asana section move) is opt-in phase 2. - Conformance keyed on policy variants (ADR 0006), not just providers: "GitLab-free (scoped-label)" and "GitLab-Premium (native status)" are distinct fixture sets, run against recorded fixtures like the S3/daemon substrates.
- Scope boundary. Schemaless DB tools (Monday/Airtable) are supportable via
FieldMappingbut are not design drivers; alerting/error-aggregation tools (PagerDuty/Sentry) are out of scope — they are not human-authored tickets. - Composition. Because tickets key off
RecordKey, ticket ⋈ graph (which symbols/files a ticket touches) and ticket ⋈ vector (semantic search over bodies) fall out for free, returning first-class records (ADR 0008).
Consequences
- Positive: Tracked work becomes a first-class
Record— versioned, synced, conflict-aware — with only the minimalRecordKindregistration every domain kind needs. The normalized model is validated across nine platforms and nine archetypes, so adding a tenth (Shortcut, ClickUp, Azure DevOps variants) is "implement one trait + declare a mapping policy + capabilities + fixtures." Read-only-first confines all per-instance write risk to an opt-in phase. - Negative: The canonical model is wide (item_type, two-axis state, actor
roles, many-to-many containers, raw passthrough) — more surface than a
GitHub-issue clone. Per-connection
FieldMappingis configuration users must get right, especially for schemaless tools. TwoRecordKinds per ticket (state + events) is more mapping than a single document. - Revisit if: a platform cannot be expressed even with
FieldMapping+fields(revisit the canonical shape); two-way sync conflict between Gonzalo's merge and a remote's authoritative state proves intractable (reconsider write- back); or the layer needs core/substrate support a pure layer cannot provide.
ADR 0011 · Knowledge store over the capability layers
- Status: accepted
- Date: 2026-06-14
- Source: #16
Context
Caliban needs a "what do we know about X" retrieval surface that spans its
durable record kinds — memory tiers, auto-memory topics, sessions, and now
tickets (ADR 0010) — backed by semantic (vector) and, later, structural (graph)
retrieval. The ingredients already exist: gonzalo-vector (Embedder +
VectorIndex), the gonzalo-domain typed views, and gonzalo-graph — all keyed
by the shared RecordKey (ADR 0008). What is missing is a surface that composes
them. Without one, every caller re-wires embed → index → fetch by hand, and there
is no single place that decides which record kinds are knowledge-bearing or how
their text is extracted.
ADR 0008 also flagged that the shipped exact in-memory VectorIndex does not
scale; a real corpus needs a production index. That decision has been deferred
until there was a consumer for it — the knowledge store is that consumer.
Decision
Add a gonzalo-knowledge capability crate (facade feature knowledge) that
composes the existing layers; gonzalo-core does not change.
- Surface.
KnowledgeStore<S: Store, V: VectorIndex, E: Embedder>with:ingest(key)— fetch theRecord, extract its knowledge text, embed it viaE, andupsertit intoVunder the sameRecordKey.query(text, k, filter)— embed the query,VectorIndex::queryfor the top-kkeys, then resolve them throughSto first-class records.- Results are
Hit { record, score }— first-class records, per ADR 0008's principle that retrieval returns records, not bare ids.
- Knowledge-bearing kinds (extracted via
gonzalo-domainviews):MemoryTier(content),Topic(slug + bullets),Session(name + turn text),Ticket(title + body + labels),TicketEvent(body).Checkpointis not knowledge-bearing. This mapping lives in one function,knowledge_text. - Chunking. Phase 1 embeds one document per record. Per-kind chunking (a session by turn, a long tier by section) is a future refinement behind the same surface.
- First production index. Adopt sqlite-vec as the zero-infra default
(a single file, no daemon — matching the
fs-default ethos), with Qdrant as the daemon-side option. Both slot behind the existingVectorIndextrait, feature-gated per ADR 0009. These impls are follow-ups; the exact in-memory index remains the default until they land. - Composition. Because hits key off
RecordKey,vector ⋈ graphis a query-time intersection — rank by similarity, then filter/expand by a code-graph neighborhood addressed by the same key. The crate ships the vector path now; the graph-backed filter lands with aGraphStorejoin.
Consequences
- Positive: One retrieval surface instead of hand-wired embed/index/fetch;
composes by
RecordKey, so tickets, memory, and sessions are searched uniformly; embedding stays provider-agnostic (delegated toE); zero core change. - Negative:
KnowledgeStoreis generic over three type parameters (S,V,E) — some signature heft. The in-memory index still caps scale until sqlite-vec lands.knowledge_textcouplesgonzalo-knowledgeto the domain view shapes (a new knowledge-bearing kind must be added there). - Revisit if: a knowledge-bearing kind needs chunking the one-document model can't express; or the generic composition blocks a cross-store optimization a unified impl would allow.
ADR 0012 · Two-level keying for the code graph
- Status: accepted
- Date: 2026-07-03
- Source:
docs/superpowers/specs/2026-07-03-code-graph-capability-design.md
Context
gonzalo-graph parses source with tree-sitter into a CodeGraph of Symbols
and name-based References (ADR 0008 places it as a capability layer over core).
To serve agents working in isolated git worktrees — including the product case
where Caliban runs a fleet of sub-agents, each in its own worktree, on the same
repo — the graph must be persisted, kept fresh, and queried per agent.
The naive design keys a file's graph by (repo, path) with last-writer-wins.
That is wrong the moment two worktrees edit the same path differently: both
resolve to one key, the second write clobbers the first, and an agent then
queries a graph describing another worktree's code — actively lying about the
tree it is editing. "They merge later" does not help; during the divergence
window each agent needs a graph matching its own tree now.
The error is conflating two distinct identities:
- Storage identity — what dedups. A file's graph slice is a pure function of its content and the grammar version.
- Query identity — what an agent resolves against. This is per-worktree (per view), because divergent worktrees are the whole point.
This is exactly git's blob/tree split, which fits because these are literally git worktrees. A related question — which engine backs the queryable graph (in-memory, SQLite, Cozo, …) — must not be allowed to leak into the durable source of truth.
Considered options
- Key everything
(repo, path), last-writer-wins. Rejected: clobbers divergent worktrees (above). - Key everything
(repo, worktree, path), no indirection. Correct, but every worktree stores a full copy of every slice, including unchanged files — N× storage across a fleet. - Two-level: content-addressed slices + per-view manifests. Correctness and dedup. Chosen.
Decision
We will key the code graph at two levels, and never by (repo, path).
- Storage layer — content-addressed, deduped slices. A file's graph slice
(the symbols it defines, the references it emits) is stored keyed by
(file_content_hash, grammar_version), write-if-absent. Byte-identical files across worktrees share one slice. This is whatBody::Blob(content-addressed record bodies) is for. - Identity layer — per-view manifests. Each workspace view (a worktree, or
in the product case an arbitrary target-repo checkout) owns a manifest
(repo, view_id) → { path → content_hash }. An agent resolves a path through its manifest to its slice. The resolvable key is per-view; storage stays content-addressed. Neither layer is keyed(repo, path). - Slices are path-agnostic.
SymbolandReferencecarry line ranges only, not afilefield; the path is supplied by the manifest at assembly time. A rename (byte-identical content) is a manifest repoint, not a reparse — path is identity, not content. - Resolution happens at assembly time, never in a stored slice. Slices are
stored raw, name-based, and unresolved; name resolution runs over the slice set
a manifest assembles, per query. Resolution must tolerate missing targets —
a file absent from a view yields honest dangling references, which is the truth
of that tree. (
gonzalo-graph's existing "references are unresolved" state is therefore the correct layering, not merely unfinished.) - Sync is set-reconciling, not append-only. A view's manifest must equal the
set of files present in that view. The driver is
git diff/git status, which reports deletes (D) as first-class alongside adds/modifies (A/M) and reconciles the manifest to the tree by construction. A write-only file-watcher is insufficient: it leaves ghost paths resolving to dead content. Any watcher path must handle unlink events plus a periodic full reconcile. - Storage-engine invariant. No query engine (in-memory, SQLite, Cozo, …) ever
sits under the
Storesubstrate (the durable, versioned, conflict-aware source of truth). Engines back only the derived, regenerable index layers (GraphStore, and optionallyVectorIndex), which are swappable behind their traits. Content-addressed slices are aBody::Blob/ KV concern, distinct from the assembled queryable graph the engine backs.
Consequences
- Positive: Divergent worktrees each query a graph matching their own tree — correctness under a fleet — while byte-identical files share one slice (dedup, no N× blow-up). Renames are free. Deletion is a per-view manifest edit with no "whose delete wins" ambiguity, and slices reclaim through one GC path (an edit orphans an old-content hash exactly as a delete does). Conflicts nearly vanish: slices are write-if-absent (same hash ⇒ same bytes), manifests are single-writer-per-view. The engine choice stays reversible behind a trait.
- Negative: Two-level indirection is more moving parts than one keyed table:
a content-addressed blob store (
Body::Blob, pulled forward from its reserved milestone), per-view manifest records with aMergeClass::Derivedarm, GC (refcount or mark-sweep over live manifests), and assembly-time resolution that must handle dangling references. Resolution cost moves from write time to query time. - Revisit if: views stop being git worktrees (the blob/tree analogy weakens); or single-writer-per-view no longer holds (concurrent writers to one manifest would need real merge, not last-writer-wins on the rare race); or profiling shows assembly-time resolution is too costly to run per query and a resolved cache must be introduced (without baking resolution back into stored slices).
ADR 0013 · Local Candle embedder for real semantic embeddings
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-local-candle-embedder-design.md
Context
gonzalo-vector defines an Embedder trait (ADR 0008) but ships only a
bag-of-words test embedder, so the knowledge store's semantic search (ADR 0011)
has no real semantics. We need a genuine sentence embedder, subject to #40's
standing constraint: FOSS, local-only — no cloud embedding APIs, no keys in
the default path.
The decisions in play were: which model (all-MiniLM-L6-v2 vs bge-small vs
gte-small — weighing MTEB quality, parameter count, and license compatibility
with this AGPL-3.0 project per ADR 0003); how weights are acquired
(download-on-first-use vs user-provided path vs bundling ~90MB in the crate);
and where the implementation lives (a heavy ML dependency set behind a feature
in gonzalo-vector, or isolated in a dedicated crate).
Decision
We will add a new crate gonzalo-embed implementing the gonzalo-vector
Embedder trait with a local CPU embedder built on Candle
(candle-transformers BERT + tokenizers).
- Model:
sentence-transformers/all-MiniLM-L6-v2— Apache-2.0 (redistributable, compatible with AGPL-3.0), 384-dim. - Acquisition: download-on-first-use from HuggingFace via
hf-hub(anonymous, no key, cached), with anEmbedderConfig.model_pathoverride for fully-offline use. Downloading weights is not a cloud inference API and needs no key, so #40's constraint holds. - Pipeline: tokenize → BERT forward → masked mean-pool → L2-normalize →
384-dim unit vector; the sync CPU forward runs inside
spawn_blocking. - Boundary: the ML dependencies live only in
gonzalo-embed;gonzalo-vectorstays dependency-light. Matches the crate-per-substrate idiom (gonzalo-store-fs/-s3).
Errors map to CoreError::Backend. Scope is single-text embed on CPU; batch
and GPU are explicitly out of scope.
Consequences
- Positive: real semantic retrieval behind the existing trait — no caller
change (the knowledge store just gets a better
Embedder). ML weight is quarantined in one opt-in crate. License is cleanly compatible and local-only is preserved. - Negative: first use downloads ~90MB (unless
model_pathis set); Candle + tokenizers + hf-hub are a heavy dependency set for that crate; CPU inference is slower than a hosted API. Real-model tests must be#[ignore]d so CI does not download weights. - Revisit if: we need batch/GPU throughput, a different model (quality or
size), or the download-on-first-use default proves problematic in air-gapped
deployments (promote
model_path/a bundled option to the default).
ADR 0014 · Approximate vector index backend (hnsw_rs)
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-approximate-vector-index-design.md
Context
gonzalo-vector shipped only MemoryVectorIndex, an exact brute-force cosine
kNN (O(n) per query). The VectorIndex trait (ADR 0008) was designed so an
approximate backend could be added without breaking callers, and the gonzalo
design (§12) left the choice of in-process ANN crate — usearch vs hnsw_rs —
as an open question to benchmark.
Two forces decided it. First, deletion: the trait requires remove, and the
knowledge store (#29) calls it to evict orphaned chunks on re-ingest — usearch
has native removal, hnsw_rs has none. Second, build footprint: usearch
bundles a C++ library (C++ toolchain, longer builds; its unsafe FFI is internal
to the dependency), while hnsw_rs is pure Rust. CI builds with
--all-features --all-targets, so any feature-gated C++ dependency would still be
compiled in CI.
Decision
We will add HnswVectorIndex to gonzalo-vector, backed by hnsw_rs
(pure Rust), behind a non-default hnsw feature. MemoryVectorIndex remains
the exact default.
- Deletion —
hnsw_rscannot delete or update in place, so we own a tombstone-and-rebuild layer:removeand re-upserttombstone the old id; queries skip tombstoned ids; the graph is rebuilt from live entries oncetombstones > live && tombstones > 64. - Keying — a
RecordKey ↔ usizebimap bridges the trait's keys to hnsw's integer ids; vectors are retained for exact re-scoring and rebuilds. - Filtering/scoring —
queryover-fetches from hnsw, drops tombstoned ids, applies theKeyPrefixfilter, and recomputes exact cosine on the survivors (soMatch.scoreis truthful); it may return fewer thank. - Benchmark — a head-to-head
hnsw_rsvsusearchbenchmark (recall@10, insert/query latency) lives in a crate excluded from the workspace, so usearch's C++ never reaches CI. Results are documented.
Consequences
- Positive: sub-linear approximate search behind the unchanged trait; a clean
pure-Rust CI build with no C++ toolchain;
MemoryVectorIndexstill available for exact needs and small indexes. - Negative: we own the tombstone/rebuild deletion layer that
usearchwould have provided natively; approximate results meanquerycan miss a true neighbor or return<k; graph memory grows with churn until a rebuild. - Revisit if: deletion churn makes rebuilds too frequent, we need on-disk or
distributed scale, or recall proves insufficient — at which point
usearch(native delete) or a served index backend should be reconsidered.
ADR 0015 · Namespace-scoped daemon auth
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-namespace-scoped-daemon-auth-design.md
Context
The daemon (gonzalo-server) shipped with a single shared bearer token
(GONZALO_TOKEN): all-or-nothing authentication, no principals, no scoping. The
design (spec §9) always intended token auth with a RecordKey-namespace-scoped
permission check, "designed so finer-grained policy can slot in later without
touching the core." This ADR records that step.
Decisions in play: how principals are configured (an env-encoded blob vs a
readable file); how the new model stays backward-compatible with the single
token; whether operations without a natural namespace (graph queries by
repo/view, ticket sync) are scoped or merely authenticated; and whether the
daemon trusts the client-supplied Meta.author or stamps it from the
authenticated principal.
Decision
We will replace the single-token check with a principal model in a pure
auth.rs: a token maps to a Principal with per-namespace read/write lists
("*" = any namespace).
- Config —
GONZALO_AUTH_FILEpoints to a TOML file of principals; if unset butGONZALO_TOKENis set, that becomes a single admin principal (back-compat preserved); if neither is set, auth is disabled (open), matching local mode. - Enforcement — both transports authenticate at the edge (token → principal,
else 401/unauthenticated) and authorize in each handler, where the target
namespace is known:
get/listneedread,put/ticket_syncneedwrite, graph queries needreadon thereponamespace (which is the manifest's namespace).listwith no namespace requires an admin (readon*). Denied → 403/permission_denied. - Non-CRUD ops are scoped, not just authenticated — graph queries map to the
repo namespace and ticket sync to the
ticketsnamespace, both natural keys. - Author is stamped — every authenticated write overwrites
Meta.authorwith the authenticated principal, so authorship cannot be forged by a client. Open (disabled-auth) mode has no identity to stamp and stays a transparent store.
Scope is namespace-level read/write only; RBAC roles and a policy engine remain future work.
Consequences
- Positive: multi-tenant namespace isolation over both transports; existing single-token and open (local) deployments keep working unchanged; unforgeable authorship; the check lives in a pure, well-tested module.
- Negative: authorization is threaded through each handler (the namespace lives in the payload, not the headers); no sub-namespace (collection/id) granularity yet; tokens are static operator config with no rotation tooling.
- Revisit if: we need finer-grained (collection/record) permissions, roles, token rotation/issuance, or a real policy engine — the principal seam is where that slots in.
ADR 0016 · 3-way merge with content-addressed stored ancestry
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-threeway-merge-ancestry-design.md
Context
sync merges divergent records with an empty base, so merge() is a true
3-way merge only for AppendOnly (union is base-agnostic); Structured bodies
cannot distinguish a one-sided field edit from a genuine two-sided conflict. The
merge() machinery already implements correct 3-way semantics given a base — the
missing piece is the ancestor body, which the store does not retain (only the
current record per key is kept; parent is a revision hash, not content).
Options weighed for retaining ancestry: (a) a Store decorator that records each
body in a content-addressed store, (b) baking retention into every substrate's
put, or (c) sync-maintained watermark records. And for resolution scope: the
shared-parent case versus a full lowest-common-ancestor walk over retained
history.
Decision
We will add AncestryStore<S, B>, a Store decorator over any S: Store
plus a B: BlobStore. On a committed put it writes body.bytes() to the
ancestry blob store and delegates. Because Revision.hash == ContentHash::of(body.bytes()), each version's body is retrievable by its
revision hash. Retention is opt-in and changes no substrate.
sync gains sync_with_ancestry(a, b, ancestry: Option<&dyn BlobStore>);
sync(a, b) is preserved as the empty-base path. When two divergent records
share a parent revision (rec_a.parent == rec_b.parent) whose body is retained,
sync passes that body to merge() as the true base; otherwise it falls back to
the empty base (today's behavior). We scope resolution to the shared-parent
case — retained blobs carry no parent link, so a full LCA walk is not possible
without also retaining traversable history (out of scope).
Consequences
- Positive:
Structureddivergences from a real base now merge correctly (one-sided edits apply; same-field edits conflict). Ancestry reuses the existing content-addressedBlobStoreandRevisionhash — no substrate change, no new addressing scheme.AppendOnly/Opaque/Derivedbehavior is unchanged, and empty-base fallback keeps sync correct when ancestry is absent. - Negative: ancestry retention grows storage (every version's body kept) with no GC policy yet; only single-step (shared-parent) divergence gets true 3-way; the benefit requires peers to have wrapped their stores before the common write.
- Revisit if: multi-step divergence needs true LCA merges (retain parent links + walk), or retained ancestry growth needs a GC/retention policy.
ADR 0017 · Non-fast-forward git pull via content-aware merge
- Status: accepted
- Date: 2026-07-05
- Source:
docs/superpowers/specs/2026-07-05-nonff-pull-content-merge-design.md
Context
GitStore::pull was fast-forward-only, erroring on divergence
("non-fast-forward pull requires manual merge"), so replication could not
reconcile a local branch that had diverged from its remote. git already retains
history, so the merge base commit is a real common ancestor — 3-way merge
needs no separate ancestry mechanism (unlike sync, ADR 0016). The open questions
were how record content is merged (git's line-based merge vs gonzalo's
class-aware merge()) and what happens to records that cannot be auto-merged.
Decision
We will make non-fast-forward pull perform a content-aware 3-way merge:
- Diff the merge base against local and remote; a record changed on only one
side takes that side, and a record changed on both sides is reconciled with
gonzalo's
merge(kind.merge_class(), base, local, remote)— never git's line-based content merge (which mis-handles structured JSON records). - Auto-merged records are rebuilt with a fresh revision and committed in a
two-parent merge commit that advances the branch; unresolved
(
NeedsResolution) records keep local and are surfaced in a newPullReport { fast_forwarded, merged, conflicts }rather than erroring — the pull makes progress and reports what it could not reconcile, mirroringsync.
pull returns PullReport (no existing callers). Fast-forward and up-to-date
paths are preserved. Scope is merge (not rebase); push and the Store surface
are unchanged.
Consequences
- Positive: diverged git peers reconcile automatically with record-correct semantics (append-only union, structured field-merge, etc.); a single unmergeable record no longer blocks pulling everything else; conflicts are reported with both sides for resolution.
- Negative: conflicted records keep local until the caller resolves them
(the merge commit records a partial reconciliation); no rebase option; the git
tree-walking/merge logic adds complexity to
gonzalo-store-git. - Revisit if: callers need rebase semantics, automatic conflict escalation, or conflict-marker artifacts for manual resolution.
ADR 0018 · Record deletion and its sync semantics
- Status: accepted
- Date: 2026-07-11
Context
The Store trait had no way to remove a record — only get, put, and list.
The caliban integration (#1)
needs deletion to prune memory topics, retire sessions, and sweep old
checkpoints, so Store must gain a delete. Two questions had to be settled:
- Concurrency.
putis OCC-aware (expected: Option<Revision>), surfacing aConflictrather than silently clobbering a concurrent write (ADR 0005). A delete that ignored the current revision could remove a record a peer had just updated. Should delete carry the same precondition? - Replication.
sync(ADR 0016) and gitpull(ADR 0017) reconcile two substrates by copying each side's records into the other. A delete leaves no trace, so a later sync against a peer that still holds the record cannot tell "deleted here" apart from "never seen here" — and copies it back. Do we need tombstones to propagate deletes, or is a local delete enough for now?
Decision
We will add Store::delete(key, expected: Option<Revision>) -> DeleteResult,
OCC-aware and mirroring put:
expected == Noneremoves the record if present and is an idempotent no-op if absent →DeleteResult::Deleted.expected == Some(rev)removes only if the current revision matches →Deleted; a mismatch leaves the record untouched and returnsDeleteResult::Conflictholding the live record. An already-absent key is an idempotentDeleted— the named revision is already gone, so there is nothing to conflict on.- The check-and-remove runs in the same critical section as the substrate's
put(fs: the per-record flock; git: the repo lock plus a commit of the removal; s3: a conditionalDeleteObjectwithIf-Match: <etag>), so it is atomic against a concurrent writer.DeleteResult::Conflict, like aputconflict, is a normal recoverable result, not an error.
We will make delete local-only. It is not a tombstone and is not
propagated by sync: a later sync against a peer that still holds the record will
resurrect it. Full tombstone propagation (a deletion marker that survives and
replicates so a delete on one substrate erases the record everywhere) is
deferred until multi-substrate delete-sync is actually needed.
Consequences
- Positive: callers get OCC-safe deletion across every substrate with the
same conflict semantics they already know from
put; the conformance suite exercises it on fs/git/s3/server. No new replication machinery, wire tombstone type, or GC of deletion markers to build and reason about yet. - Negative: delete does not replicate — a record deleted on one substrate and then synced from a peer that still holds it comes back. Callers who need a delete to stick across a sync must delete on both sides (or not rely on sync). This is a real, documented sharp edge until tombstones land.
- Revisit if: a consumer needs a delete on one substrate to propagate to its
peers — that is the trigger to design tombstone records, their replication in
sync/pull, and their eventual garbage collection.
ADR 0019 · Qualified S3 backend for HA: RustFS
- Status: accepted
- Date: 2026-08-01
- Source:
docs/superpowers/specs/2026-07-11-gonzalo-ha-soak-design.md§"Implementation finding"
Context
ADR 0004 made the backend a configuration choice behind one Store trait, and
its "revisit if" named the failure mode precisely: a needed backend cannot
satisfy the Store contract. Multi-replica gonzalod over an S3-compatible
store is the first case that hit it.
The contract at stake is optimistic concurrency (ADR 0005): concurrent edits are
never silently lost, and a loser is told (PutResult::Conflict). Over S3 that
reduces to one requirement — atomic If-Match conditional writes. Without
them the store degrades to check-then-set, and lost updates are silent, which is
the one outcome the core exists to prevent.
"S3-compatible" turned out not to imply this. gonzalo-store-s3's own
conformance case (concurrent_updates_with_same_expected_let_exactly_one_win,
ADR 0006) expects exactly 1 of 8 racers to commit, and became a backend
qualifier. Running it against candidates:
| Backend | Atomic If-Match | License | Outcome |
|---|---|---|---|
RustFS 1.0.0-beta.8 | ✅ deterministic (3/3 + full soak) | Apache-2.0 | chosen — Rust, MinIO-compatible, drop-in |
| MinIO | ✅ | AGPL | rejected — project sustainability |
| Garage | ❌ non-atomic | AGPL | disqualified — see below |
| SeaweedFS | ⚠️ setup-blocked, upstream CAS bugs | Apache-2.0 | not pursued |
| Ceph RGW | not tested | LGPL | heavyweight, against the modest-hardware goal |
Garage fails the invariant outright. v1.0.1 let 8/8 racers commit; v2.1.0
let 3–8 commit non-deterministically — the signature of check-then-set rather
than atomic CAS. Its own S3-compatibility reference documents no
If-Match/If-None-Match for PutObject, and v2.1.0 exposes no consistency or
quorum setting that changes this. It is a design limitation, not a tunable, so
there is no configuration under which Garage is safe for gonzalo.
MinIO passes the qualifier and is technically sound — it served as the control that proved the harness itself was correct. It is rejected on sustainability rather than correctness: through 2025 the project moved functionality out of the community edition toward its commercial offering and curtailed community development. Betting the persistence tier of an AGPL-3.0 project on a vendor actively narrowing what its open edition does is a risk we decline to take, independent of today's licence text.
Decision
We will treat atomic If-Match as a hard qualification gate for any
S3-compatible backend, and qualify backends by running the existing
conditional-write conformance case against them — not by reading compatibility
matrices.
RustFS is the qualified S3 backend for multi-replica HA: the only FOSS S3
store that passes, Rust, Apache-2.0, and drop-in MinIO-compatible. The HA soak
provisions it (docker-compose.rustfs.yml, scripts/rustfs-up.sh).
RustFS is pre-1.0 (beta), so this is a near-term answer, not a permanent one. The
mature foundation is a Postgres substrate (gonzalo-store-postgres, native
atomic CAS, aligned with prospero's clustered tier), tracked separately.
Garage's compose setup is retained solely as a reproducer of the finding, not as a supported backend. Nothing in the soak harness is backend-specific — it reads an S3 endpoint — so qualifying a new candidate is a matter of pointing it at one.
This does not narrow ADR 0004: the substrate remains configuration, and fs
remains the zero-dependency default. It records which concrete S3 servers clear
the contract that ADR 0004 requires every substrate to meet.
Consequences
- Positive: HA rests on a backend proven against gonzalo's own concurrency invariant rather than a vendor's compatibility claim. The qualifier is reusable, so future candidates are a test run, not an investigation. Both the chosen backend and the toolchain stay Rust and permissively licensed.
- Negative: we depend on a pre-1.0 beta for the HA path, and inherit its stability risk until the Postgres substrate lands. The qualification gate rules out most of the S3-compatible ecosystem, so "any S3 store" is a claim we can no longer make. Documents that named Garage as the target — the k8s system design and the gonzalo chart — are now wrong and need correcting.
- Revisit if: RustFS reaches 1.0 (upgrade from tolerated to preferred) or stalls; the Postgres substrate lands and supersedes it for HA; Garage or SeaweedFS ships atomic conditional writes and passes the qualifier; or a backend passes the qualifier but fails the invariant in the full soak, which would mean the qualifier is too weak.