Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. One uniform Record, one generic Store ("Approach A"). Every domain type is a serde view over Record { key, kind, revision, parent, body, meta, links }; versioning, concurrency, conflict, and sync are written once in the core (ADR 0002).
  2. 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).
  3. Optimistic concurrency with explicit, typed conflict surfacing. put(record, expected_parent_rev); a stale parent yields PutResult::Conflict (recoverable, not an error); merge is keyed by RecordKind, and Sync reuses the exact same machinery (ADR 0005).
  4. 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).
  5. Minimal-core-touch for new capabilities. A new layer may register a RecordKind at most — no new core traits or types enter (ADR 0010, 0011). This aligns with the rule of three for promoting anything into the core.
  6. Provider-agnostic boundaries via traits. Embedding goes through Embedder, tickets through TicketSource; provider divergence is handled by capabilities() negotiation, never if provider == … branches (ADR 0008, 0010).
  7. The conformance suite is the executable contract. One shared conformance suite that every Store implementation must pass (ADR 0006).
  8. A single canonical schema behind dual transports. gRPC (tonic) and HTTP/JSON (axum) sit over one service layer; gonzalo-proto is the single schema, so the transports cannot drift (ADR 0007).
  9. Single-facade public surface + workspace discipline. Caliban depends on one crate — the gonzalo facade; substrates and layers are toggled by Cargo feature (ADR 0009).
  10. Normalize on the shared spine, preserve the raw losslessly. Tickets normalize to State { category, resolution, raw_name, raw_id } plus a bounded fields map; the status signal is configured per connection, not hard-coded per provider (ADR 0010).
  11. 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).
  12. Retrieval returns first-class records, never bare ids. Vector, graph, knowledge, and ticket queries resolve back through the Store to whole records (ADR 0008, 0011).

Inviolable invariants

  1. Concurrent edits are never silently lost — the core invariant. A stale-parent write MUST return Conflict, never overwrite; ambiguous merges MUST surface (ADR 0005).
  2. Conflict is a typed, recoverable resultPutResult::Conflict, never collapsed into GonzaloError (ADR 0005).
  3. All persistence funnels through the one Record/Store — no parallel typed store re-implements versioning (ADR 0002).
  4. Substrates implement only the generic Store and stay type-blind — no substrate-specific escape hatches (ADR 0004, 0008).
  5. Every Store implementation must pass the shared conformance suite (ADR 0006).
  6. Capability layers never bypass or mutate the core — a layer may register a RecordKind + merge class at most (ADR 0008, 0010, 0011).
  7. Sync reuses the exact local-write conflict/merge machinery — any Store can be a sync peer (ADR 0005).
  8. The daemon's two transports derive from one canonical schema + one service layer (gonzalo-proto) (ADR 0007).
  9. 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).
  10. No query engine ever sits under the Store substrate — engines back only regenerable index layers, never the durable source of truth (ADR 0012).
  11. unsafe_code is forbidden workspace-wide (ADR 0009).
  12. License is AGPL-3.0-only (ADR 0003).
  13. The core does no I/Ogonzalo-core is pure logic; all I/O lives in substrates (design spec §3).
  14. Every write carries provenance identity — an Identity; Meta records author and origin_system (design spec §4, §9).
  15. 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 index writes. gonzalo-mcp only 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
  • driverfull walk the 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.
  • driver also goes back to full walk on 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.js and friends), and anything .gitignored. Use --include <path> to re-admit a vendored path you do want indexed. --include cannot 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_ROOT is passed straight through, and nothing in gonzalo expands a leading ~. Neither bash nor zsh expands a tilde in --env KEY=~/path argument position either, so GONZALO_ROOT=~/.gonzalo creates 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

toolanswers
viewswhich (repo, view_id) pairs exist, with file counts and indexed commit
statusis the server up, which root, how many views

Whole-view — start here in an unfamiliar repo; none need a symbol name.

toolanswers
overviewcounts, breakdown by kind and language, largest files
topmost referenced (fan_in), most calls out (fan_out), most ambiguous (definitions)
listenumerate symbols filtered by path prefix, kind, name substring
unreferenceddead-code candidates — heuristic, see below

Per-symbol — for a name you already have.

toolanswers
searchwhere is this defined
nodedefinitions + callers + callees in one call
callers / calleeswho calls this / what does this call
exploreevery reference, with paths
impacttransitive caller closure, resolution-gated; takes max_depth

Across views

toolanswers
diffsymbols 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

symptomcause
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":0nothing indexed yet, or GONZALO_ROOT points somewhere unexpected — check the root it reports
a directory named ~ appearedGONZALO_ROOT=~/… was not expanded; use an absolute path
"no indexed view" errorthe selector does not match — call views
results describe code that changedstale view — compare base_commit to HEAD and re-index
new tools missing after an upgradethe 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::UniqueGlobal attributed 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()) in gonzalo-core resolved to fn chain(), a test fixture in gonzalo-graph, a crate gonzalo-core does not depend on.

    References now record the shape of the call site (RefKind::{Free, Method}), and a cross-file method call resolves to the new Resolution::ReceiverUnknown rather than guessing. Measured over gonzalo's own source: of the 388 cross-file method calls that used to resolve UniqueGlobal, 294 pointed at a different cratepush, filter, send, bytes, next and friends. Same-file method calls still resolve Local, and free and path calls (foo(), a::b::foo()) are unaffected.

    Effect on impact (#207), same 122-file source both runs:

    seedbeforeafter
    build_rust185126
    assemble6023
    resolve_references_to66

    The 24 provably-false gonzalo-core nodes in the build_rust closure are now 0. Dropped edges are reported as receiver_unknown_edges, counted separately from ambiguous_edges because the cause differs: not "too many candidates" but "cannot claim any candidate".

    RefKind is omitted from the serialized slice when free, so a file of plain calls keeps its existing content hash.

  • impact no 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: an Ambiguous reference 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_edges says how many edges could not be attributed (so a non-zero count means the true set may be larger), and truncated reports a walk stopped by the new optional max_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 UniqueGlobal over-attribution — std's Iterator::chain resolving 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 because docs/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/caliban view: 17 162 symbols → 8 549, with vendored symbols going 8 618 → 0 and the largest file becoming caliban/src/tui/events.rs (142) instead of a copy of mermaid.min.js.

  • An unknown repo/view_id is now an error, not an empty result (#210). Every graph query returned [] with isError: false when the selector named no indexed view, so a one-character typo in view_id was 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::view now fails with NotFound, 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.

    diff gets the same check on both view_a and view_b.

  • Calls inside Rust macro arguments are now recorded as references (#216). Macro arguments parse as a token_tree of raw tokens rather than expressions, so assert_eq!(f(), 1) contained no call_expression and the call to f was never seen. Because assertions are where much of a codebase is exercised, this silently removed a large share of the call graph: callers, callees, impact and top by=fan_in all undercounted, and unreferenced reported 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 like Some(_) 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 #define body is a single opaque preproc_arg token 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_indexable skipped only target, .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/caliban drops it from 16 986 symbols to 8 501 (-50.0%) with zero symbols from book/** or any *.min.js; the largest file in the view is now caliban/src/tui/events.rs (142 symbols) rather than a 4 231-symbol copy of mermaid.min.js. This also removes a reproducibility hole — indexing gitignored output made the graph depend on whether anyone had run a build.

    gonzalo index now 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. --include deliberately 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 index now 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 index writes, 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_ROOT not expanding ~ (#211), ~/.cargo/bin missing 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.

  • views discovery tool and a view count in status (#210). views lists 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; comparing base_commit against the checkout's HEAD also surfaces a stale view, the quieter form of the same problem. status now reports how many views are indexed, so the natural health-check call actually detects a server pointed at an empty or wrong store. The repo/view_id schema descriptions now say they must match an indexed view and point at views.

  • 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 by fan_in, fan_out, or definitions — a definitions score above 1 marks an ambiguous name), and list (enumerate symbols filtered by path_prefix, kind, and name_contains). Backed by new default GraphStore methods, so every store implementation inherits them. Results are bounded and report total + truncated rather than silently cutting.

  • unreferenced dead-code candidates (#214) — a fourth aggregate tool listing symbols with no inbound reference, filtered by the same path_prefix/kind/name_contains and bounded the same way. exclude_tests (default on) drops members of a mod tests/mod test block by line range and anything under a tests/ 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 by Sync. Local-only semantics; see ADR 0018. (#185)
  • Blobs over the daemon (#184) — the content-addressed BlobStore is exposed on gonzalo-server (HTTP GET|PUT|DELETE /v1/blobs/{hash}, GET /v1/blobs, plus gRPC), and ServerStore implements BlobStore over both transports, so a daemon-backed consumer gets the full Store + BlobStore surface. Blobs previously worked only on fs/s3. Adds the GONZALO_MAX_BLOB_SIZE daemon knob (default 64 MiB). (#192)

Changed

  • S3 backends are now qualified, and Garage is not among them (#52) — atomic If-Match is 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/defmodule parse as ordinary call nodes), so it uses a value-based walk() 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 put critical 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/interface declarations get their correct SymbolKind. (#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.0 and rank deterministically; the domain codec rejects a Body::Blob instead of misparsing its content hash. (#139, #149, #154)
  • cliget/ticket get exit non-zero (message on stderr) when a record is absent, index advances the persistent SQLite graph only after the manifest commits, and --gc is honored under --watch. (#152)
  • ticket connectors — Jira routes a Canceled move 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 returns success: false; the GitHub REST connector follows Link pagination 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-subprocess ParserPool. (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 Language dispatch 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 --watch file-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 composing Store + 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) — and HnswVectorIndex, an approximate ANN backend, behind the existing Embedder/VectorIndex traits. (ADR 0013, ADR 0014; #97, #9)
  • Daemon substrate selection & health: env-driven fs|s3 store selection with a native S3 BlobStore, and unauthenticated /healthz + /readyz probes 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)
  • gonzalod container image — the release artifact, published on a v* tag. (#51, #65)

Changed

  • Store sync — true 3-way merge: AncestryStore retains each version's body by revision hash so sync can 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-Match close 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 uniform Record model and a generic Store trait, with revisions, optimistic-concurrency parent tracking, PutResult::Conflict, per-RecordKind merge, 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 (typed MemoryTier/Topic/Session/Checkpoint views), and the gonzalo facade. (ADR 0004, ADR 0008, ADR 0009)
  • Git & S3 substrates + Sync (M2): gonzalo-store-git (commit-per-write, fast-forward pull/push) and gonzalo-store-s3 (S3-compatible object store), plus the Sync engine 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, and gonzalo-store-server as the client substrate. (ADR 0007)
  • Vector layer (M4): gonzalo-vectorEmbedder + VectorIndex traits 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 a GraphStore trait. (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

  • 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 generic Store trait; 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 Record shape; 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 Store trait 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 Store contract, 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 returns PutResult::Conflict rather 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 to gonzalo-cli for resolution.
  • Sync (pull → detect divergence → merge → push) reuses this exact machinery, so reconciliation and local writes share one code path.
  • Conflict is 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 Conflict outcome — 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/from Record via serde.
  • gonzalo-vectorEmbedder + VectorIndex traits. 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 — a tree-sitter-based Rust code graph (build_rust) and a GraphStore trait 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 RecordKey makes 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.
  • GitLabtier-dependent: free = open/closed + workflow::* scoped labels; Premium = native categorized Status; custom fields GA in 18.0.
  • Asanano intrinsic state (completed bool / 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.
  • Bugzillatwo-dimensional state: status × resolution (FIXED / WONTFIX / DUPLICATE / INVALID …).
  • Monday / Airtablefully schemaless: title, status, assignee are all user-named columns/fields.
  • Zendesk / ServiceNow — support/ITSM: distinct requester/assignee/submitter, a pending state, 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 typed Ticket view in gonzalo-domain, mapped to/from Record via serde, exactly as MemoryTier/Session are.
  • Normalized canonical model + lossless raw. Ticket carries an item_type; a State { category, resolution, raw_name, raw_id } (category is the cross-platform spine, resolution the second axis Bugzilla/Jira need, raw round- trips); actor roles (Requester | Assignee | Submitter | Follower); normalized common fields (title, body, priority, labels); many-to-many containers (Asana multi-home, multi-board); typed links (blocks/relates/ parent/duplicate → RecordKey or external ref); a Body { markdown, raw, format: Markdown|Adf|Html|PlainText }; and a bounded fields map for everything else. StateCategory includes a Pending/blocked member.
  • TicketSource trait is the provider boundary — the ticket analogue of Embedder, 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) replaces if 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 Cursor owned by each source (timestamp / JQL bound / GraphQL cursor / event sync token) — not Gonzalo's Revision.
  • Read-only import first; capability-gated write-back second. fetch_changed is 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 FieldMapping but 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 minimal RecordKind registration 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 FieldMapping is configuration users must get right, especially for schemaless tools. Two RecordKinds 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 the Record, extract its knowledge text, embed it via E, and upsert it into V under the same RecordKey.
    • query(text, k, filter) — embed the query, VectorIndex::query for the top-k keys, then resolve them through S to 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-domain views): MemoryTier (content), Topic (slug + bullets), Session (name + turn text), Ticket (title + body + labels), TicketEvent (body). Checkpoint is 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 existing VectorIndex trait, 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 ⋈ graph is 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 a GraphStore join.

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 to E); zero core change.
  • Negative: KnowledgeStore is generic over three type parameters (S, V, E) — some signature heft. The in-memory index still caps scale until sqlite-vec lands. knowledge_text couples gonzalo-knowledge to 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

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 what Body::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. Symbol and Reference carry line ranges only, not a file field; 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 Store substrate (the durable, versioned, conflict-aware source of truth). Engines back only the derived, regenerable index layers (GraphStore, and optionally VectorIndex), which are swappable behind their traits. Content-addressed slices are a Body::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 a MergeClass::Derived arm, 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

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-v2Apache-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 an EmbedderConfig.model_path override 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-vector stays 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_path is 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)

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.

  • Deletionhnsw_rs cannot delete or update in place, so we own a tombstone-and-rebuild layer: remove and re-upsert tombstone the old id; queries skip tombstoned ids; the graph is rebuilt from live entries once tombstones > live && tombstones > 64.
  • Keying — a RecordKey ↔ usize bimap bridges the trait's keys to hnsw's integer ids; vectors are retained for exact re-scoring and rebuilds.
  • Filtering/scoringquery over-fetches from hnsw, drops tombstoned ids, applies the KeyPrefix filter, and recomputes exact cosine on the survivors (so Match.score is truthful); it may return fewer than k.
  • Benchmark — a head-to-head hnsw_rs vs usearch benchmark (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; MemoryVectorIndex still available for exact needs and small indexes.
  • Negative: we own the tombstone/rebuild deletion layer that usearch would have provided natively; approximate results mean query can 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

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).

  • ConfigGONZALO_AUTH_FILE points to a TOML file of principals; if unset but GONZALO_TOKEN is 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/list need read, put/ticket_sync need write, graph queries need read on the repo namespace (which is the manifest's namespace). list with no namespace requires an admin (read on *). Denied → 403/permission_denied.
  • Non-CRUD ops are scoped, not just authenticated — graph queries map to the repo namespace and ticket sync to the tickets namespace, both natural keys.
  • Author is stamped — every authenticated write overwrites Meta.author with 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

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: Structured divergences from a real base now merge correctly (one-sided edits apply; same-field edits conflict). Ancestry reuses the existing content-addressed BlobStore and Revision hash — no substrate change, no new addressing scheme. AppendOnly/Opaque/Derived behavior 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

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 new PullReport { fast_forwarded, merged, conflicts } rather than erroring — the pull makes progress and reports what it could not reconcile, mirroring sync.

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:

  1. Concurrency. put is OCC-aware (expected: Option<Revision>), surfacing a Conflict rather 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?
  2. Replication. sync (ADR 0016) and git pull (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 == None removes 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 returns DeleteResult::Conflict holding the live record. An already-absent key is an idempotent Deleted — 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 conditional DeleteObject with If-Match: <etag>), so it is atomic against a concurrent writer. DeleteResult::Conflict, like a put conflict, 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

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:

BackendAtomic If-MatchLicenseOutcome
RustFS 1.0.0-beta.8✅ deterministic (3/3 + full soak)Apache-2.0chosen — Rust, MinIO-compatible, drop-in
MinIOAGPLrejected — project sustainability
Garage❌ non-atomicAGPLdisqualified — see below
SeaweedFS⚠️ setup-blocked, upstream CAS bugsApache-2.0not pursued
Ceph RGWnot testedLGPLheavyweight, 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.